@nodefony/http 10.0.0-alpha.1

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 (155) hide show
  1. package/LICENSE +544 -0
  2. package/README.md +77 -0
  3. package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorate.js +9 -0
  4. package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateMetadata.js +6 -0
  5. package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateParam.js +8 -0
  6. package/dist/index.js +108 -0
  7. package/dist/nodefony/command/assetsPublishCommand.js +102 -0
  8. package/dist/nodefony/command/certificatesCommand.js +47 -0
  9. package/dist/nodefony/command/networkCommand.js +27 -0
  10. package/dist/nodefony/command/proxyGenerateCommand.js +66 -0
  11. package/dist/nodefony/config/config.js +335 -0
  12. package/dist/nodefony/config/defineModuleConfig.js +93 -0
  13. package/dist/nodefony/interfaces/IContext.js +1 -0
  14. package/dist/nodefony/interfaces/ICookie.js +1 -0
  15. package/dist/nodefony/interfaces/IErrorRenderer.js +1 -0
  16. package/dist/nodefony/interfaces/IHttpConfig.js +1 -0
  17. package/dist/nodefony/interfaces/IHttpKernel.js +1 -0
  18. package/dist/nodefony/interfaces/IRequest.js +1 -0
  19. package/dist/nodefony/interfaces/IRequestLogger.js +1 -0
  20. package/dist/nodefony/interfaces/IResponse.js +1 -0
  21. package/dist/nodefony/interfaces/ISession.js +1 -0
  22. package/dist/nodefony/interfaces/IUpload.js +1 -0
  23. package/dist/nodefony/interfaces/index.js +1 -0
  24. package/dist/nodefony/service/HttpAdminApi.js +376 -0
  25. package/dist/nodefony/service/ProfilerAdminApi.js +73 -0
  26. package/dist/nodefony/service/audit-logger.js +159 -0
  27. package/dist/nodefony/service/certificates.js +545 -0
  28. package/dist/nodefony/service/error-renderer.js +320 -0
  29. package/dist/nodefony/service/http-kernel.js +948 -0
  30. package/dist/nodefony/service/pretty-request-logger.js +72 -0
  31. package/dist/nodefony/service/request-logger.js +54 -0
  32. package/dist/nodefony/service/servers/clientError.js +20 -0
  33. package/dist/nodefony/service/servers/server-http.js +135 -0
  34. package/dist/nodefony/service/servers/server-https.js +204 -0
  35. package/dist/nodefony/service/servers/server-static.js +192 -0
  36. package/dist/nodefony/service/servers/server-websocket-secure.js +104 -0
  37. package/dist/nodefony/service/servers/server-websocket.js +104 -0
  38. package/dist/nodefony/service/servers/serverShutdown.js +31 -0
  39. package/dist/nodefony/service/servers/wsHeartbeat.js +64 -0
  40. package/dist/nodefony/service/sessions/sessions-service.js +580 -0
  41. package/dist/nodefony/service/trace.js +72 -0
  42. package/dist/nodefony/service/upload/upload-service.js +171 -0
  43. package/dist/nodefony/src/assets/collectAssets.js +34 -0
  44. package/dist/nodefony/src/assets/prebuiltUi.js +125 -0
  45. package/dist/nodefony/src/context/Context.js +415 -0
  46. package/dist/nodefony/src/context/domainMatcher.js +88 -0
  47. package/dist/nodefony/src/context/forwarded.js +185 -0
  48. package/dist/nodefony/src/context/http/HttpContext.js +309 -0
  49. package/dist/nodefony/src/context/http/Request.js +543 -0
  50. package/dist/nodefony/src/context/http/Response.js +368 -0
  51. package/dist/nodefony/src/context/http/parser.js +188 -0
  52. package/dist/nodefony/src/context/http/urlFastPath.js +103 -0
  53. package/dist/nodefony/src/context/http2/Request.js +29 -0
  54. package/dist/nodefony/src/context/http2/Response.js +97 -0
  55. package/dist/nodefony/src/context/metaData.js +47 -0
  56. package/dist/nodefony/src/context/requestId.js +41 -0
  57. package/dist/nodefony/src/context/trustProxy.js +167 -0
  58. package/dist/nodefony/src/context/websocket/Response.js +181 -0
  59. package/dist/nodefony/src/context/websocket/WebsocketContext.js +389 -0
  60. package/dist/nodefony/src/context/websocket/wsBackpressure.js +56 -0
  61. package/dist/nodefony/src/context/websocket/wsLogContent.js +68 -0
  62. package/dist/nodefony/src/cookies/cookie.js +258 -0
  63. package/dist/nodefony/src/errors/httpError.js +69 -0
  64. package/dist/nodefony/src/profiler/FrameProfile.js +95 -0
  65. package/dist/nodefony/src/profiler/Profiler.js +139 -0
  66. package/dist/nodefony/src/proxy/generateProxyConfig.js +157 -0
  67. package/dist/nodefony/src/rateLimit/IRateLimitStore.js +1 -0
  68. package/dist/nodefony/src/rateLimit/MemoryRateLimitStore.js +146 -0
  69. package/dist/nodefony/src/rateLimit/WsConnectionCounter.js +64 -0
  70. package/dist/nodefony/src/rateLimit/rateLimitFilters.js +20 -0
  71. package/dist/nodefony/src/servers/portBinder.js +114 -0
  72. package/dist/nodefony/src/session/session.js +390 -0
  73. package/dist/nodefony/src/session/storage/MemorySessionStorage.js +185 -0
  74. package/dist/nodefony/src/session/storage/RevocationGuardStorage.js +137 -0
  75. package/dist/nodefony/src/session/storage/sessionFilters.js +83 -0
  76. package/dist/nodefony/src/session/storage/sessionSort.js +53 -0
  77. package/dist/types/index.d.ts +83 -0
  78. package/dist/types/nodefony/command/assetsPublishCommand.d.ts +23 -0
  79. package/dist/types/nodefony/command/certificatesCommand.d.ts +17 -0
  80. package/dist/types/nodefony/command/networkCommand.d.ts +8 -0
  81. package/dist/types/nodefony/command/proxyGenerateCommand.d.ts +19 -0
  82. package/dist/types/nodefony/config/config.d.ts +197 -0
  83. package/dist/types/nodefony/config/defineModuleConfig.d.ts +39 -0
  84. package/dist/types/nodefony/interfaces/IContext.d.ts +138 -0
  85. package/dist/types/nodefony/interfaces/ICookie.d.ts +47 -0
  86. package/dist/types/nodefony/interfaces/IErrorRenderer.d.ts +55 -0
  87. package/dist/types/nodefony/interfaces/IHttpConfig.d.ts +12 -0
  88. package/dist/types/nodefony/interfaces/IHttpKernel.d.ts +10 -0
  89. package/dist/types/nodefony/interfaces/IRequest.d.ts +35 -0
  90. package/dist/types/nodefony/interfaces/IRequestLogger.d.ts +31 -0
  91. package/dist/types/nodefony/interfaces/IResponse.d.ts +39 -0
  92. package/dist/types/nodefony/interfaces/ISession.d.ts +283 -0
  93. package/dist/types/nodefony/interfaces/IUpload.d.ts +66 -0
  94. package/dist/types/nodefony/interfaces/index.d.ts +7 -0
  95. package/dist/types/nodefony/service/HttpAdminApi.d.ts +18 -0
  96. package/dist/types/nodefony/service/ProfilerAdminApi.d.ts +23 -0
  97. package/dist/types/nodefony/service/audit-logger.d.ts +143 -0
  98. package/dist/types/nodefony/service/certificates.d.ts +246 -0
  99. package/dist/types/nodefony/service/error-renderer.d.ts +74 -0
  100. package/dist/types/nodefony/service/http-kernel.d.ts +377 -0
  101. package/dist/types/nodefony/service/pretty-request-logger.d.ts +25 -0
  102. package/dist/types/nodefony/service/request-logger.d.ts +18 -0
  103. package/dist/types/nodefony/service/servers/clientError.d.ts +14 -0
  104. package/dist/types/nodefony/service/servers/server-http.d.ts +42 -0
  105. package/dist/types/nodefony/service/servers/server-https.d.ts +41 -0
  106. package/dist/types/nodefony/service/servers/server-static.d.ts +62 -0
  107. package/dist/types/nodefony/service/servers/server-websocket-secure.d.ts +29 -0
  108. package/dist/types/nodefony/service/servers/server-websocket.d.ts +29 -0
  109. package/dist/types/nodefony/service/servers/serverShutdown.d.ts +27 -0
  110. package/dist/types/nodefony/service/servers/wsHeartbeat.d.ts +46 -0
  111. package/dist/types/nodefony/service/sessions/sessions-service.d.ts +218 -0
  112. package/dist/types/nodefony/service/trace.d.ts +39 -0
  113. package/dist/types/nodefony/service/upload/upload-service.d.ts +61 -0
  114. package/dist/types/nodefony/src/assets/collectAssets.d.ts +35 -0
  115. package/dist/types/nodefony/src/assets/prebuiltUi.d.ts +99 -0
  116. package/dist/types/nodefony/src/context/Context.d.ts +195 -0
  117. package/dist/types/nodefony/src/context/domainMatcher.d.ts +67 -0
  118. package/dist/types/nodefony/src/context/forwarded.d.ts +95 -0
  119. package/dist/types/nodefony/src/context/http/HttpContext.d.ts +85 -0
  120. package/dist/types/nodefony/src/context/http/Request.d.ts +203 -0
  121. package/dist/types/nodefony/src/context/http/Response.d.ts +68 -0
  122. package/dist/types/nodefony/src/context/http/parser.d.ts +65 -0
  123. package/dist/types/nodefony/src/context/http/urlFastPath.d.ts +52 -0
  124. package/dist/types/nodefony/src/context/http2/Request.d.ts +14 -0
  125. package/dist/types/nodefony/src/context/http2/Response.d.ts +20 -0
  126. package/dist/types/nodefony/src/context/metaData.d.ts +58 -0
  127. package/dist/types/nodefony/src/context/requestId.d.ts +28 -0
  128. package/dist/types/nodefony/src/context/trustProxy.d.ts +77 -0
  129. package/dist/types/nodefony/src/context/websocket/Response.d.ts +53 -0
  130. package/dist/types/nodefony/src/context/websocket/WebsocketContext.d.ts +125 -0
  131. package/dist/types/nodefony/src/context/websocket/wsBackpressure.d.ts +73 -0
  132. package/dist/types/nodefony/src/context/websocket/wsLogContent.d.ts +37 -0
  133. package/dist/types/nodefony/src/cookies/cookie.d.ts +88 -0
  134. package/dist/types/nodefony/src/errors/httpError.d.ts +15 -0
  135. package/dist/types/nodefony/src/profiler/FrameProfile.d.ts +110 -0
  136. package/dist/types/nodefony/src/profiler/Profiler.d.ts +192 -0
  137. package/dist/types/nodefony/src/proxy/generateProxyConfig.d.ts +76 -0
  138. package/dist/types/nodefony/src/rateLimit/IRateLimitStore.d.ts +98 -0
  139. package/dist/types/nodefony/src/rateLimit/MemoryRateLimitStore.d.ts +40 -0
  140. package/dist/types/nodefony/src/rateLimit/WsConnectionCounter.d.ts +37 -0
  141. package/dist/types/nodefony/src/rateLimit/rateLimitFilters.d.ts +18 -0
  142. package/dist/types/nodefony/src/servers/portBinder.d.ts +102 -0
  143. package/dist/types/nodefony/src/session/session.d.ts +171 -0
  144. package/dist/types/nodefony/src/session/storage/MemorySessionStorage.d.ts +77 -0
  145. package/dist/types/nodefony/src/session/storage/RevocationGuardStorage.d.ts +81 -0
  146. package/dist/types/nodefony/src/session/storage/sessionFilters.d.ts +102 -0
  147. package/dist/types/nodefony/src/session/storage/sessionSort.d.ts +45 -0
  148. package/docs/cookies.md +365 -0
  149. package/docs/index.md +163 -0
  150. package/docs/observabilite.md +460 -0
  151. package/docs/rate-limit.md +372 -0
  152. package/docs/servers.md +935 -0
  153. package/docs/session.md +768 -0
  154. package/docs/upload.md +460 -0
  155. package/package.json +101 -0
@@ -0,0 +1,768 @@
1
+ ---
2
+ title: "Sessions — l'état serveur qui recolle les requêtes"
3
+ navTitle: Sessions
4
+ lang: fr
5
+ module: "@nodefony/http"
6
+ topic: session
7
+ section: "Cœur runtime"
8
+ audience: [developer]
9
+ tags:
10
+ [
11
+ session,
12
+ cookie,
13
+ securite,
14
+ http,
15
+ websocket,
16
+ store,
17
+ nist,
18
+ owasp,
19
+ revocation,
20
+ pagination,
21
+ ]
22
+ version: "doc"
23
+ status: stable
24
+ updated: 2026-07-19
25
+ source: "src/packages/@nodefony/http/docs/session.md"
26
+ coverageModule: http
27
+ coverageFiles: session/session.ts,sessions-service.ts
28
+ ---
29
+
30
+ # Sessions — l'état serveur qui recolle les requêtes
31
+
32
+ > HTTP n'a pas de mémoire : chaque requête arrive anonyme. Une session recolle ces requêtes à un même
33
+ > utilisateur au moyen d'un **identifiant opaque** porté par un cookie, tout l'état restant côté
34
+ > serveur. Nodefony fait vivre **la même session en HTTP et en WebSocket**, ne l'ouvre que si une route
35
+ > la demande, et applique par défaut les deux bornes de temps NIST/OWASP. Chaque fait de cette page est
36
+ > ancré sur le code.
37
+
38
+ 📍 [Documentation](../../../../../docs/index.md) › [@nodefony/http](index.md) › **Sessions**
39
+
40
+ ## 🧠 Le modèle mental — un ticket de vestiaire, pas un coffre
41
+
42
+ Le cookie de session est un **ticket de vestiaire** : un numéro, rien d'autre. Il ne contient pas ton
43
+ manteau, il permet juste de le retrouver. Le vestiaire — le _store_ — est côté serveur.
44
+
45
+ Trois conséquences que tout le reste de la page décline :
46
+
47
+ 1. **Voler le ticket suffit** pour repartir avec le manteau → le ticket se protège (`HttpOnly`,
48
+ `Secure`, `__Host-`) et se **périme** (idle + absolute).
49
+ 2. **Le vestiaire peut déchirer un ticket** à tout moment → la révocation est immédiate et centrale,
50
+ pas une négociation avec le client.
51
+ 3. **Le contenu ne voyage jamais** → un cookie Nodefony ne porte ni données, ni jeton signé, ni JWT.
52
+
53
+ ```mermaid
54
+ flowchart TD
55
+ R["Requête HTTP ou WS"] --> I{"intent de route ?<br/>@UseSession / @Session<br/>ou cookie déjà présent"}
56
+ I -->|non| SKIP["aucune session<br/>0 lecture, 0 Set-Cookie"]
57
+ I -->|oui| C{"cookie<br/>présent ?"}
58
+ C -->|oui| RS["resume() → lit le store"]
59
+ C -->|non| CR["create() → id CSPRNG + cookie"]
60
+ RS --> V{"valide ?<br/>idle · absolute · strictMode"}
61
+ V -->|non| INV["invalidate()<br/>détruit + session neuve"]
62
+ V -->|oui| CTX["context.session"]
63
+ CR --> CTX
64
+ INV --> CTX
65
+ CTX --> W["controller lit / écrit"]
66
+ W --> S{"mutée ?"}
67
+ S -->|oui| WR["save() → write store"]
68
+ S -->|non| TO["touchIfNeeded()<br/>prolonge sans réécrire"]
69
+ ```
70
+
71
+ Le point d'activation est **unique et commun aux deux transports** : `HttpKernel.startSession()`
72
+ (`http-kernel.ts:1131`). Il commence par la garde paresseuse `if (!intent && !context.hasSession())`
73
+ (`http-kernel.ts:1137`) — sans intent de route ni cookie entrant, **aucune session n'est ouverte**.
74
+
75
+ ## 📖 Lexique
76
+
77
+ | Terme | Sens |
78
+ | ----------------- | ------------------------------------------------------------------------------------------------------ |
79
+ | Session | État serveur associé à un visiteur, retrouvé de requête en requête via un identifiant. |
80
+ | ID de session | Chaîne opaque aléatoire (32 octets CSPRNG → base64url, 43 caractères) qui indexe la session. |
81
+ | Store | Le backend qui persiste les sessions : `memory`, `drizzle` (SQL), `redis`, `mongoose` (MongoDB). |
82
+ | Intent | Déclaration d'une route qui veut une session (`@UseSession` ou un paramètre `@Session`). |
83
+ | CSPRNG | Générateur d'aléa **cryptographiquement sûr** (`node:crypto` `randomBytes`) — non devinable. |
84
+ | HMAC | Code d'authentification de message à clé — ici pour dériver une référence publique non réversible. |
85
+ | `ref` | Pseudonyme public d'une session, `HMAC-SHA256(secret, id)` tronqué, préfixé `sess_`. Jamais l'ID brut. |
86
+ | Cookie `HttpOnly` | Inaccessible à `document.cookie` → hors de portée d'un script injecté (XSS). |
87
+ | Cookie `Secure` | Envoyé uniquement sur HTTPS/WSS. |
88
+ | Préfixe `__Host-` | Préfixe de nom imposant `Secure` + `Path=/` + interdisant `Domain` (RFC 6265bis §4.1.3). |
89
+ | `SameSite` | Attribut limitant l'envoi du cookie depuis un site tiers (défaut Nodefony : `Lax`). |
90
+ | Session fixation | Attaque : forcer la victime à utiliser un identifiant de session connu de l'attaquant. |
91
+ | Session hijacking | Vol de l'identifiant/cookie pour usurper la session. |
92
+ | Idle timeout | Expiration après une période d'**inactivité** (glissante). |
93
+ | Absolute timeout | Âge **maximum** depuis la création, jamais prolongé — borne un identifiant volé. |
94
+ | Touch | Prolongation de la fenêtre d'inactivité **sans réécrire** les données. |
95
+ | Dirty-tracking | Suivi « la session a-t-elle été mutée ? » — décide s'il faut écrire dans le store. |
96
+ | GC | _Garbage collection_ : purge périodique des sessions expirées, hors chemin de requête. |
97
+ | TTL | _Time To Live_ : durée de vie native d'une clé (Redis `SET … EX`). |
98
+ | UPSERT | `INSERT … ON CONFLICT DO UPDATE` — écriture atomique « crée ou met à jour ». |
99
+ | BFF | _Backend-For-Frontend_ : le serveur gère session et jetons pour le front web. |
100
+ | ALS | `AsyncLocalStorage` — propage l'identité/le contexte à travers les appels asynchrones. |
101
+ | IDOR | _Insecure Direct Object Reference_ : accéder à l'objet d'autrui en changeant un identifiant. |
102
+ | XSS | _Cross-Site Scripting_ : exécution de script injecté dans la page de la victime. |
103
+ | NIST SP 800-63B | Référentiel d'identité numérique du NIST — impose des bornes de session. |
104
+ | OWASP | Fondation de sécurité applicative ; ici le _Session Management Cheat Sheet_. |
105
+
106
+ ## Qu'est-ce qu'une session — et quelles failles elle encadre
107
+
108
+ Sans session, un utilisateur devrait re-prouver son identité à **chaque** requête (retaper son mot de
109
+ passe pour chaque clic). La session résout ça : après connexion, le serveur garde l'état et le client
110
+ ne présente plus qu'un **identifiant opaque**.
111
+
112
+ Cet identifiant devient donc une cible. Trois attaques classiques, trois garde-fous **actifs par
113
+ défaut** dans Nodefony :
114
+
115
+ - **Vol du cookie (hijacking).** Un script injecté (XSS) ou un réseau en clair capte le cookie et
116
+ rejoue la session. → `HttpOnly` et `Secure` sont à `true` par défaut (`sessionCookieSchema`,
117
+ `config.ts:718-725`), et le nom du cookie prend le préfixe `__Host-` dès que le transport est TLS
118
+ (`Context.getSessionCookieName()`, `Context.ts:714`).
119
+ - **Fixation.** L'attaquant pose lui-même un identifiant dans le navigateur de la victime, attend
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:189`), et le login **régénère**
122
+ l'identifiant (`AuthFlow` — voir plus bas).
123
+ - **Exploitation prolongée d'un identifiant volé.** Une session maintenue artificiellement vivante
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:366`), en plus de l'idle timeout.
126
+
127
+ > [!IMPORTANT]
128
+ > Le cookie **ne chiffre rien** et n'a pas à le faire : il ne porte qu'un numéro. La sécurité repose
129
+ > sur la protection du cookie (les attributs ci-dessus), sur l'imprévisibilité de l'identifiant
130
+ > (32 octets CSPRNG) et sur le store — jamais sur un secret embarqué côté client.
131
+
132
+ ## La vision Nodefony
133
+
134
+ Quatre partis pris, chacun vérifiable dans le code.
135
+
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:226`). Modèle BFF : le web reste sur un cookie opaque, le JWT est réservé aux API et aux
139
+ agents (voir [Firewall](../../security/docs/firewall.md)).
140
+
141
+ **2. La session est paresseuse.** Elle n'existe que si une route la demande — `@UseSession`, ou la
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:1131`). Une route publique ne paie **ni lecture de store,
144
+ ni `Set-Cookie`**.
145
+
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:1391`) et `HttpKernel.onConnect()` (`http-kernel.ts:1659`) ;
148
+ l'activité HTTP **ou** WS prolonge la même session (`Session.touchIfNeeded()`, `session.ts:421`).
149
+
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
152
+ « appareils connectés » de GitHub ou Google montre une référence, jamais le jeton.
153
+
154
+ Compromis assumé : l'état serveur suppose un store **partagé** dès qu'on passe à plusieurs pods
155
+ (`redis`/`drizzle`/`mongoose`) ; `memory` reste per-pod.
156
+
157
+ ## 🚀 Démarrage rapide
158
+
159
+ Dans une app générée par `nodefony create app`, la session est déjà configurée avec des défauts sûrs.
160
+ Voici le chemin complet : configurer, écrire un contrôleur, observer.
161
+
162
+ ### 1. Déclarer le store (facultatif — `auto` fait déjà le bon choix)
163
+
164
+ ```typescript
165
+ // nodefony.config.ts — extrait
166
+ export default defineConfig(() => ({
167
+ modules: [
168
+ use("@nodefony/http", {
169
+ session: {
170
+ // "auto" (défaut) suit l'infra déclarée. On peut nommer le store :
171
+ store: "drizzle",
172
+ name: "monapp", // nom du cookie (préfixé __Host- sur TLS)
173
+ idleTimeoutS: 1800, // 30 min d'inactivité (NIST/OWASP)
174
+ absoluteTimeoutS: 43200, // 12 h d'âge max, jamais prolongé
175
+ cookie: { httpOnly: true, secure: true, hostPrefix: "auto" },
176
+ },
177
+ }),
178
+ "@nodefony/framework",
179
+ "@nodefony/drizzle", // fournit le store `drizzle` (il s'auto-enregistre)
180
+ ],
181
+ }));
182
+ ```
183
+
184
+ ### 2. Écrire le contrôleur qui lit et écrit la session
185
+
186
+ ```typescript
187
+ // nodefony/controllers/CartController.ts — complet, compile tel quel
188
+ import {
189
+ Controller,
190
+ controller,
191
+ Get,
192
+ Post,
193
+ Delete,
194
+ Session,
195
+ Body,
196
+ UseSession,
197
+ } from "@nodefony/framework";
198
+ import type { Session as HttpSession } from "@nodefony/http";
199
+
200
+ @controller("/panier")
201
+ class CartController extends Controller {
202
+ // Lecture seule : la session est reprise mais JAMAIS réécrite (0 write store).
203
+ @UseSession({ readOnly: true })
204
+ @Get("/")
205
+ async show(@Session() session: HttpSession) {
206
+ return this.renderJson({ items: session.get("items") ?? [] });
207
+ }
208
+
209
+ // Le paramètre @Session suffit à déclarer l'intent : pas besoin de @UseSession.
210
+ @Post("/ajouter")
211
+ async add(@Session() session: HttpSession, @Body() body: { sku: string }) {
212
+ const items = (session.get("items") as string[] | null) ?? [];
213
+ items.push(body.sku);
214
+ session.set("items", items); // marque la session « mutée » (dirty)
215
+ session.setFlashBag("notice", `${body.sku} ajouté`); // lu UNE fois, puis effacé
216
+ return this.renderJson({ count: items.length }); // save() écrit en fin de requête
217
+ }
218
+
219
+ @Delete("/")
220
+ async clear(@Session() session: HttpSession) {
221
+ await session.destroy(true); // détruit l'entrée store + efface le cookie
222
+ return this.renderJson({ ok: true });
223
+ }
224
+ }
225
+
226
+ export default CartController;
227
+ ```
228
+
229
+ ### 3. La même session en WebSocket
230
+
231
+ Aucune API différente : le même décorateur, sur une route WebSocket.
232
+
233
+ ```typescript
234
+ // nodefony/controllers/LiveController.ts — complet, compile tel quel
235
+ import { Controller, controller, route, UseSession } from "@nodefony/framework";
236
+ import type { WebsocketContext } from "@nodefony/http";
237
+
238
+ @controller("/live")
239
+ class LiveController extends Controller {
240
+ @route("live-panier", {
241
+ path: "/panier",
242
+ requirements: { methods: ["WEBSOCKET"] },
243
+ })
244
+ @UseSession() // session ouverte AU HANDSHAKE, réutilisée par toutes les frames
245
+ async panier(message: string | Buffer | null) {
246
+ const ws = this.context as WebsocketContext | undefined;
247
+ const items = (this.session?.get("items") as string[] | null) ?? [];
248
+ ws?.send(JSON.stringify({ items, echo: message?.toString() ?? null }));
249
+ }
250
+ }
251
+
252
+ export default LiveController;
253
+ ```
254
+
255
+ ### 4. Ce qu'on observe
256
+
257
+ ```bash
258
+ # 1) Route SANS intent de session → aucun Set-Cookie (activation paresseuse)
259
+ curl -si https://localhost:5152/ -k | grep -ci 'set-cookie'
260
+ # 0
261
+
262
+ # 2) Première écriture → création de la session + cookie durci
263
+ curl -sik -c /tmp/jar -H 'Content-Type: application/json' \
264
+ -d '{"sku":"NF-1"}' https://localhost:5152/panier/ajouter | grep -i set-cookie
265
+ # Set-Cookie: __Host-monapp=Yk9t…43-caracteres-base64url…; Path=/; HttpOnly; Secure; SameSite=Lax
266
+
267
+ # 3) Rejouer avec le cookie → l'état est retrouvé
268
+ curl -sk -b /tmp/jar https://localhost:5152/panier
269
+ # {"items":["NF-1"]}
270
+
271
+ # 4) Une simple lecture n'écrit RIEN dans le store (dirty-tracking + touch throttlé)
272
+ ```
273
+
274
+ Le cookie obtenu porte `__Host-`, `HttpOnly`, `Secure`, `SameSite=Lax`, `Path=/` et **aucun** `Domain` :
275
+ c'est exactement ce qu'assert le banc d'intégration `session-runtime` (« Set-Cookie de session sur TLS »).
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:714`).
278
+
279
+ ## ⚙️ Configuration
280
+
281
+ Source unique des défauts : le schéma Zod `sessionSchema` (`config.ts:761`) et son sous-schéma
282
+ `sessionCookieSchema` (`config.ts:727`).
283
+
284
+ | Option | Type | Défaut | Effet |
285
+ | ------------------- | ------- | ------------ | --------------------------------------------------------------------------------- |
286
+ | `store` | string | `"auto"` | Backend de persistance — voir la résolution ci-dessous (`config.ts:755`). |
287
+ | `name` | string | `"nodefony"` | Nom du cookie, préfixé `__Host-` selon `cookie.hostPrefix` (`config.ts:750`). |
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:796`). |
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 (défense en profondeur, `session.ts:366`). |
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
+
300
+ `SameSite` n'est pas dans ce bloc : il vient des options de cookie génériques, dont le défaut est
301
+ `Lax` (`defaultCookieOptions`, `cookie.ts:48`).
302
+
303
+ > [!WARNING]
304
+ > `idleTimeoutS: 0` **et** `absoluteTimeoutS: 0` désactivent les deux bornes : une session ne meurt
305
+ > alors plus jamais côté serveur. Le banc d'attaque `session-timeout.attack.test.ts` verrouille les
306
+ > défauts NIST (« défauts NIST actifs — idle 1800, absolute 43200 ») justement pour qu'un changement
307
+ > silencieux se voie.
308
+
309
+ ### Comment `store: "auto"` se résout au boot
310
+
311
+ `auto` n'est pas un store : c'est une sentinelle résolue une fois, au boot, par `resolveAutoStore()`
312
+ (`config/infra.ts:241`), 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:231`).
314
+
315
+ ```mermaid
316
+ flowchart TD
317
+ A(["session.store = auto"]) --> F{"NF_STORE posé<br/>et enregistré ?"}
318
+ F -->|oui| FO["ce store<br/>(override global, bancs)"]
319
+ F -->|non| C{"infra cache<br/>NF_REDIS_URL ?"}
320
+ C -->|oui| RE["redis"]
321
+ C -->|non| D{"infra database<br/>NF_DATABASE_URL ?"}
322
+ D -->|mongo| MO["mongoose"]
323
+ D -->|sql| DZ["drizzle"]
324
+ D -->|aucune| L{"backend local<br/>persistant chargé ?"}
325
+ L -->|drizzle| SQ["drizzle (SQLite local)"]
326
+ L -->|mongoose| MG["mongoose"]
327
+ L -->|aucun| ME["memory (volatil)"]
328
+ ```
329
+
330
+ Deux comportements à connaître :
331
+
332
+ - **Sans aucune infra déclarée, on ne tombe pas en `memory`** : si `@nodefony/drizzle` est chargé, la
333
+ session persiste en SQLite local — tes données survivent au redémarrage sans une ligne de config
334
+ (`infra.ts:288`).
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`).
337
+
338
+ ## 🗂️ Choisir son store
339
+
340
+ Le contrat est unique — `ISessionStorage` (`ISession.ts:127`) — et **tous** les backends le portent.
341
+ Ce qui change, c'est la topologie et la façon d'expirer.
342
+
343
+ | Store | Où vit l'état | Multi-pod | Expiration idle | `total` admin | Quand le choisir |
344
+ | ---------- | --------------------- | :-------: | -------------------------- | :-----------: | -------------------------------------------- |
345
+ | `memory` | RAM du process | non | GC applicatif | exact | tests, CI, bancs de charge |
346
+ | `drizzle` | SQL (SQLite/PG/MySQL) | oui¹ | GC applicatif (2 DELETE) | exact | défaut persistant, mono ou multi-nœud |
347
+ | `redis` | Redis | oui | **TTL natif** (`SET … EX`) | inconnu (-1) | forte charge, cluster, sessions volumineuses |
348
+ | `mongoose` | MongoDB | oui | GC applicatif (`$lt`) | exact | pile déjà MongoDB |
349
+
350
+ ¹ multi-pod dès que la base est partagée (PostgreSQL/MySQL) ; en SQLite local, mono-nœud.
351
+
352
+ ### `memory` — l'implémentation de référence
353
+
354
+ Store built-in de `@nodefony/http`, enregistré d'office (`sessions-service.ts:860`). Les sessions vivent
355
+ dans une `Map` du process : elles **disparaissent au redémarrage** et ne sont **pas partagées** entre
356
+ pods — c'est un choix (mesurer le framework sans le goulot disque/SQL), pas une limite.
357
+
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:87`), 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`).
362
+
363
+ ### `drizzle` — SQL, le défaut persistant
364
+
365
+ Une table `session`, une ligne par session, écrite en **UPSERT atomique** (`INSERT … ON CONFLICT DO
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
368
+ `UPDATE updatedAt` sur la clé primaire, sans réécrire le blob
369
+ (`@nodefony/drizzle/nodefony/src/SessionStorage.ts:196`).
370
+
371
+ Le GC supprime en **deux `DELETE` distincts** — idle puis absolute — plutôt qu'un `$or`, pour rester
372
+ portable sur tous les adaptateurs `orm-core`
373
+ (`@nodefony/drizzle/nodefony/src/SessionStorage.ts:166-188`). La pagination est native
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`).
376
+
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`).
379
+
380
+ ### `redis` — TTL natif, zéro balayage
381
+
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.
386
+
387
+ L'absolute timeout, lui, n'est pas exprimable par un TTL glissant : il reste honoré **à la lecture**
388
+ par `Session.isValidSession()` (`session.ts:366`). Une entrée trop vieille peut donc survivre côté
389
+ Redis jusqu'à son TTL idle, mais elle est **refusée à la reprise**.
390
+
391
+ Capacités réduites, annoncées et non simulées : la pagination est **par curseur** (pas de `total`, pas
392
+ d'ordre global) et `countSessions()` renvoie **`-1`** = « je ne sais pas »
393
+ (`@nodefony/redis/nodefony/src/SessionStorage.ts:318`). L'appelant affiche l'inconnu, il ne l'invente pas.
394
+
395
+ ### `mongoose` — MongoDB, parité de comportement
396
+
397
+ Même sémantique que le store SQL : `findOneAndUpdate({ upsert: true })` en une passe
398
+ (`@nodefony/mongoose/nodefony/src/SessionStorage.ts:102`), `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**
401
+ (epoch ms) et non des `Date` Mongo, précisément pour que le store reste interchangeable avec Drizzle.
402
+
403
+ > [!TIP]
404
+ > Ces quatre backends ne sont pas « à peu près » compatibles : leurs invariants communs sont exécutés
405
+ > par un **banc de contrat partagé** (`sessionStoreContract.ts` et `sessionPaginationContract.ts`),
406
+ > importé par chaque adaptateur. Un écart de comportement devient un test rouge, pas une surprise en
407
+ > production.
408
+
409
+ ## 🏗️ Architecture interne — le cycle de vie
410
+
411
+ ```mermaid
412
+ sequenceDiagram
413
+ participant K as HttpKernel
414
+ participant S as SessionsService
415
+ participant Se as Session
416
+ participant G as RevocationGuardStorage
417
+ participant St as Store réel
418
+ K->>K: intent de route ? cookie ?
419
+ K->>S: start(context, readOnly)
420
+ S->>Se: new Session + readOnly
421
+ Se->>G: start(id)
422
+ G->>St: start(id)
423
+ St-->>Se: blob sérialisé (ou vide)
424
+ Se->>Se: isValidSession (idle · absolute · referer)
425
+ Se-->>K: context.session
426
+ K->>K: contrôleur lit / écrit
427
+ K->>S: saveSession(context)
428
+ alt session mutée
429
+ S->>Se: save(user)
430
+ Se->>G: write(id, blob)
431
+ G->>St: write (refusé si pierre tombale)
432
+ else non mutée
433
+ S->>Se: touchIfNeeded()
434
+ Se->>G: touch(id, idle)
435
+ end
436
+ ```
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,
440
+ pose le cookie et marque la session à persister.
441
+
442
+ **Validation à la reprise.** `Session.isValidSession()` (`session.ts:365`) applique dans l'ordre le
443
+ `refererCheck` (si activé), l'**absolute** (âge depuis `created`, `session.ts:381`), puis l'**idle**
444
+ (depuis `updated`, `session.ts:394`). Échec → `invalidate()` (`session.ts:284`) détruit l'entrée et
445
+ recrée une session vierge.
446
+
447
+ **Écriture minimale.** `SessionsService.saveSession()` (`sessions-service.ts:414`) n'écrit **que** si la
448
+ session est `dirty` et non `readOnly` ; sinon il appelle `Session.touchIfNeeded()` (`session.ts:421`),
449
+ qui prolonge l'idle **sans réécrire le blob**, et seulement au-delà d'une demi-vie d'idle
450
+ (`session.ts:445`). Une requête de lecture coûte donc au pire un `UPDATE` d'horodatage toutes les
451
+ 15 minutes (défaut).
452
+
453
+ **Anti-résurrection**, sur deux niveaux. `Session.destroy()` remet `mutated = false` (`session.ts:307`)
454
+ pour que la sauvegarde de fin de requête ne réécrive pas ce qu'on vient de supprimer. Surtout, **tout**
455
+ store est décoré par `RevocationGuardStorage` (`sessions-service.ts:279`) : `destroy()` pose une
456
+ **pierre tombale** de 5 minutes (`RevocationGuardStorage.ts:144`) qui refuse ensuite tout `write`
457
+ (`RevocationGuardStorage.ts:128`) **et tout `touch`** (`RevocationGuardStorage.ts:151`) du même
458
+ identifiant — ce qui couvre la requête « en vol » d'un autre client.
459
+
460
+ **Purge hors requête.** Un `GcScheduler` est armé au `onReady`, désarmé au `onTerminate`
461
+ (`sessions-service.ts:284-313`). La passe métier nue, `SessionsService.runGc()`
462
+ (`sessions-service.ts:470`), est publique exprès : un CronJob Kubernetes peut l'appeler à la place du
463
+ timer (`gcIntervalS: 0`).
464
+
465
+ ## Entités de persistance
466
+
467
+ **Drizzle (SQL).** La table est décrite une seule fois en spec logique (`SESSION_TABLE_SPEC`,
468
+ `sessionEntity.ts:25`) et déclinée par dialecte par `buildFrameworkTable()` (`colKit.ts:543`) — mêmes **noms** de
469
+ colonnes partout, donc un store dialect-agnostique.
470
+
471
+ | Colonne | Type logique | SQLite | PostgreSQL | MySQL / MariaDB | Rôle |
472
+ | ------------ | ------------ | ------------------- | ---------- | --------------- | -------------------------------- |
473
+ | `session_id` | text (PK) | `text` | `text` | `varchar(512)` | Identifiant opaque. |
474
+ | `Attributes` | json | `text mode:json` | `jsonb` | `json` (compat) | Données applicatives. |
475
+ | `flashBag` | json | `text mode:json` | `jsonb` | `json` (compat) | Messages « une seule lecture ». |
476
+ | `metaBag` | json | `text mode:json` | `jsonb` | `json` (compat) | Métadonnées (ip, ua, host…). |
477
+ | `user` | text (null) | `text` | `text` | `text` | Propriétaire, `NULL` si anonyme. |
478
+ | `createdAt` | epoch ms | `integer` (64 bits) | `bigint` | `bigint` | Création — borne absolute. |
479
+ | `updatedAt` | epoch ms | `integer` (64 bits) | `bigint` | `bigint` | Dernière activité — borne idle. |
480
+
481
+ En MySQL/MariaDB, une colonne texte indexée devient `varchar` (un `TEXT` InnoDB n'est pas indexable
482
+ sans préfixe) et le type JSON passe par un type compatible qui tolère MariaDB, laquelle stocke le JSON
483
+ en `LONGTEXT` (`colKit.ts:466-469`).
484
+
485
+ **Mongoose (MongoDB).** Schéma équivalent (`@nodefony/mongoose/nodefony/entity/sessionEntity.ts:16`) :
486
+ `session_id` (String, index **unique**), `Attributes`/`flashBag`/`metaBag` (Object, défaut `{}`), `user`
487
+ (String, défaut `null`), `createdAt`/`updatedAt` (Number, ms).
488
+
489
+ Le connecteur diffère volontairement entre les deux adaptateurs — `"default"` pour Drizzle
490
+ (`sessionEntity.ts:11`), `"nodefony"` pour Mongoose
491
+ (`@nodefony/mongoose/nodefony/entity/sessionEntity.ts:5`) — parce que le registre d'entités est
492
+ partagé par processus : deux noms distincts évitent la collision quand les deux ORM cohabitent.
493
+
494
+ ## 🔌 HTTP et WebSocket — la même session
495
+
496
+ C'est le différenciateur du framework appliqué à l'état de session : un seul modèle, deux transports.
497
+
498
+ <!-- prettier-ignore -->
499
+ | Aspect | HTTP | WebSocket |
500
+ | --- | --- | --- |
501
+ | Ouverture | à chaque requête — `startSession()` dans `onRequestEnd()` (`http-kernel.ts:1391`) | **une fois** au handshake — `startSession()` dans `onConnect()` (`http-kernel.ts:1659`) |
502
+ | Lecture du cookie | constructeur du contexte | constructeur, même nom effectif (`WebsocketContext.ts:172`) |
503
+ | Sauvegarde | fin de requête | après **chaque frame** traitée (`WebsocketContext.ts:302`) |
504
+ | Filet de fermeture | — | `once("onFinish")` sauve si non déjà fait (`http-kernel.ts:1185`) |
505
+ | Portée ALS | une requête | **handshake + toutes les frames** (`http-kernel.ts:1495`) |
506
+
507
+ La conséquence pratique la plus utile : côté WebSocket, la bulle `AsyncLocalStorage` ouverte au
508
+ handshake par `RequestContext.run()` **enveloppe aussi les messages** (`http-kernel.ts:431`). L'identité résolue une fois est donc
509
+ disponible à chaque frame sans relire la base — c'est ce dont profite
510
+ `FirewallRealtimeAuthenticator.supports()` (`FirewallRealtimeAuthenticator.ts:80`), câblé automatiquement
511
+ par le firewall sur les zones temps réel protégées (`firewall.ts:300`).
512
+
513
+ > [!WARNING]
514
+ > Rien à écrire dans `initialize()` : il n'existe **pas** de `Controller.startSession()`. La session WS
515
+ > se déclare comme en HTTP, par `@UseSession()` **sur la route concernée**. La poser globalement ferait
516
+ > persister une session pour chaque connexion, y compris les routes qui n'en ont aucun besoin — sous
517
+ > charge (broadcast), c'est une tempête d'écritures.
518
+
519
+ ## 🔐 Sécurité
520
+
521
+ ### Régénération d'identifiant à la connexion (anti-fixation)
522
+
523
+ C'est la défense la plus importante et elle est **active**. `AuthFlow.#openSession()`
524
+ (`authFlow.ts:378`) : reprise ou ouverture de la session, mémorisation de l'ancien identifiant, puis
525
+ appel **inconditionnel** de `Session.regenerateId()` (`authFlow.ts:388`), et enfin destruction de
526
+ l'ancienne entrée du store (`authFlow.ts:390`). Un cookie pré-posé par un attaquant **ne survit donc pas
527
+ au login**. Le nouvel identifiant est un CSPRNG frais, l'état applicatif est conservé
528
+ (`Session.regenerateId()`, `session.ts:236`).
529
+
530
+ Au passage, la provenance est capturée dans le `metaBag` : `ip` (`authFlow.ts:405`) et `ua`
531
+ (`authFlow.ts:407`), en mode « au mieux » — ce sont ces deux champs que la console d'administration
532
+ affiche.
533
+
534
+ ### Révocation — immédiate et par construction
535
+
536
+ | Surface | Méthode | Portée |
537
+ | ------------------------ | ----------------------------------------------- | ------------------------------------------- |
538
+ | Déconnexion locale | `Session.destroy()` (`session.ts:300`) | la session courante + pierre tombale |
539
+ | Révocation par un admin | `destroyByRef()` (`sessions-service.ts:707`) | une session désignée par sa `ref` publique |
540
+ | « Déconnecter partout » | `destroyByUser()` (`sessions-service.ts:737`) | toutes les sessions d'un utilisateur |
541
+ | « Mes appareils » (self) | `destroyOwnByRef()` (`sessions-service.ts:834`) | une session, **restreinte au propriétaire** |
542
+
543
+ Deux finesses valent d'être connues.
544
+
545
+ `destroyByUser()` ne fait pas un seul passage : il **repasse jusqu'à ce qu'un passage complet ne
546
+ détruise plus rien** (`sessions-service.ts:737`), car supprimer en parcourant décale les rangs sous un
547
+ curseur offset. Une révocation « partout » qui en laisserait une n'est pas une imprécision, c'est une
548
+ faille — on rend donc la main avec la preuve, pas l'espoir.
549
+
550
+ `destroyOwnByRef()` ferme l'IDOR **par construction** : parcours restreint aux sessions du demandeur,
551
+ et appartenance **re-vérifiée** avant même de comparer la `ref` (`sessions-service.ts:836`). Une
552
+ `ref` d'autrui est structurellement introuvable.
553
+
554
+ ### Redaction — l'identifiant ne sort jamais du process
555
+
556
+ Trois barrières superposées :
557
+
558
+ 1. Le contrat impose que `listPage()` rende `Attributes` et `flashBag` **vides**
559
+ (`ISession.ts:203`) — les stores SQL/NoSQL ne les sélectionnent même pas.
560
+ 2. La projection vers l'extérieur passe par `toSessionSummary()` (`sessions-service.ts:112`), bâtie en
561
+ **liste blanche** : `ref`, `user`, `authenticated`, `ip`, `ua`, dates. Jamais un `delete` après coup.
562
+ 3. La `ref` elle-même est un HMAC tronqué non réversible (`computeSessionRef()`,
563
+ `sessions-service.ts:100`) ; la clé est dérivée du certificat au boot et n'est jamais sérialisée
564
+ (`SessionsService.sessionRef()`, `sessions-service.ts:511`).
565
+
566
+ ### Récapitulatif des défenses actives par défaut
567
+
568
+ | Menace | Défense | Ancrage |
569
+ | --------------------------------- | ------------------------------------------------- | -------------------------------------------------- |
570
+ | Vol par script injecté (XSS) | `HttpOnly` | `sessionCookieSchema` (`config.ts:718`) |
571
+ | Interception réseau | `Secure` + `__Host-` sur TLS | `getSessionCookieName()` (`Context.ts:714`) |
572
+ | Requête inter-sites | `SameSite=Lax` par défaut | `defaultCookieOptions` (`cookie.ts:48`) |
573
+ | Fixation (cookie pré-posé) | `strictMode` + régénération au login | `Session.resume()` (`session.ts:189`) |
574
+ | Identifiant deviné | 32 octets CSPRNG (43 caractères base64url) | `Session.generateId()` (`session.ts:226`) |
575
+ | Session volée exploitée longtemps | absolute timeout, jamais prolongé | `absoluteTimeoutS` à la reprise (`session.ts:381`) |
576
+ | Session oubliée ouverte | idle timeout glissant | `idleTimeoutS` à la reprise (`session.ts:394`) |
577
+ | Résurrection après révocation | pierre tombale 5 min sur `write` **et** `touch` | `RevocationGuardStorage.ts:121` |
578
+ | Fuite d'identifiant en admin | `ref` HMAC + projection en liste blanche | `toSessionSummary()` (`sessions-service.ts:112`) |
579
+ | IDOR sur « mes sessions » | périmètre depuis l'identité ALS, jamais du client | `destroyOwnByRef()` (`sessions-service.ts:834`) |
580
+
581
+ ## 🧰 API publique
582
+
583
+ Les signatures vivent dans `.ai/symbols.json` (jamais recopiées ici). Voici les usages réels.
584
+
585
+ **Depuis un contrôleur** — `this.session` est un getter sur le contexte (`Controller.ts:229`) ; un
586
+ paramètre `@Session()` suffit à déclarer l'intent.
587
+
588
+ | Besoin | Appel | Effet |
589
+ | -------------------------------- | ------------------------------------ | ------------------------------------------------------- |
590
+ | Lire une valeur | `session.get("panier")` | `null` si absente — jamais `undefined`. |
591
+ | Écrire une valeur | `session.set("panier", items)` | Marque la session `dirty` → écriture en fin de requête. |
592
+ | Message « une seule lecture » | `session.setFlashBag("notice", "…")` | Consommé (et effacé) au premier `getFlashBag`. |
593
+ | Lire ce message | `session.getFlashBag("notice")` | Rend la valeur puis la supprime (`session.ts:518`). |
594
+ | Métadonnée technique | `session.getMetaBag("ip")` | ip / ua / host / remoteAddress posés à la création. |
595
+ | Se déconnecter | `await session.destroy(true)` | Détruit l'entrée store **et** efface le cookie. |
596
+ | Renouveler l'identifiant | `session.regenerateId()` | Nouvel identifiant, état conservé (`session.ts:236`). |
597
+ | Savoir si une écriture aura lieu | `session.dirty` | Le drapeau de dirty-tracking (`session.ts:128`). |
598
+
599
+ **Intent de route** — `UseSession(options)` (`routerDecorators.ts:761`) s'applique à une classe **ou** à
600
+ une méthode ; la méthode l'emporte, par fusion et non par remplacement
601
+ (`resolveSessionIntent()`, `routerDecorators.ts:819`). **Une seule** option (`SessionIntent`,
602
+ `ISession.ts:17`) :
603
+
604
+ - `readOnly: true` — la session est reprise et lue mais **jamais** persistée ; une mutation tentée est
605
+ journalisée en WARNING sans écriture (`Session.save()`, `session.ts:255-264`). C'est le seul champ
606
+ propagé par le kernel (`http-kernel.ts:1482`).
607
+
608
+ En décorateur de **classe**, `@UseSession` se place **sous** `@controller` (`routerDecorators.ts:761`).
609
+
610
+ ## 🧩 Extension — brancher son propre store
611
+
612
+ Le registre est une inversion de contrôle complète : `@nodefony/http` ne connaît **aucun** backend.
613
+ Chaque module fournisseur se déclare lui-même au chargement, par
614
+ `SessionsService.registerStorage(nom, ctor)` (`sessions-service.ts:174`) — exactement ce que fait la
615
+ dernière ligne de chaque adaptateur (`@nodefony/redis/nodefony/src/SessionStorage.ts:323`).
616
+
617
+ Pour ajouter un backend :
618
+
619
+ 1. Implémenter `ISessionStorage` (`ISession.ts:127`). Le **noyau obligatoire** est court :
620
+ `read`/`start`/`write`/`open`/`close`/`destroy`/`gc`.
621
+ 2. Ajouter les capacités **optionnelles** utiles : `touch` (idle glissant sans réécriture, `ISession.ts:158`),
622
+ `listPage` + `countSessions` (administration paginée, `ISession.ts:203`), `listAll` (dump).
623
+ 3. Appeler `SessionsService.registerStorage("mon-store", MonStore)` au chargement du module.
624
+ 4. Exécuter les bancs de contrat partagés (`sessionStoreContract.ts`, `sessionPaginationContract.ts`)
625
+ contre l'implémentation — c'est ce qui garantit la parité.
626
+
627
+ Trois règles de conception se dégagent du contrat, et méritent d'être respectées :
628
+
629
+ - **Une capacité absente s'annonce.** Ne pas implémenter `listPage` fait répondre **501** à l'endpoint
630
+ d'administration (refus honnête) plutôt qu'une liste vide trompeuse (`ISession.ts:221`).
631
+ - **On n'invente pas ce qu'on ignore.** `countSessions()` renvoie `-1` quand compter coûterait trop
632
+ cher — Redis le fait (`ISession.ts:236`).
633
+ - **Une page ne matérialise jamais plus qu'une page.** C'est ce qui rend le coût d'une requête
634
+ d'administration indépendant du nombre de sessions.
635
+
636
+ ## 📜 Normes appliquées
637
+
638
+ | Domaine | Norme | Comment le code s'y conforme |
639
+ | ----------------------------- | ------------------------- | ----------------------------------------------------------------------------- |
640
+ | Attributs et préfixes cookie | RFC 6265bis §4.1.3 | `__Host-` impose `Secure` + `Path=/`, interdit `Domain` (`cookie.ts:386-403`) |
641
+ | Nom du cookie selon transport | RFC 6265bis / OWASP | `getSessionCookieName()` (`Context.ts:714`) |
642
+ | Idle timeout | NIST SP 800-63B-4 / OWASP | défaut 1800 s, glissant par `touch` (`config.ts:796`) |
643
+ | Absolute timeout | NIST SP 800-63B-4 / OWASP | défaut 43200 s, jamais prolongé (`config.ts:808`) |
644
+ | Identifiant de session | OWASP Session Management | 32 octets CSPRNG, opaque (`session.ts:226`) |
645
+ | Identifiant hors URL | OWASP Session Management | cookie uniquement — jamais de réécriture d'URL (`session.ts:20-26`) |
646
+ | Renouvellement après auth | OWASP (anti-fixation) | `regenerateId()` inconditionnel au login (`authFlow.ts:388`) |
647
+ | Révocation côté serveur | OWASP | pierre tombale générique (`RevocationGuardStorage.ts:121`) |
648
+
649
+ ## ⚡ Performance & mémoire
650
+
651
+ Le coût d'une session est **payé seulement quand elle sert** :
652
+
653
+ - **Zéro par défaut** — sans intent ni cookie, `startSession()` sort immédiatement
654
+ (`http-kernel.ts:1131`) : ni objet `Session`, ni lecture de store.
655
+ - **Objet léger** — trois sacs `{}` à plat, pas de container DI par session (`session.ts:100-104`).
656
+ - **Zéro écriture en lecture** — le dirty-tracking court-circuite `save()` (`session.ts:266`) ; le
657
+ `touch` est throttlé à une écriture par demi-vie d'idle (`session.ts:445`).
658
+ - **GC hors requête** — timer déterministe avec jitter par process, à la place du tirage
659
+ probabiliste hérité de PHP (`sessions-service.ts:306-312`).
660
+ - **Révocation quasi gratuite** — la `Map` de pierres tombales est **paresseuse** : sans révocation,
661
+ `write` ne paie qu'une comparaison `=== null`, sans même un `Date.now()`
662
+ (`RevocationGuardStorage.ts:146-151`).
663
+ - **Administration bornée** — jamais plus de `SCAN_PAGE = 200` enregistrements en mémoire
664
+ (`sessions-service.ts:75`), garde-fou à 5 000 pages (`sessions-service.ts:83`), parcours interrompu
665
+ **journalisé**.
666
+
667
+ Le banc `session-load.test.ts` verrouille ces propriétés sur serveur réel (200 sessions HTTP, 100
668
+ ouvertures/fermetures WebSocket) en mesurant le **drainage des scopes DI** — immunisé au bruit du GC —
669
+ plus un plafond de croissance du tas.
670
+
671
+ > [!NOTE]
672
+ > Les trois sacs sont des objets **littéraux** et non `Object.create(null)`. C'est délibéré :
673
+ > `drizzle-orm` déréférence le prototype via `is()`, et un objet sans prototype ferait échouer
674
+ > l'écriture (`session.ts:95-98`).
675
+
676
+ ## 📡 Observabilité — Studio
677
+
678
+ **Data plane** — `createHttpAdminApi()` (`HttpAdminApi.ts:141`) expose la surface d'administration sous
679
+ `/nodefony/http/api/` :
680
+
681
+ | Route | Verbe | Accès | Rôle |
682
+ | ----------------------------------- | ----- | ----------------------- | -------------------------------------------------- |
683
+ | `sessions` | GET | — | état du sous-système (`HttpAdminApi.ts:280`) |
684
+ | `sessions/list` | GET | `ROLE_NODEFONY_ADMIN` | page de sessions redactées (`HttpAdminApi.ts:324`) |
685
+ | `sessions/{ref}/revoke` | POST | `ROLE_NODEFONY_ADMIN` | révoquer une session (`HttpAdminApi.ts:380`) |
686
+ | `sessions/revoke-user/{identifier}` | POST | `ROLE_NODEFONY_ADMIN` | déconnecter partout (`HttpAdminApi.ts:415`) |
687
+ | `sessions/mine` | GET | utilisateur authentifié | « mes appareils » (`HttpAdminApi.ts:458`) |
688
+ | `sessions/mine/{ref}/revoke` | POST | utilisateur authentifié | fermer une de mes sessions (`HttpAdminApi.ts:514`) |
689
+
690
+ Les deux routes `mine` ne demandent pas de rôle, mais **ne sont pas anonymes** : la zone firewall des
691
+ API d'administration n'accepte que l'authenticator `session` (pas d'`anonymous`), et le périmètre est
692
+ pris sur l'identité ALS, jamais sur un paramètre client (`HttpAdminApi.ts:451-457`).
693
+
694
+ Chaque ligne rendue porte **`current`** — vrai pour LA session qui a émis la requête, et pour elle
695
+ seule. C'est le « cet appareil » des consoles d'appareils connectés, et le client ne peut pas le
696
+ déduire : la référence est un HMAC du cookie, que le navigateur ne sait pas calculer. Sans lui, aucune
697
+ ligne n'est désignable — ni celle qu'on ferme, ni celle qu'il ne faut pas fermer. Comparer les
698
+ utilisateurs ne le remplace pas : dans `sessions/mine`, toutes les lignes portent le même. La
699
+ dérivation est faite une fois par page (`sessions-service.ts` `currentSessionRef`) ; `false` quand la
700
+ requête ne porte pas de session (appel interne, invocation CLI).
701
+
702
+ Codes de réponse à connaître : **501** si le store courant ne sait pas s'énumérer
703
+ (`supportsEnumeration()` faux — `HttpAdminApi.ts:352`), **503** si le service de session est absent, **404** pour une `ref` inconnue ou
704
+ une révocation sans effet, **401** sur `mine` sans identité.
705
+
706
+ **Écrans** — la page **Sessions** (`/nodefony/sessions`) de Studio liste les sessions vivantes par `ref` et permet la
707
+ révocation unitaire ou en masse (`@nodefony/studio/frontend/src/routes/sessions/`). L'écran **Stores**
708
+ affiche le backend réellement résolu, sa provenance et son emplacement physique : ces informations sont
709
+ publiées au boot par `registerStoreResolution()` (`sessions-service.ts:290`), avec le chemin du fichier
710
+ SQLite quand c'est pertinent (`SessionStorage.location`,
711
+ `@nodefony/drizzle/nodefony/src/SessionStorage.ts:43`).
712
+
713
+ ## ⚠️ Pièges (symptôme → cause → correction)
714
+
715
+ | Symptôme | Cause | Correction |
716
+ | ------------------------------------------------------ | ------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
717
+ | 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:1142`) |
718
+ | `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 |
719
+ | 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`) |
720
+ | 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`) |
721
+ | 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 |
722
+ | 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`) |
723
+ | Total des sessions affiché « inconnu » en admin | Store Redis : compter coûterait un `SCAN` complet | Comportement voulu — `countSessions()` rend `-1`, on n'invente pas |
724
+ | Liste admin en 501 | Le store n'implémente pas `listPage` | Refus honnête ; utiliser un store énumérable pour l'administration |
725
+ | 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 |
726
+ | 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é |
727
+ | 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`) |
728
+
729
+ ## 🧪 Tests & couverture
730
+
731
+ Les six familles sont présentes — les **chiffres exacts vivent dans la carte de l'aperçu**, régénérée
732
+ depuis vitest, jamais figés ici.
733
+
734
+ <!-- prettier-ignore -->
735
+ | Type | Où | Ce qui est prouvé |
736
+ | --- | --- | --- |
737
+ | Unitaires | `unit/Session.test.ts`, `unit/MemorySessionStorage.test.ts`, `unit/SessionsAdmin.test.ts` | cycle de vie, sacs, sérialisation, surface admin |
738
+ | Unitaires (intent) | `@nodefony/framework` `unit/UseSession.test.ts` | précédence classe/méthode, intent implicite par `@Session` |
739
+ | **Tests d'attaque** | `unit/session-timeout.attack.test.ts` | absolute non contournable par `touch`, touch d'une session révoquée refusé, défauts NIST verrouillés |
740
+ | Intégration (serveur) | `http/session.test.ts`, `http/session-runtime.test.ts`, `http/session-bff.test.ts` | activation paresseuse, cookie RFC sur TLS, flashBag, `regenerateId` |
741
+ | Intégration (révocation) | `integration/session-revocation.test.ts`, `integration/stores-location.test.ts` | anti-résurrection, store réellement résolu |
742
+ | WebSocket | `websockets/websocket-session.test.ts` | session au handshake |
743
+ | Stores | `@nodefony/drizzle`, `@nodefony/mongoose`, `@nodefony/redis` (dont pagination et résilience) | comportement de chaque backend |
744
+ | **E2E (base réelle)** | `@nodefony/drizzle` `session-store-postgres.e2e.test.ts`, `session-store-mysql.e2e.test.ts` | dialectes réels — gatés par `NF_PG_URL` / `NF_MYSQL_URL` |
745
+ | **Charge / mémoire** | `load/session-load.test.ts` | scopes DI drainés + tas borné (serveur live requis) |
746
+ | **Bancs de contrat** | `tests/support/sessionStoreContract.ts`, `sessionPaginationContract.ts` | invariants tenus par **tous** les stores |
747
+
748
+ > [!CAUTION]
749
+ > Les suites E2E se **skippent** sans leurs variables d'infra, et un skip compte comme vert. Avant de
750
+ > conclure « tout passe » sur les dialectes PostgreSQL/MySQL, vérifier que `NF_PG_URL`/`NF_MYSQL_URL`
751
+ > étaient bien posées (source unique : `vitest.gates.ts` à la racine).
752
+
753
+ Skills utiles : `nodefony-load-test` (rejouer ou étendre la charge), `nodefony-check-memory-health`
754
+ (gate mémoire), `nodefony-security-review` (fixation, timeouts, révocation).
755
+
756
+ **Couverture** : `npm run coverage` dans `@nodefony/http` (vitest, reporter `json-summary`). Le
757
+ pourcentage vit dans le rapport, **jamais figé** dans ce Markdown.
758
+
759
+ ## 🔗 Pour aller plus loin
760
+
761
+ - ⬆️ **Retour au hub** : [@nodefony/http — vue du module](index.md) · [Toute la documentation](../../../../../docs/index.md)
762
+ - 🧭 **Pages sœurs** : qui authentifie la session → [Firewall](../../security/docs/firewall.md) ·
763
+ [Authenticators](../../security/docs/authenticators.md) · rejeu de mutation →
764
+ [Idempotence](../../framework/docs/idempotence.md)
765
+ - 🗄️ **Les stores en détail** : [@nodefony/drizzle](../../drizzle/docs/index.md) ·
766
+ [@nodefony/redis](../../redis/docs/index.md) · [@nodefony/mongoose](../../mongoose/docs/index.md)
767
+ - 🧰 **Guide pratique** : [choisir et configurer son stockage de session](../../../../../docs/guides/session-storage.md)
768
+ - 🏗️ **Où la session s'insère** : [pipeline de requête](../../../../../docs/architecture/pipeline-requete.md)