@nodefony/framework 10.0.0-alpha.9 → 10.0.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +4 -1
  2. package/dist/index.js +2 -2
  3. package/dist/nodefony/controller/AdminApiController.js +4 -3
  4. package/dist/nodefony/controller/ApiKeyController.js +1 -1
  5. package/dist/nodefony/controller/OAuth2Controller.js +11 -6
  6. package/dist/nodefony/controller/SessionAuthController.js +3 -3
  7. package/dist/nodefony/controller/TokenAuthController.js +2 -2
  8. package/dist/nodefony/controller/TotpController.js +5 -4
  9. package/dist/nodefony/controller/WebAuthnController.js +4 -4
  10. package/dist/nodefony/decorators/routerDecorators.js +41 -35
  11. package/dist/nodefony/service/IdempotencyStore.js +2 -3
  12. package/dist/nodefony/service/router.js +73 -23
  13. package/dist/nodefony/src/Controller.js +184 -78
  14. package/dist/nodefony/src/FrameworkAdminApi.js +19 -6
  15. package/dist/nodefony/src/KernelAdminApi.js +49 -49
  16. package/dist/nodefony/src/PlaygroundAdminApi.js +3 -3
  17. package/dist/nodefony/src/RedisIdempotencyStore.js +2 -2
  18. package/dist/nodefony/src/Resolver.js +219 -63
  19. package/dist/nodefony/src/Route.js +38 -38
  20. package/dist/nodefony/src/SyslogAdminApi.js +4 -4
  21. package/dist/nodefony/src/docsReader.js +22 -18
  22. package/dist/nodefony/src/scopeCatalog.js +1 -1
  23. package/dist/nodefony/src/singletonGuard.js +76 -0
  24. package/dist/types/nodefony/controller/BenchController.d.ts +2 -2
  25. package/dist/types/nodefony/controller/OAuth2Controller.d.ts +3 -3
  26. package/dist/types/nodefony/controller/ProtectedResourceMetadataController.d.ts +1 -1
  27. package/dist/types/nodefony/controller/SessionAuthController.d.ts +2 -1
  28. package/dist/types/nodefony/decorators/routerDecorators.d.ts +49 -43
  29. package/dist/types/nodefony/interfaces/IController.d.ts +9 -7
  30. package/dist/types/nodefony/interfaces/IResolver.d.ts +10 -10
  31. package/dist/types/nodefony/interfaces/IRoute.d.ts +13 -13
  32. package/dist/types/nodefony/service/AdminBroker.d.ts +3 -3
  33. package/dist/types/nodefony/service/router.d.ts +33 -7
  34. package/dist/types/nodefony/src/Controller.d.ts +97 -24
  35. package/dist/types/nodefony/src/Resolver.d.ts +154 -15
  36. package/dist/types/nodefony/src/ResourceController.d.ts +6 -6
  37. package/dist/types/nodefony/src/Route.d.ts +24 -22
  38. package/dist/types/nodefony/src/docsReader.d.ts +3 -3
  39. package/dist/types/nodefony/src/singletonGuard.d.ts +53 -0
  40. package/docs/admin.md +1 -1
  41. package/docs/controller.md +133 -115
  42. package/docs/decorateurs.md +95 -92
  43. package/docs/idempotence.md +21 -19
  44. package/docs/routing.md +46 -40
  45. package/docs/templates.md +14 -14
  46. package/package.json +8 -8
@@ -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:1639`)
55
+ 3. **À la première requête** de chaque route, `resolveActionMeta()` (`routerDecorators.ts:1669`)
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
 
@@ -124,16 +124,16 @@ Trois partis pris expliquent la forme de cette surface, et un développeur qui l
124
124
  jamais surprendre.
125
125
 
126
126
  **1 — Un décorateur n'écrit QUE des métadonnées.** Aucun décorateur du framework ne contient de
127
- logique de sécurité, de session ou d'idempotence. `IsGranted()` (`routerDecorators.ts:878`) pose une
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:672`). Pourquoi ce détour : `@nodefony/framework`
129
+ conteneur (`Resolver._enforceSecurity()`, `Resolver.ts:719`). 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:621`).
132
+ une erreur de démarrage (fail-closed, `Resolver.ts:724`).
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:1595`) puis gelées sur la route.
136
- L'objet `RouteActionMeta` (`routerDecorators.ts:1406`) est **partagé par toutes les requêtes** — le
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
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
 
@@ -286,40 +286,41 @@ Six familles, **36 décorateurs**, un seul fichier source. Le tableau de synthè
286
286
  | --- | --- | --- | --- |
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
- | `@route(nom, options)` | méthode | Forme complète : nom explicite, chemin, `requirements`, `defaults`, hôte (`route()`, `routerDecorators.ts:271`) | `@route("ws-echo", { path: "/echo", requirements: { methods: ["WEBSOCKET"] } })` |
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
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")` |
291
- | `@Scope("singleton")` | **classe** | Une seule instance de contrôleur partagée par toutes les requêtes (`Scope()`, `routerDecorators.ts:768`) | `@Scope("singleton")` |
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
294
294
  **efface** (`Reflect.deleteMetadata`, `routerDecorators.ts:135`) : une classe ne se monte qu'une
295
295
  fois. Il traite au passage la route « magique » `path: "*"` en **dernier**, quel que soit son ordre
296
- d'écriture (`routerDecorators.ts:281`) — sinon un attrape-tout masquerait les routes précises.
296
+ d'écriture (`hasMagic`, `routerDecorators.ts:254`) — sinon un attrape-tout masquerait les routes précises.
297
297
 
298
- **`@Scope("singleton")` est un contrat, pas une optimisation.** L'instance étant partagée, l'action
299
- ne doit lire ni écrire **aucun** état de requête sur `this` : tout passe par les arguments décorés et
300
- les accesseurs, qui retrouvent la requête courante via l'ALS. Le défaut reste `"request"` — une
301
- instance par requête (`ControllerScope`, `Controller.ts:144`).
298
+ **Le singleton — le défaut — est un contrat.** L'instance étant partagée, l'action ne doit lire ni
299
+ écrire **aucun** état de requête sur `this` : tout passe par les arguments décorés et les accesseurs,
300
+ qui retrouvent la requête courante via l'ALS (`ControllerScope`, `Controller.ts:151`). `@Scope("request")`
301
+ rend une instance par requête : pour injecter un service de portée `request`, préparer chaque
302
+ requête dans `initialize()`, ou garder un état sur `this`.
302
303
 
303
304
  > [!NOTE]
304
305
  > Le core `nodefony` exporte lui aussi un `Scope` (les portées du conteneur d'injection). Celui des
305
306
  > contrôleurs s'importe **depuis `@nodefony/framework`** — l'homonymie est signalée dans le code
306
- > (`routerDecorators.ts:747`).
307
+ > (`routerDecorators.ts:785`).
307
308
 
308
309
  ### Méthodes HTTP
309
310
 
310
- Toutes les fabriques sortent du même moule, `httpMethodDecorator()` (`routerDecorators.ts:469`) :
311
+ Toutes les fabriques sortent du même moule, `httpMethodDecorator()` (`routerDecorators.ts:489`) :
311
312
  elles nomment la route automatiquement `ClasseName::methode` et posent `requirements.methods`.
312
313
 
313
314
  | Décorateur | Méthode filtrée | Ancre | Exemple |
314
315
  | ------------------------ | --------------- | ------------------------------------- | ------------------- |
315
- | `@Get(path?, opts?)` | `GET` | `Get` (`routerDecorators.ts:515`) | `@Get("/{id}")` |
316
- | `@Post(path?, opts?)` | `POST` | `Post` (`routerDecorators.ts:516`) | `@Post("")` |
317
- | `@Put(path?, opts?)` | `PUT` | `Put` (`routerDecorators.ts:517`) | `@Put("/{id}")` |
318
- | `@Delete(path?, opts?)` | `DELETE` | `Delete` (`routerDecorators.ts:518`) | `@Delete("/{id}")` |
319
- | `@Patch(path?, opts?)` | `PATCH` | `Patch` (`routerDecorators.ts:519`) | `@Patch("/{id}")` |
320
- | `@Options(path?, opts?)` | `OPTIONS` | `Options` (`routerDecorators.ts:520`) | `@Options("/{id}")` |
321
- | `@Head(path?, opts?)` | `HEAD` | `Head` (`routerDecorators.ts:521`) | `@Head("/{id}")` |
322
- | `@All(path?, opts?)` | **aucune** | `All()` (`routerDecorators.ts:528`) | `@All("/proxy/*")` |
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}")` |
323
+ | `@All(path?, opts?)` | **aucune** | `All()` (`routerDecorators.ts:548`) | `@All("/proxy/*")` |
323
324
 
324
325
  Deux points qu'un dev découvre sinon à ses dépens :
325
326
 
@@ -341,42 +342,42 @@ async index(@Param("page") page: string) { /* … */ }
341
342
 
342
343
  ### Paramètres — ce que l'action reçoit
343
344
 
344
- Onze décorateurs, tous produits par `paramDecoratorFactory()` (`routerDecorators.ts:1162`) sauf
345
+ Onze décorateurs, tous produits par `paramDecoratorFactory()` (`routerDecorators.ts:1191`) sauf
345
346
  `@Body`, qui accepte une option supplémentaire. Chacun pose `{ source, key, index }` ; la valeur est
346
- calculée par `resolveParamArg()` (`routerDecorators.ts:1297`), une fonction **pure** — ce qui la rend
347
+ calculée par `resolveParamArg()` (`routerDecorators.ts:1326`), une fonction **pure** — ce qui la rend
347
348
  testable sans démarrer de serveur.
348
349
 
349
350
  | Décorateur | Sans clé renvoie… | Avec clé renvoie… | Ancre |
350
351
  | ------------------- | ------------------------------------ | ------------------------------------------ | -------------------------------------------- |
351
- | `@Param("id")` | toutes les variables d'URL (objet) | la variable d'URL nommée | `Param` (`routerDecorators.ts:1182`) |
352
- | `@Query("q")` | toute la query string | un paramètre de la query string | `Query` (`routerDecorators.ts:1183`) |
353
- | `@Body("field")` | le corps parsé entier | un champ du corps parsé | `Body()` (`routerDecorators.ts:1209`) |
354
- | `@Headers("x-foo")` | tous les en-têtes de requête | un en-tête (**lookup en minuscules**) | `Headers` (`routerDecorators.ts:1246`) |
355
- | `@Cookie("sid")` | la map des cookies | un cookie (objet `Cookie`, champ `.value`) | `Cookie` (`routerDecorators.ts:1247`) |
356
- | `@Session("user")` | l'objet `Session` vivant | `session.get(clé)` | `Session` (`routerDecorators.ts:1248`) |
357
- | `@CurrentUser()` | l'utilisateur résolu par le firewall | — | `CurrentUser` (`routerDecorators.ts:1250`) |
358
- | `@Req()` | la requête brute du contexte | — | `Req` (`routerDecorators.ts:1251`) |
359
- | `@Res()` | la réponse du contexte | — | `Res` (`routerDecorators.ts:1252`) |
360
- | `@UploadedFile()` | le **premier** fichier téléversé | — | `UploadedFile` (`routerDecorators.ts:1253`) |
361
- | `@UploadedFiles()` | tous les fichiers téléversés | — | `UploadedFiles` (`routerDecorators.ts:1254`) |
362
-
363
- La liste des sources possibles est fermée et typée : `ParamSource` (`routerDecorators.ts:365`).
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`) |
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`) |
363
+
364
+ La liste des sources possibles est fermée et typée : `ParamSource` (`routerDecorators.ts:385`).
364
365
 
365
366
  #### Trois comportements à connaître
366
367
 
367
368
  **`@CurrentUser` lit l'ALS, jamais un argument caché.** La valeur vient de `RequestContext.getUser()`
368
- (`routerDecorators.ts:1236`) : l'utilisateur posé par le firewall. C'est **l'utilisateur**, jamais le
369
+ (`routerDecorators.ts:1279`) : l'utilisateur posé par le firewall. C'est **l'utilisateur**, jamais le
369
370
  justificatif (mot de passe, jeton). Hors zone authentifiée, la valeur est `undefined` — le décorateur
370
371
  n'authentifie rien, il expose ce qui a déjà été prouvé.
371
372
 
372
373
  **`@Session` active la session à lui seul.** La simple présence d'un paramètre `@Session` vaut
373
- déclaration d'intention : `resolveSessionIntent()` (`routerDecorators.ts:833`) la détecte et pose
374
+ déclaration d'intention : `resolveSessionIntent()` (`routerDecorators.ts:858`) la détecte et pose
374
375
  l'intent, exactement comme `@UseSession()`. Une route sans l'un ni l'autre ne paie aucune session.
375
376
 
376
377
  **`@Body({ stream: true })` court-circuite le parsing.** Pour un gros téléversement (vidéo,
377
378
  sauvegarde), on injecte le **flux brut** de la requête au lieu du corps chargé en mémoire ; le
378
379
  pipeline saute alors le parsing pour cette route, décision prise en amont par
379
- `routeExpectsBodyStream()` (`routerDecorators.ts:1379`) :
380
+ `routeExpectsBodyStream()` (`routerDecorators.ts:1408`) :
380
381
 
381
382
  ```typescript
382
383
  @Post("/upload")
@@ -388,7 +389,7 @@ async upload(@Body({ stream: true }) stream: NodeJS.ReadableStream) {
388
389
 
389
390
  > [!TIP]
390
391
  > L'ordre d'écriture des paramètres décorés n'a aucune importance : chaque valeur est placée à son
391
- > **index déclaré** par `buildParamArgs()` (`routerDecorators.ts:1361`), et les trous restent
392
+ > **index déclaré** par `buildParamArgs()` (`routerDecorators.ts:1390`), et les trous restent
392
393
  > `undefined`. Tu peux mélanger décorés et non décorés — les non décorés reçoivent `undefined`.
393
394
 
394
395
  #### Le corps n'est pas validé — et c'est un choix
@@ -451,16 +452,16 @@ ne s'auto-promeut pas.
451
452
 
452
453
  | Décorateur | Cible | Effet | Exemple |
453
454
  | ------------------------- | ------- | ------------------------------------------------------------------------------------------ | ------------------------------------- |
454
- | `@HttpCode(201)` | méthode | Fixe le statut **avant** l'exécution de l'action (`HttpCode()`, `routerDecorators.ts:548`) | `@HttpCode(204)` |
455
- | `@Header("X-Foo", "bar")` | méthode | Ajoute un en-tête ; **s'empile** (plusieurs `@Header` cumulent, `routerDecorators.ts:580`) | `@Header("Cache-Control","no-store")` |
456
- | `@Redirect("/url", 302)` | méthode | Redirige **si** l'action ne renvoie rien (`Redirect()`, `routerDecorators.ts:628`) | `@Redirect("/login", 302)` |
455
+ | `@HttpCode(201)` | méthode | Fixe le statut **avant** l'exécution de l'action (`HttpCode()`, `routerDecorators.ts:582`) | `@HttpCode(204)` |
456
+ | `@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
+ | `@Redirect("/url", 302)` | méthode | Redirige **si** l'action ne renvoie rien (`Redirect()`, `routerDecorators.ts:649`) | `@Redirect("/login", 302)` |
457
458
 
458
- Les deux premiers sont appliqués par `Resolver._applyResponseMeta()` (`Resolver.ts:780`) **avant**
459
+ Les deux premiers sont appliqués par `Resolver._applyResponseMeta()` (`Resolver.ts:821`) **avant**
459
460
  l'appel de l'action : ton code peut donc les écraser ensuite (`this.renderJson(data, 202)` gagne).
460
461
 
461
462
  `@Redirect` a une subtilité utile : si l'action **retourne un objet** portant `url` (et
462
463
  éventuellement `statusCode`), cet objet **prend le dessus** sur les valeurs du décorateur
463
- (`Resolver._handleRedirect()`, `Resolver.ts:796`) — la cible peut donc être calculée à l'exécution :
464
+ (`Resolver._handleRedirect()`, `Resolver.ts:837`) — la cible peut donc être calculée à l'exécution :
464
465
 
465
466
  ```typescript
466
467
  @Get("/go")
@@ -471,29 +472,30 @@ async go(@Query("to") to?: string) {
471
472
  ```
472
473
 
473
474
  > [!WARNING]
474
- > Redirection sans statut explicite ailleurs dans le code : `Response.redirect()` vaut **301** par
475
- > défaut (permanent, mis en cache par les navigateurs). Passe toujours le code —
476
- > `this.redirect(url, 302)`.
475
+ > Redirection sans statut explicite ailleurs dans le code : `Response.redirect()` vaut **302** par
476
+ > défaut (Found), jamais 301 — un 301 implicite serait mis en cache par les navigateurs, presque
477
+ > irréversiblement. Un code hors de 301/302/303/307/308 retombe aussi sur 302 : passe le code voulu
478
+ > quand il compte — `this.redirect(url, 307)` préserve la méthode.
477
479
 
478
480
  ### Sécurité — qui passe, qui décide, quelles défenses
479
481
 
480
482
  Sept décorateurs, **tous duals** (classe ou méthode) et **tous sans logique** : ils posent une
481
483
  étiquette que le `Resolver` ou le firewall consommera.
482
484
 
483
- | Décorateur | Effet | Ancre |
484
- | ---------------------------------------- | -------------------------------------------------------------------------------------------------------- | -------------------------------------------- |
485
- | `@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:878`) |
486
- | `@RequireScope(scope \| scopes)` | Exige un scope `api:action` d'un **jeton machine** ; no-op pour une session humaine | `RequireScope()` (`routerDecorators.ts:975`) |
487
- | `@Anonymous()` | Rend l'action publique : annule l'autorisation **et** l'authentification (le « permitAll ») | `Anonymous()` (`routerDecorators.ts:926`) |
488
- | `@BypassFirewall` | Court-circuite le firewall (sonde de liveness, webhook signé, endpoint de login). **Sans parenthèses** | `BypassFirewall` (`routerDecorators.ts:725`) |
489
- | `@Csp({ "frame-src": [...] })` | Ajoute des directives CSP **à cette réponse** ; classe + méthode fusionnent additivement | `Csp()` (`routerDecorators.ts:1040`) |
490
- | `@CsrfProtect()` | Opt-**in** au jeton anti-CSRF (double-submit signé) en plus de la défense globale | `CsrfProtect` (`routerDecorators.ts:1104`) |
491
- | `@CsrfExempt()` | Opt-**out** de la défense CSRF **en gardant** l'authentification (webhook, POST cross-origin légitime) | `CsrfExempt` (`routerDecorators.ts:1113`) |
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`) |
492
494
 
493
495
  #### Rôles et scopes — deux axes, un seul verdict
494
496
 
495
497
  `@IsGranted` et `@RequireScope` écrivent dans **deux jeux de métadonnées distincts**, puis
496
- `computeSecurityRequirement()` (`routerDecorators.ts:1514`) les fusionne en une exigence unique dont
498
+ `computeSecurityRequirement()` (`routerDecorators.ts:1544`) les fusionne en une exigence unique dont
497
499
  toutes les clauses sont en **ET**. Une seule chaîne d'application côté `Resolver`, deux jurés
498
500
  différents côté `security` (le voteur de rôles, le voteur de scopes).
499
501
 
@@ -529,7 +531,7 @@ Trois façons d'ouvrir une porte, trois portées — les confondre coûte cher :
529
531
  | `@CsrfExempt()` | **conservée** | **conservée** | ignorée | webhook signé, API cross-origin |
530
532
 
531
533
  `@Anonymous()` pose en réalité **deux** marqueurs : « pas d'autorisation » et « pas de firewall »
532
- (`routerDecorators.ts:926-941`) — c'est un `@BypassFirewall` doublé d'une annulation des clauses
534
+ (`routerDecorators.ts:953-979`) — c'est un `@BypassFirewall` doublé d'une annulation des clauses
533
535
  héritées de la classe.
534
536
 
535
537
  > [!CAUTION]
@@ -543,7 +545,7 @@ héritées de la classe.
543
545
  | Décorateur | Cible | Effet | Ancre |
544
546
  | ------------------------------------ | ----- | ---------------------------------------------------------------------------------------------------------- | --------------------------------- |
545
547
  | `@UseSession({ readOnly?, eager? })` | dual | Déclare le besoin d'une session serveur ; **méthode > classe** (`UseSession()`, `routerDecorators.ts:800`) | `@UseSession({ readOnly: true })` |
546
- | `@Idempotent({ required? })` | dual | Protège une mutation du double effet via `Idempotency-Key` (`Idempotent()`, `routerDecorators.ts:1142`) | `@Idempotent()` |
548
+ | `@Idempotent({ required? })` | dual | Protège une mutation du double effet via `Idempotency-Key` (`Idempotent()`, `routerDecorators.ts:1171`) | `@Idempotent()` |
547
549
 
548
550
  **`@UseSession` est la seule façon d'ouvrir une session** (avec un paramètre `@Session`, ou la reprise
549
551
  d'un cookie existant). Il n'existe plus de « démarrer partout » global : une route qui ne déclare rien
@@ -566,8 +568,8 @@ recherches inutiles.
566
568
  | ----------------------------------- | -------------------- | ---------------------------------------------------------------------------------------------- |
567
569
  | `@services([…])` | `nodefony` | Déclare les services d'un module (`services()`, `kernelDecorator.ts:53`) |
568
570
  | `@injectable()` | `nodefony` | Rend une classe résoluble par le conteneur (`injectable()`, `kernelDecorator.ts:135`) |
569
- | `@inject("nom")` | `nodefony` | Injecte un service à une position de constructeur (`inject()`, `kernelDecorator.ts:167`) |
570
- | `@Inject("nom")` | `nodefony` | Idem, sur une propriété (`Inject()`, `kernelDecorator.ts:196`) |
571
+ | `@inject("nom")` | `nodefony` | Injecte un service à une position de constructeur (`inject()`, `kernelDecorator.ts:186`) |
572
+ | `@Inject("nom")` | `nodefony` | Idem, sur une propriété (`Inject()`, `kernelDecorator.ts:215`) |
571
573
  | `@RealtimeAction("méthode")` | `@nodefony/realtime` | Expose une action JSON-RPC sur socket (`RealtimeAction()`, `realtimeDecorators.ts:101`) |
572
574
  | `@RealtimeChannel("canal", policy)` | `@nodefony/realtime` | Déclare un canal temps réel et sa politique (`RealtimeChannel()`, `realtimeDecorators.ts:192`) |
573
575
  | `@RealtimeInbound("méthode")` | `@nodefony/realtime` | Traite un message entrant typé (`RealtimeInbound()`, `realtimeDecorators.ts:231`) |
@@ -615,12 +617,13 @@ Trois faits à retenir :
615
617
 
616
618
  - **Il n'existe pas de décorateur `@Ws`.** Le transport se déclare dans les `requirements` — via
617
619
  `@route`, ou via `@All("/x", { requirements: { methods: ["WEBSOCKET"] } })` si tu préfères la forme
618
- courte (les fabriques `@Get`/`@Post` écrasent, elles, `requirements.methods` par leur propre méthode,
619
- `routerDecorators.ts:508`).
620
+ courte. Les fabriques `@Get`/`@Post`… **fusionnent** les méthodes déclarées avec la leur :
621
+ `@Get("/x", { requirements: { methods: ["WEBSOCKET"] } })` sert GET **et** WebSocket
622
+ (`routerDecorators.ts:535`).
620
623
  - **Les décorateurs de paramètre fonctionnent pareil.** Pour une invocation par socket, le corps de
621
624
  la mutation voyage dans l'ALS et **prime** sur le corps HTTP (vide dans ce cas) — c'est traité dans
622
- `resolveParamArg()` (`routerDecorators.ts:1297`), et `@Query` lit la query du chemin **invoqué**,
623
- pas celle du handshake (`Resolver._buildParamArgs()`, `Resolver.ts:749`).
625
+ `resolveParamArg()` (`routerDecorators.ts:1326`), et `@Query` lit la query du chemin **invoqué**,
626
+ pas celle du handshake (`Resolver._buildParamArgs()`, `Resolver.ts:795`).
624
627
  - **Les gardes s'appliquent identiquement.** `@IsGranted` protège une action joignable par socket
625
628
  exactement comme une action HTTP : la décision est prise avant l'instanciation, quel que soit le
626
629
  transport.
@@ -637,15 +640,15 @@ pas toutes identiques — c'est la source d'erreur n°1.
637
640
  | Sujet | Règle | Ancre |
638
641
  | --- | --- | --- |
639
642
  | `@Domain` | option `host` de la route > méthode > classe | `controller()` (`routerDecorators.ts:189`) |
640
- | `@BypassFirewall` | **cumulatif** : `true` de la route, de la méthode ou de la classe suffit | `routerDecorators.ts:686` |
641
- | `@UseSession` | méthode > classe (fusion des champs) | `resolveSessionIntent()` (`routerDecorators.ts:833`) |
642
- | `@Idempotent` | méthode > classe | `computeIdempotent()` (`routerDecorators.ts:1576`) |
643
- | `@IsGranted` / `@RequireScope` | **cumul en ET** : classe **plus** méthode | `computeSecurityRequirement()` (`routerDecorators.ts:1514`) |
644
- | `@Anonymous` | méthode → annule tout ce que la classe a posé | `routerDecorators.ts:926` |
645
- | `@Csp` | fusion **additive** classe + méthode (sources concaténées) | `mergeCspDirectives()` (`routerDecorators.ts:1014`) |
646
- | `@CsrfProtect` / `@CsrfExempt` | OU logique : classe **ou** méthode suffit | `computeActionMeta()` (`routerDecorators.ts:1595`) |
647
- | `@Header` | s'empile (plusieurs en-têtes) ; même clé → dernier écrit gagne | `Header()` (`routerDecorators.ts:580`) |
648
- | `@HttpCode` | un seul par action (le dernier posé écrase) | `HttpCode()` (`routerDecorators.ts:548`) |
643
+ | `@BypassFirewall` | **cumulatif** : `true` de la route, de la méthode ou de la classe suffit | `routerDecorators.ts:750` |
644
+ | `@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`) |
650
+ | `@Header` | s'empile (plusieurs en-têtes) ; même clé → dernier écrit gagne | `Header()` (`routerDecorators.ts:603`) |
651
+ | `@HttpCode` | un seul par action (le dernier posé écrase) | `HttpCode()` (`routerDecorators.ts:582`) |
649
652
 
650
653
  ### Où placer les décorateurs de classe
651
654
 
@@ -688,7 +691,7 @@ sequenceDiagram
688
691
  RS->>RS: requêtes suivantes : lecture O(1), 0 Reflect
689
692
  ```
690
693
 
691
- Le snapshot `RouteActionMeta` (`routerDecorators.ts:1406`) regroupe **tout** ce que les décorateurs
694
+ Le snapshot `RouteActionMeta` (`routerDecorators.ts:1436`) regroupe **tout** ce que les décorateurs
692
695
  ont dit de l'action :
693
696
 
694
697
  <!-- prettier-ignore -->
@@ -706,12 +709,12 @@ ont dit de l'action :
706
709
 
707
710
  Le `Resolver` consomme ce snapshot dans un ordre qui a du sens sécurité :
708
711
  **garde d'abord, instanciation ensuite**. `security !== null` déclenche
709
- `_enforceSecurity()` (`Resolver.ts:672`) **avant** `newController()` — un `403` n'instancie pas le
712
+ `_enforceSecurity()` (`Resolver.ts:719`) **avant** `newController()` — un `403` n'instancie pas le
710
713
  contrôleur et n'exécute pas son `initialize()`. Puis viennent les arguments
711
- (`_buildParamArgs()`, `Resolver.ts:749`), les métadonnées de réponse
712
- (`_applyResponseMeta()`, `Resolver.ts:780`), l'action, et enfin la redirection éventuelle.
714
+ (`_buildParamArgs()`, `Resolver.ts:795`), les métadonnées de réponse
715
+ (`_applyResponseMeta()`, `Resolver.ts:821`), l'action, et enfin la redirection éventuelle.
713
716
 
714
- Un usage cold path mérite d'être connu : `extractActionScopes()` (`routerDecorators.ts:1490`) parcourt
717
+ Un usage cold path mérite d'être connu : `extractActionScopes()` (`routerDecorators.ts:1520`) parcourt
715
718
  les routes au démarrage pour bâtir le **catalogue des scopes déclarés** — le formulaire de création
716
719
  de clés API dans Studio propose les scopes réellement utilisés par le code, jamais une liste
717
720
  maintenue à part.
@@ -720,26 +723,26 @@ maintenue à part.
720
723
 
721
724
  Un décorateur non employé doit coûter **zéro**. C'est tenu par trois mécanismes vérifiables :
722
725
 
723
- - **Lecture unique.** `resolveActionMeta()` (`routerDecorators.ts:1639`) mémorise le snapshot sur la
726
+ - **Lecture unique.** `resolveActionMeta()` (`routerDecorators.ts:1669`) mémorise le snapshot sur la
724
727
  route au premier passage — ensuite, plus aucun appel `Reflect.getMetadata` ni `Object.entries` par
725
728
  requête. Le même schéma vaut pour la détection du flux brut
726
- (`routeExpectsBodyStream()`, `routerDecorators.ts:1379`).
729
+ (`routeExpectsBodyStream()`, `routerDecorators.ts:1408`).
727
730
  - **`null` plutôt que structure vide.** Une action sans garde a `security: null` : le `Resolver` teste
728
- un `null` et passe — ni résolution de service, ni `await`, ni allocation (`Resolver.ts:334`). Idem
731
+ un `null` et passe — ni résolution de service, ni `await`, ni allocation (`Resolver.ts:406`). Idem
729
732
  pour `idempotent`, `cspDirectives`, `paramsMeta`.
730
733
  - **Objets gelés et partagés.** Les exigences de sécurité et d'idempotence sont créées **une fois** et
731
734
  `Object.freeze`-ées (`routerDecorators.ts:1496`, `:1340`) : une seule instance pour la durée de vie
732
735
  du processus, quelle que soit la charge. Corollaire : ne les mute jamais.
733
736
 
734
737
  Coût résiduel côté montage seulement : la reconstruction de la pile d'appels dans `route()`
735
- (`stackTrace`, `routerDecorators.ts:284`) pour retrouver le fichier source. Elle a lieu **à l'import**, une fois par
738
+ (`stackTrace`, `routerDecorators.ts:304`) pour retrouver le fichier source. Elle a lieu **à l'import**, une fois par
736
739
  route, jamais pendant une requête.
737
740
 
738
741
  ## 🧩 Extension — écrire son propre décorateur
739
742
 
740
743
  Le module montre le patron à suivre : un décorateur maison **ne fait qu'écrire une métadonnée**, et
741
744
  un point du pipeline la relit. Pour un simple drapeau dual (classe + méthode), le framework fournit
742
- déjà la fabrique `booleanMarkerDecorator()` (`routerDecorators.ts:1079`), dont `@CsrfProtect` et
745
+ déjà la fabrique `booleanMarkerDecorator()` (`routerDecorators.ts:1108`), dont `@CsrfProtect` et
743
746
  `@CsrfExempt` sont les deux usages.
744
747
 
745
748
  Le squelette d'un drapeau maison, en dehors du framework :
@@ -769,7 +772,7 @@ export function Audited() {
769
772
  Deux invariants à respecter, tirés du code du module :
770
773
 
771
774
  1. **Classe → constructeur, méthode → prototype keyé par nom.** C'est la convention de toutes les
772
- métadonnées du fichier (`routerDecorators.ts:864` pour `@IsGranted`) ; s'en écarter rend la
775
+ métadonnées du fichier (`routerDecorators.ts:903` pour `@IsGranted`) ; s'en écarter rend la
773
776
  fusion classe/méthode impossible.
774
777
  2. **Aucune I/O, aucun service, aucun import lourd dans le décorateur.** Il s'exécute à l'import, hors
775
778
  de tout kernel : y résoudre un service planterait le simple fait de charger le fichier.
@@ -792,7 +795,7 @@ L'écran **Routes** (`/nodefony/routes`) et le point d'API `/nodefony/framework/
792
795
  | Symptôme | Cause (dans le code) | Correction |
793
796
  | --- | --- | --- |
794
797
  | `404` sur une route pourtant décorée | Contrôleur jamais importé, ou absent de `@controllers([…])` | L'ajouter au tableau `@controllers` du module |
795
- | `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:538`](../../../../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. |
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. |
796
799
  | `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) |
797
800
  | Le vhost de `@Domain` classe est ignoré | `@Domain` placé **au-dessus** de `@controller` → posé trop tard | Placer `@Domain` sous `@controller` |
798
801
  | `@BypassFirewall` n'ouvre rien | Écrit **avec** parenthèses — c'est un drapeau, pas une fabrique | `@BypassFirewall` (sans `()`) |
@@ -802,9 +805,9 @@ L'écran **Routes** (`/nodefony/routes`) et le point d'API `/nodefony/framework/
802
805
  | `@Session()` toujours `null` | Aucun intent : ni `@UseSession`, ni paramètre `@Session`, ni cookie repris | Ajouter `@UseSession()` sur l'action ou la classe |
803
806
  | `@Headers("X-Foo")` vaut `undefined` | Node met les en-têtes en minuscules ; la recherche est normalisée mais la clé compte | Utiliser la forme minuscule (`"x-foo"`) |
804
807
  | `@Redirect` ne redirige pas | L'action a retourné une valeur — la redirection ne joue que sur `undefined`/`null` | Ne rien retourner, ou retourner `{ url, statusCode }` |
805
- | Réponse `301` inattendue sur un `redirect()` manuel | `Response.redirect()` vaut 301 par défaut | Passer le code : `this.redirect(url, 302)` |
808
+ | Réponse `302` alors qu'un autre code était voulu | `Response.redirect()` vaut 302 par défaut, et un code hors 301/302/303/307/308 y retombe | Passer un code de redirection valide : `this.redirect(url, 307)` |
806
809
  | Une méthode nommée `session`/`request`/`response` est refusée | Même règle : ce sont des **accesseurs** de `Controller`. Sans le garde-fou ils ne cassaient rien au build — ils masquaient l'action en silence. | Renommer l'action (aussi : `get`, `set`, `method`, `context`, `route`) |
807
- | Deux requêtes se mélangent leurs données | `@Scope("singleton")` avec un état de requête stocké sur `this` | Revenir au défaut per-request, ou n'utiliser que des arguments décorés |
810
+ | Deux requêtes se mélangent leurs données | Un état de requête stocké sur `this` d'un singleton (le défaut), là où les filets ne voient pas (`#privé`, objet muté) | N'utiliser que des arguments décorés, ou déclarer `@Scope("request")` |
808
811
  | La route `*` avale toutes les autres | Attendu : elle est montée en dernier mais matche tout ce qui reste | Vérifier que les routes précises sont bien déclarées (elles gagnent) |
809
812
 
810
813
  ## 🧪 Tests & couverture
@@ -828,7 +831,7 @@ dans la carte régénérée depuis vitest, jamais figés ici :
828
831
 
829
832
  Ce qui **manque** aujourd'hui : aucun banc de charge ni test mémoire dédié à la surface décorateur —
830
833
  c'est cohérent avec le fait que tout y est cold path (montage, première requête), mais un
831
- `@Scope("singleton")` mal utilisé se prouverait mieux sous charge (skill `nodefony-load-test`).
834
+ contrôleur singleton mal utilisé se prouverait mieux sous charge (skill `nodefony-load-test`).
832
835
 
833
836
  Couverture : `npm run coverage` dans `@nodefony/framework`.
834
837
 
@@ -121,7 +121,7 @@ que **deux** appelants traduisent dans leur monde :
121
121
  - le **data plane admin** — `AdminApiController.idempotencyGate()`
122
122
  (`AdminApiController.ts:131`) → réponse `{status, headers, body}` ;
123
123
  - les **controllers userland** décorés `@Idempotent` — seam `Resolver._callWithIdempotency()`
124
- (`Resolver.ts:499`) → `nodefonyError` typée, ou réponse rejouée.
124
+ (`Resolver.ts:537`) → `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:539` et `Resolver.ts:563`). Si tu appelles le store **à la main** (cas avancé), c'est
226
+ > `Resolver.ts:578` et `Resolver.ts:563`). 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:1576`), comme
241
+ Précédence **méthode > classe** (`computeIdempotent()`, `routerDecorators.ts:1606`), 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.
@@ -266,7 +266,7 @@ Deux mécanismes rendent ça possible côté socket :
266
266
  frame `socket.mutate` créerait un doublon. Le Resolver teste donc
267
267
  `isMutationMethod(this.methodOverride ?? context.method)` (`Resolver.ts:126`).
268
268
 
269
- Le pont utilise `executeActionGuarded()` (`Resolver.ts:464`) : porte d'idempotence **sans** rendu HTTP
269
+ Le pont utilise `executeActionGuarded()` (`Resolver.ts:502`) : 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,7 +299,7 @@ sequenceDiagram
299
299
 
300
300
  ### Le parcours d'une mutation, étape par étape
301
301
 
302
- 1. **Court-circuit hot path.** `callController()` (`Resolver.ts:435`) lit `meta.idempotent` sur les
302
+ 1. **Court-circuit hot path.** `callController()` (`Resolver.ts:484`) 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
@@ -312,7 +312,7 @@ sequenceDiagram
312
312
  le verdict devient `execute` (`idempotency.ts:176`) — jamais de partage cross-identité.
313
313
  5. **Réservation.** `store.begin()` compose la clé scopée et tranche.
314
314
  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:539`). En erreur,
315
+ courant et `body` la **valeur retournée** par l'action (`Resolver.ts:570`). En erreur,
316
316
  `abort(clé)` libère la clé : **un échec ne se mémorise pas**, il doit rester réessayable.
317
317
 
318
318
  > [!CAUTION]
@@ -327,8 +327,8 @@ sequenceDiagram
327
327
 
328
328
  | Appelant | Point d'entrée | Traduction du verdict |
329
329
  | ---------------------------- | -------------------------------------------------------------------- | ---------------------------------- |
330
- | Controller userland HTTP | `callController()` (`Resolver.ts:435`) | `nodefonyError` + rendu normal |
331
- | Controller userland via WS | `executeActionGuarded()` (`Resolver.ts:464`) | valeur nue, enveloppée par le peer |
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
332
  | Data plane admin `/nodefony` | `AdminApiController.idempotencyGate()` (`AdminApiController.ts:131`) | `{status, headers, body}` |
333
333
 
334
334
  ## ⚙️ Configuration
@@ -344,7 +344,7 @@ Source unique = schéma Zod `idempotencySchema`
344
344
 
345
345
  ### Comment `store: "auto"` se résout VRAIMENT
346
346
 
347
- `Framework.onKernelBoot()` (`src/packages/@nodefony/framework/index.ts:189`) délègue à
347
+ `Framework.onKernelBoot()` (`src/packages/@nodefony/framework/index.ts:230`) délègue à
348
348
  `resolveAutoStore("ephemeral", …)` (`src/nodefony/src/config/infra.ts:289`), borné aux backends
349
349
  **réellement enregistrés** (`listIdempotencyBackends()`, `idempotencyStoreRegistry.ts:81`). L'ordre
350
350
  réel est le suivant :
@@ -453,7 +453,7 @@ déjà Postgres mais pas Redis obtient la dédup cross-pod **sans nouvelle infra
453
453
 
454
454
  - **Réservation atomique en UNE instruction** — un `INSERT` avec
455
455
  `onConflictDoUpdate` (`DrizzleIdempotencyStore.ts:234`) dont la garde `setWhere` ne réécrit que si
456
- l'entrée est morte (`DrizzleIdempotencyStore.ts:264`). Le `returning` ne rend une ligne que si
456
+ l'entrée est morte (`DrizzleIdempotencyStore.ts:269`). Le `returning` ne rend une ligne que si
457
457
  l'INSERT a passé (clé neuve) ou si le `DO UPDATE` a **volé** une entrée expirée → `fresh`. Zéro
458
458
  ligne = contention → on lit l'état réel.
459
459
  - **Invariant capital** : le store ne renvoie **jamais** `fresh` hors réservation atomique gagnée.
@@ -464,8 +464,8 @@ déjà Postgres mais pas Redis obtient la dédup cross-pod **sans nouvelle infra
464
464
  (`DrizzleIdempotencyStore.ts:233`), qui la reconstruit en deux instructions chacune atomique.
465
465
  - **Pas de TTL natif** → `gc()` (`DrizzleIdempotencyStore.ts:318`) = `DELETE WHERE expiresAt <= now`.
466
466
  C'est le **seul** store qui expose `gc`, donc le seul que le framework planifie (voir plus bas).
467
- - **Mutations conditionnelles** : `complete()` (`DrizzleIdempotencyStore.ts:296`) et `abort()`
468
- (`DrizzleIdempotencyStore.ts:314`) portent `WHERE state = 'if'` — jamais d'écrasement d'une réponse
467
+ - **Mutations conditionnelles** : `complete()` (`DrizzleIdempotencyStore.ts:301`) et `abort()`
468
+ (`DrizzleIdempotencyStore.ts:319`) portent `WHERE state = 'if'` — jamais d'écrasement d'une réponse
469
469
  déjà mémorisée, jamais de résurrection d'une clé libérée. `complete` ne touche pas `fingerprint`.
470
470
  - **Résolution lazy + dégradation gracieuse** : le handle Drizzle est résolu à **chaque** appel
471
471
  (`DrizzleIdempotencyStore.from()`, `DrizzleIdempotencyStore.ts:191`). ORM non connecté → `begin`
@@ -538,8 +538,8 @@ Signatures complètes : `.ai/symbols.json`. Ce qui compte à l'usage :
538
538
 
539
539
  ### Le décorateur
540
540
 
541
- `@Idempotent(options?)` (`routerDecorators.ts:1142`) — dual **classe + méthode**. N'écrit que des
542
- métadonnées (`IdempotentMeta`, `routerDecorators.ts:457`), zéro import de `@nodefony/security`, zéro
541
+ `@Idempotent(options?)` (`routerDecorators.ts:1171`) — dual **classe + méthode**. N'écrit que des
542
+ métadonnées (`IdempotentMeta`, `routerDecorators.ts:477`), zéro import de `@nodefony/security`, zéro
543
543
  cycle. La porte est appliquée par le Resolver.
544
544
 
545
545
  ### Le contrat de store
@@ -593,8 +593,10 @@ lot ont été consommées ; la page suivante rejoue le **même** `SCAN` et repre
593
593
 
594
594
  Deux réserves à connaître :
595
595
 
596
- - Le champ `tenantId` d'`IPageQuery` est un **slot réservé** au multi-tenant : le passer n'a
597
- aujourd'hui **aucun effet de filtrage**.
596
+ - Le champ `tenantId` d'`IPageQuery` est un **emplacement réservé** : il appartient au contrat de
597
+ pagination commun à tous les stores listables, et servira à isoler les données par organisation
598
+ quand le framework saura servir plusieurs organisations. Ici, le passer n'a **aucun effet de
599
+ filtrage** : ce store l'ignore.
598
600
  - `size` est une **approximation per-pod** pour les stores distribués (compteur local incrémenté au
599
601
  `fresh`, décrémenté au `complete`/`abort`), désalignée cross-pod et non décrémentée si un bail
600
602
  expire sans complétion. La vérité cluster passe par la base ou `redis-cli`, jamais par ce getter.
@@ -645,8 +647,8 @@ pour l'affichage Studio, `idempotencyStoreRegistry.ts:81`).
645
647
  ## ⚡ Performance et mémoire
646
648
 
647
649
  Le coût est **nul hors mutations décorées**. Sans `@Idempotent`, `RouteActionMeta.idempotent` vaut
648
- `null` (`routerDecorators.ts:1103`) : `callController()` fait **une comparaison** et repart en flux
649
- normal — zéro lookup de container, zéro `await` supplémentaire, zéro allocation (`Resolver.ts:435`).
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`).
650
652
  La métadonnée est **figée par route** et mémoïsée : aucune lecture `Reflect` par requête.
651
653
 
652
654
  Sur le chemin décoré :
@@ -689,7 +691,7 @@ Trois surfaces existent aujourd'hui :
689
691
 
690
692
  | Symptôme | Cause (dans le code) | Correction |
691
693
  | ------------------------------------------------------ | ------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------- |
692
- | Rejeu qui renvoie un corps **vide** | L'action a retourné `this.renderJson(...)` au lieu du payload (`Resolver.ts:581`) | Retourner la **valeur brute** ; un WARNING le signale déjà dans les logs |
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 |
693
695
  | `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 |
694
696
  | `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 |
695
697
  | 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 |