@nodefony/framework 10.0.0-alpha.7 → 10.0.0-alpha.9

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.
@@ -67,21 +67,21 @@ reste du pipeline ne fait que traduire son verdict.
67
67
 
68
68
  ## 📖 Lexique
69
69
 
70
- | Terme | Sens (dans ce module) |
71
- | ----------------- | -------------------------------------------------------------------------------------------------- |
72
- | Mutation | Méthode non sûre : `POST`/`PUT`/`PATCH`/`DELETE` (RFC 9110 §9.2.1). GET/HEAD/OPTIONS = no-op. |
73
- | `Idempotency-Key` | En-tête client : un identifiant par **intention** d'écriture (convention Stripe, draft IETF). |
74
- | Empreinte | SHA-256 de `route + params + corps` — détecte une clé réutilisée pour autre chose. |
75
- | Bail (_lease_) | Durée pendant laquelle une réservation _in-flight_ tient sans `complete`/`abort` (60 s). |
76
- | Rétention (TTL) | Durée pendant laquelle une réponse mémorisée reste rejouable (10 min). |
77
- | in-flight | Réservation posée, pas encore complétée ni abandonnée. |
78
- | Verdict | Décision neutre du helper : `execute` / `guarded` / `replay` / `reject`. |
79
- | Scope de clé | La clé réellement stockée = `[identité, clé client]` → anti-IDOR. |
80
- | IDOR | _Insecure Direct Object Reference_ : lire la donnée d'autrui en devinant son identifiant. |
81
- | Store | Backend qui porte les clés (`memory`, `redis`, `drizzle`) derrière le contrat `IIdempotencyStore`. |
82
- | GC | _Garbage Collection_ : purge périodique des clés expirées — nécessaire seulement sans TTL natif. |
83
- | Fail-soft | Le store indisponible n'échoue pas la mutation : elle s'exécute **sans** dédup. |
84
- | Fail-loud | Un store distribué demandé mais non câblé fait échouer le boot plutôt que dédupliquer en silence. |
70
+ | Terme | Sens (dans ce module) |
71
+ | ----------------- | -------------------------------------------------------------------------------------------------------------- |
72
+ | Mutation | Méthode non sûre : `POST`/`PUT`/`PATCH`/`DELETE` (RFC 9110 §9.2.1). GET/HEAD/OPTIONS = no-op. |
73
+ | `Idempotency-Key` | En-tête client : un identifiant par **intention** d'écriture (convention Stripe, draft IETF). |
74
+ | Empreinte | SHA-256 de `route + params + corps` — détecte une clé réutilisée pour autre chose. |
75
+ | Bail (_lease_) | Durée pendant laquelle une réservation _in-flight_ tient sans `complete`/`abort` (60 s). |
76
+ | Rétention (TTL) | Durée pendant laquelle une réponse mémorisée reste rejouable (10 min). |
77
+ | in-flight | Réservation posée, pas encore complétée ni abandonnée. |
78
+ | Verdict | Décision neutre du helper : `execute` / `guarded` / `replay` / `reject`. |
79
+ | Scope de clé | La clé réellement stockée = `[identité, clé client]` → anti-IDOR. |
80
+ | IDOR | _Insecure Direct Object Reference_ : lire la donnée d'autrui en devinant son identifiant. |
81
+ | Store | Backend qui porte les clés (`memory`, `redis`, `drizzle`, `mongoose`) derrière le contrat `IIdempotencyStore`. |
82
+ | GC | _Garbage Collection_ : purge périodique des clés expirées — nécessaire seulement sans TTL natif. |
83
+ | Fail-soft | Le store indisponible n'échoue pas la mutation : elle s'exécute **sans** dédup. |
84
+ | Fail-loud | Un store distribué demandé mais non câblé fait échouer le boot plutôt que dédupliquer en silence. |
85
85
 
86
86
  ## Qu'est-ce que c'est ?
87
87
 
@@ -104,7 +104,7 @@ Sans ce ticket, trois choses cassent :
104
104
  3. **DoS du cache** — une clé arbitrairement longue, ou un flot de clés uniques, remplirait la
105
105
  mémoire. Une clé > 255 octets est traitée comme **absente** plutôt que stockée
106
106
  (`IDEMPOTENCY_KEY_MAX`, `idempotency.ts:36`) et le cache mémoire est **borné** (`DEFAULT_CAP`,
107
- `IdempotencyStore.ts:18`).
107
+ `IdempotencyStore.ts:25`).
108
108
 
109
109
  > [!IMPORTANT]
110
110
  > L'idempotence n'est pas qu'une commodité de résilience : c'est une brique de **sécurité**. Une
@@ -119,9 +119,9 @@ connaît **aucun transport**. Il rend un verdict neutre (`IdempotencyVerdict`, `
119
119
  que **deux** appelants traduisent dans leur monde :
120
120
 
121
121
  - le **data plane admin** — `AdminApiController.idempotencyGate()`
122
- (`AdminApiController.ts:158`) → réponse `{status, headers, body}` ;
122
+ (`AdminApiController.ts:131`) → réponse `{status, headers, body}` ;
123
123
  - les **controllers userland** décorés `@Idempotent` — seam `Resolver._callWithIdempotency()`
124
- (`Resolver.ts:460`) → `nodefonyError` typée, ou réponse rejouée.
124
+ (`Resolver.ts:499`) → `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
@@ -187,7 +187,7 @@ use("@nodefony/framework", {
187
187
  // un nom distribué non câblé fait ÉCHOUER le boot en production (fail-loud)
188
188
  // plutôt que dédupliquer per-pod en silence.
189
189
  store: "redis",
190
- // Purge des clés expirées — utile UNIQUEMENT pour un store SQL (drizzle).
190
+ // Purge des clés expirées — utile pour les stores SANS TTL natif (drizzle, mongoose).
191
191
  gcIntervalS: 600,
192
192
  },
193
193
  });
@@ -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:1561`), comme
241
+ Précédence **méthode > classe** (`computeIdempotent()`, `routerDecorators.ts:1576`), 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:473`).
267
+ `isMutationMethod(this.methodOverride ?? context.method)` (`Resolver.ts:126`).
268
268
 
269
- Le pont utilise `executeActionGuarded()` (`Resolver.ts:425`) : porte d'idempotence **sans** rendu HTTP
269
+ Le pont utilise `executeActionGuarded()` (`Resolver.ts:464`) : porte d'idempotence **sans** rendu HTTP
270
270
  — la valeur nue est enveloppée par le peer WS, jamais écrite sur un transport HTTP.
271
271
 
272
272
  ## 🏗️ Architecture interne
@@ -299,7 +299,7 @@ sequenceDiagram
299
299
 
300
300
  ### Le parcours d'une mutation, étape par étape
301
301
 
302
- 1. **Court-circuit hot path.** `callController()` (`Resolver.ts:396`) lit `meta.idempotent` sur les
302
+ 1. **Court-circuit hot path.** `callController()` (`Resolver.ts:435`) 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
@@ -327,20 +327,20 @@ sequenceDiagram
327
327
 
328
328
  | Appelant | Point d'entrée | Traduction du verdict |
329
329
  | ---------------------------- | -------------------------------------------------------------------- | ---------------------------------- |
330
- | Controller userland HTTP | `callController()` (`Resolver.ts:396`) | `nodefonyError` + rendu normal |
331
- | Controller userland via WS | `executeActionGuarded()` (`Resolver.ts:425`) | valeur nue, enveloppée par le peer |
332
- | Data plane admin `/nodefony` | `AdminApiController.idempotencyGate()` (`AdminApiController.ts:158`) | `{status, headers, body}` |
330
+ | Controller userland HTTP | `callController()` (`Resolver.ts:435`) | `nodefonyError` + rendu normal |
331
+ | Controller userland via WS | `executeActionGuarded()` (`Resolver.ts:464`) | valeur nue, enveloppée par le peer |
332
+ | Data plane admin `/nodefony` | `AdminApiController.idempotencyGate()` (`AdminApiController.ts:131`) | `{status, headers, body}` |
333
333
 
334
334
  ## ⚙️ Configuration
335
335
 
336
336
  Source unique = schéma Zod `idempotencySchema`
337
337
  (`src/packages/@nodefony/framework/nodefony/config/config.ts:42`).
338
338
 
339
- | Option | Type | Défaut | Effet |
340
- | ------------- | --------- | -------- | --------------------------------------------------------------------------------------------------------------------------- |
341
- | `store` | `string` | `"auto"` | Backing du cache. `auto` suit l'infra déclarée ; `memory` / `redis` / `drizzle` sont explicites (`config.ts:43`). |
342
- | `gcIntervalS` | `int ≥ 0` | `600` | Intervalle de purge des clés expirées, **hors** hot-path. Sans effet pour `redis` (TTL natif) et `memory` (`config.ts:57`). |
343
- | `gcJitter` | `bool` | `true` | Étale le départ du GC par process — anti _thundering-herd_ sur un store SQL partagé (`config.ts:68`). |
339
+ | Option | Type | Défaut | Effet |
340
+ | ------------- | --------- | -------- | ------------------------------------------------------------------------------------------------------------------------------ |
341
+ | `store` | `string` | `"auto"` | Backing du cache. `auto` suit l'infra déclarée ; `memory` / `redis` / `drizzle` / `mongoose` sont explicites (`config.ts:43`). |
342
+ | `gcIntervalS` | `int ≥ 0` | `600` | Intervalle de purge des clés expirées, **hors** hot-path. Sans effet pour `redis` (TTL natif) et `memory` (`config.ts:57`). |
343
+ | `gcJitter` | `bool` | `true` | Étale le départ du GC par process — anti _thundering-herd_ sur un store SQL partagé (`config.ts:68`). |
344
344
 
345
345
  ### Comment `store: "auto"` se résout VRAIMENT
346
346
 
@@ -351,7 +351,7 @@ réel est le suivant :
351
351
 
352
352
  | Ordre | Condition | Résolution | Ancre |
353
353
  | ----- | ------------------------------------------------------------------------ | -------------------------------------- | -------------- |
354
- | 1 | `NF_STORE=<x>` et `<x>` enregistré pour cette brique | `<x>` (override global) | `infra.ts:251` |
354
+ | 1 | `NF_STORE=<x>` et `<x>` enregistré pour cette brique | `<x>` (override global) | `infra.ts:343` |
355
355
  | 2 | Infra **cache** déclarée (`NF_REDIS_URL`) et `redis` enregistré | `redis` | `infra.ts:258` |
356
356
  | 3 | Infra **database** déclarée (`NF_DATABASE_URL`) et le backend enregistré | `drizzle` (SQL) / `mongoose` (Mongo) | `infra.ts:261` |
357
357
  | 4 | Une préférence existait mais son backend n'est pas enregistré | `fallback` = `memory`, raison ANNONCÉE | `infra.ts:274` |
@@ -364,11 +364,11 @@ réel est le suivant :
364
364
  > que les données survivent au redémarrage. La résolution effective est toujours **journalisée au
365
365
  > boot** (`index.ts:207`) : lis cette ligne plutôt que de la déduire.
366
366
 
367
- MongoDB mérite une mention : `mongoose` n'implémente **pas encore** de store d'idempotence. Déclarer
368
- `NF_DATABASE_URL=mongodb://…` fait donc tomber la résolution en étape **4** → repli `memory` avec la
369
- raison annoncée — et un repli `memory` en cluster ne déduplique plus rien entre pods : le rejeu que
370
- cette brique promet d'empêcher passe sur un autre pod. En attendant le store Mongo (objectif « full
371
- NoSQL », `MIGRATION_STATUS.md` P7.11), la dédup cross-pod passe par `redis`.
367
+ MongoDB mérite une mention : `mongoose` porte lui aussi un store d'idempotence. Déclarer
368
+ `NF_DATABASE_URL=mongodb://…` résout donc en étape **3** → `mongoose`, et la dédup cross-pod tient
369
+ sans qu'il faille ajouter Redis à l'infrastructure. Sa réservation atomique repose sur la
370
+ contrainte d'unicité de `_id` — un `findOneAndUpdate` filtré sur l'entrée morte, en `upsert`, dont
371
+ le perdant reçoit un `E11000` : l'équivalent Mongo du `SET … NX PX` de Redis.
372
372
 
373
373
  ### Le contrat de dégradation : fail-loud, jamais silencieux
374
374
 
@@ -392,28 +392,29 @@ Tous respectent le même contrat `IIdempotencyStore`
392
392
 
393
393
  ### Choisir en 5 secondes
394
394
 
395
- | Store | Atomicité de `begin` | Expiration | `gc()` | Multi-pod | Pour… |
396
- | --------- | ----------------------------------------------- | ------------------ | :----: | :-------: | ---------------------------------------- |
397
- | `memory` | mono-thread JS | passive + cap FIFO | ❌ | ❌ | dev, tests, mono-pod |
398
- | `redis` | `SET … NX PX` | TTL natif (`PX`) | ❌ | ✅ | cluster — le choix par défaut recommandé |
399
- | `drizzle` | `INSERT … ON CONFLICT DO UPDATE … WHERE expiré` | applicative | ✅ | ✅ | cluster qui a déjà du SQL, pas de Redis |
395
+ | Store | Atomicité de `begin` | Expiration | `gc()` | Multi-pod | Pour… |
396
+ | ---------- | ----------------------------------------------- | ------------------ | :----: | :-------: | ---------------------------------------- |
397
+ | `memory` | mono-thread JS | passive + cap FIFO | ❌ | ❌ | dev, tests, mono-pod |
398
+ | `redis` | `SET … NX PX` | TTL natif (`PX`) | ❌ | ✅ | cluster — le choix par défaut recommandé |
399
+ | `drizzle` | `INSERT … ON CONFLICT DO UPDATE … WHERE expiré` | applicative | ✅ | ✅ | cluster qui a déjà du SQL, pas de Redis |
400
+ | `mongoose` | `findOneAndUpdate(expiré) upsert` → `E11000` | applicative | ✅ | ✅ | cluster qui a déjà Mongo, pas de Redis |
400
401
 
401
402
  ### `memory` — le défaut per-pod, gratuit
402
403
 
403
404
  `MemoryIdempotencyStore` (`IdempotencyStore.ts:44`) est enregistré d'office comme service DI
404
405
  `idempotencyStore` par le manifeste `@services` du module framework — zéro configuration.
405
406
 
406
- - **Atomicité par le mono-thread JS** : `begin()` (`IdempotencyStore.ts:107`) lit et écrit la `Map`
407
+ - **Atomicité par le mono-thread JS** : `begin()` (`IdempotencyStore.ts:115`) lit et écrit la `Map`
407
408
  sans point de suspension → deux `begin` concurrents ne peuvent pas se croiser.
408
- - **Constantes** : rétention `DEFAULT_TTL_MS` = 600 s (`IdempotencyStore.ts:14`), bail
409
- `DEFAULT_LEASE_MS` = 60 s (`IdempotencyStore.ts:16`), plafond `DEFAULT_CAP` = 1000 entrées
410
- (`IdempotencyStore.ts:18`). Elles ne sont **pas** configurables.
411
- - **Lazy** : la `Map` n'est allouée qu'au **1ᵉʳ** `begin` (`IdempotencyStore.ts:46`) ; aucun timer,
409
+ - **Constantes** : rétention `DEFAULT_TTL_MS` = 600 s (`IdempotencyStore.ts:21`), bail
410
+ `DEFAULT_LEASE_MS` = 60 s (`IdempotencyStore.ts:23`), plafond `DEFAULT_CAP` = 1000 entrées
411
+ (`IdempotencyStore.ts:25`). Elles ne sont **pas** configurables.
412
+ - **Lazy** : la `Map` n'est allouée qu'au **1ᵉʳ** `begin` (`IdempotencyStore.ts:115`) ; aucun timer,
412
413
  aucun listener.
413
414
  - **Purge passive** : les entrées expirées ne sont retirées qu'à l'écriture, dans `evictIfNeeded()`
414
- (`IdempotencyStore.ts:164`), qui purge d'abord les mortes puis évince en **FIFO** jusqu'à repasser
415
+ (`IdempotencyStore.ts:172`), qui purge d'abord les mortes puis évince en **FIFO** jusqu'à repasser
415
416
  sous le cap. Coût nul tant qu'on n'écrit pas.
416
- - **Garde anti-résurrection** : `complete()` (`IdempotencyStore.ts:134`) n'écrit **que** si la clé est
417
+ - **Garde anti-résurrection** : `complete()` (`IdempotencyStore.ts:142`) n'écrit **que** si la clé est
417
418
  encore _notre_ in-flight — jamais de résurrection d'une clé déjà `abort`-ée ou évincée.
418
419
 
419
420
  ⚠️ Limite structurelle : la dédup est **affine au pod**. Un rejeu routé vers un autre pod n'est pas
@@ -452,7 +453,7 @@ déjà Postgres mais pas Redis obtient la dédup cross-pod **sans nouvelle infra
452
453
 
453
454
  - **Réservation atomique en UNE instruction** — un `INSERT` avec
454
455
  `onConflictDoUpdate` (`DrizzleIdempotencyStore.ts:234`) dont la garde `setWhere` ne réécrit que si
455
- l'entrée est morte (`DrizzleIdempotencyStore.ts:244`). Le `returning` ne rend une ligne que si
456
+ l'entrée est morte (`DrizzleIdempotencyStore.ts:264`). Le `returning` ne rend une ligne que si
456
457
  l'INSERT a passé (clé neuve) ou si le `DO UPDATE` a **volé** une entrée expirée → `fresh`. Zéro
457
458
  ligne = contention → on lit l'état réel.
458
459
  - **Invariant capital** : le store ne renvoie **jamais** `fresh` hors réservation atomique gagnée.
@@ -460,14 +461,14 @@ déjà Postgres mais pas Redis obtient la dédup cross-pod **sans nouvelle infra
460
461
  (`DrizzleIdempotencyStore.ts:264`), jamais `fresh`.
461
462
  - **MySQL/MariaDB** : ni `RETURNING`, ni `WHERE` sur l'`ON DUPLICATE KEY UPDATE`, et un `affectedRows`
462
463
  ambigu → la réservation passe par `reserveIdempotencyKeyMysql()`
463
- (`DrizzleIdempotencyStore.ts:213`), qui la reconstruit en deux instructions chacune atomique.
464
+ (`DrizzleIdempotencyStore.ts:233`), qui la reconstruit en deux instructions chacune atomique.
464
465
  - **Pas de TTL natif** → `gc()` (`DrizzleIdempotencyStore.ts:318`) = `DELETE WHERE expiresAt <= now`.
465
466
  C'est le **seul** store qui expose `gc`, donc le seul que le framework planifie (voir plus bas).
466
- - **Mutations conditionnelles** : `complete()` (`DrizzleIdempotencyStore.ts:276`) et `abort()`
467
- (`DrizzleIdempotencyStore.ts:294`) portent `WHERE state = 'if'` — jamais d'écrasement d'une réponse
467
+ - **Mutations conditionnelles** : `complete()` (`DrizzleIdempotencyStore.ts:296`) et `abort()`
468
+ (`DrizzleIdempotencyStore.ts:314`) portent `WHERE state = 'if'` — jamais d'écrasement d'une réponse
468
469
  déjà mémorisée, jamais de résurrection d'une clé libérée. `complete` ne touche pas `fingerprint`.
469
470
  - **Résolution lazy + dégradation gracieuse** : le handle Drizzle est résolu à **chaque** appel
470
- (`DrizzleIdempotencyStore.from()`, `DrizzleIdempotencyStore.ts:172`). ORM non connecté → `begin`
471
+ (`DrizzleIdempotencyStore.from()`, `DrizzleIdempotencyStore.ts:191`). ORM non connecté → `begin`
471
472
  renvoie `fresh` (sans dédup), le reste est no-op.
472
473
 
473
474
  Le câblage est **automatique** : charger `@nodefony/drizzle` enregistre l'entité **et** la fabrique
@@ -522,10 +523,14 @@ Deux détails qui expliquent des surprises réelles :
522
523
  | `drizzle` (SQL) | SQLite | idem, mais mono-machine → **test** | ❌ | ✅ |
523
524
  | `memory` | RAM du pod | mono-thread JS ; cap FIFO 1000 ; TTL 10 min | ❌ | n/a |
524
525
 
525
- > [!CAUTION]
526
- > **MongoDB (`@nodefony/mongoose`) n'implémente PAS de store d'idempotence.** Sélectionner
527
- > `store: "mongoose"` échoue à la résolution (fail-loud) ; laisser `auto` avec une infra Mongo replie
528
- > sur `memory` avec une raison annoncée. En cluster Mongo, la dédup passe par `redis`.
526
+ > [!NOTE]
527
+ > **MongoDB (`@nodefony/mongoose`) porte un store d'idempotence.** `store: "mongoose"` est
528
+ > sélectionnable dès que le module est chargé, et `auto` avec une infra Mongo y résout tout seul.
529
+ > En cluster Mongo, la dédup cross-pod n'exige donc pas d'ajouter Redis.
530
+ >
531
+ > Comme la variante SQL, il n'a **pas de TTL natif** : le framework arme un `GcScheduler` parce
532
+ > que le store expose `gc()`. L'échéance d'une entrée bascule du bail à la rétention, ce qu'un
533
+ > index TTL ne saurait pas suivre.
529
534
 
530
535
  ## 🧰 API publique
531
536
 
@@ -533,8 +538,8 @@ Signatures complètes : `.ai/symbols.json`. Ce qui compte à l'usage :
533
538
 
534
539
  ### Le décorateur
535
540
 
536
- `@Idempotent(options?)` (`routerDecorators.ts:1103`) — dual **classe + méthode**. N'écrit que des
537
- métadonnées (`IdempotentMeta`, `routerDecorators.ts:443`), zéro import de `@nodefony/security`, zéro
541
+ `@Idempotent(options?)` (`routerDecorators.ts:1142`) — dual **classe + méthode**. N'écrit que des
542
+ métadonnées (`IdempotentMeta`, `routerDecorators.ts:457`), zéro import de `@nodefony/security`, zéro
538
543
  cycle. La porte est appliquée par le Resolver.
539
544
 
540
545
  ### Le contrat de store
@@ -576,7 +581,7 @@ Le mode **curseur** de Redis mérite une explication, parce qu'il piège : `SCAN
576
581
  plafond** mais un indice d'effort — Redis peut rendre plus de clés que demandé. Sans précaution, la
577
582
  page dépasserait `limit` et violerait le contrat `IPage` (`src/nodefony/src/types/IPage.ts:108`). D'où
578
583
  le **curseur composite** `"<consommé>:<curseurRedis>"` (`encodeCursor()`,
579
- `RedisIdempotencyStore.ts:57`) : on ne rend que `limit` éléments et on mémorise combien de clés du
584
+ `RedisIdempotencyStore.ts:63`) : on ne rend que `limit` éléments et on mémorise combien de clés du
580
585
  lot ont été consommées ; la page suivante rejoue le **même** `SCAN` et reprend là.
581
586
 
582
587
  > [!IMPORTANT]
@@ -641,13 +646,13 @@ pour l'affichage Studio, `idempotencyStoreRegistry.ts:81`).
641
646
 
642
647
  Le coût est **nul hors mutations décorées**. Sans `@Idempotent`, `RouteActionMeta.idempotent` vaut
643
648
  `null` (`routerDecorators.ts:1103`) : `callController()` fait **une comparaison** et repart en flux
644
- normal — zéro lookup de container, zéro `await` supplémentaire, zéro allocation (`Resolver.ts:396`).
649
+ normal — zéro lookup de container, zéro `await` supplémentaire, zéro allocation (`Resolver.ts:435`).
645
650
  La métadonnée est **figée par route** et mémoïsée : aucune lecture `Reflect` par requête.
646
651
 
647
652
  Sur le chemin décoré :
648
653
 
649
654
  - Le store mémoire n'alloue sa `Map` qu'au 1ᵉʳ `begin`, ne pose **aucun timer ni listener**, et purge
650
- en passif (`IdempotencyStore.ts:164`).
655
+ en passif (`IdempotencyStore.ts:115`).
651
656
  - L'empreinte est un hash SHA-256 court → comparaison O(1), et le payload n'est jamais conservé en
652
657
  clair.
653
658
  - Le GC est **hors hot-path** et armé pour un seul store (SQL) ; le jitter évite que N pods purgent
@@ -669,7 +674,7 @@ Trois surfaces existent aujourd'hui :
669
674
  façon la plus rapide de voir un rejeu, un `409` ou un `422` en vrai.
670
675
  - **Stores** — la brique `idempotency` y apparaît avec sa nature **éphémère**, le backend configuré,
671
676
  le backend résolu, la liste des backends disponibles et la raison de la résolution — brique
672
- `idempotency` (`stores/storesModel.ts:150`). C'est là qu'on vérifie qu'`auto` a choisi ce qu'on
677
+ `idempotency` (`stores/storesModel.ts:145`). C'est là qu'on vérifie qu'`auto` a choisi ce qu'on
673
678
  croyait.
674
679
  - **ERD** — la table `idempotency_key` est regroupée sous `@nodefony/framework`
675
680
  (`idempotencyEntity.ts:133`), pas sous l'ORM qui l'héberge.
@@ -684,7 +689,7 @@ Trois surfaces existent aujourd'hui :
684
689
 
685
690
  | Symptôme | Cause (dans le code) | Correction |
686
691
  | ------------------------------------------------------ | ------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------- |
687
- | Rejeu qui renvoie un corps **vide** | L'action a retourné `this.renderJson(...)` au lieu du payload (`Resolver.ts:549`) | Retourner la **valeur brute** ; un WARNING le signale déjà dans les logs |
692
+ | Rejeu qui renvoie un corps **vide** | L'action a retourné `this.renderJson(...)` au lieu du payload (`Resolver.ts:581`) | Retourner la **valeur brute** ; un WARNING le signale déjà dans les logs |
688
693
  | `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 |
689
694
  | `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 |
690
695
  | 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/routing.md CHANGED
@@ -55,7 +55,7 @@ Trois faits à retenir avant tout le reste :
55
55
  (`HttpError("Not Found", 404)`, `http-kernel.ts:798`).
56
56
  3. **Le chemin est vérifié avant la méthode, et le domaine entre les deux** — c'est ce qui produit un
57
57
  `403` plutôt qu'un `405` bavard quand la route appartient à un autre vhost (`Route.match()`,
58
- `Route.ts:212`).
58
+ `Route.ts:327`).
59
59
 
60
60
  ## 📖 Lexique
61
61
 
@@ -101,7 +101,7 @@ contrôleur.
101
101
 
102
102
  **La performance ne change pas la sémantique.** Sous le capot, la table est partitionnée : les chemins
103
103
  **littéraux** (aucun `{var}`, aucun métacaractère) vivent dans une `Map path → candidates` en lookup
104
- O(1) ; les chemins **dynamiques** restent un scan regex (`buildRouteIndex()`, `router.ts:92`). À la
104
+ O(1) ; les chemins **dynamiques** restent un scan regex (`buildRouteIndex()`, `router.ts:130`). À la
105
105
  résolution, les deux flux sont fusionnés **par position d'insertion** — la séquence de candidats est
106
106
  exactement celle du scan linéaire complet, moins les littérales d'un autre chemin, qui ne pouvaient de
107
107
  toute façon pas correspondre (`Router.resolve()`, `router.ts:221`). C'est cette équivalence que fige le
@@ -109,7 +109,7 @@ banc de non-régression : un refacto du routeur doit le repasser à l'identique.
109
109
 
110
110
  **Une seule table pour HTTP et WebSocket.** Il n'y a pas de « routeur WS » séparé : une action WS est
111
111
  une route dont les méthodes déclarées contiennent `WEBSOCKET` (`Route.matchRequirements()`,
112
- `Route.ts:649`). C'est le différenciateur du framework — le même contrôleur, le même contexte, les
112
+ `Route.ts:700`). C'est le différenciateur du framework — le même contrôleur, le même contexte, les
113
113
  mêmes décorateurs.
114
114
 
115
115
  **Le routeur passe avant les fichiers statiques.** Une requête qui correspond à une route ne paie
@@ -263,15 +263,15 @@ que chaque forme **produit dans la table**.
263
263
  | `@route(nom, options)` | **le tien** (stable, réutilisable) | `requirements.methods` (libre) | WebSocket, multi-méthodes, contraintes fines |
264
264
 
265
265
  - Les décorateurs de méthode HTTP délèguent tous à `@route` avec un nom auto `Classe::methode`, et
266
- posent `requirements: { methods }` (`httpMethodDecorator()`, `routerDecorators.ts:340`).
266
+ posent `requirements: { methods }` (`httpMethodDecorator()`, `routerDecorators.ts:469`).
267
267
  - `@All` n'émet **aucun** requirement de méthode : la route sert alors GET, POST, DELETE… et ne peut
268
- donc jamais produire un 405 sur la méthode (`All()`, `routerDecorators.ts:374`).
268
+ donc jamais produire un 405 sur la méthode (`All()`, `routerDecorators.ts:528`).
269
269
  - `@route` est la forme complète : elle seule permet `protocol` (sous-protocole WS), un nom lisible, et
270
270
  des requirements par variable.
271
271
 
272
272
  **Comment une déclaration devient une route.** Les décorateurs de méthode **accumulent** des métadonnées
273
273
  sur le constructeur (clé `routes:definitions`, `routerDecorators.ts:16`) ; c'est `@controller(prefix)`
274
- qui les lit et appelle `Router.createRoute()` pour chacune (`controller()`, `routerDecorators.ts:129`).
274
+ qui les lit et appelle `Router.createRoute()` pour chacune (`controller()`, `routerDecorators.ts:189`).
275
275
 
276
276
  > [!WARNING]
277
277
  > **`@route`/`@Get` doivent être SOUS `@controller`** — les décorateurs de classe s'évaluent après ceux
@@ -281,7 +281,7 @@ qui les lit et appelle `Router.createRoute()` pour chacune (`controller()`, `rou
281
281
  ## Motifs de chemin et paramètres
282
282
 
283
283
  Le chemin déclaré est compilé **une fois**, à la création de la route, en une expression régulière
284
- ancrée et **insensible à la casse** (`Route.compile()`, `Route.ts:395`). La grammaire tient en cinq
284
+ ancrée et **insensible à la casse** (`Route.compile()`, `Route.ts:424`). La grammaire tient en cinq
285
285
  briques (`REG_ROUTE`, `Route.ts:17`) :
286
286
 
287
287
  | Écriture | Motif compilé | Capture | Exemple |
@@ -295,7 +295,7 @@ briques (`REG_ROUTE`, `Route.ts:17`) :
295
295
  Et trois comportements qui surprennent la première fois :
296
296
 
297
297
  - **Le slash final est retiré avant le matching** — `/books/` et `/books` désignent la même route
298
- (`Route.cleanPathname()`, `Route.ts:274`). Corollaire : `/files/*` ne matche pas `/files/`, qui a été
298
+ (`Route.cleanPathname()`, `Route.ts:303`). Corollaire : `/files/*` ne matche pas `/files/`, qui a été
299
299
  normalisé en `/files`.
300
300
  - **Les valeurs sont URL-décodées** — `%C3%A9t%C3%A9` arrive dans l'action comme `été`
301
301
  (`decode()`, `Route.ts:79`).
@@ -307,7 +307,7 @@ Et trois comportements qui surprennent la première fois :
307
307
  C'est le mécanisme le moins évident, et le plus utile. Déclarer un `defaults` pour une variable change
308
308
  le motif compilé : le segment devient facultatif (`[^/]*`) **et son slash aussi** (`/?`), puis la valeur
309
309
  par défaut est réinjectée quand la capture est vide (`checkDefaultParameters()`, `Route.ts:99` ·
310
- `Route.hydrateDefaultParameters()`, `Route.ts:469`).
310
+ `Route.hydrateDefaultParameters()`, `Route.ts:513`).
311
311
 
312
312
  ```ts ignore
313
313
  @route("route-page", { path: "/page/{slug}", defaults: { slug: "home" } })
@@ -327,7 +327,7 @@ async page(slug: string) {
327
327
  Les captures sont passées **positionnellement**, dans l'ordre des variables du chemin — c'est pourquoi
328
328
  la signature `async method6(metier: string, format: string)` suit l'ordre de `/{metier}/{format}`. Un
329
329
  wildcard est exposé sous les clés `wildcard` et `*`. Le `Resolver` en fabrique aussi un instantané
330
- nom → valeur par requête (`Resolver.getMatchedParams()`, `Resolver.ts:170`), lu par le contexte pour
330
+ nom → valeur par requête (`Resolver.getMatchedParams()`, `Resolver.ts:188`), lu par le contexte pour
331
331
  les métadonnées et par les décorateurs `@Param`.
332
332
 
333
333
  > [!IMPORTANT]
@@ -389,11 +389,11 @@ accepte, pas seulement ce que la dernière route scannée acceptait.
389
389
  ### Situation 3 — une route réservée à un domaine
390
390
 
391
391
  Une route restreinte par `@Domain` est **invisible** aux requêtes des autres vhosts : elle lève un 403
392
- au lieu de participer au match (`Route.matchHostname()`, `Route.ts:605`). Le point de sécurité est
392
+ au lieu de participer au match (`Route.matchHostname()`, `Route.ts:641`). Le point de sécurité est
393
393
  l'**ordre des vérifications** : le domaine est vérifié **avant** la méthode. Sans cela, une route d'un
394
394
  autre vhost pourrait répondre 405 en révélant SES méthodes — une fuite d'information cross-domaine
395
- (`Route.match()`, `Route.ts:298`). La passe 2 applique la même règle : les routes d'un autre vhost sont
396
- exclues du calcul de `Allow` (`isDomainAllowed`, `router.ts:270`).
395
+ (`Route.match()`, `Route.ts:327`). La passe 2 applique la même règle : les routes d'un autre vhost sont
396
+ exclues du calcul de `Allow` (`isDomainAllowed`, `router.ts:341`).
397
397
 
398
398
  Si une autre route du même chemin sert **tous** les vhosts, le scan continue jusqu'à elle : le 403
399
399
  n'interrompt pas la recherche, il ne conclut que s'il ne reste aucune candidate.
@@ -418,12 +418,12 @@ Ce qui change par rapport au HTTP :
418
418
  inconnu ou un sous-protocole non conforme ferme la connexion **sans jamais l'ouvrir**.
419
419
  - **Le sous-protocole est un requirement de route.** Un `protocol` déclaré et non satisfait lève une
420
420
  erreur de code **1002** (Protocol Error, RFC 6455 §7.4) au lieu d'un statut HTTP
421
- (`acceptedProtocol`, `Route.ts:722`).
421
+ (`acceptedProtocol`, `Route.ts:758`).
422
422
  - **Le 405 ne s'applique pas au WebSocket.** La passe 2 est réservée au HTTP : sur un contexte WS,
423
423
  l'exception d'origine est préservée (`Router.resolve()`, `router.ts:230`).
424
424
  - **Un `Resolver` par connexion, réutilisé à chaque frame.** Il est créé au handshake, puis chaque
425
425
  message rejoue `match()` sur la route déjà trouvée avant d'appeler l'action
426
- (`WebsocketContext.handle()`, `WebsocketContext.ts:271` · boucle message,
426
+ (`WebsocketContext.handle()`, `WebsocketContext.ts:265` · boucle message,
427
427
  `callController`, `WebsocketContext.ts:508`).
428
428
  L'action est donc invoquée une fois au handshake (`message` vaut `null`), puis une fois par frame.
429
429
 
@@ -438,9 +438,9 @@ que fait le data plane d'administration pour toutes ses lectures (`AdminBroker.m
438
438
  (`routing-nonregression.test.ts:164`).
439
439
  - **Une invocation WS d'une mutation doit dire quelle méthode logique elle vise.** Sur une socket,
440
440
  `context.method` vaut toujours `WEBSOCKET` : insuffisant pour distinguer un GET d'un POST sur le même
441
- chemin. Le pont transporte donc une **méthode logique** (`methodOverride`, `Resolver.ts:116`) que la
441
+ chemin. Le pont transporte donc une **méthode logique** (`methodOverride`, `Resolver.ts:126`) que la
442
442
  route doit déclarer **en plus** du transport — une route `POST` qui n'annonce pas `WEBSOCKET` reste
443
- **injoignable** par socket (zéro contournement, `Route.ts:678`).
443
+ **injoignable** par socket (zéro contournement, `Route.ts:722`).
444
444
 
445
445
  Le routage par **message** (invoquer un chemin porté par une frame, sans toucher l'URL de la connexion)
446
446
  passe par le même `resolve()`, avec un chemin fourni en argument — l'état partagé de la socket n'est
@@ -451,7 +451,7 @@ jamais muté (`Router.resolve()`, `router.ts:230`). Détails côté socket :
451
451
 
452
452
  `@Domain` restreint une méthode (ou tout un contrôleur) à un ou plusieurs noms d'hôte. Les motifs
453
453
  acceptent l'exact (`"marseille.fr"`) et le joker d'un label (`"*.cdn.example.com"`), compilés une fois
454
- au boot en expressions ancrées (`Route.compileHost()`, `Route.ts:468`).
454
+ au boot en expressions ancrées (`Route.compileHost()`, `Route.ts:627`).
455
455
 
456
456
  ```ts ignore
457
457
  @controller("/")
@@ -463,14 +463,14 @@ class MarseilleController extends Controller {
463
463
  ```
464
464
 
465
465
  Précédence, du plus fort au plus faible : `@route({ host })` › `@Domain` sur la méthode › `@Domain` sur
466
- la classe (`controller()`, `routerDecorators.ts:89`). Une route sans domaine est servie sur **tous** les
466
+ la classe (`controller()`, `routerDecorators.ts:189`). Une route sans domaine est servie sur **tous** les
467
467
  vhosts, et ne coûte rien au matching (`hostRegexp` absent → aucun test, `Route.matchHostname()`,
468
- `Route.ts:605`).
468
+ `Route.ts:641`).
469
469
 
470
470
  > [!WARNING]
471
471
  > `@Domain` déclare quels vhosts une route **sert** ; il ne remplace pas la barrière d'entrée. Un
472
472
  > `Host` inconnu du serveur est rejeté en amont (421 Misdirected Request, `checkValidDomain()`,
473
- > `http-kernel.ts:1697`) via la liste `trustedHosts` de `@nodefony/http`.
473
+ > `http-kernel.ts:1749`) via la liste `trustedHosts` de `@nodefony/http`.
474
474
 
475
475
  ## Préfixes — contrôleur, module, data plane
476
476
 
@@ -478,14 +478,14 @@ Trois niveaux de préfixe coexistent, et un seul est à ta main.
478
478
 
479
479
  1. **Le préfixe de contrôleur** — `@controller("/api/catalog")` est concaténé devant le chemin de
480
480
  chaque route de la classe, puis le chemin est normalisé : les `//` sont réduits et le slash final
481
- retiré (`Route.setPattern()`, `Route.ts:551`). Un chemin vide (`@Get("")`) désigne donc le préfixe
481
+ retiré (`Route.setPattern()`, `Route.ts:602`). Un chemin vide (`@Get("")`) désigne donc le préfixe
482
482
  lui-même.
483
483
  2. **Le module propriétaire** — il n'ajoute **aucun** préfixe d'URL. `@controllers([…])` enregistre la
484
484
  classe au boot et propage le nom du module sur les routes déjà créées, pour l'introspection et les
485
- logs (`Router.setController()`, `router.ts:417`). Un module tiers et ton app peuvent porter deux
485
+ logs (`Router.setController()`, `router.ts:421`). Un module tiers et ton app peuvent porter deux
486
486
  contrôleurs homonymes sans collision : la clé du registre est `module:Classe` (`router.ts:164`).
487
487
  3. **Le data plane d'administration** — réservé, non négociable : `/nodefony/<namespace>/api/<endpoint>`
488
- (`AdminBroker.resolvePath()`, `AdminBroker.ts:91`). Trois segments minimum, pour ne jamais entrer en
488
+ (`AdminBroker.resolvePath()`, `AdminBroker.ts:99`). Trois segments minimum, pour ne jamais entrer en
489
489
  collision avec les routes de l'application ni avec la SPA de Studio.
490
490
 
491
491
  > [!TIP]
@@ -502,10 +502,10 @@ d'une route — il survit à un changement de chemin.
502
502
  | Besoin | Comment |
503
503
  | ---------------------------------------- | ------------------------------------------------------------------------- |
504
504
  | Retrouver une route par son nom | `router.getRoutes("ma-route")` → l'objet `Route` (`router.ts:326`) |
505
- | Lister toutes les routes | `router.getRoutes("")` → la table complète (`router.ts:387`) |
506
- | Savoir quelles routes couvrent un chemin | `router.matchRoutes("/api/x")` → les résultats de regex (`router.ts:376`) |
507
- | Appeler une autre action, en interne | `this.forward("module:Controller:action")` (`Controller.ts:445`) |
508
- | Retirer une route | `router.removeRoutes("ma-route")` (`router.ts:335`) |
505
+ | Lister toutes les routes | `router.getRoutes("")` → la table complète (`router.ts:391`) |
506
+ | Savoir quelles routes couvrent un chemin | `router.matchRoutes("/api/x")` → les résultats de regex (`router.ts:380`) |
507
+ | Appeler une autre action, en interne | `this.forward("module:Controller:action")` (`Controller.ts:534`) |
508
+ | Retirer une route | `router.removeRoutes("ma-route")` (`router.ts:400`) |
509
509
 
510
510
  **Il n'existe pas de générateur d'URL inverse côté serveur** (pas de `path("ma-route", {id})` à la
511
511
  Symfony). Le chemin déclaré est lisible sur l'objet `Route` (`route.path`), et la substitution des
@@ -515,7 +515,7 @@ chemin ; pour un appel interne, utilise `forward()`.
515
515
 
516
516
  **`forward()` n'est pas une redirection** : il résout `module:Controller:action` et exécute l'action
517
517
  dans le **même** contexte de requête, sans repasser par le réseau (`Resolver.parsePathernController()`,
518
- `Resolver.ts:185`). Une vraie redirection HTTP passe par `this.redirect(url, 302)` ou `@Redirect`.
518
+ `Resolver.ts:203`). Une vraie redirection HTTP passe par `this.redirect(url, 302)` ou `@Redirect`.
519
519
 
520
520
  ## 🧰 API publique
521
521
 
@@ -529,7 +529,7 @@ modules qui montent des routes dynamiquement). Signatures complètes : `.ai/symb
529
529
  | `router.resolve(context)` | Le cœur : rend un `Resolver` (`resolve === true` si trouvé). |
530
530
  | `router.getRoutes(nom)` · `removeRoutes()` | Introspection et démontage. |
531
531
  | `Route#path` · `#variables` · `#pattern` | Ce que la route déclare, après compilation. |
532
- | `Route#toObject()` · `#toLogLine()` | Sérialisation pour l'API admin · ligne de log lisible (`Route.ts:525`). |
532
+ | `Route#toObject()` · `#toLogLine()` | Sérialisation pour l'API admin · ligne de log lisible (`Route.ts:554`). |
533
533
  | `Resolver#route` · `#variables` | Ce que la requête courante a matché. |
534
534
  | `Resolver#getMatchedParams()` | Les variables en `nom → valeur` (`Resolver.ts:170`). |
535
535
 
@@ -546,31 +546,31 @@ alloué par requête.
546
546
 
547
547
  - **Compilation unique au montage** : motif d'URL, motifs de domaine, `Set` de méthodes en majuscules,
548
548
  chaîne `Allow`, et regex des requirements par variable sont figés à la création de la route
549
- (`Route.compileRequirements()`, `Route.ts:324`). Le matching ne fait plus que des lookups.
549
+ (`Route.compileRequirements()`, `Route.ts:476`). Le matching ne fait plus que des lookups.
550
550
  - **Un seul calcul de chemin par requête** : le `pathname` normalisé est calculé une fois puis passé à
551
551
  chaque route scannée — sinon le getter `URL.pathname`, la regex de normalisation et l'allocation de
552
- chaîne seraient refaits pour **chaque** route de la table (`Route.cleanPathname()`, `Route.ts:204`).
552
+ chaîne seraient refaits pour **chaque** route de la table (`Route.cleanPathname()`, `Route.ts:303`).
553
553
  - **Lookup O(1) pour les chemins littéraux**, scan pour les seuls chemins à motif — sans changer la
554
554
  séquence de candidats (`buildRouteIndex()`, `router.ts:130`). L'index est invalidé par toute mutation
555
555
  de la table, avec un garde-fou sur une photo `longueur/première/dernière` qui rattrape même les
556
- mutations directes de la liste (`routeIndex`, `router.ts:201`).
556
+ mutations directes de la liste (`routeIndex`, `router.ts:127`).
557
557
  - **Zéro journalisation en production** : le log « route trouvée » est promu au niveau NOTICE hors
558
558
  production seulement, et le test est résolu une fois puis mémoïsé — en production, aucune chaîne
559
559
  n'est même construite (`routeNoticePromoted`, `router.ts:299`).
560
560
  - **Métadonnées d'action mémoïsées par route** au premier passage (`@HttpCode`, `@Header`, `@Redirect`,
561
561
  paramètres, intention de session) : plus aucune lecture `Reflect` par requête
562
- (`resolveActionMeta`, `Resolver.ts:142`).
562
+ (`resolveActionMeta`, `Resolver.ts:469`).
563
563
 
564
564
  ## 📜 Normes appliquées
565
565
 
566
566
  | Sujet | Norme | Où le code s'y conforme |
567
567
  | ---------------------------------------- | ----------------- | --------------------------------------------------------------- |
568
568
  | 405 + en-tête `Allow` agrégé | RFC 9110 §15.5.6 | passe 2 (`collectSupportedMethods()`, `router.ts:31`) |
569
- | Cible identifiée par l'URI, hôte compris | RFC 9110 §7.2 | hôte vérifié avant la méthode (`Route.match()`, `Route.ts:298`) |
570
- | 403 sur ressource d'un autre vhost | RFC 9110 §15.5.4 | `Route.matchHostname()` (`Route.ts:605`) |
569
+ | Cible identifiée par l'URI, hôte compris | RFC 9110 §7.2 | hôte vérifié avant la méthode (`Route.match()`, `Route.ts:327`) |
570
+ | 403 sur ressource d'un autre vhost | RFC 9110 §15.5.4 | `Route.matchHostname()` (`Route.ts:641`) |
571
571
  | 404 quand rien ne correspond | RFC 9110 §15.5.5 | après repli statique (`http-kernel.ts:688`) |
572
- | 421 sur `Host` non servi | RFC 9110 §15.5.20 | `checkValidDomain()` (`http-kernel.ts:1740`) |
573
- | Erreur de sous-protocole WS = 1002 | RFC 6455 §7.4 | `Route.matchRequirements()` (`Route.ts:649`) |
572
+ | 421 sur `Host` non servi | RFC 9110 §15.5.20 | `checkValidDomain()` (`http-kernel.ts:1749`) |
573
+ | Erreur de sous-protocole WS = 1002 | RFC 6455 §7.4 | `Route.matchRequirements()` (`Route.ts:700`) |
574
574
  | Décodage pourcent des segments | RFC 3986 §2.1 | `decode()` (`Route.ts:79`) |
575
575
 
576
576
  ## 📡 Observabilité — Studio
@@ -581,14 +581,14 @@ La table de routes est introspectable en ligne, sans lire le code :
581
581
  méthodes, contrôleur (`FrameworkAdminApi.ts:123`). Variante paginée/triée/filtrée côté serveur :
582
582
  `routes/page`.
583
583
  - **`GET /nodefony/framework/api/info`** — résumé : nombre de routes, méthodes servies, modules
584
- propriétaires (`FrameworkAdminApi.ts:183`).
584
+ propriétaires (`FrameworkAdminApi.ts:335`).
585
585
  - **Écran Routes** de Studio (`/nodefony/routes`) — la même table, filtrable.
586
586
  - **Playground** (`/nodefony/playground`, développement uniquement) — un formulaire par action, généré depuis la table :
587
587
  transports (dont le duplex), paramètres décorés, gardes de sécurité. Il **exécute** de vraies actions,
588
588
  donc il n'est monté qu'en développement (`PlaygroundAdminApi.ts`).
589
589
 
590
590
  Au boot, avec le debug actif, chaque route est aussi journalisée en une ligne
591
- `[MÉTHODES] chemin → @module/Controller.action` (`Route.toLogLine()`, `Route.ts:402`).
591
+ `[MÉTHODES] chemin → @module/Controller.action` (`Route.toLogLine()`, `Route.ts:554`).
592
592
 
593
593
  ## ⚠️ Pièges (symptôme → cause → correction)
594
594
 
package/docs/templates.md CHANGED
@@ -104,7 +104,7 @@ Le branchement est **délibérément minimal** :
104
104
 
105
105
  > [!NOTE]
106
106
  > Le rendu **d'erreurs** ne passe **pas** par les templates : une exception devient un corps **JSON
107
- > structuré** (`ErrorRenderer.renderHttp()`, `error-renderer.ts:348`), jamais une page Eta. Le moteur
107
+ > structuré** (`ErrorRenderer.renderHttp()`, `error-renderer.ts:473`), jamais une page Eta. Le moteur
108
108
  > de vues ne sert que le HTML **que tu rends explicitement**.
109
109
 
110
110
  ## 🚀 Démarrage rapide
@@ -222,11 +222,11 @@ sequenceDiagram
222
222
  | 2 | Ouverture de la phase mesurée `render` | `phaseStart("render")` (`Controller.ts:377`) |
223
223
  | 3 | Injection des aides frontend dans les locals | `withFrontendLocals()` (`Controller.ts:448`) |
224
224
  | 4 | Rendu de la source par le moteur | `Eta.render()` → `renderStringAsync` (`Eta.ts:51`) |
225
- | 5 | `Content-Type: text/html` puis envoi | `setContextHtml()` + `renderResponse()` (`Controller.ts:331`) |
225
+ | 5 | `Content-Type: text/html` puis envoi | `setContextHtml()` + `renderResponse()` (`Controller.ts:392`) |
226
226
 
227
227
  Le point notable de l'étape 3 : `withFrontendLocals()` ajoute automatiquement `frontendTags`,
228
228
  `frontendDocument` et `asset` aux locals **si** le service `frontend` est présent — et **tes** valeurs
229
- priment (spread `param` en dernier, `Controller.ts:345`). Si le module frontend n'est pas chargé, la
229
+ priment (spread `param` en dernier, `Controller.ts:412`). Si le module frontend n'est pas chargé, la
230
230
  fonction retourne les locals inchangés : zéro couplage dur.
231
231
 
232
232
  ## 🔐 Sécurité — échappement HTML et XSS
@@ -270,7 +270,7 @@ scaffold). Les deux sont **asynchrones** (I/O non bloquante).
270
270
  | ----------------------------------- | ------------------------------------------------------- | ------------------- |
271
271
  | `renderView(path, locals, status?)` | Rendre une vue `.eta` (lit le fichier + aides frontend) | `Controller.ts:308` |
272
272
  | `render(data, encoding?, status?)` | Envoyer un corps quelconque (ex. HTML déjà prêt) | `Controller.ts:377` |
273
- | `renderJson(obj, status?)` | Réponse JSON explicite (pas un template) | `Controller.ts:392` |
273
+ | `renderJson(obj, status?)` | Réponse JSON explicite (pas un template) | `Controller.ts:481` |
274
274
 
275
275
  Les signatures exactes vivent dans le graphe symbolique `.ai/symbols.json` — jamais recopiées ici.
276
276
 
@@ -285,7 +285,7 @@ Le moteur est configuré **en dur**, pas via un bloc Zod exposé à `use()`. Tro
285
285
  | `cache` | `true` en prod, `false` sinon | compile-once des templates en production ; recompile à chaud en dev | `Template.ts:20` |
286
286
 
287
287
  Le cache n'est **pas** un booléen figé : `Template` le dérive de l'environnement du kernel
288
- (`environment === "prod"`, `Template.ts:20`) puis `Eta` l'applique au moteur (`Eta.ts:41`). En
288
+ (`environment === "prod"`, `Template.ts:20`) puis `Eta` l'applique au moteur (`Eta.ts:34`). En
289
289
  développement, un template modifié est donc pris en compte sans redémarrer.
290
290
 
291
291
  ## 🔌 HTTP et WebSocket — le même rendu
@@ -309,7 +309,7 @@ c'est le différenciateur Nodefony : une classe, deux transports, le même moteu
309
309
  | Domaine | Norme / référence | Comment le code s'y conforme |
310
310
  | ------------------ | ----------------------- | --------------------------------------------------------------------- |
311
311
  | Neutralisation XSS | OWASP — Output Encoding | échappement HTML par défaut (`autoEscape`, `Eta.ts:16`) |
312
- | Type de média HTML | `text/html` | posé par `setContextHtml()` dans `renderView()` (`Controller.ts:331`) |
312
+ | Type de média HTML | `text/html` | posé par `setContextHtml()` dans `renderView()` (`Controller.ts:410`) |
313
313
  | I/O non bloquante | Node.js async fs | lecture async du fichier (`readFile`, `Eta.ts:71`) |
314
314
 
315
315
  ## ⚡ Performance et mémoire
@@ -323,7 +323,7 @@ template), et le framework l'isole pour ça :
323
323
  - **Cache en prod** : les templates sont compilés une fois (`cache` vrai en production,
324
324
  `Template.ts:20`) ; le coût de parsing n'est payé qu'au premier rendu.
325
325
  - **Lecture non bloquante** : le fichier est lu en async (`FileClass.readAsync()` côté `renderView`,
326
- `readFile` côté `renderFile`, `Eta.ts:71`) — l'event loop n'est jamais gelé par un `readFileSync`.
326
+ `readFile` côté `renderFile`, `Eta.ts:66`) — l'event loop n'est jamais gelé par un `readFileSync`.
327
327
  - **Aides frontend paresseuses** : `withFrontendLocals()` (`Controller.ts:448`) ne construit les
328
328
  fonctions `frontendTags`/`asset` que si le service `frontend` répond — sinon il rend les locals tels
329
329
  quels, zéro allocation superflue.