@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/CHANGELOG.md +4 -0
- package/README.md +6 -1
- package/SECURITY.md +13 -0
- package/lib/admin.js +1273 -15
- package/lib/asset-manifest.json +5 -5
- package/lib/checkout.js +70 -0
- package/lib/customers.js +53 -0
- package/lib/cycle-counting.js +24 -4
- package/lib/gift-card-ledger.js +81 -10
- package/lib/giftcards.js +88 -0
- package/lib/inventory-allocations.js +33 -14
- package/lib/inventory-receive.js +116 -20
- package/lib/inventory-writeoffs.js +53 -64
- package/lib/loyalty-earn-rules.js +117 -0
- package/lib/loyalty.js +79 -0
- package/lib/newsletter.js +39 -2
- package/lib/operator-accounts.js +52 -1
- package/lib/operator-audit-log.js +186 -6
- package/lib/operator-inbox.js +202 -9
- package/lib/order.js +227 -27
- package/lib/payment.js +178 -69
- package/lib/quotes.js +107 -15
- package/lib/referrals.js +71 -0
- package/lib/security-middleware.js +27 -1
- package/lib/stock-transfers.js +185 -53
- package/lib/storefront.js +1088 -129
- package/lib/translations.js +1 -0
- package/lib/webhook-receiver.js +15 -19
- package/lib/wishlist-alerts.js +37 -0
- package/package.json +1 -1
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
|
-
//
|
|
7548
|
-
//
|
|
7549
|
-
//
|
|
7550
|
-
//
|
|
7551
|
-
//
|
|
7552
|
-
function
|
|
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
|
-
|
|
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
|
-
|
|
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=\"
|
|
10162
|
-
"<
|
|
10163
|
-
"
|
|
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\">▲</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\">▲</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
|
-
|
|
14751
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|