@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 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
  }
@@ -1,13 +1,13 @@
1
1
  {
2
- "version": "0.5.22",
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-wm9Kgl9osJlGxNW9swpY/yaFoS07Sw6ek5e9sce9RLd5W5BPwz9DcXs0wxym4oAn",
10
- "fingerprinted": "css/main.9f60d689ff4715d7.css"
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-K/rkm//Dzg8nuOfpaeenJvLKKl+6DEuvuJi1LLgd46BK+dd1HYmU8R7/gHHjtEsr",
22
- "fingerprinted": "js/cart-count.bfc2eb434b19c00a.js"
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",
@@ -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
- perDomain.push({
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
- * → array of session rows, newest-first.
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 = _uuid(input.operator_id, "operator_id");
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 = _uuid(operatorId, "operator_id");
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
- listForCustomer: async function (customerId) {
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
- var payload = JSON.stringify({ count: count });
15102
+ // The impersonation flag rides this response rather than getting an
15103
+ // endpoint of its own. Every page already calls this to fill the cart
15104
+ // count, including the edge-cached ones where per-session chrome cannot
15105
+ // be rendered server-side — so this is the one place a banner can be
15106
+ // raised on a cached product page without routing the visitor to the
15107
+ // container and destroying the cache hit rate.
15108
+ //
15109
+ // Read from the sealed cookie only: no database round trip, so an
15110
+ // ordinary shopper's call costs exactly what it did before. The
15111
+ // per-request liveness check still runs on the routes that matter; a
15112
+ // banner shown a few seconds after a session ended is harmless, and the
15113
+ // operator's next real request is refused regardless.
15114
+ var imp = _impersonationOf(req);
15115
+
15116
+ // Record the page the island reported. With edge rendering on, most of
15117
+ // the operator's browsing is answered by the Worker and never reaches the
15118
+ // guard, so without this the session's trail would be a run of
15119
+ // /cart/count calls naming nothing the operator actually looked at.
15120
+ //
15121
+ // The value comes from a browser and is treated that way: screened
15122
+ // through the shared codepoint guard, required to look like a rooted
15123
+ // path, and length-capped. A value that fails any of those is dropped
15124
+ // rather than refused — this is an observability sink on a hot path, and
15125
+ // the cart count must still come back.
15126
+ if (imp && deps.customerImpersonation &&
15127
+ typeof deps.customerImpersonation.actionsRecord === "function") {
15128
+ var reported = null;
15129
+ try {
15130
+ var u = req.url ? new URL(req.url, "http://localhost") : null;
15131
+ var p = u && u.searchParams.get("p");
15132
+ // 256 is the action log's own cap on `resource_id`. Accepting more
15133
+ // here would hand the primitive a value it refuses, and the catch
15134
+ // below would swallow the refusal — so a long product URL would
15135
+ // vanish from the trail without a trace. Truncate rather than drop:
15136
+ // a shortened path still says which page, and losing the record
15137
+ // entirely is the worse failure.
15138
+ if (typeof p === "string" && p.length && p.charAt(0) === "/" &&
15139
+ !textGuard.hasCodepointThreat(p, { singleLine: "reject" })) {
15140
+ reported = p.length > IMPERSONATION_PATH_MAX ? p.slice(0, IMPERSONATION_PATH_MAX) : p;
15141
+ }
15142
+ } catch (_e) { reported = null; }
15143
+ if (reported) {
15144
+ try {
15145
+ await deps.customerImpersonation.actionsRecord({
15146
+ impersonation_id: imp.impersonation_id,
15147
+ action: "VIEW",
15148
+ resource_kind: "page",
15149
+ resource_id: reported,
15150
+ });
15151
+ } catch (_e) { /* drop-silent — the cart count must still return */ }
15152
+ }
15153
+ }
15154
+
15155
+ var payload = JSON.stringify(imp
15156
+ ? { count: count, impersonating: { customer_id: imp.customer_id } }
15157
+ : { count: count });
14761
15158
  return res.end ? res.end(payload) : res.send(payload);
14762
15159
  });
14763
15160
 
@@ -16179,6 +16576,12 @@ function mount(router, deps) {
16179
16576
  var rpId = deps.rpId || (deps.shop_origin ? new URL(deps.shop_origin).hostname : "localhost");
16180
16577
  var expectedOrigin = deps.shop_origin || ("https://" + rpId);
16181
16578
 
16579
+ // The impersonation guard that closes these surfaces is mounted at the
16580
+ // TOP of this router, ahead of every route — see `impersonationGuard`
16581
+ // near the locale middleware. It cannot live here: `router.use` only
16582
+ // applies to routes registered after it, and /cart and /checkout are
16583
+ // registered before this block.
16584
+
16182
16585
  function _b64u(buf) {
16183
16586
  return b.crypto.toBase64Url(buf);
16184
16587
  }
@@ -16447,6 +16850,134 @@ function mount(router, deps) {
16447
16850
  });
16448
16851
  }
16449
16852
 
16853
+ // ---- impersonation: redeem, and exit --------------------------------
16854
+ //
16855
+ // The operator arrives here from the admin console holding the one-time
16856
+ // bearer the primitive minted. Redeeming it seals an ordinary auth cookie
16857
+ // for the target customer, marked with the impersonation row's id.
16858
+ //
16859
+ // Two steps, and the split is load-bearing.
16860
+ //
16861
+ // The GET only LOOKS: it verifies the token and paints a confirmation page
16862
+ // naming the customer and the stated reason. It changes nothing. The POST
16863
+ // spends the token and mints the session.
16864
+ //
16865
+ // Doing both in the GET would mean anything that follows a link ahead of
16866
+ // the operator — a browser prefetcher, a link-preview bot in whatever chat
16867
+ // the console was open next to, a security scanner walking history — burns
16868
+ // the one-time bearer. The operator then arrives to be told their link is
16869
+ // invalid, and something that is not the operator is holding the only
16870
+ // session that link will ever mint. A GET must not spend a credential.
16871
+ //
16872
+ // The confirmation is worth having on its own: the operator sees whose
16873
+ // account they are about to enter, and why they said they were entering
16874
+ // it, before they are inside it.
16875
+ var _impersonationRefused = function (res) {
16876
+ // One outcome for every failure — expired, ended, revoked, already
16877
+ // spent, never existed. An operator who mistypes learns nothing about
16878
+ // which, and a visitor guessing URLs learns nothing at all.
16879
+ res.status(303);
16880
+ res.setHeader && res.setHeader("location",
16881
+ "/account/login?error=" + encodeURIComponent("view-as-customer link is no longer valid"));
16882
+ return res.end ? res.end() : res.send("");
16883
+ };
16884
+
16885
+ if (deps.customerImpersonation) {
16886
+ router.get("/account/impersonate/:token", async function (req, res) {
16887
+ var ctx = null;
16888
+ try { ctx = await deps.customerImpersonation.verifyImpersonationToken(req.params.token); }
16889
+ catch (_e) { ctx = null; }
16890
+ if (!ctx) return _impersonationRefused(res);
16891
+
16892
+ var target = null;
16893
+ try { target = await deps.customers.get(ctx.customer_id); }
16894
+ catch (_e) { target = null; }
16895
+
16896
+ return _send(res, 200, renderImpersonationConfirm({
16897
+ shop_name: shopName,
16898
+ token: req.params.token,
16899
+ customer_id: ctx.customer_id,
16900
+ customer_name: (target && target.display_name) || "",
16901
+ reason: ctx.reason,
16902
+ expires_at: ctx.expires_at,
16903
+ csrf_token: req.csrfToken,
16904
+ }));
16905
+ });
16906
+
16907
+ router.post("/account/impersonate/start", async function (req, res) {
16908
+ var token = (req.body && req.body.token) || "";
16909
+ var ctx = null;
16910
+ try { ctx = await deps.customerImpersonation.verifyImpersonationToken(token); }
16911
+ catch (_e) { ctx = null; }
16912
+ if (!ctx) return _impersonationRefused(res);
16913
+
16914
+ // The claim authorizes the cookie, not the verify above: verification
16915
+ // is a read, so two requests carrying the same token can both pass it.
16916
+ // Only the one whose UPDATE still matched the hash gets a session.
16917
+ // Fails CLOSED on a handle that cannot claim. An older or custom
16918
+ // implementation without `consumeToken` would otherwise skip the claim
16919
+ // and still be handed a cookie, turning the single-use link back into
16920
+ // a bearer any number of holders could replay for the session's life.
16921
+ // No claim, no session.
16922
+ if (typeof deps.customerImpersonation.consumeToken !== "function") {
16923
+ return _impersonationRefused(res);
16924
+ }
16925
+ var claim = null;
16926
+ try { claim = await deps.customerImpersonation.consumeToken(ctx.impersonation_id, token); }
16927
+ catch (_e) { claim = null; }
16928
+ if (!claim || !claim.consumed) return _impersonationRefused(res);
16929
+
16930
+ // If this browser is already in a session, close it before the cookie
16931
+ // naming it is overwritten. The cookie is the only handle on it: once
16932
+ // replaced, the operator cannot reach the exit control for the old one
16933
+ // and its row would sit `active` until the hourly sweep — telling the
16934
+ // first customer "in progress now" about a session nobody is in, and
16935
+ // showing operator dashboards a session that has been abandoned.
16936
+ var previous = _impersonationOf(req);
16937
+ if (previous && previous.impersonation_id !== ctx.impersonation_id) {
16938
+ try {
16939
+ await deps.customerImpersonation.endImpersonation({
16940
+ impersonation_id: previous.impersonation_id,
16941
+ ended_by: "operator",
16942
+ reason: "operator moved to another customer's session",
16943
+ });
16944
+ } catch (_e) { /* the new cookie below is what the operator gets either way */ }
16945
+ }
16946
+
16947
+ _setAuthCookie(req, res, {
16948
+ customer_id: ctx.customer_id,
16949
+ // The cookie must not outlive the session it represents. The auth
16950
+ // cookie's usual 14 days would leave a sealed envelope naming a
16951
+ // dead impersonation long after it ended; the per-request liveness
16952
+ // read would refuse it, but there is no reason to hand out a
16953
+ // credential that outlives its authority.
16954
+ exp: ctx.expires_at,
16955
+ imp: ctx.impersonation_id,
16956
+ });
16957
+ res.status(303); res.setHeader && res.setHeader("location", "/account");
16958
+ return res.end ? res.end() : res.send("");
16959
+ });
16960
+
16961
+ // Leaving. Ends the row so the operator's next request is refused by
16962
+ // the liveness read even if the cookie survives, then clears the cookie
16963
+ // so the browser stops carrying the customer's session at all.
16964
+ router.post("/account/impersonate/end", async function (req, res) {
16965
+ var imp = _impersonationOf(req);
16966
+ if (imp) {
16967
+ try {
16968
+ await deps.customerImpersonation.endImpersonation({
16969
+ impersonation_id: imp.impersonation_id,
16970
+ ended_by: "operator",
16971
+ reason: "operator left the session",
16972
+ });
16973
+ } catch (_e) { /* clearing the cookie below is what protects the customer */ }
16974
+ }
16975
+ _clearAuthCookie(req, res);
16976
+ res.status(303); res.setHeader && res.setHeader("location", "/");
16977
+ return res.end ? res.end() : res.send("");
16978
+ });
16979
+ }
16980
+
16450
16981
  router.post("/account/passkey/register-begin", async function (req, res) {
16451
16982
  try {
16452
16983
  var body = _readJsonBody(req);
@@ -16845,11 +17376,48 @@ function mount(router, deps) {
16845
17376
  } catch (_e) { /* drop-silent — primitive may not expose listPasskeys on every build */ }
16846
17377
 
16847
17378
  var cartCount = await _cartCountForReq(req);
17379
+ // Every time an operator has opened this account for support, shown to
17380
+ // the person it belongs to. This is the customer's notification: the
17381
+ // store keeps their email as a hash only, so there is no address to
17382
+ // send to, and a notice that cannot be delivered is not a notice. Here
17383
+ // it needs no address, cannot bounce, and cannot silently fail to send.
17384
+ //
17385
+ // Read-only and best-effort: a lookup failure must never keep a
17386
+ // customer out of their own account.
17387
+ var accountAccesses = [];
17388
+ var accessPage = 0;
17389
+ var accessHasMore = false;
17390
+ if (deps.customerImpersonation &&
17391
+ typeof deps.customerImpersonation.listForCustomer === "function") {
17392
+ var acctUrl = req.url ? new URL(req.url, "http://localhost") : null;
17393
+ // Bounded per page, because these rows are never pruned and an
17394
+ // unbounded read would make this page slower for the rest of the
17395
+ // account's life — worst for the customers support has helped most
17396
+ // often. Paged rather than truncated: this is the customer's only
17397
+ // view of who opened their account, so nothing may become
17398
+ // permanently unreachable. `?access_page=N` walks back.
17399
+ accessPage = Math.max(0, parseInt((acctUrl && acctUrl.searchParams.get("access_page")) || "0", 10) || 0);
17400
+ try {
17401
+ // One extra row than the page shows, purely to learn whether there
17402
+ // is another page — cheaper than a second COUNT query.
17403
+ var fetched = await deps.customerImpersonation.listForCustomer(customer.id, {
17404
+ limit: ACCESS_PAGE_SIZE + 1,
17405
+ offset: accessPage * ACCESS_PAGE_SIZE,
17406
+ });
17407
+ accessHasMore = fetched.length > ACCESS_PAGE_SIZE;
17408
+ accountAccesses = accessHasMore ? fetched.slice(0, ACCESS_PAGE_SIZE) : fetched;
17409
+ }
17410
+ catch (_e) { accountAccesses = []; }
17411
+ }
17412
+
16848
17413
  _send(res, 200, renderAccount({
16849
17414
  asset_prefix: deps.asset_prefix || "/assets/",
16850
17415
  customer: customer,
16851
17416
  orders: orders,
16852
17417
  order_product_lookup: orderProductLookup,
17418
+ account_accesses: accountAccesses,
17419
+ account_access_page: accessPage,
17420
+ account_access_has_more: accessHasMore,
16853
17421
  passkey_count: passkeyCount,
16854
17422
  preorders_enabled: !!preorder,
16855
17423
  pickups_enabled: !!deps.clickAndCollect,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.5.22",
3
+ "version": "0.5.23",
4
4
  "description": "Open-source framework built on blamejs. Vendored stack, zero npm runtime deps, PQC-first crypto, security-on by default.",
5
5
  "main": "lib/index.js",
6
6
  "scripts": {