@blamejs/blamejs-shop 0.2.23 → 0.2.25

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 (76) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/lib/admin.js +42 -9
  3. package/lib/asset-manifest.json +1 -1
  4. package/lib/cart.js +24 -9
  5. package/lib/storefront.js +190 -38
  6. package/lib/vendor/MANIFEST.json +3 -3
  7. package/lib/vendor/blamejs/CHANGELOG.md +4 -0
  8. package/lib/vendor/blamejs/GOVERNANCE.md +2 -3
  9. package/lib/vendor/blamejs/LTS-CALENDAR.md +6 -2
  10. package/lib/vendor/blamejs/SECURITY.md +1 -1
  11. package/lib/vendor/blamejs/api-snapshot.json +2 -2
  12. package/lib/vendor/blamejs/lib/a2a.js +11 -11
  13. package/lib/vendor/blamejs/lib/agent-snapshot.js +1 -1
  14. package/lib/vendor/blamejs/lib/ai-capability.js +20 -20
  15. package/lib/vendor/blamejs/lib/ai-content-detect.js +4 -3
  16. package/lib/vendor/blamejs/lib/ai-dp.js +17 -17
  17. package/lib/vendor/blamejs/lib/ai-input.js +3 -3
  18. package/lib/vendor/blamejs/lib/ai-pref.js +9 -9
  19. package/lib/vendor/blamejs/lib/ai-quota.js +17 -17
  20. package/lib/vendor/blamejs/lib/archive-read.js +10 -7
  21. package/lib/vendor/blamejs/lib/arg-parser.js +38 -38
  22. package/lib/vendor/blamejs/lib/audit-sign.js +4 -4
  23. package/lib/vendor/blamejs/lib/auth/acr-vocabulary.js +4 -4
  24. package/lib/vendor/blamejs/lib/auth/auth-time-tracker.js +1 -1
  25. package/lib/vendor/blamejs/lib/auth/elevation-grant.js +10 -10
  26. package/lib/vendor/blamejs/lib/auth/step-up-policy.js +12 -12
  27. package/lib/vendor/blamejs/lib/auth/step-up.js +15 -15
  28. package/lib/vendor/blamejs/lib/boot-gates.js +6 -6
  29. package/lib/vendor/blamejs/lib/break-glass.js +1 -1
  30. package/lib/vendor/blamejs/lib/budr.js +6 -6
  31. package/lib/vendor/blamejs/lib/cms-codec.js +10 -9
  32. package/lib/vendor/blamejs/lib/content-credentials.js +13 -13
  33. package/lib/vendor/blamejs/lib/dark-patterns.js +15 -15
  34. package/lib/vendor/blamejs/lib/ddl-change-control.js +37 -37
  35. package/lib/vendor/blamejs/lib/dr-runbook.js +7 -7
  36. package/lib/vendor/blamejs/lib/fapi2.js +9 -9
  37. package/lib/vendor/blamejs/lib/fdx.js +7 -7
  38. package/lib/vendor/blamejs/lib/graphql-federation.js +2 -2
  39. package/lib/vendor/blamejs/lib/iab-mspa.js +5 -5
  40. package/lib/vendor/blamejs/lib/iab-tcf.js +18 -18
  41. package/lib/vendor/blamejs/lib/mail-crypto-smime.js +10 -6
  42. package/lib/vendor/blamejs/lib/mcp.js +13 -13
  43. package/lib/vendor/blamejs/lib/middleware/require-step-up.js +3 -3
  44. package/lib/vendor/blamejs/lib/mtls-ca.js +2 -2
  45. package/lib/vendor/blamejs/lib/safe-archive.js +8 -7
  46. package/lib/vendor/blamejs/lib/sec-cyber.js +3 -3
  47. package/lib/vendor/blamejs/lib/sse.js +14 -14
  48. package/lib/vendor/blamejs/lib/tcpa-10dlc.js +5 -5
  49. package/lib/vendor/blamejs/lib/tenant-quota.js +18 -18
  50. package/lib/vendor/blamejs/package.json +1 -1
  51. package/lib/vendor/blamejs/release-notes/v0.13.43.json +39 -0
  52. package/lib/vendor/blamejs/release-notes/v0.13.44.json +31 -0
  53. package/lib/vendor/blamejs/test/layer-0-primitives/a2a.test.js +1 -1
  54. package/lib/vendor/blamejs/test/layer-0-primitives/ai-capability.test.js +15 -15
  55. package/lib/vendor/blamejs/test/layer-0-primitives/ai-dp.test.js +16 -16
  56. package/lib/vendor/blamejs/test/layer-0-primitives/ai-input.test.js +2 -2
  57. package/lib/vendor/blamejs/test/layer-0-primitives/ai-pref.test.js +2 -2
  58. package/lib/vendor/blamejs/test/layer-0-primitives/ai-quota.test.js +19 -19
  59. package/lib/vendor/blamejs/test/layer-0-primitives/audit-sign-ml-dsa-65.test.js +1 -1
  60. package/lib/vendor/blamejs/test/layer-0-primitives/boot-gates.test.js +1 -1
  61. package/lib/vendor/blamejs/test/layer-0-primitives/budr.test.js +3 -3
  62. package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +52 -0
  63. package/lib/vendor/blamejs/test/layer-0-primitives/content-credentials.test.js +3 -3
  64. package/lib/vendor/blamejs/test/layer-0-primitives/dark-patterns.test.js +2 -2
  65. package/lib/vendor/blamejs/test/layer-0-primitives/dr-runbook.test.js +1 -1
  66. package/lib/vendor/blamejs/test/layer-0-primitives/fapi2.test.js +6 -6
  67. package/lib/vendor/blamejs/test/layer-0-primitives/fdx.test.js +2 -2
  68. package/lib/vendor/blamejs/test/layer-0-primitives/graphql-federation.test.js +1 -1
  69. package/lib/vendor/blamejs/test/layer-0-primitives/iab-mspa.test.js +3 -3
  70. package/lib/vendor/blamejs/test/layer-0-primitives/iab-tcf.test.js +7 -7
  71. package/lib/vendor/blamejs/test/layer-0-primitives/mcp.test.js +5 -5
  72. package/lib/vendor/blamejs/test/layer-0-primitives/sse.test.js +1 -1
  73. package/lib/vendor/blamejs/test/layer-0-primitives/tcpa-10dlc.test.js +3 -3
  74. package/lib/vendor/blamejs/test/layer-0-primitives/tenant-quota.test.js +3 -3
  75. package/lib/vendor/blamejs/test/layer-0-primitives/ws-client.test.js +4 -1
  76. 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.2.x
10
10
 
11
+ - v0.2.25 (2026-05-29) — **Harden session and admin cookies with __Host- / __Secure- name prefixes.** The session, login, and admin cookies now carry the browser-enforced `__Host-` / `__Secure-` name prefixes when served over HTTPS. These prefixes bind a cookie to a secure, same-origin context — a browser only accepts a `__Host-` cookie that was set with `Secure`, `Path=/`, and no `Domain` attribute — which closes off cookie-injection and session-fixation paths from a related origin or a non-secure context. The secure-versus-plain decision follows the forwarded request protocol, so a deployment behind a TLS-terminating proxy (the production setup) gets the hardened prefixes while local development and tests over HTTP keep the bare names and keep working. Returning visitors are not signed out by the upgrade: the cookie reader resolves both the prefixed and the legacy name during the transition. **Security:** *`__Host-` on the session and login cookies* — Over HTTPS the storefront session and login cookies are issued as `__Host-shop_sid` and `__Host-shop_auth` with `Secure`, `Path=/`, and no `Domain` — the attribute set a browser requires before it will store a `__Host-` cookie, so the session cookie can no longer be planted or overwritten by a related origin or a non-secure context. · *`__Secure-` on the admin cookie* — The admin session cookie is scoped to `Path=/admin`, so it takes the `__Secure-` prefix (which requires `Secure`) rather than `__Host-` (which mandates `Path=/`). The edge cache-skip check and the audience bucketing were updated to recognize the prefixed names so a signed-in response is never served from the shared edge cache.
12
+
13
+ - v0.2.24 (2026-05-29) — **Scope cart-line edits to your own cart, harden structured data, and redact auth errors.** A set of request-path hardening fixes. Cart line edit and remove are now scoped to your own session cart, so a cart-line id belonging to another shopper can no longer be used to change quantities on or delete their item — a mismatched id is a no-op, not a mutation. The product structured-data (JSON-LD) embedded in page heads now neutralizes every `</script` close-tag variant (with trailing space, slash, or newline), not just the exact `</script>`, closing a markup-injection path from admin-entered product fields. Account sign-in, registration, and passkey error responses now return a generic message plus a request id instead of the raw internal error string, with the full error logged server-side for the operator. And the worker DB/R2 bridge secret is checked at boot: a secret shorter than 32 characters logs a loud warning so a weak secret is visible in the boot log (the documented generator produces 43 characters, so a by-the-docs deploy stays quiet). **Security:** *Cart-line edits are scoped to your cart* — `POST /cart/lines/:id/update` and `/remove` now resolve the requester's session cart and scope the change to it (`WHERE id = ? AND cart_id = ?`), so a cart-line id from another shopper's cart can't be used to alter or delete their line — it resolves to a no-op rather than a cross-cart mutation. Legitimate edits to your own cart are unchanged. · *Structured-data close-tag escaping hardened* — JSON-LD embedded in page heads (edge and origin, byte-identical) now escapes every `</script` sequence the HTML parser treats as a tag-closer — including `</script `, `</script/`, and `</script` followed by a newline — not only the exact `</script>`. This removes a markup-injection vector from admin-entered product fields that flow into structured data. · *Account auth errors no longer leak internals* — The sign-in, registration, and passkey ceremony endpoints now return a generic message and a request id on an unexpected server error instead of the raw error string; the real error is logged server-side, correlated by that request id. Client-shape (400) and other expected responses are unchanged. · *Bridge-secret entropy warning at boot* — The worker DB/R2 bridge secret (`D1_BRIDGE_SECRET`) authorizes the database and object-storage bridge, so a weak value is high-impact. The container now logs a loud boot warning in production when it is shorter than 32 characters. The documented generator produces 43 characters; if you see the warning, regenerate the secret with the command it prints.
14
+
11
15
  - v0.2.23 (2026-05-29) — **Rate limiting and cross-site request isolation on the request lifecycle.** Two request-lifecycle defenses are now composed into every storefront and admin request. A per-client-IP rate limit backs the whole site (a generous global budget that a normal browsing and checkout session never trips) with tighter budgets on the abusable POST/auth endpoints — sign-in, passkey enrolment, checkout, gift-card balance lookup, account registration, newsletter, review/question submit, and survey response — so credential and passkey spraying, gift-card brute-forcing, and checkout flooding are shut down. Separately, a fetch-metadata gate refuses cross-site state-changing requests using the browser's Sec-Fetch headers, adding CSRF defense-in-depth on top of the SameSite session cookies. The client IP is read from the edge-forwarded header (the container sits behind the Worker), so limits apply per real visitor; the payment-webhook and health-probe paths are exempt. No change for normal use. **Added:** *Per-client rate limiting* — A generous global token-bucket limit per client IP backs the whole site, with tight fixed-window budgets on the abusable POST/auth endpoints (login, passkey register/add, checkout, gift-card balance, register, newsletter, review/question, survey). Keyed on the real client IP forwarded from the edge; payment-webhook and health-probe paths are exempt. The single-instance container uses an in-memory backend. · *Cross-site request isolation (fetch-metadata)* — Cross-site state-changing requests (the CSRF vector) are refused using the browser's Sec-Fetch-Site/Mode/Dest headers — same-origin requests and direct navigations pass; payment webhooks are exempt. This restores defense-in-depth alongside the SameSite session cookies.
12
16
 
13
17
  - v0.2.22 (2026-05-29) — **See your full order history, track shipments, reorder, and request returns.** The customer order surface is now complete end-to-end. A new order-history list shows every order (cursor-paginated), and each order page gains a status timeline, a carrier tracking panel (tracking number + the carrier's public tracking link), a one-click Reorder that rebuilds a cart from the order, and a Request-a-return button for eligible orders — the same Reorder/Return actions also appear per row on the account dashboard and the history list. On the operator side, the admin order screen gets a shipment panel to attach a carrier + tracking number and record shipment events, and the returns console's Refund now issues the payment-provider refund first (then records the RMA) whenever the order has a captured charge, so a return can't be marked refunded while the customer is left un-refunded; manual/guest orders with no charge fall back to an explicit record-only step. **Added:** *Order history + tracking + reorder + returns (customer)* — `/account/orders` lists all your orders with pagination; each order page shows a status timeline and carrier tracking, plus Reorder (`POST /orders/:id/reorder` rebuilds a cart from the order's lines at current prices) and Request-a-return for eligible orders. The dashboard and history rows carry the same actions. · *Shipment + tracking panel (admin)* — The admin order detail screen can attach a shipment (carrier + tracking number) and record shipment events; marking delivered advances the order's lifecycle. Tracking the order surfaces the carrier's public tracking link to the customer. **Changed:** *Return refunds move the money* — The returns console Refund action now issues the payment-provider refund first and records the RMA only on success when the order has a captured charge, so an approved return is never marked refunded without the customer actually being refunded. Orders with no captured charge use an explicit record-only step that points to the order.
package/lib/admin.js CHANGED
@@ -183,6 +183,17 @@ function _authOk(token, expected) {
183
183
  // read/write) — scoped to /admin, SameSite=Strict, HttpOnly + Secure.
184
184
  var ADMIN_COOKIE_NAME = "shop_admin";
185
185
 
186
+ // Cookie-prefix-hardened name. The admin session cookie is Path=/admin, so
187
+ // it CANNOT carry `__Host-` (which mandates Path=/); the correct prefix is
188
+ // `__Secure-`, which requires only the Secure attribute (RFC 6265bis
189
+ // §4.1.3.1). As with the storefront session cookies, the prefix moves in
190
+ // lockstep with Secure: a `__Secure-` cookie without Secure is invalid
191
+ // (silently dropped), and Secure cookies don't store over plain http. So
192
+ // https admin sessions carry `__Secure-shop_admin`; http dev/e2e sessions
193
+ // carry the bare `shop_admin`. The validity check resolves the prefixed
194
+ // name first and the bare name second.
195
+ var ADMIN_COOKIE_NAME_SECURE = "__Secure-" + ADMIN_COOKIE_NAME;
196
+
186
197
  var _adminJarMemo = null;
187
198
  function _adminJar() {
188
199
  if (!_adminJarMemo) {
@@ -194,14 +205,33 @@ function _adminJar() {
194
205
  return _adminJarMemo;
195
206
  }
196
207
 
197
- function _setAdminCookie(res) {
198
- _adminJar().writeSealed(res, ADMIN_COOKIE_NAME, JSON.stringify({
208
+ // Whether THIS request's PUBLIC connection is https — drives both the
209
+ // Secure attribute and the prefix choice for the admin session cookie.
210
+ // Same rule as the storefront: trust the Worker-set `x-forwarded-proto`
211
+ // (the container socket may be plain http behind the TLS-terminating
212
+ // Worker), and treat a direct dev/e2e connection (no forwarded header,
213
+ // non-encrypted socket) as http so the bare-named, non-Secure cookie is
214
+ // emitted and a real browser stores it.
215
+ function _secureForReq(req) {
216
+ return b.requestHelpers.requestProtocol(req, { trustProxy: true }) === "https";
217
+ }
218
+ function _adminCookieName(secure) { return secure ? ADMIN_COOKIE_NAME_SECURE : ADMIN_COOKIE_NAME; }
219
+
220
+ function _setAdminCookie(req, res) {
221
+ var secure = _secureForReq(req);
222
+ _adminJar().writeSealed(res, _adminCookieName(secure), JSON.stringify({
199
223
  admin: true,
200
224
  exp: Date.now() + b.constants.TIME.hours(12),
201
- }), { expires: new Date(Date.now() + b.constants.TIME.hours(12)) });
225
+ }), { expires: new Date(Date.now() + b.constants.TIME.hours(12)), secure: secure });
202
226
  }
203
- function _clearAdminCookie(res) {
204
- _adminJar().clear(res, ADMIN_COOKIE_NAME);
227
+ function _clearAdminCookie(req, res) {
228
+ // Expire-now must match the live request's protocol so the cleared
229
+ // cookie's attributes satisfy the `__Secure-` invariant: over https
230
+ // clear the prefixed name with Secure; over http clear the bare name. A
231
+ // request never carries both at once (the write side emits one per
232
+ // protocol), so clearing the protocol-matched name signs the operator out.
233
+ var secure = _secureForReq(req);
234
+ _adminJar().clear(res, _adminCookieName(secure), { secure: secure });
205
235
  }
206
236
 
207
237
  // One-time gift-card code reveal. The issue POST stashes the freshly-issued
@@ -224,7 +254,10 @@ function _takeGiftCardReveal(req, res, id) {
224
254
  return (env && env.id === id && typeof env.code === "string") ? env.code : null;
225
255
  }
226
256
  function _adminCookieValid(req) {
227
- var raw = _adminJar().readSealed(req, ADMIN_COOKIE_NAME);
257
+ // Resolve the prefixed name first and the bare name second so an admin
258
+ // session set in either environment (or mid-rollout) is recognised.
259
+ var raw = _adminJar().readSealed(req, ADMIN_COOKIE_NAME_SECURE);
260
+ if (raw === null) raw = _adminJar().readSealed(req, ADMIN_COOKIE_NAME);
228
261
  if (raw === null) return false;
229
262
  var env;
230
263
  try { env = JSON.parse(raw); } catch (_e) { return false; }
@@ -4048,7 +4081,7 @@ function mount(router, deps) {
4048
4081
  if (!_authOk(token, expectedToken)) {
4049
4082
  return _sendHtml(res, 401, renderAdminLogin({ shop_name: deps.shop_name, error: true }));
4050
4083
  }
4051
- try { _setAdminCookie(res); }
4084
+ try { _setAdminCookie(req, res); }
4052
4085
  catch (e) {
4053
4086
  // Sealed cookies need an initialized vault — surface 503 rather
4054
4087
  // than 500 so the operator knows to configure VAULT_PASSPHRASE.
@@ -4060,8 +4093,8 @@ function mount(router, deps) {
4060
4093
  _redirect(res, (await _setupComplete()) ? "/admin" : "/admin/setup");
4061
4094
  });
4062
4095
 
4063
- router.post("/admin/logout", async function (_req, res) {
4064
- _clearAdminCookie(res);
4096
+ router.post("/admin/logout", async function (req, res) {
4097
+ _clearAdminCookie(req, res);
4065
4098
  _redirect(res, "/admin");
4066
4099
  });
4067
4100
 
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.2.23",
2
+ "version": "0.2.25",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-1SIn6oAf1DjECbRfKENZasdKHJiywGdXR58wn0hsFGcdVHzUmvfgMkEz5ANIAZJ3",
package/lib/cart.js CHANGED
@@ -184,28 +184,43 @@ function create(opts) {
184
184
  return { id: id, cart_id: cartId, variant_id: input.variant_id, sku: variantRow.sku, qty: input.qty, unit_amount_minor: unitAmount, unit_currency: unitCurrency, added_at: ts, updated_at: ts };
185
185
  },
186
186
 
187
- updateLine: async function (lineId, patch) {
187
+ // Scope the mutation to (lineId, cartId): a caller who learns
188
+ // another visitor's cart_lines.id (it's rendered in their own cart
189
+ // HTML as the update/remove form action) can't mutate it because
190
+ // the row only matches when it also belongs to the caller's session
191
+ // cart. A cross-cart id matches zero rows and becomes a no-op
192
+ // (returns null), never a mutation. cartId is required — every
193
+ // caller resolves the session cart first.
194
+ updateLine: async function (lineId, cartId, patch) {
188
195
  _uuid(lineId, "line id");
196
+ _uuid(cartId, "cart_id");
189
197
  if (!patch || typeof patch !== "object") throw new TypeError("cart.updateLine: patch object required");
190
198
  if (patch.qty == null) throw new TypeError("cart.updateLine: qty is the only updatable field");
191
199
  _qty(patch.qty);
192
200
  var ts = _now();
193
201
  var r = await query(
194
- "UPDATE cart_lines SET qty = ?1, updated_at = ?2 WHERE id = ?3",
195
- [patch.qty, ts, lineId],
202
+ "UPDATE cart_lines SET qty = ?1, updated_at = ?2 WHERE id = ?3 AND cart_id = ?4",
203
+ [patch.qty, ts, lineId, cartId],
196
204
  );
197
205
  if (r.rowCount === 0) return null;
198
- var row = (await query("SELECT * FROM cart_lines WHERE id = ?1", [lineId])).rows[0];
199
- await query("UPDATE carts SET updated_at = ?1 WHERE id = ?2", [ts, row.cart_id]);
206
+ var row = (await query("SELECT * FROM cart_lines WHERE id = ?1 AND cart_id = ?2", [lineId, cartId])).rows[0];
207
+ await query("UPDATE carts SET updated_at = ?1 WHERE id = ?2", [ts, cartId]);
200
208
  return row;
201
209
  },
202
210
 
203
- removeLine: async function (lineId) {
211
+ // Same cart-scoping as updateLine: a row only deletes when it
212
+ // belongs to the caller's session cart, so a cross-cart id is a
213
+ // no-op (returns false) rather than deleting another visitor's line.
214
+ removeLine: async function (lineId, cartId) {
204
215
  _uuid(lineId, "line id");
205
- var row = (await query("SELECT cart_id FROM cart_lines WHERE id = ?1", [lineId])).rows[0];
216
+ _uuid(cartId, "cart_id");
217
+ var row = (await query(
218
+ "SELECT cart_id FROM cart_lines WHERE id = ?1 AND cart_id = ?2",
219
+ [lineId, cartId],
220
+ )).rows[0];
206
221
  if (!row) return false;
207
- await query("DELETE FROM cart_lines WHERE id = ?1", [lineId]);
208
- await query("UPDATE carts SET updated_at = ?1 WHERE id = ?2", [_now(), row.cart_id]);
222
+ await query("DELETE FROM cart_lines WHERE id = ?1 AND cart_id = ?2", [lineId, cartId]);
223
+ await query("UPDATE carts SET updated_at = ?1 WHERE id = ?2", [_now(), cartId]);
209
224
  return true;
210
225
  },
211
226
 
package/lib/storefront.js CHANGED
@@ -44,6 +44,35 @@ var b = require("./vendor/blamejs");
44
44
  // finds no store and falls back to the English baseline.
45
45
  var _localeAls = new AsyncLocalStorage();
46
46
 
47
+ // Storefront error logger. Routes through the framework's structured
48
+ // log sink (not console) so operators can redirect / quiet / structured-
49
+ // log every emission point, and so the per-request id the createApp
50
+ // requestId middleware allocates is auto-bound to each line. Used by the
51
+ // 5xx auth-route handlers to record the real failure server-side while
52
+ // the client only ever sees a generic message + the correlating id.
53
+ var _log = b.log.create({});
54
+
55
+ // Generic 500 for an auth/ceremony route: log the real error server-side
56
+ // (correlated by the framework request id) and return a fixed message to
57
+ // the client so no internal error string (stack frame, DB column, vault
58
+ // internals) leaks. The request id — set by the createApp requestId
59
+ // middleware on req.requestId and echoed in the X-Request-Id response
60
+ // header — rides in the body so an operator can grep the logs for a
61
+ // customer's failed ceremony. `where` names the route for the log line
62
+ // only; it is not reflected to the client.
63
+ function _authServerError(req, res, e, where) {
64
+ var rid = (req && req.requestId) || null;
65
+ _log.error("storefront auth route failed", {
66
+ route: where,
67
+ request_id: rid,
68
+ err: (e && e.message) || String(e),
69
+ });
70
+ res.status(500);
71
+ var ref = rid ? " (ref " + rid + ")" : "";
72
+ var msg = "Something went wrong. Please try again." + ref;
73
+ return res.end ? res.end(msg) : res.send(msg);
74
+ }
75
+
47
76
  // Payment-webhook signatures (Stripe's HMAC, PayPal's, …) are computed over
48
77
  // the EXACT raw request bytes, but the global JSON body-parser reparses and
49
78
  // discards them. This middleware buffers the raw body for the given POST
@@ -3561,10 +3590,16 @@ function _buildCompare(productId) {
3561
3590
  }
3562
3591
 
3563
3592
  // Schema.org JSON-LD block. JSON.stringify covers the standard escapes;
3564
- // the `</``<\/` rewrite neutralises any literal `</script>` in a
3565
- // value. Mirrors the edge renderer's `jsonLdScript`.
3593
+ // the `</script``<\/script` rewrite neutralises any literal closing
3594
+ // tag in a value. The HTML tokenizer ends a <script> on `</script`
3595
+ // followed by whitespace, `/`, or `>`, so matching only the exact
3596
+ // `</script>` byte sequence misses `</script `, `</script\n`,
3597
+ // `</script/` — all of which still break out. Matching `</script`
3598
+ // (any trailing byte) closes every variant. Mirrors the edge
3599
+ // renderer's `jsonLdScript` byte-for-byte (this output is dual-rendered
3600
+ // edge + container; the render-parity tests gate on the two agreeing).
3566
3601
  function _jsonLdScript(data) {
3567
- var serialised = JSON.stringify(data).replace(/<\/(?=script>)/gi, "<\\/");
3602
+ var serialised = JSON.stringify(data).replace(/<\/script/gi, "<\\/script");
3568
3603
  return "<script type=\"application/ld+json\">" + serialised + "</script>";
3569
3604
  }
3570
3605
 
@@ -4921,6 +4956,27 @@ var SESSION_COOKIE_NAME = "shop_sid";
4921
4956
  // initiated logout-everywhere).
4922
4957
  var AUTH_COOKIE_NAME = "shop_auth";
4923
4958
 
4959
+ // Cookie-prefix-hardened names for the two Path=/ session cookies. The
4960
+ // `__Host-` prefix is a browser-enforced integrity marker (RFC 6265bis
4961
+ // §4.1.3.2): a `__Host-`-named cookie is only stored when it was set
4962
+ // Secure, Path=/, and with NO Domain — pinning it to the exact host over
4963
+ // HTTPS, immune to subdomain / related-domain cookie injection + session
4964
+ // fixation. Both cookies are already Path=/ with no Domain, so `__Host-`
4965
+ // is the correct (strongest) prefix.
4966
+ //
4967
+ // The prefix moves in lockstep with the Secure attribute: a `__Host-`
4968
+ // cookie is INVALID (silently dropped by the browser) without Secure, and
4969
+ // Secure cookies are silently dropped over plain http. Local dev + the
4970
+ // e2e harness run over http, so the cookie there must carry the BARE name
4971
+ // (Secure off) or it would never store and every session would break. The
4972
+ // public protocol — https in production where the Cloudflare Worker
4973
+ // terminates TLS and forwards `x-forwarded-proto: https` to the container
4974
+ // — drives the choice via `_secureForReq` below. Read sites resolve the
4975
+ // prefixed name first and fall back to the bare name, so a request in
4976
+ // either environment (or mid-rollout) still resolves its session.
4977
+ var SESSION_COOKIE_NAME_SECURE = "__Host-" + SESSION_COOKIE_NAME;
4978
+ var AUTH_COOKIE_NAME_SECURE = "__Host-" + AUTH_COOKIE_NAME;
4979
+
4924
4980
  // WebAuthn ceremony state cookie — short-lived envelope holding the
4925
4981
  // random challenge + the ceremony-scoped metadata so register-finish /
4926
4982
  // login-finish can verify the same challenge the browser was sent.
@@ -4978,6 +5034,41 @@ function _readCookie(req, name) {
4978
5034
  return _cookieJar().read(req, name);
4979
5035
  }
4980
5036
 
5037
+ // Whether THIS request's PUBLIC connection is https — the single source
5038
+ // of truth for both the Secure attribute and the cookie-prefix choice.
5039
+ // The container socket behind the Cloudflare Worker may be plain http even
5040
+ // when the visitor's connection is https, so the decision rides on the
5041
+ // forwarded protocol the Worker sets (`x-forwarded-proto`); `trustProxy`
5042
+ // opts that header in (the framework refuses the attacker-forgeable header
5043
+ // otherwise). Direct dev / e2e connections have no forwarded header and a
5044
+ // non-encrypted socket, so this returns false there and the bare-named,
5045
+ // non-Secure cookie is emitted (a real browser drops a Secure cookie over
5046
+ // http, so the bare name is what keeps dev/e2e sessions working).
5047
+ function _secureForReq(req) {
5048
+ return b.requestHelpers.requestProtocol(req, { trustProxy: true }) === "https";
5049
+ }
5050
+
5051
+ // Resolve the on-wire name for one of the prefix-hardened cookies. Secure
5052
+ // requests (https) carry the `__Host-`-prefixed name; non-Secure requests
5053
+ // (http dev/e2e) carry the bare name so the browser actually stores it.
5054
+ function _sidCookieName(secure) { return secure ? SESSION_COOKIE_NAME_SECURE : SESSION_COOKIE_NAME; }
5055
+ function _authCookieName(secure) { return secure ? AUTH_COOKIE_NAME_SECURE : AUTH_COOKIE_NAME; }
5056
+
5057
+ // Read a prefix-hardened cookie value resolving the prefixed name first
5058
+ // and the bare name second, so a request resolves its session regardless
5059
+ // of which environment wrote the cookie (or a mid-rollout request that
5060
+ // still carries the old bare-named cookie). Returns the raw string or null.
5061
+ function _readPrefixedCookie(req, secureName, bareName) {
5062
+ var v = _cookieJar().read(req, secureName);
5063
+ if (v !== null && v !== undefined) return v;
5064
+ return _cookieJar().read(req, bareName);
5065
+ }
5066
+ function _readPrefixedSealed(req, secureName, bareName) {
5067
+ var v = _cookieJar().readSealed(req, secureName);
5068
+ if (v !== null) return v;
5069
+ return _cookieJar().readSealed(req, bareName);
5070
+ }
5071
+
4981
5072
  function _readSidCookie(req) {
4982
5073
  // A cookie carrying anything but a well-shaped session id (a stale
4983
5074
  // value from an old deploy, a tampered cookie, garbage) reads as "no
@@ -4985,27 +5076,45 @@ function _readSidCookie(req) {
4985
5076
  // malformed id and would turn every page that renders the cart count
4986
5077
  // into a 500. The cookie grants zero authority, so dropping a
4987
5078
  // malformed one silently is safe.
4988
- var v = _cookieJar().read(req, SESSION_COOKIE_NAME);
5079
+ var v = _readPrefixedCookie(req, SESSION_COOKIE_NAME_SECURE, SESSION_COOKIE_NAME);
4989
5080
  return v && SID_SHAPE_RE.test(v) ? v : null;
4990
5081
  }
4991
5082
 
4992
- function _setSidCookie(res, sid) {
5083
+ function _setSidCookie(req, res, sid) {
4993
5084
  var T = b.constants.TIME;
4994
- _cookieJar().write(res, SESSION_COOKIE_NAME, sid, { expires: new Date(Date.now() + T.days(30)) });
5085
+ var secure = _secureForReq(req);
5086
+ // The jar defaults Path=/ and no Domain — exactly the `__Host-`
5087
+ // invariant. The explicit `secure` here moves the Secure attribute and
5088
+ // the prefixed name together: an https request gets `__Host-shop_sid`
5089
+ // with Secure; an http dev/e2e request gets `shop_sid` without Secure.
5090
+ _cookieJar().write(res, _sidCookieName(secure), sid, {
5091
+ expires: new Date(Date.now() + T.days(30)),
5092
+ secure: secure,
5093
+ });
4995
5094
  }
4996
5095
 
4997
5096
  // Auth + WebAuthn-challenge cookies carry a vault-sealed JSON envelope.
4998
5097
  // writeSealed/readSealed handle the seal + the on-wire prefix; the
4999
5098
  // caller works in plain objects.
5000
- function _setAuthCookie(res, env) {
5099
+ function _setAuthCookie(req, res, env) {
5001
5100
  var T = b.constants.TIME;
5002
- _cookieJar().writeSealed(res, AUTH_COOKIE_NAME, JSON.stringify(env), { expires: new Date(Date.now() + T.days(14)) });
5101
+ var secure = _secureForReq(req);
5102
+ _cookieJar().writeSealed(res, _authCookieName(secure), JSON.stringify(env), {
5103
+ expires: new Date(Date.now() + T.days(14)),
5104
+ secure: secure,
5105
+ });
5003
5106
  }
5004
- function _clearAuthCookie(res) {
5005
- _cookieJar().clear(res, AUTH_COOKIE_NAME);
5107
+ function _clearAuthCookie(req, res) {
5108
+ // Expire-now must match the live request's protocol so the attributes
5109
+ // satisfy the prefix invariant: over https clear the `__Host-` name with
5110
+ // Secure+Path=/; over http clear the bare name. A request never carries
5111
+ // both names at once (the write side emits exactly one per protocol), so
5112
+ // clearing the protocol-matched name signs the visitor out.
5113
+ var secure = _secureForReq(req);
5114
+ _cookieJar().clear(res, _authCookieName(secure), { secure: secure });
5006
5115
  }
5007
5116
  function _readAuthEnv(req) {
5008
- var raw = _cookieJar().readSealed(req, AUTH_COOKIE_NAME);
5117
+ var raw = _readPrefixedSealed(req, AUTH_COOKIE_NAME_SECURE, AUTH_COOKIE_NAME);
5009
5118
  if (raw === null) return null;
5010
5119
  try { return JSON.parse(raw); } catch (_e) { return null; }
5011
5120
  }
@@ -6209,7 +6318,10 @@ function mount(router, deps) {
6209
6318
  router.use(function announcementMiddleware(req, _res, next) {
6210
6319
  try {
6211
6320
  _refreshAnnouncementCache(deps.announcementBar);
6212
- var viewerKind = _cookieJar().read(req, AUTH_COOKIE_NAME) ? "logged_in" : "guest";
6321
+ // Coarse logged-in/guest bucket from auth-cookie PRESENCE resolve
6322
+ // the prefixed (`__Host-`, https/production) and bare (http/dev)
6323
+ // names so an https visitor isn't mis-bucketed as a guest.
6324
+ var viewerKind = _readPrefixedCookie(req, AUTH_COOKIE_NAME_SECURE, AUTH_COOKIE_NAME) ? "logged_in" : "guest";
6213
6325
  var row = _resolveActiveAnnouncement(viewerKind);
6214
6326
  var cur = _localeAls.getStore() || {};
6215
6327
  _localeAls.enterWith(Object.assign({}, cur, { announcement: row }));
@@ -6514,7 +6626,7 @@ function mount(router, deps) {
6514
6626
  var sid = _readSidCookie(req);
6515
6627
  if (!sid) {
6516
6628
  sid = b.uuid.v7();
6517
- _setSidCookie(res, sid);
6629
+ _setSidCookie(req, res, sid);
6518
6630
  }
6519
6631
  var existing = await deps.cart.bySession(sid);
6520
6632
  if (existing) return { sid: sid, cart: existing };
@@ -7063,7 +7175,7 @@ function mount(router, deps) {
7063
7175
  if (sid) return sid;
7064
7176
  if (!mint) return null;
7065
7177
  sid = b.uuid.v7();
7066
- _setSidCookie(res, sid);
7178
+ _setSidCookie(req, res, sid);
7067
7179
  return sid;
7068
7180
  }
7069
7181
 
@@ -8067,8 +8179,11 @@ function mount(router, deps) {
8067
8179
  return res.end ? res.end(JSON.stringify(startOpts)) : res.send(JSON.stringify(startOpts));
8068
8180
  } catch (e) {
8069
8181
  if (e && e.code === "vault/not-initialized") return _serviceUnavailable(res, "auth not configured");
8070
- res.status(e instanceof TypeError ? 400 : 500);
8071
- return res.end ? res.end((e && e.message) || "register-begin failed") : res.send((e && e.message) || "register-begin failed");
8182
+ if (e instanceof TypeError) {
8183
+ res.status(400);
8184
+ return res.end ? res.end((e && e.message) || "register-begin failed") : res.send((e && e.message) || "register-begin failed");
8185
+ }
8186
+ return _authServerError(req, res, e, "register-begin");
8072
8187
  }
8073
8188
  });
8074
8189
 
@@ -8105,7 +8220,7 @@ function mount(router, deps) {
8105
8220
  transports: transports,
8106
8221
  });
8107
8222
  _clearChallengeCookie(res);
8108
- _setAuthCookie(res, {
8223
+ _setAuthCookie(req, res, {
8109
8224
  customer_id: env.customer_id,
8110
8225
  exp: Date.now() + b.constants.TIME.days(14),
8111
8226
  });
@@ -8125,8 +8240,11 @@ function mount(router, deps) {
8125
8240
  return res.end ? res.end("ok") : res.send("ok");
8126
8241
  } catch (e) {
8127
8242
  if (e && e.code === "vault/not-initialized") return _serviceUnavailable(res, "auth not configured");
8128
- res.status(e instanceof TypeError ? 400 : 500);
8129
- return res.end ? res.end((e && e.message) || "register-finish failed") : res.send((e && e.message) || "register-finish failed");
8243
+ if (e instanceof TypeError) {
8244
+ res.status(400);
8245
+ return res.end ? res.end((e && e.message) || "register-finish failed") : res.send((e && e.message) || "register-finish failed");
8246
+ }
8247
+ return _authServerError(req, res, e, "register-finish");
8130
8248
  }
8131
8249
  });
8132
8250
 
@@ -8166,8 +8284,11 @@ function mount(router, deps) {
8166
8284
  return res.end ? res.end(JSON.stringify(startOpts)) : res.send(JSON.stringify(startOpts));
8167
8285
  } catch (e) {
8168
8286
  if (e && e.code === "vault/not-initialized") return _serviceUnavailable(res, "auth not configured");
8169
- res.status(e instanceof TypeError ? 400 : 500);
8170
- return res.end ? res.end((e && e.message) || "login-begin failed") : res.send((e && e.message) || "login-begin failed");
8287
+ if (e instanceof TypeError) {
8288
+ res.status(400);
8289
+ return res.end ? res.end((e && e.message) || "login-begin failed") : res.send((e && e.message) || "login-begin failed");
8290
+ }
8291
+ return _authServerError(req, res, e, "login-begin");
8171
8292
  }
8172
8293
  });
8173
8294
 
@@ -8222,7 +8343,7 @@ function mount(router, deps) {
8222
8343
  } catch (_e) { /* best-effort merge; sign-in itself succeeds */ }
8223
8344
  }
8224
8345
  _clearChallengeCookie(res);
8225
- _setAuthCookie(res, {
8346
+ _setAuthCookie(req, res, {
8226
8347
  customer_id: customer.id,
8227
8348
  exp: Date.now() + b.constants.TIME.days(14),
8228
8349
  });
@@ -8230,8 +8351,11 @@ function mount(router, deps) {
8230
8351
  return res.end ? res.end("ok") : res.send("ok");
8231
8352
  } catch (e) {
8232
8353
  if (e && e.code === "vault/not-initialized") return _serviceUnavailable(res, "auth not configured");
8233
- res.status(e instanceof TypeError ? 400 : 500);
8234
- return res.end ? res.end((e && e.message) || "login-finish failed") : res.send((e && e.message) || "login-finish failed");
8354
+ if (e instanceof TypeError) {
8355
+ res.status(400);
8356
+ return res.end ? res.end((e && e.message) || "login-finish failed") : res.send((e && e.message) || "login-finish failed");
8357
+ }
8358
+ return _authServerError(req, res, e, "login-finish");
8235
8359
  }
8236
8360
  });
8237
8361
 
@@ -8248,7 +8372,7 @@ function mount(router, deps) {
8248
8372
  }
8249
8373
  var customer = await deps.customers.get(auth.customer_id);
8250
8374
  if (!customer) {
8251
- _clearAuthCookie(res);
8375
+ _clearAuthCookie(req, res);
8252
8376
  res.status(303); res.setHeader && res.setHeader("location", "/account/login");
8253
8377
  return res.end ? res.end() : res.send("");
8254
8378
  }
@@ -8348,8 +8472,8 @@ function mount(router, deps) {
8348
8472
  });
8349
8473
  }
8350
8474
 
8351
- router.post("/account/logout", function (_req, res) {
8352
- _clearAuthCookie(res);
8475
+ router.post("/account/logout", function (req, res) {
8476
+ _clearAuthCookie(req, res);
8353
8477
  res.status(303); res.setHeader && res.setHeader("location", "/");
8354
8478
  return res.end ? res.end() : res.send("");
8355
8479
  });
@@ -8507,8 +8631,11 @@ function mount(router, deps) {
8507
8631
  return res.end ? res.end(JSON.stringify(startOpts)) : res.send(JSON.stringify(startOpts));
8508
8632
  } catch (e) {
8509
8633
  if (e && e.code === "vault/not-initialized") return _serviceUnavailable(res, "auth not configured");
8510
- res.status(e instanceof TypeError ? 400 : 500);
8511
- return res.end ? res.end((e && e.message) || "add-begin failed") : res.send((e && e.message) || "add-begin failed");
8634
+ if (e instanceof TypeError) {
8635
+ res.status(400);
8636
+ return res.end ? res.end((e && e.message) || "add-begin failed") : res.send((e && e.message) || "add-begin failed");
8637
+ }
8638
+ return _authServerError(req, res, e, "add-begin");
8512
8639
  }
8513
8640
  });
8514
8641
 
@@ -8560,8 +8687,11 @@ function mount(router, deps) {
8560
8687
  return res.end ? res.end("ok") : res.send("ok");
8561
8688
  } catch (e) {
8562
8689
  if (e && e.code === "vault/not-initialized") return _serviceUnavailable(res, "auth not configured");
8563
- res.status(e instanceof TypeError ? 400 : 500);
8564
- return res.end ? res.end((e && e.message) || "add-finish failed") : res.send((e && e.message) || "add-finish failed");
8690
+ if (e instanceof TypeError) {
8691
+ res.status(400);
8692
+ return res.end ? res.end((e && e.message) || "add-finish failed") : res.send((e && e.message) || "add-finish failed");
8693
+ }
8694
+ return _authServerError(req, res, e, "add-finish");
8565
8695
  }
8566
8696
  });
8567
8697
 
@@ -8586,7 +8716,7 @@ function mount(router, deps) {
8586
8716
  var auth = _accountAuth(req, res); if (!auth) return;
8587
8717
  var customer = await deps.customers.get(auth.customer_id);
8588
8718
  if (!customer) {
8589
- _clearAuthCookie(res);
8719
+ _clearAuthCookie(req, res);
8590
8720
  res.status(303); res.setHeader && res.setHeader("location", "/account/login");
8591
8721
  return res.end ? res.end() : res.send("");
8592
8722
  }
@@ -8597,7 +8727,7 @@ function mount(router, deps) {
8597
8727
  var auth = _accountAuth(req, res); if (!auth) return;
8598
8728
  var customer = await deps.customers.get(auth.customer_id);
8599
8729
  if (!customer) {
8600
- _clearAuthCookie(res);
8730
+ _clearAuthCookie(req, res);
8601
8731
  res.status(303); res.setHeader && res.setHeader("location", "/account/login");
8602
8732
  return res.end ? res.end() : res.send("");
8603
8733
  }
@@ -8707,7 +8837,7 @@ function mount(router, deps) {
8707
8837
  } else {
8708
8838
  try { _clearReferralCookie(res); } catch (_e) { /* best-effort */ }
8709
8839
  }
8710
- _setAuthCookie(res, { customer_id: rv.customer.id, exp: Date.now() + b.constants.TIME.days(14) });
8840
+ _setAuthCookie(req, res, { customer_id: rv.customer.id, exp: Date.now() + b.constants.TIME.days(14) });
8711
8841
  res.status(303); res.setHeader && res.setHeader("location", "/account");
8712
8842
  return res.end ? res.end() : res.send("");
8713
8843
  });
@@ -8820,7 +8950,7 @@ function mount(router, deps) {
8820
8950
  } else {
8821
8951
  try { _clearReferralCookie(res); } catch (_e) { /* best-effort */ }
8822
8952
  }
8823
- _setAuthCookie(res, { customer_id: rv.customer.id, exp: Date.now() + b.constants.TIME.days(14) });
8953
+ _setAuthCookie(req, res, { customer_id: rv.customer.id, exp: Date.now() + b.constants.TIME.days(14) });
8824
8954
  res.status(303); res.setHeader && res.setHeader("location", "/account");
8825
8955
  return res.end ? res.end() : res.send("");
8826
8956
  });
@@ -10346,8 +10476,19 @@ function mount(router, deps) {
10346
10476
  res.status(400);
10347
10477
  return res.end ? res.end("Invalid request") : res.send("Invalid request");
10348
10478
  }
10479
+ // Resolve the requester's own session cart and scope the mutation
10480
+ // to it (same pattern as POST /cart/lines/:line_id/save). The line
10481
+ // id is rendered in every visitor's own cart HTML, so without this
10482
+ // a caller who learns another visitor's line id could change its
10483
+ // qty. A line that isn't in the caller's cart returns null → 404.
10484
+ var sid = _readSidCookie(req);
10485
+ var cart = sid ? await deps.cart.bySession(sid) : null;
10486
+ if (!cart) {
10487
+ res.status(404);
10488
+ return res.end ? res.end("Line not found") : res.send("Line not found");
10489
+ }
10349
10490
  try {
10350
- var updated = await deps.cart.updateLine(lineId, { qty: qty });
10491
+ var updated = await deps.cart.updateLine(lineId, cart.id, { qty: qty });
10351
10492
  if (!updated) {
10352
10493
  res.status(404);
10353
10494
  return res.end ? res.end("Line not found") : res.send("Line not found");
@@ -10368,8 +10509,19 @@ function mount(router, deps) {
10368
10509
  res.status(400);
10369
10510
  return res.end ? res.end("Invalid request") : res.send("Invalid request");
10370
10511
  }
10512
+ // Scope the delete to the requester's own session cart (same as the
10513
+ // update route) so a known-but-foreign line id can't delete another
10514
+ // visitor's line. No session cart → nothing of the caller's to
10515
+ // remove; redirect to /cart as the no-op success path.
10516
+ var sid = _readSidCookie(req);
10517
+ var cart = sid ? await deps.cart.bySession(sid) : null;
10518
+ if (!cart) {
10519
+ res.status(303);
10520
+ res.setHeader && res.setHeader("location", "/cart");
10521
+ return res.end ? res.end() : res.send("");
10522
+ }
10371
10523
  try {
10372
- await deps.cart.removeLine(lineId);
10524
+ await deps.cart.removeLine(lineId, cart.id);
10373
10525
  } catch (e) {
10374
10526
  res.status(e instanceof TypeError ? 400 : 500);
10375
10527
  return res.end ? res.end((e && e.message) || "Error") : res.send((e && e.message) || "Error");
@@ -10471,7 +10623,7 @@ function mount(router, deps) {
10471
10623
  var sid = _readSidCookie(req);
10472
10624
  if (!sid) {
10473
10625
  sid = b.uuid.v7();
10474
- _setSidCookie(res, sid);
10626
+ _setSidCookie(req, res, sid);
10475
10627
  }
10476
10628
  return sid;
10477
10629
  }
@@ -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.13.42",
7
- "tag": "v0.13.42",
6
+ "version": "0.13.44",
7
+ "tag": "v0.13.44",
8
8
  "license": "Apache-2.0",
9
9
  "author": "blamejs contributors",
10
10
  "source": "https://github.com/blamejs/blamejs",
@@ -13,7 +13,7 @@
13
13
  "server": "lib/vendor/blamejs/"
14
14
  },
15
15
  "bundler": "shallow git clone of release tag from github.com/blamejs/blamejs",
16
- "bundledAt": "2026-05-29"
16
+ "bundledAt": "2026-05-30"
17
17
  }
18
18
  }
19
19
  }
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.13.x
10
10
 
11
+ - v0.13.44 (2026-05-29) — **Error codes on the consent, compliance, and protocol namespaces now follow the namespace/kebab-case contract.** The framework's error contract is `err.code = "namespace/kebab-case"`, and the vast majority of namespaces already followed it. This release normalizes the holdouts: fifteen namespaces that threw bare UPPER_SNAKE codes with no namespace, and nine that used a camelCase namespace prefix. After this release every error these namespaces throw carries a `namespace/kebab-case` code, so an operator switching on `err.code` no longer has to special-case them. This is a breaking change for code that matches the old strings — pre-1.0, there is no compatibility shim, so update any `err.code` comparisons against the listed namespaces. A codebase check now enforces the convention so it cannot regress. A small set of older codes (the cluster, scheduler, circuit-breaker, object-store, and upload subsystems) is intentionally left for the 1.0 release, where it will carry a deprecation cycle. **Changed:** *Bare UPPER_SNAKE error codes are now namespaced (breaking)* — Fifteen namespaces threw bare UPPER_SNAKE error codes with no namespace prefix (for example `mcp` threw `BAD_JSON`, `BAD_ENVELOPE`, `BAD_METHOD`). Their `err.code` values are now `namespace/kebab-case` — `mcp/bad-json`, `mcp/bad-envelope`, and so on. The affected namespaces are `b.a2a`, `b.aiInput`, `b.aiPref`, `b.budr`, `b.contentCredentials`, `b.darkPatterns`, `b.fapi2`, `b.fdx`, `b.graphqlFederation`, `b.iabTcf`, `b.iabMspa`, `b.mcp`, `b.secCyber`, `b.sse`, and `b.tcpa10dlc`. Operators matching the old bare codes on `err.code` must update those comparisons; the error message text is unchanged. · *camelCase error-code namespaces are now kebab-case (breaking)* — Nine namespaces emitted error codes whose namespace segment was camelCase (for example `aiDp/bad-bound`, `argParser/flag-duplicate`). The namespace segment is now kebab-case to match every other code: `ai-dp/`, `ai-capability/`, `ai-quota/`, `arg-parser/`, `audit-sign/`, `auth-step-up/`, `ddl-change-control/`, `dr-runbook/`, `tenant-quota/`, and `boot-gates/`. The `b.*` API namespace keys themselves are unchanged (those remain camelCase, e.g. `b.argParser`); only the `err.code` string changed. Operators matching these `err.code` strings must update them. **Detectors:** *Error-code shape is enforced* — A codebase check now flags any error code constructed via `new XError(...)` or the per-class `factory(...)` whose value is a bare UPPER_SNAKE string or carries a camelCase namespace segment, so the `namespace/kebab-case` contract cannot silently regress. It correctly ignores native error constructors (whose first argument is the message, not a code).
12
+
13
+ - v0.13.43 (2026-05-29) — **LTS window stated consistently as 24 months, experimental primitives declared semver-exempt, and stale version references cleaned up.** Documentation and operator-facing string hygiene ahead of the 1.0 stability contract. The LTS support window is now stated as 24 months everywhere (GOVERNANCE.md and the LTS calendar previously disagreed — 24 vs 18). The LTS calendar gains an explicit clause that primitives marked experimental are exempt from the stability/LTS contract, so operators can tell at a glance which surfaces may change between minors. Several error messages and doc blocks that pinned to long-past version numbers ("lands in v0.10.9", "not supported in v0.12.7", "ships in v0.6.45+") are restated version-agnostically with their escape hatch, and the S/MIME module now points operators at the live PGP encrypt/decrypt path for confidentiality today. No API or behavior changes. **Changed:** *LTS support window is consistently 24 months* — `GOVERNANCE.md` promised a 24-month LTS window while `LTS-CALENDAR.md` and `SECURITY.md` stated 18 — a six-month contradiction in the single most load-bearing number of the support contract. All three now state 24 months of security-only patches per major. The calendar table and the supported-versions prose are aligned. · *Experimental primitives are declared exempt from the stability contract* — `LTS-CALENDAR.md` now states explicitly that primitives documented as experimental (shown as "experimental" on their wiki page, and via the `experimental` segment in namespaces like `b.jose.jwe.experimental`) are not covered by the stability contract or the LTS window — they may change signature, behavior, or wire format, or be removed, in any minor without a deprecation cycle. This lets the framework ship primitives that track in-flight standards without freezing an unsettled format, and tells operators precisely which surfaces are not yet frozen. **Fixed:** *Stale version references removed from operator-facing errors and docs* — Error messages and documentation that pinned to long-past versions are restated version-agnostically with the relevant escape hatch: ZIP64 and unsupported-compression errors in archive reading, the CMS AuthEnvelopedData / fielded-decoder notes, the mTLS CRL-engine error, the safe-archive format-detection summary (which also now correctly lists the supported zip / tar / tar.gz set rather than claiming only zip), and the AI-content IPTC-reader note. None changed behavior; they no longer read as broken promises against the published version history. · *S/MIME confidentiality deferral points to the working PGP path* — `b.mail.crypto.smime` ships sign + verify; encrypt/decrypt is deferred. The deferral note previously cited an open-ended internal condition; it now names the escape hatch directly — use `b.mail.crypto.pgp.encrypt` / `decrypt` for mail confidentiality today — and states the concrete trigger that would re-open S/MIME-specific (X.509-recipient) encryption. · *Governance doc no longer references an internal file operators cannot see* — `GOVERNANCE.md` cited a rule number in a contributor-only file that does not ship in the repository. The deprecation-policy statement is now self-contained.
14
+
11
15
  - v0.13.42 (2026-05-29) — **S/MIME trust-chain validation binds the leaf to the key that verified the signature.** When b.mail.crypto.smime.verify is given trust anchors, it validates the supplied certificate chain — but it picked the chain leaf unconditionally (the first cert) and never tied it to signerPublicKey, the key that actually verified the signature. A SignedData blob could therefore carry a validly-chained certificate for one identity while the signature was verified under an unrelated key, and the chain-validated result would imply a cert↔signer binding the code never made. Chain validation now selects the leaf as the certificate whose public key matches signerPublicKey, and refuses (signer-not-in-chain) when no certificate in the chain carries that key — so a chain-validated signature is bound to the cert it claims. **Security:** *Trust-chain leaf is bound to the verified signer key* — `smime.verify({ trustAnchorCertsPem })` validated the supplied chain starting from `chain[0]` without checking that the leaf's public key was the one that verified the signature (the operator-supplied `signerPublicKey`). A crafted `SignedData` could pair a validly-chained certificate for identity A with a signature verified under an unrelated key, and the chain-valid result would assert a binding that didn't hold. The chain walk now selects the leaf as the certificate whose public key equals `signerPublicKey` (matched against the certificate's SPKI, raw key or full-encoding form), and throws `mail-crypto/smime/signer-not-in-chain` when no certificate in `SignedData.certificates` carries that key. A certificate whose key cannot be extracted is treated as a non-match, so validation fails closed rather than trusting an unverifiable binding.
12
16
 
13
17
  - v0.13.41 (2026-05-29) — **Agent registry reads can be tenant-scoped; compliance-erasure docs clarify actor is an audit field.** The agent orchestrator's registry reads (list and lookup) gated only on the flat agent-registry:read scope, so any holder could enumerate every tenant's agents and resolve a handle to one — even though the event bus already scopes subscribe and delivery by tenant. The orchestrator now mirrors that: with the new tenantScope option enabled, list returns only the actor's own tenant's agents and lookup refuses a cross-tenant name, unless the actor holds the framework cross-tenant-admin scope. Off by default, so single-tenant deployments are unaffected. Separately, the subject-erasure docs now state explicitly that the recorded actor is an audit field, not authentication — the caller must be authorized upstream. **Added:** *Tenant-scoped agent registry reads (opts.tenantScope)* — `b.agent.orchestrator.create({ tenantScope: true })` now scopes `list` and `lookup` to the calling actor's tenant: `list` filters out agents in other tenants and `lookup` returns null for a cross-tenant name, unless the actor holds the cross-tenant-admin scope (`b.agent.tenant.CROSS_TENANT_ADMIN_SCOPE`). This closes a cross-tenant metadata-enumeration and handle-acquisition path — `agent-registry:read` alone no longer exposes other tenants' agents — and mirrors the tenant scoping the event bus enforces on subscribe and delivery. The option defaults off; existing single-tenant orchestrators behave exactly as before. The `tenantId` argument to `list` remains a convenience filter, distinct from this authorization boundary. **Changed:** *Subject-erasure docs clarify the actor is an audit field, not authentication* — `b.subject.erase` and `b.subject.eraseHard` gate the deletion on operator acknowledgements and the legal-hold registry, not on caller identity. Their documentation now states explicitly that the recorded `actor` is an audit-record field, not authentication — the caller MUST be authenticated and authorized by the route before invoking. No behavior change; this removes an implicit assumption that could otherwise be read as the primitive authorizing the call.