@blamejs/blamejs-shop 0.4.85 → 0.4.86

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 (59) hide show
  1. package/CHANGELOG.md +2 -0
  2. package/lib/asset-manifest.json +1 -1
  3. package/lib/order.js +4 -2
  4. package/lib/security-middleware.js +1 -0
  5. package/lib/vendor/MANIFEST.json +71 -39
  6. package/lib/vendor/blamejs/CHANGELOG.md +2 -0
  7. package/lib/vendor/blamejs/api-snapshot.json +11 -3
  8. package/lib/vendor/blamejs/lib/atomic-file.js +34 -0
  9. package/lib/vendor/blamejs/lib/auth/fido-mds3.js +10 -0
  10. package/lib/vendor/blamejs/lib/auth/password.js +1 -0
  11. package/lib/vendor/blamejs/lib/auth/saml.js +11 -3
  12. package/lib/vendor/blamejs/lib/daemon.js +4 -1
  13. package/lib/vendor/blamejs/lib/external-db.js +131 -0
  14. package/lib/vendor/blamejs/lib/graphql-federation.js +25 -15
  15. package/lib/vendor/blamejs/lib/log-stream-cloudwatch.js +1 -0
  16. package/lib/vendor/blamejs/lib/log-stream-local.js +14 -1
  17. package/lib/vendor/blamejs/lib/log-stream-otlp.js +1 -0
  18. package/lib/vendor/blamejs/lib/log-stream-webhook.js +1 -0
  19. package/lib/vendor/blamejs/lib/mail-auth.js +69 -14
  20. package/lib/vendor/blamejs/lib/mail-bimi.js +6 -0
  21. package/lib/vendor/blamejs/lib/mail-crypto-smime.js +10 -0
  22. package/lib/vendor/blamejs/lib/mail-dkim.js +68 -15
  23. package/lib/vendor/blamejs/lib/mail.js +39 -0
  24. package/lib/vendor/blamejs/lib/middleware/api-encrypt.js +6 -2
  25. package/lib/vendor/blamejs/lib/network-dns-resolver.js +61 -11
  26. package/lib/vendor/blamejs/lib/network-dns.js +47 -2
  27. package/lib/vendor/blamejs/lib/network-nts.js +16 -0
  28. package/lib/vendor/blamejs/lib/network-proxy.js +55 -2
  29. package/lib/vendor/blamejs/lib/object-store/azure-blob-bucket-ops.js +1 -0
  30. package/lib/vendor/blamejs/lib/object-store/gcs-bucket-ops.js +1 -0
  31. package/lib/vendor/blamejs/lib/object-store/http-request.js +4 -0
  32. package/lib/vendor/blamejs/lib/outbox.js +29 -0
  33. package/lib/vendor/blamejs/lib/queue-sqs.js +1 -0
  34. package/lib/vendor/blamejs/lib/request-helpers.js +25 -6
  35. package/lib/vendor/blamejs/lib/session-device-binding.js +46 -24
  36. package/lib/vendor/blamejs/lib/session.js +85 -28
  37. package/lib/vendor/blamejs/package.json +1 -1
  38. package/lib/vendor/blamejs/release-notes/v0.15.16.json +94 -0
  39. package/lib/vendor/blamejs/test/layer-0-primitives/api-encrypt.test.js +51 -0
  40. package/lib/vendor/blamejs/test/layer-0-primitives/atomic-file-open-append-nofollow.test.js +87 -0
  41. package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +48 -0
  42. package/lib/vendor/blamejs/test/layer-0-primitives/external-db-non-atomic-backend.test.js +200 -0
  43. package/lib/vendor/blamejs/test/layer-0-primitives/fido-mds3-cert-bad-validity.test.js +159 -0
  44. package/lib/vendor/blamejs/test/layer-0-primitives/graphql-federation.test.js +49 -2
  45. package/lib/vendor/blamejs/test/layer-0-primitives/mail-auth-dmarc-policy-failclosed.test.js +139 -0
  46. package/lib/vendor/blamejs/test/layer-0-primitives/mail-bimi-cert-validity-unparseable.test.js +137 -0
  47. package/lib/vendor/blamejs/test/layer-0-primitives/mail-crypto-smime-bad-validity.test.js +134 -0
  48. package/lib/vendor/blamejs/test/layer-0-primitives/mail-dkim-numericdate-failclosed.test.js +155 -0
  49. package/lib/vendor/blamejs/test/layer-0-primitives/mail-proxy-framing-bounds.test.js +263 -0
  50. package/lib/vendor/blamejs/test/layer-0-primitives/network-dns-lookup-timeout-default.test.js +116 -0
  51. package/lib/vendor/blamejs/test/layer-0-primitives/network-dns-resolver-timeout.test.js +126 -0
  52. package/lib/vendor/blamejs/test/layer-0-primitives/network-nts-handshake-byte-cap.test.js +127 -0
  53. package/lib/vendor/blamejs/test/layer-0-primitives/saml-subjectconfirmation-notbefore.test.js +238 -0
  54. package/lib/vendor/blamejs/test/layer-0-primitives/session-destroy-all-store-backed.test.js +128 -0
  55. package/lib/vendor/blamejs/test/layer-0-primitives/session-device-binding-ipv6-canonical-and-no-store.test.js +202 -0
  56. package/lib/vendor/blamejs/test/layer-0-primitives/session-device-binding.test.js +18 -9
  57. package/lib/vendor/blamejs/test/layer-0-primitives/webhook-verify-nonce-atomic.test.js +169 -0
  58. package/lib/webhooks.js +38 -9
  59. package/package.json +1 -1
@@ -28,11 +28,18 @@ var HEX_RADIX = C.BYTES.bytes(16); // parseInt / toString radix-16
28
28
  var observability = lazyRequire(function () { return require("./observability"); });
29
29
  var safeEnv = require("./parsers/safe-env");
30
30
 
31
+ // Default wall-clock deadline for every lookup (resolve / DoH / DoT /
32
+ // system). Without a non-zero default a header-then-stall or
33
+ // slow-trickle upstream hangs the request forever (CWE-400). 10s
34
+ // matches the resolver's default. Operators override via
35
+ // setLookupTimeoutMs(); 0 disables the deadline (operator opt-out).
36
+ var DEFAULT_LOOKUP_TIMEOUT_MS = C.TIME.seconds(10);
37
+
31
38
  var STATE = {
32
39
  servers: null,
33
40
  resultOrder: null,
34
41
  family: 0,
35
- lookupTimeoutMs: 0,
42
+ lookupTimeoutMs: DEFAULT_LOOKUP_TIMEOUT_MS,
36
43
  cacheTtlMs: 0,
37
44
  cacheNegativeTtlMs: 0,
38
45
  doh: null,
@@ -285,6 +292,19 @@ function _withTimeout(promise, ms, host) {
285
292
  });
286
293
  }
287
294
 
295
+ // Arm a wall-clock deadline on a raw https.request so a stalled upstream
296
+ // (headers-then-stall, slow-trickle body) tears the socket down instead
297
+ // of leaking the fd until the process exits. The promise-level
298
+ // _withTimeout rejects the caller, but only this destroys the underlying
299
+ // connection. No-op when the operator has disabled the deadline (ms<=0).
300
+ function _armRequestTimeout(req, ms, host, reject) {
301
+ if (ms <= 0) return;
302
+ req.setTimeout(ms, function () {
303
+ try { req.destroy(); } catch (_e) { /* best-effort socket teardown */ }
304
+ reject(new DnsError("dns/lookup-timeout", "dns lookup of '" + host + "' exceeded " + ms + "ms"));
305
+ });
306
+ }
307
+
288
308
  function _encodeDnsQuery(host, qtype) {
289
309
  var parts = host.split(".").filter(Boolean);
290
310
  var nameLen = 1;
@@ -450,6 +470,7 @@ async function _dohLookup(host, family) {
450
470
  });
451
471
  });
452
472
  req.on("error", function (e) { reject(new DnsError("dns/doh-failed", "DoH request failed: " + e.message)); });
473
+ _armRequestTimeout(req, STATE.lookupTimeoutMs, host, reject);
453
474
  if (usePost) req.write(enc.buf);
454
475
  req.end();
455
476
  });
@@ -511,6 +532,7 @@ async function _dohLookupSecure(host, family) {
511
532
  });
512
533
  });
513
534
  req.on("error", function (e) { reject(new DnsError("dns/doh-failed", "DoH request failed: " + e.message)); });
535
+ _armRequestTimeout(req, STATE.lookupTimeoutMs, host, reject);
514
536
  if (usePost) req.write(enc.buf);
515
537
  req.end();
516
538
  });
@@ -591,6 +613,17 @@ function _dotConnect() {
591
613
  // when idle — _dotLookup toggles this around its query. Calling
592
614
  // unref() unconditionally here let node exit during a normal lookup
593
615
  // when no other I/O kept the event loop alive.
616
+ //
617
+ // Wall-clock teardown: a stalled handshake or trickle-response upstream
618
+ // would otherwise leak the socket until the process exits (CWE-400).
619
+ // On inactivity past the deadline destroy the socket — the pool's
620
+ // error/close handlers evict it and the next lookup rebuilds. No-op
621
+ // when the operator has disabled the deadline (lookupTimeoutMs<=0).
622
+ if (STATE.lookupTimeoutMs > 0) {
623
+ sock.setTimeout(STATE.lookupTimeoutMs, function () {
624
+ try { sock.destroy(); } catch (_e) { /* best-effort socket teardown */ }
625
+ });
626
+ }
594
627
  return sock;
595
628
  }
596
629
 
@@ -865,6 +898,7 @@ async function _dohRawQuery(host, qtype) {
865
898
  });
866
899
  });
867
900
  req.on("error", function (e) { reject(new DnsError("dns/doh-failed", "DoH request failed: " + e.message)); });
901
+ _armRequestTimeout(req, STATE.lookupTimeoutMs, host, reject);
868
902
  if (usePost) req.write(enc.buf);
869
903
  req.end();
870
904
  });
@@ -979,6 +1013,17 @@ async function _systemRawQuery(host, qtype) {
979
1013
  try { sock.destroy(); } catch (_e) { /* best-effort socket teardown */ }
980
1014
  if (err) reject(err); else resolve(val);
981
1015
  }
1016
+ // Wall-clock teardown: a server that accepts the TCP connection but
1017
+ // never replies (or trickles the length-prefix) would otherwise hang
1018
+ // the query forever (CWE-400). On inactivity past the deadline
1019
+ // settle()'s destroy() tears the socket down. No-op when the operator
1020
+ // has disabled the deadline (lookupTimeoutMs<=0).
1021
+ if (STATE.lookupTimeoutMs > 0) {
1022
+ sock.setTimeout(STATE.lookupTimeoutMs, function () {
1023
+ settle(new DnsError("dns/lookup-timeout",
1024
+ "system DNS TCP query of '" + host + "' exceeded " + STATE.lookupTimeoutMs + "ms"));
1025
+ });
1026
+ }
982
1027
  sock.on("connect", function () {
983
1028
  var lenBuf = Buffer.alloc(2);
984
1029
  lenBuf.writeUInt16BE(enc.buf.length, 0);
@@ -1684,7 +1729,7 @@ function nodeLookup(host, options, callback) {
1684
1729
  function _stateForTest() { return STATE; }
1685
1730
  function _resetForTest() {
1686
1731
  STATE.servers = null; STATE.resultOrder = null; STATE.family = 0;
1687
- STATE.lookupTimeoutMs = 0; STATE.cacheTtlMs = 0; STATE.cacheNegativeTtlMs = 0;
1732
+ STATE.lookupTimeoutMs = DEFAULT_LOOKUP_TIMEOUT_MS; STATE.cacheTtlMs = 0; STATE.cacheNegativeTtlMs = 0;
1688
1733
  STATE.doh = null; STATE.dot = null; STATE.systemResolver = false;
1689
1734
  _designatedResolvers = null;
1690
1735
  _clearCache();
@@ -7,12 +7,20 @@ var nodeCrypto = require("node:crypto");
7
7
  var C = require("./constants");
8
8
  var { timingSafeEqual } = require("./crypto");
9
9
  var validateOpts = require("./validate-opts");
10
+ var safeBuffer = require("./safe-buffer");
10
11
  var { defineClass } = require("./framework-error");
11
12
 
12
13
  var NtsError = defineClass("NtsError", { alwaysPermanent: false });
13
14
 
14
15
  var NTS_KE_DEFAULT_PORT = 4460;
15
16
  var NTPV4_DEFAULT_PORT = 123;
17
+ // Upper bound on accumulated NTS-KE handshake bytes before a REC_END
18
+ // record terminates the exchange. A conformant NTS-KE response is a few
19
+ // hundred bytes (RFC 8915 §4); 64 KiB is generous. Without this ceiling
20
+ // a malicious or buggy server that streams non-END records fast enough
21
+ // OOMs the process before the wall-clock timer fires (it bounds time,
22
+ // not memory). Mirrors the ws-client handshake header cap.
23
+ var NTS_KE_HANDSHAKE_MAX_BYTES = C.BYTES.kib(64);
16
24
  // RFC 5905 §6 — seconds between 1900-01-01T00:00Z (NTP epoch) and
17
25
  // 1970-01-01T00:00Z (Unix epoch). Protocol-fixed (not a tunable).
18
26
  var NTP_TO_UNIX_OFFSET_SECONDS = 2208988800;
@@ -284,6 +292,14 @@ function performKeHandshake(opts) {
284
292
  var warnings = [];
285
293
  sock.on("data", function (chunk) {
286
294
  got = Buffer.concat([got, chunk]);
295
+ if (safeBuffer.byteLengthOf(got) > NTS_KE_HANDSHAKE_MAX_BYTES) {
296
+ clearTimeout(timer);
297
+ try { sock.destroy(); } catch (_e) { /* best-effort socket teardown */ }
298
+ done(new NtsError("nts/ke-too-large",
299
+ "NTS-KE handshake exceeded " + NTS_KE_HANDSHAKE_MAX_BYTES +
300
+ " bytes before a REC_END record"));
301
+ return;
302
+ }
287
303
  try {
288
304
  var records = _decodeRecords(got);
289
305
  var endRec = records.find(function (r) { return r.type === REC_END; });
@@ -7,6 +7,7 @@ var nodeTls = require("node:tls");
7
7
 
8
8
  var C = require("./constants");
9
9
  var lazyRequire = require("./lazy-require");
10
+ var safeBuffer = require("./safe-buffer");
10
11
  var safeUrl = require("./safe-url");
11
12
  var validateOpts = require("./validate-opts");
12
13
  var { defineClass } = require("./framework-error");
@@ -19,6 +20,23 @@ var IPV4_PREFIX_MAX_BITS = C.BYTES.bytes(32); // RFC 791 §3.1 IPv4 address bi
19
20
  var DEFAULT_HTTPS_PORT = 443; // RFC 9110 §4.2.2
20
21
  var DEFAULT_HTTP_PORT = C.BYTES.bytes(80); // RFC 9110 §4.2.1
21
22
 
23
+ // Bound the CONNECT-reply framing buffer (mirrors ws-client's handshake
24
+ // cap). A proxy that streams bytes without ever sending the CRLFCRLF
25
+ // header terminator would otherwise grow `buf` without limit and OOM the
26
+ // process; CONNECT replies are tiny, 64 KiB is generous headroom.
27
+ var TUNNEL_HEADER_MAX_BYTES = C.BYTES.kib(64);
28
+ // Wall-clock bound on the proxy connect + CONNECT handshake. Without it a
29
+ // proxy that accepts the socket but never replies (or trickles forever)
30
+ // hangs the tunnel indefinitely.
31
+ var TUNNEL_CONNECT_TIMEOUT_MS = C.TIME.seconds(30);
32
+ // Test-only override of the connect deadline so a unit test can prove the
33
+ // absolute-timeout behavior without a 30s wall-clock wait. null = use the
34
+ // real default. Cleared by _resetForTest.
35
+ var _testConnectTimeoutMs = null;
36
+ function _connectTimeoutMs() {
37
+ return typeof _testConnectTimeoutMs === "number" ? _testConnectTimeoutMs : TUNNEL_CONNECT_TIMEOUT_MS;
38
+ }
39
+
22
40
  var observability = lazyRequire(function () { return require("./observability"); });
23
41
  // Lazy so pqc-agent's TLS/audit graph isn't pulled into every process that
24
42
  // imports network-proxy but never proxies an https upstream. Used only to audit
@@ -169,7 +187,27 @@ function _connectThroughTunnel(proxyUrl, targetHost, targetPort, callback) {
169
187
  })
170
188
  : net.connect({ host: proxyUrl.hostname, port: proxyPort });
171
189
  var settled = false;
172
- function done(err, sock) { if (settled) return; settled = true; callback(err, sock); }
190
+ var connectDeadline = null;
191
+ function done(err, sock) {
192
+ if (settled) return;
193
+ settled = true;
194
+ if (connectDeadline) { try { clearTimeout(connectDeadline); } catch (_e) { /* best-effort */ } connectDeadline = null; }
195
+ callback(err, sock);
196
+ }
197
+ // ABSOLUTE wall-clock deadline on the proxy connect + CONNECT handshake. A
198
+ // proxy that accepts the socket but never sends the CRLFCRLF terminator —
199
+ // or trickles partial bytes just inside every idle window — must not hang
200
+ // the tunnel forever. socket.setTimeout is an IDLE timer that such a trickle
201
+ // resets indefinitely, so a fixed setTimeout (cleared in done()) enforces a
202
+ // real total-time bound regardless of how the bytes arrive.
203
+ var connectTimeoutMs = _connectTimeoutMs();
204
+ connectDeadline = setTimeout(function () {
205
+ done(new ProxyError("proxy/connect-timeout",
206
+ "proxy CONNECT to " + targetHost + ":" + targetPort + " timed out after " +
207
+ connectTimeoutMs + "ms"));
208
+ try { proxySocket.destroy(); } catch (_e) { /* best-effort socket teardown */ }
209
+ }, connectTimeoutMs);
210
+ if (connectDeadline && typeof connectDeadline.unref === "function") connectDeadline.unref();
173
211
  proxySocket.on("error", function (e) { done(e); });
174
212
  proxySocket.on(proxyUrl.protocol === "https:" ? "secureConnect" : "connect", function () {
175
213
  if (proxyUrl.protocol === "https:") {
@@ -191,7 +229,18 @@ function _connectThroughTunnel(proxyUrl, targetHost, targetPort, callback) {
191
229
  function onData(chunk) {
192
230
  buf = Buffer.concat([buf, chunk]);
193
231
  var idx = buf.indexOf("\r\n\r\n");
194
- if (idx === -1) return;
232
+ if (idx === -1) {
233
+ // No header terminator yet — bound the accumulator so a proxy
234
+ // that trickles non-CRLFCRLF bytes forever can't OOM the process.
235
+ if (safeBuffer.byteLengthOf(buf) > TUNNEL_HEADER_MAX_BYTES) {
236
+ proxySocket.removeListener("data", onData);
237
+ done(new ProxyError("proxy/connect-headers-too-large",
238
+ "proxy CONNECT reply exceeded " + TUNNEL_HEADER_MAX_BYTES +
239
+ " bytes before CRLFCRLF"));
240
+ try { proxySocket.destroy(); } catch (_e) { /* best-effort socket teardown */ }
241
+ }
242
+ return;
243
+ }
195
244
  proxySocket.removeListener("data", onData);
196
245
  var head = buf.slice(0, idx).toString("ascii");
197
246
  var status = head.split("\r\n")[0] || "";
@@ -271,8 +320,11 @@ function snapshot() {
271
320
  function _resetForTest() {
272
321
  STATE.http = null; STATE.https = null; STATE.noProxy = []; STATE.auth = null;
273
322
  STATE.agentCache.clear();
323
+ _testConnectTimeoutMs = null;
274
324
  }
275
325
 
326
+ function _setConnectTimeoutForTest(ms) { _testConnectTimeoutMs = ms; }
327
+
276
328
  module.exports = {
277
329
  set: set,
278
330
  fromEnv: fromEnv,
@@ -281,4 +333,5 @@ module.exports = {
281
333
  snapshot: snapshot,
282
334
  ProxyError: ProxyError,
283
335
  _resetForTest: _resetForTest,
336
+ _setConnectTimeoutForTest: _setConnectTimeoutForTest,
284
337
  };
@@ -233,6 +233,7 @@ function create(config) {
233
233
  url: url,
234
234
  headers: headers,
235
235
  body: body,
236
+ timeoutMs: timeoutMs,
236
237
  idleTimeoutMs: timeoutMs,
237
238
  allowedProtocols: allowedProtocols,
238
239
  errorClass: ObjectStoreError,
@@ -182,6 +182,7 @@ function create(config) {
182
182
  url: url,
183
183
  headers: headers,
184
184
  body: body,
185
+ timeoutMs: timeoutMs,
185
186
  idleTimeoutMs: timeoutMs,
186
187
  allowedProtocols: allowedProtocols,
187
188
  errorClass: ObjectStoreError,
@@ -36,6 +36,10 @@ function request(method, url, headers, body, opts) {
36
36
  url: url,
37
37
  headers: headers,
38
38
  body: body,
39
+ // Both caps from the operator's configured timeout: timeoutMs bounds the
40
+ // whole request (no slow-trickle hold-open), idleTimeoutMs the zero-progress
41
+ // window. Undefined leaves httpClient's defaults unchanged.
42
+ timeoutMs: opts.timeoutMs,
39
43
  idleTimeoutMs: opts.timeoutMs,
40
44
  errorClass: opts.errorClass || ObjectStoreError,
41
45
  allowedProtocols: opts.allowedProtocols,
@@ -214,6 +214,35 @@ function create(opts) {
214
214
  throw new OutboxError("outbox/bad-externaldb",
215
215
  "outbox.create: externalDb must be the b.externalDb namespace (with transaction/query)");
216
216
  }
217
+ // The outbox dual-write guarantee (enqueue runs in the SAME
218
+ // transaction as the operator's business write) is VOID on a
219
+ // stateless / autocommit-per-statement backend: BEGIN / the
220
+ // business write / the enqueue INSERT / COMMIT each land on a
221
+ // different session, so a crash or rollback can drop the business
222
+ // row while keeping the event (or vice versa). Refuse at create()
223
+ // when the backend declares it cannot provide interactive
224
+ // transactions, rather than constructing an outbox whose core
225
+ // guarantee silently does not hold. supportsTransactions may be a
226
+ // boolean (custom externalDb object) or a function (the
227
+ // b.externalDb namespace probe); only an explicit false refuses —
228
+ // an absent flag preserves the historical stateful assumption.
229
+ var cap = v.supportsTransactions;
230
+ var atomic = true;
231
+ if (typeof cap === "function") {
232
+ try { atomic = !!cap(); } catch (_e) { atomic = true; }
233
+ } else if (cap === false) {
234
+ atomic = false;
235
+ }
236
+ if (!atomic) {
237
+ throw new OutboxError("outbox/non-atomic-backend",
238
+ "outbox.create: externalDb backend cannot provide interactive " +
239
+ "transactions (supportsTransactions: false — a stateless / " +
240
+ "autocommit-per-statement adapter). The outbox dual-write guarantee " +
241
+ "requires the business write and the enqueue INSERT to commit " +
242
+ "atomically in one transaction; on this backend they would not. " +
243
+ "Supply interactive beginTx / commit / rollback hooks, or a batch " +
244
+ "adapter, on the externalDb backend first.");
245
+ }
217
246
  },
218
247
  table: function (v) {
219
248
  validateOpts.requireNonEmptyString(v,
@@ -153,6 +153,7 @@ function create(opts) {
153
153
  url: endpointUrl,
154
154
  headers: signed.headers,
155
155
  body: bodyBuf,
156
+ timeoutMs: timeoutMs,
156
157
  idleTimeoutMs: timeoutMs,
157
158
  allowedProtocols: allowedProtocols,
158
159
  errorClass: QueueError,
@@ -476,7 +476,7 @@ function _maskIpv6(ip, prefix) {
476
476
 
477
477
  /**
478
478
  * @primitive b.requestHelpers.ipPrefix
479
- * @signature b.requestHelpers.ipPrefix(ip)
479
+ * @signature b.requestHelpers.ipPrefix(ip, opts?)
480
480
  * @since 0.15.15
481
481
  * @related b.requestHelpers.clientIp, b.requestHelpers.trustedClientIp
482
482
  *
@@ -493,22 +493,41 @@ function _maskIpv6(ip, prefix) {
493
493
  * public IP within a subnet don't log a user out). Exposed so an operator who
494
494
  * drops to a function-form fingerprint field — for a custom mask width, or to
495
495
  * combine the prefix with other signals — reuses this exact algorithm instead
496
- * of re-deriving the /24 + /64 masking (and silently diverging).
496
+ * of re-deriving the /24 + /64 masking (and silently diverging). Pass
497
+ * <code>opts.v4Bits</code> / <code>opts.v6Bits</code> to override the mask
498
+ * widths (e.g. a device fingerprint that buckets at <code>/48</code> so a
499
+ * client roaming within its allocation but across a <code>/64</code> doesn't
500
+ * drift); an out-of-range or absent value falls back to the /24 + /64 default.
501
+ *
502
+ * @opts
503
+ * v4Bits: number, // IPv4 mask width in bits (default 24; valid 0..32)
504
+ * v6Bits: number, // IPv6 mask width in bits (default 64; valid 0..128)
497
505
  *
498
506
  * @example
499
507
  * b.requestHelpers.ipPrefix("203.0.113.47"); // → "203.0.113.0/24"
500
508
  * b.requestHelpers.ipPrefix("2001:db8::1"); // → "2001:db8:0:0/64"
501
509
  */
502
- function ipPrefix(ip) {
510
+ function _resolvePrefixBits(bits, def, max) {
511
+ if (typeof bits !== "number" || !isFinite(bits) || bits < 0 || bits > max) return def;
512
+ return bits;
513
+ }
514
+ function ipPrefix(ip, opts) {
503
515
  if (typeof ip !== "string" || ip.length === 0) return "";
516
+ opts = opts || {};
517
+ // Configurable mask widths (default /24 + /64); an absent or out-of-range
518
+ // value falls back to the default. This preserves a caller's documented
519
+ // prefix width (e.g. a device fingerprint bucketing at /48) instead of
520
+ // silently forcing /64 — while still canonicalizing the address.
521
+ var v4 = _resolvePrefixBits(opts.v4Bits, IPV4_DEFAULT_PREFIX, 32); // IPv4 max prefix length in bits
522
+ var v6 = _resolvePrefixBits(opts.v6Bits, IPV6_DEFAULT_PREFIX, 128); // IPv6 max prefix length in bits
504
523
  // IPv4-mapped IPv6 (::ffff:1.2.3.4) — strip the wrapper so the v4 mask
505
524
  // applies. Same bucket regardless of how the proxy reported it.
506
525
  var lower = ip.toLowerCase();
507
526
  if (lower.indexOf(V4_MAPPED_V6_PREFIX) === 0 && lower.indexOf(".") !== -1) {
508
- return _maskIpv4(lower.substring(V4_MAPPED_V6_PREFIX.length), IPV4_DEFAULT_PREFIX) || "";
527
+ return _maskIpv4(lower.substring(V4_MAPPED_V6_PREFIX.length), v4) || "";
509
528
  }
510
- if (ip.indexOf(":") !== -1) return _maskIpv6(ip, IPV6_DEFAULT_PREFIX) || "";
511
- if (ip.indexOf(".") !== -1) return _maskIpv4(ip, IPV4_DEFAULT_PREFIX) || "";
529
+ if (ip.indexOf(":") !== -1) return _maskIpv6(ip, v6) || "";
530
+ if (ip.indexOf(".") !== -1) return _maskIpv4(ip, v4) || "";
512
531
  return "";
513
532
  }
514
533
 
@@ -52,6 +52,14 @@
52
52
  * primitive falls back to b.session.touch metadata when the operator
53
53
  * passes session=b.session AND opts in via storeInSession=true.
54
54
  *
55
+ * No store at all: create() with neither bindingStore nor storeInSession
56
+ * still returns an instance — its stateless fingerprint(req) works (it
57
+ * touches no store), while bind/verify/unbind throw a clear "no store
58
+ * configured" error. Operators who only need the soft, store-free digest
59
+ * (sealed inside a self-validating cookie / JWT that compares it itself)
60
+ * can also skip create() entirely and call the static
61
+ * b.sessionDeviceBinding.fingerprint(req, opts).
62
+ *
55
63
  * Audit emissions:
56
64
  *
57
65
  * session.device.bound every successful bind()
@@ -61,7 +69,9 @@
61
69
  *
62
70
  * Validation policy:
63
71
  * - create() opts → throw at config time
64
- * - bind / verify → throw on bad token / req shape (operator typo)
72
+ * - bind / verify / unbind → throw on bad token / req shape (operator
73
+ * typo), and throw "no store configured" when called
74
+ * on a store-free instance
65
75
  * - storage errors → fail-CLOSED on verify (drift indistinguishable
66
76
  * from a wiped store, refuse rather than allow)
67
77
  * fail-OPEN on bind (don't lose a fresh session
@@ -135,28 +145,22 @@ function _normalizeAcceptEncoding(value) {
135
145
  .join(",");
136
146
  }
137
147
 
148
+ // Mask the client IP to its fingerprint bucket. Routes through the shared
149
+ // canonical masker (requestHelpers.ipPrefix) so a `::`-shorthand address and
150
+ // its fully-expanded equivalent (2001:db8::1 vs 2001:db8:0:0:0:0:0:1), or a
151
+ // leading-zero-folded group, collapse to ONE bucket — the hand-rolled textual
152
+ // ':'-group slice this replaced hashed them differently and logged a roaming
153
+ // user out on a false drift. `bits === 0` is the documented "skip the IP check
154
+ // entirely" escape hatch (mobile clients that switch networks), so it returns
155
+ // "" before any masking. `bits` is the family-resolved configured width (cfg
156
+ // .v4Bits for a v4 client, cfg.v6Bits for v6 — default /24 + /48); pass it as
157
+ // BOTH v4Bits and v6Bits so the canonical masker applies the configured width
158
+ // to whichever family it detects, instead of ipPrefix's bare /24 + /64 default
159
+ // (which would drop the configured width AND silently tighten v6 from /48 to /64).
138
160
  function _ipPrefix(ip, bits) {
139
161
  if (typeof ip !== "string" || ip.length === 0) return "";
140
162
  if (bits === 0) return "";
141
- // IPv6
142
- if (ip.indexOf(":") !== -1) {
143
- var v6Bits = bits;
144
- var groups = ip.split(":");
145
- // Naive expansion — keep the first ceil(v6Bits/16) groups intact
146
- // and zero the rest. Sufficient for fingerprint stability; not a
147
- // canonical IPv6 representation.
148
- var keepGroups = Math.ceil(v6Bits / 16); // IPv6 group width in bits
149
- var kept = groups.slice(0, keepGroups).join(":");
150
- return "v6:" + kept + "/" + v6Bits;
151
- }
152
- // IPv4
153
- var parts = ip.split(".");
154
- if (parts.length !== 4) return "v4:" + ip + "/" + bits;
155
- var v4Bits = bits;
156
- var keepOctets = Math.floor(v4Bits / 8); // IPv4 octet width in bits
157
- var maskedOctets = parts.slice(0, keepOctets);
158
- while (maskedOctets.length < 4) maskedOctets.push("0");
159
- return "v4:" + maskedOctets.join(".") + "/" + v4Bits;
163
+ return requestHelpers.ipPrefix(ip, { v4Bits: bits, v6Bits: bits });
160
164
  }
161
165
 
162
166
  // Resolve operator-supplied fingerprintExtras(req) to a stable string. A
@@ -195,6 +199,7 @@ function _computeDeviceFingerprint(req, cfg) {
195
199
  var ae = _normalizeAcceptEncoding(headers["accept-encoding"]);
196
200
  var ip = "";
197
201
  try { ip = requestHelpers.clientIp(req); } catch (_e) { ip = ""; }
202
+ if (typeof ip !== "string") ip = "";
198
203
  var family = ip.indexOf(":") !== -1 ? "v6" : "v4";
199
204
  var ipPart = _ipPrefix(ip, family === "v6" ? cfg.v6Bits : cfg.v4Bits);
200
205
  var keyPart = "";
@@ -291,10 +296,15 @@ function create(opts) {
291
296
  }
292
297
 
293
298
  var storeInSession = !!opts.storeInSession;
294
- if (!storeInSession && !opts.bindingStore) {
295
- throw new SessionDeviceBindingError("session-device-binding/bad-opt",
296
- "either bindingStore (b.cache-shaped) or storeInSession=true must be set");
297
- }
299
+ // A no-store instance is still useful: the stateless fingerprint() reads no
300
+ // store and is the soft device-binding building block for self-validating
301
+ // tokens (a sealed cookie / JWT carrying the fingerprint inside). Rather than
302
+ // refuse to construct (issue #330 — fingerprint() unreachable without a
303
+ // store), build the instance and let the persisted bind()/verify() lifecycle
304
+ // throw a clear "no store configured" when actually called. Operators wanting
305
+ // ONLY the stateless digest can also use the static
306
+ // b.sessionDeviceBinding.fingerprint(req, opts) with no create() at all.
307
+ var hasStore = !!(storeInSession || opts.bindingStore);
298
308
  if (opts.bindingStore) _requireBindingStore(opts.bindingStore);
299
309
  if (storeInSession && (!opts.session || typeof opts.session.touch !== "function")) {
300
310
  throw new SessionDeviceBindingError("session-device-binding/bad-opt",
@@ -351,8 +361,18 @@ function create(opts) {
351
361
  return { ok: true, fingerprint: r.fingerprint, components: r.components };
352
362
  }
353
363
 
364
+ function _requireStore(stage) {
365
+ if (!hasStore) {
366
+ throw new SessionDeviceBindingError("session-device-binding/no-store",
367
+ stage + ": no store configured — pass bindingStore (b.cache-shaped) or "
368
+ + "storeInSession=true to create(), or use the stateless "
369
+ + "b.sessionDeviceBinding.fingerprint(req, opts) for soft binding");
370
+ }
371
+ }
372
+
354
373
  async function bind(token, req) {
355
374
  _requireToken(token);
375
+ _requireStore("bind");
356
376
  var fp = _computeFingerprint(req);
357
377
  if (!fp.ok) {
358
378
  _emitObs("session.device.refused", { reason: fp.reason });
@@ -408,6 +428,7 @@ function create(opts) {
408
428
 
409
429
  async function verify(token, req) {
410
430
  _requireToken(token);
431
+ _requireStore("verify");
411
432
  var fpResult = _computeFingerprint(req);
412
433
  if (!fpResult.ok) {
413
434
  _emitObs("session.device.refused", { reason: fpResult.reason });
@@ -444,6 +465,7 @@ function create(opts) {
444
465
 
445
466
  async function unbind(token) {
446
467
  _requireToken(token);
468
+ _requireStore("unbind");
447
469
  if (bindingStore) {
448
470
  try { await bindingStore.del(token); } catch (_e) { /* drop-silent */ }
449
471
  }
@@ -182,6 +182,49 @@ function _validFromConflictRefs(dialect, table) {
182
182
  };
183
183
  }
184
184
 
185
+ // CREATE TABLE IF NOT EXISTS for the valid-from boundary, matching the
186
+ // framework schema in db.js (single-node) / framework-schema.js (cluster mode):
187
+ // subjectHash PRIMARY KEY, validFromEpoch + updatedAt NOT NULL. The pluggable
188
+ // store is always a dedicated node:sqlite file (b.session.stores.localDbThin —
189
+ // see session-stores.js), so the dialect is the literal "sqlite". Used to
190
+ // provision the table on demand in a store-backed-only deployment (a
191
+ // b.session.useStore consumer that never ran b.db.init(), so the framework db
192
+ // — the default home of this table — is not initialized).
193
+ function _validFromSchemaSql() {
194
+ return sql.createTable(_validFromSqlTable(), [
195
+ { name: "subjectHash", type: "text", primaryKey: true },
196
+ { name: "validFromEpoch", type: "int", notNull: true },
197
+ { name: "updatedAt", type: "int", notNull: true },
198
+ ], { dialect: "sqlite" }).sql;
199
+ }
200
+
201
+ // Run a valid-from boundary operation (bump write / validFrom read / check
202
+ // read) against the correct backend. The boundary lives in the FRAMEWORK db
203
+ // (clusterStorage) — it is a stateless-token revocation primitive shared across
204
+ // every issuer, not per-session data — so that is always the first choice and a
205
+ // present db is never silently bypassed. ONLY when the framework db is not
206
+ // initialized (single-node, b.db.init() never awaited) AND an operator store is
207
+ // configured (b.session.useStore) does the boundary fall back to that store, so
208
+ // a store-backed-only deployment's logout-everywhere still raises (and honors)
209
+ // the stateless boundary instead of 500ing on db/not-initialized (#340). With
210
+ // neither a framework db nor a store, db/not-initialized is a real
211
+ // misconfiguration and propagates unchanged (fail closed — the boundary is
212
+ // never silently dropped). The store provisions the table on demand because a
213
+ // session-data store (localDbThin) does not ship the valid-from DDL.
214
+ async function _runValidFrom(runner) {
215
+ try {
216
+ return await runner(clusterStorage);
217
+ } catch (e) {
218
+ if (e && e.code === "db/not-initialized" && _store) {
219
+ // Framework db absent, operator store present: route through the store,
220
+ // provisioning the boundary table first (idempotent CREATE IF NOT EXISTS).
221
+ await _store.execute(_validFromSchemaSql(), []);
222
+ return await runner(_store);
223
+ }
224
+ throw e;
225
+ }
226
+ }
227
+
185
228
  // Column order used for INSERT — kept as a constant so the placeholders
186
229
  // list and the values list stay in sync. Must match the session table's
187
230
  // schema in db.js (single-node) and framework-schema.js (cluster mode).
@@ -786,21 +829,23 @@ async function destroyAllForUser(userId) {
786
829
  var result = await _currentStore().execute(built.sql, built.params);
787
830
  // Also raise the stateless valid-from boundary so a "logout everywhere"
788
831
  // revokes the operator's stateless tokens (sealed cookies / JWTs checked via
789
- // b.session.check) too, not only the store-backed rows just deleted. This
790
- // bump writes to the FRAMEWORK db (clusterStorage); a pluggable-store consumer
791
- // (b.session.useStore) who never ran b.db.init() would otherwise surface the
792
- // opaque "db/not-initialized" here, after the store delete already succeeded.
793
- // Rethrow it as a session-specific, actionable error.
832
+ // b.session.check) too, not only the store-backed rows just deleted. bump()
833
+ // writes to the framework db when one is initialized, otherwise to the
834
+ // configured store (b.session.useStore) so a store-backed-only consumer who
835
+ // never ran b.db.init() still raises the boundary here instead of 500ing on
836
+ // db/not-initialized (#340). The only state in which bump still surfaces
837
+ // db/not-initialized is the default store (no useStore) with an uninitialized
838
+ // framework db — but the store DELETE above (also via clusterStorage) would
839
+ // have already thrown, so this rewrap is defensive belt-and-suspenders.
794
840
  try {
795
841
  await bump(userId);
796
842
  } catch (e) {
797
843
  if (e && e.code === "db/not-initialized") {
798
844
  throw _err("MISCONFIGURED",
799
845
  "session.destroyAllForUser raises the stateless valid-from boundary (so a " +
800
- "logout-everywhere also revokes sealed-cookie / JWT sessions), which requires " +
801
- "b.db.init() — call it at boot even when session data lives in a pluggable " +
802
- "store (b.session.useStore). The store-backed rows were already deleted; rerun " +
803
- "after b.db.init() to also raise the stateless boundary.", true);
846
+ "logout-everywhere also revokes sealed-cookie / JWT sessions). No storage is " +
847
+ "available: call b.db.init() at boot, OR configure a session store via " +
848
+ "b.session.useStore. The store-backed rows were already deleted.", true);
804
849
  }
805
850
  throw e;
806
851
  }
@@ -1340,21 +1385,27 @@ async function bump(subjectId, opts) {
1340
1385
  .returning(["validFromEpoch"])
1341
1386
  .toSql();
1342
1387
  // The valid-from boundary is a FRAMEWORK table (FRAMEWORK_SCHEMA / cluster
1343
- // DDL), NOT session data — it must execute against clusterStorage (the
1344
- // framework db), never _currentStore(): a pluggable session-data store
1345
- // (b.session.useStore / localDbThin) does not provision this table, so
1346
- // routing the bump through it throws "no such table".
1347
- var row;
1348
- if (built.readbackSql) {
1349
- // MySQL: ON DUPLICATE KEY UPDATE has no RETURNING — run the upsert, then
1350
- // the readback SELECT b.sql emits (keyed on subjectHash).
1351
- await clusterStorage.execute(built.sql, built.params);
1352
- var readback = await clusterStorage.execute(built.readbackSql.sql, built.readbackSql.params);
1353
- row = readback.rows && readback.rows[0];
1354
- } else {
1355
- var result = await clusterStorage.execute(built.sql, built.params);
1356
- row = result.rows && result.rows[0];
1357
- }
1388
+ // DDL), NOT session data — it executes against clusterStorage (the framework
1389
+ // db) whenever one is initialized, never _currentStore(). When the framework
1390
+ // db is NOT initialized but an operator store IS configured (a store-backed-
1391
+ // only b.session.useStore deployment that never ran b.db.init()), the boundary
1392
+ // falls back to that store — provisioned on demand — so logout-everywhere
1393
+ // still raises the stateless boundary instead of throwing db/not-initialized
1394
+ // (#340). _runValidFrom resolves the target; never silently drops the boundary
1395
+ // when a db is present, and propagates db/not-initialized when neither exists.
1396
+ var row = await _runValidFrom(async function (target) {
1397
+ if (built.readbackSql) {
1398
+ // MySQL: ON DUPLICATE KEY UPDATE has no RETURNING — run the upsert, then
1399
+ // the readback SELECT b.sql emits (keyed on subjectHash). MySQL only ever
1400
+ // runs against the framework cluster db; the localDbThin fallback store is
1401
+ // sqlite (RETURNING), so this branch never routes through it.
1402
+ await target.execute(built.sql, built.params);
1403
+ var readback = await target.execute(built.readbackSql.sql, built.readbackSql.params);
1404
+ return readback.rows && readback.rows[0];
1405
+ }
1406
+ var result = await target.execute(built.sql, built.params);
1407
+ return result.rows && result.rows[0];
1408
+ });
1358
1409
  var effective = row ? Number(row.validFromEpoch) : epochMs;
1359
1410
 
1360
1411
  // Best-effort audit — matches the file's emit convention (safeEmit is
@@ -1393,10 +1444,16 @@ async function validFrom(subjectId) {
1393
1444
  .columns(["validFromEpoch"])
1394
1445
  .where("subjectHash", _hashSubjectId(subjectId))
1395
1446
  .toSql();
1396
- // Framework valid-from table — read from clusterStorage (the framework db),
1397
- // not _currentStore(), so a pluggable session-data store cannot divert it (it
1398
- // does not provision this table). See bump() for the full rationale.
1399
- var row = await clusterStorage.executeOne(built.sql, built.params);
1447
+ // Framework valid-from table — read from clusterStorage (the framework db)
1448
+ // when one is initialized, falling back to the configured store only when it
1449
+ // is not (the same store bump() wrote the boundary to in a store-backed-only
1450
+ // deployment). _runValidFrom keeps the read on the SAME backend the write
1451
+ // chose, so a boundary raised via destroyAllForUser/bump is the one read back
1452
+ // here. See bump() for the full rationale (#340).
1453
+ var row = await _runValidFrom(async function (target) {
1454
+ var result = await target.execute(built.sql, built.params);
1455
+ return result.rows && result.rows.length > 0 ? result.rows[0] : null;
1456
+ });
1400
1457
  return row ? Number(row.validFromEpoch) : 0;
1401
1458
  }
1402
1459