@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,580 @@
1
+ import { SESSION_FACETS } from "../../src/session/storage/sessionFilters.js";
2
+ import __decorateMetadata from "../../../_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateMetadata.js";
3
+ import __decorate from "../../../_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorate.js";
4
+ import http_kernel_default from "../http-kernel.js";
5
+ import __decorateParam from "../../../_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateParam.js";
6
+ import Session from "../../src/session/session.js";
7
+ import MemorySessionStorage from "../../src/session/storage/MemorySessionStorage.js";
8
+ import RevocationGuardStorage from "../../src/session/storage/RevocationGuardStorage.js";
9
+ import { AUTO_STORE, EMPTY_INFRA, GcScheduler, Module, Nodefony, RequestContext, Service, countFacets, extend, inject, injectable, readStoreLocation, resolveAutoStore } from "nodefony";
10
+ import { createHash, createHmac } from "node:crypto";
11
+ //#region nodefony/service/sessions/sessions-service.ts
12
+ var _SessionsService;
13
+ /**
14
+ * Convertit un timestamp de session (`Date` en mémoire, **string ISO** après
15
+ * `JSON.parse` côté File/Redis, ou `number`) en epoch ms — `null` si absent ou
16
+ * invalide. JSON-safe pour le DTO admin.
17
+ */
18
+ function toEpoch(value) {
19
+ if (value === void 0 || value === null) return null;
20
+ const t = new Date(value).getTime();
21
+ return Number.isNaN(t) ? null : t;
22
+ }
23
+ /**
24
+ * Taille des pages lues par les parcours d'administration (révocation par
25
+ * référence, « déconnecter partout »). C'est **la borne mémoire** de ces
26
+ * opérations : quel que soit le parc — 10 ou 10 millions de sessions — le service
27
+ * ne détient jamais plus de `SCAN_PAGE` records à la fois. Assez grand pour que
28
+ * les allers-retours au store restent rares, assez petit pour rester négligeable
29
+ * en RAM.
30
+ */
31
+ const SCAN_PAGE = 200;
32
+ /**
33
+ * Nombre maximal de pages lues par un parcours d'administration (garde-fou :
34
+ * `SCAN_PAGE × MAX_ADMIN_PAGES` sessions visitées au plus). Protège d'une boucle
35
+ * infinie si un store au curseur ne convergeait jamais vers la fin. Un parcours
36
+ * interrompu est **journalisé** — partiel signalé, jamais silencieux.
37
+ */
38
+ const MAX_ADMIN_PAGES = 5e3;
39
+ /**
40
+ * Nombre maximal de passages complets d'un « déconnecter partout ». Le premier
41
+ * détruit l'essentiel, le second confirme qu'il ne reste rien ; les suivants ne
42
+ * servent que si un store à curseur faible a sauté des éléments sous la
43
+ * suppression. Au-delà, on journalise plutôt que de boucler — une révocation
44
+ * incomplète doit être VISIBLE, pas silencieuse.
45
+ */
46
+ const MAX_LOGOUT_PASSES = 10;
47
+ /**
48
+ * Dérive le pseudonyme public d'une session — `HMAC-SHA256(secret, id)` tronqué,
49
+ * préfixé `sess_`. **Non réversible** : exposer ce `ref` ne révèle pas l'id de
50
+ * session (= le jeton du cookie). Fonction **pure** (testable sans instancier le
51
+ * service ni démarrer de serveur).
52
+ */
53
+ function computeSessionRef(secret, id) {
54
+ return `sess_${createHmac("sha256", secret).update(id).digest("hex").slice(0, 24)}`;
55
+ }
56
+ /**
57
+ * Projette une entrée brute {@link ISessionRecord} en {@link ISessionSummary}
58
+ * **redacté par construction** (allowlist) : jamais `Attributes`, jamais
59
+ * `flashBag`, jamais l'id brut — seulement le `ref` + des champs sûrs
60
+ * (`user`/`ip`/`ua`/dates). Fonction **pure** (cœur de la garantie anti-fuite,
61
+ * testée isolément).
62
+ */
63
+ function toSessionSummary(rec, ref, currentRef = null) {
64
+ const data = rec.data;
65
+ const user = typeof data.user === "string" ? data.user : "";
66
+ const meta = data.metaBag ?? {};
67
+ return {
68
+ ref,
69
+ user,
70
+ authenticated: user.length > 0,
71
+ ip: typeof meta.ip === "string" ? meta.ip : null,
72
+ ua: typeof meta.ua === "string" ? meta.ua : null,
73
+ createdAt: toEpoch(data.createdAt),
74
+ updatedAt: toEpoch(data.updatedAt),
75
+ tenantId: typeof meta.tenantId === "string" ? meta.tenantId : null,
76
+ current: currentRef !== null && ref === currentRef
77
+ };
78
+ }
79
+ let SessionsService = class SessionsService extends Service {
80
+ static {
81
+ _SessionsService = this;
82
+ }
83
+ httpKernel;
84
+ /**
85
+ * Registre des storages de session — inversion de contrôle.
86
+ *
87
+ * Chaque module qui fournit un storage l'enregistre à son chargement
88
+ * (`SessionsService.registerStorage("drizzle", DrizzleStorage)`). Ainsi
89
+ * `@nodefony/http` **ne dépend d'aucun ORM** : pas d'import croisé, pas de
90
+ * cycle, et ajouter un driver ne touche plus ce fichier. Le handler de la
91
+ * config (`session.store`) sélectionne le storage par son nom.
92
+ */
93
+ static storages = /* @__PURE__ */ new Map();
94
+ /**
95
+ * Enregistre un storage de session sous un nom de store (insensible à la
96
+ * casse) et émet l'événement kernel `onRegisterSessionStorage` (observabilité
97
+ * Studio / extension). Le kernel peut être absent au tout premier chargement
98
+ * (registration statique) → fire gardé.
99
+ */
100
+ static registerStorage(name, ctor) {
101
+ const key = name.toLowerCase();
102
+ _SessionsService.storages.set(key, ctor);
103
+ const kernel = Nodefony.getKernel();
104
+ kernel?.fire("onRegisterSessionStorage", key, ctor);
105
+ kernel?.log(`SESSION STORAGE registered : ${key}`, "DEBUG", "SESSION");
106
+ }
107
+ /** Storage enregistré pour un store, ou `undefined`. */
108
+ static getStorage(name) {
109
+ return _SessionsService.storages.get(String(name ?? "").toLowerCase());
110
+ }
111
+ /** Noms des handlers de session enregistrés. */
112
+ static storageHandlers() {
113
+ return [..._SessionsService.storages.keys()];
114
+ }
115
+ sessionStrategy = "migrate";
116
+ storage = null;
117
+ module;
118
+ defaultSessionName = "nodefony";
119
+ secret;
120
+ iv;
121
+ certificates;
122
+ gcScheduler = null;
123
+ constructor(module, httpKernel) {
124
+ super("sessions", module.container, module.notificationsCenter, module.options.session);
125
+ this.httpKernel = httpKernel;
126
+ this.module = module;
127
+ this.certificates = this.get("certificates");
128
+ this.defaultSessionName = this.options.name;
129
+ this.once("onTerminate", () => {
130
+ this.gcScheduler?.stop();
131
+ if (this.storage) this.storage.close();
132
+ });
133
+ }
134
+ async init() {
135
+ this.secret = this.createSecret();
136
+ this.iv = this.createIv();
137
+ this.initializeStorage();
138
+ return this;
139
+ }
140
+ initializeStorage() {
141
+ let storeName = this.options.store;
142
+ const configured = storeName;
143
+ let reason = `store explicitement configuré ("${configured}")`;
144
+ if (storeName === AUTO_STORE) {
145
+ const auto = resolveAutoStore("session", this.kernel?.infra ?? EMPTY_INFRA, _SessionsService.storageHandlers(), "memory");
146
+ storeName = auto.store;
147
+ reason = auto.reason;
148
+ this.log(`session.store "auto" → "${storeName}" (${auto.reason})`, "INFO");
149
+ }
150
+ let Storage = _SessionsService.getStorage(storeName);
151
+ if (!Storage) {
152
+ const known = _SessionsService.storageHandlers().join(", ") || "aucun";
153
+ const msg = `session store "${storeName}" inconnu (enregistrés : ${known})`;
154
+ if (this.kernel?.environment === "production") throw new Error(`${msg} — sessions indisponibles : boot avorté.`);
155
+ this.log(`${msg} — repli "memory"`, "WARNING");
156
+ storeName = "memory";
157
+ reason = `repli "memory" (store "${configured}" introuvable)`;
158
+ Storage = _SessionsService.getStorage(storeName);
159
+ if (!Storage) throw new Error(`session store de repli "memory" introuvable — sessions indisponibles.`);
160
+ }
161
+ const innerStorage = new Storage(this);
162
+ const storage = new RevocationGuardStorage(innerStorage);
163
+ this.storage = storage;
164
+ this.log(`SESSION STORAGE active : ${storeName}`, "INFO");
165
+ this.fire("onSessionStorageReady", storeName, this.storage);
166
+ this.kernel?.fire("onSessionStorageReady", storeName, this.storage);
167
+ this.kernel?.on("onReady", async () => {
168
+ this.kernel?.registerStoreResolution({
169
+ brick: "session",
170
+ nature: "session",
171
+ configured,
172
+ resolved: storeName,
173
+ available: _SessionsService.storageHandlers(),
174
+ reason,
175
+ configPath: "http.session.store",
176
+ location: readStoreLocation(innerStorage)
177
+ });
178
+ await storage.open();
179
+ this.gcScheduler = new GcScheduler({
180
+ intervalS: Number(this.options.gcIntervalS ?? 600),
181
+ jitter: this.options.gcJitter !== false,
182
+ run: () => this.runGc(),
183
+ onError: (e) => this.log(e, "WARNING", "SESSION-GC")
184
+ });
185
+ this.gcScheduler.start();
186
+ });
187
+ return this.storage;
188
+ }
189
+ createSecret() {
190
+ const secret = createHash("sha512").update(this.certificates?.key).digest();
191
+ return Buffer.from(secret.buffer.slice(0, 32));
192
+ }
193
+ createIv() {
194
+ const iv = createHash("sha512").update(this.certificates?.publicKeyPem).digest();
195
+ return Buffer.from(iv.buffer.slice(0, 16));
196
+ }
197
+ async start(context, readOnly) {
198
+ return new Promise((resolve, reject) => {
199
+ if (context.sessionStarting) {
200
+ if (context.session) {
201
+ resolve(context.session);
202
+ return;
203
+ }
204
+ context.once("onSessionStart", (session, error) => {
205
+ if (session) return resolve(session);
206
+ return reject(error || /* @__PURE__ */ new Error("Bad Session"));
207
+ });
208
+ return;
209
+ }
210
+ if (context.session) {
211
+ if (context.session.status === "active") {
212
+ this.log(`SESSION ALLREADY STARTED ==> ${context.session.name} : ${context.session.id}`, "DEBUG");
213
+ return resolve(context.session);
214
+ }
215
+ }
216
+ let inst = null;
217
+ try {
218
+ context.sessionStarting = true;
219
+ inst = this.createSession(this.defaultSessionName);
220
+ inst.readOnly = readOnly === true;
221
+ } catch (e) {
222
+ context.fire("onSessionStart", null, e);
223
+ return reject(e);
224
+ }
225
+ inst.start(context).then((session) => {
226
+ try {
227
+ context.session = session;
228
+ const method = context.method;
229
+ const request = context.request;
230
+ if (method !== "WEBSOCKET" && request && request.request) request.request.session = session;
231
+ context.sessionStarting = false;
232
+ if (context.cleaned) return reject(/* @__PURE__ */ new Error("context already cleaned"));
233
+ context.fire("onSessionStart", session, null);
234
+ return resolve(session);
235
+ } catch (e) {
236
+ if (context.cleaned) return reject(e);
237
+ context.fire("onSessionStart", null, e);
238
+ return reject(e);
239
+ }
240
+ }).catch((err) => {
241
+ if (context.cleaned) return reject(err);
242
+ context.fire("onSessionStart", null, err);
243
+ return reject(err);
244
+ });
245
+ });
246
+ }
247
+ async saveSession(context) {
248
+ const session = context.session;
249
+ if (!session) return null;
250
+ if (session.dirty && !session.readOnly) return session.save(context.user ? context.user : void 0);
251
+ await session.touchIfNeeded();
252
+ return session;
253
+ }
254
+ createSession(name, options) {
255
+ options = extend({}, this.options, options);
256
+ return new Session(name, options, this);
257
+ }
258
+ setSessionStrategy(strategy) {
259
+ this.sessionStrategy = strategy;
260
+ }
261
+ /**
262
+ * Une passe de purge du store (`storage.gc(idle, absolute)`) — point d'entrée
263
+ * public d'un ordonnanceur : le {@link GcScheduler} l'appelle, mais un futur
264
+ * worker cron (`session:gc` / k8s CronJob) peut l'appeler à sa place (poser
265
+ * alors `gcIntervalS:0`). L'anti-empilement et la capture d'erreur vivent dans
266
+ * le GcScheduler (via `onError`) — ici, la passe métier nue.
267
+ */
268
+ async runGc() {
269
+ if (!this.storage) return;
270
+ await this.storage.gc(this.options.idleTimeoutS, this.options.absoluteTimeoutS);
271
+ }
272
+ /**
273
+ * `true` si le backend de session courant sait s'énumérer **par pages**
274
+ * (`listPage`). Un store KV/edge sans scan retourne `false` → l'endpoint admin
275
+ * répond **501** (refus honnête, jamais une liste vide trompeuse).
276
+ *
277
+ * C'est bien `listPage` — et non `listAll` — qui fait foi : toute la surface
278
+ * d'administration (listing, révocation par référence, « déconnecter partout »)
279
+ * est bâtie sur la pagination, pour que son coût mémoire soit **indépendant du
280
+ * nombre de sessions**. Un store qui ne saurait que tout charger serait une
281
+ * régression déguisée en capacité.
282
+ */
283
+ supportsEnumeration() {
284
+ const storage = this.storage;
285
+ return !!storage && typeof storage.listPage === "function";
286
+ }
287
+ /**
288
+ * Champs de tri que le backend de session **actuellement configuré** sait
289
+ * honorer, en vocabulaire public.
290
+ *
291
+ * La capacité se CONSTATE au runtime : la même application rend `["updatedAt",
292
+ * "id"]` sur SQLite et `[]` sur Redis (`SCAN` ne donne aucun ordre global).
293
+ * Le data plane transmet cette liste à `parsePageQuery`, qui **refuse** (400)
294
+ * un `order` qu'aucun store ne pourrait honorer — au lieu de rendre une page
295
+ * non triée en laissant croire le contraire.
296
+ *
297
+ * @returns les champs triables, liste vide si le backend ne trie pas.
298
+ */
299
+ sortableFields() {
300
+ return this.storage?.sortableFields ?? [];
301
+ }
302
+ /**
303
+ * Dérive le pseudonyme public d'une session — `HMAC-SHA256(secret, id)` tronqué,
304
+ * préfixé `sess_`. **Non réversible** : exposer ce `ref` ne révèle pas l'id de
305
+ * session (= le jeton du cookie). Le secret HMAC = celui de la couche session
306
+ * (`this.secret`, dérivé de la clé du certificat au boot), jamais sérialisé.
307
+ *
308
+ * @throws Error si le secret n'est pas initialisé (service pas démarré) — capté
309
+ * en 503 côté endpoint plutôt que d'émettre un `ref` faible.
310
+ */
311
+ sessionRef(id) {
312
+ if (!this.secret) throw new Error("sessions: HMAC secret unavailable (service not initialized)");
313
+ return computeSessionRef(this.secret, id);
314
+ }
315
+ /**
316
+ * Énumère les sessions persistées en {@link ISessionSummary} **redactés** (jamais
317
+ * d'id brut ni d'`Attributes`), des plus récentes aux plus anciennes. Le filtre
318
+ * `user` est poussé au store (WHERE SQL) PUIS ré-appliqué ici (défense si un
319
+ * store l'ignore). Pré-condition : {@link supportsEnumeration} (sinon throw).
320
+ */
321
+ /**
322
+ * Référence publique de la session qui porte la **requête en cours**, lue dans
323
+ * le contexte de l'ALS — ou `null` quand la requête n'en porte aucune (CLI,
324
+ * appel interne, session jamais démarrée).
325
+ *
326
+ * Point UNIQUE de cette dérivation : marquer « cet appareil » se fait ici, et
327
+ * les deux énumérations (admin et self-service) s'en servent — sans quoi
328
+ * chaque appelant recalculerait la règle et l'une des copies dériverait.
329
+ * Un HMAC par page d'administration, jamais sur le chemin nominal.
330
+ */
331
+ currentSessionRef() {
332
+ if (!this.secret) return null;
333
+ const id = RequestContext.getContext()?.session?.id;
334
+ return typeof id === "string" && id.length > 0 ? computeSessionRef(this.secret, id) : null;
335
+ }
336
+ async listSessionsPage(query) {
337
+ const page = await this.enumerable().listPage(query);
338
+ const wantUser = query.user;
339
+ const currentRef = this.currentSessionRef();
340
+ const items = [];
341
+ for (const rec of page.items) {
342
+ const user = typeof rec.data.user === "string" ? rec.data.user : "";
343
+ if (wantUser !== void 0 && user !== wantUser) continue;
344
+ items.push(toSessionSummary(rec, this.sessionRef(rec.id), currentRef));
345
+ }
346
+ return {
347
+ ...page,
348
+ items
349
+ };
350
+ }
351
+ /**
352
+ * Compte les sessions sans les énumérer (KPI de la console). Renvoie **`-1`**
353
+ * si le backend ne sait pas compter à coût raisonnable (Redis) — l'appelant
354
+ * affiche alors l'inconnu plutôt qu'un chiffre inventé.
355
+ */
356
+ async countSessions(query) {
357
+ const storage = this.enumerable();
358
+ if (typeof storage.countSessions !== "function") return -1;
359
+ return storage.countSessions(query);
360
+ }
361
+ /**
362
+ * Compte les utilisateurs **distincts** ayant une session — le second nombre
363
+ * de la console, celui que `countSessions` ne dit pas (« 400 sessions » n'est
364
+ * pas « 400 personnes »).
365
+ *
366
+ * @returns le nombre d'utilisateurs distincts, ou **`-1`** si le backend ne
367
+ * sait pas agréger (Redis).
368
+ */
369
+ async countDistinctUsers(query) {
370
+ const storage = this.enumerable();
371
+ if (typeof storage.countDistinctUsers !== "function") return -1;
372
+ return storage.countDistinctUsers(query);
373
+ }
374
+ /**
375
+ * Les compteurs de tête de la console — posés sur la collection ENTIÈRE, pas
376
+ * sur la page affichée.
377
+ *
378
+ * C'est la correction d'un mensonge d'affichage : les cartes étaient calculées
379
+ * dans le navigateur à partir des sessions chargées, donc bornées par la
380
+ * fenêtre du tableau. Elles décrivaient l'échantillon visible en ayant l'air
381
+ * de décrire le parc.
382
+ *
383
+ * Chaque compteur vaut `null` quand le backend ne sait pas répondre — la
384
+ * console affiche alors l'inconnu (« — ») plutôt qu'un zéro qui se lirait
385
+ * comme une absence.
386
+ *
387
+ * @param query - filtres à appliquer avant comptage (sans fenêtre).
388
+ */
389
+ async countSessionFacets(query) {
390
+ const [facets, users] = await Promise.all([countFacets(SESSION_FACETS, (facet) => this.countSessions({
391
+ ...query,
392
+ ...facet
393
+ })), this.countDistinctUsers(query)]);
394
+ return {
395
+ ...facets,
396
+ users: users >= 0 ? users : null
397
+ };
398
+ }
399
+ /**
400
+ * Storage courant, garanti énumérable — factorise la pré-condition de toute la
401
+ * surface admin (une seule formulation de l'erreur, un seul point à faire
402
+ * évoluer).
403
+ *
404
+ * @throws Error si le storage est absent ou n'implémente pas `listPage`.
405
+ */
406
+ enumerable() {
407
+ const storage = this.storage;
408
+ if (!storage || typeof storage.listPage !== "function") throw new Error("sessions: enumeration not supported by current storage");
409
+ return storage;
410
+ }
411
+ /**
412
+ * Parcourt les sessions **page par page**, en ne gardant JAMAIS plus d'une page
413
+ * en mémoire — le cœur de la gouvernance bornée : retrouver une session par sa
414
+ * référence publique impose de recalculer un HMAC sur chaque id, mais pas de
415
+ * charger le parc entier pour le faire.
416
+ *
417
+ * Gère les deux modes du contrat de façon transparente pour l'appelant :
418
+ * curseur (`nextCursor` du store) ou offset (avance de `SCAN_PAGE`). Le visiteur
419
+ * renvoie `true` pour **arrêter** le parcours (court-circuit dès le match).
420
+ *
421
+ * @param filter - restriction poussée au store (ex. `user`).
422
+ * @param visit - appelé pour chaque record ; `true` = stop.
423
+ * @returns `true` si le parcours a été arrêté par le visiteur.
424
+ */
425
+ async eachSessionRecord(filter, visit) {
426
+ const storage = this.enumerable();
427
+ let cursor;
428
+ let offset = 0;
429
+ for (let guard = 0; guard < MAX_ADMIN_PAGES; guard += 1) {
430
+ const page = await storage.listPage({
431
+ ...filter,
432
+ limit: SCAN_PAGE,
433
+ withTotal: false,
434
+ ...cursor !== void 0 ? { cursor } : { offset }
435
+ });
436
+ for (const rec of page.items) if (await visit(rec)) return true;
437
+ if (!page.hasNext) return false;
438
+ if (page.nextCursor) cursor = page.nextCursor;
439
+ else offset += SCAN_PAGE;
440
+ }
441
+ this.log(`sessions: parcours admin interrompu après ${MAX_ADMIN_PAGES} pages (résultat potentiellement partiel)`, "WARNING");
442
+ return false;
443
+ }
444
+ /**
445
+ * Révoque une session par son `ref` public : re-scanne, recalcule le HMAC de
446
+ * chaque id pour retrouver l'id réel (l'id brut ne quitte jamais le process),
447
+ * puis `destroy()`. `ref` étant public (pas un secret), la comparaison directe
448
+ * est sûre. Idempotent — `false` si aucun `ref` ne correspond.
449
+ */
450
+ async destroyByRef(ref, actor) {
451
+ const storage = this.enumerable();
452
+ let destroyed = false;
453
+ await this.eachSessionRecord(void 0, async (rec) => {
454
+ if (this.sessionRef(rec.id) !== ref) return false;
455
+ const subject = typeof rec.data.user === "string" ? rec.data.user : null;
456
+ destroyed = await storage.destroy(rec.id);
457
+ if (destroyed) {
458
+ this.log(`session revoked by admin — ref=${ref} actor=${actor ?? "admin"}`, "INFO");
459
+ this.emitAudit({
460
+ category: "session",
461
+ action: "session.revoked",
462
+ outcome: "success",
463
+ actor: actor ?? null,
464
+ resource: ref,
465
+ metadata: {
466
+ subject,
467
+ viaAdmin: true
468
+ }
469
+ });
470
+ }
471
+ return true;
472
+ });
473
+ return destroyed;
474
+ }
475
+ /**
476
+ * « Déconnexion partout » : détruit TOUTES les sessions d'un utilisateur (scan
477
+ * O(N) — pas d'index inverse, acceptable en admin). Renvoie le nombre détruit.
478
+ */
479
+ async destroyByUser(identifier, actor) {
480
+ const storage = this.enumerable();
481
+ let destroyed = 0;
482
+ for (let pass = 0; pass < MAX_LOGOUT_PASSES; pass += 1) {
483
+ let destroyedThisPass = 0;
484
+ await this.eachSessionRecord({ user: identifier }, async (rec) => {
485
+ if (rec.data.user !== identifier) return false;
486
+ if (await storage.destroy(rec.id)) destroyedThisPass += 1;
487
+ return false;
488
+ });
489
+ destroyed += destroyedThisPass;
490
+ if (destroyedThisPass === 0) break;
491
+ if (pass === 9) this.log(`sessions: logout-all interrompu après ${MAX_LOGOUT_PASSES} passages — user=${identifier} (révocation potentiellement incomplète)`, "WARNING");
492
+ }
493
+ if (destroyed > 0) {
494
+ this.log(`sessions revoked by admin (logout-all) — user=${identifier} count=${destroyed} actor=${actor ?? "admin"}`, "INFO");
495
+ this.emitAudit({
496
+ category: "session",
497
+ action: "session.revoked",
498
+ outcome: "success",
499
+ actor: actor ?? null,
500
+ resource: identifier,
501
+ reason: "logout_all",
502
+ metadata: {
503
+ count: destroyed,
504
+ viaAdmin: true
505
+ }
506
+ });
507
+ }
508
+ return destroyed;
509
+ }
510
+ /**
511
+ * Énumère **une page** des sessions APPARTENANT à `identifier`
512
+ * ({@link ISessionSummary} redactés). Délègue à {@link listSessionsPage} avec le
513
+ * filtre `user` (poussé au store PUIS ré-appliqué — défense en profondeur). Un
514
+ * `identifier` vide renvoie une page vide (jamais les sessions anonymes
515
+ * `user===""`, qui appartiennent à tout le monde et donc à personne).
516
+ */
517
+ async listOwnSessionsPage(identifier, query) {
518
+ if (!identifier) return {
519
+ items: [],
520
+ total: 0,
521
+ limit: query.limit,
522
+ offset: query.offset ?? 0,
523
+ hasNext: false
524
+ };
525
+ return this.listSessionsPage({
526
+ ...query,
527
+ user: identifier
528
+ });
529
+ }
530
+ /**
531
+ * Révoque UNE session **possédée par `identifier`**, désignée par son `ref`
532
+ * public. Contrairement à {@link destroyByRef} (admin, scan GLOBAL), le scan est
533
+ * RESTREINT aux sessions de `identifier` (+ re-check d'appartenance) : un `ref`
534
+ * qui ne lui appartient pas est introuvable → `false`, ce qui ferme l'IDOR.
535
+ * Idempotent. Audité (`self: true`, acteur = le propriétaire).
536
+ */
537
+ async destroyOwnByRef(identifier, ref, actor) {
538
+ if (!identifier) return false;
539
+ const storage = this.enumerable();
540
+ let destroyed = false;
541
+ await this.eachSessionRecord({ user: identifier }, async (rec) => {
542
+ if (rec.data.user !== identifier) return false;
543
+ if (this.sessionRef(rec.id) !== ref) return false;
544
+ destroyed = await storage.destroy(rec.id);
545
+ if (destroyed) {
546
+ this.log(`session revoked by owner — ref=${ref} user=${identifier}`, "INFO");
547
+ this.emitAudit({
548
+ category: "session",
549
+ action: "session.revoked",
550
+ outcome: "success",
551
+ actor: actor ?? identifier,
552
+ resource: ref,
553
+ metadata: {
554
+ subject: identifier,
555
+ self: true
556
+ }
557
+ });
558
+ }
559
+ return true;
560
+ });
561
+ return destroyed;
562
+ }
563
+ /**
564
+ * Émet un événement dans le journal d'audit de `@nodefony/security` s'il est
565
+ * monté (résolu par nom au runtime) — **no-op** si security est absent/désactivé.
566
+ * L'action de révocation reste tracée par `this.log()` dans tous les cas.
567
+ */
568
+ emitAudit(event) {
569
+ this.get("auditService")?.record(event);
570
+ }
571
+ };
572
+ SessionsService = _SessionsService = __decorate([
573
+ injectable(),
574
+ __decorateParam(1, inject("HttpKernel")),
575
+ __decorateMetadata("design:paramtypes", [typeof Module === "undefined" ? Object : Module, typeof http_kernel_default === "undefined" ? Object : http_kernel_default])
576
+ ], SessionsService);
577
+ SessionsService.registerStorage("memory", MemorySessionStorage);
578
+ var sessions_service_default = SessionsService;
579
+ //#endregion
580
+ export { computeSessionRef, sessions_service_default as default, toSessionSummary };
@@ -0,0 +1,72 @@
1
+ import { randomFillSync } from "node:crypto";
2
+ //#region nodefony/service/trace.ts
3
+ /**
4
+ * W3C Trace Context — parse + generate `traceparent` headers (P2.7).
5
+ *
6
+ * Spec: https://www.w3.org/TR/trace-context/
7
+ * Format: `<version>-<traceId>-<parentId>-<flags>`
8
+ * - version: 2 hex chars (currently always `00`; `ff` is reserved/invalid)
9
+ * - traceId: 32 hex chars (16 bytes), non-zero
10
+ * - parentId / spanId: 16 hex chars (8 bytes), non-zero
11
+ * - flags: 2 hex chars (`01` = sampled)
12
+ *
13
+ * Behaviour at the request boundary:
14
+ * - Valid incoming traceparent → keep `version`/`traceId`/`flags`, generate
15
+ * a fresh `parentId` (we are a child span in the existing trace).
16
+ * - Missing or invalid → mint a brand-new traceparent (version `00`,
17
+ * `flags=01` sampled by default).
18
+ *
19
+ * The result is propagated through {@link RequestContext} and echoed on the
20
+ * HTTP response so downstream services and clients can stitch the trace.
21
+ */
22
+ const TRACEPARENT_RE = /^([0-9a-f]{2})-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/;
23
+ /**
24
+ * Parse a `traceparent` header value. Returns `null` when the header is
25
+ * missing, malformed, or carries an all-zero traceId/spanId (per W3C the
26
+ * recipient MUST NOT propagate such values).
27
+ */
28
+ function parseTraceparent(header) {
29
+ if (typeof header !== "string") return null;
30
+ const m = TRACEPARENT_RE.exec(header.trim().toLowerCase());
31
+ if (!m) return null;
32
+ const [, version, traceId, parentId, flags] = m;
33
+ if (version === "ff") return null;
34
+ if (/^0+$/.test(traceId)) return null;
35
+ if (/^0+$/.test(parentId)) return null;
36
+ return {
37
+ version,
38
+ traceId,
39
+ parentId,
40
+ flags
41
+ };
42
+ }
43
+ const ENTROPY_POOL_BYTES = 4096;
44
+ const entropyPool = Buffer.allocUnsafe(ENTROPY_POOL_BYTES);
45
+ let entropyOffset = ENTROPY_POOL_BYTES;
46
+ /** `size` octets aléatoires en hex, puisés dans le pool amorti (refill si épuisé). */
47
+ function randomHex(size) {
48
+ if (entropyOffset + size > ENTROPY_POOL_BYTES) {
49
+ randomFillSync(entropyPool);
50
+ entropyOffset = 0;
51
+ }
52
+ const hex = entropyPool.toString("hex", entropyOffset, entropyOffset + size);
53
+ entropyOffset += size;
54
+ return hex;
55
+ }
56
+ /**
57
+ * Resolve the traceparent to attach to a new request. Honors an incoming
58
+ * valid header, generates a fresh one otherwise.
59
+ *
60
+ * @param header - raw value read from `request.headers.traceparent`
61
+ * @returns the traceparent string to propagate (always well-formed)
62
+ */
63
+ function resolveTraceparent(header) {
64
+ const parsed = parseTraceparent(header);
65
+ if (parsed) {
66
+ const newSpanId = randomHex(8);
67
+ return `${parsed.version}-${parsed.traceId}-${newSpanId}-${parsed.flags}`;
68
+ }
69
+ return `00-${randomHex(16)}-${randomHex(8)}-01`;
70
+ }
71
+ //#endregion
72
+ export { parseTraceparent, resolveTraceparent };