@nodefony/framework 10.0.0-alpha.9 → 10.0.0-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -2
- package/dist/_virtual/{_@oxc-project_runtime@0.151.0 → _@oxc-project_runtime@0.152.0}/helpers/esm/decorate.js +1 -1
- package/dist/_virtual/{_@oxc-project_runtime@0.151.0 → _@oxc-project_runtime@0.152.0}/helpers/esm/decorateMetadata.js +1 -1
- package/dist/index.js +4 -4
- package/dist/nodefony/controller/AdminApiController.js +4 -3
- package/dist/nodefony/controller/ApiKeyController.js +3 -2
- package/dist/nodefony/controller/OAuth2Controller.js +11 -6
- package/dist/nodefony/controller/SessionAuthController.js +3 -3
- package/dist/nodefony/controller/TokenAuthController.js +4 -4
- package/dist/nodefony/controller/TotpController.js +7 -5
- package/dist/nodefony/controller/WebAuthnController.js +4 -4
- package/dist/nodefony/decorators/routerDecorators.js +41 -35
- package/dist/nodefony/service/AdminBroker.js +2 -2
- package/dist/nodefony/service/IdempotencyStore.js +4 -5
- package/dist/nodefony/service/router.js +78 -26
- package/dist/nodefony/src/Controller.js +184 -78
- package/dist/nodefony/src/FrameworkAdminApi.js +19 -6
- package/dist/nodefony/src/KernelAdminApi.js +49 -49
- package/dist/nodefony/src/PlaygroundAdminApi.js +3 -3
- package/dist/nodefony/src/RedisIdempotencyStore.js +2 -2
- package/dist/nodefony/src/Resolver.js +221 -65
- package/dist/nodefony/src/Route.js +62 -39
- package/dist/nodefony/src/SyslogAdminApi.js +4 -4
- package/dist/nodefony/src/docsReader.js +49 -39
- package/dist/nodefony/src/scopeCatalog.js +1 -1
- package/dist/nodefony/src/singletonGuard.js +76 -0
- package/dist/types/nodefony/controller/ApiKeyController.d.ts +2 -1
- package/dist/types/nodefony/controller/BenchController.d.ts +2 -2
- package/dist/types/nodefony/controller/OAuth2Controller.d.ts +3 -3
- package/dist/types/nodefony/controller/ProtectedResourceMetadataController.d.ts +1 -1
- package/dist/types/nodefony/controller/SessionAuthController.d.ts +2 -1
- package/dist/types/nodefony/controller/TokenAuthController.d.ts +2 -2
- package/dist/types/nodefony/controller/TotpController.d.ts +2 -1
- package/dist/types/nodefony/decorators/routerDecorators.d.ts +49 -43
- package/dist/types/nodefony/interfaces/IController.d.ts +9 -7
- package/dist/types/nodefony/interfaces/IResolver.d.ts +10 -10
- package/dist/types/nodefony/interfaces/IRoute.d.ts +13 -13
- package/dist/types/nodefony/service/AdminBroker.d.ts +3 -3
- package/dist/types/nodefony/service/router.d.ts +33 -7
- package/dist/types/nodefony/src/Controller.d.ts +97 -24
- package/dist/types/nodefony/src/Resolver.d.ts +155 -16
- package/dist/types/nodefony/src/ResourceController.d.ts +6 -6
- package/dist/types/nodefony/src/Route.d.ts +39 -23
- package/dist/types/nodefony/src/docsReader.d.ts +19 -9
- package/dist/types/nodefony/src/singletonGuard.d.ts +53 -0
- package/docs/admin.md +1 -1
- package/docs/controller.md +139 -121
- package/docs/decorateurs.md +102 -98
- package/docs/idempotence.md +23 -21
- package/docs/index.md +1 -1
- package/docs/routing.md +60 -53
- package/docs/templates.md +14 -14
- package/package.json +10 -11
package/docs/decorateurs.md
CHANGED
|
@@ -52,7 +52,7 @@ flowchart TD
|
|
|
52
52
|
2. **Au montage**, `controller()` (`routerDecorators.ts:189`) relit ces métadonnées et fabrique les
|
|
53
53
|
objets `Route` ; `controllers()` (`routerDecorators.ts:111`) accroche le contrôleur au module sur
|
|
54
54
|
le hook `onBoot` du kernel.
|
|
55
|
-
3. **À la première requête** de chaque route, `resolveActionMeta()` (`routerDecorators.ts:
|
|
55
|
+
3. **À la première requête** de chaque route, `resolveActionMeta()` (`routerDecorators.ts:1669`)
|
|
56
56
|
consolide toutes les étiquettes de l'action en **un objet figé** posé sur la route. Les requêtes
|
|
57
57
|
suivantes ne lisent plus aucune métadonnée.
|
|
58
58
|
|
|
@@ -124,16 +124,16 @@ Trois partis pris expliquent la forme de cette surface, et un développeur qui l
|
|
|
124
124
|
jamais surprendre.
|
|
125
125
|
|
|
126
126
|
**1 — Un décorateur n'écrit QUE des métadonnées.** Aucun décorateur du framework ne contient de
|
|
127
|
-
logique de sécurité, de session ou d'idempotence. `IsGranted()` (`routerDecorators.ts:
|
|
127
|
+
logique de sécurité, de session ou d'idempotence. `IsGranted()` (`routerDecorators.ts:903`) pose une
|
|
128
128
|
clause ; c'est le `Resolver` qui appellera le moteur d'autorisation, **résolu par son nom** dans le
|
|
129
|
-
conteneur (`Resolver._enforceSecurity()`, `Resolver.ts:
|
|
129
|
+
conteneur (`Resolver._enforceSecurity()`, `Resolver.ts:853`). Pourquoi ce détour : `@nodefony/framework`
|
|
130
130
|
ne dépend **pas** de `@nodefony/security` — sans ça, les deux modules formeraient un cycle. Le prix à
|
|
131
131
|
payer est visible : une route gardée alors que le module `security` est absent renvoie **403**, pas
|
|
132
|
-
une erreur de démarrage (fail-closed, `Resolver.ts:
|
|
132
|
+
une erreur de démarrage (fail-closed, `Resolver.ts:860`).
|
|
133
133
|
|
|
134
134
|
**2 — Tout est figé une fois, puis relu en O(1).** Les métadonnées de l'action sont consolidées au
|
|
135
|
-
premier passage dans `computeActionMeta()` (`routerDecorators.ts:
|
|
136
|
-
L'objet `RouteActionMeta` (`routerDecorators.ts:
|
|
135
|
+
premier passage dans `computeActionMeta()` (`routerDecorators.ts:1632`) puis gelées sur la route.
|
|
136
|
+
L'objet `RouteActionMeta` (`routerDecorators.ts:1436`) est **partagé par toutes les requêtes** — le
|
|
137
137
|
framework ne le mute jamais, et ton code non plus. Une action non décorée obtient des champs à `null`,
|
|
138
138
|
ce qui vaut **zéro branche** dans le chemin chaud.
|
|
139
139
|
|
|
@@ -259,7 +259,7 @@ curl -si -b /tmp/jar -X POST http://localhost:5151/api/books \
|
|
|
259
259
|
# 4) Méthode non déclarée pour ce chemin → 405 avec l'agrégat des méthodes
|
|
260
260
|
curl -si -X PUT http://localhost:5151/api/books/42 | head -2
|
|
261
261
|
# HTTP/1.1 405 Method Not Allowed
|
|
262
|
-
# Allow: GET, DELETE
|
|
262
|
+
# Allow: GET, HEAD, DELETE
|
|
263
263
|
```
|
|
264
264
|
|
|
265
265
|
## 🧰 La table de référence — toute la surface décorateur
|
|
@@ -286,40 +286,41 @@ Six familles, **36 décorateurs**, un seul fichier source. Le tableau de synthè
|
|
|
286
286
|
| --- | --- | --- | --- |
|
|
287
287
|
| `@controllers([…])` | **module** | Rattache des contrôleurs au module sur le hook `onBoot` ; sans lui, aucune route n'est servie (`controllers()`, `routerDecorators.ts:111`) | `@controllers([BookController])` |
|
|
288
288
|
| `@controller("/prefix")` | **classe** | Pose le préfixe d'URL **et déclenche la création des routes** de la classe (`controller()`, `routerDecorators.ts:189`) | `@controller("/api/books")` |
|
|
289
|
-
| `@route(nom, options)` | méthode | Forme complète : nom explicite, chemin, `requirements`, `defaults`, hôte (`route()`, `routerDecorators.ts:
|
|
289
|
+
| `@route(nom, options)` | méthode | Forme complète : nom explicite, chemin, `requirements`, `defaults`, hôte (`route()`, `routerDecorators.ts:287`) | `@route("ws-echo", { path: "/echo", requirements: { methods: ["WEBSOCKET"] } })` |
|
|
290
290
|
| `@Domain(motif \| motifs)` | **dual** | Restreint la route (ou la classe) à un ou plusieurs vhosts ; hors domaine → **403** (`Domain()`, `routerDecorators.ts:664`) | `@Domain("*.cdn.example.com")` |
|
|
291
|
-
| `@Scope("
|
|
291
|
+
| `@Scope("request")` | **classe** | Une instance de contrôleur par requête — par connexion en WebSocket — au lieu de l'instance partagée par défaut (`Scope()`, `routerDecorators.ts:795`) | `@Scope("request")` |
|
|
292
292
|
|
|
293
293
|
**`@controller` est le déclencheur.** Il relit les métadonnées posées par `@route`/`@Get`/… puis les
|
|
294
294
|
**efface** (`Reflect.deleteMetadata`, `routerDecorators.ts:135`) : une classe ne se monte qu'une
|
|
295
295
|
fois. Il traite au passage la route « magique » `path: "*"` en **dernier**, quel que soit son ordre
|
|
296
|
-
d'écriture (`routerDecorators.ts:
|
|
296
|
+
d'écriture (`hasMagic`, `routerDecorators.ts:254`) — sinon un attrape-tout masquerait les routes précises.
|
|
297
297
|
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
instance par requête
|
|
298
|
+
**Le singleton — le défaut — est un contrat.** L'instance étant partagée, l'action ne doit lire ni
|
|
299
|
+
écrire **aucun** état de requête sur `this` : tout passe par les arguments décorés et les accesseurs,
|
|
300
|
+
qui retrouvent la requête courante via l'ALS (`ControllerScope`, `Controller.ts:151`). `@Scope("request")`
|
|
301
|
+
rend une instance par requête : pour injecter un service de portée `request`, préparer chaque
|
|
302
|
+
requête dans `initialize()`, ou garder un état sur `this`.
|
|
302
303
|
|
|
303
304
|
> [!NOTE]
|
|
304
305
|
> Le core `nodefony` exporte lui aussi un `Scope` (les portées du conteneur d'injection). Celui des
|
|
305
306
|
> contrôleurs s'importe **depuis `@nodefony/framework`** — l'homonymie est signalée dans le code
|
|
306
|
-
> (`routerDecorators.ts:
|
|
307
|
+
> (`routerDecorators.ts:785`).
|
|
307
308
|
|
|
308
309
|
### Méthodes HTTP
|
|
309
310
|
|
|
310
|
-
Toutes les fabriques sortent du même moule, `httpMethodDecorator()` (`routerDecorators.ts:
|
|
311
|
+
Toutes les fabriques sortent du même moule, `httpMethodDecorator()` (`routerDecorators.ts:489`) :
|
|
311
312
|
elles nomment la route automatiquement `ClasseName::methode` et posent `requirements.methods`.
|
|
312
313
|
|
|
313
314
|
| Décorateur | Méthode filtrée | Ancre | Exemple |
|
|
314
315
|
| ------------------------ | --------------- | ------------------------------------- | ------------------- |
|
|
315
|
-
| `@Get(path?, opts?)` | `GET` | `Get` (`routerDecorators.ts:
|
|
316
|
-
| `@Post(path?, opts?)` | `POST` | `Post` (`routerDecorators.ts:
|
|
317
|
-
| `@Put(path?, opts?)` | `PUT` | `Put` (`routerDecorators.ts:
|
|
318
|
-
| `@Delete(path?, opts?)` | `DELETE` | `Delete` (`routerDecorators.ts:
|
|
319
|
-
| `@Patch(path?, opts?)` | `PATCH` | `Patch` (`routerDecorators.ts:
|
|
320
|
-
| `@Options(path?, opts?)` | `OPTIONS` | `Options` (`routerDecorators.ts:
|
|
321
|
-
| `@Head(path?, opts?)` | `HEAD` | `Head` (`routerDecorators.ts:
|
|
322
|
-
| `@All(path?, opts?)` | **aucune** | `All()` (`routerDecorators.ts:
|
|
316
|
+
| `@Get(path?, opts?)` | `GET` | `Get` (`routerDecorators.ts:535`) | `@Get("/{id}")` |
|
|
317
|
+
| `@Post(path?, opts?)` | `POST` | `Post` (`routerDecorators.ts:536`) | `@Post("")` |
|
|
318
|
+
| `@Put(path?, opts?)` | `PUT` | `Put` (`routerDecorators.ts:537`) | `@Put("/{id}")` |
|
|
319
|
+
| `@Delete(path?, opts?)` | `DELETE` | `Delete` (`routerDecorators.ts:538`) | `@Delete("/{id}")` |
|
|
320
|
+
| `@Patch(path?, opts?)` | `PATCH` | `Patch` (`routerDecorators.ts:539`) | `@Patch("/{id}")` |
|
|
321
|
+
| `@Options(path?, opts?)` | `OPTIONS` | `Options` (`routerDecorators.ts:540`) | `@Options("/{id}")` |
|
|
322
|
+
| `@Head(path?, opts?)` | `HEAD` | `Head` (`routerDecorators.ts:541`) | `@Head("/{id}")` |
|
|
323
|
+
| `@All(path?, opts?)` | **aucune** | `All()` (`routerDecorators.ts:548`) | `@All("/proxy/*")` |
|
|
323
324
|
|
|
324
325
|
Deux points qu'un dev découvre sinon à ses dépens :
|
|
325
326
|
|
|
@@ -341,42 +342,42 @@ async index(@Param("page") page: string) { /* … */ }
|
|
|
341
342
|
|
|
342
343
|
### Paramètres — ce que l'action reçoit
|
|
343
344
|
|
|
344
|
-
Onze décorateurs, tous produits par `paramDecoratorFactory()` (`routerDecorators.ts:
|
|
345
|
+
Onze décorateurs, tous produits par `paramDecoratorFactory()` (`routerDecorators.ts:1191`) sauf
|
|
345
346
|
`@Body`, qui accepte une option supplémentaire. Chacun pose `{ source, key, index }` ; la valeur est
|
|
346
|
-
calculée par `resolveParamArg()` (`routerDecorators.ts:
|
|
347
|
+
calculée par `resolveParamArg()` (`routerDecorators.ts:1326`), une fonction **pure** — ce qui la rend
|
|
347
348
|
testable sans démarrer de serveur.
|
|
348
349
|
|
|
349
350
|
| Décorateur | Sans clé renvoie… | Avec clé renvoie… | Ancre |
|
|
350
351
|
| ------------------- | ------------------------------------ | ------------------------------------------ | -------------------------------------------- |
|
|
351
|
-
| `@Param("id")` | toutes les variables d'URL (objet) | la variable d'URL nommée | `Param` (`routerDecorators.ts:
|
|
352
|
-
| `@Query("q")` | toute la query string | un paramètre de la query string | `Query` (`routerDecorators.ts:
|
|
353
|
-
| `@Body("field")` | le corps parsé entier | un champ du corps parsé | `Body()` (`routerDecorators.ts:
|
|
354
|
-
| `@Headers("x-foo")` | tous les en-têtes de requête | un en-tête (**lookup en minuscules**) | `Headers` (`routerDecorators.ts:
|
|
355
|
-
| `@Cookie("sid")` | la map des cookies | un cookie (objet `Cookie`, champ `.value`) | `Cookie` (`routerDecorators.ts:
|
|
356
|
-
| `@Session("user")` | l'objet `Session` vivant | `session.get(clé)` | `Session` (`routerDecorators.ts:
|
|
357
|
-
| `@CurrentUser()` | l'utilisateur résolu par le firewall | — | `CurrentUser` (`routerDecorators.ts:
|
|
358
|
-
| `@Req()` | la requête brute du contexte | — | `Req` (`routerDecorators.ts:
|
|
359
|
-
| `@Res()` | la réponse du contexte | — | `Res` (`routerDecorators.ts:
|
|
360
|
-
| `@UploadedFile()` | le **premier** fichier téléversé | — | `UploadedFile` (`routerDecorators.ts:
|
|
361
|
-
| `@UploadedFiles()` | tous les fichiers téléversés | — | `UploadedFiles` (`routerDecorators.ts:
|
|
362
|
-
|
|
363
|
-
La liste des sources possibles est fermée et typée : `ParamSource` (`routerDecorators.ts:
|
|
352
|
+
| `@Param("id")` | toutes les variables d'URL (objet) | la variable d'URL nommée | `Param` (`routerDecorators.ts:1212`) |
|
|
353
|
+
| `@Query("q")` | toute la query string | un paramètre de la query string | `Query` (`routerDecorators.ts:1213`) |
|
|
354
|
+
| `@Body("field")` | le corps parsé entier | un champ du corps parsé | `Body()` (`routerDecorators.ts:1253`) |
|
|
355
|
+
| `@Headers("x-foo")` | tous les en-têtes de requête | un en-tête (**lookup en minuscules**) | `Headers` (`routerDecorators.ts:1279`) |
|
|
356
|
+
| `@Cookie("sid")` | la map des cookies | un cookie (objet `Cookie`, champ `.value`) | `Cookie` (`routerDecorators.ts:1280`) |
|
|
357
|
+
| `@Session("user")` | l'objet `Session` vivant | `session.get(clé)` | `Session` (`routerDecorators.ts:1281`) |
|
|
358
|
+
| `@CurrentUser()` | l'utilisateur résolu par le firewall | — | `CurrentUser` (`routerDecorators.ts:1283`) |
|
|
359
|
+
| `@Req()` | la requête brute du contexte | — | `Req` (`routerDecorators.ts:1284`) |
|
|
360
|
+
| `@Res()` | la réponse du contexte | — | `Res` (`routerDecorators.ts:1285`) |
|
|
361
|
+
| `@UploadedFile()` | le **premier** fichier téléversé | — | `UploadedFile` (`routerDecorators.ts:1286`) |
|
|
362
|
+
| `@UploadedFiles()` | tous les fichiers téléversés | — | `UploadedFiles` (`routerDecorators.ts:1287`) |
|
|
363
|
+
|
|
364
|
+
La liste des sources possibles est fermée et typée : `ParamSource` (`routerDecorators.ts:385`).
|
|
364
365
|
|
|
365
366
|
#### Trois comportements à connaître
|
|
366
367
|
|
|
367
368
|
**`@CurrentUser` lit l'ALS, jamais un argument caché.** La valeur vient de `RequestContext.getUser()`
|
|
368
|
-
(`routerDecorators.ts:
|
|
369
|
+
(`routerDecorators.ts:1279`) : l'utilisateur posé par le firewall. C'est **l'utilisateur**, jamais le
|
|
369
370
|
justificatif (mot de passe, jeton). Hors zone authentifiée, la valeur est `undefined` — le décorateur
|
|
370
371
|
n'authentifie rien, il expose ce qui a déjà été prouvé.
|
|
371
372
|
|
|
372
373
|
**`@Session` active la session à lui seul.** La simple présence d'un paramètre `@Session` vaut
|
|
373
|
-
déclaration d'intention : `resolveSessionIntent()` (`routerDecorators.ts:
|
|
374
|
+
déclaration d'intention : `resolveSessionIntent()` (`routerDecorators.ts:858`) la détecte et pose
|
|
374
375
|
l'intent, exactement comme `@UseSession()`. Une route sans l'un ni l'autre ne paie aucune session.
|
|
375
376
|
|
|
376
377
|
**`@Body({ stream: true })` court-circuite le parsing.** Pour un gros téléversement (vidéo,
|
|
377
378
|
sauvegarde), on injecte le **flux brut** de la requête au lieu du corps chargé en mémoire ; le
|
|
378
379
|
pipeline saute alors le parsing pour cette route, décision prise en amont par
|
|
379
|
-
`routeExpectsBodyStream()` (`routerDecorators.ts:
|
|
380
|
+
`routeExpectsBodyStream()` (`routerDecorators.ts:1408`) :
|
|
380
381
|
|
|
381
382
|
```typescript
|
|
382
383
|
@Post("/upload")
|
|
@@ -388,7 +389,7 @@ async upload(@Body({ stream: true }) stream: NodeJS.ReadableStream) {
|
|
|
388
389
|
|
|
389
390
|
> [!TIP]
|
|
390
391
|
> L'ordre d'écriture des paramètres décorés n'a aucune importance : chaque valeur est placée à son
|
|
391
|
-
> **index déclaré** par `buildParamArgs()` (`routerDecorators.ts:
|
|
392
|
+
> **index déclaré** par `buildParamArgs()` (`routerDecorators.ts:1390`), et les trous restent
|
|
392
393
|
> `undefined`. Tu peux mélanger décorés et non décorés — les non décorés reçoivent `undefined`.
|
|
393
394
|
|
|
394
395
|
#### Le corps n'est pas validé — et c'est un choix
|
|
@@ -451,16 +452,16 @@ ne s'auto-promeut pas.
|
|
|
451
452
|
|
|
452
453
|
| Décorateur | Cible | Effet | Exemple |
|
|
453
454
|
| ------------------------- | ------- | ------------------------------------------------------------------------------------------ | ------------------------------------- |
|
|
454
|
-
| `@HttpCode(201)` | méthode | Fixe le statut **avant** l'exécution de l'action (`HttpCode()`, `routerDecorators.ts:
|
|
455
|
-
| `@Header("X-Foo", "bar")` | méthode | Ajoute un en-tête ; **s'empile** (plusieurs `@Header` cumulent, `routerDecorators.ts:
|
|
456
|
-
| `@Redirect("/url", 302)` | méthode | Redirige **si** l'action ne renvoie rien (`Redirect()`, `routerDecorators.ts:
|
|
455
|
+
| `@HttpCode(201)` | méthode | Fixe le statut **avant** l'exécution de l'action (`HttpCode()`, `routerDecorators.ts:582`) | `@HttpCode(204)` |
|
|
456
|
+
| `@Header("X-Foo", "bar")` | méthode | Ajoute un en-tête ; **s'empile** (plusieurs `@Header` cumulent, `routerDecorators.ts:603`) | `@Header("Cache-Control","no-store")` |
|
|
457
|
+
| `@Redirect("/url", 302)` | méthode | Redirige **si** l'action ne renvoie rien (`Redirect()`, `routerDecorators.ts:649`) | `@Redirect("/login", 302)` |
|
|
457
458
|
|
|
458
|
-
Les deux premiers sont appliqués par `Resolver._applyResponseMeta()` (`Resolver.ts:
|
|
459
|
+
Les deux premiers sont appliqués par `Resolver._applyResponseMeta()` (`Resolver.ts:955`) **avant**
|
|
459
460
|
l'appel de l'action : ton code peut donc les écraser ensuite (`this.renderJson(data, 202)` gagne).
|
|
460
461
|
|
|
461
462
|
`@Redirect` a une subtilité utile : si l'action **retourne un objet** portant `url` (et
|
|
462
463
|
éventuellement `statusCode`), cet objet **prend le dessus** sur les valeurs du décorateur
|
|
463
|
-
(`Resolver._handleRedirect()`, `Resolver.ts:
|
|
464
|
+
(`Resolver._handleRedirect()`, `Resolver.ts:980`) — la cible peut donc être calculée à l'exécution :
|
|
464
465
|
|
|
465
466
|
```typescript
|
|
466
467
|
@Get("/go")
|
|
@@ -471,29 +472,30 @@ async go(@Query("to") to?: string) {
|
|
|
471
472
|
```
|
|
472
473
|
|
|
473
474
|
> [!WARNING]
|
|
474
|
-
> Redirection sans statut explicite ailleurs dans le code : `Response.redirect()` vaut **
|
|
475
|
-
> défaut (
|
|
476
|
-
>
|
|
475
|
+
> Redirection sans statut explicite ailleurs dans le code : `Response.redirect()` vaut **302** par
|
|
476
|
+
> défaut (Found), jamais 301 — un 301 implicite serait mis en cache par les navigateurs, presque
|
|
477
|
+
> irréversiblement. Un code hors de 301/302/303/307/308 retombe aussi sur 302 : passe le code voulu
|
|
478
|
+
> quand il compte — `this.redirect(url, 307)` préserve la méthode.
|
|
477
479
|
|
|
478
480
|
### Sécurité — qui passe, qui décide, quelles défenses
|
|
479
481
|
|
|
480
482
|
Sept décorateurs, **tous duals** (classe ou méthode) et **tous sans logique** : ils posent une
|
|
481
483
|
étiquette que le `Resolver` ou le firewall consommera.
|
|
482
484
|
|
|
483
|
-
| Décorateur | Effet | Ancre
|
|
484
|
-
| ---------------------------------------- | -------------------------------------------------------------------------------------------------------- |
|
|
485
|
-
| `@IsGranted(attr \| attrs, { subject })` | Exige un attribut (rôle `ROLE_*` ou règle métier). Tableau = **OU** ; empilés = **ET** ; refus → **403** | `IsGranted()` (`routerDecorators.ts:
|
|
486
|
-
| `@RequireScope(scope \| scopes)` | Exige un scope `api:action` d'un **jeton machine** ; no-op pour une session humaine | `RequireScope()` (`routerDecorators.ts:
|
|
487
|
-
| `@Anonymous()` | Rend l'action publique : annule l'autorisation **et** l'authentification (le « permitAll ») | `Anonymous()` (`routerDecorators.ts:
|
|
488
|
-
| `@BypassFirewall` | Court-circuite le firewall (sonde de liveness, webhook signé, endpoint de login). **Sans parenthèses** | `BypassFirewall` (`routerDecorators.ts:725`)
|
|
489
|
-
| `@Csp({ "frame-src": [...] })` | Ajoute des directives CSP **à cette réponse** ; classe + méthode fusionnent additivement | `Csp()` (`routerDecorators.ts:
|
|
490
|
-
| `@CsrfProtect()` | Opt-**in** au jeton anti-CSRF (double-submit signé) en plus de la défense globale | `CsrfProtect` (`routerDecorators.ts:
|
|
491
|
-
| `@CsrfExempt()` | Opt-**out** de la défense CSRF **en gardant** l'authentification (webhook, POST cross-origin légitime) | `CsrfExempt` (`routerDecorators.ts:
|
|
485
|
+
| Décorateur | Effet | Ancre |
|
|
486
|
+
| ---------------------------------------- | -------------------------------------------------------------------------------------------------------- | --------------------------------------------- |
|
|
487
|
+
| `@IsGranted(attr \| attrs, { subject })` | Exige un attribut (rôle `ROLE_*` ou règle métier). Tableau = **OU** ; empilés = **ET** ; refus → **403** | `IsGranted()` (`routerDecorators.ts:903`) |
|
|
488
|
+
| `@RequireScope(scope \| scopes)` | Exige un scope `api:action` d'un **jeton machine** ; no-op pour une session humaine | `RequireScope()` (`routerDecorators.ts:1002`) |
|
|
489
|
+
| `@Anonymous()` | Rend l'action publique : annule l'autorisation **et** l'authentification (le « permitAll ») | `Anonymous()` (`routerDecorators.ts:953`) |
|
|
490
|
+
| `@BypassFirewall` | Court-circuite le firewall (sonde de liveness, webhook signé, endpoint de login). **Sans parenthèses** | `BypassFirewall` (`routerDecorators.ts:725`) |
|
|
491
|
+
| `@Csp({ "frame-src": [...] })` | Ajoute des directives CSP **à cette réponse** ; classe + méthode fusionnent additivement | `Csp()` (`routerDecorators.ts:1071`) |
|
|
492
|
+
| `@CsrfProtect()` | Opt-**in** au jeton anti-CSRF (double-submit signé) en plus de la défense globale | `CsrfProtect` (`routerDecorators.ts:1133`) |
|
|
493
|
+
| `@CsrfExempt()` | Opt-**out** de la défense CSRF **en gardant** l'authentification (webhook, POST cross-origin légitime) | `CsrfExempt` (`routerDecorators.ts:1142`) |
|
|
492
494
|
|
|
493
495
|
#### Rôles et scopes — deux axes, un seul verdict
|
|
494
496
|
|
|
495
497
|
`@IsGranted` et `@RequireScope` écrivent dans **deux jeux de métadonnées distincts**, puis
|
|
496
|
-
`computeSecurityRequirement()` (`routerDecorators.ts:
|
|
498
|
+
`computeSecurityRequirement()` (`routerDecorators.ts:1544`) les fusionne en une exigence unique dont
|
|
497
499
|
toutes les clauses sont en **ET**. Une seule chaîne d'application côté `Resolver`, deux jurés
|
|
498
500
|
différents côté `security` (le voteur de rôles, le voteur de scopes).
|
|
499
501
|
|
|
@@ -529,7 +531,7 @@ Trois façons d'ouvrir une porte, trois portées — les confondre coûte cher :
|
|
|
529
531
|
| `@CsrfExempt()` | **conservée** | **conservée** | ignorée | webhook signé, API cross-origin |
|
|
530
532
|
|
|
531
533
|
`@Anonymous()` pose en réalité **deux** marqueurs : « pas d'autorisation » et « pas de firewall »
|
|
532
|
-
(`routerDecorators.ts:
|
|
534
|
+
(`routerDecorators.ts:953-979`) — c'est un `@BypassFirewall` doublé d'une annulation des clauses
|
|
533
535
|
héritées de la classe.
|
|
534
536
|
|
|
535
537
|
> [!CAUTION]
|
|
@@ -540,15 +542,16 @@ héritées de la classe.
|
|
|
540
542
|
|
|
541
543
|
### Cycle de la requête — session et anti-rejeu
|
|
542
544
|
|
|
543
|
-
| Décorateur
|
|
544
|
-
|
|
|
545
|
-
| `@UseSession({ readOnly
|
|
546
|
-
| `@Idempotent({ required? })`
|
|
545
|
+
| Décorateur | Cible | Effet | Ancre |
|
|
546
|
+
| ---------------------------- | ----- | ---------------------------------------------------------------------------------------------------------- | --------------------------------- |
|
|
547
|
+
| `@UseSession({ readOnly? })` | dual | Déclare le besoin d'une session serveur ; **méthode > classe** (`UseSession()`, `routerDecorators.ts:800`) | `@UseSession({ readOnly: true })` |
|
|
548
|
+
| `@Idempotent({ required? })` | dual | Protège une mutation du double effet via `Idempotency-Key` (`Idempotent()`, `routerDecorators.ts:1171`) | `@Idempotent()` |
|
|
547
549
|
|
|
548
550
|
**`@UseSession` est la seule façon d'ouvrir une session** (avec un paramètre `@Session`, ou la reprise
|
|
549
551
|
d'un cookie existant). Il n'existe plus de « démarrer partout » global : une route qui ne déclare rien
|
|
550
|
-
ne coûte aucune lecture de stockage.
|
|
551
|
-
zéro écriture)
|
|
552
|
+
ne coûte aucune lecture de stockage. Une seule option : `readOnly` (lire sans jamais persister —
|
|
553
|
+
zéro écriture). La régénération de l'identifiant après authentification est faite par le login
|
|
554
|
+
(`AuthFlow`), pas par une option du décorateur.
|
|
552
555
|
La forme exacte est celle de `SessionIntent` (`ISession.ts:18`).
|
|
553
556
|
|
|
554
557
|
**`@Idempotent` est strict par défaut** : une mutation sans `Idempotency-Key` reçoit **400**. Le mode
|
|
@@ -566,8 +569,8 @@ recherches inutiles.
|
|
|
566
569
|
| ----------------------------------- | -------------------- | ---------------------------------------------------------------------------------------------- |
|
|
567
570
|
| `@services([…])` | `nodefony` | Déclare les services d'un module (`services()`, `kernelDecorator.ts:53`) |
|
|
568
571
|
| `@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:
|
|
570
|
-
| `@Inject("nom")` | `nodefony` | Idem, sur une propriété (`Inject()`, `kernelDecorator.ts:
|
|
572
|
+
| `@inject("nom")` | `nodefony` | Injecte un service à une position de constructeur (`inject()`, `kernelDecorator.ts:186`) |
|
|
573
|
+
| `@Inject("nom")` | `nodefony` | Idem, sur une propriété (`Inject()`, `kernelDecorator.ts:215`) |
|
|
571
574
|
| `@RealtimeAction("méthode")` | `@nodefony/realtime` | Expose une action JSON-RPC sur socket (`RealtimeAction()`, `realtimeDecorators.ts:101`) |
|
|
572
575
|
| `@RealtimeChannel("canal", policy)` | `@nodefony/realtime` | Déclare un canal temps réel et sa politique (`RealtimeChannel()`, `realtimeDecorators.ts:192`) |
|
|
573
576
|
| `@RealtimeInbound("méthode")` | `@nodefony/realtime` | Traite un message entrant typé (`RealtimeInbound()`, `realtimeDecorators.ts:231`) |
|
|
@@ -615,12 +618,13 @@ Trois faits à retenir :
|
|
|
615
618
|
|
|
616
619
|
- **Il n'existe pas de décorateur `@Ws`.** Le transport se déclare dans les `requirements` — via
|
|
617
620
|
`@route`, ou via `@All("/x", { requirements: { methods: ["WEBSOCKET"] } })` si tu préfères la forme
|
|
618
|
-
courte
|
|
619
|
-
|
|
621
|
+
courte. Les fabriques `@Get`/`@Post`… **fusionnent** les méthodes déclarées avec la leur :
|
|
622
|
+
`@Get("/x", { requirements: { methods: ["WEBSOCKET"] } })` sert GET **et** WebSocket
|
|
623
|
+
(`routerDecorators.ts:535`).
|
|
620
624
|
- **Les décorateurs de paramètre fonctionnent pareil.** Pour une invocation par socket, le corps de
|
|
621
625
|
la mutation voyage dans l'ALS et **prime** sur le corps HTTP (vide dans ce cas) — c'est traité dans
|
|
622
|
-
`resolveParamArg()` (`routerDecorators.ts:
|
|
623
|
-
pas celle du handshake (`Resolver._buildParamArgs()`, `Resolver.ts:
|
|
626
|
+
`resolveParamArg()` (`routerDecorators.ts:1326`), et `@Query` lit la query du chemin **invoqué**,
|
|
627
|
+
pas celle du handshake (`Resolver._buildParamArgs()`, `Resolver.ts:929`).
|
|
624
628
|
- **Les gardes s'appliquent identiquement.** `@IsGranted` protège une action joignable par socket
|
|
625
629
|
exactement comme une action HTTP : la décision est prise avant l'instanciation, quel que soit le
|
|
626
630
|
transport.
|
|
@@ -637,15 +641,15 @@ pas toutes identiques — c'est la source d'erreur n°1.
|
|
|
637
641
|
| Sujet | Règle | Ancre |
|
|
638
642
|
| --- | --- | --- |
|
|
639
643
|
| `@Domain` | option `host` de la route > méthode > classe | `controller()` (`routerDecorators.ts:189`) |
|
|
640
|
-
| `@BypassFirewall` | **cumulatif** : `true` de la route, de la méthode ou de la classe suffit | `routerDecorators.ts:
|
|
641
|
-
| `@UseSession` | méthode > classe (fusion des champs) | `resolveSessionIntent()` (`routerDecorators.ts:
|
|
642
|
-
| `@Idempotent` | méthode > classe | `computeIdempotent()` (`routerDecorators.ts:
|
|
643
|
-
| `@IsGranted` / `@RequireScope` | **cumul en ET** : classe **plus** méthode | `computeSecurityRequirement()` (`routerDecorators.ts:
|
|
644
|
-
| `@Anonymous` | méthode → annule tout ce que la classe a posé | `routerDecorators.ts:
|
|
645
|
-
| `@Csp` | fusion **additive** classe + méthode (sources concaténées) | `mergeCspDirectives()` (`routerDecorators.ts:
|
|
646
|
-
| `@CsrfProtect` / `@CsrfExempt` | OU logique : classe **ou** méthode suffit | `computeActionMeta()` (`routerDecorators.ts:
|
|
647
|
-
| `@Header` | s'empile (plusieurs en-têtes) ; même clé → dernier écrit gagne | `Header()` (`routerDecorators.ts:
|
|
648
|
-
| `@HttpCode` | un seul par action (le dernier posé écrase) | `HttpCode()` (`routerDecorators.ts:
|
|
644
|
+
| `@BypassFirewall` | **cumulatif** : `true` de la route, de la méthode ou de la classe suffit | `routerDecorators.ts:750` |
|
|
645
|
+
| `@UseSession` | méthode > classe (fusion des champs) | `resolveSessionIntent()` (`routerDecorators.ts:858`) |
|
|
646
|
+
| `@Idempotent` | méthode > classe | `computeIdempotent()` (`routerDecorators.ts:1606`) |
|
|
647
|
+
| `@IsGranted` / `@RequireScope` | **cumul en ET** : classe **plus** méthode | `computeSecurityRequirement()` (`routerDecorators.ts:1544`) |
|
|
648
|
+
| `@Anonymous` | méthode → annule tout ce que la classe a posé | `routerDecorators.ts:953` |
|
|
649
|
+
| `@Csp` | fusion **additive** classe + méthode (sources concaténées) | `mergeCspDirectives()` (`routerDecorators.ts:1043`) |
|
|
650
|
+
| `@CsrfProtect` / `@CsrfExempt` | OU logique : classe **ou** méthode suffit | `computeActionMeta()` (`routerDecorators.ts:1632`) |
|
|
651
|
+
| `@Header` | s'empile (plusieurs en-têtes) ; même clé → dernier écrit gagne | `Header()` (`routerDecorators.ts:603`) |
|
|
652
|
+
| `@HttpCode` | un seul par action (le dernier posé écrase) | `HttpCode()` (`routerDecorators.ts:582`) |
|
|
649
653
|
|
|
650
654
|
### Où placer les décorateurs de classe
|
|
651
655
|
|
|
@@ -688,7 +692,7 @@ sequenceDiagram
|
|
|
688
692
|
RS->>RS: requêtes suivantes : lecture O(1), 0 Reflect
|
|
689
693
|
```
|
|
690
694
|
|
|
691
|
-
Le snapshot `RouteActionMeta` (`routerDecorators.ts:
|
|
695
|
+
Le snapshot `RouteActionMeta` (`routerDecorators.ts:1436`) regroupe **tout** ce que les décorateurs
|
|
692
696
|
ont dit de l'action :
|
|
693
697
|
|
|
694
698
|
<!-- prettier-ignore -->
|
|
@@ -706,12 +710,12 @@ ont dit de l'action :
|
|
|
706
710
|
|
|
707
711
|
Le `Resolver` consomme ce snapshot dans un ordre qui a du sens sécurité :
|
|
708
712
|
**garde d'abord, instanciation ensuite**. `security !== null` déclenche
|
|
709
|
-
`_enforceSecurity()` (`Resolver.ts:
|
|
713
|
+
`_enforceSecurity()` (`Resolver.ts:853`) **avant** `newController()` — un `403` n'instancie pas le
|
|
710
714
|
contrôleur et n'exécute pas son `initialize()`. Puis viennent les arguments
|
|
711
|
-
(`_buildParamArgs()`, `Resolver.ts:
|
|
712
|
-
(`_applyResponseMeta()`, `Resolver.ts:
|
|
715
|
+
(`_buildParamArgs()`, `Resolver.ts:929`), les métadonnées de réponse
|
|
716
|
+
(`_applyResponseMeta()`, `Resolver.ts:955`), l'action, et enfin la redirection éventuelle.
|
|
713
717
|
|
|
714
|
-
Un usage cold path mérite d'être connu : `extractActionScopes()` (`routerDecorators.ts:
|
|
718
|
+
Un usage cold path mérite d'être connu : `extractActionScopes()` (`routerDecorators.ts:1520`) parcourt
|
|
715
719
|
les routes au démarrage pour bâtir le **catalogue des scopes déclarés** — le formulaire de création
|
|
716
720
|
de clés API dans Studio propose les scopes réellement utilisés par le code, jamais une liste
|
|
717
721
|
maintenue à part.
|
|
@@ -720,26 +724,26 @@ maintenue à part.
|
|
|
720
724
|
|
|
721
725
|
Un décorateur non employé doit coûter **zéro**. C'est tenu par trois mécanismes vérifiables :
|
|
722
726
|
|
|
723
|
-
- **Lecture unique.** `resolveActionMeta()` (`routerDecorators.ts:
|
|
727
|
+
- **Lecture unique.** `resolveActionMeta()` (`routerDecorators.ts:1669`) mémorise le snapshot sur la
|
|
724
728
|
route au premier passage — ensuite, plus aucun appel `Reflect.getMetadata` ni `Object.entries` par
|
|
725
729
|
requête. Le même schéma vaut pour la détection du flux brut
|
|
726
|
-
(`routeExpectsBodyStream()`, `routerDecorators.ts:
|
|
730
|
+
(`routeExpectsBodyStream()`, `routerDecorators.ts:1408`).
|
|
727
731
|
- **`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:
|
|
732
|
+
un `null` et passe — ni résolution de service, ni `await`, ni allocation (`Resolver.ts:486`). Idem
|
|
729
733
|
pour `idempotent`, `cspDirectives`, `paramsMeta`.
|
|
730
734
|
- **Objets gelés et partagés.** Les exigences de sécurité et d'idempotence sont créées **une fois** et
|
|
731
735
|
`Object.freeze`-ées (`routerDecorators.ts:1496`, `:1340`) : une seule instance pour la durée de vie
|
|
732
736
|
du processus, quelle que soit la charge. Corollaire : ne les mute jamais.
|
|
733
737
|
|
|
734
738
|
Coût résiduel côté montage seulement : la reconstruction de la pile d'appels dans `route()`
|
|
735
|
-
(`stackTrace`, `routerDecorators.ts:
|
|
739
|
+
(`stackTrace`, `routerDecorators.ts:304`) pour retrouver le fichier source. Elle a lieu **à l'import**, une fois par
|
|
736
740
|
route, jamais pendant une requête.
|
|
737
741
|
|
|
738
742
|
## 🧩 Extension — écrire son propre décorateur
|
|
739
743
|
|
|
740
744
|
Le module montre le patron à suivre : un décorateur maison **ne fait qu'écrire une métadonnée**, et
|
|
741
745
|
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:
|
|
746
|
+
déjà la fabrique `booleanMarkerDecorator()` (`routerDecorators.ts:1108`), dont `@CsrfProtect` et
|
|
743
747
|
`@CsrfExempt` sont les deux usages.
|
|
744
748
|
|
|
745
749
|
Le squelette d'un drapeau maison, en dehors du framework :
|
|
@@ -769,7 +773,7 @@ export function Audited() {
|
|
|
769
773
|
Deux invariants à respecter, tirés du code du module :
|
|
770
774
|
|
|
771
775
|
1. **Classe → constructeur, méthode → prototype keyé par nom.** C'est la convention de toutes les
|
|
772
|
-
métadonnées du fichier (`routerDecorators.ts:
|
|
776
|
+
métadonnées du fichier (`routerDecorators.ts:903` pour `@IsGranted`) ; s'en écarter rend la
|
|
773
777
|
fusion classe/méthode impossible.
|
|
774
778
|
2. **Aucune I/O, aucun service, aucun import lourd dans le décorateur.** Il s'exécute à l'import, hors
|
|
775
779
|
de tout kernel : y résoudre un service planterait le simple fait de charger le fichier.
|
|
@@ -792,7 +796,7 @@ L'écran **Routes** (`/nodefony/routes`) et le point d'API `/nodefony/framework/
|
|
|
792
796
|
| Symptôme | Cause (dans le code) | Correction |
|
|
793
797
|
| --- | --- | --- |
|
|
794
798
|
| `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:
|
|
799
|
+
| `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. |
|
|
796
800
|
| `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
801
|
| Le vhost de `@Domain` classe est ignoré | `@Domain` placé **au-dessus** de `@controller` → posé trop tard | Placer `@Domain` sous `@controller` |
|
|
798
802
|
| `@BypassFirewall` n'ouvre rien | Écrit **avec** parenthèses — c'est un drapeau, pas une fabrique | `@BypassFirewall` (sans `()`) |
|
|
@@ -802,9 +806,9 @@ L'écran **Routes** (`/nodefony/routes`) et le point d'API `/nodefony/framework/
|
|
|
802
806
|
| `@Session()` toujours `null` | Aucun intent : ni `@UseSession`, ni paramètre `@Session`, ni cookie repris | Ajouter `@UseSession()` sur l'action ou la classe |
|
|
803
807
|
| `@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
808
|
| `@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 `
|
|
809
|
+
| Réponse `302` alors qu'un autre code était voulu | `Response.redirect()` vaut 302 par défaut, et un code hors 301/302/303/307/308 y retombe | Passer un code de redirection valide : `this.redirect(url, 307)` |
|
|
806
810
|
| 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 |
|
|
811
|
+
| Deux requêtes se mélangent leurs données | Un état de requête stocké sur `this` d'un singleton (le défaut), là où les filets ne voient pas (`#privé`, objet muté) | N'utiliser que des arguments décorés, ou déclarer `@Scope("request")` |
|
|
808
812
|
| 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
813
|
|
|
810
814
|
## 🧪 Tests & couverture
|
|
@@ -828,7 +832,7 @@ dans la carte régénérée depuis vitest, jamais figés ici :
|
|
|
828
832
|
|
|
829
833
|
Ce qui **manque** aujourd'hui : aucun banc de charge ni test mémoire dédié à la surface décorateur —
|
|
830
834
|
c'est cohérent avec le fait que tout y est cold path (montage, première requête), mais un
|
|
831
|
-
|
|
835
|
+
contrôleur singleton mal utilisé se prouverait mieux sous charge (skill `nodefony-load-test`).
|
|
832
836
|
|
|
833
837
|
Couverture : `npm run coverage` dans `@nodefony/framework`.
|
|
834
838
|
|
package/docs/idempotence.md
CHANGED
|
@@ -121,7 +121,7 @@ que **deux** appelants traduisent dans leur monde :
|
|
|
121
121
|
- le **data plane admin** — `AdminApiController.idempotencyGate()`
|
|
122
122
|
(`AdminApiController.ts:131`) → réponse `{status, headers, body}` ;
|
|
123
123
|
- les **controllers userland** décorés `@Idempotent` — seam `Resolver._callWithIdempotency()`
|
|
124
|
-
(`Resolver.ts:
|
|
124
|
+
(`Resolver.ts:682`) → `nodefonyError` typée, ou réponse rejouée.
|
|
125
125
|
|
|
126
126
|
Conséquence pratique : il est **impossible** que l'idempotence HTTP et l'idempotence WebSocket
|
|
127
127
|
divergent — elles partagent la même fonction. C'est le différenciateur du framework (HTTP + WS
|
|
@@ -223,7 +223,7 @@ curl -si -X POST http://localhost:5151/api/payments/charge \
|
|
|
223
223
|
> [!WARNING]
|
|
224
224
|
> **Le piège n°1** : en verdict `fresh`, la réservation est _in-flight_ tant que `complete` **ou**
|
|
225
225
|
> `abort` n'a pas été appelé. Le seam `@Idempotent` gère ce couple pour toi (`try/catch`,
|
|
226
|
-
> `Resolver.ts:
|
|
226
|
+
> `Resolver.ts:759` et `Resolver.ts:783`). Si tu appelles le store **à la main** (cas avancé), c'est
|
|
227
227
|
> **ta** responsabilité : sans `abort` sur erreur, la clé reste bloquée jusqu'à l'expiration du bail
|
|
228
228
|
> (60 s), et tout rejeu identique reçoit `409` pendant ce temps.
|
|
229
229
|
|
|
@@ -238,7 +238,7 @@ une route que d'anciens clients appellent déjà sans clé, le temps de la migra
|
|
|
238
238
|
async subscribe(@Body() body: SubscribeInput) { /* … */ }
|
|
239
239
|
```
|
|
240
240
|
|
|
241
|
-
Précédence **méthode > classe** (`computeIdempotent()`, `routerDecorators.ts:
|
|
241
|
+
Précédence **méthode > classe** (`computeIdempotent()`, `routerDecorators.ts:1606`), comme
|
|
242
242
|
`@UseSession`. Poser `@Idempotent()` sur la **classe** couvre toutes les mutations du controller ;
|
|
243
243
|
une méthode peut resserrer ou relâcher le mode. Les méthodes sûres (GET…) restent des no-op même
|
|
244
244
|
sous une classe décorée.
|
|
@@ -266,7 +266,7 @@ Deux mécanismes rendent ça possible côté socket :
|
|
|
266
266
|
frame `socket.mutate` créerait un doublon. Le Resolver teste donc
|
|
267
267
|
`isMutationMethod(this.methodOverride ?? context.method)` (`Resolver.ts:126`).
|
|
268
268
|
|
|
269
|
-
Le pont utilise `executeActionGuarded()` (`Resolver.ts:
|
|
269
|
+
Le pont utilise `executeActionGuarded()` (`Resolver.ts:647`) : porte d'idempotence **sans** rendu HTTP
|
|
270
270
|
— la valeur nue est enveloppée par le peer WS, jamais écrite sur un transport HTTP.
|
|
271
271
|
|
|
272
272
|
## 🏗️ Architecture interne
|
|
@@ -299,11 +299,11 @@ sequenceDiagram
|
|
|
299
299
|
|
|
300
300
|
### Le parcours d'une mutation, étape par étape
|
|
301
301
|
|
|
302
|
-
1. **Court-circuit hot path.** `callController()` (`Resolver.ts:
|
|
302
|
+
1. **Court-circuit hot path.** `callController()` (`Resolver.ts:613`) lit `meta.idempotent` sur les
|
|
303
303
|
métadonnées d'action **figées par route**. `null` sur la quasi-totalité des routes → une
|
|
304
304
|
comparaison, flux normal, **zéro** lookup de store et zéro allocation.
|
|
305
305
|
2. **No-op sur méthode sûre.** Une action `GET` sous une classe `@Idempotent` repart directement en
|
|
306
|
-
exécution (`Resolver.ts:
|
|
306
|
+
exécution (`Resolver.ts:688`).
|
|
307
307
|
3. **Empreinte du payload.** `computeFingerprint()` (`idempotency.ts:123`) hache
|
|
308
308
|
`[nom de route, params de route, corps]` (`Resolver.ts:497`). Le corps vient de l'ALS (pont WS) ou
|
|
309
309
|
du body HTTP parsé.
|
|
@@ -312,7 +312,7 @@ sequenceDiagram
|
|
|
312
312
|
le verdict devient `execute` (`idempotency.ts:176`) — jamais de partage cross-identité.
|
|
313
313
|
5. **Réservation.** `store.begin()` compose la clé scopée et tranche.
|
|
314
314
|
6. **Mémorisation.** En succès, `complete(clé, {status, body})` où `status` est le code de réponse
|
|
315
|
-
courant et `body` la **valeur retournée** par l'action (`Resolver.ts:
|
|
315
|
+
courant et `body` la **valeur retournée** par l'action (`Resolver.ts:704`). En erreur,
|
|
316
316
|
`abort(clé)` libère la clé : **un échec ne se mémorise pas**, il doit rester réessayable.
|
|
317
317
|
|
|
318
318
|
> [!CAUTION]
|
|
@@ -327,8 +327,8 @@ sequenceDiagram
|
|
|
327
327
|
|
|
328
328
|
| Appelant | Point d'entrée | Traduction du verdict |
|
|
329
329
|
| ---------------------------- | -------------------------------------------------------------------- | ---------------------------------- |
|
|
330
|
-
| Controller userland HTTP | `callController()` (`Resolver.ts:
|
|
331
|
-
| Controller userland via WS | `executeActionGuarded()` (`Resolver.ts:
|
|
330
|
+
| Controller userland HTTP | `callController()` (`Resolver.ts:613`) | `nodefonyError` + rendu normal |
|
|
331
|
+
| Controller userland via WS | `executeActionGuarded()` (`Resolver.ts:647`) | valeur nue, enveloppée par le peer |
|
|
332
332
|
| Data plane admin `/nodefony` | `AdminApiController.idempotencyGate()` (`AdminApiController.ts:131`) | `{status, headers, body}` |
|
|
333
333
|
|
|
334
334
|
## ⚙️ Configuration
|
|
@@ -344,7 +344,7 @@ Source unique = schéma Zod `idempotencySchema`
|
|
|
344
344
|
|
|
345
345
|
### Comment `store: "auto"` se résout VRAIMENT
|
|
346
346
|
|
|
347
|
-
`Framework.onKernelBoot()` (`src/packages/@nodefony/framework/index.ts:
|
|
347
|
+
`Framework.onKernelBoot()` (`src/packages/@nodefony/framework/index.ts:230`) délègue à
|
|
348
348
|
`resolveAutoStore("ephemeral", …)` (`src/nodefony/src/config/infra.ts:289`), borné aux backends
|
|
349
349
|
**réellement enregistrés** (`listIdempotencyBackends()`, `idempotencyStoreRegistry.ts:81`). L'ordre
|
|
350
350
|
réel est le suivant :
|
|
@@ -453,7 +453,7 @@ déjà Postgres mais pas Redis obtient la dédup cross-pod **sans nouvelle infra
|
|
|
453
453
|
|
|
454
454
|
- **Réservation atomique en UNE instruction** — un `INSERT` avec
|
|
455
455
|
`onConflictDoUpdate` (`DrizzleIdempotencyStore.ts:234`) dont la garde `setWhere` ne réécrit que si
|
|
456
|
-
l'entrée est morte (`DrizzleIdempotencyStore.ts:
|
|
456
|
+
l'entrée est morte (`DrizzleIdempotencyStore.ts:269`). Le `returning` ne rend une ligne que si
|
|
457
457
|
l'INSERT a passé (clé neuve) ou si le `DO UPDATE` a **volé** une entrée expirée → `fresh`. Zéro
|
|
458
458
|
ligne = contention → on lit l'état réel.
|
|
459
459
|
- **Invariant capital** : le store ne renvoie **jamais** `fresh` hors réservation atomique gagnée.
|
|
@@ -464,15 +464,15 @@ déjà Postgres mais pas Redis obtient la dédup cross-pod **sans nouvelle infra
|
|
|
464
464
|
(`DrizzleIdempotencyStore.ts:233`), qui la reconstruit en deux instructions chacune atomique.
|
|
465
465
|
- **Pas de TTL natif** → `gc()` (`DrizzleIdempotencyStore.ts:318`) = `DELETE WHERE expiresAt <= now`.
|
|
466
466
|
C'est le **seul** store qui expose `gc`, donc le seul que le framework planifie (voir plus bas).
|
|
467
|
-
- **Mutations conditionnelles** : `complete()` (`DrizzleIdempotencyStore.ts:
|
|
468
|
-
(`DrizzleIdempotencyStore.ts:
|
|
467
|
+
- **Mutations conditionnelles** : `complete()` (`DrizzleIdempotencyStore.ts:301`) et `abort()`
|
|
468
|
+
(`DrizzleIdempotencyStore.ts:319`) portent `WHERE state = 'if'` — jamais d'écrasement d'une réponse
|
|
469
469
|
déjà mémorisée, jamais de résurrection d'une clé libérée. `complete` ne touche pas `fingerprint`.
|
|
470
470
|
- **Résolution lazy + dégradation gracieuse** : le handle Drizzle est résolu à **chaque** appel
|
|
471
471
|
(`DrizzleIdempotencyStore.from()`, `DrizzleIdempotencyStore.ts:191`). ORM non connecté → `begin`
|
|
472
472
|
renvoie `fresh` (sans dédup), le reste est no-op.
|
|
473
473
|
|
|
474
474
|
Le câblage est **automatique** : charger `@nodefony/drizzle` enregistre l'entité **et** la fabrique
|
|
475
|
-
(`registerStores.ts:316`). Activation = `store: "drizzle"` (ou `
|
|
475
|
+
(`registerStores.ts:316`). Activation = `store: "drizzle"` (ou `NF_STORE=drizzle`, qui force toutes les briques `auto`), rien
|
|
476
476
|
d'autre à écrire.
|
|
477
477
|
|
|
478
478
|
### Le GC — armé pour un seul store
|
|
@@ -538,8 +538,8 @@ Signatures complètes : `.ai/symbols.json`. Ce qui compte à l'usage :
|
|
|
538
538
|
|
|
539
539
|
### Le décorateur
|
|
540
540
|
|
|
541
|
-
`@Idempotent(options?)` (`routerDecorators.ts:
|
|
542
|
-
métadonnées (`IdempotentMeta`, `routerDecorators.ts:
|
|
541
|
+
`@Idempotent(options?)` (`routerDecorators.ts:1171`) — dual **classe + méthode**. N'écrit que des
|
|
542
|
+
métadonnées (`IdempotentMeta`, `routerDecorators.ts:477`), zéro import de `@nodefony/security`, zéro
|
|
543
543
|
cycle. La porte est appliquée par le Resolver.
|
|
544
544
|
|
|
545
545
|
### Le contrat de store
|
|
@@ -593,8 +593,10 @@ lot ont été consommées ; la page suivante rejoue le **même** `SCAN` et repre
|
|
|
593
593
|
|
|
594
594
|
Deux réserves à connaître :
|
|
595
595
|
|
|
596
|
-
- Le champ `tenantId` d'`IPageQuery` est un **
|
|
597
|
-
|
|
596
|
+
- Le champ `tenantId` d'`IPageQuery` est un **emplacement réservé** : il appartient au contrat de
|
|
597
|
+
pagination commun à tous les stores listables, et servira à isoler les données par organisation
|
|
598
|
+
quand le framework saura servir plusieurs organisations. Ici, le passer n'a **aucun effet de
|
|
599
|
+
filtrage** : ce store l'ignore.
|
|
598
600
|
- `size` est une **approximation per-pod** pour les stores distribués (compteur local incrémenté au
|
|
599
601
|
`fresh`, décrémenté au `complete`/`abort`), désalignée cross-pod et non décrémentée si un bail
|
|
600
602
|
expire sans complétion. La vérité cluster passe par la base ou `redis-cli`, jamais par ce getter.
|
|
@@ -645,8 +647,8 @@ pour l'affichage Studio, `idempotencyStoreRegistry.ts:81`).
|
|
|
645
647
|
## ⚡ Performance et mémoire
|
|
646
648
|
|
|
647
649
|
Le coût est **nul hors mutations décorées**. Sans `@Idempotent`, `RouteActionMeta.idempotent` vaut
|
|
648
|
-
`null` (`routerDecorators.ts:
|
|
649
|
-
normal — zéro lookup de container, zéro `await` supplémentaire, zéro allocation (`Resolver.ts:
|
|
650
|
+
`null` (`routerDecorators.ts:1151`) : `callController()` fait **une comparaison** et repart en flux
|
|
651
|
+
normal — zéro lookup de container, zéro `await` supplémentaire, zéro allocation (`Resolver.ts:613`).
|
|
650
652
|
La métadonnée est **figée par route** et mémoïsée : aucune lecture `Reflect` par requête.
|
|
651
653
|
|
|
652
654
|
Sur le chemin décoré :
|
|
@@ -689,7 +691,7 @@ Trois surfaces existent aujourd'hui :
|
|
|
689
691
|
|
|
690
692
|
| Symptôme | Cause (dans le code) | Correction |
|
|
691
693
|
| ------------------------------------------------------ | ------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------- |
|
|
692
|
-
| Rejeu qui renvoie un corps **vide** | L'action a retourné `this.renderJson(...)` au lieu du payload (`Resolver.ts:
|
|
694
|
+
| Rejeu qui renvoie un corps **vide** | L'action a retourné `this.renderJson(...)` au lieu du payload (`Resolver.ts:773`) | Retourner la **valeur brute** ; un WARNING le signale déjà dans les logs |
|
|
693
695
|
| `409` en boucle sur un endpoint | Action qui lève avant `complete`/`abort` → in-flight bloqué jusqu'au bail (60 s) | Le seam le gère ; en usage manuel du store, `try/finally` obligatoire |
|
|
694
696
|
| `422 Idempotency-Key is already used` | Même clé, **payload différent** (empreinte ≠, `idempotency.ts:189`) | Une clé = une intention ; nouvelle clé par requête distincte |
|
|
695
697
|
| Rien n'est dédupliqué **malgré** la clé | Pas d'identité fiable → verdict `execute` (`idempotency.ts:176`) | S'assurer que le firewall a résolu l'utilisateur **avant** la mutation |
|
package/docs/index.md
CHANGED
|
@@ -74,7 +74,7 @@ Le tableau pour choisir vite ; les cards en dessous pour savoir ce qu'on y trouv
|
|
|
74
74
|
"desc": "Une table ordonnée où le premier motif qui correspond gagne : l'arbitrage sans score de spécificité, la partition littéral/dynamique qui accélère sans changer la sémantique, le 405 et son en-tête Allow, les vhosts, le duplex HTTP+WebSocket sur un même chemin.",
|
|
75
75
|
"meta": "deux routes se disputent, ou un 404/405 surprend" },
|
|
76
76
|
{ "icon": "🔁", "title": "idempotence", "href": "idempotence.md",
|
|
77
|
-
"desc": "@Idempotent, la clé d'idempotence, les
|
|
77
|
+
"desc": "@Idempotent, la clé d'idempotence, les quatre stores (mémoire, Redis, SQL, MongoDB) et leurs capacités réelles, le GC des entrées expirées, et ce que le client observe quand il rejoue la même clé.",
|
|
78
78
|
"meta": "paiement, commande, tout effet non rejouable" },
|
|
79
79
|
{ "icon": "🛡️", "title": "admin", "href": "admin.md",
|
|
80
80
|
"desc": "Le data plane d'administration : comment un module déclare son API d'admin via AdminBroker, la convention de route /nodefony/<ns>/api/*, le RBAC fail-closed (ROLE_NODEFONY_ADMIN), le duplex HTTP/WebSocket, le catalogue et le Playground.",
|