@blamejs/blamejs-shop 0.2.22 → 0.2.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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.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.
12
+
13
+ - 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.
14
+
11
15
  - 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.
12
16
 
13
17
  - v0.2.21 (2026-05-29) — **Related products ("You may also like") on the product page.** Product pages now show a related-products rail. The picks are the other products in the product's primary collection, ordered deterministically and capped at four, rendered with the same product-card markup the home and search grids use — identical on the edge and origin paths. The rail is hidden when a product has no related items. This surfaces existing catalog relationships to shoppers without any new data or configuration. **Added:** *Related-products rail on the PDP* — A "You may also like" section on each product page lists other active products from the same primary collection (deterministic order, up to four), using the standard product-card markup byte-identically across the edge and origin renders. Hidden when there are no related products.
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.2.22",
2
+ "version": "0.2.24",
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/index.js CHANGED
@@ -40,6 +40,7 @@ try {
40
40
  module.exports.framework = framework;
41
41
 
42
42
  Object.assign(module.exports, {
43
+ securityMiddleware: require("./security-middleware"),
43
44
  externaldbD1: require("./externaldb-d1"),
44
45
  r2Bridge: require("./r2-bridge"),
45
46
  catalog: require("./catalog"),
@@ -0,0 +1,217 @@
1
+ "use strict";
2
+ /**
3
+ * Request-lifecycle security wiring — composes the vendored blamejs
4
+ * middleware that defends the storefront + admin request paths into a
5
+ * single place both the production entry point (server.js) and the
6
+ * end-to-end harness (test/e2e/serve.js) wire identically.
7
+ *
8
+ * Three layers, all per-client-IP:
9
+ *
10
+ * 1. A generous GLOBAL rate limit (token bucket) — the backstop
11
+ * against credential / passkey spraying, gift-card balance
12
+ * brute-force, checkout hammering, and unauthenticated row-flood
13
+ * writes. Sized so a normal browsing + checkout session (page
14
+ * navigations + form POSTs + the cart-count island fetch) never
15
+ * trips it; only a flood does.
16
+ *
17
+ * 2. TIGHT per-route budgets on the abusable POST / auth endpoints
18
+ * (login, passkey register, checkout, gift-card balance lookup,
19
+ * account register, newsletter, review / question submit, survey
20
+ * response). A human does well under five of these a minute, so a
21
+ * ~10/min budget is invisible to real use and shuts spray down.
22
+ *
23
+ * 3. fetch-metadata (Sec-Fetch-Site / -Mode / -Dest) — refuses
24
+ * cross-site state-changing requests WITHOUT needing a per-form
25
+ * token, restoring CSRF defense-in-depth on top of the storefront's
26
+ * SameSite session cookies. The payment webhook routes are exempt
27
+ * (a payment processor's server-to-server POST is cross-site by
28
+ * nature and carries its own HMAC signature check).
29
+ *
30
+ * THE CLIENT-IP CONSTRAINT. In production the Node container sits BEHIND
31
+ * the Cloudflare Worker: every container request arrives via the
32
+ * Worker's forward, so the socket peer is the CF fabric, not the user.
33
+ * Cloudflare injects the real client address as `cf-connecting-ip`
34
+ * (with `x-real-ip` as a mirror); the Worker forwards both verbatim.
35
+ * Every limiter here therefore keys on that header, falling back to the
36
+ * socket address only for direct (non-proxied) connections such as the
37
+ * e2e harness and local dev. Keying on the socket would put every
38
+ * visitor in ONE bucket behind the fabric and let a single global limit
39
+ * throttle the whole store.
40
+ *
41
+ * The container runs as a single instance (max_instances=1), so the
42
+ * default in-memory rate-limit backend is correct — there is no second
43
+ * replica to coordinate a shared counter with, and the in-memory token
44
+ * bucket / fixed window need no SQL hop.
45
+ */
46
+
47
+ var b = require("./vendor/blamejs");
48
+
49
+ var C = b.constants;
50
+
51
+ // Payment webhooks are server-to-server POSTs from Stripe / PayPal:
52
+ // cross-site by nature, unthrottleable by a per-IP human budget, and
53
+ // already authenticated by an HMAC signature the edge + container both
54
+ // verify. They are exempt from BOTH the rate limiters and fetch-metadata.
55
+ var WEBHOOK_PATHS = ["/api/webhooks/stripe", "/api/webhooks/paypal"];
56
+
57
+ // Liveness / readiness probe — the container's Docker HEALTHCHECK hits
58
+ // this on a fixed cadence; never rate-limit it or a slow cold start
59
+ // could wedge the health signal.
60
+ var HEALTH_PATH = "/_/health";
61
+
62
+ // The abusable endpoints — POST / auth surfaces where a human does
63
+ // well under five requests a minute. Each gets its own per-client-IP
64
+ // budget so a spray against one can't borrow another's headroom, and a
65
+ // legitimate burst on one (a shopper re-submitting a slow checkout)
66
+ // never eats the login budget. Entries are matched as path PREFIXES so
67
+ // the dynamic `:slug` / `:token` segments and the begin/finish passkey
68
+ // sub-paths are all covered by a single stem.
69
+ //
70
+ // Prefix coverage notes:
71
+ // /account/login -> /account/login, /account/login/google, ...
72
+ // /account/register -> /account/register AND /account/register-begin
73
+ // /account/passkey/ -> register-begin / register-finish / add-* begin+finish
74
+ // /products/ -> the review + question submit sub-paths (gated to POST below)
75
+ // /survey/ -> /survey/:token (GET form + POST submit)
76
+ var TIGHT_PREFIXES = [
77
+ "/account/login",
78
+ "/account/register",
79
+ "/account/passkey/",
80
+ "/checkout",
81
+ "/gift-cards/balance",
82
+ "/gift-cards",
83
+ "/newsletter",
84
+ "/products/",
85
+ "/survey/",
86
+ ];
87
+
88
+ /**
89
+ * Resolve the real client IP for rate-limit keying. Reads the
90
+ * Cloudflare-injected `cf-connecting-ip` first (the canonical single
91
+ * client address behind the fabric), then `x-real-ip` (its mirror),
92
+ * then falls back to the socket address via `b.requestHelpers.clientIp`
93
+ * for direct connections (e2e harness, local dev). Always returns a
94
+ * non-empty string so two un-identifiable clients never collapse into
95
+ * the same bucket as a real IP — request-shape reader, returns a
96
+ * default, never throws.
97
+ */
98
+ function clientKey(req) {
99
+ var headers = (req && req.headers) || {};
100
+ var cf = headers["cf-connecting-ip"];
101
+ if (typeof cf === "string" && cf.length > 0) return cf.trim();
102
+ var real = headers["x-real-ip"];
103
+ if (typeof real === "string" && real.length > 0) return real.trim();
104
+ var sock = b.requestHelpers.clientIp(req);
105
+ return sock || "unknown";
106
+ }
107
+
108
+ /**
109
+ * Build the GLOBAL rate-limit options for createApp's
110
+ * `middleware.rateLimit`. Token-bucket so a bursty-but-bounded browsing
111
+ * session is smoothed rather than clipped at a window edge.
112
+ *
113
+ * burst — the standing buffer a client may spend at once.
114
+ * refillPerSecond — the sustained per-second throughput.
115
+ *
116
+ * 300 burst + 5/s refill = a 300-request standing buffer that refills
117
+ * to full over a minute, i.e. a 300/min sustained ceiling per client
118
+ * IP. A very active human session (rapid navigation, the cart-count
119
+ * island firing once per page view, form POSTs) lands far under this;
120
+ * an unauthenticated spray flood blows straight through it. The webhook
121
+ * + health paths skip the limiter entirely.
122
+ */
123
+ function globalRateLimitOpts() {
124
+ return {
125
+ backend: "memory",
126
+ algorithm: "token-bucket",
127
+ burst: 300,
128
+ refillPerSecond: 5,
129
+ keyFn: clientKey,
130
+ skipPaths: WEBHOOK_PATHS.concat([HEALTH_PATH]),
131
+ };
132
+ }
133
+
134
+ function _hasPrefix(pathname, prefixes) {
135
+ for (var i = 0; i < prefixes.length; i += 1) {
136
+ if (pathname.indexOf(prefixes[i]) === 0) return true;
137
+ }
138
+ return false;
139
+ }
140
+
141
+ /**
142
+ * Mount the per-route tight rate limiters + the fetch-metadata gate on
143
+ * the router inside the operator's `routes(r)` chain. Call AFTER the
144
+ * webhook raw-body capture + bodyParser are mounted (so the gate reads
145
+ * a fully-shaped request) and BEFORE the storefront / admin routes.
146
+ *
147
+ * @param r the blamejs Router passed to createApp's routes(r) callback.
148
+ */
149
+ function mountRouteGuards(r) {
150
+ // --- fetch-metadata: cross-site state-change isolation -------------
151
+ //
152
+ // Refuses cross-site POST / PUT / DELETE / PATCH (the CSRF vector)
153
+ // using the browser-supplied Sec-Fetch-* headers — same-origin and
154
+ // same-site requests, plus direct navigations (typed URL / bookmark),
155
+ // pass through. Legacy / non-browser clients that omit Sec-Fetch-*
156
+ // are deferred to (allowMissing default) so server-to-server callers
157
+ // and old browsers aren't broken; the storefront's SameSite session
158
+ // cookie is the gate for those. The webhook paths are exempt because
159
+ // a payment processor's callback is legitimately cross-site.
160
+ var fmGate = b.middleware.fetchMetadata({
161
+ allowSameSite: true,
162
+ allowCrossSite: false,
163
+ allowMissing: true,
164
+ allowedNavigate: true,
165
+ });
166
+ r.use(function fetchMetadataGuard(req, res, next) {
167
+ var pathname = req.pathname || req.url || "/";
168
+ if (_hasPrefix(pathname, WEBHOOK_PATHS)) return next();
169
+ return fmGate(req, res, next);
170
+ });
171
+
172
+ // --- tight per-route rate limiters ---------------------------------
173
+ //
174
+ // One fixed-window limiter keyed on (client IP + path) so each
175
+ // abusable endpoint carries its own per-client budget. Fixed-window
176
+ // (rather than token-bucket) gives a flat, predictable "N per minute"
177
+ // ceiling that's easy to reason about for an auth surface. ~10/min is
178
+ // an order of magnitude above human use of any of these endpoints and
179
+ // shuts a spray down hard.
180
+ var tightLimiter = b.middleware.rateLimit({
181
+ backend: "memory",
182
+ algorithm: "fixed-window",
183
+ max: 10,
184
+ windowMs: C.TIME.minutes(1),
185
+ keyFn: function (req) {
186
+ return clientKey(req) + "|" + (req.pathname || req.url || "/");
187
+ },
188
+ });
189
+ r.use(function tightRateGuard(req, res, next) {
190
+ var pathname = req.pathname || req.url || "/";
191
+ // Never throttle the webhook or health paths.
192
+ if (_hasPrefix(pathname, WEBHOOK_PATHS) || pathname === HEALTH_PATH) return next();
193
+ if (!_hasPrefix(pathname, TIGHT_PREFIXES)) return next();
194
+ // `/products/` and `/gift-cards` carry GET reads (PDP, balance page
195
+ // render) that a shopper hits freely — only gate the state-changing
196
+ // / lookup POSTs there. Login / register / passkey / checkout /
197
+ // newsletter / survey are gated on every method (their GETs are the
198
+ // form render, which a spray would hammer just the same to harvest a
199
+ // fresh token, so the budget covers both).
200
+ var method = (req.method || "GET").toUpperCase();
201
+ var gateAllMethods = pathname.indexOf("/products/") !== 0 &&
202
+ pathname.indexOf("/gift-cards") !== 0;
203
+ if (!gateAllMethods && method !== "POST") return next();
204
+ return tightLimiter(req, res, next);
205
+ });
206
+
207
+ return { fetchMetadata: fmGate, tightLimiter: tightLimiter };
208
+ }
209
+
210
+ module.exports = {
211
+ clientKey: clientKey,
212
+ globalRateLimitOpts: globalRateLimitOpts,
213
+ mountRouteGuards: mountRouteGuards,
214
+ WEBHOOK_PATHS: WEBHOOK_PATHS,
215
+ HEALTH_PATH: HEALTH_PATH,
216
+ TIGHT_PREFIXES: TIGHT_PREFIXES,
217
+ };
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
 
@@ -8067,8 +8102,11 @@ function mount(router, deps) {
8067
8102
  return res.end ? res.end(JSON.stringify(startOpts)) : res.send(JSON.stringify(startOpts));
8068
8103
  } catch (e) {
8069
8104
  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");
8105
+ if (e instanceof TypeError) {
8106
+ res.status(400);
8107
+ return res.end ? res.end((e && e.message) || "register-begin failed") : res.send((e && e.message) || "register-begin failed");
8108
+ }
8109
+ return _authServerError(req, res, e, "register-begin");
8072
8110
  }
8073
8111
  });
8074
8112
 
@@ -8125,8 +8163,11 @@ function mount(router, deps) {
8125
8163
  return res.end ? res.end("ok") : res.send("ok");
8126
8164
  } catch (e) {
8127
8165
  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");
8166
+ if (e instanceof TypeError) {
8167
+ res.status(400);
8168
+ return res.end ? res.end((e && e.message) || "register-finish failed") : res.send((e && e.message) || "register-finish failed");
8169
+ }
8170
+ return _authServerError(req, res, e, "register-finish");
8130
8171
  }
8131
8172
  });
8132
8173
 
@@ -8166,8 +8207,11 @@ function mount(router, deps) {
8166
8207
  return res.end ? res.end(JSON.stringify(startOpts)) : res.send(JSON.stringify(startOpts));
8167
8208
  } catch (e) {
8168
8209
  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");
8210
+ if (e instanceof TypeError) {
8211
+ res.status(400);
8212
+ return res.end ? res.end((e && e.message) || "login-begin failed") : res.send((e && e.message) || "login-begin failed");
8213
+ }
8214
+ return _authServerError(req, res, e, "login-begin");
8171
8215
  }
8172
8216
  });
8173
8217
 
@@ -8230,8 +8274,11 @@ function mount(router, deps) {
8230
8274
  return res.end ? res.end("ok") : res.send("ok");
8231
8275
  } catch (e) {
8232
8276
  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");
8277
+ if (e instanceof TypeError) {
8278
+ res.status(400);
8279
+ return res.end ? res.end((e && e.message) || "login-finish failed") : res.send((e && e.message) || "login-finish failed");
8280
+ }
8281
+ return _authServerError(req, res, e, "login-finish");
8235
8282
  }
8236
8283
  });
8237
8284
 
@@ -8507,8 +8554,11 @@ function mount(router, deps) {
8507
8554
  return res.end ? res.end(JSON.stringify(startOpts)) : res.send(JSON.stringify(startOpts));
8508
8555
  } catch (e) {
8509
8556
  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");
8557
+ if (e instanceof TypeError) {
8558
+ res.status(400);
8559
+ return res.end ? res.end((e && e.message) || "add-begin failed") : res.send((e && e.message) || "add-begin failed");
8560
+ }
8561
+ return _authServerError(req, res, e, "add-begin");
8512
8562
  }
8513
8563
  });
8514
8564
 
@@ -8560,8 +8610,11 @@ function mount(router, deps) {
8560
8610
  return res.end ? res.end("ok") : res.send("ok");
8561
8611
  } catch (e) {
8562
8612
  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");
8613
+ if (e instanceof TypeError) {
8614
+ res.status(400);
8615
+ return res.end ? res.end((e && e.message) || "add-finish failed") : res.send((e && e.message) || "add-finish failed");
8616
+ }
8617
+ return _authServerError(req, res, e, "add-finish");
8565
8618
  }
8566
8619
  });
8567
8620
 
@@ -10161,9 +10214,11 @@ function mount(router, deps) {
10161
10214
 
10162
10215
  // POST /cart/lines — add a line. Reads variant_id + qty from the
10163
10216
  // form body (b.middleware.bodyParser parses it into req.body).
10164
- // CSRF token validation is the responsibility of the csrfProtect
10165
- // middleware mounted at the app level (server.js). Redirects to
10166
- // /cart on success so a refresh doesn't re-submit the form.
10217
+ // Cross-site forgery of this state-changing POST is refused by the
10218
+ // app-level fetch-metadata gate (Sec-Fetch-Site) plus the SameSite
10219
+ // session cookie; both are wired in server.js via
10220
+ // lib/security-middleware. Redirects to /cart on success so a
10221
+ // refresh doesn't re-submit the form.
10167
10222
  router.post("/cart/lines", async function (req, res) {
10168
10223
  var body = req.body || {};
10169
10224
  var variantId = body.variant_id;
@@ -10344,8 +10399,19 @@ function mount(router, deps) {
10344
10399
  res.status(400);
10345
10400
  return res.end ? res.end("Invalid request") : res.send("Invalid request");
10346
10401
  }
10402
+ // Resolve the requester's own session cart and scope the mutation
10403
+ // to it (same pattern as POST /cart/lines/:line_id/save). The line
10404
+ // id is rendered in every visitor's own cart HTML, so without this
10405
+ // a caller who learns another visitor's line id could change its
10406
+ // qty. A line that isn't in the caller's cart returns null → 404.
10407
+ var sid = _readSidCookie(req);
10408
+ var cart = sid ? await deps.cart.bySession(sid) : null;
10409
+ if (!cart) {
10410
+ res.status(404);
10411
+ return res.end ? res.end("Line not found") : res.send("Line not found");
10412
+ }
10347
10413
  try {
10348
- var updated = await deps.cart.updateLine(lineId, { qty: qty });
10414
+ var updated = await deps.cart.updateLine(lineId, cart.id, { qty: qty });
10349
10415
  if (!updated) {
10350
10416
  res.status(404);
10351
10417
  return res.end ? res.end("Line not found") : res.send("Line not found");
@@ -10366,8 +10432,19 @@ function mount(router, deps) {
10366
10432
  res.status(400);
10367
10433
  return res.end ? res.end("Invalid request") : res.send("Invalid request");
10368
10434
  }
10435
+ // Scope the delete to the requester's own session cart (same as the
10436
+ // update route) so a known-but-foreign line id can't delete another
10437
+ // visitor's line. No session cart → nothing of the caller's to
10438
+ // remove; redirect to /cart as the no-op success path.
10439
+ var sid = _readSidCookie(req);
10440
+ var cart = sid ? await deps.cart.bySession(sid) : null;
10441
+ if (!cart) {
10442
+ res.status(303);
10443
+ res.setHeader && res.setHeader("location", "/cart");
10444
+ return res.end ? res.end() : res.send("");
10445
+ }
10369
10446
  try {
10370
- await deps.cart.removeLine(lineId);
10447
+ await deps.cart.removeLine(lineId, cart.id);
10371
10448
  } catch (e) {
10372
10449
  res.status(e instanceof TypeError ? 400 : 500);
10373
10450
  return res.end ? res.end((e && e.message) || "Error") : res.send((e && e.message) || "Error");
@@ -10421,8 +10498,10 @@ function mount(router, deps) {
10421
10498
  //
10422
10499
  // The decision is written to a sealed first-party cookie (the gate) AND
10423
10500
  // recorded in the cookie-consent ledger (the audit trail) when the
10424
- // primitive is wired. CSRF / origin / fetch-metadata defenses are the
10425
- // framework middleware already on every POST — no per-route re-check.
10501
+ // primitive is wired. Cross-site forgery of this POST is refused by the
10502
+ // app-level fetch-metadata gate (Sec-Fetch-Site) plus the SameSite
10503
+ // session cookie (both wired in server.js via lib/security-middleware)
10504
+ // — no per-route re-check.
10426
10505
 
10427
10506
  // Same-origin path guard for `return_to`. Accepts a single leading slash
10428
10507
  // followed by a non-slash (so `//evil.example` and absolute URLs are
@@ -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.43",
7
+ "tag": "v0.13.43",
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,8 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.13.x
10
10
 
11
+ - 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.
12
+
11
13
  - 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
14
 
13
15
  - 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.
@@ -39,9 +39,8 @@ then, the maintainer is final on technical direction.
39
39
  - **Operator-impacting changes.** Pre-1.0 the framework reserves the
40
40
  right to break operator-facing surface in any minor version; major
41
41
  versions ship deprecation warnings at least one minor before
42
- removal (per project rule §6 in `CLAUDE.md`). Post-1.0 the same
43
- contract applies across majors with a 24-month LTS window per
44
- [LTS-CALENDAR.md](LTS-CALENDAR.md).
42
+ removal. Post-1.0 the same contract applies across majors with a
43
+ 24-month LTS window per [LTS-CALENDAR.md](LTS-CALENDAR.md).
45
44
  - **Releases.** Patch (`0.0.x`) is the default; minor (`0.x.0`)
46
45
  requires an explicit decision the maintainer documents in the
47
46
  release notes; major (`x.0.0`) requires a deprecation cycle. The
@@ -1,13 +1,13 @@
1
1
  # LTS calendar
2
2
 
3
3
  `@blamejs/core` ships on a published major cadence. Each major receives
4
- **18 months of security-only patches** starting the day the next major is
4
+ **24 months of security-only patches** starting the day the next major is
5
5
  published. Feature backports are not promised.
6
6
 
7
7
  | Version | First release | Security patches through | Node minimum | KEM | Cipher | KDF | Sigs |
8
8
  |---------------|---------------|-----------------------------|---------------|----------------------|-----------------------|----------|-----------------------|
9
9
  | `v0.x` (pre-1.0) | 2026-04-25 | until v1.0 ships | 24 | ML-KEM-1024 + P-384 | XChaCha20-Poly1305 | SHAKE256 | SLH-DSA-SHAKE-256f |
10
- | `v1.x` | TBD | first release + 18 months | current LTS | ML-KEM-1024 + P-384 | XChaCha20-Poly1305 | SHAKE256 | SLH-DSA-SHAKE-256f |
10
+ | `v1.x` | TBD | first release + 24 months | current LTS | ML-KEM-1024 + P-384 | XChaCha20-Poly1305 | SHAKE256 | SLH-DSA-SHAKE-256f |
11
11
 
12
12
  ## What "security patches" means
13
13
 
@@ -27,3 +27,7 @@ The "Node minimum" column is the lowest Node major the framework supports for th
27
27
  ## Pre-1.0 caveat
28
28
 
29
29
  `v0.x` has no LTS commitment. Every release may change something operators depend on; the algorithm posture is intentionally evolving. Read [CHANGELOG.md](CHANGELOG.md) before upgrading across more than a few patches at a time. The LTS calendar takes effect at v1.0.
30
+
31
+ ## Experimental primitives are exempt
32
+
33
+ Primitives documented `@status experimental` (shown as "experimental" on each wiki page, and via the `experimental` segment in namespaces such as `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 the deprecation cycle that stable primitives get. This applies on the LTS line too. The exemption exists so the framework can ship primitives that track in-flight standards (draft RFCs, pre-IANA codepoints, newly published W3C surfaces) without freezing an unsettled format for a major's full support window. A primitive graduates to stable by dropping the `@status experimental` marker in a release whose notes call out the graduation.
@@ -178,7 +178,7 @@ We coordinate with the reporter on disclosure — typical embargo is 14 days pos
178
178
 
179
179
  Pre-1.0, the supported version is the most-recent published patch on the most-recent minor. Older minors do not receive security backports unless the issue is critical AND the operator base on the older minor is non-trivial.
180
180
 
181
- Once 1.0 ships, the LTS calendar takes effect: each major gets 18 months of security-only patches after the next major's release.
181
+ Once 1.0 ships, the LTS calendar takes effect: each major gets 24 months of security-only patches after the next major's release.
182
182
 
183
183
  | Version range | Security patches |
184
184
  |---|---|
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 1,
3
- "frameworkVersion": "0.13.42",
4
- "createdAt": "2026-05-29T18:46:45.251Z",
3
+ "frameworkVersion": "0.13.43",
4
+ "createdAt": "2026-05-29T22:10:35.036Z",
5
5
  "exports": {
6
6
  "a2a": {
7
7
  "type": "object",
@@ -30,9 +30,10 @@
30
30
  * IPTC `digitalSourceType` PhotoMetadata reading is forward-watch —
31
31
  * the framework ships no XMP / EXIF parser yet, so operators that
32
32
  * want IPTC detection pre-parse with their tool of choice and pass
33
- * the field via `opts.ipmd`. AB-853 names C2PA as "widely adopted";
34
- * IPTC PhotoMetadata reader lands in v0.10.9 once a vendoring
35
- * decision is made.
33
+ * the field via `opts.ipmd`. AB-853 names C2PA as "widely adopted".
34
+ * A built-in IPTC PhotoMetadata reader is deferred pending a vendoring
35
+ * decision for an XMP/EXIF parser; the `opts.ipmd` escape hatch covers
36
+ * the gap until then.
36
37
  *
37
38
  * @card
38
39
  * Inbound provenance detector — composes C2PA verify + CAC implicit-label parser + operator-supplied IPTC field, returns a normalized report for AB-853 / EU AI Act Art. 50 / CAC disclosure UIs.
@@ -214,10 +214,11 @@ async function _readCentralDirectory(adapter, eocd) {
214
214
  "multi-disk archives are not supported (diskNumber=" + eocd.diskNumber + ")");
215
215
  }
216
216
  if (eocd.totalEntries === 0xffff || eocd.cdSize === 0xffffffff || eocd.cdOffset === 0xffffffff) {
217
- // ZIP64 sentinel — not supported in v0.12.7. Will land in a
218
- // follow-up patch when an operator surfaces a need.
217
+ // ZIP64 sentinel — unsupported. Archives at >4 GiB / >65535 entries
218
+ // use tar instead (the escape hatch); ZIP64 read support is deferred
219
+ // until an operator surfaces a need.
219
220
  throw new ArchiveReadError("archive-read/zip64-unsupported",
220
- "ZIP64 archives are not supported in v0.12.7 (operators at >4 GiB / >65535 entries should switch to tar — lands v0.12.8)");
221
+ "ZIP64 archives are unsupported (operators at >4 GiB / >65535 entries should switch to tar)");
221
222
  }
222
223
  if (eocd.cdSize === 0 || eocd.totalEntries === 0) {
223
224
  return [];
@@ -256,7 +257,7 @@ async function _readCentralDirectory(adapter, eocd) {
256
257
  }
257
258
  if (compressedSize === 0xffffffff || uncompressedSize === 0xffffffff || lfhOffset === 0xffffffff) {
258
259
  throw new ArchiveReadError("archive-read/zip64-unsupported",
259
- "central directory entry " + n + " carries ZIP64 sentinel sizes (not supported in v0.12.7)");
260
+ "central directory entry " + n + " carries ZIP64 sentinel sizes (unsupported use tar for >4 GiB / >65535 entries)");
260
261
  }
261
262
  // ZIP names are CP437 or UTF-8 (per FLAG_UTF8_NAME bit). Decode
262
263
  // as UTF-8 unconditionally — Codex P2 territory if operators in
@@ -425,7 +426,7 @@ async function _decompressEntry(adapter, entry, dataStart, bombPolicy) {
425
426
  }
426
427
  throw new ArchiveReadError("archive-read/unsupported-method",
427
428
  "entry " + JSON.stringify(entry.name) + " uses method=" + entry.method +
428
- " — only STORE (0) and DEFLATE (8) supported in v0.12.7");
429
+ " — only STORE (0) and DEFLATE (8) are supported");
429
430
  }
430
431
 
431
432
  // ---- Public read.zip factory ---------------------------------------------
@@ -668,8 +669,10 @@ function zip(adapter, opts) {
668
669
  // entry-type policy opts in.
669
670
  if (entry.isEncrypted && !extractOpts.allowEncrypted) {
670
671
  throw new ArchiveReadError("archive-read/encrypted-entry",
671
- "entry " + JSON.stringify(entry.name) + " is encrypted — " +
672
- "v0.12.7 does not decrypt; Flavor 1/2/3 land v0.12.10/v0.12.11");
672
+ "entry " + JSON.stringify(entry.name) + " is encrypted — this " +
673
+ "low-level reader does not decrypt; use b.safeArchive for " +
674
+ "encrypted-archive handling, or pass allowEncrypted to extract " +
675
+ "the raw entry");
673
676
  }
674
677
  var typeRefusal = _enforceEntryTypePolicy(entry, entryTypePolicy);
675
678
  if (typeRefusal) {
@@ -523,7 +523,7 @@ function _validatePolicySet(table, opts) {
523
523
  "' not in allowed factors [" + ALLOWED_FACTORS.join(",") + "]");
524
524
  }
525
525
  }
526
- // Model B (cryptographic mode) ships in v0.5.1. When enabled,
526
+ // Model B (cryptographic mode). When enabled,
527
527
  // glass-locked columns must be encrypted with `b.breakGlass.encryptCell`
528
528
  // at write time (the framework can't auto-encrypt at write because
529
529
  // policy-set may post-date existing data; operators run the migration
@@ -42,15 +42,16 @@
42
42
  * refuses EnvelopedData and accepts only the §5083 ContentInfo
43
43
  * OID. Cheap escape hatch: operators on such a peer compose
44
44
  * `b.asn1Der` directly to rewrap an EnvelopedData blob into an
45
- * AuthEnvelopedData ContentInfo. Lights up in v0.10.14 alongside
46
- * `b.mail.smime` sign + verify, where the on-the-wire S/MIME 4.0
47
- * content shape calls for it.
45
+ * AuthEnvelopedData ContentInfo. A built-in encode path is deferred
46
+ * until an interop case requires a peer that refuses EnvelopedData;
47
+ * the `b.asn1Der` rewrap covers the gap until then.
48
48
  * - **`b.cms.decode` parse-tree of inner SignedData / EnvelopedData**
49
- * beyond the ContentInfo wrapper. v0.10.13 returns the inner
49
+ * beyond the ContentInfo wrapper. `b.cms.decode` returns the inner
50
50
  * SEQUENCE bytes as `content` (an asn1-der node); callers that
51
- * need fielded access walk it via `b.asn1Der.readSequence`. The
52
- * fielded decoders ship alongside S/MIME verify in v0.10.14 where
53
- * they're actually consumed.
51
+ * need fielded access walk it via `b.asn1Der.readSequence`. Built-in
52
+ * fielded decoders are deferred until they're actually consumed by a
53
+ * shipping primitive; the `b.asn1Der.readSequence` walk is the escape
54
+ * hatch until then.
54
55
  *
55
56
  * Refusal posture:
56
57
  *
@@ -302,8 +303,8 @@ function encodeEnvelopedData(opts) {
302
303
  * string (e.g. `"1.2.840.113549.1.7.2"` for SignedData) and
303
304
  * `content` is the inner asn1-der node (SignedData / EnvelopedData /
304
305
  * other) — operators walk it via `b.asn1Der.readSequence`. Fielded
305
- * decoders for SignedData / EnvelopedData ship in v0.10.14 alongside
306
- * S/MIME sign+verify.
306
+ * decoders for SignedData / EnvelopedData are deferred; the
307
+ * `b.asn1Der.readSequence` walk is the escape hatch until then.
307
308
  *
308
309
  * Refuses input past `opts.maxBytes` (default 64 MiB), top-level
309
310
  * non-SEQUENCE shapes, missing OID + [0] EXPLICIT child pair.
@@ -9,8 +9,10 @@
9
9
  * @card
10
10
  * S/MIME 4.0 sign + verify (PQC-first ML-DSA / SLH-DSA signers) on
11
11
  * the b.cms substrate. RFC 8551 multipart/signed with RFC 5652
12
- * SignedData; EFAIL-class encrypt/decrypt deferred until the AAD-
13
- * binding posture lands.
12
+ * SignedData. Confidentiality (encrypt/decrypt) is deferred use
13
+ * `b.mail.crypto.pgp.encrypt`/`decrypt` today; S/MIME-specific
14
+ * (X.509-recipient) encryption re-opens when an operator surfaces a
15
+ * peer that requires it, with the EFAIL defenses below applied then.
14
16
  *
15
17
  * @intro
16
18
  * S/MIME 4.0 (RFC 8551, replacing RFC 5751) `multipart/signed;
@@ -112,10 +114,12 @@ var ALLOWED_HASHES = ["sha256", "sha384", "sha512"];
112
114
  var REFUSED_HASHES = ["md5", "sha1"]; // allow:raw-byte-literal — SHAttered / RFC 8551 §2.5
113
115
 
114
116
  // PROFILES + COMPLIANCE_POSTURES — the framework's standard cross-
115
- // primitive contract. sign() and verify() (live since v0.10.16) read
116
- // these to determine which hash + RSA-bit floors apply per operator
117
- // posture; encrypt() / decrypt() (deferred per the @intro EFAIL note)
118
- // will compose the same set when they land.
117
+ // primitive contract. sign() and verify() read these to determine which
118
+ // hash + RSA-bit floors apply per operator posture. Confidentiality
119
+ // (encrypt/decrypt) is deferred b.mail.crypto.pgp.encrypt/decrypt is the
120
+ // confidentiality path today; if S/MIME-specific X.509-recipient
121
+ // encryption is added, it composes the same set with the @intro EFAIL
122
+ // defenses applied.
119
123
  var PROFILES = ["strict", "balanced", "permissive"];
120
124
  var COMPLIANCE_POSTURES = {
121
125
  hipaa: "strict",
@@ -517,8 +517,8 @@ function create(opts) {
517
517
  opts3 = opts3 || {};
518
518
  if (typeof engine.generateCrl !== "function") {
519
519
  throw new MtlsCaError("mtls-ca/engine-no-crl",
520
- "configured engine does not implement generateCrl(); the bundled " +
521
- "engine ships in v0.6.45+");
520
+ "configured engine does not implement generateCrl(); use the " +
521
+ "framework's bundled CA engine, which supports it");
522
522
  }
523
523
  var ca = await initCA();
524
524
  var revocations = _loadRevocations().revocations;
@@ -22,10 +22,11 @@
22
22
  * pipeline manually.
23
23
  *
24
24
  * Format auto-detection sniffs the first ~512 bytes for magic
25
- * signatures. v0.12.7 ships ZIP detection (LFH magic `0x04034b50`
26
- * + EOCD magic `0x06054b50`); tar / gz / ae2 / `b.crypto.encryptPacked`-
27
- * wrapped formats are flagged as `safe-archive/format-unsupported`
28
- * in this patch — tar lands v0.12.8, gz v0.12.9, encryption v0.12.10/11.
25
+ * signatures: ZIP (LFH magic `0x04034b50` + EOCD magic `0x06054b50`),
26
+ * tar (`ustar` at offset 257), gzip / tar.gz (RFC 1952 magic), and
27
+ * `b.crypto.encryptPacked`-wrapped envelopes (auto-unwrapped before
28
+ * format detection). Unrecognized inputs are flagged
29
+ * `safe-archive/format-unsupported`.
29
30
  *
30
31
  * The orchestrator refuses the WHOLE archive on any single critical
31
32
  * guard issue — no partial extraction. Cleanup is `fs.rm`-recursive
@@ -148,7 +149,7 @@ async function _collectSourceBytes(source) {
148
149
  * @opts
149
150
  * source: b.archive.adapters.* | Buffer | string,
150
151
  * destination: string (target directory; created if missing),
151
- * format: "auto" | "zip" (v0.12.7 tar v0.12.8, gz v0.12.9),
152
+ * format: "auto" | "zip" | "tar" | "tar.gz",
152
153
  * bombPolicy: b.guardArchive.zipBombPolicy(...) | { ... },
153
154
  * entryTypePolicy: b.guardArchive.entryTypePolicy(...) | { ... },
154
155
  * guardProfile: "strict" | "balanced" | "permissive" | "hipaa" | ...,
@@ -275,8 +276,8 @@ async function extract(opts) {
275
276
  });
276
277
  } else {
277
278
  throw new SafeArchiveError("safe-archive/format-unsupported",
278
- "extract: format=" + JSON.stringify(format) + " — v0.12.9 ships ZIP + tar + tar.gz; " +
279
- "v0.12.10/v0.12.11 added wrap-recipient + wrap-passphrase envelopes (auto-unwrap as of v0.12.15)");
279
+ "extract: format=" + JSON.stringify(format) + " — supported formats are " +
280
+ "zip, tar, tar.gz; b.crypto.encryptPacked-wrapped archives are auto-unwrapped first");
280
281
  }
281
282
  var result = await reader.extract({
282
283
  destination: opts.destination,
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.13.42",
3
+ "version": "0.13.43",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
@@ -0,0 +1,39 @@
1
+ {
2
+ "$schema": "../scripts/release-notes-schema.json",
3
+ "version": "0.13.43",
4
+ "date": "2026-05-29",
5
+ "headline": "LTS window stated consistently as 24 months, experimental primitives declared semver-exempt, and stale version references cleaned up",
6
+ "summary": "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.",
7
+ "sections": [
8
+ {
9
+ "heading": "Changed",
10
+ "items": [
11
+ {
12
+ "title": "LTS support window is consistently 24 months",
13
+ "body": "`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."
14
+ },
15
+ {
16
+ "title": "Experimental primitives are declared exempt from the stability contract",
17
+ "body": "`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."
18
+ }
19
+ ]
20
+ },
21
+ {
22
+ "heading": "Fixed",
23
+ "items": [
24
+ {
25
+ "title": "Stale version references removed from operator-facing errors and docs",
26
+ "body": "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."
27
+ },
28
+ {
29
+ "title": "S/MIME confidentiality deferral points to the working PGP path",
30
+ "body": "`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."
31
+ },
32
+ {
33
+ "title": "Governance doc no longer references an internal file operators cannot see",
34
+ "body": "`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."
35
+ }
36
+ ]
37
+ }
38
+ ]
39
+ }
@@ -254,7 +254,10 @@ async function run() {
254
254
  var c8 = b.wsClient.connect("ws://127.0.0.1:" + port8 + "/foo", {
255
255
  reconnect: false, audit: false, allowInternal: true,
256
256
  });
257
- await _sleep(150);
257
+ // Poll for handshake completion rather than a fixed-budget sleep — the
258
+ // 150ms budget flakes under SMOKE_PARALLEL=64 contention (rule §11b).
259
+ await helpers.waitUntil(function () { return c8.readyState === "open"; },
260
+ { timeoutMs: 5000, label: "ws-client: c8 handshake completes (readyState open)" });
258
261
  check("getter: url", c8.url.indexOf("/foo") !== -1);
259
262
  check("getter: readyState open after handshake", c8.readyState === "open");
260
263
  c8.close();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.2.22",
3
+ "version": "0.2.24",
4
4
  "description": "Open-source framework built on blamejs. Vendored stack, zero npm runtime deps, PQC-first crypto, security-on by default.",
5
5
  "main": "lib/index.js",
6
6
  "scripts": {