{"_id":"@astratra/entitlements","_rev":"3-f80c4451dc253fd2b98b3da10d761e7c","name":"@astratra/entitlements","dist-tags":{"latest":"0.5.0"},"versions":{"0.3.0":{"name":"@astratra/entitlements","version":"0.3.0","keywords":["astratra","entitlements","plans","feature-flags","invitations","multi-tenant","saas"],"license":"MIT","_id":"@astratra/entitlements@0.3.0","maintainers":[{"name":"emch99","email":"emchkongo@gmail.com"}],"dist":{"shasum":"ec4217e8cce54d227580345883d26cb4dd0f8e23","tarball":"https://registry.npmjs.org/@astratra/entitlements/-/entitlements-0.3.0.tgz","fileCount":12,"integrity":"sha512-38cSouoPNrVEmWyzmUna+OZZSgNFfLJr5E8ZsLsooRlr4/GbAOhfSxPWqvw2kG3hTK8dQsXe+uk2DPQQI84OVg==","signatures":[{"sig":"MEUCIQDl3Ra0gV41Mc7Z23L29AYHXUlq7DncGp6wXdjiYoXrZAIgDpmCzrP++ebVaItiv3R0CGUK3RTmhsL1WcFFvAe5tUQ=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":46410},"jest":{"testMatch":["**/__tests__/**/*.test.js"]},"main":"src/index.js","type":"commonjs","types":"src/index.d.ts","engines":{"node":">=20"},"gitHead":"a3f7c737294561ba8651e14d507a18b41e199999","scripts":{"test":"jest"},"_npmUser":{"name":"emch99","email":"emchkongo@gmail.com"},"_npmVersion":"11.13.0","description":"Who may do what: plans and features, billing and status guards, screen/role matrix, fail-closed tenant isolation, and invite-by-link with hashed tokens.","directories":{},"_nodeVersion":"24.16.0","publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"jest":"30.4.2","express":"^4.18.3","supertest":"^7.0.0"},"_npmOperationalInternal":{"tmp":"tmp/entitlements_0.3.0_1787666095170_0.052277394744574934","host":"s3://npm-registry-packages-npm-production"}},"0.4.0":{"name":"@astratra/entitlements","version":"0.4.0","keywords":["astratra","entitlements","plans","feature-flags","invitations","multi-tenant","saas"],"license":"MIT","_id":"@astratra/entitlements@0.4.0","maintainers":[{"name":"emch99","email":"emchkongo@gmail.com"}],"dist":{"shasum":"cf34cbda06f5426e26a8a52e74182069993f12fd","tarball":"https://registry.npmjs.org/@astratra/entitlements/-/entitlements-0.4.0.tgz","fileCount":13,"integrity":"sha512-F38dOLO/dNK9/qqjZNf2ZGer/cThZDUyJThR42qCDwQzbHJs9+Am4VS/6YS4xiqr9ee0YXK1WZK80juqluV/tg==","signatures":[{"sig":"MEUCIQCTtVSfVV1Qf1Hduce6/JVZGt27CPvyUc0M8i5tCtnOlgIgBfsMJZd6EDF+Ydcjfk/MWGkxb7vOmnf+ieUZ2aWaD/4=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":55522},"jest":{"testMatch":["**/__tests__/**/*.test.js"]},"main":"src/index.js","type":"commonjs","types":"src/index.d.ts","engines":{"node":">=20"},"gitHead":"597c98161c5fc076a7575eaa0a3bd3cdfd5cb132","scripts":{"test":"jest"},"_npmUser":{"name":"emch99","email":"emchkongo@gmail.com"},"_npmVersion":"11.13.0","description":"Who may do what: plans and features, billing and status guards, screen/role matrix, fail-closed tenant isolation, and invite-by-link with hashed tokens.","directories":{},"_nodeVersion":"24.16.0","publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"jest":"30.4.2","express":"^4.18.3","supertest":"^7.0.0"},"_npmOperationalInternal":{"tmp":"tmp/entitlements_0.4.0_1787768550214_0.18084836273874672","host":"s3://npm-registry-packages-npm-production"}},"0.5.0":{"_id":"@astratra/entitlements@0.5.0","dist":{"shasum":"b61f34be99b35811a2437f86aee03bd6aef7d7a6","tarball":"https://registry.npmjs.org/@astratra/entitlements/-/entitlements-0.5.0.tgz","fileCount":14,"integrity":"sha512-K4tR07hW3iFF5vttFOTbMaSyP1VTpudN+wWD9icgN571SotAGmgM/U3i2jKuZiv7AhGCT920o1ITx9FhjN55Jw==","signatures":[{"sig":"MEUCIQDDrL7+QAkn4Qvi9vv65C7tet0chXvVA5cS1A5M7y6w5gIgP8R8XggJhBht3Bqk4UO5PPuL96PqW3+u1U1o/DCnNng=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"},{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEQCIAI3xbyX3F3foFcn4m7YbPm3F65J9TqedEs+RTrTc3GBAiBd2qpAg//0N8D1nwV4/XzE3WxO9Uar2gsfdTMG1ldFUg=="}],"unpackedSize":78114},"jest":{"testMatch":["**/__tests__/**/*.test.js"]},"main":"src/index.js","name":"@astratra/entitlements","type":"commonjs","types":"src/index.d.ts","engines":{"node":">=20"},"gitHead":"a6eda5bf009d1d47caff00955940c6d2303bee2c","license":"MIT","scripts":{"test":"jest"},"version":"0.5.0","_npmUser":{"name":"emch99","email":"emchkongo@gmail.com"},"keywords":["astratra","entitlements","plans","feature-flags","invitations","multi-tenant","saas"],"_npmVersion":"11.13.0","description":"Who may do what: plans and features, billing and status guards, screen/role matrix, fail-closed tenant isolation, and invite-by-link with hashed tokens.","directories":{},"maintainers":[{"name":"emch99","email":"emchkongo@gmail.com"}],"_nodeVersion":"24.16.0","publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"jest":"30.4.2","express":"^4.18.3","supertest":"^7.0.0"},"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/entitlements_0.5.0_1789991977657_0.2719713302162925"}}},"time":{"created":"2026-08-25T13:54:55.022Z","modified":"2026-09-21T11:59:38.030Z","0.3.0":"2026-08-25T13:54:55.310Z","0.4.0":"2026-08-26T18:22:30.351Z","0.5.0":"2026-09-21T11:59:37.735Z"},"license":"MIT","keywords":["astratra","entitlements","plans","feature-flags","invitations","multi-tenant","saas"],"description":"Who may do what: plans and features, billing and status guards, screen/role matrix, fail-closed tenant isolation, and invite-by-link with hashed tokens.","maintainers":[{"name":"emch99","email":"emchkongo@gmail.com"}],"readme":"# @astratra/entitlements\n\nLes plans, les fonctionnalités qu'ils débloquent, qui a le droit de voir quel\nécran, et ce que la plateforme prélève. Les quatre questions que tout produit\nvendu par abonnement finit par se poser, et qu'on finit par répondre à quatre\nendroits différents — jusqu'à ce que deux des réponses divergent.\n\nAucune dépendance à l'exécution. Aucun plan, aucun rôle, aucun taux imposé :\n`starter`/`pro`/`enterprise` est le vocabulaire d'un produit, `solo`/`équipe`\nen est un autre.\n\n# Plans et droits\n\n## La liste des invitations, côté écran\n\nLe côté serveur est plus haut : il fabrique les liens, les range en empreinte\net ne les accepte qu'une fois. Celui-ci lit ce qui revient — et une règle\njustifie à elle seule le module.\n\n```js\nconst board = createInvitationBoard({ invitable: { owner: ['seller'] } });\n\nboard.readMany(payload);\nboard.effectiveStatus(invitation);   // recalculé, jamais cru sur parole\nboard.initialTab(invitations);       // s'ouvre sur ce qui demande une action\n```\n\n**Une invitation en attente dont la date est passée est expirée, quoi qu'en\ndise le serveur.** Celui-ci ne bascule le statut qu'au moment où quelqu'un\nouvre le lien ; jusque-là la ligne dit « en attente ». L'afficher telle quelle\npromet un lien qui fonctionne alors qu'il est déjà mort : la personne l'envoie,\nle destinataire reçoit une erreur, et l'expéditeur doit deviner pourquoi.\n\n**« Terminées » réunit les expirées, les révoquées et les échouées** : dans les\ntrois cas la seule suite possible est d'en refaire une.\n\n**Un statut inconnu se lit « en attente »** — la ligne reste à l'écran et\nactionnable, plutôt que de disparaître dans un onglet que personne n'ouvre.\n\n## Le catalogue\n\n```js\nconst { createPlanCatalog } = require('@astratra/entitlements');\n\nconst catalog = createPlanCatalog({\n  plans: {\n    essai:      ['tableau_de_bord', 'rapports', 'analytique'],\n    demarrage:  ['tableau_de_bord', 'rapports'],\n    pro:        ['tableau_de_bord', 'rapports', 'analytique'],\n  },\n  labels: { essai: 'Essai gratuit', demarrage: 'Démarrage', pro: 'Pro' },\n  upgradePath: { demarrage: 'pro' },\n  // Le plan de repli quand un compte porte un plan que le catalogue ne connaît\n  // plus — une offre renommée, une migration à moitié faite.\n  fallbackPlan: 'demarrage',\n});\n\ncatalog.hasFeature('demarrage', 'analytique');              // false\ncatalog.hasFeature('demarrage', 'analytique', ['analytique']); // true\n```\n\nDeux détails qui comptent plus qu'il n'y paraît.\n\n**Un plan inconnu retombe sur le plus petit**, pas sur le plus généreux. Un plan\nmal orthographié doit donner le moins d'accès possible, pas le plus.\n\n**`overrides` existe parce que les vrais clients existent.** Un compte a négocié\nune fonctionnalité hors de son offre ; l'écrire en dur dans le plan la\ndonnerait à tout le monde.\n\n## Le garde\n\n```js\nconst { createFeatureGuard } = require('@astratra/entitlements');\n\nconst guard = createFeatureGuard({\n  catalog,\n  // Où vit le plan du compte : à toi de le dire. Renvoyer null veut dire\n  // « rien à facturer ici » et laisse passer.\n  resolveAccount: async (req) => {\n    const compte = await Comptes.findById(req.user.organisation);\n    return compte ? { plan: compte.plan, overrides: compte.extras } : null;\n  },\n  isExempt: (req) => ['support', 'fondateur'].includes(req.user.role),\n  // Interrupteur global, vérifié AVANT le plan.\n  isEnabled: async (feature) => !(await Maintenance.estCoupee(feature)),\n});\n\napp.get('/api/analytique', guard('analytique'), controleur);\n```\n\nL'ordre n'est pas un détail : **l'interrupteur de maintenance passe avant le\nplan**. Une fonctionnalité coupée l'est aussi pour l'offre qui la paie — lui\nrépondre « passez à l'offre supérieure » serait un mensonge.\n\n### En cas de panne, la porte se ferme\n\n`onError` vaut `'deny'` par défaut. Une base injoignable ne doit pas ouvrir\ntoutes les fonctionnalités payantes : un garde qui s'ouvre en tombant est un\ngarde qu'il suffit de casser, pas de vaincre.\n\nUn produit peut légitimement préférer rester debout plutôt que fermé —\n`onError: 'allow'` est là pour ça. Mais que ce soit un choix, pas un oubli.\n\n## Le blocage d'un compte entier\n\nUne suspension n'est pas une question d'offre. Un compte impayé ou gelé perd\ntout d'un coup, et lui parler de montée en gamme serait déplacé.\n\n```js\nconst { createStatusGuard } = require('@astratra/entitlements');\n\napp.use(createStatusGuard({\n  resolveStatus: async (req) => {\n    const compte = await Comptes.findById(req.user.organisation);\n    return compte ? { status: compte.statut, name: compte.nom, reason: compte.motif } : null;\n  },\n  blockedStatuses: ['suspendu', 'ferme'],\n  isExempt: (req) => req.user.role === 'support',\n}));\n```\n\n## La commission\n\n```js\nconst { createCommissionSchedule } = require('@astratra/entitlements');\n\nconst bareme = createCommissionSchedule({\n  defaultRate: 0.01,\n  rates: { entreprise: 0.005, interne: 0 },\n});\n\nbareme.commissionOn(10_000, 'entreprise');  // { rate: 0.005, commission: 50, net: 9950 }\n```\n\nLes montants sont en **unités mineures entières** — centimes, cents. Laisser une\nfraction de centime dans un virement est la façon la plus sûre de faire cesser\nun grand livre d'être équilibré. `commission + net` égale toujours le montant,\net un test le vérifie sur toute une série de valeurs.\n\n## Qui voit quel écran\n\nDifférent des fonctionnalités, et la différence compte : une fonctionnalité est\nce que le compte **paie**, un écran est ce qu'une personne a le **droit** de\nvoir. La page finances peut être incluse dans l'offre et ne regarder aucun\nenseignant.\n\n```js\nconst { createAccessMatrix, except } = require('@astratra/entitlements');\n\nconst TOUS = ['proprietaire', 'admin', 'enseignant', 'secretaire', 'parent', 'eleve'];\n\nconst acces = createAccessMatrix({\n  screens: {\n    tableau_de_bord: TOUS,\n    finances:        ['proprietaire', 'admin', 'secretaire'],\n    notes:           except(TOUS, 'parent', 'eleve'),\n  },\n  superRoles: ['support'],\n});\n\nacces.canAccess('finances', 'enseignant');  // false\nacces.screensFor('parent');                 // ['tableau_de_bord'] — de quoi bâtir un menu\n```\n\n**Un écran absent de la table est fermé**, jamais ouvert. L'inverse est la façon\ndont un écran part en production visible par tout le monde parce que personne\nn'a pensé à l'ajouter.\n\n`except()` existe parce que ces tables s'écrivent toujours « tout le monde\nsauf… », et qu'énumérer le reste à la main est la façon d'oublier un rôle sur\nune ligne.\n\n## L'isolation par locataire\n\nChaque requête multi-locataires doit porter le filtre du locataire, et\nl'oublier une seule fois montre à une école les élèves d'une autre. Le scope\ncentralise la décision — et surtout, décide de ce qui arrive quand le locataire\n**manque**.\n\n```js\nconst { createTenantScope } = require('@astratra/entitlements');\n\nconst scope = createTenantScope({\n  field: 'school',\n  globalRoles: ['hero_admin', 'support'],\n  // Pour un store qui compare strictement les types :\n  impossibleValue: new ObjectId('000000000000000000000000'),\n  onMissingTenant: (user) => logger.error('utilisateur sans établissement', user.id),\n});\n\nconst eleves = await Student.find(scope.scope(req.user, { status: 'active' }));\n```\n\n**Pas de locataire = aucune ligne, jamais toutes les lignes.** Un compte à\nmoitié migré, un jeton émis avant un changement de schéma : cet utilisateur\nreçoit un filtre qui ne peut rien trouver, pas une requête sans borne qui\nrenvoie les données de tout le monde. On échoue fermé — et `onMissingTenant`\nsonne l'alarme, parce qu'un utilisateur sans locataire mérite mieux qu'un écran\nvide silencieux.\n\n---\n\n# Invitations\n\nInviter quelqu'un à rejoindre — par lien, une fois, pour un temps. C'est le\npremier acte du cycle de vie des droits : le lien porte déjà le rôle.\n\n## Les trois propriétés qui rendent le parcours sûr\n\n**Le jeton est stocké en empreinte.** La base détient sa signature SHA-256,\njamais le jeton lui-même — une collection qui fuite ne doit pas permettre\nd'accepter toutes les invitations en attente. Le jeton complet existe\nexactement deux fois : dans le lien, et à l'instant de la vérification.\n\n**L'acceptation est à usage unique, atomiquement.** La revendication est une\ntransition de statut atomique dans le store : deux personnes qui ouvrent le\nmême lien à la même seconde créent un compte, pas deux.\n\n**Une nouvelle invitation retire les anciennes.** Inviter deux fois la même\nadresse ne doit pas laisser deux liens vivants — le plus vieux est exactement\nle genre de chose qui ressort d'une boîte mail des mois plus tard.\n\n## Mise en place\n\n```js\nconst { createInvitations } = require('@astratra/entitlements');\n\nconst invitations = createInvitations({\n  store,\n  roles: ['teacher', 'secretary', 'director'],\n\n  // Tourne À L'INTÉRIEUR de l'acceptation : un échec marque l'invitation\n  // \"failed\", jamais \"used\".\n  createAccount: async (invitation, form) =>\n    Users.create({ email: invitation.email, role: invitation.role, ...form }),\n\n  buildUrl: (token) => `${APP_URL}/register?token=${token}`,\n  deliver: ({ invitation, url }) => mailer.send({ to: invitation.email, /* … */ }),\n});\n\n// Inviter — le jeton est rendu ICI et plus jamais.\nconst { token, url } = await invitations.invite({\n  email: 'marie@ecole.cd', role: 'teacher', invitedBy: directorId,\n});\n\n// La page d'inscription pré-remplit :\nconst { email, role } = await invitations.verify(token);\n\n// Accepter :\nconst { account } = await invitations.accept(token, { name, password });\n```\n\n## Les décisions encodées, chacune testée\n\n**Le rôle vient de l'invitation, jamais du formulaire.** Un formulaire qui\nenverrait `role: 'director'` est ignoré : c'est l'inviteur qui a décidé.\n\n**Un échec de création marque `failed`, jamais `used`.** « Utilisée » pour un\ncompte qui n'est jamais né bloque la personne : lien mort, aucun compte.\n\n**Inconnue et déjà utilisée répondent le même message.** Savoir lequel des deux\nest une information qu'un attaquant qui sonde des jetons n'a pas à obtenir.\n\n**Un envoi raté ne détruit pas l'invitation.** Le lien reste copiable depuis\nl'interface et s'envoie à la main.\n\n**Une invitation sans e-mail existe** — un lien affiché à l'écran, un QR code\nen salle des professeurs.\n\n**Une révocation consigne qui a fermé le lien**, et une invitation déjà\ntranchée répond 409 plutôt que de se laisser révoquer en silence.\n\n## Le store des invitations\n\nCinq méthodes, dont une atomique :\n\n```\ncreate(data)                      findByTokenHash(hash)\nclaim(id, from[], patch)          — la transition atomique\nupdate(id, patch)                 retirePending(email)   list(filter)\n```\n\n`createMemoryInvitationStore()` est fourni pour les tests et le développement.\n\n# Un rôle, un seul titulaire\n\nLe compte au sommet — fondateur, propriétaire de la plateforme — détient les\nclés de service et l'accès de dernier recours. Un second compte de ce rang\ndouble ce pouvoir en silence, et deux titulaires peuvent se révoquer l'un\nl'autre. La règle est simple ; la tenir ne l'est pas, parce qu'un rôle arrive\npar plusieurs portes : la création, la promotion, le changement d'identifiant,\net l'écriture directe en base qui ne traverse aucun code.\n\n`createUniqueRole` garde les trois premières portes, et sa sentinelle\n(`sweep`) rattrape la quatrième. Chaque tentative refusée déclenche une alerte.\n\n```js\nconst { createUniqueRole } = require('@astratra/entitlements');\n\nconst owner = createUniqueRole({\n  role: 'owner',                              // le nom du rôle, à toi\n  anchor: () => process.env.OWNER_EMAIL,      // l'identifiant du titulaire légitime\n  store: {\n    findHolders: (role) => Users.find({ role: new RegExp(`^\\\\s*${role}\\\\s*$`, 'i') }).lean(),\n    remove: async (account, role) =>\n      (await Users.deleteOne({ _id: account._id, role })).deletedCount === 1\n  },\n  alert: async (event) => mailer.send(ownerAddress, t(`alerts.${event.kind}`, event)),\n  messages: { role_taken: t('errors.role_taken'), holder_protected: t('errors.holder_protected') }\n});\n\n// sur chaque chemin d'écriture\nawait owner.assertCanCreate({ role: body.role, actor: req.user });\nawait owner.assertCanChangeRole({ account: stored, nextRole: body.role, actor: req.user });\nawait owner.assertCanRename({ account: stored, nextIdentifier: body.email, actor: req.user });\nawait owner.assertCanModify({ target: stored, actor: req.user });\n\n// toutes les heures, sous un verrou partagé entre instances\nawait owner.sweep();\n```\n\n## Les décisions encodées, chacune testée\n\n| Tentative | Réponse | Alerte |\n|---|---|---|\n| Créer un titulaire alors qu'un autre existe | `409 role_taken` | `create_refused` |\n| Promouvoir un compte alors qu'un autre titulaire existe | `409 role_taken` | `promotion_refused` |\n| Rétrograder le titulaire | `409 last_holder` | `demotion_refused` |\n| Prendre l'identifiant du titulaire (quelqu'un d'autre) | `409 identity_taken` | `rename_refused` |\n| Modifier ou supprimer le compte du titulaire (quelqu'un d'autre) | `403 holder_protected` | `modify_refused` |\n| Créer le premier titulaire, plateforme vide | autorisé (`allowBootstrap: false` pour l'interdire, `403`) | — |\n| Le titulaire enregistre son propre compte | autorisé | — |\n\n- **La casse et les espaces ne contournent rien** : ` OWNER ` est le rôle.\n- **Le store fait foi, pas l'objet passé** : un compte qui arrive avec le rôle\n  déjà appliqué (le document vu par un hook d'ORM) est quand même comparé aux\n  titulaires en base, exclu par son identifiant seulement.\n- **Aucun texte en dur** : sans `messages[reason]`, le message est le code.\n  L'alerte reçoit un événement structuré ; destinataire et formulation sont à toi.\n- **L'alerte ne décide jamais** : si elle échoue, le refus tient quand même, et\n  la sentinelle efface quand même.\n\n## La sentinelle\n\nElle efface des comptes : elle est écrite pour ne jamais effacer le bon. En\ncas de doute, elle ne fait RIEN.\n\n| Situation en base | Effacement | Alerte |\n|---|---|---|\n| Un titulaire, l'ancré | aucun | — |\n| Plusieurs titulaires, l'ancré parmi eux | tous sauf l'ancré | `intruders_removed` |\n| Plusieurs titulaires, pas d'ancre configurée | aucun | `anchor_missing` |\n| Plusieurs titulaires, aucun ne porte l'ancre | aucun | `anchor_not_found` |\n| Un seul titulaire, qui n'est PAS l'ancré | aucun | `anchor_mismatch` — c'est à ça que ressemble un titulaire remplacé |\n\n`sweep({ dryRun: true })` dit ce qu'elle ferait sans rien effacer ni envoyer.\n`store.remove` doit n'effacer que si le compte porte TOUJOURS le rôle : un\ncompte rétrogradé entre la lecture et l'écriture n'est pas supprimé par erreur.\n\n## Ce que ce module ne fait pas\n\n- **Il ne ferme pas la course à l'amorçage.** Deux créations simultanées sur une\n  plateforme vide passent toutes deux la vérification ; seul un index unique\n  partiel en base (`{ role: 1 }` unique où `role = 'owner'`) l'empêche.\n- Il ne planifie rien : horaire et verrou entre instances sont à toi.\n- Il ne masque pas le titulaire dans les listes ni les exports.\n- Il ne voit pas les écritures en masse (`updateMany`, `insertMany`,\n  `bulkWrite`) si tu ne l'appelles pas pour chaque compte concerné.\n\n# Ce que ce package ne fait pas\n\n- Il ne sait pas **où** vit le plan d'un compte : tu le lui donnes.\n- Il ne décide pas **qui** est exempté de facturation.\n- Il n'impose ni plans, ni rôles, ni taux, ni devise.\n- Il ne crée pas les comptes invités : `createAccount` est à toi, avec ton\n  hachage de mot de passe et tes modèles.\n- Il n'envoie rien : `deliver` se branche sur `@astratra/notify` ou autre.\n- Il ne stocke rien et n'appelle aucune base.\n\n## Tests\n\n```bash\nnpm test --workspace @astratra/entitlements\n```\n","readmeFilename":"README.md"}