@nodefony/security 10.0.0-beta.3 → 10.0.0-beta.5

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 (165) hide show
  1. package/dist/_virtual/{_@oxc-project_runtime@0.153.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorate.js +1 -2
  2. package/dist/index.js +6 -8
  3. package/dist/nodefony/command/{security-token.js → security-mcp-token.js} +18 -11
  4. package/dist/nodefony/command/security-oauth-doctor.js +5 -3
  5. package/dist/nodefony/command/security-secrets.js +10 -4
  6. package/dist/nodefony/command/security-user-add.js +9 -3
  7. package/dist/nodefony/command/security-user-delete.js +5 -3
  8. package/dist/nodefony/command/security-user-list.js +5 -3
  9. package/dist/nodefony/command/security-user-password.js +5 -3
  10. package/dist/nodefony/config/config.js +20 -4
  11. package/dist/nodefony/config/defineModuleConfig.js +1 -2
  12. package/dist/nodefony/contracts/IAccessVoter.js +1 -2
  13. package/dist/nodefony/errors/AccessDeniedError.js +1 -2
  14. package/dist/nodefony/errors/ApiKeyError.js +3 -2
  15. package/dist/nodefony/errors/AuthenticationError.js +1 -2
  16. package/dist/nodefony/errors/CsrfError.js +1 -2
  17. package/dist/nodefony/errors/InsecureTransportError.js +1 -2
  18. package/dist/nodefony/errors/InvalidTargetError.js +1 -2
  19. package/dist/nodefony/errors/SsrfError.js +1 -2
  20. package/dist/nodefony/errors/ThrottledError.js +1 -2
  21. package/dist/nodefony/errors/UnverifiableTokenError.js +1 -2
  22. package/dist/nodefony/errors/WebAuthnError.js +1 -2
  23. package/dist/nodefony/keycloak/keycloakAdmin.js +1 -2
  24. package/dist/nodefony/keycloak/keycloakRealm.js +1 -2
  25. package/dist/nodefony/keycloak/keycloakRealmInput.js +1 -2
  26. package/dist/nodefony/keycloak/scaffold.js +3 -3
  27. package/dist/nodefony/keycloak/security-keycloak-realm.js +5 -3
  28. package/dist/nodefony/scaffold/contribute.js +1 -2
  29. package/dist/nodefony/service/accessTokenVerifier.js +101 -34
  30. package/dist/nodefony/service/apiKeys.js +45 -5
  31. package/dist/nodefony/service/auditService.js +4 -5
  32. package/dist/nodefony/service/authFlow.js +10 -8
  33. package/dist/nodefony/service/authorization.js +1 -2
  34. package/dist/nodefony/service/cors.js +1 -2
  35. package/dist/nodefony/service/csrf.js +1 -2
  36. package/dist/nodefony/service/firewall.js +5 -4
  37. package/dist/nodefony/service/keycloak.js +1 -2
  38. package/dist/nodefony/service/oauth2.js +29 -7
  39. package/dist/nodefony/service/securityHeaders.js +1 -2
  40. package/dist/nodefony/service/tokenService.js +38 -22
  41. package/dist/nodefony/service/totp.js +4 -5
  42. package/dist/nodefony/service/webAuthn.js +4 -5
  43. package/dist/nodefony/service/webhooks.js +4 -5
  44. package/dist/nodefony/src/RoleHierarchyWalker.js +1 -2
  45. package/dist/nodefony/src/SecuredArea.js +1 -2
  46. package/dist/nodefony/src/accountBinding.js +96 -0
  47. package/dist/nodefony/src/admin/SecurityAdminApi.js +1 -2
  48. package/dist/nodefony/src/admin/WebhookAdminApi.js +23 -7
  49. package/dist/nodefony/src/admin/adminAudit.js +1 -2
  50. package/dist/nodefony/src/admin/userRevocationCascade.js +79 -15
  51. package/dist/nodefony/src/apikey/apiKeyFormat.js +1 -2
  52. package/dist/nodefony/src/audit/MemoryAuditStore.js +1 -2
  53. package/dist/nodefony/src/audit/auditBridge.js +1 -2
  54. package/dist/nodefony/src/audit/auditFilters.js +1 -2
  55. package/dist/nodefony/src/audit/auditStoreRegistry.js +1 -2
  56. package/dist/nodefony/src/audit/readAuditContext.js +1 -2
  57. package/dist/nodefony/src/audit/recordAudit.js +1 -2
  58. package/dist/nodefony/src/authenticator/AnonymousAuthenticator.js +1 -2
  59. package/dist/nodefony/src/authenticator/ApiKeyAuthenticator.js +3 -2
  60. package/dist/nodefony/src/authenticator/ExternalJwtAuthenticator.js +8 -113
  61. package/dist/nodefony/src/authenticator/ExternalSubjectResolver.js +180 -0
  62. package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +42 -4
  63. package/dist/nodefony/src/authenticator/JwtAuthenticator.js +91 -16
  64. package/dist/nodefony/src/authenticator/SessionAuthenticator.js +9 -5
  65. package/dist/nodefony/src/authenticator/UserPasswordAuthenticator.js +1 -2
  66. package/dist/nodefony/src/authenticator/authenticatorRegistry.js +3 -17
  67. package/dist/nodefony/src/authenticator/externalSubject.js +1 -2
  68. package/dist/nodefony/src/authenticator/peekIssuer.js +1 -2
  69. package/dist/nodefony/src/boot/publicRoutesNotice.js +1 -2
  70. package/dist/nodefony/src/crypto/secretCipher.js +1 -2
  71. package/dist/nodefony/src/csp.js +1 -2
  72. package/dist/nodefony/src/csrfToken.js +1 -2
  73. package/dist/nodefony/src/entryPoint.js +1 -2
  74. package/dist/nodefony/src/net/ssrfGuard.js +1 -2
  75. package/dist/nodefony/src/net/transportFailure.js +57 -0
  76. package/dist/nodefony/src/oauth/clientCredentials.js +1 -2
  77. package/dist/nodefony/src/oauth/httpJson.js +3 -3
  78. package/dist/nodefony/src/oauth/metadata.js +5 -16
  79. package/dist/nodefony/src/oauth/oauth2Client.js +1 -2
  80. package/dist/nodefony/src/oauth/oauthProviderRegistry.js +1 -2
  81. package/dist/nodefony/src/oauth/providerDiagnosis.js +5 -6
  82. package/dist/nodefony/src/oauth/providerRoles.js +1 -2
  83. package/dist/nodefony/src/oauth/providers/github.js +16 -6
  84. package/dist/nodefony/src/oauth/providers/oidc.js +9 -6
  85. package/dist/nodefony/src/openToSignup.js +1 -2
  86. package/dist/nodefony/src/realtime/UserRealtimeToken.js +17 -3
  87. package/dist/nodefony/src/realtime/frameAuthorizer.js +2 -3
  88. package/dist/nodefony/src/rolesWithin.js +57 -0
  89. package/dist/nodefony/src/sessionAuthentication.js +1 -2
  90. package/dist/nodefony/src/sessionIdentity.js +29 -3
  91. package/dist/nodefony/src/throttle/LoginThrottler.js +1 -2
  92. package/dist/nodefony/src/token/AnonymousToken.js +1 -2
  93. package/dist/nodefony/src/token/JwtKeystore.js +14 -5
  94. package/dist/nodefony/src/token/MemoryTokenStore.js +1 -2
  95. package/dist/nodefony/src/token/RemoteJwtVerifier.js +6 -3
  96. package/dist/nodefony/src/token/UserToken.js +1 -2
  97. package/dist/nodefony/src/token/jwtRuntime.js +1 -2
  98. package/dist/nodefony/src/token/scopeGrants.js +109 -0
  99. package/dist/nodefony/src/token/secretFile.js +1 -2
  100. package/dist/nodefony/src/token/tokenCriteria.js +1 -2
  101. package/dist/nodefony/src/token/tokenFilters.js +1 -2
  102. package/dist/nodefony/src/token/tokenSort.js +1 -2
  103. package/dist/nodefony/src/token/tokenStatus.js +1 -2
  104. package/dist/nodefony/src/token/tokenStoreRegistry.js +1 -2
  105. package/dist/nodefony/src/totp/MemoryTotpSecretStore.js +1 -2
  106. package/dist/nodefony/src/totp/totpCipher.js +1 -2
  107. package/dist/nodefony/src/totp/totpCrypto.js +1 -2
  108. package/dist/nodefony/src/totp/totpOperations.js +1 -2
  109. package/dist/nodefony/src/totp/totpSecretStoreRegistry.js +1 -2
  110. package/dist/nodefony/src/transport/CredentialTransportPolicy.js +1 -2
  111. package/dist/nodefony/src/voter/RoleVoter.js +1 -2
  112. package/dist/nodefony/src/voter/ScopeVoter.js +1 -2
  113. package/dist/nodefony/src/voter/voterRegistry.js +1 -2
  114. package/dist/nodefony/src/webauthn/MemoryWebAuthnCredentialStore.js +1 -2
  115. package/dist/nodefony/src/webauthn/webAuthnCredentialStoreRegistry.js +1 -2
  116. package/dist/nodefony/src/webhook/MemoryWebhookStore.js +1 -2
  117. package/dist/nodefony/src/webhook/WebhookDispatcher.js +1 -2
  118. package/dist/nodefony/src/webhook/webhookCipher.js +1 -2
  119. package/dist/nodefony/src/webhook/webhookDelivery.js +1 -2
  120. package/dist/nodefony/src/webhook/webhookFilters.js +1 -2
  121. package/dist/nodefony/src/webhook/webhookSignature.js +1 -2
  122. package/dist/nodefony/src/webhook/webhookSort.js +1 -2
  123. package/dist/nodefony/src/webhook/webhookStoreRegistry.js +1 -2
  124. package/dist/nodefony/testing/index.js +108 -0
  125. package/dist/types/nodefony/command/{security-token.d.ts → security-mcp-token.d.ts} +7 -6
  126. package/dist/types/nodefony/config/config.d.ts +1 -0
  127. package/dist/types/nodefony/errors/ApiKeyError.d.ts +3 -1
  128. package/dist/types/nodefony/service/accessTokenVerifier.d.ts +35 -14
  129. package/dist/types/nodefony/service/apiKeys.d.ts +13 -2
  130. package/dist/types/nodefony/service/oauth2.d.ts +20 -0
  131. package/dist/types/nodefony/src/accountBinding.d.ts +82 -0
  132. package/dist/types/nodefony/src/admin/userRevocationCascade.d.ts +23 -5
  133. package/dist/types/nodefony/src/authenticator/ExternalJwtAuthenticator.d.ts +2 -44
  134. package/dist/types/nodefony/src/authenticator/ExternalSubjectResolver.d.ts +99 -0
  135. package/dist/types/nodefony/src/authenticator/FirewallRealtimeAuthenticator.d.ts +4 -1
  136. package/dist/types/nodefony/src/authenticator/JwtAuthenticator.d.ts +37 -1
  137. package/dist/types/nodefony/src/net/transportFailure.d.ts +22 -0
  138. package/dist/types/nodefony/src/oauth/httpJson.d.ts +0 -8
  139. package/dist/types/nodefony/src/realtime/UserRealtimeToken.d.ts +7 -1
  140. package/dist/types/nodefony/src/realtime/frameAuthorizer.d.ts +2 -2
  141. package/dist/types/nodefony/src/realtime/realtimeContracts.d.ts +6 -0
  142. package/dist/types/nodefony/src/rolesWithin.d.ts +27 -0
  143. package/dist/types/nodefony/src/sessionIdentity.d.ts +22 -0
  144. package/dist/types/nodefony/src/token/scopeGrants.d.ts +77 -0
  145. package/dist/types/nodefony/testing/index.d.ts +51 -0
  146. package/docs/api-keys.md +181 -174
  147. package/docs/audit.md +140 -140
  148. package/docs/authenticators.md +101 -101
  149. package/docs/authorization.md +116 -69
  150. package/docs/cors.md +64 -64
  151. package/docs/csrf.md +74 -71
  152. package/docs/external-jwt.md +22 -12
  153. package/docs/firewall.md +85 -82
  154. package/docs/headers.md +81 -81
  155. package/docs/index.md +4 -4
  156. package/docs/keycloak.md +95 -78
  157. package/docs/login-page.md +23 -23
  158. package/docs/oauth2.md +157 -146
  159. package/docs/obtenir-un-jeton.md +116 -26
  160. package/docs/tokens.md +128 -122
  161. package/docs/totp.md +136 -136
  162. package/docs/webauthn.md +153 -153
  163. package/docs/webhooks.md +136 -124
  164. package/package.json +19 -13
  165. package/dist/_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorateMetadata.js +0 -6
package/docs/tokens.md CHANGED
@@ -87,20 +87,20 @@ défaut » en prod.
87
87
  ## La vision Nodefony — un service propriétaire, des endpoints minces
88
88
 
89
89
  `TokenService` est **propriétaire** du store et du keystore : à `TokenService.#build()`
90
- (`tokenService.ts:158`), si `jwt.enabled` ou `apiKeys.enabled`, il résout le store pluggable, pose
91
- `tokenStore` au container (`tokenService.ts:219`) puis crée le keystore et pose `jwtKeystore`
92
- (`tokenService.ts:225-231`) — consommés par le `JwtAuthenticator` et les endpoints. Il arme un
90
+ (`tokenService.ts#TokenService.#build`), si `jwt.enabled` ou `apiKeys.enabled`, il résout le store pluggable, pose
91
+ `tokenStore` au container (`tokenService.ts#TokenService.#build`) puis crée le keystore et pose `jwtKeystore`
92
+ (`tokenService.ts#TokenService.#build`) — consommés par le `JwtAuthenticator` et les endpoints. Il arme un
93
93
  **gc** via `GcScheduler` (timer `unref` + **jitter** de phase pour étaler les balayages entre pods,
94
- `tokenService.ts:233-239`).
94
+ `tokenService.ts#TokenService.#build`).
95
95
 
96
96
  Les endpoints HTTP sont des **adaptateurs minces** portés par `@nodefony/framework`, couplés **par
97
97
  nom de service** via le contrat structurel `ITokenIssuer` — framework n'importe jamais security
98
- (`TokenAuthController.ts:11-22`).
98
+ (`TokenAuthController.ts#ITokenIssuer`).
99
99
 
100
100
  Constat clé de cohérence : `iss`/`aud`/`ttl` sont dérivés **une seule fois** par
101
- `resolveJwtRuntime()` (`jwtRuntime.ts:30-41`) et **partagés** entre l'émetteur et le vérificateur —
101
+ `resolveJwtRuntime()` (`jwtRuntime.ts#resolveJwtRuntime`) et **partagés** entre l'émetteur et le vérificateur —
102
102
  une divergence ferait tout rejeter. Fonction pure : les deux côtés obtiennent la même valeur sans la
103
- partager par référence. `issuer` omis → `"nodefony"` (`jwtRuntime.ts:31`), à surcharger en prod.
103
+ partager par référence. `issuer` omis → `"nodefony"` (`jwtRuntime.ts#resolveJwtRuntime`), à surcharger en prod.
104
104
 
105
105
  ### Être DÉCOUVRABLE — publier ses clés (RFC 8414)
106
106
 
@@ -136,36 +136,41 @@ seulement ses signatures vérifiables. Omettre `grant_types_supported` annoncera
136
136
  ### Les endpoints d'émission sont FOURNIS
137
137
 
138
138
  Dans une app `nodefony create app`, dès que le module security est chargé avec `jwt.enabled` (défaut),
139
- le framework monte deux routes (`mountTokenAuthRoutes()`, `TokenAuthController.ts:161-176`) :
139
+ le framework monte deux routes (`mountTokenAuthRoutes()`, `TokenAuthController.ts#mountTokenAuthRoutes`) :
140
140
 
141
141
  - `POST /nodefony/security/api/token` — body `{username, password, scope?}` → couple access/refresh ;
142
142
  - `POST /nodefony/security/api/token/refresh` — body `{refresh_token}` → rotation.
143
143
 
144
144
  > [!IMPORTANT]
145
145
  > Ces routes n'existent **que si** `@nodefony/security` est chargé — sinon 404, zéro surface
146
- > (`framework/index.ts:415`). Module chargé mais JWT désactivé : elles existent et répondent
147
- > `503 Token issuance unavailable` (`TokenAuthController.ts:69`). Elles sont `bypassFirewall: true` (`TokenAuthController.ts:188`) :
146
+ > (`framework/index.ts#Framework.onKernelReady`). Module chargé mais JWT désactivé : elles existent et répondent
147
+ > `503 Token issuance unavailable` (`TokenAuthController.ts#TokenAuthController.token`). Elles sont `bypassFirewall: true` (`TokenAuthController.ts#mountTokenAuthRoutes`) :
148
148
  > elles SONT le mécanisme d'émission — protégées, obtenir un token exigerait d'être déjà
149
149
  > authentifié (deadlock). Le JWT part en **réponse JSON** (Bearer), jamais en cookie ni en URL.
150
150
 
151
151
  ### La config : une zone protégée par `jwt` + une clé qui survit au redémarrage
152
152
 
153
- ```typescript
154
- // nodefony.config.ts (extrait) — la zone API machine + la source de clé
155
- use("@nodefony/security", {
156
- jwt: {
157
- // dev : persiste la clé Ed25519 sous var/ (sinon clé ÉPHÉMÈRE + warning).
158
- // prod : NF_JWT_KEYSET, la même clé sur tous les pods — voir la section keystore.
159
- keystore: {
160
- keySetJson: process.env.NF_JWT_KEYSET,
161
- dir: "var/keys",
153
+ ```typescript ignore
154
+ // nodefony/config/security.ts — la zone API machine + la source de clé
155
+ import type { ConfigContext } from "nodefony";
156
+ import type { ISecurityConfigInput } from "@nodefony/security";
157
+ import type { env } from "../../env";
158
+
159
+ export const securityConfig = (ctx: ConfigContext<typeof env>) =>
160
+ ({
161
+ jwt: {
162
+ // dev : persiste la clé Ed25519 sous var/ (sinon clé ÉPHÉMÈRE + warning).
163
+ // prod : NF_JWT_KEYSET, la même clé sur tous les pods — voir la section keystore.
164
+ keystore: {
165
+ keySetJson: ctx.env.NF_JWT_KEYSET,
166
+ dir: "var/keys",
167
+ },
162
168
  },
163
- },
164
- areas: {
165
- // Le firewall vérifie le Bearer JWT sur CHAQUE requête de la zone.
166
- api: { pattern: "^/api/v1", authenticators: ["jwt"] },
167
- },
168
- });
169
+ areas: {
170
+ // Le firewall vérifie le Bearer JWT sur CHAQUE requête de la zone.
171
+ api: { pattern: "^/api/v1", authenticators: ["jwt"] },
172
+ },
173
+ }) satisfies ISecurityConfigInput;
169
174
  ```
170
175
 
171
176
  ### Ce que TU écris : le controller scopé
@@ -222,45 +227,46 @@ curl -s -H 'Content-Type: application/json' \
222
227
  # 4) Rejouer l'ANCIEN refresh → 401 {"error":"invalid_grant"} + famille coupée
223
228
  ```
224
229
 
225
- Erreurs mappées par duck-typing dans `#renderAuthError()` (`TokenAuthController.ts:108-120`) :
230
+ Erreurs mappées par duck-typing dans `#renderAuthError()` (`TokenAuthController.ts#TokenAuthController.#renderAuthError`) :
226
231
  401 message uniforme `invalid_grant` (anti-énumération), 429 avec `Retry-After` du throttler NIST.
227
232
  Émission indisponible (JWT désactivé, store absent) → 503 `isEnabled()`
228
- (`TokenAuthController.ts:67-69`).
233
+ (`TokenAuthController.ts#ITokenIssuer.isEnabled`).
229
234
 
230
235
  ## 🏗️ Architecture interne — la vie d'un couple access/refresh
231
236
 
232
237
  ### Émission (grant M2M/CLI)
233
238
 
234
- `issueForCredentials()` (`tokenService.ts:387`) vérifie l'identifiant/mot de passe via le
239
+ `issueForCredentials()` (`tokenService.ts#TokenService.issueForCredentials`) vérifie l'identifiant/mot de passe via le
235
240
  service `users`, avec le **throttling NIST partagé** — `ThrottledError` avant tout hachage
236
- (`tokenService.ts:398`). Chaque tentative échouée est auditée `login.failure`/`login.throttled`
237
- par `#auditGrant()` (`tokenService.ts:414-426`). Puis `issueTokens()` (`tokenService.ts:582`)
241
+ (`tokenService.ts#TokenService.issueForCredentials`). Chaque tentative échouée est auditée `login.failure`/`login.throttled`
242
+ par `#auditGrant()` (`tokenService.ts#TokenService.#auditGrant`). Puis `issueTokens()` (`tokenService.ts#TokenService.issueTokens`)
238
243
  produit :
239
244
 
240
245
  - un **access token** : JWT signé EdDSA, en-tête `typ:"at+jwt"` + `kid`, claims
241
- `iss`/`sub`/`aud`/`exp` (15 min) + `jti` — `#signAccess()` (`tokenService.ts:694-707`) ;
246
+ `iss`/`sub`/`aud`/`exp` (15 min) + `jti` — `#signAccess()` (`tokenService.ts#TokenService.#signAccess`) ;
242
247
  - un **refresh token** : secret opaque haute entropie `nfr_<32 octets base64url>`, **stocké haché**
243
248
  `sha256` (le clair n'existe qu'en réponse, jamais au repos) — `#buildRefresh()`
244
- (`tokenService.ts:726-761`).
249
+ (`tokenService.ts#TokenService.#buildRefresh`).
245
250
 
246
- La réponse suit RFC 6749 §5.1 — `ITokenResponse` (`tokenService.ts:50-58`). Tout succès est audité
247
- `token.issued` via `recordAudit` avec le `tokenId` corrélable (`tokenService.ts:357-363`).
251
+ La réponse suit RFC 6749 §5.1 — `ITokenResponse` (`tokenService.ts#ITokenResponse`). Tout succès est audité
252
+ `token.issued` via `recordAudit` avec le `tokenId` corrélable (`tokenService.ts#TokenService.runGc`).
248
253
 
249
254
  ### Rotation & détection de rejeu (RFC 9700 §4.14)
250
255
 
251
- `refresh()` (`tokenService.ts:647`) est le cœur défensif, dans l'ordre :
256
+ `refresh()` (`tokenService.ts#TokenService.refresh`) est le cœur défensif, dans l'ordre :
252
257
 
253
- 1. Lookup par hash — `findByHash`, refus uniforme si inconnu/mauvais type (`tokenService.ts:653`).
258
+ 1. Lookup par hash — `findByHash`, refus uniforme si inconnu/mauvais type (`tokenService.ts#TokenService.refresh`).
254
259
  2. **Détection de rejeu** : refresh **déjà révoqué** re-présenté → `revokeFamily` coupe toute la
255
- famille + audit `token.reuse_detected`, signal d'attaque fort (`tokenService.ts:622-636`).
256
- 3. Expiration `expiresAt` vérifiée (`tokenService.ts:793`).
260
+ famille + audit `token.reuse_detected`, signal d'attaque fort (`tokenService.ts#TokenService.refresh`).
261
+ 3. Expiration `expiresAt` vérifiée (`tokenService.ts#TokenService.refresh`).
257
262
  4. **Sujet revérifié** — compte disparu/inactif/verrouillé rejeté sans attendre l'exp,
258
- `#resolveUserForRefresh()` (`tokenService.ts:852`).
259
- 5. **Downscoping** : les `scopes` du nouveau couple sont ceux de l'ancien, jamais plus
260
- (`tokenService.ts:684`).
263
+ `#resolveUserForRefresh()` (`tokenService.ts#TokenService.#resolveUserForRefresh`).
264
+ 5. **Downscoping** : les `scopes` du nouveau couple sont ceux de l'ancien, jamais plus — et ils
265
+ sont REJUGÉS sur les rôles du compte relu (`grantableScopes`, règle d'administration) : un rôle
266
+ retiré retire son scope au prochain renouvellement (`tokenService.ts#TokenService.#grantableScopes`).
261
267
  6. **Rotation** : nouveau refresh (même famille), l'ancien chaîné `replacedBy` + révoqué
262
- `"rotated"` (`tokenService.ts:716-718`). Si `rotateRefresh` est désactivé, l'access est réémis
263
- et le refresh courant reste valide (`tokenService.ts:702`).
268
+ `"rotated"` (`tokenService.ts#TokenService.refresh`). Si `rotateRefresh` est désactivé, l'access est réémis
269
+ et le refresh courant reste valide (`tokenService.ts#TokenService.refresh`).
264
270
 
265
271
  ### Mise en situation — ton refresh token a été volé
266
272
 
@@ -280,24 +286,24 @@ la victime est déconnectée (signal visible) au lieu d'un vol silencieux indéf
280
286
 
281
287
  ## 🔐 Le keystore Ed25519 — la clé ne fuit pas, pas de secret « par défaut » en prod
282
288
 
283
- `JwtKeystore.#load()` résout la source de clé par **priorité** (`JwtKeystore.ts:219`), pensée
289
+ `JwtKeystore.#load()` résout la source de clé par **priorité** (`JwtKeystore.ts#JwtKeystore.#load`), pensée
284
290
  pour ne jamais auto-générer une clé en clair silencieusement en prod :
285
291
 
286
292
  1. **env** — `keySetJson` (`NF_JWT_KEYSET` dans une application générée) : la MÊME clé pour tous
287
- les process qui servent l'application (`JwtKeystore.ts:222`).
293
+ les process qui servent l'application (`JwtKeystore.ts#JwtKeystore.#load`).
288
294
  2. **fichier** — `dir/keyset.json`, généré si absent, en mode 600, par une création **exclusive**
289
- (`createSecretExclusive`, `secretFile.ts:188`) : quand plusieurs workers de `nodefony cluster`
290
- démarrent ensemble, un seul crée la clé et les autres relisent la sienne (`JwtKeystore.ts:242`).
295
+ (`createSecretExclusive`, `secretFile.ts#createSecretExclusive`) : quand plusieurs workers de `nodefony cluster`
296
+ démarrent ensemble, un seul crée la clé et les autres relisent la sienne (`JwtKeystore.ts#JwtKeystore.#load`).
291
297
  Source de développement, ou d'un serveur unique à disque persistant.
292
- 3. **mémoire** — aucune source → clé **éphémère**, propre au process (`JwtKeystore.ts:263`). En
298
+ 3. **mémoire** — aucune source → clé **éphémère**, propre au process (`JwtKeystore.ts#JwtKeystore.#load`). En
293
299
  développement : un WARNING au premier jeton. En production, un process qui sert **refuse de
294
300
  démarrer** (§ suivant).
295
301
 
296
- Le JWKS servi par `getPublicJWKS()` est **public** — `JwtKeystore.ts:209`.
302
+ Le JWKS servi par `getPublicJWKS()` est **public** — `JwtKeystore.ts#JwtKeystore.getPublicJWKS`.
297
303
  La composante privée `d` en est retirée à l'import, par liste BLANCHE de paramètres
298
- (`#importKeyset()`, `JwtKeystore.ts:275`, RFC 8037/7517).
299
- C'est ce JWKS qu'utilise le vérificateur local (`createLocalJWKSet`, `JwtAuthenticator.ts:174`),
300
- jamais une clé venue du jeton. Le chargement est mémoïsé — `#ensureLoaded()` (`JwtKeystore.ts:215`).
304
+ (`#importKeyset()`, `JwtKeystore.ts#JwtKeystore.#importKeyset`, RFC 8037/7517).
305
+ C'est ce JWKS qu'utilise le vérificateur local (`createLocalJWKSet`, `JwtAuthenticator.ts#JwtAuthenticator.#ensureGetKey`),
306
+ jamais une clé venue du jeton. Le chargement est mémoïsé — `#ensureLoaded()` (`JwtKeystore.ts#JwtKeystore.#ensureLoaded`).
301
307
 
302
308
  ### Plusieurs pods ou workers — UNE clé pour tous
303
309
 
@@ -306,10 +312,10 @@ les jetons l'un de l'autre : derrière un répartiteur, l'utilisateur est décon
306
312
  et tout redémarrage invalide les jetons en vol.
307
313
 
308
314
  **En production, un process qui SERT sans source partagée refuse de démarrer**
309
- (`#requireSharedSigningKey`, `tokenService.ts:123`), avec le motif et les trois issues dans le
315
+ (`#requireSharedSigningKey`, `tokenService.ts#TokenService.#requireSharedSigningKey`), avec le motif et les trois issues dans le
310
316
  journal. Une commande de console (`orm:migrate`, `security:user:add`) n'est pas concernée : elle
311
317
  ne signe aucun jeton. Sous `nodefony cluster`, un worker refusé ne se relance pas — le master
312
- arrête le cluster et sort en **78** (`ClusterManager.ts:212`), le code d'une faute de configuration.
318
+ arrête le cluster et sort en **78** (`ClusterManager.ts#ClusterManager.#onExit`), le code d'une faute de configuration.
313
319
 
314
320
  Le déploiement, de bout en bout :
315
321
 
@@ -353,22 +359,22 @@ jwt: {
353
359
  ### Le contrat et l'enregistrement
354
360
 
355
361
  Le `tokenStore` héberge **trois structures** : les records (refresh + PAT), la denylist `jti` et le
356
- seuil `invalidBefore` par porteur (`ITokenStore.ts:11-15`). Les backends s'enregistrent par
357
- fabrique — `registerTokenStore()` (`tokenStoreRegistry.ts:41-46`) : les adapters lourds importent
362
+ seuil `invalidBefore` par porteur (`ITokenStore.ts`). Les backends s'enregistrent par
363
+ fabrique — `registerTokenStore()` (`tokenStoreRegistry.ts#registerTokenStore`) : les adapters lourds importent
358
364
  `import type { ITokenStore }` (effacé à la compilation), zéro couplage runtime.
359
365
 
360
- Sa résolution au boot (`tokenService.ts:156-207`) suit la doctrine `store:"auto"` du framework :
366
+ Sa résolution au boot (`tokenService.ts#TokenService.#build`) suit la doctrine `store:"auto"` du framework :
361
367
 
362
368
  - `auto` (défaut) → suit l'infra database déclarée via `resolveAutoStore` — **borné aux backends
363
- réellement enregistrés**, repli memory **annoncé** (`tokenService.ts:160-168`) ;
369
+ réellement enregistrés**, repli memory **annoncé** (`tokenService.ts#TokenService.#build`) ;
364
370
  - store explicite **inconnu** → en prod, **boot avorté** (fail-loud) ; en dev, brique désactivée et
365
- annoncée avec la liste `listTokenStores()` (`tokenService.ts:171-182`) — jamais de fallback
371
+ annoncée avec la liste `listTokenStores()` (`tokenService.ts#TokenService.#build`) — jamais de fallback
366
372
  memory silencieux pour du durable ;
367
373
  - store `memory` **en prod** → `WARNING` nommant l'impact : denylist/refresh/clés API per-pod et
368
- volatils, révocation non partagée (`tokenService.ts:186-193`).
374
+ volatils, révocation non partagée (`tokenService.ts#TokenService.#build`).
369
375
 
370
376
  La décision (configuré → résolu, raison) est publiée au kernel par `registerStoreResolution()`
371
- (`tokenService.ts:195-204`) — visible dans Studio.
377
+ (`tokenService.ts#TokenService.#build`) — visible dans Studio.
372
378
 
373
379
  ### Mise en situation — quel store pour quelle app ?
374
380
 
@@ -384,143 +390,143 @@ La décision (configuré → résolu, raison) est publiée au kernel par `regist
384
390
 
385
391
  ### `memory` — la référence 0 dépendance
386
392
 
387
- - Builtin, enregistré à l'import du module via `registerTokenStore` (`tokenStoreRegistry.ts:63-68`).
393
+ - Builtin, enregistré à l'import du module via `registerTokenStore` (`tokenStoreRegistry.ts#registerTokenStore`).
388
394
  - `listPage` : tri `createdAt` DESC + tiebreaker `id`, déterministe pour l'offset — parité SQL
389
- (`MemoryTokenStore.ts:165-185`).
395
+ (`MemoryTokenStore.ts#MemoryTokenStore.listPage`).
390
396
  - Denylist bornée : purge **amortie** tous les 256 ajouts — `#maybeSweep()`
391
- (`MemoryTokenStore.ts:393`) + expiration paresseuse à la lecture. Pas de minuterie, pas de fuite.
397
+ (`MemoryTokenStore.ts#MemoryTokenStore.#maybeSweep`) + expiration paresseuse à la lecture. Pas de minuterie, pas de fuite.
392
398
  - `snapshot()`/`restore()` sérialisables — base d'une persistance fichier, index reconstruits
393
- (`MemoryTokenStore.ts:324-354`).
399
+ (`MemoryTokenStore.ts#MemoryTokenStore.restore`).
394
400
  - Volatil, par-process : dev/tests. Pilote le banc de contrat commun.
395
401
 
396
402
  ### `drizzle` — SQL, le défaut durable
397
403
 
398
- - Enregistré par le module drizzle (`drizzle/nodefony/registerStores.ts:261`).
404
+ - Enregistré par le module drizzle (`drizzle/nodefony/registerStores.ts#registerDrizzleFrameworkStores`).
399
405
  - Élu par `store:"auto"` dès qu'une infra database SQL est déclarée ; sinon sqlite local si drizzle
400
- est chargé (`config.ts:452-457`).
406
+ est chargé (`config.ts#jwtSchema`).
401
407
  - Pagination **offset + total** (helper `paginate()` d'orm-core) ; e2e sur PostgreSQL et MySQL réels.
402
408
 
403
409
  ### `mongoose` — MongoDB
404
410
 
405
- - Enregistré par le module mongoose (`mongoose/nodefony/registerStores.ts:170`).
406
- - Pagination **offset + total** via `listPage` (`MongooseTokenStore.ts:236-246`).
407
- - Purge par `gc()` explicite sur `expiresAt` (`MongooseTokenStore.ts:170`).
411
+ - Enregistré par le module mongoose (`mongoose/nodefony/registerStores.ts#registerMongooseFrameworkStores`).
412
+ - Pagination **offset + total** via `listPage` (`MongooseTokenStore.ts#MongooseTokenStore.listPage`).
413
+ - Purge par `gc()` explicite sur `expiresAt` (`MongooseTokenStore.ts#MongooseTokenStore.#toRecord`).
408
414
 
409
415
  ### `redis` — cluster, TTL natif
410
416
 
411
- - Enregistré par le module redis (`redis/nodefony/registerStores.ts:75`).
412
- - TTL natif : `expire()` posé à l'écriture du record (`RedisTokenStore.ts:94`) — l'expiration ne
417
+ - Enregistré par le module redis (`redis/nodefony/registerStores.ts#registerRedisFrameworkStores`).
418
+ - TTL natif : `expire()` posé à l'écriture du record (`RedisTokenStore.ts#RedisClientLike.expire`) — l'expiration ne
413
419
  dépend pas du gc.
414
420
  - Listing par `SCAN` : curseur opaque `skip:scanCursor`, `decodeCursor()`
415
- (`RedisTokenStore.ts:428`) — sans ordre global ni total, capacité réduite **assumée**.
421
+ (`RedisTokenStore.ts#RedisTokenStore.listAll`) — sans ordre global ni total, capacité réduite **assumée**.
416
422
  - `countTokens()` renvoie `-1` : un comptage exact exigerait un SCAN complet O(N), refusé
417
- (`RedisTokenStore.ts:529`).
423
+ (`RedisTokenStore.ts#RedisTokenStore.countTokens`).
418
424
 
419
425
  ### Le record — une seule table pour PAT et refresh
420
426
 
421
- `IAccessTokenRecord` (`ITokenStore.ts:69`) est **single-table** : un même schéma porte PAT et
422
- refresh, champs non pertinents à `null`, horodatages epoch ms (`ITokenStore.ts:60-64`). Deux
427
+ `IAccessTokenRecord` (`ITokenStore.ts#IAccessTokenRecord`) est **single-table** : un même schéma porte PAT et
428
+ refresh, champs non pertinents à `null`, horodatages epoch ms (`ITokenStore.ts#IAccessTokenRecord`). Deux
423
429
  constats de design :
424
430
 
425
431
  - `subjectId` est une **référence logique souple, PAS une FK SQL** : porteur polymorphe
426
432
  (user/service), store multi-backend, découplage des modules — intégrité assurée à l'usage
427
- (`ITokenStore.ts:80-95`) ;
433
+ (`ITokenStore.ts#IAccessTokenRecord.subjectId`) ;
428
434
  - slots prêts sans migration : `resources` (permissions fine-grained façon GitHub) et `cnf`
429
- (sender-constrained DPoP/mTLS) (`ITokenStore.ts:101-119`).
435
+ (sender-constrained DPoP/mTLS) (`ITokenStore.ts#IAccessTokenRecord.cnf`).
430
436
 
431
437
  Les colonnes par dialecte vivent dans la doc de chaque adapter (règle anti-triple-vérité).
432
438
 
433
439
  ### Lister pour l'admin — pagination native
434
440
 
435
- - `listPage()` ne matérialise **jamais** plus d'une page (`ITokenStore.ts:233`) — capacité par
441
+ - `listPage()` ne matérialise **jamais** plus d'une page (`ITokenStore.ts#ITokenStore.listPage`) — capacité par
436
442
  backend : **offset + total** (SQL/Mongo/mémoire) ou **curseur** (`nextCursor`, Redis).
437
- - `countTokens()` donne le `total` ; Redis répond `-1` (`ITokenStore.ts:238`).
438
- - Filtres portables `ITokenListQuery` : `subjectId`, `kind`, `revoked` (`ITokenStore.ts:154-161`) —
439
- prédicat partagé `matchesTokenQuery()` (`MemoryTokenStore.ts:11-26`).
443
+ - `countTokens()` donne le `total` ; Redis répond `-1` (`ITokenStore.ts#ITokenStore.countTokens`).
444
+ - Filtres portables `ITokenListQuery` : `subjectId`, `kind`, `revoked` (`ITokenStore.ts#ITokenListQuery`) —
445
+ prédicat partagé `matchesTokenQuery()` (`MemoryTokenStore.ts#matchesTokenQuery`).
440
446
  - `listAll()` reste réservé au **dump d'incident** cross-porteur, cold-path admin
441
- (`ITokenStore.ts:222`).
447
+ (`ITokenStore.ts#ITokenStore.listAll`).
442
448
  - Consommateur type : le data plane des clés API — `ApiKeyService.listPagePat()`
443
- (`apiKeys.ts:209`), jamais un listAll matérialisé.
449
+ (`apiKeys.ts#ApiKeyService.listPagePat`), jamais un listAll matérialisé.
444
450
 
445
451
  ### Révoquer — trois portées
446
452
 
447
- - **Un access** avant son `exp` : denylist `denyJti()`/`isJtiDenied()` (`ITokenStore.ts:251`).
453
+ - **Un access** avant son `exp` : denylist `denyJti()`/`isJtiDenied()` (`ITokenStore.ts#ITokenStore.isJtiDenied`).
448
454
  - **Un refresh/PAT** : `revoke()` idempotent, `revokeFamily()` pour la chaîne de rotation
449
- (`ITokenStore.ts:242`).
455
+ (`ITokenStore.ts#ITokenStore.revokeFamily`).
450
456
  - **Tout un porteur** (logout global, ban) : seuil `revokeAllForSubject()` — tout access dont
451
- `iat < invalidBefore` est rejeté (`ITokenStore.ts:259-263`) ; le seuil est **monotone**, deux
452
- logouts successifs ne le reculent pas (`MemoryTokenStore.ts:262-267`).
457
+ `iat < invalidBefore` est rejeté (`ITokenStore.ts#ITokenStore`) ; le seuil est **monotone**, deux
458
+ logouts successifs ne le reculent pas (`MemoryTokenStore.ts#MemoryTokenStore.revokeAllForSubject`).
453
459
 
454
460
  ### La maintenance (gc)
455
461
 
456
462
  Le `gc()` du store purge la `denylist` expirée, les records à terme, les PAT révoqués au-delà de
457
- la rétention (`ITokenStore.ts:274-280`). Orchestré par le `GcScheduler` du service ; `runGc()` reste public pour
458
- un futur worker cron — poser alors `gcIntervalS: 0` (`tokenService.ts:338`).
463
+ la rétention (`ITokenStore.ts#ITokenStore.gc`). Orchestré par le `GcScheduler` du service ; `runGc()` reste public pour
464
+ un futur worker cron — poser alors `gcIntervalS: 0` (`tokenService.ts#TokenService.getPublicJWKS`).
459
465
 
460
466
  > [!TIP]
461
467
  > Un refresh révoqué **par rotation** n'est PAS purgé tout de suite : il est conservé jusqu'à son
462
468
  > `expiresAt` — c'est la **fenêtre de détection de rejeu** — puis tombe au gc, `#isPurgeable()`
463
- > (`MemoryTokenStore.ts:301-310`). Un store **local** (memory) est par-process : seul SON process
469
+ > (`MemoryTokenStore.ts#MemoryTokenStore.#isPurgeable`). Un store **local** (memory) est par-process : seul SON process
464
470
  > peut le purger → ne déléguez le gc au cron QUE pour un store partagé.
465
471
 
466
472
  ## ⚙️ Configuration
467
473
 
468
- Tables dérivées du schéma Zod — `jwtSchema` (`config.ts:373-449`) et `tokenStoreSchema`
469
- (`config.ts:482-514`), défauts inclus.
474
+ Tables dérivées du schéma Zod — `jwtSchema` (`config.ts#jwtSchema`) et `tokenStoreSchema`
475
+ (`config.ts#tokenStoreSchema`), défauts inclus.
470
476
 
471
477
  ### `jwt.*`
472
478
 
473
479
  <!-- prettier-ignore -->
474
480
  | Option | Type | Défaut | Effet |
475
481
  | --- | --- | --- | --- |
476
- | `enabled` | boolean | `true` | Active signature + refresh (`config.ts:375`) |
477
- | `alg` | `EdDSA` \| `RS256` | `EdDSA` | `RS256` = slot non câblé (`jwtRuntime.ts:21`) |
478
- | `accessTtlS` | number (s) | `900` | TTL de l'access token — 15 min (`config.ts:420`) |
479
- | `refreshTtlS` | number (s) | `604800` | TTL du refresh — 7 jours (`config.ts:425`) |
480
- | `rotateRefresh` | boolean | `true` | Rotation du refresh à chaque usage, OWASP (`config.ts:430`) |
482
+ | `enabled` | boolean | `true` | Active signature + refresh (`config.ts#rateLimitSchema`) |
483
+ | `alg` | `EdDSA` \| `RS256` | `EdDSA` | `RS256` = slot non câblé (`jwtRuntime.ts#IJwtRuntime.alg`) |
484
+ | `accessTtlS` | number (s) | `900` | TTL de l'access token — 15 min (`config.ts#jwtSchema`) |
485
+ | `refreshTtlS` | number (s) | `604800` | TTL du refresh — 7 jours (`config.ts#jwtSchema`) |
486
+ | `rotateRefresh` | boolean | `true` | Rotation du refresh à chaque usage, OWASP (`config.ts#jwtSchema`) |
481
487
  | `jwks` | boolean | `true` | Publie `/.well-known/jwks.json` + les métadonnées RFC 8414 — sans `issuer` en URL https, rien n'est publié |
482
- | `audiences` | string[] | `[]` | `aud` acceptées (RFC 8707) ; vide = `[issuer]` (`config.ts:446`) |
488
+ | `audiences` | string[] | `[]` | `aud` acceptées (RFC 8707) ; vide = `[issuer]` (`config.ts#jwtSchema`) |
483
489
  | `issuer` | string? | — | Claim `iss`, **STABLE** après émission ; omis → repli `"nodefony"`, qui n'est PAS publiable (RFC 8414 §2 exige une URL https) |
484
- | `keystore.keySetJson` | string? | — | JWK Set privé partagé par tous les process (`NF_JWT_KEYSET`) — SECRET, forme vérifiée au démarrage (`security/nodefony/config/config.ts:452`) |
485
- | `keystore.dir` | string? | — | Dossier `keyset.json` chmod 600, création exclusive — dev, ou serveur unique à disque persistant (`config.ts:434`) |
490
+ | `keystore.keySetJson` | string? | — | JWK Set privé partagé par tous les process (`NF_JWT_KEYSET`) — SECRET, forme vérifiée au démarrage (`security/nodefony/config/config.ts#jwtSchema`) |
491
+ | `keystore.dir` | string? | — | Dossier `keyset.json` chmod 600, création exclusive — dev, ou serveur unique à disque persistant (`config.ts#jwtSchema`) |
486
492
 
487
493
  ### `tokenStore.*`
488
494
 
489
- | Option | Type | Défaut | Effet |
490
- | ---------------------- | ---------- | -------- | ------------------------------------------------------------------------------------- |
491
- | `store` | string | `"auto"` | `auto`\|`memory`\|`drizzle`\|`mongoose`\|`redis` — pluggable (`config.ts:452-457`) |
492
- | `gcIntervalS` | number (s) | `600` | Purge périodique ; `0` = désactivé — chaque process purge SON store (`config.ts:501`) |
493
- | `gcJitter` | boolean | `true` | Étale le gc d'un délai aléatoire par process — cluster (`config.ts:509`) |
494
- | `retentionRevokedDays` | number (j) | `30` | Rétention d'un PAT révoqué SANS expiration avant purge (`config.ts:515`) |
495
+ | Option | Type | Défaut | Effet |
496
+ | ---------------------- | ---------- | -------- | -------------------------------------------------------------------------------------------------- |
497
+ | `store` | string | `"auto"` | `auto`\|`memory`\|`drizzle`\|`mongoose`\|`redis` — pluggable (`config.ts#jwtSchema`) |
498
+ | `gcIntervalS` | number (s) | `600` | Purge périodique ; `0` = désactivé — chaque process purge SON store (`config.ts#tokenStoreSchema`) |
499
+ | `gcJitter` | boolean | `true` | Étale le gc d'un délai aléatoire par process — cluster (`config.ts#tokenStoreSchema`) |
500
+ | `retentionRevokedDays` | number (j) | `30` | Rétention d'un PAT révoqué SANS expiration avant purge (`config.ts#tokenStoreSchema`) |
495
501
 
496
502
  ## 📜 Normes appliquées
497
503
 
498
- | Domaine | Norme | Ancrage |
499
- | -------------------------------- | --------------- | ------------------------------------------------------- |
500
- | Réponse d'émission | RFC 6749 §5.1 | `ITokenResponse` (`tokenService.ts:50-58`) |
501
- | Rotation + détection de rejeu | RFC 9700 §4.14 | `refresh()` (`tokenService.ts:647`) |
502
- | Profil access token `typ:at+jwt` | RFC 9068 | `#signAccess()` (`tokenService.ts:694-707`) |
503
- | Claims JWT (`iss/sub/aud/exp`) | RFC 7519 | `#signAccess()` (`tokenService.ts:731-739`) |
504
- | Ed25519 / JWK / JWKS public | RFC 8037 · 7517 | `#importKeyset()` (`JwtKeystore.ts:275`) |
505
- | Audiences liées à la ressource | RFC 8707 | `audience` du record (`ITokenStore.ts:104-105`) |
506
- | 429 + `Retry-After` | RFC 6585 | `#renderAuthError()` (`TokenAuthController.ts:111-118`) |
507
- | Backoff de login | NIST SP 800-63B | `ThrottledError` avant hachage (`tokenService.ts:433`) |
504
+ | Domaine | Norme | Ancrage |
505
+ | -------------------------------- | --------------- | ------------------------------------------------------------------------------------ |
506
+ | Réponse d'émission | RFC 6749 §5.1 | `ITokenResponse` (`tokenService.ts#ITokenResponse`) |
507
+ | Rotation + détection de rejeu | RFC 9700 §4.14 | `refresh()` (`tokenService.ts#TokenService.refresh`) |
508
+ | Profil access token `typ:at+jwt` | RFC 9068 | `#signAccess()` (`tokenService.ts#TokenService.#signAccess`) |
509
+ | Claims JWT (`iss/sub/aud/exp`) | RFC 7519 | `#signAccess()` (`tokenService.ts#TokenService.#signAccess`) |
510
+ | Ed25519 / JWK / JWKS public | RFC 8037 · 7517 | `#importKeyset()` (`JwtKeystore.ts#JwtKeystore.#importKeyset`) |
511
+ | Audiences liées à la ressource | RFC 8707 | `audience` du record (`ITokenStore.ts#IAccessTokenRecord.audience`) |
512
+ | 429 + `Retry-After` | RFC 6585 | `#renderAuthError()` (`TokenAuthController.ts#TokenAuthController.#renderAuthError`) |
513
+ | Backoff de login | NIST SP 800-63B | `ThrottledError` avant hachage (`tokenService.ts#TokenService.issueForCredentials`) |
508
514
 
509
515
  ## ⚡ Performance & mémoire
510
516
 
511
- - **`jose` importé lazy** (dep lourde) : `#ensureJose()` au premier usage (`tokenService.ts:826`)
517
+ - **`jose` importé lazy** (dep lourde) : `#ensureJose()` au premier usage (`tokenService.ts#TokenService.#ensureJose`)
512
518
  — le boot ne paie rien si le JWT n'est jamais sollicité ; keystore mémoïsé pareil.
513
519
  - **Rien sur le hot path requête** : émission et rotation sont des endpoints cold-path ; la
514
520
  vérification (hot path) vit chez le `JwtAuthenticator`.
515
521
  - **Timers civilisés** : `GcScheduler` `unref` (n'empêche pas l'arrêt) + jitter anti-balayages
516
- simultanés (`tokenService.ts:233-239`) ; denylist mémoire bornée par purge amortie
517
- (`MemoryTokenStore.ts:331-341`).
518
- - **Jamais N en RAM** : `listPage()` borne toute lecture admin à une page (`ITokenStore.ts:233`).
522
+ simultanés (`tokenService.ts#TokenService.#build`) ; denylist mémoire bornée par purge amortie
523
+ (`MemoryTokenStore.ts#MemoryTokenStore.restore`).
524
+ - **Jamais N en RAM** : `listPage()` borne toute lecture admin à une page (`ITokenStore.ts#ITokenStore.listPage`).
519
525
 
520
526
  ## 📡 Observabilité — Studio
521
527
 
522
528
  - **Écran Stores** (`/nodefony/stores`) : la résolution du store de jetons (configuré → résolu +
523
- raison) publiée par `registerStoreResolution()` (`tokenService.ts:195-204`).
529
+ raison) publiée par `registerStoreResolution()` (`tokenService.ts#TokenService.#build`).
524
530
  - **Écran Audit** (`/nodefony/audit`) : événements `token.issued`, `token.reuse_detected` (signal d'attaque),
525
531
  `login.failure`/`login.throttled` du grant — corrélables par `tokenId`.
526
532
  - **Écran ApiKeys** : le même store côté PAT, listing paginé serveur.