@blamejs/blamejs-shop 0.3.56 → 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 +4 -0
- package/README.md +1 -1
- package/SECURITY.md +11 -0
- package/lib/asset-manifest.json +3 -3
- package/lib/checkout.js +89 -10
- package/lib/security-middleware.js +17 -1
- package/lib/storefront.js +156 -51
- package/lib/vendor/MANIFEST.json +84 -72
- package/lib/vendor/blamejs/CHANGELOG.md +6 -0
- package/lib/vendor/blamejs/README.md +3 -3
- package/lib/vendor/blamejs/SECURITY.md +5 -0
- package/lib/vendor/blamejs/api-snapshot.json +15 -3
- package/lib/vendor/blamejs/lib/agent-orchestrator.js +10 -4
- package/lib/vendor/blamejs/lib/ai-prompt.js +1 -1
- package/lib/vendor/blamejs/lib/app-shutdown.js +28 -0
- package/lib/vendor/blamejs/lib/archive-read.js +215 -16
- package/lib/vendor/blamejs/lib/archive.js +206 -52
- package/lib/vendor/blamejs/lib/auth/oauth.js +58 -0
- package/lib/vendor/blamejs/lib/auth/oid4vci.js +84 -27
- package/lib/vendor/blamejs/lib/breach-deadline.js +166 -1
- package/lib/vendor/blamejs/lib/cloud-events.js +3 -1
- package/lib/vendor/blamejs/lib/codepoint-class.js +21 -0
- package/lib/vendor/blamejs/lib/db-schema.js +120 -3
- package/lib/vendor/blamejs/lib/db.js +10 -3
- package/lib/vendor/blamejs/lib/error-page.js +93 -9
- package/lib/vendor/blamejs/lib/external-db.js +164 -13
- package/lib/vendor/blamejs/lib/guard-email.js +36 -3
- package/lib/vendor/blamejs/lib/http-client.js +37 -7
- package/lib/vendor/blamejs/lib/mail-auth.js +554 -55
- package/lib/vendor/blamejs/lib/mail-send-deliver.js +15 -5
- package/lib/vendor/blamejs/lib/mail-sieve.js +2 -1
- package/lib/vendor/blamejs/lib/middleware/ai-act-disclosure.js +88 -19
- package/lib/vendor/blamejs/lib/middleware/api-encrypt.js +58 -11
- package/lib/vendor/blamejs/lib/middleware/asyncapi-serve.js +56 -4
- package/lib/vendor/blamejs/lib/middleware/attach-user.js +45 -10
- package/lib/vendor/blamejs/lib/middleware/body-parser.js +70 -14
- package/lib/vendor/blamejs/lib/middleware/csp-report.js +30 -2
- package/lib/vendor/blamejs/lib/middleware/deny-response.js +29 -9
- package/lib/vendor/blamejs/lib/middleware/openapi-serve.js +56 -4
- package/lib/vendor/blamejs/lib/middleware/scim-server.js +301 -14
- package/lib/vendor/blamejs/lib/openapi-paths-builder.js +105 -29
- package/lib/vendor/blamejs/lib/openapi.js +225 -100
- package/lib/vendor/blamejs/lib/problem-details.js +15 -3
- package/lib/vendor/blamejs/lib/queue-local.js +148 -38
- package/lib/vendor/blamejs/lib/queue.js +41 -11
- package/lib/vendor/blamejs/lib/render.js +21 -3
- package/lib/vendor/blamejs/lib/router.js +13 -6
- package/lib/vendor/blamejs/lib/safe-buffer.js +55 -0
- package/lib/vendor/blamejs/lib/sse.js +7 -5
- package/lib/vendor/blamejs/lib/static.js +46 -17
- package/lib/vendor/blamejs/lib/uri-template.js +3 -1
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.14.17.json +57 -0
- package/lib/vendor/blamejs/release-notes/v0.14.18.json +127 -0
- package/lib/vendor/blamejs/release-notes/v0.14.19.json +61 -0
- package/lib/vendor/blamejs/test/00-primitives.js +151 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/agent-orchestrator.test.js +18 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/api-encrypt.test.js +86 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/app-shutdown.test.js +58 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/archive-read.test.js +201 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/archive.test.js +179 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/asyncapi.test.js +19 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/attach-user-bearer-scheme.test.js +154 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/body-parser-chunked-malformed.test.js +10 -8
- package/lib/vendor/blamejs/test/layer-0-primitives/body-parser-smuggling.test.js +99 -20
- package/lib/vendor/blamejs/test/layer-0-primitives/breach-deadline.test.js +85 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +50 -1
- package/lib/vendor/blamejs/test/layer-0-primitives/compliance-ai-act.test.js +63 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/csp-report.test.js +107 -2
- package/lib/vendor/blamejs/test/layer-0-primitives/db-schema-drift.test.js +145 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/deny-response.test.js +32 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/external-db-hardening.test.js +119 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/federation-vc-suite.test.js +121 -1
- package/lib/vendor/blamejs/test/layer-0-primitives/guard-email.test.js +14 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/http-client-stream.test.js +53 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/mail-auth.test.js +179 -5
- package/lib/vendor/blamejs/test/layer-0-primitives/mail-send-deliver.test.js +45 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/oauth-callback.test.js +80 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/openapi.test.js +177 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/queue-byo-db.test.js +312 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/scim-server.test.js +165 -2
- package/lib/vendor/blamejs/test/layer-0-primitives/sse.test.js +33 -1
- package/lib/vendor/blamejs/test/layer-0-primitives/static.test.js +59 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.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
|
+
|
|
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.
|
|
14
|
+
|
|
11
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.
|
|
12
16
|
|
|
13
17
|
- v0.3.55 (2026-06-02) — **Accessibility: field-level form-error messages and horizontally scrollable admin tables.** Two accessibility improvements. When a storefront form is rejected, the specific field at fault is now marked for assistive technology with an inline error message tied to that input, in addition to the existing summary at the top of the form. And wide tables in the admin console now scroll horizontally inside their own container instead of overflowing the page on a narrow or zoomed viewport. **Fixed:** *Field-level error messages on storefront forms* — When the address, review, product-question, or support-ticket form is rejected, the offending field is now marked aria-invalid with an inline error message associated to it (aria-describedby), so a screen-reader user is taken to the exact field to fix rather than only hearing a top-of-form summary. The server remains the only validator; this only changes how a rejected field is presented. Forms that fail on a whole-form condition continue to show the summary message. · *Admin tables reflow on narrow screens* — Wide data tables in the admin console (orders, products, inventory, and the rest) are now wrapped in a horizontal-scroll container, so columns stay reachable on a smaller laptop window, a split screen, or at high zoom instead of overflowing the layout. Column header semantics are preserved.
|
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)
|
package/lib/asset-manifest.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.3.
|
|
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-
|
|
10
|
-
"fingerprinted": "css/main.
|
|
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" ||
|
|
53
|
-
throw new TypeError("checkout: ship_to.country
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
6394
|
-
//
|
|
6395
|
-
|
|
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)</
|
|
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)</
|
|
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
|
-
|
|
6624
|
-
|
|
6625
|
-
|
|
6626
|
-
|
|
6627
|
-
|
|
6628
|
-
|
|
6629
|
-
//
|
|
6630
|
-
|
|
6631
|
-
|
|
6632
|
-
|
|
6633
|
-
|
|
6634
|
-
|
|
6635
|
-
|
|
6636
|
-
|
|
6637
|
-
|
|
6638
|
-
//
|
|
6639
|
-
//
|
|
6640
|
-
//
|
|
6641
|
-
|
|
6642
|
-
|
|
6643
|
-
|
|
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
|
-
|
|
6646
|
-
|
|
6647
|
-
|
|
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
|
-
|
|
6650
|
-
|
|
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,
|