@blamejs/blamejs-shop 0.3.57 → 0.3.58

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,8 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.3.x
10
10
 
11
+ - 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
+
11
13
  - 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.
12
14
 
13
15
  - v0.3.56 (2026-06-02) — **Opt-in wishlist alert and digest emails, passwordless magic-link sign-in, and a documented list of planned integrations.** Two opt-in capabilities are wired, and the operator docs now state plainly which integrations are planned-but-not-yet-available so nothing reads as silently missing. A customer can opt in to wishlist price-drop and back-in-stock alerts and a periodic wishlist digest; and a passwordless email magic-link offers an account sign-in path alongside passkeys and OAuth. Both stay inert until an operator configures email sending, so a store with no mailer behaves exactly as before. **Added:** *Opt-in wishlist alert and digest emails* — A customer can opt in, from their wishlist, to price-drop and back-in-stock alerts and to a periodic wishlist digest. Scheduled sweeps send them. The feature is off until an operator configures email sending and an email resolver; with no mailer it is a no-op. Digest emails are rendered field-by-field with output escaping. · *Passwordless magic-link sign-in* — When a mailer is configured, a customer can request a single-use, time-limited sign-in link by email, in addition to passkeys and OAuth. The request response is identical whether or not the email matches an account (no account enumeration), and the link establishes a sealed session on use. **Changed:** *Documented planned integrations* — The README and SECURITY docs now list the integrations that are intentionally not yet available — address validation, live chat, web-push notifications, in-console payment-key configuration, an age gate, and per-page promo banners — each with the condition under which it would be built, so operators know what is and isn't supported rather than discovering a gap.
package/README.md CHANGED
@@ -68,7 +68,7 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
68
68
  | **`lib/shipping.js`** | Operator-table adapter. Services with zones (flat or per-gram + base + min/max), free-over-threshold, `digital_only` flag. |
69
69
  | **`lib/payment.js`** | Payment adapters — **Stripe** (verify webhook HMAC-SHA256 via upstream `b.webhook.verify`, create / retrieve / confirm / cancel PaymentIntent, refund, register / list payment-method domains for Apple/Google Pay) and **PayPal** (`adapter: "paypal"` — OAuth2 client-credentials token, create / capture / get / refund Orders v2, webhook verify via PayPal's verify-webhook-signature API). No `stripe` / `paypal` npm dep — outbound through `b.httpClient` (SSRF-gated, retried, circuit-broken). |
70
70
  | **`lib/order.js`** | FSM-driven post-checkout record via upstream `b.fsm`. States: pending → paid → fulfilling → shipped → delivered (+ refunded / cancelled). Every transition appends to `order_transitions`. |
71
- | **`lib/checkout.js`** | Orchestrator. `quote()` returns priced quote; `confirm()` creates a Stripe PaymentIntent + persists order pending; `handleStripeEvent()` verifies webhook + fires the FSM transition. PayPal path: `createPaypalOrder()` opens a PayPal order + persists pending, `capturePaypalOrder()` captures → paid, `handlePaypalEvent()` is the webhook backstop. All idempotent on re-delivery. |
71
+ | **`lib/checkout.js`** | Orchestrator. `quote()` returns priced quote; `confirm()` validates the ship-to address (real ISO 3166-1 country; US/CA state + ZIP/postal formats; lenient elsewhere) and the customer email shape, then creates a Stripe PaymentIntent + persists order pending; `handleStripeEvent()` verifies webhook + fires the FSM transition. PayPal path: `createPaypalOrder()` opens a PayPal order + persists pending, `capturePaypalOrder()` captures → paid, `handlePaypalEvent()` is the webhook backstop. All idempotent on re-delivery. |
72
72
  | **`lib/email.js`** | Transactional templates — order receipt, ship notification, refund confirmation, wishlist price-drop, abandoned-cart, review request, back-in-stock, **wishlist digest** (the periodic saved-items rollup, rendered per-line from the structured digest so every title / price is independently escaped), and **email magic-link sign-in**. Strict `{{var}}` renderer with HTML escape + refusal of unknown / unused placeholders. Composed on `b.mail` (DKIM/SPF/DMARC/BIMI upstream). |
73
73
  | **`lib/storefront.js`** | Server-rendered HTML — utility bar + sticky header + dark hero with code-preview card + primitives marquee + featured-product callout + collections grid + framework feature band + designed catalog grid + newsletter band + four-column footer. Designed surfaces also for PDP, cart, checkout, pay, order, account login / register / dashboard, search results, `/admin` API landing, 404. Image-bearing cards on the home + search grids pull from `catalog.media`. The default theme stylesheet is external (R2-served `themes/default/assets/css/main.css`) and CSP-compliant; the typeface (Inter / Inter Tight) is self-hosted from `themes/default/assets/fonts`, so no page loads a cross-origin font. Operators override by uploading a replacement at the same key, by passing `opts.theme_css` to renderers, or by registering a named theme through the `theme` primitive. |
74
74
  | **`lib/customers.js`** | Customer accounts — passkey (WebAuthn) + **Sign in with Google / Apple** (OIDC). Email is stored hash-only (`b.crypto.namespaceHash` namespace `customer-email`); the raw address never lands in D1. Passkey credentials carry CBOR-encoded public keys, transport hints, and SHA3-512-fingerprinted attestation. `signInWithOIDC` keys federated accounts on the provider `(provider, subject)` and links an existing account only on a provider-verified email (never on an unverified one). `mintAppleClientSecret` produces Apple's required ES256 client-secret JWT from a Services-ID `.p8` key (the one classical signature the protocol mandates; the PQC default doesn't apply to an external IdP's wire format). Account routes (`/account/login`, `/account/register`, `/account`, `/account/login/google`, `/account/login/apple`) ship as designed cards on the storefront; signed-in customers manage their own passkeys (`/account/passkeys` — list, add another, confirm-gated revoke scoped to the account with a last-sign-in-method guard) and edit their profile (`/account/profile`). |
package/SECURITY.md CHANGED
@@ -120,6 +120,17 @@ node -e "
120
120
  the value identically on the Worker (`wrangler secret put
121
121
  D1_BRIDGE_SECRET`) and the container env. Rotate quarterly or after
122
122
  any Worker-credential compromise.
123
+ - **Referrer-Policy is load-bearing for CSRF.** The app sends
124
+ `Referrer-Policy: same-origin` on both the edge and the container.
125
+ Browsers serialize the `Origin` header as `null` on same-origin
126
+ navigational form POSTs when the page's referrer policy is
127
+ `no-referrer`, and the CSRF gate's origin pre-check refuses a null
128
+ Origin before the token is read — switching the policy back to
129
+ `no-referrer` 403s every tokened form submission (checkout,
130
+ sign-in, account, admin) in real browsers while loopback tests
131
+ stay green. Keep `same-origin` (referrer data still never leaves
132
+ the site), or pair any stricter policy with an origin-tolerant
133
+ CSRF configuration you have tested in a real browser.
123
134
  - **Stripe webhook signature.** Inbound `POST` to
124
135
  `/api/webhooks/stripe` is signature-verified at the Worker edge
125
136
  (HMAC-SHA256 over `<timestamp>.<body>`, 5-minute tolerance window)
@@ -1,13 +1,13 @@
1
1
  {
2
- "version": "0.3.57",
2
+ "version": "0.3.58",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-6k53cvkRrxMgmeStLIoLjVXZQHqIJgTmv1Izd8TYhh1HOC4POgE6GCvx1bsalyEP",
6
6
  "fingerprinted": "css/admin.44eb97700c660798.css"
7
7
  },
8
8
  "css/main.css": {
9
- "integrity": "sha384-2nlE5YDlOjcnP/l1EY5kcYwvbV/TboHx+Hi6fAaDNyaau3nzQppJnNyXoLiFE8w1",
10
- "fingerprinted": "css/main.59afbe779336867a.css"
9
+ "integrity": "sha384-pEf1nxijlK9mXs8LacGqzNmr+WzjMR7c17Gsx24YS1PharS/BWa62RU/fUL8XSa6",
10
+ "fingerprinted": "css/main.bb97cd4c4b867440.css"
11
11
  },
12
12
  "js/announcement.js": {
13
13
  "integrity": "sha384-z4zcEMn+tScoVnYRE4nEf8N/oyvpxdpaxTNrT4QO/jURChid4+qjAvWkzatCaAPq",
package/lib/checkout.js CHANGED
@@ -47,16 +47,83 @@ function _email(s) {
47
47
  catch (e) { throw new TypeError("checkout: customer.email — " + (e && e.message || "invalid email")); }
48
48
  }
49
49
 
50
+ // ---- address format validation -------------------------------------------
51
+ //
52
+ // ISO 3166-1 alpha-2 validity via the platform Intl data (zero-dep): an
53
+ // assigned region code resolves to a display name, an unknown one round-
54
+ // trips unchanged, so the `.of()` echo test distinguishes them. CLDR also
55
+ // names a handful of reserved/sentinel codes that are NOT assigned ISO
56
+ // 3166-1 countries (ZZ "Unknown Region", UK/EU/EZ/UN/SU/FX, the XA/XB
57
+ // pseudo-locales, QO) — deny-listed below so a shopper typing "UK" gets
58
+ // the crisp field error pointing at GB instead of a confusing no-
59
+ // shipping-zone failure later. XK (Kosovo, user-assigned) stays accepted
60
+ // — it ships. Memoized — one formatter per process. Falls back to the
61
+ // shape check if Intl region data is unavailable so checkout never
62
+ // hard-fails on a stripped ICU build.
63
+ var _NON_ISO_REGION = Object.freeze({
64
+ ZZ: 1, UK: 1, EU: 1, EZ: 1, UN: 1, SU: 1, FX: 1, XA: 1, XB: 1, QO: 1,
65
+ });
66
+ var _regionNames = null;
67
+ function _isIsoCountry(cc) {
68
+ if (typeof cc !== "string" || !/^[A-Z]{2}$/.test(cc)) return false;
69
+ if (_NON_ISO_REGION[cc]) return false;
70
+ try {
71
+ if (!_regionNames) _regionNames = new Intl.DisplayNames(["en"], { type: "region" });
72
+ return _regionNames.of(cc) !== cc; // unknown codes echo the input back
73
+ } catch (_e) {
74
+ return true; // Intl region data unavailable → shape-only validation
75
+ }
76
+ }
77
+
78
+ // USPS state/territory and Canada Post province/territory codes, plus the
79
+ // two countries' postal formats. Format validation tightens ONLY for these
80
+ // two well-known sets; every other destination keeps the lenient length-
81
+ // bounded checks — international address formats defy tight rules, and
82
+ // over-validating them locks real customers out of checkout.
83
+ var _US_STATES = Object.freeze({
84
+ AL: 1, AK: 1, AZ: 1, AR: 1, CA: 1, CO: 1, CT: 1, DE: 1, DC: 1, FL: 1,
85
+ GA: 1, HI: 1, ID: 1, IL: 1, IN: 1, IA: 1, KS: 1, KY: 1, LA: 1, ME: 1,
86
+ MD: 1, MA: 1, MI: 1, MN: 1, MS: 1, MO: 1, MT: 1, NE: 1, NV: 1, NH: 1,
87
+ NJ: 1, NM: 1, NY: 1, NC: 1, ND: 1, OH: 1, OK: 1, OR: 1, PA: 1, RI: 1,
88
+ SC: 1, SD: 1, TN: 1, TX: 1, UT: 1, VT: 1, VA: 1, WA: 1, WV: 1, WI: 1,
89
+ WY: 1, AS: 1, GU: 1, MP: 1, PR: 1, VI: 1,
90
+ });
91
+ var _CA_PROVINCES = Object.freeze({
92
+ AB: 1, BC: 1, MB: 1, NB: 1, NL: 1, NS: 1, NT: 1, NU: 1, ON: 1, PE: 1,
93
+ QC: 1, SK: 1, YT: 1,
94
+ });
95
+ var _US_ZIP = /^\d{5}(-\d{4})?$/;
96
+ var _CA_POSTAL = /^[A-Za-z]\d[A-Za-z] ?\d[A-Za-z]\d$/;
97
+
50
98
  function _shipTo(s) {
51
99
  if (!s || typeof s !== "object") throw new TypeError("checkout: ship_to must be an object");
52
- if (typeof s.country !== "string" || !/^[A-Z]{2}$/.test(s.country)) {
53
- throw new TypeError("checkout: ship_to.country must be a 2-letter ISO 3166-1 code");
100
+ if (typeof s.country !== "string" || !_isIsoCountry(s.country)) {
101
+ throw new TypeError("checkout: ship_to.country Enter a valid two-letter country code (e.g. US, GB).");
54
102
  }
55
- if (s.state && (typeof s.state !== "string" || !/^[A-Z0-9]{1,5}$/.test(s.state))) {
56
- throw new TypeError("checkout: ship_to.state malformed");
57
- }
58
- if (s.postal && (typeof s.postal !== "string" || !/^[A-Za-z0-9 -]{1,16}$/.test(s.postal))) {
59
- throw new TypeError("checkout: ship_to.postal malformed");
103
+ // US + Canada get real region-code and postal-format validation when the
104
+ // fields are present; presence itself is the storefront form's contract
105
+ // (a digital-only order legitimately carries a bare country). The
106
+ // messages are customer-facing the checkout form echoes them on the
107
+ // rejected field.
108
+ if (s.country === "US" || s.country === "CA") {
109
+ var regionCodes = s.country === "US" ? _US_STATES : _CA_PROVINCES;
110
+ if (s.state && (typeof s.state !== "string" || !Object.prototype.hasOwnProperty.call(regionCodes, s.state))) {
111
+ throw new TypeError(s.country === "US"
112
+ ? "checkout: ship_to.state — Enter a valid US state or territory code (e.g. CA)."
113
+ : "checkout: ship_to.state — Enter a valid Canadian province code (e.g. ON).");
114
+ }
115
+ if (s.postal && (typeof s.postal !== "string" || !(s.country === "US" ? _US_ZIP : _CA_POSTAL).test(s.postal))) {
116
+ throw new TypeError(s.country === "US"
117
+ ? "checkout: ship_to.postal — Enter a valid ZIP code (12345 or 12345-6789)."
118
+ : "checkout: ship_to.postal — Enter a valid postal code (A1A 1A1).");
119
+ }
120
+ } else {
121
+ if (s.state && (typeof s.state !== "string" || !/^[A-Za-z0-9 .-]{1,32}$/.test(s.state))) {
122
+ throw new TypeError("checkout: ship_to.state — Enter a valid state / province code.");
123
+ }
124
+ if (s.postal && (typeof s.postal !== "string" || !/^[A-Za-z0-9 -]{1,16}$/.test(s.postal))) {
125
+ throw new TypeError("checkout: ship_to.postal — Enter a valid postal code.");
126
+ }
60
127
  }
61
128
  // Street address + city are free-text (international addresses defy a
62
129
  // tight character class), so validation is shape + length only. They
@@ -66,13 +133,13 @@ function _shipTo(s) {
66
133
  // export). The checkout form marks line1 + city required so the
67
134
  // common physical-goods path collects a complete address.
68
135
  if (s.line1 && (typeof s.line1 !== "string" || s.line1.length > 200)) {
69
- throw new TypeError("checkout: ship_to.line1 malformed");
136
+ throw new TypeError("checkout: ship_to.line1 — Enter a street address of 200 characters or fewer.");
70
137
  }
71
138
  if (s.line2 && (typeof s.line2 !== "string" || s.line2.length > 200)) {
72
- throw new TypeError("checkout: ship_to.line2 malformed");
139
+ throw new TypeError("checkout: ship_to.line2 — Enter an apt / suite of 200 characters or fewer.");
73
140
  }
74
141
  if (s.city && (typeof s.city !== "string" || s.city.length > 120)) {
75
- throw new TypeError("checkout: ship_to.city malformed");
142
+ throw new TypeError("checkout: ship_to.city — Enter a city of 120 characters or fewer.");
76
143
  }
77
144
  return s;
78
145
  }
@@ -611,6 +678,18 @@ function create(deps) {
611
678
  if (!input || typeof input !== "object") throw new TypeError("checkout.confirm: input required");
612
679
  if (!input.customer || typeof input.customer !== "object") throw new TypeError("checkout.confirm: customer required");
613
680
  var email = _email(input.customer.customer_email || input.customer.email);
681
+ // guardEmail.sanitize is a content-safety gate (CRLF smuggling,
682
+ // homoglyphs, multi-@) — it passes a plain non-address string
683
+ // through unchanged, so the shape gate lives here: a single @, a
684
+ // dotted domain, the RFC 5321 length cap. Customer-facing message —
685
+ // the checkout form echoes it on the field.
686
+ if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email) || email.length > 254) {
687
+ throw new TypeError("checkout: customer.email — Enter a valid email address.");
688
+ }
689
+ var custName = input.customer.name;
690
+ if (custName != null && (typeof custName !== "string" || custName.length > 120)) {
691
+ throw new TypeError("checkout: customer.name — Enter a name of 120 characters or fewer.");
692
+ }
614
693
  if (!input.selected_shipping_id) throw new TypeError("checkout.confirm: selected_shipping_id required");
615
694
  var idempotencyKey = input.idempotency_key;
616
695
  if (typeof idempotencyKey !== "string" || idempotencyKey.length < 8) {
@@ -193,9 +193,25 @@ function clientKey(req) {
193
193
  * Options, etc.) stays ON. Pass-through to the framework primitive — we
194
194
  * compose its `documentPolicy: false` override, never patch the vendored
195
195
  * tree.
196
+ *
197
+ * `referrerPolicy: "same-origin"` overrides the vendored `no-referrer`
198
+ * default. Under `no-referrer`, browsers serialize the Origin header as
199
+ * the opaque string "null" on same-origin NAVIGATIONAL form POSTs (Fetch
200
+ * spec: a no-referrer referrer policy opaques the Origin on non-GET
201
+ * navigations). The CSRF gate's origin pre-check refuses "null" before
202
+ * the double-submit token is read, so every checkout / sign-in / account
203
+ * / admin form submit 403s in a real browser — while loopback tests,
204
+ * which send no Origin header at all, pass. `same-origin` keeps the
205
+ * external privacy posture identical (no referrer ever leaves the site)
206
+ * while restoring a real Origin on same-origin POSTs, which the gate
207
+ * verifies against PUBLIC_ORIGINS. Cross-site and sandboxed-iframe
208
+ * submits still arrive with a foreign or "null" Origin and are still
209
+ * refused — the defense is unchanged; only the false positive is gone.
210
+ * The Worker emits the same policy (worker/index.js _SECURITY_HEADERS)
211
+ * so both substrates stay header-consistent.
196
212
  */
197
213
  function securityHeadersOpts() {
198
- return { documentPolicy: false };
214
+ return { documentPolicy: false, referrerPolicy: "same-origin" };
199
215
  }
200
216
 
201
217
  // ---- route-scoped CSP (payment processors + CAPTCHA providers) ----------
package/lib/storefront.js CHANGED
@@ -2218,6 +2218,25 @@ function _fieldFromValidatorError(e, modulePrefix, formFields) {
2218
2218
  return { field: field, message: raw.replace(prefixRe, "") };
2219
2219
  }
2220
2220
 
2221
+ // Checkout's validators throw dotted container paths — "checkout:
2222
+ // ship_to.<field> <reason>" / "checkout: customer.<field> <reason>" — so the
2223
+ // shared extractor above (whose <field> token is undotted) can't map them to
2224
+ // the checkout form's flat input names. This strips the container segment
2225
+ // (and the em-dash separator the checkout messages carry) and maps the inner
2226
+ // token to the form's fields; non-form internals return null so only the
2227
+ // page-top banner shows for them.
2228
+ var _CHECKOUT_FORM_FIELDS = {
2229
+ email: 1, name: 1, line1: 1, line2: 1, city: 1, state: 1, postal: 1, country: 1,
2230
+ };
2231
+ function _checkoutFieldFromError(e) {
2232
+ var raw = (e && e.message) || "";
2233
+ var m = /^checkout(?:\.\w+)?:\s+(?:ship_to|customer)\.([a-z_0-9]+)\b/.exec(raw);
2234
+ var field = m && m[1];
2235
+ if (!field || !Object.prototype.hasOwnProperty.call(_CHECKOUT_FORM_FIELDS, field)) return null;
2236
+ var message = raw.replace(/^checkout(?:\.\w+)?:\s+(?:ship_to|customer)\.[a-z_0-9]+\s*(?:—|-)?\s*/, "");
2237
+ return { field: field, message: message || "Please check this field." };
2238
+ }
2239
+
2221
2240
  // Build the aria-invalid + aria-describedby attribute fragment for an input
2222
2241
  // when `inv` (a { field, message } from _fieldFromValidatorError) names this
2223
2242
  // field. Empty string otherwise. `idPrefix` + field is the static, escaped id.
@@ -6388,29 +6407,46 @@ var CART_LINE_EDITABLE =
6388
6407
  // same labelled-input builder the account address book uses) so the
6389
6408
  // checkout + saved-address forms collect an identical address shape.
6390
6409
  // `p` pre-fills each field (from a signed-in customer's default shipping
6391
- // address); every value is escaped by `_addrField` / the email builder.
6410
+ // address, or the shopper's own typed values on a validation re-render);
6411
+ // every value is escaped by `_addrField` / the email builder.
6392
6412
  // Street line 1 + city are marked required for the common physical-goods
6393
- // path; the backend (checkout._shipTo) is authoritative and treats them
6394
- // as optional so a digital-only order still completes.
6395
- function _checkoutShippingFields(p) {
6413
+ // path, and the POST handler enforces the same set server-side via
6414
+ // _requireCheckoutFields (backend validates, frontend displays). The
6415
+ // service tier (checkout._shipTo) stays presence-optional so a non-form
6416
+ // caller can still complete a digital-only order with a bare country.
6417
+ function _checkoutShippingFields(p, inv) {
6396
6418
  p = p || {};
6397
6419
  var esc = b.template.escapeHtml;
6420
+ // Merge the per-field invalid marker (a { field, message } from
6421
+ // _checkoutFieldFromError) into a field's opts when it is the one the
6422
+ // backend validator rejected. Static "co-err-<name>" id — attacker-
6423
+ // uncontrolled. Mirrors _addressForm's _mark.
6424
+ function _mark(name, opts) {
6425
+ if (inv && inv.field === name) {
6426
+ opts.invalid = true;
6427
+ opts.error_id = "co-err-" + name;
6428
+ opts.error_msg = inv.message;
6429
+ }
6430
+ return opts;
6431
+ }
6398
6432
  var email =
6399
6433
  "<label class=\"form-field\">" +
6400
6434
  "<span class=\"form-field__label\">Email <span class=\"form-field__req\" aria-hidden=\"true\">*</span><span class=\"sr-only\">(required)</span></span>" +
6401
- "<input type=\"email\" name=\"email\" value=\"" + esc(p.email == null ? "" : String(p.email)) + "\" required autocomplete=\"email\">" +
6435
+ "<input type=\"email\" name=\"email\" value=\"" + esc(p.email == null ? "" : String(p.email)) + "\" required autocomplete=\"email\"" +
6436
+ _fieldAriaAttr("co-err-", "email", inv) + ">" +
6437
+ _fieldErrorSpan("co-err-", "email", inv) +
6402
6438
  "</label>";
6403
6439
  return email +
6404
- _addrField("name", "Full name", p.name, { required: true, maxlength: 120, autocomplete: "name" }) +
6405
- _addrField("line1", "Street address", p.line1, { required: true, maxlength: 200, autocomplete: "address-line1" }) +
6406
- _addrField("line2", "Apt / suite / unit (optional)", p.line2, { maxlength: 200, autocomplete: "address-line2" }) +
6440
+ _addrField("name", "Full name", p.name, _mark("name", { required: true, maxlength: 120, autocomplete: "name" })) +
6441
+ _addrField("line1", "Street address", p.line1, _mark("line1", { required: true, maxlength: 200, autocomplete: "address-line1" })) +
6442
+ _addrField("line2", "Apt / suite / unit (optional)", p.line2, _mark("line2", { maxlength: 200, autocomplete: "address-line2" })) +
6407
6443
  "<div class=\"form-row form-row--inline\">" +
6408
- _addrField("city", "City", p.city, { required: true, maxlength: 120, autocomplete: "address-level2" }) +
6409
- _addrField("state", "State / province code", p.state, { maxlength: 5, pattern: "[A-Za-z0-9]{1,5}", autocomplete: "address-level1" }) +
6444
+ _addrField("city", "City", p.city, _mark("city", { required: true, maxlength: 120, autocomplete: "address-level2" })) +
6445
+ _addrField("state", "State / province code", p.state, _mark("state", { maxlength: 5, pattern: "[A-Za-z0-9]{1,5}", autocomplete: "address-level1" })) +
6410
6446
  "</div>" +
6411
6447
  "<div class=\"form-row form-row--inline\">" +
6412
- _addrField("postal", "Postal code", p.postal, { maxlength: 16, autocomplete: "postal-code" }) +
6413
- _addrField("country", "Country (ISO 3166-1)", p.country || "US", { required: true, maxlength: 2, pattern: "[A-Za-z]{2}", autocomplete: "country" }) +
6448
+ _addrField("postal", "Postal code", p.postal, _mark("postal", { maxlength: 16, autocomplete: "postal-code" })) +
6449
+ _addrField("country", "Country (ISO 3166-1)", p.country || "US", _mark("country", { required: true, maxlength: 2, pattern: "[A-Za-z]{2}", autocomplete: "country" })) +
6414
6450
  "</div>";
6415
6451
  }
6416
6452
 
@@ -6432,6 +6468,41 @@ function _shipToFromBody(body) {
6432
6468
  };
6433
6469
  }
6434
6470
 
6471
+ // Required fields for the checkout FORM path, enforced before confirm so an
6472
+ // empty field gets its own per-field error instead of a service-tier shape
6473
+ // failure deeper in. The service tier (checkout._shipTo) keeps presence
6474
+ // optional — a digital-only order legitimately ships nothing — but the
6475
+ // storefront form marks email / name / line1 / city required, and the
6476
+ // backend enforces the same contract (backend validates, frontend
6477
+ // displays). US + Canada additionally need a region code and postal code:
6478
+ // both feed destination tax math. Throws in the same "checkout:
6479
+ // <container>.<field> — <message>" shape as the service tier so
6480
+ // _checkoutFieldFromError maps each to its input. The PayPal create route
6481
+ // shares _shipToFromBody but not this gate — its format errors surface
6482
+ // through the same service-tier validators.
6483
+ function _requireCheckoutFields(body, shipTo) {
6484
+ if (!body.email || !String(body.email).trim()) {
6485
+ throw new TypeError("checkout: customer.email — Enter your email address.");
6486
+ }
6487
+ if (!body.name || !String(body.name).trim()) {
6488
+ throw new TypeError("checkout: customer.name — Enter the recipient's full name.");
6489
+ }
6490
+ if (!shipTo.line1) throw new TypeError("checkout: ship_to.line1 — Enter a street address.");
6491
+ if (!shipTo.city) throw new TypeError("checkout: ship_to.city — Enter a city.");
6492
+ if (shipTo.country === "US" || shipTo.country === "CA") {
6493
+ if (!shipTo.state) {
6494
+ throw new TypeError(shipTo.country === "US"
6495
+ ? "checkout: ship_to.state — Enter a US state or territory code (e.g. CA)."
6496
+ : "checkout: ship_to.state — Enter a Canadian province code (e.g. ON).");
6497
+ }
6498
+ if (!shipTo.postal) {
6499
+ throw new TypeError(shipTo.country === "US"
6500
+ ? "checkout: ship_to.postal — Enter a ZIP code."
6501
+ : "checkout: ship_to.postal — Enter a postal code.");
6502
+ }
6503
+ }
6504
+ }
6505
+
6435
6506
  // Checkout mirrors the cart's two-column shell: the shipping form on the
6436
6507
  // left, a sticky order-summary rail on the right. The summary lists the
6437
6508
  // cart line items + a full Subtotal → tax → shipping → discount → Total
@@ -6470,6 +6541,13 @@ var CHECKOUT_PAGE =
6470
6541
  " </div>\n" +
6471
6542
  "</section>\n";
6472
6543
 
6544
+ // Splice anchor for the optional checkout sub-blocks (pickup picker, gift
6545
+ // options, loyalty redeem, CAPTCHA). They insert BEFORE this literal so
6546
+ // they land inside the form, above the submit row — appending before
6547
+ // "</form>" instead would orphan them below the CTA. Must stay
6548
+ // byte-identical to the action-row line in CHECKOUT_PAGE above.
6549
+ var CHECKOUT_ACTIONS_ANCHOR = " <div class=\"form-actions\">";
6550
+
6473
6551
  // One order-summary line item in the checkout rail: thumbnail + title +
6474
6552
  // qty + line total, mirroring the cart row but compact. `l` is the raw
6475
6553
  // cart line; `lookup` is the variant_id → { product, hero_media } map the
@@ -6531,9 +6609,9 @@ function _checkoutGiftFields(opts) {
6531
6609
  (hasWrap
6532
6610
  ? "<p class=\"checkout-gift__note\">A gift wrap is in your cart. Add a message and recipient below.</p>"
6533
6611
  : "<p class=\"checkout-gift__note\">Sending this as a gift? Add a message and recipient. (Choose a gift wrap on the cart page.)</p>") +
6534
- "<label class=\"form-field\"><span>Recipient name <small>(optional)</small></span>" +
6612
+ "<label class=\"form-field\"><span class=\"form-field__label\">Recipient name <span class=\"small\">(optional)</span></span>" +
6535
6613
  "<input type=\"text\" name=\"gift_recipient_name\" maxlength=\"120\" autocomplete=\"off\"></label>" +
6536
- "<label class=\"form-field\"><span>Gift message <small>(optional)</small></span>" +
6614
+ "<label class=\"form-field\"><span class=\"form-field__label\">Gift message <span class=\"small\">(optional)</span></span>" +
6537
6615
  "<textarea name=\"gift_message\" rows=\"3\" maxlength=\"500\" placeholder=\"Add a note for the recipient\"></textarea></label>" +
6538
6616
  "<label class=\"form-field form-field--check\"><input type=\"checkbox\" name=\"gift_hide_prices\" value=\"1\">" +
6539
6617
  "<span>Hide prices on the packing slip (gift receipt)</span></label>" +
@@ -6558,7 +6636,7 @@ function _checkoutPickupPicker(opts) {
6558
6636
  }).join("");
6559
6637
  return "<fieldset class=\"checkout-pickup\">" +
6560
6638
  "<legend>Delivery method</legend>" +
6561
- "<label class=\"form-field\"><span>How would you like to get your order?</span>" +
6639
+ "<label class=\"form-field\"><span class=\"form-field__label\">How would you like to get your order?</span>" +
6562
6640
  "<select name=\"pickup_location_code\">" + options + "</select></label>" +
6563
6641
  "<p class=\"checkout-pickup__note\">Choose a store to pick up in person, or ship to your address. Shipping is still quoted on the total.</p>" +
6564
6642
  "</fieldset>";
@@ -6620,44 +6698,42 @@ function renderCheckoutForm(opts) {
6620
6698
  var inlineError = opts.inline_error
6621
6699
  ? "<p class=\"auth-form__message auth-form__message--err\" role=\"alert\">" + b.template.escapeHtml(String(opts.inline_error)) + "</p>"
6622
6700
  : "";
6623
- var body = _render(CHECKOUT_PAGE, {})
6624
- .replace("RAW_TOTALS_ROWS", totalsRows)
6625
- .replace("RAW_SUMMARY_NOTE", summaryNote)
6626
- .replace("RAW_INLINE_ERROR", inlineError)
6627
- .replace("RAW_SHIPPING_FIELDS", _checkoutShippingFields(opts.prefill))
6628
- .replace("RAW_SUMMARY_LINES", summaryLines);
6629
- // CAPTCHA challenge inside the form — when the operator has an active
6630
- // provider (opts.captcha_kind + opts.captcha_public_key set), the widget
6631
- // renders just above the submit button so its hidden token field rides the
6632
- // form POST. Absent a provider this is "" — the checkout is byte-identical
6633
- // to the unconfigured store. The route-scoped CSP that admits the provider
6634
- // SDK host is set by the GET/POST /checkout handlers.
6635
- if (checkoutCaptcha) {
6636
- body = body.replace("</form>", checkoutCaptcha + "</form>");
6637
- }
6638
- // Pick-up-in-store location picker + gift personalization fields, appended
6639
- // into the form (container-only). Spliced via _spliceRaw so an operator
6640
- // free-text location name carrying a `$` can't trip dollar substitution.
6641
- var checkoutPickup = _checkoutPickupPicker(opts);
6642
- if (checkoutPickup) {
6643
- body = _spliceRaw(body, "</form>", checkoutPickup + "</form>");
6701
+ // Every RAW_* swap goes through _spliceRaw: the shipping fields echo
6702
+ // customer-typed values on a validation re-render and the summary lines
6703
+ // carry operator product titles — a `$&`/"$`" in either would corrupt
6704
+ // the document via String.replace dollar substitution. `opts.
6705
+ // invalid_field` (a { field, message } from _checkoutFieldFromError)
6706
+ // marks the one rejected input with aria-invalid + a per-field error
6707
+ // span; null renders the no-error form byte-identically.
6708
+ var body = _render(CHECKOUT_PAGE, {});
6709
+ body = _spliceRaw(body, "RAW_TOTALS_ROWS", totalsRows);
6710
+ body = _spliceRaw(body, "RAW_SUMMARY_NOTE", summaryNote);
6711
+ body = _spliceRaw(body, "RAW_INLINE_ERROR", inlineError);
6712
+ body = _spliceRaw(body, "RAW_SHIPPING_FIELDS", _checkoutShippingFields(opts.prefill, opts.invalid_field));
6713
+ body = _spliceRaw(body, "RAW_SUMMARY_LINES", summaryLines);
6714
+ // Pick-up-in-store picker, gift personalization, loyalty redeem, and the
6715
+ // CAPTCHA widget are spliced in ABOVE the submit action row — inside the
6716
+ // form in reading order, never after the CTA where they render as an
6717
+ // orphaned block below the button. Order: delivery method, gift options,
6718
+ // loyalty redeem, then the CAPTCHA directly above the button so its
6719
+ // hidden token field rides the form POST. Each builder returns "" when
6720
+ // its feature isn't wired, so an unconfigured store's checkout is
6721
+ // byte-identical. Spliced via _spliceRaw (never String.replace) so an
6722
+ // operator free-text location name carrying a `$` can't trip dollar
6723
+ // substitution. The loyalty block's balance + value are numbers we
6724
+ // control and the conversion ratio is the ledger's own constant; it
6725
+ // renders only when there's a balance to spend.
6726
+ var preActions = _checkoutPickupPicker(opts) + _checkoutGiftFields(opts);
6727
+ if (opts.loyalty_balance && opts.loyalty_balance.balance > 0) {
6728
+ preActions += _loyaltyCheckoutField(opts.loyalty_balance, opts.loyalty_points_per_usd);
6644
6729
  }
6645
- var checkoutGift = _checkoutGiftFields(opts);
6646
- if (checkoutGift) {
6647
- body = body.replace("</form>", checkoutGift + "</form>");
6730
+ // The route-scoped CSP that admits the CAPTCHA provider SDK host is set
6731
+ // by the GET/POST /checkout handlers.
6732
+ if (checkoutCaptcha) {
6733
+ preActions += checkoutCaptcha;
6648
6734
  }
6649
- // Signed-in customer with a spendable points balance — surface a
6650
- // redeem-at-checkout field. The block is appended as raw HTML (the
6651
- // balance + value are numbers we control, the conversion ratio is the
6652
- // ledger's own constant) so it slots into the existing form via a
6653
- // small client island that copies the field into the POST. Rendered
6654
- // only when there's a balance to spend; absent that the checkout is
6655
- // unchanged for guests + zero-balance customers.
6656
- if (opts.loyalty_balance && opts.loyalty_balance.balance > 0) {
6657
- body = body.replace(
6658
- "</form>",
6659
- _loyaltyCheckoutField(opts.loyalty_balance, opts.loyalty_points_per_usd) + "</form>",
6660
- );
6735
+ if (preActions) {
6736
+ body = _spliceRaw(body, CHECKOUT_ACTIONS_ANCHOR, preActions + CHECKOUT_ACTIONS_ANCHOR);
6661
6737
  }
6662
6738
  // When PayPal is configured, append its button below the card form. The
6663
6739
  // block is built as raw HTML (appended after the strict render) so the SDK
@@ -12235,6 +12311,7 @@ function mount(router, deps) {
12235
12311
  }
12236
12312
  var shipTo = _shipToFromBody(body);
12237
12313
  try {
12314
+ _requireCheckoutFields(body, shipTo);
12238
12315
  // default_shipping_id may be a literal string or an
12239
12316
  // operator-supplied async resolver (e.g. backed by the
12240
12317
  // config primitive) so re-reads happen per request without
@@ -12303,6 +12380,34 @@ function mount(router, deps) {
12303
12380
  }
12304
12381
  } catch (_re) { /* fall through to the styled error page */ }
12305
12382
  }
12383
+ // A validation TypeError naming one of the form's own fields is the
12384
+ // shopper's to fix in place: re-render the shipping form with that
12385
+ // field marked (aria-invalid + adjacent error span) and every typed
12386
+ // value preserved. Guests have no saved-address prefill, so the POST
12387
+ // body is the only source of what they typed — echo it (escaped at
12388
+ // the field builder). Totals re-render as the estimate (confirmedTo
12389
+ // null): the rejected address can't be priced, and re-pricing
12390
+ // against it would just re-throw the same validator.
12391
+ if (e instanceof TypeError) {
12392
+ var invField = _checkoutFieldFromError(e);
12393
+ if (invField) {
12394
+ try {
12395
+ var vLines = await _repriceCartLines(await deps.cart.listLines(c.id));
12396
+ if (vLines.length) {
12397
+ _setCheckoutCsp(res);
12398
+ var vOpts = await _checkoutRenderOpts(req, c, vLines,
12399
+ "Some shipping details need a correction — check the highlighted field.", null);
12400
+ vOpts.prefill = {
12401
+ email: body.email, name: body.name,
12402
+ line1: body.line1, line2: body.line2, city: body.city,
12403
+ state: body.state, postal: body.postal, country: body.country,
12404
+ };
12405
+ vOpts.invalid_field = invField;
12406
+ return _send(res, 400, renderCheckoutForm(vOpts));
12407
+ }
12408
+ } catch (_re) { /* fall through to the styled error page */ }
12409
+ }
12410
+ }
12306
12411
  // A malformed address shape (TypeError) is still the shopper's to
12307
12412
  // fix; anything else is a server-side failure. Either way, render a
12308
12413
  // styled, recoverable page rather than raw text — back to the cart,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.3.57",
3
+ "version": "0.3.58",
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": {