@blamejs/blamejs-shop 0.3.60 → 0.3.63

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.3.x
10
10
 
11
+ - v0.3.63 (2026-06-04) — **Digital-only carts can check out, and the outbound TLS posture is documented end to end.** A cart containing only digital goods could never complete checkout: the shipping resolver correctly filters physical services out for a cart that ships nothing, but checkout then demanded the configured default physical service resolve anyway and refused the order. Digital-only carts now select the operator's digital-shipping service when one is configured, and otherwise complete with a synthesized zero-cost no-shipping selection; a physical cart with a misconfigured service id still fails loudly, since that is a real configuration error. Separately, every outbound TLS connection has been audited against the framework's post-quantum-first key-exchange posture: of all fixed external hosts, only the Stripe API edge refuses the post-quantum groups (already handled by the payment module's explicit agent) — the CAPTCHA providers, PayPal, and Cloudflare all negotiate hybrids cleanly. Operator-supplied destinations — webhook receiver URLs and admin media source URLs — deliberately keep the post-quantum-first default, with the failure mode documented where operators will look for it: a receiver that cannot negotiate a hybrid group sees deliveries fail with a TLS handshake failure, retry on the backoff schedule, and land in the dead-letter queue; a media source URL that cannot fails as the existing clean fetch error. A regression test pins that these dials never silently downgrade. **Changed:** *Outbound TLS posture audited and documented* — Every outbound connection was audited against the framework's post-quantum-first TLS key-exchange list. Fixed external hosts: the Stripe API edge is the only one that refuses the post-quantum groups, and the payment module's explicitly scoped agent already covers it (PayPal currently negotiates hybrids; the agent remains as defense). Operator-supplied destinations keep the post-quantum-first default deliberately: an outbound-webhook receiver that cannot negotiate a hybrid group sees deliveries fail with a TLS handshake failure (alert 40), retry on the backoff schedule, and reach the dead-letter queue after the fifth attempt — front such a receiver with a post-quantum-capable proxy or upgrade its TLS stack; an admin media source URL that cannot negotiate fails as the existing clean fetch error, and the file can be uploaded directly instead. Module documentation now states this where the integrations are configured, and a regression test pins that these dials never silently downgrade. **Fixed:** *Digital-only carts complete checkout* — Shipping rate resolution offers only digital-shipping services to a cart in which no line requires shipping — so the configured default physical service id could never resolve, and checkout refused every all-digital order. Checkout now falls back for all-digital carts: the operator's digital-shipping service is selected when configured, and a zero-cost "No shipping required" selection is synthesized otherwise, so the order completes and records zero shipping. Physical carts are unchanged: an unresolvable service id still fails at the boundary, because that is a real configuration typo.
12
+
13
+ - v0.3.62 (2026-06-04) — **Live card payments work, and back-in-stock unsubscribe is token-gated.** Checkout failed at the charge step on Stripe-configured deploys: the framework's outbound HTTP client offers only post-quantum hybrid TLS key-exchange groups, and the Stripe and PayPal API edges do not negotiate any of them yet, so every processor call died with a TLS handshake failure (alert 40) and checkout rendered a server error. The payment module now dials the processors through an explicitly constructed agent — TLS 1.3 minimum, the platform's default key-exchange groups — which is the framework's prescribed pattern for a peer that cannot meet the post-quantum list: the downgrade is visible in the code, scoped to the processor connections only, and every other outbound connection keeps the post-quantum-first default. Verified against the live Stripe API. The downgrade reverts when the processor edges add hybrid key-exchange support. Separately, the checkout error page no longer echoes upstream failure detail (such as TLS library internals) to the customer — that detail goes to the audit log, and the page states plainly that nothing was charged. This release also token-gates back-in-stock unsubscribe: cancelling a back-in-stock alert previously took only the subscriber's email address and the product SKU — guessable values, so a third party could silently cancel someone else's restock notification. Unsubscribe is now authorized by an opaque per-subscription token, carried in the confirmation and notification emails as a one-click link, exactly like the newsletter's unsubscribe. Tokens are stored only as keyed hashes, compared in constant time, and unknown tokens get the same response as valid ones, so the endpoint is not a subscription-existence oracle. A schema migration adds the token column; it applies automatically on deploy. **Changed:** *Back-in-stock unsubscribe requires the emailed token* — POST /stock-alert/unsubscribe no longer accepts an email + SKU pair; it requires the per-subscription token from the unsubscribe link in the confirmation or back-in-stock email. The token is minted at subscribe time for the confirmation mail and rotated per notification send, stored only as a keyed hash, and verified with a constant-time compare; bad or unknown tokens return the same uniform response as valid ones. The one-click link works without cookies or a signed-in session, so mail clients can fire it directly; each email carries the token that is current for its send. Operators upgrading: migration 0207 adds the token column and applies with the normal deploy. **Fixed:** *Stripe and PayPal connections complete their TLS handshake* — The outbound HTTP client's default TLS posture offers exclusively post-quantum hybrid key-exchange groups. Processor API edges currently answer that ClientHello with handshake_failure (TLS alert 40), so the first real charge attempt on a Stripe-configured deploy failed checkout with a server error. The payment module now passes its own connection agent on every processor call — TLS 1.3 minimum is kept, key exchange falls back to the platform defaults — while the client's SSRF pinning, retries, and response caps stay in force. The agent is scoped to the payment-processor dials; all other outbound traffic keeps the post-quantum-first posture. A regression test pins the agent's presence and configuration on the charge path. · *Checkout server errors no longer echo upstream detail to the customer* — When the charge step failed, the checkout error page interpolated the underlying error string — in this case raw TLS library internals — into the customer-facing message. Server-side failure detail now goes to the audit log; the customer sees a plain recoverable message that confirms nothing was charged. Field-validation messages, which are written for customers, are unaffected.
14
+
11
15
  - v0.3.60 (2026-06-04) — **Exact integer math for percentage discounts and insurance premiums, and an honest Add-a-card state under partial Stripe configuration.** Every place that computes a percentage of a money amount — automatic percent-off discounts, bundle discounts and their per-line allocation, partial-refund policy amounts, and shipping-insurance premiums — now uses exact integer arithmetic instead of a floating-point intermediate. On very large amounts the float product loses precision past 2^53 and can drift the rounded result by a minor unit; charged totals were never affected (the charge path was already integer end-to-end), but displayed and clamped values now match exact math everywhere, with each site's rounding direction preserved. Separately, the account Payment-methods screen no longer links into a 503 when the Stripe publishable key is missing: the Add-a-card affordance now renders as a disabled control with an explanatory note until the configuration is complete, matching how the unconfigured checkout behaves. **Fixed:** *Percentage-of-amount math is exact integer arithmetic* — Automatic percent-off discounts, bundle discounts, the cart's per-line bundle allocation, partial-refund policy amounts, and shipping-insurance premiums all computed `amount × basis_points / 10000` through a floating-point multiply. Amounts and rates are both integers, so the computation is now exact integer arithmetic with each site's rounding direction unchanged — half-away-from-zero for discounts, floor for allocations and refunds, ceiling for insurance premiums (the insurer is never short). The drift was only reachable on very large amounts (products beyond 2^53) and never touched a charged total, which was already integer end-to-end; tests now pin exact results on amounts past the float-precision boundary. · *Add a card renders honestly when Stripe configuration is incomplete* — Saving a card needs the Stripe publishable key in addition to the server-side API keys. With the server keys set but the publishable key missing, the Payment-methods screen rendered a live "Add a card" link that dead-ended on a 503. The link now gates on the same condition the add route enforces: when the publishable key is absent it renders as a disabled control with a note explaining the missing configuration, while listing, choosing a default, and removing saved cards keep working. The route's own 503 remains as defense in depth for a directly typed URL.
12
16
 
13
17
  - v0.3.59 (2026-06-04) — **Back-in-stock subscribe rate-limited, payment-method metadata in privacy exports, and accessible wishlist alert toggles.** The anonymous back-in-stock "notify me" endpoint sends a confirmation email to the address the request supplies; it now sits in the same tight per-IP rate budget as login, checkout, and the newsletter, closing a victim-addressed email-flooding vector. Privacy (GDPR/CCPA) data exports now include saved-payment-method display metadata — card brand, last four, expiry — which is personal data the export was silently omitting. The wishlist alert and digest toggle buttons gain accessible names that include the alert they control, so a screen reader no longer announces a list of identical "Turn on" buttons. Internal housekeeping rides along: module imports hoisted to file tops, a per-test wall-clock-ceiling helper backing an existing test gate, and stale comments corrected. **Fixed:** *Back-in-stock subscribe joins the tight rate limit* — POST /stock-alert/subscribe is anonymous and sends a double-opt-in confirmation email to the request-supplied address. It previously sat only behind the loose global token bucket, so a script could direct a burst of confirmation emails at an arbitrary victim address — an email-flooding and sender-reputation risk. The route now shares the tight per-IP, per-path budget that already covers login, registration, checkout, gift-card balance, and the newsletter; the integration suite pins the throttle. · *Privacy exports include saved-payment-method metadata* — The subject-access (GDPR/CCPA) export composed every reader except saved payment methods, which was wired to an empty handle — an export silently omitted the card brand, last-four digits, and expiry the shop stores alongside the opaque processor token. The reader now receives the live handle, so a customer's export reflects everything held about them. Only display metadata is stored or exported — never card numbers. · *Wishlist alert toggles are screen-reader distinguishable* — The account wishlist-alerts screen rendered visually identical "Turn on" / "Turn off" / "Subscribe" buttons whose accessible names did not say which alert or digest they control. Each toggle now carries an accessible name that includes its alert label (for example "Turn off Price-drop alerts"), satisfying WCAG 2.4.6 for assistive-technology users walking the button list.
package/lib/admin.js CHANGED
@@ -1172,6 +1172,18 @@ function mount(router, deps) {
1172
1172
  // records the media row. Endpoint is omitted entirely when no
1173
1173
  // r2_bridge is wired (operator hasn't set D1_BRIDGE_URL +
1174
1174
  // D1_BRIDGE_SECRET).
1175
+ //
1176
+ // TLS posture — `source_url` is operator-pasted and arbitrary, so the
1177
+ // fetch keeps b.httpClient's PQC-first default (ML-KEM hybrid groups,
1178
+ // TLS 1.3 minimum). A `source_url` host that doesn't negotiate an
1179
+ // ML-KEM hybrid group answers with a handshake_failure (TLS alert 40);
1180
+ // that surfaces below as the same clean 502 `source-fetch-failed`
1181
+ // (`e.message` carries the alert) the operator already retries by
1182
+ // re-submitting. We do NOT pin a classical-downgrade agent here the
1183
+ // way the PSP adapters do for their two fixed endpoints — an arbitrary
1184
+ // operator-supplied URL is not a fixed processor edge, so holding the
1185
+ // PQC default is the deliberate posture. Operators sourcing from a
1186
+ // host that can't meet the PQC list upload the file directly instead.
1175
1187
  if (r2) {
1176
1188
  // Fetch → store → attach, shared by the JSON upload route and the
1177
1189
  // browser POST alias. Throws TypeError on bad input (mapped to 400);
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.3.60",
2
+ "version": "0.3.63",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-6k53cvkRrxMgmeStLIoLjVXZQHqIJgTmv1Izd8TYhh1HOC4POgE6GCvx1bsalyEP",
package/lib/checkout.js CHANGED
@@ -631,8 +631,25 @@ function create(deps) {
631
631
  }
632
632
  }
633
633
  if (!selected) {
634
- throw new TypeError("checkout: selected_shipping_id " + JSON.stringify(input.selected_shipping_id) +
635
- " not available for ship_to");
634
+ // All-digital cart: shipping.rates() deliberately filters the
635
+ // physical services out, so the caller's default (a physical
636
+ // service id like "std") can never resolve — that is the cart's
637
+ // shape, not a config error. Prefer the operator's digital_only
638
+ // offering when one is configured; otherwise synthesize the
639
+ // zero-cost no-shipping selection so a digital-only order
640
+ // completes. A physical cart with an unresolvable id still
641
+ // throws — that IS a config typo and must surface.
642
+ var anyShippable = enrichedLines.some(function (l) {
643
+ return l.requires_shipping === undefined ? true : !!l.requires_shipping;
644
+ });
645
+ if (!anyShippable) {
646
+ selected = ratesRow.services.length
647
+ ? ratesRow.services[0]
648
+ : { id: "digital_none", label: "No shipping required", amount_minor: 0, digital_only: true };
649
+ } else {
650
+ throw new TypeError("checkout: selected_shipping_id " + JSON.stringify(input.selected_shipping_id) +
651
+ " not available for ship_to");
652
+ }
636
653
  }
637
654
  }
638
655
 
package/lib/email.js CHANGED
@@ -303,6 +303,7 @@ var STOCK_ALERT_CONFIRM_HTML =
303
303
  " <p style=\"margin:0 0 16px;\">You asked to be emailed when <strong>{{product_title}}</strong> (SKU {{sku}}) is back in stock. Confirm below and we'll email you once, the moment it returns.</p>\n" +
304
304
  " <p style=\"margin:24px 0;\"><a href=\"{{confirm_url}}\" style=\"background:#fa4f09;color:#ffffff;padding:12px 20px;text-decoration:none;display:inline-block;font-weight:bold;\">Confirm my alert</a></p>\n" +
305
305
  " <p style=\"margin:0;color:#0d0d0d;font-size:13px;\">If you didn't request this, ignore this email — no alert is set until you confirm. Link: {{confirm_url}}</p>\n" +
306
+ " <p style=\"margin:16px 0 0;color:#0d0d0d;font-size:13px;\">Changed your mind? Cancel this alert: {{unsubscribe_url}}</p>\n" +
306
307
  "</div>\n" +
307
308
  "</body></html>\n";
308
309
 
@@ -311,7 +312,8 @@ var STOCK_ALERT_CONFIRM_TEXT =
311
312
  "You asked to be emailed when {{product_title}} (SKU {{sku}}) is back in stock.\n" +
312
313
  "Confirm to set the alert — we'll email you once, the moment it returns.\n\n" +
313
314
  "Confirm: {{confirm_url}}\n\n" +
314
- "If you didn't request this, ignore this email — no alert is set until you confirm.\n";
315
+ "If you didn't request this, ignore this email — no alert is set until you confirm.\n" +
316
+ "Changed your mind? Cancel this alert: {{unsubscribe_url}}\n";
315
317
 
316
318
  var BACK_IN_STOCK_HTML =
317
319
  "<!DOCTYPE html>\n" +
@@ -664,8 +666,9 @@ function create(opts) {
664
666
  );
665
667
  },
666
668
 
667
- // Back-in-stock double-opt-in confirmation. `confirm_url` is built by
668
- // the route from SHOP_ORIGIN + the plaintext token returned once by
669
+ // Back-in-stock double-opt-in confirmation. `confirm_url` +
670
+ // `unsubscribe_url` are built by the route from SHOP_ORIGIN + the
671
+ // plaintext confirm/unsubscribe tokens returned once by
669
672
  // stockAlerts.subscribe. product_title / sku are catalog data; the
670
673
  // strict renderer escapes every field anyway (escape-by-default).
671
674
  sendStockAlertConfirmation: async function (input) {
@@ -677,9 +680,10 @@ function create(opts) {
677
680
  throw new TypeError("email.sendStockAlertConfirmation: confirm_url required");
678
681
  }
679
682
  var vars = {
680
- product_title: input.product_title == null ? input.sku || "this item" : String(input.product_title),
681
- sku: input.sku == null ? "—" : String(input.sku),
682
- confirm_url: input.confirm_url,
683
+ product_title: input.product_title == null ? input.sku || "this item" : String(input.product_title),
684
+ sku: input.sku == null ? "—" : String(input.sku),
685
+ confirm_url: input.confirm_url,
686
+ unsubscribe_url: input.unsubscribe_url == null ? "" : String(input.unsubscribe_url),
683
687
  };
684
688
  var html = _render(STOCK_ALERT_CONFIRM_HTML, vars);
685
689
  var text = _render(STOCK_ALERT_CONFIRM_TEXT, vars);
package/lib/payment.js CHANGED
@@ -23,9 +23,26 @@
23
23
  * token can't smuggle unsigned events into the order pipeline.
24
24
  */
25
25
 
26
+ var nodeHttps = require("node:https"); // allow:non-shop-require — the PSP TLS agent must be a real node https.Agent for the vendored httpClient's caller-agent (h1) path; no b.* primitive constructs one, and the framework's downgrade prescription is an explicitly-built agent in the consumer's diff
27
+
26
28
  var b = require("./vendor/blamejs");
27
29
  var C = b.constants;
28
30
 
31
+ // External payment processors do not negotiate the framework's PQC-hybrid
32
+ // TLS groups yet: the vendored httpClient offers ONLY ML-KEM hybrid groups
33
+ // (constants.TLS_GROUP_PREFERENCE), and api.stripe.com / api-m.paypal.com
34
+ // answer that ClientHello with handshake_failure (TLS alert 40) — which
35
+ // surfaced as a checkout failure the moment a charge was attempted. The
36
+ // framework's own prescription for a peer that can't meet the PQC list is
37
+ // an explicitly-constructed agent so the downgrade is visible in this
38
+ // diff: TLS 1.3 minimum stays pinned, the key-exchange groups fall back to
39
+ // Node's defaults (classical X25519 / P-256 today). The vendored client
40
+ // honors a caller agent per request (h1 path) while keeping its SSRF-
41
+ // pinned DNS lookup, retries, and response caps. Scoped to the PSP dials
42
+ // only — every other outbound keeps the PQC-first default. Revisit when
43
+ // the processor edges negotiate ML-KEM hybrid groups.
44
+ var _PSP_TLS_AGENT = new nodeHttps.Agent({ keepAlive: true, minVersion: "TLSv1.3" });
45
+
29
46
  var STRIPE_API_BASE_DEFAULT = "https://api.stripe.com/v1";
30
47
  var STRIPE_WEBHOOK_TOLERANCE = 300; // ± 5 minutes (Stripe default)
31
48
  var STRIPE_HTTP_TIMEOUT_MS = 15000;
@@ -185,6 +202,7 @@ async function _stripeCall(opts, method, path, params, idempotencyKey) {
185
202
  headers: headers,
186
203
  body: body || undefined,
187
204
  timeoutMs: opts.timeoutMs || STRIPE_HTTP_TIMEOUT_MS,
205
+ agent: _PSP_TLS_AGENT,
188
206
  });
189
207
  var text = res.body && res.body.toString ? res.body.toString("utf8") : "";
190
208
  var json = null;
@@ -641,6 +659,7 @@ async function _paypalToken(opts, state) {
641
659
  },
642
660
  body: "grant_type=client_credentials",
643
661
  timeoutMs: opts.timeoutMs || PAYPAL_HTTP_TIMEOUT_MS,
662
+ agent: _PSP_TLS_AGENT,
644
663
  });
645
664
  var text = res.body && res.body.toString ? res.body.toString("utf8") : "";
646
665
  var json; try { json = text.length ? JSON.parse(text) : {}; } catch (_e) { json = {}; }
@@ -675,6 +694,7 @@ async function _paypalCall(opts, state, method, path, bodyObj, requestId) {
675
694
  headers: headers,
676
695
  body: body,
677
696
  timeoutMs: opts.timeoutMs || PAYPAL_HTTP_TIMEOUT_MS,
697
+ agent: _PSP_TLS_AGENT,
678
698
  });
679
699
  var text = res.body && res.body.toString ? res.body.toString("utf8") : "";
680
700
  var json; try { json = text.length ? JSON.parse(text) : {}; } catch (_e) { json = { _raw: text }; }
@@ -141,6 +141,13 @@ var EDGE_POST_PATHS = [
141
141
  // the `edge-form-csrf-exempt` detector lookahead in
142
142
  // test/layer-0-primitives/codebase-patterns.test.js.
143
143
  "/stock-alert/subscribe",
144
+ // Back-in-stock one-click unsubscribe — same bearer-token model as
145
+ // /unsubscribe above. The opaque, per-row token in the `?token=` link IS
146
+ // the authorization; the POST a mail client fires carries no cookies and no
147
+ // session CSRF token, so the path is exempt from the double-submit check
148
+ // (it keeps SameSite + fetch-metadata defense and is enumeration-safe +
149
+ // timing-safe at the token layer).
150
+ "/stock-alert/unsubscribe",
144
151
  ];
145
152
 
146
153
  // The TLS-terminated public origin(s) the storefront is served on. Behind
@@ -36,9 +36,14 @@
36
36
  * Composes:
37
37
  * - `b.crypto.namespaceHash` — email hash + token hash.
38
38
  * - `b.crypto.generateBytes` — 24 random bytes →
39
- * base64url-encoded to a 32-character plaintext token. The
40
- * plaintext is returned ONCE from `subscribe`; only the hash
41
- * persists.
39
+ * base64url-encoded to a 32-character plaintext token, used for
40
+ * BOTH the double-opt-in confirmation token and the unsubscribe
41
+ * bearer token. Each plaintext is returned ONCE (confirm + unsub
42
+ * tokens from `subscribe`; a fresh unsub token per fired row from
43
+ * `scanAndNotify`); only the hashes persist.
44
+ * - `b.crypto.timingSafeEqual` — constant-time re-comparison of the
45
+ * stored unsubscribe-token hash against the recomputed hash so the
46
+ * unknown-token miss path can't be distinguished by latency.
42
47
  * - `b.guardEmail` — strict-profile email validation +
43
48
  * sanitization before normalisation.
44
49
  * - `b.guardUuid` — optional customer_id / variant_id shape gate.
@@ -49,11 +54,13 @@
49
54
  *
50
55
  * @primitive stockAlerts
51
56
  * @related b.crypto.namespaceHash, b.crypto.generateBytes,
52
- * b.guardEmail, b.guardUuid, notifications
57
+ * b.crypto.timingSafeEqual, b.guardEmail, b.guardUuid,
58
+ * notifications
53
59
  */
54
60
 
55
61
  var EMAIL_NAMESPACE = "stock-alert-email";
56
62
  var TOKEN_NAMESPACE = "stock-alert-token";
63
+ var UNSUB_NAMESPACE = "stock-alert-unsub-token";
57
64
  var TOKEN_BYTES = 24; // 24 raw bytes → 32 base64url chars
58
65
  var TOKEN_LEN = 32;
59
66
  var TOKEN_RE = /^[A-Za-z0-9_-]{32}$/;
@@ -146,6 +153,10 @@ function _hashToken(plaintext) {
146
153
  return b.crypto.namespaceHash(TOKEN_NAMESPACE, plaintext);
147
154
  }
148
155
 
156
+ function _hashUnsubToken(plaintext) {
157
+ return b.crypto.namespaceHash(UNSUB_NAMESPACE, plaintext);
158
+ }
159
+
149
160
  // ---- factory ------------------------------------------------------------
150
161
 
151
162
  function create(opts) {
@@ -180,6 +191,7 @@ function create(opts) {
180
191
  return {
181
192
  EMAIL_NAMESPACE: EMAIL_NAMESPACE,
182
193
  TOKEN_NAMESPACE: TOKEN_NAMESPACE,
194
+ UNSUB_NAMESPACE: UNSUB_NAMESPACE,
183
195
  TOKEN_LEN: TOKEN_LEN,
184
196
  EVENT_TYPE: EVENT_TYPE,
185
197
 
@@ -193,12 +205,14 @@ function create(opts) {
193
205
  // Subscribe to the back-in-stock alert for a SKU (and optionally
194
206
  // a specific variant). Returns:
195
207
  // { id, status: "subscribed" | "already-pending" |
196
- // "already-confirmed", confirmation_token?, email_hash,
197
- // expires_at }
198
- // The plaintext `confirmation_token` is returned ONLY on a
199
- // brand-new subscription. Re-subscriptions surface the existing
200
- // row's status operators that want to re-send the confirmation
201
- // email mint a fresh subscription by calling `unsubscribe` first.
208
+ // "already-confirmed", confirmation_token?, unsubscribe_token?,
209
+ // email_hash, expires_at }
210
+ // The plaintext `confirmation_token` and `unsubscribe_token` are
211
+ // returned ONLY on a brand-new subscription the confirmation email
212
+ // carries both (the confirm link + a one-click unsubscribe link). Only
213
+ // their hashes persist. Re-subscriptions surface the existing row's
214
+ // status — operators that want to re-send the confirmation email mint
215
+ // a fresh subscription by calling `unsubscribeByToken` first.
202
216
  subscribe: async function (input) {
203
217
  if (!input || typeof input !== "object") {
204
218
  throw new TypeError("stockAlerts.subscribe: input object required");
@@ -237,23 +251,26 @@ function create(opts) {
237
251
  await query("DELETE FROM stock_alerts WHERE id = ?1", [existing.id]);
238
252
  }
239
253
 
240
- var token = _mintToken();
241
- var tokenHash = _hashToken(token);
242
- var id = b.uuid.v7();
243
- var expiresAt = now + ttlMs;
254
+ var token = _mintToken();
255
+ var tokenHash = _hashToken(token);
256
+ var unsubToken = _mintToken();
257
+ var unsubHash = _hashUnsubToken(unsubToken);
258
+ var id = b.uuid.v7();
259
+ var expiresAt = now + ttlMs;
244
260
 
245
261
  await query(
246
262
  "INSERT INTO stock_alerts " +
247
263
  "(id, email_hash, email_normalised, sku, variant_id, customer_id, " +
248
- " confirmation_token_hash, confirmed_at, notified_at, expires_at, created_at) " +
249
- "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, NULL, NULL, ?8, ?9)",
250
- [id, emailHash, emailNorm, sku, variantId, customerId, tokenHash, expiresAt, now],
264
+ " confirmation_token_hash, unsubscribe_token_hash, confirmed_at, notified_at, expires_at, created_at) " +
265
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, NULL, NULL, ?9, ?10)",
266
+ [id, emailHash, emailNorm, sku, variantId, customerId, tokenHash, unsubHash, expiresAt, now],
251
267
  );
252
268
  return {
253
269
  id: id,
254
270
  status: "subscribed",
255
271
  email_hash: emailHash,
256
272
  confirmation_token: token,
273
+ unsubscribe_token: unsubToken,
257
274
  expires_at: expiresAt,
258
275
  };
259
276
  },
@@ -293,23 +310,45 @@ function create(opts) {
293
310
  return row;
294
311
  },
295
312
 
296
- // Unsubscribe by (email, sku, variant?) the storefront's
297
- // unsubscribe link from the notification email lands here.
298
- // Returns `{ removed: boolean }`. Idempotent: a missing row is
299
- // not an error.
300
- unsubscribe: async function (input) {
301
- if (!input || typeof input !== "object") {
302
- throw new TypeError("stockAlerts.unsubscribe: input object required");
313
+ // Unsubscribe by the per-row bearer token carried in the email's
314
+ // unsubscribe link. The token IS the authorization knowing a
315
+ // victim's email + a SKU is no longer enough to cancel their alert,
316
+ // only the opaque token the shop emailed them is. Resolves the row by
317
+ // the token's namespaceHash and deletes it.
318
+ //
319
+ // Enumeration-safe by construction: a bad-shape, empty, or unknown
320
+ // token returns the SAME `{ removed: false }` shape a valid-but-
321
+ // already-removed token does (idempotent re-click), and the miss path
322
+ // burns the same constant-time comparison as the hit path so the
323
+ // unknown-vs-known distinction doesn't leak through response latency.
324
+ // Never throws on a token argument — the public route renders a
325
+ // uniform "you're unsubscribed" page for every outcome.
326
+ unsubscribeByToken: async function (plaintext) {
327
+ if (typeof plaintext !== "string" || !TOKEN_RE.test(plaintext)) {
328
+ // Bad shape can't match any stored hash — short-circuit to the
329
+ // same no-op the unknown-token path returns. No oracle.
330
+ return { removed: false };
303
331
  }
304
- var emailNorm = _normaliseEmail(input.email);
305
- var sku = _validateSku(input.sku);
306
- var variantId = _optUuid(input.variant_id, "variant_id");
307
- var emailHash = _hashEmail(emailNorm);
308
- var r = await query(
309
- "DELETE FROM stock_alerts WHERE email_hash = ?1 AND sku = ?2 " +
310
- "AND COALESCE(variant_id, '') = COALESCE(?3, '')",
311
- [emailHash, sku, variantId],
312
- );
332
+ var unsubHash = _hashUnsubToken(plaintext);
333
+ var row = (await query(
334
+ "SELECT id, unsubscribe_token_hash FROM stock_alerts " +
335
+ "WHERE unsubscribe_token_hash = ?1 LIMIT 1",
336
+ [unsubHash],
337
+ )).rows[0];
338
+ if (!row) {
339
+ // Miss path — burn the same comparison work as the hit path so
340
+ // the latency profile matches (the compared values are equal by
341
+ // construction; the result is discarded).
342
+ b.crypto.timingSafeEqual(unsubHash, unsubHash);
343
+ return { removed: false };
344
+ }
345
+ // Constant-time check of the stored hash against the recomputed
346
+ // hash — belt-and-braces against any future change to the index
347
+ // lookup. A mismatch reads as a miss (uniform no-op).
348
+ if (!b.crypto.timingSafeEqual(row.unsubscribe_token_hash, unsubHash)) {
349
+ return { removed: false };
350
+ }
351
+ var r = await query("DELETE FROM stock_alerts WHERE id = ?1", [row.id]);
313
352
  return { removed: Number(r.rowCount || 0) > 0 };
314
353
  },
315
354
 
@@ -391,11 +430,18 @@ function create(opts) {
391
430
  // Operator-driven sweeper. Walks every pending (confirmed +
392
431
  // un-notified + un-expired) subscription whose SKU now has
393
432
  // catalog.inventory.stock_on_hand - stock_held > 0 and:
394
- // 1. stamps notified_at on the row, and
395
- // 2. (if notifications is wired) enqueues an in-app message
433
+ // 1. mints a fresh unsubscribe bearer token, rotating the row's
434
+ // unsubscribe_token_hash so the notification email's one-click
435
+ // unsubscribe link is a live, single-purpose handle (the prior
436
+ // confirmation-email token is superseded — a notified row is
437
+ // terminal so its older link is moot anyway),
438
+ // 2. stamps notified_at on the row, and
439
+ // 3. (if notifications is wired) enqueues an in-app message
396
440
  // keyed by the recipient's email_hash.
397
441
  // Returns `{ scanned, notified, enqueued, rows }` — `rows` is the
398
- // shape that fired, post-update. The sweeper is idempotent at the
442
+ // shape that fired, post-update, each carrying the plaintext
443
+ // `unsubscribe_token` (returned ONCE here, for the email builder to
444
+ // embed; only the hash persists). The sweeper is idempotent at the
399
445
  // row level: a row that's already been notified is skipped.
400
446
  scanAndNotify: async function (sweepOpts) {
401
447
  sweepOpts = sweepOpts || {};
@@ -453,11 +499,23 @@ function create(opts) {
453
499
  var rows = bySku[skuKey];
454
500
  for (var k = 0; k < rows.length; k += 1) {
455
501
  var row = rows[k];
456
- await query(
457
- "UPDATE stock_alerts SET notified_at = ?1 WHERE id = ?2 AND notified_at IS NULL",
458
- [now, row.id],
502
+ // Mint + rotate a fresh unsubscribe bearer for this fired row.
503
+ // The plaintext rides out on the row object (the email builder
504
+ // embeds it); only the hash is durable. The `notified_at IS
505
+ // NULL` guard makes the update a claim: when two sweeps race
506
+ // the same pending row, exactly one wins. The loser MUST skip
507
+ // the row — its freshly minted token never persisted, so an
508
+ // email built from it would carry a dead unsubscribe link, and
509
+ // pushing the row would double-notify the subscriber.
510
+ var rowUnsubToken = _mintToken();
511
+ var claim = await query(
512
+ "UPDATE stock_alerts SET notified_at = ?1, unsubscribe_token_hash = ?2 " +
513
+ "WHERE id = ?3 AND notified_at IS NULL",
514
+ [now, _hashUnsubToken(rowUnsubToken), row.id],
459
515
  );
516
+ if (!claim || Number(claim.rowCount) !== 1) continue;
460
517
  row.notified_at = now;
518
+ row.unsubscribe_token = rowUnsubToken;
461
519
  fired.push(row);
462
520
  if (notifications) {
463
521
  try {
@@ -541,5 +599,6 @@ module.exports = {
541
599
  create: create,
542
600
  EMAIL_NAMESPACE: EMAIL_NAMESPACE,
543
601
  TOKEN_NAMESPACE: TOKEN_NAMESPACE,
602
+ UNSUB_NAMESPACE: UNSUB_NAMESPACE,
544
603
  TOKEN_LEN: TOKEN_LEN,
545
604
  };
package/lib/storefront.js CHANGED
@@ -8156,17 +8156,17 @@ function renderStockAlertConfirm(opts) {
8156
8156
  });
8157
8157
  }
8158
8158
 
8159
- // The unsubscribe confirm page (GET). A no-JS form POSTs the (email, sku,
8160
- // variant) tuple back to /stock-alert/unsubscribe — a deliberate "yes,
8161
- // stop these alerts" step (no link-prefetcher unsubscribe). The tuple
8162
- // rides in hidden fields (HTML-escaped). This page is container-rendered
8163
- // from a GET, so it CAN carry the `_csrf` token the action is NOT in
8164
- // EDGE_POST_PATHS, so `_injectCsrfFields` tokens it automatically.
8159
+ // The unsubscribe confirm page (GET). A no-JS form POSTs the opaque,
8160
+ // single-purpose bearer token back to /stock-alert/unsubscribe — a
8161
+ // deliberate "yes, stop these alerts" step (no link-prefetcher
8162
+ // unsubscribe). The token rides in a hidden field (HTML-escaped) and is
8163
+ // the only handle no email address or SKU is shown or carried, so the
8164
+ // page reveals nothing about the subscription. The token IS the
8165
+ // authorization (the action is in EDGE_POST_PATHS — CSRF-exempt — exactly
8166
+ // like the newsletter one-click unsubscribe), so no session is needed.
8165
8167
  function renderStockAlertUnsubscribeConfirm(opts) {
8166
8168
  opts = opts || {};
8167
- var email = typeof opts.email === "string" ? opts.email : "";
8168
- var sku = typeof opts.sku === "string" ? opts.sku : "";
8169
- var variantId = typeof opts.variant_id === "string" ? opts.variant_id : "";
8169
+ var token = typeof opts.token === "string" ? opts.token : "";
8170
8170
  var body =
8171
8171
  "<section class=\"newsletter-thanks\">" +
8172
8172
  "<div class=\"newsletter-thanks__card\">" +
@@ -8174,9 +8174,7 @@ function renderStockAlertUnsubscribeConfirm(opts) {
8174
8174
  "<h1 class=\"newsletter-thanks__title\">Stop this alert?</h1>" +
8175
8175
  "<p class=\"newsletter-thanks__lede\">Confirm below and we'll cancel your back-in-stock alert for this item. You can set it again any time from the product page.</p>" +
8176
8176
  "<form class=\"newsletter-unsub__form\" method=\"post\" action=\"/stock-alert/unsubscribe\">" +
8177
- "<input type=\"hidden\" name=\"email\" value=\"" + _escAttr(email) + "\">" +
8178
- "<input type=\"hidden\" name=\"sku\" value=\"" + _escAttr(sku) + "\">" +
8179
- (variantId ? "<input type=\"hidden\" name=\"variant_id\" value=\"" + _escAttr(variantId) + "\">" : "") +
8177
+ "<input type=\"hidden\" name=\"token\" value=\"" + _escAttr(token) + "\">" +
8180
8178
  "<div class=\"newsletter-thanks__cta\">" +
8181
8179
  "<button type=\"submit\" class=\"btn-primary\">Stop this alert</button>" +
8182
8180
  "<a href=\"/\" class=\"btn-ghost\">Keep me subscribed</a>" +
@@ -12425,13 +12423,24 @@ function mount(router, deps) {
12425
12423
  // fix; anything else is a server-side failure. Either way, render a
12426
12424
  // styled, recoverable page rather than raw text — back to the cart,
12427
12425
  // or back to the shipping form to re-enter the address.
12426
+ // Server-side failure detail (a PSP / TLS / upstream error string)
12427
+ // is operator material, not customer copy — it goes to the audit
12428
+ // sink; the page gets the generic recoverable message. TypeError
12429
+ // messages are this module's own validator prose and stay inline.
12428
12430
  var clientErr = (e instanceof TypeError);
12431
+ if (!clientErr) {
12432
+ b.audit.safeEmit({
12433
+ action: "storefront.checkout.confirm.error",
12434
+ outcome: "failure",
12435
+ metadata: { message: msg },
12436
+ });
12437
+ }
12429
12438
  return _send(res, clientErr ? 400 : 500, renderCheckoutError({
12430
12439
  shop_name: shopName, theme: theme, eyebrow: "Checkout",
12431
12440
  title_text: clientErr ? "We couldn't process your shipping details" : "Checkout didn't go through",
12432
12441
  reason: clientErr
12433
12442
  ? "Some shipping details couldn't be read: " + msg + " Go back and check the address fields."
12434
- : "Something went wrong completing your order. Your cart is saved — please try again. (" + msg + ")",
12443
+ : "Something went wrong completing your order. Your cart is saved and nothing was charged — please try again in a moment.",
12435
12444
  back_href: "/checkout", back_label: "Edit shipping",
12436
12445
  secondary_href: "/cart", secondary_label: "Back to cart",
12437
12446
  }));
@@ -17742,6 +17751,9 @@ function mount(router, deps) {
17742
17751
  if (result && result.status === "subscribed" && result.confirmation_token && deps.email) {
17743
17752
  var origin = _stockAlertOrigin(req);
17744
17753
  var confirmUrl = origin + "/stock-alert/confirm/" + encodeURIComponent(result.confirmation_token);
17754
+ // One-click unsubscribe link — the per-row bearer token IS the
17755
+ // authorization (no email/sku tuple in the URL to guess).
17756
+ var unsubscribeUrl = origin + "/stock-alert/unsubscribe?token=" + encodeURIComponent(result.unsubscribe_token);
17745
17757
  // Resolve the product title for the SKU (cheap indexed read; drop-
17746
17758
  // silent → fall back to the SKU as the title).
17747
17759
  var titleForSku = body.sku;
@@ -17753,10 +17765,11 @@ function mount(router, deps) {
17753
17765
  } catch (_e) { /* drop-silent — fall back to the SKU as the title */ }
17754
17766
  try {
17755
17767
  await deps.email.sendStockAlertConfirmation({
17756
- to: body.email,
17757
- product_title: titleForSku,
17758
- sku: body.sku,
17759
- confirm_url: confirmUrl,
17768
+ to: body.email,
17769
+ product_title: titleForSku,
17770
+ sku: body.sku,
17771
+ confirm_url: confirmUrl,
17772
+ unsubscribe_url: unsubscribeUrl,
17760
17773
  });
17761
17774
  } catch (_mailErr) { /* drop-silent — the row is written; the cron still fires on confirm */ }
17762
17775
  }
@@ -17801,9 +17814,13 @@ function mount(router, deps) {
17801
17814
  }));
17802
17815
  });
17803
17816
 
17804
- // Unsubscribe — by (email, sku, variant) tuple carried in the link query
17805
- // (stockAlerts has no unsubscribe-token API). GET renders a friendly
17806
- // confirm page (token-carrying container form); POST consumes it.
17817
+ // Unsubscribe — by the opaque, single-purpose bearer token carried in
17818
+ // the email's `?token=` link (the same shape as the newsletter
17819
+ // one-click unsubscribe). The token IS the authorization, so knowing a
17820
+ // victim's email + a SKU is no longer enough to cancel their alert.
17821
+ // GET renders a friendly confirm page (no link-prefetcher unsubscribe);
17822
+ // POST validates the token and deletes the row. The route is CSRF-exempt
17823
+ // (EDGE_POST_PATHS) — the token is the bearer, there's no session.
17807
17824
  router.get("/stock-alert/unsubscribe", async function (req, res) {
17808
17825
  var q = (req.query && typeof req.query === "object") ? req.query : {};
17809
17826
  var cartCount = 0;
@@ -17811,36 +17828,32 @@ function mount(router, deps) {
17811
17828
  return _send(res, 200, renderStockAlertUnsubscribeConfirm({
17812
17829
  shop_name: shopName,
17813
17830
  cart_count: cartCount,
17814
- email: typeof q.email === "string" ? q.email : "",
17815
- sku: typeof q.sku === "string" ? q.sku : "",
17816
- variant_id: typeof q.variant_id === "string" ? q.variant_id : "",
17831
+ token: typeof q.token === "string" ? q.token : "",
17817
17832
  }));
17818
17833
  });
17819
17834
 
17820
17835
  router.post("/stock-alert/unsubscribe", async function (req, res) {
17821
17836
  var body = req.body || {};
17837
+ var token = typeof body.token === "string" ? body.token : "";
17822
17838
  var cartCount = 0;
17823
17839
  try { cartCount = await _cartCountForReq(req); } catch (_e) { /* drop-silent — empty cart fallback */ }
17824
17840
  var outcome;
17825
17841
  try {
17826
- // Idempotent a missing row returns { removed: false }, which still
17827
- // reads as "you're unsubscribed" (clicking the link twice is fine).
17828
- await deps.stockAlerts.unsubscribe({
17829
- email: body.email,
17830
- sku: body.sku,
17831
- variant_id: (body.variant_id != null && body.variant_id !== "") ? body.variant_id : null,
17832
- });
17842
+ // `unsubscribeByToken` never throws on a token argument and returns
17843
+ // a uniform `{ removed }` for valid / unknown / bad-shape tokens —
17844
+ // no subscription-existence oracle. Every outcome renders the same
17845
+ // "you're unsubscribed" copy (re-clicking the link twice is fine).
17846
+ await deps.stockAlerts.unsubscribeByToken(token);
17833
17847
  outcome = "unsubscribed";
17834
17848
  } catch (e) {
17835
- // A bad-shape tuple throws TypeError generic non-leaking page; a
17836
- // real fault degrades the same way rather than 500-ing a public route.
17849
+ // Only a real infrastructure fault (D1 unreachable) lands here
17850
+ // record it and render the generic non-leaking page rather than a
17851
+ // 500 that exposes internals on a public, unauthenticated route.
17837
17852
  outcome = "invalid";
17838
- if (!(e instanceof TypeError)) {
17839
- _log.error("storefront stock-alert unsubscribe failed", {
17840
- route: "/stock-alert/unsubscribe", request_id: (req && req.requestId) || null,
17841
- err: (e && e.message) || String(e),
17842
- });
17843
- }
17853
+ _log.error("storefront stock-alert unsubscribe failed", {
17854
+ route: "/stock-alert/unsubscribe", request_id: (req && req.requestId) || null,
17855
+ err: (e && e.message) || String(e),
17856
+ });
17844
17857
  }
17845
17858
  return _send(res, 200, renderStockAlertUnsubscribeResult({
17846
17859
  shop_name: shopName, cart_count: cartCount, outcome: outcome,
package/lib/webhooks.js CHANGED
@@ -34,6 +34,23 @@
34
34
  * `b.uuid.v7()` per delivery — re-deliveries (manual retry) carry a
35
35
  * new id so the consumer's nonce store doesn't reject the replay as
36
36
  * a duplicate.
37
+ *
38
+ * TLS posture — outbound deliveries dial through `b.httpClient`,
39
+ * which offers ONLY the framework's PQC-hybrid key-exchange groups
40
+ * (ML-KEM hybrid, `constants.TLS_GROUP_PREFERENCE`) with a TLS 1.3
41
+ * minimum. A receiver whose TLS stack does not yet negotiate an
42
+ * ML-KEM hybrid group answers the ClientHello with a
43
+ * handshake_failure (TLS alert 40); the framework surfaces that as a
44
+ * transport error, so the delivery row records `last_error`, runs the
45
+ * backoff retry schedule, and — if the endpoint never negotiates a
46
+ * supported group — lands in `webhook_dlq` after the fifth attempt.
47
+ * This is a deliberate posture choice, NOT a silent classical
48
+ * downgrade: the PSP adapters (lib/payment.js) pin a downgraded agent
49
+ * for their two FIXED endpoints, but a webhook receiver URL is
50
+ * operator-arbitrary, so the framework's PQC-first default is held.
51
+ * An operator whose receiver can't meet the PQC list terminates TLS
52
+ * for it on a fronting proxy (Cloudflare, a load balancer) that does,
53
+ * or upgrades the receiver's TLS stack.
37
54
  */
38
55
 
39
56
  var b = require("./vendor/blamejs");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.3.60",
3
+ "version": "0.3.63",
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": {