@blamejs/blamejs-shop 0.4.5 → 0.4.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.4.x
10
10
 
11
+ - 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.
12
+
11
13
  - 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
14
 
13
15
  - 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.6",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-6k53cvkRrxMgmeStLIoLjVXZQHqIJgTmv1Izd8TYhh1HOC4POgE6GCvx1bsalyEP",
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,
package/lib/storefront.js CHANGED
@@ -7599,6 +7599,50 @@ function _orderRatingBlock(opts) {
7599
7599
  return "";
7600
7600
  }
7601
7601
 
7602
+ // Post-checkout "save your details / create an account" offer on the order
7603
+ // confirmation page. Shown ONLY to a guest who just checked out (the route
7604
+ // resolves the sealed claim cookie for THIS order and confirms the visitor
7605
+ // is not signed in) — a signed-in buyer never sees it. One click sends a
7606
+ // single-use sign-in link to the email the buyer checked out with; the
7607
+ // address is MASKED on screen (the route passes `masked_email`, never the
7608
+ // full address) so the confirmation HTML never carries the plaintext. When
7609
+ // an account already exists for that email the copy says "sign in" instead
7610
+ // of "create an account" — but the SEND path is identical either way (no
7611
+ // account-existence oracle; the response after the POST is the same
7612
+ // regardless). `claim_notice` flags the post-send confirmation (the POST
7613
+ // redirects to ?claim=sent), replacing the form with a neutral "check your
7614
+ // inbox" line. The form posts to /orders/:id/claim-account with no email
7615
+ // field (the address lives only in the sealed cookie) and is auto-tokened
7616
+ // by _injectCsrfFields (/orders/ is not an edge-exempt prefix).
7617
+ function _orderClaimAccountBlock(opts) {
7618
+ if (opts.claim_notice === "sent") {
7619
+ return "<section class=\"order-claim\" aria-live=\"polite\">" +
7620
+ "<h2 class=\"pdp__variants-title\">Check your inbox</h2>" +
7621
+ "<p class=\"order-claim__sent\">If that address can have an account, we've emailed a single-use sign-in link. " +
7622
+ "Follow it to finish setting up your account — your order will be saved to it automatically.</p>" +
7623
+ "</section>";
7624
+ }
7625
+ var offer = opts.claim_offer;
7626
+ if (!offer || typeof offer.masked_email !== "string") return "";
7627
+ var esc = b.template.escapeHtml;
7628
+ var o = opts.order;
7629
+ var exists = !!offer.account_exists;
7630
+ var heading = exists ? "Sign in to save this order" : "Save your details";
7631
+ var lede = exists
7632
+ ? "Looks like you already have an account with <strong>" + esc(offer.masked_email) +
7633
+ "</strong>. Sign in and we'll attach this order to it — one click, nothing to type."
7634
+ : "Create an account with <strong>" + esc(offer.masked_email) +
7635
+ "</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.";
7636
+ var cta = exists ? "Email me a sign-in link" : "Create my account";
7637
+ return "<section class=\"order-claim\">" +
7638
+ "<h2 class=\"pdp__variants-title\">" + esc(heading) + "</h2>" +
7639
+ "<p class=\"order-claim__lede\">" + lede + "</p>" +
7640
+ "<form class=\"order-claim__form form-stack\" method=\"post\" action=\"/orders/" + esc(String(o.id)) + "/claim-account\">" +
7641
+ "<div class=\"form-actions\"><button type=\"submit\" class=\"btn-primary\">" + esc(cta) + "</button></div>" +
7642
+ "</form>" +
7643
+ "</section>";
7644
+ }
7645
+
7602
7646
  // Map a ?rate_err= code (set by a rejected rating POST on its PRG redirect)
7603
7647
  // to a clean, operator-safe correction message rendered above the rating
7604
7648
  // form. Defensive request reader — an unknown / absent code yields no
@@ -7711,6 +7755,11 @@ function renderOrder(opts) {
7711
7755
  // passes the resolved getRating row (rating) + the eligibility/notice
7712
7756
  // flags; the edge render never does, so the panel is empty at the edge.
7713
7757
  var ratingHtml = _orderRatingBlock(opts);
7758
+ // Post-checkout account-claim offer — container-only (the route resolves
7759
+ // it from the sealed claim cookie; the edge render never passes it). Empty
7760
+ // string for a signed-in buyer, a non-guest order, or any page without a
7761
+ // matching claim cookie.
7762
+ var claimHtml = _orderClaimAccountBlock(opts);
7714
7763
  if (opts.theme) {
7715
7764
  return opts.theme.render("order", {
7716
7765
  title: "Order " + o.id,
@@ -7731,6 +7780,7 @@ function renderOrder(opts) {
7731
7780
  gift_html: _orderGiftBlock(opts.gift_options),
7732
7781
  actions_html: actionsHtml,
7733
7782
  rating_html: ratingHtml,
7783
+ claim_html: claimHtml,
7734
7784
  can_return: _orderEligibleForReturn(o.status),
7735
7785
  can_reorder: _orderEligibleForReorder(o.status),
7736
7786
  can_cancel: _orderEligibleForCancel(o.status),
@@ -7810,12 +7860,17 @@ function renderOrder(opts) {
7810
7860
  // can't trip String.replace dollar substitution. Empty string when no badges
7811
7861
  // / no dep.
7812
7862
  var trustBadgesHtml = typeof opts.trust_badges_html === "string" ? opts.trust_badges_html : "";
7863
+ // The claim offer carries only escaped values (the masked email is run
7864
+ // through escapeHtml in the block builder, the rest is static copy), so a
7865
+ // direct concat is safe — no `$`-substitution hazard. Placed right after
7866
+ // the order body so a just-checked-out guest sees the offer before the
7867
+ // cross-sell rail.
7813
7868
  return _wrap({
7814
7869
  title: "Order " + o.id,
7815
7870
  shop_name: shopName,
7816
7871
  cart_count: cartCount,
7817
7872
  theme_css: opts.theme_css,
7818
- body: body + railHtml + trustBadgesHtml,
7873
+ body: body + claimHtml + railHtml + trustBadgesHtml,
7819
7874
  });
7820
7875
  }
7821
7876
 
@@ -8788,6 +8843,21 @@ var OAUTH_COOKIE_NAME = "shop_oauth";
8788
8843
  // Sealed so it can't be forged to mis-attribute a signup.
8789
8844
  var REFERRAL_COOKIE_NAME = "shop_ref";
8790
8845
 
8846
+ // Sealed cookie carrying the just-checked-out buyer's plaintext email so
8847
+ // the order confirmation page can offer a one-click "save your details"
8848
+ // account-creation prompt WITHOUT persisting the plaintext anywhere
8849
+ // durable. The customers store keeps email only as a one-way hash; the
8850
+ // plaintext exists transiently at checkout confirm, so it is stashed HERE
8851
+ // (AEAD-sealed, never written to D1) keyed to the order id, then read back
8852
+ // on /orders/:id and the claim-account trigger. Short-lived (matches the
8853
+ // pay-window TTL) and Path "/" so it survives the Stripe /pay → /orders
8854
+ // return. Sealed so a tampering client can't swap in another address to
8855
+ // trigger a victim-addressed sign-in mail. `email` is the address the
8856
+ // buyer typed at confirm; `order_id` pins the cookie to the one order so a
8857
+ // stale cookie from an earlier order doesn't surface on a different
8858
+ // confirmation page.
8859
+ var CLAIM_COOKIE_NAME = "shop_claim";
8860
+
8791
8861
  // Sealed cookie holding the visitor's chosen DISPLAY currency (ISO 4217).
8792
8862
  // Display-only: the cart / order / payment currency is unchanged — this
8793
8863
  // only selects which currency the price strings are rendered in. Sealed
@@ -8934,6 +9004,187 @@ function _readReferralEnv(req) {
8934
9004
  try { return JSON.parse(raw); } catch (_e) { return null; }
8935
9005
  }
8936
9006
 
9007
+ // Post-checkout account-claim cookie. Carries the plaintext buyer email
9008
+ // (sealed) + the order id it was placed under, so the confirmation page can
9009
+ // mask the address on screen and the claim-account trigger can mint a
9010
+ // sign-in link to it — without ever reading the plaintext from D1 (the
9011
+ // customers store has only the hash). 15-minute TTL matches the pay window;
9012
+ // the post-redemption order-link uses the durable email-hash match, so the
9013
+ // cookie's only job is the on-page offer + the one-click send.
9014
+ function _setClaimCookie(req, res, env) {
9015
+ var T = b.constants.TIME;
9016
+ var secure = _secureForReq(req);
9017
+ _cookieJar().writeSealed(res, CLAIM_COOKIE_NAME, JSON.stringify(env), {
9018
+ expires: new Date(Date.now() + T.minutes(15)),
9019
+ secure: secure,
9020
+ });
9021
+ }
9022
+ function _clearClaimCookie(req, res) {
9023
+ var secure = _secureForReq(req);
9024
+ _cookieJar().clear(res, CLAIM_COOKIE_NAME, { secure: secure });
9025
+ }
9026
+ function _readClaimEnv(req) {
9027
+ var raw = _cookieJar().readSealed(req, CLAIM_COOKIE_NAME);
9028
+ if (raw === null) return null;
9029
+ try { return JSON.parse(raw); } catch (_e) { return null; }
9030
+ }
9031
+
9032
+ // Sealed cookie that grants the PLACING browser durable read access to its
9033
+ // own guest-order confirmation pages. A guest order carries no customer_id,
9034
+ // so the IDOR ownership check can't gate it the way it gates an owned order;
9035
+ // without this, the order's full name / address / line items would be
9036
+ // readable by anyone who learns (or guesses, since ids are timestamp-ordered
9037
+ // UUIDv7) the order UUID. At confirm time the order id is prepended to a
9038
+ // capped, rotating list sealed into this cookie; a later GET /orders/:id for
9039
+ // a guest order is admitted only when the id is in this list (the buyer's own
9040
+ // browser), a valid emailed access token is presented (any device), or the
9041
+ // request is the signed-in owner. The list is capped so the cookie can't grow
9042
+ // without bound (a heavy guest buyer keeps access to their most recent
9043
+ // GUEST_ORDER_ACCESS_MAX orders; older ones fall off and are reachable only
9044
+ // via the emailed token). 30-day TTL, refreshed on each write. Sealed so a
9045
+ // tampering client can't forge access to an arbitrary id.
9046
+ var GUEST_ORDER_ACCESS_COOKIE_NAME = "shop_oacc";
9047
+ var GUEST_ORDER_ACCESS_MAX = 20;
9048
+
9049
+ // Read the placing browser's granted guest-order id list. Defensive reader:
9050
+ // a missing / tampered / malformed cookie reads as "no grants" (empty array)
9051
+ // rather than throwing — the cookie grants only read access to one's own
9052
+ // just-placed receipt, so a bad value simply withholds it.
9053
+ function _readGuestOrderAccessIds(req) {
9054
+ var raw = _cookieJar().readSealed(req, GUEST_ORDER_ACCESS_COOKIE_NAME);
9055
+ if (raw === null) return [];
9056
+ var env;
9057
+ try { env = JSON.parse(raw); } catch (_e) { return []; }
9058
+ if (!env || !Array.isArray(env.ids)) return [];
9059
+ var out = [];
9060
+ for (var i = 0; i < env.ids.length && out.length < GUEST_ORDER_ACCESS_MAX; i += 1) {
9061
+ var id = env.ids[i];
9062
+ if (typeof id === "string" && id && out.indexOf(id) === -1) out.push(id);
9063
+ }
9064
+ return out;
9065
+ }
9066
+
9067
+ // Grant the placing browser durable access to `orderId` by prepending it to
9068
+ // the rotating list and resealing (newest-first, de-duplicated, capped). Must
9069
+ // run on the confirm path BEFORE the Stripe redirect leaves the site so the
9070
+ // buyer returns (redirect_status=succeeded) already holding access; the
9071
+ // 303 PRG strip preserves it (the cookie is set on the redirect response).
9072
+ function _grantGuestOrderAccess(req, res, orderId) {
9073
+ if (typeof orderId !== "string" || !orderId) return;
9074
+ var T = b.constants.TIME;
9075
+ var secure = _secureForReq(req);
9076
+ var ids = _readGuestOrderAccessIds(req);
9077
+ var idx = ids.indexOf(orderId);
9078
+ if (idx !== -1) ids.splice(idx, 1);
9079
+ ids.unshift(orderId);
9080
+ if (ids.length > GUEST_ORDER_ACCESS_MAX) ids = ids.slice(0, GUEST_ORDER_ACCESS_MAX);
9081
+ _cookieJar().writeSealed(res, GUEST_ORDER_ACCESS_COOKIE_NAME, JSON.stringify({ v: 1, ids: ids }), {
9082
+ expires: new Date(Date.now() + T.days(30)),
9083
+ secure: secure,
9084
+ });
9085
+ }
9086
+
9087
+ function _hasGuestOrderAccessCookie(req, orderId) {
9088
+ if (typeof orderId !== "string" || !orderId) return false;
9089
+ return _readGuestOrderAccessIds(req).indexOf(orderId) !== -1;
9090
+ }
9091
+
9092
+ // ---- emailed guest-order access token ----------------------------------
9093
+ //
9094
+ // A signed, order-scoped token carried in the confirmation email's order
9095
+ // link (?k=<token>) so the buyer can open their receipt on ANY device, not
9096
+ // just the placing browser. The token is `<exp_b36>.<tag>` where `exp_b36`
9097
+ // is the base-36 expiry (epoch ms) and `tag` is HMAC-SHA3-512(secret,
9098
+ // "order-access:v1:" + orderId + ":" + exp_b36), truncated to 32 hex chars
9099
+ // (128 bits — ample for an unguessable, non-replayable-past-expiry handle).
9100
+ // HMAC-SHA3-512 is the PQC-aligned keyed MAC from the vendored crypto
9101
+ // surface; the key is derived in server.js from the operator's app secret
9102
+ // (domain-separated via namespaceHash), never a per-request value. The link
9103
+ // is MINTED in lib/email.js (which holds the same key) and only VERIFIED here:
9104
+ // the tag is recomputed over the SAME orderId+exp and compared constant-time
9105
+ // (b.crypto.timingSafeEqual), then the embedded expiry is checked — so a
9106
+ // tampered id, a tampered exp, or an expired link all fail closed (→ 404).
9107
+ // The two sides share the `order-access:v1:` namespace + 32-hex-char width
9108
+ // as one contract; email.js mints with the identical format.
9109
+ var GUEST_ORDER_TOKEN_NS = "order-access:v1:";
9110
+ var GUEST_ORDER_TOKEN_TAG_HEX_LEN = 32;
9111
+
9112
+ function _orderAccessTokenTag(secret, orderId, expB36) {
9113
+ // hmacSha3 returns a lowercase hex string; truncate to a fixed width so the
9114
+ // token stays compact while keeping 128 bits of MAC.
9115
+ return b.crypto.hmacSha3(secret, GUEST_ORDER_TOKEN_NS + orderId + ":" + expB36)
9116
+ .slice(0, GUEST_ORDER_TOKEN_TAG_HEX_LEN);
9117
+ }
9118
+
9119
+ // Verify a presented token for `orderId`. Constant-time tag compare, then the
9120
+ // embedded-expiry check. Any structural problem, a tampered id/exp, or an
9121
+ // expired token returns false (the route maps that to a 404). No secret wired
9122
+ // → no token can ever verify (false).
9123
+ function _verifyOrderAccessToken(secret, orderId, token) {
9124
+ if (typeof secret !== "string" || !secret) return false;
9125
+ if (typeof orderId !== "string" || !orderId) return false;
9126
+ if (typeof token !== "string" || !token) return false;
9127
+ var dot = token.indexOf(".");
9128
+ if (dot <= 0 || dot === token.length - 1) return false;
9129
+ var expB36 = token.slice(0, dot);
9130
+ var tag = token.slice(dot + 1);
9131
+ if (!/^[0-9a-z]+$/.test(expB36) || !/^[0-9a-f]+$/.test(tag)) return false;
9132
+ var exp = parseInt(expB36, 36);
9133
+ if (!isFinite(exp) || exp <= 0) return false;
9134
+ var expected = _orderAccessTokenTag(secret, orderId, expB36);
9135
+ // Compare the supplied tag against the recomputed one in constant time
9136
+ // BEFORE the (cheap, non-secret) expiry check — a length mismatch is its
9137
+ // own non-match. timingSafeEqual throws on unequal-length inputs, so guard.
9138
+ if (tag.length !== expected.length) return false;
9139
+ if (!b.crypto.timingSafeEqual(tag, expected)) return false;
9140
+ return Date.now() < exp;
9141
+ }
9142
+
9143
+ // Mask an email for on-screen display so the confirmation page can show
9144
+ // WHICH address the sign-in link goes to without printing the full address
9145
+ // into cached/over-the-shoulder-visible HTML. `robert@example.com` →
9146
+ // `r***@e***.com`: first char of the local part, first char of each
9147
+ // dot-segment of the domain, the TLD kept whole (it's not identifying).
9148
+ // Defensive: a missing `@`, an empty part, or a non-string returns a fixed
9149
+ // "your email" so the offer never throws or leaks a malformed value.
9150
+ function _maskEmail(email) {
9151
+ if (typeof email !== "string" || email.indexOf("@") === -1) return "your email";
9152
+ var at = email.lastIndexOf("@");
9153
+ var local = email.slice(0, at);
9154
+ var domain = email.slice(at + 1);
9155
+ if (!local.length || !domain.length) return "your email";
9156
+ var maskedLocal = local.charAt(0) + "***";
9157
+ var segs = domain.split(".");
9158
+ var maskedDomain;
9159
+ if (segs.length >= 2) {
9160
+ var tld = segs[segs.length - 1];
9161
+ var head = segs.slice(0, segs.length - 1).map(function (s) {
9162
+ return s.length ? (s.charAt(0) + "***") : "***";
9163
+ }).join(".");
9164
+ maskedDomain = head + "." + tld;
9165
+ } else {
9166
+ maskedDomain = domain.charAt(0) + "***";
9167
+ }
9168
+ return maskedLocal + "@" + maskedDomain;
9169
+ }
9170
+
9171
+ // Derive a non-empty display name for an account auto-created from a
9172
+ // post-checkout claim. customers.register refuses an empty / control-byte /
9173
+ // over-long name, so take the local part of the email, strip control bytes,
9174
+ // and fall back to a fixed "Shopper" when nothing usable remains. Capped well
9175
+ // under the store's display-name limit. Never carries the full address (only
9176
+ // the local part) and is the buyer's to rename from their account.
9177
+ function _displayNameFromEmail(email) {
9178
+ var fallback = "Shopper";
9179
+ if (typeof email !== "string") return fallback;
9180
+ var at = email.lastIndexOf("@");
9181
+ var local = at > 0 ? email.slice(0, at) : email;
9182
+ // Strip control bytes; keep it readable + short.
9183
+ var clean = local.replace(/[\x00-\x1f\x7f]/g, "").trim();
9184
+ if (!clean.length) return fallback;
9185
+ return clean.length > 64 ? clean.slice(0, 64) : clean;
9186
+ }
9187
+
8937
9188
  // ---- cookie-consent cookies --------------------------------------------
8938
9189
  //
8939
9190
  // Two cookies carry a visitor's cookie-consent decision:
@@ -11199,6 +11450,41 @@ function mount(router, deps) {
11199
11450
  return env;
11200
11451
  }
11201
11452
 
11453
+ // The operator's order-access signing key (derived in server.js from the
11454
+ // app secret, domain-separated). Absent it, the emailed-token access path
11455
+ // is inert — the placing-browser cookie and signed-in-owner paths still
11456
+ // gate a guest order. Read once at mount.
11457
+ var _orderAccessSecret = (typeof deps.order_access_secret === "string" && deps.order_access_secret)
11458
+ ? deps.order_access_secret : "";
11459
+
11460
+ // Whether THIS request is permitted to read/act on order `o`.
11461
+ //
11462
+ // * Owned order (customer_id set): the signed-in owner ONLY — anyone else
11463
+ // (different customer or anonymous) is refused. This is the pre-existing
11464
+ // IDOR gate, unchanged.
11465
+ // * Guest order (no customer_id): admitted only when the request proves it
11466
+ // is the buyer — the placing browser (sealed access cookie carrying this
11467
+ // id), a valid emailed access token (?k=, any device), OR a sealed claim
11468
+ // cookie pinned to this order (the post-checkout account-claim offer
11469
+ // requires that cookie, and holding it implies the buyer placed the
11470
+ // order). A bare-UUID request with none of these is refused, so a guest
11471
+ // order's name/address/items no longer leak to anyone who learns the id.
11472
+ //
11473
+ // Returns true when access is granted, false when the route should 404
11474
+ // (indistinguishable from a missing order).
11475
+ function _orderAccessGranted(req, o, orderAuth, accessToken) {
11476
+ if (!o) return false;
11477
+ if (o.customer_id) {
11478
+ return !!(orderAuth && o.customer_id === orderAuth.customer_id);
11479
+ }
11480
+ // Guest order — capability proofs, any one suffices.
11481
+ if (_hasGuestOrderAccessCookie(req, o.id)) return true;
11482
+ if (_orderAccessSecret && _verifyOrderAccessToken(_orderAccessSecret, o.id, accessToken)) return true;
11483
+ var claimEnv = _readClaimEnv(req);
11484
+ if (claimEnv && claimEnv.order_id === o.id) return true;
11485
+ return false;
11486
+ }
11487
+
11202
11488
  // Resolve a product id into the { slug, title, price, image_url,
11203
11489
  // image_alt } shape `_buildProductCard` expects. Returns null for an
11204
11490
  // archived / missing product so it drops out of any grid (collections,
@@ -13104,6 +13390,43 @@ function mount(router, deps) {
13104
13390
  // schedule land here. A failure must never roll back a paid order, so
13105
13391
  // each is its own try/catch (mirrors _recordAutoDiscounts).
13106
13392
  await _persistGiftAndPickup(c, result.order, body);
13393
+ // Guest-order access grant. A guest order (no owner) is gated on
13394
+ // GET /orders/:id by a capability proof — stamp the placing browser's
13395
+ // sealed access cookie HERE, before either redirect leaves the site,
13396
+ // so the buyer can revisit their confirmation. Critically this runs
13397
+ // BEFORE the Stripe hand-off: the buyer returns from Stripe
13398
+ // (redirect_status=succeeded) in THIS browser already holding access,
13399
+ // and the 303 PRG strip preserves it (the cookie rides the redirect
13400
+ // response, not the URL). Independent of the magic-link surface — it's
13401
+ // the buyer's own receipt access, not the account-claim offer. Keyed
13402
+ // on the resulting ORDER being a guest order (no customer_id), NOT on
13403
+ // the requester's sign-in state: a signed-in shopper whose cart had no
13404
+ // customer_id (checkout.confirm derives a guest order) needs this grant
13405
+ // too — the owned-order auth gate can't admit them since the order
13406
+ // carries no owner to match. Drop-silent: a grant failure must never
13407
+ // break the checkout redirect.
13408
+ if (!result.order.customer_id) {
13409
+ try { _grantGuestOrderAccess(req, res, result.order.id); }
13410
+ catch (_eAccess) { /* drop-silent — receipt access is best-effort */ }
13411
+ }
13412
+ // Post-checkout account-claim stash. A guest (no signed-in customer
13413
+ // on this request, and the order carries no owner) just typed a
13414
+ // deliverable email at confirm — seal it into the short-lived claim
13415
+ // cookie keyed to this order so the confirmation page can offer a
13416
+ // one-click "save your details" sign-in-link send to it. The
13417
+ // plaintext lives ONLY in this AEAD-sealed cookie (never D1: the
13418
+ // customers store keeps only the hash). Skipped for a signed-in
13419
+ // buyer (their order is already owned / they have an account) and
13420
+ // only when the magic-link surface is wired (no mailer = no
13421
+ // deliverable link, so no offer). Drop-silent: a stash failure must
13422
+ // never break the checkout redirect.
13423
+ if (deps.customerPortal && deps.customerPortalEmail &&
13424
+ !result.order.customer_id && !_currentCustomerEnv(req) &&
13425
+ typeof body.email === "string" && body.email) {
13426
+ try {
13427
+ _setClaimCookie(req, res, { order_id: result.order.id, email: body.email });
13428
+ } catch (_eClaim) { /* drop-silent — the offer is best-effort */ }
13429
+ }
13107
13430
  // Consent-gated funnel event — checkout_complete (an order was
13108
13431
  // placed: the cart converted via checkout.confirm). Fires for both
13109
13432
  // the gift-card-fully-paid path and the Stripe-intent path (the
@@ -13407,20 +13730,33 @@ function mount(router, deps) {
13407
13730
  try { o = await deps.order.get(orderId); }
13408
13731
  catch (e) { if (e instanceof TypeError) { o = null; } else throw e; }
13409
13732
  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.
13733
+ // Access gate against IDOR: an order's confirmation page exposes the
13734
+ // customer's name, address, and line items by UUID alone and ids are
13735
+ // timestamp-ordered UUIDv7, so a bare UUID is not a secret. An OWNED
13736
+ // order (customer_id set) is viewable only by that signed-in customer.
13737
+ // A GUEST order (no customer_id) is admitted only when the request
13738
+ // proves it is the buyer: the placing browser (sealed access cookie),
13739
+ // a valid emailed access token (?k=, any device), or a sealed claim
13740
+ // cookie pinned to this order. Anything else 404s rather than leaking a
13741
+ // stranger's receipt. The signed-in-shopper-with-an-anonymous-cart path
13742
+ // (checkout.confirm derives the order from a cart that has no
13743
+ // customer_id) still renders via the access cookie stamped at confirm.
13420
13744
  var orderAuth = _currentCustomerEnv(req);
13421
- if (o.customer_id && (!orderAuth || o.customer_id !== orderAuth.customer_id)) {
13745
+ var ordAccessToken = (req.query && typeof req.query.k === "string") ? req.query.k : "";
13746
+ if (!_orderAccessGranted(req, o, orderAuth, ordAccessToken)) {
13422
13747
  return _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme }));
13423
13748
  }
13749
+ // A guest order opened from the emailed access link (?k=) arrives on a
13750
+ // device that may hold no access cookie yet. Stamp one now so the
13751
+ // receipt keeps resolving after the ?k= param is gone (a refresh, or the
13752
+ // PRG strip below) — the token proved the buyer; the cookie carries that
13753
+ // proof forward on this device. No-op for an owned order (the signed-in
13754
+ // owner is gated by auth, not the cookie) and idempotent when the cookie
13755
+ // already carries this id. Drop-silent: a stamp failure must never break
13756
+ // the receipt render.
13757
+ if (!o.customer_id && ordAccessToken && !_hasGuestOrderAccessCookie(req, o.id)) {
13758
+ try { _grantGuestOrderAccess(req, res, o.id); } catch (_eGrant) { /* best-effort */ }
13759
+ }
13424
13760
  // Stripe's post-payment return lands here with ?redirect_status=
13425
13761
  // succeeded — the moment the payment actually settled client-side,
13426
13762
  // with the shopper's session + consent readable. Record the funnel's
@@ -13535,6 +13871,37 @@ function mount(router, deps) {
13535
13871
  try { giftOptionsRow = await deps.giftOptions.getForOrder(o.id); }
13536
13872
  catch (e) { if (!(e instanceof TypeError)) throw e; giftOptionsRow = null; }
13537
13873
  }
13874
+ // Post-checkout account-claim offer. Resolved ONLY when the magic-link
13875
+ // surface is wired, the order is a guest order (no owner), and the
13876
+ // request is unauthenticated — a signed-in buyer (their order is theirs
13877
+ // already) never sees it. The buyer email was sealed into the claim
13878
+ // cookie at confirm time; here we read it back, confirm it pins to THIS
13879
+ // order (a stale cookie from an earlier order is ignored), mask it for
13880
+ // display, and check whether an account already exists for the address
13881
+ // (to pick "sign in" vs "create" wording). The plaintext is read only
13882
+ // from the sealed cookie — never from D1. `?claim=sent` flips the form
13883
+ // to the post-send confirmation. Drop-silent: a bad-shaped email or any
13884
+ // lookup failure simply suppresses the offer rather than 500-ing the
13885
+ // confirmation page.
13886
+ var claimOffer = null;
13887
+ var claimNotice = ordUrl ? ordUrl.searchParams.get("claim") : null;
13888
+ if (deps.customerPortal && deps.customerPortalEmail &&
13889
+ !o.customer_id && !orderAuth && claimNotice !== "sent") {
13890
+ var claimEnv = _readClaimEnv(req);
13891
+ if (claimEnv && claimEnv.order_id === o.id &&
13892
+ typeof claimEnv.email === "string" && claimEnv.email) {
13893
+ var accountExists = false;
13894
+ try {
13895
+ var claimHash = deps.customers.hashEmail(claimEnv.email);
13896
+ var claimCust = await deps.customers.byEmailHash(claimHash);
13897
+ accountExists = !!(claimCust && claimCust.id);
13898
+ } catch (_eClaim) { accountExists = false; }
13899
+ claimOffer = {
13900
+ masked_email: _maskEmail(claimEnv.email),
13901
+ account_exists: accountExists,
13902
+ };
13903
+ }
13904
+ }
13538
13905
  _send(res, 200, renderOrder({
13539
13906
  order: o,
13540
13907
  product_lookup: productLookup,
@@ -13548,11 +13915,130 @@ function mount(router, deps) {
13548
13915
  rating_notice: ordUrl ? _ratingNoticeFor(ordUrl.searchParams.get("rate_err")) : null,
13549
13916
  reordered: ordUrl ? ordUrl.searchParams.get("reordered") === "1" : false,
13550
13917
  cancelled: ordUrl ? ordUrl.searchParams.get("cancelled") === "1" : false,
13918
+ claim_offer: claimOffer,
13919
+ claim_notice: claimNotice === "sent" ? "sent" : null,
13551
13920
  shop_name: shopName,
13552
13921
  theme: theme,
13553
13922
  }));
13554
13923
  });
13555
13924
 
13925
+ // POST /orders/:order_id/claim-account — the one-click "save your details
13926
+ // / create an account" trigger from the confirmation page. Mounts only
13927
+ // when the magic-link surface is wired (customerPortal + a mailer); absent
13928
+ // it the offer never renders, so the route is inert. The buyer email is
13929
+ // NOT in the request body — it lives only in the sealed claim cookie set
13930
+ // at confirm time (the customers store keeps no plaintext). This handler:
13931
+ //
13932
+ // 1. resolves the order (malformed/unknown id → the order page, never a
13933
+ // raw 500);
13934
+ // 2. no-ops for a signed-in buyer or an already-owned order (nothing to
13935
+ // claim) — redirect to the order page;
13936
+ // 3. reads the sealed claim cookie and requires it to pin to THIS order
13937
+ // (a missing / mismatched cookie can't trigger a send — defends a
13938
+ // cross-order or forged-trigger attempt);
13939
+ // 4. resolves the customer by email hash, CREATING the account if none
13940
+ // exists, mints a single-use portal sign-in token, and emails the
13941
+ // link to the checked-out address;
13942
+ // 5. links the guest order(s) for that email to the account by the
13943
+ // durable email-hash match (the redemption flow re-links idempotently
13944
+ // on click, so a not-yet-existing account still gets its orders).
13945
+ //
13946
+ // The response is IDENTICAL whether the account already existed or was
13947
+ // just created, and whether the send succeeded — always a 303 to
13948
+ // ?claim=sent (no account-existence oracle; no enumeration). Rate-limited
13949
+ // by the /orders/ TIGHT_PREFIXES budget; CSRF-tokened by _injectCsrfFields
13950
+ // (/orders/ is not an edge-exempt prefix). Each external step is wrapped so
13951
+ // a failure still lands the buyer on the neutral confirmation.
13952
+ if (deps.customerPortal && deps.customerPortalEmail) {
13953
+ router.post("/orders/:order_id/claim-account", async function (req, res) {
13954
+ var orderId = req.params && req.params.order_id;
13955
+ function _bounce(loc) {
13956
+ res.status(303);
13957
+ res.setHeader && res.setHeader("location", loc);
13958
+ return res.end ? res.end() : res.send("");
13959
+ }
13960
+ if (!orderId) return _bounce("/account/login");
13961
+ var o;
13962
+ try { o = await deps.order.get(orderId); }
13963
+ catch (e) { if (e instanceof TypeError) { o = null; } else throw e; }
13964
+ if (!o) return _bounce("/account/login");
13965
+
13966
+ // A signed-in buyer already has an account — nothing to claim.
13967
+ if (_currentCustomerEnv(req)) return _bounce("/orders/" + o.id);
13968
+ // An order that already has an owner isn't a guest order to claim.
13969
+ if (o.customer_id) return _bounce("/orders/" + o.id);
13970
+
13971
+ // The email lives ONLY in the sealed claim cookie, pinned to this
13972
+ // order. No cookie / a different order's cookie → cannot trigger a
13973
+ // send (a forged or cross-order POST has no address to mail). Bounce
13974
+ // back to the order page WITHOUT the sent flag so no offer-state lie
13975
+ // is shown.
13976
+ var claimEnv = _readClaimEnv(req);
13977
+ if (!claimEnv || claimEnv.order_id !== o.id ||
13978
+ typeof claimEnv.email !== "string" || !claimEnv.email) {
13979
+ return _bounce("/orders/" + o.id);
13980
+ }
13981
+ var email = claimEnv.email;
13982
+
13983
+ // Resolve or create the account, mint a sign-in link, send it, and
13984
+ // link the guest order — all best-effort so the response stays
13985
+ // enumeration-safe (same outcome regardless of which branch ran or
13986
+ // whether a step failed). The claim cookie is one-shot: cleared here
13987
+ // so a refresh / back-button can't re-trigger a mail cannon.
13988
+ try {
13989
+ var emailHash = deps.customers.hashEmail(email);
13990
+ var customer = await deps.customers.byEmailHash(emailHash);
13991
+ if (!customer) {
13992
+ // No account yet → create one. register() is the same path the
13993
+ // passkey / OAuth signups use; it stores only the email hash.
13994
+ // customers.register requires a non-empty display_name (it never
13995
+ // stores the plaintext email), so derive a friendly default from
13996
+ // the local part of the checked-out address — the buyer can rename
13997
+ // it from their account later.
13998
+ var derivedName = _displayNameFromEmail(email);
13999
+ try {
14000
+ customer = await deps.customers.register({ email: email, display_name: derivedName });
14001
+ } catch (eReg) {
14002
+ // A racing create (CUSTOMER_DUPLICATE) means the row now
14003
+ // exists — re-read it rather than failing the claim.
14004
+ if (eReg && eReg.code === "CUSTOMER_DUPLICATE") {
14005
+ customer = await deps.customers.byEmailHash(emailHash);
14006
+ } else {
14007
+ throw eReg;
14008
+ }
14009
+ }
14010
+ }
14011
+ if (customer && customer.id) {
14012
+ // NOTE: the guest order is deliberately NOT linked here. Linking
14013
+ // would set the order's customer_id, and the IDOR gate on
14014
+ // GET /orders/:id then 404s the guest who is still sitting on the
14015
+ // confirmation page (they're not signed in yet) — they'd lose
14016
+ // their own receipt until they clicked the email. The link is
14017
+ // instead performed at sign-in (portal-token redemption), which
14018
+ // is also the moment the buyer PROVES control of the email
14019
+ // (clicking the mailed link). Until then the order stays a guest
14020
+ // order reachable on its capability URL.
14021
+ var minted = await deps.customerPortal.createSession({
14022
+ customer_id: customer.id,
14023
+ scope: "full",
14024
+ });
14025
+ var origin = "";
14026
+ try { origin = new URL(_requestUrls(req).canonical_url).origin; }
14027
+ catch (_eOrigin) { origin = ""; }
14028
+ var linkUrl = origin + "/account/portal/" + encodeURIComponent(minted.plaintext_token);
14029
+ await deps.customerPortalEmail.sendMagicLink({
14030
+ customer_email: email,
14031
+ link_url: linkUrl,
14032
+ });
14033
+ }
14034
+ } catch (_eClaim) { /* drop-silent — neutral confirmation regardless */ }
14035
+
14036
+ // One-shot: clear the claim cookie so the offer can't be re-fired.
14037
+ try { _clearClaimCookie(req, res); } catch (_eClear) { /* best-effort */ }
14038
+ return _bounce("/orders/" + o.id + "?claim=sent");
14039
+ });
14040
+ }
14041
+
13556
14042
  // Stripe webhook — the order-completion path. A PaymentIntent succeeds
13557
14043
  // asynchronously (the customer may close the tab before Stripe fires),
13558
14044
  // so the order's pending→paid transition lands here, not on the return
@@ -13865,6 +14351,23 @@ function mount(router, deps) {
13865
14351
  if (anonCart) await deps.cart.setCustomer(anonCart.id, rv.customer_id);
13866
14352
  } catch (_e) { /* best-effort merge; sign-in itself succeeds */ }
13867
14353
  }
14354
+ // Reconcile any guest order(s) placed under this account's email to
14355
+ // the now-authenticated customer. The portal token already proved
14356
+ // control of the email (the link was mailed to it), so the customer's
14357
+ // own stored email_hash is the verified linking key — pass it to
14358
+ // linkGuestOrdersByEmailHash, which only adopts NULL-owner orders.
14359
+ // Idempotent (re-running on a later sign-in links nothing new) and
14360
+ // best-effort: a link failure never blocks the sign-in. This is the
14361
+ // payoff for the post-checkout "save your details" claim — the buyer
14362
+ // clicks the link and finds the order already on their account.
14363
+ if (deps.order) {
14364
+ try {
14365
+ var portalCust = await deps.customers.get(rv.customer_id);
14366
+ if (portalCust && portalCust.email_hash) {
14367
+ await deps.order.linkGuestOrdersByEmailHash(rv.customer_id, portalCust.email_hash);
14368
+ }
14369
+ } catch (_eLink) { /* drop-silent — sign-in itself succeeds */ }
14370
+ }
13868
14371
  _setAuthCookie(req, res, { customer_id: rv.customer_id, exp: Date.now() + b.constants.TIME.days(14) });
13869
14372
  res.status(303); res.setHeader && res.setHeader("location", "/account");
13870
14373
  return res.end ? res.end() : res.send("");
@@ -18008,8 +18511,15 @@ function mount(router, deps) {
18008
18511
  try { o = orderId ? await deps.order.get(orderId) : null; }
18009
18512
  catch (e) { if (e instanceof TypeError) { o = null; } else throw e; }
18010
18513
  if (!o) return _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme }));
18514
+ // Same access gate as GET /orders/:id — reorder reads the order's line
18515
+ // items (the variant ids it rebuilds a cart from), so a guest order
18516
+ // must be gated by a capability proof (placing-browser cookie, emailed
18517
+ // token, or claim cookie) exactly like the confirmation page. A bare
18518
+ // UUID with no proof 404s rather than letting a stranger enumerate a
18519
+ // guest order's contents by reorder.
18011
18520
  var reAuth = _currentCustomerEnv(req);
18012
- if (o.customer_id && (!reAuth || o.customer_id !== reAuth.customer_id)) {
18521
+ var reAccessToken = (req.query && typeof req.query.k === "string") ? req.query.k : "";
18522
+ if (!_orderAccessGranted(req, o, reAuth, reAccessToken)) {
18013
18523
  return _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme }));
18014
18524
  }
18015
18525
  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.6",
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": {