@blamejs/core 0.7.1 → 0.7.18

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 (180) hide show
  1. package/CHANGELOG.md +423 -389
  2. package/README.md +150 -149
  3. package/bin/blamejs.js +0 -0
  4. package/index.js +308 -282
  5. package/lib/api-key.js +660 -672
  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.js +311 -311
  16. package/lib/auth/lockout.js +436 -436
  17. package/lib/auth/oauth.js +721 -721
  18. package/lib/auth/passkey.js +181 -181
  19. package/lib/auth/password.js +594 -594
  20. package/lib/backup/bundle.js +217 -217
  21. package/lib/backup/crypto.js +176 -176
  22. package/lib/backup/index.js +515 -515
  23. package/lib/backup/manifest.js +282 -282
  24. package/lib/break-glass.js +1338 -1338
  25. package/lib/bundler.js +441 -441
  26. package/lib/cache-redis.js +256 -266
  27. package/lib/cache.js +1206 -1211
  28. package/lib/canonical-json.js +115 -115
  29. package/lib/chain-writer.js +234 -234
  30. package/lib/cli-helpers.js +206 -206
  31. package/lib/cli.js +2334 -2334
  32. package/lib/cluster-provider-db.js +317 -317
  33. package/lib/cluster-storage.js +226 -226
  34. package/lib/cluster.js +703 -703
  35. package/lib/codepoint-class.js +196 -0
  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 +315 -315
  40. package/lib/credential-hash.js +322 -322
  41. package/lib/crypto.js +266 -266
  42. package/lib/csv.js +275 -286
  43. package/lib/db-declare-row-policy.js +267 -267
  44. package/lib/db-declare-view.js +420 -421
  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/file-upload.js +1136 -0
  56. package/lib/forms.js +422 -422
  57. package/lib/framework-error.js +293 -202
  58. package/lib/framework-schema.js +717 -717
  59. package/lib/gate-contract.js +971 -0
  60. package/lib/guard-all.js +405 -0
  61. package/lib/guard-archive.js +739 -0
  62. package/lib/guard-csv.js +816 -0
  63. package/lib/guard-email.js +744 -0
  64. package/lib/guard-filename.js +724 -0
  65. package/lib/guard-html.js +976 -0
  66. package/lib/guard-json.js +729 -0
  67. package/lib/guard-markdown.js +586 -0
  68. package/lib/guard-svg.js +976 -0
  69. package/lib/guard-xml.js +405 -0
  70. package/lib/guard-yaml.js +529 -0
  71. package/lib/handlers.js +350 -350
  72. package/lib/http-client-cookie-jar.js +508 -508
  73. package/lib/http-client.js +1195 -1195
  74. package/lib/i18n.js +878 -878
  75. package/lib/jobs.js +185 -185
  76. package/lib/log-stream-cloudwatch.js +369 -369
  77. package/lib/log-stream-local.js +146 -146
  78. package/lib/log-stream-otlp-grpc.js +410 -410
  79. package/lib/log-stream-otlp.js +286 -286
  80. package/lib/log-stream-syslog.js +302 -302
  81. package/lib/log-stream-webhook.js +199 -199
  82. package/lib/log-stream.js +330 -330
  83. package/lib/log.js +500 -500
  84. package/lib/mail-bounce.js +528 -528
  85. package/lib/mail-dkim.js +369 -362
  86. package/lib/mail.js +981 -962
  87. package/lib/metrics.js +683 -683
  88. package/lib/middleware/api-encrypt.js +936 -573
  89. package/lib/middleware/attach-user.js +157 -157
  90. package/lib/middleware/body-parser.js +1170 -1091
  91. package/lib/middleware/bot-guard.js +178 -178
  92. package/lib/middleware/compression.js +452 -452
  93. package/lib/middleware/cors.js +314 -314
  94. package/lib/middleware/csp-nonce.js +348 -348
  95. package/lib/middleware/csrf-protect.js +316 -316
  96. package/lib/middleware/db-role-for.js +264 -269
  97. package/lib/middleware/health.js +392 -392
  98. package/lib/middleware/index.js +79 -79
  99. package/lib/middleware/rate-limit.js +358 -358
  100. package/lib/middleware/request-id.js +61 -61
  101. package/lib/middleware/request-log.js +168 -168
  102. package/lib/middleware/require-auth.js +104 -104
  103. package/lib/middleware/security-headers.js +116 -116
  104. package/lib/middleware/sse.js +166 -166
  105. package/lib/migrations.js +383 -383
  106. package/lib/mtls-ca.js +518 -518
  107. package/lib/mtls-engine-default.js +481 -481
  108. package/lib/network-dns.js +632 -632
  109. package/lib/network-heartbeat.js +290 -290
  110. package/lib/network-nts.js +574 -574
  111. package/lib/network-proxy.js +265 -265
  112. package/lib/network-tls.js +328 -328
  113. package/lib/network.js +233 -233
  114. package/lib/notify.js +612 -614
  115. package/lib/ntp-check.js +229 -229
  116. package/lib/numeric-bounds.js +111 -91
  117. package/lib/object-store/azure-blob-bucket-ops.js +349 -349
  118. package/lib/object-store/azure-blob.js +488 -451
  119. package/lib/object-store/gcs-bucket-ops.js +351 -351
  120. package/lib/object-store/gcs.js +519 -479
  121. package/lib/object-store/http-put.js +153 -153
  122. package/lib/object-store/index.js +197 -197
  123. package/lib/object-store/sigv4-bucket-ops.js +1092 -1092
  124. package/lib/object-store/sigv4.js +903 -855
  125. package/lib/observability.js +151 -151
  126. package/lib/otel-export.js +269 -269
  127. package/lib/pagination.js +464 -464
  128. package/lib/parsers/index.js +80 -80
  129. package/lib/parsers/safe-env.js +642 -642
  130. package/lib/parsers/safe-ini.js +292 -292
  131. package/lib/parsers/safe-toml.js +784 -784
  132. package/lib/parsers/safe-xml.js +390 -390
  133. package/lib/parsers/safe-yaml.js +1015 -1015
  134. package/lib/permissions.js +708 -708
  135. package/lib/pqc-agent.js +87 -87
  136. package/lib/pqc-gate.js +279 -279
  137. package/lib/protobuf-encoder.js +190 -190
  138. package/lib/protocol-dispatcher.js +161 -161
  139. package/lib/pubsub-redis.js +167 -177
  140. package/lib/pubsub.js +429 -429
  141. package/lib/queue-local.js +476 -476
  142. package/lib/queue-redis.js +745 -752
  143. package/lib/queue-sqs.js +319 -319
  144. package/lib/queue.js +695 -695
  145. package/lib/redis-client.js +519 -489
  146. package/lib/request-helpers.js +340 -336
  147. package/lib/restore-bundle.js +237 -237
  148. package/lib/restore-rollback.js +259 -259
  149. package/lib/restore.js +409 -409
  150. package/lib/retry.js +376 -376
  151. package/lib/router.js +748 -748
  152. package/lib/safe-async.js +735 -735
  153. package/lib/safe-buffer.js +237 -237
  154. package/lib/safe-json.js +541 -541
  155. package/lib/safe-schema.js +1266 -1266
  156. package/lib/safe-url.js +159 -159
  157. package/lib/scheduler.js +706 -706
  158. package/lib/security-assert.js +373 -373
  159. package/lib/seeders.js +618 -630
  160. package/lib/session.js +478 -478
  161. package/lib/slug.js +269 -269
  162. package/lib/ssrf-guard.js +401 -401
  163. package/lib/static.js +879 -114
  164. package/lib/storage.js +471 -471
  165. package/lib/subject.js +281 -281
  166. package/lib/template.js +791 -791
  167. package/lib/testing.js +798 -798
  168. package/lib/time.js +310 -310
  169. package/lib/totp.js +302 -302
  170. package/lib/tracing.js +494 -494
  171. package/lib/uuid.js +132 -132
  172. package/lib/validate-opts.js +340 -270
  173. package/lib/vault/index.js +308 -308
  174. package/lib/vault/rotate.js +784 -784
  175. package/lib/vault/wrap.js +296 -296
  176. package/lib/vendor/noble-ciphers.cjs +9 -9
  177. package/lib/webhook.js +595 -598
  178. package/lib/websocket.js +1048 -1048
  179. package/package.json +77 -77
  180. package/sbom.cyclonedx.json +7 -7
package/lib/session.js CHANGED
@@ -1,478 +1,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
- 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
+ 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
+ };