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

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 (66) hide show
  1. package/README.md +19 -0
  2. package/dist/_virtual/{_@oxc-project_runtime@0.153.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorate.js +1 -2
  3. package/dist/index.js +9 -18
  4. package/dist/nodefony/config/config.js +1 -2
  5. package/dist/nodefony/config/defineModuleConfig.js +1 -2
  6. package/dist/nodefony/controller/AdminApiController.js +39 -8
  7. package/dist/nodefony/controller/ApiKeyController.js +16 -8
  8. package/dist/nodefony/controller/BenchController.js +1 -2
  9. package/dist/nodefony/controller/IssuerMetadataController.js +1 -2
  10. package/dist/nodefony/controller/LoginPageController.js +4 -11
  11. package/dist/nodefony/controller/OAuth2Controller.js +3 -4
  12. package/dist/nodefony/controller/ProtectedResourceMetadataController.js +1 -2
  13. package/dist/nodefony/controller/SessionAuthController.js +13 -4
  14. package/dist/nodefony/controller/TokenAuthController.js +3 -4
  15. package/dist/nodefony/controller/TotpController.js +2 -3
  16. package/dist/nodefony/controller/WebAuthnController.js +2 -3
  17. package/dist/nodefony/controller/oauthAuthority.js +1 -2
  18. package/dist/nodefony/decorators/routerDecorators.js +155 -53
  19. package/dist/nodefony/service/AdminBroker.js +4 -6
  20. package/dist/nodefony/service/Eta.js +1 -2
  21. package/dist/nodefony/service/IdempotencyStore.js +4 -6
  22. package/dist/nodefony/service/router.js +49 -15
  23. package/dist/nodefony/src/Controller.js +32 -39
  24. package/dist/nodefony/src/FrameworkAdminApi.js +31 -10
  25. package/dist/nodefony/src/KernelAdminApi.js +7 -9
  26. package/dist/nodefony/src/PlaygroundAdminApi.js +72 -3
  27. package/dist/nodefony/src/RedisIdempotencyStore.js +46 -4
  28. package/dist/nodefony/src/Resolver.js +68 -33
  29. package/dist/nodefony/src/ResourceController.js +13 -6
  30. package/dist/nodefony/src/Route.js +2 -3
  31. package/dist/nodefony/src/SecurityApiController.js +1 -2
  32. package/dist/nodefony/src/SyslogAdminApi.js +1 -2
  33. package/dist/nodefony/src/Template.js +1 -2
  34. package/dist/nodefony/src/configMutation.js +1 -2
  35. package/dist/nodefony/src/docsReader.js +1 -2
  36. package/dist/nodefony/src/idempotency.js +1 -2
  37. package/dist/nodefony/src/idempotencyGc.js +1 -2
  38. package/dist/nodefony/src/idempotencyStoreRegistry.js +1 -2
  39. package/dist/nodefony/src/loginPage.js +2 -3
  40. package/dist/nodefony/src/requireAuthentication.js +1 -2
  41. package/dist/nodefony/src/routeSchema.js +81 -0
  42. package/dist/nodefony/src/scopeCatalog.js +1 -2
  43. package/dist/nodefony/src/singletonGuard.js +1 -2
  44. package/dist/nodefony/src/syslogFilters.js +7 -2
  45. package/dist/types/index.d.ts +5 -10
  46. package/dist/types/nodefony/controller/AdminApiController.d.ts +13 -1
  47. package/dist/types/nodefony/controller/SessionAuthController.d.ts +7 -0
  48. package/dist/types/nodefony/decorators/routerDecorators.d.ts +133 -32
  49. package/dist/types/nodefony/interfaces/IController.d.ts +2 -3
  50. package/dist/types/nodefony/service/router.d.ts +27 -1
  51. package/dist/types/nodefony/src/Controller.d.ts +23 -17
  52. package/dist/types/nodefony/src/PlaygroundAdminApi.d.ts +12 -0
  53. package/dist/types/nodefony/src/Resolver.d.ts +33 -1
  54. package/dist/types/nodefony/src/ResourceController.d.ts +12 -4
  55. package/dist/types/nodefony/src/Route.d.ts +3 -0
  56. package/dist/types/nodefony/src/loginPage.d.ts +1 -1
  57. package/dist/types/nodefony/src/routeSchema.d.ts +112 -0
  58. package/dist/types/nodefony/src/syslogFilters.d.ts +6 -0
  59. package/docs/admin.md +131 -82
  60. package/docs/controller.md +109 -110
  61. package/docs/decorateurs.md +214 -161
  62. package/docs/idempotence.md +154 -136
  63. package/docs/routing.md +105 -90
  64. package/docs/templates.md +55 -55
  65. package/package.json +8 -11
  66. package/dist/_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorateMetadata.js +0 -6
@@ -49,10 +49,10 @@ flowchart TD
49
49
 
50
50
  1. **À l'import**, chaque décorateur appelle `Reflect.defineMetadata` et rend la main. Zéro route
51
51
  créée, zéro service résolu.
52
- 2. **Au montage**, `controller()` (`routerDecorators.ts:189`) relit ces métadonnées et fabrique les
53
- objets `Route` ; `controllers()` (`routerDecorators.ts:111`) accroche le contrôleur au module sur
52
+ 2. **Au montage**, `controller()` (`routerDecorators.ts#controller`) relit ces métadonnées et fabrique les
53
+ objets `Route` ; `controllers()` (`routerDecorators.ts#controllers`) 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:1772`)
55
+ 3. **À la première requête** de chaque route, `resolveActionMeta()` (`routerDecorators.ts#resolveActionMeta`)
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
 
@@ -100,21 +100,23 @@ décorateur supprime la double vérité** : la déclaration vit sur l'action qu'
100
100
 
101
101
  ### Le contrat TypeScript — décorateurs _legacy_, et pourquoi ça compte
102
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 :
103
+ Nodefony utilise le format **legacy** de TypeScript : `experimentalDecorators: true` (dans le
104
+ `tsconfig.json` racine, repris par le module — `framework/tsconfig.json`). Ce n'est pas un détail
105
+ historique : c'est ce qui rend possibles les **décorateurs de paramètre** (`@Param`, `@Body`…, et
106
+ `@Inject("nom")` côté conteneur) — le format standard ES ne les propose pas.
106
107
 
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:135`).
108
+ `emitDecoratorMetadata`, en revanche, vaut **`false`** : Nodefony ne lit aucun type émis par le
109
+ compilateur. Une dépendance de constructeur se **nomme** par `@Inject("nom")`
110
+ (`kernelDecorator.ts#Inject`) ; un paramètre non nommé reçoit l'argument positionnel, ou
111
+ `undefined`, quel que soit son type ([ADR-0018](../../../../../docs/adr/0018-decorateurs-sans-metadonnees-de-type.md)).
112
+ Ne le repasser à `true` que pour une bibliothèque tierce qui lit les métadonnées `design:*`.
111
113
 
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.
114
+ Concrètement, dans une app générée par `nodefony create app`, ces options sont **déjà** dans le
115
+ `tsconfig.json`. Tu n'as rien à faire — sauf si tu pars d'un `tsconfig` à toi : sans
116
+ `experimentalDecorators`, les décorateurs ne compilent pas.
115
117
 
116
118
  > [!WARNING]
117
- > `reflect-metadata` doit être chargé **avant** tout décorateur. `routerDecorators.ts:1` l'importe
119
+ > `reflect-metadata` doit être chargé **avant** tout décorateur. `routerDecorators.ts` l'importe
118
120
  > pour toi dès que tu importes un décorateur du framework — mais si tu écris ton propre décorateur
119
121
  > dans un fichier chargé plus tôt, mets-y `import "reflect-metadata";` en tête.
120
122
 
@@ -124,16 +126,16 @@ Trois partis pris expliquent la forme de cette surface, et un développeur qui l
124
126
  jamais surprendre.
125
127
 
126
128
  **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:903`) pose une
129
+ logique de sécurité, de session ou d'idempotence. `IsGranted()` (`routerDecorators.ts#IsGranted`) pose une
128
130
  clause ; c'est le `Resolver` qui appellera le moteur d'autorisation, **résolu par son nom** dans le
129
- conteneur (`Resolver._enforceSecurity()`, `Resolver.ts:871`). Pourquoi ce détour : `@nodefony/framework`
131
+ conteneur (`Resolver._enforceSecurity()`, `Resolver.ts#Resolver._enforceSecurity`). Pourquoi ce détour : `@nodefony/framework`
130
132
  ne dépend **pas** de `@nodefony/security` — sans ça, les deux modules formeraient un cycle. Le prix à
131
133
  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:865`).
134
+ une erreur de démarrage (fail-closed, `Resolver.ts#Resolver._enforceSecurity`).
133
135
 
134
136
  **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:1729`) puis gelées sur la route.
136
- L'objet `RouteActionMeta` (`routerDecorators.ts:1537`) est **partagé par toutes les requêtes** — le
137
+ premier passage dans `computeActionMeta()` (`routerDecorators.ts#computeActionMeta`) puis gelées sur la route.
138
+ L'objet `RouteActionMeta` (`routerDecorators.ts#RouteActionMeta`) est **partagé par toutes les requêtes** — le
137
139
  framework ne le mute jamais, et ton code non plus. Une action non décorée obtient des champs à `null`,
138
140
  ce qui vaut **zéro branche** dans le chemin chaud.
139
141
 
@@ -284,43 +286,43 @@ Six familles, **36 décorateurs**, un seul fichier source. Le tableau de synthè
284
286
  <!-- prettier-ignore -->
285
287
  | Décorateur | Cible | Effet | Exemple |
286
288
  | --- | --- | --- | --- |
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
- | `@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:287`) | `@route("ws-echo", { path: "/echo", requirements: { methods: ["WEBSOCKET"] } })` |
290
- | `@Domain(motif \| motifs)` | **dual** | Restreint la route (ou la classe) à un ou plusieurs vhosts ; hors domaine → **403** (`Domain()`, `routerDecorators.ts:692`) | `@Domain("*.cdn.example.com")` |
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")` |
289
+ | `@controllers([…])` | **module** | Rattache des contrôleurs au module sur le hook `onBoot` ; sans lui, aucune route n'est servie (`controllers()`, `routerDecorators.ts#controllers`) | `@controllers([BookController])` |
290
+ | `@controller("/prefix")` | **classe** | Pose le préfixe d'URL **et déclenche la création des routes** de la classe (`controller()`, `routerDecorators.ts#controller`) | `@controller("/api/books")` |
291
+ | `@route(nom, options)` | méthode | Forme complète : nom explicite, chemin, `requirements`, `defaults`, hôte (`route()`, `routerDecorators.ts#route`) | `@route("ws-echo", { path: "/echo", requirements: { methods: ["WEBSOCKET"] } })` |
292
+ | `@Domain(motif \| motifs)` | **dual** | Restreint la route (ou la classe) à un ou plusieurs vhosts ; hors domaine → **403** (`Domain()`, `routerDecorators.ts#Domain`) | `@Domain("*.cdn.example.com")` |
293
+ | `@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#Scope`) | `@Scope("request")` |
292
294
 
293
295
  **`@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
296
+ **efface** (`Reflect.deleteMetadata`, `routerDecorators.ts#controllers`) : une classe ne se monte qu'une
295
297
  fois. Il traite au passage la route « magique » `path: "*"` en **dernier**, quel que soit son ordre
296
- d'écriture (`hasMagic`, `routerDecorators.ts:254`) — sinon un attrape-tout masquerait les routes précises.
298
+ d'écriture (`hasMagic`, `routerDecorators.ts#controller`) — sinon un attrape-tout masquerait les routes précises.
297
299
 
298
300
  **Le singleton — le défaut — est un contrat.** L'instance étant partagée, l'action ne doit lire ni
299
301
  é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")`
302
+ qui retrouvent la requête courante via l'ALS (`ControllerScope`, `Controller.ts#ControllerScope`). `@Scope("request")`
301
303
  rend une instance par requête : pour injecter un service de portée `request`, préparer chaque
302
304
  requête dans `initialize()`, ou garder un état sur `this`.
303
305
 
304
306
  > [!NOTE]
305
307
  > Le core `nodefony` exporte lui aussi un `Scope` (les portées du conteneur d'injection). Celui des
306
308
  > contrôleurs s'importe **depuis `@nodefony/framework`** — l'homonymie est signalée dans le code
307
- > (`routerDecorators.ts:785`).
309
+ > (`routerDecorators.ts#Scope`).
308
310
 
309
311
  ### Méthodes HTTP
310
312
 
311
- Toutes les fabriques sortent du même moule, `httpMethodDecorator()` (`routerDecorators.ts:494`) :
313
+ Toutes les fabriques sortent du même moule, `httpMethodDecorator()` (`routerDecorators.ts#httpMethodDecorator`) :
312
314
  elles nomment la route automatiquement `ClasseName::methode` et posent `requirements.methods`.
313
315
 
314
- | Décorateur | Méthode filtrée | Ancre | Exemple |
315
- | ------------------------ | --------------- | ------------------------------------- | ------------------- |
316
- | `@Get(path?, opts?)` | `GET` | `Get` (`routerDecorators.ts:540`) | `@Get("/{id}")` |
317
- | `@Post(path?, opts?)` | `POST` | `Post` (`routerDecorators.ts:541`) | `@Post("")` |
318
- | `@Put(path?, opts?)` | `PUT` | `Put` (`routerDecorators.ts:542`) | `@Put("/{id}")` |
319
- | `@Delete(path?, opts?)` | `DELETE` | `Delete` (`routerDecorators.ts:543`) | `@Delete("/{id}")` |
320
- | `@Patch(path?, opts?)` | `PATCH` | `Patch` (`routerDecorators.ts:544`) | `@Patch("/{id}")` |
321
- | `@Options(path?, opts?)` | `OPTIONS` | `Options` (`routerDecorators.ts:545`) | `@Options("/{id}")` |
322
- | `@Head(path?, opts?)` | `HEAD` | `Head` (`routerDecorators.ts:546`) | `@Head("/{id}")` |
323
- | `@All(path?, opts?)` | **aucune** | `All()` (`routerDecorators.ts:548`) | `@All("/proxy/*")` |
316
+ | Décorateur | Méthode filtrée | Ancre | Exemple |
317
+ | ------------------------ | --------------- | ----------------------------------------- | ------------------- |
318
+ | `@Get(path?, opts?)` | `GET` | `Get` (`routerDecorators.ts#Get`) | `@Get("/{id}")` |
319
+ | `@Post(path?, opts?)` | `POST` | `Post` (`routerDecorators.ts#Post`) | `@Post("")` |
320
+ | `@Put(path?, opts?)` | `PUT` | `Put` (`routerDecorators.ts#Put`) | `@Put("/{id}")` |
321
+ | `@Delete(path?, opts?)` | `DELETE` | `Delete` (`routerDecorators.ts#Delete`) | `@Delete("/{id}")` |
322
+ | `@Patch(path?, opts?)` | `PATCH` | `Patch` (`routerDecorators.ts#Patch`) | `@Patch("/{id}")` |
323
+ | `@Options(path?, opts?)` | `OPTIONS` | `Options` (`routerDecorators.ts#Options`) | `@Options("/{id}")` |
324
+ | `@Head(path?, opts?)` | `HEAD` | `Head` (`routerDecorators.ts#Head`) | `@Head("/{id}")` |
325
+ | `@All(path?, opts?)` | **aucune** | `All()` (`routerDecorators.ts#All`) | `@All("/proxy/*")` |
324
326
 
325
327
  Deux points qu'un dev découvre sinon à ses dépens :
326
328
 
@@ -332,53 +334,59 @@ Deux points qu'un dev découvre sinon à ses dépens :
332
334
  méthodes, ne serait-ce que pour l'en-tête `Allow`.
333
335
 
334
336
  Le second argument accepte les options de route non redondantes — `Omit<RouteOptions, "path" | "method">`
335
- (`routerDecorators.ts:338`), soit `defaults`, `requirements`, `host`, `bypassFirewall`
336
- (`RouteOptions`, `Route.ts:156`) :
337
+ (`routerDecorators.ts#route`), notamment `defaults`, `requirements`, `host`, `bypassFirewall` et `fallback`
338
+ (`RouteOptions`, `Route.ts#RouteOptions`) :
337
339
 
338
340
  ```typescript
339
341
  @Get("/{page}", { defaults: { page: "1" }, requirements: { scheme: "https" } })
340
342
  async index(@Param("page") page: string) { /* … */ }
341
343
  ```
342
344
 
345
+ `requirements.scheme` se compare au scheme **effectif** de la requête — celui qu'annonce un proxy de
346
+ confiance compris : hors exigence, la route répond **403**, jamais une redirection
347
+ (`Route.ts#Route.matchRequirements`). Rediriger `http` vers `https` relève de la bordure (HSTS, proxy
348
+ inverse), pas du routage. `fallback: true` fait de la route un **repli**, servi seulement si aucune
349
+ autre ne correspond — voir [le routage](routing.md#-ordre-de-résolution--trois-situations).
350
+
343
351
  ### Paramètres — ce que l'action reçoit
344
352
 
345
- Onze décorateurs, tous produits par `paramDecoratorFactory()` (`routerDecorators.ts:1214`) sauf
353
+ Onze décorateurs, tous produits par `paramDecoratorFactory()` (`routerDecorators.ts#paramDecoratorFactory`) sauf
346
354
  `@Body`, qui accepte une option supplémentaire. Chacun pose `{ source, key, index }` ; la valeur est
347
- calculée par `resolveParamArg()` (`routerDecorators.ts:1358`), une fonction **pure** — ce qui la rend
355
+ calculée par `resolveParamArg()` (`routerDecorators.ts#resolveParamArg`), une fonction **pure** — ce qui la rend
348
356
  testable sans démarrer de serveur.
349
357
 
350
- | Décorateur | Sans clé renvoie… | Avec clé renvoie… | Ancre |
351
- | ------------------- | ------------------------------------ | ------------------------------------------ | -------------------------------------------- |
352
- | `@Param("id")` | toutes les variables d'URL (objet) | la variable d'URL nommée | `Param` (`routerDecorators.ts:1238`) |
353
- | `@Query("q")` | toute la query string | un paramètre de la query string | `Query` (`routerDecorators.ts:1239`) |
354
- | `@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:1304`) |
356
- | `@Cookie("sid")` | la map des cookies | un cookie (objet `Cookie`, champ `.value`) | `Cookie` (`routerDecorators.ts:1305`) |
357
- | `@Session("user")` | l'objet `Session` vivant | `session.get(clé)` | `Session` (`routerDecorators.ts:1306`) |
358
- | `@CurrentUser()` | l'utilisateur résolu par le firewall | — | `CurrentUser` (`routerDecorators.ts:1308`) |
359
- | `@Req()` | la requête brute du contexte | — | `Req` (`routerDecorators.ts:1309`) |
360
- | `@Res()` | la réponse du contexte | — | `Res` (`routerDecorators.ts:1310`) |
361
- | `@UploadedFile()` | le **premier** fichier téléversé | — | `UploadedFile` (`routerDecorators.ts:1311`) |
362
- | `@UploadedFiles()` | tous les fichiers téléversés | — | `UploadedFiles` (`routerDecorators.ts:1312`) |
363
-
364
- La liste des sources possibles est fermée et typée : `ParamSource` (`routerDecorators.ts:390`).
358
+ | Décorateur | Sans clé renvoie… | Avec clé renvoie… | Ancre |
359
+ | ------------------- | ------------------------------------ | ------------------------------------------ | ----------------------------------------------------- |
360
+ | `@Param("id")` | toutes les variables d'URL (objet) | la variable d'URL nommée | `Param` (`routerDecorators.ts#Param`) |
361
+ | `@Query("q")` | toute la query string | un paramètre de la query string | `Query` (`routerDecorators.ts#Query`) |
362
+ | `@Body("field")` | le corps parsé entier | un champ du corps parsé | `Body()` (`routerDecorators.ts#Body`) |
363
+ | `@Headers("x-foo")` | tous les en-têtes de requête | un en-tête (**lookup en minuscules**) | `Headers` (`routerDecorators.ts#Headers`) |
364
+ | `@Cookie("sid")` | la map des cookies | un cookie (objet `Cookie`, champ `.value`) | `Cookie` (`routerDecorators.ts#Cookie`) |
365
+ | `@Session("user")` | l'objet `Session` vivant | `session.get(clé)` | `Session` (`routerDecorators.ts#Session`) |
366
+ | `@CurrentUser()` | l'utilisateur résolu par le firewall | — | `CurrentUser` (`routerDecorators.ts#CurrentUser`) |
367
+ | `@Req()` | la requête brute du contexte | — | `Req` (`routerDecorators.ts#Req`) |
368
+ | `@Res()` | la réponse du contexte | — | `Res` (`routerDecorators.ts#Res`) |
369
+ | `@UploadedFile()` | le **premier** fichier téléversé | — | `UploadedFile` (`routerDecorators.ts#UploadedFile`) |
370
+ | `@UploadedFiles()` | tous les fichiers téléversés | — | `UploadedFiles` (`routerDecorators.ts#UploadedFiles`) |
371
+
372
+ La liste des sources possibles est fermée et typée : `ParamSource` (`routerDecorators.ts#ParamSource`).
365
373
 
366
374
  #### Trois comportements à connaître
367
375
 
368
376
  **`@CurrentUser` lit l'ALS, jamais un argument caché.** La valeur vient de `RequestContext.getUser()`
369
- (`routerDecorators.ts:1308`) : l'utilisateur posé par le firewall. C'est **l'utilisateur**, jamais le
377
+ (`routerDecorators.ts#CurrentUser`) : l'utilisateur posé par le firewall. C'est **l'utilisateur**, jamais le
370
378
  justificatif (mot de passe, jeton). Hors zone authentifiée, la valeur est `undefined` — y compris
371
379
  pour l'anonyme d'une zone qui l'accepte — : le décorateur n'authentifie rien, il expose ce qui a déjà
372
380
  été prouvé. Sans décorateur, la même valeur se lit par `this.user` dans le contrôleur.
373
381
 
374
382
  **`@Session` active la session à lui seul.** La simple présence d'un paramètre `@Session` vaut
375
- déclaration d'intention : `resolveSessionIntent()` (`routerDecorators.ts:858`) la détecte et pose
383
+ déclaration d'intention : `resolveSessionIntent()` (`routerDecorators.ts#resolveSessionIntent`) la détecte et pose
376
384
  l'intent, exactement comme `@UseSession()`. Une route sans l'un ni l'autre ne paie aucune session.
377
385
 
378
386
  **`@Body({ stream: true })` court-circuite le parsing.** Pour un gros téléversement (vidéo,
379
387
  sauvegarde), on injecte le **flux brut** de la requête au lieu du corps chargé en mémoire ; le
380
388
  pipeline saute alors le parsing pour cette route, décision prise en amont par
381
- `routeExpectsBodyStream()` (`routerDecorators.ts:1509`) :
389
+ `routeExpectsBodyStream()` (`routerDecorators.ts#routeExpectsBodyStream`) :
382
390
 
383
391
  ```typescript
384
392
  @Post("/upload")
@@ -390,79 +398,125 @@ async upload(@Body({ stream: true }) stream: NodeJS.ReadableStream) {
390
398
 
391
399
  > [!TIP]
392
400
  > L'ordre d'écriture des paramètres décorés n'a aucune importance : chaque valeur est placée à son
393
- > **index déclaré** par `buildParamArgs()` (`routerDecorators.ts:1422`), et les trous restent
401
+ > **index déclaré** par `buildParamArgs()` (`routerDecorators.ts#buildParamArgs`), et les trous restent
394
402
  > `undefined`. Tu peux mélanger décorés et non décorés — les non décorés reçoivent `undefined`.
395
403
 
396
- #### Le corps n'est pas validé — et c'est un choix
404
+ ### Routes à schéma — l'entrée validée ET typée
405
+
406
+ Un décorateur de paramètre **affirme** un type sans le vérifier : `@Body() dto: CreateOrder` compile,
407
+ et un client peut envoyer autre chose. L'option `schema` des décorateurs de route (`@route`, `@Get`,
408
+ `@Post`…) **déclare** le contrat de l'action, partie par partie, et le framework le fait respecter
409
+ avant de l'exécuter (ADR-0018) :
397
410
 
398
- `@Body()` injecte le corps **tel qu'il a été parsé**. Le type écrit à côté n'est pas vérifié à
399
- l'exécution : `@Body() dto: CreateOrder` compile, et un client peut très bien envoyer autre chose.
411
+ ```typescript
412
+ import { Post, type RouteInput } from "@nodefony/framework";
413
+ import { z } from "zod";
400
414
 
401
- Ce n'est pas un oubli. Valider ici ne garderait que la porte **HTTP** — la même écriture arrivant
402
- par WebSocket ou par une commande CLI passerait à côté — et la validation devrait rester
403
- **synchrone**, puisque `resolveParamArg()` l'est ; la rendre asynchrone coûterait une microtâche à
404
- toute requête à paramètres décorés, y compris celles qui ne valident rien.
415
+ const postParams = z.object({ id: z.coerce.number().int().positive() });
416
+ const postBody = z.object({ title: z.string().min(3) });
405
417
 
406
- La validation vit donc **plus bas**, là où tous les chemins se rejoignent.
418
+ @Post("/posts/{id}", { schema: { params: postParams, body: postBody } })
419
+ update(input: RouteInput<{ params: typeof postParams; body: typeof postBody }>) {
420
+ return { id: input.params.id, title: input.body.title }; // id : number
421
+ }
422
+ ```
407
423
 
408
- **Une entité → les hooks du service.** `AbstractCrudService` appelle `beforeCreate` et
409
- `beforeUpdate` en `await` (`orm-core/nodefony/src/AbstractCrudService.ts:150` et `:175`) : une règle
410
- asynchrone — vérifier qu'un courriel est libre — y est donc possible, et le contrôle s'applique à
411
- REST, à la socket et à la CLI d'un seul geste. C'est exactement ce que `nodefony create entity`
412
- génère :
424
+ | Partie | Ce qu'elle valide |
425
+ | --------- | ---------------------------------------------------------------------------------------- |
426
+ | `params` | les variables de l'URL (`/posts/{id}`) |
427
+ | `query` | la query — sur le pont WebSocket, celle du chemin **invoqué** |
428
+ | `body` | le corps **décodé** (JSON, formulaire) — sur le pont WebSocket, celui porté par la trame |
429
+ | `headers` | les en-têtes, clés en minuscules — sur WebSocket, ceux de la poignée de main |
430
+
431
+ **Ce que fait le framework, dans l'ordre :**
432
+
433
+ - chaque partie déclarée est un [Standard Schema](https://standardschema.dev) — Zod, Valibot,
434
+ ArkType… ; l'interface est recopiée au cœur (`runtime/standardSchema.ts#IStandardSchemaV1`), aucune
435
+ dépendance n'est imposée ;
436
+ - la validation a lieu **après** les gardes (`@IsGranted` : un 403 ne révèle pas le contrat) et
437
+ **avant** l'instanciation du contrôleur : une entrée invalide ne construit rien
438
+ (`Resolver.ts#Resolver.executeAction`) ;
439
+ - elle est **synchrone** tant que chaque validateur l'est ; un raffinement asynchrone (« ce slug
440
+ est-il libre ? ») la fait attendre, et seulement lui (`routeSchema.ts#validateRouteInput`) ;
441
+ - les quatre parties sont jugées ensemble : un échec rend **toutes** les anomalies en une fois, dans
442
+ une `SchemaValidationError` (`runtime/standardSchema.ts#SchemaValidationError`) — **422**, la même
443
+ forme qu'une `ZodError`, sur HTTP (`error.fields`) comme sur le pont WebSocket (`data.fields`).
444
+ Chaque champ porte sa partie : `{ "field": "title", "in": "body", "message": "…" }` ;
445
+ - l'action reçoit **un seul argument**, `input`, qui porte toujours les quatre parties : la
446
+ **sortie** du schéma (coercitions, valeurs par défaut) pour une partie déclarée, la valeur brute
447
+ pour les autres.
448
+
449
+ **La signature est vérifiée à la compilation.** Un `input` dont un champ diverge du schéma, ou un
450
+ second argument, ne compile pas (`routerDecorators.ts#IHttpMethodDecorator`). L'identité se lit par
451
+ `this.user`, pas par `@CurrentUser`.
452
+
453
+ **Exclusif des décorateurs de paramètre.** Une action à schéma reçoit un argument unique : la mêler de
454
+ `@Body`, `@Query`… est refusé **au démarrage**, par une erreur qui nomme la route
455
+ (`routerDecorators.ts#assertRouteSchemaDeclaration`, appelé par `Router.setController`). Même refus pour
456
+ une partie inconnue (`bodies`) et pour une valeur qui n'est pas un Standard Schema.
457
+
458
+ **Un gros corps en flux : `bodyStream: true`.** Il remplace `@Body({ stream: true })` : `input.body`
459
+ est alors le flux brut, jamais lu en mémoire. `bodyStream` et `schema.body` s'excluent — un flux non
460
+ lu ne se valide pas —, et `bodyStream` **exige** un schéma : sans `input`, le flux ne serait livré à
461
+ personne. La compilation refuse les deux fautes, le démarrage aussi ; une action sans schéma demande
462
+ son flux par `@Body({ stream: true })`.
413
463
 
414
464
  ```typescript
415
- protected override beforeCreate(data: Partial<PostRow>): Partial<PostRow> {
416
- return createPostSchema.parse(data) as Partial<PostRow>;
465
+ const uploadQuery = z.object({ name: z.string() });
466
+
467
+ @Post("/upload", { schema: { query: uploadQuery }, bodyStream: true })
468
+ async upload(input: RouteInput<{ query: typeof uploadQuery }, true>) {
469
+ await pipeline(input.body, createWriteStream(`/data/${input.query.name}`));
417
470
  }
418
471
  ```
419
472
 
420
- **Un cas isolé → le schéma en tête d'action.** Même geste qu'`assertPageQuery()`, la garde de
421
- pagination du cœur : une fonction appelée en première ligne, qui lève. Rien d'autre à écrire — une
422
- `ZodError` qui remonte devient un **422** portant `error.fields` :
473
+ **Des octets se valident tels quels.** La valeur passée au schéma est celle que le transport a
474
+ **décodée** — ni recopiée, ni re-sérialisée : `z.instanceof(Uint8Array)` valide un corps binaire
475
+ sans copie, ce qui tiendra quand la socket portera du binaire.
476
+
477
+ **Sans schéma, rien ne change** — et rien n'est alloué de plus : la route garde ses décorateurs de
478
+ paramètre, et le Resolver ne paie qu'une comparaison.
479
+
480
+ **Une route créée par programme** (`Router.createRoute`) n'accepte ni `schema` ni `bodyStream` : ils
481
+ se rangent sur la méthode par un décorateur, et les ignorer en silence mentirait — l'appel est refusé.
482
+
483
+ #### Et une entité ?
484
+
485
+ `nodefony create entity` génère les deux étages : la route déclare `createPostSchema` /
486
+ `updatePostSchema` (422 à la porte, avant tout appel au service), et le service les revalide dans
487
+ `beforeCreate` / `beforeUpdate` (`orm-core/nodefony/src/AbstractCrudService.ts#AbstractCrudService.create`)
488
+ pour ses autres portes — CLI, actions temps réel —, où une règle asynchrone (unicité en base) reste
489
+ possible. Le schéma d'entrée ne décrit pas la table : ni `id` ni horodatages n'y figurent, et les
490
+ champs inconnus sont **retirés** — un client qui glisserait `{ "role": "admin" }` ne s'auto-promeut pas.
491
+
492
+ #### Sans schéma, le corps n'est pas validé
493
+
494
+ `@Body()` injecte le corps **tel qu'il a été lu**. Pour un cas isolé hors route à schéma, le geste
495
+ reste le schéma en tête d'action — une `ZodError` qui remonte devient le même **422**, reconnu par sa
496
+ forme (`http/nodefony/service/error-renderer.ts#toValidationFields`) et non par `instanceof` :
423
497
 
424
498
  ```typescript
425
499
  @Post("/subscribe")
426
500
  subscribe(@Body() body: unknown) {
427
501
  const dto = subscribeSchema.parse(body); // lève → 422 + fields
428
- return this.renderJson({ ok: true, email: dto.email });
502
+ return { ok: true, email: dto.email };
429
503
  }
430
504
  ```
431
505
 
432
- Le rendu est assuré par `toValidationFields()` (`http/nodefony/service/error-renderer.ts:126`), qui
433
- reconnaît l'erreur **par sa forme** (`name` + `issues`) et non par `instanceof` — une application
434
- qui embarque sa propre copie de zod est donc servie pareil. Le client reçoit **422** (RFC 9110
435
- §15.5.21 : le corps est lisible, c'est son contenu qui viole le contrat) et la liste des champs
436
- fautifs, avec pour chacun son message et la règle qui a échoué.
437
-
438
- **Comment typer le paramètre**, puisque le décorateur ne promet rien :
439
-
440
- | Écriture | Ce que ça annonce | Verdict |
441
- | ----------------------------- | --------------------------------------------------- | ----------------------- |
442
- | `@Body() b: Partial<PostRow>` | la ligne de **table** — `id` et horodatages compris | promet trop |
443
- | `@Body() b: unknown` | rien, honnêtement | juste, mais peu commode |
444
- | `@Body() b: CreatePost` | le contrat d'**entrée**, `z.infer` du schéma | ✅ à préférer |
445
-
446
- `CreatePost` et `UpdatePost` sont générés à côté du schéma (`nodefony/entity/Post.schema.ts`) : le
447
- type et la validation dérivent de la même source, ils ne peuvent donc pas diverger. Un schéma
448
- d'entrée ne décrit d'ailleurs pas la table — ni `id` ni horodatages n'y figurent, ils sont posés par
449
- le serveur —, et zod **retire** les champs inconnus : un client qui glisserait `{ "role": "admin" }`
450
- ne s'auto-promeut pas.
451
-
452
506
  ### Réponse — statut, en-têtes, redirection
453
507
 
454
- | Décorateur | Cible | Effet | Exemple |
455
- | ------------------------- | ------- | ------------------------------------------------------------------------------------------ | ------------------------------------- |
456
- | `@HttpCode(201)` | méthode | Fixe le statut **avant** l'exécution de l'action (`HttpCode()`, `routerDecorators.ts:582`) | `@HttpCode(204)` |
457
- | `@Header("X-Foo", "bar")` | méthode | Ajoute un en-tête ; **s'empile** (plusieurs `@Header` cumulent, `routerDecorators.ts:603`) | `@Header("Cache-Control","no-store")` |
458
- | `@Redirect("/url", 302)` | méthode | Redirige **si** l'action ne renvoie rien (`Redirect()`, `routerDecorators.ts:649`) | `@Redirect("/login", 302)` |
508
+ | Décorateur | Cible | Effet | Exemple |
509
+ | ------------------------- | ------- | ----------------------------------------------------------------------------------------------- | ------------------------------------- |
510
+ | `@HttpCode(201)` | méthode | Fixe le statut **avant** l'exécution de l'action (`HttpCode()`, `routerDecorators.ts#HttpCode`) | `@HttpCode(204)` |
511
+ | `@Header("X-Foo", "bar")` | méthode | Ajoute un en-tête ; **s'empile** (plusieurs `@Header` cumulent, `routerDecorators.ts#Header`) | `@Header("Cache-Control","no-store")` |
512
+ | `@Redirect("/url", 302)` | méthode | Redirige **si** l'action ne renvoie rien (`Redirect()`, `routerDecorators.ts#Redirect`) | `@Redirect("/login", 302)` |
459
513
 
460
- Les deux premiers sont appliqués par `Resolver._applyResponseMeta()` (`Resolver.ts:977`) **avant**
514
+ Les deux premiers sont appliqués par `Resolver._applyResponseMeta()` (`Resolver.ts#Resolver._applyResponseMeta`) **avant**
461
515
  l'appel de l'action : ton code peut donc les écraser ensuite (`this.renderJson(data, 202)` gagne).
462
516
 
463
517
  `@Redirect` a une subtilité utile : si l'action **retourne un objet** portant `url` (et
464
518
  éventuellement `statusCode`), cet objet **prend le dessus** sur les valeurs du décorateur
465
- (`Resolver._handleRedirect()`, `Resolver.ts:1002`) — la cible peut donc être calculée à l'exécution :
519
+ (`Resolver._handleRedirect()`, `Resolver.ts#Resolver._handleRedirect`) — la cible peut donc être calculée à l'exécution :
466
520
 
467
521
  ```typescript
468
522
  @Get("/go")
@@ -483,21 +537,21 @@ async go(@Query("to") to?: string) {
483
537
  Sept décorateurs, **tous duals** (classe ou méthode) et **tous sans logique** : ils posent une
484
538
  étiquette que le `Resolver` ou le firewall consommera.
485
539
 
486
- | Décorateur | Effet | Ancre |
487
- | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ |
488
- | `@IsGranted(attr \| attrs, { subject })` | Exige un attribut (rôle `ROLE_*` ou règle métier). Tableau = **OU** ; empilés = **ET** ; refus → **403** | `IsGranted()` (`routerDecorators.ts:903`) |
489
- | `@RequireScope(scope \| scopes)` | Exige un scope `api:action` d'un **jeton machine** ; no-op pour une session humaine | `RequireScope()` (`routerDecorators.ts:1002`) |
490
- | `@RequireAuthentication({ maxAge, amr })` | Exige une connexion **récente** (`maxAge`, s) et/ou faite avec certains moyens (`amr` RFC 8176, ex. `otp`) ; évalué après `@IsGranted` ; refus → **401** RFC 9470 | `RequireAuthentication()` (`routerDecorators.ts:1883`) |
491
- | `@Anonymous()` | Rend l'action publique : annule l'autorisation **et** l'authentification (le « permitAll ») | `Anonymous()` (`routerDecorators.ts:969`) |
492
- | `@BypassFirewall` | Court-circuite le firewall (sonde de liveness, webhook signé, endpoint de login). **Sans parenthèses** | `BypassFirewall` (`routerDecorators.ts:725`) |
493
- | `@Csp({ "frame-src": [...] })` | Ajoute des directives CSP **à cette réponse** ; classe + méthode fusionnent additivement | `Csp()` (`routerDecorators.ts:1092`) |
494
- | `@CsrfProtect()` | Opt-**in** au jeton anti-CSRF (double-submit signé) en plus de la défense globale | `CsrfProtect` (`routerDecorators.ts:1156`) |
495
- | `@CsrfExempt()` | Opt-**out** de la défense CSRF **en gardant** l'authentification (webhook, POST cross-origin légitime) | `CsrfExempt` (`routerDecorators.ts:1165`) |
540
+ | Décorateur | Effet | Ancre |
541
+ | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
542
+ | `@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#IsGranted`) |
543
+ | `@RequireScope(scope \| scopes)` | Exige un scope `api:action` d'un **jeton machine** ; no-op pour une session humaine | `RequireScope()` (`routerDecorators.ts#RequireScope`) |
544
+ | `@RequireAuthentication({ maxAge, amr })` | Exige une connexion **récente** (`maxAge`, s) et/ou faite avec certains moyens (`amr` RFC 8176, ex. `otp`) ; évalué après `@IsGranted` ; refus → **401** RFC 9470 | `RequireAuthentication()` (`routerDecorators.ts#RequireAuthentication`) |
545
+ | `@Anonymous()` | Rend l'action publique : annule l'autorisation **et** l'authentification (le « permitAll ») | `Anonymous()` (`routerDecorators.ts#Anonymous`) |
546
+ | `@BypassFirewall` | Court-circuite le firewall (sonde de liveness, webhook signé, endpoint de login). **Sans parenthèses** | `BypassFirewall` (`routerDecorators.ts#BypassFirewall`) |
547
+ | `@Csp({ "frame-src": [...] })` | Ajoute des directives CSP **à cette réponse** ; classe + méthode fusionnent additivement | `Csp()` (`routerDecorators.ts#Csp`) |
548
+ | `@CsrfProtect()` | Opt-**in** au jeton anti-CSRF (double-submit signé) en plus de la défense globale | `CsrfProtect` (`routerDecorators.ts#CsrfProtect`) |
549
+ | `@CsrfExempt()` | Opt-**out** de la défense CSRF **en gardant** l'authentification (webhook, POST cross-origin légitime) | `CsrfExempt` (`routerDecorators.ts#CsrfExempt`) |
496
550
 
497
551
  #### Rôles et scopes — deux axes, un seul verdict
498
552
 
499
553
  `@IsGranted` et `@RequireScope` écrivent dans **deux jeux de métadonnées distincts**, puis
500
- `computeSecurityRequirement()` (`routerDecorators.ts:1648`) les fusionne en une exigence unique dont
554
+ `computeSecurityRequirement()` (`routerDecorators.ts#computeSecurityRequirement`) les fusionne en une exigence unique dont
501
555
  toutes les clauses sont en **ET**. Une seule chaîne d'application côté `Resolver`, deux jurés
502
556
  différents côté `security` (le voteur de rôles, le voteur de scopes).
503
557
 
@@ -533,7 +587,7 @@ Trois façons d'ouvrir une porte, trois portées — les confondre coûte cher :
533
587
  | `@CsrfExempt()` | **conservée** | **conservée** | ignorée | webhook signé, API cross-origin |
534
588
 
535
589
  `@Anonymous()` pose en réalité **deux** marqueurs : « pas d'autorisation » et « pas de firewall »
536
- (`routerDecorators.ts:953-979`) — c'est un `@BypassFirewall` doublé d'une annulation des clauses
590
+ (`routerDecorators.ts#Anonymous`) — c'est un `@BypassFirewall` doublé d'une annulation des clauses
537
591
  héritées de la classe.
538
592
 
539
593
  > [!CAUTION]
@@ -544,17 +598,17 @@ héritées de la classe.
544
598
 
545
599
  ### Cycle de la requête — session et anti-rejeu
546
600
 
547
- | Décorateur | Cible | Effet | Ancre |
548
- | ---------------------------- | ----- | ---------------------------------------------------------------------------------------------------------- | --------------------------------- |
549
- | `@UseSession({ readOnly? })` | dual | Déclare le besoin d'une session serveur ; **méthode > classe** (`UseSession()`, `routerDecorators.ts:830`) | `@UseSession({ readOnly: true })` |
550
- | `@Idempotent({ required? })` | dual | Protège une mutation du double effet via `Idempotency-Key` (`Idempotent()`, `routerDecorators.ts:1171`) | `@Idempotent()` |
601
+ | Décorateur | Cible | Effet | Ancre |
602
+ | ---------------------------- | ----- | ----------------------------------------------------------------------------------------------------------------- | --------------------------------- |
603
+ | `@UseSession({ readOnly? })` | dual | Déclare le besoin d'une session serveur ; **méthode > classe** (`UseSession()`, `routerDecorators.ts#UseSession`) | `@UseSession({ readOnly: true })` |
604
+ | `@Idempotent({ required? })` | dual | Protège une mutation du double effet via `Idempotency-Key` (`Idempotent()`, `routerDecorators.ts#Idempotent`) | `@Idempotent()` |
551
605
 
552
606
  **`@UseSession` est la seule façon d'ouvrir une session** (avec un paramètre `@Session`, ou la reprise
553
607
  d'un cookie existant). Il n'existe plus de « démarrer partout » global : une route qui ne déclare rien
554
608
  ne coûte aucune lecture de stockage. Une seule option : `readOnly` (lire sans jamais persister —
555
609
  zéro écriture). La régénération de l'identifiant après authentification est faite par le login
556
610
  (`AuthFlow`), pas par une option du décorateur.
557
- La forme exacte est celle de `SessionIntent` (`ISession.ts:18`).
611
+ La forme exacte est celle de `SessionIntent` (`ISession.ts#SessionIntent.readOnly`).
558
612
 
559
613
  **`@Idempotent` est strict par défaut** : une mutation sans `Idempotency-Key` reçoit **400**. Le mode
560
614
  souple s'obtient par `@Idempotent({ required: false })` — sans effet en WebSocket, toujours strict
@@ -567,15 +621,14 @@ identité et les stockages distribués sont traités dans la page dédiée →
567
621
  Ils ne viennent pas de `@nodefony/framework`, mais complètent la même DX ; on les cite pour éviter les
568
622
  recherches inutiles.
569
623
 
570
- | Décorateur | Paquet | Rôle |
571
- | ----------------------------------- | -------------------- | ---------------------------------------------------------------------------------------------- |
572
- | `@services([…])` | `nodefony` | Déclare les services d'un module (`services()`, `kernelDecorator.ts:53`) |
573
- | `@injectable()` | `nodefony` | Rend une classe résoluble par le conteneur (`injectable()`, `kernelDecorator.ts:135`) |
574
- | `@inject("nom")` | `nodefony` | Injecte un service à une position de constructeur (`inject()`, `kernelDecorator.ts:186`) |
575
- | `@Inject("nom")` | `nodefony` | Idem, sur une propriété (`Inject()`, `kernelDecorator.ts:215`) |
576
- | `@RealtimeAction("méthode")` | `@nodefony/realtime` | Expose une action JSON-RPC sur socket (`RealtimeAction()`, `realtimeDecorators.ts:191`) |
577
- | `@RealtimeChannel("canal", policy)` | `@nodefony/realtime` | Déclare un canal temps réel et sa politique (`RealtimeChannel()`, `realtimeDecorators.ts:240`) |
578
- | `@RealtimeInbound("méthode")` | `@nodefony/realtime` | Traite un message entrant typé (`RealtimeInbound()`, `realtimeDecorators.ts:278`) |
624
+ | Décorateur | Paquet | Rôle |
625
+ | ----------------------------------- | -------------------- | ---------------------------------------------------------------------------------------------------------- |
626
+ | `@services([…])` | `nodefony` | Déclare les services d'un module (`services()`, `kernelDecorator.ts#services`) |
627
+ | `@injectable()` | `nodefony` | Rend une classe résoluble par le conteneur (`injectable()`, `kernelDecorator.ts#injectable`) |
628
+ | `@Inject("nom")` | `nodefony` | Injecte un service à une position de constructeur (`kernelDecorator.ts#Inject`) |
629
+ | `@RealtimeAction("méthode")` | `@nodefony/realtime` | Expose une action JSON-RPC sur socket (`RealtimeAction()`, `realtimeDecorators.ts#RealtimeAction`) |
630
+ | `@RealtimeChannel("canal", policy)` | `@nodefony/realtime` | Déclare un canal temps réel et sa politique (`RealtimeChannel()`, `realtimeDecorators.ts#RealtimeChannel`) |
631
+ | `@RealtimeInbound("méthode")` | `@nodefony/realtime` | Traite un message entrant typé (`RealtimeInbound()`, `realtimeDecorators.ts#RealtimeInbound`) |
579
632
 
580
633
  Injection et portées → [injection-portees](../../../../../docs/architecture/injection-portees.md) ·
581
634
  socket → [realtime](../../realtime/docs/index.md).
@@ -590,7 +643,7 @@ socket → [realtime](../../realtime/docs/index.md).
590
643
  ## 🔌 HTTP et WebSocket — les mêmes décorateurs
591
644
 
592
645
  Un contrôleur ne change pas de forme selon le transport : ce sont les `requirements.methods` qui
593
- déclarent le canal, `WEBSOCKET` étant une pseudo-méthode du type `HTTPMethod` (`Context.ts:104`).
646
+ déclarent le canal, `WEBSOCKET` étant une pseudo-méthode du type `HTTPMethod` (`Context.ts#HTTPMethod`).
594
647
 
595
648
  ```typescript
596
649
  @controller("/ws/chat")
@@ -622,11 +675,11 @@ Trois faits à retenir :
622
675
  `@route`, ou via `@All("/x", { requirements: { methods: ["WEBSOCKET"] } })` si tu préfères la forme
623
676
  courte. Les fabriques `@Get`/`@Post`… **fusionnent** les méthodes déclarées avec la leur :
624
677
  `@Get("/x", { requirements: { methods: ["WEBSOCKET"] } })` sert GET **et** WebSocket
625
- (`routerDecorators.ts:540`).
678
+ (`routerDecorators.ts#Get`).
626
679
  - **Les décorateurs de paramètre fonctionnent pareil.** Pour une invocation par socket, le corps de
627
680
  la mutation voyage dans l'ALS et **prime** sur le corps HTTP (vide dans ce cas) — c'est traité dans
628
- `resolveParamArg()` (`routerDecorators.ts:1358`), et `@Query` lit la query du chemin **invoqué**,
629
- pas celle du handshake (`Resolver._buildParamArgs()`, `Resolver.ts:947`).
681
+ `resolveParamArg()` (`routerDecorators.ts#resolveParamArg`), et `@Query` lit la query du chemin **invoqué**,
682
+ pas celle du handshake (`Resolver._buildParamArgs()`, `Resolver.ts#Resolver._buildParamArgs`).
630
683
  - **Les gardes s'appliquent identiquement.** `@IsGranted` protège une action joignable par socket
631
684
  exactement comme une action HTTP : la décision est prise avant l'instanciation, quel que soit le
632
685
  transport.
@@ -642,16 +695,16 @@ pas toutes identiques — c'est la source d'erreur n°1.
642
695
  <!-- prettier-ignore -->
643
696
  | Sujet | Règle | Ancre |
644
697
  | --- | --- | --- |
645
- | `@Domain` | option `host` de la route > méthode > classe | `controller()` (`routerDecorators.ts:189`) |
646
- | `@BypassFirewall` | **cumulatif** : `true` de la route, de la méthode ou de la classe suffit | `routerDecorators.ts:750` |
647
- | `@UseSession` | méthode > classe (fusion des champs) | `resolveSessionIntent()` (`routerDecorators.ts:858`) |
648
- | `@Idempotent` | méthode > classe | `computeIdempotent()` (`routerDecorators.ts:1710`) |
649
- | `@IsGranted` / `@RequireScope` | **cumul en ET** : classe **plus** méthode | `computeSecurityRequirement()` (`routerDecorators.ts:1648`) |
650
- | `@Anonymous` | méthode → annule tout ce que la classe a posé | `routerDecorators.ts:969` |
651
- | `@Csp` | fusion **additive** classe + méthode (sources concaténées) | `mergeCspDirectives()` (`routerDecorators.ts:1066`) |
652
- | `@CsrfProtect` / `@CsrfExempt` | OU logique : classe **ou** méthode suffit | `computeActionMeta()` (`routerDecorators.ts:1729`) |
653
- | `@Header` | s'empile (plusieurs en-têtes) ; même clé → dernier écrit gagne | `Header()` (`routerDecorators.ts:603`) |
654
- | `@HttpCode` | un seul par action (le dernier posé écrase) | `HttpCode()` (`routerDecorators.ts:582`) |
698
+ | `@Domain` | option `host` de la route > méthode > classe | `controller()` (`routerDecorators.ts#controller`) |
699
+ | `@BypassFirewall` | **cumulatif** : `true` de la route, de la méthode ou de la classe suffit | `routerDecorators.ts#BypassFirewall` |
700
+ | `@UseSession` | méthode > classe (fusion des champs) | `resolveSessionIntent()` (`routerDecorators.ts#resolveSessionIntent`) |
701
+ | `@Idempotent` | méthode > classe | `computeIdempotent()` (`routerDecorators.ts#computeIdempotent`) |
702
+ | `@IsGranted` / `@RequireScope` | **cumul en ET** : classe **plus** méthode | `computeSecurityRequirement()` (`routerDecorators.ts#computeSecurityRequirement`) |
703
+ | `@Anonymous` | méthode → annule tout ce que la classe a posé | `routerDecorators.ts#Anonymous` |
704
+ | `@Csp` | fusion **additive** classe + méthode (sources concaténées) | `mergeCspDirectives()` (`routerDecorators.ts#mergeCspDirectives`) |
705
+ | `@CsrfProtect` / `@CsrfExempt` | OU logique : classe **ou** méthode suffit | `computeActionMeta()` (`routerDecorators.ts#computeActionMeta`) |
706
+ | `@Header` | s'empile (plusieurs en-têtes) ; même clé → dernier écrit gagne | `Header()` (`routerDecorators.ts#Header`) |
707
+ | `@HttpCode` | un seul par action (le dernier posé écrase) | `HttpCode()` (`routerDecorators.ts#HttpCode`) |
655
708
 
656
709
  ### Où placer les décorateurs de classe
657
710
 
@@ -694,7 +747,7 @@ sequenceDiagram
694
747
  RS->>RS: requêtes suivantes : lecture O(1), 0 Reflect
695
748
  ```
696
749
 
697
- Le snapshot `RouteActionMeta` (`routerDecorators.ts:1537`) regroupe **tout** ce que les décorateurs
750
+ Le snapshot `RouteActionMeta` (`routerDecorators.ts#RouteActionMeta`) regroupe **tout** ce que les décorateurs
698
751
  ont dit de l'action :
699
752
 
700
753
  <!-- prettier-ignore -->
@@ -712,12 +765,12 @@ ont dit de l'action :
712
765
 
713
766
  Le `Resolver` consomme ce snapshot dans un ordre qui a du sens sécurité :
714
767
  **garde d'abord, instanciation ensuite**. `security !== null` déclenche
715
- `_enforceSecurity()` (`Resolver.ts:871`) **avant** `newController()` — un `403` n'instancie pas le
768
+ `_enforceSecurity()` (`Resolver.ts#Resolver._enforceSecurity`) **avant** `newController()` — un `403` n'instancie pas le
716
769
  contrôleur et n'exécute pas son `initialize()`. Puis viennent les arguments
717
- (`_buildParamArgs()`, `Resolver.ts:947`), les métadonnées de réponse
718
- (`_applyResponseMeta()`, `Resolver.ts:977`), l'action, et enfin la redirection éventuelle.
770
+ (`_buildParamArgs()`, `Resolver.ts#Resolver._buildParamArgs`), les métadonnées de réponse
771
+ (`_applyResponseMeta()`, `Resolver.ts#Resolver._applyResponseMeta`), l'action, et enfin la redirection éventuelle.
719
772
 
720
- Un usage cold path mérite d'être connu : `extractActionScopes()` (`routerDecorators.ts:1624`) parcourt
773
+ Un usage cold path mérite d'être connu : `extractActionScopes()` (`routerDecorators.ts#extractActionScopes`) parcourt
721
774
  les routes au démarrage pour bâtir le **catalogue des scopes déclarés** — le formulaire de création
722
775
  de clés API dans Studio propose les scopes réellement utilisés par le code, jamais une liste
723
776
  maintenue à part.
@@ -726,26 +779,26 @@ maintenue à part.
726
779
 
727
780
  Un décorateur non employé doit coûter **zéro**. C'est tenu par trois mécanismes vérifiables :
728
781
 
729
- - **Lecture unique.** `resolveActionMeta()` (`routerDecorators.ts:1772`) mémorise le snapshot sur la
782
+ - **Lecture unique.** `resolveActionMeta()` (`routerDecorators.ts#resolveActionMeta`) mémorise le snapshot sur la
730
783
  route au premier passage — ensuite, plus aucun appel `Reflect.getMetadata` ni `Object.entries` par
731
784
  requête. Le même schéma vaut pour la détection du flux brut
732
- (`routeExpectsBodyStream()`, `routerDecorators.ts:1509`).
785
+ (`routeExpectsBodyStream()`, `routerDecorators.ts#routeExpectsBodyStream`).
733
786
  - **`null` plutôt que structure vide.** Une action sans garde a `security: null` : le `Resolver` teste
734
- un `null` et passe — ni résolution de service, ni `await`, ni allocation (`Resolver.ts:490`). Idem
787
+ un `null` et passe — ni résolution de service, ni `await`, ni allocation (`Resolver.ts#Resolver._enforceActionSecurity`). Idem
735
788
  pour `idempotent`, `cspDirectives`, `paramsMeta`.
736
789
  - **Objets gelés et partagés.** Les exigences de sécurité et d'idempotence sont créées **une fois** et
737
- `Object.freeze`-ées (`routerDecorators.ts:1650`, `:1696`) : une seule instance pour la durée de vie
790
+ `Object.freeze`-ées (`routerDecorators.ts#computeSecurityRequirement`, `routerDecorators.ts#computeCspDirectives`) : une seule instance pour la durée de vie
738
791
  du processus, quelle que soit la charge. Corollaire : ne les mute jamais.
739
792
 
740
793
  Coût résiduel côté montage seulement : la reconstruction de la pile d'appels dans `route()`
741
- (`stackTrace`, `routerDecorators.ts:304`) pour retrouver le fichier source. Elle a lieu **à l'import**, une fois par
794
+ (`stackTrace`, `routerDecorators.ts#route`) pour retrouver le fichier source. Elle a lieu **à l'import**, une fois par
742
795
  route, jamais pendant une requête.
743
796
 
744
797
  ## 🧩 Extension — écrire son propre décorateur
745
798
 
746
799
  Le module montre le patron à suivre : un décorateur maison **ne fait qu'écrire une métadonnée**, et
747
800
  un point du pipeline la relit. Pour un simple drapeau dual (classe + méthode), le framework fournit
748
- déjà la fabrique `booleanMarkerDecorator()` (`routerDecorators.ts:1131`), dont `@CsrfProtect` et
801
+ déjà la fabrique `booleanMarkerDecorator()` (`routerDecorators.ts#booleanMarkerDecorator`), dont `@CsrfProtect` et
749
802
  `@CsrfExempt` sont les deux usages.
750
803
 
751
804
  Le squelette d'un drapeau maison, en dehors du framework :
@@ -775,7 +828,7 @@ export function Audited() {
775
828
  Deux invariants à respecter, tirés du code du module :
776
829
 
777
830
  1. **Classe → constructeur, méthode → prototype keyé par nom.** C'est la convention de toutes les
778
- métadonnées du fichier (`routerDecorators.ts:903` pour `@IsGranted`) ; s'en écarter rend la
831
+ métadonnées du fichier (`routerDecorators.ts#IsGranted` pour `@IsGranted`) ; s'en écarter rend la
779
832
  fusion classe/méthode impossible.
780
833
  2. **Aucune I/O, aucun service, aucun import lourd dans le décorateur.** Il s'exécute à l'import, hors
781
834
  de tout kernel : y résoudre un service planterait le simple fait de charger le fichier.
@@ -798,7 +851,7 @@ L'écran **Routes** (`/nodefony/routes`) et le point d'API `/nodefony/framework/
798
851
  | Symptôme | Cause (dans le code) | Correction |
799
852
  | --- | --- | --- |
800
853
  | `404` sur une route pourtant décorée | Contrôleur jamais importé, ou absent de `@controllers([…])` | L'ajouter au tableau `@controllers` du module |
801
- | `Action « remove » … : ce nom est RÉSERVÉ` au démarrage — ou `TS2416` au build | L'action reprend le nom d'un membre de `Controller` : la classe étend `Service`, qui expose déjà `remove(name): boolean` ([`Service.ts:608`](../../../../nodefony/src/Service.ts)), `set`, `get`, `clean`… Le décorateur refuse le nom avant que le conflit n'atteigne le compilateur. | Renommer l'action (`destroy`, `deleteOne`…). Le nom d'une action est libre : c'est le chemin du décorateur qui fait l'URL. |
854
+ | `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#Service.remove`](../../../../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. |
802
855
  | `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) |
803
856
  | Le vhost de `@Domain` classe est ignoré | `@Domain` placé **au-dessus** de `@controller` → posé trop tard | Placer `@Domain` sous `@controller` |
804
857
  | `@BypassFirewall` n'ouvre rien | Écrit **avec** parenthèses — c'est un drapeau, pas une fabrique | `@BypassFirewall` (sans `()`) |