@nodefony/framework 10.0.0-beta.1 → 10.0.0-beta.3

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 (53) hide show
  1. package/README.md +1 -1
  2. package/dist/_virtual/{_@oxc-project_runtime@0.151.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorate.js +1 -1
  3. package/dist/_virtual/{_@oxc-project_runtime@0.151.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorateMetadata.js +1 -1
  4. package/dist/index.js +9 -5
  5. package/dist/nodefony/controller/AdminApiController.js +7 -4
  6. package/dist/nodefony/controller/ApiKeyController.js +5 -4
  7. package/dist/nodefony/controller/LoginPageController.js +124 -0
  8. package/dist/nodefony/controller/OAuth2Controller.js +131 -14
  9. package/dist/nodefony/controller/SessionAuthController.js +37 -9
  10. package/dist/nodefony/controller/TokenAuthController.js +13 -6
  11. package/dist/nodefony/controller/TotpController.js +7 -6
  12. package/dist/nodefony/controller/WebAuthnController.js +26 -9
  13. package/dist/nodefony/decorators/routerDecorators.js +114 -13
  14. package/dist/nodefony/service/AdminBroker.js +2 -2
  15. package/dist/nodefony/service/IdempotencyStore.js +2 -2
  16. package/dist/nodefony/service/router.js +54 -23
  17. package/dist/nodefony/src/Controller.js +49 -4
  18. package/dist/nodefony/src/FrameworkAdminApi.js +2 -1
  19. package/dist/nodefony/src/KernelAdminApi.js +76 -10
  20. package/dist/nodefony/src/Resolver.js +41 -4
  21. package/dist/nodefony/src/Route.js +76 -6
  22. package/dist/nodefony/src/SecurityApiController.js +24 -0
  23. package/dist/nodefony/src/docsReader.js +64 -41
  24. package/dist/nodefony/src/idempotency.js +13 -20
  25. package/dist/nodefony/src/loginPage.js +431 -0
  26. package/dist/nodefony/src/requireAuthentication.js +36 -0
  27. package/dist/types/index.d.ts +4 -3
  28. package/dist/types/nodefony/controller/ApiKeyController.d.ts +4 -3
  29. package/dist/types/nodefony/controller/LoginPageController.d.ts +32 -0
  30. package/dist/types/nodefony/controller/OAuth2Controller.d.ts +67 -8
  31. package/dist/types/nodefony/controller/SessionAuthController.d.ts +39 -8
  32. package/dist/types/nodefony/controller/TokenAuthController.d.ts +8 -6
  33. package/dist/types/nodefony/controller/TotpController.d.ts +4 -3
  34. package/dist/types/nodefony/controller/WebAuthnController.d.ts +12 -4
  35. package/dist/types/nodefony/decorators/routerDecorators.d.ts +88 -3
  36. package/dist/types/nodefony/interfaces/IRoute.d.ts +1 -2
  37. package/dist/types/nodefony/service/router.d.ts +13 -0
  38. package/dist/types/nodefony/src/Controller.d.ts +40 -1
  39. package/dist/types/nodefony/src/Resolver.d.ts +20 -1
  40. package/dist/types/nodefony/src/Route.d.ts +33 -2
  41. package/dist/types/nodefony/src/SecurityApiController.d.ts +17 -0
  42. package/dist/types/nodefony/src/docsReader.d.ts +26 -12
  43. package/dist/types/nodefony/src/idempotency.d.ts +12 -10
  44. package/dist/types/nodefony/src/loginPage.d.ts +144 -0
  45. package/dist/types/nodefony/src/requireAuthentication.d.ts +29 -0
  46. package/docs/admin.md +28 -23
  47. package/docs/controller.md +84 -82
  48. package/docs/decorateurs.md +77 -74
  49. package/docs/idempotence.md +31 -29
  50. package/docs/index.md +1 -1
  51. package/docs/routing.md +58 -57
  52. package/docs/templates.md +12 -12
  53. package/package.json +11 -12
@@ -52,7 +52,7 @@ flowchart TD
52
52
  2. **Au montage**, `controller()` (`routerDecorators.ts:189`) relit ces métadonnées et fabrique les
53
53
  objets `Route` ; `controllers()` (`routerDecorators.ts:111`) accroche le contrôleur au module sur
54
54
  le hook `onBoot` du kernel.
55
- 3. **À la première requête** de chaque route, `resolveActionMeta()` (`routerDecorators.ts:1669`)
55
+ 3. **À la première requête** de chaque route, `resolveActionMeta()` (`routerDecorators.ts:1772`)
56
56
  consolide toutes les étiquettes de l'action en **un objet figé** posé sur la route. Les requêtes
57
57
  suivantes ne lisent plus aucune métadonnée.
58
58
 
@@ -126,14 +126,14 @@ jamais surprendre.
126
126
  **1 — Un décorateur n'écrit QUE des métadonnées.** Aucun décorateur du framework ne contient de
127
127
  logique de sécurité, de session ou d'idempotence. `IsGranted()` (`routerDecorators.ts:903`) pose une
128
128
  clause ; c'est le `Resolver` qui appellera le moteur d'autorisation, **résolu par son nom** dans le
129
- conteneur (`Resolver._enforceSecurity()`, `Resolver.ts:719`). Pourquoi ce détour : `@nodefony/framework`
129
+ conteneur (`Resolver._enforceSecurity()`, `Resolver.ts:871`). Pourquoi ce détour : `@nodefony/framework`
130
130
  ne dépend **pas** de `@nodefony/security` — sans ça, les deux modules formeraient un cycle. Le prix à
131
131
  payer est visible : une route gardée alors que le module `security` est absent renvoie **403**, pas
132
- une erreur de démarrage (fail-closed, `Resolver.ts:724`).
132
+ une erreur de démarrage (fail-closed, `Resolver.ts:865`).
133
133
 
134
134
  **2 — Tout est figé une fois, puis relu en O(1).** Les métadonnées de l'action sont consolidées au
135
- premier passage dans `computeActionMeta()` (`routerDecorators.ts:1632`) puis gelées sur la route.
136
- L'objet `RouteActionMeta` (`routerDecorators.ts:1436`) est **partagé par toutes les requêtes** — le
135
+ premier passage dans `computeActionMeta()` (`routerDecorators.ts:1729`) puis gelées sur la route.
136
+ L'objet `RouteActionMeta` (`routerDecorators.ts:1537`) est **partagé par toutes les requêtes** — le
137
137
  framework ne le mute jamais, et ton code non plus. Une action non décorée obtient des champs à `null`,
138
138
  ce qui vaut **zéro branche** dans le chemin chaud.
139
139
 
@@ -259,7 +259,7 @@ curl -si -b /tmp/jar -X POST http://localhost:5151/api/books \
259
259
  # 4) Méthode non déclarée pour ce chemin → 405 avec l'agrégat des méthodes
260
260
  curl -si -X PUT http://localhost:5151/api/books/42 | head -2
261
261
  # HTTP/1.1 405 Method Not Allowed
262
- # Allow: GET, DELETE
262
+ # Allow: GET, HEAD, DELETE
263
263
  ```
264
264
 
265
265
  ## 🧰 La table de référence — toute la surface décorateur
@@ -274,7 +274,7 @@ Six familles, **36 décorateurs**, un seul fichier source. Le tableau de synthè
274
274
  | **Méthodes HTTP** | Quand l'action répond | `@Get` `@Post` `@Put` `@Patch` `@Delete` `@Options` `@Head` `@All` |
275
275
  | **Paramètres** | Ce que l'action reçoit en arguments | `@Param` `@Query` `@Body` `@Headers` `@Cookie` `@Session` `@CurrentUser` `@Req` `@Res` `@UploadedFile` `@UploadedFiles` |
276
276
  | **Réponse** | Statut, en-têtes, redirection | `@HttpCode` `@Header` `@Redirect` |
277
- | **Sécurité** | Qui passe, qui décide, quelles défenses | `@IsGranted` `@RequireScope` `@Anonymous` `@BypassFirewall` `@Csp` `@CsrfProtect` `@CsrfExempt` |
277
+ | **Sécurité** | Qui passe, qui décide, quelles défenses | `@IsGranted` `@RequireScope` `@RequireAuthentication` `@Anonymous` `@BypassFirewall` `@Csp` `@CsrfProtect` `@CsrfExempt` |
278
278
  | **Cycle de la requête** | Session, anti-rejeu | `@UseSession` `@Idempotent` |
279
279
 
280
280
  > Tous s'importent depuis `"@nodefony/framework"` — jamais par un chemin relatif interne.
@@ -287,7 +287,7 @@ Six familles, **36 décorateurs**, un seul fichier source. Le tableau de synthè
287
287
  | `@controllers([…])` | **module** | Rattache des contrôleurs au module sur le hook `onBoot` ; sans lui, aucune route n'est servie (`controllers()`, `routerDecorators.ts:111`) | `@controllers([BookController])` |
288
288
  | `@controller("/prefix")` | **classe** | Pose le préfixe d'URL **et déclenche la création des routes** de la classe (`controller()`, `routerDecorators.ts:189`) | `@controller("/api/books")` |
289
289
  | `@route(nom, options)` | méthode | Forme complète : nom explicite, chemin, `requirements`, `defaults`, hôte (`route()`, `routerDecorators.ts:287`) | `@route("ws-echo", { path: "/echo", requirements: { methods: ["WEBSOCKET"] } })` |
290
- | `@Domain(motif \| motifs)` | **dual** | Restreint la route (ou la classe) à un ou plusieurs vhosts ; hors domaine → **403** (`Domain()`, `routerDecorators.ts:664`) | `@Domain("*.cdn.example.com")` |
290
+ | `@Domain(motif \| motifs)` | **dual** | Restreint la route (ou la classe) à un ou plusieurs vhosts ; hors domaine → **403** (`Domain()`, `routerDecorators.ts:692`) | `@Domain("*.cdn.example.com")` |
291
291
  | `@Scope("request")` | **classe** | Une instance de contrôleur par requête — par connexion en WebSocket — au lieu de l'instance partagée par défaut (`Scope()`, `routerDecorators.ts:795`) | `@Scope("request")` |
292
292
 
293
293
  **`@controller` est le déclencheur.** Il relit les métadonnées posées par `@route`/`@Get`/… puis les
@@ -308,18 +308,18 @@ requête dans `initialize()`, ou garder un état sur `this`.
308
308
 
309
309
  ### Méthodes HTTP
310
310
 
311
- Toutes les fabriques sortent du même moule, `httpMethodDecorator()` (`routerDecorators.ts:489`) :
311
+ Toutes les fabriques sortent du même moule, `httpMethodDecorator()` (`routerDecorators.ts:494`) :
312
312
  elles nomment la route automatiquement `ClasseName::methode` et posent `requirements.methods`.
313
313
 
314
314
  | Décorateur | Méthode filtrée | Ancre | Exemple |
315
315
  | ------------------------ | --------------- | ------------------------------------- | ------------------- |
316
- | `@Get(path?, opts?)` | `GET` | `Get` (`routerDecorators.ts:535`) | `@Get("/{id}")` |
317
- | `@Post(path?, opts?)` | `POST` | `Post` (`routerDecorators.ts:536`) | `@Post("")` |
318
- | `@Put(path?, opts?)` | `PUT` | `Put` (`routerDecorators.ts:537`) | `@Put("/{id}")` |
319
- | `@Delete(path?, opts?)` | `DELETE` | `Delete` (`routerDecorators.ts:538`) | `@Delete("/{id}")` |
320
- | `@Patch(path?, opts?)` | `PATCH` | `Patch` (`routerDecorators.ts:539`) | `@Patch("/{id}")` |
321
- | `@Options(path?, opts?)` | `OPTIONS` | `Options` (`routerDecorators.ts:540`) | `@Options("/{id}")` |
322
- | `@Head(path?, opts?)` | `HEAD` | `Head` (`routerDecorators.ts:541`) | `@Head("/{id}")` |
316
+ | `@Get(path?, opts?)` | `GET` | `Get` (`routerDecorators.ts:540`) | `@Get("/{id}")` |
317
+ | `@Post(path?, opts?)` | `POST` | `Post` (`routerDecorators.ts:541`) | `@Post("")` |
318
+ | `@Put(path?, opts?)` | `PUT` | `Put` (`routerDecorators.ts:542`) | `@Put("/{id}")` |
319
+ | `@Delete(path?, opts?)` | `DELETE` | `Delete` (`routerDecorators.ts:543`) | `@Delete("/{id}")` |
320
+ | `@Patch(path?, opts?)` | `PATCH` | `Patch` (`routerDecorators.ts:544`) | `@Patch("/{id}")` |
321
+ | `@Options(path?, opts?)` | `OPTIONS` | `Options` (`routerDecorators.ts:545`) | `@Options("/{id}")` |
322
+ | `@Head(path?, opts?)` | `HEAD` | `Head` (`routerDecorators.ts:546`) | `@Head("/{id}")` |
323
323
  | `@All(path?, opts?)` | **aucune** | `All()` (`routerDecorators.ts:548`) | `@All("/proxy/*")` |
324
324
 
325
325
  Deux points qu'un dev découvre sinon à ses dépens :
@@ -342,33 +342,34 @@ async index(@Param("page") page: string) { /* … */ }
342
342
 
343
343
  ### Paramètres — ce que l'action reçoit
344
344
 
345
- Onze décorateurs, tous produits par `paramDecoratorFactory()` (`routerDecorators.ts:1191`) sauf
345
+ Onze décorateurs, tous produits par `paramDecoratorFactory()` (`routerDecorators.ts:1214`) sauf
346
346
  `@Body`, qui accepte une option supplémentaire. Chacun pose `{ source, key, index }` ; la valeur est
347
- calculée par `resolveParamArg()` (`routerDecorators.ts:1326`), une fonction **pure** — ce qui la rend
347
+ calculée par `resolveParamArg()` (`routerDecorators.ts:1358`), une fonction **pure** — ce qui la rend
348
348
  testable sans démarrer de serveur.
349
349
 
350
350
  | Décorateur | Sans clé renvoie… | Avec clé renvoie… | Ancre |
351
351
  | ------------------- | ------------------------------------ | ------------------------------------------ | -------------------------------------------- |
352
- | `@Param("id")` | toutes les variables d'URL (objet) | la variable d'URL nommée | `Param` (`routerDecorators.ts:1212`) |
353
- | `@Query("q")` | toute la query string | un paramètre de la query string | `Query` (`routerDecorators.ts:1213`) |
352
+ | `@Param("id")` | toutes les variables d'URL (objet) | la variable d'URL nommée | `Param` (`routerDecorators.ts:1238`) |
353
+ | `@Query("q")` | toute la query string | un paramètre de la query string | `Query` (`routerDecorators.ts:1239`) |
354
354
  | `@Body("field")` | le corps parsé entier | un champ du corps parsé | `Body()` (`routerDecorators.ts:1253`) |
355
- | `@Headers("x-foo")` | tous les en-têtes de requête | un en-tête (**lookup en minuscules**) | `Headers` (`routerDecorators.ts:1279`) |
356
- | `@Cookie("sid")` | la map des cookies | un cookie (objet `Cookie`, champ `.value`) | `Cookie` (`routerDecorators.ts:1280`) |
357
- | `@Session("user")` | l'objet `Session` vivant | `session.get(clé)` | `Session` (`routerDecorators.ts:1281`) |
358
- | `@CurrentUser()` | l'utilisateur résolu par le firewall | — | `CurrentUser` (`routerDecorators.ts:1283`) |
359
- | `@Req()` | la requête brute du contexte | — | `Req` (`routerDecorators.ts:1284`) |
360
- | `@Res()` | la réponse du contexte | — | `Res` (`routerDecorators.ts:1285`) |
361
- | `@UploadedFile()` | le **premier** fichier téléversé | — | `UploadedFile` (`routerDecorators.ts:1286`) |
362
- | `@UploadedFiles()` | tous les fichiers téléversés | — | `UploadedFiles` (`routerDecorators.ts:1287`) |
355
+ | `@Headers("x-foo")` | tous les en-têtes de requête | un en-tête (**lookup en minuscules**) | `Headers` (`routerDecorators.ts:1304`) |
356
+ | `@Cookie("sid")` | la map des cookies | un cookie (objet `Cookie`, champ `.value`) | `Cookie` (`routerDecorators.ts:1305`) |
357
+ | `@Session("user")` | l'objet `Session` vivant | `session.get(clé)` | `Session` (`routerDecorators.ts:1306`) |
358
+ | `@CurrentUser()` | l'utilisateur résolu par le firewall | — | `CurrentUser` (`routerDecorators.ts:1308`) |
359
+ | `@Req()` | la requête brute du contexte | — | `Req` (`routerDecorators.ts:1309`) |
360
+ | `@Res()` | la réponse du contexte | — | `Res` (`routerDecorators.ts:1310`) |
361
+ | `@UploadedFile()` | le **premier** fichier téléversé | — | `UploadedFile` (`routerDecorators.ts:1311`) |
362
+ | `@UploadedFiles()` | tous les fichiers téléversés | — | `UploadedFiles` (`routerDecorators.ts:1312`) |
363
363
 
364
- La liste des sources possibles est fermée et typée : `ParamSource` (`routerDecorators.ts:385`).
364
+ La liste des sources possibles est fermée et typée : `ParamSource` (`routerDecorators.ts:390`).
365
365
 
366
366
  #### Trois comportements à connaître
367
367
 
368
368
  **`@CurrentUser` lit l'ALS, jamais un argument caché.** La valeur vient de `RequestContext.getUser()`
369
- (`routerDecorators.ts:1279`) : l'utilisateur posé par le firewall. C'est **l'utilisateur**, jamais le
370
- justificatif (mot de passe, jeton). Hors zone authentifiée, la valeur est `undefined` — le décorateur
371
- n'authentifie rien, il expose ce qui a déjà été prouvé.
369
+ (`routerDecorators.ts:1308`) : l'utilisateur posé par le firewall. C'est **l'utilisateur**, jamais le
370
+ justificatif (mot de passe, jeton). Hors zone authentifiée, la valeur est `undefined` — y compris
371
+ pour l'anonyme d'une zone qui l'accepte — : le décorateur n'authentifie rien, il expose ce qui a déjà
372
+ été prouvé. Sans décorateur, la même valeur se lit par `this.user` dans le contrôleur.
372
373
 
373
374
  **`@Session` active la session à lui seul.** La simple présence d'un paramètre `@Session` vaut
374
375
  déclaration d'intention : `resolveSessionIntent()` (`routerDecorators.ts:858`) la détecte et pose
@@ -377,7 +378,7 @@ l'intent, exactement comme `@UseSession()`. Une route sans l'un ni l'autre ne pa
377
378
  **`@Body({ stream: true })` court-circuite le parsing.** Pour un gros téléversement (vidéo,
378
379
  sauvegarde), on injecte le **flux brut** de la requête au lieu du corps chargé en mémoire ; le
379
380
  pipeline saute alors le parsing pour cette route, décision prise en amont par
380
- `routeExpectsBodyStream()` (`routerDecorators.ts:1408`) :
381
+ `routeExpectsBodyStream()` (`routerDecorators.ts:1509`) :
381
382
 
382
383
  ```typescript
383
384
  @Post("/upload")
@@ -389,7 +390,7 @@ async upload(@Body({ stream: true }) stream: NodeJS.ReadableStream) {
389
390
 
390
391
  > [!TIP]
391
392
  > L'ordre d'écriture des paramètres décorés n'a aucune importance : chaque valeur est placée à son
392
- > **index déclaré** par `buildParamArgs()` (`routerDecorators.ts:1390`), et les trous restent
393
+ > **index déclaré** par `buildParamArgs()` (`routerDecorators.ts:1422`), et les trous restent
393
394
  > `undefined`. Tu peux mélanger décorés et non décorés — les non décorés reçoivent `undefined`.
394
395
 
395
396
  #### Le corps n'est pas validé — et c'est un choix
@@ -456,12 +457,12 @@ ne s'auto-promeut pas.
456
457
  | `@Header("X-Foo", "bar")` | méthode | Ajoute un en-tête ; **s'empile** (plusieurs `@Header` cumulent, `routerDecorators.ts:603`) | `@Header("Cache-Control","no-store")` |
457
458
  | `@Redirect("/url", 302)` | méthode | Redirige **si** l'action ne renvoie rien (`Redirect()`, `routerDecorators.ts:649`) | `@Redirect("/login", 302)` |
458
459
 
459
- Les deux premiers sont appliqués par `Resolver._applyResponseMeta()` (`Resolver.ts:821`) **avant**
460
+ Les deux premiers sont appliqués par `Resolver._applyResponseMeta()` (`Resolver.ts:977`) **avant**
460
461
  l'appel de l'action : ton code peut donc les écraser ensuite (`this.renderJson(data, 202)` gagne).
461
462
 
462
463
  `@Redirect` a une subtilité utile : si l'action **retourne un objet** portant `url` (et
463
464
  éventuellement `statusCode`), cet objet **prend le dessus** sur les valeurs du décorateur
464
- (`Resolver._handleRedirect()`, `Resolver.ts:837`) — la cible peut donc être calculée à l'exécution :
465
+ (`Resolver._handleRedirect()`, `Resolver.ts:1002`) — la cible peut donc être calculée à l'exécution :
465
466
 
466
467
  ```typescript
467
468
  @Get("/go")
@@ -482,20 +483,21 @@ async go(@Query("to") to?: string) {
482
483
  Sept décorateurs, **tous duals** (classe ou méthode) et **tous sans logique** : ils posent une
483
484
  étiquette que le `Resolver` ou le firewall consommera.
484
485
 
485
- | Décorateur | Effet | Ancre |
486
- | ---------------------------------------- | -------------------------------------------------------------------------------------------------------- | --------------------------------------------- |
487
- | `@IsGranted(attr \| attrs, { subject })` | Exige un attribut (rôle `ROLE_*` ou règle métier). Tableau = **OU** ; empilés = **ET** ; refus → **403** | `IsGranted()` (`routerDecorators.ts:903`) |
488
- | `@RequireScope(scope \| scopes)` | Exige un scope `api:action` d'un **jeton machine** ; no-op pour une session humaine | `RequireScope()` (`routerDecorators.ts:1002`) |
489
- | `@Anonymous()` | Rend l'action publique : annule l'autorisation **et** l'authentification (le « permitAll ») | `Anonymous()` (`routerDecorators.ts:953`) |
490
- | `@BypassFirewall` | Court-circuite le firewall (sonde de liveness, webhook signé, endpoint de login). **Sans parenthèses** | `BypassFirewall` (`routerDecorators.ts:725`) |
491
- | `@Csp({ "frame-src": [...] })` | Ajoute des directives CSP **à cette réponse** ; classe + méthode fusionnent additivement | `Csp()` (`routerDecorators.ts:1071`) |
492
- | `@CsrfProtect()` | Opt-**in** au jeton anti-CSRF (double-submit signé) en plus de la défense globale | `CsrfProtect` (`routerDecorators.ts:1133`) |
493
- | `@CsrfExempt()` | Opt-**out** de la défense CSRF **en gardant** l'authentification (webhook, POST cross-origin légitime) | `CsrfExempt` (`routerDecorators.ts:1142`) |
486
+ | Décorateur | Effet | Ancre |
487
+ | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ |
488
+ | `@IsGranted(attr \| attrs, { subject })` | Exige un attribut (rôle `ROLE_*` ou règle métier). Tableau = **OU** ; empilés = **ET** ; refus → **403** | `IsGranted()` (`routerDecorators.ts:903`) |
489
+ | `@RequireScope(scope \| scopes)` | Exige un scope `api:action` d'un **jeton machine** ; no-op pour une session humaine | `RequireScope()` (`routerDecorators.ts:1002`) |
490
+ | `@RequireAuthentication({ maxAge, amr })` | Exige une connexion **récente** (`maxAge`, s) et/ou faite avec certains moyens (`amr` RFC 8176, ex. `otp`) ; évalué après `@IsGranted` ; refus → **401** RFC 9470 | `RequireAuthentication()` (`routerDecorators.ts:1883`) |
491
+ | `@Anonymous()` | Rend l'action publique : annule l'autorisation **et** l'authentification (le « permitAll ») | `Anonymous()` (`routerDecorators.ts:969`) |
492
+ | `@BypassFirewall` | Court-circuite le firewall (sonde de liveness, webhook signé, endpoint de login). **Sans parenthèses** | `BypassFirewall` (`routerDecorators.ts:725`) |
493
+ | `@Csp({ "frame-src": [...] })` | Ajoute des directives CSP **à cette réponse** ; classe + méthode fusionnent additivement | `Csp()` (`routerDecorators.ts:1092`) |
494
+ | `@CsrfProtect()` | Opt-**in** au jeton anti-CSRF (double-submit signé) en plus de la défense globale | `CsrfProtect` (`routerDecorators.ts:1156`) |
495
+ | `@CsrfExempt()` | Opt-**out** de la défense CSRF **en gardant** l'authentification (webhook, POST cross-origin légitime) | `CsrfExempt` (`routerDecorators.ts:1165`) |
494
496
 
495
497
  #### Rôles et scopes — deux axes, un seul verdict
496
498
 
497
499
  `@IsGranted` et `@RequireScope` écrivent dans **deux jeux de métadonnées distincts**, puis
498
- `computeSecurityRequirement()` (`routerDecorators.ts:1544`) les fusionne en une exigence unique dont
500
+ `computeSecurityRequirement()` (`routerDecorators.ts:1648`) les fusionne en une exigence unique dont
499
501
  toutes les clauses sont en **ET**. Une seule chaîne d'application côté `Resolver`, deux jurés
500
502
  différents côté `security` (le voteur de rôles, le voteur de scopes).
501
503
 
@@ -542,15 +544,16 @@ héritées de la classe.
542
544
 
543
545
  ### Cycle de la requête — session et anti-rejeu
544
546
 
545
- | Décorateur | Cible | Effet | Ancre |
546
- | ------------------------------------ | ----- | ---------------------------------------------------------------------------------------------------------- | --------------------------------- |
547
- | `@UseSession({ readOnly?, eager? })` | dual | Déclare le besoin d'une session serveur ; **méthode > classe** (`UseSession()`, `routerDecorators.ts:800`) | `@UseSession({ readOnly: true })` |
548
- | `@Idempotent({ required? })` | dual | Protège une mutation du double effet via `Idempotency-Key` (`Idempotent()`, `routerDecorators.ts:1171`) | `@Idempotent()` |
547
+ | Décorateur | Cible | Effet | Ancre |
548
+ | ---------------------------- | ----- | ---------------------------------------------------------------------------------------------------------- | --------------------------------- |
549
+ | `@UseSession({ readOnly? })` | dual | Déclare le besoin d'une session serveur ; **méthode > classe** (`UseSession()`, `routerDecorators.ts:830`) | `@UseSession({ readOnly: true })` |
550
+ | `@Idempotent({ required? })` | dual | Protège une mutation du double effet via `Idempotency-Key` (`Idempotent()`, `routerDecorators.ts:1171`) | `@Idempotent()` |
549
551
 
550
552
  **`@UseSession` est la seule façon d'ouvrir une session** (avec un paramètre `@Session`, ou la reprise
551
553
  d'un cookie existant). Il n'existe plus de « démarrer partout » global : une route qui ne déclare rien
552
- ne coûte aucune lecture de stockage. Les deux options sont `readOnly` (lire sans jamais persister —
553
- zéro écriture) et `eager` (activer tôt, pour régénérer l'identifiant juste après une authentification).
554
+ ne coûte aucune lecture de stockage. Une seule option : `readOnly` (lire sans jamais persister —
555
+ zéro écriture). La régénération de l'identifiant après authentification est faite par le login
556
+ (`AuthFlow`), pas par une option du décorateur.
554
557
  La forme exacte est celle de `SessionIntent` (`ISession.ts:18`).
555
558
 
556
559
  **`@Idempotent` est strict par défaut** : une mutation sans `Idempotency-Key` reçoit **400**. Le mode
@@ -570,9 +573,9 @@ recherches inutiles.
570
573
  | `@injectable()` | `nodefony` | Rend une classe résoluble par le conteneur (`injectable()`, `kernelDecorator.ts:135`) |
571
574
  | `@inject("nom")` | `nodefony` | Injecte un service à une position de constructeur (`inject()`, `kernelDecorator.ts:186`) |
572
575
  | `@Inject("nom")` | `nodefony` | Idem, sur une propriété (`Inject()`, `kernelDecorator.ts:215`) |
573
- | `@RealtimeAction("méthode")` | `@nodefony/realtime` | Expose une action JSON-RPC sur socket (`RealtimeAction()`, `realtimeDecorators.ts:101`) |
574
- | `@RealtimeChannel("canal", policy)` | `@nodefony/realtime` | Déclare un canal temps réel et sa politique (`RealtimeChannel()`, `realtimeDecorators.ts:192`) |
575
- | `@RealtimeInbound("méthode")` | `@nodefony/realtime` | Traite un message entrant typé (`RealtimeInbound()`, `realtimeDecorators.ts:231`) |
576
+ | `@RealtimeAction("méthode")` | `@nodefony/realtime` | Expose une action JSON-RPC sur socket (`RealtimeAction()`, `realtimeDecorators.ts:191`) |
577
+ | `@RealtimeChannel("canal", policy)` | `@nodefony/realtime` | Déclare un canal temps réel et sa politique (`RealtimeChannel()`, `realtimeDecorators.ts:240`) |
578
+ | `@RealtimeInbound("méthode")` | `@nodefony/realtime` | Traite un message entrant typé (`RealtimeInbound()`, `realtimeDecorators.ts:278`) |
576
579
 
577
580
  Injection et portées → [injection-portees](../../../../../docs/architecture/injection-portees.md) ·
578
581
  socket → [realtime](../../realtime/docs/index.md).
@@ -619,11 +622,11 @@ Trois faits à retenir :
619
622
  `@route`, ou via `@All("/x", { requirements: { methods: ["WEBSOCKET"] } })` si tu préfères la forme
620
623
  courte. Les fabriques `@Get`/`@Post`… **fusionnent** les méthodes déclarées avec la leur :
621
624
  `@Get("/x", { requirements: { methods: ["WEBSOCKET"] } })` sert GET **et** WebSocket
622
- (`routerDecorators.ts:535`).
625
+ (`routerDecorators.ts:540`).
623
626
  - **Les décorateurs de paramètre fonctionnent pareil.** Pour une invocation par socket, le corps de
624
627
  la mutation voyage dans l'ALS et **prime** sur le corps HTTP (vide dans ce cas) — c'est traité dans
625
- `resolveParamArg()` (`routerDecorators.ts:1326`), et `@Query` lit la query du chemin **invoqué**,
626
- pas celle du handshake (`Resolver._buildParamArgs()`, `Resolver.ts:795`).
628
+ `resolveParamArg()` (`routerDecorators.ts:1358`), et `@Query` lit la query du chemin **invoqué**,
629
+ pas celle du handshake (`Resolver._buildParamArgs()`, `Resolver.ts:947`).
627
630
  - **Les gardes s'appliquent identiquement.** `@IsGranted` protège une action joignable par socket
628
631
  exactement comme une action HTTP : la décision est prise avant l'instanciation, quel que soit le
629
632
  transport.
@@ -642,11 +645,11 @@ pas toutes identiques — c'est la source d'erreur n°1.
642
645
  | `@Domain` | option `host` de la route > méthode > classe | `controller()` (`routerDecorators.ts:189`) |
643
646
  | `@BypassFirewall` | **cumulatif** : `true` de la route, de la méthode ou de la classe suffit | `routerDecorators.ts:750` |
644
647
  | `@UseSession` | méthode > classe (fusion des champs) | `resolveSessionIntent()` (`routerDecorators.ts:858`) |
645
- | `@Idempotent` | méthode > classe | `computeIdempotent()` (`routerDecorators.ts:1606`) |
646
- | `@IsGranted` / `@RequireScope` | **cumul en ET** : classe **plus** méthode | `computeSecurityRequirement()` (`routerDecorators.ts:1544`) |
647
- | `@Anonymous` | méthode → annule tout ce que la classe a posé | `routerDecorators.ts:953` |
648
- | `@Csp` | fusion **additive** classe + méthode (sources concaténées) | `mergeCspDirectives()` (`routerDecorators.ts:1043`) |
649
- | `@CsrfProtect` / `@CsrfExempt` | OU logique : classe **ou** méthode suffit | `computeActionMeta()` (`routerDecorators.ts:1632`) |
648
+ | `@Idempotent` | méthode > classe | `computeIdempotent()` (`routerDecorators.ts:1710`) |
649
+ | `@IsGranted` / `@RequireScope` | **cumul en ET** : classe **plus** méthode | `computeSecurityRequirement()` (`routerDecorators.ts:1648`) |
650
+ | `@Anonymous` | méthode → annule tout ce que la classe a posé | `routerDecorators.ts:969` |
651
+ | `@Csp` | fusion **additive** classe + méthode (sources concaténées) | `mergeCspDirectives()` (`routerDecorators.ts:1066`) |
652
+ | `@CsrfProtect` / `@CsrfExempt` | OU logique : classe **ou** méthode suffit | `computeActionMeta()` (`routerDecorators.ts:1729`) |
650
653
  | `@Header` | s'empile (plusieurs en-têtes) ; même clé → dernier écrit gagne | `Header()` (`routerDecorators.ts:603`) |
651
654
  | `@HttpCode` | un seul par action (le dernier posé écrase) | `HttpCode()` (`routerDecorators.ts:582`) |
652
655
 
@@ -691,7 +694,7 @@ sequenceDiagram
691
694
  RS->>RS: requêtes suivantes : lecture O(1), 0 Reflect
692
695
  ```
693
696
 
694
- Le snapshot `RouteActionMeta` (`routerDecorators.ts:1436`) regroupe **tout** ce que les décorateurs
697
+ Le snapshot `RouteActionMeta` (`routerDecorators.ts:1537`) regroupe **tout** ce que les décorateurs
695
698
  ont dit de l'action :
696
699
 
697
700
  <!-- prettier-ignore -->
@@ -709,12 +712,12 @@ ont dit de l'action :
709
712
 
710
713
  Le `Resolver` consomme ce snapshot dans un ordre qui a du sens sécurité :
711
714
  **garde d'abord, instanciation ensuite**. `security !== null` déclenche
712
- `_enforceSecurity()` (`Resolver.ts:719`) **avant** `newController()` — un `403` n'instancie pas le
715
+ `_enforceSecurity()` (`Resolver.ts:871`) **avant** `newController()` — un `403` n'instancie pas le
713
716
  contrôleur et n'exécute pas son `initialize()`. Puis viennent les arguments
714
- (`_buildParamArgs()`, `Resolver.ts:795`), les métadonnées de réponse
715
- (`_applyResponseMeta()`, `Resolver.ts:821`), l'action, et enfin la redirection éventuelle.
717
+ (`_buildParamArgs()`, `Resolver.ts:947`), les métadonnées de réponse
718
+ (`_applyResponseMeta()`, `Resolver.ts:977`), l'action, et enfin la redirection éventuelle.
716
719
 
717
- Un usage cold path mérite d'être connu : `extractActionScopes()` (`routerDecorators.ts:1520`) parcourt
720
+ Un usage cold path mérite d'être connu : `extractActionScopes()` (`routerDecorators.ts:1624`) parcourt
718
721
  les routes au démarrage pour bâtir le **catalogue des scopes déclarés** — le formulaire de création
719
722
  de clés API dans Studio propose les scopes réellement utilisés par le code, jamais une liste
720
723
  maintenue à part.
@@ -723,15 +726,15 @@ maintenue à part.
723
726
 
724
727
  Un décorateur non employé doit coûter **zéro**. C'est tenu par trois mécanismes vérifiables :
725
728
 
726
- - **Lecture unique.** `resolveActionMeta()` (`routerDecorators.ts:1669`) mémorise le snapshot sur la
729
+ - **Lecture unique.** `resolveActionMeta()` (`routerDecorators.ts:1772`) mémorise le snapshot sur la
727
730
  route au premier passage — ensuite, plus aucun appel `Reflect.getMetadata` ni `Object.entries` par
728
731
  requête. Le même schéma vaut pour la détection du flux brut
729
- (`routeExpectsBodyStream()`, `routerDecorators.ts:1408`).
732
+ (`routeExpectsBodyStream()`, `routerDecorators.ts:1509`).
730
733
  - **`null` plutôt que structure vide.** Une action sans garde a `security: null` : le `Resolver` teste
731
- un `null` et passe — ni résolution de service, ni `await`, ni allocation (`Resolver.ts:406`). Idem
734
+ un `null` et passe — ni résolution de service, ni `await`, ni allocation (`Resolver.ts:490`). Idem
732
735
  pour `idempotent`, `cspDirectives`, `paramsMeta`.
733
736
  - **Objets gelés et partagés.** Les exigences de sécurité et d'idempotence sont créées **une fois** et
734
- `Object.freeze`-ées (`routerDecorators.ts:1496`, `:1340`) : une seule instance pour la durée de vie
737
+ `Object.freeze`-ées (`routerDecorators.ts:1650`, `:1696`) : une seule instance pour la durée de vie
735
738
  du processus, quelle que soit la charge. Corollaire : ne les mute jamais.
736
739
 
737
740
  Coût résiduel côté montage seulement : la reconstruction de la pile d'appels dans `route()`
@@ -742,7 +745,7 @@ route, jamais pendant une requête.
742
745
 
743
746
  Le module montre le patron à suivre : un décorateur maison **ne fait qu'écrire une métadonnée**, et
744
747
  un point du pipeline la relit. Pour un simple drapeau dual (classe + méthode), le framework fournit
745
- déjà la fabrique `booleanMarkerDecorator()` (`routerDecorators.ts:1108`), dont `@CsrfProtect` et
748
+ déjà la fabrique `booleanMarkerDecorator()` (`routerDecorators.ts:1131`), dont `@CsrfProtect` et
746
749
  `@CsrfExempt` sont les deux usages.
747
750
 
748
751
  Le squelette d'un drapeau maison, en dehors du framework :
@@ -795,7 +798,7 @@ L'écran **Routes** (`/nodefony/routes`) et le point d'API `/nodefony/framework/
795
798
  | Symptôme | Cause (dans le code) | Correction |
796
799
  | --- | --- | --- |
797
800
  | `404` sur une route pourtant décorée | Contrôleur jamais importé, ou absent de `@controllers([…])` | L'ajouter au tableau `@controllers` du module |
798
- | `Action « remove » … : ce nom est RÉSERVÉ` au démarrage — ou `TS2416` au build | L'action reprend le nom d'un membre de `Controller` : la classe étend `Service`, qui expose déjà `remove(name): boolean` ([`Service.ts:545`](../../../../nodefony/src/Service.ts)), `set`, `get`, `clean`… Le décorateur refuse le nom avant que le conflit n'atteigne le compilateur. | Renommer l'action (`destroy`, `deleteOne`…). Le nom d'une action est libre : c'est le chemin du décorateur qui fait l'URL. |
801
+ | `Action « remove » … : ce nom est RÉSERVÉ` au démarrage — ou `TS2416` au build | L'action reprend le nom d'un membre de `Controller` : la classe étend `Service`, qui expose déjà `remove(name): boolean` ([`Service.ts:608`](../../../../nodefony/src/Service.ts)), `set`, `get`, `clean`… Le décorateur refuse le nom avant que le conflit n'atteigne le compilateur. | Renommer l'action (`destroy`, `deleteOne`…). Le nom d'une action est libre : c'est le chemin du décorateur qui fait l'URL. |
799
802
  | `404` après avoir déplacé `@controller` sous `@Domain` | `@controller` monte les routes ; les décorateurs lus au montage doivent être **sous** | Remettre `@controller` en **premier** (le plus haut) |
800
803
  | Le vhost de `@Domain` classe est ignoré | `@Domain` placé **au-dessus** de `@controller` → posé trop tard | Placer `@Domain` sous `@controller` |
801
804
  | `@BypassFirewall` n'ouvre rien | Écrit **avec** parenthèses — c'est un drapeau, pas une fabrique | `@BypassFirewall` (sans `()`) |
@@ -53,7 +53,7 @@ stateDiagram-v2
53
53
  ```
54
54
 
55
55
  Les quatre verdicts du store et leur traduction, décidés par `evaluateIdempotency()`
56
- (`idempotency.ts:142`) :
56
+ (`idempotency.ts:131`) :
57
57
 
58
58
  - **fresh** → tu détiens la réservation : exécute l'action, puis **obligatoirement** `complete(clé,
59
59
  réponse)` (succès) ou `abort(clé)` (échec). Verdict rendu : `guarded`.
@@ -98,7 +98,7 @@ Sans ce ticket, trois choses cassent :
98
98
  donc il **doit** retenter ; c'est au serveur de rendre le retry inoffensif.
99
99
  2. **IDOR sur le cache** — si la clé stockée était la clé brute du client, il suffirait de deviner
100
100
  `order-42` pour lire la **réponse mémorisée d'un autre utilisateur**. Nodefony stocke
101
- `JSON.stringify([identité, cléClient])` (`idempotency.ts:182`) : l'anti-IDOR n'est pas un contrôle
101
+ `JSON.stringify([identité, cléClient])` (`idempotency.ts:171`) : l'anti-IDOR n'est pas un contrôle
102
102
  ajouté, il est **structurel**, encodé dans la clé de cache. Le JSON sert de frontière non ambiguë
103
103
  (aucun séparateur magique qui pourrait entrer en collision).
104
104
  3. **DoS du cache** — une clé arbitrairement longue, ou un flot de clés uniques, remplirait la
@@ -114,14 +114,14 @@ Sans ce ticket, trois choses cassent :
114
114
  ## La vision Nodefony
115
115
 
116
116
  Le constat de conception qui compte : la sémantique IETF (quels statuts, quel scope, quelle
117
- empreinte) est décidée dans **un helper pur**, `evaluateIdempotency()` (`idempotency.ts:142`), qui ne
117
+ empreinte) est décidée dans **un helper pur**, `evaluateIdempotency()` (`idempotency.ts:131`), qui ne
118
118
  connaît **aucun transport**. Il rend un verdict neutre (`IdempotencyVerdict`, `idempotency.ts:50`)
119
119
  que **deux** appelants traduisent dans leur monde :
120
120
 
121
121
  - le **data plane admin** — `AdminApiController.idempotencyGate()`
122
- (`AdminApiController.ts:131`) → réponse `{status, headers, body}` ;
122
+ (`AdminApiController.ts:155`) → réponse `{status, headers, body}` ;
123
123
  - les **controllers userland** décorés `@Idempotent` — seam `Resolver._callWithIdempotency()`
124
- (`Resolver.ts:537`) → `nodefonyError` typée, ou réponse rejouée.
124
+ (`Resolver.ts:687`) → `nodefonyError` typée, ou réponse rejouée.
125
125
 
126
126
  Conséquence pratique : il est **impossible** que l'idempotence HTTP et l'idempotence WebSocket
127
127
  divergent — elles partagent la même fonction. C'est le différenciateur du framework (HTTP + WS
@@ -223,7 +223,7 @@ curl -si -X POST http://localhost:5151/api/payments/charge \
223
223
  > [!WARNING]
224
224
  > **Le piège n°1** : en verdict `fresh`, la réservation est _in-flight_ tant que `complete` **ou**
225
225
  > `abort` n'a pas été appelé. Le seam `@Idempotent` gère ce couple pour toi (`try/catch`,
226
- > `Resolver.ts:578` et `Resolver.ts:563`). Si tu appelles le store **à la main** (cas avancé), c'est
226
+ > `Resolver.ts:764` et `Resolver.ts:788`). Si tu appelles le store **à la main** (cas avancé), c'est
227
227
  > **ta** responsabilité : sans `abort` sur erreur, la clé reste bloquée jusqu'à l'expiration du bail
228
228
  > (60 s), et tout rejeu identique reçoit `409` pendant ce temps.
229
229
 
@@ -238,7 +238,7 @@ une route que d'anciens clients appellent déjà sans clé, le temps de la migra
238
238
  async subscribe(@Body() body: SubscribeInput) { /* … */ }
239
239
  ```
240
240
 
241
- Précédence **méthode > classe** (`computeIdempotent()`, `routerDecorators.ts:1606`), comme
241
+ Précédence **méthode > classe** (`computeIdempotent()`, `routerDecorators.ts:1710`), comme
242
242
  `@UseSession`. Poser `@Idempotent()` sur la **classe** couvre toutes les mutations du controller ;
243
243
  une méthode peut resserrer ou relâcher le mode. Les méthodes sûres (GET…) restent des no-op même
244
244
  sous une classe décorée.
@@ -264,9 +264,9 @@ Deux mécanismes rendent ça possible côté socket :
264
264
  - **La méthode logique voyage par `methodOverride`** — sur une socket, `context.method` vaut
265
265
  `WEBSOCKET`, qui n'est **pas** une mutation. Sans override, la porte serait sautée et un rejeu de
266
266
  frame `socket.mutate` créerait un doublon. Le Resolver teste donc
267
- `isMutationMethod(this.methodOverride ?? context.method)` (`Resolver.ts:126`).
267
+ `isMutationMethod(this.methodOverride ?? context.method)` (`Resolver.ts:130`).
268
268
 
269
- Le pont utilise `executeActionGuarded()` (`Resolver.ts:502`) : porte d'idempotence **sans** rendu HTTP
269
+ Le pont utilise `executeActionGuarded()` (`Resolver.ts:665`) : porte d'idempotence **sans** rendu HTTP
270
270
  — la valeur nue est enveloppée par le peer WS, jamais écrite sur un transport HTTP.
271
271
 
272
272
  ## 🏗️ Architecture interne
@@ -299,20 +299,22 @@ sequenceDiagram
299
299
 
300
300
  ### Le parcours d'une mutation, étape par étape
301
301
 
302
- 1. **Court-circuit hot path.** `callController()` (`Resolver.ts:484`) lit `meta.idempotent` sur les
302
+ 1. **Court-circuit hot path.** `callController()` (`Resolver.ts:618`) lit `meta.idempotent` sur les
303
303
  métadonnées d'action **figées par route**. `null` sur la quasi-totalité des routes → une
304
304
  comparaison, flux normal, **zéro** lookup de store et zéro allocation.
305
305
  2. **No-op sur méthode sûre.** Une action `GET` sous une classe `@Idempotent` repart directement en
306
- exécution (`Resolver.ts:473`).
307
- 3. **Empreinte du payload.** `computeFingerprint()` (`idempotency.ts:123`) hache
308
- `[nom de route, params de route, corps]` (`Resolver.ts:497`). Le corps vient de l'ALS (pont WS) ou
306
+ exécution (`Resolver.ts:693`).
307
+ 3. **Empreinte du payload.** `computeFingerprint()` (`idempotency.ts:112`) hache
308
+ `[nom de route, params de route, corps]` (`Resolver.ts:501`). Le corps vient de l'ALS (pont WS) ou
309
309
  du body HTTP parsé.
310
- 4. **Identité.** `resolveIdentity()` (`idempotency.ts:100`) dérive l'identité de `request.user`
311
- (`username` → `identifier` → `id`), avec repli sur l'`userId` de l'ALS. **`null` = pas de cache** :
312
- le verdict devient `execute` (`idempotency.ts:176`) — jamais de partage cross-identité.
310
+ 4. **Identité.** `resolveIdentity()` (`idempotency.ts:102`) rend l'`identifier` de l'utilisateur
311
+ AUTHENTIFIÉ de la requête (`RequestContext.getAuthenticatedUser()`, la règle unique du cœur).
312
+ L'anonyme d'une zone `anonymous` n'a pas d'identité : tous les visiteurs anonymes partagent le
313
+ même `identifier`, ils partageraient donc le même cache. **`null` = pas de cache** : le verdict
314
+ devient `execute` — jamais de partage cross-identité.
313
315
  5. **Réservation.** `store.begin()` compose la clé scopée et tranche.
314
316
  6. **Mémorisation.** En succès, `complete(clé, {status, body})` où `status` est le code de réponse
315
- courant et `body` la **valeur retournée** par l'action (`Resolver.ts:570`). En erreur,
317
+ courant et `body` la **valeur retournée** par l'action (`Resolver.ts:722`). En erreur,
316
318
  `abort(clé)` libère la clé : **un échec ne se mémorise pas**, il doit rester réessayable.
317
319
 
318
320
  > [!CAUTION]
@@ -321,15 +323,15 @@ sequenceDiagram
321
323
  > double-effet reste évité, mais le corps rejoué est vide. Pire, si le corps retourné n'est pas
322
324
  > sérialisable (retour d'un objet Response circulaire), un store SQL/Redis lève au `stringify`. Le
323
325
  > Resolver attrape ce cas : il **journalise un WARNING explicite** puis mémorise le statut avec un
324
- > corps `null` (`Resolver.ts:549`) — la dédup est préservée plutôt que perdue en silence.
326
+ > corps `null` (`Resolver.ts:554`) — la dédup est préservée plutôt que perdue en silence.
325
327
 
326
328
  ### Où la porte est câblée
327
329
 
328
330
  | Appelant | Point d'entrée | Traduction du verdict |
329
331
  | ---------------------------- | -------------------------------------------------------------------- | ---------------------------------- |
330
- | Controller userland HTTP | `callController()` (`Resolver.ts:484`) | `nodefonyError` + rendu normal |
331
- | Controller userland via WS | `executeActionGuarded()` (`Resolver.ts:502`) | valeur nue, enveloppée par le peer |
332
- | Data plane admin `/nodefony` | `AdminApiController.idempotencyGate()` (`AdminApiController.ts:131`) | `{status, headers, body}` |
332
+ | Controller userland HTTP | `callController()` (`Resolver.ts:618`) | `nodefonyError` + rendu normal |
333
+ | Controller userland via WS | `executeActionGuarded()` (`Resolver.ts:665`) | valeur nue, enveloppée par le peer |
334
+ | Data plane admin `/nodefony` | `AdminApiController.idempotencyGate()` (`AdminApiController.ts:155`) | `{status, headers, body}` |
333
335
 
334
336
  ## ⚙️ Configuration
335
337
 
@@ -472,7 +474,7 @@ déjà Postgres mais pas Redis obtient la dédup cross-pod **sans nouvelle infra
472
474
  renvoie `fresh` (sans dédup), le reste est no-op.
473
475
 
474
476
  Le câblage est **automatique** : charger `@nodefony/drizzle` enregistre l'entité **et** la fabrique
475
- (`registerStores.ts:316`). Activation = `store: "drizzle"` (ou `NF_IDEMPOTENCY_STORE=drizzle`), rien
477
+ (`registerStores.ts:316`). Activation = `store: "drizzle"` (ou `NF_STORE=drizzle`, qui force toutes les briques `auto`), rien
476
478
  d'autre à écrire.
477
479
 
478
480
  ### Le GC — armé pour un seul store
@@ -554,8 +556,8 @@ cycle. La porte est appliquée par le Resolver.
554
556
  | `size` | ✅ | Nombre d'entrées vivantes, **sync best-effort** (`src/nodefony/src/types/IIdempotencyStore.ts:159`). |
555
557
 
556
558
  Les helpers du seam sont exportés et réutilisables : `isMutationMethod()` (`idempotency.ts:28`),
557
- `resolveIdempotencyKey()` (`idempotency.ts:68`), `resolveIdentity()` (`idempotency.ts:100`),
558
- `computeFingerprint()` (`idempotency.ts:123`), `evaluateIdempotency()` (`idempotency.ts:142`).
559
+ `resolveIdempotencyKey()` (`idempotency.ts:68`), `resolveIdentity()` (`idempotency.ts:102`),
560
+ `computeFingerprint()` (`idempotency.ts:112`), `evaluateIdempotency()` (`idempotency.ts:131`).
559
561
 
560
562
  ### `listPage` — capacités RÉELLES par store
561
563
 
@@ -636,9 +638,9 @@ pour l'affichage Studio, `idempotencyStoreRegistry.ts:81`).
636
638
  <!-- prettier-ignore -->
637
639
  | Sujet | Norme | Ancrage |
638
640
  | --- | --- | --- |
639
- | En-tête `Idempotency-Key`, statuts, rejeu | `draft-ietf-httpapi-idempotency-key-header-06` | `evaluateIdempotency()` (`idempotency.ts:142`) |
641
+ | En-tête `Idempotency-Key`, statuts, rejeu | `draft-ietf-httpapi-idempotency-key-header-06` | `evaluateIdempotency()` (`idempotency.ts:131`) |
640
642
  | Clé réutilisée avec un autre payload → 422 | draft §2.2 / §2.7 | `idempotency.ts:189` |
641
- | Exécution concurrente identique → 409 | draft §2.6 | `idempotency.ts:197` |
643
+ | Exécution concurrente identique → 409 | draft §2.6 | `idempotency.ts:186` |
642
644
  | Clé requise absente → 400 | draft §2.7 | `idempotency.ts:165` |
643
645
  | Méthodes non sûres = mutations | RFC 9110 §9.2.1 | `MUTATION_METHODS` (`idempotency.ts:25`) |
644
646
  | Sémantique du 422 | RFC 9110 §15.5.21 | `IdempotencyVerdict` (`idempotency.ts:50`) |
@@ -647,8 +649,8 @@ pour l'affichage Studio, `idempotencyStoreRegistry.ts:81`).
647
649
  ## ⚡ Performance et mémoire
648
650
 
649
651
  Le coût est **nul hors mutations décorées**. Sans `@Idempotent`, `RouteActionMeta.idempotent` vaut
650
- `null` (`routerDecorators.ts:1151`) : `callController()` fait **une comparaison** et repart en flux
651
- normal — zéro lookup de container, zéro `await` supplémentaire, zéro allocation (`Resolver.ts:473`).
652
+ `null` (`routerDecorators.ts:1194`) : `callController()` fait **une comparaison** et repart en flux
653
+ normal — zéro lookup de container, zéro `await` supplémentaire, zéro allocation (`Resolver.ts:618`).
652
654
  La métadonnée est **figée par route** et mémoïsée : aucune lecture `Reflect` par requête.
653
655
 
654
656
  Sur le chemin décoré :
@@ -691,7 +693,7 @@ Trois surfaces existent aujourd'hui :
691
693
 
692
694
  | Symptôme | Cause (dans le code) | Correction |
693
695
  | ------------------------------------------------------ | ------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------- |
694
- | Rejeu qui renvoie un corps **vide** | L'action a retourné `this.renderJson(...)` au lieu du payload (`Resolver.ts:628`) | Retourner la **valeur brute** ; un WARNING le signale déjà dans les logs |
696
+ | Rejeu qui renvoie un corps **vide** | L'action a retourné `this.renderJson(...)` au lieu du payload (`Resolver.ts:778`) | Retourner la **valeur brute** ; un WARNING le signale déjà dans les logs |
695
697
  | `409` en boucle sur un endpoint | Action qui lève avant `complete`/`abort` → in-flight bloqué jusqu'au bail (60 s) | Le seam le gère ; en usage manuel du store, `try/finally` obligatoire |
696
698
  | `422 Idempotency-Key is already used` | Même clé, **payload différent** (empreinte ≠, `idempotency.ts:189`) | Une clé = une intention ; nouvelle clé par requête distincte |
697
699
  | Rien n'est dédupliqué **malgré** la clé | Pas d'identité fiable → verdict `execute` (`idempotency.ts:176`) | S'assurer que le firewall a résolu l'utilisateur **avant** la mutation |
package/docs/index.md CHANGED
@@ -74,7 +74,7 @@ Le tableau pour choisir vite ; les cards en dessous pour savoir ce qu'on y trouv
74
74
  "desc": "Une table ordonnée où le premier motif qui correspond gagne : l'arbitrage sans score de spécificité, la partition littéral/dynamique qui accélère sans changer la sémantique, le 405 et son en-tête Allow, les vhosts, le duplex HTTP+WebSocket sur un même chemin.",
75
75
  "meta": "deux routes se disputent, ou un 404/405 surprend" },
76
76
  { "icon": "🔁", "title": "idempotence", "href": "idempotence.md",
77
- "desc": "@Idempotent, la clé d'idempotence, les trois stores et leurs capacités réelles, le GC des entrées expirées, et ce que le client observe quand il rejoue la même clé.",
77
+ "desc": "@Idempotent, la clé d'idempotence, les quatre stores (mémoire, Redis, SQL, MongoDB) et leurs capacités réelles, le GC des entrées expirées, et ce que le client observe quand il rejoue la même clé.",
78
78
  "meta": "paiement, commande, tout effet non rejouable" },
79
79
  { "icon": "🛡️", "title": "admin", "href": "admin.md",
80
80
  "desc": "Le data plane d'administration : comment un module déclare son API d'admin via AdminBroker, la convention de route /nodefony/<ns>/api/*, le RBAC fail-closed (ROLE_NODEFONY_ADMIN), le duplex HTTP/WebSocket, le catalogue et le Playground.",