{"_id":"@astratra/credentials","_rev":"2-74eb28dcb2d664e5d4290a91df577ec9","name":"@astratra/credentials","dist-tags":{"latest":"0.3.0"},"versions":{"0.2.0":{"name":"@astratra/credentials","version":"0.2.0","keywords":["astratra","credentials","secrets","api-keys"],"license":"MIT","_id":"@astratra/credentials@0.2.0","maintainers":[{"name":"emch99","email":"emchkongo@gmail.com"}],"dist":{"shasum":"220b2f8ccb23bd89439ccefe7f389333c2b7ebb4","tarball":"https://registry.npmjs.org/@astratra/credentials/-/credentials-0.2.0.tgz","fileCount":14,"integrity":"sha512-e3pTdExTp+Vl2Qwh+ZZoXLnt+0+JqOjjn8IoWGYhRAwvFgGJyj5i915irRG9A6fwu3UrFqQ89CWKLPSV1RWPVQ==","signatures":[{"sig":"MEYCIQDAAb+/7ODMaQEGfNcT/3XK0WPoo2+EqxLlayXc/mDACwIhAIC0LpfgKD+sHEAESX8NBhrpxwPthnuuhCnkO7usbUgq","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":67666},"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":"Encrypted service keys stored in your database, editable from the interface, with no restart.","directories":{},"_nodeVersion":"24.16.0","dependencies":{"express":"^4.18.3","@astratra/core":"^1.0.0","express-validator":"^7.3.2"},"publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"jest":"30.4.2","supertest":"^7.0.0","@astratra/security":"^1.8.0"},"_npmOperationalInternal":{"tmp":"tmp/credentials_0.2.0_1787666092636_0.609075615209905","host":"s3://npm-registry-packages-npm-production"}},"0.3.0":{"name":"@astratra/credentials","version":"0.3.0","description":"Encrypted service keys stored in your database, editable from the interface, with no restart.","engines":{"node":">=20"},"main":"src/index.js","types":"src/index.d.ts","type":"commonjs","scripts":{"test":"jest"},"jest":{"testMatch":["**/__tests__/**/*.test.js"]},"keywords":["astratra","credentials","secrets","api-keys"],"license":"MIT","publishConfig":{"access":"public"},"dependencies":{"@astratra/core":"^1.0.0","express":"^4.18.3","express-validator":"^7.3.2"},"devDependencies":{"@astratra/security":"^1.8.0","jest":"30.4.2","supertest":"^7.0.0"},"gitHead":"597c98161c5fc076a7575eaa0a3bd3cdfd5cb132","_id":"@astratra/credentials@0.3.0","_nodeVersion":"24.16.0","_npmVersion":"11.13.0","dist":{"integrity":"sha512-I+CO6fwFI0eYuvB8omdwouXKKAdEHPHCWiNT8Tn4ECqiUFZaU8trgak/Bm9DqV+Ug+4QaIHOkDibI58zxs+OCA==","shasum":"3238fb777d4a5a763495d8fdfe8c8f9bf0dbc7ad","tarball":"https://registry.npmjs.org/@astratra/credentials/-/credentials-0.3.0.tgz","fileCount":15,"unpackedSize":74904,"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEQCIDf5gPdZXayjMYFpusDRCbxospeCWyoMP5K8pYoJUm47AiBlWQh5BwFKF6oeSpj6Ci1HrOImWmIe64CArnX8NhHlgg=="}]},"_npmUser":{"name":"emch99","email":"emchkongo@gmail.com"},"directories":{},"maintainers":[{"name":"emch99","email":"emchkongo@gmail.com"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/credentials_0.3.0_1787768547821_0.9062672458905292"},"_hasShrinkwrap":false}},"time":{"created":"2026-08-25T13:54:52.480Z","modified":"2026-08-26T18:22:28.341Z","0.2.0":"2026-08-25T13:54:52.788Z","0.3.0":"2026-08-26T18:22:27.946Z"},"license":"MIT","keywords":["astratra","credentials","secrets","api-keys"],"description":"Encrypted service keys stored in your database, editable from the interface, with no restart.","maintainers":[{"name":"emch99","email":"emchkongo@gmail.com"}],"readme":"# @astratra/credentials\n\nLes clés de service — paiement, e-mail, IA — rangées **chiffrées dans ta base**\nplutôt que dans un `.env` sur le serveur. Elles se saisissent depuis l'interface,\nprennent effet en moins d'une minute, sans redémarrage et sans session SSH.\n\nLe `.env` garde son rôle de secours : rien ne casse tant qu'aucune clé n'a été\nsaisie.\n\nCe package ne connaît ni ta base, ni ton chiffrement, ni tes fournisseurs. Tout\nest injecté : le store, le cipher, le catalogue de clés, la règle qui protège\nles valeurs sensibles, et la fonction qui envoie le code de déverrouillage.\n\n## Le problème\n\nChanger une clé Stripe ou une clé d'API obligeait à ouvrir une session SSH,\néditer un fichier, relancer le service. Trois occasions de se tromper, et un\nsecret en clair sur le disque du serveur.\n\n## L'ordre, qui ne change jamais\n\n1. une valeur **saisie dans l'interface** → déchiffrée et utilisée ;\n2. une clé **explicitement débranchée** → rien, même si le `.env` en a une ;\n3. **rien en base** → le `.env`, comme avant.\n\nLe point 2 est le piège. Sans marqueur de débranchement, supprimer une clé la\nferait réapparaître par la variable d'environnement de secours — et on ne\nsaurait jamais si un service est vraiment débranché.\n\n## Mise en place\n\n```js\nconst { createFieldCipher } = require('@astratra/security');\nconst {\n  createCredentialCatalog,\n  createCredentialVault,\n  createEnvHydrator,\n  createMongoCredentialStore\n} = require('@astratra/credentials');\n\n// 1. Ce que l'interface a le droit de piloter. Jamais un champ libre :\n//    sans catalogue, n'importe quel nom de variable pourrait être écrit en\n//    base puis lu au démarrage.\nconst catalog = createCredentialCatalog({\n  spaces: [\n    {\n      id: 'ai',\n      label: 'Intelligence artificielle',\n      hint: 'Interrogés dans cet ordre.',\n      keys: [\n        {\n          key: 'GROQ_API_KEY',\n          label: 'Groq',\n          help: \"Le premier fournisseur interrogé. Sans lui, on bascule sur le suivant.\",\n          where: 'console.groq.com → API Keys'\n        }\n      ]\n    }\n  ],\n  // La serrure ne se range pas dans le coffre qu'elle ferme.\n  reservedKeys: ['ENCRYPTION_KEY', 'JWT_SECRET', 'MONGODB_URI']\n});\n\n// 2. Où c'est rangé, et comment c'est chiffré.\nconst store = createMongoCredentialStore({\n  collection: mongoose.connection.collection('credentials'),\n  isReady: () => mongoose.connection.readyState === 1\n});\nconst cipher = createFieldCipher({ key: process.env.ENCRYPTION_KEY });\n\nconst vault = createCredentialVault({ store, catalog, cipher });\n\n// 3. Le reste du code lit toujours process.env — il n'a rien à changer.\nconst hydrator = createEnvHydrator({ vault });\nawait hydrator.hydrate(catalog.keys());\nhydrator.startRefresh(catalog.keys(), { intervalMs: 60_000 });\n```\n\n`vault.get('GROQ_API_KEY')` répond directement, et `process.env.GROQ_API_KEY`\ndit la vérité pour tout le code qui ne sait pas lire de façon asynchrone.\n\n## Le coffre\n\n| Méthode | Ce qu'elle fait |\n|---|---|\n| `get(key)` | la base d'abord, le `.env` ensuite, `null` si débranchée |\n| `getMany(keys)` | plusieurs clés en une seule lecture |\n| `stored()` | ce que dit la BASE, sans repli — absente ≠ débranchée |\n| `set(key, value, { updatedBy })` | chiffre et enregistre |\n| `disconnect(key)` | débranche : le `.env` ne reprend PAS la main |\n| `status()` | l'état de chaque clé, sûr à envoyer au navigateur |\n| `forget()` | vide le cache (une minute par défaut) |\n\n`status()` ne renvoie **jamais** une valeur secrète : seulement les quatre\nderniers caractères. Une capture d'écran de la page des réglages ne doit rien\ncompromettre. Une valeur déclarée `secret: false` — un identifiant OAuth public,\nune adresse d'expédition — est renvoyée en clair : elle existe pour être\nvérifiée d'un coup d'œil.\n\nRien ne lève à la lecture. Pas de base, pas de cipher, une valeur abîmée : on\nretombe sur le `.env`. **Un paiement ne doit pas échouer parce que la base a\nhoqueté.** Une clé illisible n'emporte pas les autres.\n\n## Le garde-valeur\n\nPoste de développement et production partagent souvent le même store — c'est\ntout l'intérêt de centraliser les clés. C'est sans danger tant que les valeurs\nsont des valeurs de test ; c'est un désastre le jour où un portable récupère une\nclé de paiement réelle et encaisse pour de vrai.\n\nLe jugement porte donc sur la **valeur**, pas seulement sur l'environnement :\n\n```js\nconst { createValueGuard } = require('@astratra/credentials');\n\nconst guard = createValueGuard({\n  keys: ['STRIPE_SECRET_KEY', 'STRIPE_WEBHOOK_SECRET'],\n  // La clé dont le préfixe décide du sort de tout le groupe : une signature de\n  // webhook ne dit rien d'elle-même, mais appartient au MÊME compte que la clé\n  // secrète et doit suivre son sort.\n  decidingKey: 'STRIPE_SECRET_KEY',\n  livePattern: /^(sk|pk|rk)_live_/,\n  testPattern: /^(sk|pk|rk)_test_/\n});\n\ncreateCredentialVault({ store, catalog, cipher, guard });\ncreateEnvHydrator({ vault, guard });\n```\n\nUne valeur qu'on ne sait pas classer n'est jamais traitée comme réelle : un\ngarde-fou qui bloque à tort finit par être contourné.\n\nSans `guard`, rien n'est restreint — c'est ce que renvoie `createPermissiveGuard()`,\nutilisable explicitement quand on veut le dire plutôt que l'omettre. Astratra n'a\npas d'avis sur les clés qui déplacent de l'argent chez toi.\n\n## Le code de déverrouillage\n\nSavoir le mot de passe du compte ne suffit pas. Remplacer une clé de paiement\npar la sienne ne casse rien d'apparent — l'argent part simplement ailleurs.\nC'est le genre de vol qu'on ne remarque qu'au relevé.\n\nCe module ne connaît pas l'e-mail : il fabrique, range et vérifie le code, et\n`deliverCode` est à toi. **Envoie-le à l'adresse déjà enregistrée sur le compte,\njamais à une adresse fournie dans la requête** — ce serait offrir au voleur le\nmoyen de se l'envoyer à lui-même.\n\n```js\nconst { createUnlockChallenge, createMongoChallengeStore, maskEmail } =\n  require('@astratra/credentials');\n\nconst challenge = createUnlockChallenge({\n  store: createMongoChallengeStore({ collection: db.collection('credentialChallenges') }),\n  deliverCode: async ({ subjectId, code }) => {\n    const account = await Users.findById(subjectId);\n    await sendEmail(account.email, 'Code de modification', `Votre code : ${code}`);\n    return { sentTo: maskEmail(account.email) };\n  }\n});\n```\n\nSix chiffres tirés par `crypto.randomInt`, comparés à durée constante, jamais\nconservés en clair : une fuite de cette collection ne déverrouille rien. Cinq\ntentatives, dix minutes de validité, puis dix minutes de fenêtre de saisie —\nsinon poser les dix clés d'un fournisseur demanderait dix e-mails, et le\ngarde-fou finirait contourné plutôt qu'utilisé. Demander un nouveau code\n**referme** la fenêtre en cours.\n\n## L'écran qui les gère\n\nTout ce qui précède tourne sur le serveur. Ces fonctions-ci tournent dans\nl'application — site ou mobile — et lisent ce que les routes renvoient.\n\n```js\nconst spaces = readSpaces(payload);\n\ncoverageOf(spaces[0]);        // { done: 3, total: 5 } — la pastille de l'onglet\nmissingKeys(spaces);          // ce qu'il reste à régler, tous espaces confondus\nfirstSpaceToOpen(spaces);     // celui où il y a le plus à faire\nunlockState(payload);         // { unlocked: true, minutesLeft: 4 }\ncleanUnlockCode(' 12 34 56'); // '123456'\n```\n\n**La réponse du serveur est lue sans confiance en sa forme.** Un champ absent,\nune source renommée, un `null` à la place d'un espace : chacun de ces cas\ndonnait un écran blanc avec une erreur de type derrière.\n\n**Le doute profite au secret.** Une clé dont le drapeau `secret` manque est\n**masquée**. Une clé mal étiquetée affichée en clair est une fuite ; masquée à\ntort, c'est un désagrément mineur.\n\n**Une clé volontairement débranchée compte comme manquante.** C'est un choix,\nmais un choix qui laisse un service déconnecté : l'écran doit le montrer, pas\nl'enterrer.\n\n**La fenêtre de modification est jugée au moment de la LECTURE**, jamais à la\nréception. Le serveur envoie une date, pas un compte à rebours : décider\n« ouverte » à l'arrivée et s'y fier ensuite laisse un écran de réglages ouvert\nune heure, persuadé qu'il a encore le droit d'écrire.\n\n## Les routes\n\n```js\nconst { createCredentialsRoutes } = require('@astratra/credentials');\nconst { authorizeRoles } = require('@astratra/security');\n\napp.use('/api/credentials', createCredentialsRoutes({\n  vault,\n  challenge,\n  authorize: authorizeRoles('owner')\n}));\n```\n\n| Route | Effet |\n|---|---|\n| `GET /` | l'état des clés + la fenêtre ouverte, le cas échéant |\n| `POST /challenge` | envoie le code à l'adresse du compte |\n| `POST /unlock` | vérifie le code, ouvre la fenêtre |\n| `PUT /:key` | enregistre une valeur |\n| `DELETE /:key` | débranche une clé |\n\n`authorize` est **obligatoire** : ces clés engagent les paiements de toute la\nplateforme, pas ceux d'un locataire, et Astratra n'a pas d'avis sur qui possède\nl'argent. Sans `challenge`, les écritures passent directement — c'est ta\ndécision, pas un défaut.\n\n## Les stores\n\nDeux contrats, minuscules :\n\n```\nstore de clés       : findAll() -> rows, upsert(row)\nstore de challenges : find(subjectId) -> record|null, save(subjectId, record)\n```\n\nUne ligne vaut `{ key, value, secret, updatedAt, updatedBy }`. `value` est déjà\nchiffrée quand `secret` vaut `true` — le chiffrement a lieu dans le coffre, donc\nun adapter ne voit jamais un secret en clair qu'il pourrait logger par accident.\n\nFournis : `createMemoryCredentialStore` et `createMemoryChallengeStore` (tests et\ndéveloppement), `createMongoCredentialStore` et `createMongoChallengeStore`\n(n'importe quelle collection du driver MongoDB, sans mongoose ni schéma).\n\n`isReady` compte plus qu'il n'y paraît : mongoose met les requêtes en file\nd'attente quand il n'est pas connecté. Sans ce garde, une lecture resterait\nsuspendue au lieu de retomber sur le `.env` — un paiement figé plutôt qu'un\npaiement qui marche.\n\n## L'hydratation de `process.env`\n\nLa plupart des codebases lisent leurs secrets en `process.env.MACHIN`, à\ntrente-six endroits. Convertir chacun en lecture asynchrone est un chantier\nrisqué pour un bénéfice nul : il suffit que `process.env` **dise la vérité**.\n\nTrois cas, et le troisième est celui qu'on oublie :\n\n- la base a une valeur → elle remplace celle du `.env` ;\n- la base dit « débranchée » → la variable est **effacée**, le `.env` ne reprend pas ;\n- la base ne dit rien → la valeur **d'origine** du `.env` est restaurée.\n\nSans le troisième, supprimer une clé laisserait l'ancienne valeur figée dans\n`process.env` jusqu'au prochain redémarrage : un service qu'on croit débranché\net qui continue de fonctionner.\n\nLes valeurs d'origine sont capturées **une seule fois**, au premier appel. Après\nla première hydratation, `process.env` ne dit plus d'où il vient.\n\n## Appliquer une saisie tout de suite\n\n`onChange` est appelé après chaque écriture — branche-y l'hydratation pour que\nla nouvelle valeur serve immédiatement plutôt qu'à la minute suivante :\n\n```js\nconst vault = createCredentialVault({\n  store, catalog, cipher, guard,\n  onChange: () => hydrator.hydrate(catalog.keys())\n});\n```\n\n## Changer de clé sans rien perdre\n\nC'est la panne silencieuse par excellence. Remplace le cipher d'un coup et rien\nne lève : le coffre attrape l'échec de déchiffrement ligne par ligne, retombe\nsur le `.env`, et toutes tes clés cessent simplement d'être utilisées. Les\npaiements passent par l'ancienne valeur du fichier, ou ne passent plus du tout.\nTu l'apprends par un client, pas par un log.\n\nUne rotation, c'est donc trois gestes — et celui du milieu est tout l'intérêt :\n\n```js\nconst { createCredentialRotation } = require('@astratra/credentials');\n\n// 1. Le coffre lit les DEUX générations. Rien n'a bougé, rien n'est cassé.\nconst vault = createCredentialVault({\n  store, catalog, cipher: nouveauCipher, previousCipher: ancienCipher\n});\n\n// 2. Migrer, valeur par valeur.\nconst rotation = createCredentialRotation({\n  store, catalog, to: nouveauCipher, from: ancienCipher\n});\n\nconsole.log(await rotation.plan());   // ce qui SERAIT fait — n'écrit rien\nawait rotation.apply();               // écrit\n\n// 3. Vérifier AVANT de retirer l'ancien cipher.\nconst { complete, pending, unreadable, unreadableKeys } = await rotation.isComplete();\n```\n\nNe saute jamais l'étape 3. Une valeur qu'aucun des deux ciphers ne lit est déjà\nperdue, et retirer l'ancien est ce qui rend cette perte définitive —\n`unreadableKeys` te dit lesquelles regarder.\n\nLes écritures utilisent **toujours** le cipher courant, jamais l'ancien : c'est\nce qui fait converger la rotation au lieu de la faire osciller.\n\nTout est rejouable. Une valeur déjà migrée est reconnue et laissée intacte, donc\nun passage interrompu se relance simplement.\n\n| Ce que la rotation renvoie | Sens |\n|---|---|\n| `rotated` | relue avec l'ancien cipher, réécrite avec le nouveau |\n| `already` | déjà lisible avec le nouveau — rien à faire |\n| `plain` | déclarée `secret: false`, son clair est voulu |\n| `skipped` | marqueur de débranchement ou valeur vide |\n| `unreadable` | **aucun des deux ciphers ne la lit** — à examiner |\n\n## Ce que ce package ne fait pas\n\n- Il ne fournit **aucun catalogue** de fournisseurs. Quelles clés existent et ce\n  qui cesse de marcher sans chacune, c'est ton produit qui le sait.\n- Il ne **chiffre** rien lui-même : passe-lui un cipher, par exemple\n  `createFieldCipher` de `@astratra/security`.\n- Il ne décide pas **qui** a le droit d'y toucher.\n- Il n'envoie **aucun e-mail**.\n\n## Tests\n\n```bash\nnpm test --workspace @astratra/credentials\n```\n","readmeFilename":"README.md"}