@blamejs/blamejs-shop 0.4.23 → 0.4.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +2 -0
- package/README.md +6 -1
- package/SECURITY.md +13 -0
- package/lib/admin.js +1228 -15
- package/lib/asset-manifest.json +5 -5
- 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-audit-log.js +20 -0
- package/lib/operator-inbox.js +202 -9
- package/lib/order.js +227 -27
- 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 +979 -126
- 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.
|
|
@@ -9148,6 +9452,17 @@ function _setAuthCookie(req, res, env) {
|
|
|
9148
9452
|
var fp = _authDeviceFingerprint(req);
|
|
9149
9453
|
if (fp) sealed = Object.assign({}, env, { fp: fp });
|
|
9150
9454
|
}
|
|
9455
|
+
// Stamp the issued-at so the server-side revocation gate can tell a
|
|
9456
|
+
// cookie minted BEFORE a customer's revocation boundary (erasure /
|
|
9457
|
+
// passkey-revoke / sign-out) from one minted after. A caller that
|
|
9458
|
+
// already set `iat` keeps it; absent, fill it now. Pre-binding cookies
|
|
9459
|
+
// (minted before this shipped) carry none — the gate treats a missing
|
|
9460
|
+
// `iat` as "predates any boundary" so a revoked customer's old cookie
|
|
9461
|
+
// still dies, while a customer with no boundary is unaffected.
|
|
9462
|
+
if (sealed && sealed.iat == null) {
|
|
9463
|
+
sealed = (sealed === env) ? Object.assign({}, env) : sealed;
|
|
9464
|
+
sealed.iat = Date.now();
|
|
9465
|
+
}
|
|
9151
9466
|
_cookieJar().writeSealed(res, _authCookieName(secure), JSON.stringify(sealed), {
|
|
9152
9467
|
expires: new Date(Date.now() + T.days(14)),
|
|
9153
9468
|
secure: secure,
|
|
@@ -10229,9 +10544,13 @@ function renderProfile(opts) {
|
|
|
10229
10544
|
"<div class=\"form-row\"><label class=\"form-field\"><span class=\"form-field__label\">Display name</span>" +
|
|
10230
10545
|
"<input type=\"text\" name=\"display_name\" maxlength=\"128\" required autocomplete=\"name\" value=\"" + displayValue + "\"></label></div>" +
|
|
10231
10546
|
"<div class=\"form-row\"><label class=\"form-field\"><span class=\"form-field__label\">Email</span>" +
|
|
10232
|
-
"<input type=\"text\" value=\"
|
|
10233
|
-
"<
|
|
10234
|
-
"
|
|
10547
|
+
"<input type=\"text\" value=\"Stored as a one-way hash — never in readable form\" disabled aria-describedby=\"email-note\"></label></div>" +
|
|
10548
|
+
"<div id=\"email-note\" class=\"form-field__hint\">" +
|
|
10549
|
+
"<p>We never keep your email address in readable form. At sign-up it's run through a one-way hash so we can match you at sign-in without ever storing the address itself — there is no plaintext copy to display here or to edit.</p>" +
|
|
10550
|
+
"<p>Because of that, an email change isn't a field we can offer: keep signing in with the address you registered. " +
|
|
10551
|
+
"To move to a different address, register a new account with it. " +
|
|
10552
|
+
"If you need your past orders carried over to a new address, contact support and we'll help link them.</p>" +
|
|
10553
|
+
"</div>" +
|
|
10235
10554
|
"<div class=\"form-actions\"><button type=\"submit\" class=\"btn-primary\">Save changes</button> " +
|
|
10236
10555
|
"<a class=\"btn-ghost\" href=\"/account\">Cancel</a></div>" +
|
|
10237
10556
|
"</form>" +
|
|
@@ -10407,6 +10726,178 @@ function renderSurveyPage(opts) {
|
|
|
10407
10726
|
});
|
|
10408
10727
|
}
|
|
10409
10728
|
|
|
10729
|
+
// ---- suggestion box (public idea board) --------------------------------
|
|
10730
|
+
|
|
10731
|
+
// Customer-facing labels for the suggestion FSM statuses. The operator-side
|
|
10732
|
+
// `under_consideration` / `planned` / `shipped` / `declined` map to plain
|
|
10733
|
+
// shopper-readable copy; `open` reads as "Open" and `duplicate` rows are
|
|
10734
|
+
// never listed publicly (the primitive's listSuggestions filters them out of
|
|
10735
|
+
// the default board only by spam/archive, so the page itself skips them).
|
|
10736
|
+
var _SUGGESTION_STATUS_LABEL = {
|
|
10737
|
+
open: "Open",
|
|
10738
|
+
under_consideration: "Under review",
|
|
10739
|
+
planned: "Planned",
|
|
10740
|
+
shipped: "Shipped",
|
|
10741
|
+
declined: "Not planned",
|
|
10742
|
+
duplicate: "Merged",
|
|
10743
|
+
};
|
|
10744
|
+
|
|
10745
|
+
// Human label for a suggestion category value.
|
|
10746
|
+
var _SUGGESTION_CATEGORY_LABEL = {
|
|
10747
|
+
product_idea: "Product idea",
|
|
10748
|
+
feature_request: "Feature request",
|
|
10749
|
+
improvement: "Improvement",
|
|
10750
|
+
complaint: "Issue",
|
|
10751
|
+
general: "General",
|
|
10752
|
+
};
|
|
10753
|
+
|
|
10754
|
+
// One public suggestion row → its card markup. Every operator/customer
|
|
10755
|
+
// free-text value (title, body, response) is HTML-escaped at the sink; the
|
|
10756
|
+
// vote control is a no-JS POST form so the board works without scripts.
|
|
10757
|
+
function _suggestionCard(row, opts) {
|
|
10758
|
+
var esc = function (s) { return b.template.escapeHtml(String(s == null ? "" : s)); };
|
|
10759
|
+
var slug = esc(row.id);
|
|
10760
|
+
var statusKey = _SUGGESTION_STATUS_LABEL[row.status] ? row.status : "open";
|
|
10761
|
+
var statusLbl = _SUGGESTION_STATUS_LABEL[statusKey];
|
|
10762
|
+
var catLbl = _SUGGESTION_CATEGORY_LABEL[row.category] || "General";
|
|
10763
|
+
var voteCount = Number(row.vote_count) || 0;
|
|
10764
|
+
var response = (row.response_text && String(row.response_text).trim().length)
|
|
10765
|
+
? "<div class=\"suggestion-card__response\"><span class=\"suggestion-card__response-by\">Response from the team</span>" +
|
|
10766
|
+
"<p>" + esc(row.response_text) + "</p></div>"
|
|
10767
|
+
: "";
|
|
10768
|
+
// The vote form is only offered for a row that is still open to voting —
|
|
10769
|
+
// terminal statuses freeze the count at the primitive layer (it refuses a
|
|
10770
|
+
// vote with SUGGESTION_VOTING_CLOSED), so the page omits the control rather
|
|
10771
|
+
// than render a button that always errors.
|
|
10772
|
+
var votable = ["open", "under_consideration", "planned"].indexOf(row.status) !== -1;
|
|
10773
|
+
var voteForm = votable
|
|
10774
|
+
? "<form class=\"suggestion-card__vote\" method=\"post\" action=\"/suggestions/" + slug + "/vote\">" +
|
|
10775
|
+
"<input type=\"hidden\" name=\"vote\" value=\"upvote\">" +
|
|
10776
|
+
"<button class=\"suggestion-card__vote-btn\" type=\"submit\" aria-label=\"Upvote this suggestion\">" +
|
|
10777
|
+
"<span class=\"suggestion-card__vote-arrow\" aria-hidden=\"true\">▲</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
|
+
|
|
10410
10901
|
// ---- quote (token-gated / account-gated) -------------------------------
|
|
10411
10902
|
|
|
10412
10903
|
// One quote's status → the customer-facing label + lede shown when the quote
|
|
@@ -11556,6 +12047,68 @@ function mount(router, deps) {
|
|
|
11556
12047
|
});
|
|
11557
12048
|
}
|
|
11558
12049
|
|
|
12050
|
+
// Sidebar-widget resolution — synchronous (so its `enterWith` reaches the
|
|
12051
|
+
// page handler) and audience-bucketed off the auth-cookie presence, exactly
|
|
12052
|
+
// like the announcement bar + promo banners. Derives the page_key from the
|
|
12053
|
+
// request path (the edge cache key), resolves the placed widgets for that
|
|
12054
|
+
// page + viewer from a short-TTL in-memory cache refreshed out-of-band here
|
|
12055
|
+
// (fire-and-forget — never blocks the render), and seeds the request ALS
|
|
12056
|
+
// with the resolved rows + page_key + handle so `_wrap` can build the rail
|
|
12057
|
+
// and fire impressions. Best-effort: any failure drops the rail, never the
|
|
12058
|
+
// page. Only runs for paths that carry a sidebar (home / collection / search
|
|
12059
|
+
// / cart / product); other paths seed nothing.
|
|
12060
|
+
if (typeof router.use === "function" && deps.sidebarWidgets) {
|
|
12061
|
+
router.use(function sidebarWidgetMiddleware(req, _res, next) {
|
|
12062
|
+
try {
|
|
12063
|
+
_refreshSidebarCache(deps.sidebarWidgets);
|
|
12064
|
+
var pathname = "/";
|
|
12065
|
+
try { pathname = new URL(req.url || "/", "http://localhost").pathname || "/"; }
|
|
12066
|
+
catch (_eu) { pathname = "/"; }
|
|
12067
|
+
var pageKey = _sidebarPageKeyForPath(pathname);
|
|
12068
|
+
if (pageKey) {
|
|
12069
|
+
var viewerKind = _readPrefixedCookie(req, AUTH_COOKIE_NAME_SECURE, AUTH_COOKIE_NAME) ? "logged_in" : "guest";
|
|
12070
|
+
var rows = _resolveSidebarWidgets(pageKey, viewerKind);
|
|
12071
|
+
var cur = _localeAls.getStore() || {};
|
|
12072
|
+
_localeAls.enterWith(Object.assign({}, cur, {
|
|
12073
|
+
sidebar_widgets_rows: rows,
|
|
12074
|
+
sidebar_widgets_page_key: pageKey,
|
|
12075
|
+
sidebar_widgets_handle: deps.sidebarWidgets,
|
|
12076
|
+
}));
|
|
12077
|
+
}
|
|
12078
|
+
} catch (_e) { /* drop-silent — no rail this request */ }
|
|
12079
|
+
next();
|
|
12080
|
+
});
|
|
12081
|
+
|
|
12082
|
+
// Click pass-through for a widget's outbound link — bumps recordClick
|
|
12083
|
+
// (best-effort, drop-silent) then 303-redirects to a validated same-origin
|
|
12084
|
+
// path supplied in `?to=`. The destination is re-validated at the sink
|
|
12085
|
+
// (a /-rooted path, not protocol-relative `//`), so a hostile/stale value
|
|
12086
|
+
// falls back to "/" and can never open-redirect or 500. `page_key` carries
|
|
12087
|
+
// the page the click occurred on for the per-page CTR breakdown.
|
|
12088
|
+
router.get("/sidebar/:slug/click", async function (req, res) {
|
|
12089
|
+
var slug = (req.params && typeof req.params.slug === "string") ? req.params.slug : "";
|
|
12090
|
+
var url = null;
|
|
12091
|
+
try { url = new URL(req.url || "/", "http://localhost"); } catch (_eu) { url = null; }
|
|
12092
|
+
var toRaw = url ? url.searchParams.get("to") : null;
|
|
12093
|
+
var pageKey = url ? url.searchParams.get("page_key") : null;
|
|
12094
|
+
var to = "/";
|
|
12095
|
+
if (typeof toRaw === "string" && toRaw.charAt(0) === "/" && toRaw.charAt(1) !== "/" &&
|
|
12096
|
+
toRaw.indexOf("\\") === -1 && !/[\x00-\x1f\x7f]/.test(toRaw)) {
|
|
12097
|
+
to = toRaw;
|
|
12098
|
+
}
|
|
12099
|
+
if (/^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/.test(slug) &&
|
|
12100
|
+
typeof pageKey === "string" && _SIDEBAR_PAGE_KEYS.indexOf(pageKey) !== -1) {
|
|
12101
|
+
try {
|
|
12102
|
+
var r = deps.sidebarWidgets.recordClick({ widget_slug: slug, page_key: pageKey });
|
|
12103
|
+
if (r && typeof r.then === "function") { try { await r; } catch (_e) { /* drop-silent */ } }
|
|
12104
|
+
} catch (_e) { /* unknown slug / read error → still redirect */ }
|
|
12105
|
+
}
|
|
12106
|
+
res.status(303);
|
|
12107
|
+
res.setHeader && res.setHeader("location", to);
|
|
12108
|
+
return res.end ? res.end() : res.send("");
|
|
12109
|
+
});
|
|
12110
|
+
}
|
|
12111
|
+
|
|
11559
12112
|
// ---- customer survey (token-gated) ----------------------------------
|
|
11560
12113
|
// The invitation token IS the access — no login. GET renders the survey
|
|
11561
12114
|
// (or a state notice); POST records the response. Container-only (the
|
|
@@ -11643,6 +12196,118 @@ function mount(router, deps) {
|
|
|
11643
12196
|
});
|
|
11644
12197
|
}
|
|
11645
12198
|
|
|
12199
|
+
// ---- suggestion box (public idea board) -----------------------------
|
|
12200
|
+
// The customer-driven "tell us what to build" loop. GET renders the
|
|
12201
|
+
// submit form + the browsable, upvotable board; POST submits a new idea;
|
|
12202
|
+
// POST /:id/vote records an upvote. Container-only — the page carries a
|
|
12203
|
+
// per-session `_csrf` token (it is NOT an EDGE_POST_PATHS prefix, so
|
|
12204
|
+
// `_injectCsrfFields` tokens both forms) and is never edge-cached.
|
|
12205
|
+
// Anonymous: no login is required to submit or vote (an optional email is
|
|
12206
|
+
// hashed at the primitive boundary, never stored raw). The submit + vote
|
|
12207
|
+
// POSTs ride the /suggestions tight rate-limit budget. Resilient: a bad
|
|
12208
|
+
// submit re-renders the form with a cleaned notice; an unknown vote target
|
|
12209
|
+
// or a closed suggestion degrades gracefully, never a 500.
|
|
12210
|
+
if (deps.suggestionBox) {
|
|
12211
|
+
var _suggestionCtx = function (_req) {
|
|
12212
|
+
return {
|
|
12213
|
+
shop_name: (deps.config && deps.config.shop_name) || "blamejs.shop",
|
|
12214
|
+
cart_count: 0,
|
|
12215
|
+
theme_css: (deps.theme && deps.theme.assetUrl) ? deps.theme.assetUrl("css/main.css") : DEFAULT_THEME_CSS_URL,
|
|
12216
|
+
};
|
|
12217
|
+
};
|
|
12218
|
+
|
|
12219
|
+
// Page size for the public board. The primitive caps at MAX_LIST_LIMIT;
|
|
12220
|
+
// a single screen of cards keeps the first paint light and pages the rest
|
|
12221
|
+
// through the opaque cursor.
|
|
12222
|
+
var _SUGGESTION_PAGE_SIZE = 20;
|
|
12223
|
+
|
|
12224
|
+
// Load a board page for a sort + optional cursor. Drop-silent to an empty
|
|
12225
|
+
// board on any read error (an unmigrated table, a malformed cursor) so the
|
|
12226
|
+
// page renders rather than 500-ing.
|
|
12227
|
+
var _loadSuggestionBoard = async function (sort, cursor) {
|
|
12228
|
+
try {
|
|
12229
|
+
var listOpts = { sort: sort, limit: _SUGGESTION_PAGE_SIZE };
|
|
12230
|
+
if (cursor) listOpts.cursor = cursor;
|
|
12231
|
+
var page = await deps.suggestionBox.listSuggestions(listOpts);
|
|
12232
|
+
return { rows: page.rows || [], next_cursor: page.next_cursor || null, sort: page.sort || sort };
|
|
12233
|
+
} catch (_e) {
|
|
12234
|
+
return { rows: [], next_cursor: null, sort: sort };
|
|
12235
|
+
}
|
|
12236
|
+
};
|
|
12237
|
+
|
|
12238
|
+
router.get("/suggestions", async function (req, res) {
|
|
12239
|
+
var ctx = _suggestionCtx(req);
|
|
12240
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
12241
|
+
var sort = (url && url.searchParams.get("sort") === "top_voted") ? "top_voted" : "newest";
|
|
12242
|
+
var cursor = url ? url.searchParams.get("cursor") : null;
|
|
12243
|
+
var board = await _loadSuggestionBoard(sort, cursor);
|
|
12244
|
+
_send(res, 200, renderSuggestionsPage({
|
|
12245
|
+
suggestions: board.rows,
|
|
12246
|
+
sort: board.sort,
|
|
12247
|
+
next_cursor: board.next_cursor,
|
|
12248
|
+
submitted: url && url.searchParams.get("submitted") === "1",
|
|
12249
|
+
voted: url && url.searchParams.get("voted") === "1",
|
|
12250
|
+
shop_name: ctx.shop_name, cart_count: ctx.cart_count, theme_css: ctx.theme_css,
|
|
12251
|
+
}));
|
|
12252
|
+
});
|
|
12253
|
+
|
|
12254
|
+
router.post("/suggestions", async function (req, res) {
|
|
12255
|
+
var ctx = _suggestionCtx(req);
|
|
12256
|
+
var body = req.body || {};
|
|
12257
|
+
// Coerce the form into the submit shape: trimmed title/body, the
|
|
12258
|
+
// category select, and an optional email (blank → omitted, so an
|
|
12259
|
+
// anonymous submission carries no identity). The primitive validates
|
|
12260
|
+
// length / control bytes / category and throws a TypeError on a bad
|
|
12261
|
+
// shape, which we degrade to a re-render of the form with the cleaned
|
|
12262
|
+
// message + the operator's typed values kept sticky.
|
|
12263
|
+
var input = {
|
|
12264
|
+
title: typeof body.title === "string" ? body.title : "",
|
|
12265
|
+
body: typeof body.body === "string" ? body.body : "",
|
|
12266
|
+
category: typeof body.category === "string" && body.category ? body.category : "general",
|
|
12267
|
+
};
|
|
12268
|
+
var emailRaw = typeof body.customer_email === "string" ? body.customer_email.trim() : "";
|
|
12269
|
+
if (emailRaw) input.customer_email = emailRaw;
|
|
12270
|
+
try {
|
|
12271
|
+
await deps.suggestionBox.submitSuggestion(input);
|
|
12272
|
+
} catch (e) {
|
|
12273
|
+
if (!(e instanceof TypeError)) throw e;
|
|
12274
|
+
var board = await _loadSuggestionBoard("newest", null);
|
|
12275
|
+
return _send(res, 400, renderSuggestionsPage({
|
|
12276
|
+
suggestions: board.rows, sort: board.sort, next_cursor: board.next_cursor,
|
|
12277
|
+
notice: (e.message || "Please check your suggestion.").replace(/^suggestionBox[.:]\s*/, ""),
|
|
12278
|
+
form: { title: input.title, body: input.body, category: input.category, customer_email: emailRaw },
|
|
12279
|
+
shop_name: ctx.shop_name, cart_count: ctx.cart_count, theme_css: ctx.theme_css,
|
|
12280
|
+
}));
|
|
12281
|
+
}
|
|
12282
|
+
res.status(303);
|
|
12283
|
+
res.setHeader && res.setHeader("location", "/suggestions?submitted=1");
|
|
12284
|
+
return res.end ? res.end() : res.send("");
|
|
12285
|
+
});
|
|
12286
|
+
|
|
12287
|
+
router.post("/suggestions/:id/vote", async function (req, res) {
|
|
12288
|
+
var id = (req.params && typeof req.params.id === "string") ? req.params.id : "";
|
|
12289
|
+
// The vote is keyed on the cart session id so a repeat vote from the
|
|
12290
|
+
// same browser session collapses to a no-op at the storage UNIQUE. A
|
|
12291
|
+
// visitor with no session yet can't be deduped, so we require one — a
|
|
12292
|
+
// session-less POST simply lands back on the board (the primitive needs
|
|
12293
|
+
// a session_id and would throw a TypeError otherwise). The single
|
|
12294
|
+
// supported direction is "upvote" (the board ranks by demand).
|
|
12295
|
+
var sid = _readSidCookie(req);
|
|
12296
|
+
if (sid) {
|
|
12297
|
+
try {
|
|
12298
|
+
await deps.suggestionBox.voteOnSuggestion({ suggestion_id: id, session_id: sid, vote: "upvote" });
|
|
12299
|
+
} catch (_e) {
|
|
12300
|
+
// Unknown id / closed voting / already voted — drop-silent. The
|
|
12301
|
+
// board re-render reflects the live count; a hostile or stale id
|
|
12302
|
+
// can never 500 the route.
|
|
12303
|
+
}
|
|
12304
|
+
}
|
|
12305
|
+
res.status(303);
|
|
12306
|
+
res.setHeader && res.setHeader("location", "/suggestions?voted=1");
|
|
12307
|
+
return res.end ? res.end() : res.send("");
|
|
12308
|
+
});
|
|
12309
|
+
}
|
|
12310
|
+
|
|
11646
12311
|
// ---- quote (token-gated customer page) ------------------------------
|
|
11647
12312
|
// A request-for-quote a customer can review + accept / decline via a single
|
|
11648
12313
|
// capability link (/quote/:token) — no login. The token IS the access (the
|
|
@@ -12006,6 +12671,33 @@ function mount(router, deps) {
|
|
|
12006
12671
|
return env;
|
|
12007
12672
|
}
|
|
12008
12673
|
|
|
12674
|
+
// Server-side session-revocation gate. The sealed cookie is otherwise
|
|
12675
|
+
// self-validating for its 14-day TTL, so erasure / passkey-revoke /
|
|
12676
|
+
// sign-out have no way to kill a LIVE cookie without this check. Resolves
|
|
12677
|
+
// the customer's revocation boundary (sessions_valid_from) and reports
|
|
12678
|
+
// whether THIS envelope is revoked:
|
|
12679
|
+
//
|
|
12680
|
+
// * no boundary (0) → never revoked, live (the default; a
|
|
12681
|
+
// deploy adding the table signs no-one out)
|
|
12682
|
+
// * boundary set, cookie iat → revoked iff iat < boundary
|
|
12683
|
+
// * boundary set, no iat → revoked (a pre-`iat` cookie can't prove
|
|
12684
|
+
// it postdates the boundary)
|
|
12685
|
+
//
|
|
12686
|
+
// Best-effort fail-OPEN on a lookup error: a transient D1 blip must not
|
|
12687
|
+
// lock every signed-in customer out of their account. The durable
|
|
12688
|
+
// credential deletion in erasure is the backstop — a fail-open window
|
|
12689
|
+
// can't re-mint a session, only briefly extend an already-issued one.
|
|
12690
|
+
async function _sessionRevoked(env) {
|
|
12691
|
+
if (!env || !env.customer_id) return false;
|
|
12692
|
+
if (!deps.customers || typeof deps.customers.sessionsValidFrom !== "function") return false;
|
|
12693
|
+
var boundary;
|
|
12694
|
+
try { boundary = await deps.customers.sessionsValidFrom(env.customer_id); }
|
|
12695
|
+
catch (_e) { return false; }
|
|
12696
|
+
if (!boundary || boundary <= 0) return false;
|
|
12697
|
+
if (typeof env.iat !== "number") return true;
|
|
12698
|
+
return env.iat < boundary;
|
|
12699
|
+
}
|
|
12700
|
+
|
|
12009
12701
|
// The operator's order-access signing key (derived in server.js from the
|
|
12010
12702
|
// app secret, domain-separated). Absent it, the emailed-token access path
|
|
12011
12703
|
// is inert — the placing-browser cookie and signed-in-owner paths still
|
|
@@ -14600,11 +15292,113 @@ function mount(router, deps) {
|
|
|
14600
15292
|
cancelled: ordUrl ? ordUrl.searchParams.get("cancelled") === "1" : false,
|
|
14601
15293
|
claim_offer: claimOffer,
|
|
14602
15294
|
claim_notice: claimNotice === "sent" ? "sent" : null,
|
|
15295
|
+
// Carry a guest order's emailed access token onto the receipt-download
|
|
15296
|
+
// link so a viewer on a fresh device (cookie not yet stamped) can pull
|
|
15297
|
+
// the receipt. Only forwarded for a guest order opened via ?k= — an
|
|
15298
|
+
// owned order's receipt is gated by the session, so no token is needed
|
|
15299
|
+
// and none is leaked into the page for a signed-in owner.
|
|
15300
|
+
access_token: (!o.customer_id && ordAccessToken) ? ordAccessToken : "",
|
|
14603
15301
|
shop_name: shopName,
|
|
14604
15302
|
theme: theme,
|
|
14605
15303
|
}));
|
|
14606
15304
|
});
|
|
14607
15305
|
|
|
15306
|
+
// GET /orders/:order_id/receipt — stream a downloadable HTML receipt for
|
|
15307
|
+
// an order the requester is allowed to read. Mounts only when the
|
|
15308
|
+
// printReceipts primitive is wired; absent it the order page renders no
|
|
15309
|
+
// download link and this route is never reached. Access is the SAME gate
|
|
15310
|
+
// as GET /orders/:id — an owned order is downloadable only by its
|
|
15311
|
+
// signed-in owner; a guest order needs a capability proof (placing-browser
|
|
15312
|
+
// cookie, emailed ?k= token, or claim cookie). Anything else 404s,
|
|
15313
|
+
// indistinguishable from a missing order, so a stranger can't pull a
|
|
15314
|
+
// receipt (name / address / line items) by guessing an id.
|
|
15315
|
+
//
|
|
15316
|
+
// The document is written to the socket as the render source yields it —
|
|
15317
|
+
// header first, then one chunk per batch via res.write — so the response
|
|
15318
|
+
// never buffers the whole receipt in memory. printReceipts.htmlPdf returns
|
|
15319
|
+
// a complete self-contained HTML document today; _streamReceiptDocument
|
|
15320
|
+
// slices it through an async-iterable so a future streaming receipt source
|
|
15321
|
+
// (a bulk multi-page document) drops into the same bounded-memory loop.
|
|
15322
|
+
if (deps.printReceipts) {
|
|
15323
|
+
router.get("/orders/:order_id/receipt", async function (req, res) {
|
|
15324
|
+
var orderId = req.params && req.params.order_id;
|
|
15325
|
+
var o;
|
|
15326
|
+
// A malformed / unknown id is a missing receipt, not a server fault —
|
|
15327
|
+
// map the TypeError order.get throws on a non-UUID id to the same 404
|
|
15328
|
+
// path a well-formed-but-unknown id takes (mirrors the order page).
|
|
15329
|
+
try { o = orderId ? await deps.order.get(orderId) : null; }
|
|
15330
|
+
catch (e) { if (e instanceof TypeError) { o = null; } else throw e; }
|
|
15331
|
+
if (!o) return _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme }));
|
|
15332
|
+
var rcptAuth = _currentCustomerEnv(req);
|
|
15333
|
+
var rcptToken = (req.query && typeof req.query.k === "string") ? req.query.k : "";
|
|
15334
|
+
if (!_orderAccessGranted(req, o, rcptAuth, rcptToken)) {
|
|
15335
|
+
return _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme }));
|
|
15336
|
+
}
|
|
15337
|
+
// A receipt only exists once the order is paid for — a still-pending
|
|
15338
|
+
// (unpaid) order has nothing to receipt. Bounce to the order page
|
|
15339
|
+
// rather than render an empty/misleading document.
|
|
15340
|
+
if (!_orderEligibleForReceipt(o.status)) {
|
|
15341
|
+
res.status(303);
|
|
15342
|
+
res.setHeader && res.setHeader("location", "/orders/" + encodeURIComponent(o.id));
|
|
15343
|
+
return res.end ? res.end() : res.send("");
|
|
15344
|
+
}
|
|
15345
|
+
// A guest order opened from the emailed link carries the proof in ?k=;
|
|
15346
|
+
// stamp the device cookie now (idempotent) so a later receipt pull
|
|
15347
|
+
// without the param keeps resolving. Drop-silent: a stamp failure must
|
|
15348
|
+
// never break the download.
|
|
15349
|
+
if (!o.customer_id && rcptToken && !_hasGuestOrderAccessCookie(req, o.id)) {
|
|
15350
|
+
try { _grantGuestOrderAccess(req, res, o.id); } catch (_eGrant) { /* best-effort */ }
|
|
15351
|
+
}
|
|
15352
|
+
// Render the document source. A coded conflict (an order that raced out
|
|
15353
|
+
// of a receiptable state) maps to 409, a not-found shape to 404, BOTH
|
|
15354
|
+
// checked BEFORE the TypeError→400 fallback so a coded error isn't
|
|
15355
|
+
// swallowed as a generic bad-request. Any other failure is a 500.
|
|
15356
|
+
var locale = (req.query && typeof req.query.locale === "string") ? req.query.locale : undefined;
|
|
15357
|
+
var doc;
|
|
15358
|
+
try {
|
|
15359
|
+
doc = await deps.printReceipts.htmlPdf({ order_id: o.id, locale: locale });
|
|
15360
|
+
} catch (e) {
|
|
15361
|
+
var code = (e && typeof e.code === "string") ? e.code : "";
|
|
15362
|
+
if (code === "CONFLICT" || code === "ORDER_TRANSITION_REFUSED") {
|
|
15363
|
+
return _send(res, 409, renderNotFound({ shop_name: shopName, theme: theme }));
|
|
15364
|
+
}
|
|
15365
|
+
if (code === "NOT_FOUND") {
|
|
15366
|
+
return _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme }));
|
|
15367
|
+
}
|
|
15368
|
+
if (e instanceof TypeError) {
|
|
15369
|
+
return _send(res, 400, renderNotFound({ shop_name: shopName, theme: theme }));
|
|
15370
|
+
}
|
|
15371
|
+
throw e;
|
|
15372
|
+
}
|
|
15373
|
+
// Header first, then stream the document body per chunk. The filename
|
|
15374
|
+
// is built from the validated UUID order id alone (hex + hyphens),
|
|
15375
|
+
// carrying no quote/CRLF that could break out of the header.
|
|
15376
|
+
var safeId = String(o.id).replace(/[^A-Za-z0-9._-]/g, "");
|
|
15377
|
+
res.status(200);
|
|
15378
|
+
if (res.setHeader) {
|
|
15379
|
+
res.setHeader("content-type", "text/html; charset=utf-8");
|
|
15380
|
+
res.setHeader("content-disposition", "attachment; filename=\"receipt-" + safeId + ".html\"");
|
|
15381
|
+
res.setHeader("x-content-type-options", "nosniff");
|
|
15382
|
+
// The receipt carries the buyer's name + address + line items —
|
|
15383
|
+
// keep it out of any shared/proxy cache.
|
|
15384
|
+
res.setHeader("cache-control", "no-store");
|
|
15385
|
+
}
|
|
15386
|
+
// One chunk per batch as the source yields it — bounded memory
|
|
15387
|
+
// regardless of document size. A response without an incremental
|
|
15388
|
+
// write() (a JSON test stub) falls back to buffering.
|
|
15389
|
+
if (typeof res.write === "function" && typeof res.end === "function") {
|
|
15390
|
+
for await (var chunk of _streamReceiptDocument(doc)) {
|
|
15391
|
+
if (chunk) res.write(chunk);
|
|
15392
|
+
}
|
|
15393
|
+
res.end();
|
|
15394
|
+
} else {
|
|
15395
|
+
var buffered = "";
|
|
15396
|
+
for await (var c2 of _streamReceiptDocument(doc)) buffered += c2;
|
|
15397
|
+
if (res.end) res.end(buffered); else res.send(buffered);
|
|
15398
|
+
}
|
|
15399
|
+
});
|
|
15400
|
+
}
|
|
15401
|
+
|
|
14608
15402
|
// POST /orders/:order_id/claim-account — the one-click "save your details
|
|
14609
15403
|
// / create an account" trigger from the confirmation page. Mounts only
|
|
14610
15404
|
// when the magic-link surface is wired (customerPortal + a mailer); absent
|
|
@@ -14837,8 +15631,22 @@ function mount(router, deps) {
|
|
|
14837
15631
|
return b.crypto.toBase64Url(buf);
|
|
14838
15632
|
}
|
|
14839
15633
|
|
|
14840
|
-
|
|
14841
|
-
|
|
15634
|
+
// Resolve the signed-in customer from the sealed cookie AND enforce
|
|
15635
|
+
// server-side revocation: a cookie that passes the stateless checks but
|
|
15636
|
+
// belongs to an erased / re-secured / signed-out session (its `iat`
|
|
15637
|
+
// predates the customer's revocation boundary) is dead. Returning null
|
|
15638
|
+
// here — and stamping `req._sessionRevoked` so the auth gate can show the
|
|
15639
|
+
// revoked notice — centralizes revocation across EVERY authenticated read,
|
|
15640
|
+
// not just the handlers that route through `_accountAuth`. Async because
|
|
15641
|
+
// the boundary is a per-customer DB read (a no-op for anonymous requests,
|
|
15642
|
+
// which short-circuit before any query).
|
|
15643
|
+
async function _currentCustomer(req) {
|
|
15644
|
+
var env = _currentCustomerEnv(req);
|
|
15645
|
+
if (env && await _sessionRevoked(env)) {
|
|
15646
|
+
if (req) req._sessionRevoked = true;
|
|
15647
|
+
return null;
|
|
15648
|
+
}
|
|
15649
|
+
return env;
|
|
14842
15650
|
}
|
|
14843
15651
|
|
|
14844
15652
|
function _serviceUnavailable(res, msg) {
|
|
@@ -14889,7 +15697,7 @@ function mount(router, deps) {
|
|
|
14889
15697
|
// send them to their account instead of re-rendering a login form
|
|
14890
15698
|
// (mirrors the /account guard's auth read + vault-not-configured catch).
|
|
14891
15699
|
var signedIn;
|
|
14892
|
-
try { signedIn = _currentCustomer(req); }
|
|
15700
|
+
try { signedIn = await _currentCustomer(req); }
|
|
14893
15701
|
catch (e) {
|
|
14894
15702
|
if (e && e.code === "vault/not-initialized") return _serviceUnavailable(res, "auth not configured");
|
|
14895
15703
|
throw e;
|
|
@@ -15335,7 +16143,7 @@ function mount(router, deps) {
|
|
|
15335
16143
|
|
|
15336
16144
|
router.get("/account", async function (req, res) {
|
|
15337
16145
|
var auth;
|
|
15338
|
-
try { auth = _currentCustomer(req); }
|
|
16146
|
+
try { auth = await _currentCustomer(req); }
|
|
15339
16147
|
catch (e) {
|
|
15340
16148
|
if (e && e.code === "vault/not-initialized") return _serviceUnavailable(res, "auth not configured");
|
|
15341
16149
|
throw e;
|
|
@@ -15422,7 +16230,7 @@ function mount(router, deps) {
|
|
|
15422
16230
|
if (deps.order) {
|
|
15423
16231
|
router.get("/account/orders", async function (req, res) {
|
|
15424
16232
|
var ordersAuth;
|
|
15425
|
-
try { ordersAuth = _currentCustomer(req); }
|
|
16233
|
+
try { ordersAuth = await _currentCustomer(req); }
|
|
15426
16234
|
catch (e) {
|
|
15427
16235
|
if (e && e.code === "vault/not-initialized") return _serviceUnavailable(res, "auth not configured");
|
|
15428
16236
|
throw e;
|
|
@@ -15479,7 +16287,7 @@ function mount(router, deps) {
|
|
|
15479
16287
|
}
|
|
15480
16288
|
|
|
15481
16289
|
router.get("/account/quotes", async function (req, res) {
|
|
15482
|
-
var auth = _accountAuth(req, res);
|
|
16290
|
+
var auth = await _accountAuth(req, res);
|
|
15483
16291
|
if (!auth) return;
|
|
15484
16292
|
var rows = [];
|
|
15485
16293
|
try { rows = await deps.quotes.quotesForCustomer(auth.customer_id, { limit: 100 }); }
|
|
@@ -15491,7 +16299,7 @@ function mount(router, deps) {
|
|
|
15491
16299
|
});
|
|
15492
16300
|
|
|
15493
16301
|
router.get("/account/quotes/:id", async function (req, res) {
|
|
15494
|
-
var auth = _accountAuth(req, res);
|
|
16302
|
+
var auth = await _accountAuth(req, res);
|
|
15495
16303
|
if (!auth) return;
|
|
15496
16304
|
var q = await _ownedQuote(auth.customer_id, req.params && req.params.id);
|
|
15497
16305
|
var count = await _cartCountForReq(req);
|
|
@@ -15513,7 +16321,7 @@ function mount(router, deps) {
|
|
|
15513
16321
|
// machinery is wired, so the holds land at acceptance.
|
|
15514
16322
|
var _accountQuoteAction = function (action) {
|
|
15515
16323
|
return async function (req, res) {
|
|
15516
|
-
var auth = _accountAuth(req, res);
|
|
16324
|
+
var auth = await _accountAuth(req, res);
|
|
15517
16325
|
if (!auth) return;
|
|
15518
16326
|
var q = await _ownedQuote(auth.customer_id, req.params && req.params.id);
|
|
15519
16327
|
var count = await _cartCountForReq(req);
|
|
@@ -15551,7 +16359,7 @@ function mount(router, deps) {
|
|
|
15551
16359
|
// quote's account page. An empty / missing cart re-renders the cart
|
|
15552
16360
|
// with a notice. The optional `message` field rides along.
|
|
15553
16361
|
router.post("/account/quotes/request", async function (req, res) {
|
|
15554
|
-
var auth = _accountAuth(req, res);
|
|
16362
|
+
var auth = await _accountAuth(req, res);
|
|
15555
16363
|
if (!auth) return;
|
|
15556
16364
|
var sid = _readSidCookie(req);
|
|
15557
16365
|
var cart = sid ? await deps.cart.bySession(sid) : null;
|
|
@@ -15596,13 +16404,26 @@ function mount(router, deps) {
|
|
|
15596
16404
|
// registration page drives, but bound to the ALREADY-authed customer
|
|
15597
16405
|
// (no email form), so a new credential always lands on the right
|
|
15598
16406
|
// account.
|
|
15599
|
-
function _accountAuth(req, res) {
|
|
16407
|
+
async function _accountAuth(req, res) {
|
|
15600
16408
|
var auth;
|
|
15601
|
-
try { auth = _currentCustomer(req); }
|
|
16409
|
+
try { auth = await _currentCustomer(req); }
|
|
15602
16410
|
catch (e) {
|
|
15603
16411
|
if (e && e.code === "vault/not-initialized") { _serviceUnavailable(res, "auth not configured"); return null; }
|
|
15604
16412
|
throw e;
|
|
15605
16413
|
}
|
|
16414
|
+
// Server-side revocation: a cookie that passes the stateless checks but
|
|
16415
|
+
// belongs to an erased / re-secured / signed-out session is dead.
|
|
16416
|
+
// `_currentCustomer` already enforces this for every authenticated read —
|
|
16417
|
+
// it returns null and stamps `req._sessionRevoked`. Here we turn that into
|
|
16418
|
+
// the soft sign-out (clear the cookie + bounce to a revoked notice) rather
|
|
16419
|
+
// than the generic not-signed-in redirect.
|
|
16420
|
+
if (req && req._sessionRevoked) {
|
|
16421
|
+
_clearAuthCookie(req, res);
|
|
16422
|
+
res.status(303);
|
|
16423
|
+
res.setHeader && res.setHeader("location", "/account/login?signed_out=revoked");
|
|
16424
|
+
res.end ? res.end() : res.send("");
|
|
16425
|
+
return null;
|
|
16426
|
+
}
|
|
15606
16427
|
if (!auth) {
|
|
15607
16428
|
// Device-binding drift: clear the now-stale cookie and bounce to a
|
|
15608
16429
|
// neutral sign-in notice (never a hard 401 mid-page). Any other
|
|
@@ -15671,7 +16492,7 @@ function mount(router, deps) {
|
|
|
15671
16492
|
}
|
|
15672
16493
|
|
|
15673
16494
|
router.get("/account/passkeys", async function (req, res) {
|
|
15674
|
-
var auth = _accountAuth(req, res); if (!auth) return;
|
|
16495
|
+
var auth = await _accountAuth(req, res); if (!auth) return;
|
|
15675
16496
|
await _renderPasskeysPage(req, res, auth, null);
|
|
15676
16497
|
});
|
|
15677
16498
|
|
|
@@ -15679,7 +16500,7 @@ function mount(router, deps) {
|
|
|
15679
16500
|
// server-rendered confirm page; the POST that actually revokes lives
|
|
15680
16501
|
// behind it.
|
|
15681
16502
|
router.get("/account/passkeys/:id/remove", async function (req, res) {
|
|
15682
|
-
var auth = _accountAuth(req, res); if (!auth) return;
|
|
16503
|
+
var auth = await _accountAuth(req, res); if (!auth) return;
|
|
15683
16504
|
var pk = await _ownedPasskey(req, res, auth); if (!pk) return;
|
|
15684
16505
|
var cartCount = await _cartCountForReq(req);
|
|
15685
16506
|
_send(res, 200, renderPasskeyRemoveConfirm({
|
|
@@ -15690,7 +16511,7 @@ function mount(router, deps) {
|
|
|
15690
16511
|
});
|
|
15691
16512
|
|
|
15692
16513
|
router.post("/account/passkeys/:id/revoke", async function (req, res) {
|
|
15693
|
-
var auth = _accountAuth(req, res); if (!auth) return;
|
|
16514
|
+
var auth = await _accountAuth(req, res); if (!auth) return;
|
|
15694
16515
|
var pk = await _ownedPasskey(req, res, auth); if (!pk) return;
|
|
15695
16516
|
// Last-credential guard: refuse to remove the only sign-in method
|
|
15696
16517
|
// when there's no federated fallback — surface a clear notice rather
|
|
@@ -15719,7 +16540,7 @@ function mount(router, deps) {
|
|
|
15719
16540
|
// on both so an add-finish can't be replayed against a register/login
|
|
15720
16541
|
// challenge.
|
|
15721
16542
|
router.post("/account/passkey/add-begin", async function (req, res) {
|
|
15722
|
-
var auth = _accountAuth(req, res); if (!auth) return;
|
|
16543
|
+
var auth = await _accountAuth(req, res); if (!auth) return;
|
|
15723
16544
|
try {
|
|
15724
16545
|
var customer = await deps.customers.get(auth.customer_id);
|
|
15725
16546
|
if (!customer) { res.status(401); return res.end ? res.end("unknown customer") : res.send("unknown customer"); }
|
|
@@ -15761,7 +16582,7 @@ function mount(router, deps) {
|
|
|
15761
16582
|
});
|
|
15762
16583
|
|
|
15763
16584
|
router.post("/account/passkey/add-finish", async function (req, res) {
|
|
15764
|
-
var auth = _accountAuth(req, res); if (!auth) return;
|
|
16585
|
+
var auth = await _accountAuth(req, res); if (!auth) return;
|
|
15765
16586
|
try {
|
|
15766
16587
|
var env = _readChallengeEnv(req);
|
|
15767
16588
|
if (!env) { res.status(400); return res.end ? res.end("missing challenge") : res.send("missing challenge"); }
|
|
@@ -15873,7 +16694,7 @@ function mount(router, deps) {
|
|
|
15873
16694
|
}
|
|
15874
16695
|
|
|
15875
16696
|
router.get("/account/payment-methods", async function (req, res) {
|
|
15876
|
-
var auth = _accountAuth(req, res); if (!auth) return;
|
|
16697
|
+
var auth = await _accountAuth(req, res); if (!auth) return;
|
|
15877
16698
|
await _renderPaymentMethodsPage(req, res, auth, null);
|
|
15878
16699
|
});
|
|
15879
16700
|
|
|
@@ -15883,7 +16704,7 @@ function mount(router, deps) {
|
|
|
15883
16704
|
// script). Requires the publishable key; absent it, a 503 like the pay
|
|
15884
16705
|
// page.
|
|
15885
16706
|
router.get("/account/payment-methods/add", async function (req, res) {
|
|
15886
|
-
var auth = _accountAuth(req, res); if (!auth) return;
|
|
16707
|
+
var auth = await _accountAuth(req, res); if (!auth) return;
|
|
15887
16708
|
var pk = deps.stripe_publishable_key || "";
|
|
15888
16709
|
if (!pk) {
|
|
15889
16710
|
return _send(res, 503, _wrap({
|
|
@@ -15910,7 +16731,7 @@ function mount(router, deps) {
|
|
|
15910
16731
|
// the customers table; Stripe dedupes by metadata/email) — acceptable
|
|
15911
16732
|
// v1, documented in the build spec.
|
|
15912
16733
|
router.post("/account/payment-methods/setup-intent", async function (req, res) {
|
|
15913
|
-
var auth = _accountAuth(req, res); if (!auth) return;
|
|
16734
|
+
var auth = await _accountAuth(req, res); if (!auth) return;
|
|
15914
16735
|
function _json(status, obj) {
|
|
15915
16736
|
res.status(status);
|
|
15916
16737
|
res.setHeader && res.setHeader("content-type", "application/json; charset=utf-8");
|
|
@@ -15933,7 +16754,7 @@ function mount(router, deps) {
|
|
|
15933
16754
|
// pm_… → its display fields → stores via paymentMethods.add. A
|
|
15934
16755
|
// duplicate token (already on file) is an idempotent notice, not a 500.
|
|
15935
16756
|
router.post("/account/payment-methods", async function (req, res) {
|
|
15936
|
-
var auth = _accountAuth(req, res); if (!auth) return;
|
|
16757
|
+
var auth = await _accountAuth(req, res); if (!auth) return;
|
|
15937
16758
|
var body = req.body || {};
|
|
15938
16759
|
var setupIntentId = typeof body.setup_intent_id === "string" ? body.setup_intent_id : "";
|
|
15939
16760
|
if (!setupIntentId) return _renderPaymentMethodsPage(req, res, auth, "Couldn't add that card — please try again.", 400);
|
|
@@ -15968,7 +16789,7 @@ function mount(router, deps) {
|
|
|
15968
16789
|
});
|
|
15969
16790
|
|
|
15970
16791
|
router.post("/account/payment-methods/:id/default", async function (req, res) {
|
|
15971
|
-
var auth = _accountAuth(req, res); if (!auth) return;
|
|
16792
|
+
var auth = await _accountAuth(req, res); if (!auth) return;
|
|
15972
16793
|
var pm = await _ownedPaymentMethod(req, res, auth); if (!pm) return;
|
|
15973
16794
|
try { await deps.paymentMethods.setDefault(pm.id); }
|
|
15974
16795
|
catch (e) {
|
|
@@ -15982,7 +16803,7 @@ function mount(router, deps) {
|
|
|
15982
16803
|
});
|
|
15983
16804
|
|
|
15984
16805
|
router.post("/account/payment-methods/:id/archive", async function (req, res) {
|
|
15985
|
-
var auth = _accountAuth(req, res); if (!auth) return;
|
|
16806
|
+
var auth = await _accountAuth(req, res); if (!auth) return;
|
|
15986
16807
|
var pm = await _ownedPaymentMethod(req, res, auth); if (!pm) return;
|
|
15987
16808
|
try { await deps.paymentMethods.archive({ payment_method_id: pm.id, reason: "customer_request" }); }
|
|
15988
16809
|
catch (e) {
|
|
@@ -16015,7 +16836,7 @@ function mount(router, deps) {
|
|
|
16015
16836
|
}
|
|
16016
16837
|
|
|
16017
16838
|
router.get("/account/profile", async function (req, res) {
|
|
16018
|
-
var auth = _accountAuth(req, res); if (!auth) return;
|
|
16839
|
+
var auth = await _accountAuth(req, res); if (!auth) return;
|
|
16019
16840
|
var customer = await deps.customers.get(auth.customer_id);
|
|
16020
16841
|
if (!customer) {
|
|
16021
16842
|
_clearAuthCookie(req, res);
|
|
@@ -16026,7 +16847,7 @@ function mount(router, deps) {
|
|
|
16026
16847
|
});
|
|
16027
16848
|
|
|
16028
16849
|
router.post("/account/profile", async function (req, res) {
|
|
16029
|
-
var auth = _accountAuth(req, res); if (!auth) return;
|
|
16850
|
+
var auth = await _accountAuth(req, res); if (!auth) return;
|
|
16030
16851
|
var customer = await deps.customers.get(auth.customer_id);
|
|
16031
16852
|
if (!customer) {
|
|
16032
16853
|
_clearAuthCookie(req, res);
|
|
@@ -16269,7 +17090,7 @@ function mount(router, deps) {
|
|
|
16269
17090
|
// a forged slug can't drive an open redirect).
|
|
16270
17091
|
router.post("/wishlist/toggle", async function (req, res) {
|
|
16271
17092
|
var auth;
|
|
16272
|
-
try { auth = _currentCustomer(req); }
|
|
17093
|
+
try { auth = await _currentCustomer(req); }
|
|
16273
17094
|
catch (e) {
|
|
16274
17095
|
if (e && e.code === "vault/not-initialized") return _serviceUnavailable(res, "auth not configured");
|
|
16275
17096
|
throw e;
|
|
@@ -16309,7 +17130,7 @@ function mount(router, deps) {
|
|
|
16309
17130
|
// archived renders as "unavailable" (the row is orphan-tolerant).
|
|
16310
17131
|
router.get("/account/wishlist", async function (req, res) {
|
|
16311
17132
|
var auth;
|
|
16312
|
-
try { auth = _currentCustomer(req); }
|
|
17133
|
+
try { auth = await _currentCustomer(req); }
|
|
16313
17134
|
catch (e) {
|
|
16314
17135
|
if (e && e.code === "vault/not-initialized") return _serviceUnavailable(res, "auth not configured");
|
|
16315
17136
|
throw e;
|
|
@@ -16425,7 +17246,7 @@ function mount(router, deps) {
|
|
|
16425
17246
|
if (deps.wishlistAlerts) {
|
|
16426
17247
|
router.post("/account/wishlist/alerts", async function (req, res) {
|
|
16427
17248
|
var auth;
|
|
16428
|
-
try { auth = _currentCustomer(req); }
|
|
17249
|
+
try { auth = await _currentCustomer(req); }
|
|
16429
17250
|
catch (e) {
|
|
16430
17251
|
if (e && e.code === "vault/not-initialized") return _serviceUnavailable(res, "auth not configured");
|
|
16431
17252
|
throw e;
|
|
@@ -16459,7 +17280,7 @@ function mount(router, deps) {
|
|
|
16459
17280
|
if (deps.wishlistDigest) {
|
|
16460
17281
|
router.post("/account/wishlist/digest", async function (req, res) {
|
|
16461
17282
|
var auth;
|
|
16462
|
-
try { auth = _currentCustomer(req); }
|
|
17283
|
+
try { auth = await _currentCustomer(req); }
|
|
16463
17284
|
catch (e) {
|
|
16464
17285
|
if (e && e.code === "vault/not-initialized") return _serviceUnavailable(res, "auth not configured");
|
|
16465
17286
|
throw e;
|
|
@@ -16569,7 +17390,7 @@ function mount(router, deps) {
|
|
|
16569
17390
|
// redirect so the one-time URL is shown.
|
|
16570
17391
|
router.post("/wishlist/share", async function (req, res) {
|
|
16571
17392
|
var auth;
|
|
16572
|
-
try { auth = _currentCustomer(req); }
|
|
17393
|
+
try { auth = await _currentCustomer(req); }
|
|
16573
17394
|
catch (e) {
|
|
16574
17395
|
if (e && e.code === "vault/not-initialized") return _serviceUnavailable(res, "auth not configured");
|
|
16575
17396
|
throw e;
|
|
@@ -16608,7 +17429,7 @@ function mount(router, deps) {
|
|
|
16608
17429
|
// its id (IDOR).
|
|
16609
17430
|
router.post("/wishlist/share/:share_id/revoke", async function (req, res) {
|
|
16610
17431
|
var auth;
|
|
16611
|
-
try { auth = _currentCustomer(req); }
|
|
17432
|
+
try { auth = await _currentCustomer(req); }
|
|
16612
17433
|
catch (e) {
|
|
16613
17434
|
if (e && e.code === "vault/not-initialized") return _serviceUnavailable(res, "auth not configured");
|
|
16614
17435
|
throw e;
|
|
@@ -16755,7 +17576,7 @@ function mount(router, deps) {
|
|
|
16755
17576
|
// its progress rollup, plus the create form.
|
|
16756
17577
|
router.get("/account/registry", async function (req, res) {
|
|
16757
17578
|
var auth;
|
|
16758
|
-
try { auth = _currentCustomer(req); }
|
|
17579
|
+
try { auth = await _currentCustomer(req); }
|
|
16759
17580
|
catch (e) {
|
|
16760
17581
|
if (e && e.code === "vault/not-initialized") return _serviceUnavailable(res, "auth not configured");
|
|
16761
17582
|
throw e;
|
|
@@ -16789,7 +17610,7 @@ function mount(router, deps) {
|
|
|
16789
17610
|
// shopper can only ever create a registry under their own id.
|
|
16790
17611
|
router.post("/account/registry", async function (req, res) {
|
|
16791
17612
|
var auth;
|
|
16792
|
-
try { auth = _currentCustomer(req); }
|
|
17613
|
+
try { auth = await _currentCustomer(req); }
|
|
16793
17614
|
catch (e) {
|
|
16794
17615
|
if (e && e.code === "vault/not-initialized") return _serviceUnavailable(res, "auth not configured");
|
|
16795
17616
|
throw e;
|
|
@@ -16848,7 +17669,7 @@ function mount(router, deps) {
|
|
|
16848
17669
|
// slug) 404s.
|
|
16849
17670
|
router.get("/account/registry/:slug", async function (req, res) {
|
|
16850
17671
|
var auth;
|
|
16851
|
-
try { auth = _currentCustomer(req); }
|
|
17672
|
+
try { auth = await _currentCustomer(req); }
|
|
16852
17673
|
catch (e) {
|
|
16853
17674
|
if (e && e.code === "vault/not-initialized") return _serviceUnavailable(res, "auth not configured");
|
|
16854
17675
|
throw e;
|
|
@@ -16915,7 +17736,7 @@ function mount(router, deps) {
|
|
|
16915
17736
|
// session customer owns. Ownership-scoped (404 on a foreign / unknown
|
|
16916
17737
|
// registry).
|
|
16917
17738
|
router.post("/account/registry/:slug/items", async function (req, res) {
|
|
16918
|
-
var auth = _registryAuthOrRedirect(req, res);
|
|
17739
|
+
var auth = await _registryAuthOrRedirect(req, res);
|
|
16919
17740
|
if (!auth) return;
|
|
16920
17741
|
var slug = (req.params && req.params.slug) || "";
|
|
16921
17742
|
var reg = await _ownedRegistry(slug, auth.customer_id);
|
|
@@ -16943,7 +17764,7 @@ function mount(router, deps) {
|
|
|
16943
17764
|
// POST /account/registry/:slug/items/:item_id/remove — archive an item
|
|
16944
17765
|
// on a registry the session customer owns.
|
|
16945
17766
|
router.post("/account/registry/:slug/items/:item_id/remove", async function (req, res) {
|
|
16946
|
-
var auth = _registryAuthOrRedirect(req, res);
|
|
17767
|
+
var auth = await _registryAuthOrRedirect(req, res);
|
|
16947
17768
|
if (!auth) return;
|
|
16948
17769
|
var slug = (req.params && req.params.slug) || "";
|
|
16949
17770
|
var reg = await _ownedRegistry(slug, auth.customer_id);
|
|
@@ -16967,7 +17788,7 @@ function mount(router, deps) {
|
|
|
16967
17788
|
// (title / recipient_name / event_date / privacy) on a registry the
|
|
16968
17789
|
// session customer owns.
|
|
16969
17790
|
router.post("/account/registry/:slug/edit", async function (req, res) {
|
|
16970
|
-
var auth = _registryAuthOrRedirect(req, res);
|
|
17791
|
+
var auth = await _registryAuthOrRedirect(req, res);
|
|
16971
17792
|
if (!auth) return;
|
|
16972
17793
|
var slug = (req.params && req.params.slug) || "";
|
|
16973
17794
|
var reg = await _ownedRegistry(slug, auth.customer_id);
|
|
@@ -16999,7 +17820,7 @@ function mount(router, deps) {
|
|
|
16999
17820
|
// POST /account/registry/:slug/close — close a registry the session
|
|
17000
17821
|
// customer owns (the only FSM transition; refuses further mutation).
|
|
17001
17822
|
router.post("/account/registry/:slug/close", async function (req, res) {
|
|
17002
|
-
var auth = _registryAuthOrRedirect(req, res);
|
|
17823
|
+
var auth = await _registryAuthOrRedirect(req, res);
|
|
17003
17824
|
if (!auth) return;
|
|
17004
17825
|
var slug = (req.params && req.params.slug) || "";
|
|
17005
17826
|
var reg = await _ownedRegistry(slug, auth.customer_id);
|
|
@@ -17129,7 +17950,7 @@ function mount(router, deps) {
|
|
|
17129
17950
|
var buyerId = null;
|
|
17130
17951
|
if (reveal) {
|
|
17131
17952
|
var giverAuth = null;
|
|
17132
|
-
try { giverAuth = _currentCustomer(req); } catch (_e) { giverAuth = null; }
|
|
17953
|
+
try { giverAuth = await _currentCustomer(req); } catch (_e) { giverAuth = null; }
|
|
17133
17954
|
if (giverAuth && giverAuth.customer_id) buyerId = giverAuth.customer_id;
|
|
17134
17955
|
else reveal = false; // not signed in → fall back to anonymous
|
|
17135
17956
|
}
|
|
@@ -17160,9 +17981,9 @@ function mount(router, deps) {
|
|
|
17160
17981
|
// Shared owner-route auth gate: resolve the session customer or send the
|
|
17161
17982
|
// redirect / 503 and return null. Mirrors the wishlist `_savedAuth`
|
|
17162
17983
|
// shape so every owner registry write funnels through one check.
|
|
17163
|
-
function _registryAuthOrRedirect(req, res) {
|
|
17984
|
+
async function _registryAuthOrRedirect(req, res) {
|
|
17164
17985
|
var auth;
|
|
17165
|
-
try { auth = _currentCustomer(req); }
|
|
17986
|
+
try { auth = await _currentCustomer(req); }
|
|
17166
17987
|
catch (e) {
|
|
17167
17988
|
if (e && e.code === "vault/not-initialized") { _serviceUnavailable(res, "auth not configured"); return null; }
|
|
17168
17989
|
throw e;
|
|
@@ -17217,9 +18038,9 @@ function mount(router, deps) {
|
|
|
17217
18038
|
// Save for later — move a cart line into a per-customer holding
|
|
17218
18039
|
// list and back. Login required (the list is per-customer).
|
|
17219
18040
|
if (deps.saveForLater) {
|
|
17220
|
-
function _savedAuth(req, res) {
|
|
18041
|
+
async function _savedAuth(req, res) {
|
|
17221
18042
|
var auth;
|
|
17222
|
-
try { auth = _currentCustomer(req); }
|
|
18043
|
+
try { auth = await _currentCustomer(req); }
|
|
17223
18044
|
catch (e) {
|
|
17224
18045
|
if (e && e.code === "vault/not-initialized") { _serviceUnavailable(res, "auth not configured"); return null; }
|
|
17225
18046
|
throw e;
|
|
@@ -17235,7 +18056,7 @@ function mount(router, deps) {
|
|
|
17235
18056
|
// POST /cart/lines/:line_id/save — move the line out of the cart
|
|
17236
18057
|
// into the customer's saved list. Redirects back to /cart.
|
|
17237
18058
|
router.post("/cart/lines/:line_id/save", async function (req, res) {
|
|
17238
|
-
var auth = _savedAuth(req, res);
|
|
18059
|
+
var auth = await _savedAuth(req, res);
|
|
17239
18060
|
if (!auth) return;
|
|
17240
18061
|
var sid = _readSidCookie(req);
|
|
17241
18062
|
var cart = sid ? await deps.cart.bySession(sid) : null;
|
|
@@ -17259,7 +18080,7 @@ function mount(router, deps) {
|
|
|
17259
18080
|
|
|
17260
18081
|
// GET /account/saved — the customer's saved-for-later list.
|
|
17261
18082
|
router.get("/account/saved", async function (req, res) {
|
|
17262
|
-
var auth = _savedAuth(req, res);
|
|
18083
|
+
var auth = await _savedAuth(req, res);
|
|
17263
18084
|
if (!auth) return;
|
|
17264
18085
|
var page = await deps.saveForLater.listForCustomer({ customer_id: auth.customer_id, limit: 50 });
|
|
17265
18086
|
var items = [];
|
|
@@ -17290,7 +18111,7 @@ function mount(router, deps) {
|
|
|
17290
18111
|
// POST /saved/:save_id/move-to-cart — move a saved row back into
|
|
17291
18112
|
// the session cart (created if absent). Redirects to /cart.
|
|
17292
18113
|
router.post("/saved/:save_id/move-to-cart", async function (req, res) {
|
|
17293
|
-
var auth = _savedAuth(req, res);
|
|
18114
|
+
var auth = await _savedAuth(req, res);
|
|
17294
18115
|
if (!auth) return;
|
|
17295
18116
|
var resolved = await _getOrCreateCart(req, res, "USD");
|
|
17296
18117
|
try {
|
|
@@ -17330,7 +18151,7 @@ function mount(router, deps) {
|
|
|
17330
18151
|
|
|
17331
18152
|
// POST /saved/:save_id/remove — drop a saved row.
|
|
17332
18153
|
router.post("/saved/:save_id/remove", async function (req, res) {
|
|
17333
|
-
var auth = _savedAuth(req, res);
|
|
18154
|
+
var auth = await _savedAuth(req, res);
|
|
17334
18155
|
if (!auth) return;
|
|
17335
18156
|
try {
|
|
17336
18157
|
await deps.saveForLater.remove({ customer_id: auth.customer_id, save_id: req.params && req.params.save_id });
|
|
@@ -17348,9 +18169,9 @@ function mount(router, deps) {
|
|
|
17348
18169
|
// (the primitive operates by id alone, so ownership is enforced here
|
|
17349
18170
|
// to prevent cross-customer access via a guessed id).
|
|
17350
18171
|
if (deps.addresses) {
|
|
17351
|
-
function _addrAuth(req, res) {
|
|
18172
|
+
async function _addrAuth(req, res) {
|
|
17352
18173
|
var auth;
|
|
17353
|
-
try { auth = _currentCustomer(req); }
|
|
18174
|
+
try { auth = await _currentCustomer(req); }
|
|
17354
18175
|
catch (e) {
|
|
17355
18176
|
if (e && e.code === "vault/not-initialized") { _serviceUnavailable(res, "auth not configured"); return null; }
|
|
17356
18177
|
throw e;
|
|
@@ -17439,18 +18260,18 @@ function mount(router, deps) {
|
|
|
17439
18260
|
}
|
|
17440
18261
|
|
|
17441
18262
|
router.get("/account/addresses", async function (req, res) {
|
|
17442
|
-
var auth = _addrAuth(req, res); if (!auth) return;
|
|
18263
|
+
var auth = await _addrAuth(req, res); if (!auth) return;
|
|
17443
18264
|
await _renderAddrPage(req, res, auth, null);
|
|
17444
18265
|
});
|
|
17445
18266
|
|
|
17446
18267
|
router.get("/account/addresses/:id/edit", async function (req, res) {
|
|
17447
|
-
var auth = _addrAuth(req, res); if (!auth) return;
|
|
18268
|
+
var auth = await _addrAuth(req, res); if (!auth) return;
|
|
17448
18269
|
var addr = await _ownedAddress(req, res, auth); if (!addr) return;
|
|
17449
18270
|
await _renderAddrPage(req, res, auth, addr);
|
|
17450
18271
|
});
|
|
17451
18272
|
|
|
17452
18273
|
router.post("/account/addresses", async function (req, res) {
|
|
17453
|
-
var auth = _addrAuth(req, res); if (!auth) return;
|
|
18274
|
+
var auth = await _addrAuth(req, res); if (!auth) return;
|
|
17454
18275
|
try {
|
|
17455
18276
|
await deps.addresses.add(_addrInput(req.body || {}, auth.customer_id));
|
|
17456
18277
|
} catch (e) {
|
|
@@ -17465,7 +18286,7 @@ function mount(router, deps) {
|
|
|
17465
18286
|
});
|
|
17466
18287
|
|
|
17467
18288
|
router.post("/account/addresses/:id", async function (req, res) {
|
|
17468
|
-
var auth = _addrAuth(req, res); if (!auth) return;
|
|
18289
|
+
var auth = await _addrAuth(req, res); if (!auth) return;
|
|
17469
18290
|
var addr = await _ownedAddress(req, res, auth); if (!addr) return;
|
|
17470
18291
|
try {
|
|
17471
18292
|
await deps.addresses.update(addr.id, _addrInput(req.body || {}, auth.customer_id));
|
|
@@ -17483,7 +18304,7 @@ function mount(router, deps) {
|
|
|
17483
18304
|
|
|
17484
18305
|
function _addrAction(verb, okKind, fn) {
|
|
17485
18306
|
router.post("/account/addresses/:id/" + verb, async function (req, res) {
|
|
17486
|
-
var auth = _addrAuth(req, res); if (!auth) return;
|
|
18307
|
+
var auth = await _addrAuth(req, res); if (!auth) return;
|
|
17487
18308
|
var addr = await _ownedAddress(req, res, auth); if (!addr) return;
|
|
17488
18309
|
try { await fn(addr.id); }
|
|
17489
18310
|
catch (e) {
|
|
@@ -17502,7 +18323,7 @@ function mount(router, deps) {
|
|
|
17502
18323
|
// actually archives lives behind that page. The list then surfaces a
|
|
17503
18324
|
// success notice with an Undo (unarchive) control.
|
|
17504
18325
|
router.get("/account/addresses/:id/remove", async function (req, res) {
|
|
17505
|
-
var auth = _addrAuth(req, res); if (!auth) return;
|
|
18326
|
+
var auth = await _addrAuth(req, res); if (!auth) return;
|
|
17506
18327
|
var addr = await _ownedAddress(req, res, auth); if (!addr) return;
|
|
17507
18328
|
var cartCount = await _cartCountForReq(req);
|
|
17508
18329
|
_send(res, 200, renderAddressRemoveConfirm({
|
|
@@ -17513,7 +18334,7 @@ function mount(router, deps) {
|
|
|
17513
18334
|
});
|
|
17514
18335
|
|
|
17515
18336
|
router.post("/account/addresses/:id/archive", async function (req, res) {
|
|
17516
|
-
var auth = _addrAuth(req, res); if (!auth) return;
|
|
18337
|
+
var auth = await _addrAuth(req, res); if (!auth) return;
|
|
17517
18338
|
var addr = await _ownedAddress(req, res, auth); if (!addr) return;
|
|
17518
18339
|
try { await deps.addresses.archive(addr.id); }
|
|
17519
18340
|
catch (e) {
|
|
@@ -17530,7 +18351,7 @@ function mount(router, deps) {
|
|
|
17530
18351
|
// address is archived by definition here) but still enforces
|
|
17531
18352
|
// customer ownership before un-archiving.
|
|
17532
18353
|
router.post("/account/addresses/:id/unarchive", async function (req, res) {
|
|
17533
|
-
var auth = _addrAuth(req, res); if (!auth) return;
|
|
18354
|
+
var auth = await _addrAuth(req, res); if (!auth) return;
|
|
17534
18355
|
var addr;
|
|
17535
18356
|
try { addr = await deps.addresses.get(req.params && req.params.id); }
|
|
17536
18357
|
catch (e) {
|
|
@@ -17569,9 +18390,9 @@ function mount(router, deps) {
|
|
|
17569
18390
|
// handle. The list above stays a read-only view when this is absent.
|
|
17570
18391
|
var subControls = deps.subscriptionControls || null;
|
|
17571
18392
|
|
|
17572
|
-
function _subsAuth(req, res) {
|
|
18393
|
+
async function _subsAuth(req, res) {
|
|
17573
18394
|
var auth;
|
|
17574
|
-
try { auth = _currentCustomer(req); }
|
|
18395
|
+
try { auth = await _currentCustomer(req); }
|
|
17575
18396
|
catch (e) {
|
|
17576
18397
|
if (e && e.code === "vault/not-initialized") { _serviceUnavailable(res, "auth not configured"); return null; }
|
|
17577
18398
|
throw e;
|
|
@@ -17628,7 +18449,7 @@ function mount(router, deps) {
|
|
|
17628
18449
|
}
|
|
17629
18450
|
|
|
17630
18451
|
router.get("/account/subscriptions", async function (req, res) {
|
|
17631
|
-
var auth = _subsAuth(req, res); if (!auth) return;
|
|
18452
|
+
var auth = await _subsAuth(req, res); if (!auth) return;
|
|
17632
18453
|
var rows = await _subsForCustomer(auth.customer_id);
|
|
17633
18454
|
var cartCount = await _cartCountForReq(req);
|
|
17634
18455
|
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
@@ -17711,7 +18532,7 @@ function mount(router, deps) {
|
|
|
17711
18532
|
// currently-active subscription; a paused/cancelled row bounces
|
|
17712
18533
|
// back to the list.
|
|
17713
18534
|
router.get("/account/subscriptions/:id/pause", async function (req, res) {
|
|
17714
|
-
var auth = _subsAuth(req, res); if (!auth) return;
|
|
18535
|
+
var auth = await _subsAuth(req, res); if (!auth) return;
|
|
17715
18536
|
var sub = await _ownedManageableSubscription(req, res, auth); if (!sub) return;
|
|
17716
18537
|
if (_subscriptionControlState(sub) !== "active") return _redirect(res, "");
|
|
17717
18538
|
if (sub.plan_id != null) {
|
|
@@ -17727,7 +18548,7 @@ function mount(router, deps) {
|
|
|
17727
18548
|
});
|
|
17728
18549
|
|
|
17729
18550
|
router.post("/account/subscriptions/:id/pause", async function (req, res) {
|
|
17730
|
-
var auth = _subsAuth(req, res); if (!auth) return;
|
|
18551
|
+
var auth = await _subsAuth(req, res); if (!auth) return;
|
|
17731
18552
|
var sub = await _ownedManageableSubscription(req, res, auth); if (!sub) return;
|
|
17732
18553
|
try {
|
|
17733
18554
|
await subControls.pause({ subscription_id: sub.id, reason: "customer self-service pause", actor: SELF_ACTOR });
|
|
@@ -17739,7 +18560,7 @@ function mount(router, deps) {
|
|
|
17739
18560
|
});
|
|
17740
18561
|
|
|
17741
18562
|
router.post("/account/subscriptions/:id/resume", async function (req, res) {
|
|
17742
|
-
var auth = _subsAuth(req, res); if (!auth) return;
|
|
18563
|
+
var auth = await _subsAuth(req, res); if (!auth) return;
|
|
17743
18564
|
var sub = await _ownedManageableSubscription(req, res, auth); if (!sub) return;
|
|
17744
18565
|
try {
|
|
17745
18566
|
await subControls.resume({ subscription_id: sub.id, reason: "customer self-service resume", actor: SELF_ACTOR });
|
|
@@ -17751,7 +18572,7 @@ function mount(router, deps) {
|
|
|
17751
18572
|
});
|
|
17752
18573
|
|
|
17753
18574
|
router.post("/account/subscriptions/:id/skip", async function (req, res) {
|
|
17754
|
-
var auth = _subsAuth(req, res); if (!auth) return;
|
|
18575
|
+
var auth = await _subsAuth(req, res); if (!auth) return;
|
|
17755
18576
|
var sub = await _ownedManageableSubscription(req, res, auth); if (!sub) return;
|
|
17756
18577
|
try {
|
|
17757
18578
|
await subControls.skipNext({ subscription_id: sub.id, count: 1, reason: "customer self-service skip", actor: SELF_ACTOR });
|
|
@@ -17763,7 +18584,7 @@ function mount(router, deps) {
|
|
|
17763
18584
|
});
|
|
17764
18585
|
|
|
17765
18586
|
router.post("/account/subscriptions/:id/quantity", async function (req, res) {
|
|
17766
|
-
var auth = _subsAuth(req, res); if (!auth) return;
|
|
18587
|
+
var auth = await _subsAuth(req, res); if (!auth) return;
|
|
17767
18588
|
var sub = await _ownedManageableSubscription(req, res, auth); if (!sub) return;
|
|
17768
18589
|
// Backend validates: a non-positive / non-integer / missing value
|
|
17769
18590
|
// is a client error → bounce with the quantity error code rather
|
|
@@ -17790,7 +18611,7 @@ function mount(router, deps) {
|
|
|
17790
18611
|
});
|
|
17791
18612
|
|
|
17792
18613
|
router.post("/account/subscriptions/:id/frequency", async function (req, res) {
|
|
17793
|
-
var auth = _subsAuth(req, res); if (!auth) return;
|
|
18614
|
+
var auth = await _subsAuth(req, res); if (!auth) return;
|
|
17794
18615
|
var sub = await _ownedManageableSubscription(req, res, auth); if (!sub) return;
|
|
17795
18616
|
// Backend validates: reject anything outside the allowed cadence
|
|
17796
18617
|
// enum before composing the primitive.
|
|
@@ -17807,7 +18628,7 @@ function mount(router, deps) {
|
|
|
17807
18628
|
});
|
|
17808
18629
|
|
|
17809
18630
|
router.post("/account/subscriptions/:id/reactivate", async function (req, res) {
|
|
17810
|
-
var auth = _subsAuth(req, res); if (!auth) return;
|
|
18631
|
+
var auth = await _subsAuth(req, res); if (!auth) return;
|
|
17811
18632
|
// Reactivate is the recovery path for a cancelled subscription,
|
|
17812
18633
|
// so it is NOT gated on the terminal-status guard (a Stripe-
|
|
17813
18634
|
// canceled status is the normal state of a row being
|
|
@@ -17832,7 +18653,7 @@ function mount(router, deps) {
|
|
|
17832
18653
|
// immediate cancel. A subscription that isn't cancelable
|
|
17833
18654
|
// (already canceled / winding down) redirects back to the list.
|
|
17834
18655
|
router.get("/account/subscriptions/:id/cancel", async function (req, res) {
|
|
17835
|
-
var auth = _subsAuth(req, res); if (!auth) return;
|
|
18656
|
+
var auth = await _subsAuth(req, res); if (!auth) return;
|
|
17836
18657
|
var sub = await _ownedSubscription(req, res, auth); if (!sub) return;
|
|
17837
18658
|
if (!_subscriptionIsCancelable(sub)) {
|
|
17838
18659
|
res.status(303); res.setHeader && res.setHeader("location", "/account/subscriptions");
|
|
@@ -17853,7 +18674,7 @@ function mount(router, deps) {
|
|
|
17853
18674
|
});
|
|
17854
18675
|
|
|
17855
18676
|
router.post("/account/subscriptions/:id/cancel", async function (req, res) {
|
|
17856
|
-
var auth = _subsAuth(req, res); if (!auth) return;
|
|
18677
|
+
var auth = await _subsAuth(req, res); if (!auth) return;
|
|
17857
18678
|
var sub = await _ownedSubscription(req, res, auth); if (!sub) return;
|
|
17858
18679
|
// Default cancel-at-period-end (the customer keeps access through
|
|
17859
18680
|
// the period they've paid for); the form opts into immediate via
|
|
@@ -17884,9 +18705,9 @@ function mount(router, deps) {
|
|
|
17884
18705
|
// the launch flow later converts it into a regular (Stripe-gated) order.
|
|
17885
18706
|
// Mounts only when the preorder primitive is wired.
|
|
17886
18707
|
if (preorder) {
|
|
17887
|
-
function _preorderAuth(req, res) {
|
|
18708
|
+
async function _preorderAuth(req, res) {
|
|
17888
18709
|
var auth;
|
|
17889
|
-
try { auth = _currentCustomer(req); }
|
|
18710
|
+
try { auth = await _currentCustomer(req); }
|
|
17890
18711
|
catch (e) {
|
|
17891
18712
|
if (e && e.code === "vault/not-initialized") { _serviceUnavailable(res, "auth not configured"); return null; }
|
|
17892
18713
|
throw e;
|
|
@@ -17918,7 +18739,7 @@ function mount(router, deps) {
|
|
|
17918
18739
|
// shows. Over-cap / closed / missing campaign → a clean 4xx PRG back to
|
|
17919
18740
|
// the PDP with a fixed ?preorder error code (no raw error text).
|
|
17920
18741
|
router.post("/products/:slug/preorder", async function (req, res) {
|
|
17921
|
-
var auth = _preorderAuth(req, res); if (!auth) return;
|
|
18742
|
+
var auth = await _preorderAuth(req, res); if (!auth) return;
|
|
17922
18743
|
var slug = req.params && req.params.slug;
|
|
17923
18744
|
var enc = encodeURIComponent(slug || "");
|
|
17924
18745
|
var product = slug ? await deps.catalog.products.bySlug(slug) : null;
|
|
@@ -18001,7 +18822,7 @@ function mount(router, deps) {
|
|
|
18001
18822
|
}
|
|
18002
18823
|
|
|
18003
18824
|
router.get("/account/preorders", async function (req, res) {
|
|
18004
|
-
var auth = _preorderAuth(req, res); if (!auth) return;
|
|
18825
|
+
var auth = await _preorderAuth(req, res); if (!auth) return;
|
|
18005
18826
|
var rows = await _preordersForCustomer(auth.customer_id);
|
|
18006
18827
|
var cartCount = await _cartCountForReq(req);
|
|
18007
18828
|
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
@@ -18021,7 +18842,7 @@ function mount(router, deps) {
|
|
|
18021
18842
|
// non-active reservation (already converted / cancelled) is a clean PRG
|
|
18022
18843
|
// back to the list, not a 500.
|
|
18023
18844
|
router.post("/account/preorders/:id/cancel", async function (req, res) {
|
|
18024
|
-
var auth = _preorderAuth(req, res); if (!auth) return;
|
|
18845
|
+
var auth = await _preorderAuth(req, res); if (!auth) return;
|
|
18025
18846
|
var resv = await _ownedReservation(req, res, auth); if (!resv) return;
|
|
18026
18847
|
try {
|
|
18027
18848
|
await preorder.cancelReservation({ reservation_id: resv.id, reason: "customer-cancelled" });
|
|
@@ -18041,9 +18862,9 @@ function mount(router, deps) {
|
|
|
18041
18862
|
// the admin /admin/returns queue. Needs the returns primitive + an
|
|
18042
18863
|
// order handle (to load + ownership-check the order being returned).
|
|
18043
18864
|
if (deps.returns && deps.order) {
|
|
18044
|
-
function _returnsAuth(req, res) {
|
|
18865
|
+
async function _returnsAuth(req, res) {
|
|
18045
18866
|
var auth;
|
|
18046
|
-
try { auth = _currentCustomer(req); }
|
|
18867
|
+
try { auth = await _currentCustomer(req); }
|
|
18047
18868
|
catch (e) {
|
|
18048
18869
|
if (e && e.code === "vault/not-initialized") { _serviceUnavailable(res, "auth not configured"); return null; }
|
|
18049
18870
|
throw e;
|
|
@@ -18107,7 +18928,7 @@ function mount(router, deps) {
|
|
|
18107
18928
|
}
|
|
18108
18929
|
|
|
18109
18930
|
router.get("/account/returns", async function (req, res) {
|
|
18110
|
-
var auth = _returnsAuth(req, res); if (!auth) return;
|
|
18931
|
+
var auth = await _returnsAuth(req, res); if (!auth) return;
|
|
18111
18932
|
var page = await deps.returns.listForCustomer(auth.customer_id, { limit: 50 });
|
|
18112
18933
|
var cartCount = await _cartCountForReq(req);
|
|
18113
18934
|
var retUrl = req.url ? new URL(req.url, "http://localhost") : null;
|
|
@@ -18124,7 +18945,7 @@ function mount(router, deps) {
|
|
|
18124
18945
|
// through _ownedReturn (foreign / unknown / malformed id → 404). A
|
|
18125
18946
|
// return with no issued label renders a neutral "no label yet" state.
|
|
18126
18947
|
router.get("/account/returns/:id", async function (req, res) {
|
|
18127
|
-
var auth = _returnsAuth(req, res); if (!auth) return;
|
|
18948
|
+
var auth = await _returnsAuth(req, res); if (!auth) return;
|
|
18128
18949
|
var rma = await _ownedReturn(req, res, auth); if (!rma) return;
|
|
18129
18950
|
var label = await _labelForReturn(rma.id);
|
|
18130
18951
|
var events = [];
|
|
@@ -18152,7 +18973,7 @@ function mount(router, deps) {
|
|
|
18152
18973
|
// primitive is wired.
|
|
18153
18974
|
if (deps.returnLabels) {
|
|
18154
18975
|
router.get("/account/returns/:id/label", async function (req, res) {
|
|
18155
|
-
var auth = _returnsAuth(req, res); if (!auth) return;
|
|
18976
|
+
var auth = await _returnsAuth(req, res); if (!auth) return;
|
|
18156
18977
|
var rma = await _ownedReturn(req, res, auth); if (!rma) return;
|
|
18157
18978
|
var label = await _labelForReturn(rma.id);
|
|
18158
18979
|
if (!label || !label.label_url) {
|
|
@@ -18172,7 +18993,7 @@ function mount(router, deps) {
|
|
|
18172
18993
|
}
|
|
18173
18994
|
|
|
18174
18995
|
router.get("/account/orders/:order_id/return", async function (req, res) {
|
|
18175
|
-
var auth = _returnsAuth(req, res); if (!auth) return;
|
|
18996
|
+
var auth = await _returnsAuth(req, res); if (!auth) return;
|
|
18176
18997
|
var order = await _ownedOrder(req, res, auth); if (!order) return;
|
|
18177
18998
|
var cartCount = await _cartCountForReq(req);
|
|
18178
18999
|
// An ineligible order (unpaid, cancelled, or already refunded) never
|
|
@@ -18191,7 +19012,7 @@ function mount(router, deps) {
|
|
|
18191
19012
|
});
|
|
18192
19013
|
|
|
18193
19014
|
router.post("/account/orders/:order_id/return", async function (req, res) {
|
|
18194
|
-
var auth = _returnsAuth(req, res); if (!auth) return;
|
|
19015
|
+
var auth = await _returnsAuth(req, res); if (!auth) return;
|
|
18195
19016
|
var order = await _ownedOrder(req, res, auth); if (!order) return;
|
|
18196
19017
|
var body = req.body || {};
|
|
18197
19018
|
var cartCount = await _cartCountForReq(req);
|
|
@@ -18268,9 +19089,9 @@ function mount(router, deps) {
|
|
|
18268
19089
|
// exactly like the returns + support gates. The exchange primitive
|
|
18269
19090
|
// moves a row by id alone, so the route owns the ownership decision.
|
|
18270
19091
|
if (deps.orderExchanges && deps.order) {
|
|
18271
|
-
function _exchangeAuth(req, res) {
|
|
19092
|
+
async function _exchangeAuth(req, res) {
|
|
18272
19093
|
var auth;
|
|
18273
|
-
try { auth = _currentCustomer(req); }
|
|
19094
|
+
try { auth = await _currentCustomer(req); }
|
|
18274
19095
|
catch (e) {
|
|
18275
19096
|
if (e && e.code === "vault/not-initialized") { _serviceUnavailable(res, "auth not configured"); return null; }
|
|
18276
19097
|
throw e;
|
|
@@ -18353,7 +19174,7 @@ function mount(router, deps) {
|
|
|
18353
19174
|
// primitive resolves the customer→order linkage through the injected
|
|
18354
19175
|
// order handle, so a foreign order's exchange never appears here.
|
|
18355
19176
|
router.get("/account/exchanges", async function (req, res) {
|
|
18356
|
-
var auth = _exchangeAuth(req, res); if (!auth) return;
|
|
19177
|
+
var auth = await _exchangeAuth(req, res); if (!auth) return;
|
|
18357
19178
|
var exchanges = [];
|
|
18358
19179
|
try { exchanges = await deps.orderExchanges.exchangesForCustomer(auth.customer_id, { limit: 100 }); }
|
|
18359
19180
|
catch (e) { if (!(e instanceof TypeError)) throw e; exchanges = []; }
|
|
@@ -18370,7 +19191,7 @@ function mount(router, deps) {
|
|
|
18370
19191
|
// One exchange's status detail. Ownership-scoped through the parent
|
|
18371
19192
|
// order (foreign / unknown → 404).
|
|
18372
19193
|
router.get("/account/exchanges/:id", async function (req, res) {
|
|
18373
|
-
var auth = _exchangeAuth(req, res); if (!auth) return;
|
|
19194
|
+
var auth = await _exchangeAuth(req, res); if (!auth) return;
|
|
18374
19195
|
var exchange = await _ownedExchange(req, res, auth); if (!exchange) return;
|
|
18375
19196
|
var cartCount = await _cartCountForReq(req);
|
|
18376
19197
|
_send(res, 200, renderExchangeDetail({ exchange: exchange, shop_name: shopName, cart_count: cartCount }));
|
|
@@ -18379,7 +19200,7 @@ function mount(router, deps) {
|
|
|
18379
19200
|
// The exchange-request form for one of the customer's own orders,
|
|
18380
19201
|
// gated on the same eligibility window as a return.
|
|
18381
19202
|
router.get("/account/orders/:order_id/exchange", async function (req, res) {
|
|
18382
|
-
var auth = _exchangeAuth(req, res); if (!auth) return;
|
|
19203
|
+
var auth = await _exchangeAuth(req, res); if (!auth) return;
|
|
18383
19204
|
var order = await _ownedOrderForExchange(req, res, auth); if (!order) return;
|
|
18384
19205
|
var cartCount = await _cartCountForReq(req);
|
|
18385
19206
|
if (!_orderEligibleForExchange(order.status)) {
|
|
@@ -18398,7 +19219,7 @@ function mount(router, deps) {
|
|
|
18398
19219
|
});
|
|
18399
19220
|
|
|
18400
19221
|
router.post("/account/orders/:order_id/exchange", async function (req, res) {
|
|
18401
|
-
var auth = _exchangeAuth(req, res); if (!auth) return;
|
|
19222
|
+
var auth = await _exchangeAuth(req, res); if (!auth) return;
|
|
18402
19223
|
var order = await _ownedOrderForExchange(req, res, auth); if (!order) return;
|
|
18403
19224
|
var body = req.body || {};
|
|
18404
19225
|
var cartCount = await _cartCountForReq(req);
|
|
@@ -18496,7 +19317,7 @@ function mount(router, deps) {
|
|
|
18496
19317
|
// when the primitive is wired.
|
|
18497
19318
|
if (deps.clickAndCollect) {
|
|
18498
19319
|
router.get("/account/pickups", async function (req, res) {
|
|
18499
|
-
var auth = _accountAuth(req, res); if (!auth) return;
|
|
19320
|
+
var auth = await _accountAuth(req, res); if (!auth) return;
|
|
18500
19321
|
var schedules = [];
|
|
18501
19322
|
try { schedules = await deps.clickAndCollect.customerSchedules(auth.customer_id); }
|
|
18502
19323
|
catch (e) { if (!(e instanceof TypeError)) throw e; schedules = []; }
|
|
@@ -18521,9 +19342,9 @@ function mount(router, deps) {
|
|
|
18521
19342
|
if (deps.supportTickets) {
|
|
18522
19343
|
var support = deps.supportTickets;
|
|
18523
19344
|
|
|
18524
|
-
function _supportAuth(req, res) {
|
|
19345
|
+
async function _supportAuth(req, res) {
|
|
18525
19346
|
var auth;
|
|
18526
|
-
try { auth = _currentCustomer(req); }
|
|
19347
|
+
try { auth = await _currentCustomer(req); }
|
|
18527
19348
|
catch (e) {
|
|
18528
19349
|
if (e && e.code === "vault/not-initialized") { _serviceUnavailable(res, "auth not configured"); return null; }
|
|
18529
19350
|
throw e;
|
|
@@ -18558,7 +19379,7 @@ function mount(router, deps) {
|
|
|
18558
19379
|
|
|
18559
19380
|
// The customer's own ticket list, scoped to their session customer_id.
|
|
18560
19381
|
router.get("/account/support", async function (req, res) {
|
|
18561
|
-
var auth = _supportAuth(req, res); if (!auth) return;
|
|
19382
|
+
var auth = await _supportAuth(req, res); if (!auth) return;
|
|
18562
19383
|
var tickets = await support.listByCustomerId(auth.customer_id, { limit: 100 });
|
|
18563
19384
|
var cartCount = await _cartCountForReq(req);
|
|
18564
19385
|
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
@@ -18573,7 +19394,7 @@ function mount(router, deps) {
|
|
|
18573
19394
|
// The new-ticket form. Offers the customer's recent orders as an
|
|
18574
19395
|
// optional attachment.
|
|
18575
19396
|
router.get("/account/support/new", async function (req, res) {
|
|
18576
|
-
var auth = _supportAuth(req, res); if (!auth) return;
|
|
19397
|
+
var auth = await _supportAuth(req, res); if (!auth) return;
|
|
18577
19398
|
var orders = [];
|
|
18578
19399
|
if (deps.order) {
|
|
18579
19400
|
try {
|
|
@@ -18599,7 +19420,7 @@ function mount(router, deps) {
|
|
|
18599
19420
|
// email shape and surfaces a TypeError on bad input as a clean
|
|
18600
19421
|
// re-render, never a 500.
|
|
18601
19422
|
router.post("/account/support", async function (req, res) {
|
|
18602
|
-
var auth = _supportAuth(req, res); if (!auth) return;
|
|
19423
|
+
var auth = await _supportAuth(req, res); if (!auth) return;
|
|
18603
19424
|
var body = req.body || {};
|
|
18604
19425
|
var cartCount = await _cartCountForReq(req);
|
|
18605
19426
|
|
|
@@ -18650,7 +19471,7 @@ function mount(router, deps) {
|
|
|
18650
19471
|
// Internal operator notes are filtered out before render — the
|
|
18651
19472
|
// customer never sees an internal=1 message.
|
|
18652
19473
|
router.get("/account/support/:id", async function (req, res) {
|
|
18653
|
-
var auth = _supportAuth(req, res); if (!auth) return;
|
|
19474
|
+
var auth = await _supportAuth(req, res); if (!auth) return;
|
|
18654
19475
|
var ticket = await _ownedTicket(req, res, auth); if (!ticket) return;
|
|
18655
19476
|
var thread = await support.thread(ticket.id);
|
|
18656
19477
|
var visible = (thread.messages || []).filter(function (m) { return Number(m.internal) !== 1; });
|
|
@@ -18670,7 +19491,7 @@ function mount(router, deps) {
|
|
|
18670
19491
|
// SUPPORT_TICKET_CLOSED error — surfaced as a 409 re-render, never a
|
|
18671
19492
|
// 500). author is pinned to "customer".
|
|
18672
19493
|
router.post("/account/support/:id/reply", async function (req, res) {
|
|
18673
|
-
var auth = _supportAuth(req, res); if (!auth) return;
|
|
19494
|
+
var auth = await _supportAuth(req, res); if (!auth) return;
|
|
18674
19495
|
var ticket = await _ownedTicket(req, res, auth); if (!ticket) return;
|
|
18675
19496
|
var body = req.body || {};
|
|
18676
19497
|
var cartCount = await _cartCountForReq(req);
|
|
@@ -18731,7 +19552,7 @@ function mount(router, deps) {
|
|
|
18731
19552
|
var streamDsr = deps.streamDsrBundle;
|
|
18732
19553
|
|
|
18733
19554
|
router.get("/account/privacy", async function (req, res) {
|
|
18734
|
-
var auth = _accountAuth(req, res); if (!auth) return;
|
|
19555
|
+
var auth = await _accountAuth(req, res); if (!auth) return;
|
|
18735
19556
|
var history = [];
|
|
18736
19557
|
try { history = await dsr.auditForCustomer(auth.customer_id); } catch (_e) { history = []; }
|
|
18737
19558
|
var cartCount = await _cartCountForReq(req);
|
|
@@ -18749,7 +19570,7 @@ function mount(router, deps) {
|
|
|
18749
19570
|
// with a notice, never a 500. No row is created on a bad enum (the
|
|
18750
19571
|
// primitive validates before INSERT).
|
|
18751
19572
|
router.post("/account/privacy/export", async function (req, res) {
|
|
18752
|
-
var auth = _accountAuth(req, res); if (!auth) return;
|
|
19573
|
+
var auth = await _accountAuth(req, res); if (!auth) return;
|
|
18753
19574
|
var body = req.body || {};
|
|
18754
19575
|
try {
|
|
18755
19576
|
await dsr.requestExport({
|
|
@@ -18778,7 +19599,7 @@ function mount(router, deps) {
|
|
|
18778
19599
|
});
|
|
18779
19600
|
|
|
18780
19601
|
router.get("/account/delete", async function (req, res) {
|
|
18781
|
-
var auth = _accountAuth(req, res); if (!auth) return;
|
|
19602
|
+
var auth = await _accountAuth(req, res); if (!auth) return;
|
|
18782
19603
|
var cartCount = await _cartCountForReq(req);
|
|
18783
19604
|
_send(res, 200, renderAccountDelete({ shop_name: shopName, cart_count: cartCount }));
|
|
18784
19605
|
});
|
|
@@ -18788,7 +19609,7 @@ function mount(router, deps) {
|
|
|
18788
19609
|
// (the operator reviews identity + runs processDeletion from the admin
|
|
18789
19610
|
// queue). A bad enum / empty reason throws TypeError → a 400 re-render.
|
|
18790
19611
|
router.post("/account/delete", async function (req, res) {
|
|
18791
|
-
var auth = _accountAuth(req, res); if (!auth) return;
|
|
19612
|
+
var auth = await _accountAuth(req, res); if (!auth) return;
|
|
18792
19613
|
var body = req.body || {};
|
|
18793
19614
|
try {
|
|
18794
19615
|
await dsr.requestDeletion({
|
|
@@ -18820,7 +19641,7 @@ function mount(router, deps) {
|
|
|
18820
19641
|
// a fulfilled/delivered export, then stream. Status + ownership are
|
|
18821
19642
|
// validated BEFORE the first write (the bundle streams header-first).
|
|
18822
19643
|
router.get("/account/privacy/:id/export.json", async function (req, res) {
|
|
18823
|
-
var auth = _accountAuth(req, res); if (!auth) return;
|
|
19644
|
+
var auth = await _accountAuth(req, res); if (!auth) return;
|
|
18824
19645
|
var row;
|
|
18825
19646
|
try { row = await dsr.getRequest(req.params && req.params.id); }
|
|
18826
19647
|
catch (e) {
|
|
@@ -18844,9 +19665,9 @@ function mount(router, deps) {
|
|
|
18844
19665
|
// control. Login-gated; a read failure on any optional sub-read
|
|
18845
19666
|
// degrades that section to empty rather than 500-ing the page.
|
|
18846
19667
|
if (deps.loyalty) {
|
|
18847
|
-
function _loyaltyAuth(req, res) {
|
|
19668
|
+
async function _loyaltyAuth(req, res) {
|
|
18848
19669
|
var auth;
|
|
18849
|
-
try { auth = _currentCustomer(req); }
|
|
19670
|
+
try { auth = await _currentCustomer(req); }
|
|
18850
19671
|
catch (e) {
|
|
18851
19672
|
if (e && e.code === "vault/not-initialized") { _serviceUnavailable(res, "auth not configured"); return null; }
|
|
18852
19673
|
throw e;
|
|
@@ -18903,7 +19724,7 @@ function mount(router, deps) {
|
|
|
18903
19724
|
}
|
|
18904
19725
|
|
|
18905
19726
|
router.get("/account/loyalty", async function (req, res) {
|
|
18906
|
-
var auth = _loyaltyAuth(req, res); if (!auth) return;
|
|
19727
|
+
var auth = await _loyaltyAuth(req, res); if (!auth) return;
|
|
18907
19728
|
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
18908
19729
|
var cursorRaw = url && url.searchParams.get("cursor");
|
|
18909
19730
|
var cursor;
|
|
@@ -18923,7 +19744,7 @@ function mount(router, deps) {
|
|
|
18923
19744
|
// not-redeemable surface as a 400 re-render, never a 500.
|
|
18924
19745
|
if (deps.loyaltyRedemption) {
|
|
18925
19746
|
router.post("/account/loyalty/redeem", async function (req, res) {
|
|
18926
|
-
var auth = _loyaltyAuth(req, res); if (!auth) return;
|
|
19747
|
+
var auth = await _loyaltyAuth(req, res); if (!auth) return;
|
|
18927
19748
|
var body = req.body || {};
|
|
18928
19749
|
var rewardSlug = body.reward_slug;
|
|
18929
19750
|
try {
|
|
@@ -18971,9 +19792,9 @@ function mount(router, deps) {
|
|
|
18971
19792
|
// their OWN wallet. Granting / deducting credit is operator-only on
|
|
18972
19793
|
// the admin customer-detail screen — this surface writes nothing.
|
|
18973
19794
|
if (deps.storeCredit) {
|
|
18974
|
-
function _storeCreditAuth(req, res) {
|
|
19795
|
+
async function _storeCreditAuth(req, res) {
|
|
18975
19796
|
var auth;
|
|
18976
|
-
try { auth = _currentCustomer(req); }
|
|
19797
|
+
try { auth = await _currentCustomer(req); }
|
|
18977
19798
|
catch (e) {
|
|
18978
19799
|
if (e && e.code === "vault/not-initialized") { _serviceUnavailable(res, "auth not configured"); return null; }
|
|
18979
19800
|
throw e;
|
|
@@ -19018,7 +19839,7 @@ function mount(router, deps) {
|
|
|
19018
19839
|
}
|
|
19019
19840
|
|
|
19020
19841
|
router.get("/account/credit", async function (req, res) {
|
|
19021
|
-
var auth = _storeCreditAuth(req, res); if (!auth) return;
|
|
19842
|
+
var auth = await _storeCreditAuth(req, res); if (!auth) return;
|
|
19022
19843
|
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
19023
19844
|
var cursorRaw = url && url.searchParams.get("cursor");
|
|
19024
19845
|
var cursor;
|
|
@@ -19066,9 +19887,9 @@ function mount(router, deps) {
|
|
|
19066
19887
|
// overwrites the cookie when none is already set (below), so the
|
|
19067
19888
|
// first referral link a visitor follows wins.
|
|
19068
19889
|
if (deps.referrals) {
|
|
19069
|
-
function _referralsAuth(req, res) {
|
|
19890
|
+
async function _referralsAuth(req, res) {
|
|
19070
19891
|
var auth;
|
|
19071
|
-
try { auth = _currentCustomer(req); }
|
|
19892
|
+
try { auth = await _currentCustomer(req); }
|
|
19072
19893
|
catch (e) {
|
|
19073
19894
|
if (e && e.code === "vault/not-initialized") { _serviceUnavailable(res, "auth not configured"); return null; }
|
|
19074
19895
|
throw e;
|
|
@@ -19179,13 +20000,13 @@ function mount(router, deps) {
|
|
|
19179
20000
|
}
|
|
19180
20001
|
|
|
19181
20002
|
router.get("/account/referrals", async function (req, res) {
|
|
19182
|
-
var auth = _referralsAuth(req, res); if (!auth) return;
|
|
20003
|
+
var auth = await _referralsAuth(req, res); if (!auth) return;
|
|
19183
20004
|
var view = await _referralsView(req, auth, {});
|
|
19184
20005
|
_send(res, 200, renderReferrals(view));
|
|
19185
20006
|
});
|
|
19186
20007
|
|
|
19187
20008
|
router.post("/account/referrals/code", async function (req, res) {
|
|
19188
|
-
var auth = _referralsAuth(req, res); if (!auth) return;
|
|
20009
|
+
var auth = await _referralsAuth(req, res); if (!auth) return;
|
|
19189
20010
|
try {
|
|
19190
20011
|
await deps.referrals.issueCode({ referrer_customer_id: auth.customer_id });
|
|
19191
20012
|
} catch (e) {
|
|
@@ -19214,9 +20035,9 @@ function mount(router, deps) {
|
|
|
19214
20035
|
// history. Views are recorded server-side on the (container-rendered)
|
|
19215
20036
|
// PDP; this surface lets the customer review + clear that history.
|
|
19216
20037
|
if (deps.recentlyViewed) {
|
|
19217
|
-
function _rvAuth(req, res) {
|
|
20038
|
+
async function _rvAuth(req, res) {
|
|
19218
20039
|
var auth;
|
|
19219
|
-
try { auth = _currentCustomer(req); }
|
|
20040
|
+
try { auth = await _currentCustomer(req); }
|
|
19220
20041
|
catch (e) {
|
|
19221
20042
|
if (e && e.code === "vault/not-initialized") { _serviceUnavailable(res, "auth not configured"); return null; }
|
|
19222
20043
|
throw e;
|
|
@@ -19230,7 +20051,7 @@ function mount(router, deps) {
|
|
|
19230
20051
|
}
|
|
19231
20052
|
|
|
19232
20053
|
router.get("/account/recently-viewed", async function (req, res) {
|
|
19233
|
-
var auth = _rvAuth(req, res); if (!auth) return;
|
|
20054
|
+
var auth = await _rvAuth(req, res); if (!auth) return;
|
|
19234
20055
|
// A read failure (table not migrated) degrades to the empty
|
|
19235
20056
|
// state rather than 500-ing the account page.
|
|
19236
20057
|
var rows = [];
|
|
@@ -19246,7 +20067,7 @@ function mount(router, deps) {
|
|
|
19246
20067
|
});
|
|
19247
20068
|
|
|
19248
20069
|
router.post("/account/recently-viewed/clear", async function (req, res) {
|
|
19249
|
-
var auth = _rvAuth(req, res); if (!auth) return;
|
|
20070
|
+
var auth = await _rvAuth(req, res); if (!auth) return;
|
|
19250
20071
|
try { await deps.recentlyViewed.purgeCustomer(auth.customer_id); }
|
|
19251
20072
|
catch (_e) { /* drop-silent — a failed clear leaves history intact, no error surface needed */ }
|
|
19252
20073
|
res.status(303); res.setHeader && res.setHeader("location", "/account/recently-viewed");
|
|
@@ -19264,7 +20085,7 @@ function mount(router, deps) {
|
|
|
19264
20085
|
var product = slug ? await deps.catalog.products.bySlug(slug) : null;
|
|
19265
20086
|
if (!product) { _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme })); return null; }
|
|
19266
20087
|
var auth;
|
|
19267
|
-
try { auth = _currentCustomer(req); }
|
|
20088
|
+
try { auth = await _currentCustomer(req); }
|
|
19268
20089
|
catch (e) {
|
|
19269
20090
|
if (e && e.code === "vault/not-initialized") { _serviceUnavailable(res, "auth not configured"); return null; }
|
|
19270
20091
|
throw e;
|
|
@@ -19353,7 +20174,7 @@ function mount(router, deps) {
|
|
|
19353
20174
|
var product = slug ? await deps.catalog.products.bySlug(slug) : null;
|
|
19354
20175
|
if (!product) { _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme })); return null; }
|
|
19355
20176
|
var auth;
|
|
19356
|
-
try { auth = _currentCustomer(req); }
|
|
20177
|
+
try { auth = await _currentCustomer(req); }
|
|
19357
20178
|
catch (e) {
|
|
19358
20179
|
if (e && e.code === "vault/not-initialized") { _serviceUnavailable(res, "auth not configured"); return null; }
|
|
19359
20180
|
throw e;
|
|
@@ -20445,6 +21266,32 @@ function mount(router, deps) {
|
|
|
20445
21266
|
// requirements also replace the file at the same path via R2 (the
|
|
20446
21267
|
// Worker's static-asset bridge serves it ahead of this route if a
|
|
20447
21268
|
// `robots.txt` key exists in the bucket).
|
|
21269
|
+
// Apple Pay domain-association file. Apple Pay (rendered by Stripe's
|
|
21270
|
+
// Express Checkout Element on the pay page) only appears once Apple has
|
|
21271
|
+
// verified the domain, which it does by fetching this exact path. In
|
|
21272
|
+
// production that crawl lands on the edge Worker, which serves the same
|
|
21273
|
+
// bytes from its own binding (worker/index.js); this container twin keeps
|
|
21274
|
+
// a direct-to-container hit (and the e2e harness) in parity. The file's
|
|
21275
|
+
// bytes are Stripe-provided (downloaded from the Stripe dashboard); the
|
|
21276
|
+
// operator supplies them verbatim via the APPLE_PAY_DOMAIN_ASSOCIATION
|
|
21277
|
+
// value injected here as `deps.apple_pay_domain_association`. Served
|
|
21278
|
+
// unchanged as text/plain — Apple's crawl rejects any HTML wrapper,
|
|
21279
|
+
// redirect, or appended byte, so no transform is applied. Unset → 404:
|
|
21280
|
+
// the documented fail-open posture where the Apple Pay button simply does
|
|
21281
|
+
// not render and every other payment method is unaffected.
|
|
21282
|
+
var applePayAssoc = typeof deps.apple_pay_domain_association === "string"
|
|
21283
|
+
? deps.apple_pay_domain_association : "";
|
|
21284
|
+
router.get("/.well-known/apple-developer-merchantid-domain-association", function (req, res) {
|
|
21285
|
+
res.setHeader && res.setHeader("content-type", "text/plain; charset=utf-8");
|
|
21286
|
+
if (!applePayAssoc) {
|
|
21287
|
+
res.status(404);
|
|
21288
|
+
return res.end ? res.end("Not Found") : res.send("Not Found");
|
|
21289
|
+
}
|
|
21290
|
+
res.status(200);
|
|
21291
|
+
res.setHeader && res.setHeader("cache-control", "public, max-age=300");
|
|
21292
|
+
res.end ? res.end(applePayAssoc) : res.send(applePayAssoc);
|
|
21293
|
+
});
|
|
21294
|
+
|
|
20448
21295
|
router.get("/robots.txt", async function (req, res) {
|
|
20449
21296
|
res.status(200);
|
|
20450
21297
|
res.setHeader && res.setHeader("content-type", "text/plain; charset=utf-8");
|
|
@@ -20687,8 +21534,14 @@ module.exports = {
|
|
|
20687
21534
|
renderAccountSubscriptions: renderAccountSubscriptions,
|
|
20688
21535
|
renderCookiePreferences: renderCookiePreferences,
|
|
20689
21536
|
renderSurveyPage: renderSurveyPage,
|
|
21537
|
+
renderSuggestionsPage: renderSuggestionsPage,
|
|
20690
21538
|
renderNewsletterError: renderNewsletterError,
|
|
20691
21539
|
renderNotFound: renderNotFound,
|
|
21540
|
+
// Sidebar-widget render builders exposed so the dual-render parity test can
|
|
21541
|
+
// pin the container output byte-for-byte against the edge twin.
|
|
21542
|
+
buildSidebarWidget: _buildSidebarWidget,
|
|
21543
|
+
buildSidebarRail: _buildSidebarRail,
|
|
21544
|
+
sidebarPageKeyForPath: _sidebarPageKeyForPath,
|
|
20692
21545
|
// Layout exposed so operators forking the framework can override.
|
|
20693
21546
|
_wrap: _wrap,
|
|
20694
21547
|
LAYOUT: LAYOUT,
|