@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.
- package/CHANGELOG.md +4 -0
- package/README.md +2 -1
- package/SECURITY.md +25 -1
- package/lib/addresses.js +16 -2
- package/lib/admin.js +116 -1
- package/lib/affiliates.js +3 -3
- package/lib/announcement-bar.js +2 -2
- package/lib/api-keys.js +2 -2
- package/lib/asset-manifest.json +5 -5
- package/lib/blog-articles.js +2 -2
- package/lib/carrier-accounts.js +2 -2
- package/lib/catalog-drafts.js +1 -1
- package/lib/category-navigation.js +6 -3
- package/lib/compliance-export.js +40 -3
- package/lib/customer-impersonation.js +105 -7
- package/lib/customer-notes.js +1 -1
- package/lib/customer-roles.js +1 -1
- package/lib/customer-surveys.js +2 -2
- package/lib/customers.js +1 -1
- package/lib/dispute-resolution.js +3 -3
- package/lib/dropship-forwarding.js +1 -1
- package/lib/dunning.js +1 -1
- package/lib/email-ab-tests.js +1 -1
- package/lib/email-campaigns.js +15 -2
- package/lib/email-warmup.js +1 -1
- package/lib/externaldb-d1.js +0 -8
- package/lib/gift-options.js +3 -3
- package/lib/knowledge-base.js +3 -3
- package/lib/line-gift-wrap.js +6 -2
- package/lib/live-chat.js +1 -1
- package/lib/notifications.js +1 -1
- package/lib/operator-accounts.js +1 -1
- package/lib/operator-help-center.js +3 -3
- package/lib/operator-inbox.js +2 -2
- package/lib/operator-roles.js +1 -1
- package/lib/order-notes.js +2 -2
- package/lib/order-ratings.js +3 -3
- package/lib/payment-methods.js +1 -1
- package/lib/payment-retries.js +1 -1
- package/lib/pixel-events.js +14 -6
- package/lib/plan-changes.js +1 -1
- package/lib/product-qa.js +1 -1
- package/lib/push-notifications.js +2 -2
- package/lib/reviews.js +3 -3
- package/lib/seller-signup.js +4 -4
- package/lib/sidebar-widgets.js +1 -1
- package/lib/sms-dispatcher.js +3 -3
- package/lib/stock-receipts.js +1 -1
- package/lib/storefront-dashboards.js +1 -1
- package/lib/storefront-forms.js +1 -1
- package/lib/storefront-pages.js +2 -2
- package/lib/storefront.js +569 -1
- package/lib/subscription-billing.js +1 -1
- package/lib/subscription-controls.js +1 -1
- package/lib/suggestion-box.js +4 -4
- package/lib/support-tickets.js +3 -3
- package/lib/tenants.js +1 -1
- package/lib/text-guard.js +44 -2
- package/lib/theme-assets.js +1 -1
- package/lib/vendor/MANIFEST.json +12 -12
- package/lib/vendor/blamejs/CHANGELOG.md +32 -0
- package/lib/vendor/blamejs/NOTICE +1 -1
- package/lib/vendor/blamejs/README.md +1 -1
- package/lib/vendor/blamejs/lib/network-tls.js +98 -13
- package/lib/vendor/blamejs/lib/vendor/MANIFEST.json +12 -12
- package/lib/vendor/blamejs/lib/vendor/blamejs-pki.cjs +568 -304
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/sbom.cdx.json +6 -6
- package/lib/vendors.js +3 -3
- package/lib/webhook-receiver.js +1 -1
- package/lib/webhook-subscriptions.js +1 -1
- package/lib/wishlist-sharing.js +1 -1
- 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
|
-
|
|
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
|
|