@blamejs/blamejs-shop 0.4.4 → 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 +4 -0
- package/README.md +1 -1
- package/SECURITY.md +9 -0
- package/lib/asset-manifest.json +3 -3
- package/lib/email.js +77 -8
- package/lib/storefront.js +567 -16
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.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
|
+
|
|
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.
|
|
14
|
+
|
|
11
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.
|
|
12
16
|
|
|
13
17
|
- v0.4.3 (2026-06-05) — **Passkey sign-in works again: WebAuthn is permitted on the pages that host it.** The framework's deny-all Permissions-Policy disabled the browser's WebAuthn API everywhere — including the sign-in page's own top-level document — so attempting a passkey sign-in failed with the browser reporting that publickey-credentials-get is not enabled. The policy now permits exactly the WebAuthn capability each ceremony page needs: credential assertion on the sign-in page, credential creation on the registration and passkey-management pages, both scoped to the page's own origin. Every other page keeps the strict deny-all policy, and every other feature (camera, microphone, geolocation, payment outside the payment page) remains denied on the ceremony pages too. **Fixed:** *Passkey ceremonies are no longer blocked by Permissions-Policy* — Sign-in carries publickey-credentials-get=(self); registration and passkey management carry publickey-credentials-create=(self). The allowance is scoped per route following the same pattern the payment page uses, grants apply only to the page's own origin (no cross-origin delegation), and unrecognized feature requests relax nothing. Tests assert the exact header tokens per route and that unrelated pages still deny both WebAuthn features.
|
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
|
package/lib/asset-manifest.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.4.
|
|
2
|
+
"version": "0.4.6",
|
|
3
3
|
"assets": {
|
|
4
4
|
"css/admin.css": {
|
|
5
5
|
"integrity": "sha384-6k53cvkRrxMgmeStLIoLjVXZQHqIJgTmv1Izd8TYhh1HOC4POgE6GCvx1bsalyEP",
|
|
@@ -30,8 +30,8 @@
|
|
|
30
30
|
"fingerprinted": "js/passkey-add.b535e6a3eef4514e.js"
|
|
31
31
|
},
|
|
32
32
|
"js/passkey-login.js": {
|
|
33
|
-
"integrity": "sha384-
|
|
34
|
-
"fingerprinted": "js/passkey-login.
|
|
33
|
+
"integrity": "sha384-YcFe/H5GiEIXJ3bvx4RMUKtKwW249rKWyOYbd6xRcDrlAhDeDpt7HGZYPs+R6yWO",
|
|
34
|
+
"fingerprinted": "js/passkey-login.41da9ad7da816e97.js"
|
|
35
35
|
},
|
|
36
36
|
"js/passkey-register.js": {
|
|
37
37
|
"integrity": "sha384-BjuUhbPZ18pHFMyOwT+309BEXu+VAc54RSj8lvN93jfFGDydEgmapiAOKTQM1yma",
|
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
|
-
|
|
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
|
|
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
|
-
|
|
392
|
-
|
|
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
|
-
|
|
729
|
-
|
|
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:
|
|
@@ -9129,6 +9380,17 @@ function _setLocaleCookie(res, locale) {
|
|
|
9129
9380
|
|
|
9130
9381
|
// ---- account-page renderers --------------------------------------------
|
|
9131
9382
|
|
|
9383
|
+
// One sign-in screen offering BOTH passwordless paths. The passkey form is
|
|
9384
|
+
// the primary action (a JS island posts the WebAuthn ceremony to
|
|
9385
|
+
// /account/passkey/login-*). The email magic-link is rendered inline as a
|
|
9386
|
+
// SERVER-RENDERED form that POSTs to /account/login/link — it works with
|
|
9387
|
+
// JavaScript disabled and is the always-available backup so a browser
|
|
9388
|
+
// without WebAuthn (or a failed ceremony) is never a dead end. The two
|
|
9389
|
+
// forms are independent <form> elements: the no-JS fallback never depends
|
|
9390
|
+
// on the island. The magic-link block only renders when the operator has
|
|
9391
|
+
// wired the customer-portal primitive AND a transactional mailer (the GET
|
|
9392
|
+
// route sets `magic_link_enabled`); absent it, the page degrades to the
|
|
9393
|
+
// passkey path with no broken affordance.
|
|
9132
9394
|
var ACCOUNT_LOGIN_PAGE =
|
|
9133
9395
|
"<section class=\"auth-page\">\n" +
|
|
9134
9396
|
" <div class=\"auth-card\">\n" +
|
|
@@ -9149,6 +9411,23 @@ var ACCOUNT_LOGIN_PAGE =
|
|
|
9149
9411
|
" RAW_LOGIN_SCRIPT\n" +
|
|
9150
9412
|
"</section>\n";
|
|
9151
9413
|
|
|
9414
|
+
// The inline email magic-link path on the unified login screen. A distinct
|
|
9415
|
+
// server-rendered <form> (its own email field) that POSTs to
|
|
9416
|
+
// /account/login/link with no JavaScript — the `_injectCsrfFields` wrap
|
|
9417
|
+
// chokepoint stamps the `_csrf` token automatically (the action is not an
|
|
9418
|
+
// EDGE_POST_PATHS prefix), so a no-JS browser submits an accepted token.
|
|
9419
|
+
// `data-passkey-fallback` lets passkey-login.js point a user here in one tap
|
|
9420
|
+
// when WebAuthn is unsupported or the ceremony fails — no dead end.
|
|
9421
|
+
var LOGIN_MAGIC_INLINE =
|
|
9422
|
+
"<div class=\"auth-oauth\" data-passkey-fallback>" +
|
|
9423
|
+
"<div class=\"auth-oauth__divider\"><span>or</span></div>" +
|
|
9424
|
+
"<p class=\"auth-card__lede\">No passkey on this device? We'll email you a single-use sign-in link.</p>" +
|
|
9425
|
+
"<form method=\"post\" action=\"/account/login/link\" class=\"form-stack auth-form auth-form--magic\">" +
|
|
9426
|
+
"<div class=\"form-row\"><label class=\"form-field\"><span class=\"form-field__label\">Email</span><input type=\"email\" name=\"email\" id=\"magic-email\" required autocomplete=\"email\"></label></div>" +
|
|
9427
|
+
"<div class=\"form-actions\"><button type=\"submit\" class=\"btn-secondary auth-form__submit\">Email me a sign-in link</button></div>" +
|
|
9428
|
+
"</form>" +
|
|
9429
|
+
"</div>";
|
|
9430
|
+
|
|
9152
9431
|
var LOGIN_ERROR_MESSAGES = {
|
|
9153
9432
|
oauth: "We couldn't complete that sign-in. Please try again.",
|
|
9154
9433
|
"email-conflict": "That email already has an account — sign in with your passkey instead.",
|
|
@@ -9173,9 +9452,9 @@ function renderAccountLogin(opts) {
|
|
|
9173
9452
|
var errHtml = (opts.error && LOGIN_ERROR_MESSAGES[opts.error])
|
|
9174
9453
|
? "<p class=\"auth-form__message auth-form__message--err\">" + b.template.escapeHtml(LOGIN_ERROR_MESSAGES[opts.error]) + "</p>"
|
|
9175
9454
|
: "";
|
|
9176
|
-
|
|
9177
|
-
|
|
9178
|
-
|
|
9455
|
+
// Render the email-link path INLINE (a working no-JS form), not as a link
|
|
9456
|
+
// to a separate page, so both passwordless paths live on one screen.
|
|
9457
|
+
var magicHtml = opts.magic_link_enabled ? LOGIN_MAGIC_INLINE : "";
|
|
9179
9458
|
var body = ACCOUNT_LOGIN_PAGE
|
|
9180
9459
|
.replace("RAW_LOGIN_OAUTH", oauthHtml)
|
|
9181
9460
|
.replace("RAW_LOGIN_MAGIC", magicHtml)
|
|
@@ -11171,6 +11450,41 @@ function mount(router, deps) {
|
|
|
11171
11450
|
return env;
|
|
11172
11451
|
}
|
|
11173
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
|
+
|
|
11174
11488
|
// Resolve a product id into the { slug, title, price, image_url,
|
|
11175
11489
|
// image_alt } shape `_buildProductCard` expects. Returns null for an
|
|
11176
11490
|
// archived / missing product so it drops out of any grid (collections,
|
|
@@ -13076,6 +13390,43 @@ function mount(router, deps) {
|
|
|
13076
13390
|
// schedule land here. A failure must never roll back a paid order, so
|
|
13077
13391
|
// each is its own try/catch (mirrors _recordAutoDiscounts).
|
|
13078
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
|
+
}
|
|
13079
13430
|
// Consent-gated funnel event — checkout_complete (an order was
|
|
13080
13431
|
// placed: the cart converted via checkout.confirm). Fires for both
|
|
13081
13432
|
// the gift-card-fully-paid path and the Stripe-intent path (the
|
|
@@ -13379,20 +13730,33 @@ function mount(router, deps) {
|
|
|
13379
13730
|
try { o = await deps.order.get(orderId); }
|
|
13380
13731
|
catch (e) { if (e instanceof TypeError) { o = null; } else throw e; }
|
|
13381
13732
|
if (!o) return _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme }));
|
|
13382
|
-
//
|
|
13383
|
-
//
|
|
13384
|
-
//
|
|
13385
|
-
//
|
|
13386
|
-
//
|
|
13387
|
-
//
|
|
13388
|
-
// (
|
|
13389
|
-
//
|
|
13390
|
-
//
|
|
13391
|
-
//
|
|
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.
|
|
13392
13744
|
var orderAuth = _currentCustomerEnv(req);
|
|
13393
|
-
|
|
13745
|
+
var ordAccessToken = (req.query && typeof req.query.k === "string") ? req.query.k : "";
|
|
13746
|
+
if (!_orderAccessGranted(req, o, orderAuth, ordAccessToken)) {
|
|
13394
13747
|
return _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme }));
|
|
13395
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
|
+
}
|
|
13396
13760
|
// Stripe's post-payment return lands here with ?redirect_status=
|
|
13397
13761
|
// succeeded — the moment the payment actually settled client-side,
|
|
13398
13762
|
// with the shopper's session + consent readable. Record the funnel's
|
|
@@ -13507,6 +13871,37 @@ function mount(router, deps) {
|
|
|
13507
13871
|
try { giftOptionsRow = await deps.giftOptions.getForOrder(o.id); }
|
|
13508
13872
|
catch (e) { if (!(e instanceof TypeError)) throw e; giftOptionsRow = null; }
|
|
13509
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
|
+
}
|
|
13510
13905
|
_send(res, 200, renderOrder({
|
|
13511
13906
|
order: o,
|
|
13512
13907
|
product_lookup: productLookup,
|
|
@@ -13520,11 +13915,130 @@ function mount(router, deps) {
|
|
|
13520
13915
|
rating_notice: ordUrl ? _ratingNoticeFor(ordUrl.searchParams.get("rate_err")) : null,
|
|
13521
13916
|
reordered: ordUrl ? ordUrl.searchParams.get("reordered") === "1" : false,
|
|
13522
13917
|
cancelled: ordUrl ? ordUrl.searchParams.get("cancelled") === "1" : false,
|
|
13918
|
+
claim_offer: claimOffer,
|
|
13919
|
+
claim_notice: claimNotice === "sent" ? "sent" : null,
|
|
13523
13920
|
shop_name: shopName,
|
|
13524
13921
|
theme: theme,
|
|
13525
13922
|
}));
|
|
13526
13923
|
});
|
|
13527
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
|
+
|
|
13528
14042
|
// Stripe webhook — the order-completion path. A PaymentIntent succeeds
|
|
13529
14043
|
// asynchronously (the customer may close the tab before Stripe fires),
|
|
13530
14044
|
// so the order's pending→paid transition lands here, not on the return
|
|
@@ -13688,6 +14202,19 @@ function mount(router, deps) {
|
|
|
13688
14202
|
}
|
|
13689
14203
|
|
|
13690
14204
|
router.get("/account/login", async function (req, res) {
|
|
14205
|
+
// An already-signed-in visitor has no business on the sign-in screen —
|
|
14206
|
+
// send them to their account instead of re-rendering a login form
|
|
14207
|
+
// (mirrors the /account guard's auth read + vault-not-configured catch).
|
|
14208
|
+
var signedIn;
|
|
14209
|
+
try { signedIn = _currentCustomer(req); }
|
|
14210
|
+
catch (e) {
|
|
14211
|
+
if (e && e.code === "vault/not-initialized") return _serviceUnavailable(res, "auth not configured");
|
|
14212
|
+
throw e;
|
|
14213
|
+
}
|
|
14214
|
+
if (signedIn) {
|
|
14215
|
+
res.status(303); res.setHeader && res.setHeader("location", "/account");
|
|
14216
|
+
return res.end ? res.end() : res.send("");
|
|
14217
|
+
}
|
|
13691
14218
|
var cartCount = await _cartCountForReq(req);
|
|
13692
14219
|
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
13693
14220
|
// Login captcha is opt-in (CAPTCHA_GATE_LOGIN). The widget + the scoped
|
|
@@ -13824,6 +14351,23 @@ function mount(router, deps) {
|
|
|
13824
14351
|
if (anonCart) await deps.cart.setCustomer(anonCart.id, rv.customer_id);
|
|
13825
14352
|
} catch (_e) { /* best-effort merge; sign-in itself succeeds */ }
|
|
13826
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
|
+
}
|
|
13827
14371
|
_setAuthCookie(req, res, { customer_id: rv.customer_id, exp: Date.now() + b.constants.TIME.days(14) });
|
|
13828
14372
|
res.status(303); res.setHeader && res.setHeader("location", "/account");
|
|
13829
14373
|
return res.end ? res.end() : res.send("");
|
|
@@ -17967,8 +18511,15 @@ function mount(router, deps) {
|
|
|
17967
18511
|
try { o = orderId ? await deps.order.get(orderId) : null; }
|
|
17968
18512
|
catch (e) { if (e instanceof TypeError) { o = null; } else throw e; }
|
|
17969
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.
|
|
17970
18520
|
var reAuth = _currentCustomerEnv(req);
|
|
17971
|
-
|
|
18521
|
+
var reAccessToken = (req.query && typeof req.query.k === "string") ? req.query.k : "";
|
|
18522
|
+
if (!_orderAccessGranted(req, o, reAuth, reAccessToken)) {
|
|
17972
18523
|
return _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme }));
|
|
17973
18524
|
}
|
|
17974
18525
|
if (!_orderEligibleForReorder(o.status)) {
|
package/package.json
CHANGED