@blamejs/blamejs-shop 0.5.21 → 0.5.23

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.
Files changed (73) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/README.md +2 -1
  3. package/SECURITY.md +25 -1
  4. package/lib/addresses.js +16 -2
  5. package/lib/admin.js +116 -1
  6. package/lib/affiliates.js +3 -3
  7. package/lib/announcement-bar.js +2 -2
  8. package/lib/api-keys.js +2 -2
  9. package/lib/asset-manifest.json +5 -5
  10. package/lib/blog-articles.js +2 -2
  11. package/lib/carrier-accounts.js +2 -2
  12. package/lib/catalog-drafts.js +1 -1
  13. package/lib/category-navigation.js +6 -3
  14. package/lib/compliance-export.js +40 -3
  15. package/lib/customer-impersonation.js +105 -7
  16. package/lib/customer-notes.js +1 -1
  17. package/lib/customer-roles.js +1 -1
  18. package/lib/customer-surveys.js +2 -2
  19. package/lib/customers.js +1 -1
  20. package/lib/dispute-resolution.js +3 -3
  21. package/lib/dropship-forwarding.js +1 -1
  22. package/lib/dunning.js +1 -1
  23. package/lib/email-ab-tests.js +1 -1
  24. package/lib/email-campaigns.js +15 -2
  25. package/lib/email-warmup.js +1 -1
  26. package/lib/externaldb-d1.js +0 -8
  27. package/lib/gift-options.js +3 -3
  28. package/lib/knowledge-base.js +3 -3
  29. package/lib/line-gift-wrap.js +6 -2
  30. package/lib/live-chat.js +1 -1
  31. package/lib/notifications.js +1 -1
  32. package/lib/operator-accounts.js +1 -1
  33. package/lib/operator-help-center.js +3 -3
  34. package/lib/operator-inbox.js +2 -2
  35. package/lib/operator-roles.js +1 -1
  36. package/lib/order-notes.js +2 -2
  37. package/lib/order-ratings.js +3 -3
  38. package/lib/payment-methods.js +1 -1
  39. package/lib/payment-retries.js +1 -1
  40. package/lib/pixel-events.js +14 -6
  41. package/lib/plan-changes.js +1 -1
  42. package/lib/product-qa.js +1 -1
  43. package/lib/push-notifications.js +2 -2
  44. package/lib/reviews.js +3 -3
  45. package/lib/seller-signup.js +4 -4
  46. package/lib/sidebar-widgets.js +1 -1
  47. package/lib/sms-dispatcher.js +3 -3
  48. package/lib/stock-receipts.js +1 -1
  49. package/lib/storefront-dashboards.js +1 -1
  50. package/lib/storefront-forms.js +1 -1
  51. package/lib/storefront-pages.js +2 -2
  52. package/lib/storefront.js +569 -1
  53. package/lib/subscription-billing.js +1 -1
  54. package/lib/subscription-controls.js +1 -1
  55. package/lib/suggestion-box.js +4 -4
  56. package/lib/support-tickets.js +3 -3
  57. package/lib/tenants.js +1 -1
  58. package/lib/text-guard.js +44 -2
  59. package/lib/theme-assets.js +1 -1
  60. package/lib/vendor/MANIFEST.json +12 -12
  61. package/lib/vendor/blamejs/CHANGELOG.md +32 -0
  62. package/lib/vendor/blamejs/NOTICE +1 -1
  63. package/lib/vendor/blamejs/README.md +1 -1
  64. package/lib/vendor/blamejs/lib/network-tls.js +98 -13
  65. package/lib/vendor/blamejs/lib/vendor/MANIFEST.json +12 -12
  66. package/lib/vendor/blamejs/lib/vendor/blamejs-pki.cjs +568 -304
  67. package/lib/vendor/blamejs/package.json +1 -1
  68. package/lib/vendor/blamejs/sbom.cdx.json +6 -6
  69. package/lib/vendors.js +3 -3
  70. package/lib/webhook-receiver.js +1 -1
  71. package/lib/webhook-subscriptions.js +1 -1
  72. package/lib/wishlist-sharing.js +1 -1
  73. package/package.json +1 -1
package/lib/storefront.js CHANGED
@@ -352,6 +352,14 @@ var LAYOUT =
352
352
  " </div>\n" +
353
353
  " </header>\n" +
354
354
  "\n" +
355
+ // Empty on every render, on both substrates — the session-chrome island
356
+ // fills it when /cart/count reports this browser is an operator viewing a
357
+ // customer's account. Emitting it always (rather than server-rendering the
358
+ // banner) keeps the edge-cached body identical for every visitor, so the
359
+ // cache still serves one page to everyone. It sits OUTSIDE <main> because
360
+ // the render-parity tests compare that region byte for byte.
361
+ " <div id=\"impersonation-banner\"></div>\n" +
362
+ "\n" +
355
363
  " <div class=\"page-shell\">\n" +
356
364
  " <main id=\"main\">{{body}}</main>\n" +
357
365
  "RAW_SIDEBAR_RAIL" +
@@ -5602,6 +5610,91 @@ function renderAccountDelete(opts) {
5602
5610
  });
5603
5611
  }
5604
5612
 
5613
+ // The confirmation an operator sees before entering a customer's account.
5614
+ //
5615
+ // Exists so that FOLLOWING the handoff link changes nothing: a prefetcher, a
5616
+ // link-preview bot or a scanner that fetches this URL gets a page, not a
5617
+ // session. Only the POST underneath spends the token. It also puts the two
5618
+ // facts the operator should see before they are inside someone else's account
5619
+ // in front of them — whose account, and the reason they themselves gave.
5620
+ function renderImpersonationConfirm(opts) {
5621
+ var esc = b.template.escapeHtml;
5622
+ var who = opts.customer_name
5623
+ ? esc(String(opts.customer_name)) + " (" + esc(String(opts.customer_id)) + ")"
5624
+ : esc(String(opts.customer_id));
5625
+ var until = Number(opts.expires_at);
5626
+ var untilStr = Number.isFinite(until)
5627
+ ? new Date(until).toISOString().slice(0, 16).replace("T", " ") + " UTC"
5628
+ : "one hour";
5629
+ var csrf = opts.csrf_token
5630
+ ? "<input type=\"hidden\" name=\"_csrf\" value=\"" + esc(String(opts.csrf_token)) + "\">"
5631
+ : "";
5632
+ var body =
5633
+ "<section class=\"return-form-page\">" +
5634
+ "<h1 class=\"return-form-page__title\">View the store as this customer?</h1>" +
5635
+ "<p class=\"form-notice\" role=\"note\">You are about to browse as <strong>" + who + "</strong>. " +
5636
+ "Everything you do will be recorded against this session and shown to the customer " +
5637
+ "on their own account page. The session ends automatically at " + esc(untilStr) + ".</p>" +
5638
+ "<p class=\"form-notice\" role=\"note\">Your stated reason: <em>" +
5639
+ esc(String(opts.reason || "—")) + "</em></p>" +
5640
+ "<p class=\"form-notice\" role=\"note\">You will not be able to change their password, " +
5641
+ "passkeys, email address or linked sign-ins, delete the account, or export their data.</p>" +
5642
+ "<form method=\"post\" action=\"/account/impersonate/start\">" +
5643
+ csrf +
5644
+ "<input type=\"hidden\" name=\"token\" value=\"" + esc(String(opts.token || "")) + "\">" +
5645
+ "<div class=\"actions-row\">" +
5646
+ "<button type=\"submit\" class=\"btn-primary\">Start viewing as this customer</button>" +
5647
+ "<a class=\"btn-secondary\" href=\"/\">Cancel</a>" +
5648
+ "</div>" +
5649
+ "</form>" +
5650
+ "</section>";
5651
+ return _wrap({
5652
+ title: "View as customer",
5653
+ shop_name: opts.shop_name || "blamejs.shop",
5654
+ cart_count: opts.cart_count == null ? 0 : opts.cart_count,
5655
+ theme_css: opts.theme_css,
5656
+ body: body,
5657
+ });
5658
+ }
5659
+
5660
+ // Shown to an operator who reached a credential surface while viewing a
5661
+ // customer's account. Addressed to the operator, not the customer — nobody
5662
+ // else can see this page, because reaching it requires the impersonation
5663
+ // cookie. It names the boundary rather than just refusing, so the operator
5664
+ // knows the account is intact and what to do instead.
5665
+ function renderAccountImpersonationRefused(opts) {
5666
+ var esc = b.template.escapeHtml;
5667
+ var body =
5668
+ "<section class=\"return-form-page\">" +
5669
+ "<h1 class=\"return-form-page__title\">Not available while viewing as a customer</h1>" +
5670
+ "<p class=\"form-notice form-notice--error\" role=\"alert\">" +
5671
+ "You are signed in as this customer for support. Anything that decides who owns " +
5672
+ "the account — a passkey, a linked sign-in, the email address, erasure, or a data " +
5673
+ "export — stays closed while you are here, so a support session can never become " +
5674
+ "a takeover and you cannot lock the customer out." +
5675
+ "</p>" +
5676
+ "<p class=\"form-notice\" role=\"note\">Refused: <code>" + esc(String(opts.path || "")) + "</code>. " +
5677
+ "This attempt is recorded against the session. If the customer needs one of these " +
5678
+ "changed, walk them through it on their own device.</p>" +
5679
+ "<div class=\"actions-row\">" +
5680
+ "<a class=\"btn-secondary\" href=\"/account\">Back to the account</a>" +
5681
+ "<form method=\"post\" action=\"/account/impersonate/end\" style=\"display:inline\">" +
5682
+ (opts.csrf_token
5683
+ ? "<input type=\"hidden\" name=\"_csrf\" value=\"" + esc(String(opts.csrf_token)) + "\">"
5684
+ : "") +
5685
+ "<button type=\"submit\" class=\"btn-primary\">Stop viewing as customer</button>" +
5686
+ "</form>" +
5687
+ "</div>" +
5688
+ "</section>";
5689
+ return _wrap({
5690
+ title: "Not available while viewing as a customer",
5691
+ shop_name: opts.shop_name || "blamejs.shop",
5692
+ cart_count: opts.cart_count == null ? 0 : opts.cart_count,
5693
+ theme_css: opts.theme_css,
5694
+ body: body,
5695
+ });
5696
+ }
5697
+
5605
5698
  // Loyalty transaction-type pill — reuses the `pdp__badge` class the
5606
5699
  // theme already styles. The type is one of the ledger's closed enum
5607
5700
  // (earn / redeem / expire / adjust / tier-bonus).
@@ -9742,6 +9835,17 @@ var SESSION_COOKIE_NAME = "shop_sid";
9742
9835
  // initiated logout-everywhere).
9743
9836
  var AUTH_COOKIE_NAME = "shop_auth";
9744
9837
 
9838
+ // How many support-session records the account page shows per page. Paged
9839
+ // rather than capped: the rows are the customer's only view of who opened
9840
+ // their account, so an older one must stay reachable however many there are.
9841
+ var ACCESS_PAGE_SIZE = 10;
9842
+
9843
+ // Longest page path the impersonation action log will store, matching that
9844
+ // primitive's own `resource_id` cap. Kept in step deliberately: a value this
9845
+ // side accepts and the primitive refuses would be dropped by the recorder's
9846
+ // catch, losing the page from the trail silently.
9847
+ var IMPERSONATION_PATH_MAX = 256;
9848
+
9745
9849
  // Cookie-prefix-hardened names for the two Path=/ session cookies. The
9746
9850
  // `__Host-` prefix is a browser-enforced integrity marker (RFC 6265bis
9747
9851
  // §4.1.3.2): a `__Host-`-named cookie is only stored when it was set
@@ -10659,6 +10763,68 @@ var ACCOUNT_DASH_ORDER_ROW =
10659
10763
  " <td data-label=\"Actions\">RAW_ACCOUNT_ORDER_ACTIONS</td>\n" +
10660
10764
  "</tr>\n";
10661
10765
 
10766
+ // "Account access" — every time a support operator opened this account,
10767
+ // shown to the person it belongs to.
10768
+ //
10769
+ // This IS the customer's notification. The store keeps their email as a hash
10770
+ // only, so there is no address to write to; a notice that cannot be delivered
10771
+ // is not a notice, and stamping one as sent would put a false line in an audit
10772
+ // trail. Rendered here it needs no address, cannot bounce, and cannot fail
10773
+ // silently — the customer sees it the next time they sign in.
10774
+ //
10775
+ // Renders NOTHING when the account has never been opened, so an ordinary
10776
+ // customer is never shown a scary empty security panel.
10777
+ function _accountAccessSection(sessions, page, hasMore) {
10778
+ if (!Array.isArray(sessions) || !sessions.length) return "";
10779
+ var esc = b.template.escapeHtml;
10780
+ page = page || 0;
10781
+
10782
+ var rows = sessions.map(function (s) {
10783
+ var started = Number(s.started_at);
10784
+ var when = Number.isFinite(started)
10785
+ ? new Date(started).toISOString().slice(0, 16).replace("T", " ") + " UTC"
10786
+ : "—";
10787
+ // `ended` and `expired` both mean the session is over; only `active`
10788
+ // means someone may be looking right now, and saying so plainly is the
10789
+ // point of the panel.
10790
+ var live = s.status === "active";
10791
+ return "<tr>" +
10792
+ "<td>" + esc(when) + "</td>" +
10793
+ "<td>" + esc(String(s.reason || "—")) + "</td>" +
10794
+ "<td>" + (live
10795
+ ? "<strong>in progress now</strong>"
10796
+ : esc(String(s.status || "ended"))) + "</td>" +
10797
+ "</tr>";
10798
+ }).join("");
10799
+
10800
+ return "<section class=\"account-access\" aria-labelledby=\"account-access-title\">" +
10801
+ "<h2 id=\"account-access-title\">Account access by our support team</h2>" +
10802
+ "<p>For your security we record every time a member of our team opened your " +
10803
+ "account to help with a problem, and why. They cannot change your password, " +
10804
+ "your passkeys, your email address, or any other way of signing in — and they " +
10805
+ "cannot delete your account or export your data.</p>" +
10806
+ "<table class=\"account-access__table\">" +
10807
+ "<thead><tr><th scope=\"col\">When</th><th scope=\"col\">Reason</th>" +
10808
+ "<th scope=\"col\">Status</th></tr></thead>" +
10809
+ "<tbody>" + rows + "</tbody>" +
10810
+ "</table>" +
10811
+ // Paging, not truncation: this is the customer's only view of who opened
10812
+ // their account, so an older session must never become unreachable.
10813
+ "<div class=\"actions-row\">" +
10814
+ (page > 0
10815
+ ? "<a class=\"btn-secondary\" href=\"/account?access_page=" + (page - 1) +
10816
+ "#account-access-title\">Newer</a>"
10817
+ : "") +
10818
+ (hasMore
10819
+ ? "<a class=\"btn-secondary\" href=\"/account?access_page=" + (page + 1) +
10820
+ "#account-access-title\">Older</a>"
10821
+ : "") +
10822
+ "</div>" +
10823
+ "<p class=\"form-notice\" role=\"note\">If any of this looks wrong, contact us — " +
10824
+ "reply to any order email and mention the date above.</p>" +
10825
+ "</section>";
10826
+ }
10827
+
10662
10828
  function renderAccount(opts) {
10663
10829
  if (!opts || !opts.customer) throw new TypeError("storefront.renderAccount: opts.customer required");
10664
10830
  var orders = opts.orders || [];
@@ -10751,6 +10917,8 @@ function renderAccount(opts) {
10751
10917
  .replace("RAW_QUOTES_LINK", opts.quotes_enabled
10752
10918
  ? "<a class=\"btn-secondary\" href=\"/account/quotes\">Quotes</a>"
10753
10919
  : "");
10920
+ body += _accountAccessSection(
10921
+ opts.account_accesses, opts.account_access_page, opts.account_access_has_more);
10754
10922
  return _wrap({
10755
10923
  title: "Account",
10756
10924
  shop_name: opts.shop_name || "blamejs.shop",
@@ -12453,6 +12621,133 @@ function mount(router, deps) {
12453
12621
  // request. Resolution is best-effort (falls back to the English
12454
12622
  // baseline). The audit-log write is fired off without awaiting so it
12455
12623
  // never blocks the render. Only mounted when the router exposes `.use`.
12624
+ // An impersonating operator can act as the customer — fix a cart, update an
12625
+ // address, place an order. They cannot touch anything that decides WHO the
12626
+ // account belongs to. That distinction is the whole reason impersonation is
12627
+ // defensible: a support session must never become a takeover, and an
12628
+ // operator must never be able to lock a customer out of their own account.
12629
+ //
12630
+ // Prefix-matched, so a route added under one of these later is closed by
12631
+ // where it lives rather than by its author remembering.
12632
+ var IMPERSONATION_CLOSED_PREFIXES = [
12633
+ "/account/delete", // destroying the account
12634
+ "/account/passkey/", // enrolling a credential
12635
+ "/account/passkeys/", // revoking someone else's credential
12636
+ "/account/auth/", // linking an identity provider
12637
+ "/account/login/link", // mailing a sign-in link to the address
12638
+ "/account/privacy/", // exporting or erasing the whole record
12639
+ ];
12640
+
12641
+ // ---- impersonation guard, ahead of EVERY route ------------------------
12642
+ //
12643
+ // Mounted here rather than beside the account routes because `router.use`
12644
+ // only sees routes registered after it. Down there it would have left
12645
+ // /cart, /checkout and their mutation endpoints uncovered — so an operator
12646
+ // whose session had been ended could still have gone on spending the
12647
+ // customer's money until the cookie expired, which is the precise opposite
12648
+ // of the next-request revocation this feature promises.
12649
+ //
12650
+ // Ordinary visitors leave in the first line: no marker on the cookie, no
12651
+ // work, no database read.
12652
+ if (typeof router.use === "function" && deps.customerImpersonation) {
12653
+ router.use(async function impersonationGuard(req, res, next) {
12654
+ var imp = _impersonationOf(req);
12655
+ if (!imp) return next();
12656
+
12657
+ // Is the session behind this cookie still live? Re-read on every
12658
+ // impersonated request so `end` and `revoke` bite on the operator's
12659
+ // very next click rather than whenever the cookie happens to expire.
12660
+ var live = await _impersonationStillLive(_currentCustomerEnv(req));
12661
+ if (!live) {
12662
+ _clearAuthCookie(req, res);
12663
+ res.status(303);
12664
+ res.setHeader && res.setHeader("location",
12665
+ "/account/login?error=" + encodeURIComponent("that view-as-customer session has ended"));
12666
+ return res.end ? res.end() : res.send("");
12667
+ }
12668
+
12669
+ var pathname = req.pathname || req.url || "/";
12670
+ var q = pathname.indexOf("?");
12671
+ if (q !== -1) pathname = pathname.slice(0, q);
12672
+
12673
+ // Everything the operator does under this session is recorded, not just
12674
+ // what is refused — that is the whole basis on which impersonation is
12675
+ // defensible. Reads and writes both: knowing an operator LOOKED at an
12676
+ // order is as much a part of the trail as knowing they changed it.
12677
+ // Awaited, so the row is durable before the response goes out and a
12678
+ // failure lands in the surrounding handler rather than as an unhandled
12679
+ // rejection.
12680
+ // "Sign out" means LEAVE, not "sign the customer out".
12681
+ //
12682
+ // The account page's own sign-out control posts to /account/logout,
12683
+ // which revokes every session the customer has and invalidates their
12684
+ // pending sign-in links. An operator reaching for it means "get me out
12685
+ // of here" — and letting it through would log the customer out of their
12686
+ // phone and laptop, which is precisely the lock-out this feature
12687
+ // promises an operator cannot cause. Ending the impersonation is what
12688
+ // they meant, so that is what it does.
12689
+ if (pathname === "/account/logout") {
12690
+ // Recorded before it is acted on — leaving is part of the trail, and
12691
+ // this path returns before the common recording block below.
12692
+ if (typeof deps.customerImpersonation.actionsRecord === "function") {
12693
+ try {
12694
+ await deps.customerImpersonation.actionsRecord({
12695
+ impersonation_id: imp.impersonation_id,
12696
+ action: "exit",
12697
+ resource_kind: "route",
12698
+ resource_id: pathname,
12699
+ });
12700
+ } catch (_e) { /* observability sink — must not block the exit */ }
12701
+ }
12702
+ try {
12703
+ await deps.customerImpersonation.endImpersonation({
12704
+ impersonation_id: imp.impersonation_id,
12705
+ ended_by: "operator",
12706
+ reason: "operator signed out of the session",
12707
+ });
12708
+ } catch (_e) { /* clearing the cookie below is what protects the customer */ }
12709
+ _clearAuthCookie(req, res);
12710
+ res.status(303);
12711
+ res.setHeader && res.setHeader("location", "/");
12712
+ return res.end ? res.end() : res.send("");
12713
+ }
12714
+
12715
+ var closed = IMPERSONATION_CLOSED_PREFIXES.some(function (p) {
12716
+ return pathname === p || pathname.indexOf(p) === 0;
12717
+ });
12718
+
12719
+ if (typeof deps.customerImpersonation.actionsRecord === "function") {
12720
+ try {
12721
+ await deps.customerImpersonation.actionsRecord({
12722
+ impersonation_id: imp.impersonation_id,
12723
+ action: closed ? "refused" : String(req.method || "GET").toUpperCase(),
12724
+ resource_kind: closed ? "credential_surface" : "route",
12725
+ resource_id: pathname,
12726
+ });
12727
+ } catch (_e) {
12728
+ // Drop-silent by design: this is an observability sink on the hot
12729
+ // path, and a failed audit insert must not take down the request
12730
+ // the operator is making. The refusal below still happens.
12731
+ }
12732
+ }
12733
+
12734
+ if (!closed) return next();
12735
+ res.status(403);
12736
+ return _send(res, 403, renderAccountImpersonationRefused({
12737
+ shop_name: shopName,
12738
+ path: pathname,
12739
+ // Passed explicitly. This guard runs ahead of every route — including
12740
+ // ahead of the locale middleware that normally puts the token where
12741
+ // `_wrap` finds it — so without this the exit form on the refusal page
12742
+ // would carry no `_csrf` and the csrf guard would reject it. That
12743
+ // button is the operator's way OUT of someone else's account; a
12744
+ // silently dead one is worse than none. The app-level csrf middleware
12745
+ // has already run by here, so the token is on the request.
12746
+ csrf_token: req.csrfToken,
12747
+ }));
12748
+ });
12749
+ }
12750
+
12456
12751
  if (typeof router.use === "function") {
12457
12752
  router.use(function localeMiddleware(req, _res, next) {
12458
12753
  try {
@@ -13218,6 +13513,53 @@ function mount(router, deps) {
13218
13513
  return env;
13219
13514
  }
13220
13515
 
13516
+ // ---- operator impersonation ------------------------------------------
13517
+ //
13518
+ // An impersonated session is an ordinary auth cookie whose sealed envelope
13519
+ // carries `imp` — the impersonation row's id. Everything downstream reads
13520
+ // the target customer through the same `_currentCustomerEnv`, so the
13521
+ // operator sees precisely the storefront that customer sees, with no
13522
+ // parallel code path to drift out of sync.
13523
+ //
13524
+ // Two things hang off the marker: the banner (so the operator can never
13525
+ // forget whose account they are in) and the credential-surface refusal.
13526
+
13527
+ // Whether THIS request is an impersonation, and of what. Returns null for
13528
+ // an ordinary visitor, so a caller can branch on truthiness.
13529
+ function _impersonationOf(req) {
13530
+ var env = _currentCustomerEnv(req);
13531
+ if (!env || typeof env.imp !== "string" || !env.imp.length) return null;
13532
+ return { impersonation_id: env.imp, customer_id: env.customer_id };
13533
+ }
13534
+
13535
+ // Is the impersonation row behind this cookie STILL live?
13536
+ //
13537
+ // The cookie is self-validating for its own lifetime, which would leave an
13538
+ // operator browsing as a customer for hours after the session was ended or
13539
+ // revoked. Every impersonated request re-reads the row, so `end` and
13540
+ // `revoke` take effect on the operator's very next click rather than at
13541
+ // cookie expiry. Only impersonated requests pay for the read.
13542
+ //
13543
+ // Fails CLOSED, unlike the customer-revocation gate beside it: that one
13544
+ // fails open because a D1 blip must not sign out every shopper, but here a
13545
+ // blip must not extend an operator's authority over someone else's account.
13546
+ // The cost of failing closed is that the operator signs in again.
13547
+ async function _impersonationStillLive(env) {
13548
+ if (!env || typeof env.imp !== "string" || !env.imp.length) return true;
13549
+ if (!deps.customerImpersonation ||
13550
+ typeof deps.customerImpersonation.getSession !== "function") {
13551
+ return false;
13552
+ }
13553
+ var row;
13554
+ try { row = await deps.customerImpersonation.getSession(env.imp); }
13555
+ catch (_e) { return false; }
13556
+ if (!row || row.status !== "active") return false;
13557
+ if (Number(row.expires_at) <= Date.now()) return false;
13558
+ // The row must still name the customer the cookie claims, so a resealed
13559
+ // or replayed envelope cannot point a live session at a different account.
13560
+ return row.customer_id === env.customer_id;
13561
+ }
13562
+
13221
13563
  // Server-side session-revocation gate. The sealed cookie is otherwise
13222
13564
  // self-validating for its 14-day TTL, so erasure / passkey-revoke /
13223
13565
  // sign-out have no way to kill a LIVE cookie without this check. Resolves
@@ -14757,7 +15099,62 @@ function mount(router, deps) {
14757
15099
  res.status(200);
14758
15100
  res.setHeader && res.setHeader("content-type", "application/json; charset=utf-8");
14759
15101
  res.setHeader && res.setHeader("cache-control", "no-store");
14760
- var payload = JSON.stringify({ count: count });
15102
+ // The impersonation flag rides this response rather than getting an
15103
+ // endpoint of its own. Every page already calls this to fill the cart
15104
+ // count, including the edge-cached ones where per-session chrome cannot
15105
+ // be rendered server-side — so this is the one place a banner can be
15106
+ // raised on a cached product page without routing the visitor to the
15107
+ // container and destroying the cache hit rate.
15108
+ //
15109
+ // Read from the sealed cookie only: no database round trip, so an
15110
+ // ordinary shopper's call costs exactly what it did before. The
15111
+ // per-request liveness check still runs on the routes that matter; a
15112
+ // banner shown a few seconds after a session ended is harmless, and the
15113
+ // operator's next real request is refused regardless.
15114
+ var imp = _impersonationOf(req);
15115
+
15116
+ // Record the page the island reported. With edge rendering on, most of
15117
+ // the operator's browsing is answered by the Worker and never reaches the
15118
+ // guard, so without this the session's trail would be a run of
15119
+ // /cart/count calls naming nothing the operator actually looked at.
15120
+ //
15121
+ // The value comes from a browser and is treated that way: screened
15122
+ // through the shared codepoint guard, required to look like a rooted
15123
+ // path, and length-capped. A value that fails any of those is dropped
15124
+ // rather than refused — this is an observability sink on a hot path, and
15125
+ // the cart count must still come back.
15126
+ if (imp && deps.customerImpersonation &&
15127
+ typeof deps.customerImpersonation.actionsRecord === "function") {
15128
+ var reported = null;
15129
+ try {
15130
+ var u = req.url ? new URL(req.url, "http://localhost") : null;
15131
+ var p = u && u.searchParams.get("p");
15132
+ // 256 is the action log's own cap on `resource_id`. Accepting more
15133
+ // here would hand the primitive a value it refuses, and the catch
15134
+ // below would swallow the refusal — so a long product URL would
15135
+ // vanish from the trail without a trace. Truncate rather than drop:
15136
+ // a shortened path still says which page, and losing the record
15137
+ // entirely is the worse failure.
15138
+ if (typeof p === "string" && p.length && p.charAt(0) === "/" &&
15139
+ !textGuard.hasCodepointThreat(p, { singleLine: "reject" })) {
15140
+ reported = p.length > IMPERSONATION_PATH_MAX ? p.slice(0, IMPERSONATION_PATH_MAX) : p;
15141
+ }
15142
+ } catch (_e) { reported = null; }
15143
+ if (reported) {
15144
+ try {
15145
+ await deps.customerImpersonation.actionsRecord({
15146
+ impersonation_id: imp.impersonation_id,
15147
+ action: "VIEW",
15148
+ resource_kind: "page",
15149
+ resource_id: reported,
15150
+ });
15151
+ } catch (_e) { /* drop-silent — the cart count must still return */ }
15152
+ }
15153
+ }
15154
+
15155
+ var payload = JSON.stringify(imp
15156
+ ? { count: count, impersonating: { customer_id: imp.customer_id } }
15157
+ : { count: count });
14761
15158
  return res.end ? res.end(payload) : res.send(payload);
14762
15159
  });
14763
15160
 
@@ -16179,6 +16576,12 @@ function mount(router, deps) {
16179
16576
  var rpId = deps.rpId || (deps.shop_origin ? new URL(deps.shop_origin).hostname : "localhost");
16180
16577
  var expectedOrigin = deps.shop_origin || ("https://" + rpId);
16181
16578
 
16579
+ // The impersonation guard that closes these surfaces is mounted at the
16580
+ // TOP of this router, ahead of every route — see `impersonationGuard`
16581
+ // near the locale middleware. It cannot live here: `router.use` only
16582
+ // applies to routes registered after it, and /cart and /checkout are
16583
+ // registered before this block.
16584
+
16182
16585
  function _b64u(buf) {
16183
16586
  return b.crypto.toBase64Url(buf);
16184
16587
  }
@@ -16447,6 +16850,134 @@ function mount(router, deps) {
16447
16850
  });
16448
16851
  }
16449
16852
 
16853
+ // ---- impersonation: redeem, and exit --------------------------------
16854
+ //
16855
+ // The operator arrives here from the admin console holding the one-time
16856
+ // bearer the primitive minted. Redeeming it seals an ordinary auth cookie
16857
+ // for the target customer, marked with the impersonation row's id.
16858
+ //
16859
+ // Two steps, and the split is load-bearing.
16860
+ //
16861
+ // The GET only LOOKS: it verifies the token and paints a confirmation page
16862
+ // naming the customer and the stated reason. It changes nothing. The POST
16863
+ // spends the token and mints the session.
16864
+ //
16865
+ // Doing both in the GET would mean anything that follows a link ahead of
16866
+ // the operator — a browser prefetcher, a link-preview bot in whatever chat
16867
+ // the console was open next to, a security scanner walking history — burns
16868
+ // the one-time bearer. The operator then arrives to be told their link is
16869
+ // invalid, and something that is not the operator is holding the only
16870
+ // session that link will ever mint. A GET must not spend a credential.
16871
+ //
16872
+ // The confirmation is worth having on its own: the operator sees whose
16873
+ // account they are about to enter, and why they said they were entering
16874
+ // it, before they are inside it.
16875
+ var _impersonationRefused = function (res) {
16876
+ // One outcome for every failure — expired, ended, revoked, already
16877
+ // spent, never existed. An operator who mistypes learns nothing about
16878
+ // which, and a visitor guessing URLs learns nothing at all.
16879
+ res.status(303);
16880
+ res.setHeader && res.setHeader("location",
16881
+ "/account/login?error=" + encodeURIComponent("view-as-customer link is no longer valid"));
16882
+ return res.end ? res.end() : res.send("");
16883
+ };
16884
+
16885
+ if (deps.customerImpersonation) {
16886
+ router.get("/account/impersonate/:token", async function (req, res) {
16887
+ var ctx = null;
16888
+ try { ctx = await deps.customerImpersonation.verifyImpersonationToken(req.params.token); }
16889
+ catch (_e) { ctx = null; }
16890
+ if (!ctx) return _impersonationRefused(res);
16891
+
16892
+ var target = null;
16893
+ try { target = await deps.customers.get(ctx.customer_id); }
16894
+ catch (_e) { target = null; }
16895
+
16896
+ return _send(res, 200, renderImpersonationConfirm({
16897
+ shop_name: shopName,
16898
+ token: req.params.token,
16899
+ customer_id: ctx.customer_id,
16900
+ customer_name: (target && target.display_name) || "",
16901
+ reason: ctx.reason,
16902
+ expires_at: ctx.expires_at,
16903
+ csrf_token: req.csrfToken,
16904
+ }));
16905
+ });
16906
+
16907
+ router.post("/account/impersonate/start", async function (req, res) {
16908
+ var token = (req.body && req.body.token) || "";
16909
+ var ctx = null;
16910
+ try { ctx = await deps.customerImpersonation.verifyImpersonationToken(token); }
16911
+ catch (_e) { ctx = null; }
16912
+ if (!ctx) return _impersonationRefused(res);
16913
+
16914
+ // The claim authorizes the cookie, not the verify above: verification
16915
+ // is a read, so two requests carrying the same token can both pass it.
16916
+ // Only the one whose UPDATE still matched the hash gets a session.
16917
+ // Fails CLOSED on a handle that cannot claim. An older or custom
16918
+ // implementation without `consumeToken` would otherwise skip the claim
16919
+ // and still be handed a cookie, turning the single-use link back into
16920
+ // a bearer any number of holders could replay for the session's life.
16921
+ // No claim, no session.
16922
+ if (typeof deps.customerImpersonation.consumeToken !== "function") {
16923
+ return _impersonationRefused(res);
16924
+ }
16925
+ var claim = null;
16926
+ try { claim = await deps.customerImpersonation.consumeToken(ctx.impersonation_id, token); }
16927
+ catch (_e) { claim = null; }
16928
+ if (!claim || !claim.consumed) return _impersonationRefused(res);
16929
+
16930
+ // If this browser is already in a session, close it before the cookie
16931
+ // naming it is overwritten. The cookie is the only handle on it: once
16932
+ // replaced, the operator cannot reach the exit control for the old one
16933
+ // and its row would sit `active` until the hourly sweep — telling the
16934
+ // first customer "in progress now" about a session nobody is in, and
16935
+ // showing operator dashboards a session that has been abandoned.
16936
+ var previous = _impersonationOf(req);
16937
+ if (previous && previous.impersonation_id !== ctx.impersonation_id) {
16938
+ try {
16939
+ await deps.customerImpersonation.endImpersonation({
16940
+ impersonation_id: previous.impersonation_id,
16941
+ ended_by: "operator",
16942
+ reason: "operator moved to another customer's session",
16943
+ });
16944
+ } catch (_e) { /* the new cookie below is what the operator gets either way */ }
16945
+ }
16946
+
16947
+ _setAuthCookie(req, res, {
16948
+ customer_id: ctx.customer_id,
16949
+ // The cookie must not outlive the session it represents. The auth
16950
+ // cookie's usual 14 days would leave a sealed envelope naming a
16951
+ // dead impersonation long after it ended; the per-request liveness
16952
+ // read would refuse it, but there is no reason to hand out a
16953
+ // credential that outlives its authority.
16954
+ exp: ctx.expires_at,
16955
+ imp: ctx.impersonation_id,
16956
+ });
16957
+ res.status(303); res.setHeader && res.setHeader("location", "/account");
16958
+ return res.end ? res.end() : res.send("");
16959
+ });
16960
+
16961
+ // Leaving. Ends the row so the operator's next request is refused by
16962
+ // the liveness read even if the cookie survives, then clears the cookie
16963
+ // so the browser stops carrying the customer's session at all.
16964
+ router.post("/account/impersonate/end", async function (req, res) {
16965
+ var imp = _impersonationOf(req);
16966
+ if (imp) {
16967
+ try {
16968
+ await deps.customerImpersonation.endImpersonation({
16969
+ impersonation_id: imp.impersonation_id,
16970
+ ended_by: "operator",
16971
+ reason: "operator left the session",
16972
+ });
16973
+ } catch (_e) { /* clearing the cookie below is what protects the customer */ }
16974
+ }
16975
+ _clearAuthCookie(req, res);
16976
+ res.status(303); res.setHeader && res.setHeader("location", "/");
16977
+ return res.end ? res.end() : res.send("");
16978
+ });
16979
+ }
16980
+
16450
16981
  router.post("/account/passkey/register-begin", async function (req, res) {
16451
16982
  try {
16452
16983
  var body = _readJsonBody(req);
@@ -16845,11 +17376,48 @@ function mount(router, deps) {
16845
17376
  } catch (_e) { /* drop-silent — primitive may not expose listPasskeys on every build */ }
16846
17377
 
16847
17378
  var cartCount = await _cartCountForReq(req);
17379
+ // Every time an operator has opened this account for support, shown to
17380
+ // the person it belongs to. This is the customer's notification: the
17381
+ // store keeps their email as a hash only, so there is no address to
17382
+ // send to, and a notice that cannot be delivered is not a notice. Here
17383
+ // it needs no address, cannot bounce, and cannot silently fail to send.
17384
+ //
17385
+ // Read-only and best-effort: a lookup failure must never keep a
17386
+ // customer out of their own account.
17387
+ var accountAccesses = [];
17388
+ var accessPage = 0;
17389
+ var accessHasMore = false;
17390
+ if (deps.customerImpersonation &&
17391
+ typeof deps.customerImpersonation.listForCustomer === "function") {
17392
+ var acctUrl = req.url ? new URL(req.url, "http://localhost") : null;
17393
+ // Bounded per page, because these rows are never pruned and an
17394
+ // unbounded read would make this page slower for the rest of the
17395
+ // account's life — worst for the customers support has helped most
17396
+ // often. Paged rather than truncated: this is the customer's only
17397
+ // view of who opened their account, so nothing may become
17398
+ // permanently unreachable. `?access_page=N` walks back.
17399
+ accessPage = Math.max(0, parseInt((acctUrl && acctUrl.searchParams.get("access_page")) || "0", 10) || 0);
17400
+ try {
17401
+ // One extra row than the page shows, purely to learn whether there
17402
+ // is another page — cheaper than a second COUNT query.
17403
+ var fetched = await deps.customerImpersonation.listForCustomer(customer.id, {
17404
+ limit: ACCESS_PAGE_SIZE + 1,
17405
+ offset: accessPage * ACCESS_PAGE_SIZE,
17406
+ });
17407
+ accessHasMore = fetched.length > ACCESS_PAGE_SIZE;
17408
+ accountAccesses = accessHasMore ? fetched.slice(0, ACCESS_PAGE_SIZE) : fetched;
17409
+ }
17410
+ catch (_e) { accountAccesses = []; }
17411
+ }
17412
+
16848
17413
  _send(res, 200, renderAccount({
16849
17414
  asset_prefix: deps.asset_prefix || "/assets/",
16850
17415
  customer: customer,
16851
17416
  orders: orders,
16852
17417
  order_product_lookup: orderProductLookup,
17418
+ account_accesses: accountAccesses,
17419
+ account_access_page: accessPage,
17420
+ account_access_has_more: accessHasMore,
16853
17421
  passkey_count: passkeyCount,
16854
17422
  preorders_enabled: !!preorder,
16855
17423
  pickups_enabled: !!deps.clickAndCollect,
@@ -143,7 +143,7 @@ function _reason(s) {
143
143
  if (s.length > MAX_REASON_LEN) {
144
144
  throw new TypeError("subscriptionBilling: reason must be <= " + MAX_REASON_LEN + " characters");
145
145
  }
146
- textGuard.freeText(s, "subscriptionBilling: reason", { zeroWidth: "reject" });
146
+ textGuard.freeText(s, "subscriptionBilling: reason", { zeroWidth: "reject", bidiMarks: "allow" });
147
147
  return s;
148
148
  }
149
149