@blamejs/blamejs-shop 0.5.22 → 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 +2 -0
- package/README.md +1 -0
- package/SECURITY.md +18 -0
- package/lib/admin.js +116 -1
- package/lib/asset-manifest.json +5 -5
- package/lib/compliance-export.js +32 -2
- package/lib/customer-impersonation.js +105 -7
- package/lib/storefront.js +569 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.5.x
|
|
10
10
|
|
|
11
|
+
- v0.5.23 (2026-08-11) — **An operator can open the storefront as a customer to see what they are seeing — for an hour, with everything they do recorded, and with no way to take the account over.** Support work routinely needs the operator to see the store the way one specific customer sees it: to reproduce a cart total that looks wrong, a delivery estimate that will not appear, a discount that should apply and does not. Signing in as the customer is the obvious way to do that and the worst one — every action then reads as the customer's own, nothing tells the customer it happened, and a forgotten session stays open indefinitely. This is that capability built so none of those are true. An owner opens a customer's page, states a reason, and browses as them for an hour. The session shows as a banner on every page, ends the moment it is left, and appears on the customer's own account page with the reason the operator gave. The operator can act — fix the cart, correct an address, place the order — but everything that decides who owns the account is closed to them, so a support visit cannot become a takeover and cannot lock the customer out. **Added:** *View the storefront as a customer, from their page in the console* — A "View as customer" panel on each customer's admin page opens a support session. A reason is required, and it is not decoration: it is stored with the session and shown to the customer. Following the link lands on a confirmation naming the account and the stated reason before anything begins — so a link that is prefetched, previewed by a chat client, or fetched by a scanner cannot start a session on the operator's behalf, and the operator sees whose account they are entering before they are in it. The session lasts an hour. · *The customer is told, on their own account page* — An "Account access by our support team" section lists every session opened on the account: when, the reason the operator gave, and whether one is in progress right now. It is delivered this way rather than by email deliberately. This store keeps customer email as a hash, so there is no address to write to, and a notice that cannot be sent is not a notice — recording one as sent would put a line in an audit trail that never happened. Rendered on the account page it needs no address and cannot silently fail. A store that has wired its own plaintext-address store and a notification dispatcher can additionally pass the notifications handle to the primitive and get the out-of-band message as well. · *A banner on every page, and one control to leave* — While a session is live every storefront page carries a banner naming the customer and a button that ends it. It is filled in by the same small script that corrects the cart count, which is what lets it appear on cached pages too — those are served as one identical body to every visitor, so nothing about who is looking can be baked into them. Browsing the catalog during a support session therefore looks like a support session, not like an ordinary visit. **Security:** *A support session cannot become a takeover* — Everything that decides who owns the account is refused for the duration: enrolling or revoking a passkey, linking an identity provider, mailing a sign-in link, changing the email address, deleting the account, and exporting the whole record. The refusals are enforced at one point ahead of every route rather than inside each handler, so a route added under one of those paths later is closed by where it lives rather than by its author remembering. Each attempt is recorded against the session. · *Signing out ends the session, and does not sign the customer out* — The account page's own sign-out control revokes every session the customer has, on every device, and invalidates their pending sign-in links. An operator reaching for it means "get me out of here". Left alone it would have signed the customer out of their phone and laptop because someone helped them — the precise lock-out this feature promises an operator cannot cause. It ends the impersonation instead. · *Ending a session takes effect on the next request* — The session behind the cookie is re-read on every request made under it, so ending or revoking one stops the operator on their very next click rather than whenever the cookie would have expired. That check fails closed: where the equivalent gate for ordinary shoppers deliberately fails open so a database blip cannot sign everyone out, here a blip must not extend one person's authority over someone else's account. · *The handoff link is single-use* — The link is spent by being used. It remains in browser history and anywhere it was pasted, so a second holder must not be able to mint their own session from it — and cannot: the session's authority moves into a cookie bound to the browser that redeemed it. Two holders racing the same link resolve to exactly one, because the claim is a conditional write rather than a check followed by a write. · *Opening a session is its own permission* — `customers.impersonate` is separate from `customers.write`. A manager who can edit a customer record cannot become one; only the owner role holds it. A refusal names the missing authority in the audit record rather than reporting a generic write denial.
|
|
12
|
+
|
|
11
13
|
- v0.5.22 (2026-08-11) — **A postal address written in Arabic or Hebrew can be saved again, a pixel event whose provider replied with a control byte stops re-sending forever, and the vendored framework moves to 0.18.24.** The previous release began refusing Unicode bidirectional characters in stored text, to stop a value being made to display as something other than what it says. It refused too many. The bidirectional set contains two groups that do not carry the same risk: the overrides and isolates, which reverse the display order of a whole run and have no legitimate use in a shop's stored text, and the direction marks, which resolve the direction of neighbouring characters and are simply how a Latin house number inside an Arabic street name is written. Refusing the marks turned away ordinary right-to-left addresses, names and messages at the point of sale. Only the overrides and isolates are refused now, and the marks are accepted wherever prose is accepted. Separately, a marketing pixel whose provider replied with a control byte could never record that reply, so the event stayed queued and the same request was re-sent on every scheduler tick indefinitely. **Changed:** *Vendored framework refreshed to 0.18.24* — Advances the vendored framework from 0.18.21 to 0.18.24, and the store inherits the following without application changes. A certificate revocation response that declared one signature algorithm while carrying another was accepted: the field the verifier reads to choose its digest was never checked against the key it verified with. Nothing was forgeable through it — a response signed by any other key was still refused — but what a response claimed and what was actually checked could differ silently, and a mismatch is now refused. In Certificate Transparency, a consistency proof was rebuilt against the log's new tree but not against the tree the operator had pinned, except when the pinned size happened to be a power of two; a log that quietly rewrote history before the pinned point would still have passed at eleven of the sixteen smallest sizes. Both roots are now rebuilt and both are checked. A tree is also consistent with itself, which the walk previously refused at most sizes — an operator re-checking a log that had not grown was told it was inconsistent. The bundled certificate toolkit additionally clears every key-establishment secret and password copy it allocates once the derivation has consumed it. **Fixed:** *A right-to-left address, name or message is accepted again* — U+200E, U+200F and U+061C are direction marks. They carry no text of their own; they tell the renderer which way to run the characters beside them, which is what makes a Latin house number inside an Arabic street name, or a Latin brand inside a Hebrew sentence, display in the right order. A correctly written Saudi or Israeli address routinely contains one. The previous release folded them in with the bidirectional overrides and refused both, so those addresses were rejected at checkout — and the refusal said the value contained control bytes, sending the customer to look for something that was not there. Every field that takes prose accepts the marks again: shipping and billing addresses, recipient and company names, gift messages, review titles and bodies, customer notes, support tickets, knowledge-base articles. The overrides and isolates — U+202A to U+202E and U+2066 to U+2069, the half that reverses a run — stay refused, and a refusal now names the character class it actually found. · *A pixel event whose provider replied with a control byte no longer re-sends forever* — Recording a dispatch validates the provider's response body before it marks the event delivered. The previous release tightened that validation to refuse control characters, which the surrounding contract explicitly permits — a provider's reply is captured for an operator to read, and it is not the shop's to reject. The validation threw, the event was never marked delivered, and the scheduler re-sent the identical request on every tick from then on. The response body is screened only for invisible characters now, which is what it was ever meant to catch, so a reply the shop dislikes is stored rather than retried. · *A failed broadcast's reason is fully scrubbed before it reaches the ledger* — When a message in an email campaign fails, the mailer's error is folded onto one line and stored against that recipient. The expression doing the folding replaced only the first control character it found, so a multi-line error left the rest in the stored row. All of them are removed now. · *A category's hero image URL is held to the same rule as a blog post's* — Both are an operator-supplied image URL emitted into a single attribute, and a line break in one is never legitimate. The blog article's field refused them; the category navigation's did not. It does now. **Security:** *Refusing a bidirectional override still holds, and now says what it found* — The protection the previous release added is unchanged where it matters: a value carrying U+202A to U+202E or U+2066 to U+2069 is still refused everywhere it was, including a link URL inside a marketing email, where the plain-text part of the message prints that URL for the recipient to read. What changed is the blast radius. A URL, a slug or an identifier is machine-plain and now refuses the direction marks as well; prose does not, because prose in a right-to-left script needs them. The two substrates that parse search filters are held to the same set by a test that compares them across every character in the basic multilingual plane, so the edge and the container cannot come apart on it.
|
|
12
14
|
|
|
13
15
|
- v0.5.21 (2026-08-11) — **Text that one person writes and another reads can no longer be made to display in an order it was not written in, a link in a marketing email can no longer show one destination and go to another, and a sale's cost is attributed across stock layers in proportion rather than by a rounded average.** A Unicode bidirectional override is invisible, and it reverses how the characters after it are displayed. Dropped into a review title, an order-rating comment, a customer note, a support ticket or a knowledge-base article, it makes the stored text read on screen as something other than what it says — a one-star review that reads as five, a note that reads as its own opposite. Every field of that kind refused control bytes; none refused the overrides. They all do now. The same character in a campaign email's link URL was worse, because the plain-text part of the message prints that URL for the recipient to read: a link could display a bank's hostname and resolve somewhere else. Separately, the cost of a sale is now split across the stock layers it consumed in proportion to each layer's share, rather than by an average rounded to a whole minor unit with the remainder dropped on the last layer — which made the last layer consumed look more expensive than it was in every margin and inventory-valuation report. **Changed:** *A refusal now names the character class it found* — Validation messages described everything in this family as "control bytes", whatever had actually been found. A refusal now says which: a null byte, a C0 control character, a line break in a field that must stay on one line, a zero-width or invisible formatting character, or a bidirectional override. The value refused is the same in every case; only the message changed. · *The soft hyphen is refused wherever invisible characters already were* — U+00AD is invisible and carries no meaning at the point it appears; it marks a place a word may be broken across lines. The fields that already refused the zero-width family did not refuse it, and now do. It matters because text pasted out of a hyphenating word processor or a PDF carries them silently, which makes stored text that looks identical to two different values and does not match a search for either. **Fixed:** *A sale's cost is attributed across stock layers in proportion* — Weighted-average consumption priced every debited layer at the average unit cost rounded to a whole minor unit, then gave the last layer whatever was left over. Rounding loses a fraction per unit, and all of those fractions landed in one place. Consuming 40, 40 and 20 units at an average of 100.4 attributed 4000, 4000 and 2040 where the true shares are 4016, 4016 and 2008 — a 32-minor-unit misattribution from a single sale, growing with the quantities ahead of the last layer. The line total was always right, so no order was ever over- or under-charged; what was wrong was the per-layer cost that the margin and inventory-valuation reports read, where the last layer consumed absorbed everyone else's rounding and looked more expensive than it was. The split now distributes by largest remainder, so the shares sum exactly to the line total without any one layer absorbing the difference. · *A visitor's language preference is read the same way at the edge and in the container* — The edge decides whether a visitor's language needs the container, and the container decides which locale to render. Each parsed the Accept-Language header itself, and they disagreed: a header spelling the quality parameter as Q=0.9 was understood by the container and missed by the edge, whose match was case-sensitive. The edge read that tag as carrying no preference at all, ranked it first, and could route — and cache — a visitor into a different locale than the container would have chosen. The parameter name is case-insensitive, so the edge was simply wrong, and because both sides agreed on every lowercase header the disagreement never showed. Both also treated q=0 as merely lowest priority; it means the tag is not acceptable, so a locale a visitor explicitly ruled out could still be selected when nothing else matched. Both now read the header through one parser, and q=0 drops the tag. **Security:** *A bidirectional override can no longer be stored in text that renders to another person* — U+202E and its relatives are invisible characters that reverse the display order of everything after them. They are the trick behind Trojan Source, and they work the same way on a page as they do in source code. The fields where one person's writing is shown to another — a review title and body, an order-rating comment and the operator's public reply, a product question, a customer note, a support ticket, a suggestion, a knowledge-base article, a gift message — each screened their input for control bytes and null bytes, and none of them screened for the overrides. A shopper reading a product page, or an operator reading a ticket queue, could be shown text in an order nobody wrote it in. Every one of those fields now refuses them. · *A link in a marketing email can no longer display one destination and resolve to another* — A campaign body is written in a light markup where a link carries its own display text. In the HTML part of the message the URL only ever appears in the anchor, but the plain-text part prints it in full for the recipient to read — that is the whole point of the plain-text part. The screen on that URL refused the zero-width characters and the control bytes but not the bidirectional overrides, so a URL could be written to display a trusted hostname while resolving elsewhere, and a recipient checking the link before clicking would be reading the spoof. The URL is now screened for the whole class, and a link that fails is dropped while its display text is kept. · *The search filters could be made to read differently depending on the cache* — A /search facet is applied from the query string, and that query string is parsed on both substrates — at the edge for a cached page, in the container otherwise. The two screens for what a facet value may contain had come apart: the container refused the bidirectional overrides and the edge did not. The same URL therefore filtered one way off the cache and another way from the container, and the active-filter chip could be made to name a value nobody selected. Both sides now apply the same screen, and a test compares them across every character in the basic multilingual plane so that they cannot drift again.
|
package/README.md
CHANGED
|
@@ -102,6 +102,7 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
|
|
|
102
102
|
| **`lib/newsletter.js`** | Operator-collected email broadcast list — `signup({ email, source })` composes `b.guardEmail` for shape validation, `b.crypto.namespaceHash` for the dedup key, and `INSERT OR IGNORE` for idempotency. Storefront POST `/newsletter` route renders a designed thank-you card with separate copy for the `new` vs `dedup` branches. |
|
|
103
103
|
| **`lib/admin.js`** | Bearer-token-gated CRUD over catalog + orders + refunds + bulk CSV import + subscription plans + review moderation + return moderation. Token compared via `b.crypto.timingSafeEqual`. Errors as RFC 9457 problem documents via `b.problemDetails`. Audit emission on every mutation. Also serves a **browser admin console**: sign in at `/admin` by pasting the API key (sealed `shop_admin` session cookie, SameSite=Strict, /admin-scoped), with a persistent nav across every signed-in page. A guided **setup wizard** at `/admin/setup` writes shop identity to config; **Products** (`/admin/products`) browses the catalog and creates / archives / restores, and each product opens a management screen that edits its fields, adds / edits / removes variants, sets a variant's price and shows its price history, and attaches / uploads / removes images — the full path to a sellable product; **Inventory** (`/admin/inventory`) lists stock per SKU (on-hand / held / available) with a low-stock filter, restocks, sets per-SKU thresholds, tracks new SKUs, and opens the low-stock alert history (`/admin/inventory/alerts`) — each alert row is written when a checkout decrement crosses a SKU's threshold, alongside an `inventory.low_stock` webhook to subscribed endpoints; **Stock locations** (`/admin/inventory/locations`) defines warehouse / retail / virtual locations with per-location stock levels (a single-location store needs no configuration — the default location stays implicit), **Receive stock** (`/admin/inventory/receive`) records reason-coded inbound stock against a location with a batched receipt history, **Transfers** (`/admin/inventory/transfers`) moves stock between locations through a dispatch → receive state machine — the source is debited at dispatch, the destination credited on receive, and a dispatch racing a checkout hold for the last unit has exactly one winner — and **Write-offs** (`/admin/inventory/writeoffs`) records reason-coded stock losses with an audit trail, refusing a write-off that would eat into stock already held for paid orders; **Orders** (`/admin/orders`) lists recent orders with status filters, opens an order's items, totals, and shipping address, and drives the lifecycle (mark paid → fulfil → ship → deliver, cancel — Refund goes through the payment provider) through the order FSM, with a rate-bounded resend of the order confirmation to an operator-supplied address (the buyer's email is stored only as a hash, so the operator types the recipient), and attaches a shipment (carrier + tracking number) with recorded shipment events that surface a public tracking link to the customer, plus a customer-service notes thread per order (internal or customer-visible, pinnable, resolvable); **Customers** (`/admin/customers`) is a read-only roster, newest first — display name, short id, join date, sign-in method (passkey count + linked OAuth providers), and order count, with the count and sign-in methods resolved by bounded aggregate queries so a page of customers costs no per-row trips (email addresses aren't stored in the clear, so they're not shown); each customer opens to an aggregated activity timeline (orders, loyalty, wishlist, reviews, support) read from the tables those primitives already populate; customer segments export their members as a streamed CSV — id, display name, join date, order count, deliberately no email column; **Returns** (`/admin/returns`) is the RMA moderation queue — filter by status, open a request's items and reason, and approve (with refund amount) → mark received → refund, or reject with a reason, over the return FSM; **Reviews** (`/admin/reviews`) is the review moderation queue — filter by status and publish, reject (with a reason), or take down each submission inline; **Q&A** (`/admin/questions`) is the question moderation queue — filter by status, open a question to its full answer thread, approve / reject the question, post the seller answer, and approve / reject / pin individual answers; **Subscriptions** (`/admin/subscription-plans`) is the recurring-offer catalog — filter active / archived, create a plan (Stripe price id, interval, amount, trial), and archive one, with archiving terminal because the mirrored Stripe price can go stale; **Collections** (`/admin/collections`) manages manual + smart product collections — filter active / archived, create a collection (manual or smart with a starter rule), and per collection edit title / description / sort strategy, manage manual members (add by product id, remove, reorder) or edit a smart collection's rule set with a live preview of the products the rules currently match, and archive; **Gift cards** (`/admin/gift-cards`) is the gift-card ledger — list issued cards (masked code, original + remaining balance, status, issued date) filtered by lifecycle status, issue a new card (the bearer code shown once, right after creation), open a card to see its full credit / debit / expire ledger, and void an active card through a confirmation step; **Webhooks** (`/admin/webhooks`) registers outbound endpoints (https:// only) with a one-time signing-secret reveal, enables / disables / deletes them, and opens an endpoint's delivery feed to retry a failed delivery — the signing secret is shown once on create and never in the list, and order transitions fan out signed deliveries to subscribed endpoints. **Operators** (`/admin/operators`) is the staff-account console — create operators with their own credential (Argon2id password and/or a per-operator API key shown once) and a least-privilege role (owner / manager / viewer), enforced at the single admin write chokepoint on every POST/PUT/DELETE rather than by hiding menu items; disable takes effect on the operator's next request, `ADMIN_API_KEY` stays the bootstrap / break-glass owner credential so an upgrade can never lock the store out, and every operator-management action plus every role-denied attempt is audited. **Email campaigns** (`/admin/campaigns`) is the consent-gated broadcast console — author a campaign (escape-by-default Markdown body), target a mailing audience, preview, test-send to an operator-supplied address, and send to the recipients who are actually reachable: the recipient set resolves at send time from the newsletter list (the only place a deliverable address exists — customer accounts keep only an email hash), every recipient is re-checked against the unsubscribe flag and the marketing suppression list at the send moment, every message carries RFC 8058 one-click unsubscribe headers plus an in-body link, and a per-recipient send ledger makes a resumed broadcast never re-mail. Sending drains in rate-bounded batches on the scheduled tick; per-campaign delivered / failed / skipped counts show on the detail screen. **Quotes** (`/admin/quotes`) is the RFQ response queue — open a request's lines and customer message, respond with per-line pricing and a validity window, or withdraw a responded quote; an accepted quote converts to an order through the storefront's normal checkout path, holds included. **Tax** (`/admin/tax-rates`), **Shipping** (`/admin/shipping`), and **Discounts** (`/admin/discounts`) configure tax rates per jurisdiction, shipping zones + rates, and automatic-discount rules — including code-unlocked rules a shopper redeems with a discount code on the cart page — + coupon-stacking policies — create / edit / archive each. **Audit** (`/admin/audit`) is a read-only activity log of every privileged action — filtered by outcome (success / failure / denied) and paginated — composed on the framework's tamper-evident `b.audit` chain; opening it is itself recorded as an `audit.read` event. **Errors** (`/admin/errors`) lists captured server-error detail — time, status, route, and a truncated message for scrubbed 500-class failures (checkout confirm, public API, admin actions) — newest-first, with the same path answering a bearer-token request with JSON so the log is one `curl` away. **Carts** (`/admin/carts`) lists abandoned carts — active, has items, idle past a tunable window (24h default) — with line counts, value at risk, and guest/signed-in attribution; a per-cart action mints a single-use, code-gated discount the operator shares through their own channel (recovery email is impossible by design: buyer addresses are stored only as hashes, and the screen says so). **Analytics** (`/admin/analytics`) is the pre-purchase view the sales report can't see — browse-to-buy funnel with conversion rate, top search terms, most-viewed products, units-ranked top SKUs, and a revenue-by-day sparkline — cross-linked with the Reports screen, read-only, every aggregate window- and limit-bounded. **Search suggestions** (`/admin/search-suggestions`) curates the storefront autocomplete — pin a featured link to a typed prefix, set its priority / status / active window, edit or remove it inline — and surfaces a read-only popular-searches report (each term's 30-day count, zero-result share, and last-seen) so an unmatched term flags a stock or naming gap. The Customers, Returns, Reviews, Q&A, Subscriptions, Collections, Gift cards, Webhooks, Tax, Shipping, Discounts, Delivery estimates, Analytics, Search suggestions, Carts, Errors, Stock locations, Receive stock, Transfers, Write-offs, Quotes, Email campaigns, and Operators links appear only when those primitives are wired. Each console path content-negotiates: a bearer-token client still gets the JSON API unchanged, a signed-in browser gets HTML. Reachable by the cookie or the bearer token. The console's styling is an external, integrity-pinned stylesheet (`themes/default/assets/css/admin.css`) with the same self-hosted typeface — no inline styles and no third-party font host, so it renders correctly under the strict `style-src 'self'` / `font-src 'self'` CSP that governs the route. |
|
|
104
104
|
| **`lib/catalog-import.js`** | Bulk CSV import — `POST /admin/catalog/import` accepts a `text/csv` body, parses via `b.csv`, content-safety-filters every cell through `b.guardCsv` (formula-injection / bidi / control / dangerous-function denylist), validates exact header order, de-dupes rows by `product_slug`, returns per-row errors without aborting. Default 1 MiB / 10000 rows caps. |
|
|
105
|
+
| **`lib/customer-impersonation.js`** | Operator "view as customer" — a support session on the storefront carrying the customer's own session, so an operator can see what the customer sees. Started from the customer's admin page with a required reason, gated on `customers.impersonate` (owner-only; separate from `customers.write`, so a manager who can edit a customer cannot become one). One-hour TTL. The handoff bearer is minted once, hashed at rest (`namespaceHash`), redeemed through a confirmation page — a GET never spends it, so a prefetcher or link-preview bot cannot — and claimed by a conditional write, so two holders racing one link resolve to exactly one. The live session is re-read on every request, so `end` / `revoke` bite on the next click rather than at cookie expiry, failing closed. Credential surfaces (passkeys, identity linking, sign-in links, email, deletion, data export) are refused at one chokepoint ahead of every route, and `/account/logout` ends the session instead of revoking the customer's own logins. Every request under the session is recorded to an append-only action log; the customer sees each session, with its reason, on their own account page. `cleanupExpired` runs on the minute tick. |
|
|
105
106
|
| **`lib/text-guard.js`** | The shared input screen every other module validates through, composed on the framework's codepoint catalog (`b.codepointClass`) rather than a per-module character class. `freeText(v, label, policy)` throws on a refused codepoint; `hasCodepointThreat(v, policy)` answers the same question without throwing, for a reader that drops a bad value rather than refusing the request; `scrubInvisible(v)` strips instead, for a value that must be accepted whatever it holds (a search query — refusing one is a denial-of-search against the shopper who pasted a stray BOM). All three run one decision, so no two fields disagree about what "dangerous" means. Bidi overrides and isolates (CVE-2021-42574), null bytes and C0 controls are refused by default; the bidi direction MARKS are permitted by default and `bidiMarks` refuses them, for a value that is machine-plain rather than prose (an Arabic address needs a mark; a URL does not); `singleLine` adds tabs, line breaks and U+2028/U+2029 for a value rendered on one line; `zeroWidth` adds the invisible-formatting family; `mixedScript` refuses a confusable across writing systems. Also holds the ASCII-shape validators — `currencyCode` (ISO 4217 membership, not just shape), `slugLabel`, `asciiUpperLetters`, and `hostLabel` (SSRF classification + by-name internal denylist). |
|
|
106
107
|
| **`lib/theme.js`** | File-backed templates with fallback chain. Operators register a named theme under `<themesDir>/<name>/*.html` and the storefront dispatches every renderer through it. `assetUrl(path)` resolves to `/assets/themes/<name>/<path>`. The shipped `default` theme is the fallback. |
|
|
107
108
|
|
package/SECURITY.md
CHANGED
|
@@ -279,6 +279,24 @@ node -e "
|
|
|
279
279
|
an unmoderated reply can ever surface publicly. Author identity is the
|
|
280
280
|
customer id (verified against the customers primitive) or a hash-only
|
|
281
281
|
email — the raw address is never persisted.
|
|
282
|
+
- **An operator viewing a customer's account cannot take it over.** Support
|
|
283
|
+
can open the storefront as a specific customer to reproduce what that
|
|
284
|
+
customer is seeing. The session is gated on its own permission
|
|
285
|
+
(`customers.impersonate`, held by `owner` and not by `manager`, so editing
|
|
286
|
+
a customer and becoming one are different authorities), requires a stated
|
|
287
|
+
reason, lasts an hour, and shows as a banner on every page with one control
|
|
288
|
+
to leave. Everything that decides who owns the account is refused for its
|
|
289
|
+
duration — enrolling or revoking a passkey, linking an identity provider,
|
|
290
|
+
mailing a sign-in link, changing the email address, deleting the account,
|
|
291
|
+
exporting the record — enforced ahead of every route rather than per
|
|
292
|
+
handler, so a route added under one of those paths later is closed by where
|
|
293
|
+
it lives. Signing out ends the session rather than revoking the customer's
|
|
294
|
+
own logins on their other devices. Ending or revoking a session stops the
|
|
295
|
+
operator on their next request, not at cookie expiry, and that check fails
|
|
296
|
+
closed. The handoff link is single-use and cannot be spent by a prefetch.
|
|
297
|
+
Every request made under the session lands in an append-only log, and the
|
|
298
|
+
customer sees each session — with the reason the operator gave — on their
|
|
299
|
+
own account page.
|
|
282
300
|
- **Text one person writes and another reads is screened for invisible
|
|
283
301
|
characters.** A Unicode bidirectional override reverses the display
|
|
284
302
|
order of everything after it while itself rendering as nothing, so
|
package/lib/admin.js
CHANGED
|
@@ -173,6 +173,12 @@ var _ACTION_PERMISSION = Object.freeze({
|
|
|
173
173
|
inbox: "orders.write",
|
|
174
174
|
// customers
|
|
175
175
|
customer: "customers.write", customer_segment: "customers.write",
|
|
176
|
+
// Opening a session as a customer is its own permission, NOT
|
|
177
|
+
// `customers.write`. A manager who can edit a customer record still cannot
|
|
178
|
+
// become one: `customers:impersonate` is covered only by the owner role's
|
|
179
|
+
// root `*` scope. Naming it separately also means the denial audit row says
|
|
180
|
+
// which authority was missing rather than the generic customer write.
|
|
181
|
+
impersonation: "customers.impersonate",
|
|
176
182
|
// shop configuration
|
|
177
183
|
config: "settings.write",
|
|
178
184
|
// operator management (mounted by this feature)
|
|
@@ -882,6 +888,7 @@ function mount(router, deps) {
|
|
|
882
888
|
var customers = deps.customers || null; // read-only customers console disabled when absent
|
|
883
889
|
var storeCredit = deps.storeCredit || null; // per-customer store-credit panel + grant/deduct disabled when absent
|
|
884
890
|
var customerNotes = deps.customerNotes || null; // per-customer CRM notes panel disabled when absent
|
|
891
|
+
var customerImpersonation = deps.customerImpersonation || null; // "view as customer" hidden + route unmounted when absent
|
|
885
892
|
var customerSegments = deps.customerSegments || null; // per-customer segment-membership panel disabled when absent
|
|
886
893
|
var customerActivity = deps.customerActivity || null; // per-customer chronological activity-timeline panel disabled when absent
|
|
887
894
|
var orderNotes = deps.orderNotes || null; // per-order customer-service notes panel + add/lifecycle disabled when absent
|
|
@@ -3767,6 +3774,8 @@ function mount(router, deps) {
|
|
|
3767
3774
|
store_credit_history: creditHistory,
|
|
3768
3775
|
loyalty: loyaltyInfo,
|
|
3769
3776
|
loyalty_link: !!deps.loyalty,
|
|
3777
|
+
impersonation_enabled: !!customerImpersonation,
|
|
3778
|
+
impersonation_error: flags.impersonation_error || null,
|
|
3770
3779
|
can_notes: !!customerNotes,
|
|
3771
3780
|
notes: notes,
|
|
3772
3781
|
show_archived_notes: showArchivedNotes,
|
|
@@ -3819,6 +3828,7 @@ function mount(router, deps) {
|
|
|
3819
3828
|
credit_notice: url && url.searchParams.get("credit_err") ? url.searchParams.get("credit_err") : null,
|
|
3820
3829
|
note_notice: url && url.searchParams.get("note_err") ? url.searchParams.get("note_err") : null,
|
|
3821
3830
|
show_archived_notes: !!(url && url.searchParams.get("notes_archived") === "1"),
|
|
3831
|
+
impersonation_error: url && url.searchParams.get("imp_err") ? url.searchParams.get("imp_err") : null,
|
|
3822
3832
|
};
|
|
3823
3833
|
_sendHtml(res, 200, renderAdminCustomerDetail(await _customerDetailModel(c, flags)));
|
|
3824
3834
|
},
|
|
@@ -3906,6 +3916,81 @@ function mount(router, deps) {
|
|
|
3906
3916
|
));
|
|
3907
3917
|
}
|
|
3908
3918
|
|
|
3919
|
+
// ---- view as customer ------------------------------------------------
|
|
3920
|
+
//
|
|
3921
|
+
// Opens a support session on the storefront carrying the customer's own
|
|
3922
|
+
// session. Gated at the write chokepoint on `customers.impersonate` — the
|
|
3923
|
+
// `impersonation.` action prefix maps to it, and only `owner` holds it, so
|
|
3924
|
+
// a manager with `customers.write` is refused here.
|
|
3925
|
+
//
|
|
3926
|
+
// A reason is REQUIRED and stored on the row. An impersonation with no
|
|
3927
|
+
// stated purpose is the one an audit cannot evaluate afterwards, and the
|
|
3928
|
+
// customer sees this text on their own account page.
|
|
3929
|
+
if (customerImpersonation) {
|
|
3930
|
+
router.post("/admin/customers/:id/impersonate", _pageOrApi(false,
|
|
3931
|
+
W("impersonation.start", async function (req, res) {
|
|
3932
|
+
var c = await _resolveCustomer(req.params.id);
|
|
3933
|
+
if (!c) return _problem(res, 404, "customer-not-found");
|
|
3934
|
+
var reason = (req.body && req.body.reason) || "";
|
|
3935
|
+
if (typeof reason !== "string" || !reason.trim().length) {
|
|
3936
|
+
return _problem(res, 400, "bad-request", "A reason is required to view as a customer.");
|
|
3937
|
+
}
|
|
3938
|
+
var started;
|
|
3939
|
+
try {
|
|
3940
|
+
started = await customerImpersonation.startImpersonation({
|
|
3941
|
+
operator_id: req.operatorActor && req.operatorActor.operator_id,
|
|
3942
|
+
customer_id: c.id,
|
|
3943
|
+
reason: reason.trim(),
|
|
3944
|
+
});
|
|
3945
|
+
} catch (e) {
|
|
3946
|
+
if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message);
|
|
3947
|
+
throw e;
|
|
3948
|
+
}
|
|
3949
|
+
// Best-effort out-of-band notice. This store has no plaintext
|
|
3950
|
+
// customer address, so it reports `notified: false` and stamps
|
|
3951
|
+
// nothing — the customer is told on their own account page instead,
|
|
3952
|
+
// which needs no address and cannot silently fail.
|
|
3953
|
+
try { await customerImpersonation.notifyCustomer({ impersonation_id: started.impersonation_id }); }
|
|
3954
|
+
catch (_e) { /* the account-page record is the delivery that matters */ }
|
|
3955
|
+
// The plaintext bearer leaves the primitive exactly once. It goes
|
|
3956
|
+
// straight back to the operator who asked for it and is never
|
|
3957
|
+
// stored or logged.
|
|
3958
|
+
_json(res, 200, {
|
|
3959
|
+
impersonation_id: started.impersonation_id,
|
|
3960
|
+
expires_at: started.expires_at,
|
|
3961
|
+
open_url: "/account/impersonate/" + encodeURIComponent(started.plaintext_token),
|
|
3962
|
+
});
|
|
3963
|
+
return { id: c.id };
|
|
3964
|
+
}),
|
|
3965
|
+
async function (req, res) {
|
|
3966
|
+
var c = await _resolveCustomer(req.params.id);
|
|
3967
|
+
if (!c) return _redirect(res, "/admin/customers");
|
|
3968
|
+
var reason = (req.body && req.body.reason) || "";
|
|
3969
|
+
if (typeof reason !== "string" || !reason.trim().length) {
|
|
3970
|
+
return _redirect(res, "/admin/customers/" + encodeURIComponent(c.id) +
|
|
3971
|
+
"?imp_err=" + encodeURIComponent("Give a reason before viewing as this customer."));
|
|
3972
|
+
}
|
|
3973
|
+
var started;
|
|
3974
|
+
try {
|
|
3975
|
+
started = await customerImpersonation.startImpersonation({
|
|
3976
|
+
operator_id: req.operatorActor && req.operatorActor.operator_id,
|
|
3977
|
+
customer_id: c.id,
|
|
3978
|
+
reason: reason.trim(),
|
|
3979
|
+
});
|
|
3980
|
+
} catch (e) {
|
|
3981
|
+
var n = _safeNotice(e, "impersonation.start");
|
|
3982
|
+
return _redirect(res, "/admin/customers/" + encodeURIComponent(c.id) +
|
|
3983
|
+
"?imp_err=" + encodeURIComponent(n.message.replace(/^admin[.:]\s*/, "")));
|
|
3984
|
+
}
|
|
3985
|
+
try { await customerImpersonation.notifyCustomer({ impersonation_id: started.impersonation_id }); }
|
|
3986
|
+
catch (_e) { /* as above */ }
|
|
3987
|
+
// Straight into the storefront as the customer. The token is spent
|
|
3988
|
+
// by the redemption route on arrival.
|
|
3989
|
+
return _redirect(res, "/account/impersonate/" +
|
|
3990
|
+
encodeURIComponent(started.plaintext_token));
|
|
3991
|
+
}));
|
|
3992
|
+
}
|
|
3993
|
+
|
|
3909
3994
|
// ---- customer notes (add) ------------------------------------------
|
|
3910
3995
|
// Scoped to the :id customer — the note attaches to the path customer.
|
|
3911
3996
|
// Composes customerNotes.addNote (the body is required + length-capped by
|
|
@@ -17131,6 +17216,36 @@ function renderAdminCustomerDetail(opts) {
|
|
|
17131
17216
|
"</div>";
|
|
17132
17217
|
}
|
|
17133
17218
|
|
|
17219
|
+
// ---- view as customer ----------------------------------------------
|
|
17220
|
+
//
|
|
17221
|
+
// Rendered only when the primitive is wired, like every other optional
|
|
17222
|
+
// panel. The reason field is required by the route and is not decoration:
|
|
17223
|
+
// it is written to the session row and shown to the CUSTOMER on their own
|
|
17224
|
+
// account page, which is what makes the whole feature answerable to the
|
|
17225
|
+
// person whose account it is. The panel says so, so an operator knows
|
|
17226
|
+
// before they type.
|
|
17227
|
+
var impersonationPanel = "";
|
|
17228
|
+
if (opts.impersonation_enabled) {
|
|
17229
|
+
var impNotice = opts.impersonation_error
|
|
17230
|
+
? "<p class=\"notice notice--error\" role=\"alert\">" + _htmlEscape(String(opts.impersonation_error)) + "</p>"
|
|
17231
|
+
: "";
|
|
17232
|
+
impersonationPanel = "<div class=\"panel\"><h3 class=\"subhead\">View as customer</h3>" +
|
|
17233
|
+
impNotice +
|
|
17234
|
+
"<p class=\"meta\">Opens the storefront signed in as this customer so you can see exactly " +
|
|
17235
|
+
"what they see. The session lasts one hour and ends when you leave it. You will not be " +
|
|
17236
|
+
"able to change their password, passkeys, email address or linked sign-ins, delete the " +
|
|
17237
|
+
"account, or export their data.</p>" +
|
|
17238
|
+
"<form method=\"post\" action=\"/admin/customers/" + _htmlEscape(enc) + "/impersonate\">" +
|
|
17239
|
+
"<label class=\"form-field\"><span>Reason</span>" +
|
|
17240
|
+
"<input type=\"text\" name=\"reason\" required maxlength=\"200\" " +
|
|
17241
|
+
"placeholder=\"e.g. customer reports the cart total is wrong\">" +
|
|
17242
|
+
"<small>Required. Recorded against the session and shown to the customer on their " +
|
|
17243
|
+
"own account page.</small></label>" +
|
|
17244
|
+
"<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Start viewing as this customer</button></div>" +
|
|
17245
|
+
"</form>" +
|
|
17246
|
+
"</div>";
|
|
17247
|
+
}
|
|
17248
|
+
|
|
17134
17249
|
// ---- loyalty -------------------------------------------------------
|
|
17135
17250
|
var loyaltyPanel = "";
|
|
17136
17251
|
if (opts.loyalty_link) {
|
|
@@ -17262,7 +17377,7 @@ function renderAdminCustomerDetail(opts) {
|
|
|
17262
17377
|
"<div class=\"actions-row\"><h2>" + _htmlEscape(c.display_name) + "</h2>" +
|
|
17263
17378
|
"<a class=\"btn btn--ghost\" href=\"/admin/customers\"><span aria-hidden=\"true\">←</span> All customers</a></div>" +
|
|
17264
17379
|
saved +
|
|
17265
|
-
identity + ordersPanel + creditPanel + loyaltyPanel + notesPanel + segmentsPanel + activityPanel +
|
|
17380
|
+
identity + ordersPanel + creditPanel + loyaltyPanel + impersonationPanel + notesPanel + segmentsPanel + activityPanel +
|
|
17266
17381
|
"</section>";
|
|
17267
17382
|
return _renderAdminShell(opts.shop_name, c.display_name || "Customer", body, "customers", opts.nav_available);
|
|
17268
17383
|
}
|
package/lib/asset-manifest.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.5.
|
|
2
|
+
"version": "0.5.23",
|
|
3
3
|
"assets": {
|
|
4
4
|
"css/admin.css": {
|
|
5
5
|
"integrity": "sha384-imfe0otYErcB8rr2h6KLSGTtStirysptpXETSPY4zLv3bZoIT75Lo1dOvkOav+xL",
|
|
6
6
|
"fingerprinted": "css/admin.6941d5151488a7c1.css"
|
|
7
7
|
},
|
|
8
8
|
"css/main.css": {
|
|
9
|
-
"integrity": "sha384-
|
|
10
|
-
"fingerprinted": "css/main.
|
|
9
|
+
"integrity": "sha384-rZSWtyj5fcR9Bi8nZKLJZQLPAlkatv+A69WL2Bao7GD8nnNDf4IWmXDkYEDeJgq+",
|
|
10
|
+
"fingerprinted": "css/main.25ab2735c1a3acac.css"
|
|
11
11
|
},
|
|
12
12
|
"js/announcement.js": {
|
|
13
13
|
"integrity": "sha384-z4zcEMn+tScoVnYRE4nEf8N/oyvpxdpaxTNrT4QO/jURChid4+qjAvWkzatCaAPq",
|
|
@@ -18,8 +18,8 @@
|
|
|
18
18
|
"fingerprinted": "js/captcha.2aab5803642f8ac5.js"
|
|
19
19
|
},
|
|
20
20
|
"js/cart-count.js": {
|
|
21
|
-
"integrity": "sha384-
|
|
22
|
-
"fingerprinted": "js/cart-count.
|
|
21
|
+
"integrity": "sha384-ysSvGfg0EZ1rXQNHp3ZEFm21ApD7D2a6LKySj6Xl+cG3+2JQ577SaE+lWcodMfpE",
|
|
22
|
+
"fingerprinted": "js/cart-count.9ee112584156bc04.js"
|
|
23
23
|
},
|
|
24
24
|
"js/consent.js": {
|
|
25
25
|
"integrity": "sha384-XY3GHA5QDj/Nri03ZO1t7nsr9RGepZuanY45CTpFQnnTorQnygbtAVG4vVGfkNIP",
|
package/lib/compliance-export.js
CHANGED
|
@@ -207,6 +207,10 @@ var SCOPE_SECTIONS = Object.freeze({
|
|
|
207
207
|
// both directions (as referrer and as referred friend).
|
|
208
208
|
"guestOrderReconciliations", "stockAlerts", "quotes", "orderRatings",
|
|
209
209
|
"productQa", "customerNotes", "giftcards", "referrals",
|
|
210
|
+
// Support sessions opened on the account, and what was done under each.
|
|
211
|
+
// When someone looked at your account, who, why, and which pages is
|
|
212
|
+
// personal data about you, so a subject-access request returns it.
|
|
213
|
+
"customerImpersonation",
|
|
210
214
|
]),
|
|
211
215
|
orders_only: Object.freeze(["order", "orderNotes"]),
|
|
212
216
|
identity_only: Object.freeze(["customers", "addresses"]),
|
|
@@ -392,6 +396,7 @@ function create(opts) {
|
|
|
392
396
|
orderRatings: opts.orderRatings || null,
|
|
393
397
|
productQa: opts.productQa || null,
|
|
394
398
|
customerNotes: opts.customerNotes || null,
|
|
399
|
+
customerImpersonation: opts.customerImpersonation || null,
|
|
395
400
|
giftcards: opts.giftcards || null,
|
|
396
401
|
referrals: opts.referrals || null,
|
|
397
402
|
};
|
|
@@ -655,6 +660,14 @@ function create(opts) {
|
|
|
655
660
|
"supportTickets", "orderNotes", "order", "guestOrderReconciliations",
|
|
656
661
|
"subscriptions", "paymentMethods", "loyalty", "storeCredit",
|
|
657
662
|
"giftcards", "referrals", "addresses",
|
|
663
|
+
// Retains rather than erases, and the report says so with a reason.
|
|
664
|
+
// Listed here precisely BECAUSE it retains: a domain absent from this
|
|
665
|
+
// walk is never asked, so the erasure report would neither erase it nor
|
|
666
|
+
// explain why it did not — the customer would be told their record was
|
|
667
|
+
// erased while an audit trail about them quietly remained, unmentioned.
|
|
668
|
+
// Named last so the accountability record is the final line of the
|
|
669
|
+
// report rather than buried among the erasures.
|
|
670
|
+
"customerImpersonation",
|
|
658
671
|
];
|
|
659
672
|
var perDomain = [];
|
|
660
673
|
var domainsAbsent = [];
|
|
@@ -680,11 +693,28 @@ function create(opts) {
|
|
|
680
693
|
throw new TypeError("reader " + JSON.stringify(name) +
|
|
681
694
|
".forCustomerDeletion returned non-object — must return { table, deleted }");
|
|
682
695
|
}
|
|
683
|
-
|
|
696
|
+
var entry = {
|
|
684
697
|
domain: name,
|
|
685
698
|
table: effect.table == null ? name : effect.table,
|
|
686
699
|
deleted: effect.deleted == null ? 0 : Number(effect.deleted),
|
|
687
|
-
}
|
|
700
|
+
};
|
|
701
|
+
// A domain that RETAINS its rows on purpose says so, and says why.
|
|
702
|
+
// Without this the report shows a plain `deleted: 0`, which reads as
|
|
703
|
+
// "there was nothing to erase" — indistinguishable from a domain the
|
|
704
|
+
// customer had no data in. An erasure report that cannot tell those
|
|
705
|
+
// apart is the one an auditor cannot rely on, and retention here is a
|
|
706
|
+
// deliberate legal position (accounting records, accountability logs)
|
|
707
|
+
// that has to be stated rather than inferred.
|
|
708
|
+
if (effect.retained === true) {
|
|
709
|
+
entry.retained = true;
|
|
710
|
+
if (typeof effect.reason === "string" && effect.reason.length) {
|
|
711
|
+
entry.reason = effect.reason;
|
|
712
|
+
}
|
|
713
|
+
} else if (typeof effect.note === "string" && effect.note.length) {
|
|
714
|
+
// Older readers carry the same meaning as a free-text `note`.
|
|
715
|
+
entry.note = effect.note;
|
|
716
|
+
}
|
|
717
|
+
perDomain.push(entry);
|
|
688
718
|
} catch (e) {
|
|
689
719
|
failures.push({ domain: name, error: (e && e.message) ? e.message : String(e) });
|
|
690
720
|
}
|
|
@@ -113,8 +113,14 @@
|
|
|
113
113
|
* → `{ notified: boolean, customer_notified_at: <ms> | null }`
|
|
114
114
|
* - `listForOperator(operator_id, { active_only? })`
|
|
115
115
|
* → array of session rows, newest-first.
|
|
116
|
-
* - `listForCustomer(customer_id)`
|
|
117
|
-
* →
|
|
116
|
+
* - `listForCustomer(customer_id, { limit?, offset? })`
|
|
117
|
+
* → the customer's most recent sessions, newest-first. BOUNDED:
|
|
118
|
+
* 20 by default, 200 at most. The storefront renders this on
|
|
119
|
+
* every account page load and the rows are never pruned, so an
|
|
120
|
+
* unbounded read would make a customer's own dashboard slower
|
|
121
|
+
* for the rest of the account's life. Nothing becomes unreadable:
|
|
122
|
+
* `offset` walks the older pages, so a customer or a regulator
|
|
123
|
+
* asking for the whole history can still get it.
|
|
118
124
|
* - `currentlyImpersonating()`
|
|
119
125
|
* → array of active session rows.
|
|
120
126
|
* - `cleanupExpired({ now? })`
|
|
@@ -138,6 +144,13 @@
|
|
|
138
144
|
var TOKEN_NAMESPACE = "customer-impersonation-token";
|
|
139
145
|
var TOKEN_BYTES = 32;
|
|
140
146
|
var DEFAULT_TTL_SECONDS = 60 * 60; // allow:raw-time-literal — seconds value; C.TIME returns ms (60-minute default)
|
|
147
|
+
|
|
148
|
+
// How much of a customer's access history their own account page shows by
|
|
149
|
+
// default, and the ceiling a caller may ask for. Bounded because the rows are
|
|
150
|
+
// an audit trail that is never pruned: an unbounded read would make a
|
|
151
|
+
// customer's dashboard slower every time support helped them.
|
|
152
|
+
var DEFAULT_CUSTOMER_HISTORY = 20;
|
|
153
|
+
var MAX_CUSTOMER_HISTORY = 200;
|
|
141
154
|
var MIN_TTL_SECONDS = 60; // refuse sub-minute
|
|
142
155
|
var MAX_TTL_SECONDS = 8 * 60 * 60; // allow:raw-time-literal — seconds value; C.TIME returns ms (hard ceiling — eight hours)
|
|
143
156
|
var MAX_REASON_LEN = 280;
|
|
@@ -165,6 +178,24 @@ function _uuid(s, label) {
|
|
|
165
178
|
}
|
|
166
179
|
}
|
|
167
180
|
|
|
181
|
+
// The admin console has two kinds of operator identity: a staff account, whose
|
|
182
|
+
// id is a v7 UUID, and the bootstrap / break-glass credential, whose id is the
|
|
183
|
+
// reserved literal below. Both are real operators and both must be recordable
|
|
184
|
+
// here — a store running the single-credential console (the common deploy) has
|
|
185
|
+
// no UUID to offer, and refusing it would make the feature unreachable exactly
|
|
186
|
+
// where the audit trail matters most.
|
|
187
|
+
//
|
|
188
|
+
// Only this one literal is admitted; anything else still has to be a UUID, so
|
|
189
|
+
// the column cannot fill up with free-form operator labels. It matches the id
|
|
190
|
+
// the rest of the console records against the same action, which keeps an
|
|
191
|
+
// impersonation row joinable to its audit entries.
|
|
192
|
+
var BOOTSTRAP_OPERATOR_ID = "owner";
|
|
193
|
+
|
|
194
|
+
function _operatorId(s, label) {
|
|
195
|
+
if (s === BOOTSTRAP_OPERATOR_ID) return s;
|
|
196
|
+
return _uuid(s, label);
|
|
197
|
+
}
|
|
198
|
+
|
|
168
199
|
function _requiredString(s, label, maxLen) {
|
|
169
200
|
if (typeof s !== "string") {
|
|
170
201
|
throw new TypeError("customer-impersonation: " + label + " must be a string");
|
|
@@ -308,7 +339,7 @@ function create(opts) {
|
|
|
308
339
|
if (!input || typeof input !== "object") {
|
|
309
340
|
throw new TypeError("customer-impersonation.startImpersonation: input object required");
|
|
310
341
|
}
|
|
311
|
-
var operatorId =
|
|
342
|
+
var operatorId = _operatorId(input.operator_id, "operator_id");
|
|
312
343
|
var customerId = _uuid(input.customer_id, "customer_id");
|
|
313
344
|
var reason = _requiredString(input.reason, "reason", MAX_REASON_LEN);
|
|
314
345
|
var ttl = _ttlSeconds(input.ttl_seconds);
|
|
@@ -543,7 +574,7 @@ function create(opts) {
|
|
|
543
574
|
// started, newest-first. `active_only: true` filters to live
|
|
544
575
|
// sessions only.
|
|
545
576
|
listForOperator: async function (operatorId, listOpts) {
|
|
546
|
-
var oid =
|
|
577
|
+
var oid = _operatorId(operatorId, "operator_id");
|
|
547
578
|
listOpts = listOpts || {};
|
|
548
579
|
if (typeof listOpts !== "object") {
|
|
549
580
|
throw new TypeError("customer-impersonation.listForOperator: opts must be an object");
|
|
@@ -568,12 +599,33 @@ function create(opts) {
|
|
|
568
599
|
// Returns every impersonation session ever opened against the
|
|
569
600
|
// customer, newest-first. The customer-side "show me who's looked
|
|
570
601
|
// at my account" page reads this directly.
|
|
571
|
-
|
|
602
|
+
// `limit` is bounded and defaulted because the storefront renders this on
|
|
603
|
+
// every /account load. These rows are an audit trail and are never
|
|
604
|
+
// deleted, so an unbounded read would grow the query cost and the page
|
|
605
|
+
// weight of a customer's own dashboard for the rest of the account's life
|
|
606
|
+
// — worst for exactly the customers support has helped most often.
|
|
607
|
+
listForCustomer: async function (customerId, listOpts) {
|
|
572
608
|
var cid = _uuid(customerId, "customer_id");
|
|
609
|
+
var opts = listOpts || {};
|
|
610
|
+
var limit = opts.limit == null ? DEFAULT_CUSTOMER_HISTORY : opts.limit;
|
|
611
|
+
if (typeof limit !== "number" || !Number.isInteger(limit) || limit <= 0 ||
|
|
612
|
+
limit > MAX_CUSTOMER_HISTORY) {
|
|
613
|
+
throw new TypeError("customer-impersonation.listForCustomer: limit must be an integer in [1, " +
|
|
614
|
+
MAX_CUSTOMER_HISTORY + "]");
|
|
615
|
+
}
|
|
616
|
+
// Offset paging, so capping the page does not put older rows out of
|
|
617
|
+
// reach. These are audit records: a bounded default protects the
|
|
618
|
+
// account page from growing heavier with every support visit, but
|
|
619
|
+
// nothing may become unreadable — a customer or a regulator asking for
|
|
620
|
+
// the whole history has to be able to walk it.
|
|
621
|
+
var offset = opts.offset == null ? 0 : opts.offset;
|
|
622
|
+
if (typeof offset !== "number" || !Number.isInteger(offset) || offset < 0) {
|
|
623
|
+
throw new TypeError("customer-impersonation.listForCustomer: offset must be a non-negative integer");
|
|
624
|
+
}
|
|
573
625
|
var r = await query(
|
|
574
626
|
"SELECT * FROM impersonations WHERE customer_id = ?1 " +
|
|
575
|
-
"ORDER BY started_at DESC, id DESC",
|
|
576
|
-
[cid],
|
|
627
|
+
"ORDER BY started_at DESC, id DESC LIMIT ?2 OFFSET ?3",
|
|
628
|
+
[cid, limit, offset],
|
|
577
629
|
);
|
|
578
630
|
return r.rows.map(_projectRow);
|
|
579
631
|
},
|
|
@@ -703,6 +755,52 @@ function create(opts) {
|
|
|
703
755
|
// `endImpersonation` because the audit log distinguishes
|
|
704
756
|
// operator-driven completion from operator-side termination.
|
|
705
757
|
// Idempotent on already-terminal rows.
|
|
758
|
+
// ---- consumeToken -----------------------------------------------------
|
|
759
|
+
//
|
|
760
|
+
// Spend the handoff bearer WITHOUT ending the session.
|
|
761
|
+
//
|
|
762
|
+
// `startImpersonation` mints a single plaintext token and the operator
|
|
763
|
+
// follows it once, out of the console and into the storefront. Verifying
|
|
764
|
+
// it is a read, though, so until the token stops matching, that URL keeps
|
|
765
|
+
// working: it sits in browser history, in a chat message if it was pasted,
|
|
766
|
+
// in a proxy log — and anyone holding it can mint their own cookie for
|
|
767
|
+
// that customer for the rest of the hour. The handoff is meant to be
|
|
768
|
+
// one-time; this is what makes it so.
|
|
769
|
+
//
|
|
770
|
+
// Rotating the hash to a fresh random value is what spends it. The row's
|
|
771
|
+
// authority now lives entirely in the cookie the redemption sealed, which
|
|
772
|
+
// is bound to the operator's browser; the session stays `active` and
|
|
773
|
+
// `end` / `revoke` / the sweep are unaffected. Rotating (rather than
|
|
774
|
+
// nulling) also keeps the NOT NULL UNIQUE column honest.
|
|
775
|
+
//
|
|
776
|
+
// Takes the PLAINTEXT the caller was presented, and spends only that one.
|
|
777
|
+
//
|
|
778
|
+
// The match on the current hash is what makes this single-use under
|
|
779
|
+
// concurrency. Verification is a read, so two requests carrying the same
|
|
780
|
+
// link can both pass it before either writes; if the update only keyed on
|
|
781
|
+
// the row id they would both succeed and both mint a cookie, which is the
|
|
782
|
+
// guarantee this exists to provide, broken. Conditioning the write on the
|
|
783
|
+
// hash it verified means exactly one request can win — the second finds
|
|
784
|
+
// the hash already rotated and gets `consumed: false`.
|
|
785
|
+
//
|
|
786
|
+
// Deliberately NOT idempotent: a caller that cannot claim the token must
|
|
787
|
+
// not be handed a session.
|
|
788
|
+
consumeToken: async function (impersonationId, plaintext) {
|
|
789
|
+
var impId = _uuid(impersonationId, "impersonation_id");
|
|
790
|
+
if (typeof plaintext !== "string" || !plaintext.length) {
|
|
791
|
+
throw new TypeError("customer-impersonation.consumeToken: plaintext token required");
|
|
792
|
+
}
|
|
793
|
+
var presented = b.crypto.namespaceHash(TOKEN_NAMESPACE, plaintext);
|
|
794
|
+
var spent = b.crypto.namespaceHash(
|
|
795
|
+
TOKEN_NAMESPACE, "spent:" + impId + ":" + b.crypto.toBase64Url(b.crypto.generateBytes(32)));
|
|
796
|
+
var r = await query(
|
|
797
|
+
"UPDATE impersonations SET token_hash = ?1 " +
|
|
798
|
+
"WHERE id = ?2 AND status = 'active' AND token_hash = ?3",
|
|
799
|
+
[spent, impId, presented],
|
|
800
|
+
);
|
|
801
|
+
return { consumed: r.rowCount === 1 };
|
|
802
|
+
},
|
|
803
|
+
|
|
706
804
|
revoke: async function (input) {
|
|
707
805
|
if (!input || typeof input !== "object") {
|
|
708
806
|
throw new TypeError("customer-impersonation.revoke: input object required");
|
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,
|
package/package.json
CHANGED