@nodefony/framework 10.0.0-beta.2 → 10.0.0-beta.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/README.md +4 -0
  2. package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorate.js +1 -2
  3. package/dist/index.js +15 -20
  4. package/dist/nodefony/config/config.js +1 -2
  5. package/dist/nodefony/config/defineModuleConfig.js +1 -2
  6. package/dist/nodefony/controller/AdminApiController.js +45 -11
  7. package/dist/nodefony/controller/ApiKeyController.js +18 -10
  8. package/dist/nodefony/controller/BenchController.js +1 -2
  9. package/dist/nodefony/controller/IssuerMetadataController.js +1 -2
  10. package/dist/nodefony/controller/LoginPageController.js +117 -0
  11. package/dist/nodefony/controller/OAuth2Controller.js +133 -17
  12. package/dist/nodefony/controller/ProtectedResourceMetadataController.js +1 -2
  13. package/dist/nodefony/controller/SessionAuthController.js +50 -13
  14. package/dist/nodefony/controller/TokenAuthController.js +14 -8
  15. package/dist/nodefony/controller/TotpController.js +7 -8
  16. package/dist/nodefony/controller/WebAuthnController.js +28 -12
  17. package/dist/nodefony/controller/oauthAuthority.js +1 -2
  18. package/dist/nodefony/decorators/routerDecorators.js +268 -65
  19. package/dist/nodefony/service/AdminBroker.js +4 -6
  20. package/dist/nodefony/service/Eta.js +1 -2
  21. package/dist/nodefony/service/IdempotencyStore.js +4 -6
  22. package/dist/nodefony/service/router.js +97 -34
  23. package/dist/nodefony/src/Controller.js +71 -43
  24. package/dist/nodefony/src/FrameworkAdminApi.js +33 -11
  25. package/dist/nodefony/src/KernelAdminApi.js +82 -18
  26. package/dist/nodefony/src/PlaygroundAdminApi.js +72 -3
  27. package/dist/nodefony/src/RedisIdempotencyStore.js +46 -4
  28. package/dist/nodefony/src/Resolver.js +105 -36
  29. package/dist/nodefony/src/ResourceController.js +2 -3
  30. package/dist/nodefony/src/Route.js +54 -8
  31. package/dist/nodefony/src/SecurityApiController.js +23 -0
  32. package/dist/nodefony/src/SyslogAdminApi.js +1 -2
  33. package/dist/nodefony/src/Template.js +1 -2
  34. package/dist/nodefony/src/configMutation.js +1 -2
  35. package/dist/nodefony/src/docsReader.js +39 -23
  36. package/dist/nodefony/src/idempotency.js +14 -22
  37. package/dist/nodefony/src/idempotencyGc.js +1 -2
  38. package/dist/nodefony/src/idempotencyStoreRegistry.js +1 -2
  39. package/dist/nodefony/src/loginPage.js +430 -0
  40. package/dist/nodefony/src/requireAuthentication.js +35 -0
  41. package/dist/nodefony/src/routeSchema.js +81 -0
  42. package/dist/nodefony/src/scopeCatalog.js +1 -2
  43. package/dist/nodefony/src/singletonGuard.js +1 -2
  44. package/dist/nodefony/src/syslogFilters.js +1 -2
  45. package/dist/types/index.d.ts +8 -12
  46. package/dist/types/nodefony/controller/AdminApiController.d.ts +12 -0
  47. package/dist/types/nodefony/controller/ApiKeyController.d.ts +2 -2
  48. package/dist/types/nodefony/controller/LoginPageController.d.ts +32 -0
  49. package/dist/types/nodefony/controller/OAuth2Controller.d.ts +67 -8
  50. package/dist/types/nodefony/controller/SessionAuthController.d.ts +46 -8
  51. package/dist/types/nodefony/controller/TokenAuthController.d.ts +6 -4
  52. package/dist/types/nodefony/controller/TotpController.d.ts +2 -2
  53. package/dist/types/nodefony/controller/WebAuthnController.d.ts +12 -4
  54. package/dist/types/nodefony/decorators/routerDecorators.d.ts +221 -35
  55. package/dist/types/nodefony/interfaces/IController.d.ts +2 -3
  56. package/dist/types/nodefony/interfaces/IRoute.d.ts +1 -2
  57. package/dist/types/nodefony/service/router.d.ts +40 -1
  58. package/dist/types/nodefony/src/Controller.d.ts +54 -17
  59. package/dist/types/nodefony/src/PlaygroundAdminApi.d.ts +12 -0
  60. package/dist/types/nodefony/src/Resolver.d.ts +44 -1
  61. package/dist/types/nodefony/src/ResourceController.d.ts +1 -1
  62. package/dist/types/nodefony/src/Route.d.ts +21 -1
  63. package/dist/types/nodefony/src/SecurityApiController.d.ts +17 -0
  64. package/dist/types/nodefony/src/docsReader.d.ts +10 -6
  65. package/dist/types/nodefony/src/idempotency.d.ts +12 -10
  66. package/dist/types/nodefony/src/loginPage.d.ts +144 -0
  67. package/dist/types/nodefony/src/requireAuthentication.d.ts +29 -0
  68. package/dist/types/nodefony/src/routeSchema.d.ts +112 -0
  69. package/docs/admin.md +83 -78
  70. package/docs/controller.md +112 -111
  71. package/docs/decorateurs.md +212 -163
  72. package/docs/idempotence.md +137 -135
  73. package/docs/routing.md +96 -95
  74. package/docs/templates.md +55 -55
  75. package/package.json +10 -13
  76. package/dist/_virtual/_@oxc-project_runtime@0.152.0/helpers/esm/decorateMetadata.js +0 -6
@@ -37,7 +37,7 @@ coverageFiles: idempotency.ts,IdempotencyStore.ts,RedisIdempotencyStore.ts,idemp
37
37
  ## 🧠 Le modèle mental — un verrou par intention
38
38
 
39
39
  C'est LA chose à comprendre. `store.begin(clé, empreinte)` est **atomique** et rend l'un de quatre
40
- verdicts (`IdempotencyOutcome`, `src/nodefony/src/types/IIdempotencyStore.ts:34`) ; le helper les
40
+ verdicts (`IdempotencyOutcome`, `src/nodefony/src/types/IIdempotencyStore.ts#IdempotencyOutcome`) ; le helper les
41
41
  traduit en une décision neutre que le pipeline applique.
42
42
 
43
43
  ```mermaid
@@ -53,14 +53,14 @@ stateDiagram-v2
53
53
  ```
54
54
 
55
55
  Les quatre verdicts du store et leur traduction, décidés par `evaluateIdempotency()`
56
- (`idempotency.ts:142`) :
56
+ (`idempotency.ts#evaluateIdempotency`) :
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`.
60
60
  - **replayed** → la réponse de la 1ʳᵉ exécution est renvoyée telle quelle, l'action **n'est pas
61
61
  rejouée**. Verdict rendu : `replay`.
62
62
  - **in-flight** → une exécution identique est **déjà en cours** → `409` (le client réessaiera).
63
- - **mismatch** → la clé a déjà servi pour un **autre** payload → `422` (`idempotency.ts:189`).
63
+ - **mismatch** → la clé a déjà servi pour un **autre** payload → `422` (`idempotency.ts#evaluateIdempotency`).
64
64
 
65
65
  S'il ne faut retenir qu'une image : `begin` est un **verrou atomique par intention**, et tout le
66
66
  reste du pipeline ne fait que traduire son verdict.
@@ -98,13 +98,13 @@ 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:182`) : l'anti-IDOR n'est pas un contrôle
101
+ `JSON.stringify([identité, cléClient])` (`idempotency.ts#evaluateIdempotency`) : 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
105
105
  mémoire. Une clé > 255 octets est traitée comme **absente** plutôt que stockée
106
- (`IDEMPOTENCY_KEY_MAX`, `idempotency.ts:36`) et le cache mémoire est **borné** (`DEFAULT_CAP`,
107
- `IdempotencyStore.ts:25`).
106
+ (`IDEMPOTENCY_KEY_MAX`, `idempotency.ts#IDEMPOTENCY_KEY_MAX`) et le cache mémoire est **borné** (`DEFAULT_CAP`,
107
+ `IdempotencyStore.ts#DEFAULT_CAP`).
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
@@ -114,21 +114,21 @@ 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:142`), qui ne
118
- connaît **aucun transport**. Il rend un verdict neutre (`IdempotencyVerdict`, `idempotency.ts:50`)
117
+ empreinte) est décidée dans **un helper pur**, `evaluateIdempotency()` (`idempotency.ts#evaluateIdempotency`), qui ne
118
+ connaît **aucun transport**. Il rend un verdict neutre (`IdempotencyVerdict`, `idempotency.ts#IdempotencyVerdict`)
119
119
  que **deux** appelants traduisent dans leur monde :
120
120
 
121
121
  - le **data plane admin** — `AdminApiController.idempotencyGate()`
122
- (`AdminApiController.ts:131`) → réponse `{status, headers, body}` ;
122
+ (`AdminApiController.ts#AdminApiController.idempotencyGate`) → réponse `{status, headers, body}` ;
123
123
  - les **controllers userland** décorés `@Idempotent` — seam `Resolver._callWithIdempotency()`
124
- (`Resolver.ts:682`) → `nodefonyError` typée, ou réponse rejouée.
124
+ (`Resolver.ts#Resolver._callWithIdempotency`) → `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
128
128
  co-citoyens dans le même contexte controller) appliqué à une brique de sécurité.
129
129
 
130
130
  Second choix structurant : **le stockage est pluggable**. Le contrat `IIdempotencyStore` vit au
131
- **CORE** (`src/nodefony/src/types/IIdempotencyStore.ts:106`), pas dans `@nodefony/framework` — pour
131
+ **CORE** (`src/nodefony/src/types/IIdempotencyStore.ts#IIdempotencyStore`), pas dans `@nodefony/framework` — pour
132
132
  que `@nodefony/redis` et `@nodefony/drizzle`, qui sont **sous** framework dans le graphe de
133
133
  dépendances, puissent l'implémenter sans créer de cycle.
134
134
 
@@ -176,7 +176,7 @@ le fait pour toi.)
176
176
  ### 2. Le store — rien à écrire en dev, un mot en cluster
177
177
 
178
178
  Le store par défaut est **posé automatiquement** par le module framework (`@services([… ,
179
- MemoryIdempotencyStore])`, `src/packages/@nodefony/framework/index.ts:40`). En mono-pod, tu n'as
179
+ MemoryIdempotencyStore])`, `src/packages/@nodefony/framework/index.ts`). En mono-pod, tu n'as
180
180
  rien à configurer. Pour un cluster multi-pod, nomme un store **distribué** :
181
181
 
182
182
  ```typescript
@@ -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:759` et `Resolver.ts:783`). Si tu appelles le store **à la main** (cas avancé), c'est
226
+ > `Resolver.ts#Resolver._callWithIdempotency` et `Resolver.ts#Resolver._callWithIdempotency`). 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:1606`), comme
241
+ Précédence **méthode > classe** (`computeIdempotent()`, `routerDecorators.ts#computeIdempotent`), 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.
@@ -247,7 +247,7 @@ sous une classe décorée.
247
247
 
248
248
  Une socket **reconnecte et rejoue par nature** : muter sans clé y serait un piège à double-effet
249
249
  garanti. D'où la règle, dans le helper partagé : `requiredEffective = required || isWs`
250
- (`idempotency.ts:160`). Une mutation par socket **exige toujours** une clé, même quand le mode HTTP
250
+ (`idempotency.ts#evaluateIdempotency`). Une mutation par socket **exige toujours** une clé, même quand le mode HTTP
251
251
  est souple.
252
252
 
253
253
  | Situation | `@Idempotent()` (strict) | `@Idempotent({ required:false })` |
@@ -260,13 +260,13 @@ est souple.
260
260
  Deux mécanismes rendent ça possible côté socket :
261
261
 
262
262
  - **La clé voyage par l'ALS** — le pont WS la pose dans `RequestContext`, et
263
- `resolveIdempotencyKey()` (`idempotency.ts:68`) donne la **priorité à l'ALS** sur l'en-tête HTTP.
263
+ `resolveIdempotencyKey()` (`idempotency.ts#resolveIdempotencyKey`) donne la **priorité à l'ALS** sur l'en-tête HTTP.
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:126`).
267
+ `isMutationMethod(this.methodOverride ?? context.method)` (`Resolver.ts#Resolver.action`).
268
268
 
269
- Le pont utilise `executeActionGuarded()` (`Resolver.ts:647`) : porte d'idempotence **sans** rendu HTTP
269
+ Le pont utilise `executeActionGuarded()` (`Resolver.ts#Resolver.executeActionGuarded`) : 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:613`) lit `meta.idempotent` sur les
302
+ 1. **Court-circuit hot path.** `callController()` (`Resolver.ts#Resolver.callController`) 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:688`).
307
- 3. **Empreinte du payload.** `computeFingerprint()` (`idempotency.ts:123`) hache
308
- `[nom de route, params de route, corps]` (`Resolver.ts:497`). Le corps vient de l'ALS (pont WS) ou
306
+ exécution (`Resolver.ts#Resolver._callWithIdempotency`).
307
+ 3. **Empreinte du payload.** `computeFingerprint()` (`idempotency.ts#computeFingerprint`) hache
308
+ `[nom de route, params de route, corps]` (`Resolver.ts#Resolver._enforceActionSecurity`). Le corps vient de l'ALS (pont WS) ou
309
309
  du body HTTP parsé.
310
- 4. **Identité.** `resolveIdentity()` (`idempotency.ts:100`) dérive l'identité de `request.user`
311
- (`username` → `identifier` → `id`), avec repli sur l'`userId` de l'ALS. **`null` = pas de cache** :
312
- le verdict devient `execute` (`idempotency.ts:176`) — jamais de partage cross-identité.
310
+ 4. **Identité.** `resolveIdentity()` (`idempotency.ts#resolveIdentity`) 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:704`). En erreur,
317
+ courant et `body` la **valeur retournée** par l'action (`Resolver.ts#Resolver._callWithIdempotency`). 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,48 +323,48 @@ 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:549`) — la dédup est préservée plutôt que perdue en silence.
326
+ > corps `null` (`Resolver.ts#Resolver._acquireAndInvoke`) — 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
- | Appelant | Point d'entrée | Traduction du verdict |
329
- | ---------------------------- | -------------------------------------------------------------------- | ---------------------------------- |
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
- | Data plane admin `/nodefony` | `AdminApiController.idempotencyGate()` (`AdminApiController.ts:131`) | `{status, headers, body}` |
330
+ | Appelant | Point d'entrée | Traduction du verdict |
331
+ | ---------------------------- | --------------------------------------------------------------------------------------------------- | ---------------------------------- |
332
+ | Controller userland HTTP | `callController()` (`Resolver.ts#Resolver.callController`) | `nodefonyError` + rendu normal |
333
+ | Controller userland via WS | `executeActionGuarded()` (`Resolver.ts#Resolver.executeActionGuarded`) | valeur nue, enveloppée par le peer |
334
+ | Data plane admin `/nodefony` | `AdminApiController.idempotencyGate()` (`AdminApiController.ts#AdminApiController.idempotencyGate`) | `{status, headers, body}` |
333
335
 
334
336
  ## ⚙️ Configuration
335
337
 
336
338
  Source unique = schéma Zod `idempotencySchema`
337
- (`src/packages/@nodefony/framework/nodefony/config/config.ts:42`).
339
+ (`src/packages/@nodefony/framework/nodefony/config/config.ts#idempotencySchema`).
338
340
 
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`). |
341
+ | Option | Type | Défaut | Effet |
342
+ | ------------- | --------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
343
+ | `store` | `string` | `"auto"` | Backing du cache. `auto` suit l'infra déclarée ; `memory` / `redis` / `drizzle` / `mongoose` sont explicites (`config.ts#idempotencySchema`). |
344
+ | `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#idempotencySchema`). |
345
+ | `gcJitter` | `bool` | `true` | Étale le départ du GC par process — anti _thundering-herd_ sur un store SQL partagé (`config.ts#idempotencySchema`). |
344
346
 
345
347
  ### Comment `store: "auto"` se résout VRAIMENT
346
348
 
347
- `Framework.onKernelBoot()` (`src/packages/@nodefony/framework/index.ts:230`) délègue à
348
- `resolveAutoStore("ephemeral", …)` (`src/nodefony/src/config/infra.ts:289`), borné aux backends
349
- **réellement enregistrés** (`listIdempotencyBackends()`, `idempotencyStoreRegistry.ts:81`). L'ordre
349
+ `Framework.onKernelBoot()` (`src/packages/@nodefony/framework/index.ts#Framework.onKernelBoot`) délègue à
350
+ `resolveAutoStore("ephemeral", …)` (`src/nodefony/src/config/infra.ts#resolveAutoStore`), borné aux backends
351
+ **réellement enregistrés** (`listIdempotencyBackends()`, `idempotencyStoreRegistry.ts#listIdempotencyBackends`). L'ordre
350
352
  réel est le suivant :
351
353
 
352
- | Ordre | Condition | Résolution | Ancre |
353
- | ----- | ------------------------------------------------------------------------ | -------------------------------------- | -------------- |
354
- | 1 | `NF_STORE=<x>` et `<x>` enregistré pour cette brique | `<x>` (override global) | `infra.ts:343` |
355
- | 2 | Infra **cache** déclarée (`NF_REDIS_URL`) et `redis` enregistré | `redis` | `infra.ts:258` |
356
- | 3 | Infra **database** déclarée (`NF_DATABASE_URL`) et le backend enregistré | `drizzle` (SQL) / `mongoose` (Mongo) | `infra.ts:261` |
357
- | 4 | Une préférence existait mais son backend n'est pas enregistré | `fallback` = `memory`, raison ANNONCÉE | `infra.ts:274` |
358
- | 5 | **Aucune infra déclarée** mais un backend local persistant est chargé | `drizzle`, puis `mongoose` | `infra.ts:288` |
359
- | 6 | Rien de tout ça | `memory` (volatil) | `infra.ts:295` |
354
+ | Ordre | Condition | Résolution | Ancre |
355
+ | ----- | ------------------------------------------------------------------------ | -------------------------------------- | ----------------------------- |
356
+ | 1 | `NF_STORE=<x>` et `<x>` enregistré pour cette brique | `<x>` (override global) | `infra.ts#resolveAutoStore` |
357
+ | 2 | Infra **cache** déclarée (`NF_REDIS_URL`) et `redis` enregistré | `redis` | `infra.ts#CONNECTED_STORES` |
358
+ | 3 | Infra **database** déclarée (`NF_DATABASE_URL`) et le backend enregistré | `drizzle` (SQL) / `mongoose` (Mongo) | `infra.ts#durableStoreRemedy` |
359
+ | 4 | Une préférence existait mais son backend n'est pas enregistré | `fallback` = `memory`, raison ANNONCÉE | `infra.ts#resolveAutoStore` |
360
+ | 5 | **Aucune infra déclarée** mais un backend local persistant est chargé | `drizzle`, puis `mongoose` | `infra.ts#CONNECTED_STORES` |
361
+ | 6 | Rien de tout ça | `memory` (volatil) | `infra.ts#CONNECTED_STORES` |
360
362
 
361
363
  > [!NOTE]
362
364
  > L'étape **5** surprend souvent : dans une app dev qui charge `@nodefony/drizzle` **sans** déclarer
363
365
  > `NF_DATABASE_URL`, `auto` ne résout **pas** vers `memory` mais vers `drizzle` (SQLite local) — pour
364
366
  > que les données survivent au redémarrage. La résolution effective est toujours **journalisée au
365
- > boot** (`index.ts:207`) : lis cette ligne plutôt que de la déduire.
367
+ > boot** (`index.ts#Framework.onKernelRegister`) : lis cette ligne plutôt que de la déduire.
366
368
 
367
369
  MongoDB mérite une mention : `mongoose` porte lui aussi un store d'idempotence. Déclarer
368
370
  `NF_DATABASE_URL=mongodb://…` résout donc en étape **3** → `mongoose`, et la dédup cross-pod tient
@@ -374,20 +376,20 @@ le perdant reçoit un `E11000` : l'équivalent Mongo du `SET … NX PX` de Redis
374
376
 
375
377
  Quand un store **explicitement nommé** ne peut pas s'initialiser (nom inconnu, `redis` demandé sans
376
378
  le module `@nodefony/redis` chargé), la politique dépend de l'environnement
377
- (`index.ts:132`) :
379
+ (`index.ts`) :
378
380
 
379
- - **production** → **boot avorté** (`index.ts:281`). En cluster multi-pod, dégrader vers un cache
381
+ - **production** → **boot avorté** (`index.ts#Framework.onKernelBoot`). En cluster multi-pod, dégrader vers un cache
380
382
  per-pod produirait du double-effet non dédupliqué : c'est un défaut de sécurité, pas une commodité.
381
383
  - **dev / test** (mono-pod) → **WARNING fort + repli sur le cache mémoire** déjà en place. La dédup
382
384
  per-pod suffit hors cluster, et on ne casse pas le routeur pour une option d'infra absente en local.
383
385
 
384
386
  Un garde-fou supplémentaire : `store: "memory"` **en production** émet un WARNING dédié
385
- (`index.ts:212`) — la dédup n'y est que per-pod.
387
+ (`index.ts#Framework.onKernelRegister`) — la dédup n'y est que per-pod.
386
388
 
387
389
  ## 🧩 Les stores d'idempotence
388
390
 
389
391
  Tous respectent le même contrat `IIdempotencyStore`
390
- (`src/nodefony/src/types/IIdempotencyStore.ts:106`) : `begin` / `complete` / `abort` obligatoires,
392
+ (`src/nodefony/src/types/IIdempotencyStore.ts#IIdempotencyStore`) : `begin` / `complete` / `abort` obligatoires,
391
393
  `gc` **optionnel**, plus `listPage` et `size` pour l'introspection.
392
394
 
393
395
  ### Choisir en 5 secondes
@@ -401,20 +403,20 @@ Tous respectent le même contrat `IIdempotencyStore`
401
403
 
402
404
  ### `memory` — le défaut per-pod, gratuit
403
405
 
404
- `MemoryIdempotencyStore` (`IdempotencyStore.ts:44`) est enregistré d'office comme service DI
406
+ `MemoryIdempotencyStore` (`IdempotencyStore.ts#MemoryIdempotencyStore`) est enregistré d'office comme service DI
405
407
  `idempotencyStore` par le manifeste `@services` du module framework — zéro configuration.
406
408
 
407
- - **Atomicité par le mono-thread JS** : `begin()` (`IdempotencyStore.ts:115`) lit et écrit la `Map`
409
+ - **Atomicité par le mono-thread JS** : `begin()` (`IdempotencyStore.ts#MemoryIdempotencyStore.begin`) lit et écrit la `Map`
408
410
  sans point de suspension → deux `begin` concurrents ne peuvent pas se croiser.
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,
411
+ - **Constantes** : rétention `DEFAULT_TTL_MS` = 600 s (`IdempotencyStore.ts#DEFAULT_TTL_MS`), bail
412
+ `DEFAULT_LEASE_MS` = 60 s (`IdempotencyStore.ts#DEFAULT_LEASE_MS`), plafond `DEFAULT_CAP` = 1000 entrées
413
+ (`IdempotencyStore.ts#DEFAULT_CAP`). Elles ne sont **pas** configurables.
414
+ - **Lazy** : la `Map` n'est allouée qu'au **1ᵉʳ** `begin` (`IdempotencyStore.ts#MemoryIdempotencyStore.begin`) ; aucun timer,
413
415
  aucun listener.
414
416
  - **Purge passive** : les entrées expirées ne sont retirées qu'à l'écriture, dans `evictIfNeeded()`
415
- (`IdempotencyStore.ts:172`), qui purge d'abord les mortes puis évince en **FIFO** jusqu'à repasser
417
+ (`IdempotencyStore.ts#MemoryIdempotencyStore.evictIfNeeded`), qui purge d'abord les mortes puis évince en **FIFO** jusqu'à repasser
416
418
  sous le cap. Coût nul tant qu'on n'écrit pas.
417
- - **Garde anti-résurrection** : `complete()` (`IdempotencyStore.ts:142`) n'écrit **que** si la clé est
419
+ - **Garde anti-résurrection** : `complete()` (`IdempotencyStore.ts#MemoryIdempotencyStore.complete`) n'écrit **que** si la clé est
418
420
  encore _notre_ in-flight — jamais de résurrection d'une clé déjà `abort`-ée ou évincée.
419
421
 
420
422
  ⚠️ Limite structurelle : la dédup est **affine au pod**. Un rejeu routé vers un autre pod n'est pas
@@ -422,22 +424,22 @@ dédupliqué.
422
424
 
423
425
  ### `redis` — le choix cluster
424
426
 
425
- `RedisIdempotencyStore` (`RedisIdempotencyStore.ts:121`) vit dans `@nodefony/framework` (le
427
+ `RedisIdempotencyStore` (`RedisIdempotencyStore.ts#RedisIdempotencyStore`) vit dans `@nodefony/framework` (le
426
428
  consommateur du contrat), pas dans `@nodefony/redis` : il résout le service `redis` **par nom** dans
427
429
  le container (couplage structurel, zéro dépendance directe, zéro cycle). La fabrique est enregistrée
428
- au chargement du module framework (`index.ts:119`).
430
+ au chargement du module framework (`index.ts`).
429
431
 
430
- - **`SET key … NX PX`** = réservation atomique **côté serveur** (`RedisIdempotencyStore.ts:237`) : le
432
+ - **`SET key … NX PX`** = réservation atomique **côté serveur** (`RedisIdempotencyStore.ts#RedisIdempotencyStore.#prefix`) : le
431
433
  `409` in-flight fonctionne vraiment entre pods. Deux requêtes concurrentes sur deux pods, un seul
432
434
  `SET NX` gagne.
433
435
  - **TTL natif** sur le bail _et_ sur la réponse mémorisée → `gc()` **superflu**, donc **non
434
436
  implémenté** : rien à planifier.
435
- - **Empreinte préservée à la complétion** : `complete()` (`RedisIdempotencyStore.ts:300`) **relit**
437
+ - **Empreinte préservée à la complétion** : `complete()` (`RedisIdempotencyStore.ts#RedisIdempotencyStore.complete`) **relit**
436
438
  l'entrée in-flight pour reporter son empreinte dans l'entrée `done`. Sans cela, un rejeu de la clé
437
439
  avec un autre payload **après** complétion ne serait plus détecté (le 422 serait perdu).
438
440
  - **Course rare gérée** : si la clé expire entre le `SET NX` échoué et le `GET`, la réservation est
439
- retentée **une** fois ; encore prise → `in-flight` (`RedisIdempotencyStore.ts:258`).
440
- - **Namespace** : `nf:idem:<clé>` (`RedisIdempotencyStore.ts:11`).
441
+ retentée **une** fois ; encore prise → `in-flight` (`RedisIdempotencyStore.ts#RedisIdempotencyStore.begin`).
442
+ - **Namespace** : `nf:idem:<clé>` (`RedisIdempotencyStore.ts#KEY_BASE`).
441
443
  - **Dégradation gracieuse** : connexion `main` fermée (boot / shutdown) → `begin` renvoie `fresh`,
442
444
  `complete`/`abort` sont des no-op. La mutation s'exécute **sans dédup** plutôt que d'être bloquée.
443
445
 
@@ -448,47 +450,47 @@ au chargement du module framework (`index.ts:119`).
448
450
 
449
451
  ### `drizzle` — le cluster sans Redis
450
452
 
451
- `DrizzleIdempotencyStore` (`DrizzleIdempotencyStore.ts:102`). Motivation : un cluster qui possède
453
+ `DrizzleIdempotencyStore` (`DrizzleIdempotencyStore.ts#DrizzleIdempotencyStore`). Motivation : un cluster qui possède
452
454
  déjà Postgres mais pas Redis obtient la dédup cross-pod **sans nouvelle infra**.
453
455
 
454
456
  - **Réservation atomique en UNE instruction** — un `INSERT` avec
455
- `onConflictDoUpdate` (`DrizzleIdempotencyStore.ts:234`) dont la garde `setWhere` ne réécrit que si
456
- l'entrée est morte (`DrizzleIdempotencyStore.ts:269`). Le `returning` ne rend une ligne que si
457
+ `onConflictDoUpdate` (`DrizzleIdempotencyStore.ts#DrizzleIdempotencyStore.begin`) dont la garde `setWhere` ne réécrit que si
458
+ l'entrée est morte (`DrizzleIdempotencyStore.ts#DrizzleIdempotencyStore.begin`). Le `returning` ne rend une ligne que si
457
459
  l'INSERT a passé (clé neuve) ou si le `DO UPDATE` a **volé** une entrée expirée → `fresh`. Zéro
458
460
  ligne = contention → on lit l'état réel.
459
461
  - **Invariant capital** : le store ne renvoie **jamais** `fresh` hors réservation atomique gagnée.
460
462
  Même la course rare « la clé a expiré entre l'upsert et le SELECT » renvoie prudemment `in-flight`
461
- (`DrizzleIdempotencyStore.ts:264`), jamais `fresh`.
463
+ (`DrizzleIdempotencyStore.ts#DrizzleIdempotencyStore.begin`), jamais `fresh`.
462
464
  - **MySQL/MariaDB** : ni `RETURNING`, ni `WHERE` sur l'`ON DUPLICATE KEY UPDATE`, et un `affectedRows`
463
465
  ambigu → la réservation passe par `reserveIdempotencyKeyMysql()`
464
- (`DrizzleIdempotencyStore.ts:233`), qui la reconstruit en deux instructions chacune atomique.
465
- - **Pas de TTL natif** → `gc()` (`DrizzleIdempotencyStore.ts:318`) = `DELETE WHERE expiresAt <= now`.
466
+ (`DrizzleIdempotencyStore.ts#DrizzleIdempotencyStore.begin`), qui la reconstruit en deux instructions chacune atomique.
467
+ - **Pas de TTL natif** → `gc()` (`DrizzleIdempotencyStore.ts#DrizzleIdempotencyStore.abort`) = `DELETE WHERE expiresAt <= now`.
466
468
  C'est le **seul** store qui expose `gc`, donc le seul que le framework planifie (voir plus bas).
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
+ - **Mutations conditionnelles** : `complete()` (`DrizzleIdempotencyStore.ts#DrizzleIdempotencyStore.complete`) et `abort()`
470
+ (`DrizzleIdempotencyStore.ts#DrizzleIdempotencyStore.abort`) portent `WHERE state = 'if'` — jamais d'écrasement d'une réponse
469
471
  déjà mémorisée, jamais de résurrection d'une clé libérée. `complete` ne touche pas `fingerprint`.
470
472
  - **Résolution lazy + dégradation gracieuse** : le handle Drizzle est résolu à **chaque** appel
471
- (`DrizzleIdempotencyStore.from()`, `DrizzleIdempotencyStore.ts:191`). ORM non connecté → `begin`
473
+ (`DrizzleIdempotencyStore.from()`, `DrizzleIdempotencyStore.ts#DrizzleIdempotencyStore.from`). ORM non connecté → `begin`
472
474
  renvoie `fresh` (sans dédup), le reste est no-op.
473
475
 
474
476
  Le câblage est **automatique** : charger `@nodefony/drizzle` enregistre l'entité **et** la fabrique
475
- (`registerStores.ts:316`). Activation = `store: "drizzle"` (ou `NF_STORE=drizzle`, qui force toutes les briques `auto`), rien
477
+ (`registerStores.ts#registerDrizzleFrameworkStores`). Activation = `store: "drizzle"` (ou `NF_STORE=drizzle`, qui force toutes les briques `auto`), rien
476
478
  d'autre à écrire.
477
479
 
478
480
  ### Le GC — armé pour un seul store
479
481
 
480
- `scheduleIdempotencyGc()` (`idempotencyGc.ts:32`) arme un `GcScheduler` **uniquement si le store
481
- expose `gc()`** (`idempotencyGc.ts:37`). Un store à TTL natif (`redis`) ou à purge passive (`memory`)
482
+ `scheduleIdempotencyGc()` (`idempotencyGc.ts#scheduleIdempotencyGc`) arme un `GcScheduler` **uniquement si le store
483
+ expose `gc()`** (`idempotencyGc.ts#scheduleIdempotencyGc`). Un store à TTL natif (`redis`) ou à purge passive (`memory`)
482
484
  ne l'expose pas ; le brancher sur un timer serait un no-op coûteux. Le scheduler est armé au boot
483
- (`nodefony/framework/index.ts:311`) et arrêté à `onTerminate`.
485
+ (`nodefony/framework/index.ts#Framework.onKernelBoot`) et arrêté à `onTerminate`.
484
486
 
485
487
  `gcIntervalS: 0` désarme le timer — à réserver au cas où la purge est déléguée (cron, `CronJob` k8s).
486
488
 
487
489
  ## 🗄️ Entité de persistance (store SQL)
488
490
 
489
491
  Table `idempotency_key`, décrite par une **spec colKit** unique
490
- (`idempotencyEntity.ts:66`) déclinée par dialecte via `createIdempotencyTable(dialect)`
491
- (`idempotencyEntity.ts:85`).
492
+ (`idempotencyEntity.ts#IDEMPOTENCY_ENTITY_NAME`) déclinée par dialecte via `createIdempotencyTable(dialect)`
493
+ (`idempotencyEntity.ts#createIdempotencyTable`).
492
494
 
493
495
  | Colonne | Type logique | SQLite | PostgreSQL | MySQL / MariaDB | Rôle |
494
496
  | ------------- | ------------ | ------------------ | ---------- | --------------- | ------------------------------------------------------------------------------------- |
@@ -502,15 +504,15 @@ Deux détails qui expliquent des surprises réelles :
502
504
 
503
505
  - **`varchar(512)` en MySQL** n'est pas un caprice : un `TEXT` InnoDB n'est pas indexable sans
504
506
  préfixe, et 512 caractères couvrent `JSON.stringify([identité, clé ≤ 255])`.
505
- - **Aucun `DEFAULT` SQL** : le DDL dérivé n'en émet pas (`idempotencyEntity.ts:38`). Toutes les
507
+ - **Aucun `DEFAULT` SQL** : le DDL dérivé n'en émet pas (`idempotencyEntity.ts#idempotencyKeySchema`). Toutes les
506
508
  colonnes sont posées explicitement par le store — jamais d'INSERT cassé par un défaut manquant.
507
509
 
508
- `registerIdempotencyEntities(connector, dialect)` (`idempotencyEntity.ts:146`) doit être appelé
510
+ `registerIdempotencyEntities(connector, dialect)` (`idempotencyEntity.ts#registerIdempotencyEntities`) doit être appelé
509
511
  **avant** `orm.connect()` (la table est créée au connect) — le module drizzle s'en charge tout seul.
510
512
 
511
513
  > [!NOTE]
512
514
  > **SQLite = banc de test de la sémantique.** Un fichier SQLite est mono-machine (verrou d'écriture)
513
- > → aucun intérêt multi-pod (`idempotencyEntity.ts:26`). La cible réelle est PostgreSQL ou
515
+ > → aucun intérêt multi-pod (`idempotencyEntity.ts#idempotencyKeySchema`). La cible réelle est PostgreSQL ou
514
516
  > MySQL/MariaDB, où l'atomicité de l'instruction tient sous concurrence inter-pods.
515
517
 
516
518
  ## 🗃️ Dialectes et bases pris en charge
@@ -538,24 +540,24 @@ Signatures complètes : `.ai/symbols.json`. Ce qui compte à l'usage :
538
540
 
539
541
  ### Le décorateur
540
542
 
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
+ `@Idempotent(options?)` (`routerDecorators.ts#Idempotent`) — dual **classe + méthode**. N'écrit que des
544
+ métadonnées (`IdempotentMeta`, `routerDecorators.ts#IdempotentMeta`), zéro import de `@nodefony/security`, zéro
543
545
  cycle. La porte est appliquée par le Resolver.
544
546
 
545
547
  ### Le contrat de store
546
548
 
547
- | Membre | Obligatoire | Rôle |
548
- | ------------------------- | :---------: | --------------------------------------------------------------------------------------------------------------- |
549
- | `begin(key, fingerprint)` | ✅ | Réserve atomiquement, rend le verdict (`src/nodefony/src/types/IIdempotencyStore.ts:118`). |
550
- | `complete(key, response)` | ✅ | Mémorise la réponse d'une clé in-flight → rejeux futurs = `replayed`. |
551
- | `abort(key)` | ✅ | Libère une clé in-flight dont l'exécution a échoué. Rien n'est mémorisé. |
552
- | `gc(now?)` | ❌ | Purge des expirées — **uniquement** sans expiration native (`src/nodefony/src/types/IIdempotencyStore.ts:139`). |
553
- | `listPage(query)` | ✅ | Page de clés vivantes, pour l'introspection admin (`src/nodefony/src/types/IIdempotencyStore.ts:153`). |
554
- | `size` | ✅ | Nombre d'entrées vivantes, **sync best-effort** (`src/nodefony/src/types/IIdempotencyStore.ts:159`). |
549
+ | Membre | Obligatoire | Rôle |
550
+ | ------------------------- | :---------: | -------------------------------------------------------------------------------------------------------------------------------- |
551
+ | `begin(key, fingerprint)` | ✅ | Réserve atomiquement, rend le verdict (`src/nodefony/src/types/IIdempotencyStore.ts#IIdempotencyStore.begin`). |
552
+ | `complete(key, response)` | ✅ | Mémorise la réponse d'une clé in-flight → rejeux futurs = `replayed`. |
553
+ | `abort(key)` | ✅ | Libère une clé in-flight dont l'exécution a échoué. Rien n'est mémorisé. |
554
+ | `gc(now?)` | ❌ | Purge des expirées — **uniquement** sans expiration native (`src/nodefony/src/types/IIdempotencyStore.ts#IIdempotencyStore.gc`). |
555
+ | `listPage(query)` | ✅ | Page de clés vivantes, pour l'introspection admin (`src/nodefony/src/types/IIdempotencyStore.ts#IIdempotencyStore.listPage`). |
556
+ | `size` | ✅ | Nombre d'entrées vivantes, **sync best-effort** (`src/nodefony/src/types/IIdempotencyStore.ts#IIdempotencyStore.size`). |
555
557
 
556
- Les helpers du seam sont exportés et réutilisables : `isMutationMethod()` (`idempotency.ts:28`),
557
- `resolveIdempotencyKey()` (`idempotency.ts:68`), `resolveIdentity()` (`idempotency.ts:100`),
558
- `computeFingerprint()` (`idempotency.ts:123`), `evaluateIdempotency()` (`idempotency.ts:142`).
558
+ Les helpers du seam sont exportés et réutilisables : `isMutationMethod()` (`idempotency.ts#isMutationMethod`),
559
+ `resolveIdempotencyKey()` (`idempotency.ts#resolveIdempotencyKey`), `resolveIdentity()` (`idempotency.ts#resolveIdentity`),
560
+ `computeFingerprint()` (`idempotency.ts#computeFingerprint`), `evaluateIdempotency()` (`idempotency.ts#evaluateIdempotency`).
559
561
 
560
562
  ### `listPage` — capacités RÉELLES par store
561
563
 
@@ -575,18 +577,18 @@ que le code fait, store par store — à lire avant d'écrire un client :
575
577
  | `state` | ✅ | ✅ | ✅ (filtre après lecture) |
576
578
  | Page pleine à `limit` | ✅ | ✅ | ❌ peut être plus courte |
577
579
  | Exclusion des expirées | ✅ à la lecture | ✅ `expiresAt > now` | ✅ par TTL natif |
578
- | Ancre | `IdempotencyStore.ts:72` | `DrizzleIdempotencyStore.ts:340` | `RedisIdempotencyStore.ts:166` |
580
+ | Ancre | `IdempotencyStore.ts#MemoryIdempotencyStore.listPage` | `DrizzleIdempotencyStore.ts#DrizzleIdempotencyStore.gc` | `RedisIdempotencyStore.ts#RedisIdempotencyStore.listPage` |
579
581
 
580
582
  Le mode **curseur** de Redis mérite une explication, parce qu'il piège : `SCAN COUNT` **n'est pas un
581
583
  plafond** mais un indice d'effort — Redis peut rendre plus de clés que demandé. Sans précaution, la
582
- page dépasserait `limit` et violerait le contrat `IPage` (`src/nodefony/src/types/IPage.ts:108`). D'où
584
+ page dépasserait `limit` et violerait le contrat `IPage` (`src/nodefony/src/types/IPage.ts#IPage`). D'où
583
585
  le **curseur composite** `"<consommé>:<curseurRedis>"` (`encodeCursor()`,
584
- `RedisIdempotencyStore.ts:63`) : on ne rend que `limit` éléments et on mémorise combien de clés du
586
+ `RedisIdempotencyStore.ts#encodeCursor`) : on ne rend que `limit` éléments et on mémorise combien de clés du
585
587
  lot ont été consommées ; la page suivante rejoue le **même** `SCAN` et reprend là.
586
588
 
587
589
  > [!IMPORTANT]
588
590
  > **Une clé rendue par `listPage` ne contient JAMAIS la réponse mémorisée ni l'empreinte.**
589
- > `IIdempotencyKeyEntry` (`src/nodefony/src/types/IIdempotencyStore.ts:51`) n'expose que `key`,
591
+ > `IIdempotencyKeyEntry` (`src/nodefony/src/types/IIdempotencyStore.ts#IIdempotencyKeyEntry`) n'expose que `key`,
590
592
  > `state`, `expiresAtMs` et `hasResponse` (un booléen). Le corps mémorisé est la donnée métier d'un
591
593
  > utilisateur : le laisser sortir par ce chemin recréerait exactement l'IDOR sur le cache que le
592
594
  > scope de clé interdit.
@@ -626,35 +628,35 @@ Trois règles héritées du code existant, à respecter sous peine de double-eff
626
628
  3. **`complete` doit préserver l'empreinte** de l'entrée in-flight, sinon un rejeu avec un autre
627
629
  payload après complétion ne produit plus de 422.
628
630
 
629
- Fonctions du registre : `registerIdempotencyStore()` (`idempotencyStoreRegistry.ts:48`),
630
- `getIdempotencyStoreFactory()` (`idempotencyStoreRegistry.ts:56`), `listIdempotencyStores()`
631
- (distribués seuls, `idempotencyStoreRegistry.ts:67`), `listIdempotencyBackends()` (avec `memory`,
632
- pour l'affichage Studio, `idempotencyStoreRegistry.ts:81`).
631
+ Fonctions du registre : `registerIdempotencyStore()` (`idempotencyStoreRegistry.ts#registerIdempotencyStore`),
632
+ `getIdempotencyStoreFactory()` (`idempotencyStoreRegistry.ts#getIdempotencyStoreFactory`), `listIdempotencyStores()`
633
+ (distribués seuls, `idempotencyStoreRegistry.ts#listIdempotencyStores`), `listIdempotencyBackends()` (avec `memory`,
634
+ pour l'affichage Studio, `idempotencyStoreRegistry.ts#listIdempotencyBackends`).
633
635
 
634
636
  ## 📜 Normes appliquées
635
637
 
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:142`) |
640
- | 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:197` |
642
- | Clé requise absente → 400 | draft §2.7 | `idempotency.ts:165` |
643
- | Méthodes non sûres = mutations | RFC 9110 §9.2.1 | `MUTATION_METHODS` (`idempotency.ts:25`) |
644
- | Sémantique du 422 | RFC 9110 §15.5.21 | `IdempotencyVerdict` (`idempotency.ts:50`) |
645
- | Borne de clé (convention Stripe) | 255 octets | `IDEMPOTENCY_KEY_MAX` (`idempotency.ts:36`) |
641
+ | En-tête `Idempotency-Key`, statuts, rejeu | `draft-ietf-httpapi-idempotency-key-header-06` | `evaluateIdempotency()` (`idempotency.ts#evaluateIdempotency`) |
642
+ | Clé réutilisée avec un autre payload → 422 | draft §2.2 / §2.7 | `idempotency.ts#evaluateIdempotency` |
643
+ | Exécution concurrente identique → 409 | draft §2.6 | `idempotency.ts#evaluateIdempotency` |
644
+ | Clé requise absente → 400 | draft §2.7 | `idempotency.ts#evaluateIdempotency` |
645
+ | Méthodes non sûres = mutations | RFC 9110 §9.2.1 | `MUTATION_METHODS` (`idempotency.ts#MUTATION_METHODS`) |
646
+ | Sémantique du 422 | RFC 9110 §15.5.21 | `IdempotencyVerdict` (`idempotency.ts#IdempotencyVerdict`) |
647
+ | Borne de clé (convention Stripe) | 255 octets | `IDEMPOTENCY_KEY_MAX` (`idempotency.ts#IDEMPOTENCY_KEY_MAX`) |
646
648
 
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: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`).
652
+ `null` (`routerDecorators.ts#Idempotent`) : `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#Resolver.callController`).
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é :
655
657
 
656
658
  - Le store mémoire n'alloue sa `Map` qu'au 1ᵉʳ `begin`, ne pose **aucun timer ni listener**, et purge
657
- en passif (`IdempotencyStore.ts:115`).
659
+ en passif (`IdempotencyStore.ts#MemoryIdempotencyStore.begin`).
658
660
  - L'empreinte est un hash SHA-256 court → comparaison O(1), et le payload n'est jamais conservé en
659
661
  clair.
660
662
  - Le GC est **hors hot-path** et armé pour un seul store (SQL) ; le jitter évite que N pods purgent
@@ -671,15 +673,15 @@ froid par construction ; si ton profil de trafic la place sur un chemin chaud, m
671
673
  Trois surfaces existent aujourd'hui :
672
674
 
673
675
  - **Playground** (`/nodefony/playground`) — chaque mutation protégée porte un badge `@Idempotent` (strict ou souple)
674
- (`playground/PlaygroundFormat.tsx:69`). L'écran génère une clé par exécution et propose « Rejouer
675
- même clé » dès que `action.guards.idempotent` est posé (`playground/ActionPanel.tsx:516`) : c'est la
676
+ (`playground/PlaygroundFormat.tsx#GuardBadges`). L'écran génère une clé par exécution et propose « Rejouer
677
+ même clé » dès que `action.guards.idempotent` est posé (`playground/ActionPanel.tsx#ActionPanel`) : c'est la
676
678
  façon la plus rapide de voir un rejeu, un `409` ou un `422` en vrai.
677
679
  - **Stores** — la brique `idempotency` y apparaît avec sa nature **éphémère**, le backend configuré,
678
680
  le backend résolu, la liste des backends disponibles et la raison de la résolution — brique
679
- `idempotency` (`stores/storesModel.ts:145`). C'est là qu'on vérifie qu'`auto` a choisi ce qu'on
681
+ `idempotency` (`stores/storesModel.ts#BRICK_PURPOSE`). C'est là qu'on vérifie qu'`auto` a choisi ce qu'on
680
682
  croyait.
681
683
  - **ERD** — la table `idempotency_key` est regroupée sous `@nodefony/framework`
682
- (`idempotencyEntity.ts:133`), pas sous l'ORM qui l'héberge.
684
+ (`idempotencyEntity.ts#createIdempotencyEntities`), pas sous l'ORM qui l'héberge.
683
685
 
684
686
  > [!NOTE]
685
687
  > Il n'existe **pas encore** d'écran ni d'endpoint admin listant les clés d'idempotence vivantes :
@@ -689,19 +691,19 @@ Trois surfaces existent aujourd'hui :
689
691
 
690
692
  ## ⚠️ Pièges
691
693
 
692
- | Symptôme | Cause (dans le code) | Correction |
693
- | ------------------------------------------------------ | ------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------- |
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 |
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 |
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 |
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 |
698
- | Rien n'est dédupliqué, clé « un peu longue » | Clé > 255 octets → traitée comme **absente** (`idempotency.ts:36`) | Utiliser un UUID ; en mode strict la requête part en 400, pas en silence |
699
- | Dédup qui saute en cluster | `store: memory` (per-pod) | `redis` (`SET NX`) ou `drizzle` (Postgres/MySQL) |
700
- | `400 Idempotency-Key required` inattendu | Mode strict par défaut, **ou** requête WebSocket (toujours stricte) | Envoyer la clé, ou `@Idempotent({ required:false })` — sans effet en WS |
701
- | `auto` résout `drizzle` alors qu'on attendait `memory` | Repli local persistant quand aucune infra n'est déclarée (`infra.ts:288`) | Comportement voulu ; forcer avec `store: "memory"` ou `NF_STORE=memory` |
702
- | Boot qui échoue en prod sur l'idempotence | Store distribué nommé mais non câblé → fatal (`index.ts:132`) | Charger le module manquant (`@nodefony/redis`) ou corriger le nom |
703
- | Aucune purge sur un store SQL | `intervalS` à 0 → scheduler désarmé, dit dans le log de boot (`idempotencyGc.ts:49`) | Remettre un intervalle, ou assumer une purge externe (cron) |
704
- | `listPage` : `total` toujours absent | Backend Redis = mode **curseur**, `total` jamais rendu | Boucler sur `nextCursor` ; ne pas coder de pagination par offset côté client |
694
+ | Symptôme | Cause (dans le code) | Correction |
695
+ | ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- |
696
+ | Rejeu qui renvoie un corps **vide** | L'action a retourné `this.renderJson(...)` au lieu du payload (`Resolver.ts#Resolver._callWithIdempotency`) | Retourner la **valeur brute** ; un WARNING le signale déjà dans les logs |
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 |
698
+ | `422 Idempotency-Key is already used` | Même clé, **payload différent** (empreinte ≠, `idempotency.ts#evaluateIdempotency`) | Une clé = une intention ; nouvelle clé par requête distincte |
699
+ | Rien n'est dédupliqué **malgré** la clé | Pas d'identité fiable → verdict `execute` (`idempotency.ts#evaluateIdempotency`) | S'assurer que le firewall a résolu l'utilisateur **avant** la mutation |
700
+ | Rien n'est dédupliqué, clé « un peu longue » | Clé > 255 octets → traitée comme **absente** (`idempotency.ts#IDEMPOTENCY_KEY_MAX`) | Utiliser un UUID ; en mode strict la requête part en 400, pas en silence |
701
+ | Dédup qui saute en cluster | `store: memory` (per-pod) | `redis` (`SET NX`) ou `drizzle` (Postgres/MySQL) |
702
+ | `400 Idempotency-Key required` inattendu | Mode strict par défaut, **ou** requête WebSocket (toujours stricte) | Envoyer la clé, ou `@Idempotent({ required:false })` — sans effet en WS |
703
+ | `auto` résout `drizzle` alors qu'on attendait `memory` | Repli local persistant quand aucune infra n'est déclarée (`infra.ts#CONNECTED_STORES`) | Comportement voulu ; forcer avec `store: "memory"` ou `NF_STORE=memory` |
704
+ | Boot qui échoue en prod sur l'idempotence | Store distribué nommé mais non câblé → fatal (`index.ts`) | Charger le module manquant (`@nodefony/redis`) ou corriger le nom |
705
+ | Aucune purge sur un store SQL | `intervalS` à 0 → scheduler désarmé, dit dans le log de boot (`idempotencyGc.ts#IIdempotencyGcOptions.intervalS`) | Remettre un intervalle, ou assumer une purge externe (cron) |
706
+ | `listPage` : `total` toujours absent | Backend Redis = mode **curseur**, `total` jamais rendu | Boucler sur `nextCursor` ; ne pas coder de pagination par offset côté client |
705
707
 
706
708
  ## 🧪 Tests et couverture
707
709
 
@@ -744,5 +746,5 @@ Couverture : `npm run coverage` dans `@nodefony/framework` et `@nodefony/drizzle
744
746
  - 🧭 **Pages sœurs** : [Pipeline de requête](../../../../../docs/architecture/pipeline-requete.md) · [Firewall (l'identité qui scope la clé)](../../security/docs/firewall.md)
745
747
  - 🗄️ **Stores distribués** : [@nodefony/redis](../../redis/docs/index.md) · [@nodefony/drizzle](../../drizzle/docs/index.md)
746
748
  - 📖 **Contrat de pagination** partagé par tous les stores : `IPage` / `IPageQuery`
747
- (`src/nodefony/src/types/IPage.ts:18`)
749
+ (`src/nodefony/src/types/IPage.ts#IPageQuery`)
748
750
  - 🧠 **Contexte de requête** (ALS : identité, clé WS, corps) → [request-context](../../../../nodefony/docs/request-context.md)