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