@blamejs/blamejs-shop 0.5.22 → 0.5.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/storefront.js CHANGED
@@ -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",
@@ -11939,6 +12107,20 @@ function mount(router, deps) {
11939
12107
  _hasCollections = !!deps.collections;
11940
12108
  _hasCategoryNav = !!deps.categoryNavigation;
11941
12109
 
12110
+ // Deliberately NOT routed through `b.render.htmlString`, unlike the admin
12111
+ // console's equivalent helper.
12112
+ //
12113
+ // The renderer's default is `Cache-Control: private, no-cache,
12114
+ // must-revalidate`, which is right for the admin console — every response
12115
+ // there is signed-in operator data. This helper is not the same shape: it
12116
+ // serves anonymous, edge-cacheable pages (category, collection, help
12117
+ // article, opening hours) from the SAME function as the account pages.
12118
+ // Stamping `private` on all of them would tell the CDN not to cache exactly
12119
+ // the pages the edge exists to serve, trading a real performance loss for no
12120
+ // security gain on a page a logged-out visitor can already fetch.
12121
+ //
12122
+ // The per-route cache directives that do exist are set by the routes that
12123
+ // own them, which is where the public/private decision actually lives.
11942
12124
  function _send(res, status, html) {
11943
12125
  res.status(status);
11944
12126
  res.setHeader && res.setHeader("content-type", "text/html; charset=utf-8");
@@ -12453,6 +12635,133 @@ function mount(router, deps) {
12453
12635
  // request. Resolution is best-effort (falls back to the English
12454
12636
  // baseline). The audit-log write is fired off without awaiting so it
12455
12637
  // never blocks the render. Only mounted when the router exposes `.use`.
12638
+ // An impersonating operator can act as the customer — fix a cart, update an
12639
+ // address, place an order. They cannot touch anything that decides WHO the
12640
+ // account belongs to. That distinction is the whole reason impersonation is
12641
+ // defensible: a support session must never become a takeover, and an
12642
+ // operator must never be able to lock a customer out of their own account.
12643
+ //
12644
+ // Prefix-matched, so a route added under one of these later is closed by
12645
+ // where it lives rather than by its author remembering.
12646
+ var IMPERSONATION_CLOSED_PREFIXES = [
12647
+ "/account/delete", // destroying the account
12648
+ "/account/passkey/", // enrolling a credential
12649
+ "/account/passkeys/", // revoking someone else's credential
12650
+ "/account/auth/", // linking an identity provider
12651
+ "/account/login/link", // mailing a sign-in link to the address
12652
+ "/account/privacy/", // exporting or erasing the whole record
12653
+ ];
12654
+
12655
+ // ---- impersonation guard, ahead of EVERY route ------------------------
12656
+ //
12657
+ // Mounted here rather than beside the account routes because `router.use`
12658
+ // only sees routes registered after it. Down there it would have left
12659
+ // /cart, /checkout and their mutation endpoints uncovered — so an operator
12660
+ // whose session had been ended could still have gone on spending the
12661
+ // customer's money until the cookie expired, which is the precise opposite
12662
+ // of the next-request revocation this feature promises.
12663
+ //
12664
+ // Ordinary visitors leave in the first line: no marker on the cookie, no
12665
+ // work, no database read.
12666
+ if (typeof router.use === "function" && deps.customerImpersonation) {
12667
+ router.use(async function impersonationGuard(req, res, next) {
12668
+ var imp = _impersonationOf(req);
12669
+ if (!imp) return next();
12670
+
12671
+ // Is the session behind this cookie still live? Re-read on every
12672
+ // impersonated request so `end` and `revoke` bite on the operator's
12673
+ // very next click rather than whenever the cookie happens to expire.
12674
+ var live = await _impersonationStillLive(_currentCustomerEnv(req));
12675
+ if (!live) {
12676
+ _clearAuthCookie(req, res);
12677
+ res.status(303);
12678
+ res.setHeader && res.setHeader("location",
12679
+ "/account/login?error=" + encodeURIComponent("that view-as-customer session has ended"));
12680
+ return res.end ? res.end() : res.send("");
12681
+ }
12682
+
12683
+ var pathname = req.pathname || req.url || "/";
12684
+ var q = pathname.indexOf("?");
12685
+ if (q !== -1) pathname = pathname.slice(0, q);
12686
+
12687
+ // Everything the operator does under this session is recorded, not just
12688
+ // what is refused — that is the whole basis on which impersonation is
12689
+ // defensible. Reads and writes both: knowing an operator LOOKED at an
12690
+ // order is as much a part of the trail as knowing they changed it.
12691
+ // Awaited, so the row is durable before the response goes out and a
12692
+ // failure lands in the surrounding handler rather than as an unhandled
12693
+ // rejection.
12694
+ // "Sign out" means LEAVE, not "sign the customer out".
12695
+ //
12696
+ // The account page's own sign-out control posts to /account/logout,
12697
+ // which revokes every session the customer has and invalidates their
12698
+ // pending sign-in links. An operator reaching for it means "get me out
12699
+ // of here" — and letting it through would log the customer out of their
12700
+ // phone and laptop, which is precisely the lock-out this feature
12701
+ // promises an operator cannot cause. Ending the impersonation is what
12702
+ // they meant, so that is what it does.
12703
+ if (pathname === "/account/logout") {
12704
+ // Recorded before it is acted on — leaving is part of the trail, and
12705
+ // this path returns before the common recording block below.
12706
+ if (typeof deps.customerImpersonation.actionsRecord === "function") {
12707
+ try {
12708
+ await deps.customerImpersonation.actionsRecord({
12709
+ impersonation_id: imp.impersonation_id,
12710
+ action: "exit",
12711
+ resource_kind: "route",
12712
+ resource_id: pathname,
12713
+ });
12714
+ } catch (_e) { /* observability sink — must not block the exit */ }
12715
+ }
12716
+ try {
12717
+ await deps.customerImpersonation.endImpersonation({
12718
+ impersonation_id: imp.impersonation_id,
12719
+ ended_by: "operator",
12720
+ reason: "operator signed out of the session",
12721
+ });
12722
+ } catch (_e) { /* clearing the cookie below is what protects the customer */ }
12723
+ _clearAuthCookie(req, res);
12724
+ res.status(303);
12725
+ res.setHeader && res.setHeader("location", "/");
12726
+ return res.end ? res.end() : res.send("");
12727
+ }
12728
+
12729
+ var closed = IMPERSONATION_CLOSED_PREFIXES.some(function (p) {
12730
+ return pathname === p || pathname.indexOf(p) === 0;
12731
+ });
12732
+
12733
+ if (typeof deps.customerImpersonation.actionsRecord === "function") {
12734
+ try {
12735
+ await deps.customerImpersonation.actionsRecord({
12736
+ impersonation_id: imp.impersonation_id,
12737
+ action: closed ? "refused" : String(req.method || "GET").toUpperCase(),
12738
+ resource_kind: closed ? "credential_surface" : "route",
12739
+ resource_id: pathname,
12740
+ });
12741
+ } catch (_e) {
12742
+ // Drop-silent by design: this is an observability sink on the hot
12743
+ // path, and a failed audit insert must not take down the request
12744
+ // the operator is making. The refusal below still happens.
12745
+ }
12746
+ }
12747
+
12748
+ if (!closed) return next();
12749
+ res.status(403);
12750
+ return _send(res, 403, renderAccountImpersonationRefused({
12751
+ shop_name: shopName,
12752
+ path: pathname,
12753
+ // Passed explicitly. This guard runs ahead of every route — including
12754
+ // ahead of the locale middleware that normally puts the token where
12755
+ // `_wrap` finds it — so without this the exit form on the refusal page
12756
+ // would carry no `_csrf` and the csrf guard would reject it. That
12757
+ // button is the operator's way OUT of someone else's account; a
12758
+ // silently dead one is worse than none. The app-level csrf middleware
12759
+ // has already run by here, so the token is on the request.
12760
+ csrf_token: req.csrfToken,
12761
+ }));
12762
+ });
12763
+ }
12764
+
12456
12765
  if (typeof router.use === "function") {
12457
12766
  router.use(function localeMiddleware(req, _res, next) {
12458
12767
  try {
@@ -13218,6 +13527,53 @@ function mount(router, deps) {
13218
13527
  return env;
13219
13528
  }
13220
13529
 
13530
+ // ---- operator impersonation ------------------------------------------
13531
+ //
13532
+ // An impersonated session is an ordinary auth cookie whose sealed envelope
13533
+ // carries `imp` — the impersonation row's id. Everything downstream reads
13534
+ // the target customer through the same `_currentCustomerEnv`, so the
13535
+ // operator sees precisely the storefront that customer sees, with no
13536
+ // parallel code path to drift out of sync.
13537
+ //
13538
+ // Two things hang off the marker: the banner (so the operator can never
13539
+ // forget whose account they are in) and the credential-surface refusal.
13540
+
13541
+ // Whether THIS request is an impersonation, and of what. Returns null for
13542
+ // an ordinary visitor, so a caller can branch on truthiness.
13543
+ function _impersonationOf(req) {
13544
+ var env = _currentCustomerEnv(req);
13545
+ if (!env || typeof env.imp !== "string" || !env.imp.length) return null;
13546
+ return { impersonation_id: env.imp, customer_id: env.customer_id };
13547
+ }
13548
+
13549
+ // Is the impersonation row behind this cookie STILL live?
13550
+ //
13551
+ // The cookie is self-validating for its own lifetime, which would leave an
13552
+ // operator browsing as a customer for hours after the session was ended or
13553
+ // revoked. Every impersonated request re-reads the row, so `end` and
13554
+ // `revoke` take effect on the operator's very next click rather than at
13555
+ // cookie expiry. Only impersonated requests pay for the read.
13556
+ //
13557
+ // Fails CLOSED, unlike the customer-revocation gate beside it: that one
13558
+ // fails open because a D1 blip must not sign out every shopper, but here a
13559
+ // blip must not extend an operator's authority over someone else's account.
13560
+ // The cost of failing closed is that the operator signs in again.
13561
+ async function _impersonationStillLive(env) {
13562
+ if (!env || typeof env.imp !== "string" || !env.imp.length) return true;
13563
+ if (!deps.customerImpersonation ||
13564
+ typeof deps.customerImpersonation.getSession !== "function") {
13565
+ return false;
13566
+ }
13567
+ var row;
13568
+ try { row = await deps.customerImpersonation.getSession(env.imp); }
13569
+ catch (_e) { return false; }
13570
+ if (!row || row.status !== "active") return false;
13571
+ if (Number(row.expires_at) <= Date.now()) return false;
13572
+ // The row must still name the customer the cookie claims, so a resealed
13573
+ // or replayed envelope cannot point a live session at a different account.
13574
+ return row.customer_id === env.customer_id;
13575
+ }
13576
+
13221
13577
  // Server-side session-revocation gate. The sealed cookie is otherwise
13222
13578
  // self-validating for its 14-day TTL, so erasure / passkey-revoke /
13223
13579
  // sign-out have no way to kill a LIVE cookie without this check. Resolves
@@ -14757,7 +15113,62 @@ function mount(router, deps) {
14757
15113
  res.status(200);
14758
15114
  res.setHeader && res.setHeader("content-type", "application/json; charset=utf-8");
14759
15115
  res.setHeader && res.setHeader("cache-control", "no-store");
14760
- var payload = JSON.stringify({ count: count });
15116
+ // The impersonation flag rides this response rather than getting an
15117
+ // endpoint of its own. Every page already calls this to fill the cart
15118
+ // count, including the edge-cached ones where per-session chrome cannot
15119
+ // be rendered server-side — so this is the one place a banner can be
15120
+ // raised on a cached product page without routing the visitor to the
15121
+ // container and destroying the cache hit rate.
15122
+ //
15123
+ // Read from the sealed cookie only: no database round trip, so an
15124
+ // ordinary shopper's call costs exactly what it did before. The
15125
+ // per-request liveness check still runs on the routes that matter; a
15126
+ // banner shown a few seconds after a session ended is harmless, and the
15127
+ // operator's next real request is refused regardless.
15128
+ var imp = _impersonationOf(req);
15129
+
15130
+ // Record the page the island reported. With edge rendering on, most of
15131
+ // the operator's browsing is answered by the Worker and never reaches the
15132
+ // guard, so without this the session's trail would be a run of
15133
+ // /cart/count calls naming nothing the operator actually looked at.
15134
+ //
15135
+ // The value comes from a browser and is treated that way: screened
15136
+ // through the shared codepoint guard, required to look like a rooted
15137
+ // path, and length-capped. A value that fails any of those is dropped
15138
+ // rather than refused — this is an observability sink on a hot path, and
15139
+ // the cart count must still come back.
15140
+ if (imp && deps.customerImpersonation &&
15141
+ typeof deps.customerImpersonation.actionsRecord === "function") {
15142
+ var reported = null;
15143
+ try {
15144
+ var u = req.url ? new URL(req.url, "http://localhost") : null;
15145
+ var p = u && u.searchParams.get("p");
15146
+ // 256 is the action log's own cap on `resource_id`. Accepting more
15147
+ // here would hand the primitive a value it refuses, and the catch
15148
+ // below would swallow the refusal — so a long product URL would
15149
+ // vanish from the trail without a trace. Truncate rather than drop:
15150
+ // a shortened path still says which page, and losing the record
15151
+ // entirely is the worse failure.
15152
+ if (typeof p === "string" && p.length && p.charAt(0) === "/" &&
15153
+ !textGuard.hasCodepointThreat(p, { singleLine: "reject" })) {
15154
+ reported = p.length > IMPERSONATION_PATH_MAX ? p.slice(0, IMPERSONATION_PATH_MAX) : p;
15155
+ }
15156
+ } catch (_e) { reported = null; }
15157
+ if (reported) {
15158
+ try {
15159
+ await deps.customerImpersonation.actionsRecord({
15160
+ impersonation_id: imp.impersonation_id,
15161
+ action: "VIEW",
15162
+ resource_kind: "page",
15163
+ resource_id: reported,
15164
+ });
15165
+ } catch (_e) { /* drop-silent — the cart count must still return */ }
15166
+ }
15167
+ }
15168
+
15169
+ var payload = JSON.stringify(imp
15170
+ ? { count: count, impersonating: { customer_id: imp.customer_id } }
15171
+ : { count: count });
14761
15172
  return res.end ? res.end(payload) : res.send(payload);
14762
15173
  });
14763
15174
 
@@ -16179,6 +16590,12 @@ function mount(router, deps) {
16179
16590
  var rpId = deps.rpId || (deps.shop_origin ? new URL(deps.shop_origin).hostname : "localhost");
16180
16591
  var expectedOrigin = deps.shop_origin || ("https://" + rpId);
16181
16592
 
16593
+ // The impersonation guard that closes these surfaces is mounted at the
16594
+ // TOP of this router, ahead of every route — see `impersonationGuard`
16595
+ // near the locale middleware. It cannot live here: `router.use` only
16596
+ // applies to routes registered after it, and /cart and /checkout are
16597
+ // registered before this block.
16598
+
16182
16599
  function _b64u(buf) {
16183
16600
  return b.crypto.toBase64Url(buf);
16184
16601
  }
@@ -16447,6 +16864,134 @@ function mount(router, deps) {
16447
16864
  });
16448
16865
  }
16449
16866
 
16867
+ // ---- impersonation: redeem, and exit --------------------------------
16868
+ //
16869
+ // The operator arrives here from the admin console holding the one-time
16870
+ // bearer the primitive minted. Redeeming it seals an ordinary auth cookie
16871
+ // for the target customer, marked with the impersonation row's id.
16872
+ //
16873
+ // Two steps, and the split is load-bearing.
16874
+ //
16875
+ // The GET only LOOKS: it verifies the token and paints a confirmation page
16876
+ // naming the customer and the stated reason. It changes nothing. The POST
16877
+ // spends the token and mints the session.
16878
+ //
16879
+ // Doing both in the GET would mean anything that follows a link ahead of
16880
+ // the operator — a browser prefetcher, a link-preview bot in whatever chat
16881
+ // the console was open next to, a security scanner walking history — burns
16882
+ // the one-time bearer. The operator then arrives to be told their link is
16883
+ // invalid, and something that is not the operator is holding the only
16884
+ // session that link will ever mint. A GET must not spend a credential.
16885
+ //
16886
+ // The confirmation is worth having on its own: the operator sees whose
16887
+ // account they are about to enter, and why they said they were entering
16888
+ // it, before they are inside it.
16889
+ var _impersonationRefused = function (res) {
16890
+ // One outcome for every failure — expired, ended, revoked, already
16891
+ // spent, never existed. An operator who mistypes learns nothing about
16892
+ // which, and a visitor guessing URLs learns nothing at all.
16893
+ res.status(303);
16894
+ res.setHeader && res.setHeader("location",
16895
+ "/account/login?error=" + encodeURIComponent("view-as-customer link is no longer valid"));
16896
+ return res.end ? res.end() : res.send("");
16897
+ };
16898
+
16899
+ if (deps.customerImpersonation) {
16900
+ router.get("/account/impersonate/:token", async function (req, res) {
16901
+ var ctx = null;
16902
+ try { ctx = await deps.customerImpersonation.verifyImpersonationToken(req.params.token); }
16903
+ catch (_e) { ctx = null; }
16904
+ if (!ctx) return _impersonationRefused(res);
16905
+
16906
+ var target = null;
16907
+ try { target = await deps.customers.get(ctx.customer_id); }
16908
+ catch (_e) { target = null; }
16909
+
16910
+ return _send(res, 200, renderImpersonationConfirm({
16911
+ shop_name: shopName,
16912
+ token: req.params.token,
16913
+ customer_id: ctx.customer_id,
16914
+ customer_name: (target && target.display_name) || "",
16915
+ reason: ctx.reason,
16916
+ expires_at: ctx.expires_at,
16917
+ csrf_token: req.csrfToken,
16918
+ }));
16919
+ });
16920
+
16921
+ router.post("/account/impersonate/start", async function (req, res) {
16922
+ var token = (req.body && req.body.token) || "";
16923
+ var ctx = null;
16924
+ try { ctx = await deps.customerImpersonation.verifyImpersonationToken(token); }
16925
+ catch (_e) { ctx = null; }
16926
+ if (!ctx) return _impersonationRefused(res);
16927
+
16928
+ // The claim authorizes the cookie, not the verify above: verification
16929
+ // is a read, so two requests carrying the same token can both pass it.
16930
+ // Only the one whose UPDATE still matched the hash gets a session.
16931
+ // Fails CLOSED on a handle that cannot claim. An older or custom
16932
+ // implementation without `consumeToken` would otherwise skip the claim
16933
+ // and still be handed a cookie, turning the single-use link back into
16934
+ // a bearer any number of holders could replay for the session's life.
16935
+ // No claim, no session.
16936
+ if (typeof deps.customerImpersonation.consumeToken !== "function") {
16937
+ return _impersonationRefused(res);
16938
+ }
16939
+ var claim = null;
16940
+ try { claim = await deps.customerImpersonation.consumeToken(ctx.impersonation_id, token); }
16941
+ catch (_e) { claim = null; }
16942
+ if (!claim || !claim.consumed) return _impersonationRefused(res);
16943
+
16944
+ // If this browser is already in a session, close it before the cookie
16945
+ // naming it is overwritten. The cookie is the only handle on it: once
16946
+ // replaced, the operator cannot reach the exit control for the old one
16947
+ // and its row would sit `active` until the hourly sweep — telling the
16948
+ // first customer "in progress now" about a session nobody is in, and
16949
+ // showing operator dashboards a session that has been abandoned.
16950
+ var previous = _impersonationOf(req);
16951
+ if (previous && previous.impersonation_id !== ctx.impersonation_id) {
16952
+ try {
16953
+ await deps.customerImpersonation.endImpersonation({
16954
+ impersonation_id: previous.impersonation_id,
16955
+ ended_by: "operator",
16956
+ reason: "operator moved to another customer's session",
16957
+ });
16958
+ } catch (_e) { /* the new cookie below is what the operator gets either way */ }
16959
+ }
16960
+
16961
+ _setAuthCookie(req, res, {
16962
+ customer_id: ctx.customer_id,
16963
+ // The cookie must not outlive the session it represents. The auth
16964
+ // cookie's usual 14 days would leave a sealed envelope naming a
16965
+ // dead impersonation long after it ended; the per-request liveness
16966
+ // read would refuse it, but there is no reason to hand out a
16967
+ // credential that outlives its authority.
16968
+ exp: ctx.expires_at,
16969
+ imp: ctx.impersonation_id,
16970
+ });
16971
+ res.status(303); res.setHeader && res.setHeader("location", "/account");
16972
+ return res.end ? res.end() : res.send("");
16973
+ });
16974
+
16975
+ // Leaving. Ends the row so the operator's next request is refused by
16976
+ // the liveness read even if the cookie survives, then clears the cookie
16977
+ // so the browser stops carrying the customer's session at all.
16978
+ router.post("/account/impersonate/end", async function (req, res) {
16979
+ var imp = _impersonationOf(req);
16980
+ if (imp) {
16981
+ try {
16982
+ await deps.customerImpersonation.endImpersonation({
16983
+ impersonation_id: imp.impersonation_id,
16984
+ ended_by: "operator",
16985
+ reason: "operator left the session",
16986
+ });
16987
+ } catch (_e) { /* clearing the cookie below is what protects the customer */ }
16988
+ }
16989
+ _clearAuthCookie(req, res);
16990
+ res.status(303); res.setHeader && res.setHeader("location", "/");
16991
+ return res.end ? res.end() : res.send("");
16992
+ });
16993
+ }
16994
+
16450
16995
  router.post("/account/passkey/register-begin", async function (req, res) {
16451
16996
  try {
16452
16997
  var body = _readJsonBody(req);
@@ -16845,11 +17390,48 @@ function mount(router, deps) {
16845
17390
  } catch (_e) { /* drop-silent — primitive may not expose listPasskeys on every build */ }
16846
17391
 
16847
17392
  var cartCount = await _cartCountForReq(req);
17393
+ // Every time an operator has opened this account for support, shown to
17394
+ // the person it belongs to. This is the customer's notification: the
17395
+ // store keeps their email as a hash only, so there is no address to
17396
+ // send to, and a notice that cannot be delivered is not a notice. Here
17397
+ // it needs no address, cannot bounce, and cannot silently fail to send.
17398
+ //
17399
+ // Read-only and best-effort: a lookup failure must never keep a
17400
+ // customer out of their own account.
17401
+ var accountAccesses = [];
17402
+ var accessPage = 0;
17403
+ var accessHasMore = false;
17404
+ if (deps.customerImpersonation &&
17405
+ typeof deps.customerImpersonation.listForCustomer === "function") {
17406
+ var acctUrl = req.url ? new URL(req.url, "http://localhost") : null;
17407
+ // Bounded per page, because these rows are never pruned and an
17408
+ // unbounded read would make this page slower for the rest of the
17409
+ // account's life — worst for the customers support has helped most
17410
+ // often. Paged rather than truncated: this is the customer's only
17411
+ // view of who opened their account, so nothing may become
17412
+ // permanently unreachable. `?access_page=N` walks back.
17413
+ accessPage = Math.max(0, parseInt((acctUrl && acctUrl.searchParams.get("access_page")) || "0", 10) || 0);
17414
+ try {
17415
+ // One extra row than the page shows, purely to learn whether there
17416
+ // is another page — cheaper than a second COUNT query.
17417
+ var fetched = await deps.customerImpersonation.listForCustomer(customer.id, {
17418
+ limit: ACCESS_PAGE_SIZE + 1,
17419
+ offset: accessPage * ACCESS_PAGE_SIZE,
17420
+ });
17421
+ accessHasMore = fetched.length > ACCESS_PAGE_SIZE;
17422
+ accountAccesses = accessHasMore ? fetched.slice(0, ACCESS_PAGE_SIZE) : fetched;
17423
+ }
17424
+ catch (_e) { accountAccesses = []; }
17425
+ }
17426
+
16848
17427
  _send(res, 200, renderAccount({
16849
17428
  asset_prefix: deps.asset_prefix || "/assets/",
16850
17429
  customer: customer,
16851
17430
  orders: orders,
16852
17431
  order_product_lookup: orderProductLookup,
17432
+ account_accesses: accountAccesses,
17433
+ account_access_page: accessPage,
17434
+ account_access_has_more: accessHasMore,
16853
17435
  passkey_count: passkeyCount,
16854
17436
  preorders_enabled: !!preorder,
16855
17437
  pickups_enabled: !!deps.clickAndCollect,