@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
@@ -28,7 +28,7 @@ export interface IWebAuthnCredentialSummary {
28
28
  /** Compteur de signatures (anti-clone WebAuthn §6.1.1). */
29
29
  readonly signCount: number;
30
30
  /** Nom donné à l'appareil par l'utilisateur, si renseigné. */
31
- readonly nickname?: string;
31
+ readonly nickname?: string | undefined;
32
32
  /** Enrôlement (epoch ms). */
33
33
  readonly createdAt: number;
34
34
  /** Dernière authentification réussie (epoch ms), ou `null` = jamais servie. */
@@ -23,7 +23,7 @@ export declare class InvalidTargetError extends nodefonyError {
23
23
  /** Code d'erreur OAuth à rendre au client (RFC 6749 §5.2 / RFC 8707 §2). */
24
24
  readonly oauthError = "invalid_target";
25
25
  /** Ce que le client a demandé, tel qu'il l'a écrit — pour le journal. */
26
- readonly requested?: string;
26
+ readonly requested?: string | undefined;
27
27
  /**
28
28
  * @param description - raison, destinée à `error_description` (aucune fuite :
29
29
  * elle qualifie la DEMANDE, jamais la configuration du serveur)
@@ -27,7 +27,7 @@ import { nodefonyError } from "nodefony";
27
27
  */
28
28
  export declare class UnverifiableTokenError extends nodefonyError {
29
29
  /** Cause technique, destinée au JOURNAL — jamais au client. */
30
- readonly detail?: string;
30
+ readonly detail?: string | undefined;
31
31
  /**
32
32
  * @param detail - cause technique pour le journal ; n'apparaît jamais dans le
33
33
  * message rendu au client
@@ -25,8 +25,9 @@ import type { IFirewallDescription, IRoleHierarchyDescription } from "../contrac
25
25
  * d'identité down, câblage manquant) → log ERROR serveur + 401 générique
26
26
  * (aucun détail ne fuite au client).
27
27
  *
28
- * Conformité : tout 401 porte un challenge `WWW-Authenticate` (RFC 7235) fourni
29
- * par le premier authenticator de la zone qui en déclare un.
28
+ * Conformité : tout 401 porte un challenge `WWW-Authenticate` (RFC 9110
29
+ * §15.5.2) — celui du premier authenticator de la zone qui en déclare un, sinon
30
+ * le défi de repli {@link FALLBACK_CHALLENGE}.
30
31
  *
31
32
  * CORS, CSRF et autorisation par décorateurs viennent se brancher en S4/S5.
32
33
  * Toutes les structures sont **lazy** (perf : une app sans zone = zéro alloc).
@@ -13,11 +13,11 @@ export interface ISecurityHeadersOptions {
13
13
  csp: string;
14
14
  cspNonces: boolean;
15
15
  referrerPolicy: string;
16
- coop?: string;
17
- coep?: string;
18
- corp?: string;
19
- originAgentCluster?: boolean;
20
- permissionsPolicy?: string;
16
+ coop?: string | undefined;
17
+ coep?: string | undefined;
18
+ corp?: string | undefined;
19
+ originAgentCluster?: boolean | undefined;
20
+ permissionsPolicy?: string | undefined;
21
21
  }
22
22
  /**
23
23
  * En-têtes de sécurité **applicatifs** de Nodefony (couche `@nodefony/security`,
@@ -15,9 +15,9 @@ export declare class SecuredArea implements ISecuredArea {
15
15
  readonly stateless: boolean;
16
16
  readonly mode: "first" | "all";
17
17
  readonly authenticators: readonly string[];
18
- readonly host?: string;
18
+ readonly host?: string | undefined;
19
19
  readonly realtime: boolean;
20
- readonly resource?: string;
20
+ readonly resource?: string | undefined;
21
21
  /**
22
22
  * Rôles exigés par défaut dans la zone, ou `null` quand elle n'en exige
23
23
  * aucun. `null` plutôt qu'un tableau vide : le hot path teste une référence,
@@ -14,9 +14,11 @@ import type { IToken } from "../../contracts/IToken.js";
14
14
  * démarre jamais rien lui-même. L'identité est re-résolue à CHAQUE requête
15
15
  * via {@link resolveSessionIdentity} (rôles frais, révocation immédiate).
16
16
  *
17
- * Pas de `challenge()` : une session absente/expirée donne un 401 nu — le
18
- * client web redirige vers son écran de login, jamais de popup Basic. Si la
19
- * zone liste aussi `userpassword`, le firewall pose SON challenge (RFC 7235).
17
+ * Pas de `challenge()` : sur une zone à session seule, le firewall pose son
18
+ * défi de repli `Session realm="nodefony"` (RFC 9110 §15.5.2 : tout 401 en
19
+ * porte un) — schéma inconnu des navigateurs, donc jamais de popup Basic ; le
20
+ * client web redirige vers son écran de login. Si la zone liste aussi
21
+ * `userpassword`, le firewall pose SON challenge.
20
22
  */
21
23
  export declare class SessionAuthenticator implements IAuthenticator {
22
24
  #private;
@@ -137,9 +137,9 @@ export interface IOAuth2ClientOptions {
137
137
  * Implémentation de `fetch` à employer — la voie pour éprouver l'échange sans
138
138
  * réseau, plutôt que de remplacer le `fetch` global du processus.
139
139
  */
140
- readonly fetch?: typeof globalThis.fetch;
140
+ readonly fetch?: typeof globalThis.fetch | undefined;
141
141
  /** Délai d'attente de l'échange, en millisecondes. */
142
- readonly timeoutMs?: number;
142
+ readonly timeoutMs?: number | undefined;
143
143
  }
144
144
  /**
145
145
  * Client d'un serveur d'autorisation donné : construit l'URL d'autorisation puis
@@ -33,7 +33,7 @@ export interface IOAuthProviderContext {
33
33
  * champ, la forme serait ouverte dans le client et INATTEIGNABLE depuis une
34
34
  * application — donc absente.
35
35
  */
36
- readonly clientAuthMethod?: OAuth2ClientAuthMethod;
36
+ readonly clientAuthMethod?: OAuth2ClientAuthMethod | undefined;
37
37
  /** URL de callback exacte (RFC 9700). */
38
38
  readonly redirectUri: string;
39
39
  /**
@@ -41,7 +41,7 @@ export interface IOAuthProviderContext {
41
41
  * `https://kc.example/realms/app`) — `undefined` pour les fournisseurs dont
42
42
  * l'émetteur est connu d'avance (Google) ou qui n'en publient pas (GitHub).
43
43
  */
44
- readonly issuer?: string;
44
+ readonly issuer?: string | undefined;
45
45
  }
46
46
  /**
47
47
  * Fabrique d'un fournisseur OAuth pour un nom donné.
@@ -83,11 +83,11 @@ export type FrameAuthorizer = (frame: unknown, token: IRealtimeToken) => boolean
83
83
  */
84
84
  export interface IChannelPolicy {
85
85
  /** Exige une connexion authentifiée (token non anonyme). */
86
- readonly authenticated?: boolean;
86
+ readonly authenticated?: boolean | undefined;
87
87
  /** Un de ces rôles suffit (évalué AVEC la hiérarchie de rôles du firewall). */
88
- readonly roles?: readonly string[];
88
+ readonly roles?: readonly string[] | undefined;
89
89
  /** Un de ces scopes suffit (axe API : JWT/clé API ; session BFF n'en porte pas). */
90
- readonly scopes?: readonly string[];
90
+ readonly scopes?: readonly string[] | undefined;
91
91
  }
92
92
  /**
93
93
  * Surface consommée du `realtimeService` (miroir partiel de `RealtimeService`) —
@@ -5,9 +5,9 @@ type LogFn = (message: string, severity: string) => void;
5
5
  /** Source de clé configurée (`config.jwt.keystore`). */
6
6
  interface KeystoreSource {
7
7
  /** JWK Set (clés privées) injecté depuis l'env — source `env` (prod). */
8
- readonly keySetJson?: string;
8
+ readonly keySetJson?: string | undefined;
9
9
  /** Dossier de persistance `keyset.json` — source `fichier` (opt-in dev/VPS). */
10
- readonly dir?: string;
10
+ readonly dir?: string | undefined;
11
11
  }
12
12
  /**
13
13
  * Le trousseau va-t-il partir dans l'image de conteneur ?
@@ -20,7 +20,7 @@ export interface ITrustedIssuer {
20
20
  * une requête au démarrage à froid. Déclaré, il fait autorité : rien n'est
21
21
  * découvert.
22
22
  */
23
- jwksUri?: string;
23
+ jwksUri?: string | undefined;
24
24
  /**
25
25
  * Jeu de clés fourni LOCALEMENT — aucune requête, aucune découverte.
26
26
  *
@@ -58,7 +58,7 @@ export interface ITrustedIssuer {
58
58
  * réglé finement. La séparation entre jetons est déjà assurée par l'audience,
59
59
  * qui, elle, n'est pas facultative.
60
60
  */
61
- typ?: string;
61
+ typ?: string | undefined;
62
62
  /** Claims dont la PRÉSENCE est exigée, en plus de `iss`/`aud`/`sub`. */
63
63
  requiredClaims?: readonly string[];
64
64
  }
@@ -26,3 +26,13 @@ export interface IJwtRuntime {
26
26
  * propre audience (`[issuer]`).
27
27
  */
28
28
  export declare function resolveJwtRuntime(jwt: ISecurityConfig["jwt"]): IJwtRuntime;
29
+ /**
30
+ * Audience inscrite dans un jeton quand aucune ressource n'est demandée : la
31
+ * première de la liste blanche. La liste n'est jamais vide après
32
+ * {@link resolveJwtRuntime} ; un runtime construit à la main qui la laisserait
33
+ * vide retombe sur la même règle — l'application est sa propre audience.
34
+ *
35
+ * @param runtime - paramètres JWT résolus
36
+ * @returns l'audience par défaut
37
+ */
38
+ export declare function defaultAudience(runtime: IJwtRuntime): string;
@@ -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
  /**
package/docs/api-keys.md CHANGED
@@ -115,7 +115,7 @@ qu'il arrive côté serveur — révoquer exige d'ajouter… un état serveur (d
115
115
  lecture qu'on voulait éviter. Une clé d'API vit **des mois** : un jeton auto-porté de six mois qui
116
116
  fuite est une porte ouverte de six mois. Le PAT inverse le compromis : sa vérité est dans le store,
117
117
  donc `revokedAt` posé = accès coupé à la requête suivante, sans attendre aucune expiration
118
- (`ApiKeyAuthenticator.authenticate()`, `ApiKeyAuthenticator.ts:107-114`).
118
+ (`ApiKeyAuthenticator.authenticate()`, `ApiKeyAuthenticator.ts:97-104`).
119
119
 
120
120
  **Ce que Nodefony fait pour que ça reste bon marché** — trois décisions ancrées au code :
121
121
 
@@ -193,7 +193,7 @@ export default OrdersController;
193
193
  ### 3. Émettre la clé — par l'API, avec une session
194
194
 
195
195
  L'émission est un **endpoint fourni**, monté seulement si le service `apiKeys` existe
196
- (`mountApiKeyRoutes()`, `ApiKeyController.ts:191`). Il n'y a **pas** de commande CLI pour créer une
196
+ (`mountApiKeyRoutes()`, `ApiKeyController.ts:192`). Il n'y a **pas** de commande CLI pour créer une
197
197
  clé : la création exige une **session BFF** (les routes ne sont pas `bypassFirewall` —
198
198
  `ApiKeyController.ts:50-54`), parce que le porteur est **toujours** l'utilisateur courant, jamais un
199
199
  paramètre.
@@ -228,8 +228,8 @@ curl -si https://localhost:5152/api/v1/orders/list | head -1
228
228
 
229
229
  > [!WARNING]
230
230
  > Le champ `token` de la réponse 201 est la **seule** occasion de lire le secret : il n'est pas
231
- > stocké, donc pas re-dérivable (`IApiKeyCreated`, `IApiKey.ts:36`). Le listing ultérieur ne
232
- > renvoie que le `prefix` public (`#toView()`, `apiKeys.ts:393`). Perdue = ré-émise.
231
+ > stocké, donc pas re-dérivable (`IApiKeyCreated`, `IApiKey.ts:44`). Le listing ultérieur ne
232
+ > renvoie que le `prefix` public (`#toView()`, `apiKeys.ts:386`). Perdue = ré-émise.
233
233
 
234
234
  ## 🔐 Anatomie d'une clé — ce que chaque morceau paie
235
235
 
@@ -239,7 +239,7 @@ raison est dans le code (`apiKeyFormat.ts:8-10`) : le charset base64url contient
239
239
 
240
240
  | Morceau | Taille | Secret ? | À quoi ça sert |
241
241
  | -------- | -------------------- | :------: | -------------------------------------------------------------------------------------------------- |
242
- | `prefix` | ≤ 12 car. minuscules | non | Marque applicative — discrimine du JWT, aide le secret-scanning (`config.ts:730`) |
242
+ | `prefix` | ≤ 12 car. minuscules | non | Marque applicative — discrimine du JWT, aide le secret-scanning (`config.ts:736`) |
243
243
  | `pubid` | 6 octets → 8 car. | non | Identifiant affichable dans la console (`nf_a1b2c3d4`) — `generateApiKey()` (`apiKeyFormat.ts:92`) |
244
244
  | `secret` | 32 octets → 43 car. | **oui** | 256 bits d'entropie — `SECRET_BYTES` (`apiKeyFormat.ts:30`) |
245
245
  | `crc` | 4 octets → 6 car. | non | CRC32 du `prefix_pubid+secret` — `crcChunk()` (`apiKeyFormat.ts:63`) |
@@ -268,7 +268,7 @@ dépendance ni d'une variation de version Node (`crc32()`, `apiKeyFormat.ts:53`)
268
268
 
269
269
  Avant même de parser, le firewall doit savoir **quel** authenticator prend la main. C'est
270
270
  `looksLikeApiKey()` (`apiKeyFormat.ts:117`) : un simple `startsWith("<prefix>_")`, appelé par
271
- `ApiKeyAuthenticator.supports()` (`ApiKeyAuthenticator.ts:68`). C'est ce qui rend la cohabitation
271
+ `ApiKeyAuthenticator.supports()` (`ApiKeyAuthenticator.ts:75`). C'est ce qui rend la cohabitation
272
272
  `["apikey", "jwt"]` possible dans une même zone — un JWT a la structure `a.b.c`, il ne commence
273
273
  jamais par le préfixe.
274
274
 
@@ -276,24 +276,24 @@ jamais par le préfixe.
276
276
 
277
277
  ### Émission — `ApiKeyService.createForSubject()`
278
278
 
279
- `ApiKeyService.createForSubject()` (`apiKeys.ts:128`) est le seul chemin d'émission. Dans l'ordre :
279
+ `ApiKeyService.createForSubject()` (`apiKeys.ts:121`) est le seul chemin d'émission. Dans l'ordre :
280
280
 
281
281
  1. **Validation du nom** — non vide, ≤ 100 caractères ; sinon `ApiKeyError` 400 (`#normalizeName()`,
282
- `apiKeys.ts:337`).
282
+ `apiKeys.ts:330`).
283
283
  2. **Validation des scopes** — tableau de chaînes non vides, dédupliquées, et **⊆ catalogue** si
284
- `allowedScopes` est défini ; sinon 400 (`#normalizeScopes()`, `apiKeys.ts:292`).
284
+ `allowedScopes` est défini ; sinon 400 (`#normalizeScopes()`, `apiKeys.ts:341`).
285
285
  3. **Résolution de l'expiration** — `expiresInDays` explicite, sinon le défaut de config, `null` =
286
- sans expiration ; une valeur non positive lève un 400 (`#resolveExpiry()`, `apiKeys.ts:370`).
286
+ sans expiration ; une valeur non positive lève un 400 (`#resolveExpiry()`, `apiKeys.ts:363`).
287
287
  4. **Plafond anti-abus** — on ne compte que les clés **actives** (ni révoquées ni expirées) via
288
- `#isActive()` (`apiKeys.ts:386`) ; au-delà de `maxPerSubject` → 409 (`apiKeys.ts:113`).
288
+ `#isActive()` (`apiKeys.ts:379`) ; au-delà de `maxPerSubject` → 409 (`apiKeys.ts:48`).
289
289
  5. **Génération** — 32 octets aléatoires, `publicPrefix` et `secretHash` dérivés
290
290
  (`generateApiKey()`, `apiKeyFormat.ts:92`).
291
- 6. **Écriture** — le `record` de `kind:"pat"` posé au store par `store.put()` (`apiKeys.ts:147`).
291
+ 6. **Écriture** — le `record` de `kind:"pat"` posé au store par `store.put()` (`apiKeys.ts:177`).
292
292
  7. **Audit** — `apikey.created`, catégorie `token`, avec l'**id public** et les scopes, **jamais le
293
- secret** (`apiKeys.ts:153`).
293
+ secret** (`apiKeys.ts:183-189`).
294
294
 
295
295
  Le service ne connaît pas le store à la construction : il le résout **paresseusement** du container
296
- au premier usage (`#resolveStore()`, `apiKeys.ts:268`) — indépendant de l'ordre de boot. Store
296
+ au premier usage (`#resolveStore()`, `apiKeys.ts:317`) — indépendant de l'ordre de boot. Store
297
297
  absent = **503 explicite**, jamais une 500 opaque.
298
298
 
299
299
  ### Vérification — `ApiKeyAuthenticator.authenticate()`
@@ -304,12 +304,12 @@ filtre qui coûte plus cher que la précédente :
304
304
  | # | Contrôle | Coût | Ancrage |
305
305
  | --- | --------------------------------------- | --------------- | ----------------------------------------------------- |
306
306
  | 1 | Bearer présent + préfixe | regex | `readBearerHeader()` (`runtime/bearer.ts:68`) |
307
- | 2 | Longueur, charset, **CRC** | CPU local | `parseApiKey` (`ApiKeyAuthenticator.ts:99`) |
308
- | 3 | Lookup par `secretHash` | 1 lecture store | `findByHash` (`ApiKeyAuthenticator.ts:105`) |
309
- | 4 | `kind:"pat"`, non révoquée, non expirée | en mémoire | `ApiKeyAuthenticator.ts:107-114` |
310
- | 5 | Porteur banni ? (`invalidBefore`) | 1 lecture store | `getInvalidBefore` (`ApiKeyAuthenticator.ts:117`) |
307
+ | 2 | Longueur, charset, **CRC** | CPU local | `parseApiKey` (`ApiKeyAuthenticator.ts:103`) |
308
+ | 3 | Lookup par `secretHash` | 1 lecture store | `findByHash` (`ApiKeyAuthenticator.ts:109`) |
309
+ | 4 | `kind:"pat"`, non révoquée, non expirée | en mémoire | `ApiKeyAuthenticator.ts:111-118` |
310
+ | 5 | Porteur banni ? (`invalidBefore`) | 1 lecture store | `getInvalidBefore` (`ApiKeyAuthenticator.ts:121`) |
311
311
  | 6 | Compte actif et non verrouillé | 1 lecture user | `#resolveUserOrReject` (`ApiKeyAuthenticator.ts:209`) |
312
- | 7 | `lastUsedAt` (throttlé) | 0 ou 1 écriture | `markUsed` (`ApiKeyAuthenticator.ts:133`) |
312
+ | 7 | `lastUsedAt` (throttlé) | 0 ou 1 écriture | `markUsed` (`ApiKeyAuthenticator.ts:187`) |
313
313
 
314
314
  Deux points méritent d'être soulignés parce qu'ils décident du niveau de sécurité réel :
315
315
 
@@ -324,13 +324,13 @@ Deux points méritent d'être soulignés parce qu'ils décident du niveau de sé
324
324
 
325
325
  En cas de succès, le jeton est promu et porte trois attributs consommés en aval : `scopes`,
326
326
  `apiKeyId` et `tenantId` (`ApiKeyAuthenticator.ts:138-140`). Le challenge renvoyé sur un 401 de la
327
- zone est un simple `Bearer` (`challenge()`, `ApiKeyAuthenticator.ts:155`).
327
+ zone est un simple `Bearer` (`challenge()`, `ApiKeyAuthenticator.ts:205`).
328
328
 
329
329
  ### Câblage — d'où viennent le préfixe et le throttle
330
330
 
331
331
  L'authenticator n'est jamais instancié à la main : le firewall le construit depuis le registre, en
332
332
  lui injectant la config effective — `registerAuthenticatorFactory("apikey")`
333
- (`authenticatorRegistry.ts:117`), qui lit `prefix` et `lastUsedThrottleS`
333
+ (`authenticatorRegistry.ts:137`), qui lit `prefix` et `lastUsedThrottleS`
334
334
  (`authenticatorRegistry.ts:143`). Conséquence pratique : changer `apiKeys.prefix` change **à la
335
335
  fois** l'émission et la reconnaissance — les anciennes clés ne sont plus reconnues.
336
336
 
@@ -353,13 +353,13 @@ apiKeys: {
353
353
  **Ce qu'on observe** : `npx nodefony security:user:add ci-bot` (rôle `ROLE_USER` par défaut), login
354
354
  en tant que `ci-bot`, puis émission avec `{"scopes":["orders:read"]}`. Demander
355
355
  `{"scopes":["orders:write"]}` renvoie **400 `scope not allowed: orders:write`** — refusé à
356
- l'émission, pas seulement à l'usage (`#normalizeScopes()`, `apiKeys.ts:292`).
356
+ l'émission, pas seulement à l'usage (`#normalizeScopes()`, `apiKeys.ts:341`).
357
357
 
358
358
  > [!TIP]
359
359
  > Le catalogue `allowedScopes` de la config est un **complément**, pas la source : la console
360
360
  > propose aussi les scopes **découverts sur tes routes** (`@RequireScope`) par
361
361
  > `collectDeclaredApiScopes()` (`scopeCatalog.ts:29`), agrégés dans l'endpoint `capabilities`
362
- > (`ApiKeyController.ts:96`). Un formulaire de création qui ne ment pas.
362
+ > (`ApiKeyController.ts:97`). Un formulaire de création qui ne ment pas.
363
363
 
364
364
  ### Faire tourner une clé sans coupure de service
365
365
 
@@ -368,7 +368,7 @@ doit y avoir **aucune** fenêtre pendant laquelle le job échoue.
368
368
 
369
369
  **Il n'y a pas de bouton « rotate »** — et c'est délibéré : une rotation atomique impliquerait
370
370
  soit deux secrets valides sous le même id (ambigu à auditer), soit une coupure. Le motif est le
371
- **recouvrement**, rendu possible par le plafond `maxPerSubject` (`apiKeys.ts:113`) :
371
+ **recouvrement**, rendu possible par le plafond `maxPerSubject` (`apiKeys.ts:48`) :
372
372
 
373
373
  1. Émettre une **seconde** clé (même porteur, mêmes scopes, nom `CI deploy v2`).
374
374
  2. Déployer le nouveau secret dans le CI.
@@ -376,7 +376,7 @@ soit deux secrets valides sous le même id (ambigu à auditer), soit une coupure
376
376
  4. **Puis** révoquer la v1.
377
377
 
378
378
  Ce qui rend l'étape 3 fiable : `lastUsedAt` est écrit de façon **throttlée**, pas à chaque requête —
379
- la fenêtre par défaut est de 60 s (`lastUsedThrottleS`, `config.ts:746`). Attends donc une minute
379
+ la fenêtre par défaut est de 60 s (`lastUsedThrottleS`, `config.ts:752`). Attends donc une minute
380
380
  avant de conclure qu'une clé « ne sert plus ».
381
381
 
382
382
  ### Révoquer une clé qui a fuité
@@ -388,25 +388,25 @@ Deux chemins, selon qui agit :
388
388
 
389
389
  | Qui | Endpoint | Portée | Ancrage |
390
390
  | ---------- | ------------------------------------------------- | ---------------------- | --------------------------------------- |
391
- | Le porteur | `DELETE /nodefony/security/api/keys/{id}` | **ses** clés seulement | `revokeForSubject()` (`apiKeys.ts:247`) |
392
- | Un admin | `POST /nodefony/security/api/apikeys/{id}/revoke` | n'importe quelle clé | `revokeAnyPat()` (`apiKeys.ts:201`) |
391
+ | Le porteur | `DELETE /nodefony/security/api/keys/{id}` | **ses** clés seulement | `revokeForSubject()` (`apiKeys.ts:296`) |
392
+ | Un admin | `POST /nodefony/security/api/apikeys/{id}/revoke` | n'importe quelle clé | `revokeAnyPat()` (`apiKeys.ts:250`) |
393
393
 
394
394
  **Ce qu'on observe** : la révocation est **idempotente** et prend effet à la requête suivante —
395
- l'authenticator lit `revokedAt` avant toute autre décision (`ApiKeyAuthenticator.ts:107-114`).
395
+ l'authenticator lit `revokedAt` avant toute autre décision (`ApiKeyAuthenticator.ts:111-118`).
396
396
  Le banc d'intégration le prouve bout en bout : 200 avant, 401 après
397
- (`apikey-flow.test.ts:149-157`).
397
+ (`apikey-flow.test.ts:182-191`).
398
398
 
399
399
  Une propriété de sécurité facile à manquer : si la clé n'existe pas **ou** appartient à quelqu'un
400
400
  d'autre, le porteur reçoit un **404 indiscernable**, jamais un 403 (`ApiKeyController.ts:139-142`).
401
401
  Un 403 dirait « cette clé existe, mais pas à toi » — assez pour énumérer les identifiants des
402
- autres. Le banc couvre explicitement cet IDOR (`apikey-flow.test.ts:176`).
402
+ autres. Le banc couvre explicitement cet IDOR (`apikey-flow.test.ts:209`).
403
403
 
404
404
  > [!WARNING]
405
405
  > Si tu dois couper **toutes** les clés d'un porteur d'un coup (compte compromis, départ), ne les
406
406
  > révoque pas une par une : pose le seuil `invalidBefore` du porteur
407
407
  > (`revokeAllForSubject`, `ITokenStore.ts:261`). L'authenticator rejette alors toute clé créée avant
408
408
  > ce seuil : `getInvalidBefore` est comparé au `createdAt` du record
409
- > (`ApiKeyAuthenticator.ts:117-120`) — y compris pour les clés que tu aurais oubliées.
409
+ > (`ApiKeyAuthenticator.ts:121-124`) — y compris pour les clés que tu aurais oubliées.
410
410
 
411
411
  ### Auditer qui a utilisé quoi
412
412
 
@@ -417,9 +417,9 @@ Trois sources, et il faut connaître les limites de chacune :
417
417
 
418
418
  1. **L'état** — le listing d'administration paginé, tous porteurs confondus : `GET
419
419
  /nodefony/security/api/apikeys` (`SecurityAdminApi.ts:394`), servi par `listPagePat()`
420
- (`apiKeys.ts:208`). Filtres `subjectId`, `revoked`, fenêtre `limit`/`offset`/`cursor` et tri
420
+ (`apiKeys.ts:209`). Filtres `subjectId`, `revoked`, fenêtre `limit`/`offset`/`cursor` et tri
421
421
  `order=champ:ASC` (`parseTokenListQuery()`, `SecurityAdminApi.ts:126`), plafonnée à 200 entrées
422
- (`KEYS_MAX_LIMIT`, `SecurityAdminApi.ts:109`).
422
+ (`KEYS_MAX_LIMIT`, `SecurityAdminApi.ts:114`).
423
423
 
424
424
  Le tri n'est accepté que sur les champs que le backend branché **déclare** savoir trier
425
425
  (`sortableFields()`, `apiKeys.ts:102` → `ITokenStore.sortableFields`) : `createdAt`, `name`,
@@ -430,30 +430,33 @@ Trois sources, et il faut connaître les limites de chacune :
430
430
  du vocabulaire : le placement des valeurs absentes diffère d'un moteur à l'autre, et un tri dont
431
431
  l'ordre dépend de la base configurée ne vaut pas mieux qu'un tri absent.
432
432
 
433
- 2. **Le journal** — les événements d'audit `apikey.created` (`apiKeys.ts:153`) et `apikey.revoked`
434
- (`apiKeys.ts:212` côté admin, `apiKeys.ts:255` côté porteur), catégorie `token`. La révocation
433
+ 2. **Le journal** — les événements d'audit `apikey.created` (`apiKeys.ts:183-189`) et `apikey.revoked`
434
+ (`apiKeys.ts:268-271` côté admin, `apiKeys.ts:311-313` côté porteur), catégorie `token`. La révocation
435
435
  admin trace **l'acteur ET le porteur cible** — voir [audit](./audit.md).
436
- 3. **Le dernier usage** — `lastUsedAt` sur chaque clé.
436
+ 3. **Le dernier usage** — `lastUsedAt`, avec l'IP et l'agent de ce dernier appel
437
+ (`lastUsedIp`, `lastUsedUserAgent`, `ITokenStore.ts:135`), écrits ensemble par `markUsed`
438
+ (`ApiKeyAuthenticator.ts:187`). L'IP est lue par l'accesseur du contexte, qui tient compte des
439
+ proxys de confiance ; si elle ne peut pas être lue, le champ reste vide sans faire échouer
440
+ l'authentification.
437
441
 
438
- Ce que tu **n'auras pas** : un journal par requête. `markUsed` est appelé avec le seul horodatage
439
- (`ApiKeyAuthenticator.ts:133`) ; les champs `lastUsedIp` et `lastUsedUserAgent` du record
440
- (`ITokenStore.ts:135`) restent donc à `null` — ce sont des **emplacements réservés**, pas des
441
- données remplies. Pour de la traçabilité par appel, c'est le journal d'audit applicatif qu'il faut
442
- alimenter, pas le store de jetons.
442
+ Ce que tu **n'auras pas** : un journal par requête. Le dernier usage est **écrasé** à chaque
443
+ écriture, et cette écriture est throttlée (`lastUsedThrottleS`) : entre deux écritures, les appels
444
+ ne laissent aucune trace dans le store. Pour de la traçabilité par appel, c'est le journal d'audit
445
+ applicatif qu'il faut alimenter, pas le store de jetons.
443
446
 
444
447
  ## ⚙️ Configuration
445
448
 
446
- Table dérivée du schéma Zod `apiKeysSchema` (`config.ts:727`), branché à la racine de la config du
447
- module (`config.ts:727`). Toutes les valeurs ci-dessous sont les **défauts réels**.
449
+ Table dérivée du schéma Zod `apiKeysSchema` (`config.ts:733`), branché à la racine de la config du
450
+ module (`config.ts:733`). Toutes les valeurs ci-dessous sont les **défauts réels**.
448
451
 
449
452
  | Option | Type | Défaut | Effet |
450
453
  | ------------------- | ---------------- | ------ | -------------------------------------------------------------------------------------- |
451
- | `enabled` | boolean | `true` | Coupe l'émission ET le listing (l'authenticator reste déclarable) (`config.ts:533`) |
452
- | `prefix` | string ≤ 12 | `"nf"` | Marque des clés ; minuscules/chiffres — discrimine du JWT (`config.ts:730`) |
453
- | `defaultExpiryDays` | number \| null | `90` | Expiration appliquée si l'appelant n'en donne pas ; `null` = jamais (`config.ts:739`) |
454
- | `lastUsedThrottleS` | number (s) | `60` | Coalescence d'écriture de `lastUsedAt` ; `0` = à chaque usage (`config.ts:746`) |
455
- | `maxPerSubject` | number > 0 | `100` | Plafond de clés **actives** par porteur ; au-delà → 409 (`config.ts:755`) |
456
- | `allowedScopes` | string[] \| null | `null` | Catalogue fermé à la création ; `null` = tout scope non vide accepté (`config.ts:764`) |
454
+ | `enabled` | boolean | `true` | Coupe l'émission ET le listing (l'authenticator reste déclarable) (`config.ts:735`) |
455
+ | `prefix` | string ≤ 12 | `"nf"` | Marque des clés ; minuscules/chiffres — discrimine du JWT (`config.ts:736`) |
456
+ | `defaultExpiryDays` | number \| null | `90` | Expiration appliquée si l'appelant n'en donne pas ; `null` = jamais (`config.ts:745`) |
457
+ | `lastUsedThrottleS` | number (s) | `60` | Coalescence d'écriture de `lastUsedAt` ; `0` = à chaque usage (`config.ts:752`) |
458
+ | `maxPerSubject` | number > 0 | `100` | Plafond de clés **actives** par porteur ; au-delà → 409 (`config.ts:761`) |
459
+ | `allowedScopes` | string[] \| null | `null` | Catalogue fermé à la création ; `null` = tout scope non vide accepté (`config.ts:770`) |
457
460
 
458
461
  Deux réglages méritent une décision consciente :
459
462
 
@@ -468,15 +471,15 @@ Deux réglages méritent une décision consciente :
468
471
  ### Les endpoints — deux portées, jamais mélangées
469
472
 
470
473
  **Console « mes clés »** (le porteur gère les siennes) — montées par `mountApiKeyRoutes()`
471
- (`ApiKeyController.ts:191`) **seulement si** le service `apiKeys` existe (`framework/index.ts:468`) ;
474
+ (`ApiKeyController.ts:192`) **seulement si** le service `apiKeys` existe (`framework/index.ts:468`) ;
472
475
  sinon 404, zéro surface. Aucune n'est `bypassFirewall` : la zone data plane exige la session BFF.
473
476
 
474
477
  | Méthode | Chemin | Rôle | Ancrage |
475
478
  | -------- | ------------------------------------------ | ------------------------------------------------- | ------------------------------------------- |
476
- | `POST` | `/nodefony/security/api/keys` | Émission → **201** + `token` clair (1×) | `create()` (`ApiKeyController.ts:65`) |
477
- | `GET` | `/nodefony/security/api/keys` | Mes clés, sans secret | `list()` (`ApiKeyController.ts:113`) |
478
- | `GET` | `/nodefony/security/api/keys/capabilities` | Plafond, scopes proposés, préfixe, durée | `capabilities()` (`ApiKeyController.ts:96`) |
479
- | `DELETE` | `/nodefony/security/api/keys/{id}` | Révoque **ma** clé ; 404 sinon (anti-énumération) | `revoke()` (`ApiKeyController.ts:126`) |
479
+ | `POST` | `/nodefony/security/api/keys` | Émission → **201** + `token` clair (1×) | `create()` (`ApiKeyController.ts:66`) |
480
+ | `GET` | `/nodefony/security/api/keys` | Mes clés, sans secret | `list()` (`ApiKeyController.ts:114`) |
481
+ | `GET` | `/nodefony/security/api/keys/capabilities` | Plafond, scopes proposés, préfixe, durée | `capabilities()` (`ApiKeyController.ts:97`) |
482
+ | `DELETE` | `/nodefony/security/api/keys/{id}` | Révoque **ma** clé ; 404 sinon (anti-énumération) | `revoke()` (`ApiKeyController.ts:127`) |
480
483
 
481
484
  **Administration** (gouvernance, réponse à incident) — data plane `SecurityAdminApi`, RBAC
482
485
  `ROLE_NODEFONY_ADMIN` :
@@ -490,7 +493,7 @@ sinon 404, zéro surface. Aucune n'est `bypassFirewall` : la zone data plane exi
490
493
  Les deux espaces de chemins sont **disjoints** (`keys` vs `apikeys`) — aucune collision, et une
491
494
  console d'admin ne peut pas atterrir par erreur sur l'endpoint personnel.
492
495
 
493
- Codes d'erreur mappés par duck-typing sur `code` (`#renderApiKeyError()`, `ApiKeyController.ts:164`) :
496
+ Codes d'erreur mappés par duck-typing sur `code` (`#renderApiKeyError()`, `ApiKeyController.ts:165`) :
494
497
  **400** validation (nom, scope, durée), **409** plafond atteint, **503** clés indisponibles (store
495
498
  absent ou `enabled:false`).
496
499
 
@@ -514,8 +517,8 @@ import type {
514
517
  ```
515
518
 
516
519
  Les contrats vivent dans `IApiKey.ts` : `IApiKeyView` (`IApiKey.ts:6`), `IApiKeyCreated`
517
- (`IApiKey.ts:36`), `IApiKeyCapabilities` (`IApiKey.ts:47`), `ICreateApiKeyOptions`
518
- (`IApiKey.ts:61`). Les signatures détaillées vivent dans le graphe TSDoc (`.ai/symbols.json`) —
520
+ (`IApiKey.ts:44`), `IApiKeyCapabilities` (`IApiKey.ts:55`), `ICreateApiKeyOptions`
521
+ (`IApiKey.ts:69`). Les signatures détaillées vivent dans le graphe TSDoc (`.ai/symbols.json`) —
519
522
  cette page explique l'usage, elle ne recopie pas les prototypes.
520
523
 
521
524
  ## 🧑‍⚖️ Scopes — ce que la clé a le droit de faire
@@ -588,12 +591,12 @@ révocation ne traverse pas. Le détail de la résolution, des avertissements et
588
591
  | Schéma `Bearer` (transport) | RFC 6750 §2.1 | `readBearerHeader()` (`runtime/bearer.ts:68`) |
589
592
  | `invalid_token` → 401 + challenge | RFC 6750 §3.1 · RFC 7235 | `challenge()` (`ApiKeyAuthenticator.ts:205`) |
590
593
  | Secret **jamais** stocké en clair | OWASP ASVS (secret storage) | `hashApiKey()` (`apiKeyFormat.ts:70`) |
591
- | Secret montré une seule fois | Pratique « shown once » | `IApiKeyCreated.token` (`IApiKey.ts:37`) |
594
+ | Secret montré une seule fois | Pratique « shown once » | `IApiKeyCreated.token` (`IApiKey.ts:46`) |
592
595
  | Anti-énumération des ressources | OWASP API1:2023 (BOLA/IDOR) | 404 indiscernable (`ApiKeyController.ts:139-142`) |
593
596
  | Message d'échec uniforme | OWASP API2:2023 (Broken Auth) | `INVALID_TOKEN` (`ApiKeyAuthenticator.ts:17`) |
594
- | Révocation immédiate côté serveur | OWASP API2:2023 | `revokedAt` vérifié (`ApiKeyAuthenticator.ts:107-114`) |
597
+ | Révocation immédiate côté serveur | OWASP API2:2023 | `revokedAt` vérifié (`ApiKeyAuthenticator.ts:111-118`) |
595
598
  | Entropie du secret (≥ 128 bits) | NIST SP 800-63B | 32 octets aléatoires (`apiKeyFormat.ts:30`) |
596
- | Plafond de ressources par acteur | OWASP API4:2023 (Resource Consumption) | `maxPerSubject` (`apiKeys.ts:113`) |
599
+ | Plafond de ressources par acteur | OWASP API4:2023 (Resource Consumption) | `maxPerSubject` (`apiKeys.ts:48`) |
597
600
 
598
601
  ## ⚡ Performance & mémoire
599
602
 
@@ -611,7 +614,7 @@ Le coût par requête authentifiée par clé est **maîtrisé par construction**
611
614
  container au premier usage et mémoïsés (`ApiKeyAuthenticator.ts:173`, `apiKeys.ts:268`) — le boot
612
615
  ne paie rien si aucune clé n'est jamais présentée.
613
616
  - **Jamais N enregistrements en RAM** côté administration : le listing est paginé **au store**
614
- (`listPagePat()`, `apiKeys.ts:216`), fenêtre plafonnée à 200 (`SecurityAdminApi.ts:107`).
617
+ (`listPagePat()`, `apiKeys.ts:209`), fenêtre plafonnée à 200 (`SecurityAdminApi.ts:107`).
615
618
 
616
619
  Le point de vigilance restant : `createForSubject()` compte les clés actives via `findBySubject()`
617
620
  (`ITokenStore.ts:211`), qui charge **toutes** les clés du porteur. C'est borné par `maxPerSubject`
@@ -645,14 +648,13 @@ pas masqué à l'affichage. Voir aussi l'écran **Audit** pour les événements
645
648
  | 503 « API keys unavailable » | Store non provisionné (`TokenService` absent/désactivé) (`apiKeys.ts:330`) | Vérifier `jwt`/`tokenStore` — le `TokenService` pose le store |
646
649
  | 401 à la création de clé | Ces routes exigent une **session** (pas de `bypassFirewall`) | Se connecter d'abord (`/nodefony/security/api/auth/login`) |
647
650
  | Le token clair est introuvable après coup | Seul `sha256` est stocké — non re-dérivable (`apiKeyFormat.ts:70`) | Émettre une nouvelle clé, révoquer l'ancienne |
648
- | 409 « API key limit reached » | Plafond de clés **actives** atteint (`apiKeys.ts:113`) | Révoquer les clés inutilisées ou relever `maxPerSubject` |
649
- | 400 « scope not allowed » | Scope hors du catalogue `allowedScopes` (`apiKeys.ts:292`) | Ajouter le scope au catalogue, ou corriger la demande |
651
+ | 409 « API key limit reached » | Plafond de clés **actives** atteint (`apiKeys.ts:144-147`) | Révoquer les clés inutilisées ou relever `maxPerSubject` |
652
+ | 400 « scope not allowed » | Scope hors du catalogue `allowedScopes` (`apiKeys.ts:284`) | Ajouter le scope au catalogue, ou corriger la demande |
650
653
  | Toutes les clés rejetées après un changement de config | `prefix` modifié → les anciennes ne sont plus reconnues (`authenticatorRegistry.ts:142`) | Garder le `prefix` STABLE après la première émission |
651
654
  | Clé valide mais 403 sur la route | Autorisation, pas authentification : scope manquant — `ScopeVoter.vote()` (`ScopeVoter.ts:50`) | Émettre une clé portant le scope exigé par `@RequireScope` |
652
- | Clé rejetée alors qu'elle n'est ni expirée ni révoquée | Porteur désactivé/verrouillé, ou seuil `invalidBefore` (`ApiKeyAuthenticator.ts:117-120`) | Réactiver le compte, ou réémettre après le bannissement |
655
+ | Clé rejetée alors qu'elle n'est ni expirée ni révoquée | Porteur désactivé/verrouillé, ou seuil `invalidBefore` (`ApiKeyAuthenticator.ts:121-124`) | Réactiver le compte, ou réémettre après le bannissement |
653
656
  | 404 en révoquant la clé d'un autre porteur | Anti-énumération volontaire, jamais 403 (`ApiKeyController.ts:139-142`) | Attendu — passer par l'endpoint d'administration |
654
657
  | `lastUsedAt` qui ne bouge pas tout de suite | Écriture throttlée, 60 s par défaut (`ApiKeyAuthenticator.ts:127-134`) | Attendre la fenêtre, ou `lastUsedThrottleS: 0` (coût : 1 écriture/req) |
655
- | `lastUsedIp` / `lastUsedUserAgent` toujours vides | `markUsed` n'envoie que l'horodatage (`ApiKeyAuthenticator.ts:133`) | Emplacements réservés — tracer par le journal d'audit applicatif |
656
658
  | Révocation sans effet entre pods | Store `memory` en production (per-pod) | Store durable partagé — voir [tokens](./tokens.md) |
657
659
  | Listing d'admin sans `total` sur Redis | Comptage exact refusé (O(N)) — pagination par curseur | Attendu : capacité réduite annoncée, paginer par `nextCursor` |
658
660