@blamejs/blamejs-shop 0.5.21 → 0.5.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/README.md +2 -1
  3. package/SECURITY.md +25 -1
  4. package/lib/addresses.js +16 -2
  5. package/lib/admin.js +116 -1
  6. package/lib/affiliates.js +3 -3
  7. package/lib/announcement-bar.js +2 -2
  8. package/lib/api-keys.js +2 -2
  9. package/lib/asset-manifest.json +5 -5
  10. package/lib/blog-articles.js +2 -2
  11. package/lib/carrier-accounts.js +2 -2
  12. package/lib/catalog-drafts.js +1 -1
  13. package/lib/category-navigation.js +6 -3
  14. package/lib/compliance-export.js +40 -3
  15. package/lib/customer-impersonation.js +105 -7
  16. package/lib/customer-notes.js +1 -1
  17. package/lib/customer-roles.js +1 -1
  18. package/lib/customer-surveys.js +2 -2
  19. package/lib/customers.js +1 -1
  20. package/lib/dispute-resolution.js +3 -3
  21. package/lib/dropship-forwarding.js +1 -1
  22. package/lib/dunning.js +1 -1
  23. package/lib/email-ab-tests.js +1 -1
  24. package/lib/email-campaigns.js +15 -2
  25. package/lib/email-warmup.js +1 -1
  26. package/lib/externaldb-d1.js +0 -8
  27. package/lib/gift-options.js +3 -3
  28. package/lib/knowledge-base.js +3 -3
  29. package/lib/line-gift-wrap.js +6 -2
  30. package/lib/live-chat.js +1 -1
  31. package/lib/notifications.js +1 -1
  32. package/lib/operator-accounts.js +1 -1
  33. package/lib/operator-help-center.js +3 -3
  34. package/lib/operator-inbox.js +2 -2
  35. package/lib/operator-roles.js +1 -1
  36. package/lib/order-notes.js +2 -2
  37. package/lib/order-ratings.js +3 -3
  38. package/lib/payment-methods.js +1 -1
  39. package/lib/payment-retries.js +1 -1
  40. package/lib/pixel-events.js +14 -6
  41. package/lib/plan-changes.js +1 -1
  42. package/lib/product-qa.js +1 -1
  43. package/lib/push-notifications.js +2 -2
  44. package/lib/reviews.js +3 -3
  45. package/lib/seller-signup.js +4 -4
  46. package/lib/sidebar-widgets.js +1 -1
  47. package/lib/sms-dispatcher.js +3 -3
  48. package/lib/stock-receipts.js +1 -1
  49. package/lib/storefront-dashboards.js +1 -1
  50. package/lib/storefront-forms.js +1 -1
  51. package/lib/storefront-pages.js +2 -2
  52. package/lib/storefront.js +569 -1
  53. package/lib/subscription-billing.js +1 -1
  54. package/lib/subscription-controls.js +1 -1
  55. package/lib/suggestion-box.js +4 -4
  56. package/lib/support-tickets.js +3 -3
  57. package/lib/tenants.js +1 -1
  58. package/lib/text-guard.js +44 -2
  59. package/lib/theme-assets.js +1 -1
  60. package/lib/vendor/MANIFEST.json +12 -12
  61. package/lib/vendor/blamejs/CHANGELOG.md +32 -0
  62. package/lib/vendor/blamejs/NOTICE +1 -1
  63. package/lib/vendor/blamejs/README.md +1 -1
  64. package/lib/vendor/blamejs/lib/network-tls.js +98 -13
  65. package/lib/vendor/blamejs/lib/vendor/MANIFEST.json +12 -12
  66. package/lib/vendor/blamejs/lib/vendor/blamejs-pki.cjs +568 -304
  67. package/lib/vendor/blamejs/package.json +1 -1
  68. package/lib/vendor/blamejs/sbom.cdx.json +6 -6
  69. package/lib/vendors.js +3 -3
  70. package/lib/webhook-receiver.js +1 -1
  71. package/lib/webhook-subscriptions.js +1 -1
  72. package/lib/wishlist-sharing.js +1 -1
  73. 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.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
+
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.
14
+
11
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.
12
16
 
13
17
  - v0.5.20 (2026-08-10) — **A download that fails part-way through no longer arrives looking complete, a webhook body carrying non-text bytes is no longer refused, and a redirect pattern is matched in time proportional to the path rather than screened and hoped for.** Every generated download — the order export, a segment's member list, a buyer's receipt — shared a write loop with three faults. A slow client turned a deliberately bounded-memory export back into an unbounded one; a client that hung up left the export pulling rows to write them nowhere; and a failure part-way through arrived as a well-formed 200 whose final row read "Internal Server Error", which a spreadsheet or an import job reads as a complete export. All three are fixed, and a failure before the first byte now reaches a real error page instead of a download that resets. Separately, inbound webhook verification recomputed its digest over a re-encoded copy of the request body, so a delivery carrying any byte that is not valid UTF-8 was refused however correctly it was signed. And an operator's redirect pattern is now run by a matcher that cannot backtrack, so patterns that were previously refused as a denial-of-service risk are simply accepted and matched in time proportional to the request path. The vendored framework moves from 0.18.17 to 0.18.21. **Changed:** *A redirect pattern is now matched in linear time, and patterns that were refused are accepted* — A regex redirect runs against the request path on every lookup, so an operator's pattern was screened when it was registered: anything whose shape suggested it could backtrack badly was refused. Screening is an approximation in both directions — it turned away patterns that were perfectly well behaved, and it could not promise to catch every shape that was not, so each shape it missed became another case to add. Patterns are now matched by a matcher that simulates every path through the pattern at once, one character at a time, so the work cannot grow faster than the length of the path times the size of the pattern whatever was written. The screen is gone with it. Patterns previously refused — including the classic nested and ambiguous repetitions — now register and match normally; one that took a conventional engine the better part of a minute on thirty characters completes in about two milliseconds on two thousand. · *The payment processors are dialled on the framework's own TLS posture* — Both processor clients pinned a hand-built TLS agent that fell back to the platform's default key exchange. That was a workaround from a time when the framework offered post-quantum key agreement only, which one processor answers with a handshake failure. The framework's offer has since ended with a classical option, so the workaround had quietly become a downgrade: it forced classical key exchange on a processor that does negotiate a post-quantum hybrid, and it bypassed the post-handshake check that records when a connection came out weaker than intended. The dials now use the framework posture, which follows a runtime change to the preferred groups. Verified against both processors: one negotiates a post-quantum hybrid, the other a classical exchange, and both connect. · *The client address behind the proxy is trusted only from the proxy* — Per-client rate limiting keys on the client address the edge publishes in a request header. That header was read wherever it appeared. It is now honoured only when the connection it arrived on came from a trusted proxy, and the address falls back to the peer's own otherwise — a header alone never speaks for a client. The default states this deployment's topology, where the container accepts connections only from the edge. · *Vendored framework refreshed to 0.18.21* — Advances the vendored framework from 0.18.17 to 0.18.21, and the store inherits the following without application changes. Outbound connections no longer lead their key-share offer with a hybrid no deployed peer implements, which cost an extra round trip on every connection and — because a retried handshake returns an empty stapled certificate status — made the framework reject peers whose certificate status was stapled correctly. A connection that performed no ephemeral key exchange, meaning no forward secrecy at all, was recorded as though it had negotiated a post-quantum hybrid; it is now identified and reported as itself. Redis over TLS, the syslog sink, WebSocket connections, outbound mail and the telemetry collector all now use the same TLS floor and group preference rather than the platform defaults. Certificate compression is offered in both directions. In the bundled certificate toolkit, two distinguished names that differ only in how their text was encoded no longer compare as equal, which is the comparison behind certificate chaining and revocation matching. A hostname written in absolute form now reaches the resolver as written, while one carrying an empty label is refused rather than resolving a different name than was asked for. And a clock-synchronisation query that could not be built no longer leaves its socket open, which on a long-running deployment accumulated one handle per failed query. **Fixed:** *A download that fails part-way through is no longer delivered as a complete one* — Once a response's status line and headers are on the wire they cannot be replaced. The error handler tried anyway: its attempt to send a 500 threw, and the fallback appended the text "Internal Server Error" to whatever had already been written and closed the response normally. The client received a 200, the content type it asked for, and however many rows had made it out followed by that sentence. A spreadsheet or an import job ingests that as a complete, successful export whose last row happens to read oddly — there is nothing in the status or the headers to say otherwise. The transfer is now ended incomplete instead, which every HTTP client reports as a failed download. Affects the order export, a segment's member list and the emailed receipt. · *An export whose query fails before the first row reaches an error page again* — The response was committed before the producer had been asked for anything. Because the rows come from a generator over a database cursor, and a generator does no work until its first row is pulled, a query that failed immediately failed after the headers were already sent — so instead of a page explaining what went wrong, the operator got an attachment that reset mid-download. The producer is now asked for its first row before anything is committed, which puts that failure back where an error page can still be produced. This is the failure most likely to occur in practice, and the only one where there is something useful to say. · *A slow client no longer costs unbounded memory during an export* — Each export writes one chunk per database batch so that memory stays flat however many rows the range covers. The write loop discarded the signal that the socket buffer was full, which does not slow the producer down — the remaining chunks queue in memory instead. Nothing shows this in testing, because a local client drains instantly; it appears only against a genuinely slow connection, which is exactly when the export is largest. The producer now waits for the socket before continuing. · *A client that hangs up mid-export stops the work behind it* — Nothing checked whether the peer was still there, so an abandoned download kept reading pages out of the database and writing them into a closed socket until the range was exhausted. The full query cost was paid with nothing delivered. The read now stops when the client goes away, and the cursor behind it is released rather than held until the request is torn down. · *The data-subject export shares the same download discipline* — The subject-access bundle is written section by section so that the process never holds the whole thing at once, and it had the same three faults as the other exports: no wait for a slow connection, no stop when the subject navigated away, and a reader failing part-way through closing a short bundle as though it were complete. The last is the one that matters here — a subject-access response that arrives well-formed but missing a domain is a compliance answer that quietly omits data, with nothing in it to say so. It now breaks the transfer instead, and a subject who abandons the download stops the readers rather than leaving them walking every domain. · *Every database query left a pending timer behind* — Each query to the database arms a deadline so a hung request cannot wait forever. The timer behind it was only ever cancelled by a function that nothing called, so a query that completed normally — which is almost all of them — left its timer pending until it expired on its own. A pending timer also keeps the runtime's event loop awake, so under load the process carried a standing population of them and a shutdown could not settle until the last one drained. The deadline now uses a timer that is discarded when the query finishes and does not hold the loop open. · *An inbound webhook carrying non-text bytes is no longer refused* — The signature is computed over the timestamp and the exact bytes of the request body. Verification recomputed it over a text copy of that body, and any byte sequence that is not valid UTF-8 does not survive being rebuilt as text — it is replaced. The digest was therefore taken over different bytes than the sender signed, and a correctly signed delivery was rejected as though the secret were wrong. The digest is now taken over the bytes as received, and over the timestamp exactly as it appeared in the header rather than a reparsed copy of it. · *A rotated webhook secret verifies without downtime* — A signature header may carry more than one signature so that a secret can be rotated without dropping deliveries signed under the previous one. Verification only ever checked a single value. Every signature the header carries is now checked. · *Concurrent checkouts no longer request a PayPal access token each* — The access token is cached, but nothing serialised the exchange, so a burst of checkouts arriving on a cold or just-expired cache each requested one. PayPal rate-limits that endpoint well below the rate a checkout burst reaches. A burst now performs one exchange and the rest use its result. A failure at that endpoint additionally opens a short window during which a still-valid cached token is served rather than the endpoint being retried on every order. **Security:** *A redirect pattern that backtracks exponentially can no longer be registered — or matter* — The screen that vetted operator redirect patterns looked for repetition nested inside repetition, and missed the other shape that behaves the same way: a choice whose alternatives can match the same text, repeated without bound. A pattern of that shape was accepted and then run against every request path that reached the redirect table, where it takes time exponential in the length of the path — a denial of service requiring no payload, only a long URL. Patterns are now matched by an engine that cannot backtrack at all, so the shape is no longer dangerous and no longer refused.
package/README.md CHANGED
@@ -102,7 +102,8 @@ 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/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 queryrefusing 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 (CVE-2021-42574), null bytes and C0 controls are refused by default; `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). |
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. |
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
 
108
109
  ### Migrations applied to D1
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
@@ -288,7 +306,13 @@ node -e "
288
306
  review title and body, order-rating comment and operator reply, product
289
307
  question, customer note, support ticket, suggestion, knowledge-base
290
308
  article, gift message — refuses the overrides, the null byte and the C0
291
- control characters. Fields rendered on a single line additionally refuse
309
+ control characters. The direction MARKS (U+200E, U+200F, U+061C) are
310
+ deliberately permitted in prose: they carry no text and only resolve the
311
+ direction of neighbouring characters, which is how a Latin house number
312
+ inside an Arabic street name is written, and refusing them turns away
313
+ right-to-left addresses and names. A value that is machine-plain rather
314
+ than prose — a URL, a slug, an identifier — refuses the marks as well.
315
+ Fields rendered on a single line additionally refuse
292
316
  tabs, line breaks and the two line separators, so a value cannot break
293
317
  out of the row it is displayed in. Fields that are not free-form prose
294
318
  also refuse the zero-width and invisible-formatting characters; the
package/lib/addresses.js CHANGED
@@ -89,8 +89,22 @@ function _str(value, label, max, opts) {
89
89
  if (trimmed.length > max) {
90
90
  throw new TypeError("addresses: " + label + " must be <= " + max + " characters");
91
91
  }
92
- if (textGuard.hasCodepointThreat(trimmed, { singleLine: "reject" })) {
93
- throw new TypeError("addresses: " + label + " contains control bytes");
92
+ // `bidiMarks: "allow"` this one helper validates every free-text field on
93
+ // an address: label, recipient name, company, both street lines, city,
94
+ // region, postal code. A correctly written Arabic or Hebrew address carries
95
+ // a direction mark wherever a Latin house number or postcode sits inside
96
+ // the line, so refusing them here turns those customers away at checkout.
97
+ // The overrides that reverse a run stay refused.
98
+ //
99
+ // Name the class that was actually found, too. Reporting every refusal as
100
+ // "control bytes" told a customer whose address was turned away to look for
101
+ // something that was not there.
102
+ var threat = textGuard.firstCodepointThreat(trimmed, {
103
+ singleLine: "reject",
104
+ bidiMarks: "allow",
105
+ });
106
+ if (threat) {
107
+ throw new TypeError("addresses: " + label + " " + threat);
94
108
  }
95
109
  return trimmed;
96
110
  }
package/lib/admin.js CHANGED
@@ -173,6 +173,12 @@ var _ACTION_PERMISSION = Object.freeze({
173
173
  inbox: "orders.write",
174
174
  // customers
175
175
  customer: "customers.write", customer_segment: "customers.write",
176
+ // Opening a session as a customer is its own permission, NOT
177
+ // `customers.write`. A manager who can edit a customer record still cannot
178
+ // become one: `customers:impersonate` is covered only by the owner role's
179
+ // root `*` scope. Naming it separately also means the denial audit row says
180
+ // which authority was missing rather than the generic customer write.
181
+ impersonation: "customers.impersonate",
176
182
  // shop configuration
177
183
  config: "settings.write",
178
184
  // operator management (mounted by this feature)
@@ -882,6 +888,7 @@ function mount(router, deps) {
882
888
  var customers = deps.customers || null; // read-only customers console disabled when absent
883
889
  var storeCredit = deps.storeCredit || null; // per-customer store-credit panel + grant/deduct disabled when absent
884
890
  var customerNotes = deps.customerNotes || null; // per-customer CRM notes panel disabled when absent
891
+ var customerImpersonation = deps.customerImpersonation || null; // "view as customer" hidden + route unmounted when absent
885
892
  var customerSegments = deps.customerSegments || null; // per-customer segment-membership panel disabled when absent
886
893
  var customerActivity = deps.customerActivity || null; // per-customer chronological activity-timeline panel disabled when absent
887
894
  var orderNotes = deps.orderNotes || null; // per-order customer-service notes panel + add/lifecycle disabled when absent
@@ -3767,6 +3774,8 @@ function mount(router, deps) {
3767
3774
  store_credit_history: creditHistory,
3768
3775
  loyalty: loyaltyInfo,
3769
3776
  loyalty_link: !!deps.loyalty,
3777
+ impersonation_enabled: !!customerImpersonation,
3778
+ impersonation_error: flags.impersonation_error || null,
3770
3779
  can_notes: !!customerNotes,
3771
3780
  notes: notes,
3772
3781
  show_archived_notes: showArchivedNotes,
@@ -3819,6 +3828,7 @@ function mount(router, deps) {
3819
3828
  credit_notice: url && url.searchParams.get("credit_err") ? url.searchParams.get("credit_err") : null,
3820
3829
  note_notice: url && url.searchParams.get("note_err") ? url.searchParams.get("note_err") : null,
3821
3830
  show_archived_notes: !!(url && url.searchParams.get("notes_archived") === "1"),
3831
+ impersonation_error: url && url.searchParams.get("imp_err") ? url.searchParams.get("imp_err") : null,
3822
3832
  };
3823
3833
  _sendHtml(res, 200, renderAdminCustomerDetail(await _customerDetailModel(c, flags)));
3824
3834
  },
@@ -3906,6 +3916,81 @@ function mount(router, deps) {
3906
3916
  ));
3907
3917
  }
3908
3918
 
3919
+ // ---- view as customer ------------------------------------------------
3920
+ //
3921
+ // Opens a support session on the storefront carrying the customer's own
3922
+ // session. Gated at the write chokepoint on `customers.impersonate` — the
3923
+ // `impersonation.` action prefix maps to it, and only `owner` holds it, so
3924
+ // a manager with `customers.write` is refused here.
3925
+ //
3926
+ // A reason is REQUIRED and stored on the row. An impersonation with no
3927
+ // stated purpose is the one an audit cannot evaluate afterwards, and the
3928
+ // customer sees this text on their own account page.
3929
+ if (customerImpersonation) {
3930
+ router.post("/admin/customers/:id/impersonate", _pageOrApi(false,
3931
+ W("impersonation.start", async function (req, res) {
3932
+ var c = await _resolveCustomer(req.params.id);
3933
+ if (!c) return _problem(res, 404, "customer-not-found");
3934
+ var reason = (req.body && req.body.reason) || "";
3935
+ if (typeof reason !== "string" || !reason.trim().length) {
3936
+ return _problem(res, 400, "bad-request", "A reason is required to view as a customer.");
3937
+ }
3938
+ var started;
3939
+ try {
3940
+ started = await customerImpersonation.startImpersonation({
3941
+ operator_id: req.operatorActor && req.operatorActor.operator_id,
3942
+ customer_id: c.id,
3943
+ reason: reason.trim(),
3944
+ });
3945
+ } catch (e) {
3946
+ if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message);
3947
+ throw e;
3948
+ }
3949
+ // Best-effort out-of-band notice. This store has no plaintext
3950
+ // customer address, so it reports `notified: false` and stamps
3951
+ // nothing — the customer is told on their own account page instead,
3952
+ // which needs no address and cannot silently fail.
3953
+ try { await customerImpersonation.notifyCustomer({ impersonation_id: started.impersonation_id }); }
3954
+ catch (_e) { /* the account-page record is the delivery that matters */ }
3955
+ // The plaintext bearer leaves the primitive exactly once. It goes
3956
+ // straight back to the operator who asked for it and is never
3957
+ // stored or logged.
3958
+ _json(res, 200, {
3959
+ impersonation_id: started.impersonation_id,
3960
+ expires_at: started.expires_at,
3961
+ open_url: "/account/impersonate/" + encodeURIComponent(started.plaintext_token),
3962
+ });
3963
+ return { id: c.id };
3964
+ }),
3965
+ async function (req, res) {
3966
+ var c = await _resolveCustomer(req.params.id);
3967
+ if (!c) return _redirect(res, "/admin/customers");
3968
+ var reason = (req.body && req.body.reason) || "";
3969
+ if (typeof reason !== "string" || !reason.trim().length) {
3970
+ return _redirect(res, "/admin/customers/" + encodeURIComponent(c.id) +
3971
+ "?imp_err=" + encodeURIComponent("Give a reason before viewing as this customer."));
3972
+ }
3973
+ var started;
3974
+ try {
3975
+ started = await customerImpersonation.startImpersonation({
3976
+ operator_id: req.operatorActor && req.operatorActor.operator_id,
3977
+ customer_id: c.id,
3978
+ reason: reason.trim(),
3979
+ });
3980
+ } catch (e) {
3981
+ var n = _safeNotice(e, "impersonation.start");
3982
+ return _redirect(res, "/admin/customers/" + encodeURIComponent(c.id) +
3983
+ "?imp_err=" + encodeURIComponent(n.message.replace(/^admin[.:]\s*/, "")));
3984
+ }
3985
+ try { await customerImpersonation.notifyCustomer({ impersonation_id: started.impersonation_id }); }
3986
+ catch (_e) { /* as above */ }
3987
+ // Straight into the storefront as the customer. The token is spent
3988
+ // by the redemption route on arrival.
3989
+ return _redirect(res, "/account/impersonate/" +
3990
+ encodeURIComponent(started.plaintext_token));
3991
+ }));
3992
+ }
3993
+
3909
3994
  // ---- customer notes (add) ------------------------------------------
3910
3995
  // Scoped to the :id customer — the note attaches to the path customer.
3911
3996
  // Composes customerNotes.addNote (the body is required + length-capped by
@@ -17131,6 +17216,36 @@ function renderAdminCustomerDetail(opts) {
17131
17216
  "</div>";
17132
17217
  }
17133
17218
 
17219
+ // ---- view as customer ----------------------------------------------
17220
+ //
17221
+ // Rendered only when the primitive is wired, like every other optional
17222
+ // panel. The reason field is required by the route and is not decoration:
17223
+ // it is written to the session row and shown to the CUSTOMER on their own
17224
+ // account page, which is what makes the whole feature answerable to the
17225
+ // person whose account it is. The panel says so, so an operator knows
17226
+ // before they type.
17227
+ var impersonationPanel = "";
17228
+ if (opts.impersonation_enabled) {
17229
+ var impNotice = opts.impersonation_error
17230
+ ? "<p class=\"notice notice--error\" role=\"alert\">" + _htmlEscape(String(opts.impersonation_error)) + "</p>"
17231
+ : "";
17232
+ impersonationPanel = "<div class=\"panel\"><h3 class=\"subhead\">View as customer</h3>" +
17233
+ impNotice +
17234
+ "<p class=\"meta\">Opens the storefront signed in as this customer so you can see exactly " +
17235
+ "what they see. The session lasts one hour and ends when you leave it. You will not be " +
17236
+ "able to change their password, passkeys, email address or linked sign-ins, delete the " +
17237
+ "account, or export their data.</p>" +
17238
+ "<form method=\"post\" action=\"/admin/customers/" + _htmlEscape(enc) + "/impersonate\">" +
17239
+ "<label class=\"form-field\"><span>Reason</span>" +
17240
+ "<input type=\"text\" name=\"reason\" required maxlength=\"200\" " +
17241
+ "placeholder=\"e.g. customer reports the cart total is wrong\">" +
17242
+ "<small>Required. Recorded against the session and shown to the customer on their " +
17243
+ "own account page.</small></label>" +
17244
+ "<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Start viewing as this customer</button></div>" +
17245
+ "</form>" +
17246
+ "</div>";
17247
+ }
17248
+
17134
17249
  // ---- loyalty -------------------------------------------------------
17135
17250
  var loyaltyPanel = "";
17136
17251
  if (opts.loyalty_link) {
@@ -17262,7 +17377,7 @@ function renderAdminCustomerDetail(opts) {
17262
17377
  "<div class=\"actions-row\"><h2>" + _htmlEscape(c.display_name) + "</h2>" +
17263
17378
  "<a class=\"btn btn--ghost\" href=\"/admin/customers\"><span aria-hidden=\"true\">←</span> All customers</a></div>" +
17264
17379
  saved +
17265
- identity + ordersPanel + creditPanel + loyaltyPanel + notesPanel + segmentsPanel + activityPanel +
17380
+ identity + ordersPanel + creditPanel + loyaltyPanel + impersonationPanel + notesPanel + segmentsPanel + activityPanel +
17266
17381
  "</section>";
17267
17382
  return _renderAdminShell(opts.shop_name, c.display_name || "Customer", body, "customers", opts.nav_available);
17268
17383
  }
package/lib/affiliates.js CHANGED
@@ -145,7 +145,7 @@ function _name(s) {
145
145
  if (s.length > MAX_NAME_LEN) {
146
146
  throw new TypeError("affiliates: name must be <= " + MAX_NAME_LEN + " characters");
147
147
  }
148
- textGuard.freeText(s, "affiliates: name", { singleLine: "reject", zeroWidth: "reject" });
148
+ textGuard.freeText(s, "affiliates: name", { singleLine: "reject", zeroWidth: "reject", bidiMarks: "allow" });
149
149
  return s;
150
150
  }
151
151
 
@@ -167,7 +167,7 @@ function _payoutAddress(s) {
167
167
  if (s.length > MAX_PAYOUT_ADDRESS_LEN) {
168
168
  throw new TypeError("affiliates: payout_address must be <= " + MAX_PAYOUT_ADDRESS_LEN + " characters");
169
169
  }
170
- textGuard.freeText(s, "affiliates: payout_address", { singleLine: "reject", zeroWidth: "reject" });
170
+ textGuard.freeText(s, "affiliates: payout_address", { singleLine: "reject", zeroWidth: "reject", bidiMarks: "allow" });
171
171
  return s;
172
172
  }
173
173
 
@@ -244,7 +244,7 @@ function _reason(r) {
244
244
  if (r.length > MAX_REASON_LEN) {
245
245
  throw new TypeError("affiliates: reason must be <= " + MAX_REASON_LEN + " characters");
246
246
  }
247
- textGuard.freeText(r, "affiliates: reason", { zeroWidth: "reject" });
247
+ textGuard.freeText(r, "affiliates: reason", { zeroWidth: "reject", bidiMarks: "allow" });
248
248
  return r;
249
249
  }
250
250
 
@@ -167,7 +167,7 @@ function _message(s) {
167
167
  if (typeof s !== "string" || !s.length || s.length > MAX_MESSAGE_LEN) {
168
168
  throw new TypeError("announcementBar: message must be a non-empty string <= " + MAX_MESSAGE_LEN + " chars");
169
169
  }
170
- textGuard.freeText(s, "announcementBar: message", { singleLine: "reject", zeroWidth: "reject" });
170
+ textGuard.freeText(s, "announcementBar: message", { singleLine: "reject", zeroWidth: "reject", bidiMarks: "allow" });
171
171
  return s;
172
172
  }
173
173
 
@@ -175,7 +175,7 @@ function _linkLabel(s) {
175
175
  if (typeof s !== "string" || !s.length || s.length > MAX_LINK_LABEL_LEN) {
176
176
  throw new TypeError("announcementBar: link_label must be a non-empty string <= " + MAX_LINK_LABEL_LEN + " chars");
177
177
  }
178
- textGuard.freeText(s, "announcementBar: link_label", { singleLine: "reject", zeroWidth: "reject" });
178
+ textGuard.freeText(s, "announcementBar: link_label", { singleLine: "reject", zeroWidth: "reject", bidiMarks: "allow" });
179
179
  return s;
180
180
  }
181
181
 
package/lib/api-keys.js CHANGED
@@ -137,7 +137,7 @@ function _name(s) {
137
137
  if (s.length > MAX_NAME_LEN) {
138
138
  throw new TypeError("apiKeys: name must be <= " + MAX_NAME_LEN + " characters");
139
139
  }
140
- textGuard.freeText(s, "apiKeys: name", { singleLine: "reject", zeroWidth: "reject" });
140
+ textGuard.freeText(s, "apiKeys: name", { singleLine: "reject", zeroWidth: "reject", bidiMarks: "allow" });
141
141
  return s;
142
142
  }
143
143
 
@@ -207,7 +207,7 @@ function _reason(r) {
207
207
  if (r.length > MAX_REASON_LEN) {
208
208
  throw new TypeError("apiKeys: reason must be <= " + MAX_REASON_LEN + " characters");
209
209
  }
210
- textGuard.freeText(r, "apiKeys: reason", { singleLine: "reject", zeroWidth: "reject" });
210
+ textGuard.freeText(r, "apiKeys: reason", { singleLine: "reject", zeroWidth: "reject", bidiMarks: "allow" });
211
211
  return r;
212
212
  }
213
213
 
@@ -1,13 +1,13 @@
1
1
  {
2
- "version": "0.5.21",
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",
@@ -200,7 +200,7 @@ function _title(s) {
200
200
  if (typeof s !== "string" || !s.length || s.length > MAX_TITLE_LEN) {
201
201
  throw new TypeError("blogArticles: title must be a non-empty string <= " + MAX_TITLE_LEN + " chars");
202
202
  }
203
- textGuard.freeText(s, "blogArticles: title", { singleLine: "reject", zeroWidth: "reject" });
203
+ textGuard.freeText(s, "blogArticles: title", { singleLine: "reject", zeroWidth: "reject", bidiMarks: "allow" });
204
204
  return s;
205
205
  }
206
206
 
@@ -208,7 +208,7 @@ function _body(s) {
208
208
  if (typeof s !== "string" || !s.length || s.length > MAX_BODY_LEN) {
209
209
  throw new TypeError("blogArticles: body must be a non-empty string <= " + MAX_BODY_LEN + " chars");
210
210
  }
211
- textGuard.freeText(s, "blogArticles: body", { zeroWidth: "reject" });
211
+ textGuard.freeText(s, "blogArticles: body", { zeroWidth: "reject", bidiMarks: "allow" });
212
212
  return s;
213
213
  }
214
214
 
@@ -230,7 +230,7 @@ function _label(s) {
230
230
  if (s.length === 0 || s.length > MAX_LABEL_LEN) {
231
231
  throw new TypeError("carrier-accounts: account_label must be 1.." + MAX_LABEL_LEN + " characters");
232
232
  }
233
- textGuard.freeText(s, "carrier-accounts: account_label", { singleLine: "reject", zeroWidth: "reject" });
233
+ textGuard.freeText(s, "carrier-accounts: account_label", { singleLine: "reject", zeroWidth: "reject", bidiMarks: "allow" });
234
234
  return s;
235
235
  }
236
236
 
@@ -245,7 +245,7 @@ function _reason(s) {
245
245
  if (s.length > MAX_REASON_LEN) {
246
246
  throw new TypeError("carrier-accounts: reason must be <= " + MAX_REASON_LEN + " characters");
247
247
  }
248
- textGuard.freeText(s, "carrier-accounts: reason", { singleLine: "reject", zeroWidth: "reject" });
248
+ textGuard.freeText(s, "carrier-accounts: reason", { singleLine: "reject", zeroWidth: "reject", bidiMarks: "allow" });
249
249
  return s;
250
250
  }
251
251
 
@@ -152,7 +152,7 @@ function _description(s) {
152
152
  if (typeof s !== "string" || s.length > MAX_DESC_LEN) {
153
153
  throw new TypeError("catalogDrafts: description must be a string <= " + MAX_DESC_LEN + " chars when provided");
154
154
  }
155
- textGuard.freeText(s, "catalogDrafts: description");
155
+ textGuard.freeText(s, "catalogDrafts: description", { bidiMarks: "allow" });
156
156
  return s;
157
157
  }
158
158
 
@@ -157,7 +157,7 @@ function _title(s) {
157
157
  if (s.length > MAX_TITLE_LEN) {
158
158
  throw new TypeError("categoryNavigation: title must be <= " + MAX_TITLE_LEN + " characters");
159
159
  }
160
- textGuard.freeText(s, "categoryNavigation: title", { singleLine: "reject", zeroWidth: "reject" });
160
+ textGuard.freeText(s, "categoryNavigation: title", { singleLine: "reject", zeroWidth: "reject", bidiMarks: "allow" });
161
161
  return s;
162
162
  }
163
163
 
@@ -169,7 +169,7 @@ function _description(s) {
169
169
  if (s.length > MAX_DESCRIPTION_LEN) {
170
170
  throw new TypeError("categoryNavigation: description must be <= " + MAX_DESCRIPTION_LEN + " characters");
171
171
  }
172
- textGuard.freeText(s, "categoryNavigation: description", { zeroWidth: "reject" });
172
+ textGuard.freeText(s, "categoryNavigation: description", { zeroWidth: "reject", bidiMarks: "allow" });
173
173
  return s;
174
174
  }
175
175
 
@@ -186,7 +186,10 @@ function _heroImageUrl(s) {
186
186
  if (s.length > MAX_HERO_URL_LEN) {
187
187
  throw new TypeError("categoryNavigation: hero_image_url must be <= " + MAX_HERO_URL_LEN + " characters");
188
188
  }
189
- textGuard.freeText(s, "categoryNavigation: hero_image_url", { zeroWidth: "reject" });
189
+ // Same value and same slot as blogArticles.hero_image_url, so the same
190
+ // policy: a URL is emitted into a single attribute, and a line break in one
191
+ // is never legitimate.
192
+ textGuard.freeText(s, "categoryNavigation: hero_image_url", { singleLine: "reject", zeroWidth: "reject" });
190
193
  if (s.charCodeAt(0) === 47 /* "/" */) {
191
194
  if (s.length > 1 && s.charCodeAt(1) === 47) {
192
195
  throw new TypeError("categoryNavigation: hero_image_url protocol-relative `//host/...` refused — use absolute https://");
@@ -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"]),
@@ -253,7 +257,7 @@ function _requestedBy(s) {
253
257
  if (typeof s !== "string" || !s.length || s.length > MAX_REQUESTED_BY_LEN) {
254
258
  throw new TypeError("complianceExport: requested_by must be a non-empty string <= " + MAX_REQUESTED_BY_LEN + " chars");
255
259
  }
256
- textGuard.freeText(s, "complianceExport: requested_by", { zeroWidth: "reject" });
260
+ textGuard.freeText(s, "complianceExport: requested_by", { zeroWidth: "reject", bidiMarks: "allow" });
257
261
  return s;
258
262
  }
259
263
 
@@ -281,6 +285,13 @@ function _deliveryAddress(s) {
281
285
  if (typeof s !== "string" || !s.length || s.length > MAX_DELIVERY_ADDR_LEN) {
282
286
  throw new TypeError("complianceExport: delivery_address must be a non-empty string <= " + MAX_DELIVERY_ADDR_LEN + " chars");
283
287
  }
288
+ // No `bidiMarks` opt-out, despite the name. This field holds whatever the
289
+ // chosen delivery_method addresses — an email endpoint when that method is
290
+ // `email`, which is the common case — and it is recorded in the delivery
291
+ // audit trail. An invisible character in it would let the recorded
292
+ // destination read as somewhere other than where the export went. An
293
+ // operator sending to a genuinely postal address writes it without the
294
+ // direction marks.
284
295
  textGuard.freeText(s, "complianceExport: delivery_address", { zeroWidth: "reject" });
285
296
  return s;
286
297
  }
@@ -385,6 +396,7 @@ function create(opts) {
385
396
  orderRatings: opts.orderRatings || null,
386
397
  productQa: opts.productQa || null,
387
398
  customerNotes: opts.customerNotes || null,
399
+ customerImpersonation: opts.customerImpersonation || null,
388
400
  giftcards: opts.giftcards || null,
389
401
  referrals: opts.referrals || null,
390
402
  };
@@ -648,6 +660,14 @@ function create(opts) {
648
660
  "supportTickets", "orderNotes", "order", "guestOrderReconciliations",
649
661
  "subscriptions", "paymentMethods", "loyalty", "storeCredit",
650
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",
651
671
  ];
652
672
  var perDomain = [];
653
673
  var domainsAbsent = [];
@@ -673,11 +693,28 @@ function create(opts) {
673
693
  throw new TypeError("reader " + JSON.stringify(name) +
674
694
  ".forCustomerDeletion returned non-object — must return { table, deleted }");
675
695
  }
676
- perDomain.push({
696
+ var entry = {
677
697
  domain: name,
678
698
  table: effect.table == null ? name : effect.table,
679
699
  deleted: effect.deleted == null ? 0 : Number(effect.deleted),
680
- });
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);
681
718
  } catch (e) {
682
719
  failures.push({ domain: name, error: (e && e.message) ? e.message : String(e) });
683
720
  }