@nodefony/security 10.0.0-alpha.8 → 10.0.0-beta.1

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 (83) 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-token.js +4 -4
  5. package/dist/nodefony/command/security-user-add.js +2 -18
  6. package/dist/nodefony/command/security-user-delete.js +2 -2
  7. package/dist/nodefony/command/security-user-list.js +1 -1
  8. package/dist/nodefony/command/security-user-password.js +2 -2
  9. package/dist/nodefony/config/config.js +2 -2
  10. package/dist/nodefony/service/apiKeys.js +2 -2
  11. package/dist/nodefony/service/auditService.js +6 -5
  12. package/dist/nodefony/service/authFlow.js +2 -2
  13. package/dist/nodefony/service/authorization.js +3 -4
  14. package/dist/nodefony/service/firewall.js +21 -9
  15. package/dist/nodefony/service/oauth2.js +5 -3
  16. package/dist/nodefony/service/securityHeaders.js +1 -1
  17. package/dist/nodefony/service/tokenService.js +30 -20
  18. package/dist/nodefony/service/totp.js +5 -3
  19. package/dist/nodefony/service/webAuthn.js +41 -33
  20. package/dist/nodefony/service/webhooks.js +55 -44
  21. package/dist/nodefony/src/admin/SecurityAdminApi.js +2 -1
  22. package/dist/nodefony/src/admin/WebhookAdminApi.js +1 -0
  23. package/dist/nodefony/src/admin/userRevocationCascade.js +1 -1
  24. package/dist/nodefony/src/apikey/apiKeyFormat.js +1 -1
  25. package/dist/nodefony/src/audit/MemoryAuditStore.js +4 -8
  26. package/dist/nodefony/src/audit/auditBridge.js +9 -5
  27. package/dist/nodefony/src/authenticator/ApiKeyAuthenticator.js +3 -3
  28. package/dist/nodefony/src/authenticator/ExternalJwtAuthenticator.js +2 -2
  29. package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +2 -2
  30. package/dist/nodefony/src/authenticator/JwtAuthenticator.js +2 -2
  31. package/dist/nodefony/src/authenticator/SessionAuthenticator.js +5 -3
  32. package/dist/nodefony/src/authenticator/UserPasswordAuthenticator.js +2 -2
  33. package/dist/nodefony/src/csp.js +4 -5
  34. package/dist/nodefony/src/oauth/providers/github.js +1 -1
  35. package/dist/nodefony/src/realtime/frameAuthorizer.js +6 -6
  36. package/dist/nodefony/src/token/JwtKeystore.js +7 -6
  37. package/dist/nodefony/src/token/MemoryTokenStore.js +19 -6
  38. package/dist/nodefony/src/token/jwtRuntime.js +13 -1
  39. package/dist/nodefony/src/token/tokenCriteria.js +1 -0
  40. package/dist/nodefony/src/totp/MemoryTotpSecretStore.js +15 -3
  41. package/dist/nodefony/src/totp/totpCrypto.js +9 -9
  42. package/dist/nodefony/src/webauthn/MemoryWebAuthnCredentialStore.js +24 -6
  43. package/dist/nodefony/src/webhook/MemoryWebhookStore.js +18 -10
  44. package/dist/nodefony/src/webhook/WebhookDispatcher.js +4 -3
  45. package/dist/types/nodefony/command/security-user-add.d.ts +0 -16
  46. package/dist/types/nodefony/contracts/IAuditEvent.d.ts +8 -4
  47. package/dist/types/nodefony/contracts/IFirewall.d.ts +2 -2
  48. package/dist/types/nodefony/contracts/IFirewallDescription.d.ts +5 -5
  49. package/dist/types/nodefony/contracts/ISecuredArea.d.ts +4 -4
  50. package/dist/types/nodefony/contracts/ITokenStore.d.ts +3 -3
  51. package/dist/types/nodefony/contracts/ITotpSecretStore.d.ts +4 -4
  52. package/dist/types/nodefony/contracts/IWebAuthnCredentialStore.d.ts +1 -1
  53. package/dist/types/nodefony/errors/InvalidTargetError.d.ts +1 -1
  54. package/dist/types/nodefony/errors/UnverifiableTokenError.d.ts +1 -1
  55. package/dist/types/nodefony/service/firewall.d.ts +3 -2
  56. package/dist/types/nodefony/service/securityHeaders.d.ts +5 -5
  57. package/dist/types/nodefony/src/SecuredArea.d.ts +2 -2
  58. package/dist/types/nodefony/src/authenticator/SessionAuthenticator.d.ts +5 -3
  59. package/dist/types/nodefony/src/oauth/oauth2Client.d.ts +2 -2
  60. package/dist/types/nodefony/src/oauth/oauthProviderRegistry.d.ts +2 -2
  61. package/dist/types/nodefony/src/realtime/realtimeContracts.d.ts +3 -3
  62. package/dist/types/nodefony/src/token/JwtKeystore.d.ts +2 -2
  63. package/dist/types/nodefony/src/token/RemoteJwtVerifier.d.ts +2 -2
  64. package/dist/types/nodefony/src/token/jwtRuntime.d.ts +10 -0
  65. package/dist/types/nodefony/src/totp/MemoryTotpSecretStore.d.ts +0 -9
  66. package/dist/types/nodefony/src/webhook/MemoryWebhookStore.d.ts +0 -8
  67. package/docs/api-keys.md +67 -65
  68. package/docs/audit.md +87 -82
  69. package/docs/authenticators.md +61 -49
  70. package/docs/authorization.md +21 -21
  71. package/docs/cors.md +30 -30
  72. package/docs/csrf.md +28 -28
  73. package/docs/external-jwt.md +21 -13
  74. package/docs/firewall.md +32 -32
  75. package/docs/headers.md +29 -29
  76. package/docs/index.md +3 -1
  77. package/docs/oauth2.md +61 -61
  78. package/docs/obtenir-un-jeton.md +2 -2
  79. package/docs/tokens.md +56 -56
  80. package/docs/totp.md +73 -72
  81. package/docs/webauthn.md +85 -85
  82. package/docs/webhooks.md +50 -47
  83. package/package.json +19 -16
package/docs/audit.md CHANGED
@@ -30,8 +30,8 @@ source: "src/packages/@nodefony/security/docs/audit.md"
30
30
  > Chaque fois qu'une **décision de sécurité** est prise — un login réussit, un accès est refusé, une
31
31
  > clé d'API est révoquée, un jeton volé resurgit — Nodefony écrit une ligne dans un journal
32
32
  > **append-only** : qui, quoi, quand, d'où, avec quel verdict. Pas le trafic (ça, c'est le log HTTP) :
33
- > les **transitions d'état**. Ancré sur `AuditService` (`auditService.ts:48`), le contrat
34
- > `IAuditEvent` (`IAuditEvent.ts:53`) et les stores de `nodefony/src/audit/`.
33
+ > les **transitions d'état**. Ancré sur `AuditService` (`auditService.ts:51`), le contrat
34
+ > `IAuditEvent` (`IAuditEvent.ts:61`) et les stores de `nodefony/src/audit/`.
35
35
 
36
36
  📍 [Documentation](../../../../../docs/index.md) › [Sécurité](index.md) › **Journal d'audit**
37
37
 
@@ -70,7 +70,7 @@ flowchart LR
70
70
  API --> UI["Studio — Journal d'audit"]
71
71
  ```
72
72
 
73
- `AuditService.record()` (`auditService.ts:180`) est le point de passage unique : il pose l'identité et
73
+ `AuditService.record()` (`auditService.ts:187`) est le point de passage unique : il pose l'identité et
74
74
  l'horodatage, écrit **sans attendre**, et notifie les abonnés live. Tout le reste — stores, pont WS,
75
75
  webhooks, console — se branche autour de lui.
76
76
 
@@ -123,7 +123,7 @@ requête authentifiée qui passe n'écrit rien. Prouvé par les tests « SUCCÈS
123
123
  signal, pas du bruit à filtrer.
124
124
 
125
125
  **2. L'audit ne peut jamais casser le métier.** `record()` est synchrone, sans `await`, et l'écriture
126
- part en fire-and-forget avec un `.catch()` qui se contente de logger (`auditService.ts:192`). Un
126
+ part en fire-and-forget avec un `.catch()` qui se contente de logger (`auditService.ts:199`). Un
127
127
  store en panne dégrade la traçabilité, il ne renvoie pas 500 à l'utilisateur.
128
128
 
129
129
  **3. Le store est pluggable, jamais câblé en dur.** Le service résout un **nom** via un registre
@@ -196,7 +196,7 @@ export default ExportController;
196
196
  ```
197
197
 
198
198
  > [!IMPORTANT]
199
- > `category` est une **union fermée** de sous-systèmes de sécurité (`IAuditEvent.ts:16`) : il n'existe
199
+ > `category` est une **union fermée** de sous-systèmes de sécurité (`IAuditEvent.ts:71`) : il n'existe
200
200
  > pas de catégorie « métier ». C'est volontaire — ce journal est celui de la sécurité. Range ton
201
201
  > événement dans la catégorie de sécurité qu'il concerne (ici `authz` : un accès privilégié à des
202
202
  > données) et laisse `action` porter le vocabulaire métier.
@@ -254,7 +254,7 @@ et ratées, sur une fenêtre précise.
254
254
 
255
255
  Aucune configuration : les événements d'authentification sont émis par défaut. Il suffit de filtrer.
256
256
  Les critères se **combinent en ET** et sont traduits par `parseAuditQuery()`
257
- (`SecurityAdminApi.ts:191`) :
257
+ (`SecurityAdminApi.ts:184`) :
258
258
 
259
259
  | Paramètre | Effet | Exemple |
260
260
  | ----------- | ----------------------------------------------- | --------------------------- |
@@ -312,12 +312,12 @@ use("@nodefony/security", {
312
312
  ```
313
313
 
314
314
  Ce que ça déclenche : le service arme un `GcScheduler` de purge **toutes les heures**
315
- (`auditService.ts:152`, intervalle `GC_INTERVAL_MS` — `auditService.ts:31`), avec gigue
315
+ (`auditService.ts:159`, intervalle `GC_INTERVAL_MS` — `auditService.ts:34`), avec gigue
316
316
  anti-avalanche en cluster. Chaque
317
317
  tour appelle la purge du contrat — `gc()`, voisin de `listPage()` dans le même contrat
318
- (`IAuditStore.ts:67`) — qui supprime les événements plus vieux que la fenêtre : un `DELETE` par seuil
319
- côté SQL (`DrizzleAuditStore.ts:231`), un défilement de file tant que l'événement de tête dépasse le
320
- `threshold` côté mémoire (`MemoryAuditStore.ts:127`).
318
+ (`IAuditStore.ts:59`) — qui supprime les événements plus vieux que la fenêtre : un `DELETE` par seuil
319
+ côté SQL (`DrizzleAuditStore.ts:256`), un défilement de file tant que l'événement de tête dépasse le
320
+ `threshold` côté mémoire (`MemoryAuditStore.ts:133`).
321
321
 
322
322
  Ce qu'on observe dans les logs : `audit gc — 1284 événement(s) purgé(s)` en niveau DEBUG.
323
323
 
@@ -331,8 +331,8 @@ qu'il arrive.
331
331
  La base est indisponible pendant trente secondes. Que se passe-t-il ?
332
332
 
333
333
  Le choix de Nodefony est explicite : **le métier passe avant la trace**. L'écriture part en
334
- fire-and-forget — `append()` sans `await`, échec absorbé en log ERROR (`auditService.ts:192`) ; côté SQL, si l'ORM n'est
335
- pas connecté, `append()` est un no-op assumé (`DrizzleAuditStore.ts:131`). Un login n'échoue jamais
334
+ fire-and-forget — `append()` sans `await`, échec absorbé en log ERROR (`auditService.ts:199`) ; côté SQL, si l'ORM n'est
335
+ pas connecté, `append()` est un no-op assumé (`DrizzleAuditStore.ts:152`). Un login n'échoue jamais
336
336
  parce que le journal est cassé.
337
337
 
338
338
  > [!WARNING]
@@ -342,34 +342,39 @@ parce que le journal est cassé.
342
342
  > disponibilité — le contrat `IAuditStore` (`IAuditStore.ts:48`) est le point d'extension pour ça.
343
343
 
344
344
  Deux garde-fous limitent la casse au démarrage : un store **explicitement** configuré mais inconnu
345
- **avorte le boot en production** (`auditService.ts:109`), et un store `memory` en production déclenche
345
+ **avorte le boot en production** (`auditService.ts:115`), et un store `memory` en production déclenche
346
346
  un `WARNING` nommant précisément le risque — volatil, per-pod, rétention réglementaire impossible
347
- (`auditService.ts:122`).
347
+ (`auditService.ts:128`).
348
348
 
349
349
  ## 🔐 Le catalogue des événements audités
350
350
 
351
- Deux axes de classement. La **catégorie** est une union **fermée** (`IAuditEvent.ts:16`) : c'est
351
+ Deux axes de classement. La **catégorie** est une union **fermée** (`IAuditEvent.ts:24`) : c'est
352
352
  l'axe de filtrage principal de la console. L'**action** est une chaîne **ouverte**
353
- (`IAuditEvent.ts:66`) : la liste grandit sans jamais casser le contrat.
353
+ (`IAuditEvent.ts:74`) : la liste grandit sans jamais casser le contrat.
354
354
 
355
355
  ### Vue d'ensemble — choisir son filtre en cinq secondes
356
356
 
357
357
  <!-- prettier-ignore -->
358
358
  | Catégorie | Ce qu'elle trace | Actions réellement émises par le framework |
359
359
  | --- | --- | --- |
360
- | `auth` | authentification, chaîne du firewall | `auth.failure` · `auth.throttled` · `auth.denied` · `login.success` · `login.failure` · `login.throttled` · `login.mfa_required` · `user.totp_disabled` |
361
- | `authz` | autorisation (voters, `@IsGranted`) | `access.denied` |
360
+ | `auth` | authentification, chaîne du firewall | `auth.failure` · `auth.unverifiable` · `auth.throttled` · `auth.denied` · `login.success` · `login.failure` · `login.throttled` · `login.mfa_required` · `user.totp_disabled` · `user.password_change_self` (par `@nodefony/user`) |
361
+ | `authz` | autorisation (voters, `@IsGranted`), administration des comptes | `access.denied` · `user.created` · `user.updated` · `user.password_changed` · `user.deleted` (ces quatre-là par `@nodefony/user`) |
362
362
  | `token` | jetons longue durée et clés d'API | `token.issued` · `token.reuse_detected` · `apikey.created` · `apikey.revoked` |
363
- | `session` | cycle de vie de session | `logout` |
363
+ | `session` | cycle de vie de session | `logout` · `session.revoked` (révocation depuis l'administration, par `@nodefony/http`) |
364
364
  | `webauthn` | passkeys | `user.passkey_revoked` |
365
365
  | `ws` | verrou de frame WebSocket | `frame.denied` |
366
366
  | `webhook` | webhooks sortants | `webhook.created` · `webhook.updated` · `webhook.deleted` · `webhook.rotated` · `webhook.revealed` · `webhook.disabled` |
367
367
  | `oauth` | login social OAuth2 | _catégorie déclarée, aucune action émise aujourd'hui_ |
368
368
  | `csrf` | défense CSRF | _catégorie déclarée, aucune action émise aujourd'hui_ |
369
369
  | `cors` | politique CORS | _catégorie déclarée, aucune action émise aujourd'hui_ |
370
- | `config` | mutation de config runtime depuis Studio | _catégorie déclarée, aucune action émise aujourd'hui_ |
370
+ | `config` | mutation de config runtime depuis Studio | `config.update` · `log.debug.set` · `log.debug.clear` (par `@nodefony/framework`) |
371
371
 
372
- Les trois issues possibles (`IAuditEvent.ts:35`) ne sont pas interchangeables : `failure` = **l'acteur
372
+ Toute catégorie émise appartient au type `AuditCategory`, y compris depuis un paquet qui ne peut pas
373
+ l'importer (`@nodefony/user`, `@nodefony/framework`) : `tests/unit/auditCategoryEmitters.test.ts`
374
+ relit les sources de tous les paquets et refuse un littéral hors contrat. Un filtre sur les
375
+ catégories déclarées voit donc tout le journal.
376
+
377
+ Les trois issues possibles (`IAuditEvent.ts:43`) ne sont pas interchangeables : `failure` = **l'acteur
373
378
  a échoué une preuve** (mauvais mot de passe, signature invalide) ; `denied` = **une politique a
374
379
  refusé** un acteur pourtant bien formé (Zero Trust, rôle manquant) ; `success` = l'action a abouti.
375
380
  Pour un auditeur, la colonne `denied` est celle des tentatives d'accès non autorisé.
@@ -377,19 +382,21 @@ Pour un auditeur, la colonne `denied` est celle des tentatives d'accès non auto
377
382
  ### `auth` — la chaîne d'authentification
378
383
 
379
384
  Quatre sorties d'échec du firewall passent par le même helper `Firewall.#recordAuth()`
380
- (`firewall.ts:693`), qui enrichit l'événement de la provenance et pose la **zone** en `resource` :
385
+ (`firewall.ts:907`), qui enrichit l'événement de la provenance et pose la **zone** en `resource` :
381
386
 
382
- - `auth.throttled` — backoff NIST déclenché, réponse 429 (`firewall.ts:768`) ;
383
- - `auth.failure` — un credential a été **présenté** et rejeté (`firewall.ts:794`) ;
387
+ - `auth.throttled` — backoff NIST déclenché, réponse 429 (`firewall.ts:781`) ;
388
+ - `auth.failure` — un credential a été **présenté** et rejeté (`firewall.ts:807`) ;
389
+ - `auth.unverifiable` — le vérificateur de jetons est indisponible : réponse 503, sans défi
390
+ (`firewall.ts:795`) ;
384
391
  - `auth.denied` / `no_credentials` — Zero Trust : rien n'a été présenté sur une zone protégée
385
- (`firewall.ts:811`) ;
392
+ (`firewall.ts:822`) ;
386
393
  - `auth.denied` / `unauthenticated` — un jeton non promu hors `anonymous` (`firewall.ts:850`).
387
394
 
388
395
  Le parcours de login BFF émet en parallèle son propre vocabulaire depuis `AuthFlow` :
389
396
  `login.failure` sur identité inconnue (`authFlow.ts:125`) ou mot de passe faux (`authFlow.ts:153`),
390
397
  `login.throttled` (`authFlow.ts:139`), `login.mfa_required` quand un second facteur est réclamé
391
398
  (`authFlow.ts:175`), et `login.success` après la preuve complète (`authFlow.ts:191`,
392
- `authFlow.ts:229`).
399
+ `authFlow.ts:232`).
393
400
 
394
401
  ### `authz` — le refus d'autorisation
395
402
 
@@ -399,10 +406,10 @@ Un seul événement, mais c'est le plus parlant : `access.denied`, émis par le
399
406
  ### `token` — la vie et la mort des jetons longue durée
400
407
 
401
408
  - `token.issued` — un couple access/refresh vient d'être émis, donc une surface d'attaque vient
402
- d'être créée ; les `scopes` et l'identifiant du jeton partent en `metadata` (`tokenService.ts:312`) ;
409
+ d'être créée ; les `scopes` et l'identifiant du jeton partent en `metadata` (`tokenService.ts:535`) ;
403
410
  - `token.reuse_detected` — **le signal d'attaque le plus fort du journal** : un refresh déjà révoqué
404
411
  a été re-présenté, donc quelqu'un détient un jeton volé. Toute la famille est coupée
405
- (`tokenService.ts:353`, RFC 9700 §4.14) ;
412
+ (`tokenService.ts:585`, RFC 9700 §4.14) ;
406
413
  - `apikey.created` / `apikey.revoked` — cycle de vie des clés d'API (`apiKeys.ts:153`,
407
414
  `apiKeys.ts:212`).
408
415
 
@@ -427,18 +434,18 @@ depuis l'utilisateur de la requête — avec repli `"admin"`, jamais une décisi
427
434
 
428
435
  ## 🧰 Le contrat d'une entrée
429
436
 
430
- Un événement est un objet **sérialisable JSON** (`IAuditEvent.ts:53`). L'émetteur ne fournit qu'un
431
- brouillon `IAuditEventDraft` (`IAuditEvent.ts:105`) : `id` et `ts` sont posés par le service, ce qui
437
+ Un événement est un objet **sérialisable JSON** (`IAuditEvent.ts:61`). L'émetteur ne fournit qu'un
438
+ brouillon `IAuditEventDraft` (`IAuditEvent.ts:113`) : `id` et `ts` sont posés par le service, ce qui
432
439
  garantit un seul appel d'horloge, centralisé hors des points d'émission.
433
440
 
434
441
  | Champ | Type | Posé par | Rôle |
435
442
  | ----------- | -------------------------- | -------- | ------------------------------------------------------------ |
436
443
  | `id` | `string` | service | unique dans le process ; sert aussi de composante de curseur |
437
- | `ts` | `number` (epoch ms) | service | horodatage (`auditService.ts:188`) |
444
+ | `ts` | `number` (epoch ms) | service | horodatage (`auditService.ts:195`) |
438
445
  | `category` | `AuditCategory` | émetteur | sous-système — union fermée |
439
446
  | `action` | `string` | émetteur | le fait, `<sujet>.<verbe>` — chaîne ouverte |
440
447
  | `outcome` | `success\|failure\|denied` | émetteur | le verdict |
441
- | `actor` | `string \| null` | émetteur | libellé d'identité, `null` si anonyme (`IAuditEvent.ts:74`) |
448
+ | `actor` | `string \| null` | émetteur | libellé d'identité, `null` si anonyme (`IAuditEvent.ts:82`) |
442
449
  | `resource` | `string \| null` | émetteur | zone, route, canal, attribut — descripteur **léger** |
443
450
  | `reason` | `string \| null` | émetteur | motif **machine** filtrable, pas un message traduit |
444
451
  | `ip` | `string \| null` | contexte | provenance réseau |
@@ -458,19 +465,19 @@ Quatre mécanismes, tous prouvés par les tests.
458
465
 
459
466
  **1. Le chemin nominal n'émet rien.** Ce n'est pas une optimisation, c'est le modèle : le firewall
460
467
  n'appelle `#recordAuth()` que depuis ses quatre sorties d'échec, jamais depuis le succès
461
- (`firewall.ts:900`). Le verrou WS ne tire sa closure `onDeny` que sur refus (`firewall.ts:341`).
462
- Prouvé : « frame AUTORISÉE → onDeny JAMAIS appelé » (`auditEmissionHotPath.test.ts:324`).
468
+ (`firewall.ts:907`). Le verrou WS ne tire sa closure `onDeny` que sur refus (`firewall.ts:358`).
469
+ Prouvé : « frame AUTORISÉE → onDeny JAMAIS appelé » (`auditEmissionHotPath.test.ts:333`).
463
470
 
464
471
  **2. Audit désactivé = coût nul, pas juste coût faible.** `record()` sort avant toute allocation et
465
- avant tout appel d'horloge si le service est inactif (`auditService.ts:182`). Aucun objet créé,
472
+ avant tout appel d'horloge si le service est inactif (`auditService.ts:187`). Aucun objet créé,
466
473
  aucun appel d'horloge. Prouvé par le banc « audit désactivé → `issueTokens` n'est pas journalisé »
467
474
  (`auditEmissionHotPath.test.ts:499`).
468
475
 
469
476
  **3. L'écriture ne bloque pas.** `append()` part sans `await`, avec un `.catch()` qui logge
470
- (`auditService.ts:192`). La latence du store n'entre jamais dans la latence de la requête.
477
+ (`auditService.ts:199`). La latence du store n'entre jamais dans la latence de la requête.
471
478
 
472
479
  **4. Tout ce qui n'est pas utilisé n'est pas alloué.** La liste d'abonnés live reste `null` tant que
473
- personne n'écoute et **redevient** `null` au dernier désabonnement (`auditService.ts:221`). Le pont
480
+ personne n'écoute et **redevient** `null` au dernier désabonnement (`auditService.ts:228`). Le pont
474
481
  WS n'existe qu'entre le premier et le dernier auditeur connecté, et son tampon circulaire n'est
475
482
  alloué qu'au premier événement reçu (`auditBridge.ts:62`) ; son minuteur est armé à la demande et
476
483
  `unref` (`auditBridge.ts:93`).
@@ -478,13 +485,13 @@ alloué qu'au premier événement reçu (`auditBridge.ts:62`) ; son minuteur est
478
485
  Le pont applique en plus un **coalescing borné** : au plus une frame WS toutes les 250 ms
479
486
  (`auditBridge.ts:59`), tampon plafonné à 200 événements (`auditBridge.ts:60`). Sous une rafale
480
487
  d'échecs de login, le tampon écrase les plus anciens et compte les omis dans `dropped`
481
- (`auditBridge.ts:84`) — la console affiche un récapitulatif au lieu de se figer. Superviser ne doit
488
+ (`auditBridge.ts:86`) — la console affiche un récapitulatif au lieu de se figer. Superviser ne doit
482
489
  jamais faire tomber ce qu'on supervise.
483
490
 
484
491
  ## ⚙️ Configuration
485
492
 
486
- Table dérivée du schéma Zod `auditSchema` (`config.ts:877`), rattaché à la racine sous la clé `audit`
487
- (`config.ts:1152`).
493
+ Table dérivée du schéma Zod `auditSchema` (`config.ts:883`), rattaché à la racine sous la clé `audit`
494
+ (`config.ts:1158`).
488
495
 
489
496
  | Option | Type | Défaut | Effet |
490
497
  | --------------- | --------- | -------- | ------------------------------------------------------------------------------------ |
@@ -498,18 +505,18 @@ Table dérivée du schéma Zod `auditSchema` (`config.ts:877`), rattaché à la
498
505
  > `immutable` et `stream` sont déclarés dans le schéma mais **ne sont lus par aucun code** aujourd'hui.
499
506
  > Les mettre à `false` ne change rien. L'immuabilité vient du **contrat** `IAuditStore`, qui n'expose
500
507
  > ni `update` ni `delete` ciblé (`IAuditStore.ts:48`) ; la diffusion live est gouvernée par la
501
- > **présence d'abonnés** — la liste `#listeners` du service (`auditService.ts:194`).
508
+ > **présence d'abonnés** — la liste `#listeners` du service (`auditService.ts:55`).
502
509
 
503
510
  ### Comment `store: "auto"` décide
504
511
 
505
512
  Le défaut ne suppose rien : il **suit l'infrastructure déclarée**, borné aux backends réellement
506
- enregistrés (`auditService.ts:92`, logique `resolveAutoStore()` dans `infra.ts:289`).
513
+ enregistrés (`auditService.ts:96`, logique `resolveAutoStore()` dans `infra.ts:289`).
507
514
 
508
515
  1. `NF_STORE` posée et le backend est enregistré pour l'audit → il gagne (levier de banc de charge) ;
509
516
  2. sinon, une base est déclarée (`NF_DATABASE_URL`) → `drizzle`, ou `mongoose` selon la famille ;
510
517
  3. sinon, repli **annoncé** sur `memory` — la décision est loggée en INFO, jamais silencieuse.
511
518
 
512
- La résolution finale est publiée au kernel (`auditService.ts:139`), ce qui alimente l'écran des stores
519
+ La résolution finale est publiée au kernel (`auditService.ts:145`), ce qui alimente l'écran des stores
513
520
  de Studio : configuré, résolu, disponible, motif, emplacement physique.
514
521
 
515
522
  ## 🏗️ Architecture interne
@@ -536,14 +543,14 @@ Quatre pièces, quatre responsabilités :
536
543
  - **`recordAudit()`** (`recordAudit.ts:15`) — le point d'émission côté appelant. Une résolution par le
537
544
  container, sur le cold-path uniquement. C'est ce qui rend l'audit **découplé** : module absent →
538
545
  aucun effet, jamais d'exception qui remonterait dans le flux métier.
539
- - **`AuditService`** (`auditService.ts:48`) — le propriétaire. Il construit le store au boot, le pose
540
- au container sous le nom `auditStore` (`auditService.ts:138`), arme la purge, et implémente
546
+ - **`AuditService`** (`auditService.ts:51`) — le propriétaire. Il construit le store au boot, le pose
547
+ au container sous le nom `auditStore` (`auditService.ts:144`), arme la purge, et implémente
541
548
  `IAuditSink` (`IAuditStore.ts:77`).
542
549
  - **`IAuditStore`** (`IAuditStore.ts:48`) — le contrat de persistance : `append`, `listPage`, `gc`.
543
550
  **`append` est la seule écriture** : ni `update`, ni `delete` ciblé. L'immuabilité EST la garantie
544
551
  d'audit.
545
552
  - **`createAuditBridge()`** (`auditBridge.ts:53`) — le pont vers le canal WS `nodefony:audit`
546
- (`auditBridge.ts:8`), enregistré comme canal **système** par le firewall (`firewall.ts:322`) et donc
553
+ (`auditBridge.ts:8`), enregistré comme canal **système** par le firewall (`firewall.ts:387`) et donc
547
554
  gardé par le plancher `security:` → `ROLE_NODEFONY_ADMIN` (`frameAuthorizer.ts:106`).
548
555
 
549
556
  ### La lecture paginée — pourquoi un curseur et pas un décalage
@@ -565,30 +572,28 @@ tiennent identiquement :
565
572
 
566
573
  Le `total` est **refusable** (`withTotal: false`) : un `COUNT` filtré sur une rétention longue se
567
574
  paie (`IAuditStore.ts:59`). `hasNext` et `nextCursor` restent fiables dans les deux cas, grâce à une
568
- ligne de garde `limit + 1` (`DrizzleAuditStore.ts:198`).
575
+ ligne de garde `limit + 1` (`DrizzleAuditStore.ts:225`).
569
576
 
570
577
  ## Entité de persistance et dialectes pris en charge
571
578
 
572
- **Un seul backend durable** est fourni : `@nodefony/drizzle`. Le décompte honnête :
579
+ **Les deux backends durables portent l'audit** : `@nodefony/drizzle` et `@nodefony/mongoose`. Le
580
+ décompte honnête :
573
581
 
574
- | Backend | Store d'audit | Dialectes prouvés |
582
+ | Backend | Store d'audit | Moteurs prouvés |
575
583
  | -------------------- | ------------- | ----------------------------------- |
576
584
  | builtin `memory` | ✅ fourni | — (mémoire du process, per-pod) |
577
585
  | `@nodefony/drizzle` | ✅ fourni | SQLite · PostgreSQL · MySQL/MariaDB |
578
- | `@nodefony/mongoose` | ❌ absent | — |
586
+ | `@nodefony/mongoose` | ✅ fourni | MongoDB |
579
587
  | `@nodefony/redis` | ❌ absent | — |
580
588
 
581
- Mongoose porte session, user, jetons, passkeys et webhooks, mais **pas** l'audit ; Redis non plus.
582
- Ces deux absences n'ont pas le même statut. Redis n'en aura pas, et la raison n'est pas
583
- « c'est un cache » — il porte déjà des données durables comme les passkeys, en opt-in assumé. C'est
584
- que le journal d'audit **croît sans borne**, se conserve des mois pour la conformité, et se
585
- **consulte** (recherche, filtres, pagination) : garder tout ça en mémoire vive coûte cher pour un
586
- motif d'accès qui n'est pas le sien. Mongo, lui, est un chemin **durable** :
587
- l'audit y **manque**, et c'est un manque à combler (objectif « full NoSQL ») — un utilisateur choisit sa base de données, pas de perdre sa traçabilité.
589
+ Une application peut donc tourner **entièrement sur MongoDB** sans perdre sa traçabilité : c'était
590
+ la condition pour que Mongo soit un chemin complet, et pas un chemin qui oblige à rapatrier une
591
+ base SQL pour une seule brique.
588
592
 
589
- En attendant, une application MongoDB qui veut un journal durable a trois voies : brancher un store
590
- maison (voir l'extension ci-dessous), charger `@nodefony/drizzle` à côté de Mongo — même en SQLite
591
- local, les deux modules cohabitent — ou héberger le journal sur une base SQL.
593
+ **Redis n'en aura pas, et la raison n'est pas « c'est un cache »** — il porte déjà des données
594
+ durables comme les passkeys, en opt-in assumé. C'est que le journal d'audit **croît sans borne**,
595
+ se conserve des mois pour la conformité, et se **consulte** (recherche, filtres, pagination) :
596
+ garder tout ça en mémoire vive coûte cher pour un motif d'accès qui n'est pas le sien.
592
597
 
593
598
  ### La table `audit_event`
594
599
 
@@ -630,9 +635,9 @@ La règle d'or est écrite en tête du contrat (`IAuditEvent.ts:8`) : **un secre
630
635
  Ce que le code garantit, concrètement :
631
636
 
632
637
  - **Le typage rend le secret difficile à faire entrer.** `actor` est documenté comme un _libellé
633
- d'identité_ (`IAuditEvent.ts:74`) et `resource` comme un _descripteur léger_ — « jamais le corps ni
634
- les en-têtes de la requête » (`IAuditEvent.ts:79`).
635
- - **La présence remplace la valeur.** `IAuditEventFlags` (`IAuditEvent.ts:41`) ne porte que deux
638
+ d'identité_ (`IAuditEvent.ts:82`) et `resource` comme un _descripteur léger_ — « jamais le corps ni
639
+ les en-têtes de la requête » (`IAuditEvent.ts:87`).
640
+ - **La présence remplace la valeur.** `IAuditEventFlags` (`IAuditEvent.ts:53`) ne porte que deux
636
641
  booléens : un en-tête `Authorization` était-il là, un cookie était-il là. `readAuditContext()`
637
642
  les calcule par un simple `Boolean(headers[…])` (`readAuditContext.ts:40`) — la valeur n'est jamais
638
643
  copiée.
@@ -641,7 +646,7 @@ Ce que le code garantit, concrètement :
641
646
  complète de l'événement **ne contient pas** le secret créé
642
647
  (`auditEmissionHotPath.test.ts:545`).
643
648
  - **Le motif reste machine.** `reason` est une valeur stable et filtrable, pas un message d'erreur
644
- libre (`IAuditEvent.ts:84`) : c'est ce qui empêche une cause fine de fuir dans la trace… et qui
649
+ libre (`IAuditEvent.ts:92`) : c'est ce qui empêche une cause fine de fuir dans la trace… et qui
645
650
  permet de la garder **côté audit** alors que le client, lui, reçoit un message d'échec uniforme
646
651
  (anti-énumération).
647
652
 
@@ -660,11 +665,11 @@ Deux autres propriétés de sécurité valent d'être connues :
660
665
  | ----------------------------------------- | --------------------------------- | --------------------------------------------------------------------------------- |
661
666
  | Journaliser les échecs d'authentification | OWASP Logging Cheat Sheet | `auth.failure`/`auth.throttled`/`auth.denied` (`firewall.ts:693`) |
662
667
  | Journaliser les refus d'autorisation | OWASP A09:2021 | `access.denied` sur tout refus du jury (`authorization.ts:130`) |
663
- | Ne jamais journaliser de secret | OWASP Logging Cheat Sheet | flags de **présence** seuls (`IAuditEvent.ts:41`) |
664
- | Traçabilité « qui, quoi, quand, d'où » | ISO 27001 A.8.15 (journalisation) | acteur, action, horodatage et provenance dans `IAuditEvent` (`IAuditEvent.ts:53`) |
668
+ | Ne jamais journaliser de secret | OWASP Logging Cheat Sheet | flags de **présence** seuls (`IAuditEvent.ts:49`) |
669
+ | Traçabilité « qui, quoi, quand, d'où » | ISO 27001 A.8.15 (journalisation) | acteur, action, horodatage et provenance dans `IAuditEvent` (`IAuditEvent.ts:61`) |
665
670
  | Journal inaltérable | ISO 27001 A.8.15 | contrat append-only, aucune mutation exposée (`IAuditStore.ts:48`) |
666
- | Rétention bornée / minimisation | RGPD art. 5.1.e | purge par âge pilotée par `retentionDays` (`config.ts:906`) |
667
- | Détection de rejeu de jeton | RFC 9700 §4.14 | `token.reuse_detected` + coupure de famille (`tokenService.ts:353`) |
671
+ | Rétention bornée / minimisation | RGPD art. 5.1.e | purge par âge pilotée par `retentionDays` (`config.ts:912`) |
672
+ | Détection de rejeu de jeton | RFC 9700 §4.14 | `token.reuse_detected` + coupure de famille (`tokenService.ts:585`) |
668
673
  | Backoff de login journalisé | NIST SP 800-63B | `auth.throttled` avec `reason: "throttled"` (`firewall.ts:773`) |
669
674
 
670
675
  ## 📡 Observabilité — Studio
@@ -674,7 +679,7 @@ L'écran **Sécurité → Journal d'audit** (`/nodefony/audit`) (`Audit.tsx:62`)
674
679
 
675
680
  - un tableau filtrable par heure, catégorie, action, issue, acteur, raison et IP (`Audit.tsx:227`) ;
676
681
  - un indicateur du **store réellement résolu** pour la brique `audit`, alimenté par la publication de
677
- résolution du service (`auditService.ts:139`) ;
682
+ résolution du service (`auditService.ts:145`) ;
678
683
  - un interrupteur **Temps réel** qui s'abonne au canal `nodefony:audit` (`AuditLive.tsx:20`) — pensé
679
684
  pour les pics d'activité : un journal d'audit se consulte, il ne se regarde pas défiler ;
680
685
  - un renvoi vers la **trace de requête** quand l'événement porte un `requestId`, ce qui recolle
@@ -692,19 +697,19 @@ webhook. Détail des souscriptions, de la signature et des relivraisons → [web
692
697
 
693
698
  ## ⚠️ Pièges
694
699
 
695
- | Symptôme | Cause (dans le code) | Correction |
696
- | --------------------------------------------------- | ------------------------------------------------------------------ | ---------------------------------------------------------- |
697
- | Journal vide après un redémarrage | Store `memory` : volatile et per-pod (`MemoryAuditStore.ts:37`) | `audit.store: "drizzle"` (ou déclarer `NF_DATABASE_URL`) |
698
- | Un pod voit des événements, l'autre non | Store `memory` non partagé | Store durable partagé |
699
- | Le boot échoue en production sur l'audit | Store **explicite** inconnu, fail-closed (`auditService.ts:109`) | Corriger le nom, ou charger l'adapter qui l'enregistre |
700
- | `limit=5000` ne rend que 500 événements | Plafond du store (`MemoryAuditStore.ts:12`) | Paginer avec `nextCursor`, jamais gonfler `limit` |
701
- | La page 2 répète ou saute des événements | Pagination réimplémentée en offset | Repasser le `nextCursor` reçu — le curseur est opaque |
702
- | Filtre `?category=authen` sans effet | Catégorie inconnue **ignorée** (`SecurityAdminApi.ts:191`) | Utiliser une valeur de l'union (`auth`, `authz`, `token`…) |
703
- | Le paramètre `q` ne filtre rien | Non appliqué sur ce journal (`IAuditStore.ts:20`) | Filtrer par `category`/`actor`/`action`/`requestId` |
704
- | `stream: false` ne coupe pas le live | Drapeau non lu ; le live suit `#listeners` (`auditService.ts:194`) | Retirer le rôle admin, ou ne pas exposer le canal |
705
- | Trous dans le journal pendant une panne de base | Écriture best-effort (`auditService.ts:192`) | Store à haute disponibilité si la conformité l'exige |
706
- | Aucun `login.success` alors que les logins marchent | Le succès du **firewall** est muet ; `login.success` vient du BFF | Filtrer `action=login.success`, pas `category=auth` seul |
707
- | Endpoint d'audit en 503 | `audit.enabled: false` (`SecurityAdminApi.ts:320`) | Réactiver l'audit en configuration |
700
+ | Symptôme | Cause (dans le code) | Correction |
701
+ | --------------------------------------------------- | ----------------------------------------------------------------- | ---------------------------------------------------------- |
702
+ | Journal vide après un redémarrage | Store `memory` : volatile et per-pod (`MemoryAuditStore.ts:37`) | `audit.store: "drizzle"` (ou déclarer `NF_DATABASE_URL`) |
703
+ | Un pod voit des événements, l'autre non | Store `memory` non partagé | Store durable partagé |
704
+ | Le boot échoue en production sur l'audit | Store **explicite** inconnu, fail-closed (`auditService.ts:115`) | Corriger le nom, ou charger l'adapter qui l'enregistre |
705
+ | `limit=5000` ne rend que 500 événements | Plafond du store (`MemoryAuditStore.ts:12`) | Paginer avec `nextCursor`, jamais gonfler `limit` |
706
+ | La page 2 répète ou saute des événements | Pagination réimplémentée en offset | Repasser le `nextCursor` reçu — le curseur est opaque |
707
+ | Filtre `?category=authen` sans effet | Catégorie inconnue **ignorée** (`SecurityAdminApi.ts:191`) | Utiliser une valeur de l'union (`auth`, `authz`, `token`…) |
708
+ | Le paramètre `q` ne filtre rien | Non appliqué sur ce journal (`IAuditStore.ts:20`) | Filtrer par `category`/`actor`/`action`/`requestId` |
709
+ | `stream: false` ne coupe pas le live | Drapeau non lu ; le live suit `#listeners` (`auditService.ts:55`) | Retirer le rôle admin, ou ne pas exposer le canal |
710
+ | Trous dans le journal pendant une panne de base | Écriture best-effort (`auditService.ts:199`) | Store à haute disponibilité si la conformité l'exige |
711
+ | Aucun `login.success` alors que les logins marchent | Le succès du **firewall** est muet ; `login.success` vient du BFF | Filtrer `action=login.success`, pas `category=auth` seul |
712
+ | Endpoint d'audit en 503 | `audit.enabled: false` (`SecurityAdminApi.ts:320`) | Réactiver l'audit en configuration |
708
713
 
709
714
  ## 🧪 Tests & couverture
710
715