@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.
- package/LICENSE +544 -0
- package/README.md +77 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorate.js +9 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateMetadata.js +6 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateParam.js +8 -0
- package/dist/index.js +108 -0
- package/dist/nodefony/command/assetsPublishCommand.js +102 -0
- package/dist/nodefony/command/certificatesCommand.js +47 -0
- package/dist/nodefony/command/networkCommand.js +27 -0
- package/dist/nodefony/command/proxyGenerateCommand.js +66 -0
- package/dist/nodefony/config/config.js +335 -0
- package/dist/nodefony/config/defineModuleConfig.js +93 -0
- package/dist/nodefony/interfaces/IContext.js +1 -0
- package/dist/nodefony/interfaces/ICookie.js +1 -0
- package/dist/nodefony/interfaces/IErrorRenderer.js +1 -0
- package/dist/nodefony/interfaces/IHttpConfig.js +1 -0
- package/dist/nodefony/interfaces/IHttpKernel.js +1 -0
- package/dist/nodefony/interfaces/IRequest.js +1 -0
- package/dist/nodefony/interfaces/IRequestLogger.js +1 -0
- package/dist/nodefony/interfaces/IResponse.js +1 -0
- package/dist/nodefony/interfaces/ISession.js +1 -0
- package/dist/nodefony/interfaces/IUpload.js +1 -0
- package/dist/nodefony/interfaces/index.js +1 -0
- package/dist/nodefony/service/HttpAdminApi.js +376 -0
- package/dist/nodefony/service/ProfilerAdminApi.js +73 -0
- package/dist/nodefony/service/audit-logger.js +159 -0
- package/dist/nodefony/service/certificates.js +545 -0
- package/dist/nodefony/service/error-renderer.js +320 -0
- package/dist/nodefony/service/http-kernel.js +948 -0
- package/dist/nodefony/service/pretty-request-logger.js +72 -0
- package/dist/nodefony/service/request-logger.js +54 -0
- package/dist/nodefony/service/servers/clientError.js +20 -0
- package/dist/nodefony/service/servers/server-http.js +135 -0
- package/dist/nodefony/service/servers/server-https.js +204 -0
- package/dist/nodefony/service/servers/server-static.js +192 -0
- package/dist/nodefony/service/servers/server-websocket-secure.js +104 -0
- package/dist/nodefony/service/servers/server-websocket.js +104 -0
- package/dist/nodefony/service/servers/serverShutdown.js +31 -0
- package/dist/nodefony/service/servers/wsHeartbeat.js +64 -0
- package/dist/nodefony/service/sessions/sessions-service.js +580 -0
- package/dist/nodefony/service/trace.js +72 -0
- package/dist/nodefony/service/upload/upload-service.js +171 -0
- package/dist/nodefony/src/assets/collectAssets.js +34 -0
- package/dist/nodefony/src/assets/prebuiltUi.js +125 -0
- package/dist/nodefony/src/context/Context.js +415 -0
- package/dist/nodefony/src/context/domainMatcher.js +88 -0
- package/dist/nodefony/src/context/forwarded.js +185 -0
- package/dist/nodefony/src/context/http/HttpContext.js +309 -0
- package/dist/nodefony/src/context/http/Request.js +543 -0
- package/dist/nodefony/src/context/http/Response.js +368 -0
- package/dist/nodefony/src/context/http/parser.js +188 -0
- package/dist/nodefony/src/context/http/urlFastPath.js +103 -0
- package/dist/nodefony/src/context/http2/Request.js +29 -0
- package/dist/nodefony/src/context/http2/Response.js +97 -0
- package/dist/nodefony/src/context/metaData.js +47 -0
- package/dist/nodefony/src/context/requestId.js +41 -0
- package/dist/nodefony/src/context/trustProxy.js +167 -0
- package/dist/nodefony/src/context/websocket/Response.js +181 -0
- package/dist/nodefony/src/context/websocket/WebsocketContext.js +389 -0
- package/dist/nodefony/src/context/websocket/wsBackpressure.js +56 -0
- package/dist/nodefony/src/context/websocket/wsLogContent.js +68 -0
- package/dist/nodefony/src/cookies/cookie.js +258 -0
- package/dist/nodefony/src/errors/httpError.js +69 -0
- package/dist/nodefony/src/profiler/FrameProfile.js +95 -0
- package/dist/nodefony/src/profiler/Profiler.js +139 -0
- package/dist/nodefony/src/proxy/generateProxyConfig.js +157 -0
- package/dist/nodefony/src/rateLimit/IRateLimitStore.js +1 -0
- package/dist/nodefony/src/rateLimit/MemoryRateLimitStore.js +146 -0
- package/dist/nodefony/src/rateLimit/WsConnectionCounter.js +64 -0
- package/dist/nodefony/src/rateLimit/rateLimitFilters.js +20 -0
- package/dist/nodefony/src/servers/portBinder.js +114 -0
- package/dist/nodefony/src/session/session.js +390 -0
- package/dist/nodefony/src/session/storage/MemorySessionStorage.js +185 -0
- package/dist/nodefony/src/session/storage/RevocationGuardStorage.js +137 -0
- package/dist/nodefony/src/session/storage/sessionFilters.js +83 -0
- package/dist/nodefony/src/session/storage/sessionSort.js +53 -0
- package/dist/types/index.d.ts +83 -0
- package/dist/types/nodefony/command/assetsPublishCommand.d.ts +23 -0
- package/dist/types/nodefony/command/certificatesCommand.d.ts +17 -0
- package/dist/types/nodefony/command/networkCommand.d.ts +8 -0
- package/dist/types/nodefony/command/proxyGenerateCommand.d.ts +19 -0
- package/dist/types/nodefony/config/config.d.ts +197 -0
- package/dist/types/nodefony/config/defineModuleConfig.d.ts +39 -0
- package/dist/types/nodefony/interfaces/IContext.d.ts +138 -0
- package/dist/types/nodefony/interfaces/ICookie.d.ts +47 -0
- package/dist/types/nodefony/interfaces/IErrorRenderer.d.ts +55 -0
- package/dist/types/nodefony/interfaces/IHttpConfig.d.ts +12 -0
- package/dist/types/nodefony/interfaces/IHttpKernel.d.ts +10 -0
- package/dist/types/nodefony/interfaces/IRequest.d.ts +35 -0
- package/dist/types/nodefony/interfaces/IRequestLogger.d.ts +31 -0
- package/dist/types/nodefony/interfaces/IResponse.d.ts +39 -0
- package/dist/types/nodefony/interfaces/ISession.d.ts +283 -0
- package/dist/types/nodefony/interfaces/IUpload.d.ts +66 -0
- package/dist/types/nodefony/interfaces/index.d.ts +7 -0
- package/dist/types/nodefony/service/HttpAdminApi.d.ts +18 -0
- package/dist/types/nodefony/service/ProfilerAdminApi.d.ts +23 -0
- package/dist/types/nodefony/service/audit-logger.d.ts +143 -0
- package/dist/types/nodefony/service/certificates.d.ts +246 -0
- package/dist/types/nodefony/service/error-renderer.d.ts +74 -0
- package/dist/types/nodefony/service/http-kernel.d.ts +377 -0
- package/dist/types/nodefony/service/pretty-request-logger.d.ts +25 -0
- package/dist/types/nodefony/service/request-logger.d.ts +18 -0
- package/dist/types/nodefony/service/servers/clientError.d.ts +14 -0
- package/dist/types/nodefony/service/servers/server-http.d.ts +42 -0
- package/dist/types/nodefony/service/servers/server-https.d.ts +41 -0
- package/dist/types/nodefony/service/servers/server-static.d.ts +62 -0
- package/dist/types/nodefony/service/servers/server-websocket-secure.d.ts +29 -0
- package/dist/types/nodefony/service/servers/server-websocket.d.ts +29 -0
- package/dist/types/nodefony/service/servers/serverShutdown.d.ts +27 -0
- package/dist/types/nodefony/service/servers/wsHeartbeat.d.ts +46 -0
- package/dist/types/nodefony/service/sessions/sessions-service.d.ts +218 -0
- package/dist/types/nodefony/service/trace.d.ts +39 -0
- package/dist/types/nodefony/service/upload/upload-service.d.ts +61 -0
- package/dist/types/nodefony/src/assets/collectAssets.d.ts +35 -0
- package/dist/types/nodefony/src/assets/prebuiltUi.d.ts +99 -0
- package/dist/types/nodefony/src/context/Context.d.ts +195 -0
- package/dist/types/nodefony/src/context/domainMatcher.d.ts +67 -0
- package/dist/types/nodefony/src/context/forwarded.d.ts +95 -0
- package/dist/types/nodefony/src/context/http/HttpContext.d.ts +85 -0
- package/dist/types/nodefony/src/context/http/Request.d.ts +203 -0
- package/dist/types/nodefony/src/context/http/Response.d.ts +68 -0
- package/dist/types/nodefony/src/context/http/parser.d.ts +65 -0
- package/dist/types/nodefony/src/context/http/urlFastPath.d.ts +52 -0
- package/dist/types/nodefony/src/context/http2/Request.d.ts +14 -0
- package/dist/types/nodefony/src/context/http2/Response.d.ts +20 -0
- package/dist/types/nodefony/src/context/metaData.d.ts +58 -0
- package/dist/types/nodefony/src/context/requestId.d.ts +28 -0
- package/dist/types/nodefony/src/context/trustProxy.d.ts +77 -0
- package/dist/types/nodefony/src/context/websocket/Response.d.ts +53 -0
- package/dist/types/nodefony/src/context/websocket/WebsocketContext.d.ts +125 -0
- package/dist/types/nodefony/src/context/websocket/wsBackpressure.d.ts +73 -0
- package/dist/types/nodefony/src/context/websocket/wsLogContent.d.ts +37 -0
- package/dist/types/nodefony/src/cookies/cookie.d.ts +88 -0
- package/dist/types/nodefony/src/errors/httpError.d.ts +15 -0
- package/dist/types/nodefony/src/profiler/FrameProfile.d.ts +110 -0
- package/dist/types/nodefony/src/profiler/Profiler.d.ts +192 -0
- package/dist/types/nodefony/src/proxy/generateProxyConfig.d.ts +76 -0
- package/dist/types/nodefony/src/rateLimit/IRateLimitStore.d.ts +98 -0
- package/dist/types/nodefony/src/rateLimit/MemoryRateLimitStore.d.ts +40 -0
- package/dist/types/nodefony/src/rateLimit/WsConnectionCounter.d.ts +37 -0
- package/dist/types/nodefony/src/rateLimit/rateLimitFilters.d.ts +18 -0
- package/dist/types/nodefony/src/servers/portBinder.d.ts +102 -0
- package/dist/types/nodefony/src/session/session.d.ts +171 -0
- package/dist/types/nodefony/src/session/storage/MemorySessionStorage.d.ts +77 -0
- package/dist/types/nodefony/src/session/storage/RevocationGuardStorage.d.ts +81 -0
- package/dist/types/nodefony/src/session/storage/sessionFilters.d.ts +102 -0
- package/dist/types/nodefony/src/session/storage/sessionSort.d.ts +45 -0
- package/docs/cookies.md +365 -0
- package/docs/index.md +163 -0
- package/docs/observabilite.md +460 -0
- package/docs/rate-limit.md +372 -0
- package/docs/servers.md +935 -0
- package/docs/session.md +768 -0
- package/docs/upload.md +460 -0
- 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 };
|