@blamejs/core 0.8.60 → 0.8.64

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/session.js CHANGED
@@ -53,10 +53,29 @@ var clusterStorage = require("./cluster-storage");
53
53
  var C = require("./constants");
54
54
  var { generateToken, sha3Hash } = require("./crypto");
55
55
  var cryptoField = require("./crypto-field");
56
+ var lazyRequire = require("./lazy-require");
56
57
  var requestHelpers = require("./request-helpers");
57
58
  var safeJson = require("./safe-json");
58
59
  var { SessionError } = require("./framework-error");
59
60
 
61
+ // vault is initialized at boot before sessions; lazyRequire keeps the
62
+ // load order independent of module-import order. Used to seal/unseal
63
+ // the cookie-side sid so the wire token is ciphertext rather than
64
+ // plaintext (sealed-cookie default since v0.8.61).
65
+ var vault = lazyRequire(function () { return require("./vault"); });
66
+
67
+ // Pluggable session-storage backend. Default uses cluster-storage (which
68
+ // in turn dispatches to the framework's main DB or external DB). An
69
+ // operator can switch to an isolated backend (e.g. an in-memory or
70
+ // tmpfs SQLite via `b.session.stores.localDbThin`) so heavy session
71
+ // churn doesn't fight the main DB's WAL fsync + at-rest re-encryption
72
+ // cycle. Set once at boot via `b.session.useStore(store)`; the store
73
+ // must expose `execute(sql, params)` and `executeOne(sql, params)`
74
+ // returning the same `{ rowCount, rows? }` / row-or-null shape the
75
+ // cluster-storage path returns.
76
+ var _store = null;
77
+ function _currentStore() { return _store || clusterStorage; }
78
+
60
79
  var _err = SessionError.factory;
61
80
 
62
81
  var DEFAULT_TTL_MS = C.TIME.days(7);
@@ -109,6 +128,41 @@ function _hashSid(sid) {
109
128
  return sha3Hash(SID_NAMESPACE + sid);
110
129
  }
111
130
 
131
+ // Sealed-cookie format. `b.vault.seal` produces a `vault:`-prefixed
132
+ // envelope (ML-KEM-1024 + P-384 hybrid + XChaCha20-Poly1305). Pre-
133
+ // v0.8.61 the framework returned the plaintext sid to the caller; the
134
+ // sealed default since v0.8.61 keeps the wire token as ciphertext. The
135
+ // DB still keys on `sha3('bj-session:' || sid)` — sealing is a
136
+ // wire-format upgrade, not a storage change.
137
+ //
138
+ // Pre-v1.0 the framework ships no backwards-compat path: raw-format
139
+ // cookies from before v0.8.61 fail to unseal here and the affected
140
+ // caller force-logs-out (re-auths and gets a sealed cookie). The
141
+ // upgrade is operator-visible and documented in the release notes.
142
+ var SEALED_COOKIE_PREFIX = "vault:";
143
+
144
+ function _sealCookieToken(sid) {
145
+ // vault.seal is idempotent on already-sealed input. Defensive null
146
+ // pass-through matches vault's contract — feed it the raw sid only.
147
+ return vault().seal(sid);
148
+ }
149
+
150
+ function _unsealCookieToken(token) {
151
+ if (typeof token !== "string" || token.length === 0) return null;
152
+ if (token.indexOf(SEALED_COOKIE_PREFIX) !== 0) {
153
+ // Pre-v0.8.61 raw-sid format — refused under the sealed-cookie
154
+ // default. Returning null surfaces as "session not found" at the
155
+ // verify call site so the caller force-logs-out cleanly.
156
+ return null;
157
+ }
158
+ try { return vault().unseal(token); }
159
+ catch (_e) {
160
+ // Tampered cookie / wrong keypair / vault rotation skew — treat as
161
+ // not-found. The caller already handles `null` (re-auth flow).
162
+ return null;
163
+ }
164
+ }
165
+
112
166
  // Build a sealed row object with all SESSION_COLS keys present (null
113
167
  // where not set). The cryptoField.sealRow call seals userId/data and
114
168
  // produces userIdHash from userId.
@@ -138,6 +192,120 @@ function _sealForInsert(row) {
138
192
  // itself, extended to the fingerprint.
139
193
  var DEFAULT_FINGERPRINT_FIELDS = ["clientIp", "userAgent", "acceptLanguage"];
140
194
 
195
+ // Subnet binding: roaming carriers (T-Mobile / Verizon / etc.) flip the
196
+ // public client IP every few requests as the device hops cells, so a
197
+ // strict full-IP fingerprint logs out healthy mobile users. The
198
+ // "clientIpPrefix" field hashes a /24 mask for IPv4 (256-address bucket
199
+ // — same Class C-shaped neighborhood) and a /64 mask for IPv6 (the IPv6
200
+ // "site" prefix the RIRs allocate to every ISP customer). Drift across
201
+ // /24 OR /64 is meaningfully suspicious; drift within is not.
202
+ //
203
+ // Per the IPv6 addressing architecture (RFC 4291 §2.5.4) every customer
204
+ // LAN is assigned at least a /64; tightening below /64 punishes IPv6
205
+ // privacy-extension address rotation. /24 IPv4 is the original
206
+ // IP-geolocation bucket size and matches the legacy carrier-NAT pool
207
+ // stride. Operators with stricter needs pass a function-form
208
+ // fingerprint field for custom mask widths.
209
+ //
210
+ // Protocol constants — named so the bit-arithmetic stays readable.
211
+ var IP_BITS_PER_BYTE = 8; // allow:raw-byte-literal — bits per byte; protocol constant, not a byte size
212
+ var IPV4_OCTET_COUNT = 4;
213
+ var IPV4_OCTET_RANGE = 256; // allow:raw-byte-literal — 0..255 inclusive; v4 octet domain
214
+ var IPV4_TOTAL_BITS = 32; // allow:raw-byte-literal — IPv4 address width in bits
215
+ var IPV4_DEFAULT_PREFIX = 24; // allow:raw-byte-literal — /24 carrier-NAT pool stride
216
+ var IPV6_GROUP_COUNT = 8; // allow:raw-byte-literal — 8 16-bit groups in v6
217
+ var IPV6_BYTE_COUNT = 16; // allow:raw-byte-literal — 16 bytes in v6
218
+ var IPV6_DEFAULT_PREFIX = 64; // allow:raw-byte-literal — /64 customer LAN per RFC 4291 §2.5.4
219
+ var BYTE_MASK = 0xff;
220
+ var HEX_RADIX = 16; // allow:raw-byte-literal — base-16 radix
221
+ var V4_MAPPED_V6_PREFIX = "::ffff:";
222
+
223
+ function _maskIpv4(ip, prefix) {
224
+ // ip = "a.b.c.d"; prefix is bits to keep (1..32).
225
+ var parts = String(ip).split(".");
226
+ if (parts.length !== IPV4_OCTET_COUNT) return null;
227
+ var n = 0;
228
+ for (var i = 0; i < IPV4_OCTET_COUNT; i++) {
229
+ var oct = parseInt(parts[i], 10);
230
+ if (!Number.isInteger(oct) || oct < 0 || oct >= IPV4_OCTET_RANGE) return null;
231
+ n = (n * IPV4_OCTET_RANGE) + oct;
232
+ }
233
+ // Apply prefix mask.
234
+ var mask = prefix === 0 ? 0 : (-1 >>> (IPV4_TOTAL_BITS - prefix)) << (IPV4_TOTAL_BITS - prefix);
235
+ // Bitwise on 32-bit unsigned. JS coerces to 32-bit signed, so use
236
+ // unsigned right shift to recover.
237
+ var masked = (n & mask) >>> 0;
238
+ return ((masked >>> IP_BITS_PER_BYTE * 3) & BYTE_MASK) + "." +
239
+ ((masked >>> IP_BITS_PER_BYTE * 2) & BYTE_MASK) + "." +
240
+ ((masked >>> IP_BITS_PER_BYTE) & BYTE_MASK) + "." +
241
+ (masked & BYTE_MASK) + "/" + prefix;
242
+ }
243
+
244
+ function _maskIpv6(ip, prefix) {
245
+ // Expand to 8 16-bit groups. Accept :: shorthand. Reject if invalid.
246
+ var raw = String(ip).toLowerCase();
247
+ // Strip an embedded zone id (fe80::1%eth0); not part of the address.
248
+ var pct = raw.indexOf("%");
249
+ if (pct !== -1) raw = raw.substring(0, pct);
250
+ var doubleColonAt = raw.indexOf("::");
251
+ var groups;
252
+ if (doubleColonAt === -1) {
253
+ groups = raw.split(":");
254
+ if (groups.length !== IPV6_GROUP_COUNT) return null;
255
+ } else {
256
+ var left = raw.substring(0, doubleColonAt).split(":");
257
+ var right = raw.substring(doubleColonAt + 2).split(":");
258
+ if (left.length === 1 && left[0] === "") left = [];
259
+ if (right.length === 1 && right[0] === "") right = [];
260
+ var fillCount = IPV6_GROUP_COUNT - left.length - right.length;
261
+ if (fillCount < 0) return null;
262
+ var middle = [];
263
+ for (var fi = 0; fi < fillCount; fi++) middle.push("0");
264
+ groups = left.concat(middle).concat(right);
265
+ }
266
+ // Each group is 1–4 hex chars.
267
+ var bytes = [];
268
+ for (var gi = 0; gi < IPV6_GROUP_COUNT; gi++) {
269
+ var g = groups[gi];
270
+ if (typeof g !== "string" || g.length === 0 || g.length > 4 || /[^0-9a-f]/.test(g)) return null;
271
+ var v = parseInt(g, HEX_RADIX);
272
+ if (!Number.isInteger(v) || v < 0 || v > 0xffff) return null;
273
+ bytes.push((v >> IP_BITS_PER_BYTE) & BYTE_MASK);
274
+ bytes.push(v & BYTE_MASK);
275
+ }
276
+ // Apply prefix in bits.
277
+ var keepBytes = Math.floor(prefix / IP_BITS_PER_BYTE);
278
+ var keepBits = prefix % IP_BITS_PER_BYTE;
279
+ for (var bi = 0; bi < IPV6_BYTE_COUNT; bi++) {
280
+ if (bi < keepBytes) continue;
281
+ if (bi === keepBytes && keepBits > 0) {
282
+ var m = (BYTE_MASK << (IP_BITS_PER_BYTE - keepBits)) & BYTE_MASK;
283
+ bytes[bi] = bytes[bi] & m;
284
+ } else {
285
+ bytes[bi] = 0;
286
+ }
287
+ }
288
+ // Re-emit as colon-hex (no compression — deterministic for hashing).
289
+ var out = [];
290
+ for (var oi = 0; oi < IPV6_BYTE_COUNT; oi += 2) {
291
+ out.push(((bytes[oi] << IP_BITS_PER_BYTE) | bytes[oi + 1]).toString(HEX_RADIX));
292
+ }
293
+ return out.join(":") + "/" + prefix;
294
+ }
295
+
296
+ function _ipPrefix(ip) {
297
+ if (typeof ip !== "string" || ip.length === 0) return "";
298
+ // IPv4-mapped IPv6 (::ffff:1.2.3.4) — strip the wrapper so the v4
299
+ // mask applies. Same bucket regardless of how the proxy reported it.
300
+ var lower = ip.toLowerCase();
301
+ if (lower.indexOf(V4_MAPPED_V6_PREFIX) === 0 && lower.indexOf(".") !== -1) {
302
+ return _maskIpv4(lower.substring(V4_MAPPED_V6_PREFIX.length), IPV4_DEFAULT_PREFIX) || "";
303
+ }
304
+ if (ip.indexOf(":") !== -1) return _maskIpv6(ip, IPV6_DEFAULT_PREFIX) || "";
305
+ if (ip.indexOf(".") !== -1) return _maskIpv4(ip, IPV4_DEFAULT_PREFIX) || "";
306
+ return "";
307
+ }
308
+
141
309
  function _buildFingerprintInputs(req, fields) {
142
310
  if (!req) return null;
143
311
  var headers = req.headers || {};
@@ -146,6 +314,9 @@ function _buildFingerprintInputs(req, fields) {
146
314
  var f = fields[i];
147
315
  if (f === "clientIp") {
148
316
  inputs.clientIp = requestHelpers.clientIp(req) || "";
317
+ } else if (f === "clientIpPrefix") {
318
+ // /24 v4 + /64 v6 — see _ipPrefix() commentary.
319
+ inputs.clientIpPrefix = _ipPrefix(requestHelpers.clientIp(req) || "");
149
320
  } else if (f === "userAgent") {
150
321
  inputs.userAgent = String(headers["user-agent"] || "");
151
322
  } else if (f === "acceptLanguage") {
@@ -206,10 +377,33 @@ function _hashFingerprint(sid, inputs) {
206
377
  * res.setHeader("Set-Cookie", "sid=" + s.token + "; HttpOnly; Secure; SameSite=Strict");
207
378
  * // → { token: "9f2c…", expiresAt: 1735689600000 }
208
379
  */
380
+ // Anonymous-session prefix. b.session.create({ anonymous: true })
381
+ // auto-mints userId = ANON_PREFIX + crypto.randomUUID() so operators
382
+ // running pre-login flows (cart, partial-funnel telemetry, public
383
+ // landing-page personalization) keep the framework's full sealed-
384
+ // cookie + sealed-userId + sidHash + idle/absolute timeout posture
385
+ // without rolling their own opaque-id pattern. destroyAllForUser
386
+ // refuses anon ids: they're per-session and aren't portable.
387
+ var ANON_PREFIX = "anon:";
388
+ function _isAnonymousUserId(id) {
389
+ return typeof id === "string" && id.indexOf(ANON_PREFIX) === 0;
390
+ }
391
+
209
392
  async function create(opts) {
210
393
  cluster.requireLeader();
211
- if (!opts || !opts.userId) {
212
- throw _err("INVALID_ARG", "session.create requires { userId }", true);
394
+ opts = opts || {};
395
+ if (opts.anonymous === true) {
396
+ if (opts.userId !== undefined && opts.userId !== null) {
397
+ throw _err("INVALID_ARG",
398
+ "session.create: pass either anonymous: true OR userId, not both", true);
399
+ }
400
+ // crypto.randomUUID is the framework's existing entropy source
401
+ // for opaque ids; anon sessions inherit the same 122-bit space.
402
+ var nodeCryptoForUuid = require("node:crypto"); // allow:inline-require — only the anon path needs randomUUID
403
+ opts = Object.assign({}, opts, { userId: ANON_PREFIX + nodeCryptoForUuid.randomUUID() });
404
+ }
405
+ if (!opts.userId) {
406
+ throw _err("INVALID_ARG", "session.create requires { userId } (or { anonymous: true })", true);
213
407
  }
214
408
  var ttl = opts.ttlMs !== undefined ? opts.ttlMs : DEFAULT_TTL_MS;
215
409
  _validateTtl(ttl, "session.create");
@@ -242,12 +436,12 @@ async function create(opts) {
242
436
  var values = SESSION_COLS.map(function (c) { return sealed[c]; });
243
437
  var placeholders = SESSION_COLS.map(function () { return "?"; }).join(", ");
244
438
  var quoted = SESSION_COLS.map(function (c) { return '"' + c + '"'; }).join(", ");
245
- await clusterStorage.execute(
439
+ await _currentStore().execute(
246
440
  "INSERT INTO _blamejs_sessions (" + quoted + ") VALUES (" + placeholders + ")",
247
441
  values
248
442
  );
249
443
 
250
- return { token: sid, expiresAt: expiresAt };
444
+ return { token: _sealCookieToken(sid), expiresAt: expiresAt };
251
445
  }
252
446
 
253
447
  /**
@@ -295,9 +489,14 @@ async function create(opts) {
295
489
  async function verify(token, verifyOpts) {
296
490
  if (typeof token !== "string" || token.length === 0) return null;
297
491
  verifyOpts = verifyOpts || {};
298
- var sidHash = _hashSid(token);
492
+ // Sealed-cookie default — unseal the wire token to recover the sid.
493
+ // Pre-v0.8.61 raw cookies / tampered envelopes return null and the
494
+ // caller re-auths. The plaintext sid never leaves this function.
495
+ var sid = _unsealCookieToken(token);
496
+ if (sid === null) return null;
497
+ var sidHash = _hashSid(sid);
299
498
 
300
- var row = await clusterStorage.executeOne(
499
+ var row = await _currentStore().executeOne(
301
500
  "SELECT sidHash, userId, userIdHash, data, createdAt, expiresAt, lastActivity " +
302
501
  "FROM _blamejs_sessions WHERE sidHash = ?",
303
502
  [sidHash]
@@ -399,7 +598,7 @@ async function verify(token, verifyOpts) {
399
598
  var fpFields = Array.isArray(verifyOpts.fingerprintFields) && verifyOpts.fingerprintFields.length > 0
400
599
  ? verifyOpts.fingerprintFields : DEFAULT_FINGERPRINT_FIELDS;
401
600
  var currentInputs = _buildFingerprintInputs(verifyOpts.req, fpFields);
402
- var currentHash = _hashFingerprint(token, currentInputs);
601
+ var currentHash = _hashFingerprint(sid, currentInputs);
403
602
  if (currentHash !== storedFingerprint) {
404
603
  fingerprintDrift = true;
405
604
  // Operator-supplied scorer: receives { storedHash, currentInputs,
@@ -474,11 +673,13 @@ async function verify(token, verifyOpts) {
474
673
  async function destroy(token) {
475
674
  cluster.requireLeader();
476
675
  if (typeof token !== "string" || token.length === 0) return false;
477
- return await _deleteBySidHash(_hashSid(token));
676
+ var sid = _unsealCookieToken(token);
677
+ if (sid === null) return false;
678
+ return await _deleteBySidHash(_hashSid(sid));
478
679
  }
479
680
 
480
681
  async function _deleteBySidHash(sidHash) {
481
- var result = await clusterStorage.execute(
682
+ var result = await _currentStore().execute(
482
683
  "DELETE FROM _blamejs_sessions WHERE sidHash = ?",
483
684
  [sidHash]
484
685
  );
@@ -506,6 +707,16 @@ async function _deleteBySidHash(sidHash) {
506
707
  async function destroyAllForUser(userId) {
507
708
  cluster.requireLeader();
508
709
  if (!userId) throw _err("INVALID_ARG", "session.destroyAllForUser requires a userId", true);
710
+ if (_isAnonymousUserId(userId)) {
711
+ // Anon ids are minted per-session and aren't reused — destroying
712
+ // "all" anon sessions for one anon id is the same as destroying
713
+ // that single session. Refuse loudly so the operator doesn't
714
+ // think they're sweeping across an anon population.
715
+ throw _err("INVALID_ARG",
716
+ "session.destroyAllForUser: anonymous-prefix ids (\"anon:...\") are per-session — " +
717
+ "use destroy(token) for that session, OR purgeExpired() for housekeeping",
718
+ true);
719
+ }
509
720
  // userId is sealed; look up via derived userIdHash.
510
721
  var lookup = cryptoField.lookupHash("_blamejs_sessions", "userId", userId);
511
722
  if (!lookup) {
@@ -513,7 +724,7 @@ async function destroyAllForUser(userId) {
513
724
  "_blamejs_sessions schema is missing the userIdHash derived hash — framework misconfigured",
514
725
  true);
515
726
  }
516
- var result = await clusterStorage.execute(
727
+ var result = await _currentStore().execute(
517
728
  "DELETE FROM _blamejs_sessions WHERE userIdHash = ?",
518
729
  [lookup.value]
519
730
  );
@@ -551,7 +762,9 @@ async function touch(token, opts) {
551
762
  cluster.requireLeader();
552
763
  opts = opts || {};
553
764
  if (typeof token !== "string" || token.length === 0) return false;
554
- var sidHash = _hashSid(token);
765
+ var sid = _unsealCookieToken(token);
766
+ if (sid === null) return false;
767
+ var sidHash = _hashSid(sid);
555
768
  var nowMs = Date.now();
556
769
  // Two SQL paths so the SET list stays static (no dynamic column
557
770
  // assembly) and matches the call shape clusterStorage expects.
@@ -563,14 +776,14 @@ async function touch(token, opts) {
563
776
  if (opts.extendBy !== undefined && opts.extendBy !== null) {
564
777
  _validateTtl(opts.extendBy, "session.touch");
565
778
  var newExpires = nowMs + opts.extendBy;
566
- var result = await clusterStorage.execute(
779
+ var result = await _currentStore().execute(
567
780
  "UPDATE _blamejs_sessions SET lastActivity = ?, expiresAt = ? " +
568
781
  "WHERE sidHash = ? AND expiresAt >= ?",
569
782
  [nowMs, newExpires, sidHash, nowMs]
570
783
  );
571
784
  return (result.rowCount || 0) > 0;
572
785
  }
573
- var result2 = await clusterStorage.execute(
786
+ var result2 = await _currentStore().execute(
574
787
  "UPDATE _blamejs_sessions SET lastActivity = ? " +
575
788
  "WHERE sidHash = ? AND expiresAt >= ?",
576
789
  [nowMs, sidHash, nowMs]
@@ -618,10 +831,12 @@ async function rotate(oldToken, opts) {
618
831
  cluster.requireLeader();
619
832
  if (typeof oldToken !== "string" || oldToken.length === 0) return null;
620
833
  opts = opts || {};
834
+ var oldSid = _unsealCookieToken(oldToken);
835
+ if (oldSid === null) return null;
621
836
 
622
837
  var newSid = generateToken(SID_BYTES);
623
838
  var newSidHash = _hashSid(newSid);
624
- var oldSidHash = _hashSid(oldToken);
839
+ var oldSidHash = _hashSid(oldSid);
625
840
  var nowMs = Date.now();
626
841
  var newExpires = null;
627
842
  if (opts.ttlMs !== undefined) {
@@ -646,11 +861,11 @@ async function rotate(oldToken, opts) {
646
861
  var sql = "UPDATE _blamejs_sessions SET " + setParts.join(", ") +
647
862
  " WHERE sidHash = ? AND expiresAt >= ?";
648
863
  var params = setParams.concat([oldSidHash, nowMs]);
649
- var result = await clusterStorage.execute(sql, params);
864
+ var result = await _currentStore().execute(sql, params);
650
865
  if ((result.rowCount || 0) === 0) return null;
651
866
 
652
867
  // Read the row's effective expiresAt to return — single source of truth.
653
- var row = await clusterStorage.executeOne(
868
+ var row = await _currentStore().executeOne(
654
869
  'SELECT "expiresAt" FROM _blamejs_sessions WHERE sidHash = ?',
655
870
  [newSidHash]
656
871
  );
@@ -667,7 +882,7 @@ async function rotate(oldToken, opts) {
667
882
  });
668
883
  } catch (_e) { /* audit emit best-effort — never block rotate() */ }
669
884
 
670
- return { token: newSid, expiresAt: expiresAt };
885
+ return { token: _sealCookieToken(newSid), expiresAt: expiresAt };
671
886
  }
672
887
 
673
888
  /**
@@ -696,7 +911,7 @@ async function rotate(oldToken, opts) {
696
911
  */
697
912
  async function purgeExpired() {
698
913
  cluster.requireLeader();
699
- var result = await clusterStorage.execute(
914
+ var result = await _currentStore().execute(
700
915
  "DELETE FROM _blamejs_sessions WHERE expiresAt < ?",
701
916
  [Date.now()]
702
917
  );
@@ -722,14 +937,82 @@ async function purgeExpired() {
722
937
  * // → 482
723
938
  */
724
939
  async function count() {
725
- var row = await clusterStorage.executeOne(
940
+ var row = await _currentStore().executeOne(
726
941
  "SELECT COUNT(*) AS c FROM _blamejs_sessions WHERE expiresAt >= ?",
727
942
  [Date.now()]
728
943
  );
729
944
  return row ? Number(row.c) : 0;
730
945
  }
731
946
 
732
- function _resetForTest() { /* no module state to reset; clusterStorage and cryptoField own theirs */ }
947
+ function _resetForTest() { _store = null; }
948
+
949
+ /**
950
+ * @primitive b.session.useStore
951
+ * @signature b.session.useStore(store)
952
+ * @since 0.8.61
953
+ * @status stable
954
+ * @related b.session.stores.localDbThin
955
+ *
956
+ * Replace the default `_blamejs_sessions` storage backend (the
957
+ * framework's main DB / external DB via cluster-storage) with an
958
+ * operator-supplied store. The store must expose
959
+ * `execute(sql, params)` and `executeOne(sql, params)` returning the
960
+ * same `{ rows, rowCount }` / `row | null` shape `b.clusterStorage`
961
+ * returns. Pass `null` to revert to the default.
962
+ *
963
+ * Typical use is to point session writes at an isolated SQLite file
964
+ * (often tmpfs) so session churn doesn't fight the main DB's encrypted-
965
+ * at-rest re-flush cycle. The first-party adapter is
966
+ * `b.session.stores.localDbThin({ file })`.
967
+ *
968
+ * Call this once at boot, BEFORE the first `session.create` /
969
+ * `session.verify`. Switching stores on a running app strands every
970
+ * existing session in the old store.
971
+ *
972
+ * @example
973
+ * var b = require("@blamejs/core");
974
+ * await b.vault.init({ dataDir: "/var/lib/blamejs", mode: "plaintext" });
975
+ * await b.db.init({ dataDir: "/var/lib/blamejs" });
976
+ * var sessionStore = b.session.stores.localDbThin({ file: "/dev/shm/sessions.db" });
977
+ * b.session.useStore(sessionStore);
978
+ * // Every b.session.* call now routes through the tmpfs file.
979
+ */
980
+ function useStore(store) {
981
+ if (store === null || store === undefined) {
982
+ _store = null;
983
+ return;
984
+ }
985
+ if (typeof store !== "object" ||
986
+ typeof store.execute !== "function" ||
987
+ typeof store.executeOne !== "function") {
988
+ throw _err("INVALID_ARG",
989
+ "session.useStore: store must expose execute(sql,params) and executeOne(sql,params)", true);
990
+ }
991
+ _store = store;
992
+ }
993
+
994
+ /**
995
+ * @primitive b.session.isAnonymous
996
+ * @signature b.session.isAnonymous(userId)
997
+ * @since 0.8.62
998
+ * @status stable
999
+ * @related b.session.create
1000
+ *
1001
+ * Returns `true` if the supplied userId was minted by
1002
+ * `b.session.create({ anonymous: true })` (i.e., starts with the
1003
+ * `anon:` prefix). Operators use this to gate post-auth behavior
1004
+ * (e.g., refuse a payment confirmation when the session is still
1005
+ * anonymous, or render the "log in to continue" banner).
1006
+ *
1007
+ * @example
1008
+ * var info = await b.session.verify(req.cookies.sid);
1009
+ * if (info && b.session.isAnonymous(info.userId)) {
1010
+ * res.statusCode = 401; res.end("login required"); return;
1011
+ * }
1012
+ */
1013
+ function isAnonymous(userId) {
1014
+ return _isAnonymousUserId(userId);
1015
+ }
733
1016
 
734
1017
  module.exports = {
735
1018
  create: create,
@@ -740,6 +1023,10 @@ module.exports = {
740
1023
  rotate: rotate,
741
1024
  purgeExpired: purgeExpired,
742
1025
  count: count,
1026
+ useStore: useStore,
1027
+ isAnonymous: isAnonymous,
1028
+ stores: require("./session-stores"), // allow:inline-require — session-stores depends on local-db-thin which requires audit lazily; eager require is fine here
743
1029
  DEFAULT_TTL_MS: DEFAULT_TTL_MS,
1030
+ ANON_PREFIX: ANON_PREFIX,
744
1031
  _resetForTest: _resetForTest,
745
1032
  };
@@ -308,6 +308,46 @@ function makeAuditEmitter(audit) {
308
308
  };
309
309
  }
310
310
 
311
+ // makeNamespacedEmitters — collapses the per-primitive
312
+ // function _emitAudit(action, outcome, metadata) { audit.safeEmit({...}) }
313
+ // function _emitMetric(verb) { observability.safeEvent(...) }
314
+ // boilerplate into one helper. Every primitive that emits both audit
315
+ // events AND observability metrics under a fixed prefix shares the
316
+ // same shape; pre-v0.8.62 this was inlined in 13+ lib/auth files.
317
+ //
318
+ // var emit = validateOpts.makeNamespacedEmitters("auth.ciba", { audit, observability });
319
+ // emit.audit("token_received", "success", { hash: ... });
320
+ // emit.metric("token-received");
321
+ //
322
+ // The audit/observability arguments are lazyRequire-resolved at the
323
+ // call site so the helper itself adds no module-load coupling.
324
+ function makeNamespacedEmitters(prefix, deps) {
325
+ if (typeof prefix !== "string" || prefix.length === 0) {
326
+ throw new Error("makeNamespacedEmitters: prefix must be a non-empty string");
327
+ }
328
+ deps = deps || {};
329
+ function audit(action, outcome, metadata) {
330
+ var auditMod = deps.audit;
331
+ if (typeof auditMod === "function") auditMod = auditMod();
332
+ if (!auditMod || typeof auditMod.safeEmit !== "function") return;
333
+ try {
334
+ auditMod.safeEmit({
335
+ action: prefix + "." + action,
336
+ outcome: outcome,
337
+ metadata: metadata || {},
338
+ });
339
+ } catch (_e) { /* audit best-effort */ }
340
+ }
341
+ function metric(verb, value, attrs) {
342
+ var obsMod = deps.observability;
343
+ if (typeof obsMod === "function") obsMod = obsMod();
344
+ if (!obsMod || typeof obsMod.safeEvent !== "function") return;
345
+ try { obsMod.safeEvent(prefix + "." + verb, value || 1, attrs || {}); }
346
+ catch (_e) { /* observability best-effort */ }
347
+ }
348
+ return { audit: audit, metric: metric };
349
+ }
350
+
311
351
  // observabilityShape — operator-supplied `opts.observability` must
312
352
  // expose an `event` function. Parallel to auditShape; the n=1 catalog
313
353
  // tracks both inline-shape regexes.
@@ -338,3 +378,4 @@ module.exports.observabilityShape = observabilityShape;
338
378
  module.exports.requireObject = requireObject;
339
379
  module.exports.applyDefaults = applyDefaults;
340
380
  module.exports.makeAuditEmitter = makeAuditEmitter;
381
+ module.exports.makeNamespacedEmitters = makeNamespacedEmitters;