@blamejs/blamejs-shop 0.4.23 → 0.4.24

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/lib/storefront.js CHANGED
@@ -313,7 +313,10 @@ var LAYOUT =
313
313
  " </div>\n" +
314
314
  " </header>\n" +
315
315
  "\n" +
316
+ " <div class=\"page-shell\">\n" +
316
317
  " <main id=\"main\">{{body}}</main>\n" +
318
+ "RAW_SIDEBAR_RAIL" +
319
+ " </div>\n" +
317
320
  "\n" +
318
321
  " <section class=\"newsletter-band\" aria-labelledby=\"newsletter-title\">\n" +
319
322
  " <div class=\"newsletter-band__inner\">\n" +
@@ -367,6 +370,7 @@ var LAYOUT =
367
370
  " <ul>\n" +
368
371
  " <li><a href=\"/account\">{{footer_operators_account}}</a></li>\n" +
369
372
  " <li><a href=\"/orders\">{{footer_operators_orders}}</a></li>\n" +
373
+ " <li><a href=\"/suggestions\">{{footer_operators_suggestions}}</a></li>\n" +
370
374
  " <li><a href=\"mailto:hello@blamejs.shop\">{{footer_operators_contact}}</a></li>\n" +
371
375
  " </ul>\n" +
372
376
  " </div>\n" +
@@ -739,6 +743,243 @@ function _promoBannerHtml(opts, placement) {
739
743
  return "";
740
744
  }
741
745
 
746
+ // ---- sidebar widgets ---------------------------------------------------
747
+ //
748
+ // Operator-curated content blocks rendered in the storefront's right rail.
749
+ // Each page declares an ordered widget list via the admin console's page
750
+ // placement; the storefront resolves the active widgets for the current page
751
+ // + viewer per request and splices the rail into the LAYOUT. Resolution
752
+ // mirrors the announcement bar + promo banners: a short-TTL in-memory cache
753
+ // of every active widget (refreshed out-of-band, fire-and-forget) feeds a
754
+ // SYNCHRONOUS per-request resolver so the picked widgets ride the same locale
755
+ // ALS the page handler reads. No per-request DB read on the hot render path.
756
+ //
757
+ // Edge-cache-safe: a widget's rendered markup is fully operator-authored and
758
+ // global (no per-session / per-customer data is baked in), and the page_key
759
+ // is derived purely from the request path (the edge cache key), so the rail
760
+ // can render at the Cloudflare edge byte-identical with the container (the
761
+ // `worker/render/_lib.js` `sidebarWidget` twin guards the markup). Audience is
762
+ // the same coarse guest / logged_in cookie bucket the other chrome uses;
763
+ // segment audience is skipped at resolve time (the console doesn't offer it).
764
+ //
765
+ // The render is intentionally static per kind — it surfaces the operator's
766
+ // configured copy (headline, message, badge slugs, CTA labels). Dynamic data
767
+ // enrichment (a real recently-viewed product list, a live visitor count, a
768
+ // rendered featured-collection grid) is layered by client islands keyed off
769
+ // the widget's `data-widget-kind` / `data-widget-slug` attributes, which both
770
+ // substrates emit identically — so the server contract stays edge-cacheable
771
+ // and the dynamic layer is progressive enhancement, never a per-render DB hit.
772
+ var _SIDEBAR_TTL_MS = 30000;
773
+ // page_keys the resolver recognises — kept in lockstep with the admin
774
+ // placement editor's page list + `worker/data/sidebar-widgets.js`.
775
+ var _SIDEBAR_PAGE_KEYS = ["home", "collection", "search", "cart", "product"];
776
+ // Cache shape: `byPage[page_key][viewer_kind]` → ordered array of resolved
777
+ // widget rows (already audience + schedule filtered by the primitive's
778
+ // widgetsForPage). The refresh resolves both viewer buckets per page so the
779
+ // sync per-request resolver just indexes in.
780
+ var _sidebarCache = { byPage: Object.create(null), at: 0, inflight: false };
781
+
782
+ // The page_key for a request path, derived purely from the path (the edge
783
+ // cache key) so the rail stays edge-cacheable. Kept in lockstep with
784
+ // `worker/data/sidebar-widgets.js`'s `sidebarPageKeyForPath`.
785
+ function _sidebarPageKeyForPath(pathname) {
786
+ if (typeof pathname !== "string" || !pathname.length) return null;
787
+ if (pathname === "/") return "home";
788
+ if (pathname === "/cart") return "cart";
789
+ if (pathname === "/search") return "search";
790
+ if (pathname.indexOf("/collections/") === 0) return "collection";
791
+ if (pathname.indexOf("/products/") === 0) return "product";
792
+ return null;
793
+ }
794
+
795
+ // Refresh the per-page resolved-widget cache when it's older than the TTL.
796
+ // Async + fire-and-forget — a slow D1 read never stalls a render; the request
797
+ // resolves against whatever the cache last held (empty on a cold first
798
+ // request, populated within the TTL after). Resolves both viewer buckets for
799
+ // each page so the sync resolver doesn't have to await. The guest bucket is
800
+ // resolved with no customer_id; the logged_in bucket needs a customer_id, but
801
+ // the storefront only buckets on auth-cookie PRESENCE (exact identity isn't
802
+ // known on the cache path) — so logged_in is resolved with a sentinel id, and
803
+ // any segment-audience widget is skipped at resolve time exactly like the
804
+ // announcement bar (the console never offers segment). A non-segment
805
+ // logged_in widget surfaces correctly because widgetsForPage's audience gate
806
+ // for `logged_in` only checks viewer_kind, not the specific customer.
807
+ function _refreshSidebarCache(sidebarWidgets) {
808
+ if (!sidebarWidgets) return;
809
+ var now = Date.now();
810
+ if (_sidebarCache.inflight) return;
811
+ if (now - _sidebarCache.at < _SIDEBAR_TTL_MS && _sidebarCache.at !== 0) return;
812
+ _sidebarCache.inflight = true;
813
+ Promise.resolve()
814
+ .then(async function () {
815
+ var nowTs = Date.now();
816
+ var byPage = Object.create(null);
817
+ for (var i = 0; i < _SIDEBAR_PAGE_KEYS.length; i += 1) {
818
+ var key = _SIDEBAR_PAGE_KEYS[i];
819
+ byPage[key] = { guest: [], logged_in: [] };
820
+ try {
821
+ byPage[key].guest = await sidebarWidgets.widgetsForPage({
822
+ page_key: key, viewer_kind: "guest", now: nowTs,
823
+ });
824
+ } catch (_eg) { byPage[key].guest = []; }
825
+ try {
826
+ byPage[key].logged_in = await sidebarWidgets.widgetsForPage({
827
+ page_key: key, viewer_kind: "logged_in",
828
+ customer_id: SIDEBAR_LOGGED_IN_SENTINEL, now: nowTs,
829
+ });
830
+ } catch (_el) {
831
+ // A segment-audience widget without a wired segments handle throws
832
+ // here; the cache then falls back to the guest set for the bucket
833
+ // so a placed all/guest/logged_in widget still renders (the segment
834
+ // row is the only one that would drop, matching the announcement
835
+ // bar's segment skip).
836
+ byPage[key].logged_in = byPage[key].guest;
837
+ }
838
+ }
839
+ _sidebarCache.byPage = byPage;
840
+ _sidebarCache.at = Date.now();
841
+ })
842
+ .catch(function () { /* drop-silent — keep serving the prior cache */ })
843
+ .then(function () { _sidebarCache.inflight = false; });
844
+ }
845
+
846
+ // Sentinel customer id for the logged_in cache bucket — a syntactically valid
847
+ // non-empty string the audience gate accepts. The storefront only buckets on
848
+ // auth-cookie presence (it doesn't know the exact customer on the cache
849
+ // path), and a non-segment logged_in widget's audience gate checks only
850
+ // viewer_kind, so the sentinel resolves the same ordered set every signed-in
851
+ // visitor sees. (Segment widgets are skipped — the console never offers one.)
852
+ var SIDEBAR_LOGGED_IN_SENTINEL = "00000000-0000-7000-8000-000000000000";
853
+
854
+ // Resolve the ordered widget rows for a page_key + viewer from the cache.
855
+ function _resolveSidebarWidgets(pageKey, viewerKind) {
856
+ if (!pageKey) return [];
857
+ var page = _sidebarCache.byPage[pageKey];
858
+ if (!page) return [];
859
+ var rows = (viewerKind === "logged_in") ? page.logged_in : page.guest;
860
+ return Array.isArray(rows) ? rows : [];
861
+ }
862
+
863
+ // Build the markup for a single resolved widget row — BYTE-IDENTICAL to the
864
+ // edge twin `worker/render/_lib.js`'s `sidebarWidget`. Every operator field is
865
+ // HTML-escaped at the sink. Returns "" for a null/garbage row. The kind-
866
+ // specific body surfaces the operator's static configuration; the dynamic
867
+ // kinds carry a `data-widget-*` shell a client island can enrich.
868
+ function _buildSidebarWidget(w, pageKey) {
869
+ if (!w || typeof w !== "object") return "";
870
+ var esc = function (s) { return b.template.escapeHtml(String(s == null ? "" : s)); };
871
+ var kind = esc(w.kind);
872
+ var slug = esc(w.slug);
873
+ var title = esc(w.title);
874
+ var payload = (w.payload && typeof w.payload === "object") ? w.payload : {};
875
+ // Route an outbound widget link through the container click counter:
876
+ // `/sidebar/<slug>/click?to=<dest>&page_key=<page>`. The dest + page_key
877
+ // are derived from operator-validated slugs (narrow charsets), so the href
878
+ // is byte-identical edge + container; the edge link simply navigates to the
879
+ // container counter, which records the click + 303s to `to`. A page_key the
880
+ // builder wasn't given (a unit test rendering a lone widget) links direct.
881
+ var _clickHref = function (dest) {
882
+ if (!pageKey) return dest;
883
+ return "/sidebar/" + slug + "/click?to=" + encodeURIComponent(dest) + "&page_key=" + esc(pageKey);
884
+ };
885
+ var inner;
886
+
887
+ if (w.kind === "newsletter_signup") {
888
+ // A no-JS POST to /newsletter (the existing sitewide newsletter handler).
889
+ // list_id rides as a hidden field; the handler ignores unknown fields.
890
+ inner =
891
+ "<form class=\"sidebar-widget__newsletter\" method=\"post\" action=\"/newsletter\">" +
892
+ "<input type=\"hidden\" name=\"list_id\" value=\"" + esc(payload.list_id) + "\">" +
893
+ "<label class=\"skip-link\" for=\"sw-news-" + slug + "\">Email</label>" +
894
+ "<input id=\"sw-news-" + slug + "\" type=\"email\" name=\"email\" required placeholder=\"you@example.com\" autocomplete=\"email\">" +
895
+ "<button type=\"submit\">" + esc(payload.cta_label || "Subscribe") + "</button>" +
896
+ "</form>" +
897
+ (payload.headline ? "<p class=\"sidebar-widget__lede\">" + esc(payload.headline) + "</p>" : "");
898
+ } else if (w.kind === "trust_badges") {
899
+ var badges = Array.isArray(payload.badges) ? payload.badges : [];
900
+ var items = "";
901
+ for (var i = 0; i < badges.length; i += 1) {
902
+ items += "<li class=\"sidebar-widget__badge\" data-badge=\"" + esc(badges[i]) + "\">" + esc(badges[i]) + "</li>";
903
+ }
904
+ inner = "<ul class=\"sidebar-widget__badges\">" + items + "</ul>";
905
+ } else if (w.kind === "social_proof") {
906
+ inner = (payload.headline ? "<p class=\"sidebar-widget__lede\">" + esc(payload.headline) + "</p>" : "") +
907
+ "<p class=\"sidebar-widget__proof\" data-message-template=\"" + esc(payload.message_template) + "\">" +
908
+ esc(payload.message_template) + "</p>";
909
+ } else if (w.kind === "size_chart") {
910
+ inner = "<a class=\"sidebar-widget__link\" href=\"" + esc(_clickHref("/pages/" + String(payload.chart_slug == null ? "" : payload.chart_slug))) + "\">View size chart</a>";
911
+ } else if (w.kind === "featured_collection") {
912
+ inner = "<a class=\"sidebar-widget__link\" href=\"" + esc(_clickHref("/collections/" + String(payload.collection_slug == null ? "" : payload.collection_slug))) +
913
+ "\" data-collection-slug=\"" + esc(payload.collection_slug) + "\" data-limit=\"" + esc(String(payload.limit == null ? "" : payload.limit)) + "\">Shop the collection</a>";
914
+ } else if (w.kind === "recently_viewed") {
915
+ inner = "<div class=\"sidebar-widget__recent\" data-limit=\"" + esc(String(payload.limit == null ? "" : payload.limit)) +
916
+ "\"><p class=\"sidebar-widget__hint\">Items you've looked at appear here.</p></div>";
917
+ } else if (w.kind === "live_visitors") {
918
+ inner = "<div class=\"sidebar-widget__live\" data-window-minutes=\"" + esc(String(payload.window_minutes == null ? "" : payload.window_minutes)) +
919
+ "\" data-min-threshold=\"" + esc(String(payload.min_threshold == null ? "" : payload.min_threshold)) + "\"></div>";
920
+ } else if (w.kind === "countdown_timer") {
921
+ inner = "<div class=\"sidebar-widget__countdown\" data-target-at=\"" + esc(String(payload.target_at == null ? "" : payload.target_at)) + "\">" +
922
+ "<span class=\"sidebar-widget__countdown-done\">" + esc(payload.completed_label) + "</span></div>";
923
+ } else if (w.kind === "sticky_addtocart") {
924
+ inner = "<div class=\"sidebar-widget__sticky\" data-variant-slug=\"" + esc(payload.variant_slug) + "\"></div>";
925
+ } else {
926
+ inner = "";
927
+ }
928
+
929
+ return "<section class=\"sidebar-widget sidebar-widget--" + kind +
930
+ "\" data-widget-slug=\"" + slug + "\" data-widget-kind=\"" + kind + "\">" +
931
+ "<h2 class=\"sidebar-widget__title\">" + title + "</h2>" +
932
+ inner +
933
+ "</section>";
934
+ }
935
+
936
+ // Fire-and-forget impression bump for a rendered widget. Drop-silent on the
937
+ // hot render path — the counter is supplementary; an absent handle / failed
938
+ // write never affects the response. recordImpression is itself drop-silent.
939
+ function _fireSidebarImpression(handle, slug, pageKey) {
940
+ if (!handle || typeof handle.recordImpression !== "function" || !slug || !pageKey) return;
941
+ try {
942
+ var r = handle.recordImpression({ widget_slug: slug, page_key: pageKey });
943
+ if (r && typeof r.then === "function") r.then(function () {}, function () {});
944
+ } catch (_e) { /* drop-silent — impression bump must not affect render */ }
945
+ }
946
+
947
+ // Build the full right-rail `<aside>` for a page from a list of resolved
948
+ // widget rows. Returns "" for an empty list so a page with no placed widgets
949
+ // renders no rail. BYTE-IDENTICAL to the edge twin (`worker/render/_lib.js`'s
950
+ // `sidebarRail`). Container-side, a best-effort impression fires per rendered
951
+ // widget (the explicit-opts / edge path passes no handle, so it never counts).
952
+ function _buildSidebarRail(rows, opts) {
953
+ if (!Array.isArray(rows) || !rows.length) return "";
954
+ var inner = "";
955
+ var handle = (opts && opts._sidebar_handle) || null;
956
+ var pageKey = (opts && opts._sidebar_page_key) || null;
957
+ for (var i = 0; i < rows.length; i += 1) {
958
+ var html = _buildSidebarWidget(rows[i], pageKey);
959
+ if (html) {
960
+ inner += html;
961
+ if (handle) _fireSidebarImpression(handle, rows[i].slug, pageKey);
962
+ }
963
+ }
964
+ if (!inner) return "";
965
+ return "<aside class=\"sidebar-rail\" role=\"complementary\" aria-label=\"More from the shop\">" + inner + "</aside>";
966
+ }
967
+
968
+ // Resolve the pre-rendered rail HTML for the request. An explicit
969
+ // `opts.sidebar_widgets` string (the edge twin / a unit test) wins; otherwise
970
+ // the ALS store carries the resolved rows + the page_key + handle seeded by
971
+ // `sidebarWidgetMiddleware`, and the rail is built (and impressions fired)
972
+ // here on the container render path. Returns "" when no widgets are placed.
973
+ function _sidebarRailHtml(opts) {
974
+ if (opts && typeof opts.sidebar_widgets === "string") return opts.sidebar_widgets;
975
+ var storeCtx = _localeAls.getStore();
976
+ if (!storeCtx || !Array.isArray(storeCtx.sidebar_widgets_rows) || !storeCtx.sidebar_widgets_rows.length) return "";
977
+ return _buildSidebarRail(storeCtx.sidebar_widgets_rows, {
978
+ _sidebar_handle: storeCtx.sidebar_widgets_handle,
979
+ _sidebar_page_key: storeCtx.sidebar_widgets_page_key,
980
+ });
981
+ }
982
+
742
983
  // Multi-currency display switcher — a GET form in the footer listing the
743
984
  // operator's display currencies. Selecting one POSTs to /currency, which
744
985
  // sets the sealed `shop_ccy` cookie and redirects back. The currently
@@ -963,6 +1204,11 @@ function _wrap(opts) {
963
1204
  // matching render fn, not the LAYOUT.
964
1205
  var promoTopStrip = _promoBannerHtml(opts, "top_strip");
965
1206
  var promoFooter = _promoBannerHtml(opts, "footer");
1207
+ // Sidebar rail — operator-curated right-rail widgets for the current page,
1208
+ // pre-rendered from the ALS-resolved rows (or "" when none are placed).
1209
+ // Byte-identical to the edge `sidebarRail`; container-side this also fires a
1210
+ // best-effort impression per rendered widget.
1211
+ var sidebarRailHtml = _sidebarRailHtml(opts);
966
1212
  var chrome = localeCtx.chrome;
967
1213
  var cartCount = opts.cart_count == null ? 0 : opts.cart_count;
968
1214
  // The cart aria-label carries the count: when the resolved string is
@@ -1058,6 +1304,10 @@ function _wrap(opts) {
1058
1304
  // announcement bar — byte-consistent with the edge `spliceRaw`.
1059
1305
  assembled = _spliceRaw(assembled, "RAW_PROMO_TOP_STRIP", promoTopStrip);
1060
1306
  assembled = _spliceRaw(assembled, "RAW_PROMO_FOOTER", promoFooter);
1307
+ // Sidebar rail — operator widget copy (HTML-escaped, but a `$` can survive),
1308
+ // so splice via the replacer-function helper like the other chrome blocks;
1309
+ // byte-consistent with the edge `spliceRaw`.
1310
+ assembled = _spliceRaw(assembled, "RAW_SIDEBAR_RAIL", sidebarRailHtml);
1061
1311
  // Primary nav — a raw splice (post strict-render) so the chrome strings
1062
1312
  // it consumes (nav_shop / nav_collections / nav_categories /
1063
1313
  // nav_framework / nav_account / nav_menu / nav_cart_aria) need no LAYOUT
@@ -7544,14 +7794,62 @@ function _orderTrackingBlock(shipments) {
7544
7794
  "<ul class=\"order-shipment-list\">" + cards + "</ul></div>";
7545
7795
  }
7546
7796
 
7547
- // Request-a-return + Reorder affordances for an order, gated on its
7548
- // status. Reorder is a POST (it mutates the cart) carrying the order id
7549
- // in the path; Request-a-return links to the existing return form. The
7550
- // same builder feeds the order page and (via _orderRowActions) the
7551
- // dashboard rows so the eligibility rules live in one place.
7552
- function _orderActionsBlock(o) {
7797
+ // A printable receipt is offered on the order page once the order has been
7798
+ // paid for a still-pending (unpaid) order has nothing to receipt, and the
7799
+ // terminal off-ramps (cancelled / refunded) still get one because a buyer
7800
+ // often needs the receipt for a cancelled/refunded purchase's paperwork. The
7801
+ // document itself is rendered + streamed by GET /orders/:id/receipt.
7802
+ function _orderEligibleForReceipt(status) {
7803
+ return status !== "pending";
7804
+ }
7805
+
7806
+ // Size of each receipt body chunk written to the socket. 16 KiB keeps the
7807
+ // per-write payload small enough that a slow client backpressures naturally
7808
+ // while staying large enough that a single-page receipt finishes in a couple
7809
+ // of writes.
7810
+ var RECEIPT_CHUNK_BYTES = 16 * 1024;
7811
+
7812
+ // Yield a rendered receipt document as an async-iterable of bounded chunks so
7813
+ // the download route streams it through res.write rather than buffering the
7814
+ // whole body. The source (printReceipts.htmlPdf) returns a complete string
7815
+ // today; this slices it on UTF-16 boundaries into RECEIPT_CHUNK_BYTES-sized
7816
+ // pieces. A source that already yields chunks (a future bulk/multi-page
7817
+ // receipt) plugs into the same `for await` loop in the route unchanged.
7818
+ async function* _streamReceiptDocument(source) {
7819
+ if (source == null) return;
7820
+ // An already-async-iterable source streams straight through.
7821
+ if (typeof source !== "string" && typeof source[Symbol.asyncIterator] === "function") {
7822
+ for await (var part of source) {
7823
+ if (part != null) yield String(part);
7824
+ }
7825
+ return;
7826
+ }
7827
+ var text = String(source);
7828
+ for (var i = 0; i < text.length; i += RECEIPT_CHUNK_BYTES) {
7829
+ yield text.slice(i, i + RECEIPT_CHUNK_BYTES);
7830
+ }
7831
+ }
7832
+
7833
+ // Request-a-return + Reorder + Download-receipt affordances for an order,
7834
+ // gated on its status. Reorder is a POST (it mutates the cart) carrying the
7835
+ // order id in the path; Request-a-return links to the existing return form;
7836
+ // Download receipt is a GET that streams an HTML document. The same builder
7837
+ // feeds the order page and (via _orderRowActions) the dashboard rows so the
7838
+ // eligibility rules live in one place. `accessToken` is the guest order's
7839
+ // emailed capability token (?k=) when the page was opened from the email
7840
+ // link — carried onto the receipt link so a fresh-device viewer's download
7841
+ // re-proves access; empty/absent for owned orders (gated by the session).
7842
+ function _orderActionsBlock(o, accessToken) {
7553
7843
  var esc = b.template.escapeHtml;
7554
7844
  var btns = [];
7845
+ if (_orderEligibleForReceipt(o.status)) {
7846
+ var receiptHref = "/orders/" + esc(String(o.id)) + "/receipt";
7847
+ if (typeof accessToken === "string" && accessToken) {
7848
+ receiptHref += "?k=" + esc(encodeURIComponent(accessToken));
7849
+ }
7850
+ btns.push(
7851
+ "<a class=\"btn-secondary order-action\" href=\"" + receiptHref + "\">Download receipt</a>");
7852
+ }
7555
7853
  if (_orderEligibleForReorder(o.status)) {
7556
7854
  btns.push(
7557
7855
  "<form class=\"order-action\" method=\"post\" action=\"/orders/" + esc(String(o.id)) + "/reorder\">" +
@@ -7842,7 +8140,13 @@ function renderOrder(opts) {
7842
8140
  var shipments = opts.shipments || [];
7843
8141
  var timelineHtml = _orderTimelineBlock(o.status);
7844
8142
  var trackingHtml = _orderTrackingBlock(shipments);
7845
- var actionsHtml = _orderActionsBlock(o);
8143
+ // The receipt-download link carries the guest order's ?k= access token when
8144
+ // the page was opened from the emailed capability link, so the download
8145
+ // re-proves access on a device that holds no access cookie yet. Only set on
8146
+ // the container render (the route passes it); never present at the edge,
8147
+ // which doesn't render this page.
8148
+ var ordAccessToken = typeof opts.access_token === "string" ? opts.access_token : "";
8149
+ var actionsHtml = _orderActionsBlock(o, ordAccessToken);
7846
8150
  // Post-purchase rating panel — container-only (session-gated). The route
7847
8151
  // passes the resolved getRating row (rating) + the eligibility/notice
7848
8152
  // flags; the edge render never does, so the panel is empty at the edge.
@@ -9148,6 +9452,17 @@ function _setAuthCookie(req, res, env) {
9148
9452
  var fp = _authDeviceFingerprint(req);
9149
9453
  if (fp) sealed = Object.assign({}, env, { fp: fp });
9150
9454
  }
9455
+ // Stamp the issued-at so the server-side revocation gate can tell a
9456
+ // cookie minted BEFORE a customer's revocation boundary (erasure /
9457
+ // passkey-revoke / sign-out) from one minted after. A caller that
9458
+ // already set `iat` keeps it; absent, fill it now. Pre-binding cookies
9459
+ // (minted before this shipped) carry none — the gate treats a missing
9460
+ // `iat` as "predates any boundary" so a revoked customer's old cookie
9461
+ // still dies, while a customer with no boundary is unaffected.
9462
+ if (sealed && sealed.iat == null) {
9463
+ sealed = (sealed === env) ? Object.assign({}, env) : sealed;
9464
+ sealed.iat = Date.now();
9465
+ }
9151
9466
  _cookieJar().writeSealed(res, _authCookieName(secure), JSON.stringify(sealed), {
9152
9467
  expires: new Date(Date.now() + T.days(14)),
9153
9468
  secure: secure,
@@ -10229,9 +10544,13 @@ function renderProfile(opts) {
10229
10544
  "<div class=\"form-row\"><label class=\"form-field\"><span class=\"form-field__label\">Display name</span>" +
10230
10545
  "<input type=\"text\" name=\"display_name\" maxlength=\"128\" required autocomplete=\"name\" value=\"" + displayValue + "\"></label></div>" +
10231
10546
  "<div class=\"form-row\"><label class=\"form-field\"><span class=\"form-field__label\">Email</span>" +
10232
- "<input type=\"text\" value=\"Hidden for privacy — stored as a one-way hash\" disabled aria-describedby=\"email-note\"></label></div>" +
10233
- "<p id=\"email-note\" class=\"form-field__hint\">Your email address is never stored in readable form, so it can't be changed or shown here. " +
10234
- "Sign in with the address you registered. To use a different address, create a new account with it, or contact support if you need help moving your order history.</p>" +
10547
+ "<input type=\"text\" value=\"Stored as a one-way hash — never in readable form\" disabled aria-describedby=\"email-note\"></label></div>" +
10548
+ "<div id=\"email-note\" class=\"form-field__hint\">" +
10549
+ "<p>We never keep your email address in readable form. At sign-up it's run through a one-way hash so we can match you at sign-in without ever storing the address itself there is no plaintext copy to display here or to edit.</p>" +
10550
+ "<p>Because of that, an email change isn't a field we can offer: keep signing in with the address you registered. " +
10551
+ "To move to a different address, register a new account with it. " +
10552
+ "If you need your past orders carried over to a new address, contact support and we'll help link them.</p>" +
10553
+ "</div>" +
10235
10554
  "<div class=\"form-actions\"><button type=\"submit\" class=\"btn-primary\">Save changes</button> " +
10236
10555
  "<a class=\"btn-ghost\" href=\"/account\">Cancel</a></div>" +
10237
10556
  "</form>" +
@@ -10407,6 +10726,178 @@ function renderSurveyPage(opts) {
10407
10726
  });
10408
10727
  }
10409
10728
 
10729
+ // ---- suggestion box (public idea board) --------------------------------
10730
+
10731
+ // Customer-facing labels for the suggestion FSM statuses. The operator-side
10732
+ // `under_consideration` / `planned` / `shipped` / `declined` map to plain
10733
+ // shopper-readable copy; `open` reads as "Open" and `duplicate` rows are
10734
+ // never listed publicly (the primitive's listSuggestions filters them out of
10735
+ // the default board only by spam/archive, so the page itself skips them).
10736
+ var _SUGGESTION_STATUS_LABEL = {
10737
+ open: "Open",
10738
+ under_consideration: "Under review",
10739
+ planned: "Planned",
10740
+ shipped: "Shipped",
10741
+ declined: "Not planned",
10742
+ duplicate: "Merged",
10743
+ };
10744
+
10745
+ // Human label for a suggestion category value.
10746
+ var _SUGGESTION_CATEGORY_LABEL = {
10747
+ product_idea: "Product idea",
10748
+ feature_request: "Feature request",
10749
+ improvement: "Improvement",
10750
+ complaint: "Issue",
10751
+ general: "General",
10752
+ };
10753
+
10754
+ // One public suggestion row → its card markup. Every operator/customer
10755
+ // free-text value (title, body, response) is HTML-escaped at the sink; the
10756
+ // vote control is a no-JS POST form so the board works without scripts.
10757
+ function _suggestionCard(row, opts) {
10758
+ var esc = function (s) { return b.template.escapeHtml(String(s == null ? "" : s)); };
10759
+ var slug = esc(row.id);
10760
+ var statusKey = _SUGGESTION_STATUS_LABEL[row.status] ? row.status : "open";
10761
+ var statusLbl = _SUGGESTION_STATUS_LABEL[statusKey];
10762
+ var catLbl = _SUGGESTION_CATEGORY_LABEL[row.category] || "General";
10763
+ var voteCount = Number(row.vote_count) || 0;
10764
+ var response = (row.response_text && String(row.response_text).trim().length)
10765
+ ? "<div class=\"suggestion-card__response\"><span class=\"suggestion-card__response-by\">Response from the team</span>" +
10766
+ "<p>" + esc(row.response_text) + "</p></div>"
10767
+ : "";
10768
+ // The vote form is only offered for a row that is still open to voting —
10769
+ // terminal statuses freeze the count at the primitive layer (it refuses a
10770
+ // vote with SUGGESTION_VOTING_CLOSED), so the page omits the control rather
10771
+ // than render a button that always errors.
10772
+ var votable = ["open", "under_consideration", "planned"].indexOf(row.status) !== -1;
10773
+ var voteForm = votable
10774
+ ? "<form class=\"suggestion-card__vote\" method=\"post\" action=\"/suggestions/" + slug + "/vote\">" +
10775
+ "<input type=\"hidden\" name=\"vote\" value=\"upvote\">" +
10776
+ "<button class=\"suggestion-card__vote-btn\" type=\"submit\" aria-label=\"Upvote this suggestion\">" +
10777
+ "<span class=\"suggestion-card__vote-arrow\" aria-hidden=\"true\">&#9650;</span>" +
10778
+ "<span class=\"suggestion-card__vote-count\">" + esc(String(voteCount)) + "</span>" +
10779
+ "</button>" +
10780
+ "</form>"
10781
+ : "<div class=\"suggestion-card__vote suggestion-card__vote--closed\">" +
10782
+ "<span class=\"suggestion-card__vote-arrow\" aria-hidden=\"true\">&#9650;</span>" +
10783
+ "<span class=\"suggestion-card__vote-count\">" + esc(String(voteCount)) + "</span>" +
10784
+ "</div>";
10785
+ return "<article class=\"suggestion-card\" data-suggestion-id=\"" + slug + "\">" +
10786
+ voteForm +
10787
+ "<div class=\"suggestion-card__body\">" +
10788
+ "<div class=\"suggestion-card__meta\">" +
10789
+ "<span class=\"suggestion-card__category\">" + esc(catLbl) + "</span>" +
10790
+ "<span class=\"suggestion-card__status suggestion-card__status--" + esc(statusKey) + "\">" + esc(statusLbl) + "</span>" +
10791
+ "</div>" +
10792
+ "<h3 class=\"suggestion-card__title\">" + esc(row.title) + "</h3>" +
10793
+ "<p class=\"suggestion-card__text\">" + esc(row.body) + "</p>" +
10794
+ response +
10795
+ "</div>" +
10796
+ "</article>";
10797
+ }
10798
+
10799
+ // Render the public suggestion board: the submit form on top, then the
10800
+ // browsable list of existing suggestions with their vote controls. `opts`:
10801
+ // - suggestions : array of hydrated public rows (newest / top-voted)
10802
+ // - sort : the active sort ("newest" | "top_voted")
10803
+ // - next_cursor : opaque pagination cursor or null
10804
+ // - notice : a form-level error message (re-rendered submit failure)
10805
+ // - submitted : truthy after a successful submit (PRG landing)
10806
+ // - voted : truthy after a vote POST (PRG landing)
10807
+ // - form : sticky create-form values on a validation re-render
10808
+ function renderSuggestionsPage(opts) {
10809
+ opts = opts || {};
10810
+ var esc = function (s) { return b.template.escapeHtml(String(s == null ? "" : s)); };
10811
+ var rows = opts.suggestions || [];
10812
+ var form = opts.form || {};
10813
+
10814
+ var notice = opts.notice
10815
+ ? "<p class=\"form-notice\">" + esc(opts.notice) + "</p>"
10816
+ : "";
10817
+ var flash = "";
10818
+ if (opts.submitted) {
10819
+ flash = "<p class=\"suggestion-flash suggestion-flash--ok\">Thanks — your suggestion is in. The team reviews every idea.</p>";
10820
+ } else if (opts.voted) {
10821
+ flash = "<p class=\"suggestion-flash suggestion-flash--ok\">Thanks for the vote.</p>";
10822
+ }
10823
+
10824
+ var categoryOpts = ["product_idea", "feature_request", "improvement", "complaint", "general"].map(function (c) {
10825
+ var sel = (form.category === c) ? " selected" : "";
10826
+ return "<option value=\"" + c + "\"" + sel + ">" + esc(_SUGGESTION_CATEGORY_LABEL[c]) + "</option>";
10827
+ }).join("");
10828
+
10829
+ var submitForm =
10830
+ "<section class=\"suggestion-submit\">" +
10831
+ "<h2 class=\"suggestion-submit__title\">Have an idea?</h2>" +
10832
+ "<p class=\"suggestion-submit__lede\">Tell us what you'd like to see — a product to stock, a feature to build, or something to fix. Browse and upvote others' ideas below.</p>" +
10833
+ notice +
10834
+ "<form class=\"suggestion-form\" method=\"post\" action=\"/suggestions\">" +
10835
+ "<label class=\"suggestion-form__field\"><span>Title</span>" +
10836
+ "<input type=\"text\" name=\"title\" maxlength=\"200\" required value=\"" + esc(form.title || "") + "\" placeholder=\"A short summary of your idea\"></label>" +
10837
+ "<label class=\"suggestion-form__field\"><span>Details</span>" +
10838
+ "<textarea name=\"body\" maxlength=\"5000\" rows=\"4\" required placeholder=\"What would you like, and why?\">" + esc(form.body || "") + "</textarea></label>" +
10839
+ "<label class=\"suggestion-form__field\"><span>Category</span>" +
10840
+ "<select name=\"category\">" + categoryOpts + "</select></label>" +
10841
+ "<label class=\"suggestion-form__field\"><span>Email (optional)</span>" +
10842
+ "<input type=\"email\" name=\"customer_email\" autocomplete=\"email\" value=\"" + esc(form.customer_email || "") + "\" placeholder=\"So we can follow up — never shown publicly\"></label>" +
10843
+ "<div class=\"suggestion-form__actions\"><button class=\"btn-primary\" type=\"submit\">Submit idea</button></div>" +
10844
+ "</form>" +
10845
+ "</section>";
10846
+
10847
+ // Sort toggle — newest vs most-upvoted. Plain links so the no-JS board
10848
+ // re-queries with the chosen order.
10849
+ var sort = (opts.sort === "top_voted") ? "top_voted" : "newest";
10850
+ var sortToggle =
10851
+ "<div class=\"suggestion-board__sort\">" +
10852
+ "<a class=\"suggestion-board__sort-link" + (sort === "newest" ? " is-active" : "") + "\" href=\"/suggestions\">Newest</a>" +
10853
+ "<a class=\"suggestion-board__sort-link" + (sort === "top_voted" ? " is-active" : "") + "\" href=\"/suggestions?sort=top_voted\">Most wanted</a>" +
10854
+ "</div>";
10855
+
10856
+ var cards = rows.length
10857
+ ? rows.map(function (r) { return _suggestionCard(r, opts); }).join("")
10858
+ : "<p class=\"empty\">No suggestions yet — be the first to share one.</p>";
10859
+
10860
+ // "Load more" is a plain link carrying the opaque cursor (and the active
10861
+ // sort), so pagination works without scripts.
10862
+ var more = "";
10863
+ if (opts.next_cursor) {
10864
+ var moreHref = "/suggestions?" +
10865
+ (sort === "top_voted" ? "sort=top_voted&" : "") +
10866
+ "cursor=" + encodeURIComponent(opts.next_cursor);
10867
+ more = "<div class=\"suggestion-board__more\"><a class=\"btn-ghost\" href=\"" + esc(moreHref) + "\">Load more</a></div>";
10868
+ }
10869
+
10870
+ var board =
10871
+ "<section class=\"suggestion-board\">" +
10872
+ "<div class=\"suggestion-board__head\">" +
10873
+ "<h2 class=\"suggestion-board__title\">What people are asking for</h2>" +
10874
+ sortToggle +
10875
+ "</div>" +
10876
+ "<div class=\"suggestion-board__list\">" + cards + "</div>" +
10877
+ more +
10878
+ "</section>";
10879
+
10880
+ var body =
10881
+ "<section class=\"suggestions-page\"><div class=\"suggestions-page__inner\">" +
10882
+ "<p class=\"eyebrow\">Suggestion box</p>" +
10883
+ "<h1 class=\"suggestions-page__title\">Help shape the shop</h1>" +
10884
+ flash +
10885
+ submitForm +
10886
+ board +
10887
+ "</div></section>";
10888
+
10889
+ return _wrap({
10890
+ title: "Suggestion box",
10891
+ shop_name: opts.shop_name || "blamejs.shop",
10892
+ cart_count: opts.cart_count == null ? 0 : opts.cart_count,
10893
+ theme_css: opts.theme_css,
10894
+ canonical_url: opts.canonical_url,
10895
+ og_url: opts.og_url,
10896
+ og_description: "Share product ideas, feature requests, and improvements — and upvote what others have suggested.",
10897
+ body: body,
10898
+ });
10899
+ }
10900
+
10410
10901
  // ---- quote (token-gated / account-gated) -------------------------------
10411
10902
 
10412
10903
  // One quote's status → the customer-facing label + lede shown when the quote
@@ -11556,6 +12047,68 @@ function mount(router, deps) {
11556
12047
  });
11557
12048
  }
11558
12049
 
12050
+ // Sidebar-widget resolution — synchronous (so its `enterWith` reaches the
12051
+ // page handler) and audience-bucketed off the auth-cookie presence, exactly
12052
+ // like the announcement bar + promo banners. Derives the page_key from the
12053
+ // request path (the edge cache key), resolves the placed widgets for that
12054
+ // page + viewer from a short-TTL in-memory cache refreshed out-of-band here
12055
+ // (fire-and-forget — never blocks the render), and seeds the request ALS
12056
+ // with the resolved rows + page_key + handle so `_wrap` can build the rail
12057
+ // and fire impressions. Best-effort: any failure drops the rail, never the
12058
+ // page. Only runs for paths that carry a sidebar (home / collection / search
12059
+ // / cart / product); other paths seed nothing.
12060
+ if (typeof router.use === "function" && deps.sidebarWidgets) {
12061
+ router.use(function sidebarWidgetMiddleware(req, _res, next) {
12062
+ try {
12063
+ _refreshSidebarCache(deps.sidebarWidgets);
12064
+ var pathname = "/";
12065
+ try { pathname = new URL(req.url || "/", "http://localhost").pathname || "/"; }
12066
+ catch (_eu) { pathname = "/"; }
12067
+ var pageKey = _sidebarPageKeyForPath(pathname);
12068
+ if (pageKey) {
12069
+ var viewerKind = _readPrefixedCookie(req, AUTH_COOKIE_NAME_SECURE, AUTH_COOKIE_NAME) ? "logged_in" : "guest";
12070
+ var rows = _resolveSidebarWidgets(pageKey, viewerKind);
12071
+ var cur = _localeAls.getStore() || {};
12072
+ _localeAls.enterWith(Object.assign({}, cur, {
12073
+ sidebar_widgets_rows: rows,
12074
+ sidebar_widgets_page_key: pageKey,
12075
+ sidebar_widgets_handle: deps.sidebarWidgets,
12076
+ }));
12077
+ }
12078
+ } catch (_e) { /* drop-silent — no rail this request */ }
12079
+ next();
12080
+ });
12081
+
12082
+ // Click pass-through for a widget's outbound link — bumps recordClick
12083
+ // (best-effort, drop-silent) then 303-redirects to a validated same-origin
12084
+ // path supplied in `?to=`. The destination is re-validated at the sink
12085
+ // (a /-rooted path, not protocol-relative `//`), so a hostile/stale value
12086
+ // falls back to "/" and can never open-redirect or 500. `page_key` carries
12087
+ // the page the click occurred on for the per-page CTR breakdown.
12088
+ router.get("/sidebar/:slug/click", async function (req, res) {
12089
+ var slug = (req.params && typeof req.params.slug === "string") ? req.params.slug : "";
12090
+ var url = null;
12091
+ try { url = new URL(req.url || "/", "http://localhost"); } catch (_eu) { url = null; }
12092
+ var toRaw = url ? url.searchParams.get("to") : null;
12093
+ var pageKey = url ? url.searchParams.get("page_key") : null;
12094
+ var to = "/";
12095
+ if (typeof toRaw === "string" && toRaw.charAt(0) === "/" && toRaw.charAt(1) !== "/" &&
12096
+ toRaw.indexOf("\\") === -1 && !/[\x00-\x1f\x7f]/.test(toRaw)) {
12097
+ to = toRaw;
12098
+ }
12099
+ if (/^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/.test(slug) &&
12100
+ typeof pageKey === "string" && _SIDEBAR_PAGE_KEYS.indexOf(pageKey) !== -1) {
12101
+ try {
12102
+ var r = deps.sidebarWidgets.recordClick({ widget_slug: slug, page_key: pageKey });
12103
+ if (r && typeof r.then === "function") { try { await r; } catch (_e) { /* drop-silent */ } }
12104
+ } catch (_e) { /* unknown slug / read error → still redirect */ }
12105
+ }
12106
+ res.status(303);
12107
+ res.setHeader && res.setHeader("location", to);
12108
+ return res.end ? res.end() : res.send("");
12109
+ });
12110
+ }
12111
+
11559
12112
  // ---- customer survey (token-gated) ----------------------------------
11560
12113
  // The invitation token IS the access — no login. GET renders the survey
11561
12114
  // (or a state notice); POST records the response. Container-only (the
@@ -11643,6 +12196,118 @@ function mount(router, deps) {
11643
12196
  });
11644
12197
  }
11645
12198
 
12199
+ // ---- suggestion box (public idea board) -----------------------------
12200
+ // The customer-driven "tell us what to build" loop. GET renders the
12201
+ // submit form + the browsable, upvotable board; POST submits a new idea;
12202
+ // POST /:id/vote records an upvote. Container-only — the page carries a
12203
+ // per-session `_csrf` token (it is NOT an EDGE_POST_PATHS prefix, so
12204
+ // `_injectCsrfFields` tokens both forms) and is never edge-cached.
12205
+ // Anonymous: no login is required to submit or vote (an optional email is
12206
+ // hashed at the primitive boundary, never stored raw). The submit + vote
12207
+ // POSTs ride the /suggestions tight rate-limit budget. Resilient: a bad
12208
+ // submit re-renders the form with a cleaned notice; an unknown vote target
12209
+ // or a closed suggestion degrades gracefully, never a 500.
12210
+ if (deps.suggestionBox) {
12211
+ var _suggestionCtx = function (_req) {
12212
+ return {
12213
+ shop_name: (deps.config && deps.config.shop_name) || "blamejs.shop",
12214
+ cart_count: 0,
12215
+ theme_css: (deps.theme && deps.theme.assetUrl) ? deps.theme.assetUrl("css/main.css") : DEFAULT_THEME_CSS_URL,
12216
+ };
12217
+ };
12218
+
12219
+ // Page size for the public board. The primitive caps at MAX_LIST_LIMIT;
12220
+ // a single screen of cards keeps the first paint light and pages the rest
12221
+ // through the opaque cursor.
12222
+ var _SUGGESTION_PAGE_SIZE = 20;
12223
+
12224
+ // Load a board page for a sort + optional cursor. Drop-silent to an empty
12225
+ // board on any read error (an unmigrated table, a malformed cursor) so the
12226
+ // page renders rather than 500-ing.
12227
+ var _loadSuggestionBoard = async function (sort, cursor) {
12228
+ try {
12229
+ var listOpts = { sort: sort, limit: _SUGGESTION_PAGE_SIZE };
12230
+ if (cursor) listOpts.cursor = cursor;
12231
+ var page = await deps.suggestionBox.listSuggestions(listOpts);
12232
+ return { rows: page.rows || [], next_cursor: page.next_cursor || null, sort: page.sort || sort };
12233
+ } catch (_e) {
12234
+ return { rows: [], next_cursor: null, sort: sort };
12235
+ }
12236
+ };
12237
+
12238
+ router.get("/suggestions", async function (req, res) {
12239
+ var ctx = _suggestionCtx(req);
12240
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
12241
+ var sort = (url && url.searchParams.get("sort") === "top_voted") ? "top_voted" : "newest";
12242
+ var cursor = url ? url.searchParams.get("cursor") : null;
12243
+ var board = await _loadSuggestionBoard(sort, cursor);
12244
+ _send(res, 200, renderSuggestionsPage({
12245
+ suggestions: board.rows,
12246
+ sort: board.sort,
12247
+ next_cursor: board.next_cursor,
12248
+ submitted: url && url.searchParams.get("submitted") === "1",
12249
+ voted: url && url.searchParams.get("voted") === "1",
12250
+ shop_name: ctx.shop_name, cart_count: ctx.cart_count, theme_css: ctx.theme_css,
12251
+ }));
12252
+ });
12253
+
12254
+ router.post("/suggestions", async function (req, res) {
12255
+ var ctx = _suggestionCtx(req);
12256
+ var body = req.body || {};
12257
+ // Coerce the form into the submit shape: trimmed title/body, the
12258
+ // category select, and an optional email (blank → omitted, so an
12259
+ // anonymous submission carries no identity). The primitive validates
12260
+ // length / control bytes / category and throws a TypeError on a bad
12261
+ // shape, which we degrade to a re-render of the form with the cleaned
12262
+ // message + the operator's typed values kept sticky.
12263
+ var input = {
12264
+ title: typeof body.title === "string" ? body.title : "",
12265
+ body: typeof body.body === "string" ? body.body : "",
12266
+ category: typeof body.category === "string" && body.category ? body.category : "general",
12267
+ };
12268
+ var emailRaw = typeof body.customer_email === "string" ? body.customer_email.trim() : "";
12269
+ if (emailRaw) input.customer_email = emailRaw;
12270
+ try {
12271
+ await deps.suggestionBox.submitSuggestion(input);
12272
+ } catch (e) {
12273
+ if (!(e instanceof TypeError)) throw e;
12274
+ var board = await _loadSuggestionBoard("newest", null);
12275
+ return _send(res, 400, renderSuggestionsPage({
12276
+ suggestions: board.rows, sort: board.sort, next_cursor: board.next_cursor,
12277
+ notice: (e.message || "Please check your suggestion.").replace(/^suggestionBox[.:]\s*/, ""),
12278
+ form: { title: input.title, body: input.body, category: input.category, customer_email: emailRaw },
12279
+ shop_name: ctx.shop_name, cart_count: ctx.cart_count, theme_css: ctx.theme_css,
12280
+ }));
12281
+ }
12282
+ res.status(303);
12283
+ res.setHeader && res.setHeader("location", "/suggestions?submitted=1");
12284
+ return res.end ? res.end() : res.send("");
12285
+ });
12286
+
12287
+ router.post("/suggestions/:id/vote", async function (req, res) {
12288
+ var id = (req.params && typeof req.params.id === "string") ? req.params.id : "";
12289
+ // The vote is keyed on the cart session id so a repeat vote from the
12290
+ // same browser session collapses to a no-op at the storage UNIQUE. A
12291
+ // visitor with no session yet can't be deduped, so we require one — a
12292
+ // session-less POST simply lands back on the board (the primitive needs
12293
+ // a session_id and would throw a TypeError otherwise). The single
12294
+ // supported direction is "upvote" (the board ranks by demand).
12295
+ var sid = _readSidCookie(req);
12296
+ if (sid) {
12297
+ try {
12298
+ await deps.suggestionBox.voteOnSuggestion({ suggestion_id: id, session_id: sid, vote: "upvote" });
12299
+ } catch (_e) {
12300
+ // Unknown id / closed voting / already voted — drop-silent. The
12301
+ // board re-render reflects the live count; a hostile or stale id
12302
+ // can never 500 the route.
12303
+ }
12304
+ }
12305
+ res.status(303);
12306
+ res.setHeader && res.setHeader("location", "/suggestions?voted=1");
12307
+ return res.end ? res.end() : res.send("");
12308
+ });
12309
+ }
12310
+
11646
12311
  // ---- quote (token-gated customer page) ------------------------------
11647
12312
  // A request-for-quote a customer can review + accept / decline via a single
11648
12313
  // capability link (/quote/:token) — no login. The token IS the access (the
@@ -12006,6 +12671,33 @@ function mount(router, deps) {
12006
12671
  return env;
12007
12672
  }
12008
12673
 
12674
+ // Server-side session-revocation gate. The sealed cookie is otherwise
12675
+ // self-validating for its 14-day TTL, so erasure / passkey-revoke /
12676
+ // sign-out have no way to kill a LIVE cookie without this check. Resolves
12677
+ // the customer's revocation boundary (sessions_valid_from) and reports
12678
+ // whether THIS envelope is revoked:
12679
+ //
12680
+ // * no boundary (0) → never revoked, live (the default; a
12681
+ // deploy adding the table signs no-one out)
12682
+ // * boundary set, cookie iat → revoked iff iat < boundary
12683
+ // * boundary set, no iat → revoked (a pre-`iat` cookie can't prove
12684
+ // it postdates the boundary)
12685
+ //
12686
+ // Best-effort fail-OPEN on a lookup error: a transient D1 blip must not
12687
+ // lock every signed-in customer out of their account. The durable
12688
+ // credential deletion in erasure is the backstop — a fail-open window
12689
+ // can't re-mint a session, only briefly extend an already-issued one.
12690
+ async function _sessionRevoked(env) {
12691
+ if (!env || !env.customer_id) return false;
12692
+ if (!deps.customers || typeof deps.customers.sessionsValidFrom !== "function") return false;
12693
+ var boundary;
12694
+ try { boundary = await deps.customers.sessionsValidFrom(env.customer_id); }
12695
+ catch (_e) { return false; }
12696
+ if (!boundary || boundary <= 0) return false;
12697
+ if (typeof env.iat !== "number") return true;
12698
+ return env.iat < boundary;
12699
+ }
12700
+
12009
12701
  // The operator's order-access signing key (derived in server.js from the
12010
12702
  // app secret, domain-separated). Absent it, the emailed-token access path
12011
12703
  // is inert — the placing-browser cookie and signed-in-owner paths still
@@ -14600,11 +15292,113 @@ function mount(router, deps) {
14600
15292
  cancelled: ordUrl ? ordUrl.searchParams.get("cancelled") === "1" : false,
14601
15293
  claim_offer: claimOffer,
14602
15294
  claim_notice: claimNotice === "sent" ? "sent" : null,
15295
+ // Carry a guest order's emailed access token onto the receipt-download
15296
+ // link so a viewer on a fresh device (cookie not yet stamped) can pull
15297
+ // the receipt. Only forwarded for a guest order opened via ?k= — an
15298
+ // owned order's receipt is gated by the session, so no token is needed
15299
+ // and none is leaked into the page for a signed-in owner.
15300
+ access_token: (!o.customer_id && ordAccessToken) ? ordAccessToken : "",
14603
15301
  shop_name: shopName,
14604
15302
  theme: theme,
14605
15303
  }));
14606
15304
  });
14607
15305
 
15306
+ // GET /orders/:order_id/receipt — stream a downloadable HTML receipt for
15307
+ // an order the requester is allowed to read. Mounts only when the
15308
+ // printReceipts primitive is wired; absent it the order page renders no
15309
+ // download link and this route is never reached. Access is the SAME gate
15310
+ // as GET /orders/:id — an owned order is downloadable only by its
15311
+ // signed-in owner; a guest order needs a capability proof (placing-browser
15312
+ // cookie, emailed ?k= token, or claim cookie). Anything else 404s,
15313
+ // indistinguishable from a missing order, so a stranger can't pull a
15314
+ // receipt (name / address / line items) by guessing an id.
15315
+ //
15316
+ // The document is written to the socket as the render source yields it —
15317
+ // header first, then one chunk per batch via res.write — so the response
15318
+ // never buffers the whole receipt in memory. printReceipts.htmlPdf returns
15319
+ // a complete self-contained HTML document today; _streamReceiptDocument
15320
+ // slices it through an async-iterable so a future streaming receipt source
15321
+ // (a bulk multi-page document) drops into the same bounded-memory loop.
15322
+ if (deps.printReceipts) {
15323
+ router.get("/orders/:order_id/receipt", async function (req, res) {
15324
+ var orderId = req.params && req.params.order_id;
15325
+ var o;
15326
+ // A malformed / unknown id is a missing receipt, not a server fault —
15327
+ // map the TypeError order.get throws on a non-UUID id to the same 404
15328
+ // path a well-formed-but-unknown id takes (mirrors the order page).
15329
+ try { o = orderId ? await deps.order.get(orderId) : null; }
15330
+ catch (e) { if (e instanceof TypeError) { o = null; } else throw e; }
15331
+ if (!o) return _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme }));
15332
+ var rcptAuth = _currentCustomerEnv(req);
15333
+ var rcptToken = (req.query && typeof req.query.k === "string") ? req.query.k : "";
15334
+ if (!_orderAccessGranted(req, o, rcptAuth, rcptToken)) {
15335
+ return _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme }));
15336
+ }
15337
+ // A receipt only exists once the order is paid for — a still-pending
15338
+ // (unpaid) order has nothing to receipt. Bounce to the order page
15339
+ // rather than render an empty/misleading document.
15340
+ if (!_orderEligibleForReceipt(o.status)) {
15341
+ res.status(303);
15342
+ res.setHeader && res.setHeader("location", "/orders/" + encodeURIComponent(o.id));
15343
+ return res.end ? res.end() : res.send("");
15344
+ }
15345
+ // A guest order opened from the emailed link carries the proof in ?k=;
15346
+ // stamp the device cookie now (idempotent) so a later receipt pull
15347
+ // without the param keeps resolving. Drop-silent: a stamp failure must
15348
+ // never break the download.
15349
+ if (!o.customer_id && rcptToken && !_hasGuestOrderAccessCookie(req, o.id)) {
15350
+ try { _grantGuestOrderAccess(req, res, o.id); } catch (_eGrant) { /* best-effort */ }
15351
+ }
15352
+ // Render the document source. A coded conflict (an order that raced out
15353
+ // of a receiptable state) maps to 409, a not-found shape to 404, BOTH
15354
+ // checked BEFORE the TypeError→400 fallback so a coded error isn't
15355
+ // swallowed as a generic bad-request. Any other failure is a 500.
15356
+ var locale = (req.query && typeof req.query.locale === "string") ? req.query.locale : undefined;
15357
+ var doc;
15358
+ try {
15359
+ doc = await deps.printReceipts.htmlPdf({ order_id: o.id, locale: locale });
15360
+ } catch (e) {
15361
+ var code = (e && typeof e.code === "string") ? e.code : "";
15362
+ if (code === "CONFLICT" || code === "ORDER_TRANSITION_REFUSED") {
15363
+ return _send(res, 409, renderNotFound({ shop_name: shopName, theme: theme }));
15364
+ }
15365
+ if (code === "NOT_FOUND") {
15366
+ return _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme }));
15367
+ }
15368
+ if (e instanceof TypeError) {
15369
+ return _send(res, 400, renderNotFound({ shop_name: shopName, theme: theme }));
15370
+ }
15371
+ throw e;
15372
+ }
15373
+ // Header first, then stream the document body per chunk. The filename
15374
+ // is built from the validated UUID order id alone (hex + hyphens),
15375
+ // carrying no quote/CRLF that could break out of the header.
15376
+ var safeId = String(o.id).replace(/[^A-Za-z0-9._-]/g, "");
15377
+ res.status(200);
15378
+ if (res.setHeader) {
15379
+ res.setHeader("content-type", "text/html; charset=utf-8");
15380
+ res.setHeader("content-disposition", "attachment; filename=\"receipt-" + safeId + ".html\"");
15381
+ res.setHeader("x-content-type-options", "nosniff");
15382
+ // The receipt carries the buyer's name + address + line items —
15383
+ // keep it out of any shared/proxy cache.
15384
+ res.setHeader("cache-control", "no-store");
15385
+ }
15386
+ // One chunk per batch as the source yields it — bounded memory
15387
+ // regardless of document size. A response without an incremental
15388
+ // write() (a JSON test stub) falls back to buffering.
15389
+ if (typeof res.write === "function" && typeof res.end === "function") {
15390
+ for await (var chunk of _streamReceiptDocument(doc)) {
15391
+ if (chunk) res.write(chunk);
15392
+ }
15393
+ res.end();
15394
+ } else {
15395
+ var buffered = "";
15396
+ for await (var c2 of _streamReceiptDocument(doc)) buffered += c2;
15397
+ if (res.end) res.end(buffered); else res.send(buffered);
15398
+ }
15399
+ });
15400
+ }
15401
+
14608
15402
  // POST /orders/:order_id/claim-account — the one-click "save your details
14609
15403
  // / create an account" trigger from the confirmation page. Mounts only
14610
15404
  // when the magic-link surface is wired (customerPortal + a mailer); absent
@@ -14837,8 +15631,22 @@ function mount(router, deps) {
14837
15631
  return b.crypto.toBase64Url(buf);
14838
15632
  }
14839
15633
 
14840
- function _currentCustomer(req) {
14841
- return _currentCustomerEnv(req);
15634
+ // Resolve the signed-in customer from the sealed cookie AND enforce
15635
+ // server-side revocation: a cookie that passes the stateless checks but
15636
+ // belongs to an erased / re-secured / signed-out session (its `iat`
15637
+ // predates the customer's revocation boundary) is dead. Returning null
15638
+ // here — and stamping `req._sessionRevoked` so the auth gate can show the
15639
+ // revoked notice — centralizes revocation across EVERY authenticated read,
15640
+ // not just the handlers that route through `_accountAuth`. Async because
15641
+ // the boundary is a per-customer DB read (a no-op for anonymous requests,
15642
+ // which short-circuit before any query).
15643
+ async function _currentCustomer(req) {
15644
+ var env = _currentCustomerEnv(req);
15645
+ if (env && await _sessionRevoked(env)) {
15646
+ if (req) req._sessionRevoked = true;
15647
+ return null;
15648
+ }
15649
+ return env;
14842
15650
  }
14843
15651
 
14844
15652
  function _serviceUnavailable(res, msg) {
@@ -14889,7 +15697,7 @@ function mount(router, deps) {
14889
15697
  // send them to their account instead of re-rendering a login form
14890
15698
  // (mirrors the /account guard's auth read + vault-not-configured catch).
14891
15699
  var signedIn;
14892
- try { signedIn = _currentCustomer(req); }
15700
+ try { signedIn = await _currentCustomer(req); }
14893
15701
  catch (e) {
14894
15702
  if (e && e.code === "vault/not-initialized") return _serviceUnavailable(res, "auth not configured");
14895
15703
  throw e;
@@ -15335,7 +16143,7 @@ function mount(router, deps) {
15335
16143
 
15336
16144
  router.get("/account", async function (req, res) {
15337
16145
  var auth;
15338
- try { auth = _currentCustomer(req); }
16146
+ try { auth = await _currentCustomer(req); }
15339
16147
  catch (e) {
15340
16148
  if (e && e.code === "vault/not-initialized") return _serviceUnavailable(res, "auth not configured");
15341
16149
  throw e;
@@ -15422,7 +16230,7 @@ function mount(router, deps) {
15422
16230
  if (deps.order) {
15423
16231
  router.get("/account/orders", async function (req, res) {
15424
16232
  var ordersAuth;
15425
- try { ordersAuth = _currentCustomer(req); }
16233
+ try { ordersAuth = await _currentCustomer(req); }
15426
16234
  catch (e) {
15427
16235
  if (e && e.code === "vault/not-initialized") return _serviceUnavailable(res, "auth not configured");
15428
16236
  throw e;
@@ -15479,7 +16287,7 @@ function mount(router, deps) {
15479
16287
  }
15480
16288
 
15481
16289
  router.get("/account/quotes", async function (req, res) {
15482
- var auth = _accountAuth(req, res);
16290
+ var auth = await _accountAuth(req, res);
15483
16291
  if (!auth) return;
15484
16292
  var rows = [];
15485
16293
  try { rows = await deps.quotes.quotesForCustomer(auth.customer_id, { limit: 100 }); }
@@ -15491,7 +16299,7 @@ function mount(router, deps) {
15491
16299
  });
15492
16300
 
15493
16301
  router.get("/account/quotes/:id", async function (req, res) {
15494
- var auth = _accountAuth(req, res);
16302
+ var auth = await _accountAuth(req, res);
15495
16303
  if (!auth) return;
15496
16304
  var q = await _ownedQuote(auth.customer_id, req.params && req.params.id);
15497
16305
  var count = await _cartCountForReq(req);
@@ -15513,7 +16321,7 @@ function mount(router, deps) {
15513
16321
  // machinery is wired, so the holds land at acceptance.
15514
16322
  var _accountQuoteAction = function (action) {
15515
16323
  return async function (req, res) {
15516
- var auth = _accountAuth(req, res);
16324
+ var auth = await _accountAuth(req, res);
15517
16325
  if (!auth) return;
15518
16326
  var q = await _ownedQuote(auth.customer_id, req.params && req.params.id);
15519
16327
  var count = await _cartCountForReq(req);
@@ -15551,7 +16359,7 @@ function mount(router, deps) {
15551
16359
  // quote's account page. An empty / missing cart re-renders the cart
15552
16360
  // with a notice. The optional `message` field rides along.
15553
16361
  router.post("/account/quotes/request", async function (req, res) {
15554
- var auth = _accountAuth(req, res);
16362
+ var auth = await _accountAuth(req, res);
15555
16363
  if (!auth) return;
15556
16364
  var sid = _readSidCookie(req);
15557
16365
  var cart = sid ? await deps.cart.bySession(sid) : null;
@@ -15596,13 +16404,26 @@ function mount(router, deps) {
15596
16404
  // registration page drives, but bound to the ALREADY-authed customer
15597
16405
  // (no email form), so a new credential always lands on the right
15598
16406
  // account.
15599
- function _accountAuth(req, res) {
16407
+ async function _accountAuth(req, res) {
15600
16408
  var auth;
15601
- try { auth = _currentCustomer(req); }
16409
+ try { auth = await _currentCustomer(req); }
15602
16410
  catch (e) {
15603
16411
  if (e && e.code === "vault/not-initialized") { _serviceUnavailable(res, "auth not configured"); return null; }
15604
16412
  throw e;
15605
16413
  }
16414
+ // Server-side revocation: a cookie that passes the stateless checks but
16415
+ // belongs to an erased / re-secured / signed-out session is dead.
16416
+ // `_currentCustomer` already enforces this for every authenticated read —
16417
+ // it returns null and stamps `req._sessionRevoked`. Here we turn that into
16418
+ // the soft sign-out (clear the cookie + bounce to a revoked notice) rather
16419
+ // than the generic not-signed-in redirect.
16420
+ if (req && req._sessionRevoked) {
16421
+ _clearAuthCookie(req, res);
16422
+ res.status(303);
16423
+ res.setHeader && res.setHeader("location", "/account/login?signed_out=revoked");
16424
+ res.end ? res.end() : res.send("");
16425
+ return null;
16426
+ }
15606
16427
  if (!auth) {
15607
16428
  // Device-binding drift: clear the now-stale cookie and bounce to a
15608
16429
  // neutral sign-in notice (never a hard 401 mid-page). Any other
@@ -15671,7 +16492,7 @@ function mount(router, deps) {
15671
16492
  }
15672
16493
 
15673
16494
  router.get("/account/passkeys", async function (req, res) {
15674
- var auth = _accountAuth(req, res); if (!auth) return;
16495
+ var auth = await _accountAuth(req, res); if (!auth) return;
15675
16496
  await _renderPasskeysPage(req, res, auth, null);
15676
16497
  });
15677
16498
 
@@ -15679,7 +16500,7 @@ function mount(router, deps) {
15679
16500
  // server-rendered confirm page; the POST that actually revokes lives
15680
16501
  // behind it.
15681
16502
  router.get("/account/passkeys/:id/remove", async function (req, res) {
15682
- var auth = _accountAuth(req, res); if (!auth) return;
16503
+ var auth = await _accountAuth(req, res); if (!auth) return;
15683
16504
  var pk = await _ownedPasskey(req, res, auth); if (!pk) return;
15684
16505
  var cartCount = await _cartCountForReq(req);
15685
16506
  _send(res, 200, renderPasskeyRemoveConfirm({
@@ -15690,7 +16511,7 @@ function mount(router, deps) {
15690
16511
  });
15691
16512
 
15692
16513
  router.post("/account/passkeys/:id/revoke", async function (req, res) {
15693
- var auth = _accountAuth(req, res); if (!auth) return;
16514
+ var auth = await _accountAuth(req, res); if (!auth) return;
15694
16515
  var pk = await _ownedPasskey(req, res, auth); if (!pk) return;
15695
16516
  // Last-credential guard: refuse to remove the only sign-in method
15696
16517
  // when there's no federated fallback — surface a clear notice rather
@@ -15719,7 +16540,7 @@ function mount(router, deps) {
15719
16540
  // on both so an add-finish can't be replayed against a register/login
15720
16541
  // challenge.
15721
16542
  router.post("/account/passkey/add-begin", async function (req, res) {
15722
- var auth = _accountAuth(req, res); if (!auth) return;
16543
+ var auth = await _accountAuth(req, res); if (!auth) return;
15723
16544
  try {
15724
16545
  var customer = await deps.customers.get(auth.customer_id);
15725
16546
  if (!customer) { res.status(401); return res.end ? res.end("unknown customer") : res.send("unknown customer"); }
@@ -15761,7 +16582,7 @@ function mount(router, deps) {
15761
16582
  });
15762
16583
 
15763
16584
  router.post("/account/passkey/add-finish", async function (req, res) {
15764
- var auth = _accountAuth(req, res); if (!auth) return;
16585
+ var auth = await _accountAuth(req, res); if (!auth) return;
15765
16586
  try {
15766
16587
  var env = _readChallengeEnv(req);
15767
16588
  if (!env) { res.status(400); return res.end ? res.end("missing challenge") : res.send("missing challenge"); }
@@ -15873,7 +16694,7 @@ function mount(router, deps) {
15873
16694
  }
15874
16695
 
15875
16696
  router.get("/account/payment-methods", async function (req, res) {
15876
- var auth = _accountAuth(req, res); if (!auth) return;
16697
+ var auth = await _accountAuth(req, res); if (!auth) return;
15877
16698
  await _renderPaymentMethodsPage(req, res, auth, null);
15878
16699
  });
15879
16700
 
@@ -15883,7 +16704,7 @@ function mount(router, deps) {
15883
16704
  // script). Requires the publishable key; absent it, a 503 like the pay
15884
16705
  // page.
15885
16706
  router.get("/account/payment-methods/add", async function (req, res) {
15886
- var auth = _accountAuth(req, res); if (!auth) return;
16707
+ var auth = await _accountAuth(req, res); if (!auth) return;
15887
16708
  var pk = deps.stripe_publishable_key || "";
15888
16709
  if (!pk) {
15889
16710
  return _send(res, 503, _wrap({
@@ -15910,7 +16731,7 @@ function mount(router, deps) {
15910
16731
  // the customers table; Stripe dedupes by metadata/email) — acceptable
15911
16732
  // v1, documented in the build spec.
15912
16733
  router.post("/account/payment-methods/setup-intent", async function (req, res) {
15913
- var auth = _accountAuth(req, res); if (!auth) return;
16734
+ var auth = await _accountAuth(req, res); if (!auth) return;
15914
16735
  function _json(status, obj) {
15915
16736
  res.status(status);
15916
16737
  res.setHeader && res.setHeader("content-type", "application/json; charset=utf-8");
@@ -15933,7 +16754,7 @@ function mount(router, deps) {
15933
16754
  // pm_… → its display fields → stores via paymentMethods.add. A
15934
16755
  // duplicate token (already on file) is an idempotent notice, not a 500.
15935
16756
  router.post("/account/payment-methods", async function (req, res) {
15936
- var auth = _accountAuth(req, res); if (!auth) return;
16757
+ var auth = await _accountAuth(req, res); if (!auth) return;
15937
16758
  var body = req.body || {};
15938
16759
  var setupIntentId = typeof body.setup_intent_id === "string" ? body.setup_intent_id : "";
15939
16760
  if (!setupIntentId) return _renderPaymentMethodsPage(req, res, auth, "Couldn't add that card — please try again.", 400);
@@ -15968,7 +16789,7 @@ function mount(router, deps) {
15968
16789
  });
15969
16790
 
15970
16791
  router.post("/account/payment-methods/:id/default", async function (req, res) {
15971
- var auth = _accountAuth(req, res); if (!auth) return;
16792
+ var auth = await _accountAuth(req, res); if (!auth) return;
15972
16793
  var pm = await _ownedPaymentMethod(req, res, auth); if (!pm) return;
15973
16794
  try { await deps.paymentMethods.setDefault(pm.id); }
15974
16795
  catch (e) {
@@ -15982,7 +16803,7 @@ function mount(router, deps) {
15982
16803
  });
15983
16804
 
15984
16805
  router.post("/account/payment-methods/:id/archive", async function (req, res) {
15985
- var auth = _accountAuth(req, res); if (!auth) return;
16806
+ var auth = await _accountAuth(req, res); if (!auth) return;
15986
16807
  var pm = await _ownedPaymentMethod(req, res, auth); if (!pm) return;
15987
16808
  try { await deps.paymentMethods.archive({ payment_method_id: pm.id, reason: "customer_request" }); }
15988
16809
  catch (e) {
@@ -16015,7 +16836,7 @@ function mount(router, deps) {
16015
16836
  }
16016
16837
 
16017
16838
  router.get("/account/profile", async function (req, res) {
16018
- var auth = _accountAuth(req, res); if (!auth) return;
16839
+ var auth = await _accountAuth(req, res); if (!auth) return;
16019
16840
  var customer = await deps.customers.get(auth.customer_id);
16020
16841
  if (!customer) {
16021
16842
  _clearAuthCookie(req, res);
@@ -16026,7 +16847,7 @@ function mount(router, deps) {
16026
16847
  });
16027
16848
 
16028
16849
  router.post("/account/profile", async function (req, res) {
16029
- var auth = _accountAuth(req, res); if (!auth) return;
16850
+ var auth = await _accountAuth(req, res); if (!auth) return;
16030
16851
  var customer = await deps.customers.get(auth.customer_id);
16031
16852
  if (!customer) {
16032
16853
  _clearAuthCookie(req, res);
@@ -16269,7 +17090,7 @@ function mount(router, deps) {
16269
17090
  // a forged slug can't drive an open redirect).
16270
17091
  router.post("/wishlist/toggle", async function (req, res) {
16271
17092
  var auth;
16272
- try { auth = _currentCustomer(req); }
17093
+ try { auth = await _currentCustomer(req); }
16273
17094
  catch (e) {
16274
17095
  if (e && e.code === "vault/not-initialized") return _serviceUnavailable(res, "auth not configured");
16275
17096
  throw e;
@@ -16309,7 +17130,7 @@ function mount(router, deps) {
16309
17130
  // archived renders as "unavailable" (the row is orphan-tolerant).
16310
17131
  router.get("/account/wishlist", async function (req, res) {
16311
17132
  var auth;
16312
- try { auth = _currentCustomer(req); }
17133
+ try { auth = await _currentCustomer(req); }
16313
17134
  catch (e) {
16314
17135
  if (e && e.code === "vault/not-initialized") return _serviceUnavailable(res, "auth not configured");
16315
17136
  throw e;
@@ -16425,7 +17246,7 @@ function mount(router, deps) {
16425
17246
  if (deps.wishlistAlerts) {
16426
17247
  router.post("/account/wishlist/alerts", async function (req, res) {
16427
17248
  var auth;
16428
- try { auth = _currentCustomer(req); }
17249
+ try { auth = await _currentCustomer(req); }
16429
17250
  catch (e) {
16430
17251
  if (e && e.code === "vault/not-initialized") return _serviceUnavailable(res, "auth not configured");
16431
17252
  throw e;
@@ -16459,7 +17280,7 @@ function mount(router, deps) {
16459
17280
  if (deps.wishlistDigest) {
16460
17281
  router.post("/account/wishlist/digest", async function (req, res) {
16461
17282
  var auth;
16462
- try { auth = _currentCustomer(req); }
17283
+ try { auth = await _currentCustomer(req); }
16463
17284
  catch (e) {
16464
17285
  if (e && e.code === "vault/not-initialized") return _serviceUnavailable(res, "auth not configured");
16465
17286
  throw e;
@@ -16569,7 +17390,7 @@ function mount(router, deps) {
16569
17390
  // redirect so the one-time URL is shown.
16570
17391
  router.post("/wishlist/share", async function (req, res) {
16571
17392
  var auth;
16572
- try { auth = _currentCustomer(req); }
17393
+ try { auth = await _currentCustomer(req); }
16573
17394
  catch (e) {
16574
17395
  if (e && e.code === "vault/not-initialized") return _serviceUnavailable(res, "auth not configured");
16575
17396
  throw e;
@@ -16608,7 +17429,7 @@ function mount(router, deps) {
16608
17429
  // its id (IDOR).
16609
17430
  router.post("/wishlist/share/:share_id/revoke", async function (req, res) {
16610
17431
  var auth;
16611
- try { auth = _currentCustomer(req); }
17432
+ try { auth = await _currentCustomer(req); }
16612
17433
  catch (e) {
16613
17434
  if (e && e.code === "vault/not-initialized") return _serviceUnavailable(res, "auth not configured");
16614
17435
  throw e;
@@ -16755,7 +17576,7 @@ function mount(router, deps) {
16755
17576
  // its progress rollup, plus the create form.
16756
17577
  router.get("/account/registry", async function (req, res) {
16757
17578
  var auth;
16758
- try { auth = _currentCustomer(req); }
17579
+ try { auth = await _currentCustomer(req); }
16759
17580
  catch (e) {
16760
17581
  if (e && e.code === "vault/not-initialized") return _serviceUnavailable(res, "auth not configured");
16761
17582
  throw e;
@@ -16789,7 +17610,7 @@ function mount(router, deps) {
16789
17610
  // shopper can only ever create a registry under their own id.
16790
17611
  router.post("/account/registry", async function (req, res) {
16791
17612
  var auth;
16792
- try { auth = _currentCustomer(req); }
17613
+ try { auth = await _currentCustomer(req); }
16793
17614
  catch (e) {
16794
17615
  if (e && e.code === "vault/not-initialized") return _serviceUnavailable(res, "auth not configured");
16795
17616
  throw e;
@@ -16848,7 +17669,7 @@ function mount(router, deps) {
16848
17669
  // slug) 404s.
16849
17670
  router.get("/account/registry/:slug", async function (req, res) {
16850
17671
  var auth;
16851
- try { auth = _currentCustomer(req); }
17672
+ try { auth = await _currentCustomer(req); }
16852
17673
  catch (e) {
16853
17674
  if (e && e.code === "vault/not-initialized") return _serviceUnavailable(res, "auth not configured");
16854
17675
  throw e;
@@ -16915,7 +17736,7 @@ function mount(router, deps) {
16915
17736
  // session customer owns. Ownership-scoped (404 on a foreign / unknown
16916
17737
  // registry).
16917
17738
  router.post("/account/registry/:slug/items", async function (req, res) {
16918
- var auth = _registryAuthOrRedirect(req, res);
17739
+ var auth = await _registryAuthOrRedirect(req, res);
16919
17740
  if (!auth) return;
16920
17741
  var slug = (req.params && req.params.slug) || "";
16921
17742
  var reg = await _ownedRegistry(slug, auth.customer_id);
@@ -16943,7 +17764,7 @@ function mount(router, deps) {
16943
17764
  // POST /account/registry/:slug/items/:item_id/remove — archive an item
16944
17765
  // on a registry the session customer owns.
16945
17766
  router.post("/account/registry/:slug/items/:item_id/remove", async function (req, res) {
16946
- var auth = _registryAuthOrRedirect(req, res);
17767
+ var auth = await _registryAuthOrRedirect(req, res);
16947
17768
  if (!auth) return;
16948
17769
  var slug = (req.params && req.params.slug) || "";
16949
17770
  var reg = await _ownedRegistry(slug, auth.customer_id);
@@ -16967,7 +17788,7 @@ function mount(router, deps) {
16967
17788
  // (title / recipient_name / event_date / privacy) on a registry the
16968
17789
  // session customer owns.
16969
17790
  router.post("/account/registry/:slug/edit", async function (req, res) {
16970
- var auth = _registryAuthOrRedirect(req, res);
17791
+ var auth = await _registryAuthOrRedirect(req, res);
16971
17792
  if (!auth) return;
16972
17793
  var slug = (req.params && req.params.slug) || "";
16973
17794
  var reg = await _ownedRegistry(slug, auth.customer_id);
@@ -16999,7 +17820,7 @@ function mount(router, deps) {
16999
17820
  // POST /account/registry/:slug/close — close a registry the session
17000
17821
  // customer owns (the only FSM transition; refuses further mutation).
17001
17822
  router.post("/account/registry/:slug/close", async function (req, res) {
17002
- var auth = _registryAuthOrRedirect(req, res);
17823
+ var auth = await _registryAuthOrRedirect(req, res);
17003
17824
  if (!auth) return;
17004
17825
  var slug = (req.params && req.params.slug) || "";
17005
17826
  var reg = await _ownedRegistry(slug, auth.customer_id);
@@ -17129,7 +17950,7 @@ function mount(router, deps) {
17129
17950
  var buyerId = null;
17130
17951
  if (reveal) {
17131
17952
  var giverAuth = null;
17132
- try { giverAuth = _currentCustomer(req); } catch (_e) { giverAuth = null; }
17953
+ try { giverAuth = await _currentCustomer(req); } catch (_e) { giverAuth = null; }
17133
17954
  if (giverAuth && giverAuth.customer_id) buyerId = giverAuth.customer_id;
17134
17955
  else reveal = false; // not signed in → fall back to anonymous
17135
17956
  }
@@ -17160,9 +17981,9 @@ function mount(router, deps) {
17160
17981
  // Shared owner-route auth gate: resolve the session customer or send the
17161
17982
  // redirect / 503 and return null. Mirrors the wishlist `_savedAuth`
17162
17983
  // shape so every owner registry write funnels through one check.
17163
- function _registryAuthOrRedirect(req, res) {
17984
+ async function _registryAuthOrRedirect(req, res) {
17164
17985
  var auth;
17165
- try { auth = _currentCustomer(req); }
17986
+ try { auth = await _currentCustomer(req); }
17166
17987
  catch (e) {
17167
17988
  if (e && e.code === "vault/not-initialized") { _serviceUnavailable(res, "auth not configured"); return null; }
17168
17989
  throw e;
@@ -17217,9 +18038,9 @@ function mount(router, deps) {
17217
18038
  // Save for later — move a cart line into a per-customer holding
17218
18039
  // list and back. Login required (the list is per-customer).
17219
18040
  if (deps.saveForLater) {
17220
- function _savedAuth(req, res) {
18041
+ async function _savedAuth(req, res) {
17221
18042
  var auth;
17222
- try { auth = _currentCustomer(req); }
18043
+ try { auth = await _currentCustomer(req); }
17223
18044
  catch (e) {
17224
18045
  if (e && e.code === "vault/not-initialized") { _serviceUnavailable(res, "auth not configured"); return null; }
17225
18046
  throw e;
@@ -17235,7 +18056,7 @@ function mount(router, deps) {
17235
18056
  // POST /cart/lines/:line_id/save — move the line out of the cart
17236
18057
  // into the customer's saved list. Redirects back to /cart.
17237
18058
  router.post("/cart/lines/:line_id/save", async function (req, res) {
17238
- var auth = _savedAuth(req, res);
18059
+ var auth = await _savedAuth(req, res);
17239
18060
  if (!auth) return;
17240
18061
  var sid = _readSidCookie(req);
17241
18062
  var cart = sid ? await deps.cart.bySession(sid) : null;
@@ -17259,7 +18080,7 @@ function mount(router, deps) {
17259
18080
 
17260
18081
  // GET /account/saved — the customer's saved-for-later list.
17261
18082
  router.get("/account/saved", async function (req, res) {
17262
- var auth = _savedAuth(req, res);
18083
+ var auth = await _savedAuth(req, res);
17263
18084
  if (!auth) return;
17264
18085
  var page = await deps.saveForLater.listForCustomer({ customer_id: auth.customer_id, limit: 50 });
17265
18086
  var items = [];
@@ -17290,7 +18111,7 @@ function mount(router, deps) {
17290
18111
  // POST /saved/:save_id/move-to-cart — move a saved row back into
17291
18112
  // the session cart (created if absent). Redirects to /cart.
17292
18113
  router.post("/saved/:save_id/move-to-cart", async function (req, res) {
17293
- var auth = _savedAuth(req, res);
18114
+ var auth = await _savedAuth(req, res);
17294
18115
  if (!auth) return;
17295
18116
  var resolved = await _getOrCreateCart(req, res, "USD");
17296
18117
  try {
@@ -17330,7 +18151,7 @@ function mount(router, deps) {
17330
18151
 
17331
18152
  // POST /saved/:save_id/remove — drop a saved row.
17332
18153
  router.post("/saved/:save_id/remove", async function (req, res) {
17333
- var auth = _savedAuth(req, res);
18154
+ var auth = await _savedAuth(req, res);
17334
18155
  if (!auth) return;
17335
18156
  try {
17336
18157
  await deps.saveForLater.remove({ customer_id: auth.customer_id, save_id: req.params && req.params.save_id });
@@ -17348,9 +18169,9 @@ function mount(router, deps) {
17348
18169
  // (the primitive operates by id alone, so ownership is enforced here
17349
18170
  // to prevent cross-customer access via a guessed id).
17350
18171
  if (deps.addresses) {
17351
- function _addrAuth(req, res) {
18172
+ async function _addrAuth(req, res) {
17352
18173
  var auth;
17353
- try { auth = _currentCustomer(req); }
18174
+ try { auth = await _currentCustomer(req); }
17354
18175
  catch (e) {
17355
18176
  if (e && e.code === "vault/not-initialized") { _serviceUnavailable(res, "auth not configured"); return null; }
17356
18177
  throw e;
@@ -17439,18 +18260,18 @@ function mount(router, deps) {
17439
18260
  }
17440
18261
 
17441
18262
  router.get("/account/addresses", async function (req, res) {
17442
- var auth = _addrAuth(req, res); if (!auth) return;
18263
+ var auth = await _addrAuth(req, res); if (!auth) return;
17443
18264
  await _renderAddrPage(req, res, auth, null);
17444
18265
  });
17445
18266
 
17446
18267
  router.get("/account/addresses/:id/edit", async function (req, res) {
17447
- var auth = _addrAuth(req, res); if (!auth) return;
18268
+ var auth = await _addrAuth(req, res); if (!auth) return;
17448
18269
  var addr = await _ownedAddress(req, res, auth); if (!addr) return;
17449
18270
  await _renderAddrPage(req, res, auth, addr);
17450
18271
  });
17451
18272
 
17452
18273
  router.post("/account/addresses", async function (req, res) {
17453
- var auth = _addrAuth(req, res); if (!auth) return;
18274
+ var auth = await _addrAuth(req, res); if (!auth) return;
17454
18275
  try {
17455
18276
  await deps.addresses.add(_addrInput(req.body || {}, auth.customer_id));
17456
18277
  } catch (e) {
@@ -17465,7 +18286,7 @@ function mount(router, deps) {
17465
18286
  });
17466
18287
 
17467
18288
  router.post("/account/addresses/:id", async function (req, res) {
17468
- var auth = _addrAuth(req, res); if (!auth) return;
18289
+ var auth = await _addrAuth(req, res); if (!auth) return;
17469
18290
  var addr = await _ownedAddress(req, res, auth); if (!addr) return;
17470
18291
  try {
17471
18292
  await deps.addresses.update(addr.id, _addrInput(req.body || {}, auth.customer_id));
@@ -17483,7 +18304,7 @@ function mount(router, deps) {
17483
18304
 
17484
18305
  function _addrAction(verb, okKind, fn) {
17485
18306
  router.post("/account/addresses/:id/" + verb, async function (req, res) {
17486
- var auth = _addrAuth(req, res); if (!auth) return;
18307
+ var auth = await _addrAuth(req, res); if (!auth) return;
17487
18308
  var addr = await _ownedAddress(req, res, auth); if (!addr) return;
17488
18309
  try { await fn(addr.id); }
17489
18310
  catch (e) {
@@ -17502,7 +18323,7 @@ function mount(router, deps) {
17502
18323
  // actually archives lives behind that page. The list then surfaces a
17503
18324
  // success notice with an Undo (unarchive) control.
17504
18325
  router.get("/account/addresses/:id/remove", async function (req, res) {
17505
- var auth = _addrAuth(req, res); if (!auth) return;
18326
+ var auth = await _addrAuth(req, res); if (!auth) return;
17506
18327
  var addr = await _ownedAddress(req, res, auth); if (!addr) return;
17507
18328
  var cartCount = await _cartCountForReq(req);
17508
18329
  _send(res, 200, renderAddressRemoveConfirm({
@@ -17513,7 +18334,7 @@ function mount(router, deps) {
17513
18334
  });
17514
18335
 
17515
18336
  router.post("/account/addresses/:id/archive", async function (req, res) {
17516
- var auth = _addrAuth(req, res); if (!auth) return;
18337
+ var auth = await _addrAuth(req, res); if (!auth) return;
17517
18338
  var addr = await _ownedAddress(req, res, auth); if (!addr) return;
17518
18339
  try { await deps.addresses.archive(addr.id); }
17519
18340
  catch (e) {
@@ -17530,7 +18351,7 @@ function mount(router, deps) {
17530
18351
  // address is archived by definition here) but still enforces
17531
18352
  // customer ownership before un-archiving.
17532
18353
  router.post("/account/addresses/:id/unarchive", async function (req, res) {
17533
- var auth = _addrAuth(req, res); if (!auth) return;
18354
+ var auth = await _addrAuth(req, res); if (!auth) return;
17534
18355
  var addr;
17535
18356
  try { addr = await deps.addresses.get(req.params && req.params.id); }
17536
18357
  catch (e) {
@@ -17569,9 +18390,9 @@ function mount(router, deps) {
17569
18390
  // handle. The list above stays a read-only view when this is absent.
17570
18391
  var subControls = deps.subscriptionControls || null;
17571
18392
 
17572
- function _subsAuth(req, res) {
18393
+ async function _subsAuth(req, res) {
17573
18394
  var auth;
17574
- try { auth = _currentCustomer(req); }
18395
+ try { auth = await _currentCustomer(req); }
17575
18396
  catch (e) {
17576
18397
  if (e && e.code === "vault/not-initialized") { _serviceUnavailable(res, "auth not configured"); return null; }
17577
18398
  throw e;
@@ -17628,7 +18449,7 @@ function mount(router, deps) {
17628
18449
  }
17629
18450
 
17630
18451
  router.get("/account/subscriptions", async function (req, res) {
17631
- var auth = _subsAuth(req, res); if (!auth) return;
18452
+ var auth = await _subsAuth(req, res); if (!auth) return;
17632
18453
  var rows = await _subsForCustomer(auth.customer_id);
17633
18454
  var cartCount = await _cartCountForReq(req);
17634
18455
  var url = req.url ? new URL(req.url, "http://localhost") : null;
@@ -17711,7 +18532,7 @@ function mount(router, deps) {
17711
18532
  // currently-active subscription; a paused/cancelled row bounces
17712
18533
  // back to the list.
17713
18534
  router.get("/account/subscriptions/:id/pause", async function (req, res) {
17714
- var auth = _subsAuth(req, res); if (!auth) return;
18535
+ var auth = await _subsAuth(req, res); if (!auth) return;
17715
18536
  var sub = await _ownedManageableSubscription(req, res, auth); if (!sub) return;
17716
18537
  if (_subscriptionControlState(sub) !== "active") return _redirect(res, "");
17717
18538
  if (sub.plan_id != null) {
@@ -17727,7 +18548,7 @@ function mount(router, deps) {
17727
18548
  });
17728
18549
 
17729
18550
  router.post("/account/subscriptions/:id/pause", async function (req, res) {
17730
- var auth = _subsAuth(req, res); if (!auth) return;
18551
+ var auth = await _subsAuth(req, res); if (!auth) return;
17731
18552
  var sub = await _ownedManageableSubscription(req, res, auth); if (!sub) return;
17732
18553
  try {
17733
18554
  await subControls.pause({ subscription_id: sub.id, reason: "customer self-service pause", actor: SELF_ACTOR });
@@ -17739,7 +18560,7 @@ function mount(router, deps) {
17739
18560
  });
17740
18561
 
17741
18562
  router.post("/account/subscriptions/:id/resume", async function (req, res) {
17742
- var auth = _subsAuth(req, res); if (!auth) return;
18563
+ var auth = await _subsAuth(req, res); if (!auth) return;
17743
18564
  var sub = await _ownedManageableSubscription(req, res, auth); if (!sub) return;
17744
18565
  try {
17745
18566
  await subControls.resume({ subscription_id: sub.id, reason: "customer self-service resume", actor: SELF_ACTOR });
@@ -17751,7 +18572,7 @@ function mount(router, deps) {
17751
18572
  });
17752
18573
 
17753
18574
  router.post("/account/subscriptions/:id/skip", async function (req, res) {
17754
- var auth = _subsAuth(req, res); if (!auth) return;
18575
+ var auth = await _subsAuth(req, res); if (!auth) return;
17755
18576
  var sub = await _ownedManageableSubscription(req, res, auth); if (!sub) return;
17756
18577
  try {
17757
18578
  await subControls.skipNext({ subscription_id: sub.id, count: 1, reason: "customer self-service skip", actor: SELF_ACTOR });
@@ -17763,7 +18584,7 @@ function mount(router, deps) {
17763
18584
  });
17764
18585
 
17765
18586
  router.post("/account/subscriptions/:id/quantity", async function (req, res) {
17766
- var auth = _subsAuth(req, res); if (!auth) return;
18587
+ var auth = await _subsAuth(req, res); if (!auth) return;
17767
18588
  var sub = await _ownedManageableSubscription(req, res, auth); if (!sub) return;
17768
18589
  // Backend validates: a non-positive / non-integer / missing value
17769
18590
  // is a client error → bounce with the quantity error code rather
@@ -17790,7 +18611,7 @@ function mount(router, deps) {
17790
18611
  });
17791
18612
 
17792
18613
  router.post("/account/subscriptions/:id/frequency", async function (req, res) {
17793
- var auth = _subsAuth(req, res); if (!auth) return;
18614
+ var auth = await _subsAuth(req, res); if (!auth) return;
17794
18615
  var sub = await _ownedManageableSubscription(req, res, auth); if (!sub) return;
17795
18616
  // Backend validates: reject anything outside the allowed cadence
17796
18617
  // enum before composing the primitive.
@@ -17807,7 +18628,7 @@ function mount(router, deps) {
17807
18628
  });
17808
18629
 
17809
18630
  router.post("/account/subscriptions/:id/reactivate", async function (req, res) {
17810
- var auth = _subsAuth(req, res); if (!auth) return;
18631
+ var auth = await _subsAuth(req, res); if (!auth) return;
17811
18632
  // Reactivate is the recovery path for a cancelled subscription,
17812
18633
  // so it is NOT gated on the terminal-status guard (a Stripe-
17813
18634
  // canceled status is the normal state of a row being
@@ -17832,7 +18653,7 @@ function mount(router, deps) {
17832
18653
  // immediate cancel. A subscription that isn't cancelable
17833
18654
  // (already canceled / winding down) redirects back to the list.
17834
18655
  router.get("/account/subscriptions/:id/cancel", async function (req, res) {
17835
- var auth = _subsAuth(req, res); if (!auth) return;
18656
+ var auth = await _subsAuth(req, res); if (!auth) return;
17836
18657
  var sub = await _ownedSubscription(req, res, auth); if (!sub) return;
17837
18658
  if (!_subscriptionIsCancelable(sub)) {
17838
18659
  res.status(303); res.setHeader && res.setHeader("location", "/account/subscriptions");
@@ -17853,7 +18674,7 @@ function mount(router, deps) {
17853
18674
  });
17854
18675
 
17855
18676
  router.post("/account/subscriptions/:id/cancel", async function (req, res) {
17856
- var auth = _subsAuth(req, res); if (!auth) return;
18677
+ var auth = await _subsAuth(req, res); if (!auth) return;
17857
18678
  var sub = await _ownedSubscription(req, res, auth); if (!sub) return;
17858
18679
  // Default cancel-at-period-end (the customer keeps access through
17859
18680
  // the period they've paid for); the form opts into immediate via
@@ -17884,9 +18705,9 @@ function mount(router, deps) {
17884
18705
  // the launch flow later converts it into a regular (Stripe-gated) order.
17885
18706
  // Mounts only when the preorder primitive is wired.
17886
18707
  if (preorder) {
17887
- function _preorderAuth(req, res) {
18708
+ async function _preorderAuth(req, res) {
17888
18709
  var auth;
17889
- try { auth = _currentCustomer(req); }
18710
+ try { auth = await _currentCustomer(req); }
17890
18711
  catch (e) {
17891
18712
  if (e && e.code === "vault/not-initialized") { _serviceUnavailable(res, "auth not configured"); return null; }
17892
18713
  throw e;
@@ -17918,7 +18739,7 @@ function mount(router, deps) {
17918
18739
  // shows. Over-cap / closed / missing campaign → a clean 4xx PRG back to
17919
18740
  // the PDP with a fixed ?preorder error code (no raw error text).
17920
18741
  router.post("/products/:slug/preorder", async function (req, res) {
17921
- var auth = _preorderAuth(req, res); if (!auth) return;
18742
+ var auth = await _preorderAuth(req, res); if (!auth) return;
17922
18743
  var slug = req.params && req.params.slug;
17923
18744
  var enc = encodeURIComponent(slug || "");
17924
18745
  var product = slug ? await deps.catalog.products.bySlug(slug) : null;
@@ -18001,7 +18822,7 @@ function mount(router, deps) {
18001
18822
  }
18002
18823
 
18003
18824
  router.get("/account/preorders", async function (req, res) {
18004
- var auth = _preorderAuth(req, res); if (!auth) return;
18825
+ var auth = await _preorderAuth(req, res); if (!auth) return;
18005
18826
  var rows = await _preordersForCustomer(auth.customer_id);
18006
18827
  var cartCount = await _cartCountForReq(req);
18007
18828
  var url = req.url ? new URL(req.url, "http://localhost") : null;
@@ -18021,7 +18842,7 @@ function mount(router, deps) {
18021
18842
  // non-active reservation (already converted / cancelled) is a clean PRG
18022
18843
  // back to the list, not a 500.
18023
18844
  router.post("/account/preorders/:id/cancel", async function (req, res) {
18024
- var auth = _preorderAuth(req, res); if (!auth) return;
18845
+ var auth = await _preorderAuth(req, res); if (!auth) return;
18025
18846
  var resv = await _ownedReservation(req, res, auth); if (!resv) return;
18026
18847
  try {
18027
18848
  await preorder.cancelReservation({ reservation_id: resv.id, reason: "customer-cancelled" });
@@ -18041,9 +18862,9 @@ function mount(router, deps) {
18041
18862
  // the admin /admin/returns queue. Needs the returns primitive + an
18042
18863
  // order handle (to load + ownership-check the order being returned).
18043
18864
  if (deps.returns && deps.order) {
18044
- function _returnsAuth(req, res) {
18865
+ async function _returnsAuth(req, res) {
18045
18866
  var auth;
18046
- try { auth = _currentCustomer(req); }
18867
+ try { auth = await _currentCustomer(req); }
18047
18868
  catch (e) {
18048
18869
  if (e && e.code === "vault/not-initialized") { _serviceUnavailable(res, "auth not configured"); return null; }
18049
18870
  throw e;
@@ -18107,7 +18928,7 @@ function mount(router, deps) {
18107
18928
  }
18108
18929
 
18109
18930
  router.get("/account/returns", async function (req, res) {
18110
- var auth = _returnsAuth(req, res); if (!auth) return;
18931
+ var auth = await _returnsAuth(req, res); if (!auth) return;
18111
18932
  var page = await deps.returns.listForCustomer(auth.customer_id, { limit: 50 });
18112
18933
  var cartCount = await _cartCountForReq(req);
18113
18934
  var retUrl = req.url ? new URL(req.url, "http://localhost") : null;
@@ -18124,7 +18945,7 @@ function mount(router, deps) {
18124
18945
  // through _ownedReturn (foreign / unknown / malformed id → 404). A
18125
18946
  // return with no issued label renders a neutral "no label yet" state.
18126
18947
  router.get("/account/returns/:id", async function (req, res) {
18127
- var auth = _returnsAuth(req, res); if (!auth) return;
18948
+ var auth = await _returnsAuth(req, res); if (!auth) return;
18128
18949
  var rma = await _ownedReturn(req, res, auth); if (!rma) return;
18129
18950
  var label = await _labelForReturn(rma.id);
18130
18951
  var events = [];
@@ -18152,7 +18973,7 @@ function mount(router, deps) {
18152
18973
  // primitive is wired.
18153
18974
  if (deps.returnLabels) {
18154
18975
  router.get("/account/returns/:id/label", async function (req, res) {
18155
- var auth = _returnsAuth(req, res); if (!auth) return;
18976
+ var auth = await _returnsAuth(req, res); if (!auth) return;
18156
18977
  var rma = await _ownedReturn(req, res, auth); if (!rma) return;
18157
18978
  var label = await _labelForReturn(rma.id);
18158
18979
  if (!label || !label.label_url) {
@@ -18172,7 +18993,7 @@ function mount(router, deps) {
18172
18993
  }
18173
18994
 
18174
18995
  router.get("/account/orders/:order_id/return", async function (req, res) {
18175
- var auth = _returnsAuth(req, res); if (!auth) return;
18996
+ var auth = await _returnsAuth(req, res); if (!auth) return;
18176
18997
  var order = await _ownedOrder(req, res, auth); if (!order) return;
18177
18998
  var cartCount = await _cartCountForReq(req);
18178
18999
  // An ineligible order (unpaid, cancelled, or already refunded) never
@@ -18191,7 +19012,7 @@ function mount(router, deps) {
18191
19012
  });
18192
19013
 
18193
19014
  router.post("/account/orders/:order_id/return", async function (req, res) {
18194
- var auth = _returnsAuth(req, res); if (!auth) return;
19015
+ var auth = await _returnsAuth(req, res); if (!auth) return;
18195
19016
  var order = await _ownedOrder(req, res, auth); if (!order) return;
18196
19017
  var body = req.body || {};
18197
19018
  var cartCount = await _cartCountForReq(req);
@@ -18268,9 +19089,9 @@ function mount(router, deps) {
18268
19089
  // exactly like the returns + support gates. The exchange primitive
18269
19090
  // moves a row by id alone, so the route owns the ownership decision.
18270
19091
  if (deps.orderExchanges && deps.order) {
18271
- function _exchangeAuth(req, res) {
19092
+ async function _exchangeAuth(req, res) {
18272
19093
  var auth;
18273
- try { auth = _currentCustomer(req); }
19094
+ try { auth = await _currentCustomer(req); }
18274
19095
  catch (e) {
18275
19096
  if (e && e.code === "vault/not-initialized") { _serviceUnavailable(res, "auth not configured"); return null; }
18276
19097
  throw e;
@@ -18353,7 +19174,7 @@ function mount(router, deps) {
18353
19174
  // primitive resolves the customer→order linkage through the injected
18354
19175
  // order handle, so a foreign order's exchange never appears here.
18355
19176
  router.get("/account/exchanges", async function (req, res) {
18356
- var auth = _exchangeAuth(req, res); if (!auth) return;
19177
+ var auth = await _exchangeAuth(req, res); if (!auth) return;
18357
19178
  var exchanges = [];
18358
19179
  try { exchanges = await deps.orderExchanges.exchangesForCustomer(auth.customer_id, { limit: 100 }); }
18359
19180
  catch (e) { if (!(e instanceof TypeError)) throw e; exchanges = []; }
@@ -18370,7 +19191,7 @@ function mount(router, deps) {
18370
19191
  // One exchange's status detail. Ownership-scoped through the parent
18371
19192
  // order (foreign / unknown → 404).
18372
19193
  router.get("/account/exchanges/:id", async function (req, res) {
18373
- var auth = _exchangeAuth(req, res); if (!auth) return;
19194
+ var auth = await _exchangeAuth(req, res); if (!auth) return;
18374
19195
  var exchange = await _ownedExchange(req, res, auth); if (!exchange) return;
18375
19196
  var cartCount = await _cartCountForReq(req);
18376
19197
  _send(res, 200, renderExchangeDetail({ exchange: exchange, shop_name: shopName, cart_count: cartCount }));
@@ -18379,7 +19200,7 @@ function mount(router, deps) {
18379
19200
  // The exchange-request form for one of the customer's own orders,
18380
19201
  // gated on the same eligibility window as a return.
18381
19202
  router.get("/account/orders/:order_id/exchange", async function (req, res) {
18382
- var auth = _exchangeAuth(req, res); if (!auth) return;
19203
+ var auth = await _exchangeAuth(req, res); if (!auth) return;
18383
19204
  var order = await _ownedOrderForExchange(req, res, auth); if (!order) return;
18384
19205
  var cartCount = await _cartCountForReq(req);
18385
19206
  if (!_orderEligibleForExchange(order.status)) {
@@ -18398,7 +19219,7 @@ function mount(router, deps) {
18398
19219
  });
18399
19220
 
18400
19221
  router.post("/account/orders/:order_id/exchange", async function (req, res) {
18401
- var auth = _exchangeAuth(req, res); if (!auth) return;
19222
+ var auth = await _exchangeAuth(req, res); if (!auth) return;
18402
19223
  var order = await _ownedOrderForExchange(req, res, auth); if (!order) return;
18403
19224
  var body = req.body || {};
18404
19225
  var cartCount = await _cartCountForReq(req);
@@ -18496,7 +19317,7 @@ function mount(router, deps) {
18496
19317
  // when the primitive is wired.
18497
19318
  if (deps.clickAndCollect) {
18498
19319
  router.get("/account/pickups", async function (req, res) {
18499
- var auth = _accountAuth(req, res); if (!auth) return;
19320
+ var auth = await _accountAuth(req, res); if (!auth) return;
18500
19321
  var schedules = [];
18501
19322
  try { schedules = await deps.clickAndCollect.customerSchedules(auth.customer_id); }
18502
19323
  catch (e) { if (!(e instanceof TypeError)) throw e; schedules = []; }
@@ -18521,9 +19342,9 @@ function mount(router, deps) {
18521
19342
  if (deps.supportTickets) {
18522
19343
  var support = deps.supportTickets;
18523
19344
 
18524
- function _supportAuth(req, res) {
19345
+ async function _supportAuth(req, res) {
18525
19346
  var auth;
18526
- try { auth = _currentCustomer(req); }
19347
+ try { auth = await _currentCustomer(req); }
18527
19348
  catch (e) {
18528
19349
  if (e && e.code === "vault/not-initialized") { _serviceUnavailable(res, "auth not configured"); return null; }
18529
19350
  throw e;
@@ -18558,7 +19379,7 @@ function mount(router, deps) {
18558
19379
 
18559
19380
  // The customer's own ticket list, scoped to their session customer_id.
18560
19381
  router.get("/account/support", async function (req, res) {
18561
- var auth = _supportAuth(req, res); if (!auth) return;
19382
+ var auth = await _supportAuth(req, res); if (!auth) return;
18562
19383
  var tickets = await support.listByCustomerId(auth.customer_id, { limit: 100 });
18563
19384
  var cartCount = await _cartCountForReq(req);
18564
19385
  var url = req.url ? new URL(req.url, "http://localhost") : null;
@@ -18573,7 +19394,7 @@ function mount(router, deps) {
18573
19394
  // The new-ticket form. Offers the customer's recent orders as an
18574
19395
  // optional attachment.
18575
19396
  router.get("/account/support/new", async function (req, res) {
18576
- var auth = _supportAuth(req, res); if (!auth) return;
19397
+ var auth = await _supportAuth(req, res); if (!auth) return;
18577
19398
  var orders = [];
18578
19399
  if (deps.order) {
18579
19400
  try {
@@ -18599,7 +19420,7 @@ function mount(router, deps) {
18599
19420
  // email shape and surfaces a TypeError on bad input as a clean
18600
19421
  // re-render, never a 500.
18601
19422
  router.post("/account/support", async function (req, res) {
18602
- var auth = _supportAuth(req, res); if (!auth) return;
19423
+ var auth = await _supportAuth(req, res); if (!auth) return;
18603
19424
  var body = req.body || {};
18604
19425
  var cartCount = await _cartCountForReq(req);
18605
19426
 
@@ -18650,7 +19471,7 @@ function mount(router, deps) {
18650
19471
  // Internal operator notes are filtered out before render — the
18651
19472
  // customer never sees an internal=1 message.
18652
19473
  router.get("/account/support/:id", async function (req, res) {
18653
- var auth = _supportAuth(req, res); if (!auth) return;
19474
+ var auth = await _supportAuth(req, res); if (!auth) return;
18654
19475
  var ticket = await _ownedTicket(req, res, auth); if (!ticket) return;
18655
19476
  var thread = await support.thread(ticket.id);
18656
19477
  var visible = (thread.messages || []).filter(function (m) { return Number(m.internal) !== 1; });
@@ -18670,7 +19491,7 @@ function mount(router, deps) {
18670
19491
  // SUPPORT_TICKET_CLOSED error — surfaced as a 409 re-render, never a
18671
19492
  // 500). author is pinned to "customer".
18672
19493
  router.post("/account/support/:id/reply", async function (req, res) {
18673
- var auth = _supportAuth(req, res); if (!auth) return;
19494
+ var auth = await _supportAuth(req, res); if (!auth) return;
18674
19495
  var ticket = await _ownedTicket(req, res, auth); if (!ticket) return;
18675
19496
  var body = req.body || {};
18676
19497
  var cartCount = await _cartCountForReq(req);
@@ -18731,7 +19552,7 @@ function mount(router, deps) {
18731
19552
  var streamDsr = deps.streamDsrBundle;
18732
19553
 
18733
19554
  router.get("/account/privacy", async function (req, res) {
18734
- var auth = _accountAuth(req, res); if (!auth) return;
19555
+ var auth = await _accountAuth(req, res); if (!auth) return;
18735
19556
  var history = [];
18736
19557
  try { history = await dsr.auditForCustomer(auth.customer_id); } catch (_e) { history = []; }
18737
19558
  var cartCount = await _cartCountForReq(req);
@@ -18749,7 +19570,7 @@ function mount(router, deps) {
18749
19570
  // with a notice, never a 500. No row is created on a bad enum (the
18750
19571
  // primitive validates before INSERT).
18751
19572
  router.post("/account/privacy/export", async function (req, res) {
18752
- var auth = _accountAuth(req, res); if (!auth) return;
19573
+ var auth = await _accountAuth(req, res); if (!auth) return;
18753
19574
  var body = req.body || {};
18754
19575
  try {
18755
19576
  await dsr.requestExport({
@@ -18778,7 +19599,7 @@ function mount(router, deps) {
18778
19599
  });
18779
19600
 
18780
19601
  router.get("/account/delete", async function (req, res) {
18781
- var auth = _accountAuth(req, res); if (!auth) return;
19602
+ var auth = await _accountAuth(req, res); if (!auth) return;
18782
19603
  var cartCount = await _cartCountForReq(req);
18783
19604
  _send(res, 200, renderAccountDelete({ shop_name: shopName, cart_count: cartCount }));
18784
19605
  });
@@ -18788,7 +19609,7 @@ function mount(router, deps) {
18788
19609
  // (the operator reviews identity + runs processDeletion from the admin
18789
19610
  // queue). A bad enum / empty reason throws TypeError → a 400 re-render.
18790
19611
  router.post("/account/delete", async function (req, res) {
18791
- var auth = _accountAuth(req, res); if (!auth) return;
19612
+ var auth = await _accountAuth(req, res); if (!auth) return;
18792
19613
  var body = req.body || {};
18793
19614
  try {
18794
19615
  await dsr.requestDeletion({
@@ -18820,7 +19641,7 @@ function mount(router, deps) {
18820
19641
  // a fulfilled/delivered export, then stream. Status + ownership are
18821
19642
  // validated BEFORE the first write (the bundle streams header-first).
18822
19643
  router.get("/account/privacy/:id/export.json", async function (req, res) {
18823
- var auth = _accountAuth(req, res); if (!auth) return;
19644
+ var auth = await _accountAuth(req, res); if (!auth) return;
18824
19645
  var row;
18825
19646
  try { row = await dsr.getRequest(req.params && req.params.id); }
18826
19647
  catch (e) {
@@ -18844,9 +19665,9 @@ function mount(router, deps) {
18844
19665
  // control. Login-gated; a read failure on any optional sub-read
18845
19666
  // degrades that section to empty rather than 500-ing the page.
18846
19667
  if (deps.loyalty) {
18847
- function _loyaltyAuth(req, res) {
19668
+ async function _loyaltyAuth(req, res) {
18848
19669
  var auth;
18849
- try { auth = _currentCustomer(req); }
19670
+ try { auth = await _currentCustomer(req); }
18850
19671
  catch (e) {
18851
19672
  if (e && e.code === "vault/not-initialized") { _serviceUnavailable(res, "auth not configured"); return null; }
18852
19673
  throw e;
@@ -18903,7 +19724,7 @@ function mount(router, deps) {
18903
19724
  }
18904
19725
 
18905
19726
  router.get("/account/loyalty", async function (req, res) {
18906
- var auth = _loyaltyAuth(req, res); if (!auth) return;
19727
+ var auth = await _loyaltyAuth(req, res); if (!auth) return;
18907
19728
  var url = req.url ? new URL(req.url, "http://localhost") : null;
18908
19729
  var cursorRaw = url && url.searchParams.get("cursor");
18909
19730
  var cursor;
@@ -18923,7 +19744,7 @@ function mount(router, deps) {
18923
19744
  // not-redeemable surface as a 400 re-render, never a 500.
18924
19745
  if (deps.loyaltyRedemption) {
18925
19746
  router.post("/account/loyalty/redeem", async function (req, res) {
18926
- var auth = _loyaltyAuth(req, res); if (!auth) return;
19747
+ var auth = await _loyaltyAuth(req, res); if (!auth) return;
18927
19748
  var body = req.body || {};
18928
19749
  var rewardSlug = body.reward_slug;
18929
19750
  try {
@@ -18971,9 +19792,9 @@ function mount(router, deps) {
18971
19792
  // their OWN wallet. Granting / deducting credit is operator-only on
18972
19793
  // the admin customer-detail screen — this surface writes nothing.
18973
19794
  if (deps.storeCredit) {
18974
- function _storeCreditAuth(req, res) {
19795
+ async function _storeCreditAuth(req, res) {
18975
19796
  var auth;
18976
- try { auth = _currentCustomer(req); }
19797
+ try { auth = await _currentCustomer(req); }
18977
19798
  catch (e) {
18978
19799
  if (e && e.code === "vault/not-initialized") { _serviceUnavailable(res, "auth not configured"); return null; }
18979
19800
  throw e;
@@ -19018,7 +19839,7 @@ function mount(router, deps) {
19018
19839
  }
19019
19840
 
19020
19841
  router.get("/account/credit", async function (req, res) {
19021
- var auth = _storeCreditAuth(req, res); if (!auth) return;
19842
+ var auth = await _storeCreditAuth(req, res); if (!auth) return;
19022
19843
  var url = req.url ? new URL(req.url, "http://localhost") : null;
19023
19844
  var cursorRaw = url && url.searchParams.get("cursor");
19024
19845
  var cursor;
@@ -19066,9 +19887,9 @@ function mount(router, deps) {
19066
19887
  // overwrites the cookie when none is already set (below), so the
19067
19888
  // first referral link a visitor follows wins.
19068
19889
  if (deps.referrals) {
19069
- function _referralsAuth(req, res) {
19890
+ async function _referralsAuth(req, res) {
19070
19891
  var auth;
19071
- try { auth = _currentCustomer(req); }
19892
+ try { auth = await _currentCustomer(req); }
19072
19893
  catch (e) {
19073
19894
  if (e && e.code === "vault/not-initialized") { _serviceUnavailable(res, "auth not configured"); return null; }
19074
19895
  throw e;
@@ -19179,13 +20000,13 @@ function mount(router, deps) {
19179
20000
  }
19180
20001
 
19181
20002
  router.get("/account/referrals", async function (req, res) {
19182
- var auth = _referralsAuth(req, res); if (!auth) return;
20003
+ var auth = await _referralsAuth(req, res); if (!auth) return;
19183
20004
  var view = await _referralsView(req, auth, {});
19184
20005
  _send(res, 200, renderReferrals(view));
19185
20006
  });
19186
20007
 
19187
20008
  router.post("/account/referrals/code", async function (req, res) {
19188
- var auth = _referralsAuth(req, res); if (!auth) return;
20009
+ var auth = await _referralsAuth(req, res); if (!auth) return;
19189
20010
  try {
19190
20011
  await deps.referrals.issueCode({ referrer_customer_id: auth.customer_id });
19191
20012
  } catch (e) {
@@ -19214,9 +20035,9 @@ function mount(router, deps) {
19214
20035
  // history. Views are recorded server-side on the (container-rendered)
19215
20036
  // PDP; this surface lets the customer review + clear that history.
19216
20037
  if (deps.recentlyViewed) {
19217
- function _rvAuth(req, res) {
20038
+ async function _rvAuth(req, res) {
19218
20039
  var auth;
19219
- try { auth = _currentCustomer(req); }
20040
+ try { auth = await _currentCustomer(req); }
19220
20041
  catch (e) {
19221
20042
  if (e && e.code === "vault/not-initialized") { _serviceUnavailable(res, "auth not configured"); return null; }
19222
20043
  throw e;
@@ -19230,7 +20051,7 @@ function mount(router, deps) {
19230
20051
  }
19231
20052
 
19232
20053
  router.get("/account/recently-viewed", async function (req, res) {
19233
- var auth = _rvAuth(req, res); if (!auth) return;
20054
+ var auth = await _rvAuth(req, res); if (!auth) return;
19234
20055
  // A read failure (table not migrated) degrades to the empty
19235
20056
  // state rather than 500-ing the account page.
19236
20057
  var rows = [];
@@ -19246,7 +20067,7 @@ function mount(router, deps) {
19246
20067
  });
19247
20068
 
19248
20069
  router.post("/account/recently-viewed/clear", async function (req, res) {
19249
- var auth = _rvAuth(req, res); if (!auth) return;
20070
+ var auth = await _rvAuth(req, res); if (!auth) return;
19250
20071
  try { await deps.recentlyViewed.purgeCustomer(auth.customer_id); }
19251
20072
  catch (_e) { /* drop-silent — a failed clear leaves history intact, no error surface needed */ }
19252
20073
  res.status(303); res.setHeader && res.setHeader("location", "/account/recently-viewed");
@@ -19264,7 +20085,7 @@ function mount(router, deps) {
19264
20085
  var product = slug ? await deps.catalog.products.bySlug(slug) : null;
19265
20086
  if (!product) { _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme })); return null; }
19266
20087
  var auth;
19267
- try { auth = _currentCustomer(req); }
20088
+ try { auth = await _currentCustomer(req); }
19268
20089
  catch (e) {
19269
20090
  if (e && e.code === "vault/not-initialized") { _serviceUnavailable(res, "auth not configured"); return null; }
19270
20091
  throw e;
@@ -19353,7 +20174,7 @@ function mount(router, deps) {
19353
20174
  var product = slug ? await deps.catalog.products.bySlug(slug) : null;
19354
20175
  if (!product) { _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme })); return null; }
19355
20176
  var auth;
19356
- try { auth = _currentCustomer(req); }
20177
+ try { auth = await _currentCustomer(req); }
19357
20178
  catch (e) {
19358
20179
  if (e && e.code === "vault/not-initialized") { _serviceUnavailable(res, "auth not configured"); return null; }
19359
20180
  throw e;
@@ -20445,6 +21266,32 @@ function mount(router, deps) {
20445
21266
  // requirements also replace the file at the same path via R2 (the
20446
21267
  // Worker's static-asset bridge serves it ahead of this route if a
20447
21268
  // `robots.txt` key exists in the bucket).
21269
+ // Apple Pay domain-association file. Apple Pay (rendered by Stripe's
21270
+ // Express Checkout Element on the pay page) only appears once Apple has
21271
+ // verified the domain, which it does by fetching this exact path. In
21272
+ // production that crawl lands on the edge Worker, which serves the same
21273
+ // bytes from its own binding (worker/index.js); this container twin keeps
21274
+ // a direct-to-container hit (and the e2e harness) in parity. The file's
21275
+ // bytes are Stripe-provided (downloaded from the Stripe dashboard); the
21276
+ // operator supplies them verbatim via the APPLE_PAY_DOMAIN_ASSOCIATION
21277
+ // value injected here as `deps.apple_pay_domain_association`. Served
21278
+ // unchanged as text/plain — Apple's crawl rejects any HTML wrapper,
21279
+ // redirect, or appended byte, so no transform is applied. Unset → 404:
21280
+ // the documented fail-open posture where the Apple Pay button simply does
21281
+ // not render and every other payment method is unaffected.
21282
+ var applePayAssoc = typeof deps.apple_pay_domain_association === "string"
21283
+ ? deps.apple_pay_domain_association : "";
21284
+ router.get("/.well-known/apple-developer-merchantid-domain-association", function (req, res) {
21285
+ res.setHeader && res.setHeader("content-type", "text/plain; charset=utf-8");
21286
+ if (!applePayAssoc) {
21287
+ res.status(404);
21288
+ return res.end ? res.end("Not Found") : res.send("Not Found");
21289
+ }
21290
+ res.status(200);
21291
+ res.setHeader && res.setHeader("cache-control", "public, max-age=300");
21292
+ res.end ? res.end(applePayAssoc) : res.send(applePayAssoc);
21293
+ });
21294
+
20448
21295
  router.get("/robots.txt", async function (req, res) {
20449
21296
  res.status(200);
20450
21297
  res.setHeader && res.setHeader("content-type", "text/plain; charset=utf-8");
@@ -20687,8 +21534,14 @@ module.exports = {
20687
21534
  renderAccountSubscriptions: renderAccountSubscriptions,
20688
21535
  renderCookiePreferences: renderCookiePreferences,
20689
21536
  renderSurveyPage: renderSurveyPage,
21537
+ renderSuggestionsPage: renderSuggestionsPage,
20690
21538
  renderNewsletterError: renderNewsletterError,
20691
21539
  renderNotFound: renderNotFound,
21540
+ // Sidebar-widget render builders exposed so the dual-render parity test can
21541
+ // pin the container output byte-for-byte against the edge twin.
21542
+ buildSidebarWidget: _buildSidebarWidget,
21543
+ buildSidebarRail: _buildSidebarRail,
21544
+ sidebarPageKeyForPath: _sidebarPageKeyForPath,
20692
21545
  // Layout exposed so operators forking the framework can override.
20693
21546
  _wrap: _wrap,
20694
21547
  LAYOUT: LAYOUT,