@blamejs/blamejs-shop 0.4.5 → 0.4.7

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.4.x
10
10
 
11
+ - v0.4.7 (2026-06-05) — **Payment page: Trusted Types now permits Stripe's loader through a real allowlist, and the Amazon Pay button loads.** Two payment-page fixes. The site enforces Trusted Types, and Stripe's browser library assigns script URLs without registering a policy — the console logged a TrustedScriptURL violation on every payment page, the failure class that can break a 3-D Secure challenge mid-payment. Rather than relaxing enforcement, the page now registers the default Trusted Types policy before Stripe loads: it admits script URLs from Stripe's own origins and throws for anything else, so enforcement stays on with a genuine gate. Second, the express-wallet row offered an Amazon Pay button whose frame the payment page's policy refused to load; the page's frame allowance now includes the Stripe-operated CDN that serves wallet buttons, scoped to the payment page only. Every other page keeps the strict policy unchanged. **Fixed:** *Trusted Types policy for the Stripe loader* — The payment page registers the default Trusted Types policy before the Stripe script tag, synchronously, integrity-pinned like every other script. The policy admits script URLs whose origin is js.stripe.com or a subdomain of it and throws for any other host — lookalike domains, plain http, and non-Stripe CDNs are refused. Browsers without Trusted Types are unaffected. Pages outside the payment route keep require-trusted-types-for enforcement with no registered loader policy, exactly as before. · *Express wallet buttons can load their frames* — The payment page's frame allowance adds Stripe's wallet-button CDN (b.stripecdn.com) and Stripe's dynamic subdomains (*.js.stripe.com) alongside the existing js.stripe.com and hooks.stripe.com entries, restoring the Amazon Pay express button. The allowance is scoped to the payment page; the storefront-wide policy still permits no third-party frames at all.
12
+
13
+ - v0.4.6 (2026-06-05) — **Guest order pages require proof of purchase, and guests can save their order to an account in one tap.** Two changes to what happens after a guest checks out. First, the order confirmation page — which shows the buyer's name, address, and items — is no longer reachable by anyone who learns the order id. A guest order now admits exactly three readers: the browser that placed it (a sealed device cookie set at checkout, surviving the payment redirect), anyone opening the signed link in the order-receipt email (an order-scoped, expiring signed token that works on any device), and the signed-in account that owns it. Everything else sees a 404 indistinguishable from a missing order, and the reorder action is gated the same way. Second, the confirmation page now offers a guest one tap to save the order to an account: it emails a sign-in link to the address they checked out with — shown masked on screen, held only in a short-lived encrypted cookie, never written to the database — and redeeming the link creates the account if needed and attaches their guest orders by a hashed-email match. The response is identical whether or not an account already exists, the trigger is rate-limited, and a second click sends nothing. **Added:** *One-tap account setup after checkout* — A guest buyer's confirmation page offers to save the order to an account: one click emails a magic sign-in link to the checkout address (masked on screen, e.g. r***@e***.com), creating the account on redemption if none exists and linking the buyer's guest orders by hashed-email match at the moment they prove control of the inbox. Signed-in buyers never see the offer; buyers whose address already has an account get sign-in wording with the identical send path. The plaintext address lives only in an encrypted cookie for fifteen minutes — the database keeps only the one-way hash it already kept. **Changed:** *Guest order confirmation pages are access-gated* — A guest order's page requires the placing browser's sealed device cookie (set at checkout, capped to recent orders, 30-day refresh), the signed token in the order-receipt email's view-order link (order-scoped, roughly 90-day expiry, constant-time verified), or the signed-in owner. Unauthorized requests — including a signed-in customer who does not own the order — receive a 404 identical to a missing order. The reorder action carries the same gate; cancel, rating, and return actions were already owner-only. The payment-provider return flow and order analytics are unaffected: the device cookie is set before the redirect to the payment page, so the buyer lands back on their confirmation with access intact.
14
+
11
15
  - v0.4.5 (2026-06-05) — **One sign-in screen: passkey first, email sign-in link as the built-in fallback.** The sign-in page previously offered the passkey ceremony with the email magic-link alternative a page away behind a link. Both now live on one screen: passkey stays the primary action, and an "Email me a sign-in link" form sits inline beneath it — a plain server-rendered form that works with JavaScript disabled. The page meets failure gracefully: a browser without WebAuthn support disables the passkey button and points to the email form, and a failed or cancelled passkey ceremony scrolls the shopper to the fallback with their typed email carried over, one tap from a sign-in link instead of a dead end. Responses are identical whether or not an account exists for the address, the trigger is rate-limited, and a visitor who is already signed in is redirected to their account instead of seeing a login form. **Changed:** *Sign-in page unifies passkey and email-link paths* — Passkey remains the primary action; the email sign-in link form renders inline on the same screen when transactional email is configured, works without JavaScript, and inherits the existing magic-link token semantics unchanged (single-use, expiring). Browsers without WebAuthn support are steered to the email form up front, and any passkey ceremony failure lands the shopper on the fallback with their email pre-filled. The email form's responses do not reveal whether an address has an account, the trigger endpoint is tightly rate-limited, and signed-in visitors are redirected away from the login form.
12
16
 
13
17
  - v0.4.4 (2026-06-05) — **Placement-targeted promo banners with scheduling, audiences, and click tracking.** Operators can now run placement-specific marketing banners alongside the existing sitewide announcement bar. A banner targets one of six placements — the top strip, the homepage hero, the product-page side, the cart side, the empty-search state, or the footer — with a schedule window, an audience (everyone, signed-in, or guests), one of four visual themes, a priority for choosing among overlapping banners, and a call-to-action whose clicks and impressions are counted. Banners are authored, edited, archived, and restored at /admin/promo-banners, render identically on edge-cached and container-rendered pages, and the click counter works on both. Separately, the edge's Permissions-Policy header now denies the same full directive set as the container's, closing a drift where nine newer denials were missing from edge-served pages, and a parity test keeps the two substrates locked together from here on. The vendored blamejs framework is refreshed from v0.14.21 to v0.14.22. **Added:** *Promo banners* — Define a banner with a slug, placement, headline, optional body, call-to-action, audience, schedule window, theme, and priority; the highest-priority active banner per placement renders. Operator-authored text is escaped at render on both substrates. Clicks route through a counting redirect that works whether the page came from the edge cache or the container; impressions count on container renders. The admin screen covers the full lifecycle including archive and restore, and the same operations are available as JSON under the admin bearer token. The sitewide announcement bar is unchanged and remains the simpler always-on notice strip — if you define a top-strip promo banner while an announcement is active, both render, so pick one for the top of the page. **Fixed:** *Edge pages deny the same Permissions-Policy directives as container pages* — The edge-served pages' Permissions-Policy was missing nine newer directives the container already denied (among them shared-storage, run-ad-auction, join-ad-interest-group, and smartcard). Both substrates now send the identical deny-all list, and a test fails the build if a framework update ever grows one list without the other.
package/README.md CHANGED
@@ -86,7 +86,7 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
86
86
  | **`lib/bundles.js`** | Sell products together at a set price. A product page shows the bundles it belongs to as a "Bundle & save" offer — the member products, the bundle price, and the saving against the parts. `POST /cart/bundle` adds the whole bundle in one action: the price is recomputed from the live catalog and allocated across the member lines (proportional to list price, remainder on the last line) so the cart subtotal matches the quoted price. The add is atomic — an archived or out-of-stock member shows the bundle unavailable and adds nothing rather than a partial set; an unpriceable bundle (missing member price / currency mismatch) is shown unavailable, never a 500. The client sends only the bundle code; the server prices it. The offer renders identically from the edge worker and the container. |
87
87
  | **`lib/quantity-discounts.js`** | Reward buying more. A product with quantity breaks shows its price tiers on the page (e.g. 1–2 / 3–5 / 6+ at descending unit prices). In the cart, each line is priced at the unit price for its quantity — reapplied on every cart render so changing the quantity re-prices, and again at checkout so the per-line price and order total written onto the order reflect the break, not the list price. A quantity under the first tier falls back to the base price; over the top tier takes the top tier. The client sends only a quantity; the server prices it. The tier table renders identically from the edge worker and the container. |
88
88
  | **`lib/recently-viewed.js`** | Signed-in customer browse history. A product-page visit records the view server-side against the customer's account (drop-silent — never blocks the page); `/account/recently-viewed` lists them newest-first as a grid with a Clear-history control. De-duped + capped per customer, archived products drop out, login-gated. Guest/session history is opt-in (a client beacon) and not shipped — the lib's `forSession` + `merge` support it. |
89
- | **`lib/recommendations.js`** | Product-recommendation engine. Operator-curated override pins first (`setOverride` — "when viewing A, show B", kind-scoped + weight-ordered), then a signal-based fallback: co-purchase (products bought in the same orders), category-popular, and in-stock-random filler. `recommendForProduct` / `recommendForCart` / `recommendForCustomer` / `recommendForCategory` each return renderable picks (active + in-stock, source product excluded). The order confirmation page (`/orders/:id`) renders a "Customers also bought" rail from it — best-effort, anchored on the order's items, excluding what was just bought. |
89
+ | **`lib/recommendations.js`** | Product-recommendation engine. Operator-curated override pins first (`setOverride` — "when viewing A, show B", kind-scoped + weight-ordered), then a signal-based fallback: co-purchase (products bought in the same orders), category-popular, and in-stock-random filler. `recommendForProduct` / `recommendForCart` / `recommendForCustomer` / `recommendForCategory` each return renderable picks (active + in-stock, source product excluded). The order confirmation page (`/orders/:id`) renders a "Customers also bought" rail from it — best-effort, anchored on the order's items, excluding what was just bought. The confirmation page itself is access-gated: a guest order admits the placing browser (sealed device cookie set at checkout), the signed link in the order-receipt email, or the signed-in owner — a bare order UUID 404s. A guest buyer also sees a one-tap "save this order to an account" offer there: it emails a sign-in link to the checkout address (shown masked; the plaintext lives only in a short-lived sealed cookie, never the database) and links their guest orders to the account when the link is redeemed. |
90
90
  | **`lib/collections.js`** | Curated + smart product groupings. `GET /collections` lists the shop's active collections; `GET /collections/:slug` renders the grid — manual collections list hand-picked members, smart collections evaluate stored rules against the active catalog and apply the collection's sort strategy. Each product resolves fresh, so archived products drop out. Public, no sign-in; a bad or unknown slug is a 404 (never a 500). Linked from the footer on every page. |
91
91
  | **`lib/category-navigation.js`** | Hierarchical category tree surfaced as public browse pages. `GET /categories` lists the active top-level categories as a card grid; `GET /categories/:slug` renders one category — its title and optional description, a breadcrumb chain from the catalog root down to the current category, an optional hero image, and a grid of the category's direct child sub-categories. Each page reads fresh against the active tree, so archived / unpublished categories drop out of every surface. Public, no sign-in; an unknown, archived, or malformed slug is a 404 (never a 500), and a category with no children renders a graceful empty state. Linked from the footer on every page. The tree itself (define / move / reorder / archive, with cycle defense bounded by `MAX_TREE_DEPTH`) is operator-managed through the primitive's write API. |
92
92
  | **`lib/search-facets.js`** | Filterable search. A search result page renders facet groups — collection, price range, in-stock — as server-rendered controls; selecting one narrows the results and rides the URL query string (`?q=…&collection=…&in_stock=1`), counts beside each option reflect the current result set, facets combine across groups, and active filters show as removable chips with a clear-all path that survives result pagination. All filtering is server-side from the query string (no client JS); unknown facet keys, out-of-range prices, and garbage values are ignored rather than erroring; an empty filtered result shows a clear-filters state. Runs identically at the edge worker and the container — the edge reads the catalog and facet registry straight from D1, missing-table-resilient. |
package/SECURITY.md CHANGED
@@ -112,6 +112,15 @@ node -e "
112
112
 
113
113
  ## Application checklist
114
114
 
115
+ - **Guest order pages are access-gated, not capability URLs.** A guest
116
+ order's confirmation page (name, address, items) requires the placing
117
+ browser's sealed device cookie, the signed `?k=` token carried by the
118
+ order-receipt email link (HMAC-SHA3-512, order-scoped, expiring), or
119
+ the signed-in owner — a bare order UUID returns 404. The token key is
120
+ derived from the app secret (`VAULT_PASSPHRASE`, falling back to
121
+ `D1_BRIDGE_SECRET`), so rotating that secret invalidates outstanding
122
+ emailed order links; rotate deliberately and expect customers to use
123
+ fresh links afterward.
115
124
  - **D1 bridge secret.** When deploying on Cloudflare, the container
116
125
  reaches D1 through a Worker service binding. The bridge is gated by
117
126
  a shared-secret header (`X-D1-Bridge-Secret`) so the route only
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.4.5",
2
+ "version": "0.4.7",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-6k53cvkRrxMgmeStLIoLjVXZQHqIJgTmv1Izd8TYhh1HOC4POgE6GCvx1bsalyEP",
@@ -37,6 +37,10 @@
37
37
  "integrity": "sha384-BjuUhbPZ18pHFMyOwT+309BEXu+VAc54RSj8lvN93jfFGDydEgmapiAOKTQM1yma",
38
38
  "fingerprinted": "js/passkey-register.02b0e196fb9608d8.js"
39
39
  },
40
+ "js/pay-trusted-types.js": {
41
+ "integrity": "sha384-jD90N8l7J3Vcu+aPSFRi6AE/5XKHQl58/avwHE9PH0mHwMavX991OCsEcznEf3n5",
42
+ "fingerprinted": "js/pay-trusted-types.53d0df13f9480a66.js"
43
+ },
40
44
  "js/pay.js": {
41
45
  "integrity": "sha384-W11JVQhv1RZq4WhsAOglu56gTZTDz1ByLd+b1HFQBCi8xGoEhJ0PZ2yI3FqbYzt6",
42
46
  "fingerprinted": "js/pay.683a905563e54a47.js"
package/lib/email.js CHANGED
@@ -71,7 +71,15 @@ function _formatMoney(amountMinor, currency) {
71
71
  // keys at composition time. Plain-text alternate parts use the same
72
72
  // data; mail clients pick whichever they prefer.
73
73
 
74
- var ORDER_RECEIPT_HTML =
74
+ // The receipt splits around an OPTIONAL "view your order" link block. The
75
+ // order-confirmation link carries a signed, order-scoped access token
76
+ // (?k=<token>) so a guest buyer can open their receipt on any device without
77
+ // the placing browser's access cookie. The token is minted by the caller
78
+ // (which holds the signing key); email only renders the already-built URL,
79
+ // HTML-escaped through the strict {{order_url}} slot like every other value.
80
+ // When no URL is supplied (e.g. the admin resend, which has no signing key),
81
+ // the link block is omitted entirely and the receipt renders as before.
82
+ var ORDER_RECEIPT_HTML_HEAD =
75
83
  "<!DOCTYPE html>\n" +
76
84
  "<html lang=\"en\"><head><meta charset=\"utf-8\"><title>Order receipt — {{order_id}}</title></head><body>\n" +
77
85
  "<h1>Thanks for your order</h1>\n" +
@@ -82,18 +90,29 @@ var ORDER_RECEIPT_HTML =
82
90
  " <tr><td>Tax ({{tax_jurisdiction}})</td><td align=\"right\">{{tax_formatted}}</td></tr>\n" +
83
91
  " <tr><td>Shipping</td><td align=\"right\">{{shipping_formatted}}</td></tr>\n" +
84
92
  " <tr><td><strong>Total</strong></td><td align=\"right\"><strong>{{grand_total_formatted}}</strong></td></tr>\n" +
85
- "</table>\n" +
93
+ "</table>\n";
94
+
95
+ var ORDER_RECEIPT_HTML_LINK =
96
+ "<p style=\"margin:24px 0;\"><a href=\"{{order_url}}\" style=\"background:#fa4f09;color:#ffffff;padding:12px 20px;text-decoration:none;display:inline-block;font-weight:bold;\">View your order</a></p>\n" +
97
+ "<p style=\"margin:0;color:#0d0d0d;font-size:13px;\">If the button doesn't work, paste this link into your browser: {{order_url}}</p>\n";
98
+
99
+ var ORDER_RECEIPT_HTML_TAIL =
86
100
  "<p>We'll email you again when your order ships.</p>\n" +
87
101
  "</body></html>\n";
88
102
 
89
- var ORDER_RECEIPT_TEXT =
103
+ var ORDER_RECEIPT_TEXT_HEAD =
90
104
  "Thanks for your order, {{customer_name}}.\n\n" +
91
105
  "Order: {{order_id}}\n" +
92
106
  "Subtotal: {{subtotal_formatted}}\n" +
93
107
  "Tax ({{tax_jurisdiction}}): {{tax_formatted}}\n" +
94
108
  "Shipping: {{shipping_formatted}}\n" +
95
109
  "-------------------------------------\n" +
96
- "Total: {{grand_total_formatted}}\n\n" +
110
+ "Total: {{grand_total_formatted}}\n\n";
111
+
112
+ var ORDER_RECEIPT_TEXT_LINK =
113
+ "View your order: {{order_url}}\n\n";
114
+
115
+ var ORDER_RECEIPT_TEXT_TAIL =
97
116
  "We'll email you again when your order ships.\n";
98
117
 
99
118
  var SHIP_NOTIFICATION_HTML =
@@ -349,12 +368,41 @@ function _email(s) {
349
368
  return b.guardEmail.sanitize(s, { profile: "strict" });
350
369
  }
351
370
 
371
+ // Guest-order access-token format. MUST stay byte-identical to the verifier
372
+ // in lib/storefront.js (`_verifyOrderAccessToken`): the link this module mints
373
+ // is validated there. `<exp_b36>.<hmac_hex[:32]>` over
374
+ // "order-access:v1:" + orderId + ":" + exp_b36, keyed by the operator's app-
375
+ // derived signing secret. HMAC-SHA3-512 is the PQC-aligned keyed MAC from the
376
+ // vendored crypto surface. ~90-day lifetime so a guest can reopen the link
377
+ // weeks later on any device. A change to either side without the other breaks
378
+ // every emitted link, so the two are kept in lockstep by this shared contract.
379
+ var ORDER_ACCESS_TOKEN_NS = "order-access:v1:";
380
+ var ORDER_ACCESS_TAG_HEX = 32;
381
+ var ORDER_ACCESS_TTL_MS = b.constants.TIME.days(90);
382
+
383
+ function _mintOrderAccessToken(secret, orderId) {
384
+ if (typeof secret !== "string" || !secret || typeof orderId !== "string" || !orderId) return "";
385
+ var expB36 = (Date.now() + ORDER_ACCESS_TTL_MS).toString(36);
386
+ var tag = b.crypto.hmacSha3(secret, ORDER_ACCESS_TOKEN_NS + orderId + ":" + expB36).slice(0, ORDER_ACCESS_TAG_HEX);
387
+ return expB36 + "." + tag;
388
+ }
389
+
352
390
  function create(opts) {
353
391
  opts = opts || {};
354
392
  if (!opts.mailer || typeof opts.mailer.send !== "function") {
355
393
  throw new TypeError("email.create: opts.mailer (a b.mail.create result with .send()) is required");
356
394
  }
357
395
  var mailer = opts.mailer;
396
+ // Optional order-confirmation deep-link material. When BOTH a signing
397
+ // secret and a shop origin are wired, `orderReceipt` auto-builds a
398
+ // tokenized "View your order" link (so a guest's resend / confirmation
399
+ // mail opens their receipt on any device). Absent either, the receipt
400
+ // simply omits the link (unchanged behaviour).
401
+ var orderAccessSecret = (typeof opts.orderAccessSecret === "string" && opts.orderAccessSecret) ? opts.orderAccessSecret : "";
402
+ // Trailing-slash trim as a loop, not `/\/+$/` — the regex shape is
403
+ // superlinear on long runs of '/' and this value is library input.
404
+ var shopOrigin = (typeof opts.shopOrigin === "string" && opts.shopOrigin) ? opts.shopOrigin : "";
405
+ while (shopOrigin.charCodeAt(shopOrigin.length - 1) === 47) shopOrigin = shopOrigin.slice(0, -1);
358
406
 
359
407
  async function _send(to, subject, html, text, replyTo) {
360
408
  var msg = {
@@ -388,8 +436,25 @@ function create(opts) {
388
436
  orderReceipt: async function (input) {
389
437
  if (!input) throw new TypeError("email.orderReceipt: input object required");
390
438
  var vars = _orderVars(input.order, input.customer);
391
- var html = _render(ORDER_RECEIPT_HTML, vars);
392
- var text = _render(ORDER_RECEIPT_TEXT, vars);
439
+ // Optional order-confirmation link. A caller may hand a fully-built URL
440
+ // (input.order_url); otherwise, when the factory was wired with a signing
441
+ // secret + shop origin, mint a tokenized link here so the guest can open
442
+ // their receipt on any device (?k=<token>, validated by storefront's
443
+ // access gate). Each segment is rendered with EXACTLY the vars it
444
+ // references so the strict renderer's unused-var guard stays on (the
445
+ // head/tail never see {{order_url}}; the link block never sees the money
446
+ // vars). Absent a URL and the mint material, the link block is dropped.
447
+ var orderUrl = (typeof input.order_url === "string" && input.order_url) ? input.order_url : "";
448
+ if (!orderUrl && orderAccessSecret && shopOrigin) {
449
+ var k = _mintOrderAccessToken(orderAccessSecret, input.order.id);
450
+ if (k) orderUrl = shopOrigin + "/orders/" + encodeURIComponent(input.order.id) + "?k=" + encodeURIComponent(k);
451
+ }
452
+ var html = _render(ORDER_RECEIPT_HTML_HEAD, vars) +
453
+ (orderUrl ? _render(ORDER_RECEIPT_HTML_LINK, { order_url: orderUrl }) : "") +
454
+ _render(ORDER_RECEIPT_HTML_TAIL, {});
455
+ var text = _render(ORDER_RECEIPT_TEXT_HEAD, vars) +
456
+ (orderUrl ? _render(ORDER_RECEIPT_TEXT_LINK, { order_url: orderUrl }) : "") +
457
+ _render(ORDER_RECEIPT_TEXT_TAIL, {});
393
458
  return await _send(input.customer.email, "Your order " + input.order.id, html, text, input.replyTo);
394
459
  },
395
460
 
@@ -725,8 +790,12 @@ module.exports = {
725
790
  // — operators may inject their own strings via opts.templates
726
791
  // (added in a follow-up patch alongside b.theme).
727
792
  templates: {
728
- ORDER_RECEIPT_HTML: ORDER_RECEIPT_HTML,
729
- ORDER_RECEIPT_TEXT: ORDER_RECEIPT_TEXT,
793
+ ORDER_RECEIPT_HTML_HEAD: ORDER_RECEIPT_HTML_HEAD,
794
+ ORDER_RECEIPT_HTML_LINK: ORDER_RECEIPT_HTML_LINK,
795
+ ORDER_RECEIPT_HTML_TAIL: ORDER_RECEIPT_HTML_TAIL,
796
+ ORDER_RECEIPT_TEXT_HEAD: ORDER_RECEIPT_TEXT_HEAD,
797
+ ORDER_RECEIPT_TEXT_LINK: ORDER_RECEIPT_TEXT_LINK,
798
+ ORDER_RECEIPT_TEXT_TAIL: ORDER_RECEIPT_TEXT_TAIL,
730
799
  SHIP_NOTIFICATION_HTML: SHIP_NOTIFICATION_HTML,
731
800
  SHIP_NOTIFICATION_TEXT: SHIP_NOTIFICATION_TEXT,
732
801
  REFUND_HTML: REFUND_HTML,
@@ -265,9 +265,27 @@ function securityHeadersOpts() {
265
265
  // what the provider's own CDN requires.
266
266
  var CSP_HOSTS = {
267
267
  stripe: {
268
- script: ["https://js.stripe.com"],
268
+ // Stripe.js v3 starts sub-frames on per-origin `*.js.stripe.com` hosts
269
+ // for performance (Stripe security guide), and its dynamic loader
270
+ // injects those sub-resource <script>s at runtime — so BOTH the apex
271
+ // and the wildcard must be admitted on script-src, or the
272
+ // TrustedScriptURL/dynamic-load path is refused (the 3DS challenge
273
+ // loader rides this).
274
+ script: ["https://js.stripe.com", "https://*.js.stripe.com"],
269
275
  connect: ["https://api.stripe.com"],
270
- frame: ["https://js.stripe.com", "https://hooks.stripe.com"],
276
+ // frame-src: the apex + `*.js.stripe.com` carry the Payment Element /
277
+ // 3DS challenge frames; hooks.stripe.com carries redirect-style
278
+ // confirmations. `b.stripecdn.com` serves the Express Checkout wallet
279
+ // button assets (e.g. the Amazon Pay button iframe at
280
+ // b.stripecdn.com/stripethirdparty-srv/...) — without it the express
281
+ // wallet button iframe is blocked (net::ERR_ABORTED) while Link /
282
+ // Google Pay, which render from *.js.stripe.com, load fine.
283
+ frame: [
284
+ "https://js.stripe.com",
285
+ "https://*.js.stripe.com",
286
+ "https://hooks.stripe.com",
287
+ "https://b.stripecdn.com",
288
+ ],
271
289
  },
272
290
  paypal: {
273
291
  script: ["https://www.paypal.com", "https://www.paypalobjects.com"],
package/lib/storefront.js CHANGED
@@ -452,8 +452,14 @@ function _islandScript(name, opts) {
452
452
  // construction at the call site, so they go in without escaping.
453
453
  var idAttr = (opts && opts.id) ? " id=\"" + opts.id + "\"" : "";
454
454
  var policyAttr = (opts && opts.policy) ? " data-consent-policy=\"" + opts.policy + "\"" : "";
455
+ // Islands `defer` by default (run after parse, in document order). The
456
+ // Trusted Types `default` policy is the exception: it MUST register
457
+ // synchronously BEFORE the parser-inserted Stripe SDK <script> executes,
458
+ // so `opts.sync` drops `defer` (and adds no `async`) — a classic blocking
459
+ // <script> that runs the instant the parser reaches it.
460
+ var timing = (opts && opts.sync) ? "" : " defer";
455
461
  return "<script" + idAttr + " src=\"" + _assetUrl("js/" + name) + "\"" +
456
- (sri ? " integrity=\"" + sri + "\"" : "") + " defer" + policyAttr + "></script>";
462
+ (sri ? " integrity=\"" + sri + "\"" : "") + timing + policyAttr + "></script>";
457
463
  }
458
464
 
459
465
  // ---- announcement bar --------------------------------------------------
@@ -7170,6 +7176,11 @@ var PAY_PAGE =
7170
7176
  " <button id=\"submit\" type=\"button\" class=\"btn-primary pay-card__submit\">Pay {{grand_total}}</button>\n" +
7171
7177
  " <p id=\"payment-message\" class=\"pay-card__message\"></p>\n" +
7172
7178
  " </div>\n" +
7179
+ // Trusted Types `default` policy — MUST be registered before the Stripe
7180
+ // SDK loads (emitted sync/blocking, no defer) so Stripe.js's dynamic
7181
+ // script-URL injections (perf sub-frames + the 3DS challenge loader) pass
7182
+ // the createScriptURL gate instead of being refused as un-typed.
7183
+ " RAW_PAY_TT_SCRIPT\n" +
7173
7184
  " <script src=\"https://js.stripe.com/v3/\"></script>\n" +
7174
7185
  " RAW_PAY_SCRIPT\n" +
7175
7186
  "</section>\n";
@@ -7195,6 +7206,10 @@ function renderPayPage(opts) {
7195
7206
  pk: opts.publishable_key,
7196
7207
  client_secret: opts.client_secret,
7197
7208
  pay_script: opts.theme.assetUrl("js/pay.js"),
7209
+ // Trusted Types `default` policy asset — rendered (sync, no defer)
7210
+ // BEFORE the Stripe SDK tag in pay.html so it registers the
7211
+ // createScriptURL gate ahead of Stripe.js's first dynamic load.
7212
+ pay_tt_script: opts.theme.assetUrl("js/pay-trusted-types.js"),
7198
7213
  asset_css_main: opts.theme.assetUrl("css/main.css"),
7199
7214
  });
7200
7215
  }
@@ -7206,7 +7221,9 @@ function renderPayPage(opts) {
7206
7221
  grand_total: grandTotal,
7207
7222
  pk: opts.publishable_key,
7208
7223
  client_secret: opts.client_secret,
7209
- }).replace("RAW_PAY_SCRIPT", _islandScript("pay.js"));
7224
+ })
7225
+ .replace("RAW_PAY_TT_SCRIPT", _islandScript("pay-trusted-types.js", { sync: true }))
7226
+ .replace("RAW_PAY_SCRIPT", _islandScript("pay.js"));
7210
7227
  // Operator trust badges at the checkout placement (container-only — the pay
7211
7228
  // page isn't edge-cached). Pre-resolved + sanitized by the route; appended
7212
7229
  // after the pay card. Empty string when no badges / no dep.
@@ -7599,6 +7616,50 @@ function _orderRatingBlock(opts) {
7599
7616
  return "";
7600
7617
  }
7601
7618
 
7619
+ // Post-checkout "save your details / create an account" offer on the order
7620
+ // confirmation page. Shown ONLY to a guest who just checked out (the route
7621
+ // resolves the sealed claim cookie for THIS order and confirms the visitor
7622
+ // is not signed in) — a signed-in buyer never sees it. One click sends a
7623
+ // single-use sign-in link to the email the buyer checked out with; the
7624
+ // address is MASKED on screen (the route passes `masked_email`, never the
7625
+ // full address) so the confirmation HTML never carries the plaintext. When
7626
+ // an account already exists for that email the copy says "sign in" instead
7627
+ // of "create an account" — but the SEND path is identical either way (no
7628
+ // account-existence oracle; the response after the POST is the same
7629
+ // regardless). `claim_notice` flags the post-send confirmation (the POST
7630
+ // redirects to ?claim=sent), replacing the form with a neutral "check your
7631
+ // inbox" line. The form posts to /orders/:id/claim-account with no email
7632
+ // field (the address lives only in the sealed cookie) and is auto-tokened
7633
+ // by _injectCsrfFields (/orders/ is not an edge-exempt prefix).
7634
+ function _orderClaimAccountBlock(opts) {
7635
+ if (opts.claim_notice === "sent") {
7636
+ return "<section class=\"order-claim\" aria-live=\"polite\">" +
7637
+ "<h2 class=\"pdp__variants-title\">Check your inbox</h2>" +
7638
+ "<p class=\"order-claim__sent\">If that address can have an account, we've emailed a single-use sign-in link. " +
7639
+ "Follow it to finish setting up your account — your order will be saved to it automatically.</p>" +
7640
+ "</section>";
7641
+ }
7642
+ var offer = opts.claim_offer;
7643
+ if (!offer || typeof offer.masked_email !== "string") return "";
7644
+ var esc = b.template.escapeHtml;
7645
+ var o = opts.order;
7646
+ var exists = !!offer.account_exists;
7647
+ var heading = exists ? "Sign in to save this order" : "Save your details";
7648
+ var lede = exists
7649
+ ? "Looks like you already have an account with <strong>" + esc(offer.masked_email) +
7650
+ "</strong>. Sign in and we'll attach this order to it — one click, nothing to type."
7651
+ : "Create an account with <strong>" + esc(offer.masked_email) +
7652
+ "</strong> to track this order and check out faster next time. One click — we'll email you a secure sign-in link, nothing to type.";
7653
+ var cta = exists ? "Email me a sign-in link" : "Create my account";
7654
+ return "<section class=\"order-claim\">" +
7655
+ "<h2 class=\"pdp__variants-title\">" + esc(heading) + "</h2>" +
7656
+ "<p class=\"order-claim__lede\">" + lede + "</p>" +
7657
+ "<form class=\"order-claim__form form-stack\" method=\"post\" action=\"/orders/" + esc(String(o.id)) + "/claim-account\">" +
7658
+ "<div class=\"form-actions\"><button type=\"submit\" class=\"btn-primary\">" + esc(cta) + "</button></div>" +
7659
+ "</form>" +
7660
+ "</section>";
7661
+ }
7662
+
7602
7663
  // Map a ?rate_err= code (set by a rejected rating POST on its PRG redirect)
7603
7664
  // to a clean, operator-safe correction message rendered above the rating
7604
7665
  // form. Defensive request reader — an unknown / absent code yields no
@@ -7711,6 +7772,11 @@ function renderOrder(opts) {
7711
7772
  // passes the resolved getRating row (rating) + the eligibility/notice
7712
7773
  // flags; the edge render never does, so the panel is empty at the edge.
7713
7774
  var ratingHtml = _orderRatingBlock(opts);
7775
+ // Post-checkout account-claim offer — container-only (the route resolves
7776
+ // it from the sealed claim cookie; the edge render never passes it). Empty
7777
+ // string for a signed-in buyer, a non-guest order, or any page without a
7778
+ // matching claim cookie.
7779
+ var claimHtml = _orderClaimAccountBlock(opts);
7714
7780
  if (opts.theme) {
7715
7781
  return opts.theme.render("order", {
7716
7782
  title: "Order " + o.id,
@@ -7731,6 +7797,7 @@ function renderOrder(opts) {
7731
7797
  gift_html: _orderGiftBlock(opts.gift_options),
7732
7798
  actions_html: actionsHtml,
7733
7799
  rating_html: ratingHtml,
7800
+ claim_html: claimHtml,
7734
7801
  can_return: _orderEligibleForReturn(o.status),
7735
7802
  can_reorder: _orderEligibleForReorder(o.status),
7736
7803
  can_cancel: _orderEligibleForCancel(o.status),
@@ -7810,12 +7877,17 @@ function renderOrder(opts) {
7810
7877
  // can't trip String.replace dollar substitution. Empty string when no badges
7811
7878
  // / no dep.
7812
7879
  var trustBadgesHtml = typeof opts.trust_badges_html === "string" ? opts.trust_badges_html : "";
7880
+ // The claim offer carries only escaped values (the masked email is run
7881
+ // through escapeHtml in the block builder, the rest is static copy), so a
7882
+ // direct concat is safe — no `$`-substitution hazard. Placed right after
7883
+ // the order body so a just-checked-out guest sees the offer before the
7884
+ // cross-sell rail.
7813
7885
  return _wrap({
7814
7886
  title: "Order " + o.id,
7815
7887
  shop_name: shopName,
7816
7888
  cart_count: cartCount,
7817
7889
  theme_css: opts.theme_css,
7818
- body: body + railHtml + trustBadgesHtml,
7890
+ body: body + claimHtml + railHtml + trustBadgesHtml,
7819
7891
  });
7820
7892
  }
7821
7893
 
@@ -8788,6 +8860,21 @@ var OAUTH_COOKIE_NAME = "shop_oauth";
8788
8860
  // Sealed so it can't be forged to mis-attribute a signup.
8789
8861
  var REFERRAL_COOKIE_NAME = "shop_ref";
8790
8862
 
8863
+ // Sealed cookie carrying the just-checked-out buyer's plaintext email so
8864
+ // the order confirmation page can offer a one-click "save your details"
8865
+ // account-creation prompt WITHOUT persisting the plaintext anywhere
8866
+ // durable. The customers store keeps email only as a one-way hash; the
8867
+ // plaintext exists transiently at checkout confirm, so it is stashed HERE
8868
+ // (AEAD-sealed, never written to D1) keyed to the order id, then read back
8869
+ // on /orders/:id and the claim-account trigger. Short-lived (matches the
8870
+ // pay-window TTL) and Path "/" so it survives the Stripe /pay → /orders
8871
+ // return. Sealed so a tampering client can't swap in another address to
8872
+ // trigger a victim-addressed sign-in mail. `email` is the address the
8873
+ // buyer typed at confirm; `order_id` pins the cookie to the one order so a
8874
+ // stale cookie from an earlier order doesn't surface on a different
8875
+ // confirmation page.
8876
+ var CLAIM_COOKIE_NAME = "shop_claim";
8877
+
8791
8878
  // Sealed cookie holding the visitor's chosen DISPLAY currency (ISO 4217).
8792
8879
  // Display-only: the cart / order / payment currency is unchanged — this
8793
8880
  // only selects which currency the price strings are rendered in. Sealed
@@ -8934,6 +9021,187 @@ function _readReferralEnv(req) {
8934
9021
  try { return JSON.parse(raw); } catch (_e) { return null; }
8935
9022
  }
8936
9023
 
9024
+ // Post-checkout account-claim cookie. Carries the plaintext buyer email
9025
+ // (sealed) + the order id it was placed under, so the confirmation page can
9026
+ // mask the address on screen and the claim-account trigger can mint a
9027
+ // sign-in link to it — without ever reading the plaintext from D1 (the
9028
+ // customers store has only the hash). 15-minute TTL matches the pay window;
9029
+ // the post-redemption order-link uses the durable email-hash match, so the
9030
+ // cookie's only job is the on-page offer + the one-click send.
9031
+ function _setClaimCookie(req, res, env) {
9032
+ var T = b.constants.TIME;
9033
+ var secure = _secureForReq(req);
9034
+ _cookieJar().writeSealed(res, CLAIM_COOKIE_NAME, JSON.stringify(env), {
9035
+ expires: new Date(Date.now() + T.minutes(15)),
9036
+ secure: secure,
9037
+ });
9038
+ }
9039
+ function _clearClaimCookie(req, res) {
9040
+ var secure = _secureForReq(req);
9041
+ _cookieJar().clear(res, CLAIM_COOKIE_NAME, { secure: secure });
9042
+ }
9043
+ function _readClaimEnv(req) {
9044
+ var raw = _cookieJar().readSealed(req, CLAIM_COOKIE_NAME);
9045
+ if (raw === null) return null;
9046
+ try { return JSON.parse(raw); } catch (_e) { return null; }
9047
+ }
9048
+
9049
+ // Sealed cookie that grants the PLACING browser durable read access to its
9050
+ // own guest-order confirmation pages. A guest order carries no customer_id,
9051
+ // so the IDOR ownership check can't gate it the way it gates an owned order;
9052
+ // without this, the order's full name / address / line items would be
9053
+ // readable by anyone who learns (or guesses, since ids are timestamp-ordered
9054
+ // UUIDv7) the order UUID. At confirm time the order id is prepended to a
9055
+ // capped, rotating list sealed into this cookie; a later GET /orders/:id for
9056
+ // a guest order is admitted only when the id is in this list (the buyer's own
9057
+ // browser), a valid emailed access token is presented (any device), or the
9058
+ // request is the signed-in owner. The list is capped so the cookie can't grow
9059
+ // without bound (a heavy guest buyer keeps access to their most recent
9060
+ // GUEST_ORDER_ACCESS_MAX orders; older ones fall off and are reachable only
9061
+ // via the emailed token). 30-day TTL, refreshed on each write. Sealed so a
9062
+ // tampering client can't forge access to an arbitrary id.
9063
+ var GUEST_ORDER_ACCESS_COOKIE_NAME = "shop_oacc";
9064
+ var GUEST_ORDER_ACCESS_MAX = 20;
9065
+
9066
+ // Read the placing browser's granted guest-order id list. Defensive reader:
9067
+ // a missing / tampered / malformed cookie reads as "no grants" (empty array)
9068
+ // rather than throwing — the cookie grants only read access to one's own
9069
+ // just-placed receipt, so a bad value simply withholds it.
9070
+ function _readGuestOrderAccessIds(req) {
9071
+ var raw = _cookieJar().readSealed(req, GUEST_ORDER_ACCESS_COOKIE_NAME);
9072
+ if (raw === null) return [];
9073
+ var env;
9074
+ try { env = JSON.parse(raw); } catch (_e) { return []; }
9075
+ if (!env || !Array.isArray(env.ids)) return [];
9076
+ var out = [];
9077
+ for (var i = 0; i < env.ids.length && out.length < GUEST_ORDER_ACCESS_MAX; i += 1) {
9078
+ var id = env.ids[i];
9079
+ if (typeof id === "string" && id && out.indexOf(id) === -1) out.push(id);
9080
+ }
9081
+ return out;
9082
+ }
9083
+
9084
+ // Grant the placing browser durable access to `orderId` by prepending it to
9085
+ // the rotating list and resealing (newest-first, de-duplicated, capped). Must
9086
+ // run on the confirm path BEFORE the Stripe redirect leaves the site so the
9087
+ // buyer returns (redirect_status=succeeded) already holding access; the
9088
+ // 303 PRG strip preserves it (the cookie is set on the redirect response).
9089
+ function _grantGuestOrderAccess(req, res, orderId) {
9090
+ if (typeof orderId !== "string" || !orderId) return;
9091
+ var T = b.constants.TIME;
9092
+ var secure = _secureForReq(req);
9093
+ var ids = _readGuestOrderAccessIds(req);
9094
+ var idx = ids.indexOf(orderId);
9095
+ if (idx !== -1) ids.splice(idx, 1);
9096
+ ids.unshift(orderId);
9097
+ if (ids.length > GUEST_ORDER_ACCESS_MAX) ids = ids.slice(0, GUEST_ORDER_ACCESS_MAX);
9098
+ _cookieJar().writeSealed(res, GUEST_ORDER_ACCESS_COOKIE_NAME, JSON.stringify({ v: 1, ids: ids }), {
9099
+ expires: new Date(Date.now() + T.days(30)),
9100
+ secure: secure,
9101
+ });
9102
+ }
9103
+
9104
+ function _hasGuestOrderAccessCookie(req, orderId) {
9105
+ if (typeof orderId !== "string" || !orderId) return false;
9106
+ return _readGuestOrderAccessIds(req).indexOf(orderId) !== -1;
9107
+ }
9108
+
9109
+ // ---- emailed guest-order access token ----------------------------------
9110
+ //
9111
+ // A signed, order-scoped token carried in the confirmation email's order
9112
+ // link (?k=<token>) so the buyer can open their receipt on ANY device, not
9113
+ // just the placing browser. The token is `<exp_b36>.<tag>` where `exp_b36`
9114
+ // is the base-36 expiry (epoch ms) and `tag` is HMAC-SHA3-512(secret,
9115
+ // "order-access:v1:" + orderId + ":" + exp_b36), truncated to 32 hex chars
9116
+ // (128 bits — ample for an unguessable, non-replayable-past-expiry handle).
9117
+ // HMAC-SHA3-512 is the PQC-aligned keyed MAC from the vendored crypto
9118
+ // surface; the key is derived in server.js from the operator's app secret
9119
+ // (domain-separated via namespaceHash), never a per-request value. The link
9120
+ // is MINTED in lib/email.js (which holds the same key) and only VERIFIED here:
9121
+ // the tag is recomputed over the SAME orderId+exp and compared constant-time
9122
+ // (b.crypto.timingSafeEqual), then the embedded expiry is checked — so a
9123
+ // tampered id, a tampered exp, or an expired link all fail closed (→ 404).
9124
+ // The two sides share the `order-access:v1:` namespace + 32-hex-char width
9125
+ // as one contract; email.js mints with the identical format.
9126
+ var GUEST_ORDER_TOKEN_NS = "order-access:v1:";
9127
+ var GUEST_ORDER_TOKEN_TAG_HEX_LEN = 32;
9128
+
9129
+ function _orderAccessTokenTag(secret, orderId, expB36) {
9130
+ // hmacSha3 returns a lowercase hex string; truncate to a fixed width so the
9131
+ // token stays compact while keeping 128 bits of MAC.
9132
+ return b.crypto.hmacSha3(secret, GUEST_ORDER_TOKEN_NS + orderId + ":" + expB36)
9133
+ .slice(0, GUEST_ORDER_TOKEN_TAG_HEX_LEN);
9134
+ }
9135
+
9136
+ // Verify a presented token for `orderId`. Constant-time tag compare, then the
9137
+ // embedded-expiry check. Any structural problem, a tampered id/exp, or an
9138
+ // expired token returns false (the route maps that to a 404). No secret wired
9139
+ // → no token can ever verify (false).
9140
+ function _verifyOrderAccessToken(secret, orderId, token) {
9141
+ if (typeof secret !== "string" || !secret) return false;
9142
+ if (typeof orderId !== "string" || !orderId) return false;
9143
+ if (typeof token !== "string" || !token) return false;
9144
+ var dot = token.indexOf(".");
9145
+ if (dot <= 0 || dot === token.length - 1) return false;
9146
+ var expB36 = token.slice(0, dot);
9147
+ var tag = token.slice(dot + 1);
9148
+ if (!/^[0-9a-z]+$/.test(expB36) || !/^[0-9a-f]+$/.test(tag)) return false;
9149
+ var exp = parseInt(expB36, 36);
9150
+ if (!isFinite(exp) || exp <= 0) return false;
9151
+ var expected = _orderAccessTokenTag(secret, orderId, expB36);
9152
+ // Compare the supplied tag against the recomputed one in constant time
9153
+ // BEFORE the (cheap, non-secret) expiry check — a length mismatch is its
9154
+ // own non-match. timingSafeEqual throws on unequal-length inputs, so guard.
9155
+ if (tag.length !== expected.length) return false;
9156
+ if (!b.crypto.timingSafeEqual(tag, expected)) return false;
9157
+ return Date.now() < exp;
9158
+ }
9159
+
9160
+ // Mask an email for on-screen display so the confirmation page can show
9161
+ // WHICH address the sign-in link goes to without printing the full address
9162
+ // into cached/over-the-shoulder-visible HTML. `robert@example.com` →
9163
+ // `r***@e***.com`: first char of the local part, first char of each
9164
+ // dot-segment of the domain, the TLD kept whole (it's not identifying).
9165
+ // Defensive: a missing `@`, an empty part, or a non-string returns a fixed
9166
+ // "your email" so the offer never throws or leaks a malformed value.
9167
+ function _maskEmail(email) {
9168
+ if (typeof email !== "string" || email.indexOf("@") === -1) return "your email";
9169
+ var at = email.lastIndexOf("@");
9170
+ var local = email.slice(0, at);
9171
+ var domain = email.slice(at + 1);
9172
+ if (!local.length || !domain.length) return "your email";
9173
+ var maskedLocal = local.charAt(0) + "***";
9174
+ var segs = domain.split(".");
9175
+ var maskedDomain;
9176
+ if (segs.length >= 2) {
9177
+ var tld = segs[segs.length - 1];
9178
+ var head = segs.slice(0, segs.length - 1).map(function (s) {
9179
+ return s.length ? (s.charAt(0) + "***") : "***";
9180
+ }).join(".");
9181
+ maskedDomain = head + "." + tld;
9182
+ } else {
9183
+ maskedDomain = domain.charAt(0) + "***";
9184
+ }
9185
+ return maskedLocal + "@" + maskedDomain;
9186
+ }
9187
+
9188
+ // Derive a non-empty display name for an account auto-created from a
9189
+ // post-checkout claim. customers.register refuses an empty / control-byte /
9190
+ // over-long name, so take the local part of the email, strip control bytes,
9191
+ // and fall back to a fixed "Shopper" when nothing usable remains. Capped well
9192
+ // under the store's display-name limit. Never carries the full address (only
9193
+ // the local part) and is the buyer's to rename from their account.
9194
+ function _displayNameFromEmail(email) {
9195
+ var fallback = "Shopper";
9196
+ if (typeof email !== "string") return fallback;
9197
+ var at = email.lastIndexOf("@");
9198
+ var local = at > 0 ? email.slice(0, at) : email;
9199
+ // Strip control bytes; keep it readable + short.
9200
+ var clean = local.replace(/[\x00-\x1f\x7f]/g, "").trim();
9201
+ if (!clean.length) return fallback;
9202
+ return clean.length > 64 ? clean.slice(0, 64) : clean;
9203
+ }
9204
+
8937
9205
  // ---- cookie-consent cookies --------------------------------------------
8938
9206
  //
8939
9207
  // Two cookies carry a visitor's cookie-consent decision:
@@ -11199,6 +11467,41 @@ function mount(router, deps) {
11199
11467
  return env;
11200
11468
  }
11201
11469
 
11470
+ // The operator's order-access signing key (derived in server.js from the
11471
+ // app secret, domain-separated). Absent it, the emailed-token access path
11472
+ // is inert — the placing-browser cookie and signed-in-owner paths still
11473
+ // gate a guest order. Read once at mount.
11474
+ var _orderAccessSecret = (typeof deps.order_access_secret === "string" && deps.order_access_secret)
11475
+ ? deps.order_access_secret : "";
11476
+
11477
+ // Whether THIS request is permitted to read/act on order `o`.
11478
+ //
11479
+ // * Owned order (customer_id set): the signed-in owner ONLY — anyone else
11480
+ // (different customer or anonymous) is refused. This is the pre-existing
11481
+ // IDOR gate, unchanged.
11482
+ // * Guest order (no customer_id): admitted only when the request proves it
11483
+ // is the buyer — the placing browser (sealed access cookie carrying this
11484
+ // id), a valid emailed access token (?k=, any device), OR a sealed claim
11485
+ // cookie pinned to this order (the post-checkout account-claim offer
11486
+ // requires that cookie, and holding it implies the buyer placed the
11487
+ // order). A bare-UUID request with none of these is refused, so a guest
11488
+ // order's name/address/items no longer leak to anyone who learns the id.
11489
+ //
11490
+ // Returns true when access is granted, false when the route should 404
11491
+ // (indistinguishable from a missing order).
11492
+ function _orderAccessGranted(req, o, orderAuth, accessToken) {
11493
+ if (!o) return false;
11494
+ if (o.customer_id) {
11495
+ return !!(orderAuth && o.customer_id === orderAuth.customer_id);
11496
+ }
11497
+ // Guest order — capability proofs, any one suffices.
11498
+ if (_hasGuestOrderAccessCookie(req, o.id)) return true;
11499
+ if (_orderAccessSecret && _verifyOrderAccessToken(_orderAccessSecret, o.id, accessToken)) return true;
11500
+ var claimEnv = _readClaimEnv(req);
11501
+ if (claimEnv && claimEnv.order_id === o.id) return true;
11502
+ return false;
11503
+ }
11504
+
11202
11505
  // Resolve a product id into the { slug, title, price, image_url,
11203
11506
  // image_alt } shape `_buildProductCard` expects. Returns null for an
11204
11507
  // archived / missing product so it drops out of any grid (collections,
@@ -13104,6 +13407,43 @@ function mount(router, deps) {
13104
13407
  // schedule land here. A failure must never roll back a paid order, so
13105
13408
  // each is its own try/catch (mirrors _recordAutoDiscounts).
13106
13409
  await _persistGiftAndPickup(c, result.order, body);
13410
+ // Guest-order access grant. A guest order (no owner) is gated on
13411
+ // GET /orders/:id by a capability proof — stamp the placing browser's
13412
+ // sealed access cookie HERE, before either redirect leaves the site,
13413
+ // so the buyer can revisit their confirmation. Critically this runs
13414
+ // BEFORE the Stripe hand-off: the buyer returns from Stripe
13415
+ // (redirect_status=succeeded) in THIS browser already holding access,
13416
+ // and the 303 PRG strip preserves it (the cookie rides the redirect
13417
+ // response, not the URL). Independent of the magic-link surface — it's
13418
+ // the buyer's own receipt access, not the account-claim offer. Keyed
13419
+ // on the resulting ORDER being a guest order (no customer_id), NOT on
13420
+ // the requester's sign-in state: a signed-in shopper whose cart had no
13421
+ // customer_id (checkout.confirm derives a guest order) needs this grant
13422
+ // too — the owned-order auth gate can't admit them since the order
13423
+ // carries no owner to match. Drop-silent: a grant failure must never
13424
+ // break the checkout redirect.
13425
+ if (!result.order.customer_id) {
13426
+ try { _grantGuestOrderAccess(req, res, result.order.id); }
13427
+ catch (_eAccess) { /* drop-silent — receipt access is best-effort */ }
13428
+ }
13429
+ // Post-checkout account-claim stash. A guest (no signed-in customer
13430
+ // on this request, and the order carries no owner) just typed a
13431
+ // deliverable email at confirm — seal it into the short-lived claim
13432
+ // cookie keyed to this order so the confirmation page can offer a
13433
+ // one-click "save your details" sign-in-link send to it. The
13434
+ // plaintext lives ONLY in this AEAD-sealed cookie (never D1: the
13435
+ // customers store keeps only the hash). Skipped for a signed-in
13436
+ // buyer (their order is already owned / they have an account) and
13437
+ // only when the magic-link surface is wired (no mailer = no
13438
+ // deliverable link, so no offer). Drop-silent: a stash failure must
13439
+ // never break the checkout redirect.
13440
+ if (deps.customerPortal && deps.customerPortalEmail &&
13441
+ !result.order.customer_id && !_currentCustomerEnv(req) &&
13442
+ typeof body.email === "string" && body.email) {
13443
+ try {
13444
+ _setClaimCookie(req, res, { order_id: result.order.id, email: body.email });
13445
+ } catch (_eClaim) { /* drop-silent — the offer is best-effort */ }
13446
+ }
13107
13447
  // Consent-gated funnel event — checkout_complete (an order was
13108
13448
  // placed: the cart converted via checkout.confirm). Fires for both
13109
13449
  // the gift-card-fully-paid path and the Stripe-intent path (the
@@ -13338,43 +13678,40 @@ function mount(router, deps) {
13338
13678
  res.status(503);
13339
13679
  return res.end ? res.end("Stripe publishable key not configured") : res.send("Stripe publishable key not configured");
13340
13680
  }
13341
- // Route-scoped CSP that admits js.stripe.com on script/connect/frame-src
13342
- // (and Trusted Types stays on) so the Stripe SDK + the same-origin
13681
+ // Route-scoped CSP that admits Stripe's hosts on script/connect/frame-src
13682
+ // (and Trusted Types stays ON) so the Stripe SDK + the same-origin
13343
13683
  // pay.js island load — without relaxing the app-level strict CSP that
13344
13684
  // governs every OTHER route. setHeader OVERWRITES the app-level header
13345
13685
  // for this response only.
13346
13686
  //
13347
- // KNOWN, NON-BLOCKING Trusted Types violation on this route (operator
13348
- // follow-up, intentionally NOT fixed here). The app-level CSP carried
13349
- // through verbatim by scopedCsp keeps `require-trusted-types-for
13350
- // 'script'` + `trusted-types 'allow-duplicates' default`. Stripe.js v3
13351
- // does NOT register a named Trusted Types policy of its own; instead it
13352
- // expects the APPLICATION to define a `default` policy whose
13353
- // createScriptURL vets Stripe's own hosts (per Stripe's integration
13354
- // security guide). When Stripe.js dynamically injects its sub-resource
13355
- // <script> (the frame-spawning performance path on *.js.stripe.com), the
13356
- // browser refuses the TrustedScriptURL assignment because no `default`
13357
- // policy is registeredthe console logs "This document requires
13358
- // 'TrustedScriptURL' assignment ... @ https://js.stripe.com/v3/". The
13359
- // card form is unaffected: it loads from the STATIC <script
13360
- // src="https://js.stripe.com/v3/"> tag, which is a direct HTML src (not
13361
- // a JS-driven sink), so require-trusted-types-for does not gate it —
13362
- // which is why card captures complete with the violation present. The
13363
- // blocked path is Stripe's dynamic sub-frame/3DS loader.
13687
+ // TRUSTED TYPES, RESOLVED. The app-level CSP carried through verbatim by
13688
+ // scopedCsp keeps `require-trusted-types-for 'script'` +
13689
+ // `trusted-types 'allow-duplicates' default`. Stripe.js v3 registers no
13690
+ // Trusted Types policy of its own; it expects the APPLICATION to define
13691
+ // a `default` policy whose createScriptURL vets Stripe's hosts (Stripe
13692
+ // security guide). The pay page now ships that policy in
13693
+ // `pay-trusted-types.js`, emitted (sync, no defer) BEFORE the Stripe SDK
13694
+ // tag, so when Stripe.js dynamically injects its sub-resource <script>
13695
+ // (the perf sub-frames on *.js.stripe.com and the 3-D Secure
13696
+ // challenge-frame loader) the browser routes the URL through the policy
13697
+ // and admits itinstead of refusing it with "This document requires
13698
+ // 'TrustedScriptURL' assignment". The policy throws for any non-Stripe
13699
+ // origin, so Trusted Types stays a real gate, not a relaxation. The
13700
+ // scoped CSP's script-src/frame-src admit both `js.stripe.com` and
13701
+ // `*.js.stripe.com` (the sub-origins the dynamic loader uses).
13702
+ //
13703
+ // EXPRESS WALLET FRAMES. The scoped frame-src also admits
13704
+ // `b.stripecdn.com`, where the Express Checkout Element loads its wallet
13705
+ // button iframes (e.g. the Amazon Pay button at
13706
+ // b.stripecdn.com/stripethirdparty-srv/...); without it that iframe is
13707
+ // blocked (net::ERR_ABORTED) while Link / Google Pay, served from
13708
+ // *.js.stripe.com, render fine.
13364
13709
  //
13365
- // We do NOT loosen Trusted Types to silence it. Naming a Stripe policy
13366
- // in the trusted-types directive does nothing (Stripe registers none);
13367
- // the only fix Stripe documents is the app shipping a same-origin
13368
- // `default` createScriptURL policy that allows js.stripe.com /
13369
- // *.js.stripe.com AND widening this scoped CSP's script-src/frame-src to
13370
- // *.js.stripe.com. A `default` TT policy is page-global (it vets EVERY
13371
- // TrustedScriptURL/TrustedHTML/TrustedScript sink, including any future
13372
- // app sink), so adopting it is a deliberate posture change, not a
13373
- // drive-by — left as an operator follow-up. To reproduce + verify a fix,
13374
- // pay with the Stripe 3-D Secure test card 4000002760003184 (forces an
13375
- // authentication challenge, which exercises the dynamic challenge-frame
13376
- // loader that trips this) and confirm the console violation is gone and
13377
- // the challenge frame renders.
13710
+ // A live 3-D Secure path still warrants a production verification: pay
13711
+ // with the Stripe 3DS test card 4000002760003184 (forces an
13712
+ // authentication challenge that exercises the dynamic challenge-frame
13713
+ // loader) and confirm the challenge frame renders with no TrustedScriptURL
13714
+ // console violation.
13378
13715
  res.setHeader && res.setHeader("content-security-policy", securityMiddleware.scopedCsp(["stripe"]));
13379
13716
  // Route-scoped Permissions-Policy that re-enables the `payment` feature
13380
13717
  // for this one response (same origin + the Stripe / Google Pay wallet
@@ -13407,20 +13744,33 @@ function mount(router, deps) {
13407
13744
  try { o = await deps.order.get(orderId); }
13408
13745
  catch (e) { if (e instanceof TypeError) { o = null; } else throw e; }
13409
13746
  if (!o) return _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme }));
13410
- // Ownership gate against IDOR: an order's confirmation page exposes
13411
- // the customer's name, address, and line items by UUID alone. An order
13412
- // that BELONGS to a customer (customer_id set) is viewable only by that
13413
- // signed-in customer anyone else (a different customer OR an
13414
- // unauthenticated request) 404s rather than leaking it. A guest order
13415
- // carries no customer_id and remains reachable via its unguessable URL
13416
- // (the capability-URL model), so BOTH the just-placed-as-guest path AND
13417
- // the signed-in-shopper-with-an-anonymous-cart path (checkout.confirm
13418
- // derives the order from a cart that has no customer_id) still render
13419
- // their own confirmation here.
13747
+ // Access gate against IDOR: an order's confirmation page exposes the
13748
+ // customer's name, address, and line items by UUID alone and ids are
13749
+ // timestamp-ordered UUIDv7, so a bare UUID is not a secret. An OWNED
13750
+ // order (customer_id set) is viewable only by that signed-in customer.
13751
+ // A GUEST order (no customer_id) is admitted only when the request
13752
+ // proves it is the buyer: the placing browser (sealed access cookie),
13753
+ // a valid emailed access token (?k=, any device), or a sealed claim
13754
+ // cookie pinned to this order. Anything else 404s rather than leaking a
13755
+ // stranger's receipt. The signed-in-shopper-with-an-anonymous-cart path
13756
+ // (checkout.confirm derives the order from a cart that has no
13757
+ // customer_id) still renders via the access cookie stamped at confirm.
13420
13758
  var orderAuth = _currentCustomerEnv(req);
13421
- if (o.customer_id && (!orderAuth || o.customer_id !== orderAuth.customer_id)) {
13759
+ var ordAccessToken = (req.query && typeof req.query.k === "string") ? req.query.k : "";
13760
+ if (!_orderAccessGranted(req, o, orderAuth, ordAccessToken)) {
13422
13761
  return _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme }));
13423
13762
  }
13763
+ // A guest order opened from the emailed access link (?k=) arrives on a
13764
+ // device that may hold no access cookie yet. Stamp one now so the
13765
+ // receipt keeps resolving after the ?k= param is gone (a refresh, or the
13766
+ // PRG strip below) — the token proved the buyer; the cookie carries that
13767
+ // proof forward on this device. No-op for an owned order (the signed-in
13768
+ // owner is gated by auth, not the cookie) and idempotent when the cookie
13769
+ // already carries this id. Drop-silent: a stamp failure must never break
13770
+ // the receipt render.
13771
+ if (!o.customer_id && ordAccessToken && !_hasGuestOrderAccessCookie(req, o.id)) {
13772
+ try { _grantGuestOrderAccess(req, res, o.id); } catch (_eGrant) { /* best-effort */ }
13773
+ }
13424
13774
  // Stripe's post-payment return lands here with ?redirect_status=
13425
13775
  // succeeded — the moment the payment actually settled client-side,
13426
13776
  // with the shopper's session + consent readable. Record the funnel's
@@ -13535,6 +13885,37 @@ function mount(router, deps) {
13535
13885
  try { giftOptionsRow = await deps.giftOptions.getForOrder(o.id); }
13536
13886
  catch (e) { if (!(e instanceof TypeError)) throw e; giftOptionsRow = null; }
13537
13887
  }
13888
+ // Post-checkout account-claim offer. Resolved ONLY when the magic-link
13889
+ // surface is wired, the order is a guest order (no owner), and the
13890
+ // request is unauthenticated — a signed-in buyer (their order is theirs
13891
+ // already) never sees it. The buyer email was sealed into the claim
13892
+ // cookie at confirm time; here we read it back, confirm it pins to THIS
13893
+ // order (a stale cookie from an earlier order is ignored), mask it for
13894
+ // display, and check whether an account already exists for the address
13895
+ // (to pick "sign in" vs "create" wording). The plaintext is read only
13896
+ // from the sealed cookie — never from D1. `?claim=sent` flips the form
13897
+ // to the post-send confirmation. Drop-silent: a bad-shaped email or any
13898
+ // lookup failure simply suppresses the offer rather than 500-ing the
13899
+ // confirmation page.
13900
+ var claimOffer = null;
13901
+ var claimNotice = ordUrl ? ordUrl.searchParams.get("claim") : null;
13902
+ if (deps.customerPortal && deps.customerPortalEmail &&
13903
+ !o.customer_id && !orderAuth && claimNotice !== "sent") {
13904
+ var claimEnv = _readClaimEnv(req);
13905
+ if (claimEnv && claimEnv.order_id === o.id &&
13906
+ typeof claimEnv.email === "string" && claimEnv.email) {
13907
+ var accountExists = false;
13908
+ try {
13909
+ var claimHash = deps.customers.hashEmail(claimEnv.email);
13910
+ var claimCust = await deps.customers.byEmailHash(claimHash);
13911
+ accountExists = !!(claimCust && claimCust.id);
13912
+ } catch (_eClaim) { accountExists = false; }
13913
+ claimOffer = {
13914
+ masked_email: _maskEmail(claimEnv.email),
13915
+ account_exists: accountExists,
13916
+ };
13917
+ }
13918
+ }
13538
13919
  _send(res, 200, renderOrder({
13539
13920
  order: o,
13540
13921
  product_lookup: productLookup,
@@ -13548,11 +13929,130 @@ function mount(router, deps) {
13548
13929
  rating_notice: ordUrl ? _ratingNoticeFor(ordUrl.searchParams.get("rate_err")) : null,
13549
13930
  reordered: ordUrl ? ordUrl.searchParams.get("reordered") === "1" : false,
13550
13931
  cancelled: ordUrl ? ordUrl.searchParams.get("cancelled") === "1" : false,
13932
+ claim_offer: claimOffer,
13933
+ claim_notice: claimNotice === "sent" ? "sent" : null,
13551
13934
  shop_name: shopName,
13552
13935
  theme: theme,
13553
13936
  }));
13554
13937
  });
13555
13938
 
13939
+ // POST /orders/:order_id/claim-account — the one-click "save your details
13940
+ // / create an account" trigger from the confirmation page. Mounts only
13941
+ // when the magic-link surface is wired (customerPortal + a mailer); absent
13942
+ // it the offer never renders, so the route is inert. The buyer email is
13943
+ // NOT in the request body — it lives only in the sealed claim cookie set
13944
+ // at confirm time (the customers store keeps no plaintext). This handler:
13945
+ //
13946
+ // 1. resolves the order (malformed/unknown id → the order page, never a
13947
+ // raw 500);
13948
+ // 2. no-ops for a signed-in buyer or an already-owned order (nothing to
13949
+ // claim) — redirect to the order page;
13950
+ // 3. reads the sealed claim cookie and requires it to pin to THIS order
13951
+ // (a missing / mismatched cookie can't trigger a send — defends a
13952
+ // cross-order or forged-trigger attempt);
13953
+ // 4. resolves the customer by email hash, CREATING the account if none
13954
+ // exists, mints a single-use portal sign-in token, and emails the
13955
+ // link to the checked-out address;
13956
+ // 5. links the guest order(s) for that email to the account by the
13957
+ // durable email-hash match (the redemption flow re-links idempotently
13958
+ // on click, so a not-yet-existing account still gets its orders).
13959
+ //
13960
+ // The response is IDENTICAL whether the account already existed or was
13961
+ // just created, and whether the send succeeded — always a 303 to
13962
+ // ?claim=sent (no account-existence oracle; no enumeration). Rate-limited
13963
+ // by the /orders/ TIGHT_PREFIXES budget; CSRF-tokened by _injectCsrfFields
13964
+ // (/orders/ is not an edge-exempt prefix). Each external step is wrapped so
13965
+ // a failure still lands the buyer on the neutral confirmation.
13966
+ if (deps.customerPortal && deps.customerPortalEmail) {
13967
+ router.post("/orders/:order_id/claim-account", async function (req, res) {
13968
+ var orderId = req.params && req.params.order_id;
13969
+ function _bounce(loc) {
13970
+ res.status(303);
13971
+ res.setHeader && res.setHeader("location", loc);
13972
+ return res.end ? res.end() : res.send("");
13973
+ }
13974
+ if (!orderId) return _bounce("/account/login");
13975
+ var o;
13976
+ try { o = await deps.order.get(orderId); }
13977
+ catch (e) { if (e instanceof TypeError) { o = null; } else throw e; }
13978
+ if (!o) return _bounce("/account/login");
13979
+
13980
+ // A signed-in buyer already has an account — nothing to claim.
13981
+ if (_currentCustomerEnv(req)) return _bounce("/orders/" + o.id);
13982
+ // An order that already has an owner isn't a guest order to claim.
13983
+ if (o.customer_id) return _bounce("/orders/" + o.id);
13984
+
13985
+ // The email lives ONLY in the sealed claim cookie, pinned to this
13986
+ // order. No cookie / a different order's cookie → cannot trigger a
13987
+ // send (a forged or cross-order POST has no address to mail). Bounce
13988
+ // back to the order page WITHOUT the sent flag so no offer-state lie
13989
+ // is shown.
13990
+ var claimEnv = _readClaimEnv(req);
13991
+ if (!claimEnv || claimEnv.order_id !== o.id ||
13992
+ typeof claimEnv.email !== "string" || !claimEnv.email) {
13993
+ return _bounce("/orders/" + o.id);
13994
+ }
13995
+ var email = claimEnv.email;
13996
+
13997
+ // Resolve or create the account, mint a sign-in link, send it, and
13998
+ // link the guest order — all best-effort so the response stays
13999
+ // enumeration-safe (same outcome regardless of which branch ran or
14000
+ // whether a step failed). The claim cookie is one-shot: cleared here
14001
+ // so a refresh / back-button can't re-trigger a mail cannon.
14002
+ try {
14003
+ var emailHash = deps.customers.hashEmail(email);
14004
+ var customer = await deps.customers.byEmailHash(emailHash);
14005
+ if (!customer) {
14006
+ // No account yet → create one. register() is the same path the
14007
+ // passkey / OAuth signups use; it stores only the email hash.
14008
+ // customers.register requires a non-empty display_name (it never
14009
+ // stores the plaintext email), so derive a friendly default from
14010
+ // the local part of the checked-out address — the buyer can rename
14011
+ // it from their account later.
14012
+ var derivedName = _displayNameFromEmail(email);
14013
+ try {
14014
+ customer = await deps.customers.register({ email: email, display_name: derivedName });
14015
+ } catch (eReg) {
14016
+ // A racing create (CUSTOMER_DUPLICATE) means the row now
14017
+ // exists — re-read it rather than failing the claim.
14018
+ if (eReg && eReg.code === "CUSTOMER_DUPLICATE") {
14019
+ customer = await deps.customers.byEmailHash(emailHash);
14020
+ } else {
14021
+ throw eReg;
14022
+ }
14023
+ }
14024
+ }
14025
+ if (customer && customer.id) {
14026
+ // NOTE: the guest order is deliberately NOT linked here. Linking
14027
+ // would set the order's customer_id, and the IDOR gate on
14028
+ // GET /orders/:id then 404s the guest who is still sitting on the
14029
+ // confirmation page (they're not signed in yet) — they'd lose
14030
+ // their own receipt until they clicked the email. The link is
14031
+ // instead performed at sign-in (portal-token redemption), which
14032
+ // is also the moment the buyer PROVES control of the email
14033
+ // (clicking the mailed link). Until then the order stays a guest
14034
+ // order reachable on its capability URL.
14035
+ var minted = await deps.customerPortal.createSession({
14036
+ customer_id: customer.id,
14037
+ scope: "full",
14038
+ });
14039
+ var origin = "";
14040
+ try { origin = new URL(_requestUrls(req).canonical_url).origin; }
14041
+ catch (_eOrigin) { origin = ""; }
14042
+ var linkUrl = origin + "/account/portal/" + encodeURIComponent(minted.plaintext_token);
14043
+ await deps.customerPortalEmail.sendMagicLink({
14044
+ customer_email: email,
14045
+ link_url: linkUrl,
14046
+ });
14047
+ }
14048
+ } catch (_eClaim) { /* drop-silent — neutral confirmation regardless */ }
14049
+
14050
+ // One-shot: clear the claim cookie so the offer can't be re-fired.
14051
+ try { _clearClaimCookie(req, res); } catch (_eClear) { /* best-effort */ }
14052
+ return _bounce("/orders/" + o.id + "?claim=sent");
14053
+ });
14054
+ }
14055
+
13556
14056
  // Stripe webhook — the order-completion path. A PaymentIntent succeeds
13557
14057
  // asynchronously (the customer may close the tab before Stripe fires),
13558
14058
  // so the order's pending→paid transition lands here, not on the return
@@ -13865,6 +14365,23 @@ function mount(router, deps) {
13865
14365
  if (anonCart) await deps.cart.setCustomer(anonCart.id, rv.customer_id);
13866
14366
  } catch (_e) { /* best-effort merge; sign-in itself succeeds */ }
13867
14367
  }
14368
+ // Reconcile any guest order(s) placed under this account's email to
14369
+ // the now-authenticated customer. The portal token already proved
14370
+ // control of the email (the link was mailed to it), so the customer's
14371
+ // own stored email_hash is the verified linking key — pass it to
14372
+ // linkGuestOrdersByEmailHash, which only adopts NULL-owner orders.
14373
+ // Idempotent (re-running on a later sign-in links nothing new) and
14374
+ // best-effort: a link failure never blocks the sign-in. This is the
14375
+ // payoff for the post-checkout "save your details" claim — the buyer
14376
+ // clicks the link and finds the order already on their account.
14377
+ if (deps.order) {
14378
+ try {
14379
+ var portalCust = await deps.customers.get(rv.customer_id);
14380
+ if (portalCust && portalCust.email_hash) {
14381
+ await deps.order.linkGuestOrdersByEmailHash(rv.customer_id, portalCust.email_hash);
14382
+ }
14383
+ } catch (_eLink) { /* drop-silent — sign-in itself succeeds */ }
14384
+ }
13868
14385
  _setAuthCookie(req, res, { customer_id: rv.customer_id, exp: Date.now() + b.constants.TIME.days(14) });
13869
14386
  res.status(303); res.setHeader && res.setHeader("location", "/account");
13870
14387
  return res.end ? res.end() : res.send("");
@@ -18008,8 +18525,15 @@ function mount(router, deps) {
18008
18525
  try { o = orderId ? await deps.order.get(orderId) : null; }
18009
18526
  catch (e) { if (e instanceof TypeError) { o = null; } else throw e; }
18010
18527
  if (!o) return _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme }));
18528
+ // Same access gate as GET /orders/:id — reorder reads the order's line
18529
+ // items (the variant ids it rebuilds a cart from), so a guest order
18530
+ // must be gated by a capability proof (placing-browser cookie, emailed
18531
+ // token, or claim cookie) exactly like the confirmation page. A bare
18532
+ // UUID with no proof 404s rather than letting a stranger enumerate a
18533
+ // guest order's contents by reorder.
18011
18534
  var reAuth = _currentCustomerEnv(req);
18012
- if (o.customer_id && (!reAuth || o.customer_id !== reAuth.customer_id)) {
18535
+ var reAccessToken = (req.query && typeof req.query.k === "string") ? req.query.k : "";
18536
+ if (!_orderAccessGranted(req, o, reAuth, reAccessToken)) {
18013
18537
  return _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme }));
18014
18538
  }
18015
18539
  if (!_orderEligibleForReorder(o.status)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.4.5",
3
+ "version": "0.4.7",
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": {