{"_id":"@astratra/privacy","_rev":"3-74da01dd39a8481853b2b3dfdd93eca9","name":"@astratra/privacy","dist-tags":{"latest":"0.3.0"},"versions":{"0.1.0":{"name":"@astratra/privacy","version":"0.1.0","keywords":["astratra","privacy","gdpr","rgpd","anonymisation"],"license":"MIT","_id":"@astratra/privacy@0.1.0","maintainers":[{"name":"emch99","email":"emchkongo@gmail.com"}],"dist":{"shasum":"02f5e7dfc72d22a0e436be544c95bb377b3f1111","tarball":"https://registry.npmjs.org/@astratra/privacy/-/privacy-0.1.0.tgz","fileCount":11,"integrity":"sha512-xEYzq+qO15RZ+8acduB9kzN57ch6VwHnqvHAn79Jo1RJz9oMOYxGigDTDYKz8LnoyMf0iqKipYUDZMYmFcBXCA==","signatures":[{"sig":"MEQCIAgIwx0dXH/2saIGWyYFJ1pjskSLPtqIByVXIu3p+ik7AiBovOWjZKHD+7J1GNk3IHTLM4qSH/TtiA22xyypPoDCHA==","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":30686},"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":"Right of access, right to erasure, and log redaction — anonymise without destroying the records you must keep.","directories":{},"_nodeVersion":"24.16.0","publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"jest":"30.4.2"},"_npmOperationalInternal":{"tmp":"tmp/privacy_0.1.0_1787666107015_0.5427386182562506","host":"s3://npm-registry-packages-npm-production"}},"0.2.0":{"name":"@astratra/privacy","version":"0.2.0","keywords":["astratra","privacy","gdpr","rgpd","anonymisation"],"license":"MIT","_id":"@astratra/privacy@0.2.0","maintainers":[{"name":"emch99","email":"emchkongo@gmail.com"}],"dist":{"shasum":"3ebe58d14f69bf4161539756b6c8ba99fab14ab5","tarball":"https://registry.npmjs.org/@astratra/privacy/-/privacy-0.2.0.tgz","fileCount":12,"integrity":"sha512-VieGgQcoU8ppgTcBSgTmP0KJwlKDzWX3j/lXpghBh2XJ2pIlMMZnS2f+bsU0EPZ8noZbgGjWffPH+F+aUJshjg==","signatures":[{"sig":"MEUCIBVXeUS6eZBeKjAzfwnR/ITugIlGWCdEfj9XqTbhp0L1AiEAqE27je8vESrisO3FwMYn+nalOZ9eht9anLtYyMVEeCM=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"},{"sig":"MEQCIEnwQHtFd9qpHXCYb29MSNcNn6Oo5u6zZmJ3BYKVU9xcAiBKW2DLmk/FNwYOrdbG4qhO6W0+/n4sv+D+SF0OOiFNSA==","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":66897},"jest":{"testMatch":["**/__tests__/**/*.test.js"]},"main":"src/index.js","type":"commonjs","types":"src/index.d.ts","engines":{"node":">=20"},"gitHead":"a6eda5bf009d1d47caff00955940c6d2303bee2c","scripts":{"test":"jest"},"_npmUser":{"name":"emch99","email":"emchkongo@gmail.com"},"_npmVersion":"11.13.0","description":"Right of access, right to erasure, and log redaction — anonymise without destroying the records you must keep.","directories":{},"_nodeVersion":"24.16.0","publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"jest":"30.4.2"},"_npmOperationalInternal":{"tmp":"tmp/privacy_0.2.0_1789991439791_0.2561754663656155","host":"s3://npm-registry-packages-npm-production"}},"0.3.0":{"_id":"@astratra/privacy@0.3.0","dist":{"shasum":"3a9a82b40e88b15a136e186152e702521004927f","tarball":"https://registry.npmjs.org/@astratra/privacy/-/privacy-0.3.0.tgz","fileCount":15,"integrity":"sha512-9YyrSZINR+zMjZT4DIcblyMCiEciB+ZNtm9j8cZTeCL8furXge67F3MfGIiHB85XtKYhuHZskYr88zHlId2fuQ==","signatures":[{"sig":"MEUCIQC9MCo0tRDgc4xzTArGhQGtC5E3veM8oCqOJTFovuqcSgIgQNEmPFAb81rLXoYXcdEIUoVAKQNdXlgSJRKhEJHviMk=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"},{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEYCIQCIRwXPq+WHTJ+XSQTFhPpaXa6Im+JiclPF6zFV8v01LwIhAJ+f/mc+c25qXS/sdguI1lC30al4VGYGFCV0+6RkhDVG"}],"unpackedSize":105692},"jest":{"testMatch":["**/__tests__/**/*.test.js"]},"main":"src/index.js","name":"@astratra/privacy","type":"commonjs","types":"src/index.d.ts","engines":{"node":">=20"},"gitHead":"fa5ec86eac3e65bcf1d4599f8af962dc57751cbd","license":"MIT","scripts":{"test":"jest"},"version":"0.3.0","_npmUser":{"name":"emch99","email":"emchkongo@gmail.com"},"keywords":["astratra","privacy","gdpr","rgpd","anonymisation"],"_npmVersion":"11.13.0","description":"Right of access, right to erasure, and log redaction — anonymise without destroying the records you must keep.","directories":{},"maintainers":[{"name":"emch99","email":"emchkongo@gmail.com"}],"_nodeVersion":"24.16.0","publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"jest":"30.4.2"},"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/privacy_0.3.0_1790628175628_0.22917902158756065"}}},"time":{"created":"2026-08-25T13:55:06.875Z","modified":"2026-09-28T20:42:55.866Z","0.1.0":"2026-08-25T13:55:07.188Z","0.2.0":"2026-09-21T11:50:39.889Z","0.3.0":"2026-09-28T20:42:55.711Z"},"license":"MIT","keywords":["astratra","privacy","gdpr","rgpd","anonymisation"],"description":"Right of access, right to erasure, and log redaction — anonymise without destroying the records you must keep.","maintainers":[{"name":"emch99","email":"emchkongo@gmail.com"}],"readme":"# @astratra/privacy\n\nDroit d'accès, droit à l'oubli, suppression de compte en libre-service, et\nfiltre de données sensibles. Sans détruire les dossiers que tu as l'obligation\nde garder.\n\nAucune dépendance à l'exécution. Rien n'est stocké ni sauvegardé à ta place.\n\n## L'idée centrale : anonymiser, pas supprimer\n\nSupprimer la ligne est le geste évident, et presque toujours le mauvais.\n\nLes notes d'un élève, les factures d'un client, les fiches de paie d'un salarié\nont leur propre durée de conservation légale, et appartiennent à\nl'établissement, à l'entreprise, au fisc autant qu'à la personne. Supprimer le\ncompte les emporte avec lui, ou les laisse orphelines et inexplicables.\n\nCe que la loi demande, c'est que la personne cesse d'être **identifiable**. Les\nchamps qui l'identifient partent, le reste demeure, et les dossiers restent\ncohérents.\n\n```js\nconst { createAnonymizer } = require('@astratra/privacy');\n\nconst anonymizer = createAnonymizer({\n  fields: {\n    fullName: 'redact',\n    email:    (_v, { token }) => `efface-${token}@invalid`,\n    phone:    'clear',\n  },\n  // Ce qu'une réécriture de champ ne peut pas exprimer : invalider les\n  // sessions, retirer les abonnements aux notifications, révoquer les jetons.\n  onAnonymised: async (compte) => {\n    compte.tokenVersion = (compte.tokenVersion || 0) + 1000;\n  },\n});\n\nawait anonymizer.anonymise(compte);\nawait compte.save();   // la persistance reste ta décision\n```\n\nDeux garde-fous que les tests fixent :\n\n**Un champ que la fiche ne porte pas est ignoré, jamais inventé.** Ajouter\n`parentPhone` à une fiche de salarié serait créer une donnée personnelle\nnouvelle au moment même de l'effacement.\n\n**C'est irréversible par construction.** Il n'y a pas d'annulation, et il ne\ndoit pas y en avoir : une anonymisation réversible n'a rien anonymisé.\n\n## L'effacement passe par un humain\n\nUn bouton de suppression irréversible en un clic est un cadeau pour qui\nemprunte une session trente secondes, et pour l'utilisateur qui passe une\nmauvaise journée.\n\n`createMemoryErasureStore()` sert aux tests et au développement : un journal\nd'effacement qui disparaît au redémarrage n'est pas une piste d'audit.\n\n```js\nconst { createErasureWorkflow } = require('@astratra/privacy');\n\nconst workflow = createErasureWorkflow({\n  store,\n  erase: async (demande) => {\n    const compte = await Comptes.findById(demande.subject);\n    await anonymizer.anonymise(compte);\n    await compte.save();\n  },\n});\n\nawait workflow.request({ subject: userId, reason: 'je quitte le service' });\nawait workflow.pending();                                     // ce qui attend\nawait workflow.approve(id, { reviewedBy: adminId, note: '…' }); // exécute\nawait workflow.reject(id, { reviewedBy: adminId, note: '…' });\n```\n\nTrois règles tenues par le code, pas par la discipline :\n\n**Personne n'approuve sa propre demande.** Le but de la barrière est une\nseconde paire d'yeux ; s'approuver soi-même restaure le bouton en un clic.\n\n**Une opération irréversible ne s'exécute pas deux fois.** Une demande déjà\ntranchée renvoie 409.\n\n**Un effacement en échec est enregistré comme échoué, jamais comme terminé.**\nConsigner une réussite qui n'a pas eu lieu, c'est dire à un régulateur qu'on a\neffacé des données qu'on détient toujours.\n\n## La suppression de compte en libre-service\n\nLes magasins d'applications exigent qu'une personne puisse supprimer son compte\nelle-même, sans écrire au support. Le faire en un clic irréversible est deux\nfois faux : ça punit le geste impulsif, et ça donne à qui emprunte une session\nle pouvoir de détruire le compte pour de bon.\n\nDonc la demande **suspend** le compte tout de suite, et l'effacement n'a lieu\nqu'à l'échéance (30 jours par défaut, `graceMs`). Se reconnecter ou annuler\npendant le délai rétablit tout.\n\n```js\nconst { createAccountDeletion, createAnonymizer } = require('@astratra/privacy');\nconst { createJobLock, createMongoLockStore } = require('@astratra/resilience');\n\nconst suppression = createAccountDeletion({\n  store,                               // voir createMemoryDeletionStore()\n  // L'effacement n'est PAS réimplémenté : il délègue à l'anonymiseur.\n  erase: async (id) => {\n    const compte = await Comptes.findById(id);\n    await anonymizer.anonymise(compte);\n    await compte.save();\n  },\n  // Les règles produit, en codes que le client traduit.\n  canRequest: async (id, { dernierAdministrateur }) => (dernierAdministrateur ? 'last_manager' : null),\n  suspend: async (id) => Comptes.updateOne({ _id: id }, { $inc: { tokenVersion: 1 } }),\n  notify: {\n    send: (id, message) => mailer.send(id, message),\n    // Les textes viennent de toi, jamais du paquet.\n    messages: {\n      scheduled: ({ daysLeft }) => t('suppression.programmee', { daysLeft }),\n      cancelled: ({ by }) => t('suppression.annulee', { by }),\n      reminder:  ({ daysLeft }) => t('suppression.imminente', { daysLeft }),\n    },\n  },\n  lock: createJobLock({ store: createMongoLockStore(db.collection('locks')) }),\n});\n\nawait suppression.request(userId, contexte);   // { ok, record } | { ok: false, reason }\nawait suppression.cancel(userId);              // depuis les réglages\nawait suppression.onSignIn(userId);            // après le second facteur, jamais avant\nawait suppression.status(userId);\nawait suppression.sweep();                     // tâche quotidienne\n```\n\nCe que les tests fixent :\n\n**Rien n'est effacé avant l'échéance**, même si l'adaptateur de stockage renvoie\ntrop de lignes : l'échéance est revérifiée. Une fiche sans date n'est jamais\nconsidérée comme due.\n\n**Chaque transition est conditionnelle** (`update`/`remove` avec les champs\nattendus). Une reconnexion qui arrive pendant la passe ne peut pas être\neffacée sur la foi d'une lecture périmée ; deux instances n'effacent pas deux\nfois ; deux appuis simultanés ne programment qu'une fois.\n\n**`onSignIn` ne lève jamais** : le mécanisme censé faire revenir la personne ne\ndoit pas l'enfermer dehors. Appelle-le quand la session est vraiment accordée,\nsecond facteur compris — sinon le seul mot de passe ressuscite le compte.\n\n**Chaque annulation prévient le titulaire.** Une reconnexion faite par quelqu'un\nd'autre ne ramène pas le compte en silence.\n\n**Si la suspension échoue, la demande est défaite.** Un compte « fermé » dont\nles sessions restent ouvertes est pire qu'une demande qui échoue visiblement.\n\n**Un courrier en échec ne défait rien**, et un rappel raté est retenté au\npassage suivant. Un effacement en échec reste programmé et repasse.\n\n**La passe est idempotente** et accepte un verrou injecté (`run(name, holdMs,\nfn)` qui rend `null` quand une autre instance le tient) : `createJobLock` de\n`@astratra/resilience` convient tel quel, sans dépendance.\n\nCe circuit ne remplace pas `createErasureWorkflow` : la demande d'effacement\nrevue par un humain reste l'outil quand quelqu'un d'autre a son mot à dire.\n\n## Le droit d'accès\n\nCe que tout le monde rate n'est pas l'export — c'est le **silence** autour.\n\nUn produit réparti sur trois services exporte ce que le premier détient et ne\ndit rien des deux autres. La personne reçoit un fichier qui a l'air complet et\nne l'est pas.\n\n```js\nconst { createDataExporter } = require('@astratra/privacy');\n\nconst exporter = createDataExporter({\n  sources: [\n    { key: 'compte',   collect: async (id) => Comptes.findById(id) },\n    { key: 'commandes', collect: async (id) => Commandes.find({ client: id }) },\n    // Nommé, pas caché.\n    { key: 'paiements', label: 'Historique des paiements',\n      elsewhere: 'détenu par le service de facturation — écrire au support' },\n  ],\n});\n\nconst fichier = await exporter.export(userId);\n// { exportedAt, sections, notIncluded, unavailable, complete: false }\n```\n\nUne source injoignable ne fait pas couler l'export, mais elle est **nommée**\ndans `unavailable`. Un export auquel il manque une section en silence est pire\nqu'un export qui dit laquelle n'a pas pu être produite.\n\n## L'anonymisation des journaux\n\nLes journaux partent chez un tiers, sont gardés des mois, et lus par quiconque a\naccès au tableau de bord. Une adresse ou un numéro qui atterrit là a quitté ton\nsystème, quoi que dise ta politique de confidentialité.\n\n```js\nconst { createRedactor } = require('@astratra/privacy');\n\nconst redactor = createRedactor({\n  extra: [{ pattern: /MAT-\\d{4}-\\d{4}/g, replacement: '[MATRICULE]' }],\n});\n\nlogger.error(redactor.redact({ message, user, payload }));\n```\n\nLe même outil sert de filtre de données sensibles avant que quoi que ce soit\nsorte : journaux, messages envoyés à une IA, exports, tickets de support.\n\nPar défaut, un jeu générique — les mêmes formes dans tous les pays :\n\n- adresses e-mail, téléphones internationaux (et longues suites de chiffres) ;\n- cartes bancaires **avec contrôle de Luhn**, IBAN **avec contrôle mod 97** —\n  un numéro qui ne passe pas le contrôle n'est pas appelé carte ;\n- jetons `Bearer`, JWT, clés de fournisseurs reconnues à leur préfixe\n  (`sk_live_`, `AKIA`, `ghp_`, `xoxb-`…), blocs de clé privée, identifiants\n  dans une URL (`https://user:pass@…`) ;\n- les secrets écrits en clair dans une ligne (`access_token=…`, `secret_key: …`)\n  et les champs dont le **nom** suffit, quelle que soit leur graphie\n  (`access_token`, `Access-Token`, `accessToken`).\n\nLes identifiants propres à un pays ou une langue (numéro national, format de\ntéléphone local, matricule) sont **les tiens**, via `extra`, avec au besoin\nleur propre `validate(match)`.\n\n```js\nconst { value, found, clean } = redactor.inspect(messagePourLIA);\n// found = { email: 1, card: 1 } — les compteurs, jamais les valeurs\nif (found.card) return refuser();\n```\n\nLes faux positifs corrigés et fixés par les tests : une date `2026-09-19 10:00`\nreste lisible, le dernier groupe d'un UUID aussi, `tokens: 1500` n'est pas un\nsecret.\n\nTrois choix qui comptent :\n\n**Les motifs ajoutés passent avant ceux par défaut.** Le motif « téléphone » est\nvolontairement large et mangerait `2026-0001` d'un matricule. Le spécifique\navant le générique, sinon un motif sur mesure ne se déclenche jamais.\n\n**Le nom du champ est conservé, seule la valeur part.** Renommer un champ casse\ncelui qui lit le journal en le cherchant.\n\n**La structure est parcourue, pas sérialisée.** Transformer l'objet en JSON,\npasser des expressions régulières dessus et le reparser est plus rapide à écrire\net discrètement faux : ça réécrit aussi les **clés**, et un remplacement\ncontenant une accolade corrompt le document qu'il devait nettoyer.\n\nLes structures cycliques et absurdement profondes sont bornées — journaliser est\nexactement l'endroit où l'on rencontre les deux.\n\n## Le consentement avant l'IA et le web\n\nLes magasins d'applications, et la loi à bien des endroits, demandent qu'une\npersonne soit informée — et dise oui — **avant** que ses questions, fichiers,\nvoix ou dossiers partent chez une IA tierce.\n\n```js\nconst { createConsent, createMemoryConsentStore } = require('@astratra/privacy');\n\nconst consentement = createConsent({\n  store,                                     // voir le contrat ci-dessous\n  scopes: {\n    ai:    { version: 2 },                   // l'assistant\n    web:   { version: 1, requires: ['ai'] }, // la recherche web\n    voice: { version: 1, requires: ['ai'] }, // l'appel vocal en direct\n  },\n});\n\nawait consentement.grant(userId, 'ai', { version: corps.version }); // le texte montré\nawait consentement.revoke(userId, 'ai');                            // refus ou retrait\nawait consentement.check(userId, 'ai');   // { state: 'granted' | 'refused' | 'required', … }\n\n// LA garde, juste avant tout envoi : un code, jamais du texte.\nconst verdict = await consentement.guard(userId, 'web');\nif (!verdict.allowed) return res.status(403).json({ code: verdict.code }); // CONSENT_REQUIRED | CONSENT_REFUSED\n\n// Ou l'action enveloppée, qui ne s'exécute jamais sans accord :\nconst envoyer = consentement.guarded('ai', (req) => req.user.id, (req) => oracle.ask(req.body));\n```\n\nCe que les tests fixent :\n\n**Le silence ne vaut jamais accord.** Sans décision, l'état est `required`.\n\n**Un oui couvre le texte montré.** Relever la `version` d'un scope redemande à\ntous ceux qui avaient dit oui ; un refus reste un refus, quelle que soit la\nversion — on ne harcèle pas celui qui a dit non.\n\n**C'est le serveur qui date la version.** Un client annonce le texte qu'il a\nmontré ; une version supérieure à l'actuelle est refusée\n(`CONSENT_INVALID_VERSION`) — sinon un client dirait « 1000 » une fois et\ncouvrirait d'avance tous les textes futurs. Une version plus ancienne (une\napplication pas encore mise à jour) est gardée telle quelle, et redemandée.\n\n**Jamais décidé n'est pas refusé.** Refus et retraits s'écrivent aussi : un\nretrait fait sur un appareil doit se voir sur les autres.\n\n**Les scopes sont séparés**, et `requires` est vérifié jusqu'au bout : retirer\nl'accord à l'assistant bloque aussi la recherche web.\n\n**Un compte ne profite jamais de l'accord d'un autre.**\n\n`overview(subject)` pour l'écran des réglages, `exportFor(subject)` pour le\ndroit d'accès, `forget(subject)` pour l'effacement.\n\n### Sur l'appareil : mémoire, synchronisation, garde\n\n```js\nconst { createConsentClient } = require('@astratra/privacy');\n\nconst accord = createConsentClient({\n  version: 2,                                  // le texte que CE client montre\n  local:  { get: (k) => AsyncStorage.getItem(k), set: (k, v) => AsyncStorage.setItem(k, v) },\n  remote: { get: () => api.get('/account/ai-consent'), put: (b) => api.put('/account/ai-consent', b) },\n  currentSubject: () => session.userId,\n});\n\nawait accord.require();          // juste avant chaque envoi — lève CONSENT_REQUIRED / CONSENT_REFUSED\nawait accord.decide(userId, true);\n```\n\nLa décision vit sur le **compte**, avec une copie sur l'appareil pour le hors\nligne et pour un serveur trop ancien pour connaître la route. La plus\n**récente** l'emporte : un retrait fait ailleurs vaut ici, un retrait fait ici\nhors ligne n'est pas effacé par l'ancien oui du serveur — il lui est poussé.\nUn chargement parti **avant** une décision ne l'écrase pas en revenant ; deux\nchargements partagent une requête.\n\n### Les mots\n\nAucun texte n'est dans le paquet. `consentCopyKeys('ai', consentAudience(role,\n{ simple: ['student'] }))` rend les **clés de catalogue** du dialogue\n(`consent.ai.simple.title`, `consent.accept`…), et `consentDialogActions(state)`\ndit que, après un refus, le second bouton referme (« garder désactivé ») au lieu\nd'écrire le même refus.\n\n### Le contrat du store\n\n```\nget(subject, scope) -> record | null\nput(record)                         (remplace : une ligne par sujet et scope)\nlist(subject)       -> records\nremove(subject, scope?) -> nombre supprimé\n```\n\n`createMemoryConsentStore()` sert aux tests. Un adaptateur réel prouve qu'il\nse comporte pareil :\n\n```js\nconst { runConsentStoreContract } = require('@astratra/privacy');\nrunConsentStoreContract(async () => createMongoConsentStore(await collectionNeuve()));\n```\n\n## Ce que ce package ne fait pas\n\n- Il ne **supprime** rien : il anonymise, via ce que tu branches.\n- Il ne sauvegarde pas — la persistance et les transactions restent à toi.\n- Il ne décide pas **qui** a le droit d'approuver un effacement.\n- Il ne connaît aucune juridiction et ne prétend pas te rendre conforme : il\n  fournit les gestes, pas l'avis juridique.\n\n## Tests\n\n```bash\nnpm test --workspace @astratra/privacy\n```\n","readmeFilename":"README.md"}