@blamejs/blamejs-shop 0.5.22 → 0.5.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +4 -0
- package/README.md +1 -0
- package/SECURITY.md +23 -0
- package/lib/admin.js +292 -20
- package/lib/asset-manifest.json +5 -5
- package/lib/business-hours.js +4 -5
- package/lib/carrier-rates.js +4 -5
- package/lib/compliance-export.js +32 -2
- package/lib/customer-impersonation.js +140 -12
- package/lib/delivery-estimate.js +4 -5
- package/lib/payment.js +5 -3
- package/lib/shipping-labels.js +4 -5
- package/lib/shipping-zones.js +4 -5
- package/lib/storefront.js +583 -1
- package/lib/webhooks.js +5 -3
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.5.x
|
|
10
10
|
|
|
11
|
+
- v0.5.24 (2026-08-11) — **A supervisor can see every operator currently inside a customer's account and end any of them, and admin responses are no longer served to a shared cache without instruction.** The support session added in the previous release could be started, watched by the customer it concerned, and left — but there was nowhere an operator could see all of them at once. That is the surface a capability like this is supervised from, so it now exists: every live session, whose account, since when, the reason the operator gave, and one control to end any of them. Separately, every response the admin console produces carried no cache directive at all. That is signed-in operator data — customer rosters, order detail, the audit chain — and a shared proxy was left to make its own decision about storing it; all of it is now marked private and must-revalidate, and framed with a content length. **Added:** *A screen showing who is inside a customer's account right now* — `/admin/impersonation` lists every live support session across all operators: who is in, whose account, when it started, when it expires on its own, and the reason they gave for opening it. Each row carries a control to end that session, which also requires a reason. Ending one stops that operator on their very next request rather than whenever their cookie would have expired. Reading the screen needs no special permission — seeing that a session is running is oversight, and restricting oversight to the same role that can start sessions is how a capability stops being watched. Ending one takes the same owner-only authority as starting one. **Fixed:** *The release pipeline no longer reports a healthy publish as unfinished* — The publish step looked up the most recent run of the publish workflow rather than the run for the tag it had just pushed. GitHub takes a few seconds to register a run, so within that window it watched the PREVIOUS release's run — already finished, already green — and then reported that the registry did not have the new version yet. It now matches the run by tag, waits for it to appear, and polls the registry for a bounded interval before concluding anything, so a slow propagation is waited out rather than announced as a problem. · *A transient Cloudflare error no longer stops a deploy* — Applying database migrations occasionally fails with an authorization error that clears on its own moments later with the same credentials. It reads exactly like a broken token and is not one. The step now retries with backoff, and if it still fails the message says how to tell the two apart: a token that lists database write access was the transient case, while one narrowed to read-only genuinely needs re-authenticating. · *Five copies of the same control-byte scanner became one call* — Business hours, carrier rates, delivery estimates, shipping labels and shipping zones each carried a character-for-character copy of the same six-line loop. All five now call the framework's own predicate. The accepted and rejected sets are unchanged — verified codepoint by codepoint before the change, which is how the one difference that mattered was caught: the framework's default permits a tab, and these fields do not. · *Comments describing the outbound TLS posture match the code again* — Four comments still described payment-processor connections as pinning a hand-built handshake configuration. That was removed when the framework's own offer gained a classical option, at which point the pin had become the downgrade it was meant to avoid. The comments said the opposite of what the code did, in the place an operator reading about outbound TLS would look. **Security:** *Admin responses are no longer cacheable by a shared proxy* — Every JSON and HTML response from the admin console was served with no `Cache-Control` header whatsoever, which leaves an intermediary free to decide for itself whether to store it. The content is signed-in operator data: customer rosters, order detail, the audit chain, captured error text. All of it now carries `private, no-cache, must-revalidate` and a `Content-Length`, so a response is framed and an intermediary is told plainly not to keep it. The console keeps its existing `noindex`.
|
|
12
|
+
|
|
13
|
+
- 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.
|
|
14
|
+
|
|
11
15
|
- 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
16
|
|
|
13
17
|
- 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,29 @@ 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. Operators get the other half of that view at
|
|
300
|
+
`/admin/impersonation`: every live session at once, with one control to end
|
|
301
|
+
any of them. Reading it needs no special permission, because restricting
|
|
302
|
+
oversight to the role that can start sessions is how a capability stops
|
|
303
|
+
being watched; ending one takes the same owner-only authority as starting
|
|
304
|
+
one, and takes effect on that operator's next request.
|
|
282
305
|
- **Text one person writes and another reads is screened for invisible
|
|
283
306
|
characters.** A Unicode bidirectional override reverses the display
|
|
284
307
|
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)
|
|
@@ -685,19 +691,22 @@ function _problem(res, status, code, detail) {
|
|
|
685
691
|
});
|
|
686
692
|
}
|
|
687
693
|
|
|
694
|
+
// Console HTML through the framework renderer — see `_json` for why. The
|
|
695
|
+
// `x-robots-tag` rides `opts.headers`, which merges OVER the renderer's
|
|
696
|
+
// defaults, so the admin console keeps its noindex while gaining the
|
|
697
|
+
// Cache-Control and Content-Length the hand-rolled version omitted.
|
|
688
698
|
function _sendHtml(res, status, html) {
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
}
|
|
694
|
-
if (res.end) res.end(html); else res.send(html);
|
|
699
|
+
b.render.htmlString(res, html, {
|
|
700
|
+
status: status,
|
|
701
|
+
headers: { "X-Robots-Tag": "noindex, nofollow" },
|
|
702
|
+
});
|
|
695
703
|
}
|
|
696
704
|
|
|
697
705
|
function _redirect(res, location) {
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
706
|
+
// `b.render.redirect` additionally validates the 3xx range and refuses an
|
|
707
|
+
// empty location — a redirect to nowhere is a blank page an operator has to
|
|
708
|
+
// debug from the network tab.
|
|
709
|
+
b.render.redirect(res, location, { status: 303 });
|
|
701
710
|
}
|
|
702
711
|
|
|
703
712
|
// Single classifier for a thrown admin error → an operator-safe outcome,
|
|
@@ -882,6 +891,7 @@ function mount(router, deps) {
|
|
|
882
891
|
var customers = deps.customers || null; // read-only customers console disabled when absent
|
|
883
892
|
var storeCredit = deps.storeCredit || null; // per-customer store-credit panel + grant/deduct disabled when absent
|
|
884
893
|
var customerNotes = deps.customerNotes || null; // per-customer CRM notes panel disabled when absent
|
|
894
|
+
var customerImpersonation = deps.customerImpersonation || null; // "view as customer" hidden + route unmounted when absent
|
|
885
895
|
var customerSegments = deps.customerSegments || null; // per-customer segment-membership panel disabled when absent
|
|
886
896
|
var customerActivity = deps.customerActivity || null; // per-customer chronological activity-timeline panel disabled when absent
|
|
887
897
|
var orderNotes = deps.orderNotes || null; // per-order customer-service notes panel + add/lifecycle disabled when absent
|
|
@@ -976,7 +986,7 @@ function mount(router, deps) {
|
|
|
976
986
|
// `reports` is always present in the nav (read-only sales summary needs no
|
|
977
987
|
// extra dep); its route mounts unconditionally and renders an unconfigured
|
|
978
988
|
// notice when the salesReports primitive isn't wired.
|
|
979
|
-
var navAvailable = { analytics: !!deps.analytics, returns: !!returns, reviews: !!reviews, productQa: !!productQa, subscriptions: !!deps.subscriptions, preorder: !!deps.preorder, webhooks: !!deps.webhooks, collections: !!deps.collections, customers: !!deps.customers, customerSegments: !!customerSegments, giftcards: !!deps.giftcards, announcementBar: !!deps.announcementBar, promoBanners: !!deps.promoBanners, suggestionBox: !!deps.suggestionBox, sidebarWidgets: !!deps.sidebarWidgets, blog: !!deps.blog, knowledgeBase: !!deps.knowledgeBase, customerSurveys: !!deps.customerSurveys, storefrontPages: !!deps.storefrontPages, businessHours: !!deps.businessHours, taxRates: !!deps.taxRates, shippingZones: !!deps.shippingZones, deliveryEstimate: !!deps.deliveryEstimate, autoDiscount: !!deps.autoDiscount, discountAllocation: !!deps.discountAllocation, quantityDiscounts: !!deps.quantityDiscounts, loyalty: !!deps.loyalty, pickLists: !!pickLists, salesTaxFilings: !!salesTaxFilings, shippingLabels: !!shippingLabels, supportTickets: !!supportTickets, complianceExport: !!complianceExport, orderExchanges: !!orderExchanges, orderRatings: !!orderRatings, clickAndCollect: !!clickAndCollect, giftOptions: !!giftOptions, searchRanking: !!searchRanking, searchSuggestions: !!searchSuggestions, trustBadges: !!trustBadges, orderExport: !!orderExport, auditLog: auditLog, errorLog: !!errorLog, carts: !!cart, inventoryLocations: !!inventoryLocations, inventoryReceive: !!inventoryReceive, stockTransfers: !!stockTransfers, inventoryWriteoffs: !!inventoryWriteoffs, quotes: !!deps.quotes, emailCampaigns: !!emailCampaigns, winback: !!winback, operators: !!operatorAccounts, inbox: !!operatorInbox };
|
|
989
|
+
var navAvailable = { analytics: !!deps.analytics, returns: !!returns, reviews: !!reviews, productQa: !!productQa, subscriptions: !!deps.subscriptions, preorder: !!deps.preorder, webhooks: !!deps.webhooks, collections: !!deps.collections, customers: !!deps.customers, customerSegments: !!customerSegments, giftcards: !!deps.giftcards, announcementBar: !!deps.announcementBar, promoBanners: !!deps.promoBanners, suggestionBox: !!deps.suggestionBox, sidebarWidgets: !!deps.sidebarWidgets, blog: !!deps.blog, knowledgeBase: !!deps.knowledgeBase, customerSurveys: !!deps.customerSurveys, storefrontPages: !!deps.storefrontPages, businessHours: !!deps.businessHours, taxRates: !!deps.taxRates, shippingZones: !!deps.shippingZones, deliveryEstimate: !!deps.deliveryEstimate, autoDiscount: !!deps.autoDiscount, discountAllocation: !!deps.discountAllocation, quantityDiscounts: !!deps.quantityDiscounts, loyalty: !!deps.loyalty, pickLists: !!pickLists, salesTaxFilings: !!salesTaxFilings, shippingLabels: !!shippingLabels, supportTickets: !!supportTickets, complianceExport: !!complianceExport, orderExchanges: !!orderExchanges, orderRatings: !!orderRatings, clickAndCollect: !!clickAndCollect, giftOptions: !!giftOptions, searchRanking: !!searchRanking, searchSuggestions: !!searchSuggestions, trustBadges: !!trustBadges, orderExport: !!orderExport, auditLog: auditLog, errorLog: !!errorLog, carts: !!cart, inventoryLocations: !!inventoryLocations, inventoryReceive: !!inventoryReceive, stockTransfers: !!stockTransfers, inventoryWriteoffs: !!inventoryWriteoffs, quotes: !!deps.quotes, emailCampaigns: !!emailCampaigns, winback: !!winback, operators: !!operatorAccounts, inbox: !!operatorInbox, impersonation: !!customerImpersonation };
|
|
980
990
|
|
|
981
991
|
try { b.audit.registerNamespace(AUDIT_NAMESPACE); } catch (_e) { /* idempotent */ }
|
|
982
992
|
|
|
@@ -1110,11 +1120,18 @@ function mount(router, deps) {
|
|
|
1110
1120
|
};
|
|
1111
1121
|
}
|
|
1112
1122
|
|
|
1123
|
+
// Every admin JSON response goes through the framework renderer rather than
|
|
1124
|
+
// a hand-rolled stringify-and-end. Composing it here rather than at the 300+
|
|
1125
|
+
// call sites means the whole surface gains what the helper was missing:
|
|
1126
|
+
//
|
|
1127
|
+
// Cache-Control: private, no-cache, must-revalidate — this is signed-in
|
|
1128
|
+
// OPERATOR data (customer rosters, order detail, audit rows) and it was
|
|
1129
|
+
// being served with no cache directive at all, which leaves a shared
|
|
1130
|
+
// proxy free to make its own decision about storing it.
|
|
1131
|
+
// Content-Length — a framed response, so a client can tell a truncated
|
|
1132
|
+
// body from a complete one.
|
|
1113
1133
|
function _json(res, status, obj) {
|
|
1114
|
-
|
|
1115
|
-
if (res.setHeader) res.setHeader("content-type", "application/json; charset=utf-8");
|
|
1116
|
-
var body = JSON.stringify(obj);
|
|
1117
|
-
if (res.end) res.end(body); else res.send(body);
|
|
1134
|
+
b.render.json(res, obj, { status: status });
|
|
1118
1135
|
}
|
|
1119
1136
|
|
|
1120
1137
|
// ---- products -------------------------------------------------------
|
|
@@ -1699,11 +1716,10 @@ function mount(router, deps) {
|
|
|
1699
1716
|
// ML-KEM hybrid group answers with a handshake_failure (TLS alert 40);
|
|
1700
1717
|
// that surfaces below as the same clean 502 `source-fetch-failed`
|
|
1701
1718
|
// (`e.message` carries the alert) the operator already retries by
|
|
1702
|
-
// re-submitting.
|
|
1703
|
-
//
|
|
1704
|
-
//
|
|
1705
|
-
//
|
|
1706
|
-
// host that can't meet the PQC list upload the file directly instead.
|
|
1719
|
+
// re-submitting. No caller-supplied TLS agent is pinned here, and none
|
|
1720
|
+
// is pinned on the processor dials either — every outbound dial in the
|
|
1721
|
+
// shop holds the framework's PQC-first default. Operators sourcing from
|
|
1722
|
+
// a host that can't meet the PQC list upload the file directly instead.
|
|
1707
1723
|
if (r2) {
|
|
1708
1724
|
// Fetch → store → attach, shared by the JSON upload route and the
|
|
1709
1725
|
// browser POST alias. Throws TypeError on bad input (mapped to 400);
|
|
@@ -3767,6 +3783,8 @@ function mount(router, deps) {
|
|
|
3767
3783
|
store_credit_history: creditHistory,
|
|
3768
3784
|
loyalty: loyaltyInfo,
|
|
3769
3785
|
loyalty_link: !!deps.loyalty,
|
|
3786
|
+
impersonation_enabled: !!customerImpersonation,
|
|
3787
|
+
impersonation_error: flags.impersonation_error || null,
|
|
3770
3788
|
can_notes: !!customerNotes,
|
|
3771
3789
|
notes: notes,
|
|
3772
3790
|
show_archived_notes: showArchivedNotes,
|
|
@@ -3819,6 +3837,7 @@ function mount(router, deps) {
|
|
|
3819
3837
|
credit_notice: url && url.searchParams.get("credit_err") ? url.searchParams.get("credit_err") : null,
|
|
3820
3838
|
note_notice: url && url.searchParams.get("note_err") ? url.searchParams.get("note_err") : null,
|
|
3821
3839
|
show_archived_notes: !!(url && url.searchParams.get("notes_archived") === "1"),
|
|
3840
|
+
impersonation_error: url && url.searchParams.get("imp_err") ? url.searchParams.get("imp_err") : null,
|
|
3822
3841
|
};
|
|
3823
3842
|
_sendHtml(res, 200, renderAdminCustomerDetail(await _customerDetailModel(c, flags)));
|
|
3824
3843
|
},
|
|
@@ -3906,6 +3925,169 @@ function mount(router, deps) {
|
|
|
3906
3925
|
));
|
|
3907
3926
|
}
|
|
3908
3927
|
|
|
3928
|
+
// ---- view as customer ------------------------------------------------
|
|
3929
|
+
//
|
|
3930
|
+
// Opens a support session on the storefront carrying the customer's own
|
|
3931
|
+
// session. Gated at the write chokepoint on `customers.impersonate` — the
|
|
3932
|
+
// `impersonation.` action prefix maps to it, and only `owner` holds it, so
|
|
3933
|
+
// a manager with `customers.write` is refused here.
|
|
3934
|
+
//
|
|
3935
|
+
// A reason is REQUIRED and stored on the row. An impersonation with no
|
|
3936
|
+
// stated purpose is the one an audit cannot evaluate afterwards, and the
|
|
3937
|
+
// customer sees this text on their own account page.
|
|
3938
|
+
if (customerImpersonation) {
|
|
3939
|
+
router.post("/admin/customers/:id/impersonate", _pageOrApi(false,
|
|
3940
|
+
W("impersonation.start", async function (req, res) {
|
|
3941
|
+
var c = await _resolveCustomer(req.params.id);
|
|
3942
|
+
if (!c) return _problem(res, 404, "customer-not-found");
|
|
3943
|
+
var reason = (req.body && req.body.reason) || "";
|
|
3944
|
+
if (typeof reason !== "string" || !reason.trim().length) {
|
|
3945
|
+
return _problem(res, 400, "bad-request", "A reason is required to view as a customer.");
|
|
3946
|
+
}
|
|
3947
|
+
var started;
|
|
3948
|
+
try {
|
|
3949
|
+
started = await customerImpersonation.startImpersonation({
|
|
3950
|
+
operator_id: req.operatorActor && req.operatorActor.operator_id,
|
|
3951
|
+
customer_id: c.id,
|
|
3952
|
+
reason: reason.trim(),
|
|
3953
|
+
});
|
|
3954
|
+
} catch (e) {
|
|
3955
|
+
if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message);
|
|
3956
|
+
throw e;
|
|
3957
|
+
}
|
|
3958
|
+
// Best-effort out-of-band notice. This store has no plaintext
|
|
3959
|
+
// customer address, so it reports `notified: false` and stamps
|
|
3960
|
+
// nothing — the customer is told on their own account page instead,
|
|
3961
|
+
// which needs no address and cannot silently fail.
|
|
3962
|
+
try { await customerImpersonation.notifyCustomer({ impersonation_id: started.impersonation_id }); }
|
|
3963
|
+
catch (_e) { /* the account-page record is the delivery that matters */ }
|
|
3964
|
+
// The plaintext bearer leaves the primitive exactly once. It goes
|
|
3965
|
+
// straight back to the operator who asked for it and is never
|
|
3966
|
+
// stored or logged.
|
|
3967
|
+
_json(res, 200, {
|
|
3968
|
+
impersonation_id: started.impersonation_id,
|
|
3969
|
+
expires_at: started.expires_at,
|
|
3970
|
+
open_url: "/account/impersonate/" + encodeURIComponent(started.plaintext_token),
|
|
3971
|
+
});
|
|
3972
|
+
return { id: c.id };
|
|
3973
|
+
}),
|
|
3974
|
+
async function (req, res) {
|
|
3975
|
+
var c = await _resolveCustomer(req.params.id);
|
|
3976
|
+
if (!c) return _redirect(res, "/admin/customers");
|
|
3977
|
+
var reason = (req.body && req.body.reason) || "";
|
|
3978
|
+
if (typeof reason !== "string" || !reason.trim().length) {
|
|
3979
|
+
return _redirect(res, "/admin/customers/" + encodeURIComponent(c.id) +
|
|
3980
|
+
"?imp_err=" + encodeURIComponent("Give a reason before viewing as this customer."));
|
|
3981
|
+
}
|
|
3982
|
+
var started;
|
|
3983
|
+
try {
|
|
3984
|
+
started = await customerImpersonation.startImpersonation({
|
|
3985
|
+
operator_id: req.operatorActor && req.operatorActor.operator_id,
|
|
3986
|
+
customer_id: c.id,
|
|
3987
|
+
reason: reason.trim(),
|
|
3988
|
+
});
|
|
3989
|
+
} catch (e) {
|
|
3990
|
+
var n = _safeNotice(e, "impersonation.start");
|
|
3991
|
+
return _redirect(res, "/admin/customers/" + encodeURIComponent(c.id) +
|
|
3992
|
+
"?imp_err=" + encodeURIComponent(n.message.replace(/^admin[.:]\s*/, "")));
|
|
3993
|
+
}
|
|
3994
|
+
try { await customerImpersonation.notifyCustomer({ impersonation_id: started.impersonation_id }); }
|
|
3995
|
+
catch (_e) { /* as above */ }
|
|
3996
|
+
// Straight into the storefront as the customer. The token is spent
|
|
3997
|
+
// by the redemption route on arrival.
|
|
3998
|
+
return _redirect(res, "/account/impersonate/" +
|
|
3999
|
+
encodeURIComponent(started.plaintext_token));
|
|
4000
|
+
}));
|
|
4001
|
+
}
|
|
4002
|
+
|
|
4003
|
+
// ---- who is in someone's account right now --------------------------
|
|
4004
|
+
//
|
|
4005
|
+
// A capability that lets one person browse as another needs a place where
|
|
4006
|
+
// ALL of its live uses are visible at once. Per-customer history answers
|
|
4007
|
+
// "was my account opened"; this answers the operator-side question —
|
|
4008
|
+
// who is inside an account at this moment, whose, since when, and why —
|
|
4009
|
+
// and gives a supervisor one control to end any of them.
|
|
4010
|
+
//
|
|
4011
|
+
// Read gate: `R` admits any authenticated operator. Seeing that a session
|
|
4012
|
+
// is running is oversight, and restricting oversight to the role that can
|
|
4013
|
+
// start sessions is how a capability stops being watched. Ending one is a
|
|
4014
|
+
// `W` and takes the same owner-only permission as starting one.
|
|
4015
|
+
if (customerImpersonation) {
|
|
4016
|
+
router.get("/admin/impersonation", _pageOrApi(true,
|
|
4017
|
+
R(async function (req, res) {
|
|
4018
|
+
// A failed lookup is NOT an empty list. The HTML page can degrade to
|
|
4019
|
+
// a notice, but a machine reading this endpoint would take
|
|
4020
|
+
// `200 { rows: [] }` as "nobody is in anyone's account" at exactly
|
|
4021
|
+
// the moment oversight is unavailable — the one answer this surface
|
|
4022
|
+
// must never give wrongly. Say unavailable instead.
|
|
4023
|
+
var rows;
|
|
4024
|
+
try { rows = await customerImpersonation.currentlyImpersonating(); }
|
|
4025
|
+
catch (_e) {
|
|
4026
|
+
return _problem(res, 503, "sessions-unavailable",
|
|
4027
|
+
"The live session list could not be read. This is not a report that no sessions are active.");
|
|
4028
|
+
}
|
|
4029
|
+
_json(res, 200, { rows: rows });
|
|
4030
|
+
}),
|
|
4031
|
+
async function (req, res) {
|
|
4032
|
+
var rows = [];
|
|
4033
|
+
var notice = null;
|
|
4034
|
+
// Read-only oversight screen: a lookup failure degrades to an empty
|
|
4035
|
+
// table with a notice rather than a 500, so the page an operator
|
|
4036
|
+
// checks during an incident is never the page that is down.
|
|
4037
|
+
try { rows = await customerImpersonation.currentlyImpersonating(); }
|
|
4038
|
+
catch (_e) { rows = []; notice = "The session list is temporarily unavailable."; }
|
|
4039
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
4040
|
+
_sendHtml(res, 200, renderAdminImpersonation({
|
|
4041
|
+
shop_name: deps.shop_name, nav_available: navAvailable,
|
|
4042
|
+
rows: rows, notice: notice,
|
|
4043
|
+
ended: url && url.searchParams.get("ended") === "1",
|
|
4044
|
+
error: url && url.searchParams.get("imp_err"),
|
|
4045
|
+
}));
|
|
4046
|
+
},
|
|
4047
|
+
));
|
|
4048
|
+
|
|
4049
|
+
router.post("/admin/impersonation/:id/revoke", _pageOrApi(false,
|
|
4050
|
+
W("impersonation.revoke", async function (req, res) {
|
|
4051
|
+
var reason = (req.body && req.body.reason) || "";
|
|
4052
|
+
if (typeof reason !== "string" || !reason.trim().length) {
|
|
4053
|
+
return _problem(res, 400, "bad-request", "A reason is required to end another operator's session.");
|
|
4054
|
+
}
|
|
4055
|
+
var out;
|
|
4056
|
+
try {
|
|
4057
|
+
out = await customerImpersonation.revoke({
|
|
4058
|
+
impersonation_id: req.params.id, reason: reason.trim(),
|
|
4059
|
+
// WHO revoked it. Without this the audit row names the operator
|
|
4060
|
+
// being removed as the one who removed themselves.
|
|
4061
|
+
revoked_by: req.operatorActor && req.operatorActor.operator_id,
|
|
4062
|
+
});
|
|
4063
|
+
} catch (e) {
|
|
4064
|
+
if (e && e.code === "IMPERSONATION_NOT_FOUND") return _problem(res, 404, "not-found");
|
|
4065
|
+
if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message);
|
|
4066
|
+
throw e;
|
|
4067
|
+
}
|
|
4068
|
+
_json(res, 200, out);
|
|
4069
|
+
return { id: req.params.id };
|
|
4070
|
+
}),
|
|
4071
|
+
async function (req, res) {
|
|
4072
|
+
var reason = (req.body && req.body.reason) || "";
|
|
4073
|
+
if (typeof reason !== "string" || !reason.trim().length) {
|
|
4074
|
+
return _redirect(res, "/admin/impersonation?imp_err=" +
|
|
4075
|
+
encodeURIComponent("Give a reason before ending someone else's session."));
|
|
4076
|
+
}
|
|
4077
|
+
try {
|
|
4078
|
+
await customerImpersonation.revoke({
|
|
4079
|
+
impersonation_id: req.params.id, reason: reason.trim(),
|
|
4080
|
+
revoked_by: req.operatorActor && req.operatorActor.operator_id,
|
|
4081
|
+
});
|
|
4082
|
+
} catch (e) {
|
|
4083
|
+
var n = _safeNotice(e, "impersonation.revoke");
|
|
4084
|
+
return _redirect(res, "/admin/impersonation?imp_err=" +
|
|
4085
|
+
encodeURIComponent(n.message.replace(/^admin[.:]\s*/, "")));
|
|
4086
|
+
}
|
|
4087
|
+
return _redirect(res, "/admin/impersonation?ended=1");
|
|
4088
|
+
}));
|
|
4089
|
+
}
|
|
4090
|
+
|
|
3909
4091
|
// ---- customer notes (add) ------------------------------------------
|
|
3910
4092
|
// Scoped to the :id customer — the note attaches to the path customer.
|
|
3911
4093
|
// Composes customerNotes.addNote (the body is required + length-capped by
|
|
@@ -15022,6 +15204,9 @@ var ADMIN_NAV_ITEMS = [
|
|
|
15022
15204
|
{ key: "reports", href: "/admin/reports", label: "Reports" },
|
|
15023
15205
|
{ key: "analytics", href: "/admin/analytics", label: "Analytics", requires: "analytics" },
|
|
15024
15206
|
{ key: "audit", href: "/admin/audit", label: "Audit", requires: "auditLog" },
|
|
15207
|
+
// Sits beside Audit rather than under Customers on purpose: it is an
|
|
15208
|
+
// oversight surface, not a customer-management one.
|
|
15209
|
+
{ key: "impersonation", href: "/admin/impersonation", label: "Customer sessions", requires: "impersonation" },
|
|
15025
15210
|
{ key: "errors", href: "/admin/errors", label: "Errors", requires: "errorLog" },
|
|
15026
15211
|
{ key: "exports", href: "/admin/exports", label: "Exports", requires: "orderExport" },
|
|
15027
15212
|
{ key: "customers", href: "/admin/customers", label: "Customers", requires: "customers" },
|
|
@@ -15672,6 +15857,62 @@ var AUDIT_OUTCOME_FILTERS = ["success", "failure", "denied"];
|
|
|
15672
15857
|
// class is un-lintable (no detector can tell a render-concat apart from a
|
|
15673
15858
|
// string-build), so the XSS-payload regression test is the guarantee — see
|
|
15674
15859
|
// test/layer-2-integration/admin-audit-console.test.js.
|
|
15860
|
+
// Who is inside a customer's account right now.
|
|
15861
|
+
//
|
|
15862
|
+
// The per-customer history answers "was my account opened". This is the other
|
|
15863
|
+
// half, and it is the one a supervisor needs: every live session at once, with
|
|
15864
|
+
// one control to end any of them. A capability that lets one person browse as
|
|
15865
|
+
// another is only as accountable as the place you can watch it from.
|
|
15866
|
+
function renderAdminImpersonation(opts) {
|
|
15867
|
+
opts = opts || {};
|
|
15868
|
+
var rows = opts.rows || [];
|
|
15869
|
+
var notice = opts.notice
|
|
15870
|
+
? "<div class=\"banner banner--warn\">" + _htmlEscape(opts.notice) + "</div>" : "";
|
|
15871
|
+
if (opts.error) {
|
|
15872
|
+
notice += "<div class=\"banner banner--warn\">" + _htmlEscape(String(opts.error)) + "</div>";
|
|
15873
|
+
}
|
|
15874
|
+
if (opts.ended) {
|
|
15875
|
+
notice += "<div class=\"banner\">Session ended. The operator is refused on their next request.</div>";
|
|
15876
|
+
}
|
|
15877
|
+
|
|
15878
|
+
var body = notice +
|
|
15879
|
+
"<h2 class=\"subhead\">Active customer sessions</h2>" +
|
|
15880
|
+
"<p class=\"meta\">Operators currently viewing the storefront as a customer. A session " +
|
|
15881
|
+
"ends on its own after an hour, when the operator leaves it, or when you end it here — " +
|
|
15882
|
+
"in every case the operator is refused on their next request rather than at cookie " +
|
|
15883
|
+
"expiry. Each customer also sees their own visits on their account page.</p>";
|
|
15884
|
+
|
|
15885
|
+
if (!rows.length) {
|
|
15886
|
+
body += "<p class=\"empty\">Nobody is viewing a customer account right now.</p>";
|
|
15887
|
+
return _renderAdminShell(opts.shop_name, "Customer sessions", body, "impersonation", opts.nav_available);
|
|
15888
|
+
}
|
|
15889
|
+
|
|
15890
|
+
var trs = rows.map(function (r) {
|
|
15891
|
+
var enc = encodeURIComponent(String(r.id));
|
|
15892
|
+
return "<tr>" +
|
|
15893
|
+
"<td><code>" + _htmlEscape(String(r.operator_id || "—")) + "</code></td>" +
|
|
15894
|
+
"<td><a href=\"/admin/customers/" + _htmlEscape(encodeURIComponent(String(r.customer_id))) +
|
|
15895
|
+
"\"><code>" + _htmlEscape(String(r.customer_id)) + "</code></a></td>" +
|
|
15896
|
+
"<td>" + _htmlEscape(_fmtTs(r.started_at)) + "</td>" +
|
|
15897
|
+
"<td>" + _htmlEscape(_fmtTs(r.expires_at)) + "</td>" +
|
|
15898
|
+
"<td>" + _htmlEscape(String(r.reason || "—")) + "</td>" +
|
|
15899
|
+
"<td><form method=\"post\" action=\"/admin/impersonation/" + _htmlEscape(enc) + "/revoke\">" +
|
|
15900
|
+
"<input type=\"text\" name=\"reason\" required maxlength=\"200\" placeholder=\"Why end it?\">" +
|
|
15901
|
+
"<button class=\"btn btn--danger\" type=\"submit\">End session</button>" +
|
|
15902
|
+
"</form></td>" +
|
|
15903
|
+
"</tr>";
|
|
15904
|
+
}).join("");
|
|
15905
|
+
|
|
15906
|
+
body +=
|
|
15907
|
+
"<table class=\"table\"><thead><tr>" +
|
|
15908
|
+
"<th scope=\"col\">Operator</th><th scope=\"col\">Customer</th>" +
|
|
15909
|
+
"<th scope=\"col\">Started</th><th scope=\"col\">Expires</th>" +
|
|
15910
|
+
"<th scope=\"col\">Stated reason</th><th scope=\"col\">End</th>" +
|
|
15911
|
+
"</tr></thead><tbody>" + trs + "</tbody></table>";
|
|
15912
|
+
|
|
15913
|
+
return _renderAdminShell(opts.shop_name, "Customer sessions", body, "impersonation", opts.nav_available);
|
|
15914
|
+
}
|
|
15915
|
+
|
|
15675
15916
|
function renderAdminAudit(opts) {
|
|
15676
15917
|
opts = opts || {};
|
|
15677
15918
|
var rows = opts.rows || [];
|
|
@@ -17131,6 +17372,36 @@ function renderAdminCustomerDetail(opts) {
|
|
|
17131
17372
|
"</div>";
|
|
17132
17373
|
}
|
|
17133
17374
|
|
|
17375
|
+
// ---- view as customer ----------------------------------------------
|
|
17376
|
+
//
|
|
17377
|
+
// Rendered only when the primitive is wired, like every other optional
|
|
17378
|
+
// panel. The reason field is required by the route and is not decoration:
|
|
17379
|
+
// it is written to the session row and shown to the CUSTOMER on their own
|
|
17380
|
+
// account page, which is what makes the whole feature answerable to the
|
|
17381
|
+
// person whose account it is. The panel says so, so an operator knows
|
|
17382
|
+
// before they type.
|
|
17383
|
+
var impersonationPanel = "";
|
|
17384
|
+
if (opts.impersonation_enabled) {
|
|
17385
|
+
var impNotice = opts.impersonation_error
|
|
17386
|
+
? "<p class=\"notice notice--error\" role=\"alert\">" + _htmlEscape(String(opts.impersonation_error)) + "</p>"
|
|
17387
|
+
: "";
|
|
17388
|
+
impersonationPanel = "<div class=\"panel\"><h3 class=\"subhead\">View as customer</h3>" +
|
|
17389
|
+
impNotice +
|
|
17390
|
+
"<p class=\"meta\">Opens the storefront signed in as this customer so you can see exactly " +
|
|
17391
|
+
"what they see. The session lasts one hour and ends when you leave it. You will not be " +
|
|
17392
|
+
"able to change their password, passkeys, email address or linked sign-ins, delete the " +
|
|
17393
|
+
"account, or export their data.</p>" +
|
|
17394
|
+
"<form method=\"post\" action=\"/admin/customers/" + _htmlEscape(enc) + "/impersonate\">" +
|
|
17395
|
+
"<label class=\"form-field\"><span>Reason</span>" +
|
|
17396
|
+
"<input type=\"text\" name=\"reason\" required maxlength=\"200\" " +
|
|
17397
|
+
"placeholder=\"e.g. customer reports the cart total is wrong\">" +
|
|
17398
|
+
"<small>Required. Recorded against the session and shown to the customer on their " +
|
|
17399
|
+
"own account page.</small></label>" +
|
|
17400
|
+
"<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Start viewing as this customer</button></div>" +
|
|
17401
|
+
"</form>" +
|
|
17402
|
+
"</div>";
|
|
17403
|
+
}
|
|
17404
|
+
|
|
17134
17405
|
// ---- loyalty -------------------------------------------------------
|
|
17135
17406
|
var loyaltyPanel = "";
|
|
17136
17407
|
if (opts.loyalty_link) {
|
|
@@ -17262,7 +17533,7 @@ function renderAdminCustomerDetail(opts) {
|
|
|
17262
17533
|
"<div class=\"actions-row\"><h2>" + _htmlEscape(c.display_name) + "</h2>" +
|
|
17263
17534
|
"<a class=\"btn btn--ghost\" href=\"/admin/customers\"><span aria-hidden=\"true\">←</span> All customers</a></div>" +
|
|
17264
17535
|
saved +
|
|
17265
|
-
identity + ordersPanel + creditPanel + loyaltyPanel + notesPanel + segmentsPanel + activityPanel +
|
|
17536
|
+
identity + ordersPanel + creditPanel + loyaltyPanel + impersonationPanel + notesPanel + segmentsPanel + activityPanel +
|
|
17266
17537
|
"</section>";
|
|
17267
17538
|
return _renderAdminShell(opts.shop_name, c.display_name || "Customer", body, "customers", opts.nav_available);
|
|
17268
17539
|
}
|
|
@@ -24250,6 +24521,7 @@ module.exports = {
|
|
|
24250
24521
|
renderAdminLoyaltyReward: renderAdminLoyaltyReward,
|
|
24251
24522
|
renderAdminConfirm: renderAdminConfirm,
|
|
24252
24523
|
renderAdminAudit: renderAdminAudit,
|
|
24524
|
+
renderAdminImpersonation: renderAdminImpersonation,
|
|
24253
24525
|
renderAdminPickupLocations: renderAdminPickupLocations,
|
|
24254
24526
|
renderAdminPickups: renderAdminPickups,
|
|
24255
24527
|
renderAdminGiftWraps: renderAdminGiftWraps,
|
package/lib/asset-manifest.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.5.
|
|
2
|
+
"version": "0.5.24",
|
|
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/business-hours.js
CHANGED
|
@@ -89,12 +89,11 @@ var SEARCH_DAYS = 366 * 2; // worst-case "find next open" walk — 2 calen
|
|
|
89
89
|
|
|
90
90
|
// ---- validators ---------------------------------------------------------
|
|
91
91
|
|
|
92
|
+
// C0 + DEL, tab included. `allowHt: false` is load-bearing: the predicate
|
|
93
|
+
// permits ASCII HT by default (it is folding whitespace in a header), and
|
|
94
|
+
// these are single-line operator fields where a tab has no business.
|
|
92
95
|
function _hasControlByte(s) {
|
|
93
|
-
|
|
94
|
-
var cc = s.charCodeAt(i);
|
|
95
|
-
if (cc <= 0x1f || cc === 0x7f) return true;
|
|
96
|
-
}
|
|
97
|
-
return false;
|
|
96
|
+
return b.structuredFields.containsControlBytes(s, { allowHt: false });
|
|
98
97
|
}
|
|
99
98
|
|
|
100
99
|
function _slug(s) {
|
package/lib/carrier-rates.js
CHANGED
|
@@ -77,12 +77,11 @@ var b = require("./vendor/blamejs");
|
|
|
77
77
|
|
|
78
78
|
// ---- validators ---------------------------------------------------------
|
|
79
79
|
|
|
80
|
+
// C0 + DEL, tab included. `allowHt: false` is load-bearing: the predicate
|
|
81
|
+
// permits ASCII HT by default (it is folding whitespace in a header), and
|
|
82
|
+
// these are single-line operator fields where a tab has no business.
|
|
80
83
|
function _hasControlByte(s) {
|
|
81
|
-
|
|
82
|
-
var cc = s.charCodeAt(i);
|
|
83
|
-
if (cc <= 0x1f || cc === 0x7f) return true;
|
|
84
|
-
}
|
|
85
|
-
return false;
|
|
84
|
+
return b.structuredFields.containsControlBytes(s, { allowHt: false });
|
|
86
85
|
}
|
|
87
86
|
|
|
88
87
|
function _slug(s) {
|