@blamejs/blamejs-shop 0.3.58 → 0.3.60

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.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
+
13
+ - 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.
14
+
11
15
  - v0.3.58 (2026-06-04) — **Browser form submissions no longer refused as cross-origin, server-side shipping-address validation, and a repaired checkout layout.** Every navigational form POST served by the container — checkout, magic-link sign-in, product reviews, returns, the account area, and the admin console — was refused with a 403 in real browsers. Pages shipped Referrer-Policy: no-referrer, which makes browsers send Origin: null on same-origin form submissions, and the CSRF gate's origin pre-check refuses a null Origin before the token is read. Both the edge and the container now send Referrer-Policy: same-origin: referrer information still never leaves the site, while same-origin posts carry the real Origin the gate verifies. Cross-site and sandboxed-iframe submissions are still refused. Checkout additionally gains backend shipping-address validation with accessible per-field errors that preserve everything the shopper typed, and the checkout page's layout is repaired — fields fill the form column instead of collapsing to its center, and the gift, store-pickup, and loyalty options render inside the form above the submit button. **Added:** *Server-side shipping-address validation with per-field errors* — The checkout POST validates the shipping address on the backend and, on rejection, re-renders the form with the one bad field marked (aria-invalid plus an adjacent role="alert" message) and every typed value preserved — replacing the previous dead-end error page. Country must be a real ISO 3166-1 alpha-2 code, validated against the platform's Intl region data (no new dependencies). US and Canadian destinations validate the state or province code and the ZIP (12345 or 12345-6789) / postal (A1A 1A1) format, and require street, city, region, and postal — the figures that feed destination tax. Every other country keeps lenient length-bounded checks, so international addresses are never over-validated. Email gains a shape check alongside the existing content-safety gate, and the customer name is length-bounded. Headless callers of checkout.confirm keep the presence-optional contract for digital-only orders; malformed values are now refused everywhere. **Fixed:** *Form posts from real browsers were refused as cross-origin (403)* — Pages were served with Referrer-Policy: no-referrer, which per the Fetch specification makes browsers serialize the Origin header as the literal string "null" on same-origin navigational POSTs. The CSRF gate's origin pre-check refuses a null Origin before the double-submit token is validated, so every container-rendered form returned {"error":"CSRF cross-origin request refused."} on submit — checkout, magic-link sign-in, reviews, returns, account self-service, and the admin console. JSON fetch() calls (passkeys, saved cards, the cart-count island) carry a real Origin and were unaffected. Both substrates now send Referrer-Policy: same-origin: no referrer information ever leaves the site (matching the previous policy's intent), same-origin posts carry a real Origin verified against the configured public origins, and cross-site or sandboxed-iframe posts still arrive as null and are still refused. The integration suite now drives the full Origin matrix — same-origin accepted, null and cross-origin refused — closing the gap between loopback tests (which send no Origin header) and real browsers. Operators composing their own securityHeaders options should not reinstate no-referrer: it re-breaks every tokened form post. · *Checkout layout: fields fill the column; gift and pickup options join the form* — A global form rule leaked align-items: center into the checkout's stacked form, collapsing every field to its content width in the middle of a wide column; the stacked form now stretches its rows. The gift-options, store-pickup, and loyalty-redeem blocks were spliced in after the submit button, rendering as an unstyled run of fields below the call to action — they now render inside the form above the submit row, framed as cards consistent with the order summary, with labels matching the shipping fields.
12
16
 
13
17
  - v0.3.57 (2026-06-02) — **Vendored blamejs framework refreshed from v0.14.16 to v0.14.19.** The storefront runs on a vendored copy of the blamejs framework. This refreshes it across three upstream patch releases (v0.14.16 to v0.14.19), picking up framework fixes and hardening. The most operator-relevant is a PKCE-downgrade defense in the OAuth/OIDC client, which the storefront composes for Sign in with Google and Apple, so the federated sign-in flow inherits it. The remaining upstream changes are in framework areas the storefront does not expose (archive writing, SCIM, verifiable-credential issuance, SPF/DMARC tooling, OpenAPI), and the storefront's own behavior is unchanged — verified by the full test suite and the vendored-tree integrity gate against the new tree. **Changed:** *Updated the vendored framework to blamejs v0.14.19* — The vendored framework moves from v0.14.16 to v0.14.19 (three upstream patch releases of fixes and additive, opt-in changes). Notably, the OAuth/OIDC client now refuses an identity provider that advertises PKCE methods without S256 — a stripped-S256 downgrade defense — which hardens the storefront's Sign in with Google and Apple. The integrity manifest over the vendored tree was re-stamped as part of the refresh.
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.3.58",
2
+ "version": "0.3.60",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-6k53cvkRrxMgmeStLIoLjVXZQHqIJgTmv1Izd8TYhh1HOC4POgE6GCvx1bsalyEP",
@@ -954,9 +954,16 @@ function create(opts) {
954
954
  }
955
955
  }
956
956
  }
957
- // Half-away-from-zero round to integer minor.
958
- var raw = (base * rule.value.basis_points) / 10000;
959
- savingsMinor = raw >= 0 ? Math.floor(raw + 0.5) : -Math.floor(-raw + 0.5);
957
+ // Half-away-from-zero round to integer minor. Exact BigInt
958
+ // math base and basis_points are integers, so a JS float
959
+ // multiply would drift on large carts; the round happens once
960
+ // on the integer numerator. base is non-negative here (subtotal
961
+ // or a sum of unit_price*qty), so half-away equals half-up:
962
+ // floor((base*bps + 5000) / 10000).
963
+ var num = BigInt(base) * BigInt(rule.value.basis_points);
964
+ savingsMinor = num >= 0n
965
+ ? Number((num + 5000n) / 10000n)
966
+ : -Number((-num + 5000n) / 10000n);
960
967
  if (savingsMinor > base) savingsMinor = base;
961
968
  } else if (rule.value.kind === "amount_off_total") {
962
969
  savingsMinor = rule.value.minor;
package/lib/bundles.js CHANGED
@@ -467,8 +467,11 @@ function create(opts) {
467
467
  // Integer floor — basis points are 1/100 of a percent (10000 =
468
468
  // 100%). The floor keeps the customer from paying a fractional
469
469
  // cent and matches the rounding convention the pricing
470
- // primitive uses for line totals.
471
- discountMinor = Math.floor((listTotal * discountBps) / 10000);
470
+ // primitive uses for line totals. Exact BigInt math (listTotal
471
+ // and discountBps are integers) so the multiply can't drift on a
472
+ // large bundle; BigInt division truncates toward zero, which is
473
+ // floor for the non-negative inputs here.
474
+ discountMinor = Number((BigInt(listTotal) * BigInt(discountBps)) / 10000n);
472
475
  }
473
476
  var grandTotal = listTotal - discountMinor;
474
477
  return {
package/lib/email.js CHANGED
@@ -27,6 +27,7 @@
27
27
  */
28
28
 
29
29
  var b = require("./vendor/blamejs");
30
+ var pricing = require("./pricing");
30
31
 
31
32
  function _htmlEscape(s) {
32
33
  if (s == null) return "";
@@ -60,7 +61,7 @@ function _formatMoney(amountMinor, currency) {
60
61
  // Delegate to pricing.format so locale + zero-decimal currency
61
62
  // handling stays consistent across surfaces. pricing is required
62
63
  // — the email module ships with it as a peer in lib/index.js.
63
- return b && require("./pricing").format(amountMinor, currency);
64
+ return b && pricing.format(amountMinor, currency);
64
65
  }
65
66
 
66
67
  // ---- templates ----------------------------------------------------------
@@ -369,7 +370,7 @@ function create(opts) {
369
370
  function _orderVars(order, customer) {
370
371
  if (!order || typeof order !== "object") throw new TypeError("email: order object required");
371
372
  if (!customer || typeof customer !== "object") throw new TypeError("email: customer object required");
372
- var format = require("./pricing").format;
373
+ var format = pricing.format;
373
374
  return {
374
375
  order_id: order.id,
375
376
  customer_name: customer.name || "there",
@@ -409,7 +410,7 @@ function create(opts) {
409
410
  if (!Number.isInteger(input.amount_minor) || input.amount_minor < 0) {
410
411
  throw new TypeError("email.refundConfirmation: amount_minor must be a non-negative integer");
411
412
  }
412
- var format = require("./pricing").format;
413
+ var format = pricing.format;
413
414
  var vars = {
414
415
  order_id: input.order.id,
415
416
  customer_name: input.customer.name || "there",
@@ -736,10 +736,12 @@ function create(opts) {
736
736
  if (p.refund_kind === "full" || p.refund_kind === "store_credit_only") {
737
737
  maxRefundMinor = input.order_total_minor;
738
738
  } else if (p.refund_kind === "partial") {
739
- // floor(total * bps / 10000) — integer math, no float
740
- // drift. The bps gate (1..9999) guarantees the result is
741
- // strictly less than the order total.
742
- maxRefundMinor = Math.floor((input.order_total_minor * p.partial_refund_bps) / 10000);
739
+ // floor(total * bps / 10000) — exact BigInt math, no float
740
+ // drift on a large order. order_total_minor and bps are
741
+ // integers; BigInt division truncates toward zero (floor for
742
+ // these non-negative inputs). The bps gate (1..9999)
743
+ // guarantees the result is strictly less than the order total.
744
+ maxRefundMinor = Number((BigInt(input.order_total_minor) * BigInt(p.partial_refund_bps)) / 10000n);
743
745
  } else /* no_refund */ {
744
746
  maxRefundMinor = 0;
745
747
  }
@@ -85,6 +85,11 @@ var HEALTH_PATH = "/_/health";
85
85
  // /survey/ -> /survey/:token (GET form + POST submit)
86
86
  // /orders/ -> /orders/:id/cancel|rate|reorder (gated to POST below;
87
87
  // the /orders/:id confirmation GET a shopper hits freely is NOT throttled)
88
+ // /stock-alert/ -> subscribe + unsubscribe. Subscribe is anonymous,
89
+ // CSRF-exempt (EDGE_POST_PATHS), and emails the
90
+ // request-supplied address — without the tight
91
+ // budget it is a victim-addressed mail cannon on
92
+ // the loose global bucket alone.
88
93
  var TIGHT_PREFIXES = [
89
94
  "/account/login",
90
95
  "/account/register",
@@ -96,6 +101,7 @@ var TIGHT_PREFIXES = [
96
101
  "/products/",
97
102
  "/survey/",
98
103
  "/orders/",
104
+ "/stock-alert/",
99
105
  ];
100
106
 
101
107
  // Edge-served state-changing POST endpoints. These forms are rendered at
@@ -221,9 +221,13 @@ function _epochMs(n, label) {
221
221
  // Integer-only; rounds up on half-cent so the insurer is never short.
222
222
 
223
223
  function _computePremium(declaredValueMinor, premiumRateBps, premiumMinMinor) {
224
- var product = declaredValueMinor * premiumRateBps;
225
- var quotient = Math.floor(product / 10000);
226
- if (quotient * 10000 < product) { quotient += 1; }
224
+ // Exact BigInt ceil — declaredValueMinor and premiumRateBps are
225
+ // integers, so a JS Number multiply would lose precision once the
226
+ // product exceeds 2^53 (a large declared value at a high rate).
227
+ // ceil(product / 10000) = floor((product + 9999) / 10000) for the
228
+ // non-negative inputs here.
229
+ var product = BigInt(declaredValueMinor) * BigInt(premiumRateBps);
230
+ var quotient = Number((product + 9999n) / 10000n);
227
231
  if (quotient < premiumMinMinor) { quotient = premiumMinMinor; }
228
232
  return quotient;
229
233
  }
package/lib/storefront.js CHANGED
@@ -2965,7 +2965,10 @@ function _wishlistPrefsPanel(opts) {
2965
2965
  "<input type=\"hidden\" name=\"on\" value=\"" + (on ? "" : "1") + "\">" +
2966
2966
  "<span class=\"wishlist-prefs__label\">" + esc(a.label || a.trigger) + "</span>" +
2967
2967
  "<span class=\"wishlist-prefs__state\">" + (on ? "On" : "Off") + "</span>" +
2968
- "<button type=\"submit\" class=\"btn-ghost btn-ghost--sm\">" + (on ? "Turn off" : "Turn on") + "</button>" +
2968
+ // aria-label carries the trigger name the visible "Turn off"/"Turn
2969
+ // on" alone is ambiguous to a screen reader walking a list of
2970
+ // identically-named buttons.
2971
+ "<button type=\"submit\" class=\"btn-ghost btn-ghost--sm\" aria-label=\"" + (on ? "Turn off " : "Turn on ") + esc(a.label || a.trigger) + "\">" + (on ? "Turn off" : "Turn on") + "</button>" +
2969
2972
  "</form>";
2970
2973
  }
2971
2974
  var alertsBlock = alertRows
@@ -2982,7 +2985,7 @@ function _wishlistPrefsPanel(opts) {
2982
2985
  "<input type=\"hidden\" name=\"on\" value=\"" + (enrolled ? "" : "1") + "\">" +
2983
2986
  "<span class=\"wishlist-prefs__label\">" + esc(d.label || d.slug) + "</span>" +
2984
2987
  "<span class=\"wishlist-prefs__state\">" + (enrolled ? "Subscribed" : "Not subscribed") + "</span>" +
2985
- "<button type=\"submit\" class=\"btn-ghost btn-ghost--sm\">" + (enrolled ? "Unsubscribe" : "Subscribe") + "</button>" +
2988
+ "<button type=\"submit\" class=\"btn-ghost btn-ghost--sm\" aria-label=\"" + (enrolled ? "Unsubscribe from " : "Subscribe to ") + esc(d.label || d.slug) + "\">" + (enrolled ? "Unsubscribe" : "Subscribe") + "</button>" +
2986
2989
  "</form>";
2987
2990
  }
2988
2991
  var digestBlock = digestRows
@@ -9237,6 +9240,16 @@ function renderPaymentMethods(opts) {
9237
9240
  var inner = rowsHtml
9238
9241
  ? "<ul class=\"pm-list\">" + rowsHtml + "</ul>"
9239
9242
  : "<div class=\"account-empty\"><p class=\"account-empty__lede\">No saved cards yet. Add one for faster checkout.</p></div>";
9243
+ // "Add a card" CTA — only a live link when the add flow can actually
9244
+ // complete (the Stripe publishable key drives the SetupIntent Payment
9245
+ // Element on the add page). Absent it, render a disabled control + an
9246
+ // honest note instead of a link that dead-ends on the add page's 503.
9247
+ // Back-compat: callers that don't pass the flag keep the live link.
9248
+ var addCardAvailable = opts.add_card_available !== false;
9249
+ var ctaHtml = addCardAvailable
9250
+ ? "<a class=\"btn-primary\" href=\"/account/payment-methods/add\">Add a card</a>"
9251
+ : "<button type=\"button\" class=\"btn-primary\" disabled aria-disabled=\"true\">Add a card</button>" +
9252
+ "<p class=\"form-notice form-notice--warn\" role=\"status\">Adding a card isn't available on this store yet — card storage isn't fully configured. Your saved cards still work for checkout.</p>";
9240
9253
  var body =
9241
9254
  "<section class=\"account-payment-methods\">" +
9242
9255
  "<nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>" +
@@ -9247,7 +9260,7 @@ function renderPaymentMethods(opts) {
9247
9260
  success + notice +
9248
9261
  inner +
9249
9262
  "<div class=\"account-payment-methods__cta\">" +
9250
- "<a class=\"btn-primary\" href=\"/account/payment-methods/add\">Add a card</a>" +
9263
+ ctaHtml +
9251
9264
  "</div>" +
9252
9265
  "</section>";
9253
9266
  return _wrap({
@@ -13606,11 +13619,16 @@ function mount(router, deps) {
13606
13619
  if (okKind === "archived") successCopy = "Card removed.";
13607
13620
  if (okKind === "exists") successCopy = "That card is already on file.";
13608
13621
  _send(res, code || 200, renderPaymentMethods({
13609
- payment_methods: rows,
13610
- notice: notice || null,
13611
- success: successCopy,
13612
- shop_name: shopName,
13613
- cart_count: cartCount,
13622
+ payment_methods: rows,
13623
+ notice: notice || null,
13624
+ success: successCopy,
13625
+ // The add-card page needs the Stripe publishable key for the
13626
+ // SetupIntent Payment Element; without it the add route 503s,
13627
+ // so render the disabled "Add a card" state rather than a link
13628
+ // into that dead end.
13629
+ add_card_available: !!deps.stripe_publishable_key,
13630
+ shop_name: shopName,
13631
+ cart_count: cartCount,
13614
13632
  }));
13615
13633
  }
13616
13634
 
@@ -17460,7 +17478,11 @@ function mount(router, deps) {
17460
17478
  for (var m = 0; m < members.length; m += 1) {
17461
17479
  var mem = members[m];
17462
17480
  var share = listTotal > 0
17463
- ? Math.floor((bundleTotal * mem.list_line) / listTotal)
17481
+ // Exact BigInt floorbundleTotal and list_line are integer
17482
+ // minor units; their product can exceed 2^53 on a large
17483
+ // bundle, so a JS float multiply would drift. BigInt division
17484
+ // truncates toward zero (floor for these non-negative values).
17485
+ ? Number((BigInt(bundleTotal) * BigInt(mem.list_line)) / BigInt(listTotal))
17464
17486
  : Math.floor(bundleTotal / members.length);
17465
17487
  mem.alloc_line = share;
17466
17488
  allocated += share;
@@ -63,6 +63,7 @@
63
63
  */
64
64
 
65
65
  var b = require("./vendor/blamejs");
66
+ var wishlistModule = require("./wishlist");
66
67
 
67
68
  // ---- constants ----------------------------------------------------------
68
69
 
@@ -256,7 +257,7 @@ function create(opts) {
256
257
  var wishlistInstance = opts.wishlist || null;
257
258
  function _wishlist() {
258
259
  if (!wishlistInstance) {
259
- wishlistInstance = require("./wishlist").create({ query: query });
260
+ wishlistInstance = wishlistModule.create({ query: query });
260
261
  }
261
262
  return wishlistInstance;
262
263
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.3.58",
3
+ "version": "0.3.60",
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": {