@blamejs/core 0.7.18 → 0.7.20

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 (169) hide show
  1. package/CHANGELOG.md +427 -423
  2. package/README.md +150 -150
  3. package/bin/blamejs.js +0 -0
  4. package/index.js +310 -308
  5. package/lib/api-key.js +660 -660
  6. package/lib/api-snapshot.js +338 -338
  7. package/lib/app-shutdown.js +385 -385
  8. package/lib/app.js +365 -365
  9. package/lib/archive.js +250 -250
  10. package/lib/atomic-file.js +544 -544
  11. package/lib/audit-chain.js +177 -177
  12. package/lib/audit-sign.js +344 -344
  13. package/lib/audit-tools.js +677 -677
  14. package/lib/audit.js +766 -766
  15. package/lib/auth/jwt-external.js +365 -0
  16. package/lib/auth/jwt.js +337 -311
  17. package/lib/auth/lockout.js +436 -436
  18. package/lib/auth/oauth.js +721 -721
  19. package/lib/auth/passkey.js +181 -181
  20. package/lib/auth/password.js +628 -594
  21. package/lib/backup/bundle.js +217 -217
  22. package/lib/backup/crypto.js +176 -176
  23. package/lib/backup/index.js +515 -515
  24. package/lib/backup/manifest.js +282 -282
  25. package/lib/break-glass.js +1338 -1338
  26. package/lib/bundler.js +441 -441
  27. package/lib/cache-redis.js +256 -256
  28. package/lib/cache.js +1206 -1206
  29. package/lib/canonical-json.js +115 -115
  30. package/lib/chain-writer.js +234 -234
  31. package/lib/cli-helpers.js +206 -206
  32. package/lib/cli.js +2334 -2334
  33. package/lib/cluster-provider-db.js +317 -317
  34. package/lib/cluster-storage.js +226 -226
  35. package/lib/cluster.js +703 -703
  36. package/lib/config-drift.js +301 -301
  37. package/lib/consent.js +222 -222
  38. package/lib/constants.js +191 -191
  39. package/lib/cookies.js +350 -315
  40. package/lib/credential-hash.js +322 -322
  41. package/lib/crypto.js +266 -266
  42. package/lib/csv.js +275 -275
  43. package/lib/db-declare-row-policy.js +267 -267
  44. package/lib/db-declare-view.js +420 -420
  45. package/lib/db-query.js +406 -406
  46. package/lib/db-schema.js +319 -319
  47. package/lib/db.js +1288 -1288
  48. package/lib/deprecate.js +222 -222
  49. package/lib/dev.js +335 -335
  50. package/lib/dual-control.js +473 -473
  51. package/lib/error-page.js +420 -420
  52. package/lib/external-db-migrate.js +441 -441
  53. package/lib/external-db.js +1061 -1061
  54. package/lib/file-type.js +273 -273
  55. package/lib/forms.js +422 -422
  56. package/lib/framework-error.js +293 -293
  57. package/lib/framework-schema.js +717 -717
  58. package/lib/handlers.js +350 -350
  59. package/lib/http-client-cookie-jar.js +508 -508
  60. package/lib/http-client.js +1195 -1195
  61. package/lib/i18n.js +878 -878
  62. package/lib/jobs.js +185 -185
  63. package/lib/log-stream-cloudwatch.js +369 -369
  64. package/lib/log-stream-local.js +146 -146
  65. package/lib/log-stream-otlp-grpc.js +410 -410
  66. package/lib/log-stream-otlp.js +286 -286
  67. package/lib/log-stream-syslog.js +302 -302
  68. package/lib/log-stream-webhook.js +199 -199
  69. package/lib/log-stream.js +330 -330
  70. package/lib/log.js +500 -500
  71. package/lib/mail-bounce.js +528 -528
  72. package/lib/mail-dkim.js +369 -369
  73. package/lib/mail.js +981 -981
  74. package/lib/metrics.js +683 -683
  75. package/lib/middleware/api-encrypt.js +936 -936
  76. package/lib/middleware/attach-user.js +157 -157
  77. package/lib/middleware/bearer-auth.js +152 -0
  78. package/lib/middleware/body-parser.js +1170 -1170
  79. package/lib/middleware/bot-guard.js +178 -178
  80. package/lib/middleware/compression.js +452 -452
  81. package/lib/middleware/cors.js +314 -314
  82. package/lib/middleware/csp-nonce.js +348 -348
  83. package/lib/middleware/csrf-protect.js +399 -316
  84. package/lib/middleware/db-role-for.js +264 -264
  85. package/lib/middleware/fetch-metadata.js +129 -0
  86. package/lib/middleware/health.js +392 -392
  87. package/lib/middleware/index.js +85 -79
  88. package/lib/middleware/rate-limit.js +358 -358
  89. package/lib/middleware/request-id.js +61 -61
  90. package/lib/middleware/request-log.js +168 -168
  91. package/lib/middleware/require-auth.js +104 -104
  92. package/lib/middleware/security-headers.js +121 -116
  93. package/lib/middleware/sse.js +166 -166
  94. package/lib/migrations.js +383 -383
  95. package/lib/mtls-ca.js +518 -518
  96. package/lib/mtls-engine-default.js +481 -481
  97. package/lib/network-dns.js +632 -632
  98. package/lib/network-heartbeat.js +290 -290
  99. package/lib/network-nts.js +574 -574
  100. package/lib/network-proxy.js +265 -265
  101. package/lib/network-tls.js +328 -328
  102. package/lib/network.js +233 -233
  103. package/lib/notify.js +612 -612
  104. package/lib/ntp-check.js +229 -229
  105. package/lib/numeric-bounds.js +111 -111
  106. package/lib/object-store/azure-blob-bucket-ops.js +349 -349
  107. package/lib/object-store/azure-blob.js +488 -488
  108. package/lib/object-store/gcs-bucket-ops.js +351 -351
  109. package/lib/object-store/gcs.js +519 -519
  110. package/lib/object-store/http-put.js +153 -153
  111. package/lib/object-store/index.js +197 -197
  112. package/lib/object-store/sigv4-bucket-ops.js +1092 -1092
  113. package/lib/object-store/sigv4.js +903 -903
  114. package/lib/observability.js +151 -151
  115. package/lib/otel-export.js +269 -269
  116. package/lib/pagination.js +464 -464
  117. package/lib/parsers/index.js +80 -80
  118. package/lib/parsers/safe-env.js +642 -642
  119. package/lib/parsers/safe-ini.js +292 -292
  120. package/lib/parsers/safe-toml.js +784 -784
  121. package/lib/parsers/safe-xml.js +390 -390
  122. package/lib/parsers/safe-yaml.js +1015 -1015
  123. package/lib/permissions.js +708 -708
  124. package/lib/pqc-agent.js +87 -87
  125. package/lib/pqc-gate.js +279 -279
  126. package/lib/protobuf-encoder.js +190 -190
  127. package/lib/protocol-dispatcher.js +161 -161
  128. package/lib/pubsub-redis.js +167 -167
  129. package/lib/pubsub.js +429 -429
  130. package/lib/queue-local.js +476 -476
  131. package/lib/queue-redis.js +745 -745
  132. package/lib/queue-sqs.js +319 -319
  133. package/lib/queue.js +695 -695
  134. package/lib/redis-client.js +519 -519
  135. package/lib/request-helpers.js +340 -340
  136. package/lib/restore-bundle.js +237 -237
  137. package/lib/restore-rollback.js +259 -259
  138. package/lib/restore.js +409 -409
  139. package/lib/retry.js +376 -376
  140. package/lib/router.js +748 -748
  141. package/lib/safe-async.js +735 -735
  142. package/lib/safe-buffer.js +237 -237
  143. package/lib/safe-json.js +541 -541
  144. package/lib/safe-schema.js +1266 -1266
  145. package/lib/safe-url.js +159 -159
  146. package/lib/scheduler.js +706 -706
  147. package/lib/security-assert.js +373 -373
  148. package/lib/seeders.js +618 -618
  149. package/lib/session.js +535 -478
  150. package/lib/slug.js +269 -269
  151. package/lib/ssrf-guard.js +401 -401
  152. package/lib/static.js +7 -5
  153. package/lib/storage.js +471 -471
  154. package/lib/subject.js +281 -281
  155. package/lib/template.js +791 -791
  156. package/lib/testing.js +798 -798
  157. package/lib/time.js +310 -310
  158. package/lib/totp.js +302 -302
  159. package/lib/tracing.js +494 -494
  160. package/lib/uuid.js +132 -132
  161. package/lib/validate-opts.js +340 -340
  162. package/lib/vault/index.js +308 -308
  163. package/lib/vault/rotate.js +784 -784
  164. package/lib/vault/wrap.js +296 -296
  165. package/lib/vendor/noble-ciphers.cjs +9 -9
  166. package/lib/webhook.js +595 -595
  167. package/lib/websocket.js +1048 -1048
  168. package/package.json +77 -77
  169. package/sbom.cyclonedx.json +7 -7
package/lib/session.js CHANGED
@@ -1,478 +1,535 @@
1
- "use strict";
2
- /**
3
- * Session store — DB-backed, vault-sealed, sid-hashed-at-rest.
4
- *
5
- * Single-node: stored in the framework's main DB under `_blamejs_sessions`
6
- * (baked into db.js's FRAMEWORK_SCHEMA — apps cannot opt out).
7
- * Cluster mode: stored in external-db under the same name (via
8
- * frameworkSchema.ensureSchema). cluster-storage.execute routes the SQL
9
- * to the right place based on cluster.isClusterMode(); session.js itself
10
- * doesn't branch on mode.
11
- *
12
- * Token discipline:
13
- * - The session id (sid) is a 32-byte random value returned to the caller
14
- * once. The caller stores it in a cookie / authorization header / etc.
15
- * - The DB primary key is sha3('bj-session:' || sid) — the sid itself
16
- * never lands in the database. DB exfiltration alone cannot impersonate
17
- * a session: the attacker would also need the original sid (which only
18
- * the user has).
19
- * - data is vault-sealed JSON; userId is sealed; userIdHash indexes for
20
- * destroyAllForUser without unsealing every row.
21
- *
22
- * Public API:
23
- *
24
- * session.create({ userId, data?, ttlMs? }) → { token, expiresAt }
25
- * session.verify(token) → { userId, data, createdAt, expiresAt, lastActivity } or null
26
- * session.destroy(token) → boolean
27
- * session.destroyAllForUser(userId) → number deleted
28
- * session.touch(token, { extendBy? }) → boolean (updates lastActivity, optionally extends expiresAt)
29
- * session.purgeExpired() → number deleted
30
- * session.count() → number of active (non-expired) sessions
31
- *
32
- * Cluster posture per blamejs-cluster-spec.md:
33
- * create / destroy / destroyAllForUser / touch / purgeExpired
34
- * — leader-only (cluster.requireLeader gate at call entry)
35
- * verify / count
36
- * — anywhere (any node can read shared session state)
37
- */
38
- var audit = require("./audit");
39
- var canonicalJson = require("./canonical-json");
40
- var cluster = require("./cluster");
41
- var clusterStorage = require("./cluster-storage");
42
- var C = require("./constants");
43
- var { generateToken, sha3Hash } = require("./crypto");
44
- var cryptoField = require("./crypto-field");
45
- var requestHelpers = require("./request-helpers");
46
- var safeJson = require("./safe-json");
47
- var { SessionError } = require("./framework-error");
48
-
49
- var _err = SessionError.factory;
50
-
51
- var DEFAULT_TTL_MS = C.TIME.days(7);
52
- // Sanity bound: any session that lives longer than this is almost
53
- // certainly a misconfigured Infinity / oversized literal. Keeps
54
- // expiresAt away from epoch overflow + database-int boundary issues.
55
- var MAX_TTL_MS = C.TIME.days(3650); // ~10 years
56
-
57
- function _validateTtl(ttl, where) {
58
- if (typeof ttl !== "number" || !isFinite(ttl) || ttl <= 0) {
59
- throw _err("INVALID_ARG",
60
- where + ": ttlMs must be a positive finite number, got " + JSON.stringify(ttl), true);
61
- }
62
- if (ttl > MAX_TTL_MS) {
63
- throw _err("INVALID_ARG",
64
- where + ": ttlMs " + ttl + " exceeds maximum " + MAX_TTL_MS + " (~10 years). " +
65
- "Sessions this long suggest a misconfigured value.", true);
66
- }
67
- }
68
- var SID_NAMESPACE = "bj-session:";
69
- // Session ID = 32 random bytes (64 hex chars) 256-bit entropy floor
70
- // keeps sids unforgeable even before sealed-cookie encryption layers on
71
- // top. Routed through C.BYTES so every byte literal in the file lives
72
- // behind the same helper.
73
- var SID_BYTES = C.BYTES.bytes(32);
74
-
75
- // Column order used for INSERT kept as a constant so the placeholders
76
- // list and the values list stay in sync. Must match _blamejs_sessions's
77
- // schema in db.js (single-node) and framework-schema.js (cluster mode).
78
- var SESSION_COLS = ["sidHash", "userId", "userIdHash", "data", "createdAt", "expiresAt", "lastActivity"];
79
-
80
- function _hashSid(sid) {
81
- return sha3Hash(SID_NAMESPACE + sid);
82
- }
83
-
84
- // Build a sealed row object with all SESSION_COLS keys present (null
85
- // where not set). The cryptoField.sealRow call seals userId/data and
86
- // produces userIdHash from userId.
87
- function _sealForInsert(row) {
88
- var sealed = cryptoField.sealRow("_blamejs_sessions", row);
89
- for (var i = 0; i < SESSION_COLS.length; i++) {
90
- if (!(SESSION_COLS[i] in sealed)) sealed[SESSION_COLS[i]] = null;
91
- }
92
- return sealed;
93
- }
94
-
95
- // ---- Public API ----
96
-
97
- // Build a stable fingerprint from a request's client-derived signals.
98
- // Operators opt in via session.create({ req, fingerprintFields }) and
99
- // session.verify({ req, ... }); when the fingerprint drifts (different
100
- // IP / user-agent / accept-language), the verify result carries
101
- // fingerprintDrift: true and an audit event fires. Operators in strict
102
- // mode pass requireFingerprintMatch:true to make the drift kill the
103
- // session; default returns the session (so a phone roaming between
104
- // wifi and LTE doesn't get logged out, but the operator AND ops sees
105
- // the drift signal).
106
- //
107
- // The fingerprint is HMAC'd with the session sid so a stolen DB can't
108
- // be cross-correlated with public IP-UA logs to attribute sessions to
109
- // users — same defense the sidHash already provides for the token
110
- // itself, extended to the fingerprint.
111
- var DEFAULT_FINGERPRINT_FIELDS = ["clientIp", "userAgent", "acceptLanguage"];
112
-
113
- function _buildFingerprintInputs(req, fields) {
114
- if (!req) return null;
115
- var headers = req.headers || {};
116
- var inputs = {};
117
- for (var i = 0; i < fields.length; i++) {
118
- var f = fields[i];
119
- if (f === "clientIp") {
120
- inputs.clientIp = requestHelpers.clientIp(req) || "";
121
- } else if (f === "userAgent") {
122
- inputs.userAgent = String(headers["user-agent"] || "");
123
- } else if (f === "acceptLanguage") {
124
- // Take only the primary tag (en-US,en;q=0.9 en-US) so a
125
- // browser's secondary q-list reordering doesn't flap drift.
126
- var raw = String(headers["accept-language"] || "");
127
- var primary = raw.split(",")[0] || "";
128
- inputs.acceptLanguage = primary.split(";")[0].trim().toLowerCase();
129
- } else if (typeof f === "function") {
130
- try { inputs[f.name || ("custom" + i)] = String(f(req) || ""); }
131
- catch (_e) { inputs[f.name || ("custom" + i)] = ""; }
132
- }
133
- }
134
- return inputs;
135
- }
136
-
137
- function _hashFingerprint(sid, inputs) {
138
- if (!inputs) return null;
139
- // Deterministic stringify with sorted keys at every depth — refuses to
140
- // silently round-trip Date / Buffer / Map / Set / BigInt to "{}".
141
- var canonical = canonicalJson.stringify(inputs);
142
- return sha3Hash("bj-session-fingerprint:" + sid + ":" + canonical);
143
- }
144
-
145
- async function create(opts) {
146
- cluster.requireLeader();
147
- if (!opts || !opts.userId) {
148
- throw _err("INVALID_ARG", "session.create requires { userId }", true);
149
- }
150
- var ttl = opts.ttlMs !== undefined ? opts.ttlMs : DEFAULT_TTL_MS;
151
- _validateTtl(ttl, "session.create");
152
-
153
- var sid = generateToken(SID_BYTES); // hex-encoded; only place the plaintext sid lives
154
- var sidHash = _hashSid(sid);
155
- var nowMs = Date.now();
156
- var expiresAt = nowMs + ttl;
157
-
158
- // Fingerprint capture (opt-in via opts.req). Stored as a reserved
159
- // key inside the sealed `data` field so it lives alongside the
160
- // operator-supplied session data without needing a schema column.
161
- var dataObj = opts.data ? Object.assign({}, opts.data) : null;
162
- var fpFields = Array.isArray(opts.fingerprintFields) && opts.fingerprintFields.length > 0
163
- ? opts.fingerprintFields : DEFAULT_FINGERPRINT_FIELDS;
164
- var fpInputs = _buildFingerprintInputs(opts.req, fpFields);
165
- if (fpInputs) {
166
- if (!dataObj) dataObj = {};
167
- dataObj.__bj_fingerprint = _hashFingerprint(sid, fpInputs);
168
- }
169
-
170
- var sealed = _sealForInsert({
171
- sidHash: sidHash,
172
- userId: opts.userId,
173
- data: dataObj ? JSON.stringify(dataObj) : null,
174
- createdAt: nowMs,
175
- expiresAt: expiresAt,
176
- lastActivity: nowMs,
177
- });
178
- var values = SESSION_COLS.map(function (c) { return sealed[c]; });
179
- var placeholders = SESSION_COLS.map(function () { return "?"; }).join(", ");
180
- var quoted = SESSION_COLS.map(function (c) { return '"' + c + '"'; }).join(", ");
181
- await clusterStorage.execute(
182
- "INSERT INTO _blamejs_sessions (" + quoted + ") VALUES (" + placeholders + ")",
183
- values
184
- );
185
-
186
- return { token: sid, expiresAt: expiresAt };
187
- }
188
-
189
- async function verify(token, verifyOpts) {
190
- if (typeof token !== "string" || token.length === 0) return null;
191
- verifyOpts = verifyOpts || {};
192
- var sidHash = _hashSid(token);
193
-
194
- var row = await clusterStorage.executeOne(
195
- "SELECT sidHash, userId, userIdHash, data, createdAt, expiresAt, lastActivity " +
196
- "FROM _blamejs_sessions WHERE sidHash = ?",
197
- [sidHash]
198
- );
199
- if (!row) return null;
200
- if (Number(row.expiresAt) < Date.now()) {
201
- // Expired — clean up and return null. Cleanup is leader-only;
202
- // verify is anywhere, so a follower observing an expired row
203
- // skips the cleanup (next leader-side call will purge it).
204
- if (cluster.isLeader()) {
205
- try { await _deleteBySidHash(sidHash); } catch (_e) { /* best-effort */ }
206
- }
207
- return null;
208
- }
209
- // Unseal sealed columns (userId, data) using the cryptoField pipeline
210
- // so we return cleartext to the caller — same shape as the previous
211
- // db().from(...).first() path delivered.
212
- var unsealed = cryptoField.unsealRow("_blamejs_sessions", row);
213
- var data = null;
214
- var storedFingerprint = null;
215
- if (unsealed.data) {
216
- try {
217
- data = safeJson.parse(unsealed.data);
218
- if (data && typeof data === "object" && typeof data.__bj_fingerprint === "string") {
219
- storedFingerprint = data.__bj_fingerprint;
220
- // Strip the reserved key from the operator-visible data so
221
- // routes don't accidentally render it / log it / pass it on.
222
- delete data.__bj_fingerprint;
223
- if (Object.keys(data).length === 0) data = null;
224
- }
225
- }
226
- catch (e) {
227
- // Decrypt-then-parse failure is rare but operationally important —
228
- // it usually signals key-rotation skew, DB corruption, or
229
- // tampering. Emit an audit event so ops can spot it before the
230
- // operator notices empty-`data` flows. data stays null so the
231
- // session remains usable for non-data flows.
232
- data = null;
233
- try {
234
- audit.safeEmit({
235
- action: "auth.session.data_unparseable",
236
- outcome: "failure",
237
- reason: (e && e.message) || String(e),
238
- metadata: { hasUserId: !!unsealed.userId },
239
- });
240
- } catch (_ignored) { /* audit best-effort */ }
241
- }
242
- }
243
-
244
- // Fingerprint check opt-in via verifyOpts.req. When the stored
245
- // fingerprint differs from the current request's fingerprint, audit
246
- // the drift and (in strict mode) refuse the session. Default mode
247
- // returns the session with `fingerprintDrift: true` so the operator
248
- // can decide (some drift — phone roaming wifi/LTE — is benign; a
249
- // login-from-Tokyo-then-immediately-from-Brazil pattern is not).
250
- var fingerprintDrift = false;
251
- var fingerprintAnomalyScore = null;
252
- if (storedFingerprint && verifyOpts.req) {
253
- var fpFields = Array.isArray(verifyOpts.fingerprintFields) && verifyOpts.fingerprintFields.length > 0
254
- ? verifyOpts.fingerprintFields : DEFAULT_FINGERPRINT_FIELDS;
255
- var currentInputs = _buildFingerprintInputs(verifyOpts.req, fpFields);
256
- var currentHash = _hashFingerprint(token, currentInputs);
257
- if (currentHash !== storedFingerprint) {
258
- fingerprintDrift = true;
259
- // Operator-supplied scorer: receives { storedHash, currentInputs,
260
- // currentHash, sessionAge: ms-since-create }. Returns a number
261
- // in [0, 1] 0 = benign drift (phone roaming wifi), 1 =
262
- // definitely-malicious. Errors are swallowed; scorer-throw
263
- // doesn't break verify.
264
- if (typeof verifyOpts.scorer === "function") {
265
- try {
266
- var rawScore = verifyOpts.scorer({
267
- storedHash: storedFingerprint,
268
- currentInputs: currentInputs,
269
- currentHash: currentHash,
270
- sessionAge: Date.now() - Number(unsealed.createdAt),
271
- });
272
- if (typeof rawScore === "number" && isFinite(rawScore)) {
273
- fingerprintAnomalyScore = Math.max(0, Math.min(1, rawScore));
274
- }
275
- } catch (_e) { /* scorer best-effort */ }
276
- }
277
- try {
278
- audit.safeEmit({
279
- action: "auth.session.fingerprint_drift",
280
- outcome: "warning",
281
- metadata: { hasUserId: !!unsealed.userId,
282
- anomalyScore: fingerprintAnomalyScore },
283
- });
284
- } catch (_ignored) { /* audit best-effort */ }
285
- // Strict modes:
286
- // requireFingerprintMatch: true — any drift kills the session
287
- // maxAnomalyScore: <0..1> — drift above threshold kills
288
- if (verifyOpts.requireFingerprintMatch === true) {
289
- return null;
290
- }
291
- if (typeof verifyOpts.maxAnomalyScore === "number" &&
292
- fingerprintAnomalyScore !== null &&
293
- fingerprintAnomalyScore > verifyOpts.maxAnomalyScore) {
294
- return null;
295
- }
296
- }
297
- }
298
-
299
- return {
300
- userId: unsealed.userId,
301
- data: data,
302
- createdAt: Number(unsealed.createdAt),
303
- expiresAt: Number(unsealed.expiresAt),
304
- lastActivity: Number(unsealed.lastActivity),
305
- fingerprintDrift: fingerprintDrift,
306
- fingerprintAnomalyScore: fingerprintAnomalyScore,
307
- };
308
- }
309
-
310
- async function destroy(token) {
311
- cluster.requireLeader();
312
- if (typeof token !== "string" || token.length === 0) return false;
313
- return await _deleteBySidHash(_hashSid(token));
314
- }
315
-
316
- async function _deleteBySidHash(sidHash) {
317
- var result = await clusterStorage.execute(
318
- "DELETE FROM _blamejs_sessions WHERE sidHash = ?",
319
- [sidHash]
320
- );
321
- return (result.rowCount || 0) > 0;
322
- }
323
-
324
- async function destroyAllForUser(userId) {
325
- cluster.requireLeader();
326
- if (!userId) throw _err("INVALID_ARG", "session.destroyAllForUser requires a userId", true);
327
- // userId is sealed; look up via derived userIdHash.
328
- var lookup = cryptoField.lookupHash("_blamejs_sessions", "userId", userId);
329
- if (!lookup) {
330
- throw _err("MISCONFIGURED",
331
- "_blamejs_sessions schema is missing the userIdHash derived hash — framework misconfigured",
332
- true);
333
- }
334
- var result = await clusterStorage.execute(
335
- "DELETE FROM _blamejs_sessions WHERE userIdHash = ?",
336
- [lookup.value]
337
- );
338
- return result.rowCount || 0;
339
- }
340
-
341
- async function touch(token, opts) {
342
- cluster.requireLeader();
343
- opts = opts || {};
344
- if (typeof token !== "string" || token.length === 0) return false;
345
- var sidHash = _hashSid(token);
346
- var nowMs = Date.now();
347
- // Two SQL paths so the SET list stays static (no dynamic column
348
- // assembly) and matches the call shape clusterStorage expects.
349
- // extendBy resets expiresAt relative to NOW, not relative to the
350
- // current expiresAt — a soaked session with continuous traffic
351
- // shouldn't accumulate unbounded expiry. The same MAX_TTL_MS
352
- // ceiling create() and rotate() apply gates extendBy too — repeated
353
- // touch() calls cannot push expiresAt past the framework's bound.
354
- if (opts.extendBy !== undefined && opts.extendBy !== null) {
355
- _validateTtl(opts.extendBy, "session.touch");
356
- var newExpires = nowMs + opts.extendBy;
357
- var result = await clusterStorage.execute(
358
- "UPDATE _blamejs_sessions SET lastActivity = ?, expiresAt = ? " +
359
- "WHERE sidHash = ? AND expiresAt >= ?",
360
- [nowMs, newExpires, sidHash, nowMs]
361
- );
362
- return (result.rowCount || 0) > 0;
363
- }
364
- var result2 = await clusterStorage.execute(
365
- "UPDATE _blamejs_sessions SET lastActivity = ? " +
366
- "WHERE sidHash = ? AND expiresAt >= ?",
367
- [nowMs, sidHash, nowMs]
368
- );
369
- return (result2.rowCount || 0) > 0;
370
- }
371
-
372
- // rotate(oldToken, opts?) — session fixation defense. Generates a fresh
373
- // sid for the same userId + data, atomically replacing the old sid in
374
- // the row. Standard pattern: call after auth state changes (login from
375
- // anonymous, MFA verified, role escalation) so any sid an attacker
376
- // might have planted pre-login becomes invalid.
377
- //
378
- // opts:
379
- // data: optional replacement session data (re-sealed)
380
- // ttlMs: optional new TTL; if absent, expiresAt is preserved
381
- // reason: free-form audit metadata ('login', 'mfa', etc.)
382
- //
383
- // Returns { token, expiresAt } on success, or null when the old token
384
- // doesn't exist / has expired (operator distinguishes by checking
385
- // for null).
386
- //
387
- // Atomicity: single UPDATE swaps sidHash. The old + new tokens never
388
- // coexist the moment the UPDATE commits, only the new token verifies.
389
- // Backends that can't do the WHERE-guarded UPDATE atomically (none of
390
- // the framework's supported backends fall in that bucket) would need
391
- // a transactional shim.
392
- async function rotate(oldToken, opts) {
393
- cluster.requireLeader();
394
- if (typeof oldToken !== "string" || oldToken.length === 0) return null;
395
- opts = opts || {};
396
-
397
- var newSid = generateToken(SID_BYTES);
398
- var newSidHash = _hashSid(newSid);
399
- var oldSidHash = _hashSid(oldToken);
400
- var nowMs = Date.now();
401
- var newExpires = null;
402
- if (opts.ttlMs !== undefined) {
403
- _validateTtl(opts.ttlMs, "session.rotate");
404
- newExpires = nowMs + opts.ttlMs;
405
- }
406
-
407
- var setParts = ['"sidHash" = ?', '"lastActivity" = ?'];
408
- var setParams = [newSidHash, nowMs];
409
-
410
- if (opts.data !== undefined) {
411
- var dataJson = opts.data ? JSON.stringify(opts.data) : null;
412
- var sealedRow = cryptoField.sealRow("_blamejs_sessions", { data: dataJson });
413
- setParts.push('"data" = ?');
414
- setParams.push(sealedRow.data);
415
- }
416
- if (newExpires !== null) {
417
- setParts.push('"expiresAt" = ?');
418
- setParams.push(newExpires);
419
- }
420
-
421
- var sql = "UPDATE _blamejs_sessions SET " + setParts.join(", ") +
422
- " WHERE sidHash = ? AND expiresAt >= ?";
423
- var params = setParams.concat([oldSidHash, nowMs]);
424
- var result = await clusterStorage.execute(sql, params);
425
- if ((result.rowCount || 0) === 0) return null;
426
-
427
- // Read the row's effective expiresAt to return — single source of truth.
428
- var row = await clusterStorage.executeOne(
429
- 'SELECT "expiresAt" FROM _blamejs_sessions WHERE sidHash = ?',
430
- [newSidHash]
431
- );
432
- var expiresAt = row ? Number(row.expiresAt) : null;
433
-
434
- // Audit emit — best-effort. The framework's audit chain logs the
435
- // privilege transition so post-incident review can trace which
436
- // session id covered which privilege state.
437
- try {
438
- audit.emit({
439
- action: "auth.session.rotate",
440
- outcome: "success",
441
- metadata: { reason: opts.reason || "explicit" },
442
- });
443
- } catch (_e) { /* audit emit best-effort — never block rotate() */ }
444
-
445
- return { token: newSid, expiresAt: expiresAt };
446
- }
447
-
448
- async function purgeExpired() {
449
- cluster.requireLeader();
450
- var result = await clusterStorage.execute(
451
- "DELETE FROM _blamejs_sessions WHERE expiresAt < ?",
452
- [Date.now()]
453
- );
454
- return result.rowCount || 0;
455
- }
456
-
457
- async function count() {
458
- var row = await clusterStorage.executeOne(
459
- "SELECT COUNT(*) AS c FROM _blamejs_sessions WHERE expiresAt >= ?",
460
- [Date.now()]
461
- );
462
- return row ? Number(row.c) : 0;
463
- }
464
-
465
- function _resetForTest() { /* no module state to reset; clusterStorage and cryptoField own theirs */ }
466
-
467
- module.exports = {
468
- create: create,
469
- verify: verify,
470
- destroy: destroy,
471
- destroyAllForUser: destroyAllForUser,
472
- touch: touch,
473
- rotate: rotate,
474
- purgeExpired: purgeExpired,
475
- count: count,
476
- DEFAULT_TTL_MS: DEFAULT_TTL_MS,
477
- _resetForTest: _resetForTest,
478
- };
1
+ "use strict";
2
+ /**
3
+ * Session store — DB-backed, vault-sealed, sid-hashed-at-rest.
4
+ *
5
+ * Single-node: stored in the framework's main DB under `_blamejs_sessions`
6
+ * (baked into db.js's FRAMEWORK_SCHEMA — apps cannot opt out).
7
+ * Cluster mode: stored in external-db under the same name (via
8
+ * frameworkSchema.ensureSchema). cluster-storage.execute routes the SQL
9
+ * to the right place based on cluster.isClusterMode(); session.js itself
10
+ * doesn't branch on mode.
11
+ *
12
+ * Token discipline:
13
+ * - The session id (sid) is a 32-byte random value returned to the caller
14
+ * once. The caller stores it in a cookie / authorization header / etc.
15
+ * - The DB primary key is sha3('bj-session:' || sid) — the sid itself
16
+ * never lands in the database. DB exfiltration alone cannot impersonate
17
+ * a session: the attacker would also need the original sid (which only
18
+ * the user has).
19
+ * - data is vault-sealed JSON; userId is sealed; userIdHash indexes for
20
+ * destroyAllForUser without unsealing every row.
21
+ *
22
+ * Public API:
23
+ *
24
+ * session.create({ userId, data?, ttlMs? }) → { token, expiresAt }
25
+ * session.verify(token) → { userId, data, createdAt, expiresAt, lastActivity } or null
26
+ * session.destroy(token) → boolean
27
+ * session.destroyAllForUser(userId) → number deleted
28
+ * session.touch(token, { extendBy? }) → boolean (updates lastActivity, optionally extends expiresAt)
29
+ * session.purgeExpired() → number deleted
30
+ * session.count() → number of active (non-expired) sessions
31
+ *
32
+ * Cluster posture per blamejs-cluster-spec.md:
33
+ * create / destroy / destroyAllForUser / touch / purgeExpired
34
+ * — leader-only (cluster.requireLeader gate at call entry)
35
+ * verify / count
36
+ * — anywhere (any node can read shared session state)
37
+ */
38
+ var audit = require("./audit");
39
+ var canonicalJson = require("./canonical-json");
40
+ var cluster = require("./cluster");
41
+ var clusterStorage = require("./cluster-storage");
42
+ var C = require("./constants");
43
+ var { generateToken, sha3Hash } = require("./crypto");
44
+ var cryptoField = require("./crypto-field");
45
+ var requestHelpers = require("./request-helpers");
46
+ var safeJson = require("./safe-json");
47
+ var { SessionError } = require("./framework-error");
48
+
49
+ var _err = SessionError.factory;
50
+
51
+ var DEFAULT_TTL_MS = C.TIME.days(7);
52
+ // Sanity bound: any session that lives longer than this is almost
53
+ // certainly a misconfigured Infinity / oversized literal. Keeps
54
+ // expiresAt away from epoch overflow + database-int boundary issues.
55
+ var MAX_TTL_MS = C.TIME.days(3650); // ~10 years
56
+
57
+ // Idle + absolute timeout defaults per OWASP ASVS 5.0 §3.3 + NIST
58
+ // SP 800-63B-4. expiresAt is the operator-set window; idle/absolute
59
+ // are independent enforcement floors that shorten the effective
60
+ // session lifetime even when the operator picked a long ttlMs.
61
+ //
62
+ // - idle: session expires N ms after the last verify() / touch().
63
+ // Default 30 minutes — short enough to defeat session-token
64
+ // theft via short-lived foothold; long enough that a user
65
+ // reading a long article doesn't get logged out.
66
+ // - absolute: session always expires at most N ms after creation,
67
+ // regardless of activity. Default 12 hours — re-auth at
68
+ // least once per shift even on a continuously-active
69
+ // session. Repeated touch() with extendBy cannot push past
70
+ // this ceiling.
71
+ var DEFAULT_IDLE_TIMEOUT_MS = C.TIME.minutes(30);
72
+ var DEFAULT_ABSOLUTE_TIMEOUT_MS = C.TIME.hours(12);
73
+
74
+ function _validateTtl(ttl, where) {
75
+ if (typeof ttl !== "number" || !isFinite(ttl) || ttl <= 0) {
76
+ throw _err("INVALID_ARG",
77
+ where + ": ttlMs must be a positive finite number, got " + JSON.stringify(ttl), true);
78
+ }
79
+ if (ttl > MAX_TTL_MS) {
80
+ throw _err("INVALID_ARG",
81
+ where + ": ttlMs " + ttl + " exceeds maximum " + MAX_TTL_MS + " (~10 years). " +
82
+ "Sessions this long suggest a misconfigured value.", true);
83
+ }
84
+ }
85
+ var SID_NAMESPACE = "bj-session:";
86
+ // Session ID = 32 random bytes (64 hex chars) — 256-bit entropy floor
87
+ // keeps sids unforgeable even before sealed-cookie encryption layers on
88
+ // top. Routed through C.BYTES so every byte literal in the file lives
89
+ // behind the same helper.
90
+ var SID_BYTES = C.BYTES.bytes(32);
91
+
92
+ // Column order used for INSERT — kept as a constant so the placeholders
93
+ // list and the values list stay in sync. Must match _blamejs_sessions's
94
+ // schema in db.js (single-node) and framework-schema.js (cluster mode).
95
+ var SESSION_COLS = ["sidHash", "userId", "userIdHash", "data", "createdAt", "expiresAt", "lastActivity"];
96
+
97
+ function _hashSid(sid) {
98
+ return sha3Hash(SID_NAMESPACE + sid);
99
+ }
100
+
101
+ // Build a sealed row object with all SESSION_COLS keys present (null
102
+ // where not set). The cryptoField.sealRow call seals userId/data and
103
+ // produces userIdHash from userId.
104
+ function _sealForInsert(row) {
105
+ var sealed = cryptoField.sealRow("_blamejs_sessions", row);
106
+ for (var i = 0; i < SESSION_COLS.length; i++) {
107
+ if (!(SESSION_COLS[i] in sealed)) sealed[SESSION_COLS[i]] = null;
108
+ }
109
+ return sealed;
110
+ }
111
+
112
+ // ---- Public API ----
113
+
114
+ // Build a stable fingerprint from a request's client-derived signals.
115
+ // Operators opt in via session.create({ req, fingerprintFields }) and
116
+ // session.verify({ req, ... }); when the fingerprint drifts (different
117
+ // IP / user-agent / accept-language), the verify result carries
118
+ // fingerprintDrift: true and an audit event fires. Operators in strict
119
+ // mode pass requireFingerprintMatch:true to make the drift kill the
120
+ // session; default returns the session (so a phone roaming between
121
+ // wifi and LTE doesn't get logged out, but the operator AND ops sees
122
+ // the drift signal).
123
+ //
124
+ // The fingerprint is HMAC'd with the session sid so a stolen DB can't
125
+ // be cross-correlated with public IP-UA logs to attribute sessions to
126
+ // users same defense the sidHash already provides for the token
127
+ // itself, extended to the fingerprint.
128
+ var DEFAULT_FINGERPRINT_FIELDS = ["clientIp", "userAgent", "acceptLanguage"];
129
+
130
+ function _buildFingerprintInputs(req, fields) {
131
+ if (!req) return null;
132
+ var headers = req.headers || {};
133
+ var inputs = {};
134
+ for (var i = 0; i < fields.length; i++) {
135
+ var f = fields[i];
136
+ if (f === "clientIp") {
137
+ inputs.clientIp = requestHelpers.clientIp(req) || "";
138
+ } else if (f === "userAgent") {
139
+ inputs.userAgent = String(headers["user-agent"] || "");
140
+ } else if (f === "acceptLanguage") {
141
+ // Take only the primary tag (en-US,en;q=0.9 → en-US) so a
142
+ // browser's secondary q-list reordering doesn't flap drift.
143
+ var raw = String(headers["accept-language"] || "");
144
+ var primary = raw.split(",")[0] || "";
145
+ inputs.acceptLanguage = primary.split(";")[0].trim().toLowerCase();
146
+ } else if (typeof f === "function") {
147
+ try { inputs[f.name || ("custom" + i)] = String(f(req) || ""); }
148
+ catch (_e) { inputs[f.name || ("custom" + i)] = ""; }
149
+ }
150
+ }
151
+ return inputs;
152
+ }
153
+
154
+ function _hashFingerprint(sid, inputs) {
155
+ if (!inputs) return null;
156
+ // Deterministic stringify with sorted keys at every depth — refuses to
157
+ // silently round-trip Date / Buffer / Map / Set / BigInt to "{}".
158
+ var canonical = canonicalJson.stringify(inputs);
159
+ return sha3Hash("bj-session-fingerprint:" + sid + ":" + canonical);
160
+ }
161
+
162
+ async function create(opts) {
163
+ cluster.requireLeader();
164
+ if (!opts || !opts.userId) {
165
+ throw _err("INVALID_ARG", "session.create requires { userId }", true);
166
+ }
167
+ var ttl = opts.ttlMs !== undefined ? opts.ttlMs : DEFAULT_TTL_MS;
168
+ _validateTtl(ttl, "session.create");
169
+
170
+ var sid = generateToken(SID_BYTES); // hex-encoded; only place the plaintext sid lives
171
+ var sidHash = _hashSid(sid);
172
+ var nowMs = Date.now();
173
+ var expiresAt = nowMs + ttl;
174
+
175
+ // Fingerprint capture (opt-in via opts.req). Stored as a reserved
176
+ // key inside the sealed `data` field so it lives alongside the
177
+ // operator-supplied session data without needing a schema column.
178
+ var dataObj = opts.data ? Object.assign({}, opts.data) : null;
179
+ var fpFields = Array.isArray(opts.fingerprintFields) && opts.fingerprintFields.length > 0
180
+ ? opts.fingerprintFields : DEFAULT_FINGERPRINT_FIELDS;
181
+ var fpInputs = _buildFingerprintInputs(opts.req, fpFields);
182
+ if (fpInputs) {
183
+ if (!dataObj) dataObj = {};
184
+ dataObj.__bj_fingerprint = _hashFingerprint(sid, fpInputs);
185
+ }
186
+
187
+ var sealed = _sealForInsert({
188
+ sidHash: sidHash,
189
+ userId: opts.userId,
190
+ data: dataObj ? JSON.stringify(dataObj) : null,
191
+ createdAt: nowMs,
192
+ expiresAt: expiresAt,
193
+ lastActivity: nowMs,
194
+ });
195
+ var values = SESSION_COLS.map(function (c) { return sealed[c]; });
196
+ var placeholders = SESSION_COLS.map(function () { return "?"; }).join(", ");
197
+ var quoted = SESSION_COLS.map(function (c) { return '"' + c + '"'; }).join(", ");
198
+ await clusterStorage.execute(
199
+ "INSERT INTO _blamejs_sessions (" + quoted + ") VALUES (" + placeholders + ")",
200
+ values
201
+ );
202
+
203
+ return { token: sid, expiresAt: expiresAt };
204
+ }
205
+
206
+ async function verify(token, verifyOpts) {
207
+ if (typeof token !== "string" || token.length === 0) return null;
208
+ verifyOpts = verifyOpts || {};
209
+ var sidHash = _hashSid(token);
210
+
211
+ var row = await clusterStorage.executeOne(
212
+ "SELECT sidHash, userId, userIdHash, data, createdAt, expiresAt, lastActivity " +
213
+ "FROM _blamejs_sessions WHERE sidHash = ?",
214
+ [sidHash]
215
+ );
216
+ if (!row) return null;
217
+ var nowMs = Date.now();
218
+ if (Number(row.expiresAt) < nowMs) {
219
+ // Expired (operator-set ttl) — clean up and return null. Cleanup
220
+ // is leader-only; verify is anywhere, so a follower observing an
221
+ // expired row skips the cleanup (next leader-side call purges it).
222
+ if (cluster.isLeader()) {
223
+ try { await _deleteBySidHash(sidHash); } catch (_e) { /* best-effort */ }
224
+ }
225
+ return null;
226
+ }
227
+
228
+ // Idle + absolute timeout enforcement (OWASP ASVS 5.0 §3.3 / NIST
229
+ // SP 800-63B-4). These shorten the effective lifetime even when the
230
+ // operator picked a long ttlMs. Defaults: idle 30m, absolute 12h.
231
+ // Operator opt-out by passing 0 (disables that timeout).
232
+ var idleMs = verifyOpts.idleTimeoutMs !== undefined
233
+ ? verifyOpts.idleTimeoutMs : DEFAULT_IDLE_TIMEOUT_MS;
234
+ var absMs = verifyOpts.absoluteTimeoutMs !== undefined
235
+ ? verifyOpts.absoluteTimeoutMs : DEFAULT_ABSOLUTE_TIMEOUT_MS;
236
+ if (idleMs > 0) {
237
+ var lastActivity = Number(row.lastActivity);
238
+ if ((nowMs - lastActivity) > idleMs) {
239
+ try {
240
+ audit.safeEmit({
241
+ action: "auth.session.expired_idle", outcome: "warning",
242
+ metadata: { idleMs: nowMs - lastActivity, threshold: idleMs },
243
+ });
244
+ } catch (_ignored) { /* audit best-effort */ }
245
+ if (cluster.isLeader()) {
246
+ try { await _deleteBySidHash(sidHash); } catch (_e) { /* best-effort */ }
247
+ }
248
+ return null;
249
+ }
250
+ }
251
+ if (absMs > 0) {
252
+ var createdAt = Number(row.createdAt);
253
+ if ((nowMs - createdAt) > absMs) {
254
+ try {
255
+ audit.safeEmit({
256
+ action: "auth.session.expired_absolute", outcome: "warning",
257
+ metadata: { ageMs: nowMs - createdAt, threshold: absMs },
258
+ });
259
+ } catch (_ignored) { /* audit best-effort */ }
260
+ if (cluster.isLeader()) {
261
+ try { await _deleteBySidHash(sidHash); } catch (_e) { /* best-effort */ }
262
+ }
263
+ return null;
264
+ }
265
+ }
266
+ // Unseal sealed columns (userId, data) using the cryptoField pipeline
267
+ // so we return cleartext to the caller — same shape as the previous
268
+ // db().from(...).first() path delivered.
269
+ var unsealed = cryptoField.unsealRow("_blamejs_sessions", row);
270
+ var data = null;
271
+ var storedFingerprint = null;
272
+ if (unsealed.data) {
273
+ try {
274
+ data = safeJson.parse(unsealed.data);
275
+ if (data && typeof data === "object" && typeof data.__bj_fingerprint === "string") {
276
+ storedFingerprint = data.__bj_fingerprint;
277
+ // Strip the reserved key from the operator-visible data so
278
+ // routes don't accidentally render it / log it / pass it on.
279
+ delete data.__bj_fingerprint;
280
+ if (Object.keys(data).length === 0) data = null;
281
+ }
282
+ }
283
+ catch (e) {
284
+ // Decrypt-then-parse failure is rare but operationally important
285
+ // it usually signals key-rotation skew, DB corruption, or
286
+ // tampering. Emit an audit event so ops can spot it before the
287
+ // operator notices empty-`data` flows. data stays null so the
288
+ // session remains usable for non-data flows.
289
+ data = null;
290
+ try {
291
+ audit.safeEmit({
292
+ action: "auth.session.data_unparseable",
293
+ outcome: "failure",
294
+ reason: (e && e.message) || String(e),
295
+ metadata: { hasUserId: !!unsealed.userId },
296
+ });
297
+ } catch (_ignored) { /* audit best-effort */ }
298
+ }
299
+ }
300
+
301
+ // Fingerprint check — opt-in via verifyOpts.req. When the stored
302
+ // fingerprint differs from the current request's fingerprint, audit
303
+ // the drift and (in strict mode) refuse the session. Default mode
304
+ // returns the session with `fingerprintDrift: true` so the operator
305
+ // can decide (some drift — phone roaming wifi/LTE — is benign; a
306
+ // login-from-Tokyo-then-immediately-from-Brazil pattern is not).
307
+ var fingerprintDrift = false;
308
+ var fingerprintAnomalyScore = null;
309
+ if (storedFingerprint && verifyOpts.req) {
310
+ var fpFields = Array.isArray(verifyOpts.fingerprintFields) && verifyOpts.fingerprintFields.length > 0
311
+ ? verifyOpts.fingerprintFields : DEFAULT_FINGERPRINT_FIELDS;
312
+ var currentInputs = _buildFingerprintInputs(verifyOpts.req, fpFields);
313
+ var currentHash = _hashFingerprint(token, currentInputs);
314
+ if (currentHash !== storedFingerprint) {
315
+ fingerprintDrift = true;
316
+ // Operator-supplied scorer: receives { storedHash, currentInputs,
317
+ // currentHash, sessionAge: ms-since-create }. Returns a number
318
+ // in [0, 1] 0 = benign drift (phone roaming wifi), 1 =
319
+ // definitely-malicious. Errors are swallowed; scorer-throw
320
+ // doesn't break verify.
321
+ if (typeof verifyOpts.scorer === "function") {
322
+ try {
323
+ var rawScore = verifyOpts.scorer({
324
+ storedHash: storedFingerprint,
325
+ currentInputs: currentInputs,
326
+ currentHash: currentHash,
327
+ sessionAge: Date.now() - Number(unsealed.createdAt),
328
+ });
329
+ if (typeof rawScore === "number" && isFinite(rawScore)) {
330
+ fingerprintAnomalyScore = Math.max(0, Math.min(1, rawScore));
331
+ }
332
+ } catch (_e) { /* scorer best-effort */ }
333
+ }
334
+ try {
335
+ audit.safeEmit({
336
+ action: "auth.session.fingerprint_drift",
337
+ outcome: "warning",
338
+ metadata: { hasUserId: !!unsealed.userId,
339
+ anomalyScore: fingerprintAnomalyScore },
340
+ });
341
+ } catch (_ignored) { /* audit best-effort */ }
342
+ // Strict modes:
343
+ // requireFingerprintMatch: true — any drift kills the session
344
+ // maxAnomalyScore: <0..1> — drift above threshold kills
345
+ if (verifyOpts.requireFingerprintMatch === true) {
346
+ return null;
347
+ }
348
+ if (typeof verifyOpts.maxAnomalyScore === "number" &&
349
+ fingerprintAnomalyScore !== null &&
350
+ fingerprintAnomalyScore > verifyOpts.maxAnomalyScore) {
351
+ return null;
352
+ }
353
+ }
354
+ }
355
+
356
+ return {
357
+ userId: unsealed.userId,
358
+ data: data,
359
+ createdAt: Number(unsealed.createdAt),
360
+ expiresAt: Number(unsealed.expiresAt),
361
+ lastActivity: Number(unsealed.lastActivity),
362
+ fingerprintDrift: fingerprintDrift,
363
+ fingerprintAnomalyScore: fingerprintAnomalyScore,
364
+ };
365
+ }
366
+
367
+ async function destroy(token) {
368
+ cluster.requireLeader();
369
+ if (typeof token !== "string" || token.length === 0) return false;
370
+ return await _deleteBySidHash(_hashSid(token));
371
+ }
372
+
373
+ async function _deleteBySidHash(sidHash) {
374
+ var result = await clusterStorage.execute(
375
+ "DELETE FROM _blamejs_sessions WHERE sidHash = ?",
376
+ [sidHash]
377
+ );
378
+ return (result.rowCount || 0) > 0;
379
+ }
380
+
381
+ async function destroyAllForUser(userId) {
382
+ cluster.requireLeader();
383
+ if (!userId) throw _err("INVALID_ARG", "session.destroyAllForUser requires a userId", true);
384
+ // userId is sealed; look up via derived userIdHash.
385
+ var lookup = cryptoField.lookupHash("_blamejs_sessions", "userId", userId);
386
+ if (!lookup) {
387
+ throw _err("MISCONFIGURED",
388
+ "_blamejs_sessions schema is missing the userIdHash derived hash framework misconfigured",
389
+ true);
390
+ }
391
+ var result = await clusterStorage.execute(
392
+ "DELETE FROM _blamejs_sessions WHERE userIdHash = ?",
393
+ [lookup.value]
394
+ );
395
+ return result.rowCount || 0;
396
+ }
397
+
398
+ async function touch(token, opts) {
399
+ cluster.requireLeader();
400
+ opts = opts || {};
401
+ if (typeof token !== "string" || token.length === 0) return false;
402
+ var sidHash = _hashSid(token);
403
+ var nowMs = Date.now();
404
+ // Two SQL paths so the SET list stays static (no dynamic column
405
+ // assembly) and matches the call shape clusterStorage expects.
406
+ // extendBy resets expiresAt relative to NOW, not relative to the
407
+ // current expiresAt a soaked session with continuous traffic
408
+ // shouldn't accumulate unbounded expiry. The same MAX_TTL_MS
409
+ // ceiling create() and rotate() apply gates extendBy too — repeated
410
+ // touch() calls cannot push expiresAt past the framework's bound.
411
+ if (opts.extendBy !== undefined && opts.extendBy !== null) {
412
+ _validateTtl(opts.extendBy, "session.touch");
413
+ var newExpires = nowMs + opts.extendBy;
414
+ var result = await clusterStorage.execute(
415
+ "UPDATE _blamejs_sessions SET lastActivity = ?, expiresAt = ? " +
416
+ "WHERE sidHash = ? AND expiresAt >= ?",
417
+ [nowMs, newExpires, sidHash, nowMs]
418
+ );
419
+ return (result.rowCount || 0) > 0;
420
+ }
421
+ var result2 = await clusterStorage.execute(
422
+ "UPDATE _blamejs_sessions SET lastActivity = ? " +
423
+ "WHERE sidHash = ? AND expiresAt >= ?",
424
+ [nowMs, sidHash, nowMs]
425
+ );
426
+ return (result2.rowCount || 0) > 0;
427
+ }
428
+
429
+ // rotate(oldToken, opts?) session fixation defense. Generates a fresh
430
+ // sid for the same userId + data, atomically replacing the old sid in
431
+ // the row. Standard pattern: call after auth state changes (login from
432
+ // anonymous, MFA verified, role escalation) so any sid an attacker
433
+ // might have planted pre-login becomes invalid.
434
+ //
435
+ // opts:
436
+ // data: optional replacement session data (re-sealed)
437
+ // ttlMs: optional new TTL; if absent, expiresAt is preserved
438
+ // reason: free-form audit metadata ('login', 'mfa', etc.)
439
+ //
440
+ // Returns { token, expiresAt } on success, or null when the old token
441
+ // doesn't exist / has expired (operator distinguishes by checking
442
+ // for null).
443
+ //
444
+ // Atomicity: single UPDATE swaps sidHash. The old + new tokens never
445
+ // coexist the moment the UPDATE commits, only the new token verifies.
446
+ // Backends that can't do the WHERE-guarded UPDATE atomically (none of
447
+ // the framework's supported backends fall in that bucket) would need
448
+ // a transactional shim.
449
+ async function rotate(oldToken, opts) {
450
+ cluster.requireLeader();
451
+ if (typeof oldToken !== "string" || oldToken.length === 0) return null;
452
+ opts = opts || {};
453
+
454
+ var newSid = generateToken(SID_BYTES);
455
+ var newSidHash = _hashSid(newSid);
456
+ var oldSidHash = _hashSid(oldToken);
457
+ var nowMs = Date.now();
458
+ var newExpires = null;
459
+ if (opts.ttlMs !== undefined) {
460
+ _validateTtl(opts.ttlMs, "session.rotate");
461
+ newExpires = nowMs + opts.ttlMs;
462
+ }
463
+
464
+ var setParts = ['"sidHash" = ?', '"lastActivity" = ?'];
465
+ var setParams = [newSidHash, nowMs];
466
+
467
+ if (opts.data !== undefined) {
468
+ var dataJson = opts.data ? JSON.stringify(opts.data) : null;
469
+ var sealedRow = cryptoField.sealRow("_blamejs_sessions", { data: dataJson });
470
+ setParts.push('"data" = ?');
471
+ setParams.push(sealedRow.data);
472
+ }
473
+ if (newExpires !== null) {
474
+ setParts.push('"expiresAt" = ?');
475
+ setParams.push(newExpires);
476
+ }
477
+
478
+ var sql = "UPDATE _blamejs_sessions SET " + setParts.join(", ") +
479
+ " WHERE sidHash = ? AND expiresAt >= ?";
480
+ var params = setParams.concat([oldSidHash, nowMs]);
481
+ var result = await clusterStorage.execute(sql, params);
482
+ if ((result.rowCount || 0) === 0) return null;
483
+
484
+ // Read the row's effective expiresAt to return — single source of truth.
485
+ var row = await clusterStorage.executeOne(
486
+ 'SELECT "expiresAt" FROM _blamejs_sessions WHERE sidHash = ?',
487
+ [newSidHash]
488
+ );
489
+ var expiresAt = row ? Number(row.expiresAt) : null;
490
+
491
+ // Audit emit — best-effort. The framework's audit chain logs the
492
+ // privilege transition so post-incident review can trace which
493
+ // session id covered which privilege state.
494
+ try {
495
+ audit.emit({
496
+ action: "auth.session.rotate",
497
+ outcome: "success",
498
+ metadata: { reason: opts.reason || "explicit" },
499
+ });
500
+ } catch (_e) { /* audit emit best-effort — never block rotate() */ }
501
+
502
+ return { token: newSid, expiresAt: expiresAt };
503
+ }
504
+
505
+ async function purgeExpired() {
506
+ cluster.requireLeader();
507
+ var result = await clusterStorage.execute(
508
+ "DELETE FROM _blamejs_sessions WHERE expiresAt < ?",
509
+ [Date.now()]
510
+ );
511
+ return result.rowCount || 0;
512
+ }
513
+
514
+ async function count() {
515
+ var row = await clusterStorage.executeOne(
516
+ "SELECT COUNT(*) AS c FROM _blamejs_sessions WHERE expiresAt >= ?",
517
+ [Date.now()]
518
+ );
519
+ return row ? Number(row.c) : 0;
520
+ }
521
+
522
+ function _resetForTest() { /* no module state to reset; clusterStorage and cryptoField own theirs */ }
523
+
524
+ module.exports = {
525
+ create: create,
526
+ verify: verify,
527
+ destroy: destroy,
528
+ destroyAllForUser: destroyAllForUser,
529
+ touch: touch,
530
+ rotate: rotate,
531
+ purgeExpired: purgeExpired,
532
+ count: count,
533
+ DEFAULT_TTL_MS: DEFAULT_TTL_MS,
534
+ _resetForTest: _resetForTest,
535
+ };