@blamejs/blamejs-shop 0.3.12 → 0.3.14

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 (90) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/lib/admin.js +155 -113
  3. package/lib/asset-manifest.json +1 -1
  4. package/lib/security-middleware.js +28 -0
  5. package/lib/vendor/MANIFEST.json +2 -2
  6. package/lib/vendor/blamejs/CHANGELOG.md +2 -0
  7. package/lib/vendor/blamejs/README.md +3 -2
  8. package/lib/vendor/blamejs/SECURITY.md +3 -0
  9. package/lib/vendor/blamejs/api-snapshot.json +14 -2
  10. package/lib/vendor/blamejs/lib/agent-event-bus.js +4 -4
  11. package/lib/vendor/blamejs/lib/agent-idempotency.js +6 -6
  12. package/lib/vendor/blamejs/lib/agent-orchestrator.js +9 -9
  13. package/lib/vendor/blamejs/lib/agent-posture-chain.js +10 -10
  14. package/lib/vendor/blamejs/lib/agent-saga.js +6 -7
  15. package/lib/vendor/blamejs/lib/agent-snapshot.js +8 -8
  16. package/lib/vendor/blamejs/lib/agent-stream.js +3 -3
  17. package/lib/vendor/blamejs/lib/agent-tenant.js +4 -4
  18. package/lib/vendor/blamejs/lib/agent-trace.js +5 -5
  19. package/lib/vendor/blamejs/lib/ai-disclosure.js +3 -3
  20. package/lib/vendor/blamejs/lib/app.js +2 -2
  21. package/lib/vendor/blamejs/lib/archive-read.js +1 -1
  22. package/lib/vendor/blamejs/lib/archive-tar-read.js +1 -1
  23. package/lib/vendor/blamejs/lib/archive-wrap.js +5 -5
  24. package/lib/vendor/blamejs/lib/audit-tools.js +65 -5
  25. package/lib/vendor/blamejs/lib/audit.js +2 -2
  26. package/lib/vendor/blamejs/lib/auth/ciba.js +1 -1
  27. package/lib/vendor/blamejs/lib/auth/dpop.js +1 -1
  28. package/lib/vendor/blamejs/lib/auth/fal.js +1 -1
  29. package/lib/vendor/blamejs/lib/auth/fido-mds3.js +2 -3
  30. package/lib/vendor/blamejs/lib/auth/jwt-external.js +2 -2
  31. package/lib/vendor/blamejs/lib/auth/oauth.js +9 -9
  32. package/lib/vendor/blamejs/lib/auth/oid4vci.js +7 -7
  33. package/lib/vendor/blamejs/lib/auth/oid4vp.js +1 -1
  34. package/lib/vendor/blamejs/lib/auth/openid-federation.js +5 -5
  35. package/lib/vendor/blamejs/lib/auth/passkey.js +6 -6
  36. package/lib/vendor/blamejs/lib/auth/saml.js +1 -1
  37. package/lib/vendor/blamejs/lib/auth/sd-jwt-vc.js +3 -6
  38. package/lib/vendor/blamejs/lib/backup/index.js +18 -18
  39. package/lib/vendor/blamejs/lib/cache.js +4 -4
  40. package/lib/vendor/blamejs/lib/calendar.js +5 -5
  41. package/lib/vendor/blamejs/lib/circuit-breaker.js +1 -1
  42. package/lib/vendor/blamejs/lib/cms-codec.js +2 -2
  43. package/lib/vendor/blamejs/lib/compliance.js +14 -14
  44. package/lib/vendor/blamejs/lib/crypto-field.js +58 -21
  45. package/lib/vendor/blamejs/lib/crypto.js +5 -6
  46. package/lib/vendor/blamejs/lib/db-query.js +131 -9
  47. package/lib/vendor/blamejs/lib/db.js +106 -22
  48. package/lib/vendor/blamejs/lib/external-db.js +64 -16
  49. package/lib/vendor/blamejs/lib/framework-schema.js +4 -4
  50. package/lib/vendor/blamejs/lib/guard-list-id.js +2 -2
  51. package/lib/vendor/blamejs/lib/guard-list-unsubscribe.js +1 -2
  52. package/lib/vendor/blamejs/lib/incident-report.js +150 -0
  53. package/lib/vendor/blamejs/lib/mail-crypto-smime.js +1 -1
  54. package/lib/vendor/blamejs/lib/mail-deploy.js +3 -3
  55. package/lib/vendor/blamejs/lib/mail-server-managesieve.js +2 -2
  56. package/lib/vendor/blamejs/lib/mail-server-pop3.js +2 -2
  57. package/lib/vendor/blamejs/lib/mail-store.js +1 -1
  58. package/lib/vendor/blamejs/lib/metrics.js +8 -8
  59. package/lib/vendor/blamejs/lib/middleware/csrf-protect.js +1 -1
  60. package/lib/vendor/blamejs/lib/middleware/dpop.js +5 -5
  61. package/lib/vendor/blamejs/lib/middleware/idempotency-key.js +21 -22
  62. package/lib/vendor/blamejs/lib/middleware/protected-resource-metadata.js +2 -2
  63. package/lib/vendor/blamejs/lib/network-dns-resolver.js +2 -2
  64. package/lib/vendor/blamejs/lib/network-dns.js +1 -2
  65. package/lib/vendor/blamejs/lib/network-tls.js +0 -1
  66. package/lib/vendor/blamejs/lib/outbox.js +1 -1
  67. package/lib/vendor/blamejs/lib/pqc-agent.js +1 -1
  68. package/lib/vendor/blamejs/lib/retention.js +1 -1
  69. package/lib/vendor/blamejs/lib/retry.js +1 -1
  70. package/lib/vendor/blamejs/lib/safe-archive.js +2 -2
  71. package/lib/vendor/blamejs/lib/safe-ical.js +2 -2
  72. package/lib/vendor/blamejs/lib/safe-mime.js +1 -1
  73. package/lib/vendor/blamejs/lib/self-update-standalone-verifier.js +1 -1
  74. package/lib/vendor/blamejs/lib/self-update.js +2 -2
  75. package/lib/vendor/blamejs/lib/static.js +1 -1
  76. package/lib/vendor/blamejs/lib/subject.js +2 -2
  77. package/lib/vendor/blamejs/lib/vault/index.js +64 -1
  78. package/lib/vendor/blamejs/lib/vault/rotate.js +19 -0
  79. package/lib/vendor/blamejs/lib/vendor-data.js +1 -1
  80. package/lib/vendor/blamejs/package.json +1 -1
  81. package/lib/vendor/blamejs/release-notes/v0.14.7.json +77 -0
  82. package/lib/vendor/blamejs/scripts/release.js +28 -3
  83. package/lib/vendor/blamejs/test/layer-0-primitives/audit-tools-dual-control.test.js +115 -0
  84. package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +163 -3
  85. package/lib/vendor/blamejs/test/layer-0-primitives/crypto-field-derived-hash.test.js +102 -0
  86. package/lib/vendor/blamejs/test/layer-0-primitives/db-column-gate.test.js +150 -0
  87. package/lib/vendor/blamejs/test/layer-0-primitives/db-key-aad.test.js +109 -0
  88. package/lib/vendor/blamejs/test/layer-0-primitives/external-db-hardening.test.js +53 -0
  89. package/lib/vendor/blamejs/test/layer-0-primitives/incident-report.test.js +65 -0
  90. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.3.x
10
10
 
11
+ - v0.3.14 (2026-05-30) — **Drop the inert Document-Policy header that only produced browser warnings.** Container-rendered pages were sending a Document-Policy response header whose feature tokens (document-write, unsized-media, oversized-images) are no longer recognized by current browsers — so it enforced nothing while making the browser log a warning for each token on every page. The header is now omitted; every other security header (Content-Security-Policy, Permissions-Policy, HSTS, X-Frame-Options, the COOP/CORP set, and the rest) is unchanged. The edge and container paths now agree — neither sends Document-Policy — closing a header-parity gap. **Fixed:** *No more Document-Policy console warnings* — The Document-Policy header is no longer emitted on container-rendered pages. Its tokens were rejected by current browsers (the policy applied nothing and logged a warning per token on every page), so the header is dropped rather than shipped inert; the full set of other security headers is retained unchanged. Edge and container responses are now consistent.
12
+
13
+ - v0.3.13 (2026-05-30) — **Updated runtime hardens the storage and audit layers; admin error pages no longer show raw database text.** The vendored blamejs runtime is updated to v0.14.7, which tightens the data and audit layers the shop is built on: database queries are checked against each table's declared columns (a reference to an undeclared column fails closed), the database encryption key is bound to its deployment so a copied key can't be unsealed elsewhere, raw SQL fragments refuse embedded string literals so values bind through placeholders, audit-chain purges can require two authorizers, and breach-notification deadlines raise a running clock. Separately, when an admin form action fails the console now shows a short generic message with the correct status instead of rendering a raw database or parser string into its error banner. No operator action is required. **Changed:** *Vendored runtime updated to v0.14.7 (storage + audit hardening)* — The underlying runtime now checks every database query against the table's declared columns and fails closed on an undeclared reference, binds the database encryption key to its data directory and key path so a relocated key won't unseal, refuses embedded string literals in raw SQL fragments, supports two-authorizer dual control on audit-chain purges, can compute sealed-column lookup hashes as a keyed MAC, and ships a running clock for breach-notification deadlines. The shop inherits these protections; no configuration change is needed. **Fixed:** *Console error banners show a safe message, not internal text* — Every admin HTML form handler now routes a thrown error through one shared classifier: a duplicate key becomes a 409 "That value is already in use.", a missing referenced record a generic not-found, malformed input a generic "Invalid input.", and an unexpected failure a generic message whose detail is recorded server-side rather than shown. Operator-facing validation messages are unchanged. This closes the path where a create form could render a raw database constraint string into its error banner, matching the API behavior the bearer path already had.
14
+
11
15
  - v0.3.12 (2026-05-30) — **Currency and outbound-host validation consolidated behind one shared check.** Input validation that was duplicated across the codebase is now a single shared module. Currency codes are validated against the ISO 4217 catalog consistently wherever the admin binds money — the same check the gift-card flow gained last release — and the outbound webhook host guard (refusing loopback, private, link-local, reserved, and cloud-metadata destinations, including trailing-dot forms) is one shared function used by both the delivery and registration paths. The shared module composes the framework's Unicode codepoint catalog so free-text fields can reject control, bidi-override, and zero-width characters when needed. Behavior is equal-or-stricter than before; no operator action is required. **Changed:** *Currency validation is consistently ISO 4217* — Admin paths that bind a currency now validate it against the ISO 4217 catalog in one place, so a code that isn't a real currency is rejected consistently rather than only in some flows. · *Outbound-host SSRF guard is a single shared check* — The webhook delivery and registration paths now share one host guard — loopback, private (RFC 1918), link-local, reserved, and cloud-metadata addresses (by IP literal or known name, including trailing-dot forms) are refused identically on both, so the two can't drift apart.
12
16
 
13
17
  - v0.3.11 (2026-05-30) — **Webhook endpoints reject internal addresses; admin errors stay clean and typed.** Outbound webhook endpoints can no longer be pointed at loopback, private, link-local, or cloud-metadata addresses, closing a server-side request forgery path from operator-supplied URLs. Several admin endpoints that previously returned a 500 on bad input now return the correct 4xx — a duplicate slug or SKU is a 409 Conflict, a missing referenced record and malformed JSON are a 400 — and none of them echo internal database or parser text back in the response. Gift cards now reject a currency that isn't a real ISO 4217 code, and the returns refund action reports a malformed id the same way its sibling actions do. No configuration change is required; upgrading picks these up automatically. **Fixed:** *Admin errors return the right status and don't leak internals* — Creating a product, variant, or inventory row with a duplicate key now returns 409 Conflict instead of 500; attaching media to a missing product, and pasting malformed JSON into the shipping-zone editor, now return 400. None of these responses include raw database or JSON-parser text — the detail is a generic, operator-facing message and the underlying error is recorded server-side. The same applies to the receipt and packing-slip routes. · *Gift cards validate the currency* — Issuing a gift card with a currency that isn't a real ISO 4217 code is now rejected with a 400 instead of creating a card in a non-existent currency. · *Consistent error status for refunds* — The returns refund action reports a malformed return id with the same 400 its approve, receive, and reject siblings use; a well-formed id that doesn't exist is still a 404. **Security:** *Webhook endpoints can't target internal addresses* — A webhook endpoint URL that points at a loopback, private (RFC 1918), link-local, reserved, or cloud-metadata address — by IP literal or by a known name such as localhost, metadata.google.internal, or any *.internal host — is now refused when the subscription is created, on both the delivery and outbound-registration paths. A trailing-dot form of those hosts (for example localhost. or 169.254.169.254.) is normalized before the check so it can't slip past. Public https endpoints are unaffected.
package/lib/admin.js CHANGED
@@ -386,6 +386,59 @@ function _redirect(res, location) {
386
386
  if (res.end) res.end(); else res.send("");
387
387
  }
388
388
 
389
+ // Single classifier for a thrown admin error → an operator-safe outcome,
390
+ // shared by BOTH the bearer JSON path (_wrap) and every cookie/HTML form
391
+ // path so the two can never diverge on what a given error class is allowed
392
+ // to reveal. Returns `{ status, code, message }`:
393
+ //
394
+ // - TypeError → 400 bad-request, `e.message` verbatim. These are
395
+ // our own validation throws (`currency "ZZZ" is not
396
+ // a known ISO 4217 code`, `regions_json must be valid
397
+ // JSON`) — intended, operator-safe text the form must
398
+ // surface so the operator can correct the input.
399
+ // - SyntaxError → 400 bad-request, generic "Invalid input." A parser
400
+ // SyntaxError that reached here echoes the parse
401
+ // position ("...JSON at position 1"); never surface it.
402
+ // - UNIQUE / FOREIGN KEY constraint → 409 conflict, generic in-use /
403
+ // referenced-record text — never the table/column/SQL.
404
+ // - CHECK / NOT NULL constraint → 400 bad-request, generic
405
+ // missing-or-invalid text.
406
+ // - anything else → 500 internal-error, generic "Something went wrong
407
+ // — please try again." The raw message is recorded
408
+ // server-side via the framework audit (drop-silent,
409
+ // outcome:"failure") so an operator can correlate;
410
+ // it never reaches the client.
411
+ //
412
+ // `auditAction` (optional) names the audit action for the unknown-error
413
+ // record; defaults to "request" so a bare call still files under a sensible
414
+ // namespace.
415
+ function _safeNotice(e, auditAction) {
416
+ if (e instanceof TypeError) {
417
+ return { status: 400, code: "bad-request", message: (e && e.message) || "Invalid input." };
418
+ }
419
+ if (e instanceof SyntaxError) {
420
+ return { status: 400, code: "bad-request", message: "Invalid input." };
421
+ }
422
+ var msg = (e && e.message) || "";
423
+ if (/UNIQUE constraint failed/i.test(msg) || /FOREIGN KEY constraint failed/i.test(msg)) {
424
+ return /FOREIGN KEY/i.test(msg)
425
+ ? { status: 409, code: "conflict", message: "A referenced record does not exist." }
426
+ : { status: 409, code: "conflict", message: "That value is already in use." };
427
+ }
428
+ if (/(?:CHECK|NOT NULL) constraint failed/i.test(msg)) {
429
+ return { status: 400, code: "bad-request", message: "A required value is missing or invalid." };
430
+ }
431
+ // Genuine unknown error — record it server-side via the framework audit
432
+ // (drop-silent) so operators can correlate, then hand back a generic
433
+ // message + a 500. The raw message never reaches the client.
434
+ b.audit.safeEmit({
435
+ action: AUDIT_NAMESPACE + "." + (auditAction || "request") + ".error",
436
+ outcome: "failure",
437
+ metadata: { message: msg || String(e) },
438
+ });
439
+ return { status: 500, code: "internal-error", message: "Something went wrong — please try again." };
440
+ }
441
+
389
442
  function _wrap(handler, opts) {
390
443
  // Every admin handler routes through this wrapper: bearer-token
391
444
  // gate, error-to-problem-details translation, audit write on the
@@ -411,35 +464,13 @@ function _wrap(handler, opts) {
411
464
  }
412
465
  return result;
413
466
  } catch (e) {
414
- if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message);
415
- // Malformed JSON body the body parser raises a SyntaxError whose
416
- // message echoes the parser's position ("...JSON at position 1").
417
- // Surface a clean 400 with a generic detail rather than leaking the
418
- // parser internals (also the defense-in-depth net for any call site
419
- // that JSON.parses an operator-supplied field without its own guard).
420
- if (e instanceof SyntaxError) return _problem(res, 400, "bad-request", "Invalid JSON in request.");
421
- // Storage-engine constraint violations reach here as plain Errors
422
- // whose message names the table/column/SQL ("UNIQUE constraint
423
- // failed: products.slug"). Map the class to a clean 4xx with a
424
- // GENERIC operator-facing detail — never echo the table/column/SQL.
425
- var msg = (e && e.message) || "";
426
- if (/UNIQUE constraint failed/i.test(msg) || /FOREIGN KEY constraint failed/i.test(msg)) {
427
- return _problem(res, 409, "conflict",
428
- /FOREIGN KEY/i.test(msg) ? "A referenced record does not exist." : "That value is already in use.");
429
- }
430
- if (/(?:CHECK|NOT NULL) constraint failed/i.test(msg)) {
431
- return _problem(res, 400, "bad-request", "A required value is missing or invalid.");
432
- }
433
- // Genuine unknown error — record it server-side via the framework
434
- // audit (drop-silent) so operators can correlate, then return a
435
- // generic 500 with NO detail. The raw message never reaches the
436
- // client.
437
- b.audit.safeEmit({
438
- action: AUDIT_NAMESPACE + "." + (opts.audit || "request") + ".error",
439
- outcome: "failure",
440
- metadata: { message: msg || String(e) },
441
- });
442
- return _problem(res, 500, "internal-error");
467
+ // Single classification, shared with the cookie/HTML paths via
468
+ // _safeNotice. A 5xx carries NO error-derived detail (the unknown
469
+ // case is recorded server-side inside _safeNotice); 4xx surface the
470
+ // generic / validation message.
471
+ var n = _safeNotice(e, opts.audit);
472
+ if (n.status >= 500) return _problem(res, n.status, n.code);
473
+ return _problem(res, n.status, n.code, n.message);
443
474
  }
444
475
  };
445
476
  }
@@ -550,14 +581,12 @@ function mount(router, deps) {
550
581
  try {
551
582
  made = await catalog.products.create(req.body || {});
552
583
  } catch (e) {
553
- if (e instanceof TypeError || e.code === "CATALOG_DUPLICATE" || /slug|exists|duplicate/i.test(e.message || "")) {
554
- var page = await catalog.products.list({ limit: 100 });
555
- return _sendHtml(res, 400, renderAdminProducts({
556
- shop_name: deps.shop_name, nav_available: navAvailable, products: page.rows || [],
557
- notice: (e && e.message) || "Couldn't create that product.",
558
- }));
559
- }
560
- throw e;
584
+ var n = _safeNotice(e, "product.create");
585
+ var page = await catalog.products.list({ limit: 100 });
586
+ return _sendHtml(res, n.status, renderAdminProducts({
587
+ shop_name: deps.shop_name, nav_available: navAvailable, products: page.rows || [],
588
+ notice: n.message,
589
+ }));
561
590
  }
562
591
  b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".product.create", outcome: "success", metadata: { id: made.id } });
563
592
  _redirect(res, "/admin/products/" + encodeURIComponent(made.id) + "?created=1");
@@ -969,14 +998,12 @@ function mount(router, deps) {
969
998
  if (!body.sku) throw new TypeError("sku required");
970
999
  await catalog.inventory.create(body.sku, { stock_on_hand: parseInt(body.stock_on_hand, 10) || 0 });
971
1000
  } catch (e) {
972
- if (e instanceof TypeError || /exists|duplicate|UNIQUE/i.test(e.message || "")) {
973
- var page = await catalog.inventory.list({ limit: 500 });
974
- return _sendHtml(res, 400, renderAdminInventory({
975
- shop_name: deps.shop_name, nav_available: navAvailable, inventory: page.rows || [],
976
- notice: (e && e.message) || "Couldn't create that SKU.",
977
- }));
978
- }
979
- throw e;
1001
+ var n = _safeNotice(e, "inventory.create");
1002
+ var page = await catalog.inventory.list({ limit: 500 });
1003
+ return _sendHtml(res, n.status, renderAdminInventory({
1004
+ shop_name: deps.shop_name, nav_available: navAvailable, inventory: page.rows || [],
1005
+ notice: n.message,
1006
+ }));
980
1007
  }
981
1008
  b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".inventory.create", outcome: "success", metadata: { sku: body.sku } });
982
1009
  _redirect(res, "/admin/inventory?created=1");
@@ -1087,7 +1114,12 @@ function mount(router, deps) {
1087
1114
  alt_text: typeof body.alt_text === "string" ? body.alt_text : undefined,
1088
1115
  });
1089
1116
  } catch (e) {
1090
- if (!(e instanceof TypeError)) throw e;
1117
+ // Bad input (TypeError), a missing product (FOREIGN KEY constraint),
1118
+ // or any other failure → a notice via PRG. _safeNotice records an
1119
+ // unknown error server-side; the redirect itself carries no raw
1120
+ // message, so the storage-engine string can't ride the query into
1121
+ // the detail banner.
1122
+ _safeNotice(e, "media.attach");
1091
1123
  return _redirect(res, "/admin/products/" + enc + "?err=1");
1092
1124
  }
1093
1125
  b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".media.attach", outcome: "success", metadata: { id: id } });
@@ -1879,17 +1911,15 @@ function mount(router, deps) {
1879
1911
  var win = { to: Date.now(), from: Date.now() - b.constants.TIME.days(30) };
1880
1912
  try { status = _labelStatusParam(url); win = _labelWindow(url); }
1881
1913
  catch (e) {
1882
- if (!(e instanceof TypeError)) throw e;
1883
- notice = e.message.replace(/^admin:\s*/, "");
1914
+ notice = _safeNotice(e, "shipping_label.list").message.replace(/^admin:\s*/, "");
1884
1915
  }
1885
1916
  var payload;
1886
1917
  try { payload = await _labelListPayload(status, win); }
1887
1918
  catch (e2) {
1888
- if (!(e2 instanceof TypeError)) throw e2;
1889
1919
  // A bad range that slipped past the param parse (primitive
1890
1920
  // re-validates from < to) → fall back to the default window.
1891
1921
  win = { to: Date.now(), from: Date.now() - b.constants.TIME.days(30) };
1892
- notice = e2.message.replace(/^shipping-labels:\s*/, "");
1922
+ notice = _safeNotice(e2, "shipping_label.list").message.replace(/^shipping-labels:\s*/, "");
1893
1923
  payload = await _labelListPayload(status, win);
1894
1924
  }
1895
1925
  _sendHtml(res, 200, renderAdminShippingLabels({
@@ -1910,8 +1940,7 @@ function mount(router, deps) {
1910
1940
  var rows = [], notice = null;
1911
1941
  try { rows = await shippingLabels.pendingLabels({ limit: MAX_LABEL_LIST_LIMIT }); }
1912
1942
  catch (e) {
1913
- if (!(e instanceof TypeError)) throw e;
1914
- notice = e.message.replace(/^shipping-labels:\s*/, "");
1943
+ notice = _safeNotice(e, "shipping_label.pending").message.replace(/^shipping-labels:\s*/, "");
1915
1944
  }
1916
1945
  _sendHtml(res, 200, renderAdminShippingLabelsPending({
1917
1946
  shop_name: deps.shop_name, nav_available: navAvailable,
@@ -1937,16 +1966,14 @@ function mount(router, deps) {
1937
1966
  var win = { to: Date.now(), from: Date.now() - b.constants.TIME.days(30) };
1938
1967
  try { win = _labelWindow(url); carrier = _labelCarrierParam(url); }
1939
1968
  catch (e) {
1940
- if (!(e instanceof TypeError)) throw e;
1941
- notice = e.message.replace(/^admin:\s*/, "");
1969
+ notice = _safeNotice(e, "shipping_label.costs").message.replace(/^admin:\s*/, "");
1942
1970
  }
1943
1971
  var report;
1944
1972
  try { report = await _labelCostsReport(win, carrier); }
1945
1973
  catch (e2) {
1946
- if (!(e2 instanceof TypeError)) throw e2;
1947
1974
  win = { to: Date.now(), from: Date.now() - b.constants.TIME.days(30) };
1948
1975
  carrier = null;
1949
- notice = e2.message.replace(/^shipping-labels:\s*/, "");
1976
+ notice = _safeNotice(e2, "shipping_label.costs").message.replace(/^shipping-labels:\s*/, "");
1950
1977
  report = await _labelCostsReport(win, carrier);
1951
1978
  }
1952
1979
  _sendHtml(res, 200, renderAdminShippingLabelCosts({
@@ -3220,11 +3247,11 @@ function mount(router, deps) {
3220
3247
  try {
3221
3248
  ep = await webhooks.endpoints.create({ url: (typeof body.url === "string" ? body.url.trim() : body.url), events: events });
3222
3249
  } catch (e) {
3223
- if (!(e instanceof TypeError)) throw e;
3250
+ var n = _safeNotice(e, "webhook.create");
3224
3251
  var rows = await webhooks.endpoints.list();
3225
- return _sendHtml(res, 400, renderAdminWebhooks({
3252
+ return _sendHtml(res, n.status, renderAdminWebhooks({
3226
3253
  shop_name: deps.shop_name, nav_available: navAvailable, endpoints: rows,
3227
- known_events: KNOWN_WH_EVENTS, notice: e.message.replace(/^webhooks:\s*/, ""),
3254
+ known_events: KNOWN_WH_EVENTS, notice: n.message.replace(/^webhooks:\s*/, ""),
3228
3255
  }));
3229
3256
  }
3230
3257
  b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".webhook.create", outcome: "success", metadata: { id: ep.id } });
@@ -3459,10 +3486,6 @@ function mount(router, deps) {
3459
3486
  return n;
3460
3487
  }
3461
3488
 
3462
- function _cleanCreateMessage(e) {
3463
- return (e && e.message || "Couldn't create that collection.").replace(/^collections[.:]\s*/, "");
3464
- }
3465
-
3466
3489
  // Map the ?active= query to a collections.list filter: 1/true →
3467
3490
  // active-only, 0/false → archived-only, absent → all.
3468
3491
  function _collectionsFilter(activeS) {
@@ -3545,11 +3568,11 @@ function mount(router, deps) {
3545
3568
  });
3546
3569
  }
3547
3570
  } catch (e) {
3548
- if (!(e instanceof TypeError)) throw e;
3571
+ var n = _safeNotice(e, "collection.create");
3549
3572
  var rows = await _listForBrowser({});
3550
- return _sendHtml(res, 400, renderAdminCollections({
3573
+ return _sendHtml(res, n.status, renderAdminCollections({
3551
3574
  shop_name: deps.shop_name, nav_available: navAvailable, collections: rows,
3552
- notice: _cleanCreateMessage(e), form_type: type,
3575
+ notice: n.message.replace(/^collections[.:]\s*/, ""), form_type: type,
3553
3576
  }));
3554
3577
  }
3555
3578
  b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".collection.create", outcome: "success" });
@@ -3863,11 +3886,11 @@ function mount(router, deps) {
3863
3886
  try {
3864
3887
  await announcements.defineAnnouncement(_announcementFromForm(req.body || {}));
3865
3888
  } catch (e) {
3866
- if (!(e instanceof TypeError)) throw e;
3889
+ var n = _safeNotice(e, "announcement.define");
3867
3890
  var rows = await announcements.listAnnouncements({});
3868
- return _sendHtml(res, 400, renderAdminAnnouncements({
3891
+ return _sendHtml(res, n.status, renderAdminAnnouncements({
3869
3892
  shop_name: deps.shop_name, nav_available: navAvailable, announcements: rows,
3870
- notice: (e && e.message || "Couldn't save that announcement.").replace(/^announcementBar[.:]\s*/, ""),
3893
+ notice: n.message.replace(/^announcementBar[.:]\s*/, ""),
3871
3894
  }));
3872
3895
  }
3873
3896
  b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".announcement.define", outcome: "success" });
@@ -3942,11 +3965,11 @@ function mount(router, deps) {
3942
3965
  questions: _standardSurveyQuestions(body.kind),
3943
3966
  });
3944
3967
  } catch (e) {
3945
- if (!(e instanceof TypeError)) throw e;
3968
+ var n = _safeNotice(e, "survey.define");
3946
3969
  var rows = await surveys.listSurveys({});
3947
- return _sendHtml(res, 400, renderAdminSurveys({
3970
+ return _sendHtml(res, n.status, renderAdminSurveys({
3948
3971
  shop_name: deps.shop_name, nav_available: navAvailable, surveys: rows,
3949
- notice: (e.message || "Couldn't create that survey.").replace(/^customerSurveys[.:]\s*/, ""),
3972
+ notice: n.message.replace(/^customerSurveys[.:]\s*/, ""),
3950
3973
  }));
3951
3974
  }
3952
3975
  b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".survey.define", outcome: "success" });
@@ -4055,11 +4078,11 @@ function mount(router, deps) {
4055
4078
  try {
4056
4079
  await hours.defineSchedule(_scheduleFromForm(req.body || {}));
4057
4080
  } catch (e) {
4058
- if (!(e instanceof TypeError)) throw e;
4081
+ var n = _safeNotice(e, "hours.define");
4059
4082
  var rows = await hours.listSchedules();
4060
- return _sendHtml(res, 400, renderAdminHours({
4083
+ return _sendHtml(res, n.status, renderAdminHours({
4061
4084
  shop_name: deps.shop_name, nav_available: navAvailable, schedules: rows,
4062
- notice: (e.message || "Couldn't save that schedule.").replace(/^businessHours[.:]\s*/, ""),
4085
+ notice: n.message.replace(/^businessHours[.:]\s*/, ""),
4063
4086
  }));
4064
4087
  }
4065
4088
  b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".hours.define", outcome: "success" });
@@ -4240,11 +4263,10 @@ function mount(router, deps) {
4240
4263
  var win, notice = null;
4241
4264
  try { win = _reportWindow(url); }
4242
4265
  catch (e) {
4243
- if (!(e instanceof TypeError)) throw e;
4244
4266
  // Bad range → re-render with the default window + a correction
4245
4267
  // notice (config/entry tier: the operator fixes the typo).
4246
4268
  win = { to: Date.now(), from: Date.now() - b.constants.TIME.days(30) };
4247
- notice = e.message.replace(/^admin:\s*/, "");
4269
+ notice = _safeNotice(e, "report.view").message.replace(/^admin:\s*/, "");
4248
4270
  }
4249
4271
  // CSV download from the browser surface too (a link, not a fetch).
4250
4272
  if (url && url.searchParams.get("format") === "csv") {
@@ -4265,9 +4287,8 @@ function mount(router, deps) {
4265
4287
  var report;
4266
4288
  try { report = await _buildReport(win); }
4267
4289
  catch (e3) {
4268
- if (!(e3 instanceof TypeError)) throw e3;
4269
4290
  win = { to: Date.now(), from: Date.now() - b.constants.TIME.days(30) };
4270
- notice = e3.message.replace(/^salesReports:\s*/, "");
4291
+ notice = _safeNotice(e3, "report.view").message.replace(/^salesReports:\s*/, "");
4271
4292
  report = await _buildReport(win);
4272
4293
  }
4273
4294
  _sendHtml(res, 200, renderAdminReports({
@@ -4467,11 +4488,11 @@ function mount(router, deps) {
4467
4488
  try {
4468
4489
  await subscriptions.plans.create(input);
4469
4490
  } catch (e) {
4470
- if (!(e instanceof TypeError)) throw e;
4491
+ var n = _safeNotice(e, "subscription_plan.create");
4471
4492
  var rows = await subscriptions.plans.list({});
4472
- return _sendHtml(res, 400, renderAdminSubscriptionPlans({
4493
+ return _sendHtml(res, n.status, renderAdminSubscriptionPlans({
4473
4494
  shop_name: deps.shop_name, nav_available: navAvailable, plans: rows,
4474
- notice: e.message.replace(/^subscriptions[.:]\s*/, ""),
4495
+ notice: n.message.replace(/^subscriptions[.:]\s*/, ""),
4475
4496
  }));
4476
4497
  }
4477
4498
  b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".subscription_plan.create", outcome: "success" });
@@ -4629,11 +4650,11 @@ function mount(router, deps) {
4629
4650
  try {
4630
4651
  issued = await _issueGiftCard(req.body || {});
4631
4652
  } catch (e) {
4632
- if (!(e instanceof TypeError)) throw e;
4653
+ var n = _safeNotice(e, "gift_card.issue");
4633
4654
  var rows = await giftcards.list({});
4634
- return _sendHtml(res, 400, renderAdminGiftCards({
4655
+ return _sendHtml(res, n.status, renderAdminGiftCards({
4635
4656
  shop_name: deps.shop_name, nav_available: navAvailable, cards: rows,
4636
- notice: e.message.replace(/^giftcards?[.:]\s*/i, ""),
4657
+ notice: n.message.replace(/^giftcards?[.:]\s*/i, ""),
4637
4658
  }));
4638
4659
  }
4639
4660
  b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".gift_card.issue", outcome: "success", metadata: { id: issued.id } });
@@ -4821,12 +4842,18 @@ function mount(router, deps) {
4821
4842
  try {
4822
4843
  await taxRates.defineRate(_taxRateInput(body));
4823
4844
  } catch (e) {
4824
- if (!(e instanceof TypeError) && e.code !== "TAX_RATE_OVERLAP") throw e;
4845
+ // The primitive's overlap code carries an operator-safe message;
4846
+ // everything else routes through the shared classifier so a raw
4847
+ // constraint / parser / unknown error can't reach the banner.
4848
+ var overlap = e && e.code === "TAX_RATE_OVERLAP";
4849
+ var n = overlap ? { status: 400, message: (e.message || "").replace(/^taxRates[.:]\s*/, "") }
4850
+ : _safeNotice(e, "tax_rate.create");
4851
+ var noticeMsg = overlap ? n.message : n.message.replace(/^taxRates[.:]\s*/, "");
4825
4852
  var rows = await _taxRatesForBrowser(jurisdiction);
4826
- return _sendHtml(res, 400, renderAdminTaxRates({
4853
+ return _sendHtml(res, n.status, renderAdminTaxRates({
4827
4854
  shop_name: deps.shop_name, nav_available: navAvailable, rates: rows,
4828
4855
  jurisdiction: jurisdiction, sources: taxRates.SOURCES,
4829
- notice: (e && e.message || "").replace(/^taxRates[.:]\s*/, ""),
4856
+ notice: noticeMsg,
4830
4857
  }));
4831
4858
  }
4832
4859
  b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".tax_rate.create", outcome: "success" });
@@ -5014,14 +5041,21 @@ function mount(router, deps) {
5014
5041
  var filing;
5015
5042
  try { filing = await salesTaxFilings.defineFilingPeriod(_filingPeriodInput(req.body || {})); }
5016
5043
  catch (e) {
5017
- if (!(e instanceof TypeError) && e.code !== "SALES_TAX_FILING_DUPLICATE") throw e;
5044
+ // The primitive's own duplicate-period code carries an operator-safe
5045
+ // message (a named UNIQUE index, no raw SQL); everything else routes
5046
+ // through the shared classifier so a raw constraint / parser / unknown
5047
+ // error can't reach the banner.
5048
+ var dup = e && e.code === "SALES_TAX_FILING_DUPLICATE";
5049
+ var n = dup ? { status: 400, message: (e.message || "").replace(/^salesTaxFilings[.:]\s*/, "") }
5050
+ : _safeNotice(e, "tax_filing.create");
5051
+ var noticeMsg = dup ? n.message : n.message.replace(/^salesTaxFilings[.:]\s*/, "");
5018
5052
  var rows = await _filingsForBrowser({});
5019
5053
  var upcoming = await _upcomingForBrowser();
5020
- return _sendHtml(res, 400, renderAdminTaxFilings({
5054
+ return _sendHtml(res, n.status, renderAdminTaxFilings({
5021
5055
  shop_name: deps.shop_name, nav_available: navAvailable,
5022
5056
  filings: rows, upcoming: upcoming,
5023
5057
  kinds: salesTaxFilings.KINDS, statuses: salesTaxFilings.STATUSES,
5024
- notice: (e && e.message || "").replace(/^salesTaxFilings[.:]\s*/, ""),
5058
+ notice: noticeMsg,
5025
5059
  }));
5026
5060
  }
5027
5061
  b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".tax_filing.create", outcome: "success", metadata: { id: filing.id } });
@@ -5064,7 +5098,7 @@ function mount(router, deps) {
5064
5098
  notice = "Enter both a from and a to date (epoch-ms) for the report window.";
5065
5099
  } else {
5066
5100
  try { report = await salesTaxFilings.auditReportForJurisdiction({ jurisdiction: jurisdiction, from: from, to: to }); }
5067
- catch (e) { if (!(e instanceof TypeError)) throw e; notice = (e.message || "").replace(/^salesTaxFilings[.:]\s*/, ""); }
5101
+ catch (e) { notice = _safeNotice(e, "tax_filing.report").message.replace(/^salesTaxFilings[.:]\s*/, ""); }
5068
5102
  }
5069
5103
  _sendHtml(res, 200, renderAdminTaxFilingReport({
5070
5104
  shop_name: deps.shop_name, nav_available: navAvailable,
@@ -5132,10 +5166,13 @@ function mount(router, deps) {
5132
5166
  try {
5133
5167
  await run(req.params.id, req.body || {});
5134
5168
  } catch (e) {
5135
- if (!(e instanceof TypeError) &&
5136
- e.code !== "SALES_TAX_FILING_NOT_FOUND" &&
5137
- e.code !== "SALES_TAX_FILING_BAD_TRANSITION") throw e;
5138
- var msg = (e && e.message || "").replace(/^salesTaxFilings[.:]\s*/, "");
5169
+ // The primitive's not-found / bad-transition codes carry
5170
+ // operator-safe messages; everything else routes through the
5171
+ // shared classifier so a raw constraint / parser / unknown error
5172
+ // can't ride the err_msg query param into the detail banner.
5173
+ var safe = e && (e.code === "SALES_TAX_FILING_NOT_FOUND" || e.code === "SALES_TAX_FILING_BAD_TRANSITION");
5174
+ var msg = (safe ? (e.message || "") : _safeNotice(e, "tax_filing." + audit).message)
5175
+ .replace(/^salesTaxFilings[.:]\s*/, "");
5139
5176
  return _redirect(res, "/admin/tax-filings/" + enc + "?err=1&err_msg=" + encodeURIComponent(msg));
5140
5177
  }
5141
5178
  b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".tax_filing." + audit, outcome: "success", metadata: { id: req.params.id } });
@@ -5232,11 +5269,17 @@ function mount(router, deps) {
5232
5269
  try {
5233
5270
  await shippingZones.defineZone(_shippingZoneInput(req.body || {}));
5234
5271
  } catch (e) {
5235
- if (!(e instanceof TypeError) && e.code !== "SHIPPING_ZONE_EXISTS") throw e;
5272
+ // The primitive's already-exists code carries an operator-safe
5273
+ // message; everything else routes through the shared classifier so
5274
+ // a raw constraint / parser / unknown error can't reach the banner.
5275
+ var dup = e && e.code === "SHIPPING_ZONE_EXISTS";
5276
+ var n = dup ? { status: 400, message: (e.message || "").replace(/^shippingZones[.:]\s*/, "") }
5277
+ : _safeNotice(e, "shipping_zone.create");
5278
+ var noticeMsg = dup ? n.message : n.message.replace(/^shippingZones[.:]\s*/, "");
5236
5279
  var rows = await shippingZones.listZones({});
5237
- return _sendHtml(res, 400, renderAdminShipping({
5280
+ return _sendHtml(res, n.status, renderAdminShipping({
5238
5281
  shop_name: deps.shop_name, nav_available: navAvailable, zones: rows,
5239
- notice: (e && e.message || "").replace(/^shippingZones[.:]\s*/, ""),
5282
+ notice: noticeMsg,
5240
5283
  }));
5241
5284
  }
5242
5285
  b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".shipping_zone.create", outcome: "success" });
@@ -5430,9 +5473,9 @@ function mount(router, deps) {
5430
5473
  try {
5431
5474
  await autoDiscount.defineRule(_discountInput(req.body || {}));
5432
5475
  } catch (e) {
5433
- if (!(e instanceof TypeError)) throw e;
5434
- return _sendHtml(res, 400, await _renderDiscounts({
5435
- notice: (e && e.message || "").replace(/^autoDiscount[.:]\s*/, ""),
5476
+ var n = _safeNotice(e, "auto_discount.create");
5477
+ return _sendHtml(res, n.status, await _renderDiscounts({
5478
+ notice: n.message.replace(/^autoDiscount[.:]\s*/, ""),
5436
5479
  }));
5437
5480
  }
5438
5481
  b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".auto_discount.create", outcome: "success" });
@@ -5523,9 +5566,9 @@ function mount(router, deps) {
5523
5566
  try {
5524
5567
  await couponStacking.definePolicy(_policyInput(req.body || {}));
5525
5568
  } catch (e) {
5526
- if (!(e instanceof TypeError)) throw e;
5527
- return _sendHtml(res, 400, await _renderDiscounts({
5528
- notice: (e && e.message || "").replace(/^couponStacking[.:]\s*/, ""),
5569
+ var n = _safeNotice(e, "coupon_policy.create");
5570
+ return _sendHtml(res, n.status, await _renderDiscounts({
5571
+ notice: n.message.replace(/^couponStacking[.:]\s*/, ""),
5529
5572
  }));
5530
5573
  }
5531
5574
  b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".coupon_policy.create", outcome: "success" });
@@ -5639,10 +5682,6 @@ function mount(router, deps) {
5639
5682
  return input;
5640
5683
  }
5641
5684
 
5642
- function _qdCleanMessage(e) {
5643
- return (e && e.message || "Couldn't save that tier set.").replace(/^quantityDiscounts[.:]\s*/, "");
5644
- }
5645
-
5646
5685
  async function _renderQdList(flags) {
5647
5686
  flags = flags || {};
5648
5687
  var rows = await quantityDiscounts.list(_qdFilter(flags.archived_filter));
@@ -5682,8 +5721,10 @@ function mount(router, deps) {
5682
5721
  try {
5683
5722
  await quantityDiscounts.defineTier(_qdDefineInput(req.body || {}));
5684
5723
  } catch (e) {
5685
- if (!(e instanceof TypeError)) throw e;
5686
- return _sendHtml(res, 400, await _renderQdList({ notice: _qdCleanMessage(e) }));
5724
+ var n = _safeNotice(e, "quantity_discount.create");
5725
+ return _sendHtml(res, n.status, await _renderQdList({
5726
+ notice: n.message.replace(/^quantityDiscounts[.:]\s*/, ""),
5727
+ }));
5687
5728
  }
5688
5729
  b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".quantity_discount.create", outcome: "success" });
5689
5730
  _redirect(res, "/admin/quantity-discounts?created=1");
@@ -6027,9 +6068,10 @@ function mount(router, deps) {
6027
6068
  if (values.support_url) await config.put("shop.support_url", values.support_url);
6028
6069
  await config.put("setup.completed", true);
6029
6070
  } catch (e) {
6030
- return _sendHtml(res, 500, renderAdminSetup({
6071
+ var n = _safeNotice(e, "setup.save");
6072
+ return _sendHtml(res, n.status, renderAdminSetup({
6031
6073
  shop_name: deps.shop_name, values: values, nav_available: navAvailable,
6032
- notice: "Couldn't save — " + ((e && e.message) || "please try again."),
6074
+ notice: n.message,
6033
6075
  }));
6034
6076
  }
6035
6077
  b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".setup.save", outcome: "success", metadata: {} });
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.3.12",
2
+ "version": "0.3.14",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-1SIn6oAf1DjECbRfKENZasdKHJiywGdXR58wn0hsFGcdVHzUmvfgMkEz5ANIAZJ3",
@@ -142,6 +142,33 @@ function clientKey(req) {
142
142
  return sock || "unknown";
143
143
  }
144
144
 
145
+ /**
146
+ * Build the security-headers options for createApp's
147
+ * `middleware.securityHeaders`. The vendored blamejs default emits a
148
+ * `Document-Policy` header asserting `document-write=?0`,
149
+ * `unsized-media=?0`, and `oversized-images=?0`. Current Chromium
150
+ * recognizes none of those three feature names — it parses the header,
151
+ * rejects every token, logs "Unrecognized document policy feature name
152
+ * <x>" to the console, and applies nothing. The header is therefore
153
+ * inert: it adds console noise on every container response while
154
+ * enforcing no policy. (The recognized Document-Policy feature set today
155
+ * is `force-load-at-top` / `js-profiling` /
156
+ * `include-js-call-stacks-in-crash-reports` / `expect-no-linked-resources`
157
+ * / `network-efficiency-guardrails` — none of which is a control this
158
+ * storefront needs to assert.) We have no valid, useful Document-Policy
159
+ * to send, so disable the header rather than ship one the browser
160
+ * rejects. This also matches the edge: the Worker's `_SECURITY_HEADERS`
161
+ * set (worker/index.js) emits no Document-Policy, so suppressing it on
162
+ * the container makes the two substrates header-consistent. Every other
163
+ * vendored default (HSTS, CSP, Permissions-Policy, COOP/CORP, X-Frame-
164
+ * Options, etc.) stays ON. Pass-through to the framework primitive — we
165
+ * compose its `documentPolicy: false` override, never patch the vendored
166
+ * tree.
167
+ */
168
+ function securityHeadersOpts() {
169
+ return { documentPolicy: false };
170
+ }
171
+
145
172
  /**
146
173
  * Build the GLOBAL rate-limit options for createApp's
147
174
  * `middleware.rateLimit`. Token-bucket so a bursty-but-bounded browsing
@@ -282,6 +309,7 @@ function mountRouteGuards(r) {
282
309
 
283
310
  module.exports = {
284
311
  clientKey: clientKey,
312
+ securityHeadersOpts: securityHeadersOpts,
285
313
  globalRateLimitOpts: globalRateLimitOpts,
286
314
  mountRouteGuards: mountRouteGuards,
287
315
  WEBHOOK_PATHS: WEBHOOK_PATHS,
@@ -3,8 +3,8 @@
3
3
  "_about": "blamejs.shop vendors a single framework — blamejs — which itself bundles every server-side crypto/identity dependency. The transitive packages blamejs ships are surfaced in its own MANIFEST.json at lib/vendor/blamejs/lib/vendor/MANIFEST.json — Trivy / Grype rely on that nested data for CVE attribution.",
4
4
  "packages": {
5
5
  "blamejs": {
6
- "version": "0.14.6",
7
- "tag": "v0.14.6",
6
+ "version": "0.14.7",
7
+ "tag": "v0.14.7",
8
8
  "license": "Apache-2.0",
9
9
  "author": "blamejs contributors",
10
10
  "source": "https://github.com/blamejs/blamejs",
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.14.x
10
10
 
11
+ - v0.14.7 (2026-05-30) — **Storage and audit-trail hardening: queries are gated to declared columns, raw SQL refuses embedded literals, the database key is bound to its location, sealed-column lookup hashes gain a keyed mode, audit-chain purges can require dual control, and breach deadlines ship a running clock.** This release tightens the data and audit layers against a set of failure modes that were previously reachable. Database queries are now checked against the columns a table declared in its schema: a reference to an undeclared column fails closed by default instead of silently matching nothing, and the `whereRaw` escape hatch refuses an embedded string literal so values bind through placeholders. The database encryption key is sealed with its purpose, data directory, and key path as additional authenticated data, so a key file cannot be relocated to another deployment and unsealed there; an older key without that binding upgrades itself on first load. Sealed-column equality-lookup hashes can now be computed as a keyed MAC (HMAC-SHAKE256) off a per-deployment key, making the lookup hash unforgeable without that key, while the salted-SHA3 default is unchanged. Purging the tamper-evident audit chain can be placed under a two-authorizer dual-control grant so one operator cannot erase it alone, and database credential-rejection audits now record which relation the rejected credential tried to reach. Finally, breach-notification deadlines get a running clock that raises approaching and passed alerts as each regime's window elapses. One behavior change to note: the column gate defaults to reject — if a service issues queries against columns it did not declare in its schema, set `db.init({ columnGate: "warn" })` (audited, allowed) or `"off"` while the schema is reconciled. **Added:** *Column-membership gate on every query* — `b.db.from(table)` now checks each referenced column against the table's declared schema. The mode is set with `db.init({ columnGate: "reject" | "warn" | "off" })` (default `reject`), and `query.allowedColumns([...])` narrows a single query to an explicit allowlist that is always enforced. `b.db.getDeclaredColumns(table)` returns a table's declared column names (or `null` for an unknown table). This is defense in depth against typo'd or caller-influenced column names reaching the SQL layer (CWE-89). · *Keyed mode for sealed-column lookup hashes* — Equality-lookup ("derived") hashes for sealed columns can be computed as `hmac-shake256` — a keyed MAC over a per-deployment key — instead of the default `salted-sha3`. Set it per table with `cryptoField.registerTable(name, { derivedHashMode: "hmac-shake256" })` or per column with `{ from, mode: "hmac-shake256" }`. The keyed hash is unforgeable and un-correlatable without the deployment's MAC key, which raises the bar against offline lookup-table attacks on low-entropy sealed values (CWE-916). `b.vault.getDerivedHashMacKey()` exposes the 32-byte per-deployment key; it is created on first use and re-sealed across key rotation automatically. · *Dual-control gate on audit-chain purge* — `b.auditTools.purge` accepts `dualControlGrant`. When `audit_log` is placed under dual control, a verified archive and `confirm: true` are no longer sufficient: the purge additionally requires a consumed m-of-n grant whose action is bound to the purge, so a grant minted for another operation cannot be replayed and one operator cannot erase the tamper-evident chain alone (NIST SP 800-53 AU-9, separation of duties). · *Running clock for breach-notification deadlines* — `b.incident.report.createDeadlineClock({ notify, approachThresholds })` tracks open incidents and raises `deadline_approaching` and `deadline_passed` alerts as each regime's window elapses (GDPR 72h, DORA, NIS2, and the rest of the registry). Alerts are deduplicated per incident and stage, suppressed once a submission stage is acknowledged, and the clock can run on an interval or be ticked manually. **Changed:** *Queries against undeclared columns now fail closed by default* — The column gate defaults to `reject`: a query that references a column the table did not declare throws rather than silently matching nothing. A service that intentionally queries undeclared columns can set `db.init({ columnGate: "warn" })` to audit and allow, or `"off"` to disable the gate, while its schema is reconciled. Framework-declared columns (including `_id` and derived-hash columns) are always members. **Security:** *Database encryption key bound to its location* — `db.key.enc` is sealed with additional authenticated data over its purpose, resolved data directory, and resolved key path. A sealed key copied to a different deployment or path no longer unseals there — the AEAD authentication fails — which prevents silent key relocation. A legacy key sealed without this binding is detected and re-sealed in the bound format on first load, with no operator action required. · *`whereRaw` refuses embedded string literals* — `whereRaw(sql, params)` and `WhereBuilder.raw(sql, params)` reject a raw fragment containing a string literal (`'...'`); values must bind through the `params` array. A static, operator-controlled literal can opt in with `{ allowLiterals: true }`. This closes a path where a value concatenated into a raw fragment would reintroduce SQL injection (CWE-89). · *Credential-rejection audits record the attempted relation* — A `db.auth.failed` audit row (SQLSTATE 28000 / 28P01 / 42501) now carries `attemptedTable`, the relation the rejected credential tried to reach, extracted defensively from the statement. Triage can scope the blast radius of a credential-abuse event without correlating back to the raw SQL log (CWE-778). **Detectors:** *Audit-purge dual-control gate* — A new check fails the build if a call to `purgeAuditChain` appears in a file that does not also route through the dual-control gate, so a future caller cannot physically delete chain rows without two-authorizer enforcement. · *Raw-SQL literal/interpolation guard* — A new check fails the build on a `whereRaw` / `.raw` call whose SQL argument is built by template interpolation or string concatenation, keeping the bound-params discipline enforceable in framework code. · *Hand-rolled lookup-hash guard* — A new check fails the build if a sealed-column lookup hash is derived from the per-deployment salt outside the canonical helper, so call sites cannot bypass the keyed-mode and per-column mode policy. · *Auth-audit attempted-relation guard* — A new check fails the build if a `db.auth.failed` audit is emitted in a file that does not name `attemptedTable`, so the forensic field cannot be dropped from a future emitter.
12
+
11
13
  - v0.14.6 (2026-05-30) — **Access-refusal middleware can return RFC 9457 problem+json or a custom response, and several documented-but-uncallable APIs are now reachable.** Every access-refusal middleware — the auth gates (bearer, DPoP, mTLS, AAL, bound-key), CSRF, CORS, rate-limit, bot-guard, age-gate, the host and network allowlists, and the method and content-type gates — now accepts two uniform options: `problemDetails: true` returns an RFC 9457 `application/problem+json` body, and `onDeny(req, res, info)` hands the response to the caller. With neither set the refusal is byte-for-byte what it was, so this is a drop-in change that lets a service standardize one error envelope across its API instead of working around each middleware's hardcoded body. Alongside that: `b.middleware.requireBoundKey` is now exported (it was documented and tested but never wired into the middleware surface), `b.middleware.bearerAuth` accepts `requiredScopes` (previously rejected at construction, which made its scope-enforcement path unreachable), API-key refusals send the RFC 6750 challenge code that matches the failure, two documented call paths that named a missing namespace segment are corrected, and the release flow now flags stale GitHub Actions and vendored bundles — with a ready-to-paste pin — before a dependency PR is needed. **Added:** *Uniform `onDeny` and `problemDetails` options on every access-refusal middleware* — Each request-lifecycle middleware that refuses a request now takes `problemDetails: true` to emit an RFC 9457 `application/problem+json` body (composing `b.problemDetails`) and `onDeny(req, res, info)` to take over the response entirely; `info` carries the status, a machine reason, and the middleware-specific fields. The deny-path response headers (`Allow`, `WWW-Authenticate`, `Retry-After`, `Accept`) survive every mode. When neither option is set the response is unchanged. Covers `requireAuth`, `requireAal`, `requireMethods`, `requireContentType`, `requireMtls`, `requireBoundKey`, `bearerAuth`, `dpop`, `csrfProtect`, `fetchMetadata`, `botGuard`, `ageGate`, `hostAllowlist`, `networkAllowlist`, `cors`, `rateLimit`, and `dailyByteQuota` (whose existing `onExceeded` keeps working as an alias of `onDeny`). **Fixed:** *`b.middleware.requireBoundKey` is now callable* — The Bearer-API-key middleware was documented (with examples and tests) but never exported on `b.middleware`, so `b.middleware.requireBoundKey(...)` threw `undefined is not a function`. It is now wired into the middleware surface. · *`b.middleware.bearerAuth` accepts `requiredScopes`* — The RFC 6750 scope-enforcement path read `opts.requiredScopes`, but the option was rejected at construction with `unknown option`, making the 403 `insufficient_scope` behavior unreachable. `requiredScopes` is now an accepted option. · *RFC 6750 challenge codes on API-key refusals* — `b.middleware.requireBoundKey` now sends the `WWW-Authenticate` error code that matches the failure: `insufficient_scope` on a 403 missing-scope, `invalid_token` on an unknown or revoked token, and no error code on a 401 that presented no credentials (RFC 6750 §3). It previously sent `invalid_request` for every refusal. · *Corrected two documented call paths* — The compliance and network references named a path that dropped a namespace segment: the conformity-assessment scaffold is at `b.cra.report.conformityAssessment` (not `b.cra.conformityAssessment`), and the per-socket tuning helper is at `b.network.socket.applyToSocket` (not `b.network.applyToSocket`). The documented signatures now match the callable paths. · *GitHub Actions pins refreshed* — `github/codeql-action` 4.35.5 to 4.36.0, and `docker/login-action`, `docker/setup-buildx-action`, and `docker/setup-qemu-action` to their latest releases. **Detectors:** *`@primitive` reachability gate* — A new check resolves every documented `b.X.Y` primitive against the actual public surface and fails the build when a documented path is not callable (factory-instance shorthands excluded). This is the gate that would have caught the `requireBoundKey` and call-path issues above. · *Deny-path composition gate* — A new check requires every access-refusal middleware to route its refusal through the shared deny-response writer, so a future middleware cannot reintroduce a hardcoded body that locks callers out of `onDeny` / `problemDetails`. · *Actions and vendor currency in the release flow* — The release flow now fails the cut when a SHA-pinned GitHub Action or a vendored bundle is behind its latest upstream release. The actions report prints a ready-to-paste `owner/repo@<sha> # vX.Y.Z` pin and every file and line that uses it, so the bump is copy-paste rather than an after-the-fact dependency PR. Transient registry or API errors stay advisory so a flaky network response does not block an unrelated release.
12
14
 
13
15
  - v0.14.5 (2026-05-30) — **Finished cleaning up the mislabeled byte-literal lint suppressions, with no API or behavior changes.** A follow-up to the byte-literal lint tightening. The remaining suppression comments that named the byte-literal check on values that are not byte sizes — JSON-RPC error codes, HTTP status codes, octet ranges, day-in-milliseconds constants — are removed, keeping their explanatory text and any correctly-named companion suppression. Every byte-literal suppression that remains is now on genuine 1024-scale byte arithmetic. Source-comment hygiene only. **Changed:** *Remaining mislabeled byte-literal suppressions removed* — The byte-literal lint was previously a check on any multiple-of-8 integer, so suppression comments naming it were scattered across non-byte values. The last of those (in a handful of files, in mixed comment formats) are now removed — their explanatory text is retained as plain comments, and any correctly-named companion suppression is kept. The only byte-literal suppressions that remain are on genuine 1024-scale byte arithmetic. No change to any exported API, error code, wire format, or runtime behavior.