@blamejs/blamejs-shop 0.4.22 → 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.
@@ -9085,13 +9389,81 @@ function _setSidCookie(req, res, sid) {
9085
9389
  });
9086
9390
  }
9087
9391
 
9392
+ // Store-free device-fingerprint binding for the sealed auth cookie. The
9393
+ // sealed envelope is tamper-proof but device-PORTABLE: a cookie lifted
9394
+ // off one device replays for the full 14-day life on any other. We bind
9395
+ // it softly to a SHAKE256 fingerprint of the device shape (User-Agent +
9396
+ // sorted Accept-Language / Accept-Encoding) carried INSIDE the sealed
9397
+ // envelope, recomputed + constant-time-compared on read. No external
9398
+ // store: `binding.fingerprint(req)` is a pure function of the request, so
9399
+ // the fingerprint lives in the cookie itself rather than a session table.
9400
+ //
9401
+ // The IP component is deliberately disabled (`ipPrefixBits {v4:0, v6:0}`):
9402
+ // a mobile or VPN visitor roams networks constantly, and signing them out
9403
+ // on a network hop is a worse outcome than the residual portability a
9404
+ // UA+Accept-only fingerprint leaves. Drift in the device shape is the
9405
+ // signal; a network change is not.
9406
+ //
9407
+ // `b.sessionDeviceBinding.create()` refuses to construct without either a
9408
+ // bindingStore or storeInSession — neither of which the store-free
9409
+ // `fingerprint()` path touches. Pass a b.cache-shaped no-op so the
9410
+ // constructor's opt-shape gate is satisfied; its methods are never called.
9411
+ var _NOOP_BINDING_STORE = {
9412
+ get: function () { return undefined; },
9413
+ set: function () { return undefined; },
9414
+ del: function () { return undefined; },
9415
+ };
9416
+ var _deviceBinding = null;
9417
+ function _deviceBindingInstance() {
9418
+ if (!_deviceBinding) {
9419
+ _deviceBinding = b.sessionDeviceBinding.create({
9420
+ bindingStore: _NOOP_BINDING_STORE,
9421
+ ipPrefixBits: { v4: 0, v6: 0 },
9422
+ });
9423
+ }
9424
+ return _deviceBinding;
9425
+ }
9426
+
9427
+ // The hex device fingerprint for THIS request, or null when it can't be
9428
+ // computed (a request shape the primitive refuses). A null fingerprint is
9429
+ // stored as "no binding" — the read side skips the check rather than
9430
+ // signing the visitor out over a missing signal.
9431
+ function _authDeviceFingerprint(req) {
9432
+ try {
9433
+ var fp = _deviceBindingInstance().fingerprint(req);
9434
+ return fp ? fp.toString("hex") : null;
9435
+ } catch (_e) {
9436
+ return null;
9437
+ }
9438
+ }
9439
+
9088
9440
  // Auth + WebAuthn-challenge cookies carry a vault-sealed JSON envelope.
9089
9441
  // writeSealed/readSealed handle the seal + the on-wire prefix; the
9090
9442
  // caller works in plain objects.
9091
9443
  function _setAuthCookie(req, res, env) {
9092
9444
  var T = b.constants.TIME;
9093
9445
  var secure = _secureForReq(req);
9094
- _cookieJar().writeSealed(res, _authCookieName(secure), JSON.stringify(env), {
9446
+ // Stash the device fingerprint inside the sealed envelope at mint time
9447
+ // so a later read can detect a cookie that has moved to a different
9448
+ // device shape. Additive: an env handed in WITHOUT `fp` (a caller that
9449
+ // doesn't know about binding) just gets it filled here.
9450
+ var sealed = env;
9451
+ if (env && env.fp == null) {
9452
+ var fp = _authDeviceFingerprint(req);
9453
+ if (fp) sealed = Object.assign({}, env, { fp: fp });
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
+ }
9466
+ _cookieJar().writeSealed(res, _authCookieName(secure), JSON.stringify(sealed), {
9095
9467
  expires: new Date(Date.now() + T.days(14)),
9096
9468
  secure: secure,
9097
9469
  });
@@ -9570,6 +9942,14 @@ var LOGIN_ERROR_MESSAGES = {
9570
9942
  link: "That sign-in link is invalid or has expired. Request a fresh one.",
9571
9943
  };
9572
9944
 
9945
+ // Neutral (non-error) notices surfaced on the sign-in screen. The
9946
+ // device-binding soft sign-out lands here: a reassuring "sign in again"
9947
+ // message, never an alarming error, and it discloses nothing about WHY
9948
+ // (no "your session looked suspicious" — the visitor just signs in again).
9949
+ var LOGIN_NOTICE_MESSAGES = {
9950
+ device: "You've been signed out for your security. Please sign in again.",
9951
+ };
9952
+
9573
9953
  function renderAccountLogin(opts) {
9574
9954
  opts = opts || {};
9575
9955
  var oauthButtons = "";
@@ -9588,6 +9968,12 @@ function renderAccountLogin(opts) {
9588
9968
  var errHtml = (opts.error && LOGIN_ERROR_MESSAGES[opts.error])
9589
9969
  ? "<p class=\"auth-form__message auth-form__message--err\">" + b.template.escapeHtml(LOGIN_ERROR_MESSAGES[opts.error]) + "</p>"
9590
9970
  : "";
9971
+ // Neutral notice (e.g. the device-binding soft sign-out) renders in the
9972
+ // same slot with non-error styling. Error wins if both are somehow set.
9973
+ if (!errHtml && opts.notice && LOGIN_NOTICE_MESSAGES[opts.notice]) {
9974
+ errHtml = "<p class=\"auth-form__message\" role=\"status\">" +
9975
+ b.template.escapeHtml(LOGIN_NOTICE_MESSAGES[opts.notice]) + "</p>";
9976
+ }
9591
9977
  // Render the email-link path INLINE (a working no-JS form), not as a link
9592
9978
  // to a separate page, so both passwordless paths live on one screen.
9593
9979
  var magicHtml = opts.magic_link_enabled ? LOGIN_MAGIC_INLINE : "";
@@ -10158,9 +10544,13 @@ function renderProfile(opts) {
10158
10544
  "<div class=\"form-row\"><label class=\"form-field\"><span class=\"form-field__label\">Display name</span>" +
10159
10545
  "<input type=\"text\" name=\"display_name\" maxlength=\"128\" required autocomplete=\"name\" value=\"" + displayValue + "\"></label></div>" +
10160
10546
  "<div class=\"form-row\"><label class=\"form-field\"><span class=\"form-field__label\">Email</span>" +
10161
- "<input type=\"text\" value=\"Hidden for privacy — stored as a one-way hash\" disabled aria-describedby=\"email-note\"></label></div>" +
10162
- "<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. " +
10163
- "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>" +
10164
10554
  "<div class=\"form-actions\"><button type=\"submit\" class=\"btn-primary\">Save changes</button> " +
10165
10555
  "<a class=\"btn-ghost\" href=\"/account\">Cancel</a></div>" +
10166
10556
  "</form>" +
@@ -10336,6 +10726,178 @@ function renderSurveyPage(opts) {
10336
10726
  });
10337
10727
  }
10338
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
+
10339
10901
  // ---- quote (token-gated / account-gated) -------------------------------
10340
10902
 
10341
10903
  // One quote's status → the customer-facing label + lede shown when the quote
@@ -11485,6 +12047,68 @@ function mount(router, deps) {
11485
12047
  });
11486
12048
  }
11487
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
+
11488
12112
  // ---- customer survey (token-gated) ----------------------------------
11489
12113
  // The invitation token IS the access — no login. GET renders the survey
11490
12114
  // (or a state notice); POST records the response. Container-only (the
@@ -11572,6 +12196,118 @@ function mount(router, deps) {
11572
12196
  });
11573
12197
  }
11574
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
+
11575
12311
  // ---- quote (token-gated customer page) ------------------------------
11576
12312
  // A request-for-quote a customer can review + accept / decline via a single
11577
12313
  // capability link (/quote/:token) — no login. The token IS the access (the
@@ -11910,12 +12646,58 @@ function mount(router, deps) {
11910
12646
  // block) and the account routes inside it, so there's one auth-cookie
11911
12647
  // reader rather than a copy per call site. A missing / malformed /
11912
12648
  // expired cookie returns null — never throws.
12649
+ //
12650
+ // Device-binding (soft): an envelope carrying a stashed `fp` is checked
12651
+ // against THIS request's recomputed fingerprint with a constant-time
12652
+ // compare. On drift the visitor reads as signed-out (return null) and a
12653
+ // one-shot `req._authDeviceDrift` flag is set so the page-level gates
12654
+ // clear the now-stale cookie and bounce to a neutral sign-in — never a
12655
+ // hard 401 mid-page. A pre-binding envelope (no `fp`, minted before this
12656
+ // shipped) passes through unchanged until its natural expiry, so a
12657
+ // deploy never mass-signs-out live sessions.
11913
12658
  function _currentCustomerEnv(req) {
11914
12659
  var env = _readAuthEnv(req);
11915
12660
  if (!env || !env.customer_id || !env.exp || env.exp < Date.now()) return null;
12661
+ if (typeof env.fp === "string" && env.fp.length > 0) {
12662
+ var current = _authDeviceFingerprint(req);
12663
+ // A request whose fingerprint can't be recomputed (current === null)
12664
+ // is NOT treated as drift — absence of signal is not evidence of a
12665
+ // moved cookie. Only a present-but-mismatching fingerprint signs out.
12666
+ if (current !== null && !b.crypto.timingSafeEqual(env.fp, current)) {
12667
+ if (req) req._authDeviceDrift = true;
12668
+ return null;
12669
+ }
12670
+ }
11916
12671
  return env;
11917
12672
  }
11918
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
+
11919
12701
  // The operator's order-access signing key (derived in server.js from the
11920
12702
  // app secret, domain-separated). Absent it, the emailed-token access path
11921
12703
  // is inert — the placing-browser cookie and signed-in-owner paths still
@@ -14510,11 +15292,113 @@ function mount(router, deps) {
14510
15292
  cancelled: ordUrl ? ordUrl.searchParams.get("cancelled") === "1" : false,
14511
15293
  claim_offer: claimOffer,
14512
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 : "",
14513
15301
  shop_name: shopName,
14514
15302
  theme: theme,
14515
15303
  }));
14516
15304
  });
14517
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
+
14518
15402
  // POST /orders/:order_id/claim-account — the one-click "save your details
14519
15403
  // / create an account" trigger from the confirmation page. Mounts only
14520
15404
  // when the magic-link surface is wired (customerPortal + a mailer); absent
@@ -14747,8 +15631,22 @@ function mount(router, deps) {
14747
15631
  return b.crypto.toBase64Url(buf);
14748
15632
  }
14749
15633
 
14750
- function _currentCustomer(req) {
14751
- 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;
14752
15650
  }
14753
15651
 
14754
15652
  function _serviceUnavailable(res, msg) {
@@ -14799,7 +15697,7 @@ function mount(router, deps) {
14799
15697
  // send them to their account instead of re-rendering a login form
14800
15698
  // (mirrors the /account guard's auth read + vault-not-configured catch).
14801
15699
  var signedIn;
14802
- try { signedIn = _currentCustomer(req); }
15700
+ try { signedIn = await _currentCustomer(req); }
14803
15701
  catch (e) {
14804
15702
  if (e && e.code === "vault/not-initialized") return _serviceUnavailable(res, "auth not configured");
14805
15703
  throw e;
@@ -14808,6 +15706,9 @@ function mount(router, deps) {
14808
15706
  res.status(303); res.setHeader && res.setHeader("location", "/account");
14809
15707
  return res.end ? res.end() : res.send("");
14810
15708
  }
15709
+ // A drifted cookie that reached the sign-in screen directly still gets
15710
+ // cleared so the next request carries no stale envelope.
15711
+ if (req && req._authDeviceDrift) _clearAuthCookie(req, res);
14811
15712
  var cartCount = await _cartCountForReq(req);
14812
15713
  var url = req.url ? new URL(req.url, "http://localhost") : null;
14813
15714
  // Login captcha is opt-in (CAPTCHA_GATE_LOGIN). The widget + the scoped
@@ -14827,6 +15728,7 @@ function mount(router, deps) {
14827
15728
  apple_enabled: !!deps.oauthApple,
14828
15729
  magic_link_enabled: !!(deps.customerPortal && deps.customerPortalEmail),
14829
15730
  error: url && url.searchParams.get("error"),
15731
+ notice: url && url.searchParams.get("signed_out"),
14830
15732
  captcha_kind: captchaLoginOn ? captchaKind : null,
14831
15733
  captcha_public_key: captchaLoginOn ? captchaPubKey : null,
14832
15734
  }));
@@ -15241,13 +16143,18 @@ function mount(router, deps) {
15241
16143
 
15242
16144
  router.get("/account", async function (req, res) {
15243
16145
  var auth;
15244
- try { auth = _currentCustomer(req); }
16146
+ try { auth = await _currentCustomer(req); }
15245
16147
  catch (e) {
15246
16148
  if (e && e.code === "vault/not-initialized") return _serviceUnavailable(res, "auth not configured");
15247
16149
  throw e;
15248
16150
  }
15249
16151
  if (!auth) {
15250
- res.status(303); res.setHeader && res.setHeader("location", "/account/login");
16152
+ // On device-binding drift, clear the stale cookie + surface a
16153
+ // neutral sign-in notice (matches _accountAuth's soft sign-out).
16154
+ if (req && req._authDeviceDrift) _clearAuthCookie(req, res);
16155
+ res.status(303);
16156
+ res.setHeader && res.setHeader("location",
16157
+ (req && req._authDeviceDrift) ? "/account/login?signed_out=device" : "/account/login");
15251
16158
  return res.end ? res.end() : res.send("");
15252
16159
  }
15253
16160
  var customer = await deps.customers.get(auth.customer_id);
@@ -15323,7 +16230,7 @@ function mount(router, deps) {
15323
16230
  if (deps.order) {
15324
16231
  router.get("/account/orders", async function (req, res) {
15325
16232
  var ordersAuth;
15326
- try { ordersAuth = _currentCustomer(req); }
16233
+ try { ordersAuth = await _currentCustomer(req); }
15327
16234
  catch (e) {
15328
16235
  if (e && e.code === "vault/not-initialized") return _serviceUnavailable(res, "auth not configured");
15329
16236
  throw e;
@@ -15380,7 +16287,7 @@ function mount(router, deps) {
15380
16287
  }
15381
16288
 
15382
16289
  router.get("/account/quotes", async function (req, res) {
15383
- var auth = _accountAuth(req, res);
16290
+ var auth = await _accountAuth(req, res);
15384
16291
  if (!auth) return;
15385
16292
  var rows = [];
15386
16293
  try { rows = await deps.quotes.quotesForCustomer(auth.customer_id, { limit: 100 }); }
@@ -15392,7 +16299,7 @@ function mount(router, deps) {
15392
16299
  });
15393
16300
 
15394
16301
  router.get("/account/quotes/:id", async function (req, res) {
15395
- var auth = _accountAuth(req, res);
16302
+ var auth = await _accountAuth(req, res);
15396
16303
  if (!auth) return;
15397
16304
  var q = await _ownedQuote(auth.customer_id, req.params && req.params.id);
15398
16305
  var count = await _cartCountForReq(req);
@@ -15414,7 +16321,7 @@ function mount(router, deps) {
15414
16321
  // machinery is wired, so the holds land at acceptance.
15415
16322
  var _accountQuoteAction = function (action) {
15416
16323
  return async function (req, res) {
15417
- var auth = _accountAuth(req, res);
16324
+ var auth = await _accountAuth(req, res);
15418
16325
  if (!auth) return;
15419
16326
  var q = await _ownedQuote(auth.customer_id, req.params && req.params.id);
15420
16327
  var count = await _cartCountForReq(req);
@@ -15452,7 +16359,7 @@ function mount(router, deps) {
15452
16359
  // quote's account page. An empty / missing cart re-renders the cart
15453
16360
  // with a notice. The optional `message` field rides along.
15454
16361
  router.post("/account/quotes/request", async function (req, res) {
15455
- var auth = _accountAuth(req, res);
16362
+ var auth = await _accountAuth(req, res);
15456
16363
  if (!auth) return;
15457
16364
  var sid = _readSidCookie(req);
15458
16365
  var cart = sid ? await deps.cart.bySession(sid) : null;
@@ -15497,15 +16404,35 @@ function mount(router, deps) {
15497
16404
  // registration page drives, but bound to the ALREADY-authed customer
15498
16405
  // (no email form), so a new credential always lands on the right
15499
16406
  // account.
15500
- function _accountAuth(req, res) {
16407
+ async function _accountAuth(req, res) {
15501
16408
  var auth;
15502
- try { auth = _currentCustomer(req); }
16409
+ try { auth = await _currentCustomer(req); }
15503
16410
  catch (e) {
15504
16411
  if (e && e.code === "vault/not-initialized") { _serviceUnavailable(res, "auth not configured"); return null; }
15505
16412
  throw e;
15506
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
+ }
15507
16427
  if (!auth) {
15508
- res.status(303); res.setHeader && res.setHeader("location", "/account/login");
16428
+ // Device-binding drift: clear the now-stale cookie and bounce to a
16429
+ // neutral sign-in notice (never a hard 401 mid-page). Any other
16430
+ // not-signed-in case (no cookie, expired) bounces to the plain
16431
+ // login with no notice.
16432
+ if (req && req._authDeviceDrift) _clearAuthCookie(req, res);
16433
+ res.status(303);
16434
+ res.setHeader && res.setHeader("location",
16435
+ (req && req._authDeviceDrift) ? "/account/login?signed_out=device" : "/account/login");
15509
16436
  res.end ? res.end() : res.send("");
15510
16437
  return null;
15511
16438
  }
@@ -15565,7 +16492,7 @@ function mount(router, deps) {
15565
16492
  }
15566
16493
 
15567
16494
  router.get("/account/passkeys", async function (req, res) {
15568
- var auth = _accountAuth(req, res); if (!auth) return;
16495
+ var auth = await _accountAuth(req, res); if (!auth) return;
15569
16496
  await _renderPasskeysPage(req, res, auth, null);
15570
16497
  });
15571
16498
 
@@ -15573,7 +16500,7 @@ function mount(router, deps) {
15573
16500
  // server-rendered confirm page; the POST that actually revokes lives
15574
16501
  // behind it.
15575
16502
  router.get("/account/passkeys/:id/remove", async function (req, res) {
15576
- var auth = _accountAuth(req, res); if (!auth) return;
16503
+ var auth = await _accountAuth(req, res); if (!auth) return;
15577
16504
  var pk = await _ownedPasskey(req, res, auth); if (!pk) return;
15578
16505
  var cartCount = await _cartCountForReq(req);
15579
16506
  _send(res, 200, renderPasskeyRemoveConfirm({
@@ -15584,7 +16511,7 @@ function mount(router, deps) {
15584
16511
  });
15585
16512
 
15586
16513
  router.post("/account/passkeys/:id/revoke", async function (req, res) {
15587
- var auth = _accountAuth(req, res); if (!auth) return;
16514
+ var auth = await _accountAuth(req, res); if (!auth) return;
15588
16515
  var pk = await _ownedPasskey(req, res, auth); if (!pk) return;
15589
16516
  // Last-credential guard: refuse to remove the only sign-in method
15590
16517
  // when there's no federated fallback — surface a clear notice rather
@@ -15613,7 +16540,7 @@ function mount(router, deps) {
15613
16540
  // on both so an add-finish can't be replayed against a register/login
15614
16541
  // challenge.
15615
16542
  router.post("/account/passkey/add-begin", async function (req, res) {
15616
- var auth = _accountAuth(req, res); if (!auth) return;
16543
+ var auth = await _accountAuth(req, res); if (!auth) return;
15617
16544
  try {
15618
16545
  var customer = await deps.customers.get(auth.customer_id);
15619
16546
  if (!customer) { res.status(401); return res.end ? res.end("unknown customer") : res.send("unknown customer"); }
@@ -15655,7 +16582,7 @@ function mount(router, deps) {
15655
16582
  });
15656
16583
 
15657
16584
  router.post("/account/passkey/add-finish", async function (req, res) {
15658
- var auth = _accountAuth(req, res); if (!auth) return;
16585
+ var auth = await _accountAuth(req, res); if (!auth) return;
15659
16586
  try {
15660
16587
  var env = _readChallengeEnv(req);
15661
16588
  if (!env) { res.status(400); return res.end ? res.end("missing challenge") : res.send("missing challenge"); }
@@ -15767,7 +16694,7 @@ function mount(router, deps) {
15767
16694
  }
15768
16695
 
15769
16696
  router.get("/account/payment-methods", async function (req, res) {
15770
- var auth = _accountAuth(req, res); if (!auth) return;
16697
+ var auth = await _accountAuth(req, res); if (!auth) return;
15771
16698
  await _renderPaymentMethodsPage(req, res, auth, null);
15772
16699
  });
15773
16700
 
@@ -15777,7 +16704,7 @@ function mount(router, deps) {
15777
16704
  // script). Requires the publishable key; absent it, a 503 like the pay
15778
16705
  // page.
15779
16706
  router.get("/account/payment-methods/add", async function (req, res) {
15780
- var auth = _accountAuth(req, res); if (!auth) return;
16707
+ var auth = await _accountAuth(req, res); if (!auth) return;
15781
16708
  var pk = deps.stripe_publishable_key || "";
15782
16709
  if (!pk) {
15783
16710
  return _send(res, 503, _wrap({
@@ -15804,7 +16731,7 @@ function mount(router, deps) {
15804
16731
  // the customers table; Stripe dedupes by metadata/email) — acceptable
15805
16732
  // v1, documented in the build spec.
15806
16733
  router.post("/account/payment-methods/setup-intent", async function (req, res) {
15807
- var auth = _accountAuth(req, res); if (!auth) return;
16734
+ var auth = await _accountAuth(req, res); if (!auth) return;
15808
16735
  function _json(status, obj) {
15809
16736
  res.status(status);
15810
16737
  res.setHeader && res.setHeader("content-type", "application/json; charset=utf-8");
@@ -15827,7 +16754,7 @@ function mount(router, deps) {
15827
16754
  // pm_… → its display fields → stores via paymentMethods.add. A
15828
16755
  // duplicate token (already on file) is an idempotent notice, not a 500.
15829
16756
  router.post("/account/payment-methods", async function (req, res) {
15830
- var auth = _accountAuth(req, res); if (!auth) return;
16757
+ var auth = await _accountAuth(req, res); if (!auth) return;
15831
16758
  var body = req.body || {};
15832
16759
  var setupIntentId = typeof body.setup_intent_id === "string" ? body.setup_intent_id : "";
15833
16760
  if (!setupIntentId) return _renderPaymentMethodsPage(req, res, auth, "Couldn't add that card — please try again.", 400);
@@ -15862,7 +16789,7 @@ function mount(router, deps) {
15862
16789
  });
15863
16790
 
15864
16791
  router.post("/account/payment-methods/:id/default", async function (req, res) {
15865
- var auth = _accountAuth(req, res); if (!auth) return;
16792
+ var auth = await _accountAuth(req, res); if (!auth) return;
15866
16793
  var pm = await _ownedPaymentMethod(req, res, auth); if (!pm) return;
15867
16794
  try { await deps.paymentMethods.setDefault(pm.id); }
15868
16795
  catch (e) {
@@ -15876,7 +16803,7 @@ function mount(router, deps) {
15876
16803
  });
15877
16804
 
15878
16805
  router.post("/account/payment-methods/:id/archive", async function (req, res) {
15879
- var auth = _accountAuth(req, res); if (!auth) return;
16806
+ var auth = await _accountAuth(req, res); if (!auth) return;
15880
16807
  var pm = await _ownedPaymentMethod(req, res, auth); if (!pm) return;
15881
16808
  try { await deps.paymentMethods.archive({ payment_method_id: pm.id, reason: "customer_request" }); }
15882
16809
  catch (e) {
@@ -15909,7 +16836,7 @@ function mount(router, deps) {
15909
16836
  }
15910
16837
 
15911
16838
  router.get("/account/profile", async function (req, res) {
15912
- var auth = _accountAuth(req, res); if (!auth) return;
16839
+ var auth = await _accountAuth(req, res); if (!auth) return;
15913
16840
  var customer = await deps.customers.get(auth.customer_id);
15914
16841
  if (!customer) {
15915
16842
  _clearAuthCookie(req, res);
@@ -15920,7 +16847,7 @@ function mount(router, deps) {
15920
16847
  });
15921
16848
 
15922
16849
  router.post("/account/profile", async function (req, res) {
15923
- var auth = _accountAuth(req, res); if (!auth) return;
16850
+ var auth = await _accountAuth(req, res); if (!auth) return;
15924
16851
  var customer = await deps.customers.get(auth.customer_id);
15925
16852
  if (!customer) {
15926
16853
  _clearAuthCookie(req, res);
@@ -16163,7 +17090,7 @@ function mount(router, deps) {
16163
17090
  // a forged slug can't drive an open redirect).
16164
17091
  router.post("/wishlist/toggle", async function (req, res) {
16165
17092
  var auth;
16166
- try { auth = _currentCustomer(req); }
17093
+ try { auth = await _currentCustomer(req); }
16167
17094
  catch (e) {
16168
17095
  if (e && e.code === "vault/not-initialized") return _serviceUnavailable(res, "auth not configured");
16169
17096
  throw e;
@@ -16203,7 +17130,7 @@ function mount(router, deps) {
16203
17130
  // archived renders as "unavailable" (the row is orphan-tolerant).
16204
17131
  router.get("/account/wishlist", async function (req, res) {
16205
17132
  var auth;
16206
- try { auth = _currentCustomer(req); }
17133
+ try { auth = await _currentCustomer(req); }
16207
17134
  catch (e) {
16208
17135
  if (e && e.code === "vault/not-initialized") return _serviceUnavailable(res, "auth not configured");
16209
17136
  throw e;
@@ -16319,7 +17246,7 @@ function mount(router, deps) {
16319
17246
  if (deps.wishlistAlerts) {
16320
17247
  router.post("/account/wishlist/alerts", async function (req, res) {
16321
17248
  var auth;
16322
- try { auth = _currentCustomer(req); }
17249
+ try { auth = await _currentCustomer(req); }
16323
17250
  catch (e) {
16324
17251
  if (e && e.code === "vault/not-initialized") return _serviceUnavailable(res, "auth not configured");
16325
17252
  throw e;
@@ -16353,7 +17280,7 @@ function mount(router, deps) {
16353
17280
  if (deps.wishlistDigest) {
16354
17281
  router.post("/account/wishlist/digest", async function (req, res) {
16355
17282
  var auth;
16356
- try { auth = _currentCustomer(req); }
17283
+ try { auth = await _currentCustomer(req); }
16357
17284
  catch (e) {
16358
17285
  if (e && e.code === "vault/not-initialized") return _serviceUnavailable(res, "auth not configured");
16359
17286
  throw e;
@@ -16463,7 +17390,7 @@ function mount(router, deps) {
16463
17390
  // redirect so the one-time URL is shown.
16464
17391
  router.post("/wishlist/share", async function (req, res) {
16465
17392
  var auth;
16466
- try { auth = _currentCustomer(req); }
17393
+ try { auth = await _currentCustomer(req); }
16467
17394
  catch (e) {
16468
17395
  if (e && e.code === "vault/not-initialized") return _serviceUnavailable(res, "auth not configured");
16469
17396
  throw e;
@@ -16502,7 +17429,7 @@ function mount(router, deps) {
16502
17429
  // its id (IDOR).
16503
17430
  router.post("/wishlist/share/:share_id/revoke", async function (req, res) {
16504
17431
  var auth;
16505
- try { auth = _currentCustomer(req); }
17432
+ try { auth = await _currentCustomer(req); }
16506
17433
  catch (e) {
16507
17434
  if (e && e.code === "vault/not-initialized") return _serviceUnavailable(res, "auth not configured");
16508
17435
  throw e;
@@ -16649,7 +17576,7 @@ function mount(router, deps) {
16649
17576
  // its progress rollup, plus the create form.
16650
17577
  router.get("/account/registry", async function (req, res) {
16651
17578
  var auth;
16652
- try { auth = _currentCustomer(req); }
17579
+ try { auth = await _currentCustomer(req); }
16653
17580
  catch (e) {
16654
17581
  if (e && e.code === "vault/not-initialized") return _serviceUnavailable(res, "auth not configured");
16655
17582
  throw e;
@@ -16683,7 +17610,7 @@ function mount(router, deps) {
16683
17610
  // shopper can only ever create a registry under their own id.
16684
17611
  router.post("/account/registry", async function (req, res) {
16685
17612
  var auth;
16686
- try { auth = _currentCustomer(req); }
17613
+ try { auth = await _currentCustomer(req); }
16687
17614
  catch (e) {
16688
17615
  if (e && e.code === "vault/not-initialized") return _serviceUnavailable(res, "auth not configured");
16689
17616
  throw e;
@@ -16742,7 +17669,7 @@ function mount(router, deps) {
16742
17669
  // slug) 404s.
16743
17670
  router.get("/account/registry/:slug", async function (req, res) {
16744
17671
  var auth;
16745
- try { auth = _currentCustomer(req); }
17672
+ try { auth = await _currentCustomer(req); }
16746
17673
  catch (e) {
16747
17674
  if (e && e.code === "vault/not-initialized") return _serviceUnavailable(res, "auth not configured");
16748
17675
  throw e;
@@ -16809,7 +17736,7 @@ function mount(router, deps) {
16809
17736
  // session customer owns. Ownership-scoped (404 on a foreign / unknown
16810
17737
  // registry).
16811
17738
  router.post("/account/registry/:slug/items", async function (req, res) {
16812
- var auth = _registryAuthOrRedirect(req, res);
17739
+ var auth = await _registryAuthOrRedirect(req, res);
16813
17740
  if (!auth) return;
16814
17741
  var slug = (req.params && req.params.slug) || "";
16815
17742
  var reg = await _ownedRegistry(slug, auth.customer_id);
@@ -16837,7 +17764,7 @@ function mount(router, deps) {
16837
17764
  // POST /account/registry/:slug/items/:item_id/remove — archive an item
16838
17765
  // on a registry the session customer owns.
16839
17766
  router.post("/account/registry/:slug/items/:item_id/remove", async function (req, res) {
16840
- var auth = _registryAuthOrRedirect(req, res);
17767
+ var auth = await _registryAuthOrRedirect(req, res);
16841
17768
  if (!auth) return;
16842
17769
  var slug = (req.params && req.params.slug) || "";
16843
17770
  var reg = await _ownedRegistry(slug, auth.customer_id);
@@ -16861,7 +17788,7 @@ function mount(router, deps) {
16861
17788
  // (title / recipient_name / event_date / privacy) on a registry the
16862
17789
  // session customer owns.
16863
17790
  router.post("/account/registry/:slug/edit", async function (req, res) {
16864
- var auth = _registryAuthOrRedirect(req, res);
17791
+ var auth = await _registryAuthOrRedirect(req, res);
16865
17792
  if (!auth) return;
16866
17793
  var slug = (req.params && req.params.slug) || "";
16867
17794
  var reg = await _ownedRegistry(slug, auth.customer_id);
@@ -16893,7 +17820,7 @@ function mount(router, deps) {
16893
17820
  // POST /account/registry/:slug/close — close a registry the session
16894
17821
  // customer owns (the only FSM transition; refuses further mutation).
16895
17822
  router.post("/account/registry/:slug/close", async function (req, res) {
16896
- var auth = _registryAuthOrRedirect(req, res);
17823
+ var auth = await _registryAuthOrRedirect(req, res);
16897
17824
  if (!auth) return;
16898
17825
  var slug = (req.params && req.params.slug) || "";
16899
17826
  var reg = await _ownedRegistry(slug, auth.customer_id);
@@ -17023,7 +17950,7 @@ function mount(router, deps) {
17023
17950
  var buyerId = null;
17024
17951
  if (reveal) {
17025
17952
  var giverAuth = null;
17026
- try { giverAuth = _currentCustomer(req); } catch (_e) { giverAuth = null; }
17953
+ try { giverAuth = await _currentCustomer(req); } catch (_e) { giverAuth = null; }
17027
17954
  if (giverAuth && giverAuth.customer_id) buyerId = giverAuth.customer_id;
17028
17955
  else reveal = false; // not signed in → fall back to anonymous
17029
17956
  }
@@ -17054,9 +17981,9 @@ function mount(router, deps) {
17054
17981
  // Shared owner-route auth gate: resolve the session customer or send the
17055
17982
  // redirect / 503 and return null. Mirrors the wishlist `_savedAuth`
17056
17983
  // shape so every owner registry write funnels through one check.
17057
- function _registryAuthOrRedirect(req, res) {
17984
+ async function _registryAuthOrRedirect(req, res) {
17058
17985
  var auth;
17059
- try { auth = _currentCustomer(req); }
17986
+ try { auth = await _currentCustomer(req); }
17060
17987
  catch (e) {
17061
17988
  if (e && e.code === "vault/not-initialized") { _serviceUnavailable(res, "auth not configured"); return null; }
17062
17989
  throw e;
@@ -17111,9 +18038,9 @@ function mount(router, deps) {
17111
18038
  // Save for later — move a cart line into a per-customer holding
17112
18039
  // list and back. Login required (the list is per-customer).
17113
18040
  if (deps.saveForLater) {
17114
- function _savedAuth(req, res) {
18041
+ async function _savedAuth(req, res) {
17115
18042
  var auth;
17116
- try { auth = _currentCustomer(req); }
18043
+ try { auth = await _currentCustomer(req); }
17117
18044
  catch (e) {
17118
18045
  if (e && e.code === "vault/not-initialized") { _serviceUnavailable(res, "auth not configured"); return null; }
17119
18046
  throw e;
@@ -17129,7 +18056,7 @@ function mount(router, deps) {
17129
18056
  // POST /cart/lines/:line_id/save — move the line out of the cart
17130
18057
  // into the customer's saved list. Redirects back to /cart.
17131
18058
  router.post("/cart/lines/:line_id/save", async function (req, res) {
17132
- var auth = _savedAuth(req, res);
18059
+ var auth = await _savedAuth(req, res);
17133
18060
  if (!auth) return;
17134
18061
  var sid = _readSidCookie(req);
17135
18062
  var cart = sid ? await deps.cart.bySession(sid) : null;
@@ -17153,7 +18080,7 @@ function mount(router, deps) {
17153
18080
 
17154
18081
  // GET /account/saved — the customer's saved-for-later list.
17155
18082
  router.get("/account/saved", async function (req, res) {
17156
- var auth = _savedAuth(req, res);
18083
+ var auth = await _savedAuth(req, res);
17157
18084
  if (!auth) return;
17158
18085
  var page = await deps.saveForLater.listForCustomer({ customer_id: auth.customer_id, limit: 50 });
17159
18086
  var items = [];
@@ -17184,7 +18111,7 @@ function mount(router, deps) {
17184
18111
  // POST /saved/:save_id/move-to-cart — move a saved row back into
17185
18112
  // the session cart (created if absent). Redirects to /cart.
17186
18113
  router.post("/saved/:save_id/move-to-cart", async function (req, res) {
17187
- var auth = _savedAuth(req, res);
18114
+ var auth = await _savedAuth(req, res);
17188
18115
  if (!auth) return;
17189
18116
  var resolved = await _getOrCreateCart(req, res, "USD");
17190
18117
  try {
@@ -17224,7 +18151,7 @@ function mount(router, deps) {
17224
18151
 
17225
18152
  // POST /saved/:save_id/remove — drop a saved row.
17226
18153
  router.post("/saved/:save_id/remove", async function (req, res) {
17227
- var auth = _savedAuth(req, res);
18154
+ var auth = await _savedAuth(req, res);
17228
18155
  if (!auth) return;
17229
18156
  try {
17230
18157
  await deps.saveForLater.remove({ customer_id: auth.customer_id, save_id: req.params && req.params.save_id });
@@ -17242,9 +18169,9 @@ function mount(router, deps) {
17242
18169
  // (the primitive operates by id alone, so ownership is enforced here
17243
18170
  // to prevent cross-customer access via a guessed id).
17244
18171
  if (deps.addresses) {
17245
- function _addrAuth(req, res) {
18172
+ async function _addrAuth(req, res) {
17246
18173
  var auth;
17247
- try { auth = _currentCustomer(req); }
18174
+ try { auth = await _currentCustomer(req); }
17248
18175
  catch (e) {
17249
18176
  if (e && e.code === "vault/not-initialized") { _serviceUnavailable(res, "auth not configured"); return null; }
17250
18177
  throw e;
@@ -17333,18 +18260,18 @@ function mount(router, deps) {
17333
18260
  }
17334
18261
 
17335
18262
  router.get("/account/addresses", async function (req, res) {
17336
- var auth = _addrAuth(req, res); if (!auth) return;
18263
+ var auth = await _addrAuth(req, res); if (!auth) return;
17337
18264
  await _renderAddrPage(req, res, auth, null);
17338
18265
  });
17339
18266
 
17340
18267
  router.get("/account/addresses/:id/edit", async function (req, res) {
17341
- var auth = _addrAuth(req, res); if (!auth) return;
18268
+ var auth = await _addrAuth(req, res); if (!auth) return;
17342
18269
  var addr = await _ownedAddress(req, res, auth); if (!addr) return;
17343
18270
  await _renderAddrPage(req, res, auth, addr);
17344
18271
  });
17345
18272
 
17346
18273
  router.post("/account/addresses", async function (req, res) {
17347
- var auth = _addrAuth(req, res); if (!auth) return;
18274
+ var auth = await _addrAuth(req, res); if (!auth) return;
17348
18275
  try {
17349
18276
  await deps.addresses.add(_addrInput(req.body || {}, auth.customer_id));
17350
18277
  } catch (e) {
@@ -17359,7 +18286,7 @@ function mount(router, deps) {
17359
18286
  });
17360
18287
 
17361
18288
  router.post("/account/addresses/:id", async function (req, res) {
17362
- var auth = _addrAuth(req, res); if (!auth) return;
18289
+ var auth = await _addrAuth(req, res); if (!auth) return;
17363
18290
  var addr = await _ownedAddress(req, res, auth); if (!addr) return;
17364
18291
  try {
17365
18292
  await deps.addresses.update(addr.id, _addrInput(req.body || {}, auth.customer_id));
@@ -17377,7 +18304,7 @@ function mount(router, deps) {
17377
18304
 
17378
18305
  function _addrAction(verb, okKind, fn) {
17379
18306
  router.post("/account/addresses/:id/" + verb, async function (req, res) {
17380
- var auth = _addrAuth(req, res); if (!auth) return;
18307
+ var auth = await _addrAuth(req, res); if (!auth) return;
17381
18308
  var addr = await _ownedAddress(req, res, auth); if (!addr) return;
17382
18309
  try { await fn(addr.id); }
17383
18310
  catch (e) {
@@ -17396,7 +18323,7 @@ function mount(router, deps) {
17396
18323
  // actually archives lives behind that page. The list then surfaces a
17397
18324
  // success notice with an Undo (unarchive) control.
17398
18325
  router.get("/account/addresses/:id/remove", async function (req, res) {
17399
- var auth = _addrAuth(req, res); if (!auth) return;
18326
+ var auth = await _addrAuth(req, res); if (!auth) return;
17400
18327
  var addr = await _ownedAddress(req, res, auth); if (!addr) return;
17401
18328
  var cartCount = await _cartCountForReq(req);
17402
18329
  _send(res, 200, renderAddressRemoveConfirm({
@@ -17407,7 +18334,7 @@ function mount(router, deps) {
17407
18334
  });
17408
18335
 
17409
18336
  router.post("/account/addresses/:id/archive", async function (req, res) {
17410
- var auth = _addrAuth(req, res); if (!auth) return;
18337
+ var auth = await _addrAuth(req, res); if (!auth) return;
17411
18338
  var addr = await _ownedAddress(req, res, auth); if (!addr) return;
17412
18339
  try { await deps.addresses.archive(addr.id); }
17413
18340
  catch (e) {
@@ -17424,7 +18351,7 @@ function mount(router, deps) {
17424
18351
  // address is archived by definition here) but still enforces
17425
18352
  // customer ownership before un-archiving.
17426
18353
  router.post("/account/addresses/:id/unarchive", async function (req, res) {
17427
- var auth = _addrAuth(req, res); if (!auth) return;
18354
+ var auth = await _addrAuth(req, res); if (!auth) return;
17428
18355
  var addr;
17429
18356
  try { addr = await deps.addresses.get(req.params && req.params.id); }
17430
18357
  catch (e) {
@@ -17463,9 +18390,9 @@ function mount(router, deps) {
17463
18390
  // handle. The list above stays a read-only view when this is absent.
17464
18391
  var subControls = deps.subscriptionControls || null;
17465
18392
 
17466
- function _subsAuth(req, res) {
18393
+ async function _subsAuth(req, res) {
17467
18394
  var auth;
17468
- try { auth = _currentCustomer(req); }
18395
+ try { auth = await _currentCustomer(req); }
17469
18396
  catch (e) {
17470
18397
  if (e && e.code === "vault/not-initialized") { _serviceUnavailable(res, "auth not configured"); return null; }
17471
18398
  throw e;
@@ -17522,7 +18449,7 @@ function mount(router, deps) {
17522
18449
  }
17523
18450
 
17524
18451
  router.get("/account/subscriptions", async function (req, res) {
17525
- var auth = _subsAuth(req, res); if (!auth) return;
18452
+ var auth = await _subsAuth(req, res); if (!auth) return;
17526
18453
  var rows = await _subsForCustomer(auth.customer_id);
17527
18454
  var cartCount = await _cartCountForReq(req);
17528
18455
  var url = req.url ? new URL(req.url, "http://localhost") : null;
@@ -17605,7 +18532,7 @@ function mount(router, deps) {
17605
18532
  // currently-active subscription; a paused/cancelled row bounces
17606
18533
  // back to the list.
17607
18534
  router.get("/account/subscriptions/:id/pause", async function (req, res) {
17608
- var auth = _subsAuth(req, res); if (!auth) return;
18535
+ var auth = await _subsAuth(req, res); if (!auth) return;
17609
18536
  var sub = await _ownedManageableSubscription(req, res, auth); if (!sub) return;
17610
18537
  if (_subscriptionControlState(sub) !== "active") return _redirect(res, "");
17611
18538
  if (sub.plan_id != null) {
@@ -17621,7 +18548,7 @@ function mount(router, deps) {
17621
18548
  });
17622
18549
 
17623
18550
  router.post("/account/subscriptions/:id/pause", async function (req, res) {
17624
- var auth = _subsAuth(req, res); if (!auth) return;
18551
+ var auth = await _subsAuth(req, res); if (!auth) return;
17625
18552
  var sub = await _ownedManageableSubscription(req, res, auth); if (!sub) return;
17626
18553
  try {
17627
18554
  await subControls.pause({ subscription_id: sub.id, reason: "customer self-service pause", actor: SELF_ACTOR });
@@ -17633,7 +18560,7 @@ function mount(router, deps) {
17633
18560
  });
17634
18561
 
17635
18562
  router.post("/account/subscriptions/:id/resume", async function (req, res) {
17636
- var auth = _subsAuth(req, res); if (!auth) return;
18563
+ var auth = await _subsAuth(req, res); if (!auth) return;
17637
18564
  var sub = await _ownedManageableSubscription(req, res, auth); if (!sub) return;
17638
18565
  try {
17639
18566
  await subControls.resume({ subscription_id: sub.id, reason: "customer self-service resume", actor: SELF_ACTOR });
@@ -17645,7 +18572,7 @@ function mount(router, deps) {
17645
18572
  });
17646
18573
 
17647
18574
  router.post("/account/subscriptions/:id/skip", async function (req, res) {
17648
- var auth = _subsAuth(req, res); if (!auth) return;
18575
+ var auth = await _subsAuth(req, res); if (!auth) return;
17649
18576
  var sub = await _ownedManageableSubscription(req, res, auth); if (!sub) return;
17650
18577
  try {
17651
18578
  await subControls.skipNext({ subscription_id: sub.id, count: 1, reason: "customer self-service skip", actor: SELF_ACTOR });
@@ -17657,7 +18584,7 @@ function mount(router, deps) {
17657
18584
  });
17658
18585
 
17659
18586
  router.post("/account/subscriptions/:id/quantity", async function (req, res) {
17660
- var auth = _subsAuth(req, res); if (!auth) return;
18587
+ var auth = await _subsAuth(req, res); if (!auth) return;
17661
18588
  var sub = await _ownedManageableSubscription(req, res, auth); if (!sub) return;
17662
18589
  // Backend validates: a non-positive / non-integer / missing value
17663
18590
  // is a client error → bounce with the quantity error code rather
@@ -17684,7 +18611,7 @@ function mount(router, deps) {
17684
18611
  });
17685
18612
 
17686
18613
  router.post("/account/subscriptions/:id/frequency", async function (req, res) {
17687
- var auth = _subsAuth(req, res); if (!auth) return;
18614
+ var auth = await _subsAuth(req, res); if (!auth) return;
17688
18615
  var sub = await _ownedManageableSubscription(req, res, auth); if (!sub) return;
17689
18616
  // Backend validates: reject anything outside the allowed cadence
17690
18617
  // enum before composing the primitive.
@@ -17701,7 +18628,7 @@ function mount(router, deps) {
17701
18628
  });
17702
18629
 
17703
18630
  router.post("/account/subscriptions/:id/reactivate", async function (req, res) {
17704
- var auth = _subsAuth(req, res); if (!auth) return;
18631
+ var auth = await _subsAuth(req, res); if (!auth) return;
17705
18632
  // Reactivate is the recovery path for a cancelled subscription,
17706
18633
  // so it is NOT gated on the terminal-status guard (a Stripe-
17707
18634
  // canceled status is the normal state of a row being
@@ -17726,7 +18653,7 @@ function mount(router, deps) {
17726
18653
  // immediate cancel. A subscription that isn't cancelable
17727
18654
  // (already canceled / winding down) redirects back to the list.
17728
18655
  router.get("/account/subscriptions/:id/cancel", async function (req, res) {
17729
- var auth = _subsAuth(req, res); if (!auth) return;
18656
+ var auth = await _subsAuth(req, res); if (!auth) return;
17730
18657
  var sub = await _ownedSubscription(req, res, auth); if (!sub) return;
17731
18658
  if (!_subscriptionIsCancelable(sub)) {
17732
18659
  res.status(303); res.setHeader && res.setHeader("location", "/account/subscriptions");
@@ -17747,7 +18674,7 @@ function mount(router, deps) {
17747
18674
  });
17748
18675
 
17749
18676
  router.post("/account/subscriptions/:id/cancel", async function (req, res) {
17750
- var auth = _subsAuth(req, res); if (!auth) return;
18677
+ var auth = await _subsAuth(req, res); if (!auth) return;
17751
18678
  var sub = await _ownedSubscription(req, res, auth); if (!sub) return;
17752
18679
  // Default cancel-at-period-end (the customer keeps access through
17753
18680
  // the period they've paid for); the form opts into immediate via
@@ -17778,9 +18705,9 @@ function mount(router, deps) {
17778
18705
  // the launch flow later converts it into a regular (Stripe-gated) order.
17779
18706
  // Mounts only when the preorder primitive is wired.
17780
18707
  if (preorder) {
17781
- function _preorderAuth(req, res) {
18708
+ async function _preorderAuth(req, res) {
17782
18709
  var auth;
17783
- try { auth = _currentCustomer(req); }
18710
+ try { auth = await _currentCustomer(req); }
17784
18711
  catch (e) {
17785
18712
  if (e && e.code === "vault/not-initialized") { _serviceUnavailable(res, "auth not configured"); return null; }
17786
18713
  throw e;
@@ -17812,7 +18739,7 @@ function mount(router, deps) {
17812
18739
  // shows. Over-cap / closed / missing campaign → a clean 4xx PRG back to
17813
18740
  // the PDP with a fixed ?preorder error code (no raw error text).
17814
18741
  router.post("/products/:slug/preorder", async function (req, res) {
17815
- var auth = _preorderAuth(req, res); if (!auth) return;
18742
+ var auth = await _preorderAuth(req, res); if (!auth) return;
17816
18743
  var slug = req.params && req.params.slug;
17817
18744
  var enc = encodeURIComponent(slug || "");
17818
18745
  var product = slug ? await deps.catalog.products.bySlug(slug) : null;
@@ -17895,7 +18822,7 @@ function mount(router, deps) {
17895
18822
  }
17896
18823
 
17897
18824
  router.get("/account/preorders", async function (req, res) {
17898
- var auth = _preorderAuth(req, res); if (!auth) return;
18825
+ var auth = await _preorderAuth(req, res); if (!auth) return;
17899
18826
  var rows = await _preordersForCustomer(auth.customer_id);
17900
18827
  var cartCount = await _cartCountForReq(req);
17901
18828
  var url = req.url ? new URL(req.url, "http://localhost") : null;
@@ -17915,7 +18842,7 @@ function mount(router, deps) {
17915
18842
  // non-active reservation (already converted / cancelled) is a clean PRG
17916
18843
  // back to the list, not a 500.
17917
18844
  router.post("/account/preorders/:id/cancel", async function (req, res) {
17918
- var auth = _preorderAuth(req, res); if (!auth) return;
18845
+ var auth = await _preorderAuth(req, res); if (!auth) return;
17919
18846
  var resv = await _ownedReservation(req, res, auth); if (!resv) return;
17920
18847
  try {
17921
18848
  await preorder.cancelReservation({ reservation_id: resv.id, reason: "customer-cancelled" });
@@ -17935,9 +18862,9 @@ function mount(router, deps) {
17935
18862
  // the admin /admin/returns queue. Needs the returns primitive + an
17936
18863
  // order handle (to load + ownership-check the order being returned).
17937
18864
  if (deps.returns && deps.order) {
17938
- function _returnsAuth(req, res) {
18865
+ async function _returnsAuth(req, res) {
17939
18866
  var auth;
17940
- try { auth = _currentCustomer(req); }
18867
+ try { auth = await _currentCustomer(req); }
17941
18868
  catch (e) {
17942
18869
  if (e && e.code === "vault/not-initialized") { _serviceUnavailable(res, "auth not configured"); return null; }
17943
18870
  throw e;
@@ -18001,7 +18928,7 @@ function mount(router, deps) {
18001
18928
  }
18002
18929
 
18003
18930
  router.get("/account/returns", async function (req, res) {
18004
- var auth = _returnsAuth(req, res); if (!auth) return;
18931
+ var auth = await _returnsAuth(req, res); if (!auth) return;
18005
18932
  var page = await deps.returns.listForCustomer(auth.customer_id, { limit: 50 });
18006
18933
  var cartCount = await _cartCountForReq(req);
18007
18934
  var retUrl = req.url ? new URL(req.url, "http://localhost") : null;
@@ -18018,7 +18945,7 @@ function mount(router, deps) {
18018
18945
  // through _ownedReturn (foreign / unknown / malformed id → 404). A
18019
18946
  // return with no issued label renders a neutral "no label yet" state.
18020
18947
  router.get("/account/returns/:id", async function (req, res) {
18021
- var auth = _returnsAuth(req, res); if (!auth) return;
18948
+ var auth = await _returnsAuth(req, res); if (!auth) return;
18022
18949
  var rma = await _ownedReturn(req, res, auth); if (!rma) return;
18023
18950
  var label = await _labelForReturn(rma.id);
18024
18951
  var events = [];
@@ -18046,7 +18973,7 @@ function mount(router, deps) {
18046
18973
  // primitive is wired.
18047
18974
  if (deps.returnLabels) {
18048
18975
  router.get("/account/returns/:id/label", async function (req, res) {
18049
- var auth = _returnsAuth(req, res); if (!auth) return;
18976
+ var auth = await _returnsAuth(req, res); if (!auth) return;
18050
18977
  var rma = await _ownedReturn(req, res, auth); if (!rma) return;
18051
18978
  var label = await _labelForReturn(rma.id);
18052
18979
  if (!label || !label.label_url) {
@@ -18066,7 +18993,7 @@ function mount(router, deps) {
18066
18993
  }
18067
18994
 
18068
18995
  router.get("/account/orders/:order_id/return", async function (req, res) {
18069
- var auth = _returnsAuth(req, res); if (!auth) return;
18996
+ var auth = await _returnsAuth(req, res); if (!auth) return;
18070
18997
  var order = await _ownedOrder(req, res, auth); if (!order) return;
18071
18998
  var cartCount = await _cartCountForReq(req);
18072
18999
  // An ineligible order (unpaid, cancelled, or already refunded) never
@@ -18085,7 +19012,7 @@ function mount(router, deps) {
18085
19012
  });
18086
19013
 
18087
19014
  router.post("/account/orders/:order_id/return", async function (req, res) {
18088
- var auth = _returnsAuth(req, res); if (!auth) return;
19015
+ var auth = await _returnsAuth(req, res); if (!auth) return;
18089
19016
  var order = await _ownedOrder(req, res, auth); if (!order) return;
18090
19017
  var body = req.body || {};
18091
19018
  var cartCount = await _cartCountForReq(req);
@@ -18162,9 +19089,9 @@ function mount(router, deps) {
18162
19089
  // exactly like the returns + support gates. The exchange primitive
18163
19090
  // moves a row by id alone, so the route owns the ownership decision.
18164
19091
  if (deps.orderExchanges && deps.order) {
18165
- function _exchangeAuth(req, res) {
19092
+ async function _exchangeAuth(req, res) {
18166
19093
  var auth;
18167
- try { auth = _currentCustomer(req); }
19094
+ try { auth = await _currentCustomer(req); }
18168
19095
  catch (e) {
18169
19096
  if (e && e.code === "vault/not-initialized") { _serviceUnavailable(res, "auth not configured"); return null; }
18170
19097
  throw e;
@@ -18247,7 +19174,7 @@ function mount(router, deps) {
18247
19174
  // primitive resolves the customer→order linkage through the injected
18248
19175
  // order handle, so a foreign order's exchange never appears here.
18249
19176
  router.get("/account/exchanges", async function (req, res) {
18250
- var auth = _exchangeAuth(req, res); if (!auth) return;
19177
+ var auth = await _exchangeAuth(req, res); if (!auth) return;
18251
19178
  var exchanges = [];
18252
19179
  try { exchanges = await deps.orderExchanges.exchangesForCustomer(auth.customer_id, { limit: 100 }); }
18253
19180
  catch (e) { if (!(e instanceof TypeError)) throw e; exchanges = []; }
@@ -18264,7 +19191,7 @@ function mount(router, deps) {
18264
19191
  // One exchange's status detail. Ownership-scoped through the parent
18265
19192
  // order (foreign / unknown → 404).
18266
19193
  router.get("/account/exchanges/:id", async function (req, res) {
18267
- var auth = _exchangeAuth(req, res); if (!auth) return;
19194
+ var auth = await _exchangeAuth(req, res); if (!auth) return;
18268
19195
  var exchange = await _ownedExchange(req, res, auth); if (!exchange) return;
18269
19196
  var cartCount = await _cartCountForReq(req);
18270
19197
  _send(res, 200, renderExchangeDetail({ exchange: exchange, shop_name: shopName, cart_count: cartCount }));
@@ -18273,7 +19200,7 @@ function mount(router, deps) {
18273
19200
  // The exchange-request form for one of the customer's own orders,
18274
19201
  // gated on the same eligibility window as a return.
18275
19202
  router.get("/account/orders/:order_id/exchange", async function (req, res) {
18276
- var auth = _exchangeAuth(req, res); if (!auth) return;
19203
+ var auth = await _exchangeAuth(req, res); if (!auth) return;
18277
19204
  var order = await _ownedOrderForExchange(req, res, auth); if (!order) return;
18278
19205
  var cartCount = await _cartCountForReq(req);
18279
19206
  if (!_orderEligibleForExchange(order.status)) {
@@ -18292,7 +19219,7 @@ function mount(router, deps) {
18292
19219
  });
18293
19220
 
18294
19221
  router.post("/account/orders/:order_id/exchange", async function (req, res) {
18295
- var auth = _exchangeAuth(req, res); if (!auth) return;
19222
+ var auth = await _exchangeAuth(req, res); if (!auth) return;
18296
19223
  var order = await _ownedOrderForExchange(req, res, auth); if (!order) return;
18297
19224
  var body = req.body || {};
18298
19225
  var cartCount = await _cartCountForReq(req);
@@ -18390,7 +19317,7 @@ function mount(router, deps) {
18390
19317
  // when the primitive is wired.
18391
19318
  if (deps.clickAndCollect) {
18392
19319
  router.get("/account/pickups", async function (req, res) {
18393
- var auth = _accountAuth(req, res); if (!auth) return;
19320
+ var auth = await _accountAuth(req, res); if (!auth) return;
18394
19321
  var schedules = [];
18395
19322
  try { schedules = await deps.clickAndCollect.customerSchedules(auth.customer_id); }
18396
19323
  catch (e) { if (!(e instanceof TypeError)) throw e; schedules = []; }
@@ -18415,9 +19342,9 @@ function mount(router, deps) {
18415
19342
  if (deps.supportTickets) {
18416
19343
  var support = deps.supportTickets;
18417
19344
 
18418
- function _supportAuth(req, res) {
19345
+ async function _supportAuth(req, res) {
18419
19346
  var auth;
18420
- try { auth = _currentCustomer(req); }
19347
+ try { auth = await _currentCustomer(req); }
18421
19348
  catch (e) {
18422
19349
  if (e && e.code === "vault/not-initialized") { _serviceUnavailable(res, "auth not configured"); return null; }
18423
19350
  throw e;
@@ -18452,7 +19379,7 @@ function mount(router, deps) {
18452
19379
 
18453
19380
  // The customer's own ticket list, scoped to their session customer_id.
18454
19381
  router.get("/account/support", async function (req, res) {
18455
- var auth = _supportAuth(req, res); if (!auth) return;
19382
+ var auth = await _supportAuth(req, res); if (!auth) return;
18456
19383
  var tickets = await support.listByCustomerId(auth.customer_id, { limit: 100 });
18457
19384
  var cartCount = await _cartCountForReq(req);
18458
19385
  var url = req.url ? new URL(req.url, "http://localhost") : null;
@@ -18467,7 +19394,7 @@ function mount(router, deps) {
18467
19394
  // The new-ticket form. Offers the customer's recent orders as an
18468
19395
  // optional attachment.
18469
19396
  router.get("/account/support/new", async function (req, res) {
18470
- var auth = _supportAuth(req, res); if (!auth) return;
19397
+ var auth = await _supportAuth(req, res); if (!auth) return;
18471
19398
  var orders = [];
18472
19399
  if (deps.order) {
18473
19400
  try {
@@ -18493,7 +19420,7 @@ function mount(router, deps) {
18493
19420
  // email shape and surfaces a TypeError on bad input as a clean
18494
19421
  // re-render, never a 500.
18495
19422
  router.post("/account/support", async function (req, res) {
18496
- var auth = _supportAuth(req, res); if (!auth) return;
19423
+ var auth = await _supportAuth(req, res); if (!auth) return;
18497
19424
  var body = req.body || {};
18498
19425
  var cartCount = await _cartCountForReq(req);
18499
19426
 
@@ -18544,7 +19471,7 @@ function mount(router, deps) {
18544
19471
  // Internal operator notes are filtered out before render — the
18545
19472
  // customer never sees an internal=1 message.
18546
19473
  router.get("/account/support/:id", async function (req, res) {
18547
- var auth = _supportAuth(req, res); if (!auth) return;
19474
+ var auth = await _supportAuth(req, res); if (!auth) return;
18548
19475
  var ticket = await _ownedTicket(req, res, auth); if (!ticket) return;
18549
19476
  var thread = await support.thread(ticket.id);
18550
19477
  var visible = (thread.messages || []).filter(function (m) { return Number(m.internal) !== 1; });
@@ -18564,7 +19491,7 @@ function mount(router, deps) {
18564
19491
  // SUPPORT_TICKET_CLOSED error — surfaced as a 409 re-render, never a
18565
19492
  // 500). author is pinned to "customer".
18566
19493
  router.post("/account/support/:id/reply", async function (req, res) {
18567
- var auth = _supportAuth(req, res); if (!auth) return;
19494
+ var auth = await _supportAuth(req, res); if (!auth) return;
18568
19495
  var ticket = await _ownedTicket(req, res, auth); if (!ticket) return;
18569
19496
  var body = req.body || {};
18570
19497
  var cartCount = await _cartCountForReq(req);
@@ -18625,7 +19552,7 @@ function mount(router, deps) {
18625
19552
  var streamDsr = deps.streamDsrBundle;
18626
19553
 
18627
19554
  router.get("/account/privacy", async function (req, res) {
18628
- var auth = _accountAuth(req, res); if (!auth) return;
19555
+ var auth = await _accountAuth(req, res); if (!auth) return;
18629
19556
  var history = [];
18630
19557
  try { history = await dsr.auditForCustomer(auth.customer_id); } catch (_e) { history = []; }
18631
19558
  var cartCount = await _cartCountForReq(req);
@@ -18643,7 +19570,7 @@ function mount(router, deps) {
18643
19570
  // with a notice, never a 500. No row is created on a bad enum (the
18644
19571
  // primitive validates before INSERT).
18645
19572
  router.post("/account/privacy/export", async function (req, res) {
18646
- var auth = _accountAuth(req, res); if (!auth) return;
19573
+ var auth = await _accountAuth(req, res); if (!auth) return;
18647
19574
  var body = req.body || {};
18648
19575
  try {
18649
19576
  await dsr.requestExport({
@@ -18672,7 +19599,7 @@ function mount(router, deps) {
18672
19599
  });
18673
19600
 
18674
19601
  router.get("/account/delete", async function (req, res) {
18675
- var auth = _accountAuth(req, res); if (!auth) return;
19602
+ var auth = await _accountAuth(req, res); if (!auth) return;
18676
19603
  var cartCount = await _cartCountForReq(req);
18677
19604
  _send(res, 200, renderAccountDelete({ shop_name: shopName, cart_count: cartCount }));
18678
19605
  });
@@ -18682,7 +19609,7 @@ function mount(router, deps) {
18682
19609
  // (the operator reviews identity + runs processDeletion from the admin
18683
19610
  // queue). A bad enum / empty reason throws TypeError → a 400 re-render.
18684
19611
  router.post("/account/delete", async function (req, res) {
18685
- var auth = _accountAuth(req, res); if (!auth) return;
19612
+ var auth = await _accountAuth(req, res); if (!auth) return;
18686
19613
  var body = req.body || {};
18687
19614
  try {
18688
19615
  await dsr.requestDeletion({
@@ -18714,7 +19641,7 @@ function mount(router, deps) {
18714
19641
  // a fulfilled/delivered export, then stream. Status + ownership are
18715
19642
  // validated BEFORE the first write (the bundle streams header-first).
18716
19643
  router.get("/account/privacy/:id/export.json", async function (req, res) {
18717
- var auth = _accountAuth(req, res); if (!auth) return;
19644
+ var auth = await _accountAuth(req, res); if (!auth) return;
18718
19645
  var row;
18719
19646
  try { row = await dsr.getRequest(req.params && req.params.id); }
18720
19647
  catch (e) {
@@ -18738,9 +19665,9 @@ function mount(router, deps) {
18738
19665
  // control. Login-gated; a read failure on any optional sub-read
18739
19666
  // degrades that section to empty rather than 500-ing the page.
18740
19667
  if (deps.loyalty) {
18741
- function _loyaltyAuth(req, res) {
19668
+ async function _loyaltyAuth(req, res) {
18742
19669
  var auth;
18743
- try { auth = _currentCustomer(req); }
19670
+ try { auth = await _currentCustomer(req); }
18744
19671
  catch (e) {
18745
19672
  if (e && e.code === "vault/not-initialized") { _serviceUnavailable(res, "auth not configured"); return null; }
18746
19673
  throw e;
@@ -18797,7 +19724,7 @@ function mount(router, deps) {
18797
19724
  }
18798
19725
 
18799
19726
  router.get("/account/loyalty", async function (req, res) {
18800
- var auth = _loyaltyAuth(req, res); if (!auth) return;
19727
+ var auth = await _loyaltyAuth(req, res); if (!auth) return;
18801
19728
  var url = req.url ? new URL(req.url, "http://localhost") : null;
18802
19729
  var cursorRaw = url && url.searchParams.get("cursor");
18803
19730
  var cursor;
@@ -18817,7 +19744,7 @@ function mount(router, deps) {
18817
19744
  // not-redeemable surface as a 400 re-render, never a 500.
18818
19745
  if (deps.loyaltyRedemption) {
18819
19746
  router.post("/account/loyalty/redeem", async function (req, res) {
18820
- var auth = _loyaltyAuth(req, res); if (!auth) return;
19747
+ var auth = await _loyaltyAuth(req, res); if (!auth) return;
18821
19748
  var body = req.body || {};
18822
19749
  var rewardSlug = body.reward_slug;
18823
19750
  try {
@@ -18865,9 +19792,9 @@ function mount(router, deps) {
18865
19792
  // their OWN wallet. Granting / deducting credit is operator-only on
18866
19793
  // the admin customer-detail screen — this surface writes nothing.
18867
19794
  if (deps.storeCredit) {
18868
- function _storeCreditAuth(req, res) {
19795
+ async function _storeCreditAuth(req, res) {
18869
19796
  var auth;
18870
- try { auth = _currentCustomer(req); }
19797
+ try { auth = await _currentCustomer(req); }
18871
19798
  catch (e) {
18872
19799
  if (e && e.code === "vault/not-initialized") { _serviceUnavailable(res, "auth not configured"); return null; }
18873
19800
  throw e;
@@ -18912,7 +19839,7 @@ function mount(router, deps) {
18912
19839
  }
18913
19840
 
18914
19841
  router.get("/account/credit", async function (req, res) {
18915
- var auth = _storeCreditAuth(req, res); if (!auth) return;
19842
+ var auth = await _storeCreditAuth(req, res); if (!auth) return;
18916
19843
  var url = req.url ? new URL(req.url, "http://localhost") : null;
18917
19844
  var cursorRaw = url && url.searchParams.get("cursor");
18918
19845
  var cursor;
@@ -18960,9 +19887,9 @@ function mount(router, deps) {
18960
19887
  // overwrites the cookie when none is already set (below), so the
18961
19888
  // first referral link a visitor follows wins.
18962
19889
  if (deps.referrals) {
18963
- function _referralsAuth(req, res) {
19890
+ async function _referralsAuth(req, res) {
18964
19891
  var auth;
18965
- try { auth = _currentCustomer(req); }
19892
+ try { auth = await _currentCustomer(req); }
18966
19893
  catch (e) {
18967
19894
  if (e && e.code === "vault/not-initialized") { _serviceUnavailable(res, "auth not configured"); return null; }
18968
19895
  throw e;
@@ -19073,13 +20000,13 @@ function mount(router, deps) {
19073
20000
  }
19074
20001
 
19075
20002
  router.get("/account/referrals", async function (req, res) {
19076
- var auth = _referralsAuth(req, res); if (!auth) return;
20003
+ var auth = await _referralsAuth(req, res); if (!auth) return;
19077
20004
  var view = await _referralsView(req, auth, {});
19078
20005
  _send(res, 200, renderReferrals(view));
19079
20006
  });
19080
20007
 
19081
20008
  router.post("/account/referrals/code", async function (req, res) {
19082
- var auth = _referralsAuth(req, res); if (!auth) return;
20009
+ var auth = await _referralsAuth(req, res); if (!auth) return;
19083
20010
  try {
19084
20011
  await deps.referrals.issueCode({ referrer_customer_id: auth.customer_id });
19085
20012
  } catch (e) {
@@ -19108,9 +20035,9 @@ function mount(router, deps) {
19108
20035
  // history. Views are recorded server-side on the (container-rendered)
19109
20036
  // PDP; this surface lets the customer review + clear that history.
19110
20037
  if (deps.recentlyViewed) {
19111
- function _rvAuth(req, res) {
20038
+ async function _rvAuth(req, res) {
19112
20039
  var auth;
19113
- try { auth = _currentCustomer(req); }
20040
+ try { auth = await _currentCustomer(req); }
19114
20041
  catch (e) {
19115
20042
  if (e && e.code === "vault/not-initialized") { _serviceUnavailable(res, "auth not configured"); return null; }
19116
20043
  throw e;
@@ -19124,7 +20051,7 @@ function mount(router, deps) {
19124
20051
  }
19125
20052
 
19126
20053
  router.get("/account/recently-viewed", async function (req, res) {
19127
- var auth = _rvAuth(req, res); if (!auth) return;
20054
+ var auth = await _rvAuth(req, res); if (!auth) return;
19128
20055
  // A read failure (table not migrated) degrades to the empty
19129
20056
  // state rather than 500-ing the account page.
19130
20057
  var rows = [];
@@ -19140,7 +20067,7 @@ function mount(router, deps) {
19140
20067
  });
19141
20068
 
19142
20069
  router.post("/account/recently-viewed/clear", async function (req, res) {
19143
- var auth = _rvAuth(req, res); if (!auth) return;
20070
+ var auth = await _rvAuth(req, res); if (!auth) return;
19144
20071
  try { await deps.recentlyViewed.purgeCustomer(auth.customer_id); }
19145
20072
  catch (_e) { /* drop-silent — a failed clear leaves history intact, no error surface needed */ }
19146
20073
  res.status(303); res.setHeader && res.setHeader("location", "/account/recently-viewed");
@@ -19158,7 +20085,7 @@ function mount(router, deps) {
19158
20085
  var product = slug ? await deps.catalog.products.bySlug(slug) : null;
19159
20086
  if (!product) { _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme })); return null; }
19160
20087
  var auth;
19161
- try { auth = _currentCustomer(req); }
20088
+ try { auth = await _currentCustomer(req); }
19162
20089
  catch (e) {
19163
20090
  if (e && e.code === "vault/not-initialized") { _serviceUnavailable(res, "auth not configured"); return null; }
19164
20091
  throw e;
@@ -19247,7 +20174,7 @@ function mount(router, deps) {
19247
20174
  var product = slug ? await deps.catalog.products.bySlug(slug) : null;
19248
20175
  if (!product) { _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme })); return null; }
19249
20176
  var auth;
19250
- try { auth = _currentCustomer(req); }
20177
+ try { auth = await _currentCustomer(req); }
19251
20178
  catch (e) {
19252
20179
  if (e && e.code === "vault/not-initialized") { _serviceUnavailable(res, "auth not configured"); return null; }
19253
20180
  throw e;
@@ -20339,6 +21266,32 @@ function mount(router, deps) {
20339
21266
  // requirements also replace the file at the same path via R2 (the
20340
21267
  // Worker's static-asset bridge serves it ahead of this route if a
20341
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
+
20342
21295
  router.get("/robots.txt", async function (req, res) {
20343
21296
  res.status(200);
20344
21297
  res.setHeader && res.setHeader("content-type", "text/plain; charset=utf-8");
@@ -20581,8 +21534,14 @@ module.exports = {
20581
21534
  renderAccountSubscriptions: renderAccountSubscriptions,
20582
21535
  renderCookiePreferences: renderCookiePreferences,
20583
21536
  renderSurveyPage: renderSurveyPage,
21537
+ renderSuggestionsPage: renderSuggestionsPage,
20584
21538
  renderNewsletterError: renderNewsletterError,
20585
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,
20586
21545
  // Layout exposed so operators forking the framework can override.
20587
21546
  _wrap: _wrap,
20588
21547
  LAYOUT: LAYOUT,