@nodefony/security 10.0.0-alpha.9 → 10.0.0-beta.2

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 (74) hide show
  1. package/README.md +7 -5
  2. package/dist/_virtual/{_@oxc-project_runtime@0.151.0 → _@oxc-project_runtime@0.152.0}/helpers/esm/decorate.js +1 -1
  3. package/dist/_virtual/{_@oxc-project_runtime@0.151.0 → _@oxc-project_runtime@0.152.0}/helpers/esm/decorateMetadata.js +1 -1
  4. package/dist/index.js +2 -2
  5. package/dist/nodefony/command/security-token.js +4 -4
  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/service/apiKeys.js +2 -2
  9. package/dist/nodefony/service/auditService.js +3 -3
  10. package/dist/nodefony/service/authFlow.js +2 -2
  11. package/dist/nodefony/service/authorization.js +3 -4
  12. package/dist/nodefony/service/firewall.js +21 -9
  13. package/dist/nodefony/service/oauth2.js +5 -3
  14. package/dist/nodefony/service/securityHeaders.js +13 -2
  15. package/dist/nodefony/service/tokenService.js +27 -18
  16. package/dist/nodefony/service/webAuthn.js +38 -31
  17. package/dist/nodefony/service/webhooks.js +50 -41
  18. package/dist/nodefony/src/admin/SecurityAdminApi.js +2 -1
  19. package/dist/nodefony/src/admin/WebhookAdminApi.js +1 -0
  20. package/dist/nodefony/src/admin/userRevocationCascade.js +1 -1
  21. package/dist/nodefony/src/apikey/apiKeyFormat.js +1 -1
  22. package/dist/nodefony/src/audit/MemoryAuditStore.js +4 -8
  23. package/dist/nodefony/src/audit/auditBridge.js +9 -5
  24. package/dist/nodefony/src/authenticator/ApiKeyAuthenticator.js +3 -3
  25. package/dist/nodefony/src/authenticator/ExternalJwtAuthenticator.js +2 -2
  26. package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +2 -2
  27. package/dist/nodefony/src/authenticator/JwtAuthenticator.js +2 -2
  28. package/dist/nodefony/src/authenticator/SessionAuthenticator.js +5 -3
  29. package/dist/nodefony/src/authenticator/UserPasswordAuthenticator.js +2 -2
  30. package/dist/nodefony/src/csp.js +4 -5
  31. package/dist/nodefony/src/oauth/providers/github.js +1 -1
  32. package/dist/nodefony/src/realtime/frameAuthorizer.js +6 -6
  33. package/dist/nodefony/src/token/JwtKeystore.js +7 -6
  34. package/dist/nodefony/src/token/MemoryTokenStore.js +1 -1
  35. package/dist/nodefony/src/token/jwtRuntime.js +13 -1
  36. package/dist/nodefony/src/token/tokenCriteria.js +1 -0
  37. package/dist/nodefony/src/totp/totpCrypto.js +9 -9
  38. package/dist/nodefony/src/webhook/WebhookDispatcher.js +4 -3
  39. package/dist/types/nodefony/contracts/IAuditEvent.d.ts +6 -2
  40. package/dist/types/nodefony/contracts/IFirewall.d.ts +2 -2
  41. package/dist/types/nodefony/contracts/IFirewallDescription.d.ts +5 -5
  42. package/dist/types/nodefony/contracts/ISecuredArea.d.ts +4 -4
  43. package/dist/types/nodefony/contracts/ITokenStore.d.ts +3 -3
  44. package/dist/types/nodefony/contracts/ITotpSecretStore.d.ts +4 -4
  45. package/dist/types/nodefony/contracts/IWebAuthnCredentialStore.d.ts +1 -1
  46. package/dist/types/nodefony/errors/InvalidTargetError.d.ts +1 -1
  47. package/dist/types/nodefony/errors/UnverifiableTokenError.d.ts +1 -1
  48. package/dist/types/nodefony/service/firewall.d.ts +3 -2
  49. package/dist/types/nodefony/service/securityHeaders.d.ts +7 -6
  50. package/dist/types/nodefony/src/SecuredArea.d.ts +2 -2
  51. package/dist/types/nodefony/src/authenticator/SessionAuthenticator.d.ts +5 -3
  52. package/dist/types/nodefony/src/oauth/oauth2Client.d.ts +2 -2
  53. package/dist/types/nodefony/src/oauth/oauthProviderRegistry.d.ts +2 -2
  54. package/dist/types/nodefony/src/realtime/realtimeContracts.d.ts +3 -3
  55. package/dist/types/nodefony/src/token/JwtKeystore.d.ts +2 -2
  56. package/dist/types/nodefony/src/token/RemoteJwtVerifier.d.ts +2 -2
  57. package/dist/types/nodefony/src/token/jwtRuntime.d.ts +10 -0
  58. package/docs/api-keys.md +16 -16
  59. package/docs/audit.md +27 -20
  60. package/docs/authenticators.md +8 -8
  61. package/docs/authorization.md +15 -15
  62. package/docs/cors.md +7 -7
  63. package/docs/csrf.md +8 -8
  64. package/docs/external-jwt.md +19 -10
  65. package/docs/firewall.md +28 -28
  66. package/docs/headers.md +20 -20
  67. package/docs/index.md +6 -4
  68. package/docs/lexique.md +15 -15
  69. package/docs/oauth2.md +23 -20
  70. package/docs/tokens.md +11 -10
  71. package/docs/totp.md +15 -14
  72. package/docs/webauthn.md +51 -54
  73. package/docs/webhooks.md +27 -30
  74. package/package.json +13 -13
package/docs/api-keys.md CHANGED
@@ -137,7 +137,7 @@ protégerait que des secrets faibles, et coûterait sur le chemin chaud.
137
137
 
138
138
  ### 1. Déclarer la zone machine et les règles d'émission
139
139
 
140
- Dans une app générée par `nodefony create app`, tout se déclare dans `nodefony.config.ts`. Une zone
140
+ Dans une app générée par `nodefony create app`, tout se déclare dans la config du module — `nodefony/config/security.ts`, importé par `nodefony.config.ts`. Une zone
141
141
  dont les authenticators contiennent `apikey` exige un `Authorization: Bearer nf_…` valide :
142
142
 
143
143
  ```typescript
@@ -229,7 +229,7 @@ curl -si https://localhost:5152/api/v1/orders/list | head -1
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
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:393`). Perdue = ré-émise.
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
 
@@ -276,16 +276,16 @@ 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:348`).
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:55`).
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
291
  6. **Écriture** — le `record` de `kind:"pat"` posé au store par `store.put()` (`apiKeys.ts:177`).
@@ -293,7 +293,7 @@ jamais par le préfixe.
293
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:324`) — 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()`
@@ -353,7 +353,7 @@ 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:348`).
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
@@ -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:55`) :
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.
@@ -388,8 +388,8 @@ 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:303`) |
392
- | Un admin | `POST /nodefony/security/api/apikeys/{id}/revoke` | n'importe quelle clé | `revokeAnyPat()` (`apiKeys.ts:257`) |
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
395
  l'authenticator lit `revokedAt` avant toute autre décision (`ApiKeyAuthenticator.ts:111-118`).
@@ -417,7 +417,7 @@ 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:216`). 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
422
  (`KEYS_MAX_LIMIT`, `SecurityAdminApi.ts:114`).
423
423
 
@@ -596,7 +596,7 @@ révocation ne traverse pas. Le détail de la résolution, des avertissements et
596
596
  | Message d'échec uniforme | OWASP API2:2023 (Broken Auth) | `INVALID_TOKEN` (`ApiKeyAuthenticator.ts:17`) |
597
597
  | Révocation immédiate côté serveur | OWASP API2:2023 | `revokedAt` vérifié (`ApiKeyAuthenticator.ts:111-118`) |
598
598
  | Entropie du secret (≥ 128 bits) | NIST SP 800-63B | 32 octets aléatoires (`apiKeyFormat.ts:30`) |
599
- | Plafond de ressources par acteur | OWASP API4:2023 (Resource Consumption) | `maxPerSubject` (`apiKeys.ts:55`) |
599
+ | Plafond de ressources par acteur | OWASP API4:2023 (Resource Consumption) | `maxPerSubject` (`apiKeys.ts:48`) |
600
600
 
601
601
  ## ⚡ Performance & mémoire
602
602
 
@@ -614,7 +614,7 @@ Le coût par requête authentifiée par clé est **maîtrisé par construction**
614
614
  container au premier usage et mémoïsés (`ApiKeyAuthenticator.ts:173`, `apiKeys.ts:268`) — le boot
615
615
  ne paie rien si aucune clé n'est jamais présentée.
616
616
  - **Jamais N enregistrements en RAM** côté administration : le listing est paginé **au store**
617
- (`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`).
618
618
 
619
619
  Le point de vigilance restant : `createForSubject()` compte les clés actives via `findBySubject()`
620
620
  (`ITokenStore.ts:211`), qui charge **toutes** les clés du porteur. C'est borné par `maxPerSubject`
@@ -649,7 +649,7 @@ pas masqué à l'affichage. Voir aussi l'écran **Audit** pour les événements
649
649
  | 401 à la création de clé | Ces routes exigent une **session** (pas de `bypassFirewall`) | Se connecter d'abord (`/nodefony/security/api/auth/login`) |
650
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 |
651
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:363`) | Ajouter le scope au catalogue, ou corriger la demande |
652
+ | 400 « scope not allowed » | Scope hors du catalogue `allowedScopes` (`apiKeys.ts:284`) | Ajouter le scope au catalogue, ou corriger la demande |
653
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 |
654
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` |
655
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 |
package/docs/audit.md CHANGED
@@ -135,7 +135,8 @@ lourds s'enregistrent depuis **leur** module. Un
135
135
  ## 🚀 Démarrage rapide
136
136
 
137
137
  Dans une app générée par `nodefony create app`, l'audit est **déjà actif** (`enabled: true` par
138
- défaut, `config.ts:729`) sur un store mémoire. Voici le parcours complet : configurer, émettre,
138
+ défaut, `config.ts:729`) sur le store que résout `auto` : la base SQLite locale de l'application
139
+ générée, la mémoire seulement si aucun ORM n'est chargé. Voici le parcours complet : configurer, émettre,
139
140
  relire.
140
141
 
141
142
  ### 1. Choisir où le journal est écrit
@@ -196,7 +197,7 @@ export default ExportController;
196
197
  ```
197
198
 
198
199
  > [!IMPORTANT]
199
- > `category` est une **union fermée** de sous-systèmes de sécurité (`IAuditEvent.ts:24`) : il n'existe
200
+ > `category` est une **union fermée** de sous-systèmes de sécurité (`IAuditEvent.ts:71`) : il n'existe
200
201
  > pas de catégorie « métier ». C'est volontaire — ce journal est celui de la sécurité. Range ton
201
202
  > événement dans la catégorie de sécurité qu'il concerne (ici `authz` : un accès privilégié à des
202
203
  > données) et laisse `action` porter le vocabulaire métier.
@@ -265,7 +266,7 @@ Les critères se **combinent en ET** et sont traduits par `parseAuditQuery()`
265
266
  | `requestId` | tous les événements d'**une seule requête** | `requestId=req-7c1e` |
266
267
  | `since` | borne basse d'horodatage, epoch ms **inclus** | `since=1763503200000` |
267
268
  | `until` | borne haute d'horodatage, epoch ms **inclus** | `until=1763524800000` |
268
- | `limit` | taille de page (défaut 100, **plafonné à 500**) | `limit=200` |
269
+ | `limit` | taille de page (défaut 100, **plafonné à 200**) | `limit=200` |
269
270
  | `cursor` | le `nextCursor` de la page précédente | `cursor=1763548800123:9f-1` |
270
271
 
271
272
  ```bash
@@ -317,7 +318,7 @@ anti-avalanche en cluster. Chaque
317
318
  tour appelle la purge du contrat — `gc()`, voisin de `listPage()` dans le même contrat
318
319
  (`IAuditStore.ts:59`) — qui supprime les événements plus vieux que la fenêtre : un `DELETE` par seuil
319
320
  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:138`).
321
+ `threshold` côté mémoire (`MemoryAuditStore.ts:133`).
321
322
 
322
323
  Ce qu'on observe dans les logs : `audit gc — 1284 événement(s) purgé(s)` en niveau DEBUG.
323
324
 
@@ -332,7 +333,7 @@ La base est indisponible pendant trente secondes. Que se passe-t-il ?
332
333
 
333
334
  Le choix de Nodefony est explicite : **le métier passe avant la trace**. L'écriture part en
334
335
  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:146`). Un login n'échoue jamais
336
+ pas connecté, `append()` est un no-op assumé (`DrizzleAuditStore.ts:152`). Un login n'échoue jamais
336
337
  parce que le journal est cassé.
337
338
 
338
339
  > [!WARNING]
@@ -357,17 +358,22 @@ l'axe de filtrage principal de la console. L'**action** est une chaîne **ouvert
357
358
  <!-- prettier-ignore -->
358
359
  | Catégorie | Ce qu'elle trace | Actions réellement émises par le framework |
359
360
  | --- | --- | --- |
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` |
361
- | `authz` | autorisation (voters, `@IsGranted`) | `access.denied` |
361
+ | `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`) |
362
+ | `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
363
  | `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` |
364
+ | `session` | cycle de vie de session | `logout` · `session.revoked` (révocation depuis l'administration, par `@nodefony/http`) |
364
365
  | `webauthn` | passkeys | `user.passkey_revoked` |
365
366
  | `ws` | verrou de frame WebSocket | `frame.denied` |
366
367
  | `webhook` | webhooks sortants | `webhook.created` · `webhook.updated` · `webhook.deleted` · `webhook.rotated` · `webhook.revealed` · `webhook.disabled` |
367
368
  | `oauth` | login social OAuth2 | _catégorie déclarée, aucune action émise aujourd'hui_ |
368
369
  | `csrf` | défense CSRF | _catégorie déclarée, aucune action émise aujourd'hui_ |
369
370
  | `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_ |
371
+ | `config` | mutation de config runtime depuis Studio | `config.update` · `log.debug.set` · `log.debug.clear` (par `@nodefony/framework`) |
372
+
373
+ Toute catégorie émise appartient au type `AuditCategory`, y compris depuis un paquet qui ne peut pas
374
+ l'importer (`@nodefony/user`, `@nodefony/framework`) : `tests/unit/auditCategoryEmitters.test.ts`
375
+ relit les sources de tous les paquets et refuse un littéral hors contrat. Un filtre sur les
376
+ catégories déclarées voit donc tout le journal.
371
377
 
372
378
  Les trois issues possibles (`IAuditEvent.ts:43`) ne sont pas interchangeables : `failure` = **l'acteur
373
379
  a échoué une preuve** (mauvais mot de passe, signature invalide) ; `denied` = **une politique a
@@ -377,7 +383,7 @@ Pour un auditeur, la colonne `denied` est celle des tentatives d'accès non auto
377
383
  ### `auth` — la chaîne d'authentification
378
384
 
379
385
  Quatre sorties d'échec du firewall passent par le même helper `Firewall.#recordAuth()`
380
- (`firewall.ts:900`), qui enrichit l'événement de la provenance et pose la **zone** en `resource` :
386
+ (`firewall.ts:907`), qui enrichit l'événement de la provenance et pose la **zone** en `resource` :
381
387
 
382
388
  - `auth.throttled` — backoff NIST déclenché, réponse 429 (`firewall.ts:781`) ;
383
389
  - `auth.failure` — un credential a été **présenté** et rejeté (`firewall.ts:807`) ;
@@ -460,7 +466,7 @@ Quatre mécanismes, tous prouvés par les tests.
460
466
 
461
467
  **1. Le chemin nominal n'émet rien.** Ce n'est pas une optimisation, c'est le modèle : le firewall
462
468
  n'appelle `#recordAuth()` que depuis ses quatre sorties d'échec, jamais depuis le succès
463
- (`firewall.ts:900`). Le verrou WS ne tire sa closure `onDeny` que sur refus (`firewall.ts:352`).
469
+ (`firewall.ts:907`). Le verrou WS ne tire sa closure `onDeny` que sur refus (`firewall.ts:358`).
464
470
  Prouvé : « frame AUTORISÉE → onDeny JAMAIS appelé » (`auditEmissionHotPath.test.ts:333`).
465
471
 
466
472
  **2. Audit désactivé = coût nul, pas juste coût faible.** `record()` sort avant toute allocation et
@@ -480,7 +486,7 @@ alloué qu'au premier événement reçu (`auditBridge.ts:62`) ; son minuteur est
480
486
  Le pont applique en plus un **coalescing borné** : au plus une frame WS toutes les 250 ms
481
487
  (`auditBridge.ts:59`), tampon plafonné à 200 événements (`auditBridge.ts:60`). Sous une rafale
482
488
  d'échecs de login, le tampon écrase les plus anciens et compte les omis dans `dropped`
483
- (`auditBridge.ts:79`) — la console affiche un récapitulatif au lieu de se figer. Superviser ne doit
489
+ (`auditBridge.ts:86`) — la console affiche un récapitulatif au lieu de se figer. Superviser ne doit
484
490
  jamais faire tomber ce qu'on supervise.
485
491
 
486
492
  ## ⚙️ Configuration
@@ -491,7 +497,7 @@ Table dérivée du schéma Zod `auditSchema` (`config.ts:883`), rattaché à la
491
497
  | Option | Type | Défaut | Effet |
492
498
  | --------------- | --------- | -------- | ------------------------------------------------------------------------------------ |
493
499
  | `enabled` | `boolean` | `true` | `false` → `record()` no-op à coût nul, `listPage()` page vide, endpoint admin en 503 |
494
- | `store` | `string` | `"auto"` | nom résolu par le registre : `auto`, `memory`, `drizzle` |
500
+ | `store` | `string` | `"auto"` | nom résolu par le registre : `auto`, `memory`, `drizzle`, `mongoose` |
495
501
  | `retentionDays` | `number` | `365` | fenêtre de conservation ; au-delà, la purge horaire supprime |
496
502
  | `immutable` | `boolean` | `true` | déclaré au schéma — voir l'avertissement ci-dessous |
497
503
  | `stream` | `boolean` | `true` | déclaré au schéma — voir l'avertissement ci-dessous |
@@ -509,7 +515,8 @@ enregistrés (`auditService.ts:96`, logique `resolveAutoStore()` dans `infra.ts:
509
515
 
510
516
  1. `NF_STORE` posée et le backend est enregistré pour l'audit → il gagne (levier de banc de charge) ;
511
517
  2. sinon, une base est déclarée (`NF_DATABASE_URL`) → `drizzle`, ou `mongoose` selon la famille ;
512
- 3. sinon, repli **annoncé** sur `memory` — la décision est loggée en INFO, jamais silencieuse.
518
+ 3. sinon, un backend local persistant réellement chargé (`drizzle` = SQLite, puis `mongoose`) ;
519
+ 4. sinon, repli **annoncé** sur `memory` (volatil) — la décision est loggée en INFO, jamais silencieuse.
513
520
 
514
521
  La résolution finale est publiée au kernel (`auditService.ts:145`), ce qui alimente l'écran des stores
515
522
  de Studio : configuré, résolu, disponible, motif, emplacement physique.
@@ -545,7 +552,7 @@ Quatre pièces, quatre responsabilités :
545
552
  **`append` est la seule écriture** : ni `update`, ni `delete` ciblé. L'immuabilité EST la garantie
546
553
  d'audit.
547
554
  - **`createAuditBridge()`** (`auditBridge.ts:53`) — le pont vers le canal WS `nodefony:audit`
548
- (`auditBridge.ts:8`), enregistré comme canal **système** par le firewall (`firewall.ts:322`) et donc
555
+ (`auditBridge.ts:8`), enregistré comme canal **système** par le firewall (`firewall.ts:387`) et donc
549
556
  gardé par le plancher `security:` → `ROLE_NODEFONY_ADMIN` (`frameAuthorizer.ts:106`).
550
557
 
551
558
  ### La lecture paginée — pourquoi un curseur et pas un décalage
@@ -567,7 +574,7 @@ tiennent identiquement :
567
574
 
568
575
  Le `total` est **refusable** (`withTotal: false`) : un `COUNT` filtré sur une rétention longue se
569
576
  paie (`IAuditStore.ts:59`). `hasNext` et `nextCursor` restent fiables dans les deux cas, grâce à une
570
- ligne de garde `limit + 1` (`DrizzleAuditStore.ts:198`).
577
+ ligne de garde `limit + 1` (`DrizzleAuditStore.ts:225`).
571
578
 
572
579
  ## Entité de persistance et dialectes pris en charge
573
580
 
@@ -632,7 +639,7 @@ Ce que le code garantit, concrètement :
632
639
  - **Le typage rend le secret difficile à faire entrer.** `actor` est documenté comme un _libellé
633
640
  d'identité_ (`IAuditEvent.ts:82`) et `resource` comme un _descripteur léger_ — « jamais le corps ni
634
641
  les en-têtes de la requête » (`IAuditEvent.ts:87`).
635
- - **La présence remplace la valeur.** `IAuditEventFlags` (`IAuditEvent.ts:49`) ne porte que deux
642
+ - **La présence remplace la valeur.** `IAuditEventFlags` (`IAuditEvent.ts:53`) ne porte que deux
636
643
  booléens : un en-tête `Authorization` était-il là, un cookie était-il là. `readAuditContext()`
637
644
  les calcule par un simple `Boolean(headers[…])` (`readAuditContext.ts:40`) — la valeur n'est jamais
638
645
  copiée.
@@ -697,10 +704,10 @@ webhook. Détail des souscriptions, de la signature et des relivraisons → [web
697
704
  | 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
705
  | Un pod voit des événements, l'autre non | Store `memory` non partagé | Store durable partagé |
699
706
  | 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 |
700
- | `limit=5000` ne rend que 500 événements | Plafond du store (`MemoryAuditStore.ts:12`) | Paginer avec `nextCursor`, jamais gonfler `limit` |
707
+ | `limit=5000` ne rend que 200 événements | Plafond de l'endpoint (`parsePageQuery`, `pageQuery.ts:77`) | Paginer avec `nextCursor`, jamais gonfler `limit` |
701
708
  | 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` |
709
+ | `?category=authen` renvoie 400 | Valeur hors union refusée (`PageQueryError`) | Utiliser une valeur de l'union (`auth`, `authz`, `token`…) |
710
+ | Le paramètre `q` renvoie 400 | Recherche plein texte non supportée sur ce journal | Filtrer par `category`/`actor`/`action`/`requestId` |
704
711
  | `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 |
705
712
  | Trous dans le journal pendant une panne de base | Écriture best-effort (`auditService.ts:199`) | Store à haute disponibilité si la conformité l'exige |
706
713
  | 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 |
@@ -105,7 +105,7 @@ totalement agnostique de la stratégie :
105
105
  ### Le registre pluggable
106
106
 
107
107
  Les authenticators sont résolus par **nom** : `Firewall.#instantiateAuthenticators()`
108
- (`firewall.ts:429`) interroge `getAuthenticatorFactory()` (`authenticatorRegistry.ts:59`) — jamais
108
+ (`firewall.ts:435`) interroge `getAuthenticatorFactory()` (`authenticatorRegistry.ts:59`) — jamais
109
109
  un `if (name === "jwt")` dans le firewall, qui trahirait la promesse « pluggable ».
110
110
 
111
111
  - Les **six builtins HTTP** (`anonymous`, `userpassword`, `session`, `jwt`, `external-jwt`, `apikey`)
@@ -116,7 +116,7 @@ un `if (name === "jwt")` dans le firewall, qui trahirait la promesse « pluggabl
116
116
  - La fabrique ne fait que **construire** ; les résolutions de services coûteuses (`users`,
117
117
  `tokenStore`, keystore) restent **lazy** dans l'instance (cold path).
118
118
  - Un nom inconnu en config = boot **fail-closed** — `#configError` posé + log CRITIC
119
- (`firewall.ts:582`) : jamais de zone « protégée » silencieusement ouverte à cause d'une
119
+ (`firewall.ts:588`) : jamais de zone « protégée » silencieusement ouverte à cause d'une
120
120
  faute de frappe.
121
121
 
122
122
  ## 🚀 Démarrage rapide
@@ -185,7 +185,7 @@ curl -si http://localhost:5151/api/v1/whoami | grep -E "^(HTTP|WWW)"
185
185
 
186
186
  # 2) Web — login BFF (compte dev seedé admin/nodefony-dev-42) → cookie de session
187
187
  curl -si -c /tmp/jar -H 'Content-Type: application/json' \
188
- -d '{"username":"admin","password":"admin"}' \
188
+ -d '{"username":"admin","password":"nodefony-dev-42"}' \
189
189
  http://localhost:5151/nodefony/security/api/auth/login | head -1
190
190
  # HTTP/1.1 200 OK
191
191
 
@@ -228,7 +228,7 @@ Le seul authenticator autorisé à produire un token **non authentifié** sans d
228
228
  si preuve présente, sinon visiteur anonyme accepté ». En mode `all`, utile en **dernier** :
229
229
  « canal prouvé (ex. mTLS), identité utilisateur optionnelle ».
230
230
  - Sans lui, zone protégée + aucune preuve = 401 : la défense en profondeur du firewall n'accepte un
231
- token non authentifié que si `anonymous` est le maillon déclaré (`firewall.ts:827`).
231
+ token non authentifié que si `anonymous` est le maillon déclaré (`firewall.ts:850`).
232
232
  - **Faille fermée** : l'anonymat _implicite_ — ici il est un choix explicite et auditable, jamais un
233
233
  défaut.
234
234
 
@@ -349,7 +349,7 @@ l'ALS. `FirewallRealtimeAuthenticator.supports()` ne fait que le constater
349
349
 
350
350
  > [!NOTE]
351
351
  > **Asymétrie de révocation HTTP↔WS (assumée)** : le jeton realtime est figé au handshake (les
352
- > frames lisent un cache O(1)) → une révocation prend effet **à la reconnexion**, pas à la frame
352
+ > frames lisent un cache O(1)) → une révocation prend effet **en une fenêtre** (tick du hub, et devant chaque `api.request`), pas à la frame
353
353
  > suivante. C'est l'état de l'art (Socket.IO/Phoenix figent aussi au handshake) ; la révocation
354
354
  > immédiate forte passe par le JWT + un canal « token révoqué ».
355
355
 
@@ -409,7 +409,7 @@ paresse : c'est une **défense anti-énumération / anti-oracle**.
409
409
  Distinguer « compte inconnu » de « mot de passe faux », ou « token expiré » de « signature
410
410
  invalide », donnerait à un attaquant une sonde. La cause fine part **toujours** en log d'audit ; le
411
411
  client n'obtient qu'un 401 + son challenge — posé par le firewall, premier maillon de la zone qui
412
- en déclare un (`Firewall.#setChallenge()`, `firewall.ts:1207`).
412
+ en déclare un (`Firewall.#setChallenge()`, `firewall.ts:1216`).
413
413
 
414
414
  ## 🧩 Ajouter un authenticator maison
415
415
 
@@ -435,7 +435,7 @@ registerAuthenticatorFactory("ldap", ({ container, config }) => {
435
435
  <!-- prettier-ignore -->
436
436
  | Domaine | Norme | Ancrage |
437
437
  | --- | --- | --- |
438
- | Challenge d'auth (401) | RFC 7235 | `Firewall.#setChallenge()` (`firewall.ts:1207`) |
438
+ | Challenge d'auth (401) | RFC 7235 | `Firewall.#setChallenge()` (`firewall.ts:1216`) |
439
439
  | Bearer | RFC 6750 | `readBearerHeader()` (`runtime/bearer.ts:68`, cœur) — une porte UNIQUE au cœur, plus une constante par authenticator |
440
440
  | JWT (BCP) | RFC 7519, 8725 | `jwtVerify` durci : allowlist + claims (`JwtAuthenticator.ts:103-107`) |
441
441
  | HTTP Basic | RFC 7617 | `UserPasswordAuthenticator` (`UserPasswordAuthenticator.ts:25-27`) |
@@ -463,7 +463,7 @@ registerAuthenticatorFactory("ldap", ({ container, config }) => {
463
463
  <!-- prettier-ignore -->
464
464
  | Symptôme | Cause (dans le code) | Correction |
465
465
  | --- | --- | --- |
466
- | 401 systématique sur une zone protégée | Aucune preuve + `anonymous` non listé — Zero Trust (`firewall.ts:827`) | Ajouter `anonymous` en dernier si l'anonymat est voulu |
466
+ | 401 systématique sur une zone protégée | Aucune preuve + `anonymous` non listé — Zero Trust (`firewall.ts:850`) | Ajouter `anonymous` en dernier si l'anonymat est voulu |
467
467
  | 401 générique + log ERROR « service `users` » | Câblage : pas de `UserService` au container (`authenticatorRegistry.ts:85-88`) | Enregistrer un `UserService` au boot de l'app |
468
468
  | JWT rejeté alors qu'il « semble » valide | `aud`/`iss`/`typ` non conformes, ou `alg` ≠ EdDSA (`JwtAuthenticator.ts:103-107`) | Émettre via le `TokenService` (mêmes iss/aud/typ) |
469
469
  | Clé API révoquée encore acceptée quelques secondes | Confusion avec un JWT (auto-porté) — `revokedAt` est lu à chaque requête (`ApiKeyAuthenticator.ts:110`) | Un PAT est révoqué immédiatement ; vérifier `revokedAt` |
@@ -331,7 +331,7 @@ Dès le `DENY`, le jury **s'arrête** — court-circuit, inutile de finir (`auth
331
331
 
332
332
  **Contre-exemple piégeux** : le veto ne traverse **pas** une clause OR. Dans
333
333
  `@IsGranted(["ROLE_ADMIN", "doc.edit"])`, chaque attribut est un **jury séparé**
334
- (`Resolver.ts:689-702`) : si `ROLE_ADMIN` est accordé, `doc.edit` — et son veto — n'est même pas
334
+ (`Resolver.ts:878-885`) : si `ROLE_ADMIN` est accordé, `doc.edit` — et son veto — n'est même pas
335
335
  consulté. Un interdit absolu se porte en clause **AND** : empiler `@IsGranted("ROLE_ADMIN")` puis
336
336
  `@IsGranted("doc.edit", { subject: "id" })`.
337
337
 
@@ -360,20 +360,20 @@ moteur `authorization` est résolu **par nom** au runtime (`Resolver.ts:673-674`
360
360
 
361
361
  | Déclaration | Sémantique |
362
362
  | ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
363
- | `@IsGranted("ROLE_ADMIN")` | un attribut — rôle, scope ou verbe métier (`IsGranted()`, `routerDecorators.ts:878`) |
364
- | `@IsGranted(["A", "B"])` | **OR interne** — un attribut accordé suffit (`SecurityClause.anyOf`, `routerDecorators.ts:407-412`) |
365
- | empiler `@IsGranted` / `@RequireScope` | **AND** — toutes les clauses doivent passer (`SecurityRequirement.clauses`, `routerDecorators.ts:426`) |
366
- | décorateur de classe + de méthode | fusion en **AND**, figée UNE fois par route (`computeSecurityRequirement()`, `routerDecorators.ts:1514`) |
367
- | `@IsGranted("doc.edit", { subject: "id" })` | le param de route `id` est passé au voter (`Resolver._resolveSubject()`, `Resolver.ts:743-747`) |
368
- | `@RequireScope("orders:read")` | axe scope — metadata dédiée, fusionnée dans le même `SecurityRequirement` (`RequireScope()`, `routerDecorators.ts:975`) |
369
- | `@Anonymous()` | action **publique** — override les gardes de classe (`security: null`) + skip l'authn (`Anonymous()`, `routerDecorators.ts:926`) |
370
- | `@CurrentUser()` | injecte l'utilisateur de l'ALS — jamais le credential (`CurrentUser`, `routerDecorators.ts:1250`) |
363
+ | `@IsGranted("ROLE_ADMIN")` | un attribut — rôle, scope ou verbe métier (`IsGranted()`, `routerDecorators.ts:903`) |
364
+ | `@IsGranted(["A", "B"])` | **OR interne** — un attribut accordé suffit (`SecurityClause.anyOf`, `routerDecorators.ts:424-433`) |
365
+ | empiler `@IsGranted` / `@RequireScope` | **AND** — toutes les clauses doivent passer (`SecurityRequirement.clauses`, `routerDecorators.ts:1579`) |
366
+ | décorateur de classe + de méthode | fusion en **AND**, figée UNE fois par route (`computeSecurityRequirement()`, `routerDecorators.ts:1544`) |
367
+ | `@IsGranted("doc.edit", { subject: "id" })` | le param de route `id` est passé au voter (`Resolver._resolveSubject()`, `Resolver.ts:923`) |
368
+ | `@RequireScope("orders:read")` | axe scope — metadata dédiée, fusionnée dans le même `SecurityRequirement` (`RequireScope()`, `routerDecorators.ts:1002`) |
369
+ | `@Anonymous()` | action **publique** — override les gardes de classe (`security: null`) + skip l'authn (`Anonymous()`, `routerDecorators.ts:953`) |
370
+ | `@CurrentUser()` | injecte l'utilisateur de l'ALS — jamais le credential (`CurrentUser`, `routerDecorators.ts:1283`) |
371
371
 
372
372
  La garde s'évalue dans `Resolver.executeAction()` **AVANT** l'instanciation DI du controller — un
373
- 403 court-circuite tout, y compris `initialize()` (`_enforceSecurity`, `Resolver.ts:672-677`). Le
373
+ 403 court-circuite tout, y compris `initialize()` (`_enforceSecurity`, `Resolver.ts:489-491`). Le
374
374
  même `executeAction` sert le pipeline HTTP **et** l'invoke WS-RPC : une garde, tous les
375
375
  transports. L'enforcement déroule chaque clause : OR interne via un `decide()` par attribut, AND
376
- entre clauses (`Resolver._enforceSecurity()`, `Resolver.ts:672-702`).
376
+ entre clauses (`Resolver._enforceSecurity()`, `Resolver.ts:853`).
377
377
 
378
378
  > [!IMPORTANT]
379
379
  > **Fail-closed intégral** : route gardée mais moteur `authorization` absent (module security non
@@ -511,7 +511,7 @@ compilation** — rien à scanner au runtime ; le registre **est** le marqueur e
511
511
 
512
512
  ## 🔌 HTTP et WebSocket — une garde, N transports
513
513
 
514
- - **La même garde** : `Resolver.executeAction()` (`Resolver.ts:335`) est le point unique
514
+ - **La même garde** : `Resolver.executeAction()` (`Resolver.ts:446`) est le point unique
515
515
  d'enforcement — pipeline HTTP classique **et** invoke WS-RPC (pont `api.request`). Un
516
516
  `@IsGranted` protège donc l'action quel que soit le transport (prouvé bout en bout par le banc
517
517
  `ws-isgranted-jwt`).
@@ -541,10 +541,10 @@ compilation** — rien à scanner au runtime ; le registre **est** le marqueur e
541
541
  ## ⚡ Performance & mémoire
542
542
 
543
543
  - **Hot path à coût nul** : une route non gardée porte `security: null` → 0 lookup, 0 await, 0
544
- alloc (`Resolver.ts:358`) ; l'exigence est **figée une fois** par route et partagée entre
545
- requêtes (`SecurityRequirement`, `routerDecorators.ts:424`).
544
+ alloc (`Resolver.ts:486`) ; l'exigence est **figée une fois** par route et partagée entre
545
+ requêtes (`SecurityRequirement`, `routerDecorators.ts:444`).
546
546
  - **`decide()` sans allocation** : itération en place des voters (`authorization.ts:70-72`),
547
- instanciés **une seule fois** au boot (`authorization.ts:51`).
547
+ instanciés **une seule fois** au boot (`authorization.ts:70`).
548
548
  - **`hasRole()` O(1)** : hiérarchie aplatie au boot, rien de récursif par requête
549
549
  (`RoleHierarchyWalker.ts:23-30`).
550
550
  - **Audit = cold path** : uniquement sur refus, avec un descripteur léger du sujet — jamais de
package/docs/cors.md CHANGED
@@ -134,7 +134,7 @@ chaque appel du front.
134
134
  ### 1. La config — dans `nodefony.config.ts`
135
135
 
136
136
  ```typescript
137
- // nodefony.config.ts (extrait généré par `nodefony create app`, puis complété)
137
+ // nodefony/config/security.ts (extrait généré par `nodefony create app`, puis complété)
138
138
  use("@nodefony/security", {
139
139
  cors: {
140
140
  // Allowlist EXACTE : scheme + host + port. Un sous-domaine n'est PAS inclus.
@@ -323,7 +323,7 @@ sequenceDiagram
323
323
  ```
324
324
 
325
325
  `Firewall.handleCors()` (`firewall.ts:1007`) est appelé **en tête de** `HttpKernel.handleHttp()`
326
- (`http-kernel.ts:1310`), à la ligne `http-kernel.ts:1361` — **avant le routing**. La raison est
326
+ (`http-kernel.ts:1456`), à la ligne `http-kernel.ts:1456` — **avant le routing**. La raison est
327
327
  concrète : un preflight `OPTIONS /api/articles` n'a **pas de route déclarée** ; s'il traversait le
328
328
  router, il repartirait en 405. Et selon le Fetch Standard, un preflight ne transporte jamais de
329
329
  credentials — il ne doit donc ni s'authentifier, ni exécuter le moindre code applicatif.
@@ -354,7 +354,7 @@ Deux nuances utiles :
354
354
  `Access-Control-Request-Headers` du client (`cors.ts:78`). Ce que tu déclares est ce qui est annoncé,
355
355
  point. Un en-tête custom non déclaré fait échouer le preflight côté navigateur.
356
356
  - **Les fichiers statiques sont couverts.** `handleCors` s'exécute avant le fallback `serve-static`
357
- (`http-kernel.ts:1361`) : une police ou une image servie cross-origin reçoit les mêmes en-têtes que
357
+ (`http-kernel.ts:1630`) : une police ou une image servie cross-origin reçoit les mêmes en-têtes que
358
358
  tes routes.
359
359
 
360
360
  Le contrat est publié dans l'interface du firewall (`IFirewall.ts:35`) : `number | undefined` — `204`
@@ -387,7 +387,7 @@ victime** : c'est le CSWSH. C'est pourquoi `handleCors` s'arrête net sur un con
387
387
  (`firewall.ts:1007`) — il n'y aurait rien à protéger avec des en-têtes que personne ne lit.
388
388
 
389
389
  La garde équivalente vit dans le transport : `HttpKernel.checkWebsocketOrigin()`
390
- (`http-kernel.ts:604`) valide l'`Origin` **au handshake**, avant l'accept, et ferme en code WS `1008`
390
+ (`http-kernel.ts:621`) valide l'`Origin` **au handshake**, avant l'accept, et ferme en code WS `1008`
391
391
  si elle est refusée. Sa doctrine :
392
392
 
393
393
  - **same-origin par défaut** : l'`Origin` du handshake doit correspondre au `Host` servi ;
@@ -409,7 +409,7 @@ Deux réglages distincts, parce que deux mécanismes navigateur distincts.
409
409
  | `*` incompatible avec credentials | Fetch Standard · OWASP CORS | rejet au boot (`config.ts:156-159`) + reflet défensif (`cors.ts:58`) |
410
410
  | Correction de cache | RFC 9110 (`Vary`) | `Vary: Origin` dès que l'origine est reflétée (`cors.ts:81`, `cors.ts:94`) |
411
411
  | Comparaison d'origines | RFC 6454 (Web Origin) | match **exact** `scheme://host:port` — `Cors.#allowOrigin()` (`cors.ts:57`) |
412
- | Anti-CSWSH | OWASP WSTG-CLNT-10 | `HttpKernel.checkWebsocketOrigin()` (`http-kernel.ts:604`) |
412
+ | Anti-CSWSH | OWASP WSTG-CLNT-10 | `HttpKernel.checkWebsocketOrigin()` (`http-kernel.ts:621`) |
413
413
 
414
414
  ## ⚡ Performance & mémoire
415
415
 
@@ -428,7 +428,7 @@ Le coût par requête est donc :
428
428
  ## 📡 Observabilité — Studio
429
429
 
430
430
  La configuration CORS **résolue** (celle qui tourne réellement, pas le fichier source) est exposée par
431
- `Firewall.describe()` (`firewall.ts:549`), qui délègue à `Firewall.#describeDefenses()`
431
+ `Firewall.describe()` (`firewall.ts:555`), qui délègue à `Firewall.#describeDefenses()`
432
432
  (`firewall.ts:591`). La projection CORS y expose `origins`, `credentials`, `methods`,
433
433
  `allowedHeaders`, `exposedHeaders` et `maxAgeS` (`firewall.ts:610`) — aucun secret ne transite par
434
434
  cette surface.
@@ -455,7 +455,7 @@ secondes les « pourtant j'ai bien mis l'origine ».
455
455
  | Le preflight échoue sur un en-tête custom | `allowedHeaders` est statique, il ne reflète pas la demande du client (`cors.ts:78`) | Déclarer l'en-tête dans `cors.allowedHeaders` |
456
456
  | Un cache sert la réponse d'une origine à une autre | `Vary: Origin` écrasé en aval (la politique le pose, `cors.ts:81`) | Ne pas `setHeader("Vary", …)` dans un controller — utiliser `appendHeader` |
457
457
  | `OPTIONS` renvoie 405 au lieu de 204 | Requête `OPTIONS` **sans** `Access-Control-Request-Method` : ce n'est pas un preflight | Envoyer l'en-tête, ou déclarer une route `OPTIONS` |
458
- | Page tierce qui ouvre un WebSocket authentifié | CORS ne couvre pas le WS | C'est `checkWebsocketOrigin` qui garde (`http-kernel.ts:604`) — vérifier `allowedOrigins` |
458
+ | Page tierce qui ouvre un WebSocket authentifié | CORS ne couvre pas le WS | C'est `checkWebsocketOrigin` qui garde (`http-kernel.ts:621`) — vérifier `allowedOrigins` |
459
459
  | Ouverture CORS « temporaire » restée en production | `origins:["*"]` posé en dev | Vérifier la valeur **résolue** dans Studio, pas le fichier source |
460
460
 
461
461
  ## 🧪 Tests & couverture
package/docs/csrf.md CHANGED
@@ -93,7 +93,7 @@ contrôleur** : l'attaque meurt sans avoir touché ton code.
93
93
  - **Vérifier la provenance d'abord** (OWASP 2025, modèle Go 1.25 `CrossOriginProtection`) : la
94
94
  couche 1 est la défense **par défaut**, `csrf.enabled: true` (`config.ts:163-168`).
95
95
  - **Globale, pas liée aux zones** : toute mutation cross-site est refusée, route publique ou non —
96
- branchée dans le pipeline HTTP — l'appel `enforceCsrf` (`http-kernel.ts:1479`) arrive **après** le
96
+ branchée dans le pipeline HTTP — l'appel `enforceCsrf` (`http-kernel.ts:1747`) arrive **après** le
97
97
  resolve (les marqueurs de route sont lisibles) et **avant** la session (rejet précoce : un
98
98
  attaquant ne coûte ni lecture de session ni authentification).
99
99
  - **Logique pure** : la classe `Csrf` est synchrone, sans I/O ni allocation sur le hot-path —
@@ -148,7 +148,7 @@ export default ProfileController;
148
148
 
149
149
  (Wiring : `@controllers([ProfileController])` dans le module de l'app — `nodefony create controller`
150
150
  le fait pour toi. Posé sur la **classe**, `@CsrfProtect()` couvre toutes les actions : les marqueurs
151
- `csrfProtect`/`csrfExempt` acceptent méthode OU classe, `routerDecorators.ts:1620-1626`.)
151
+ `csrfProtect`/`csrfExempt` acceptent méthode OU classe, `routerDecorators.ts:1650-1655`.)
152
152
 
153
153
  ### Comment le front obtient — puis rejoue — le token
154
154
 
@@ -241,7 +241,7 @@ autorisation :
241
241
  ```
242
242
 
243
243
  > [!WARNING]
244
- > `@CsrfExempt` (`routerDecorators.ts:1113`) est un opt-out **ciblé CSRF**. Ne jamais « débloquer un
244
+ > `@CsrfExempt` (`routerDecorators.ts:1142`) est un opt-out **ciblé CSRF**. Ne jamais « débloquer un
245
245
  > webhook » avec `@BypassFirewall`/`@Anonymous` : eux désactivent l'authentification de la zone.
246
246
 
247
247
  Cas voisin — **façade multi-domaine** (`www.example.com` poste vers l'API d'un autre domaine à toi) :
@@ -296,7 +296,7 @@ de session (TSDoc `CsrfTokenManager`, `csrfToken.ts:12-15`).
296
296
  1. `@CsrfProtect`/`@CsrfExempt` posent un **marqueur** de metadata — zéro import de
297
297
  `@nodefony/security` côté framework, zéro cycle (`routerDecorators.ts:886`).
298
298
  2. Au match de la route, `Resolver.match()` recopie les marqueurs sur le contexte
299
- (`Resolver.ts:173-174`) — champs portés par le `Context` de base, HTTP comme WS
299
+ (`Resolver.ts:156-186`) — champs portés par le `Context` de base, HTTP comme WS
300
300
  (`Context.ts:241-243`).
301
301
  3. `Firewall.enforceCsrf()` (`firewall.ts:948`) fait les trois rôles : **émission** du token sur
302
302
  requête sûre `@CsrfProtect`, **couche 1** sur toute mutation, **couche 2** en plus si
@@ -315,7 +315,7 @@ de session (TSDoc `CsrfTokenManager`, `csrfToken.ts:12-15`).
315
315
  | `fetchMetadata` | boolean · `true` | Défense primaire `Sec-Fetch-Site` (`config.ts:169-174`). |
316
316
  | `checkOrigin` | boolean · `true` | Repli `Origin`/`Referer` same-host pour les navigateurs sans `Sec-Fetch-*` (`config.ts:176-181`). |
317
317
  | `strictSameSite` | boolean · `false` | `true` = refuser aussi `same-site` (sous-domaine non maîtrisé / multi-tenant) — distinct de l'attribut cookie (`config.ts:182-187`). |
318
- | `sameSite` | enum · `Lax` | **Déclaratif** : surfacé dans l'introspection (`firewall.ts:582`) ; l'attribut effectif du cookie `csrf-token` est `Strict` en dur (`HttpContext.ts:497`). |
318
+ | `sameSite` | enum · `Lax` | **Déclaratif** : surfacé dans l'introspection (`firewall.ts:604`) ; l'attribut effectif du cookie `csrf-token` est `Strict` en dur (`HttpContext.ts:542`). |
319
319
  | `trustedOrigins` | string[] · `[]` | Alias **exacts** (`scheme://host[:port]`) autorisés même cross-site — sans ouvrir la lecture CORS (`config.ts:188-193`). |
320
320
  | `secret` | string ≥ 16 car. · — | Secret HMAC du synchronizer — PROD : via env, **partagé cluster** ; absent = éphémère dev (`config.ts:194-200`). |
321
321
 
@@ -325,7 +325,7 @@ de session (TSDoc `CsrfTokenManager`, `csrfToken.ts:12-15`).
325
325
  | ------------------------------ | --------------------------------- | -------------------------------------- |
326
326
  | Méthodes sûres | RFC 9110 §9.2.1 | `SAFE_METHODS` (`csrf.ts:8-13`) |
327
327
  | Provenance | W3C Fetch Metadata | `Csrf.enforce()` (`csrf.ts:85`) |
328
- | Valeur `site` inconnue → repli | Fetch Metadata « SHOULD ignore » | `csrf.ts:107` |
328
+ | Valeur `site` inconnue → repli | Fetch Metadata « SHOULD ignore » | `csrf.ts:96` |
329
329
  | Token signé | OWASP Signed Double-Submit Cookie | `CsrfTokenManager` (`csrfToken.ts:23`) |
330
330
  | Refus 403 | RFC 9110 §15.5.4 | `CsrfError` (`CsrfError.ts:17-21`) |
331
331
  | Modèle de référence | Go 1.25 `CrossOriginProtection` | TSDoc `Csrf` (`csrf.ts:38-41`) |
@@ -339,14 +339,14 @@ de session (TSDoc `CsrfTokenManager`, `csrfToken.ts:12-15`).
339
339
  - **Lazy** : `#csrf`/`#csrfTokens` restent `null` si la défense est désactivée — aucune structure
340
340
  allouée « au cas où » (`firewall.ts:164`).
341
341
  - Le coût HMAC (1 à l'émission, 1 à la vérif) n'est payé **que** sur les routes `@CsrfProtect` ; les
342
- marqueurs sont lus depuis le memo de route — 0 `Reflect` par requête (`Resolver.ts:162`).
342
+ marqueurs sont lus depuis le memo de route — 0 `Reflect` par requête (`Resolver.ts:205`).
343
343
 
344
344
  ## 📡 Observabilité — Studio
345
345
 
346
346
  L'écran **Firewall** de Studio expose la défense dans son onglet Défenses (`FirewallDefenses`,
347
347
  `Firewall.tsx:313-314`). La projection est **sans secret par construction** :
348
348
  `Firewall.#describeDefenses()` (`firewall.ts:591`) publie la config résolue, et `synchronizerToken`
349
- n'est que la **présence** du secret armé — jamais sa valeur (`firewall.ts:601`).
349
+ n'est que la **présence** du secret armé — jamais sa valeur (`firewall.ts:607`).
350
350
 
351
351
  ## ⚠️ Pièges (symptôme → cause → correction)
352
352
 
@@ -44,16 +44,24 @@ d'accès » :
44
44
 
45
45
  ```ts
46
46
  // nodefony.config.ts
47
- security: {
48
- resourceServer: {
49
- issuers: [
50
- {
51
- issuer: "https://auth.example.com/realms/mon-royaume",
52
- algorithms: ["RS256"],
47
+ import { defineConfig, use } from "nodefony";
48
+
49
+ export default defineConfig({
50
+ modules: [
51
+ "@nodefony/http",
52
+ "@nodefony/framework",
53
+ use("@nodefony/security", {
54
+ resourceServer: {
55
+ issuers: [
56
+ {
57
+ issuer: "https://auth.example.com/realms/mon-royaume",
58
+ algorithms: ["RS256"],
59
+ },
60
+ ],
53
61
  },
54
- ],
55
- },
56
- }
62
+ }),
63
+ ],
64
+ });
57
65
  ```
58
66
 
59
67
  Cela suffit : les clés publiques de l'émetteur sont découvertes par ses points de métadonnées
@@ -115,11 +123,12 @@ concernent d'après la liste d'émetteurs, puis délègue la vérification au se
115
123
  La liste sert donc à deux choses : router, et dire dans quel espace de noms lire le sujet.
116
124
 
117
125
  ```ts
118
- firewall: {
126
+ areas: {
119
127
  api: {
120
128
  pattern: "^/api",
121
129
  stateless: true,
122
130
  authenticators: ["external-jwt"],
131
+ resource: "https://api.example.com",
123
132
  },
124
133
  }
125
134
  ```