@blamejs/blamejs-shop 0.5.21 → 0.5.22

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 (70) hide show
  1. package/CHANGELOG.md +2 -0
  2. package/README.md +1 -1
  3. package/SECURITY.md +7 -1
  4. package/lib/addresses.js +16 -2
  5. package/lib/affiliates.js +3 -3
  6. package/lib/announcement-bar.js +2 -2
  7. package/lib/api-keys.js +2 -2
  8. package/lib/asset-manifest.json +1 -1
  9. package/lib/blog-articles.js +2 -2
  10. package/lib/carrier-accounts.js +2 -2
  11. package/lib/catalog-drafts.js +1 -1
  12. package/lib/category-navigation.js +6 -3
  13. package/lib/compliance-export.js +8 -1
  14. package/lib/customer-notes.js +1 -1
  15. package/lib/customer-roles.js +1 -1
  16. package/lib/customer-surveys.js +2 -2
  17. package/lib/customers.js +1 -1
  18. package/lib/dispute-resolution.js +3 -3
  19. package/lib/dropship-forwarding.js +1 -1
  20. package/lib/dunning.js +1 -1
  21. package/lib/email-ab-tests.js +1 -1
  22. package/lib/email-campaigns.js +15 -2
  23. package/lib/email-warmup.js +1 -1
  24. package/lib/externaldb-d1.js +0 -8
  25. package/lib/gift-options.js +3 -3
  26. package/lib/knowledge-base.js +3 -3
  27. package/lib/line-gift-wrap.js +6 -2
  28. package/lib/live-chat.js +1 -1
  29. package/lib/notifications.js +1 -1
  30. package/lib/operator-accounts.js +1 -1
  31. package/lib/operator-help-center.js +3 -3
  32. package/lib/operator-inbox.js +2 -2
  33. package/lib/operator-roles.js +1 -1
  34. package/lib/order-notes.js +2 -2
  35. package/lib/order-ratings.js +3 -3
  36. package/lib/payment-methods.js +1 -1
  37. package/lib/payment-retries.js +1 -1
  38. package/lib/pixel-events.js +14 -6
  39. package/lib/plan-changes.js +1 -1
  40. package/lib/product-qa.js +1 -1
  41. package/lib/push-notifications.js +2 -2
  42. package/lib/reviews.js +3 -3
  43. package/lib/seller-signup.js +4 -4
  44. package/lib/sidebar-widgets.js +1 -1
  45. package/lib/sms-dispatcher.js +3 -3
  46. package/lib/stock-receipts.js +1 -1
  47. package/lib/storefront-dashboards.js +1 -1
  48. package/lib/storefront-forms.js +1 -1
  49. package/lib/storefront-pages.js +2 -2
  50. package/lib/subscription-billing.js +1 -1
  51. package/lib/subscription-controls.js +1 -1
  52. package/lib/suggestion-box.js +4 -4
  53. package/lib/support-tickets.js +3 -3
  54. package/lib/tenants.js +1 -1
  55. package/lib/text-guard.js +44 -2
  56. package/lib/theme-assets.js +1 -1
  57. package/lib/vendor/MANIFEST.json +12 -12
  58. package/lib/vendor/blamejs/CHANGELOG.md +32 -0
  59. package/lib/vendor/blamejs/NOTICE +1 -1
  60. package/lib/vendor/blamejs/README.md +1 -1
  61. package/lib/vendor/blamejs/lib/network-tls.js +98 -13
  62. package/lib/vendor/blamejs/lib/vendor/MANIFEST.json +12 -12
  63. package/lib/vendor/blamejs/lib/vendor/blamejs-pki.cjs +568 -304
  64. package/lib/vendor/blamejs/package.json +1 -1
  65. package/lib/vendor/blamejs/sbom.cdx.json +6 -6
  66. package/lib/vendors.js +3 -3
  67. package/lib/webhook-receiver.js +1 -1
  68. package/lib/webhook-subscriptions.js +1 -1
  69. package/lib/wishlist-sharing.js +1 -1
  70. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.5.x
10
10
 
11
+ - v0.5.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
+
11
13
  - 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
14
 
13
15
  - 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,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/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 (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/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
106
  | **`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
107
 
108
108
  ### Migrations applied to D1
package/SECURITY.md CHANGED
@@ -288,7 +288,13 @@ node -e "
288
288
  review title and body, order-rating comment and operator reply, product
289
289
  question, customer note, support ticket, suggestion, knowledge-base
290
290
  article, gift message — refuses the overrides, the null byte and the C0
291
- control characters. Fields rendered on a single line additionally refuse
291
+ control characters. The direction MARKS (U+200E, U+200F, U+061C) are
292
+ deliberately permitted in prose: they carry no text and only resolve the
293
+ direction of neighbouring characters, which is how a Latin house number
294
+ inside an Arabic street name is written, and refusing them turns away
295
+ right-to-left addresses and names. A value that is machine-plain rather
296
+ than prose — a URL, a slug, an identifier — refuses the marks as well.
297
+ Fields rendered on a single line additionally refuse
292
298
  tabs, line breaks and the two line separators, so a value cannot break
293
299
  out of the row it is displayed in. Fields that are not free-form prose
294
300
  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/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,5 +1,5 @@
1
1
  {
2
- "version": "0.5.21",
2
+ "version": "0.5.22",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-imfe0otYErcB8rr2h6KLSGTtStirysptpXETSPY4zLv3bZoIT75Lo1dOvkOav+xL",
@@ -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://");
@@ -253,7 +253,7 @@ function _requestedBy(s) {
253
253
  if (typeof s !== "string" || !s.length || s.length > MAX_REQUESTED_BY_LEN) {
254
254
  throw new TypeError("complianceExport: requested_by must be a non-empty string <= " + MAX_REQUESTED_BY_LEN + " chars");
255
255
  }
256
- textGuard.freeText(s, "complianceExport: requested_by", { zeroWidth: "reject" });
256
+ textGuard.freeText(s, "complianceExport: requested_by", { zeroWidth: "reject", bidiMarks: "allow" });
257
257
  return s;
258
258
  }
259
259
 
@@ -281,6 +281,13 @@ function _deliveryAddress(s) {
281
281
  if (typeof s !== "string" || !s.length || s.length > MAX_DELIVERY_ADDR_LEN) {
282
282
  throw new TypeError("complianceExport: delivery_address must be a non-empty string <= " + MAX_DELIVERY_ADDR_LEN + " chars");
283
283
  }
284
+ // No `bidiMarks` opt-out, despite the name. This field holds whatever the
285
+ // chosen delivery_method addresses — an email endpoint when that method is
286
+ // `email`, which is the common case — and it is recorded in the delivery
287
+ // audit trail. An invisible character in it would let the recorded
288
+ // destination read as somewhere other than where the export went. An
289
+ // operator sending to a genuinely postal address writes it without the
290
+ // direction marks.
284
291
  textGuard.freeText(s, "complianceExport: delivery_address", { zeroWidth: "reject" });
285
292
  return s;
286
293
  }
@@ -142,7 +142,7 @@ function _body(s) {
142
142
  if (s.length > MAX_BODY_LEN) {
143
143
  throw new TypeError("customerNotes: body must be <= " + MAX_BODY_LEN + " characters");
144
144
  }
145
- textGuard.freeText(s, "customerNotes: body", { zeroWidth: "reject" });
145
+ textGuard.freeText(s, "customerNotes: body", { zeroWidth: "reject", bidiMarks: "allow" });
146
146
  return s;
147
147
  }
148
148
 
@@ -152,7 +152,7 @@ function _title(s) {
152
152
  if (typeof s !== "string" || !s.length || s.length > MAX_TITLE_LEN) {
153
153
  throw new TypeError("customerRoles: title must be a non-empty string <= " + MAX_TITLE_LEN + " chars");
154
154
  }
155
- textGuard.freeText(s, "customerRoles: title", { singleLine: "reject" });
155
+ textGuard.freeText(s, "customerRoles: title", { singleLine: "reject", bidiMarks: "allow" });
156
156
  return s;
157
157
  }
158
158
 
@@ -142,7 +142,7 @@ function _title(s) {
142
142
  if (typeof s !== "string" || !s.length || s.length > MAX_TITLE_LEN) {
143
143
  throw new TypeError("customerSurveys: title must be a non-empty string <= " + MAX_TITLE_LEN + " chars");
144
144
  }
145
- textGuard.freeText(s, "customerSurveys: title");
145
+ textGuard.freeText(s, "customerSurveys: title", { bidiMarks: "allow" });
146
146
  return s;
147
147
  }
148
148
 
@@ -187,7 +187,7 @@ function _closeReason(s) {
187
187
  if (typeof s !== "string" || !s.length || s.length > MAX_CLOSE_REASON_LEN) {
188
188
  throw new TypeError("customerSurveys: reason must be a non-empty string <= " + MAX_CLOSE_REASON_LEN + " chars");
189
189
  }
190
- textGuard.freeText(s, "customerSurveys: reason");
190
+ textGuard.freeText(s, "customerSurveys: reason", { bidiMarks: "allow" });
191
191
  return s;
192
192
  }
193
193
 
package/lib/customers.js CHANGED
@@ -105,7 +105,7 @@ function _displayName(s) {
105
105
  if (s.length > MAX_DISPLAY_NAME_LEN) {
106
106
  throw new TypeError("customers: display_name must be <= " + MAX_DISPLAY_NAME_LEN + " characters");
107
107
  }
108
- textGuard.freeText(s, "customers: display_name", { singleLine: "reject" });
108
+ textGuard.freeText(s, "customers: display_name", { singleLine: "reject", bidiMarks: "allow" });
109
109
  return s;
110
110
  }
111
111
 
@@ -249,7 +249,7 @@ function _narrative(s) {
249
249
  " exceeds cap " + MAX_NARRATIVE_LEN
250
250
  );
251
251
  }
252
- textGuard.freeText(s, "disputeResolution: narrative");
252
+ textGuard.freeText(s, "disputeResolution: narrative", { bidiMarks: "allow" });
253
253
  return s;
254
254
  }
255
255
 
@@ -277,7 +277,7 @@ function _notesOpt(s) {
277
277
  "disputeResolution: notes length " + s.length + " exceeds cap " + MAX_NOTES_LEN
278
278
  );
279
279
  }
280
- textGuard.freeText(s, "disputeResolution: notes");
280
+ textGuard.freeText(s, "disputeResolution: notes", { bidiMarks: "allow" });
281
281
  return s;
282
282
  }
283
283
 
@@ -301,7 +301,7 @@ function _writeoffReason(s) {
301
301
  " exceeds cap " + MAX_WRITEOFF_REASON_LEN
302
302
  );
303
303
  }
304
- textGuard.freeText(s, "disputeResolution: writeoff reason");
304
+ textGuard.freeText(s, "disputeResolution: writeoff reason", { bidiMarks: "allow" });
305
305
  return s;
306
306
  }
307
307
 
@@ -183,7 +183,7 @@ function _returnPolicy(s) {
183
183
  if (typeof s !== "string" || !s.length || s.length > MAX_RETURN_POLICY_LEN) {
184
184
  throw new TypeError("dropship-forwarding: return_policy must be a non-empty string <= " + MAX_RETURN_POLICY_LEN + " chars");
185
185
  }
186
- textGuard.freeText(s, "dropship-forwarding: return_policy", { singleLine: "reject" });
186
+ textGuard.freeText(s, "dropship-forwarding: return_policy", { singleLine: "reject", bidiMarks: "allow" });
187
187
  }
188
188
  function _positiveInt(n, label) {
189
189
  if (!Number.isInteger(n) || n <= 0) {
package/lib/dunning.js CHANGED
@@ -202,7 +202,7 @@ function _reason(s) {
202
202
  if (s.length > MAX_REASON_LEN) {
203
203
  throw new TypeError("dunning: reason must be <= " + MAX_REASON_LEN + " characters");
204
204
  }
205
- textGuard.freeText(s, "dunning: reason", { zeroWidth: "reject" });
205
+ textGuard.freeText(s, "dunning: reason", { zeroWidth: "reject", bidiMarks: "allow" });
206
206
  return s;
207
207
  }
208
208
 
@@ -165,7 +165,7 @@ function _title(s) {
165
165
  if (typeof s !== "string" || !s.length || s.length > MAX_TITLE_LEN) {
166
166
  throw new TypeError("emailABTests: title must be a non-empty string <= " + MAX_TITLE_LEN + " chars");
167
167
  }
168
- textGuard.freeText(s, "emailABTests: title");
168
+ textGuard.freeText(s, "emailABTests: title", { bidiMarks: "allow" });
169
169
  return s;
170
170
  }
171
171
 
@@ -170,12 +170,25 @@ var SEND_OUTCOMES = ["sent", "failed", "skipped_unsubscribed", "skipped_suppress
170
170
  // policy rather than a local class. The class this replaced covered the
171
171
  // zero-width family but not the bidi overrides, which let a campaign body
172
172
  // carry a URL that displays as one host and resolves to another.
173
- var LINK_URL_POLICY = Object.freeze({ singleLine: "reject", zeroWidth: "reject" });
173
+ // `bidiMarks` too: prose legitimately carries a direction mark (an Arabic
174
+ // street name, a Hebrew review) and the shared screen permits them by default,
175
+ // but a URL is machine-plain — a mark inside one is only ever there to make the
176
+ // host read as something it is not.
177
+ var LINK_URL_POLICY = Object.freeze({
178
+ singleLine: "reject",
179
+ zeroWidth: "reject",
180
+ bidiMarks: "reject",
181
+ });
174
182
 
175
183
  // Free-form text folded into one ledger line. Stripped rather than refused —
176
184
  // this runs on an error path that must not itself fail.
177
185
  var CONTROL_BYTE_LINE_RE = /[\u0000-\u001f\u007f]/;
178
186
 
187
+ // Global twin, derived so the class is written once. `.replace` with an
188
+ // unglobbed regex rewrites only the FIRST match, and a mailer's error message
189
+ // can carry several control bytes — the rest would reach the stored ledger row.
190
+ var CONTROL_BYTE_LINE_RE_G = new RegExp(CONTROL_BYTE_LINE_RE.source, "g");
191
+
179
192
  var STATUSES = ["draft", "scheduled", "sending", "sent", "paused", "cancelled"];
180
193
  var EVENT_TYPES = ["delivered", "opened", "clicked", "bounced", "unsubscribed"];
181
194
  var TERMINAL = ["sent", "cancelled"];
@@ -982,7 +995,7 @@ function create(opts) {
982
995
  // strip control bytes (no CRLF / NUL into the stored row) and
983
996
  // cap length. The recipient address is never echoed into the
984
997
  // reason — only the mailer's own message.
985
- var reason = String(sendErr && sendErr.message || sendErr).replace(CONTROL_BYTE_LINE_RE, " ").slice(0, 280);
998
+ var reason = String(sendErr && sendErr.message || sendErr).replace(CONTROL_BYTE_LINE_RE_G, " ").slice(0, 280);
986
999
  await _finalizeSend(row.slug, consentHash || emailHash, "failed", reason);
987
1000
  continue;
988
1001
  }
@@ -231,7 +231,7 @@ function _reason(s) {
231
231
  if (s.length > MAX_REASON_LEN) {
232
232
  throw new TypeError("emailWarmup: reason must be <= " + MAX_REASON_LEN + " characters");
233
233
  }
234
- textGuard.freeText(s, "emailWarmup: reason", { singleLine: "reject", zeroWidth: "reject" });
234
+ textGuard.freeText(s, "emailWarmup: reason", { singleLine: "reject", zeroWidth: "reject", bidiMarks: "allow" });
235
235
  return s;
236
236
  }
237
237
 
@@ -181,14 +181,6 @@ function _isTransient(err) {
181
181
  return c === "ECONNRESET" || c === "ETIMEDOUT" || c === "ENOTFOUND" || c === "EAI_AGAIN" || c === "UND_ERR_SOCKET";
182
182
  }
183
183
 
184
- function _backoff(attempt) {
185
- // Jittered exponential — 200ms, 400ms, ... with ±25% jitter so
186
- // retries from many container replicas don't synchronize.
187
- var base = RETRY_BACKOFF_MS * Math.pow(2, attempt);
188
- var jitter = base * (0.75 + (b.crypto.randomInt(0, 50) / 100));
189
- return new Promise(function (r) { setTimeout(r, jitter); });
190
- }
191
-
192
184
  function _httpErr(status, label) {
193
185
  var e = new Error("externaldbD1: " + label + " failed with HTTP " + status);
194
186
  e.code = "D1_HTTP_" + status;
@@ -103,7 +103,7 @@ function _title(s) {
103
103
  if (typeof s !== "string" || !s.length || s.length > MAX_TITLE_LEN) {
104
104
  throw new TypeError("giftOptions: title must be a non-empty string ≤ " + MAX_TITLE_LEN + " chars");
105
105
  }
106
- textGuard.freeText(s, "giftOptions: title", { singleLine: "reject", zeroWidth: "reject" });
106
+ textGuard.freeText(s, "giftOptions: title", { singleLine: "reject", zeroWidth: "reject", bidiMarks: "allow" });
107
107
  return s;
108
108
  }
109
109
 
@@ -154,7 +154,7 @@ function _giftMessage(s) {
154
154
  if (s.length > MAX_MESSAGE_LEN) {
155
155
  throw new TypeError("giftOptions: gift_message must be ≤ " + MAX_MESSAGE_LEN + " chars");
156
156
  }
157
- textGuard.freeText(s, "giftOptions: gift_message", { zeroWidth: "reject" });
157
+ textGuard.freeText(s, "giftOptions: gift_message", { zeroWidth: "reject", bidiMarks: "allow" });
158
158
  return s;
159
159
  }
160
160
 
@@ -166,7 +166,7 @@ function _recipientName(s) {
166
166
  if (s.length > MAX_RECIPIENT_LEN) {
167
167
  throw new TypeError("giftOptions: recipient_name must be ≤ " + MAX_RECIPIENT_LEN + " chars");
168
168
  }
169
- textGuard.freeText(s, "giftOptions: recipient_name", { singleLine: "reject", zeroWidth: "reject" });
169
+ textGuard.freeText(s, "giftOptions: recipient_name", { singleLine: "reject", zeroWidth: "reject", bidiMarks: "allow" });
170
170
  return s;
171
171
  }
172
172
 
@@ -159,7 +159,7 @@ function _title(s) {
159
159
  if (s.length > MAX_TITLE_LEN) {
160
160
  throw new TypeError("knowledgeBase: title must be <= " + MAX_TITLE_LEN + " characters");
161
161
  }
162
- textGuard.freeText(s, "knowledgeBase: title", { singleLine: "reject", zeroWidth: "reject" });
162
+ textGuard.freeText(s, "knowledgeBase: title", { singleLine: "reject", zeroWidth: "reject", bidiMarks: "allow" });
163
163
  return s;
164
164
  }
165
165
 
@@ -177,7 +177,7 @@ function _body(s) {
177
177
  if (textGuard.hasCodepointThreat(s)) {
178
178
  throw new TypeError("knowledgeBase: body contains control bytes");
179
179
  }
180
- textGuard.freeText(s, "knowledgeBase: body", { zeroWidth: "reject" });
180
+ textGuard.freeText(s, "knowledgeBase: body", { zeroWidth: "reject", bidiMarks: "allow" });
181
181
  return s;
182
182
  }
183
183
 
@@ -271,7 +271,7 @@ function _queryStr(s) {
271
271
  if (s.length > MAX_QUERY_LEN) {
272
272
  throw new TypeError("knowledgeBase: query must be <= " + MAX_QUERY_LEN + " characters");
273
273
  }
274
- textGuard.freeText(s, "knowledgeBase: query", { zeroWidth: "reject" });
274
+ textGuard.freeText(s, "knowledgeBase: query", { zeroWidth: "reject", bidiMarks: "allow" });
275
275
  return s;
276
276
  }
277
277
 
@@ -151,7 +151,10 @@ function _giftMessage(s) {
151
151
  if (s.length > MAX_MESSAGE_LEN) {
152
152
  throw new TypeError("lineGiftWrap: gift_message must be ≤ " + MAX_MESSAGE_LEN + " chars");
153
153
  }
154
- textGuard.freeText(s, "lineGiftWrap: gift_message", { zeroWidth: "reject" });
154
+ // Prose a customer writes for another person to read — a message in Arabic
155
+ // or Hebrew carries a direction mark wherever a Latin word or number sits
156
+ // inside the line. The overrides that reverse a run stay refused.
157
+ textGuard.freeText(s, "lineGiftWrap: gift_message", { zeroWidth: "reject", bidiMarks: "allow" });
155
158
  return s;
156
159
  }
157
160
 
@@ -163,7 +166,8 @@ function _recipientName(s) {
163
166
  if (s.length > MAX_RECIPIENT_LEN) {
164
167
  throw new TypeError("lineGiftWrap: recipient_name must be ≤ " + MAX_RECIPIENT_LEN + " chars");
165
168
  }
166
- textGuard.freeText(s, "lineGiftWrap: recipient_name", { singleLine: "reject", zeroWidth: "reject" });
169
+ // A person's name, in whatever script they write it.
170
+ textGuard.freeText(s, "lineGiftWrap: recipient_name", { singleLine: "reject", zeroWidth: "reject", bidiMarks: "allow" });
167
171
  return s;
168
172
  }
169
173
 
package/lib/live-chat.js CHANGED
@@ -130,7 +130,7 @@ function _body(s) {
130
130
  if (s.length > MAX_BODY_LEN) {
131
131
  throw new TypeError("live-chat: body must be <= " + MAX_BODY_LEN + " characters");
132
132
  }
133
- textGuard.freeText(s, "live-chat: body", { zeroWidth: "reject" });
133
+ textGuard.freeText(s, "live-chat: body", { zeroWidth: "reject", bidiMarks: "allow" });
134
134
  return s;
135
135
  }
136
136
 
@@ -99,7 +99,7 @@ function _title(s) {
99
99
  if (s.length > MAX_TITLE_LEN) {
100
100
  throw new TypeError("notifications: title must be <= " + MAX_TITLE_LEN + " characters");
101
101
  }
102
- textGuard.freeText(s, "notifications: title");
102
+ textGuard.freeText(s, "notifications: title", { bidiMarks: "allow" });
103
103
  return s;
104
104
  }
105
105
 
@@ -212,7 +212,7 @@ function _displayName(s) {
212
212
  if (typeof s !== "string" || !s.length || s.length > MAX_DISPLAY_NAME_LEN) {
213
213
  throw new TypeError("operatorAccounts: display_name must be a non-empty string (<= " + MAX_DISPLAY_NAME_LEN + " chars)");
214
214
  }
215
- textGuard.freeText(s, "operatorAccounts: display_name", { singleLine: "reject" });
215
+ textGuard.freeText(s, "operatorAccounts: display_name", { singleLine: "reject", bidiMarks: "allow" });
216
216
  return s;
217
217
  }
218
218