@blamejs/blamejs-shop 0.4.90 → 0.4.92

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 (48) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/lib/asset-manifest.json +1 -1
  3. package/lib/gift-card-ledger.js +8 -2
  4. package/lib/order.js +1 -0
  5. package/lib/vendor/MANIFEST.json +49 -41
  6. package/lib/vendor/blamejs/CHANGELOG.md +6 -0
  7. package/lib/vendor/blamejs/SECURITY.md +1 -0
  8. package/lib/vendor/blamejs/api-snapshot.json +208 -2
  9. package/lib/vendor/blamejs/examples/wiki/test/e2e.js +7 -4
  10. package/lib/vendor/blamejs/examples/wiki/test/integration.js +15 -12
  11. package/lib/vendor/blamejs/index.js +2 -0
  12. package/lib/vendor/blamejs/lib/audit-sign.js +34 -1
  13. package/lib/vendor/blamejs/lib/backup/manifest.js +191 -44
  14. package/lib/vendor/blamejs/lib/codepoint-class.js +284 -77
  15. package/lib/vendor/blamejs/lib/framework-error.js +14 -0
  16. package/lib/vendor/blamejs/lib/fsm.js +80 -24
  17. package/lib/vendor/blamejs/lib/log.js +32 -0
  18. package/lib/vendor/blamejs/lib/middleware/rate-limit.js +18 -2
  19. package/lib/vendor/blamejs/lib/middleware/request-id.js +24 -4
  20. package/lib/vendor/blamejs/lib/request-helpers.js +50 -0
  21. package/lib/vendor/blamejs/lib/safe-path.js +24 -10
  22. package/lib/vendor/blamejs/lib/sql.js +133 -0
  23. package/lib/vendor/blamejs/lib/totp.js +98 -33
  24. package/lib/vendor/blamejs/lib/ws-client.js +39 -28
  25. package/lib/vendor/blamejs/package.json +1 -1
  26. package/lib/vendor/blamejs/release-notes/v0.15.21.json +51 -0
  27. package/lib/vendor/blamejs/release-notes/v0.15.22.json +18 -0
  28. package/lib/vendor/blamejs/release-notes/v0.15.23.json +22 -0
  29. package/lib/vendor/blamejs/test/00-primitives.js +80 -0
  30. package/lib/vendor/blamejs/test/_smoke-worker.js +81 -0
  31. package/lib/vendor/blamejs/test/integration/federation-auth.test.js +7 -4
  32. package/lib/vendor/blamejs/test/integration/mail-crypto-smime.test.js +7 -4
  33. package/lib/vendor/blamejs/test/layer-0-primitives/backup-manifest-signature.test.js +91 -0
  34. package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +65 -0
  35. package/lib/vendor/blamejs/test/layer-0-primitives/codepoint-class.test.js +58 -0
  36. package/lib/vendor/blamejs/test/layer-0-primitives/defineguard-default-gate-posture-caps.test.js +5 -2
  37. package/lib/vendor/blamejs/test/layer-0-primitives/dpop-middleware-replaystore-required.test.js +9 -1
  38. package/lib/vendor/blamejs/test/layer-0-primitives/fsm.test.js +99 -0
  39. package/lib/vendor/blamejs/test/layer-0-primitives/money.test.js +30 -0
  40. package/lib/vendor/blamejs/test/layer-0-primitives/otlp-attr-redaction.test.js +9 -6
  41. package/lib/vendor/blamejs/test/layer-0-primitives/rate-limit-xff-spoofing.test.js +36 -0
  42. package/lib/vendor/blamejs/test/layer-0-primitives/request-helpers.test.js +33 -0
  43. package/lib/vendor/blamejs/test/layer-0-primitives/request-id-async-context.test.js +117 -0
  44. package/lib/vendor/blamejs/test/layer-0-primitives/safe-path.test.js +64 -0
  45. package/lib/vendor/blamejs/test/layer-0-primitives/sql.test.js +96 -0
  46. package/lib/vendor/blamejs/test/layer-0-primitives/ws-client.test.js +55 -0
  47. package/lib/vendor/blamejs/test/smoke.js +93 -10
  48. package/package.json +1 -1
@@ -360,6 +360,9 @@ function create(opts) {
360
360
  var store = { requestId: id || null, _extra: {} };
361
361
  return _als.run(store, fn);
362
362
  }
363
+ function enterRequestId(id) {
364
+ _als.enterWith({ requestId: id || null, _extra: {} });
365
+ }
363
366
  function runWithContext(ctx, fn) {
364
367
  var existing = _getStore();
365
368
  var rid = (ctx && ctx.requestId) || (existing && existing.requestId) || null;
@@ -417,6 +420,7 @@ function create(opts) {
417
420
  getLevel: getLevel,
418
421
  isLevelEnabled: level_in,
419
422
  runWithRequestId: runWithRequestId,
423
+ enterRequestId: enterRequestId,
420
424
  runWithContext: runWithContext,
421
425
  getRequestId: getRequestId,
422
426
  middleware: middleware,
@@ -639,6 +643,33 @@ function runWithRequestId(id, fn) {
639
643
  return _als.run({ requestId: id || null, _extra: {} }, fn);
640
644
  }
641
645
 
646
+ /**
647
+ * @primitive b.log.enterRequestId
648
+ * @signature b.log.enterRequestId(id)
649
+ * @since 0.15.21
650
+ * @status stable
651
+ * @related b.log.runWithRequestId, b.log.getRequestId
652
+ *
653
+ * Bind `id` into the AsyncLocalStorage scope for the REMAINDER of the
654
+ * current async execution — without nesting a callback. Where
655
+ * `runWithRequestId(id, fn)` wraps a function (and the binding closes when
656
+ * `fn` returns), this uses `AsyncLocalStorage.enterWith` so the id survives a
657
+ * dispatch model that hands control back to its caller before the awaited
658
+ * work runs — a boolean-`next` middleware chain (`b.router`), where the route
659
+ * handler executes after the middleware returns. Call it once per request,
660
+ * inside the per-request async context, so each request stays isolated. The
661
+ * companion to `b.middleware.requestId({ asyncContext: true })`, which calls
662
+ * it for you.
663
+ *
664
+ * @example
665
+ * // inside a per-request middleware, before next():
666
+ * b.log.enterRequestId(req.requestId);
667
+ * // any awaited handler downstream now sees b.log.getRequestId() === req.requestId
668
+ */
669
+ function enterRequestId(id) {
670
+ _als.enterWith({ requestId: id || null, _extra: {} });
671
+ }
672
+
642
673
  module.exports = {
643
674
  create: create,
644
675
  boot: boot,
@@ -649,4 +680,5 @@ module.exports = {
649
680
  // instance handy but still need to read ALS state.
650
681
  getRequestId: getRequestId,
651
682
  runWithRequestId: runWithRequestId,
683
+ enterRequestId: enterRequestId,
652
684
  };
@@ -448,6 +448,7 @@ function _resolveBackend(opts) {
448
448
  * pruneIntervalMs: number,
449
449
  * trustedProxies: string|string[], // CIDRs of your reverse proxies — peer-gates X-Forwarded-For for the IP key
450
450
  * clientIpResolver: function(req): string|null, // own the rate-limit key's client IP
451
+ * ipKeyMode: "exact"|"prefix64", // default "exact"; "prefix64" keys IPv6 by its /64 (IPv4 stays exact) so one end-site can't rotate the low 64 bits to evade the limit
451
452
  * trustProxy: boolean|number, // legacy; refused with the default IP key (spoofable) — use trustedProxies
452
453
  * }
453
454
  *
@@ -465,7 +466,7 @@ function create(opts) {
465
466
  opts = opts || {};
466
467
  validateOpts(opts, [
467
468
  "keyFn", "statusOnLimit", "bodyOnLimit", "onDeny", "problemDetails",
468
- "header", "headerPrefix", "skipPaths", "scope",
469
+ "header", "headerPrefix", "skipPaths", "scope", "ipKeyMode",
469
470
  "backend", "trustProxy", "trustedProxies", "clientIpResolver", "algorithm",
470
471
  // memory backend (token-bucket)
471
472
  "burst", "refillPerSecond",
@@ -493,7 +494,22 @@ function create(opts) {
493
494
  "or set your own keyFn.");
494
495
  }
495
496
  var _clientIp = function (req) { return _ipResolver.resolve(req) || "unknown"; };
496
- var keyFn = opts.keyFn || _clientIp;
497
+ // ipKeyMode "prefix64" collapses an IPv6 client to its routing-significant
498
+ // /64 for the DEFAULT key, so one end-site can't rotate the low 64 bits to
499
+ // mint unlimited fresh buckets and walk its per-IP limit (RFC 6177); IPv4
500
+ // stays exact. The audit actor still records the full resolved IP via
501
+ // _clientIp — only the rate-limit key is bucketed.
502
+ if (opts.ipKeyMode !== undefined && opts.ipKeyMode !== "exact" && opts.ipKeyMode !== "prefix64") {
503
+ throw new Error("middleware.rateLimit: ipKeyMode must be \"exact\" (default) or \"prefix64\"");
504
+ }
505
+ var _defaultKey = (opts.ipKeyMode === "prefix64")
506
+ ? function (req) {
507
+ var ip = _ipResolver.resolve(req);
508
+ if (!ip) return "unknown";
509
+ return requestHelpers.ipKey(ip, { ipv6Bits: 64 }) || ip;
510
+ }
511
+ : _clientIp;
512
+ var keyFn = opts.keyFn || _defaultKey;
497
513
  var statusOnLimit = opts.statusOnLimit || 429;
498
514
  var bodyOnLimit = opts.bodyOnLimit !== undefined ? opts.bodyOnLimit : "Too Many Requests";
499
515
  var onDeny = typeof opts.onDeny === "function" ? opts.onDeny : null;
@@ -8,6 +8,7 @@
8
8
  var C = require("../constants");
9
9
  var { generateToken } = require("../crypto");
10
10
  var validateOpts = require("../validate-opts");
11
+ var log = require("../log");
11
12
 
12
13
  var DEFAULT_FORMAT = /^[A-Za-z0-9._-]{8,128}$/;
13
14
  // Hard cap on inbound header length. The DEFAULT_FORMAT regex caps at
@@ -33,30 +34,43 @@ var MAX_INBOUND_LEN = C.BYTES.bytes(256);
33
34
  * the chain — every later primitive expects `req.requestId` to
34
35
  * be present for log lines and audit-record metadata.
35
36
  *
37
+ * Pass `asyncContext: true` to additionally bind the id into the framework's
38
+ * AsyncLocalStorage scope so `b.log.getRequestId()` (and every
39
+ * `b.log.create`-built logger) returns it inside awaited route-handler code,
40
+ * not just on `req.requestId`. The `b.router` dispatch model is boolean-`next`
41
+ * — the route handler runs after this middleware returns — so the binding uses
42
+ * `AsyncLocalStorage.enterWith` (it persists forward through the awaited
43
+ * chain) rather than a callback wrap (which would close before the handler
44
+ * runs). Each request runs in its own async context, so the binding is
45
+ * request-scoped.
46
+ *
36
47
  * @opts
37
48
  * {
38
49
  * headerName: string, // default "X-Request-Id"
39
50
  * trustUpstream: boolean, // default true; false → always re-mint
40
51
  * formatRegex: RegExp, // default /^[A-Za-z0-9._-]{8,128}$/
52
+ * asyncContext: boolean, // default false; true → bind into b.log ALS for awaited handler code
41
53
  * }
42
54
  *
43
55
  * @example
44
56
  * var b = require("@blamejs/core");
45
57
  * var app = b.router.create();
46
- * app.use(b.middleware.requestId({ trustUpstream: true }));
47
- * app.get("/health", function (req, res) {
48
- * res.end(req.requestId);
58
+ * app.use(b.middleware.requestId({ asyncContext: true }));
59
+ * app.get("/health", async function (req, res) {
60
+ * await somethingAsync();
61
+ * res.end(b.log.getRequestId()); // → the request's id, even after await
49
62
  * });
50
63
  */
51
64
  function create(opts) {
52
65
  opts = opts || {};
53
66
  validateOpts(opts, [
54
- "headerName", "trustUpstream", "formatRegex",
67
+ "headerName", "trustUpstream", "formatRegex", "asyncContext",
55
68
  ], "middleware.requestId");
56
69
  var headerName = (opts.headerName || "X-Request-Id");
57
70
  var headerNameLower = headerName.toLowerCase();
58
71
  var trustUpstream = opts.trustUpstream !== false;
59
72
  var format = opts.formatRegex || DEFAULT_FORMAT;
73
+ var asyncContext = opts.asyncContext === true;
60
74
 
61
75
  return function requestId(req, res, next) {
62
76
  var inbound = req.headers && req.headers[headerNameLower];
@@ -72,6 +86,12 @@ function create(opts) {
72
86
  if (typeof res.setHeader === "function") {
73
87
  res.setHeader(headerName, id);
74
88
  }
89
+ // Bind into the log ALS so awaited handler code reads the id via
90
+ // b.log.getRequestId(). enterWith (not run-with-callback) because the
91
+ // boolean-next dispatcher runs the handler after this returns.
92
+ if (asyncContext) {
93
+ log.enterRequestId(id);
94
+ }
75
95
  next();
76
96
  };
77
97
  }
@@ -531,6 +531,55 @@ function ipPrefix(ip, opts) {
531
531
  return "";
532
532
  }
533
533
 
534
+ /**
535
+ * @primitive b.requestHelpers.ipKey
536
+ * @signature b.requestHelpers.ipKey(ip, opts?)
537
+ * @since 0.15.21
538
+ * @related b.requestHelpers.ipPrefix, b.requestHelpers.clientIp
539
+ *
540
+ * Derive a stable rate-limit / blocklist key from a client IP: the IPv4
541
+ * address <strong>verbatim</strong> (one IPv4 is one host) but the IPv6
542
+ * address collapsed to its routing-significant <code>/64</code> prefix. A
543
+ * single IPv6 end-site is allocated a whole <code>/64</code> (RFC 6177 / RFC
544
+ * 4291 §2.5.4) and freely rotates the low 64 bits, so keying on the full
545
+ * 128-bit address lets one site mint unlimited fresh keys — defeating a
546
+ * per-IP throttle and an exact-address block. Keying on the <code>/64</code>
547
+ * closes that while still distinguishing real end-sites. Unlike
548
+ * <code>ipPrefix</code> (which masks IPv4 to a <code>/24</code> pool), this
549
+ * keeps IPv4 exact — a rate limiter wants per-host IPv4 granularity.
550
+ *
551
+ * Returns the canonical key string, or <code>""</code> for a non-string /
552
+ * empty / unparseable input (caller falls back to its own bucket). An
553
+ * IPv4-mapped IPv6 address (<code>::ffff:1.2.3.4</code>) folds to its dotted
554
+ * IPv4 form so a client keys the same however a proxy reported it. Pass
555
+ * <code>opts.ipv6Bits</code> to override the IPv6 mask width (default 64).
556
+ *
557
+ * @opts
558
+ * ipv6Bits: number, // IPv6 mask width in bits (default 64; valid 0..128)
559
+ *
560
+ * @example
561
+ * b.requestHelpers.ipKey("203.0.113.47"); // → "203.0.113.47" (exact)
562
+ * b.requestHelpers.ipKey("2001:db8:1:2:dead:beef:0:1"); // → "2001:db8:1:2:0:0:0:0/64"
563
+ */
564
+ function ipKey(ip, opts) {
565
+ if (typeof ip !== "string" || ip.length === 0) return "";
566
+ opts = opts || {};
567
+ var v6 = _resolvePrefixBits(opts.ipv6Bits, IPV6_DEFAULT_PREFIX, 128); // IPv6 max prefix length in bits
568
+ var lower = ip.toLowerCase();
569
+ // IPv4-mapped IPv6 (::ffff:1.2.3.4) → its exact dotted host, so a v4 client
570
+ // keys identically whether the proxy reported 1.2.3.4 or ::ffff:1.2.3.4.
571
+ if (lower.indexOf(V4_MAPPED_V6_PREFIX) === 0 && lower.indexOf(".") !== -1) {
572
+ var m4 = _maskIpv4(lower.substring(V4_MAPPED_V6_PREFIX.length), 32); // /32 = exact host
573
+ return m4 ? m4.replace(/\/32$/, "") : "";
574
+ }
575
+ if (ip.indexOf(":") !== -1) return _maskIpv6(ip, v6) || ""; // IPv6 → /ipv6Bits prefix
576
+ if (ip.indexOf(".") !== -1) { // IPv4 → exact host, verbatim
577
+ var m = _maskIpv4(ip, 32);
578
+ return m ? m.replace(/\/32$/, "") : "";
579
+ }
580
+ return "";
581
+ }
582
+
534
583
  /**
535
584
  * @primitive b.requestHelpers.trustedProtocol
536
585
  * @signature b.requestHelpers.trustedProtocol(opts?)
@@ -1307,6 +1356,7 @@ module.exports = {
1307
1356
  clientIp: clientIp,
1308
1357
  trustedClientIp: trustedClientIp,
1309
1358
  ipPrefix: ipPrefix,
1359
+ ipKey: ipKey,
1310
1360
  requestProtocol: requestProtocol,
1311
1361
  trustedProtocol: trustedProtocol,
1312
1362
  requestHost: requestHost,
@@ -206,19 +206,33 @@ function _resolveCore(base, rel, opts) {
206
206
  }
207
207
  }
208
208
 
209
- // Lexical resolve.
210
- var baseResolved = nodePath.resolve(base);
211
- var joined = nodePath.resolve(baseResolved, rel);
212
- // Cross-check via posix.normalize so a Windows host with mixed
213
- // separators still surfaces escapes consistently.
214
- var sepChar = isWin ? "\\" : "/";
209
+ // Lexical resolve + containment using the TARGET platform's path semantics
210
+ // (nodePath.win32 / nodePath.posix), NOT the runtime's. The runtime nodePath
211
+ // would treat the OTHER platform's separator as an ordinary filename
212
+ // character so a Windows-target validation on a POSIX host would NOT collapse
213
+ // `ok\..\..\outside` and would wrongly accept a path that escapes the base
214
+ // when later interpreted with Windows path rules (the cross-platform
215
+ // backslash-traversal hole). The target module collapses the target's
216
+ // separators + `..` and its sep matches the resolved output, which both closes
217
+ // that hole AND stops the inverse false-refusal of legitimate in-base paths.
218
+ var pathMod = isWin ? nodePath.win32 : nodePath.posix;
219
+ var baseResolved = pathMod.resolve(base);
220
+ var joined = pathMod.resolve(baseResolved, rel);
221
+ var sepChar = pathMod.sep;
215
222
  if (joined !== baseResolved && joined.slice(0, baseResolved.length + 1) !== baseResolved + sepChar) {
216
223
  _refuse("safe-path/escapes-base",
217
224
  "b.safePath.resolve: rel resolves outside base ('" + joined + "' not inside '" + baseResolved + "')");
218
225
  }
219
226
  if (opts.realpath === true) {
227
+ // realpath resolves symlinks on the RUNTIME filesystem, so it must use the
228
+ // runtime path module and runtime-resolved paths (a foreign-platform path
229
+ // can't be symlink-resolved on this host). The lexical check above already
230
+ // refused a cross-platform escape; this adds the on-disk symlink check.
231
+ var rtBaseResolved = nodePath.resolve(base);
232
+ var rtJoined = nodePath.resolve(rtBaseResolved, rel);
233
+ var rtSep = nodePath.sep;
220
234
  var baseRealpath;
221
- try { baseRealpath = nodeFs.realpathSync.native(baseResolved); }
235
+ try { baseRealpath = nodeFs.realpathSync.native(rtBaseResolved); }
222
236
  catch (e) {
223
237
  _refuse("safe-path/realpath-base-unresolvable",
224
238
  "b.safePath.resolve: opts.realpath set but base realpath failed: " + (e && e.message));
@@ -227,12 +241,12 @@ function _resolveCore(base, rel, opts) {
227
241
  // ancestor that exists, and check its realpath. Operators want
228
242
  // refusal when ANY ancestor symlink escapes — nodeFs.realpathSync on a
229
243
  // non-existent path would throw.
230
- var ancestor = joined;
231
- while (ancestor.length > baseResolved.length) {
244
+ var ancestor = rtJoined;
245
+ while (ancestor.length > rtBaseResolved.length) {
232
246
  try {
233
247
  var ancRealpath = nodeFs.realpathSync.native(ancestor);
234
248
  if (ancRealpath !== baseRealpath &&
235
- ancRealpath.slice(0, baseRealpath.length + 1) !== baseRealpath + sepChar) {
249
+ ancRealpath.slice(0, baseRealpath.length + 1) !== baseRealpath + rtSep) {
236
250
  _refuse("safe-path/realpath-escapes-base",
237
251
  "b.safePath.resolve: symlink resolution at '" + ancestor +
238
252
  "' escapes base realpath '" + baseRealpath + "'");
@@ -2041,6 +2041,45 @@ class UpdateBuilder extends Builder {
2041
2041
  this._where = new Predicate(this, "AND");
2042
2042
  this._returning = null;
2043
2043
  this._allowNoWhere = false;
2044
+ // guardedUpdate() flips _requireGuard so _render refuses to emit a CAS
2045
+ // statement that has no compare-and-swap fence (which would silently be a
2046
+ // plain unconditional-on-state update). _guardCount tracks guardWhere calls.
2047
+ this._requireGuard = false;
2048
+ this._guardCount = 0;
2049
+ }
2050
+
2051
+ // guardWhere(col, expected) - the compare-and-swap fence. ANDs
2052
+ // `col = <expected>` (a bound ?) into the WHERE so the UPDATE lands ONLY if
2053
+ // the row is STILL in the expected value - the cross-instance atomic claim
2054
+ // (the transaction substitute on autocommit-only substrates: D1 over an HTTP
2055
+ // bridge, any adapter without interactive transactions). An EXPLICIT
2056
+ // `null` becomes `col IS NULL` (since `col = NULL` is never true) so a
2057
+ // null-state fence works; `undefined` is REFUSED rather than silently
2058
+ // collapsing to `IS NULL`, because an omitted/unset expected value would turn
2059
+ // a CAS into "match the NULL-state rows" and update the wrong rows. The
2060
+ // won/lost result is read from rowCount via b.sql.casWon.
2061
+ guardWhere(col, expected) {
2062
+ if (expected === undefined) {
2063
+ throw _err("guardWhere expected value is undefined - pass an explicit null for an " +
2064
+ "IS NULL fence, or a value; refusing to silently match NULL-state rows",
2065
+ "sql-builder/bad-guard-value");
2066
+ }
2067
+ if (expected === null) {
2068
+ this._where.whereNull(col);
2069
+ } else {
2070
+ this._where.whereOp(col, "=", expected);
2071
+ }
2072
+ this._guardCount += 1;
2073
+ return this;
2074
+ }
2075
+
2076
+ // guardWhereOp(col, op, expected) - a non-equality CAS fence (e.g. an
2077
+ // optimistic-version `>=`, or a balance `>= amount` debit guard). Routes the
2078
+ // operator through the same whereOp allowlist every other predicate uses.
2079
+ guardWhereOp(col, op, expected) {
2080
+ this._where.whereOp(col, op, expected);
2081
+ this._guardCount += 1;
2082
+ return this;
2044
2083
  }
2045
2084
 
2046
2085
  // set(obj) - column->value assignments. set(col, value) - single
@@ -2112,6 +2151,11 @@ class UpdateBuilder extends Builder {
2112
2151
 
2113
2152
  _render() {
2114
2153
  if (this._set.length === 0) throw _err("update requires a set(...) call", "sql-builder/empty-set");
2154
+ if (this._requireGuard && this._guardCount === 0) {
2155
+ throw _err("guardedUpdate requires at least one guardWhere(...) / guardWhereOp(...) " +
2156
+ "compare-and-swap fence - without it this is a plain update; use b.sql.update for that",
2157
+ "sql-builder/no-guard");
2158
+ }
2115
2159
  if (this._where.length === 0 && !this._allowNoWhere) {
2116
2160
  throw _err("refusing unconditional update - call where(...) first or allowNoWhere()",
2117
2161
  "sql-builder/no-where");
@@ -3896,6 +3940,93 @@ function insertSelectWhere(tableNameOrRef, opts) { return new InsertSelectWhereB
3896
3940
  */
3897
3941
  function update(tableNameOrRef, opts) { return new UpdateBuilder(tableNameOrRef, opts); }
3898
3942
 
3943
+ /**
3944
+ * @primitive b.sql.guardedUpdate
3945
+ * @signature b.sql.guardedUpdate(table, opts?)
3946
+ * @since 0.15.21
3947
+ * @status stable
3948
+ * @related b.sql.update, b.sql.insertSelectWhere, b.sql.casWon
3949
+ *
3950
+ * Start a compare-and-swap `UPDATE` builder - the cross-instance-safe way to
3951
+ * advance a status / version on a single-statement-per-request backend (D1
3952
+ * over an HTTP bridge, or any autocommit-only adapter without interactive
3953
+ * transactions). It is `b.sql.update` plus a required `guardWhere(col,
3954
+ * expected)` fence: the statement lands ONLY when the row is STILL in the
3955
+ * expected value, so two racing transitions cannot both win. Refuses to
3956
+ * render without at least one `guardWhere(...)` / `guardWhereOp(...)` - an
3957
+ * unfenced one would just be a plain update.
3958
+ *
3959
+ * Read the winner from the result's `rowCount` with `b.sql.casWon(result)`:
3960
+ * exactly one row matched (`won: true`) means this caller made the
3961
+ * transition; zero (`won: false`) means it lost the race and must no-op /
3962
+ * refuse. The sibling of `b.sql.insertSelectWhere` (the conditional-INSERT
3963
+ * debit) for the conditional-UPDATE case, and the b.fsm composition partner
3964
+ * (resolve the destination side-effect-free with `instance.target(event)`,
3965
+ * then guard on the from-state here).
3966
+ *
3967
+ * @opts
3968
+ * dialect: string, // postgres | sqlite | mysql (default sqlite)
3969
+ * schema: string, // schema qualifier
3970
+ * prefix: string, // operator app-table namespace prefix
3971
+ * allowedColumns: array, // column-membership gate set
3972
+ *
3973
+ * @example
3974
+ * var b = require("@blamejs/core");
3975
+ * // advance order id=7 from "paid" -> "shipped" iff still "paid"
3976
+ * var q = b.sql.guardedUpdate("orders")
3977
+ * .set({ status: "shipped" })
3978
+ * .where("id", 7)
3979
+ * .guardWhere("status", "paid")
3980
+ * .toSql();
3981
+ * // -> { sql: 'UPDATE orders SET "status" = ? WHERE "id" = ? AND "status" = ?',
3982
+ * // params: ["shipped", 7, "paid"] }
3983
+ * // var res = await b.db.raw(q.sql, q.params);
3984
+ * // if (!b.sql.casWon(res).won) { return refuse(); } // lost the race
3985
+ */
3986
+ function guardedUpdate(tableNameOrRef, opts) {
3987
+ var builder = new UpdateBuilder(tableNameOrRef, opts);
3988
+ builder._requireGuard = true;
3989
+ return builder;
3990
+ }
3991
+
3992
+ /**
3993
+ * @primitive b.sql.casWon
3994
+ * @signature b.sql.casWon(result)
3995
+ * @since 0.15.21
3996
+ * @status stable
3997
+ * @related b.sql.guardedUpdate, b.sql.insertSelectWhere
3998
+ *
3999
+ * Interpret a compare-and-swap result's affected-row count into a won/lost
4000
+ * verdict, owning the `Number(rowCount) === 1` check and the cross-adapter
4001
+ * field-name divergence (`b.db` / `b.externalDb` normalize to `rowCount`; raw
4002
+ * sqlite reports `changes`, raw mysql `affectedRows` / `rowsAffected`).
4003
+ * Returns `{ won, rowCount }` where `won` is true only when exactly one row
4004
+ * was affected. Throws when the result carries no recognizable numeric
4005
+ * row-count field - an indeterminate result must surface, never be silently
4006
+ * read as a win (a phantom win on a CAS is a double-spend).
4007
+ *
4008
+ * @example
4009
+ * var v = b.sql.casWon(await b.db.raw(q.sql, q.params));
4010
+ * if (v.won) { applyTransition(); } else { refuseLostRace(v.rowCount); }
4011
+ */
4012
+ function casWon(result) {
4013
+ if (!result || typeof result !== "object") {
4014
+ throw _err("casWon: result must be the object returned by the query runner",
4015
+ "sql-builder/bad-cas-result");
4016
+ }
4017
+ var count = null;
4018
+ var fields = ["rowCount", "changes", "affectedRows", "rowsAffected"];
4019
+ for (var i = 0; i < fields.length; i += 1) {
4020
+ var v = result[fields[i]];
4021
+ if (typeof v === "number" && isFinite(v)) { count = v; break; }
4022
+ }
4023
+ if (count === null) {
4024
+ throw _err("casWon: result has no numeric rowCount / changes / affectedRows field - " +
4025
+ "cannot determine the compare-and-swap outcome", "sql-builder/no-row-count");
4026
+ }
4027
+ return { won: count === 1, rowCount: count };
4028
+ }
4029
+
3899
4030
  /**
3900
4031
  * @primitive b.sql.delete
3901
4032
  * @signature b.sql.delete(table, opts?)
@@ -3976,6 +4107,8 @@ module.exports = {
3976
4107
  insert: insert,
3977
4108
  insertSelectWhere: insertSelectWhere,
3978
4109
  update: update,
4110
+ guardedUpdate: guardedUpdate,
4111
+ casWon: casWon,
3979
4112
  delete: del,
3980
4113
  upsert: upsert,
3981
4114
  // Table reference
@@ -7,16 +7,28 @@
7
7
  * posture. SHA-256 is supported as backward-compatible opt-in for
8
8
  * deployments whose authenticator app only goes that high.
9
9
  *
10
- * SHA-1 is NOT supported — explicitly rejected at compute(). Most
11
- * authenticator apps still default to SHA-1 for legacy reasons (it's
12
- * what RFC 6238 prescribes as the default), but the major modern apps
13
- * (Authy, 1Password, Bitwarden, Microsoft Authenticator, Aegis) all
14
- * support SHA-512 when the otpauth URI declares it via the
15
- * `algorithm` parameter. Operators selecting authenticator apps
16
- * should verify SHA-512 support — Google Authenticator's older
17
- * versions and minimal hardware tokens may not. A clear "reject SHA-1
18
- * and surface" stance is preferable to a silent SHA-1 default that
19
- * undermines the framework's algorithm posture.
10
+ * SHA-1 is NOT supported for generation — explicitly rejected at
11
+ * compute() / generate() / uri(). Most authenticator apps still default
12
+ * to SHA-1 for legacy reasons (it's what RFC 6238 prescribes as the
13
+ * default), but the major modern apps (Authy, 1Password, Bitwarden,
14
+ * Microsoft Authenticator, Aegis) all support SHA-512 when the otpauth
15
+ * URI declares it via the `algorithm` parameter. Operators selecting
16
+ * authenticator apps should verify SHA-512 support — Google
17
+ * Authenticator's older versions and minimal hardware tokens may not. A
18
+ * clear "reject SHA-1 and surface" stance is preferable to a silent
19
+ * SHA-1 default that undermines the framework's algorithm posture.
20
+ *
21
+ * One verify-only exception exists for migration: a consumer holding
22
+ * pre-existing RFC-6238-default secrets (SHA-1) can verify a single
23
+ * legacy code during a re-enrollment flow with
24
+ * `verify(secret, code, { algorithm: "sha1", verifyOnly: true })`. This
25
+ * widens nothing on the generation side — compute()/generate()/uri()
26
+ * still refuse SHA-1, so new-enrollment posture stays SHA-512-only — it
27
+ * only lets the maintained verifier (separator stripping, 64-bit
28
+ * counter, drift/replay semantics) authenticate the final legacy login
29
+ * instead of forcing the consumer to hand-roll a parallel HOTP. Each
30
+ * such verification emits an `auth.totp.legacy_sha1_verify` audit signal
31
+ * so a compliance dashboard sees the migration traffic.
20
32
  *
21
33
  * Public API:
22
34
  *
@@ -24,6 +36,9 @@
24
36
  * totp.generate(secret, opts?) → string (current code)
25
37
  * totp.compute(secret, timeStep, opts?) → string (code at specific step)
26
38
  * totp.verify(secret, code, opts?) → step | false
39
+ * (opts.verifyOnly + opts.algorithm
40
+ * "sha1" accepts a legacy secret;
41
+ * see the SHA-1 note above)
27
42
  * totp.uri(secret, account, opts) → string (otpauth://…)
28
43
  * totp.generateBackupCodes(opts?) → string[]
29
44
  *
@@ -77,6 +92,13 @@ var DEFAULT_DRIFT_STEPS = 1;
77
92
  // docstring for the rationale.
78
93
  var DEFAULT_ALGORITHM = "sha512";
79
94
  var SUPPORTED_ALGORITHMS = Object.freeze(["sha256", "sha512"]);
95
+ // Algorithms accepted ONLY on the verify() path, and only when the caller
96
+ // explicitly passes { verifyOnly: true } — never by compute()/generate()/
97
+ // uri(). SHA-1 is RFC 6238's default and the algorithm pre-existing secrets
98
+ // were provisioned with; this lets a consumer authenticate a final legacy
99
+ // code during re-enrollment without hand-rolling a parallel HOTP, while
100
+ // keeping new-enrollment generation SHA-512-only.
101
+ var VERIFY_ONLY_ALGORITHMS = Object.freeze(["sha1"]);
80
102
  // Default secret length matches the HMAC-SHA512 block size (1024 bits).
81
103
  // MIN_SECRET_BYTES is RFC 4226 §4's hard floor — operators can opt down
82
104
  // to it for QR provisioning into apps that balk at long secrets, but no
@@ -106,28 +128,51 @@ function _base32Decode(str) {
106
128
 
107
129
  // ---- Core HOTP (RFC 4226 §5.3) ----
108
130
 
109
- function _resolveOpts(opts) {
131
+ function _emitTotpAudit(action, alg) {
132
+ setImmediate(function () {
133
+ try {
134
+ var auditMod = require("./audit"); // allow:inline-require — circular-load defense
135
+ auditMod.safeEmit({
136
+ action: action,
137
+ outcome: "success",
138
+ metadata: { algorithm: alg, frameworkDefault: DEFAULT_ALGORITHM },
139
+ });
140
+ } catch (_e) { /* drop-silent */ }
141
+ });
142
+ }
143
+
144
+ // ctx.allowVerifyOnly is set ONLY by verify() — it is the single gate that
145
+ // lets a verify-only legacy algorithm (SHA-1) through, and even then only
146
+ // when the caller also passes opts.verifyOnly:true. compute()/generate()/
147
+ // uri() call _resolveOpts with no ctx, so SHA-1 can never reach the
148
+ // generation path regardless of opts.
149
+ function _resolveOpts(opts, ctx) {
110
150
  opts = opts || {};
151
+ var allowVerifyOnly = !!(ctx && ctx.allowVerifyOnly);
111
152
  var alg = (opts.algorithm || DEFAULT_ALGORITHM).toLowerCase();
153
+ var verifyOnly = false;
112
154
  if (SUPPORTED_ALGORITHMS.indexOf(alg) === -1) {
113
- throw new AuthError("auth-totp/bad-alg",
114
- "algorithm must be one of " + SUPPORTED_ALGORITHMS.join(", ") + " (got: " + alg + ")");
155
+ if (allowVerifyOnly && opts.verifyOnly === true && VERIFY_ONLY_ALGORITHMS.indexOf(alg) !== -1) {
156
+ // Legacy-secret verification path accepted, but flagged so the
157
+ // HOTP core knows this is a one-final-login migration verify and the
158
+ // audit trail records it.
159
+ verifyOnly = true;
160
+ } else {
161
+ throw new AuthError("auth-totp/bad-alg",
162
+ "algorithm must be one of " + SUPPORTED_ALGORITHMS.join(", ") + " (got: " + alg + ")" +
163
+ (VERIFY_ONLY_ALGORITHMS.indexOf(alg) !== -1
164
+ ? "; " + alg + " is accepted only by verify() with { verifyOnly: true }"
165
+ : ""));
166
+ }
115
167
  }
116
168
  // SHA-256 is supported for back-compat with authenticator apps that
117
- // don't yet honor SHA-512. Emit an audit signal each time it's
118
- // selected so operator compliance dashboards see which accounts run
119
- // on the weaker hash and can plan the migration.
169
+ // don't yet honor SHA-512. Emit an audit signal when it's selected so
170
+ // operator compliance dashboards see which accounts run on the weaker
171
+ // hash and can plan the migration; SHA-1 verify-only gets its own signal.
120
172
  if (alg === "sha256") {
121
- setImmediate(function () {
122
- try {
123
- var auditMod = require("./audit"); // allow:inline-require — circular-load defense
124
- auditMod.safeEmit({
125
- action: "auth.totp.algorithm_downgraded",
126
- outcome: "success",
127
- metadata: { algorithm: alg, frameworkDefault: DEFAULT_ALGORITHM },
128
- });
129
- } catch (_e) { /* drop-silent */ }
130
- });
173
+ _emitTotpAudit("auth.totp.algorithm_downgraded", alg);
174
+ } else if (verifyOnly) {
175
+ _emitTotpAudit("auth.totp.legacy_sha1_verify", alg);
131
176
  }
132
177
  var digits = opts.digits != null ? opts.digits : DEFAULT_DIGITS;
133
178
  if (typeof digits !== "number" || digits < 6 || digits > 10) {
@@ -141,7 +186,10 @@ function _resolveOpts(opts) {
141
186
  if (typeof driftSteps !== "number" || driftSteps < 0) {
142
187
  throw new AuthError("auth-totp/bad-drift", "driftSteps must be >= 0 (got: " + driftSteps + ")");
143
188
  }
144
- return { algorithm: alg, digits: digits, stepSeconds: stepSeconds, driftSteps: driftSteps };
189
+ return {
190
+ algorithm: alg, digits: digits, stepSeconds: stepSeconds,
191
+ driftSteps: driftSteps, verifyOnly: verifyOnly,
192
+ };
145
193
  }
146
194
 
147
195
  function _validateSecret(secret) {
@@ -150,10 +198,11 @@ function _validateSecret(secret) {
150
198
  }
151
199
  }
152
200
 
153
- // HOTP truncation per RFC 4226 §5.3 — produces digit-string code.
154
- function compute(secret, timeStep, opts) {
155
- _validateSecret(secret);
156
- var resolved = _resolveOpts(opts);
201
+ // HOTP truncation per RFC 4226 §5.3 — produces a digit-string code from an
202
+ // ALREADY-RESOLVED opts object. Split out from compute() so verify() can run
203
+ // it with a verify-resolved algorithm (including the verify-only SHA-1 path)
204
+ // without going back through compute()'s resolver, which always refuses SHA-1.
205
+ function _hotp(secret, timeStep, resolved) {
157
206
  var key = _base32Decode(secret);
158
207
  if (key.length === 0) {
159
208
  throw new AuthError("auth-totp/bad-secret", "secret decoded to zero bytes");
@@ -177,6 +226,13 @@ function compute(secret, timeStep, opts) {
177
226
  return String(binCode % modulus).padStart(resolved.digits, "0");
178
227
  }
179
228
 
229
+ // Public generation path — always refuses SHA-1 (no verify-only ctx).
230
+ function compute(secret, timeStep, opts) {
231
+ _validateSecret(secret);
232
+ var resolved = _resolveOpts(opts);
233
+ return _hotp(secret, timeStep, resolved);
234
+ }
235
+
180
236
  // ---- Public API ----
181
237
 
182
238
  function generateSecret(opts) {
@@ -203,7 +259,11 @@ function verify(secret, code, opts) {
203
259
  // catching exceptions per call.
204
260
  if (typeof secret !== "string" || secret.length === 0) return false;
205
261
  if (code == null) return false;
206
- var resolved = _resolveOpts(opts);
262
+ // verify() is the single path that may accept a verify-only legacy
263
+ // algorithm (SHA-1) — gated on opts.verifyOnly inside _resolveOpts. A
264
+ // genuinely-bad algorithm/digits/step/drift still throws (a misconfigured
265
+ // verifier must surface, not silently read as "didn't match").
266
+ var resolved = _resolveOpts(opts, { allowVerifyOnly: true });
207
267
  var nowMs = (opts && opts.now) || Date.now();
208
268
  var currentStep = Math.floor(nowMs / 1000 / resolved.stepSeconds);
209
269
  var lastUsedStep = (opts && typeof opts.lastUsedStep === "number") ? opts.lastUsedStep : null;
@@ -224,7 +284,11 @@ function verify(secret, code, opts) {
224
284
  var step = currentStep + d;
225
285
  if (lastUsedStep !== null && step <= lastUsedStep) continue; // reject replays at-or-below the last accepted step
226
286
  var expected;
227
- try { expected = compute(secret, step, opts); }
287
+ // Run the HOTP core with the verify-resolved algorithm directly — going
288
+ // back through compute() would re-resolve and refuse the verify-only
289
+ // SHA-1 path. Resolution already happened once above (no per-step audit
290
+ // spam, and the legacy-verify audit fires exactly once per verify()).
291
+ try { expected = _hotp(secret, step, resolved); }
228
292
  catch (_e) { return false; }
229
293
  var expectedBuf = Buffer.from(expected);
230
294
  if (timingSafeEqual(expectedBuf, userBuf)) {
@@ -294,4 +358,5 @@ module.exports = {
294
358
  DEFAULT_SECRET_BYTES: DEFAULT_SECRET_BYTES,
295
359
  MIN_SECRET_BYTES: MIN_SECRET_BYTES,
296
360
  SUPPORTED_ALGORITHMS: SUPPORTED_ALGORITHMS,
361
+ VERIFY_ONLY_ALGORITHMS: VERIFY_ONLY_ALGORITHMS,
297
362
  };