@nodefony/framework 10.0.0-alpha.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 (96) hide show
  1. package/LICENSE +544 -0
  2. package/README.md +50 -0
  3. package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorate.js +9 -0
  4. package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateMetadata.js +6 -0
  5. package/dist/index.js +211 -0
  6. package/dist/nodefony/config/config.js +61 -0
  7. package/dist/nodefony/config/defineModuleConfig.js +36 -0
  8. package/dist/nodefony/controller/AdminApiController.js +163 -0
  9. package/dist/nodefony/controller/ApiKeyController.js +151 -0
  10. package/dist/nodefony/controller/BenchController.js +132 -0
  11. package/dist/nodefony/controller/IssuerMetadataController.js +148 -0
  12. package/dist/nodefony/controller/OAuth2Controller.js +133 -0
  13. package/dist/nodefony/controller/ProtectedResourceMetadataController.js +221 -0
  14. package/dist/nodefony/controller/SessionAuthController.js +141 -0
  15. package/dist/nodefony/controller/TokenAuthController.js +113 -0
  16. package/dist/nodefony/controller/TotpController.js +129 -0
  17. package/dist/nodefony/controller/WebAuthnController.js +242 -0
  18. package/dist/nodefony/controller/oauthAuthority.js +74 -0
  19. package/dist/nodefony/decorators/routerDecorators.js +967 -0
  20. package/dist/nodefony/interfaces/IAdminBroker.js +1 -0
  21. package/dist/nodefony/interfaces/IController.js +1 -0
  22. package/dist/nodefony/interfaces/IIdempotencyStore.js +1 -0
  23. package/dist/nodefony/interfaces/IResolver.js +1 -0
  24. package/dist/nodefony/interfaces/IRoute.js +1 -0
  25. package/dist/nodefony/interfaces/index.js +1 -0
  26. package/dist/nodefony/service/AdminBroker.js +106 -0
  27. package/dist/nodefony/service/Eta.js +68 -0
  28. package/dist/nodefony/service/IdempotencyStore.js +136 -0
  29. package/dist/nodefony/service/router.js +243 -0
  30. package/dist/nodefony/src/Controller.js +515 -0
  31. package/dist/nodefony/src/FrameworkAdminApi.js +268 -0
  32. package/dist/nodefony/src/KernelAdminApi.js +1243 -0
  33. package/dist/nodefony/src/PlaygroundAdminApi.js +97 -0
  34. package/dist/nodefony/src/RedisIdempotencyStore.js +254 -0
  35. package/dist/nodefony/src/Resolver.js +416 -0
  36. package/dist/nodefony/src/ResourceController.js +148 -0
  37. package/dist/nodefony/src/Route.js +476 -0
  38. package/dist/nodefony/src/SyslogAdminApi.js +466 -0
  39. package/dist/nodefony/src/Template.js +15 -0
  40. package/dist/nodefony/src/configMutation.js +186 -0
  41. package/dist/nodefony/src/docsReader.js +929 -0
  42. package/dist/nodefony/src/idempotency.js +137 -0
  43. package/dist/nodefony/src/idempotencyGc.js +32 -0
  44. package/dist/nodefony/src/idempotencyStoreRegistry.js +36 -0
  45. package/dist/nodefony/src/scopeCatalog.js +40 -0
  46. package/dist/nodefony/src/syslogFilters.js +51 -0
  47. package/dist/types/index.d.ts +96 -0
  48. package/dist/types/nodefony/config/config.d.ts +41 -0
  49. package/dist/types/nodefony/config/defineModuleConfig.d.ts +27 -0
  50. package/dist/types/nodefony/controller/AdminApiController.d.ts +70 -0
  51. package/dist/types/nodefony/controller/ApiKeyController.d.ts +49 -0
  52. package/dist/types/nodefony/controller/BenchController.d.ts +45 -0
  53. package/dist/types/nodefony/controller/IssuerMetadataController.d.ts +86 -0
  54. package/dist/types/nodefony/controller/OAuth2Controller.d.ts +81 -0
  55. package/dist/types/nodefony/controller/ProtectedResourceMetadataController.d.ts +147 -0
  56. package/dist/types/nodefony/controller/SessionAuthController.d.ts +74 -0
  57. package/dist/types/nodefony/controller/TokenAuthController.d.ts +56 -0
  58. package/dist/types/nodefony/controller/TotpController.d.ts +42 -0
  59. package/dist/types/nodefony/controller/WebAuthnController.d.ts +123 -0
  60. package/dist/types/nodefony/controller/oauthAuthority.d.ts +54 -0
  61. package/dist/types/nodefony/decorators/routerDecorators.d.ts +632 -0
  62. package/dist/types/nodefony/interfaces/IAdminBroker.d.ts +79 -0
  63. package/dist/types/nodefony/interfaces/IController.d.ts +38 -0
  64. package/dist/types/nodefony/interfaces/IIdempotencyStore.d.ts +1 -0
  65. package/dist/types/nodefony/interfaces/IResolver.d.ts +25 -0
  66. package/dist/types/nodefony/interfaces/IRoute.d.ts +31 -0
  67. package/dist/types/nodefony/interfaces/index.d.ts +5 -0
  68. package/dist/types/nodefony/service/AdminBroker.d.ts +37 -0
  69. package/dist/types/nodefony/service/Eta.d.ts +25 -0
  70. package/dist/types/nodefony/service/IdempotencyStore.d.ts +45 -0
  71. package/dist/types/nodefony/service/router.d.ts +53 -0
  72. package/dist/types/nodefony/src/Controller.d.ts +193 -0
  73. package/dist/types/nodefony/src/FrameworkAdminApi.d.ts +35 -0
  74. package/dist/types/nodefony/src/KernelAdminApi.d.ts +71 -0
  75. package/dist/types/nodefony/src/PlaygroundAdminApi.d.ts +98 -0
  76. package/dist/types/nodefony/src/RedisIdempotencyStore.d.ts +104 -0
  77. package/dist/types/nodefony/src/Resolver.d.ts +165 -0
  78. package/dist/types/nodefony/src/ResourceController.d.ts +171 -0
  79. package/dist/types/nodefony/src/Route.d.ts +192 -0
  80. package/dist/types/nodefony/src/SyslogAdminApi.d.ts +38 -0
  81. package/dist/types/nodefony/src/Template.d.ts +8 -0
  82. package/dist/types/nodefony/src/configMutation.d.ts +109 -0
  83. package/dist/types/nodefony/src/docsReader.d.ts +369 -0
  84. package/dist/types/nodefony/src/idempotency.d.ts +96 -0
  85. package/dist/types/nodefony/src/idempotencyGc.d.ts +30 -0
  86. package/dist/types/nodefony/src/idempotencyStoreRegistry.d.ts +59 -0
  87. package/dist/types/nodefony/src/scopeCatalog.d.ts +26 -0
  88. package/dist/types/nodefony/src/syslogFilters.d.ts +52 -0
  89. package/docs/admin.md +451 -0
  90. package/docs/controller.md +645 -0
  91. package/docs/decorateurs.md +845 -0
  92. package/docs/idempotence.md +741 -0
  93. package/docs/index.md +151 -0
  94. package/docs/routing.md +648 -0
  95. package/docs/templates.md +380 -0
  96. package/package.json +83 -0
@@ -0,0 +1,845 @@
1
+ ---
2
+ title: "Décorateurs — la surface déclarative des contrôleurs"
3
+ navTitle: Décorateurs
4
+ lang: fr
5
+ module: "@nodefony/framework"
6
+ topic: decorateurs
7
+ section: "Cœur runtime"
8
+ audience: [developer]
9
+ tags: [decorateurs, controller, route, parametres, reponse, securite, websocket]
10
+ version: "doc"
11
+ status: stable
12
+ updated: 2026-07-19
13
+ source: "src/packages/@nodefony/framework/docs/decorateurs.md"
14
+ coverageModule: framework
15
+ coverageFiles: routerDecorators.ts,Resolver.ts,Route.ts
16
+ ---
17
+
18
+ # Décorateurs — la surface déclarative des contrôleurs
19
+
20
+ > Un contrôleur Nodefony ne s'enregistre pas, ne se configure pas, ne se branche pas : il se
21
+ > **décrit**. `@controller` dit où il vit, `@Get` dit quand il répond, `@Body` dit ce qu'il reçoit,
22
+ > `@HttpCode` dit comment il répond, `@IsGranted` dit qui a le droit. Cette page est **la table de
23
+ > référence** des 36 décorateurs du module : pour chacun, sa cible, son effet et un exemple court.
24
+ > Tout est ancré sur `nodefony/decorators/routerDecorators.ts` — le fichier unique qui les porte tous.
25
+
26
+ 📍 [Documentation](../../../../../docs/index.md) › [Framework](index.md) › **Décorateurs**
27
+
28
+ ## 🧠 Le modèle mental — trois temps, jamais confondus
29
+
30
+ C'est LA chose à comprendre : un décorateur **ne fait rien** au moment où tu l'écris. Il écrit une
31
+ étiquette. Trois moments distincts se partagent le travail, et chaque bizarrerie de la page découle
32
+ de ce découpage.
33
+
34
+ ```mermaid
35
+ flowchart TD
36
+ subgraph T1["1 · À l'IMPORT du fichier"]
37
+ D["@Get / @Body / @IsGranted…<br/>posent des métadonnées Reflect"]
38
+ end
39
+ subgraph T2["2 · Au MONTAGE (une seule fois)"]
40
+ C["@controller lit routes:definitions<br/>→ Router.createRoute()"]
41
+ CS["@controllers → hook onBoot<br/>→ Router.setController()"]
42
+ end
43
+ subgraph T3["3 · À la 1ʳᵉ REQUÊTE de la route"]
44
+ RM["resolveActionMeta()<br/>fige RouteActionMeta sur la route"]
45
+ RQ["requêtes suivantes : 0 Reflect, O(1)"]
46
+ end
47
+ D --> C --> CS --> RM --> RQ
48
+ ```
49
+
50
+ 1. **À l'import**, chaque décorateur appelle `Reflect.defineMetadata` et rend la main. Zéro route
51
+ créée, zéro service résolu.
52
+ 2. **Au montage**, `controller()` (`routerDecorators.ts:75`) relit ces métadonnées et fabrique les
53
+ objets `Route` ; `controllers()` (`routerDecorators.ts:18`) accroche le contrôleur au module sur
54
+ le hook `onBoot` du kernel.
55
+ 3. **À la première requête** de chaque route, `resolveActionMeta()` (`routerDecorators.ts:1624`)
56
+ consolide toutes les étiquettes de l'action en **un objet figé** posé sur la route. Les requêtes
57
+ suivantes ne lisent plus aucune métadonnée.
58
+
59
+ > [!IMPORTANT]
60
+ > Conséquence directe : **une route n'existe que si son fichier a été importé**. Un contrôleur oublié
61
+ > dans le tableau `@controllers([...])` ne produit aucune erreur — il produit un `404`.
62
+
63
+ ## 📖 Lexique
64
+
65
+ | Terme | Sens |
66
+ | ---------------------- | -------------------------------------------------------------------------------------------------------------------- |
67
+ | Décorateur | Annotation TS (`@Get(…)`) exécutée à l'import, qui attache une information à une classe, une méthode ou un argument. |
68
+ | Décorateur legacy | Le format historique TypeScript (`experimentalDecorators`), le seul utilisé ici — voir « Le contrat TypeScript ». |
69
+ | Métadonnée (`Reflect`) | Étiquette clé→valeur rangée sur une classe par `reflect-metadata`, relisible plus tard sans toucher au code. |
70
+ | Cible | Ce que le décorateur annote : **classe**, **méthode**, ou **paramètre** d'une méthode. |
71
+ | Décorateur **dual** | Utilisable en classe (vaut pour toutes les actions) **et** en méthode (une seule action). |
72
+ | Action | La méthode du contrôleur qui traite la requête. |
73
+ | Montage | Le moment où `@controller` transforme les métadonnées en routes réelles dans le `Router`. |
74
+ | `RouteActionMeta` | Le résumé figé (par route) de tous les décorateurs de l'action — lu par le `Resolver`. |
75
+ | Clause (autorisation) | Un `@IsGranted`/`@RequireScope` : plusieurs attributs en **OU**, plusieurs clauses en **ET**. |
76
+ | Scope (`api:action`) | Droit porté par un **jeton machine** (clé API, JWT) ; ne bride jamais un humain. |
77
+ | ALS | _AsyncLocalStorage_ : la bulle Node qui transporte la requête courante sans la passer en argument. |
78
+ | Mutation | Méthode non sûre : `POST`/`PUT`/`PATCH`/`DELETE` (RFC 9110 §9.2.1). |
79
+ | Hot path / cold path | Chemin parcouru à **chaque** requête / chemin parcouru rarement (montage, 1ʳᵉ requête). |
80
+
81
+ ## Qu'est-ce qu'un décorateur, concrètement ?
82
+
83
+ Imagine des **étiquettes collées sur une machine** avant sa mise en service. Aucune ne fait tourner
84
+ la machine ; elles disent au monteur quoi brancher : « alimentation 220 V », « ne pas ouvrir sans
85
+ habilitation », « sortie : 3 bars ». Le monteur passe une fois, lit toutes les étiquettes, et câble
86
+ en conséquence.
87
+
88
+ Un décorateur Nodefony, c'est exactement ça :
89
+
90
+ ```typescript
91
+ @Get("/{id}") // étiquette : réponds à GET /prefix/{id}
92
+ @HttpCode(200) // étiquette : statut par défaut 200
93
+ @IsGranted("ROLE_USER") // étiquette : réservé aux porteurs du rôle
94
+ async read(@Param("id") id: string) // étiquette d'argument : passe-moi la variable d'URL `id`
95
+ ```
96
+
97
+ Sans décorateurs, il faudrait écrire à la main un fichier de routes (le chemin, la méthode, le nom du
98
+ contrôleur, l'action, les droits), le maintenir en parallèle du code, et le voir diverger. **Le
99
+ décorateur supprime la double vérité** : la déclaration vit sur l'action qu'elle décrit.
100
+
101
+ ### Le contrat TypeScript — décorateurs _legacy_, et pourquoi ça compte
102
+
103
+ Nodefony utilise le format **legacy** de TypeScript : `experimentalDecorators: true` **et**
104
+ `emitDecoratorMetadata: true` (`tsconfig.json:5-6`, repris par le module —
105
+ `framework/tsconfig.json:5-6`). Ce n'est pas un détail historique, c'est ce qui rend possible :
106
+
107
+ - les **décorateurs de paramètre** (`@Param`, `@Body`…) — le format standard ES ne les propose pas ;
108
+ - l'**injection par type** du conteneur : `emitDecoratorMetadata` fait émettre au compilateur la
109
+ liste des types du constructeur sous la clé `design:paramtypes`, que l'injecteur relit pour
110
+ résoudre les dépendances sans les nommer (cf `injectable()`, `kernelDecorator.ts:82`).
111
+
112
+ Concrètement, dans une app générée par `nodefony create app`, ces deux options sont **déjà** dans le
113
+ `tsconfig.json`. Tu n'as rien à faire — sauf si tu pars d'un `tsconfig` à toi : sans elles, les
114
+ décorateurs ne compilent pas.
115
+
116
+ > [!WARNING]
117
+ > `reflect-metadata` doit être chargé **avant** tout décorateur. `routerDecorators.ts:1` l'importe
118
+ > pour toi dès que tu importes un décorateur du framework — mais si tu écris ton propre décorateur
119
+ > dans un fichier chargé plus tôt, mets-y `import "reflect-metadata";` en tête.
120
+
121
+ ## La vision Nodefony
122
+
123
+ Trois partis pris expliquent la forme de cette surface, et un développeur qui les connaît ne se fait
124
+ jamais surprendre.
125
+
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:839`) pose une
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:576`). Pourquoi ce détour : `@nodefony/framework`
130
+ ne dépend **pas** de `@nodefony/security` — sans ça, les deux modules formeraient un cycle. Le prix à
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:582`).
133
+
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:1580`) puis gelées sur la route.
136
+ L'objet `RouteActionMeta` (`routerDecorators.ts:1392`) est **partagé par toutes les requêtes** — le
137
+ framework ne le mute jamais, et ton code non plus. Une action non décorée obtient des champs à `null`,
138
+ ce qui vaut **zéro branche** dans le chemin chaud.
139
+
140
+ **3 — Les mêmes décorateurs pour HTTP et WebSocket.** C'est le différenciateur du framework : un
141
+ contrôleur ne change pas de forme selon le transport. Une action WS se déclare avec `@route` et le
142
+ transport `WEBSOCKET` dans ses `requirements` ; ses paramètres s'injectent avec les mêmes `@Body`,
143
+ `@Query`, `@CurrentUser`.
144
+
145
+ ## 🚀 Démarrage rapide
146
+
147
+ Vu depuis une app créée par `nodefony create app`. Rien à configurer : **les décorateurs ne se
148
+ règlent pas, ils se déclarent**.
149
+
150
+ ### Le contrôleur
151
+
152
+ ```typescript
153
+ // nodefony/controller/BookController.ts — complet, compile tel quel
154
+ import {
155
+ Controller,
156
+ controller,
157
+ Get,
158
+ Post,
159
+ Delete,
160
+ Param,
161
+ Query,
162
+ Body,
163
+ HttpCode,
164
+ Header,
165
+ IsGranted,
166
+ CurrentUser,
167
+ } from "@nodefony/framework";
168
+ import type { IUser } from "@nodefony/user";
169
+
170
+ interface BookInput {
171
+ title: string;
172
+ author: string;
173
+ }
174
+
175
+ // Le préfixe s'applique à TOUTES les routes de la classe.
176
+ @controller("/api/books")
177
+ class BookController extends Controller {
178
+ // GET /api/books?q=… — `@Query` sans valeur present → undefined, jamais throw.
179
+ @Get("")
180
+ async list(@Query("q") q?: string) {
181
+ return this.renderJson({ items: [], q: q ?? null });
182
+ }
183
+
184
+ // GET /api/books/{id} — `{id}` est capturé et injecté par son NOM.
185
+ @Get("/{id}")
186
+ async read(@Param("id") id: string) {
187
+ return this.renderJson({ id, title: "Le Horla" });
188
+ }
189
+
190
+ // POST /api/books — 201 + en-tête posés AVANT l'exécution de l'action.
191
+ // @IsGranted est évalué encore avant : un 403 n'instancie même pas ce contrôleur.
192
+ @Post("")
193
+ @HttpCode(201)
194
+ @Header("Cache-Control", "no-store")
195
+ @IsGranted(["ROLE_USER"])
196
+ async create(@Body() dto: BookInput, @CurrentUser() user: IUser) {
197
+ return this.renderJson({ id: "b_42", ...dto, owner: user.identifier });
198
+ }
199
+
200
+ // DELETE /api/books/{id} — `subject: "id"` passe la variable d'URL au voter
201
+ // métier (« cet utilisateur est-il propriétaire de CE livre ? »).
202
+ @Delete("/{id}")
203
+ @HttpCode(204)
204
+ @IsGranted("book.delete", { subject: "id" })
205
+ // ⚠️ PAS `remove` : `Controller` hérite de `Service.remove()` — voir les Pièges.
206
+ async destroy(@Param("id") id: string) {
207
+ void id;
208
+ return null; // 204 : le Resolver envoie une réponse vide (RFC 9110)
209
+ }
210
+ }
211
+
212
+ export default BookController;
213
+ ```
214
+
215
+ ### Le branchement (une ligne, dans le module de l'app)
216
+
217
+ ```typescript
218
+ // index.ts du module — `nodefony create controller` fait ce câblage pour toi
219
+ import { Kernel, Module } from "nodefony";
220
+ import { Controller, controller, controllers, Get } from "@nodefony/framework";
221
+
222
+ @controller("/hello")
223
+ class HelloController extends Controller {
224
+ @Get("")
225
+ async index() {
226
+ return this.renderJson({ hello: "nodefony" });
227
+ }
228
+ }
229
+
230
+ // Sans cette ligne, les routes existent mais aucun module ne les porte → 404.
231
+ @controllers([HelloController])
232
+ class AppModule extends Module {
233
+ constructor(kernel: Kernel) {
234
+ super("app", kernel, import.meta.url, {});
235
+ }
236
+ }
237
+
238
+ export default AppModule;
239
+ ```
240
+
241
+ ### Ce qu'on observe
242
+
243
+ ```bash
244
+ # 1) Lecture publique
245
+ curl -s http://localhost:5151/api/books/42
246
+ # {"id":"42","title":"Le Horla"}
247
+
248
+ # 2) Création sans rôle → 403 rendu AVANT l'instanciation du contrôleur
249
+ curl -si -X POST http://localhost:5151/api/books \
250
+ -H 'Content-Type: application/json' -d '{"title":"X","author":"Y"}' | head -1
251
+ # HTTP/1.1 403 Forbidden
252
+
253
+ # 3) Créée avec le rôle : le 201 et l'en-tête viennent des décorateurs
254
+ curl -si -b /tmp/jar -X POST http://localhost:5151/api/books \
255
+ -H 'Content-Type: application/json' -d '{"title":"X","author":"Y"}' | head -3
256
+ # HTTP/1.1 201 Created
257
+ # Cache-Control: no-store
258
+
259
+ # 4) Méthode non déclarée pour ce chemin → 405 avec l'agrégat des méthodes
260
+ curl -si -X PUT http://localhost:5151/api/books/42 | head -2
261
+ # HTTP/1.1 405 Method Not Allowed
262
+ # Allow: GET, DELETE
263
+ ```
264
+
265
+ ## 🧰 La table de référence — toute la surface décorateur
266
+
267
+ Six familles, **36 décorateurs**, un seul fichier source. Le tableau de synthèse sert à choisir en
268
+ 5 secondes ; les tables détaillées qui suivent donnent l'effet exact et un exemple.
269
+
270
+ <!-- prettier-ignore -->
271
+ | Famille | Ce qu'elle décide | Décorateurs |
272
+ | --- | --- | --- |
273
+ | **Déclaration** | Où vit le contrôleur, quelles routes il porte | `@controllers` `@controller` `@route` `@Domain` `@Scope` |
274
+ | **Méthodes HTTP** | Quand l'action répond | `@Get` `@Post` `@Put` `@Patch` `@Delete` `@Options` `@Head` `@All` |
275
+ | **Paramètres** | Ce que l'action reçoit en arguments | `@Param` `@Query` `@Body` `@Headers` `@Cookie` `@Session` `@CurrentUser` `@Req` `@Res` `@UploadedFile` `@UploadedFiles` |
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` |
278
+ | **Cycle de la requête** | Session, anti-rejeu | `@UseSession` `@Idempotent` |
279
+
280
+ > Tous s'importent depuis `"@nodefony/framework"` — jamais par un chemin relatif interne.
281
+
282
+ ### Déclaration — classe, module, route
283
+
284
+ <!-- prettier-ignore -->
285
+ | Décorateur | Cible | Effet | Exemple |
286
+ | --- | --- | --- | --- |
287
+ | `@controllers([…])` | **module** | Rattache des contrôleurs au module sur le hook `onBoot` ; sans lui, aucune route n'est servie (`controllers()`, `routerDecorators.ts:18`) | `@controllers([BookController])` |
288
+ | `@controller("/prefix")` | **classe** | Pose le préfixe d'URL **et déclenche la création des routes** de la classe (`controller()`, `routerDecorators.ts:75`) | `@controller("/api/books")` |
289
+ | `@route(nom, options)` | méthode | Forme complète : nom explicite, chemin, `requirements`, `defaults`, hôte (`route()`, `routerDecorators.ts:157`) | `@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:625`) | `@Domain("*.cdn.example.com")` |
291
+ | `@Scope("singleton")` | **classe** | Une seule instance de contrôleur partagée par toutes les requêtes (`Scope()`, `routerDecorators.ts:729`) | `@Scope("singleton")` |
292
+
293
+ **`@controller` est le déclencheur.** Il relit les métadonnées posées par `@route`/`@Get`/… puis les
294
+ **efface** (`Reflect.deleteMetadata`, `routerDecorators.ts:135`) : une classe ne se monte qu'une
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.
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:110`).
302
+
303
+ > [!NOTE]
304
+ > Le core `nodefony` exporte lui aussi un `Scope` (les portées du conteneur d'injection). Celui des
305
+ > contrôleurs s'importe **depuis `@nodefony/framework`** — l'homonymie est signalée dans le code
306
+ > (`routerDecorators.ts:747`).
307
+
308
+ ### Méthodes HTTP
309
+
310
+ Toutes les fabriques sortent du même moule, `httpMethodDecorator()` (`routerDecorators.ts:455`) :
311
+ elles nomment la route automatiquement `ClasseName::methode` et posent `requirements.methods`.
312
+
313
+ | Décorateur | Méthode filtrée | Ancre | Exemple |
314
+ | ------------------------ | --------------- | ------------------------------------- | ------------------- |
315
+ | `@Get(path?, opts?)` | `GET` | `Get` (`routerDecorators.ts:476`) | `@Get("/{id}")` |
316
+ | `@Post(path?, opts?)` | `POST` | `Post` (`routerDecorators.ts:477`) | `@Post("")` |
317
+ | `@Put(path?, opts?)` | `PUT` | `Put` (`routerDecorators.ts:478`) | `@Put("/{id}")` |
318
+ | `@Delete(path?, opts?)` | `DELETE` | `Delete` (`routerDecorators.ts:479`) | `@Delete("/{id}")` |
319
+ | `@Patch(path?, opts?)` | `PATCH` | `Patch` (`routerDecorators.ts:480`) | `@Patch("/{id}")` |
320
+ | `@Options(path?, opts?)` | `OPTIONS` | `Options` (`routerDecorators.ts:481`) | `@Options("/{id}")` |
321
+ | `@Head(path?, opts?)` | `HEAD` | `Head` (`routerDecorators.ts:367`) | `@Head("/{id}")` |
322
+ | `@All(path?, opts?)` | **aucune** | `All()` (`routerDecorators.ts:374`) | `@All("/proxy/*")` |
323
+
324
+ Deux points qu'un dev découvre sinon à ses dépens :
325
+
326
+ - **Le nom de route est automatique et déterministe** : `BookController::read`. Utile pour les logs,
327
+ l'écran Routes de Studio et `forward()`. Deux actions homonymes dans deux classes ne collisionnent
328
+ pas ; deux méthodes de même nom dans la même classe, si (c'est impossible en TS).
329
+ - **`@All` n'émet aucun `requirements.methods`** — la route matche donc **toutes** les méthodes et ne
330
+ produit jamais de `405`. À réserver aux proxies et attrape-tout ; une API REST gagne à déclarer ses
331
+ méthodes, ne serait-ce que pour l'en-tête `Allow`.
332
+
333
+ Le second argument accepte les options de route non redondantes — `Omit<RouteOptions, "path" | "method">`
334
+ (`routerDecorators.ts:338`), soit `defaults`, `requirements`, `host`, `bypassFirewall`
335
+ (`RouteOptions`, `Route.ts:94`) :
336
+
337
+ ```typescript
338
+ @Get("/{page}", { defaults: { page: "1" }, requirements: { scheme: "https" } })
339
+ async index(@Param("page") page: string) { /* … */ }
340
+ ```
341
+
342
+ ### Paramètres — ce que l'action reçoit
343
+
344
+ Onze décorateurs, tous produits par `paramDecoratorFactory()` (`routerDecorators.ts:1148`) sauf
345
+ `@Body`, qui accepte une option supplémentaire. Chacun pose `{ source, key, index }` ; la valeur est
346
+ calculée par `resolveParamArg()` (`routerDecorators.ts:1283`), une fonction **pure** — ce qui la rend
347
+ testable sans démarrer de serveur.
348
+
349
+ | Décorateur | Sans clé renvoie… | Avec clé renvoie… | Ancre |
350
+ | ------------------- | ------------------------------------ | ------------------------------------------ | -------------------------------------------- |
351
+ | `@Param("id")` | toutes les variables d'URL (objet) | la variable d'URL nommée | `Param` (`routerDecorators.ts:1168`) |
352
+ | `@Query("q")` | toute la query string | un paramètre de la query string | `Query` (`routerDecorators.ts:1169`) |
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:1232`) |
355
+ | `@Cookie("sid")` | la map des cookies | un cookie (objet `Cookie`, champ `.value`) | `Cookie` (`routerDecorators.ts:1233`) |
356
+ | `@Session("user")` | l'objet `Session` vivant | `session.get(clé)` | `Session` (`routerDecorators.ts:1234`) |
357
+ | `@CurrentUser()` | l'utilisateur résolu par le firewall | — | `CurrentUser` (`routerDecorators.ts:1236`) |
358
+ | `@Req()` | la requête brute du contexte | — | `Req` (`routerDecorators.ts:1237`) |
359
+ | `@Res()` | la réponse du contexte | — | `Res` (`routerDecorators.ts:1238`) |
360
+ | `@UploadedFile()` | le **premier** fichier téléversé | — | `UploadedFile` (`routerDecorators.ts:1239`) |
361
+ | `@UploadedFiles()` | tous les fichiers téléversés | — | `UploadedFiles` (`routerDecorators.ts:1240`) |
362
+
363
+ La liste des sources possibles est fermée et typée : `ParamSource` (`routerDecorators.ts:365`).
364
+
365
+ #### Trois comportements à connaître
366
+
367
+ **`@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
+ justificatif (mot de passe, jeton). Hors zone authentifiée, la valeur est `undefined` — le décorateur
370
+ n'authentifie rien, il expose ce qui a déjà été prouvé.
371
+
372
+ **`@Session` active la session à lui seul.** La simple présence d'un paramètre `@Session` vaut
373
+ déclaration d'intention : `resolveSessionIntent()` (`routerDecorators.ts:819`) la détecte et pose
374
+ l'intent, exactement comme `@UseSession()`. Une route sans l'un ni l'autre ne paie aucune session.
375
+
376
+ **`@Body({ stream: true })` court-circuite le parsing.** Pour un gros téléversement (vidéo,
377
+ sauvegarde), on injecte le **flux brut** de la requête au lieu du corps chargé en mémoire ; le
378
+ pipeline saute alors le parsing pour cette route, décision prise en amont par
379
+ `routeExpectsBodyStream()` (`routerDecorators.ts:1365`) :
380
+
381
+ ```typescript
382
+ @Post("/upload")
383
+ async upload(@Body({ stream: true }) stream: NodeJS.ReadableStream) {
384
+ await pipeline(stream, createWriteStream("/data/upload.bin")); // 0 pic mémoire
385
+ return this.renderJson({ ok: true });
386
+ }
387
+ ```
388
+
389
+ > [!TIP]
390
+ > 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:1347`), et les trous restent
392
+ > `undefined`. Tu peux mélanger décorés et non décorés — les non décorés reçoivent `undefined`.
393
+
394
+ #### Le corps n'est pas validé — et c'est un choix
395
+
396
+ `@Body()` injecte le corps **tel qu'il a été parsé**. Le type écrit à côté n'est pas vérifié à
397
+ l'exécution : `@Body() dto: CreateOrder` compile, et un client peut très bien envoyer autre chose.
398
+
399
+ Ce n'est pas un oubli. Valider ici ne garderait que la porte **HTTP** — la même écriture arrivant
400
+ par WebSocket ou par une commande CLI passerait à côté — et la validation devrait rester
401
+ **synchrone**, puisque `resolveParamArg()` l'est ; la rendre asynchrone coûterait une microtâche à
402
+ toute requête à paramètres décorés, y compris celles qui ne valident rien.
403
+
404
+ La validation vit donc **plus bas**, là où tous les chemins se rejoignent.
405
+
406
+ **Une entité → les hooks du service.** `AbstractCrudService` appelle `beforeCreate` et
407
+ `beforeUpdate` en `await` (`orm-core/nodefony/src/AbstractCrudService.ts:150` et `:175`) : une règle
408
+ asynchrone — vérifier qu'un courriel est libre — y est donc possible, et le contrôle s'applique à
409
+ REST, à la socket et à la CLI d'un seul geste. C'est exactement ce que `nodefony create entity`
410
+ génère :
411
+
412
+ ```typescript
413
+ protected override beforeCreate(data: Partial<PostRow>): Partial<PostRow> {
414
+ return createPostSchema.parse(data) as Partial<PostRow>;
415
+ }
416
+ ```
417
+
418
+ **Un cas isolé → le schéma en tête d'action.** Même geste qu'`assertPageQuery()`, la garde de
419
+ pagination du cœur : une fonction appelée en première ligne, qui lève. Rien d'autre à écrire — une
420
+ `ZodError` qui remonte devient un **422** portant `error.fields` :
421
+
422
+ ```typescript
423
+ @Post("/subscribe")
424
+ subscribe(@Body() body: unknown) {
425
+ const dto = subscribeSchema.parse(body); // lève → 422 + fields
426
+ return this.renderJson({ ok: true, email: dto.email });
427
+ }
428
+ ```
429
+
430
+ Le rendu est assuré par `toValidationFields()` (`http/nodefony/service/error-renderer.ts:126`), qui
431
+ reconnaît l'erreur **par sa forme** (`name` + `issues`) et non par `instanceof` — une application
432
+ qui embarque sa propre copie de zod est donc servie pareil. Le client reçoit **422** (RFC 9110
433
+ §15.5.21 : le corps est lisible, c'est son contenu qui viole le contrat) et la liste des champs
434
+ fautifs, avec pour chacun son message et la règle qui a échoué.
435
+
436
+ **Comment typer le paramètre**, puisque le décorateur ne promet rien :
437
+
438
+ | Écriture | Ce que ça annonce | Verdict |
439
+ | ----------------------------- | --------------------------------------------------- | ----------------------- |
440
+ | `@Body() b: Partial<PostRow>` | la ligne de **table** — `id` et horodatages compris | promet trop |
441
+ | `@Body() b: unknown` | rien, honnêtement | juste, mais peu commode |
442
+ | `@Body() b: CreatePost` | le contrat d'**entrée**, `z.infer` du schéma | ✅ à préférer |
443
+
444
+ `CreatePost` et `UpdatePost` sont générés à côté du schéma (`nodefony/entity/Post.schema.ts`) : le
445
+ type et la validation dérivent de la même source, ils ne peuvent donc pas diverger. Un schéma
446
+ d'entrée ne décrit d'ailleurs pas la table — ni `id` ni horodatages n'y figurent, ils sont posés par
447
+ le serveur —, et zod **retire** les champs inconnus : un client qui glisserait `{ "role": "admin" }`
448
+ ne s'auto-promeut pas.
449
+
450
+ ### Réponse — statut, en-têtes, redirection
451
+
452
+ | Décorateur | Cible | Effet | Exemple |
453
+ | ------------------------- | ------- | ------------------------------------------------------------------------------------------ | ------------------------------------- |
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:589`) | `@Redirect("/login", 302)` |
457
+
458
+ Les deux premiers sont appliqués par `Resolver._applyResponseMeta()` (`Resolver.ts:650`) **avant**
459
+ l'appel de l'action : ton code peut donc les écraser ensuite (`this.renderJson(data, 202)` gagne).
460
+
461
+ `@Redirect` a une subtilité utile : si l'action **retourne un objet** portant `url` (et
462
+ éventuellement `statusCode`), cet objet **prend le dessus** sur les valeurs du décorateur
463
+ (`Resolver._handleRedirect()`, `Resolver.ts:666`) — la cible peut donc être calculée à l'exécution :
464
+
465
+ ```typescript
466
+ @Get("/go")
467
+ @Redirect("/fallback", 302) // cible par défaut
468
+ async go(@Query("to") to?: string) {
469
+ return to ? { url: to, statusCode: 307 } : undefined; // undefined → /fallback
470
+ }
471
+ ```
472
+
473
+ > [!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)`.
477
+
478
+ ### Sécurité — qui passe, qui décide, quelles défenses
479
+
480
+ Sept décorateurs, **tous duals** (classe ou méthode) et **tous sans logique** : ils posent une
481
+ étiquette que le `Resolver` ou le firewall consommera.
482
+
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:839`) |
486
+ | `@RequireScope(scope \| scopes)` | Exige un scope `api:action` d'un **jeton machine** ; no-op pour une session humaine | `RequireScope()` (`routerDecorators.ts:936`) |
487
+ | `@Anonymous()` | Rend l'action publique : annule l'autorisation **et** l'authentification (le « permitAll ») | `Anonymous()` (`routerDecorators.ts:912`) |
488
+ | `@BypassFirewall` | Court-circuite le firewall (sonde de liveness, webhook signé, endpoint de login). **Sans parenthèses** | `BypassFirewall` (`routerDecorators.ts:686`) |
489
+ | `@Csp({ "frame-src": [...] })` | Ajoute des directives CSP **à cette réponse** ; classe + méthode fusionnent additivement | `Csp()` (`routerDecorators.ts:1001`) |
490
+ | `@CsrfProtect()` | Opt-**in** au jeton anti-CSRF (double-submit signé) en plus de la défense globale | `CsrfProtect` (`routerDecorators.ts:1090`) |
491
+ | `@CsrfExempt()` | Opt-**out** de la défense CSRF **en gardant** l'authentification (webhook, POST cross-origin légitime) | `CsrfExempt` (`routerDecorators.ts:1099`) |
492
+
493
+ #### Rôles et scopes — deux axes, un seul verdict
494
+
495
+ `@IsGranted` et `@RequireScope` écrivent dans **deux jeux de métadonnées distincts**, puis
496
+ `computeSecurityRequirement()` (`routerDecorators.ts:1444`) les fusionne en une exigence unique dont
497
+ toutes les clauses sont en **ET**. Une seule chaîne d'application côté `Resolver`, deux jurés
498
+ différents côté `security` (le voteur de rôles, le voteur de scopes).
499
+
500
+ ```typescript
501
+ @controller("/api/orders")
502
+ @IsGranted("ROLE_USER") // vaut pour TOUTES les actions de la classe
503
+ class OrderController extends Controller {
504
+ @Get("") // hérite ROLE_USER
505
+ async list() {}
506
+
507
+ @Post("")
508
+ @RequireScope("orders:write") // + un scope si l'appelant est une clé API
509
+ async create() {} // ⇒ ROLE_USER ET orders:write
510
+
511
+ @Get("/health")
512
+ @Anonymous() // annule la garde de classe → route publique
513
+ async health() {}
514
+ }
515
+ ```
516
+
517
+ Pourquoi deux axes plutôt qu'un : **les rôles disent qui tu es**, **les scopes disent ce qu'une clé a
518
+ le droit de faire**. Un humain connecté ne doit pas être bridé par une notion prévue pour restreindre
519
+ un jeton délégué — d'où le no-op côté session.
520
+
521
+ #### La différence entre `@Anonymous`, `@BypassFirewall` et `@CsrfExempt`
522
+
523
+ Trois façons d'ouvrir une porte, trois portées — les confondre coûte cher :
524
+
525
+ | Décorateur | Authentification | Autorisation | Défense CSRF | Cas d'usage typique |
526
+ | ----------------- | :--------------: | :--------------: | :----------: | ------------------------------------- |
527
+ | `@Anonymous()` | ignorée | ignorée | conservée | page publique d'un contrôleur protégé |
528
+ | `@BypassFirewall` | ignorée | (rien à évaluer) | conservée | sonde `/health`, endpoint de login |
529
+ | `@CsrfExempt()` | **conservée** | **conservée** | ignorée | webhook signé, API cross-origin |
530
+
531
+ `@Anonymous()` pose en réalité **deux** marqueurs : « pas d'autorisation » et « pas de firewall »
532
+ (`routerDecorators.ts:719-734`) — c'est un `@BypassFirewall` doublé d'une annulation des clauses
533
+ héritées de la classe.
534
+
535
+ > [!CAUTION]
536
+ > `@BypassFirewall` s'écrit **sans parenthèses** : c'est un drapeau, pas une fabrique. Écrire
537
+ > `@BypassFirewall()` appelle la fonction avec `undefined` en cible et **n'ouvre rien** — la route
538
+ > reste gardée. Le sens du défaut est volontaire (_fail-closed_) : un oubli laisse la route fermée,
539
+ > jamais ouverte par erreur.
540
+
541
+ ### Cycle de la requête — session et anti-rejeu
542
+
543
+ | Décorateur | Cible | Effet | Ancre |
544
+ | ------------------------------------ | ----- | ---------------------------------------------------------------------------------------------------------- | --------------------------------- |
545
+ | `@UseSession({ readOnly?, eager? })` | dual | Déclare le besoin d'une session serveur ; **méthode > classe** (`UseSession()`, `routerDecorators.ts:761`) | `@UseSession({ readOnly: true })` |
546
+ | `@Idempotent({ required? })` | dual | Protège une mutation du double effet via `Idempotency-Key` (`Idempotent()`, `routerDecorators.ts:1103`) | `@Idempotent()` |
547
+
548
+ **`@UseSession` est la seule façon d'ouvrir une session** (avec un paramètre `@Session`, ou la reprise
549
+ d'un cookie existant). Il n'existe plus de « démarrer partout » global : une route qui ne déclare rien
550
+ ne coûte aucune lecture de stockage. Les deux options sont `readOnly` (lire sans jamais persister —
551
+ zéro écriture) et `eager` (activer tôt, pour régénérer l'identifiant juste après une authentification).
552
+ La forme exacte est celle de `SessionIntent` (`ISession.ts:18`).
553
+
554
+ **`@Idempotent` est strict par défaut** : une mutation sans `Idempotency-Key` reçoit **400**. Le mode
555
+ souple s'obtient par `@Idempotent({ required: false })` — sans effet en WebSocket, toujours strict
556
+ puisqu'une socket rejoue par nature. Les cinq verdicts, les statuts 409/422, la clé scopée par
557
+ identité et les stockages distribués sont traités dans la page dédiée →
558
+ [idempotence](./idempotence.md).
559
+
560
+ ### Le voisinage — décorateurs des autres modules
561
+
562
+ Ils ne viennent pas de `@nodefony/framework`, mais complètent la même DX ; on les cite pour éviter les
563
+ recherches inutiles.
564
+
565
+ | Décorateur | Paquet | Rôle |
566
+ | ----------------------------------- | -------------------- | ---------------------------------------------------------------------------------------------- |
567
+ | `@services([…])` | `nodefony` | Déclare les services d'un module (`services()`, `kernelDecorator.ts:24`) |
568
+ | `@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:114`) |
570
+ | `@Inject("nom")` | `nodefony` | Idem, sur une propriété (`Inject()`, `kernelDecorator.ts:143`) |
571
+ | `@RealtimeAction("méthode")` | `@nodefony/realtime` | Expose une action JSON-RPC sur socket (`RealtimeAction()`, `realtimeDecorators.ts:101`) |
572
+ | `@RealtimeChannel("canal", policy)` | `@nodefony/realtime` | Déclare un canal temps réel et sa politique (`RealtimeChannel()`, `realtimeDecorators.ts:142`) |
573
+ | `@RealtimeInbound("méthode")` | `@nodefony/realtime` | Traite un message entrant typé (`RealtimeInbound()`, `realtimeDecorators.ts:182`) |
574
+
575
+ Injection et portées → [injection-portees](../../../../../docs/architecture/injection-portees.md) ·
576
+ socket → [realtime](../../realtime/docs/index.md).
577
+
578
+ > [!NOTE]
579
+ > **`@nodefony/security` n'exporte aucun décorateur.** Toutes les annotations de sécurité
580
+ > (`@IsGranted`, `@RequireScope`, `@Anonymous`, `@Csp`, `@Csrf*`, `@BypassFirewall`) vivent **ici**,
581
+ > dans le framework, précisément pour qu'aucun cycle de dépendance ne se forme. Le moteur qui les
582
+ > applique, lui, est dans security → [firewall](../../security/docs/firewall.md) ·
583
+ > [autorisation](../../security/docs/authorization.md).
584
+
585
+ ## 🔌 HTTP et WebSocket — les mêmes décorateurs
586
+
587
+ Un contrôleur ne change pas de forme selon le transport : ce sont les `requirements.methods` qui
588
+ déclarent le canal, `WEBSOCKET` étant une pseudo-méthode du type `HTTPMethod` (`Context.ts:100`).
589
+
590
+ ```typescript
591
+ @controller("/ws/chat")
592
+ class ChatController extends Controller {
593
+ // Handshake + chaque message arrivent dans CETTE action.
594
+ @route("chat-echo", {
595
+ path: "/echo",
596
+ requirements: { methods: ["WEBSOCKET"] },
597
+ })
598
+ async echo(message: string | Buffer | null) {
599
+ if (!message) return this.renderJson({ handshake: true }); // 1er passage
600
+ return this.render(message.toString());
601
+ }
602
+
603
+ // DUPLEX : la même action est joignable en GET et par une frame `api.request`.
604
+ @route("chat-rooms", {
605
+ path: "/rooms",
606
+ requirements: { methods: ["GET", "WEBSOCKET"] },
607
+ })
608
+ async rooms(@Query("limit") limit?: string) {
609
+ return this.renderJson({ rooms: [], limit: limit ?? "25" });
610
+ }
611
+ }
612
+ ```
613
+
614
+ Trois faits à retenir :
615
+
616
+ - **Il n'existe pas de décorateur `@Ws`.** Le transport se déclare dans les `requirements` — via
617
+ `@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:476`).
620
+ - **Les décorateurs de paramètre fonctionnent pareil.** Pour une invocation par socket, le corps de
621
+ la mutation voyage dans l'ALS et **prime** sur le corps HTTP (vide dans ce cas) — c'est traité dans
622
+ `resolveParamArg()` (`routerDecorators.ts:1283`), et `@Query` lit la query du chemin **invoqué**,
623
+ pas celle du handshake (`Resolver._buildParamArgs()`, `Resolver.ts:637`).
624
+ - **Les gardes s'appliquent identiquement.** `@IsGranted` protège une action joignable par socket
625
+ exactement comme une action HTTP : la décision est prise avant l'instanciation, quel que soit le
626
+ transport.
627
+
628
+ Les décorateurs propres au temps réel (canaux, actions JSON-RPC) appartiennent à `@nodefony/realtime`
629
+ → [socket Nodefony](../../realtime/docs/index.md).
630
+
631
+ ## ⚙️ Options communes et règles de précédence
632
+
633
+ Quand la même chose est déclarée à deux endroits, qui gagne ? Les règles sont fixes, et elles ne sont
634
+ pas toutes identiques — c'est la source d'erreur n°1.
635
+
636
+ <!-- prettier-ignore -->
637
+ | Sujet | Règle | Ancre |
638
+ | --- | --- | --- |
639
+ | `@Domain` | option `host` de la route > méthode > classe | `controller()` (`routerDecorators.ts:88`) |
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:819`) |
642
+ | `@Idempotent` | méthode > classe | `computeIdempotent()` (`routerDecorators.ts:1561`) |
643
+ | `@IsGranted` / `@RequireScope` | **cumul en ET** : classe **plus** méthode | `computeSecurityRequirement()` (`routerDecorators.ts:1444`) |
644
+ | `@Anonymous` | méthode → annule tout ce que la classe a posé | `routerDecorators.ts:912` |
645
+ | `@Csp` | fusion **additive** classe + méthode (sources concaténées) | `mergeCspDirectives()` (`routerDecorators.ts:1000`) |
646
+ | `@CsrfProtect` / `@CsrfExempt` | OU logique : classe **ou** méthode suffit | `computeActionMeta()` (`routerDecorators.ts:1580`) |
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`) |
649
+
650
+ ### Où placer les décorateurs de classe
651
+
652
+ TypeScript applique les décorateurs de classe **de bas en haut** : celui écrit le plus près de la
653
+ classe s'exécute en premier. Deux régimes en découlent :
654
+
655
+ - **Lus AU MONTAGE** — `@Domain`, `@BypassFirewall` : ils doivent avoir posé leur métadonnée **avant**
656
+ que `@controller` ne construise les routes, donc **sous** `@controller`.
657
+ - **Lus PARESSEUSEMENT** (à la 1ʳᵉ requête) — `@IsGranted`, `@RequireScope`, `@Csp`, `@Csrf*`,
658
+ `@Idempotent`, `@UseSession`, `@Scope` : l'ordre est indifférent.
659
+
660
+ Une seule règle à retenir, sûre dans tous les cas : **`@controller` en haut, le reste en dessous.**
661
+
662
+ ```typescript
663
+ @controller("/admin") // ← toujours en premier
664
+ @Domain("admin.example.com")
665
+ @IsGranted("ROLE_ADMIN")
666
+ class AdminController extends Controller {
667
+ /* … */
668
+ }
669
+ ```
670
+
671
+ ## 🏗️ Architecture interne — de l'import à la requête
672
+
673
+ ```mermaid
674
+ sequenceDiagram
675
+ participant TS as Fichier contrôleur
676
+ participant R as Reflect metadata
677
+ participant CT as @controller
678
+ participant RTR as Router
679
+ participant RS as Resolver
680
+
681
+ TS->>R: @Get / @Body / @IsGranted (à l'import)
682
+ TS->>CT: @controller("/prefix") (dernier décorateur de classe)
683
+ CT->>R: getMetadata("routes:definitions")
684
+ CT->>RTR: createRoute(nom, options) × N
685
+ CT->>R: deleteMetadata (une classe = un montage)
686
+ Note over RTR: onBoot — @controllers → setController(classe, module)
687
+ RS->>R: 1ʳᵉ requête : computeActionMeta → RouteActionMeta figé
688
+ RS->>RS: requêtes suivantes : lecture O(1), 0 Reflect
689
+ ```
690
+
691
+ Le snapshot `RouteActionMeta` (`routerDecorators.ts:1392`) regroupe **tout** ce que les décorateurs
692
+ ont dit de l'action :
693
+
694
+ <!-- prettier-ignore -->
695
+ | Champ | Vient de | `null`/`false` quand |
696
+ | --- | --- | --- |
697
+ | `paramsMeta` | `@Param`/`@Body`/… | aucun paramètre décoré |
698
+ | `httpCode` | `@HttpCode` | absent |
699
+ | `headerEntries` | `@Header` | absent (entrées pré-dépliées une fois) |
700
+ | `redirectMeta` | `@Redirect` | absent |
701
+ | `sessionIntent` | `@UseSession` / `@Session` | la route ne veut pas de session |
702
+ | `security` | `@IsGranted` + `@RequireScope` | action non gardée (ou `@Anonymous`) |
703
+ | `cspDirectives` | `@Csp` | aucune directive déclarée |
704
+ | `csrfProtect` / `csrfExempt` | `@CsrfProtect` / `@CsrfExempt` | non déclarés |
705
+ | `idempotent` | `@Idempotent` | action non protégée |
706
+
707
+ Le `Resolver` consomme ce snapshot dans un ordre qui a du sens sécurité :
708
+ **garde d'abord, instanciation ensuite**. `security !== null` déclenche
709
+ `_enforceSecurity()` (`Resolver.ts:576`) **avant** `newController()` — un `403` n'instancie pas le
710
+ contrôleur et n'exécute pas son `initialize()`. Puis viennent les arguments
711
+ (`_buildParamArgs()`, `Resolver.ts:619`), les métadonnées de réponse
712
+ (`_applyResponseMeta()`, `Resolver.ts:650`), l'action, et enfin la redirection éventuelle.
713
+
714
+ Un usage cold path mérite d'être connu : `extractActionScopes()` (`routerDecorators.ts:1476`) parcourt
715
+ les routes au démarrage pour bâtir le **catalogue des scopes déclarés** — le formulaire de création
716
+ de clés API dans Studio propose les scopes réellement utilisés par le code, jamais une liste
717
+ maintenue à part.
718
+
719
+ ## ⚡ Performance & mémoire
720
+
721
+ Un décorateur non employé doit coûter **zéro**. C'est tenu par trois mécanismes vérifiables :
722
+
723
+ - **Lecture unique.** `resolveActionMeta()` (`routerDecorators.ts:1624`) mémorise le snapshot sur la
724
+ route au premier passage — ensuite, plus aucun appel `Reflect.getMetadata` ni `Object.entries` par
725
+ requête. Le même schéma vaut pour la détection du flux brut
726
+ (`routeExpectsBodyStream()`, `routerDecorators.ts:1365`).
727
+ - **`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
729
+ pour `idempotent`, `cspDirectives`, `paramsMeta`.
730
+ - **Objets gelés et partagés.** Les exigences de sécurité et d'idempotence sont créées **une fois** et
731
+ `Object.freeze`-ées (`routerDecorators.ts:1496`, `:1340`) : une seule instance pour la durée de vie
732
+ du processus, quelle que soit la charge. Corollaire : ne les mute jamais.
733
+
734
+ Coût résiduel côté montage seulement : la reconstruction de la pile d'appels dans `route()`
735
+ (`stackTrace`, `routerDecorators.ts:169`) pour retrouver le fichier source. Elle a lieu **à l'import**, une fois par
736
+ route, jamais pendant une requête.
737
+
738
+ ## 🧩 Extension — écrire son propre décorateur
739
+
740
+ Le module montre le patron à suivre : un décorateur maison **ne fait qu'écrire une métadonnée**, et
741
+ 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:1065`), dont `@CsrfProtect` et
743
+ `@CsrfExempt` sont les deux usages.
744
+
745
+ Le squelette d'un drapeau maison, en dehors du framework :
746
+
747
+ ```typescript
748
+ import "reflect-metadata";
749
+
750
+ const AUDIT_METADATA = "app:audit";
751
+
752
+ /** `@Audited()` — marque une action à tracer. Dual : classe ou méthode. */
753
+ export function Audited() {
754
+ return function (
755
+ target: any,
756
+ propertyKey?: string,
757
+ descriptor?: PropertyDescriptor,
758
+ ): any {
759
+ if (propertyKey === undefined) {
760
+ Reflect.defineMetadata(AUDIT_METADATA, true, target); // classe → constructeur
761
+ return target;
762
+ }
763
+ Reflect.defineMetadata(AUDIT_METADATA, true, target, propertyKey); // méthode → prototype
764
+ return descriptor;
765
+ };
766
+ }
767
+ ```
768
+
769
+ Deux invariants à respecter, tirés du code du module :
770
+
771
+ 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
773
+ fusion classe/méthode impossible.
774
+ 2. **Aucune I/O, aucun service, aucun import lourd dans le décorateur.** Il s'exécute à l'import, hors
775
+ de tout kernel : y résoudre un service planterait le simple fait de charger le fichier.
776
+
777
+ La lecture, elle, se fait au **cold path** (montage ou première requête), jamais à chaque requête.
778
+
779
+ ## 📡 Observabilité — Studio
780
+
781
+ Le **Playground** (`/nodefony/playground`, dev uniquement) construit un formulaire par action **à
782
+ partir des décorateurs** : transports déclarés, paramètres décorés triés par index, et badges de
783
+ gardes (`@IsGranted`, scopes, `@Idempotent`, CSRF, intent de session, bypass firewall). C'est le
784
+ miroir exact de ce que cette page décrit — si un badge manque, c'est que le décorateur n'est pas là.
785
+
786
+ L'écran **Routes** (`/nodefony/routes`) et le point d'API `/nodefony/framework/api/routes` listent les routes issues de
787
+ `@controller`/`@route`, avec leur nom auto-généré et leurs `requirements`.
788
+
789
+ ## ⚠️ Pièges (symptôme → cause → correction)
790
+
791
+ <!-- prettier-ignore -->
792
+ | Symptôme | Cause (dans le code) | Correction |
793
+ | --- | --- | --- |
794
+ | `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:452`](../../../../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
+ | `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
+ | Le vhost de `@Domain` classe est ignoré | `@Domain` placé **au-dessus** de `@controller` → posé trop tard | Placer `@Domain` sous `@controller` |
798
+ | `@BypassFirewall` n'ouvre rien | Écrit **avec** parenthèses — c'est un drapeau, pas une fabrique | `@BypassFirewall` (sans `()`) |
799
+ | Une route de classe reste publique malgré l'option | `bypassFirewall` est **cumulatif** : le `true` de la classe l'emporte | Retirer `@BypassFirewall` de la classe et le poser action par action |
800
+ | `403` alors que le rôle est bon | Module `security` absent, ou route hors zone firewall → aucun jeton (fail-closed) | Charger `@nodefony/security` et couvrir la route par une zone |
801
+ | `@CurrentUser()` vaut `undefined` | Route hors zone firewall — l'identité n'est jamais résolue hors zone | Couvrir la route par une zone (voir [firewall](../../security/docs/firewall.md)) |
802
+ | `@Session()` toujours `null` | Aucun intent : ni `@UseSession`, ni paramètre `@Session`, ni cookie repris | Ajouter `@UseSession()` sur l'action ou la classe |
803
+ | `@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
+ | `@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)` |
806
+ | 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 |
808
+ | 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
+
810
+ ## 🧪 Tests & couverture
811
+
812
+ Quatre suites unitaires et deux bancs d'intégration couvrent la surface — les chiffres exacts vivent
813
+ dans la carte régénérée depuis vitest, jamais figés ici :
814
+
815
+ - **unit `routerDecorators`** : création de route par `@controller`, application du préfixe, routes
816
+ multiples, effacement des métadonnées après montage, route magique `*` montée en dernier, stockage
817
+ des métadonnées `@Param`/`@Body`/`@Query` ;
818
+ - **unit `httpMethodDecorators`** : nommage automatique `Classe::méthode`, `requirements.methods` par
819
+ verbe, `@All` sans contrainte, `405` sur méthode non déclarée, `@HttpCode`/`@Header`
820
+ (accumulation)/`@Redirect` et leurs combinaisons ;
821
+ - **unit `paramDecorators`** : pose des métadonnées, accumulation sur une même méthode, résolution de
822
+ chaque source, robustesse sur contexte partiel (WS), placement positionnel des arguments ;
823
+ - **unit `securityDecorators`** : OU interne d'une clause, ET entre clauses empilées, fusion
824
+ classe+méthode, `subject`, annulation par `@Anonymous`, axe scope, descripteur gelé,
825
+ `@CurrentUser` depuis l'ALS ;
826
+ - **intégration** (`@nodefony/http`, serveur réel) : `decorators` (paramètres bout en bout) et
827
+ `decorators-response` (statut et en-têtes réellement émis).
828
+
829
+ Ce qui **manque** aujourd'hui : aucun banc de charge ni test mémoire dédié à la surface décorateur —
830
+ 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`).
832
+
833
+ Couverture : `npm run coverage` dans `@nodefony/framework`.
834
+
835
+ ## 🔗 Pour aller plus loin
836
+
837
+ - ⬆️ **Retour au hub** : [@nodefony/framework — vue du module](index.md) · [Toute la documentation](../../../../../docs/index.md)
838
+ - 🧭 **Pages sœurs** : [routing](./routing.md) (comment une route est compilée et choisie) ·
839
+ [controller](./controller.md) (cycle de vie et helpers de rendu) ·
840
+ [idempotence](./idempotence.md) (`@Idempotent` en profondeur)
841
+ - 🔐 **Le moteur derrière les gardes** : [firewall](../../security/docs/firewall.md) ·
842
+ [autorisation](../../security/docs/authorization.md) · [CSRF](../../security/docs/csrf.md)
843
+ - 🔌 **Socket et décorateurs temps réel** : [realtime](../../realtime/docs/index.md)
844
+ - 🏗️ **Où tout ça s'insère** : [pipeline-requete](../../../../../docs/architecture/pipeline-requete.md) ·
845
+ [injection-portees](../../../../../docs/architecture/injection-portees.md)