@nodefony/http 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 (139) hide show
  1. package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorate.js +1 -2
  2. package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorateParam.js +1 -2
  3. package/dist/index.js +19 -11
  4. package/dist/nodefony/command/assetsPublishCommand.js +5 -3
  5. package/dist/nodefony/command/certificatesCommand.js +5 -3
  6. package/dist/nodefony/command/networkCommand.js +5 -3
  7. package/dist/nodefony/command/proxyGenerateCommand.js +8 -3
  8. package/dist/nodefony/config/config.js +45 -6
  9. package/dist/nodefony/config/defineModuleConfig.js +2 -3
  10. package/dist/nodefony/interfaces/IReverseProxy.js +1 -0
  11. package/dist/nodefony/service/HttpAdminApi.js +2 -3
  12. package/dist/nodefony/service/ProfilerAdminApi.js +1 -2
  13. package/dist/nodefony/service/audit-logger.js +22 -16
  14. package/dist/nodefony/service/certificates.js +228 -19
  15. package/dist/nodefony/service/error-renderer.js +30 -13
  16. package/dist/nodefony/service/http-kernel.js +331 -82
  17. package/dist/nodefony/service/pretty-request-logger.js +5 -4
  18. package/dist/nodefony/service/request-logger.js +24 -16
  19. package/dist/nodefony/service/reverse-proxy.js +336 -0
  20. package/dist/nodefony/service/servers/clientError.js +22 -3
  21. package/dist/nodefony/service/servers/server-h2c.js +126 -0
  22. package/dist/nodefony/service/servers/server-http.js +5 -8
  23. package/dist/nodefony/service/servers/server-https.js +12 -24
  24. package/dist/nodefony/service/servers/server-static.js +7 -10
  25. package/dist/nodefony/service/servers/server-websocket-secure.js +18 -18
  26. package/dist/nodefony/service/servers/server-websocket.js +18 -18
  27. package/dist/nodefony/service/servers/serverShutdown.js +78 -18
  28. package/dist/nodefony/service/servers/wsHeartbeat.js +32 -17
  29. package/dist/nodefony/service/sessions/sessions-service.js +34 -20
  30. package/dist/nodefony/service/trace.js +1 -2
  31. package/dist/nodefony/service/upload/upload-service.js +5 -8
  32. package/dist/nodefony/service/x509.js +1 -2
  33. package/dist/nodefony/src/assets/collectAssets.js +1 -2
  34. package/dist/nodefony/src/assets/prebuiltUi.js +1 -2
  35. package/dist/nodefony/src/context/Context.js +51 -32
  36. package/dist/nodefony/src/context/domainMatcher.js +21 -4
  37. package/dist/nodefony/src/context/forwarded.js +3 -4
  38. package/dist/nodefony/src/context/http/HttpContext.js +98 -46
  39. package/dist/nodefony/src/context/http/Request.js +34 -38
  40. package/dist/nodefony/src/context/http/Response.js +39 -7
  41. package/dist/nodefony/src/context/http/SseStream.js +349 -0
  42. package/dist/nodefony/src/context/http/parser.js +36 -10
  43. package/dist/nodefony/src/context/http/urlFastPath.js +1 -2
  44. package/dist/nodefony/src/context/http2/Request.js +4 -8
  45. package/dist/nodefony/src/context/http2/Response.js +22 -9
  46. package/dist/nodefony/src/context/metaData.js +1 -2
  47. package/dist/nodefony/src/context/requestId.js +1 -2
  48. package/dist/nodefony/src/context/responseEnded.js +26 -0
  49. package/dist/nodefony/src/context/transport.js +45 -0
  50. package/dist/nodefony/src/context/trustProxy.js +1 -2
  51. package/dist/nodefony/src/context/websocket/Response.js +42 -6
  52. package/dist/nodefony/src/context/websocket/WebsocketContext.js +94 -20
  53. package/dist/nodefony/src/context/websocket/isWebsocketContext.js +17 -0
  54. package/dist/nodefony/src/context/websocket/websocketUrl.js +20 -0
  55. package/dist/nodefony/src/context/websocket/wsBackpressure.js +1 -2
  56. package/dist/nodefony/src/context/websocket/wsCloseReason.js +1 -2
  57. package/dist/nodefony/src/context/websocket/wsLogContent.js +1 -2
  58. package/dist/nodefony/src/cookies/cookie.js +3 -7
  59. package/dist/nodefony/src/errors/httpError.js +1 -2
  60. package/dist/nodefony/src/errors/thrownMessage.js +1 -2
  61. package/dist/nodefony/src/profiler/FrameProfile.js +1 -2
  62. package/dist/nodefony/src/profiler/Profiler.js +108 -30
  63. package/dist/nodefony/src/profiler/aggregate.js +422 -0
  64. package/dist/nodefony/src/proxy/forward.js +500 -0
  65. package/dist/nodefony/src/proxy/generateProxyConfig.js +14 -6
  66. package/dist/nodefony/src/proxy/rules.js +188 -0
  67. package/dist/nodefony/src/rateLimit/MemoryRateLimitStore.js +1 -2
  68. package/dist/nodefony/src/rateLimit/WsConnectionCounter.js +1 -2
  69. package/dist/nodefony/src/rateLimit/rateLimitFilters.js +1 -2
  70. package/dist/nodefony/src/servers/http2Limits.js +50 -0
  71. package/dist/nodefony/src/servers/kernelServers.js +12 -3
  72. package/dist/nodefony/src/servers/portBinder.js +41 -10
  73. package/dist/nodefony/src/servers/upgradeDispatch.js +128 -0
  74. package/dist/nodefony/src/session/session.js +30 -15
  75. package/dist/nodefony/src/session/sessionStoreFailure.js +1 -2
  76. package/dist/nodefony/src/session/storage/MemorySessionStorage.js +1 -2
  77. package/dist/nodefony/src/session/storage/RevocationGuardStorage.js +1 -2
  78. package/dist/nodefony/src/session/storage/sessionFilters.js +1 -2
  79. package/dist/nodefony/src/session/storage/sessionSort.js +1 -2
  80. package/dist/types/index.d.ts +12 -3
  81. package/dist/types/nodefony/config/config.d.ts +16 -0
  82. package/dist/types/nodefony/interfaces/IContext.d.ts +65 -4
  83. package/dist/types/nodefony/interfaces/IErrorRenderer.d.ts +1 -1
  84. package/dist/types/nodefony/interfaces/IRequest.d.ts +9 -4
  85. package/dist/types/nodefony/interfaces/IReverseProxy.d.ts +67 -0
  86. package/dist/types/nodefony/interfaces/IRouting.d.ts +26 -0
  87. package/dist/types/nodefony/interfaces/ISecurity.d.ts +6 -0
  88. package/dist/types/nodefony/interfaces/index.d.ts +3 -2
  89. package/dist/types/nodefony/service/audit-logger.d.ts +2 -1
  90. package/dist/types/nodefony/service/certificates.d.ts +147 -5
  91. package/dist/types/nodefony/service/error-renderer.d.ts +32 -1
  92. package/dist/types/nodefony/service/http-kernel.d.ts +157 -15
  93. package/dist/types/nodefony/service/reverse-proxy.d.ts +156 -0
  94. package/dist/types/nodefony/service/servers/clientError.d.ts +16 -0
  95. package/dist/types/nodefony/service/servers/server-h2c.d.ts +51 -0
  96. package/dist/types/nodefony/service/servers/server-http.d.ts +3 -3
  97. package/dist/types/nodefony/service/servers/server-https.d.ts +4 -4
  98. package/dist/types/nodefony/service/servers/server-websocket-secure.d.ts +2 -3
  99. package/dist/types/nodefony/service/servers/server-websocket.d.ts +2 -3
  100. package/dist/types/nodefony/service/servers/serverShutdown.d.ts +30 -16
  101. package/dist/types/nodefony/service/servers/wsHeartbeat.d.ts +26 -0
  102. package/dist/types/nodefony/service/sessions/sessions-service.d.ts +19 -1
  103. package/dist/types/nodefony/src/context/Context.d.ts +35 -6
  104. package/dist/types/nodefony/src/context/domainMatcher.d.ts +11 -1
  105. package/dist/types/nodefony/src/context/http/HttpContext.d.ts +48 -1
  106. package/dist/types/nodefony/src/context/http/Request.d.ts +20 -15
  107. package/dist/types/nodefony/src/context/http/Response.d.ts +14 -0
  108. package/dist/types/nodefony/src/context/http/SseStream.d.ts +155 -0
  109. package/dist/types/nodefony/src/context/http/parser.d.ts +18 -2
  110. package/dist/types/nodefony/src/context/http2/Request.d.ts +1 -1
  111. package/dist/types/nodefony/src/context/http2/Response.d.ts +15 -5
  112. package/dist/types/nodefony/src/context/responseEnded.d.ts +20 -0
  113. package/dist/types/nodefony/src/context/transport.d.ts +53 -0
  114. package/dist/types/nodefony/src/context/websocket/Response.d.ts +18 -2
  115. package/dist/types/nodefony/src/context/websocket/WebsocketContext.d.ts +57 -7
  116. package/dist/types/nodefony/src/context/websocket/isWebsocketContext.d.ts +15 -0
  117. package/dist/types/nodefony/src/context/websocket/websocketUrl.d.ts +16 -0
  118. package/dist/types/nodefony/src/profiler/FrameProfile.d.ts +13 -12
  119. package/dist/types/nodefony/src/profiler/Profiler.d.ts +69 -15
  120. package/dist/types/nodefony/src/profiler/aggregate.d.ts +215 -0
  121. package/dist/types/nodefony/src/proxy/forward.d.ts +168 -0
  122. package/dist/types/nodefony/src/proxy/generateProxyConfig.d.ts +16 -0
  123. package/dist/types/nodefony/src/proxy/rules.d.ts +88 -0
  124. package/dist/types/nodefony/src/servers/http2Limits.d.ts +37 -0
  125. package/dist/types/nodefony/src/servers/kernelServers.d.ts +10 -1
  126. package/dist/types/nodefony/src/servers/portBinder.d.ts +29 -5
  127. package/dist/types/nodefony/src/servers/upgradeDispatch.d.ts +69 -0
  128. package/dist/types/nodefony/src/session/session.d.ts +2 -0
  129. package/docs/cookies.md +85 -78
  130. package/docs/index.md +5 -1
  131. package/docs/observabilite.md +81 -81
  132. package/docs/rate-limit.md +83 -83
  133. package/docs/reverse-proxy.md +522 -0
  134. package/docs/servers.md +378 -230
  135. package/docs/session.md +180 -180
  136. package/docs/sse.md +287 -0
  137. package/docs/upload.md +120 -117
  138. package/package.json +10 -11
  139. package/dist/_virtual/_@oxc-project_runtime@0.152.0/helpers/esm/decorateMetadata.js +0 -6
package/docs/session.md CHANGED
@@ -69,8 +69,8 @@ flowchart TD
69
69
  ```
70
70
 
71
71
  Le point d'activation est **unique et commun aux deux transports** : `HttpKernel.startSession()`
72
- (`http-kernel.ts:1246`). Il commence par la garde paresseuse `if (!intent && !context.hasSession())`
73
- (`http-kernel.ts:1283`) — sans intent de route ni cookie entrant, **aucune session n'est ouverte**.
72
+ (`http-kernel.ts#HttpKernel.startSession`). Il commence par la garde paresseuse `if (!intent && !context.hasSession())`
73
+ (`http-kernel.ts#HttpKernel.publishRuntimePorts`) — sans intent de route ni cookie entrant, **aucune session n'est ouverte**.
74
74
 
75
75
  ## 📖 Lexique
76
76
 
@@ -114,15 +114,15 @@ défaut** dans Nodefony :
114
114
 
115
115
  - **Vol du cookie (hijacking).** Un script injecté (XSS) ou un réseau en clair capte le cookie et
116
116
  rejoue la session. → `HttpOnly` et `Secure` sont à `true` par défaut (`sessionCookieSchema`,
117
- `config.ts:752-755`), et le nom du cookie prend le préfixe `__Host-` dès que le transport est TLS
118
- (`Context.getSessionCookieName()`, `Context.ts:733`).
117
+ `config.ts#sessionCookieSchema`), et le nom du cookie prend le préfixe `__Host-` dès que le transport est TLS
118
+ (`Context.getSessionCookieName()`, `Context.ts#Context.getSessionCookieName`).
119
119
  - **Fixation.** L'attaquant pose lui-même un identifiant dans le navigateur de la victime, attend
120
120
  qu'elle se connecte, puis réutilise **le même** identifiant. → double défense : `strictMode` rejette
121
- tout identifiant inconnu du store (`Session.resume()`, `session.ts:177`), et le login **régénère**
121
+ tout identifiant inconnu du store (`Session.resume()`, `session.ts#Session.resume`), et le login **régénère**
122
122
  l'identifiant (`AuthFlow` — voir plus bas).
123
123
  - **Exploitation prolongée d'un identifiant volé.** Une session maintenue artificiellement vivante
124
124
  resterait exploitable indéfiniment. → l'**absolute timeout** borne l'âge depuis la création et n'est
125
- **jamais** prolongé (`Session.isValidSession()`, `session.ts:371`), en plus de l'idle timeout.
125
+ **jamais** prolongé (`Session.isValidSession()`, `session.ts#Session.isValidSession`), en plus de l'idle timeout.
126
126
 
127
127
  > [!IMPORTANT]
128
128
  > Le cookie **ne chiffre rien** et n'a pas à le faire : il ne porte qu'un numéro. La sécurité repose
@@ -134,21 +134,21 @@ défaut** dans Nodefony :
134
134
  Quatre partis pris, chacun vérifiable dans le code.
135
135
 
136
136
  **1. Le cookie ne transporte que l'identifiant.** `Session.getSession()` lit la **valeur brute** du
137
- cookie, sans déchiffrement (`session.ts:160`) ; l'identifiant vient de `Session.generateId()`
138
- (`session.ts:231`). Modèle BFF : le web reste sur un cookie opaque, le JWT est réservé aux API et aux
137
+ cookie, sans déchiffrement (`session.ts#Session.getSession`) ; l'identifiant vient de `Session.generateId()`
138
+ (`session.ts#Session.generateId`). Modèle BFF : le web reste sur un cookie opaque, le JWT est réservé aux API et aux
139
139
  agents (voir [Firewall](../../security/docs/firewall.md)).
140
140
 
141
141
  **2. La session est paresseuse.** Elle n'existe que si une route la demande — `@UseSession`, ou la
142
142
  seule présence d'un paramètre `@Session` — ou si un cookie arrive déjà : c'est la garde de
143
- `HttpKernel.startSession()` (`http-kernel.ts:1246`). Une route publique ne paie **ni lecture de store,
143
+ `HttpKernel.startSession()` (`http-kernel.ts#HttpKernel.startSession`). Une route publique ne paie **ni lecture de store,
144
144
  ni `Set-Cookie`**.
145
145
 
146
146
  **3. Un seul modèle d'état pour le web et le temps réel.** Le même `startSession()` sert
147
- `HttpKernel.onRequestEnd()` (`http-kernel.ts:1697`) et `HttpKernel.onConnect()` (`http-kernel.ts:2008`) ;
148
- l'activité HTTP **ou** WS prolonge la même session (`Session.touchIfNeeded()`, `session.ts:421`).
147
+ `HttpKernel.onRequestEnd()` (`http-kernel.ts#HttpKernel.onRequestEnd`) et `HttpKernel.onConnect()` (`http-kernel.ts#HttpKernel.onConnect`) ;
148
+ l'activité HTTP **ou** WS prolonge la même session (`Session.touchIfNeeded()`, `session.ts#Session.touchIfNeeded`).
149
149
 
150
150
  **4. L'administration ne voit jamais un identifiant.** Un opérateur manipule une `ref`, HMAC tronqué
151
- non réversible produit par `computeSessionRef()` (`sessions-service.ts:100`) — comme la liste
151
+ non réversible produit par `computeSessionRef()` (`sessions-service.ts#computeSessionRef`) — comme la liste
152
152
  « appareils connectés » de GitHub ou Google montre une référence, jamais le jeton.
153
153
 
154
154
  Compromis assumé : l'état serveur suppose un store **partagé** dès qu'on passe à plusieurs pods
@@ -274,31 +274,31 @@ curl -sk -b /tmp/jar https://localhost:5152/panier
274
274
  Le cookie obtenu porte `__Host-`, `HttpOnly`, `Secure`, `SameSite=Lax`, `Path=/` et **aucun** `Domain` :
275
275
  c'est exactement ce qu'assert le banc d'intégration `session-runtime` (« Set-Cookie de session sur TLS »).
276
276
  Sur un transport en clair (port 5151), le préfixe `__Host-` est omis — le navigateur le rejetterait
277
- faute de `Secure` (`Context.getSessionCookieName()`, `Context.ts:733`).
277
+ faute de `Secure` (`Context.getSessionCookieName()`, `Context.ts#Context.getSessionCookieName`).
278
278
 
279
279
  ## ⚙️ Configuration
280
280
 
281
- Source unique des défauts : le schéma Zod `sessionSchema` (`config.ts:786`) et son sous-schéma
282
- `sessionCookieSchema` (`config.ts:752`).
283
-
284
- | Option | Type | Défaut | Effet |
285
- | ------------------- | ------- | ------------ | ---------------------------------------------------------------------------------------- |
286
- | `store` | string | `"auto"` | Backend de persistance — voir la résolution ci-dessous (`config.ts:795`). |
287
- | `name` | string | `"nodefony"` | Nom du cookie, préfixé `__Host-` selon `cookie.hostPrefix` (`config.ts:770`). |
288
- | `strictMode` | bool | `true` | Un identifiant inconnu du store est rejeté → session neuve (anti-fixation). |
289
- | `idleTimeoutS` | int ≥ 0 | `1800` | Inactivité max (30 min). `0` = pas d'expiration par inactivité (`config.ts:823`). |
290
- | `absoluteTimeoutS` | int ≥ 0 | `43200` | Âge max depuis la création (12 h), **jamais** prolongé. `0` = désactivé. |
291
- | `gcIntervalS` | int ≥ 0 | `600` | Période de purge des sessions expirées, hors requête. `0` = timer désarmé. |
292
- | `gcJitter` | bool | `true` | Décale le départ du GC par process (anti _thundering herd_ sur un store partagé). |
293
- | `refererCheck` | bool | `false` | Lie la session à l'hôte de création — ⚠️ court-circuite les expirations (voir plus bas). |
294
- | `cookie.maxAge` | int ≥ 0 | `0` | `0` = cookie de session (effacé à la fermeture du navigateur). |
295
- | `cookie.httpOnly` | bool | `true` | Inaccessible depuis JavaScript — anti-XSS. |
296
- | `cookie.secure` | bool | `true` | Envoyé sur TLS uniquement. |
297
- | `cookie.signed` | bool | `false` | Signe le cookie avec le secret HMAC du kernel. |
298
- | `cookie.hostPrefix` | enum | `"auto"` | `__Host-` : `auto` (sur TLS) \| `true` (toujours) \| `false` (jamais). |
281
+ Source unique des défauts : le schéma Zod `sessionSchema` (`config.ts#sessionSchema`) et son sous-schéma
282
+ `sessionCookieSchema` (`config.ts#sessionCookieSchema`).
283
+
284
+ | Option | Type | Défaut | Effet |
285
+ | ------------------- | ------- | ------------ | --------------------------------------------------------------------------------------------- |
286
+ | `store` | string | `"auto"` | Backend de persistance — voir la résolution ci-dessous (`config.ts#sessionCookieSchema`). |
287
+ | `name` | string | `"nodefony"` | Nom du cookie, préfixé `__Host-` selon `cookie.hostPrefix` (`config.ts#sessionCookieSchema`). |
288
+ | `strictMode` | bool | `true` | Un identifiant inconnu du store est rejeté → session neuve (anti-fixation). |
289
+ | `idleTimeoutS` | int ≥ 0 | `1800` | Inactivité max (30 min). `0` = pas d'expiration par inactivité (`config.ts#sessionSchema`). |
290
+ | `absoluteTimeoutS` | int ≥ 0 | `43200` | Âge max depuis la création (12 h), **jamais** prolongé. `0` = désactivé. |
291
+ | `gcIntervalS` | int ≥ 0 | `600` | Période de purge des sessions expirées, hors requête. `0` = timer désarmé. |
292
+ | `gcJitter` | bool | `true` | Décale le départ du GC par process (anti _thundering herd_ sur un store partagé). |
293
+ | `refererCheck` | bool | `false` | Lie la session à l'hôte de création — ⚠️ court-circuite les expirations (voir plus bas). |
294
+ | `cookie.maxAge` | int ≥ 0 | `0` | `0` = cookie de session (effacé à la fermeture du navigateur). |
295
+ | `cookie.httpOnly` | bool | `true` | Inaccessible depuis JavaScript — anti-XSS. |
296
+ | `cookie.secure` | bool | `true` | Envoyé sur TLS uniquement. |
297
+ | `cookie.signed` | bool | `false` | Signe le cookie avec le secret HMAC du kernel. |
298
+ | `cookie.hostPrefix` | enum | `"auto"` | `__Host-` : `auto` (sur TLS) \| `true` (toujours) \| `false` (jamais). |
299
299
 
300
300
  `SameSite` n'est pas dans ce bloc : il vient des options de cookie génériques, dont le défaut est
301
- `Lax` (`cookieDefaultSettings`, `cookie.ts:39`).
301
+ `Lax` (`cookieDefaultSettings`, `cookie.ts#cookieDefaultSettings`).
302
302
 
303
303
  > [!WARNING]
304
304
  > `idleTimeoutS: 0` **et** `absoluteTimeoutS: 0` désactivent les deux bornes : une session ne meurt
@@ -309,8 +309,8 @@ Source unique des défauts : le schéma Zod `sessionSchema` (`config.ts:786`) et
309
309
  ### Comment `store: "auto"` se résout au boot
310
310
 
311
311
  `auto` n'est pas un store : c'est une sentinelle résolue une fois, au boot, par `resolveAutoStore()`
312
- (`config/infra.ts:297`), puis journalisée. Elle suit **l'infra que tu as déclarée**, bornée aux stores
313
- réellement enregistrés (`SessionsService.initializeStorage()`, `sessions-service.ts:258`).
312
+ (`config/infra.ts#resolveAutoStore`), puis journalisée. Elle suit **l'infra que tu as déclarée**, bornée aux stores
313
+ réellement enregistrés (`SessionsService.initializeStorage()`, `sessions-service.ts#SessionsService.initializeStorage`).
314
314
 
315
315
  ```mermaid
316
316
  flowchart TD
@@ -331,13 +331,13 @@ Deux comportements à connaître :
331
331
 
332
332
  - **Sans aucune infra déclarée, on ne tombe pas en `memory`** : si `@nodefony/drizzle` est chargé, la
333
333
  session persiste en SQLite local — tes données survivent au redémarrage sans une ligne de config
334
- (`infra.ts:288`).
334
+ (`infra.ts#CONNECTED_STORES`).
335
335
  - **Un `store` explicite inconnu ne dégrade pas en silence** : en production le boot est **avorté**, en
336
- développement il y a repli `memory` **annoncé** en WARNING (`sessions-service.ts:252-273`).
336
+ développement il y a repli `memory` **annoncé** en WARNING (`sessions-service.ts#SessionsService`).
337
337
 
338
338
  ## 🗂️ Choisir son store
339
339
 
340
- Le contrat est unique — `ISessionStorage` (`ISession.ts:142`) — et **tous** les backends le portent.
340
+ Le contrat est unique — `ISessionStorage` (`ISession.ts#ISessionStorage`) — et **tous** les backends le portent.
341
341
  Ce qui change, c'est la topologie et la façon d'expirer.
342
342
 
343
343
  | Store | Où vit l'état | Multi-pod | Expiration idle | `total` admin | Quand le choisir |
@@ -351,53 +351,53 @@ Ce qui change, c'est la topologie et la façon d'expirer.
351
351
 
352
352
  ### `memory` — l'implémentation de référence
353
353
 
354
- Store built-in de `@nodefony/http`, enregistré d'office (`sessions-service.ts:913`). Les sessions vivent
354
+ Store built-in de `@nodefony/http`, enregistré d'office (`sessions-service.ts`). Les sessions vivent
355
355
  dans une `Map` du process : elles **disparaissent au redémarrage** et ne sont **pas partagées** entre
356
356
  pods — c'est un choix (mesurer le framework sans le goulot disque/SQL), pas une limite.
357
357
 
358
358
  Il porte quand même **toute** la sémantique du contrat : `createdAt` figé à la création, `updatedAt`
359
- rafraîchi par `MemorySessionStorage.touch()` (`MemorySessionStorage.ts:96`), purge sur les deux bornes
360
- par `gc(idleSeconds, absoluteSeconds)` (`MemorySessionStorage.ts:99`), pagination offset à `total` exact
361
- et tri déterministe (`MemorySessionStorage.listPage()`, `MemorySessionStorage.ts:161`).
359
+ rafraîchi par `MemorySessionStorage.touch()` (`MemorySessionStorage.ts#MemorySessionStorage.touch`), purge sur les deux bornes
360
+ par `gc(idleSeconds, absoluteSeconds)` (`MemorySessionStorage.ts#MemorySessionStorage.touch`), pagination offset à `total` exact
361
+ et tri déterministe (`MemorySessionStorage.listPage()`, `MemorySessionStorage.ts#MemorySessionStorage.listPage`).
362
362
 
363
363
  ### `drizzle` — SQL, le défaut persistant
364
364
 
365
365
  Une table `session`, une ligne par session, écrite en **UPSERT atomique** (`INSERT … ON CONFLICT DO
366
366
  UPDATE … RETURNING`) : une seule requête, aucune course entre insertion et mise à jour
367
- (`@nodefony/drizzle/nodefony/src/SessionStorage.ts:124`). Le `touch` est un simple
367
+ (`@nodefony/drizzle/nodefony/src/SessionStorage.ts#SessionStorage.write`). Le `touch` est un simple
368
368
  `UPDATE updatedAt` sur la clé primaire, sans réécrire le blob
369
- (`@nodefony/drizzle/nodefony/src/SessionStorage.ts:196`).
369
+ (`@nodefony/drizzle/nodefony/src/SessionStorage.ts#SessionStorage.gc`).
370
370
 
371
371
  Le GC supprime en **deux `DELETE` distincts** — idle puis absolute — plutôt qu'un `$or`, pour rester
372
372
  portable sur tous les adaptateurs `orm-core`
373
- (`@nodefony/drizzle/nodefony/src/SessionStorage.ts:166-188`). La pagination est native
373
+ (`@nodefony/drizzle/nodefony/src/SessionStorage.ts#SessionStorage`). La pagination est native
374
374
  (`LIMIT`/`OFFSET` + `COUNT`), ordonnée `updatedAt DESC` puis `session_id ASC` pour rester déterministe
375
- à horodatage égal (`@nodefony/drizzle/nodefony/src/SessionStorage.ts:252`).
375
+ à horodatage égal (`@nodefony/drizzle/nodefony/src/SessionStorage.ts#SessionStorage.#toRecord`).
376
376
 
377
377
  Détail à connaître : une session anonyme est stockée `user = NULL`, pas chaîne vide — le filtre le
378
- traduit (`$null`) au lieu de chercher `""` (`@nodefony/drizzle/nodefony/src/SessionStorage.ts:258-261`).
378
+ traduit (`$null`) au lieu de chercher `""` (`@nodefony/drizzle/nodefony/src/SessionStorage.ts#SessionStorage.#toRecord`).
379
379
 
380
380
  ### `redis` — TTL natif, zéro balayage
381
381
 
382
382
  Ici l'expiration **idle** est portée par Redis lui-même : `SET … EX` pose le TTL à chaque écriture
383
- (`@nodefony/redis/nodefony/src/SessionStorage.ts:137`) et `touch` le repositionne par un `EXPIRE` O(1)
384
- (`@nodefony/redis/nodefony/src/SessionStorage.ts:166`). Conséquence : `gc()` est un **no-op assumé**
385
- (`@nodefony/redis/nodefony/src/SessionStorage.ts:168`) — aucun balayage périodique.
383
+ (`@nodefony/redis/nodefony/src/SessionStorage.ts#RedisSessionStorage.touch`) et `touch` le repositionne par un `EXPIRE` O(1)
384
+ (`@nodefony/redis/nodefony/src/SessionStorage.ts#RedisSessionStorage.write`). Conséquence : `gc()` est un **no-op assumé**
385
+ (`@nodefony/redis/nodefony/src/SessionStorage.ts#RedisSessionStorage.write`) — aucun balayage périodique.
386
386
 
387
387
  L'absolute timeout, lui, n'est pas exprimable par un TTL glissant : il reste honoré **à la lecture**
388
- par `Session.isValidSession()` (`session.ts:371`). Une entrée trop vieille peut donc survivre côté
388
+ par `Session.isValidSession()` (`session.ts#Session.isValidSession`). Une entrée trop vieille peut donc survivre côté
389
389
  Redis jusqu'à son TTL idle, mais elle est **refusée à la reprise**.
390
390
 
391
391
  Capacités réduites, annoncées et non simulées : la pagination est **par curseur** (pas de `total`, pas
392
392
  d'ordre global) et `countSessions()` renvoie **`-1`** = « je ne sais pas »
393
- (`@nodefony/redis/nodefony/src/SessionStorage.ts:303`). L'appelant affiche l'inconnu, il ne l'invente pas.
393
+ (`@nodefony/redis/nodefony/src/SessionStorage.ts#RedisSessionStorage.countSessions`). L'appelant affiche l'inconnu, il ne l'invente pas.
394
394
 
395
395
  ### `mongoose` — MongoDB, parité de comportement
396
396
 
397
397
  Même sémantique que le store SQL : `findOneAndUpdate({ upsert: true })` en une passe
398
- (`@nodefony/mongoose/nodefony/src/SessionStorage.ts:195`), `touch` en `updateOne`
399
- (`@nodefony/mongoose/nodefony/src/SessionStorage.ts:174`), GC en deux suppressions `$lt`
400
- (`@nodefony/mongoose/nodefony/src/SessionStorage.ts:144-166`). Les horodatages sont des **nombres**
398
+ (`@nodefony/mongoose/nodefony/src/SessionStorage.ts#SessionStorage.write`), `touch` en `updateOne`
399
+ (`@nodefony/mongoose/nodefony/src/SessionStorage.ts#SessionStorage.gc`), GC en deux suppressions `$lt`
400
+ (`@nodefony/mongoose/nodefony/src/SessionStorage.ts#SessionStorage`). Les horodatages sont des **nombres**
401
401
  (epoch ms) et non des `Date` Mongo, précisément pour que le store reste interchangeable avec Drizzle.
402
402
 
403
403
  > [!TIP]
@@ -435,12 +435,12 @@ sequenceDiagram
435
435
  end
436
436
  ```
437
437
 
438
- **Reprise ou création.** `Session.start()` (`session.ts:144`) délègue à `getSession()` : cookie présent
439
- → `resume()` (`session.ts:177`), sinon `create()` (`session.ts:204`) qui tire un identifiant CSPRNG,
438
+ **Reprise ou création.** `Session.start()` (`session.ts#Session.start`) délègue à `getSession()` : cookie présent
439
+ → `resume()` (`session.ts#Session.resume`), sinon `create()` (`session.ts#Session.create`) qui tire un identifiant CSPRNG,
440
440
  pose le cookie et marque la session à persister.
441
441
 
442
- **Validation à la reprise.** `Session.isValidSession()` (`session.ts:371`) applique l'**absolute**
443
- (âge depuis `created`) puis l'**idle** (depuis `updated`). Échec → `invalidate()` (`session.ts:290`)
442
+ **Validation à la reprise.** `Session.isValidSession()` (`session.ts#Session.isValidSession`) applique l'**absolute**
443
+ (âge depuis `created`) puis l'**idle** (depuis `updated`). Échec → `invalidate()` (`session.ts#Session.invalidate`)
444
444
  détruit l'entrée et recrée une session vierge.
445
445
 
446
446
  > [!WARNING]
@@ -449,28 +449,28 @@ détruit l'entrée et recrée une session vierge.
449
449
  > **idle** ne sont alors **pas** évaluées. Tant que ce comportement n'est pas corrigé, ne pas
450
450
  > activer `refererCheck` quand l'expiration des sessions compte — c'est-à-dire presque toujours.
451
451
 
452
- **Écriture minimale.** `SessionsService.saveSession()` (`sessions-service.ts:463`) n'écrit **que** si la
453
- session est `dirty` et non `readOnly` ; sinon il appelle `Session.touchIfNeeded()` (`session.ts:421`),
452
+ **Écriture minimale.** `SessionsService.saveSession()` (`sessions-service.ts#SessionsService.saveSession`) n'écrit **que** si la
453
+ session est `dirty` et non `readOnly` ; sinon il appelle `Session.touchIfNeeded()` (`session.ts#Session.touchIfNeeded`),
454
454
  qui prolonge l'idle **sans réécrire le blob**, et seulement au-delà d'une demi-vie d'idle
455
- (`session.ts:445`). Une requête de lecture coûte donc au pire un `UPDATE` d'horodatage toutes les
455
+ (`session.ts#Session.touchIfNeeded`). Une requête de lecture coûte donc au pire un `UPDATE` d'horodatage toutes les
456
456
  15 minutes (défaut).
457
457
 
458
- **Anti-résurrection**, sur deux niveaux. `Session.destroy()` remet `mutated = false` (`session.ts:307`)
458
+ **Anti-résurrection**, sur deux niveaux. `Session.destroy()` remet `mutated = false` (`session.ts#Session.invalidate`)
459
459
  pour que la sauvegarde de fin de requête ne réécrive pas ce qu'on vient de supprimer. Surtout, **tout**
460
- store est décoré par `RevocationGuardStorage` (`sessions-service.ts:309`) : `destroy()` pose une
461
- **pierre tombale** de 5 minutes (`RevocationGuardStorage.ts:156`) qui refuse ensuite tout `write`
462
- (`RevocationGuardStorage.ts:163`) **et tout `touch`** (`RevocationGuardStorage.ts:130`) du même
460
+ store est décoré par `RevocationGuardStorage` (`sessions-service.ts#SessionsService.initializeStorage`) : `destroy()` pose une
461
+ **pierre tombale** de 5 minutes (`RevocationGuardStorage.ts#RevocationGuardStorage.destroy`) qui refuse ensuite tout `write`
462
+ (`RevocationGuardStorage.ts#RevocationGuardStorage.write`) **et tout `touch`** (`RevocationGuardStorage.ts#RevocationGuardStorage.touch`) du même
463
463
  identifiant — ce qui couvre la requête « en vol » d'un autre client.
464
464
 
465
465
  **Purge hors requête.** Un `GcScheduler` est armé au `onReady`, désarmé au `onTerminate`
466
- (`sessions-service.ts:284-313`). La passe métier nue, `SessionsService.runGc()`
467
- (`sessions-service.ts:496`), est publique exprès : un CronJob Kubernetes peut l'appeler à la place du
466
+ (`sessions-service.ts#SessionsService.initializeStorage`). La passe métier nue, `SessionsService.runGc()`
467
+ (`sessions-service.ts#SessionsService.runGc`), est publique exprès : un CronJob Kubernetes peut l'appeler à la place du
468
468
  timer (`gcIntervalS: 0`).
469
469
 
470
470
  ## Entités de persistance
471
471
 
472
472
  **Drizzle (SQL).** La table est décrite une seule fois en spec logique (`SESSION_TABLE_SPEC`,
473
- `sessionEntity.ts:25`) et déclinée par dialecte par `buildFrameworkTable()` (`colKit.ts:543`) — mêmes **noms** de
473
+ `sessionEntity.ts#SESSION_TABLE_SPEC`) et déclinée par dialecte par `buildFrameworkTable()` (`colKit.ts#buildFrameworkTable`) — mêmes **noms** de
474
474
  colonnes partout, donc un store dialect-agnostique.
475
475
 
476
476
  | Colonne | Type logique | SQLite | PostgreSQL | MySQL / MariaDB | Rôle |
@@ -485,15 +485,15 @@ colonnes partout, donc un store dialect-agnostique.
485
485
 
486
486
  En MySQL/MariaDB, une colonne texte indexée devient `varchar` (un `TEXT` InnoDB n'est pas indexable
487
487
  sans préfixe) et le type JSON passe par un type compatible qui tolère MariaDB, laquelle stocke le JSON
488
- en `LONGTEXT` (`colKit.ts:466-469`).
488
+ en `LONGTEXT` (`colKit.ts#mysqlColumn`).
489
489
 
490
- **Mongoose (MongoDB).** Schéma équivalent (`@nodefony/mongoose/nodefony/entity/sessionEntity.ts:16`) :
490
+ **Mongoose (MongoDB).** Schéma équivalent (`@nodefony/mongoose/nodefony/entity/sessionEntity.ts#schema`) :
491
491
  `session_id` (String, index **unique**), `Attributes`/`flashBag`/`metaBag` (Object, défaut `{}`), `user`
492
492
  (String, défaut `null`), `createdAt`/`updatedAt` (Number, ms).
493
493
 
494
494
  Le connecteur diffère volontairement entre les deux adaptateurs — `"default"` pour Drizzle
495
- (`sessionEntity.ts:11`), `"nodefony"` pour Mongoose
496
- (`@nodefony/mongoose/nodefony/entity/sessionEntity.ts:5`) — parce que le registre d'entités est
495
+ (`sessionEntity.ts#schema`), `"nodefony"` pour Mongoose
496
+ (`@nodefony/mongoose/nodefony/entity/sessionEntity.ts#SESSION_CONNECTOR`) — parce que le registre d'entités est
497
497
  partagé par processus : deux noms distincts évitent la collision quand les deux ORM cohabitent.
498
498
 
499
499
  ## 🔌 HTTP et WebSocket — la même session
@@ -503,17 +503,17 @@ C'est le différenciateur du framework appliqué à l'état de session : un seul
503
503
  <!-- prettier-ignore -->
504
504
  | Aspect | HTTP | WebSocket |
505
505
  | --- | --- | --- |
506
- | Ouverture | à chaque requête — `startSession()` dans `onRequestEnd()` (`http-kernel.ts:1697`) | **une fois** au handshake — `startSession()` dans `onConnect()` (`http-kernel.ts:2008`) |
507
- | Lecture du cookie | constructeur du contexte | constructeur, même nom effectif (`WebsocketContext.ts:172`) |
508
- | Sauvegarde | fin de requête | après **chaque frame** traitée (`WebsocketContext.ts:302`) |
509
- | Filet de fermeture | — | `once("onFinish")` sauve si non déjà fait (`http-kernel.ts:1801`) |
510
- | Portée ALS | une requête | **handshake + toutes les frames** (`http-kernel.ts:1495`) |
506
+ | Ouverture | à chaque requête — `startSession()` dans `onRequestEnd()` (`http-kernel.ts#HttpKernel.onRequestEnd`) | **une fois** au handshake — `startSession()` dans `onConnect()` (`http-kernel.ts#HttpKernel.onConnect`) |
507
+ | Lecture du cookie | constructeur du contexte | constructeur, même nom effectif (`WebsocketContext.ts#WebsocketContext.constructor`) |
508
+ | Sauvegarde | fin de requête | après **chaque frame** traitée (`WebsocketContext.ts#WebsocketContext.handle`) |
509
+ | Filet de fermeture | — | `once("onFinish")` sauve si non déjà fait (`http-kernel.ts#HttpKernel.runFirewall`) |
510
+ | Portée ALS | une requête | **handshake + toutes les frames** (`http-kernel.ts#HttpKernel.createHttpContext`) |
511
511
 
512
512
  La conséquence pratique la plus utile : côté WebSocket, la bulle `AsyncLocalStorage` ouverte au
513
- handshake par `RequestContext.run()` **enveloppe aussi les messages** (`http-kernel.ts:1929`). L'identité résolue une fois est donc
513
+ handshake par `RequestContext.run()` **enveloppe aussi les messages** (`http-kernel.ts#HttpKernel.configureRateLimit`). L'identité résolue une fois est donc
514
514
  disponible à chaque frame sans relire la base — c'est ce dont profite
515
- `FirewallRealtimeAuthenticator.supports()` (`FirewallRealtimeAuthenticator.ts:80`), câblé automatiquement
516
- par le firewall sur les zones temps réel protégées (`firewall.ts:297`).
515
+ `FirewallRealtimeAuthenticator.supports()` (`FirewallRealtimeAuthenticator.ts#FirewallRealtimeAuthenticator.supports`), câblé automatiquement
516
+ par le firewall sur les zones temps réel protégées (`firewall.ts#Firewall.#build`).
517
517
 
518
518
  > [!WARNING]
519
519
  > Rien à écrire dans `initialize()` : il n'existe **pas** de `Controller.startSession()`. La session WS
@@ -526,34 +526,34 @@ par le firewall sur les zones temps réel protégées (`firewall.ts:297`).
526
526
  ### Régénération d'identifiant à la connexion (anti-fixation)
527
527
 
528
528
  C'est la défense la plus importante et elle est **active**. `AuthFlow.#openSession()`
529
- (`authFlow.ts:378`) : reprise ou ouverture de la session, mémorisation de l'ancien identifiant, puis
530
- appel **inconditionnel** de `Session.regenerateId()` (`authFlow.ts:388`), et enfin destruction de
531
- l'ancienne entrée du store (`authFlow.ts:390`). Un cookie pré-posé par un attaquant **ne survit donc pas
529
+ (`authFlow.ts#AuthFlow.#openSession`) : reprise ou ouverture de la session, mémorisation de l'ancien identifiant, puis
530
+ appel **inconditionnel** de `Session.regenerateId()` (`authFlow.ts#AuthFlow.logout`), et enfin destruction de
531
+ l'ancienne entrée du store (`authFlow.ts#AuthFlow.logout`). Un cookie pré-posé par un attaquant **ne survit donc pas
532
532
  au login**. Le nouvel identifiant est un CSPRNG frais, l'état applicatif est conservé
533
- (`Session.regenerateId()`, `session.ts:236`).
533
+ (`Session.regenerateId()`, `session.ts#Session.regenerateId`).
534
534
 
535
- Au passage, la provenance est capturée dans le `metaBag` : `ip` (`authFlow.ts:405`) et `ua`
536
- (`authFlow.ts:407`), en mode « au mieux » — ce sont ces deux champs que la console d'administration
535
+ Au passage, la provenance est capturée dans le `metaBag` : `ip` (`authFlow.ts#AuthFlow.#openSession`) et `ua`
536
+ (`authFlow.ts#AuthFlow.logoutWithProvider`), en mode « au mieux » — ce sont ces deux champs que la console d'administration
537
537
  affiche.
538
538
 
539
539
  ### Révocation — immédiate et par construction
540
540
 
541
- | Surface | Méthode | Portée |
542
- | ------------------------ | ----------------------------------------------- | ------------------------------------------- |
543
- | Déconnexion locale | `Session.destroy()` (`session.ts:306`) | la session courante + pierre tombale |
544
- | Révocation par un admin | `destroyByRef()` (`sessions-service.ts:745`) | une session désignée par sa `ref` publique |
545
- | « Déconnecter partout » | `destroyByUser()` (`sessions-service.ts:775`) | toutes les sessions d'un utilisateur |
546
- | « Mes appareils » (self) | `destroyOwnByRef()` (`sessions-service.ts:872`) | une session, **restreinte au propriétaire** |
541
+ | Surface | Méthode | Portée |
542
+ | ------------------------ | --------------------------------------------------------------------------- | ------------------------------------------- |
543
+ | Déconnexion locale | `Session.destroy()` (`session.ts#Session.destroy`) | la session courante + pierre tombale |
544
+ | Révocation par un admin | `destroyByRef()` (`sessions-service.ts#SessionsService.destroyByRef`) | une session désignée par sa `ref` publique |
545
+ | « Déconnecter partout » | `destroyByUser()` (`sessions-service.ts#SessionsService.destroyByUser`) | toutes les sessions d'un utilisateur |
546
+ | « Mes appareils » (self) | `destroyOwnByRef()` (`sessions-service.ts#SessionsService.destroyOwnByRef`) | une session, **restreinte au propriétaire** |
547
547
 
548
548
  Deux finesses valent d'être connues.
549
549
 
550
550
  `destroyByUser()` ne fait pas un seul passage : il **repasse jusqu'à ce qu'un passage complet ne
551
- détruise plus rien** (`sessions-service.ts:775`), car supprimer en parcourant décale les rangs sous un
551
+ détruise plus rien** (`sessions-service.ts#SessionsService.destroyByUser`), car supprimer en parcourant décale les rangs sous un
552
552
  curseur offset. Une révocation « partout » qui en laisserait une n'est pas une imprécision, c'est une
553
553
  faille — on rend donc la main avec la preuve, pas l'espoir.
554
554
 
555
555
  `destroyOwnByRef()` ferme l'IDOR **par construction** : parcours restreint aux sessions du demandeur,
556
- et appartenance **re-vérifiée** avant même de comparer la `ref` (`sessions-service.ts:863`). Une
556
+ et appartenance **re-vérifiée** avant même de comparer la `ref` (`sessions-service.ts#SessionsService.destroyOwnByRef`). Une
557
557
  `ref` d'autrui est structurellement introuvable.
558
558
 
559
559
  ### Redaction — l'identifiant ne sort jamais du process
@@ -561,70 +561,70 @@ et appartenance **re-vérifiée** avant même de comparer la `ref` (`sessions-se
561
561
  Trois barrières superposées :
562
562
 
563
563
  1. Le contrat impose que `listPage()` rende `Attributes` et `flashBag` **vides**
564
- (`ISession.ts:203`) — les stores SQL/NoSQL ne les sélectionnent même pas.
565
- 2. La projection vers l'extérieur passe par `toSessionSummary()` (`sessions-service.ts:112`), bâtie en
564
+ (`ISession.ts#ISessionStorage.listPage`) — les stores SQL/NoSQL ne les sélectionnent même pas.
565
+ 2. La projection vers l'extérieur passe par `toSessionSummary()` (`sessions-service.ts#toSessionSummary`), bâtie en
566
566
  **liste blanche** : `ref`, `user`, `authenticated`, `ip`, `ua`, dates. Jamais un `delete` après coup.
567
567
  3. La `ref` elle-même est un HMAC tronqué non réversible (`computeSessionRef()`,
568
- `sessions-service.ts:100`) ; la clé est dérivée du certificat au boot et n'est jamais sérialisée
569
- (`SessionsService.sessionRef()`, `sessions-service.ts:564`).
568
+ `sessions-service.ts#computeSessionRef`) ; la clé est dérivée du certificat au boot et n'est jamais sérialisée
569
+ (`SessionsService.sessionRef()`, `sessions-service.ts#SessionsService.sessionRef`).
570
570
 
571
571
  ### Récapitulatif des défenses actives par défaut
572
572
 
573
- | Menace | Défense | Ancrage |
574
- | --------------------------------- | ------------------------------------------------- | -------------------------------------------------- |
575
- | Vol par script injecté (XSS) | `HttpOnly` | `sessionCookieSchema` (`config.ts:752`) |
576
- | Interception réseau | `Secure` + `__Host-` sur TLS | `getSessionCookieName()` (`Context.ts:733`) |
577
- | Requête inter-sites | `SameSite=Lax` par défaut | `cookieDefaultSettings` (`cookie.ts:39`) |
578
- | Fixation (cookie pré-posé) | `strictMode` + régénération au login | `Session.resume()` (`session.ts:177`) |
579
- | Identifiant deviné | 32 octets CSPRNG (43 caractères base64url) | `Session.generateId()` (`session.ts:231`) |
580
- | Session volée exploitée longtemps | absolute timeout, jamais prolongé | `absoluteTimeoutS` à la reprise (`session.ts:388`) |
581
- | Session oubliée ouverte | idle timeout glissant | `idleTimeoutS` à la reprise (`session.ts:401`) |
582
- | Résurrection après révocation | pierre tombale 5 min sur `write` **et** `touch` | `RevocationGuardStorage.ts:127-171` |
583
- | Fuite d'identifiant en admin | `ref` HMAC + projection en liste blanche | `toSessionSummary()` (`sessions-service.ts:112`) |
584
- | IDOR sur « mes sessions » | périmètre depuis l'identité ALS, jamais du client | `destroyOwnByRef()` (`sessions-service.ts:872`) |
573
+ | Menace | Défense | Ancrage |
574
+ | --------------------------------- | ------------------------------------------------- | --------------------------------------------------------------------------- |
575
+ | Vol par script injecté (XSS) | `HttpOnly` | `sessionCookieSchema` (`config.ts#sessionCookieSchema`) |
576
+ | Interception réseau | `Secure` + `__Host-` sur TLS | `getSessionCookieName()` (`Context.ts#Context.getSessionCookieName`) |
577
+ | Requête inter-sites | `SameSite=Lax` par défaut | `cookieDefaultSettings` (`cookie.ts#cookieDefaultSettings`) |
578
+ | Fixation (cookie pré-posé) | `strictMode` + régénération au login | `Session.resume()` (`session.ts#Session.resume`) |
579
+ | Identifiant deviné | 32 octets CSPRNG (43 caractères base64url) | `Session.generateId()` (`session.ts#Session.generateId`) |
580
+ | Session volée exploitée longtemps | absolute timeout, jamais prolongé | `absoluteTimeoutS` à la reprise (`session.ts#Session.isValidSession`) |
581
+ | Session oubliée ouverte | idle timeout glissant | `idleTimeoutS` à la reprise (`session.ts#Session.isValidSession`) |
582
+ | Résurrection après révocation | pierre tombale 5 min sur `write` **et** `touch` | `RevocationGuardStorage.ts#RevocationGuardStorage.touch` |
583
+ | Fuite d'identifiant en admin | `ref` HMAC + projection en liste blanche | `toSessionSummary()` (`sessions-service.ts#toSessionSummary`) |
584
+ | IDOR sur « mes sessions » | périmètre depuis l'identité ALS, jamais du client | `destroyOwnByRef()` (`sessions-service.ts#SessionsService.destroyOwnByRef`) |
585
585
 
586
586
  ## 🧰 API publique
587
587
 
588
588
  Les signatures vivent dans `.ai/symbols.json` (jamais recopiées ici). Voici les usages réels.
589
589
 
590
- **Depuis un contrôleur** — `this.session` est un getter sur le contexte (`Controller.ts:384`) ; un
590
+ **Depuis un contrôleur** — `this.session` est un getter sur le contexte (`Controller.ts#Controller.session`) ; un
591
591
  paramètre `@Session()` suffit à déclarer l'intent.
592
592
 
593
- | Besoin | Appel | Effet |
594
- | -------------------------------- | ------------------------------------ | ------------------------------------------------------- |
595
- | Lire une valeur | `session.get("panier")` | `null` si absente — jamais `undefined`. |
596
- | Écrire une valeur | `session.set("panier", items)` | Marque la session `dirty` → écriture en fin de requête. |
597
- | Message « une seule lecture » | `session.setFlashBag("notice", "…")` | Consommé (et effacé) au premier `getFlashBag`. |
598
- | Lire ce message | `session.getFlashBag("notice")` | Rend la valeur puis la supprime (`session.ts:530`). |
599
- | Métadonnée technique | `session.getMetaBag("ip")` | ip / ua / host / remoteAddress posés à la création. |
600
- | Se déconnecter | `await session.destroy(true)` | Détruit l'entrée store **et** efface le cookie. |
601
- | Renouveler l'identifiant | `session.regenerateId()` | Nouvel identifiant, état conservé (`session.ts:236`). |
602
- | Savoir si une écriture aura lieu | `session.dirty` | Le drapeau de dirty-tracking (`session.ts:128`). |
603
-
604
- **Intent de route** — `UseSession(options)` (`routerDecorators.ts:800`) s'applique à une classe **ou** à
593
+ | Besoin | Appel | Effet |
594
+ | -------------------------------- | ------------------------------------ | ---------------------------------------------------------------------- |
595
+ | Lire une valeur | `session.get("panier")` | `null` si absente — jamais `undefined`. |
596
+ | Écrire une valeur | `session.set("panier", items)` | Marque la session `dirty` → écriture en fin de requête. |
597
+ | Message « une seule lecture » | `session.setFlashBag("notice", "…")` | Consommé (et effacé) au premier `getFlashBag`. |
598
+ | Lire ce message | `session.getFlashBag("notice")` | Rend la valeur puis la supprime (`session.ts#Session.getFlashBag`). |
599
+ | Métadonnée technique | `session.getMetaBag("ip")` | ip / ua / host / remoteAddress posés à la création. |
600
+ | Se déconnecter | `await session.destroy(true)` | Détruit l'entrée store **et** efface le cookie. |
601
+ | Renouveler l'identifiant | `session.regenerateId()` | Nouvel identifiant, état conservé (`session.ts#Session.regenerateId`). |
602
+ | Savoir si une écriture aura lieu | `session.dirty` | Le drapeau de dirty-tracking (`session.ts#Session.dirty`). |
603
+
604
+ **Intent de route** — `UseSession(options)` (`routerDecorators.ts#UseSession`) s'applique à une classe **ou** à
605
605
  une méthode ; la méthode l'emporte, par fusion et non par remplacement
606
- (`resolveSessionIntent()`, `routerDecorators.ts:858`). **Une seule** option (`SessionIntent`,
607
- `ISession.ts:17`) :
606
+ (`resolveSessionIntent()`, `routerDecorators.ts#resolveSessionIntent`). **Une seule** option (`SessionIntent`,
607
+ `ISession.ts#SessionIntent`) :
608
608
 
609
609
  - `readOnly: true` — la session est reprise et lue mais **jamais** persistée ; une mutation tentée est
610
- journalisée en WARNING sans écriture (`Session.save()`, `session.ts:255-264`). C'est le seul champ
611
- propagé par le kernel (`http-kernel.ts:1210`).
610
+ journalisée en WARNING sans écriture (`Session.save()`, `session.ts#Session.save`). C'est le seul champ
611
+ propagé par le kernel (`http-kernel.ts#HttpKernel.initServers`).
612
612
 
613
- En décorateur de **classe**, `@UseSession` se place **sous** `@controller` (`routerDecorators.ts:189`).
613
+ En décorateur de **classe**, `@UseSession` se place **sous** `@controller` (`routerDecorators.ts#controller`).
614
614
 
615
615
  ## 🧩 Extension — brancher son propre store
616
616
 
617
617
  Le registre est une inversion de contrôle complète : `@nodefony/http` ne connaît **aucun** backend.
618
618
  Chaque module fournisseur se déclare lui-même au chargement, par
619
- `SessionsService.registerStorage(nom, ctor)` (`sessions-service.ts:196`) — exactement ce que fait la
620
- dernière ligne de chaque adaptateur (`@nodefony/redis/nodefony/src/SessionStorage.ts:323`).
619
+ `SessionsService.registerStorage(nom, ctor)` (`sessions-service.ts#SessionsService.registerStorage`) — exactement ce que fait la
620
+ dernière ligne de chaque adaptateur (`@nodefony/redis/nodefony/src/SessionStorage.ts#RedisSessionStorage.listAll`).
621
621
 
622
622
  Pour ajouter un backend :
623
623
 
624
- 1. Implémenter `ISessionStorage` (`ISession.ts:142`). Le **noyau obligatoire** est court :
624
+ 1. Implémenter `ISessionStorage` (`ISession.ts#ISessionStorage`). Le **noyau obligatoire** est court :
625
625
  `read`/`start`/`write`/`open`/`close`/`destroy`/`gc`.
626
- 2. Ajouter les capacités **optionnelles** utiles : `touch` (idle glissant sans réécriture, `ISession.ts:176`),
627
- `listPage` + `countSessions` (administration paginée, `ISession.ts:236`), `listAll` (dump).
626
+ 2. Ajouter les capacités **optionnelles** utiles : `touch` (idle glissant sans réécriture, `ISession.ts#ISessionStorage.touch`),
627
+ `listPage` + `countSessions` (administration paginée, `ISession.ts#ISessionStorage.countSessions`), `listAll` (dump).
628
628
  3. Appeler `SessionsService.registerStorage("mon-store", MonStore)` au chargement du module.
629
629
  4. Exécuter les bancs de contrat partagés (`sessionStoreContract.ts`, `sessionPaginationContract.ts`)
630
630
  contre l'implémentation — c'est ce qui garantit la parité.
@@ -632,41 +632,41 @@ Pour ajouter un backend :
632
632
  Trois règles de conception se dégagent du contrat, et méritent d'être respectées :
633
633
 
634
634
  - **Une capacité absente s'annonce.** Ne pas implémenter `listPage` fait répondre **501** à l'endpoint
635
- d'administration (refus honnête) plutôt qu'une liste vide trompeuse (`ISession.ts:221`).
635
+ d'administration (refus honnête) plutôt qu'une liste vide trompeuse (`ISession.ts#ISessionStorage.listPage`).
636
636
  - **On n'invente pas ce qu'on ignore.** `countSessions()` renvoie `-1` quand compter coûterait trop
637
- cher — Redis le fait (`ISession.ts:236`).
637
+ cher — Redis le fait (`ISession.ts#ISessionStorage.countSessions`).
638
638
  - **Une page ne matérialise jamais plus qu'une page.** C'est ce qui rend le coût d'une requête
639
639
  d'administration indépendant du nombre de sessions.
640
640
 
641
641
  ## 📜 Normes appliquées
642
642
 
643
- | Domaine | Norme | Comment le code s'y conforme |
644
- | ----------------------------- | ------------------------- | ----------------------------------------------------------------------------- |
645
- | Attributs et préfixes cookie | RFC 6265bis §4.1.3 | `__Host-` impose `Secure` + `Path=/`, interdit `Domain` (`cookie.ts:386-403`) |
646
- | Nom du cookie selon transport | RFC 6265bis / OWASP | `getSessionCookieName()` (`Context.ts:733`) |
647
- | Idle timeout | NIST SP 800-63B-4 / OWASP | défaut 1800 s, glissant par `touch` (`config.ts:831`) |
648
- | Absolute timeout | NIST SP 800-63B-4 / OWASP | défaut 43200 s, jamais prolongé (`config.ts:808`) |
649
- | Identifiant de session | OWASP Session Management | 32 octets CSPRNG, opaque (`session.ts:226`) |
650
- | Identifiant hors URL | OWASP Session Management | cookie uniquement — jamais de réécriture d'URL (`session.ts:20-26`) |
651
- | Renouvellement après auth | OWASP (anti-fixation) | `regenerateId()` inconditionnel au login (`authFlow.ts:388`) |
652
- | Révocation côté serveur | OWASP | pierre tombale générique (`RevocationGuardStorage.ts:121`) |
643
+ | Domaine | Norme | Comment le code s'y conforme |
644
+ | ----------------------------- | ------------------------- | ----------------------------------------------------------------------------------------- |
645
+ | Attributs et préfixes cookie | RFC 6265bis §4.1.3 | `__Host-` impose `Secure` + `Path=/`, interdit `Domain` (`cookie.ts#Cookie.domain`) |
646
+ | Nom du cookie selon transport | RFC 6265bis / OWASP | `getSessionCookieName()` (`Context.ts#Context.getSessionCookieName`) |
647
+ | Idle timeout | NIST SP 800-63B-4 / OWASP | défaut 1800 s, glissant par `touch` (`config.ts#sessionSchema`) |
648
+ | Absolute timeout | NIST SP 800-63B-4 / OWASP | défaut 43200 s, jamais prolongé (`config.ts#sessionSchema`) |
649
+ | Identifiant de session | OWASP Session Management | 32 octets CSPRNG, opaque (`session.ts#Session.create`) |
650
+ | Identifiant hors URL | OWASP Session Management | cookie uniquement — jamais de réécriture d'URL (`session.ts#OptionsSessionType`) |
651
+ | Renouvellement après auth | OWASP (anti-fixation) | `regenerateId()` inconditionnel au login (`authFlow.ts#AuthFlow.#openSession`) |
652
+ | Révocation côté serveur | OWASP | pierre tombale générique (`RevocationGuardStorage.ts#RevocationGuardStorage.constructor`) |
653
653
 
654
654
  ## ⚡ Performance & mémoire
655
655
 
656
656
  Le coût d'une session est **payé seulement quand elle sert** :
657
657
 
658
658
  - **Zéro par défaut** — sans intent ni cookie, `startSession()` sort immédiatement
659
- (`http-kernel.ts:1246`) : ni objet `Session`, ni lecture de store.
660
- - **Objet léger** — trois sacs `{}` à plat, pas de container DI par session (`session.ts:100-104`).
661
- - **Zéro écriture en lecture** — le dirty-tracking court-circuite `save()` (`session.ts:255`) ; le
662
- `touch` est throttlé à une écriture par demi-vie d'idle (`session.ts:445`).
659
+ (`http-kernel.ts#HttpKernel.startSession`) : ni objet `Session`, ni lecture de store.
660
+ - **Objet léger** — trois sacs `{}` à plat, pas de container DI par session (`session.ts#Session`).
661
+ - **Zéro écriture en lecture** — le dirty-tracking court-circuite `save()` (`session.ts#Session.save`) ; le
662
+ `touch` est throttlé à une écriture par demi-vie d'idle (`session.ts#Session.touchIfNeeded`).
663
663
  - **GC hors requête** — timer déterministe avec jitter par process, à la place du tirage
664
- probabiliste hérité de PHP (`sessions-service.ts:306-312`).
664
+ probabiliste hérité de PHP (`sessions-service.ts#SessionsService.initializeStorage`).
665
665
  - **Révocation quasi gratuite** — la `Map` de pierres tombales est **paresseuse** : sans révocation,
666
666
  `write` ne paie qu'une comparaison `=== null`, sans même un `Date.now()`
667
- (`RevocationGuardStorage.ts:181-186`).
667
+ (`RevocationGuardStorage.ts#RevocationGuardStorage.#isRevoked`).
668
668
  - **Administration bornée** — jamais plus de `SCAN_PAGE = 200` enregistrements en mémoire
669
- (`sessions-service.ts:75`), garde-fou à 5 000 pages (`sessions-service.ts:83`), parcours interrompu
669
+ (`sessions-service.ts#SCAN_PAGE`), garde-fou à 5 000 pages (`sessions-service.ts#MAX_ADMIN_PAGES`), parcours interrompu
670
670
  **journalisé**.
671
671
 
672
672
  Le banc `session-load.test.ts` verrouille ces propriétés sur serveur réel (200 sessions HTTP, 100
@@ -676,25 +676,25 @@ plus un plafond de croissance du tas.
676
676
  > [!NOTE]
677
677
  > Les trois sacs sont des objets **littéraux** et non `Object.create(null)`. C'est délibéré :
678
678
  > `drizzle-orm` déréférence le prototype via `is()`, et un objet sans prototype ferait échouer
679
- > l'écriture (`session.ts:95-98`).
679
+ > l'écriture (`session.ts#Session.attributesBag`).
680
680
 
681
681
  ## 📡 Observabilité — Studio
682
682
 
683
- **Data plane** — `createHttpAdminApi()` (`HttpAdminApi.ts:141`) expose la surface d'administration sous
683
+ **Data plane** — `createHttpAdminApi()` (`HttpAdminApi.ts#createHttpAdminApi`) expose la surface d'administration sous
684
684
  `/nodefony/http/api/` :
685
685
 
686
- | Route | Verbe | Accès | Rôle |
687
- | ----------------------------------- | ----- | ----------------------- | -------------------------------------------------- |
688
- | `sessions` | GET | — | état du sous-système (`HttpAdminApi.ts:280`) |
689
- | `sessions/list` | GET | `ROLE_NODEFONY_ADMIN` | page de sessions redactées (`HttpAdminApi.ts:324`) |
690
- | `sessions/{ref}/revoke` | POST | `ROLE_NODEFONY_ADMIN` | révoquer une session (`HttpAdminApi.ts:380`) |
691
- | `sessions/revoke-user/{identifier}` | POST | `ROLE_NODEFONY_ADMIN` | déconnecter partout (`HttpAdminApi.ts:415`) |
692
- | `sessions/mine` | GET | utilisateur authentifié | « mes appareils » (`HttpAdminApi.ts:458`) |
693
- | `sessions/mine/{ref}/revoke` | POST | utilisateur authentifié | fermer une de mes sessions (`HttpAdminApi.ts:514`) |
686
+ | Route | Verbe | Accès | Rôle |
687
+ | ----------------------------------- | ----- | ----------------------- | ----------------------------------------------------------------- |
688
+ | `sessions` | GET | — | état du sous-système (`HttpAdminApi.ts#createHttpAdminApi`) |
689
+ | `sessions/list` | GET | `ROLE_NODEFONY_ADMIN` | page de sessions redactées (`HttpAdminApi.ts#createHttpAdminApi`) |
690
+ | `sessions/{ref}/revoke` | POST | `ROLE_NODEFONY_ADMIN` | révoquer une session (`HttpAdminApi.ts#createHttpAdminApi`) |
691
+ | `sessions/revoke-user/{identifier}` | POST | `ROLE_NODEFONY_ADMIN` | déconnecter partout (`HttpAdminApi.ts#createHttpAdminApi`) |
692
+ | `sessions/mine` | GET | utilisateur authentifié | « mes appareils » (`HttpAdminApi.ts#createHttpAdminApi`) |
693
+ | `sessions/mine/{ref}/revoke` | POST | utilisateur authentifié | fermer une de mes sessions (`HttpAdminApi.ts#createHttpAdminApi`) |
694
694
 
695
695
  Les deux routes `mine` ne demandent pas de rôle, mais **ne sont pas anonymes** : la zone firewall des
696
696
  API d'administration n'accepte que l'authenticator `session` (pas d'`anonymous`), et le périmètre est
697
- pris sur l'identité ALS, jamais sur un paramètre client (`HttpAdminApi.ts:451-457`).
697
+ pris sur l'identité ALS, jamais sur un paramètre client (`HttpAdminApi.ts#createHttpAdminApi`).
698
698
 
699
699
  Chaque ligne rendue porte **`current`** — vrai pour LA session qui a émis la requête, et pour elle
700
700
  seule. C'est le « cet appareil » des consoles d'appareils connectés, et le client ne peut pas le
@@ -705,31 +705,31 @@ dérivation est faite une fois par page (`sessions-service.ts` `currentSessionRe
705
705
  requête ne porte pas de session (appel interne, invocation CLI).
706
706
 
707
707
  Codes de réponse à connaître : **501** si le store courant ne sait pas s'énumérer
708
- (`supportsEnumeration()` faux — `HttpAdminApi.ts:62`), **503** si le service de session est absent, **404** pour une `ref` inconnue ou
708
+ (`supportsEnumeration()` faux — `HttpAdminApi.ts#SessionsAdmin.supportsEnumeration`), **503** si le service de session est absent, **404** pour une `ref` inconnue ou
709
709
  une révocation sans effet, **401** sur `mine` sans identité.
710
710
 
711
711
  **Écrans** — la page **Sessions** (`/nodefony/sessions`) de Studio liste les sessions vivantes par `ref` et permet la
712
712
  révocation unitaire ou en masse (`@nodefony/studio/frontend/src/routes/sessions/`). L'écran **Stores**
713
713
  affiche le backend réellement résolu, sa provenance et son emplacement physique : ces informations sont
714
- publiées au boot par `registerStoreResolution()` (`sessions-service.ts:321`), avec le chemin du fichier
714
+ publiées au boot par `registerStoreResolution()` (`sessions-service.ts#SessionsService.initializeStorage`), avec le chemin du fichier
715
715
  SQLite quand c'est pertinent (`SessionStorage.location`,
716
- `@nodefony/drizzle/nodefony/src/SessionStorage.ts:55`).
716
+ `@nodefony/drizzle/nodefony/src/SessionStorage.ts#SessionStorage.location`).
717
717
 
718
718
  ## ⚠️ Pièges (symptôme → cause → correction)
719
719
 
720
- | Symptôme | Cause | Correction |
721
- | ------------------------------------------------------ | ------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
722
- | Aucun `Set-Cookie`, `session` toujours vide | La route n'a **aucun intent** : ni `@UseSession`, ni paramètre `@Session` | Ajouter l'un des deux — l'activation est paresseuse (`http-kernel.ts:1283`) |
723
- | `context.session` est `null` dans un contrôleur WS | Intent posé sur la classe au lieu de la route, ou absent | `@UseSession()` **sur la route** WebSocket ; `Controller.startSession()` n'existe plus |
724
- | Mutation ignorée, WARNING « READONLY SESSION mutated » | La route est en `@UseSession({ readOnly: true })` | Retirer `readOnly` sur les routes qui écrivent (`session.ts:255-264`) |
725
- | Session détruite qui « revient » après logout | Une requête en vol réécrit le blob supprimé | Déjà couvert : pierre tombale 5 min (`RevocationGuardStorage.ts:121`) |
726
- | Sessions perdues à chaque redémarrage ou entre pods | Store `memory` (volatil, per-pod) | Déclarer une infra (`NF_DATABASE_URL`/`NF_REDIS_URL`) ou nommer le store |
727
- | Le boot s'arrête sur « session store … inconnu » | Nom de store explicite non enregistré, en production | Charger le module fournisseur, ou corriger le nom (`sessions-service.ts:258-262`) |
728
- | Total des sessions affiché « inconnu » en admin | Store Redis : compter coûterait un `SCAN` complet | Comportement voulu — `countSessions()` rend `-1`, on n'invente pas |
729
- | Liste admin en 501 | Le store n'implémente pas `listPage` | Refus honnête ; utiliser un store énumérable pour l'administration |
730
- | Cookie sans `__Host-` en développement | Transport en clair : le navigateur rejetterait le préfixe | Normal en `http://` ; forcer avec `cookie.hostPrefix: true` derrière un proxy TLS |
731
- | Session qui n'expire jamais malgré l'inactivité | `idleTimeoutS: 0` (et/ou `absoluteTimeoutS: 0`) | Garder les défauts NIST ; l'absolute borne l'âge même sous activité |
732
- | 500 pendant l'arrêt du serveur, requête en vol | L'ORM se déconnecte avant le drain des serveurs | Dégradé gracieusement : le repository rend `null` (`@nodefony/drizzle/nodefony/src/SessionStorage.ts:65`) |
720
+ | Symptôme | Cause | Correction |
721
+ | ------------------------------------------------------ | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
722
+ | Aucun `Set-Cookie`, `session` toujours vide | La route n'a **aucun intent** : ni `@UseSession`, ni paramètre `@Session` | Ajouter l'un des deux — l'activation est paresseuse (`http-kernel.ts#HttpKernel.publishRuntimePorts`) |
723
+ | `context.session` est `null` dans un contrôleur WS | Intent posé sur la classe au lieu de la route, ou absent | `@UseSession()` **sur la route** WebSocket ; `Controller.startSession()` n'existe plus |
724
+ | Mutation ignorée, WARNING « READONLY SESSION mutated » | La route est en `@UseSession({ readOnly: true })` | Retirer `readOnly` sur les routes qui écrivent (`session.ts#Session.readOnly`) |
725
+ | Session détruite qui « revient » après logout | Une requête en vol réécrit le blob supprimé | Déjà couvert : pierre tombale 5 min (`RevocationGuardStorage.ts#RevocationGuardStorage.constructor`) |
726
+ | Sessions perdues à chaque redémarrage ou entre pods | Store `memory` (volatil, per-pod) | Déclarer une infra (`NF_DATABASE_URL`/`NF_REDIS_URL`) ou nommer le store |
727
+ | Le boot s'arrête sur « session store … inconnu » | Nom de store explicite non enregistré, en production | Charger le module fournisseur, ou corriger le nom (`sessions-service.ts#SessionsService`) |
728
+ | Total des sessions affiché « inconnu » en admin | Store Redis : compter coûterait un `SCAN` complet | Comportement voulu — `countSessions()` rend `-1`, on n'invente pas |
729
+ | Liste admin en 501 | Le store n'implémente pas `listPage` | Refus honnête ; utiliser un store énumérable pour l'administration |
730
+ | Cookie sans `__Host-` en développement | Transport en clair : le navigateur rejetterait le préfixe | Normal en `http://` ; forcer avec `cookie.hostPrefix: true` derrière un proxy TLS |
731
+ | Session qui n'expire jamais malgré l'inactivité | `idleTimeoutS: 0` (et/ou `absoluteTimeoutS: 0`) | Garder les défauts NIST ; l'absolute borne l'âge même sous activité |
732
+ | 500 pendant l'arrêt du serveur, requête en vol | L'ORM se déconnecte avant le drain des serveurs | Dégradé gracieusement : le repository rend `null` (`@nodefony/drizzle/nodefony/src/SessionStorage.ts#SessionStorage.connector`) |
733
733
 
734
734
  ## 🧪 Tests & couverture
735
735
 
@@ -753,7 +753,7 @@ depuis vitest, jamais figés ici.
753
753
  > [!CAUTION]
754
754
  > Les suites E2E se **skippent** sans leurs variables d'infra, et un skip compte comme vert. Avant de
755
755
  > conclure « tout passe » sur les dialectes PostgreSQL/MySQL, vérifier que `NF_PG_URL`/`NF_MYSQL_URL`
756
- > étaient bien posées (source unique : `vitest.gates.ts` à la racine).
756
+ > étaient bien posées (source unique : `scripts/test/vitest/gates.ts` à la racine).
757
757
 
758
758
  Skills utiles : `nodefony-load-test` (rejouer ou étendre la charge), `nodefony-check-memory-health`
759
759
  (gate mémoire), `nodefony-security-review` (fixation, timeouts, révocation).