@nodefony/security 10.0.0-alpha.8 → 10.0.0-alpha.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/dist/_virtual/{_@oxc-project_runtime@0.150.0 → _@oxc-project_runtime@0.151.0}/helpers/esm/decorate.js +1 -1
  2. package/dist/_virtual/{_@oxc-project_runtime@0.150.0 → _@oxc-project_runtime@0.151.0}/helpers/esm/decorateMetadata.js +1 -1
  3. package/dist/index.js +2 -2
  4. package/dist/nodefony/command/security-user-add.js +2 -18
  5. package/dist/nodefony/command/security-user-password.js +2 -2
  6. package/dist/nodefony/config/config.js +2 -2
  7. package/dist/nodefony/service/auditService.js +3 -2
  8. package/dist/nodefony/service/tokenService.js +3 -2
  9. package/dist/nodefony/service/totp.js +5 -3
  10. package/dist/nodefony/service/webAuthn.js +3 -2
  11. package/dist/nodefony/service/webhooks.js +5 -3
  12. package/dist/nodefony/src/token/MemoryTokenStore.js +18 -5
  13. package/dist/nodefony/src/totp/MemoryTotpSecretStore.js +15 -3
  14. package/dist/nodefony/src/webauthn/MemoryWebAuthnCredentialStore.js +24 -6
  15. package/dist/nodefony/src/webhook/MemoryWebhookStore.js +18 -10
  16. package/dist/types/nodefony/command/security-user-add.d.ts +0 -16
  17. package/dist/types/nodefony/contracts/IAuditEvent.d.ts +3 -3
  18. package/dist/types/nodefony/src/totp/MemoryTotpSecretStore.d.ts +0 -9
  19. package/dist/types/nodefony/src/webhook/MemoryWebhookStore.d.ts +0 -8
  20. package/docs/api-keys.md +62 -60
  21. package/docs/audit.md +77 -77
  22. package/docs/authenticators.md +55 -43
  23. package/docs/authorization.md +13 -13
  24. package/docs/cors.md +29 -29
  25. package/docs/csrf.md +26 -26
  26. package/docs/external-jwt.md +4 -4
  27. package/docs/firewall.md +29 -29
  28. package/docs/headers.md +26 -26
  29. package/docs/oauth2.md +60 -60
  30. package/docs/obtenir-un-jeton.md +2 -2
  31. package/docs/tokens.md +55 -55
  32. package/docs/totp.md +73 -72
  33. package/docs/webauthn.md +81 -81
  34. package/docs/webhooks.md +43 -37
  35. package/package.json +15 -12
@@ -1,4 +1,4 @@
1
- //#region \0@oxc-project+runtime@0.150.0/helpers/esm/decorate.js
1
+ //#region \0@oxc-project+runtime@0.151.0/helpers/esm/decorate.js
2
2
  function __decorate(decorators, target, key, desc) {
3
3
  var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
4
4
  if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
@@ -1,4 +1,4 @@
1
- //#region \0@oxc-project+runtime@0.150.0/helpers/esm/decorateMetadata.js
1
+ //#region \0@oxc-project+runtime@0.151.0/helpers/esm/decorateMetadata.js
2
2
  function __decorateMetadata(k, v) {
3
3
  if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
4
4
  }
package/dist/index.js CHANGED
@@ -77,8 +77,8 @@ import SecurityUserPassword from "./nodefony/command/security-user-password.js";
77
77
  import SecurityToken from "./nodefony/command/security-token.js";
78
78
  import { createSecurityAdminApi, parseAuditQuery, registerSecurityAdminApi } from "./nodefony/src/admin/SecurityAdminApi.js";
79
79
  import { registerUserRevocationCascade } from "./nodefony/src/admin/userRevocationCascade.js";
80
- import __decorateMetadata from "./_virtual/_@oxc-project_runtime@0.150.0/helpers/esm/decorateMetadata.js";
81
- import __decorate from "./_virtual/_@oxc-project_runtime@0.150.0/helpers/esm/decorate.js";
80
+ import __decorateMetadata from "./_virtual/_@oxc-project_runtime@0.151.0/helpers/esm/decorateMetadata.js";
81
+ import __decorate from "./_virtual/_@oxc-project_runtime@0.151.0/helpers/esm/decorate.js";
82
82
  import { tokenStatusCriteria } from "./nodefony/src/token/tokenCriteria.js";
83
83
  import { AccessDeniedError } from "./nodefony/errors/AccessDeniedError.js";
84
84
  import "./nodefony/errors/index.js";
@@ -1,4 +1,4 @@
1
- import { CONSOLE_DATA_RUN_PROFILE, Command, askPasswordMasked } from "nodefony";
1
+ import { AUTH_LOGIN_PATH, CONSOLE_DATA_RUN_PROFILE, Command, askPasswordMasked } from "nodefony";
2
2
  import { WeakPasswordError } from "@nodefony/user";
3
3
  //#region nodefony/command/security-user-add.ts
4
4
  const options = {
@@ -17,22 +17,6 @@ const ADMIN_ROLES = ["ROLE_ADMIN", "ROLE_NODEFONY_ADMIN"];
17
17
  /** Rôle de base, toujours proposé même si l'application n'en déclare aucun. */
18
18
  const ROLE_BASE = "ROLE_USER";
19
19
  /**
20
- * La route qui échange un compte contre une SESSION — le geste qui manquait.
21
- *
22
- * Créer un compte ne sert à rien tant qu'on ne sait pas s'en servir : mesuré,
23
- * 33 minutes d'un essai réel se sont passées entre un premier refus et
24
- * l'abandon, faute de savoir quoi appeler. La commande dit donc l'étape
25
- * suivante au moment où elle a un sens.
26
- *
27
- * ⚠️ La route est MONTÉE ailleurs — `mountSessionAuthRoutes` du module
28
- * framework — et ce paquet ne peut pas l'importer pour la lire (le montage est
29
- * conditionné au service `authFlow`, et la valeur ne vit dans aucune constante
30
- * exportée). La copie est donc assumée, et un test la CONFRONTE à la table du
31
- * controller : deux copies qui divergent en silence, c'est précisément ce
32
- * qu'on refuse.
33
- */
34
- const AUTH_LOGIN_PATH = "/nodefony/security/api/auth/login";
35
- /**
36
20
  * `nodefony security:user:add [identifier]` — crée un compte utilisateur via le
37
21
  * service applicatif `users` (hash Argon2id fait par `UserService.createUser`,
38
22
  * jamais de mot de passe stocké en clair).
@@ -163,4 +147,4 @@ var SecurityUserAdd = class extends Command {
163
147
  }
164
148
  };
165
149
  //#endregion
166
- export { AUTH_LOGIN_PATH, SecurityUserAdd as default };
150
+ export { SecurityUserAdd as default };
@@ -1,4 +1,4 @@
1
- import { CONSOLE_DATA_RUN_PROFILE, Command, askPasswordMasked } from "nodefony";
1
+ import { AUTH_LOGIN_PATH, CONSOLE_DATA_RUN_PROFILE, Command, askPasswordMasked } from "nodefony";
2
2
  import { USER_REVOKED_EVENT } from "@nodefony/user";
3
3
  //#region nodefony/command/security-user-password.ts
4
4
  const options = {
@@ -106,7 +106,7 @@ var SecurityUserPassword = class extends Command {
106
106
  tenantId: null,
107
107
  reason: "password_changed"
108
108
  });
109
- process.stdout.write(`\n${GREEN}✓ mot de passe changé${RESET} — ${BOLD}${user.identifier}${RESET}\n${DIM} ses sessions et ses jetons sont révoqués : il devra se reconnecter.${RESET}\n${DIM} connexion : POST /nodefony/security/api/auth/login${RESET}\n` + (opts.password ? ` ${YELLOW}⚠ mot de passe passé en argument — pense à purger l'historique shell${RESET}\n` : "") + "\n");
109
+ process.stdout.write(`\n${GREEN}✓ mot de passe changé${RESET} — ${BOLD}${user.identifier}${RESET}\n${DIM} ses sessions et ses jetons sont révoqués : il devra se reconnecter.${RESET}\n${DIM} connexion : POST ${AUTH_LOGIN_PATH}${RESET}\n` + (opts.password ? ` ${YELLOW}⚠ mot de passe passé en argument — pense à purger l'historique shell${RESET}\n` : "") + "\n");
110
110
  return this;
111
111
  }
112
112
  };
@@ -208,7 +208,7 @@ const totpSchema = z.strictObject({
208
208
  window: z.number().int().nonnegative().default(1).describe("Tolérance de dérive d'horloge (± N pas). RFC 6238 §5.2 : « at most one time step » → 1 [défaut]. Plus = surface d'attaque accrue."),
209
209
  recoveryCodes: z.number().int().positive().default(10).describe("Nombre de codes de récupération générés à l'activation (NIST SP 800-63B « look-up secrets », usage unique, affichés 1×)."),
210
210
  encryptionKey: z.string().optional().describe("Clé de chiffrement AES-256-GCM du secret TOTP au repos (fournie par l'app depuis env, ≥ 32 octets après décodage). Omise = clé éphémère dérivée + WARNING (dev mono-process) ; en prod multi-pod, REQUISE (un secret 2FA déchiffrable doit l'être par tous les pods, et jamais en clair)."),
211
- store: z.string().default("auto").describe("Backend de stockage du secret : auto [défaut, forçable par `NF_STORE`] | memory | drizzle — plus tout backend ajouté par `registerTotpStore` (la liste qui fait foi est celle de l'écran Stores de Studio, pas ce texte). ⚠️ Contrairement aux sessions, aux jetons et aux passkeys, **seul @nodefony/drizzle fournit un store TOTP** : @nodefony/mongoose et @nodefony/redis n'en enregistrent pas. Sur une infra Mongo, `auto` ne trouve donc pas de backend durable et se replie sur `memory` (repli tracé au boot, et WARNING en production) : les secrets 2FA seraient perdus au redémarrage, verrouillant les utilisateurs hors de leur second facteur. Charger @nodefony/drizzle — même en sqlite local, à côté de Mongo — donne la persistance.")
211
+ store: z.string().default("auto").describe("Backend de stockage du secret : auto [défaut, forçable par `NF_STORE`] | memory | drizzle | mongoose — plus tout backend ajouté par `registerTotpStore` (la liste qui fait foi est celle de l'écran Stores de Studio, pas ce texte). Les deux backends DURABLES le portent : sur une infra SQL comme sur une infra Mongo, `auto` trouve un store persistant. ⚠️ @nodefony/redis n'en enregistre pas : avec Redis pour seule infra, `auto` se replie sur `memory` (repli tracé au boot, et WARNING en production) — les secrets 2FA seraient perdus au redémarrage, verrouillant les utilisateurs hors de leur second facteur.")
212
212
  }).describe("2FA TOTP (RFC 6238) — second facteur step-up au login. Secret chiffré au repos, codes de récupération hachés.");
213
213
  const remoteJwtAlgSchema = z.enum([
214
214
  "RS256",
@@ -276,7 +276,7 @@ const webhooksSchema = z.strictObject({
276
276
  }).describe("Webhooks sortants signés (Standard Webhooks).");
277
277
  const auditSchema = z.strictObject({
278
278
  enabled: z.boolean().default(true),
279
- store: z.string().default("auto").describe("Store du journal (résolu via `auditStoreRegistry`). `auto` = suit l'infra database déclarée, repli memory (défaut) ; `memory` = per-pod, volatile, borné ; `drizzle` = persistant + partagé multi-pod (auto-register par l'adapter). Vocabulaire unifié : données = `store`."),
279
+ store: z.string().default("auto").describe("Store du journal (résolu via `auditStoreRegistry`). `auto` = suit l'infra database déclarée, repli memory (défaut) ; `memory` = per-pod, volatile, borné ; `drizzle` et `mongoose` = persistants + partagés multi-pod (auto-register par l'adapter chargé). @nodefony/redis n'en fournit pas : un journal croît sans borne et se consulte, ce n'est pas le motif d'accès d'un cache. Vocabulaire unifié : données = `store`."),
280
280
  immutable: z.boolean().default(true).meta({
281
281
  reserved: true,
282
282
  description: "INERTE — l'immuabilité du journal vient du contrat `IAuditStore` (aucune méthode `update`/`delete`), pas de ce drapeau. Le passer à `false` n'ouvre aucune mutation."
@@ -1,6 +1,6 @@
1
1
  import { defineSecurityConfig } from "../config/defineModuleConfig.js";
2
2
  import { getAuditStoreFactory, listAuditStores } from "../src/audit/auditStoreRegistry.js";
3
- import { AUTO_STORE, EMPTY_INFRA, GcScheduler, Service, durableStoreRemedy, readStoreLocation, resolveAutoStore, runNeedsExternalServices } from "nodefony";
3
+ import { AUTO_STORE, EMPTY_INFRA, GcScheduler, Service, durableStoreRemedy, readStoreConnector, readStoreLocation, resolveAutoStore, runNeedsExternalServices } from "nodefony";
4
4
  import { randomBytes } from "node:crypto";
5
5
  //#region nodefony/service/auditService.ts
6
6
  const serviceName = "auditService";
@@ -78,7 +78,8 @@ var AuditService = class extends Service {
78
78
  available: listAuditStores(),
79
79
  reason,
80
80
  configPath: "security.audit.store",
81
- location: readStoreLocation(this.#store)
81
+ location: readStoreLocation(this.#store),
82
+ connector: readStoreConnector(this.#store)
82
83
  });
83
84
  this.#gc = new GcScheduler({
84
85
  intervalS: GC_INTERVAL_MS / 1e3,
@@ -6,7 +6,7 @@ import { recordAudit } from "../src/audit/recordAudit.js";
6
6
  import { InvalidTargetError } from "../errors/InvalidTargetError.js";
7
7
  import { getTokenStoreFactory, listTokenStores } from "../src/token/tokenStoreRegistry.js";
8
8
  import { JwtKeystore } from "../src/token/JwtKeystore.js";
9
- import { AUTO_STORE, EMPTY_INFRA, GcScheduler, Service, canonicalIssuer, durableStoreRemedy, readStoreLocation, refusedAdminScopes, resolveAutoStore, runNeedsExternalServices } from "nodefony";
9
+ import { AUTO_STORE, EMPTY_INFRA, GcScheduler, Service, canonicalIssuer, durableStoreRemedy, readStoreConnector, readStoreLocation, refusedAdminScopes, resolveAutoStore, runNeedsExternalServices } from "nodefony";
10
10
  import { createHash, randomBytes, randomUUID } from "node:crypto";
11
11
  //#region nodefony/service/tokenService.ts
12
12
  const serviceName = "tokenService";
@@ -84,7 +84,8 @@ var TokenService = class extends Service {
84
84
  available: listTokenStores(),
85
85
  reason,
86
86
  configPath: "security.tokenStore.store",
87
- location: readStoreLocation(this.#store)
87
+ location: readStoreLocation(this.#store),
88
+ connector: readStoreConnector(this.#store)
88
89
  });
89
90
  this.container?.set("tokenStore", this.#store);
90
91
  if (jwtEnabled) {
@@ -3,7 +3,7 @@ import { getTotpStoreFactory, listTotpStores } from "../src/totp/totpSecretStore
3
3
  import { generateEphemeralKey } from "../src/crypto/secretCipher.js";
4
4
  import { deriveTotpKey } from "../src/totp/totpCipher.js";
5
5
  import { beginTotpEnrollment, confirmTotpEnrollment, disableTotp, totpStatus, verifyTotpLogin } from "../src/totp/totpOperations.js";
6
- import { AUTO_STORE, EMPTY_INFRA, Service, deriveStoreBackend, durableStoreRemedy, readStoreLocation, resolveAutoStore, runNeedsExternalServices } from "nodefony";
6
+ import { AUTO_STORE, EMPTY_INFRA, Service, deriveStoreBackend, durableStoreRemedy, readStoreConnector, readStoreLocation, resolveAutoStore, runNeedsExternalServices } from "nodefony";
7
7
  //#region nodefony/service/totp.ts
8
8
  const serviceName = "totp";
9
9
  function isFlushable(s) {
@@ -83,7 +83,8 @@ var TotpService = class extends Service {
83
83
  available: listTotpStores(),
84
84
  reason: "adapter posé au container (infra database déclarée)",
85
85
  configPath: "security.totp.store",
86
- location: readStoreLocation(existing)
86
+ location: readStoreLocation(existing),
87
+ connector: readStoreConnector(existing)
87
88
  });
88
89
  return existing;
89
90
  }
@@ -116,7 +117,8 @@ var TotpService = class extends Service {
116
117
  available: listTotpStores(),
117
118
  reason,
118
119
  configPath: "security.totp.store",
119
- location: readStoreLocation(store)
120
+ location: readStoreLocation(store),
121
+ connector: readStoreConnector(store)
120
122
  });
121
123
  return store;
122
124
  }
@@ -2,7 +2,7 @@ import { AuthenticationError } from "../errors/AuthenticationError.js";
2
2
  import { defineSecurityConfig } from "../config/defineModuleConfig.js";
3
3
  import { WebAuthnError } from "../errors/WebAuthnError.js";
4
4
  import { getWebAuthnStoreFactory, listWebAuthnStores } from "../src/webauthn/webAuthnCredentialStoreRegistry.js";
5
- import { AUTO_STORE, EMPTY_INFRA, Service, deriveStoreBackend, durableStoreRemedy, readStoreLocation, resolveAutoStore, runNeedsExternalServices } from "nodefony";
5
+ import { AUTO_STORE, EMPTY_INFRA, Service, deriveStoreBackend, durableStoreRemedy, readStoreConnector, readStoreLocation, resolveAutoStore, runNeedsExternalServices } from "nodefony";
6
6
  import { Buffer } from "node:buffer";
7
7
  //#region nodefony/service/webAuthn.ts
8
8
  const serviceName = "webauthn";
@@ -102,7 +102,8 @@ var WebAuthnService = class extends Service {
102
102
  available: listWebAuthnStores(),
103
103
  reason,
104
104
  configPath: "security.passkeys.store",
105
- location: readStoreLocation(this.#store)
105
+ location: readStoreLocation(this.#store),
106
+ connector: readStoreConnector(this.#store)
106
107
  });
107
108
  if (config.passkeys.attestation !== "none") this.log(`passkeys.attestation "${config.passkeys.attestation}" — l'attestation est demandée au navigateur mais N'EST PAS VÉRIFIÉE : ni métadonnées FIDO (MDS), ni certificats racines fabricant, et l'AAGUID n'est pas conservé. Ce réglage ne tient donc pas un AAL3 régulé. Repasser à "none" si la garantie n'est pas requise.`, "WARNING");
108
109
  if (config.passkeys.residentKey === "discouraged") this.log("passkeys.residentKey \"discouraged\" — les credentials enrôlés ne sont pas découvrables, or le login par passkey est usernameless (aucun ciblage depuis la requête, anti-énumération). Ces porteurs ne pourront pas se connecter : repasser à \"preferred\" ou \"required\".", "WARNING");
@@ -6,7 +6,7 @@ import { deriveWebhookKey } from "../src/webhook/webhookCipher.js";
6
6
  import { assertPublicUrl } from "../src/net/ssrfGuard.js";
7
7
  import { WebhookDispatcher } from "../src/webhook/WebhookDispatcher.js";
8
8
  import { deliverWebhook } from "../src/webhook/webhookDelivery.js";
9
- import { AUTO_STORE, EMPTY_INFRA, Service, countFacets, deriveStoreBackend, durableStoreRemedy, readStoreLocation, resolveAutoStore, runNeedsExternalServices } from "nodefony";
9
+ import { AUTO_STORE, EMPTY_INFRA, Service, countFacets, deriveStoreBackend, durableStoreRemedy, readStoreConnector, readStoreLocation, resolveAutoStore, runNeedsExternalServices } from "nodefony";
10
10
  import { randomBytes } from "node:crypto";
11
11
  import { Buffer } from "node:buffer";
12
12
  import { schemaMismatchOf } from "@nodefony/http";
@@ -146,7 +146,8 @@ var WebhookService = class extends Service {
146
146
  available: listWebhookStores(),
147
147
  reason: "adapter posé au container (infra database déclarée)",
148
148
  configPath: "security.webhooks.store",
149
- location: readStoreLocation(existing)
149
+ location: readStoreLocation(existing),
150
+ connector: readStoreConnector(existing)
150
151
  });
151
152
  return existing;
152
153
  }
@@ -179,7 +180,8 @@ var WebhookService = class extends Service {
179
180
  available: listWebhookStores(),
180
181
  reason,
181
182
  configPath: "security.webhooks.store",
182
- location: readStoreLocation(store)
183
+ location: readStoreLocation(store),
184
+ connector: readStoreConnector(store)
183
185
  });
184
186
  return store;
185
187
  }
@@ -17,6 +17,14 @@ function matchesTokenQuery(record, query, now) {
17
17
  return true;
18
18
  }
19
19
  /**
20
+ * Copie PROFONDE d'un record lu : `scopes`, `audience`, `resources`, `cnf` et
21
+ * `metadata` sont mutables — rendre la référence laisserait l'appelant muter le
22
+ * store en place, ce que les backends persistants rendent impossible.
23
+ */
24
+ function cloneOrNull(record) {
25
+ return record ? structuredClone(record) : null;
26
+ }
27
+ /**
20
28
  * Store de jetons **en mémoire** — implémentation de référence d'{@link ITokenStore}.
21
29
  *
22
30
  * 0 dépendance, idéale pour le développement mono-process et les **tests**. NON
@@ -59,6 +67,11 @@ var MemoryTokenStore = class {
59
67
  this.#retentionRevokedMs = retentionRevokedMs;
60
68
  }
61
69
  put(record) {
70
+ const owner = this.#idByHash.get(record.secretHash);
71
+ if (owner !== void 0 && owner !== record.id) return Promise.reject(/* @__PURE__ */ new Error(`secretHash déjà porté par le jeton ${owner} : un secret ne désigne qu'un jeton`));
72
+ const previous = this.#byId.get(record.id);
73
+ if (previous) this.#removeRecord(record.id, previous);
74
+ record = structuredClone(record);
62
75
  this.#byId.set(record.id, record);
63
76
  this.#idByHash.set(record.secretHash, record.id);
64
77
  this.#addToIndex(this.#idsBySubject, record.subjectId, record.id);
@@ -66,11 +79,11 @@ var MemoryTokenStore = class {
66
79
  return Promise.resolve();
67
80
  }
68
81
  findById(id) {
69
- return Promise.resolve(this.#byId.get(id) ?? null);
82
+ return Promise.resolve(cloneOrNull(this.#byId.get(id)));
70
83
  }
71
84
  findByHash(secretHash) {
72
85
  const id = this.#idByHash.get(secretHash);
73
- return Promise.resolve(id !== void 0 ? this.#byId.get(id) ?? null : null);
86
+ return Promise.resolve(id !== void 0 ? cloneOrNull(this.#byId.get(id)) : null);
74
87
  }
75
88
  findBySubject(subjectId) {
76
89
  const ids = this.#idsBySubject.get(subjectId);
@@ -78,12 +91,12 @@ var MemoryTokenStore = class {
78
91
  const out = [];
79
92
  for (const id of ids) {
80
93
  const record = this.#byId.get(id);
81
- if (record) out.push(record);
94
+ if (record) out.push(structuredClone(record));
82
95
  }
83
96
  return Promise.resolve(out);
84
97
  }
85
98
  listAll() {
86
- return Promise.resolve([...this.#byId.values()]);
99
+ return Promise.resolve([...this.#byId.values()].map((r) => structuredClone(r)));
87
100
  }
88
101
  listPage(query) {
89
102
  assertPageQuery(query, "offset");
@@ -93,7 +106,7 @@ var MemoryTokenStore = class {
93
106
  const filtered = [...this.#byId.values()].filter((r) => matchesTokenQuery(r, query, now));
94
107
  const order = pickOrder(query.order, this.sortableFields, TOKEN_DEFAULT_ORDER);
95
108
  filtered.sort(compareByOrder(order, (r, field) => r[field]));
96
- const items = filtered.slice(offset, offset + limit);
109
+ const items = filtered.slice(offset, offset + limit).map((r) => structuredClone(r));
97
110
  const total = query.withTotal === false ? void 0 : filtered.length;
98
111
  return Promise.resolve({
99
112
  items,
@@ -37,21 +37,33 @@ function matchesTotpQuery(secret, query) {
37
37
  * un adapter ORM ou Redis. Perf : la `Map` n'existe que si le store est instancié
38
38
  * (2FA activé), jamais sur le hot path par requête.
39
39
  */
40
+ /**
41
+ * Copie d'un secret : `recoveryCodes` est un tableau mutable — rendre ou garder
42
+ * la référence laisserait l'appelant rendre un code de secours réutilisable, ou
43
+ * en effacer un, sans passer par le store.
44
+ */
45
+ function cloneSecret(s) {
46
+ return {
47
+ ...s,
48
+ recoveryCodes: [...s.recoveryCodes]
49
+ };
50
+ }
40
51
  var MemoryTotpSecretStore = class {
41
52
  /** userId → secret (source de vérité). */
42
53
  #byUser = /* @__PURE__ */ new Map();
43
54
  findByUser(userId) {
44
- return Promise.resolve(this.#byUser.get(userId) ?? null);
55
+ const secret = this.#byUser.get(userId);
56
+ return Promise.resolve(secret ? cloneSecret(secret) : null);
45
57
  }
46
58
  save(secret) {
47
- this.#byUser.set(secret.userId, secret);
59
+ this.#byUser.set(secret.userId, cloneSecret(secret));
48
60
  return Promise.resolve();
49
61
  }
50
62
  update(userId, patch) {
51
63
  const secret = this.#byUser.get(userId);
52
64
  if (secret) {
53
65
  if (patch.confirmedAt !== void 0) secret.confirmedAt = patch.confirmedAt;
54
- if (patch.recoveryCodes !== void 0) secret.recoveryCodes = patch.recoveryCodes;
66
+ if (patch.recoveryCodes !== void 0) secret.recoveryCodes = [...patch.recoveryCodes];
55
67
  if (patch.lastUsedStep !== void 0) secret.lastUsedStep = patch.lastUsedStep;
56
68
  if (patch.lastUsedAt !== void 0) secret.lastUsedAt = patch.lastUsedAt;
57
69
  }
@@ -18,13 +18,25 @@ function toWebAuthnSummary(c) {
18
18
  ...c.nickname !== void 0 ? { nickname: c.nickname } : {}
19
19
  };
20
20
  }
21
+ /**
22
+ * Copie d'une passkey : `transports` est un tableau mutable — rendre ou garder
23
+ * la référence laisserait l'appelant muter le store en place, ce que les
24
+ * backends persistants rendent impossible par sérialisation.
25
+ */
26
+ function cloneCredential(c) {
27
+ return {
28
+ ...c,
29
+ transports: [...c.transports]
30
+ };
31
+ }
21
32
  var MemoryWebAuthnCredentialStore = class {
22
33
  /** id (base64url) → credential (source de vérité). */
23
34
  #byId = /* @__PURE__ */ new Map();
24
35
  /** userId → ids (allowCredentials + « mes appareils »). */
25
36
  #idsByUser = /* @__PURE__ */ new Map();
26
37
  findById(credentialId) {
27
- return Promise.resolve(this.#byId.get(credentialId) ?? null);
38
+ const cred = this.#byId.get(credentialId);
39
+ return Promise.resolve(cred ? cloneCredential(cred) : null);
28
40
  }
29
41
  findByUser(userId) {
30
42
  const ids = this.#idsByUser.get(userId);
@@ -32,7 +44,7 @@ var MemoryWebAuthnCredentialStore = class {
32
44
  const out = [];
33
45
  for (const id of ids) {
34
46
  const cred = this.#byId.get(id);
35
- if (cred) out.push(cred);
47
+ if (cred) out.push(cloneCredential(cred));
36
48
  }
37
49
  return Promise.resolve(out);
38
50
  }
@@ -40,7 +52,9 @@ var MemoryWebAuthnCredentialStore = class {
40
52
  return Promise.resolve(this.#idsByUser.get(userId)?.size ?? 0);
41
53
  }
42
54
  save(credential) {
43
- this.#byId.set(credential.id, credential);
55
+ const previous = this.#byId.get(credential.id);
56
+ if (previous && previous.userId !== credential.userId) this.#unindex(previous.userId, credential.id);
57
+ this.#byId.set(credential.id, cloneCredential(credential));
44
58
  let set = this.#idsByUser.get(credential.userId);
45
59
  if (!set) {
46
60
  set = /* @__PURE__ */ new Set();
@@ -63,12 +77,16 @@ var MemoryWebAuthnCredentialStore = class {
63
77
  const cred = this.#byId.get(credentialId);
64
78
  if (!cred) return Promise.resolve();
65
79
  this.#byId.delete(credentialId);
66
- const set = this.#idsByUser.get(cred.userId);
80
+ this.#unindex(cred.userId, credentialId);
81
+ return Promise.resolve();
82
+ }
83
+ /** Retire un id de l'index d'un porteur ; l'entrée vide disparaît. */
84
+ #unindex(userId, credentialId) {
85
+ const set = this.#idsByUser.get(userId);
67
86
  if (set) {
68
87
  set.delete(credentialId);
69
- if (set.size === 0) this.#idsByUser.delete(cred.userId);
88
+ if (set.size === 0) this.#idsByUser.delete(userId);
70
89
  }
71
- return Promise.resolve();
72
90
  }
73
91
  /** Credentials filtrés, dans l'ordre contractuel (createdAt DESC, id ASC). */
74
92
  #filtered(query) {
@@ -27,6 +27,18 @@ function matchesWebhookQuery(e, query) {
27
27
  * Map indexée par id (O(1)). Les lectures renvoient une **copie défensive** : le
28
28
  * store détient la vérité, un consommateur ne peut pas muter un record en place.
29
29
  */
30
+ /**
31
+ * Copie PROFONDE d'un endpoint : `events` et `metadata` sont des structures
32
+ * mutables — une copie de surface laisserait l'appelant muter le store en place,
33
+ * ce que les backends persistants rendent impossible par sérialisation.
34
+ */
35
+ function cloneEndpoint(e) {
36
+ return {
37
+ ...e,
38
+ events: [...e.events],
39
+ metadata: structuredClone(e.metadata)
40
+ };
41
+ }
30
42
  var MemoryWebhookStore = class {
31
43
  /**
32
44
  * {@inheritDoc IWebhookStore.sortableFields}
@@ -37,25 +49,25 @@ var MemoryWebhookStore = class {
37
49
  sortableFields = WEBHOOK_SORTABLE_FIELDS;
38
50
  #byId = /* @__PURE__ */ new Map();
39
51
  async save(endpoint) {
40
- this.#byId.set(endpoint.id, { ...endpoint });
52
+ this.#byId.set(endpoint.id, cloneEndpoint(endpoint));
41
53
  }
42
54
  async findById(id) {
43
55
  const found = this.#byId.get(id);
44
- return found ? { ...found } : null;
56
+ return found ? cloneEndpoint(found) : null;
45
57
  }
46
58
  async update(id, patch) {
47
59
  const current = this.#byId.get(id);
48
60
  if (!current) return;
49
- this.#byId.set(id, {
61
+ this.#byId.set(id, cloneEndpoint({
50
62
  ...current,
51
63
  ...patch
52
- });
64
+ }));
53
65
  }
54
66
  async delete(id) {
55
67
  this.#byId.delete(id);
56
68
  }
57
69
  async listAll() {
58
- return [...this.#byId.values()].map((e) => ({ ...e }));
70
+ return [...this.#byId.values()].map(cloneEndpoint);
59
71
  }
60
72
  async listPage(query) {
61
73
  assertPageQuery(query, "offset");
@@ -64,11 +76,7 @@ var MemoryWebhookStore = class {
64
76
  const filtered = [...this.#byId.values()].filter((e) => matchesWebhookQuery(e, query));
65
77
  const order = pickOrder(query.order, this.sortableFields, WEBHOOK_DEFAULT_ORDER);
66
78
  filtered.sort(compareByOrder(order, (e, field) => e[field]));
67
- const items = filtered.slice(offset, offset + limit).map((e) => ({
68
- ...e,
69
- events: [...e.events],
70
- metadata: { ...e.metadata }
71
- }));
79
+ const items = filtered.slice(offset, offset + limit).map(cloneEndpoint);
72
80
  return {
73
81
  items,
74
82
  total: query.withTotal === false ? void 0 : filtered.length,
@@ -1,20 +1,4 @@
1
1
  import { CliKernel, Command } from "nodefony";
2
- /**
3
- * La route qui échange un compte contre une SESSION — le geste qui manquait.
4
- *
5
- * Créer un compte ne sert à rien tant qu'on ne sait pas s'en servir : mesuré,
6
- * 33 minutes d'un essai réel se sont passées entre un premier refus et
7
- * l'abandon, faute de savoir quoi appeler. La commande dit donc l'étape
8
- * suivante au moment où elle a un sens.
9
- *
10
- * ⚠️ La route est MONTÉE ailleurs — `mountSessionAuthRoutes` du module
11
- * framework — et ce paquet ne peut pas l'importer pour la lire (le montage est
12
- * conditionné au service `authFlow`, et la valeur ne vit dans aucune constante
13
- * exportée). La copie est donc assumée, et un test la CONFRONTE à la table du
14
- * controller : deux copies qui divergent en silence, c'est précisément ce
15
- * qu'on refuse.
16
- */
17
- export declare const AUTH_LOGIN_PATH = "/nodefony/security/api/auth/login";
18
2
  /**
19
3
  * `nodefony security:user:add [identifier]` — crée un compte utilisateur via le
20
4
  * service applicatif `users` (hash Argon2id fait par `UserService.createUser`,
@@ -12,13 +12,13 @@
12
12
  * Sous-système de sécurité concerné — axe de filtrage principal de la console
13
13
  * d'audit (Studio P6.15). Fermé : la liste des sous-systèmes est stable.
14
14
  *
15
- * ⚠️ Quatre catégories sont RÉSERVÉES — déclarées au contrat mais **sans émetteur à
15
+ * ⚠️ Trois catégories sont RÉSERVÉES — déclarées au contrat mais **sans émetteur à
16
16
  * ce jour** (aucun `record({ category: … })` ne les produit) : `oauth`, `csrf`,
17
- * `cors`, `config`. La console Studio ne propose donc PAS de filtre pour elles (un
17
+ * `cors`. La console Studio ne propose donc PAS de filtre pour elles (un
18
18
  * filtre qui ne rend jamais rien induit en erreur — cf `studio/.../auditModel.ts`).
19
19
  * En câbler une = ajouter son émetteur ICI (l'émetteur), puis la métadonnée
20
20
  * d'affichage dans `AUDIT_CATEGORIES`. Émettent réellement : auth, authz, token,
21
- * session, webauthn, ws, webhook.
21
+ * session, webauthn, ws, webhook, config (`config.update`).
22
22
  */
23
23
  export type AuditCategory = "auth" | "authz" | "token" | "session" | "oauth" | "webauthn" | "csrf" | "cors" | "ws" | "webhook" | "config";
24
24
  /**
@@ -16,15 +16,6 @@ export declare function matchesTotpQuery(secret: ITotpSecret, query: ITotpListQu
16
16
  export interface TotpStoreSnapshot {
17
17
  secrets: ITotpSecret[];
18
18
  }
19
- /**
20
- * Store de secrets TOTP **en mémoire** — implémentation de référence
21
- * d'{@link ITotpSecretStore}. Clé = `userId` (un secret par utilisateur).
22
- *
23
- * 0 dépendance, idéale pour le développement mono-process et les **tests**. NON
24
- * partagée entre process et **volatile** → en production multi-process, utiliser
25
- * un adapter ORM ou Redis. Perf : la `Map` n'existe que si le store est instancié
26
- * (2FA activé), jamais sur le hot path par requête.
27
- */
28
19
  export declare class MemoryTotpSecretStore implements ITotpSecretStore {
29
20
  #private;
30
21
  findByUser(userId: string): Promise<ITotpSecret | null>;
@@ -10,14 +10,6 @@ import type { IWebhookEndpoint, WebhookEndpointUpdate } from "../../contracts/IW
10
10
  * @returns `true` si l'endpoint appartient à la collection filtrée.
11
11
  */
12
12
  export declare function matchesWebhookQuery(e: IWebhookEndpoint, query: IWebhookListQuery): boolean;
13
- /**
14
- * Store d'endpoints webhook **en mémoire** — défaut dev/test (non persistant :
15
- * les endpoints sont perdus au redémarrage). En prod, utiliser `drizzle`/
16
- * `mongoose` (config `webhooks.store`).
17
- *
18
- * Map indexée par id (O(1)). Les lectures renvoient une **copie défensive** : le
19
- * store détient la vérité, un consommateur ne peut pas muter un record en place.
20
- */
21
13
  export declare class MemoryWebhookStore implements IWebhookStore {
22
14
  #private;
23
15
  /**