@blamejs/blamejs-shop 0.5.20 → 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 (102) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/README.md +1 -0
  3. package/SECURITY.md +23 -0
  4. package/lib/addresses.js +17 -3
  5. package/lib/admin.js +14 -5
  6. package/lib/affiliates.js +7 -23
  7. package/lib/announcement-bar.js +5 -22
  8. package/lib/api-keys.js +4 -13
  9. package/lib/asset-manifest.json +1 -1
  10. package/lib/banner-ab-tests.js +5 -9
  11. package/lib/blog-articles.js +8 -26
  12. package/lib/captcha-gate.js +7 -17
  13. package/lib/carrier-accounts.js +3 -10
  14. package/lib/catalog-drafts.js +5 -12
  15. package/lib/category-navigation.js +7 -20
  16. package/lib/cms-blocks.js +4 -7
  17. package/lib/compliance-export.js +11 -11
  18. package/lib/consent-ledger.js +2 -4
  19. package/lib/cost-layers.js +56 -21
  20. package/lib/customer-import.js +2 -2
  21. package/lib/customer-notes.js +7 -23
  22. package/lib/customer-roles.js +3 -5
  23. package/lib/customer-segments.js +17 -14
  24. package/lib/customer-surveys.js +7 -14
  25. package/lib/customers.js +2 -4
  26. package/lib/dispute-resolution.js +5 -13
  27. package/lib/dropship-forwarding.js +3 -5
  28. package/lib/dunning.js +2 -7
  29. package/lib/email-ab-tests.js +4 -8
  30. package/lib/email-campaigns.js +39 -15
  31. package/lib/email-warmup.js +2 -7
  32. package/lib/experiments.js +6 -10
  33. package/lib/externaldb-d1.js +47 -31
  34. package/lib/geolocation.js +18 -28
  35. package/lib/gift-options.js +5 -23
  36. package/lib/knowledge-base.js +7 -22
  37. package/lib/line-gift-wrap.js +7 -17
  38. package/lib/live-chat.js +5 -18
  39. package/lib/metered-usage.js +3 -8
  40. package/lib/notifications.js +3 -7
  41. package/lib/operator-accounts.js +3 -5
  42. package/lib/operator-approvals.js +6 -8
  43. package/lib/operator-audit-log.js +2 -7
  44. package/lib/operator-help-center.js +5 -21
  45. package/lib/operator-inbox.js +4 -11
  46. package/lib/operator-roles.js +5 -7
  47. package/lib/order-escalation.js +2 -2
  48. package/lib/order-export.js +24 -20
  49. package/lib/order-notes.js +4 -14
  50. package/lib/order-ratings.js +4 -10
  51. package/lib/payment-methods.js +5 -13
  52. package/lib/payment-retries.js +2 -7
  53. package/lib/pixel-events.js +21 -28
  54. package/lib/plan-changes.js +2 -7
  55. package/lib/product-qa.js +4 -8
  56. package/lib/promo-banners.js +7 -15
  57. package/lib/purchase-orders.js +3 -6
  58. package/lib/push-notifications.js +7 -32
  59. package/lib/pwa-manifest.js +6 -11
  60. package/lib/quotes.js +5 -9
  61. package/lib/reviews.js +4 -10
  62. package/lib/robots-config.js +6 -14
  63. package/lib/sales-tax-filings.js +2 -2
  64. package/lib/search-facets.js +5 -5
  65. package/lib/search-ranking.js +4 -7
  66. package/lib/search-synonyms.js +7 -21
  67. package/lib/security-middleware.js +86 -58
  68. package/lib/seller-signup.js +10 -26
  69. package/lib/sidebar-widgets.js +6 -15
  70. package/lib/site-redirects.js +5 -16
  71. package/lib/sitemap-generator.js +3 -7
  72. package/lib/sms-dispatcher.js +7 -23
  73. package/lib/stock-receipts.js +4 -10
  74. package/lib/storefront-dashboards.js +2 -10
  75. package/lib/storefront-forms.js +9 -22
  76. package/lib/storefront-pages.js +6 -20
  77. package/lib/storefront.js +2 -2
  78. package/lib/subscription-billing.js +3 -8
  79. package/lib/subscription-controls.js +2 -7
  80. package/lib/suggestion-box.js +9 -26
  81. package/lib/support-tickets.js +6 -24
  82. package/lib/tax-remittance.js +2 -2
  83. package/lib/tenants.js +2 -7
  84. package/lib/text-guard.js +179 -35
  85. package/lib/theme-assets.js +4 -16
  86. package/lib/translations.js +3 -6
  87. package/lib/trust-badges.js +4 -7
  88. package/lib/vendor/MANIFEST.json +12 -12
  89. package/lib/vendor/blamejs/CHANGELOG.md +32 -0
  90. package/lib/vendor/blamejs/NOTICE +1 -1
  91. package/lib/vendor/blamejs/README.md +1 -1
  92. package/lib/vendor/blamejs/lib/network-tls.js +98 -13
  93. package/lib/vendor/blamejs/lib/vendor/MANIFEST.json +12 -12
  94. package/lib/vendor/blamejs/lib/vendor/blamejs-pki.cjs +568 -304
  95. package/lib/vendor/blamejs/package.json +1 -1
  96. package/lib/vendor/blamejs/sbom.cdx.json +6 -6
  97. package/lib/vendor-invoices.js +3 -6
  98. package/lib/vendors.js +5 -19
  99. package/lib/webhook-receiver.js +3 -10
  100. package/lib/webhook-subscriptions.js +3 -13
  101. package/lib/wishlist-sharing.js +5 -10
  102. 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.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
+
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.
14
+
11
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.
12
16
 
13
17
  - v0.5.19 (2026-08-08) — **Concurrent subscription plan changes no longer double-charge the card or silently skip a proration, the minimum Node is now 24.19.0, and the vendored framework moves to 0.18.17 — signature-verified before it is unpacked.** Three strands. An immediate plan change on a Stripe-backed subscription pushed the repricing to Stripe before it claimed the transition locally, so two overlapping changes each cut an always_invoice proration invoice and double-billed the upgrade delta, while the losing change left a phantom executed history row. The transition is now claimed first, and the claim is held until the provider call settles, so a following change cannot start a second repricing while the first is still in flight; the provider call is keyed on the claimed change row, so a retry of the same unsettled transition dedupes at Stripe while a genuinely repeated transition between the same two plans in one billing period still bills. Separately the minimum Node.js is raised to 24.19.0, which carries the fixes from the 24.18.1 security release that the previous 24.18.0 floor predates. Finally, refreshing the vendored framework now downloads the published, signed release artifact and verifies its SHA3-512 digest and ML-DSA-65 signature against a signing key pinned in this repository before writing anything, instead of taking a mutable git tag on trust — and the framework itself advances to 0.18.17, which rebuilds the store's outbound TLS posture and is where the 24.19.0 requirement comes from. **Added:** *planChanges.reconcileStrandedTransitions settles subscriptions left mid-transition* — Scheduler-callable, and already wired into the once-a-minute subscription plan-change tick, so an operator running that cron needs no new scheduling. It finds subscriptions whose provider call never returned a definite answer and replays the repricing under the original idempotency key: if the first call did land the provider returns the recorded response and nothing is billed twice, and if it never landed this is the call that lands it. Either way the subscription converges on the plan its change row names and the held claim is released, so later plan changes for that customer are no longer blocked. A subscription the provider refuses outright is rolled back and voided. One unresolved subscription never aborts the run. The tick's JSON summary gains a reconciled count alongside applied. **Changed:** *Minimum Node.js raised to 24.19.0* — engines.node is now >=24.19.0, up from >=24.18.0. Operators must run Node 24.19.0 or newer. The move is motivated by the 24.18.1 security release, which the previous floor predates: it fixes eleven issues including three rated high, covering HTTP/2 header memory retention, HTTP/2 stream reset handling, and the permission model. 24.19.0 contains all of them. The .nvmrc, the container base image, the CI runner pins, and the README and architecture docs are synced to the new floor; it is a patch-level move within the same LTS line, so an existing 24.x install updates in place. · *Vendored framework refreshed to 0.18.17* — Advances the vendored framework from 0.17.23 to 0.18.17. The store inherits all of the following without application code changes. Outbound TLS is the bulk of it: the framework's key-share offer previously led with a draft hybrid that deployed TLS stacks do not implement, so nearly every outbound connection — payment processors, outbound webhooks, encrypted DNS, telemetry — completed only after a HelloRetryRequest, costing an extra round trip; worse, Node reports an empty stapled OCSP response across a retried handshake, so the framework's stapling requirement rejected peers that had stapled correctly. The offer now leads with the widely implemented hybrid, with no group added or removed. A connection that performed no ephemeral key exchange — meaning no forward secrecy at all — was previously recorded as though it had negotiated a post-quantum hybrid, because on older Node a hybrid reported nothing; detection is now positive and a non-ephemeral exchange raises its own audit action. The inbound post-quantum gate no longer refuses a client whose only offered hybrid is one the framework itself advertises. Redis over TLS, the syslog sink, wss:// connections, outbound mail and the OTLP collector now all carry the framework's TLS floor and group preference rather than negotiating on Node's defaults, and certificate compression is advertised in both directions. Away from TLS: distinguished-name comparison in the bundled PKI no longer treats a NumericString value as equal to a PrintableString or UTF8String value with the same characters, which is the comparison that decides certificate chaining, revocation-issuer matching and name-constraint evaluation for mutual TLS; and a hostname written in absolute form now reaches the resolver as written, while one carrying an empty label that is not the root is refused rather than resolving a different name than the caller asked for. · *The Node floor may now lead the vendored framework's own requirement* — The gate that keeps every Node version pin in step previously required all of them to equal the vendored framework's declared floor exactly, which also prevented the application from ever requiring a newer Node than the framework asks for. That blocked adopting a Node security release ahead of the framework's own schedule. The gate now enforces that every pin is at or above the framework's floor and that the pins agree with each other, comparing versions numerically rather than as strings. Regenerating the pins raises them and never lowers a deliberate lead, so a vendor refresh can no longer quietly walk the runtime back to an older Node. **Fixed:** *Concurrent immediate plan changes no longer cut two Stripe proration invoices* — For a Stripe-backed subscription, an immediate plan change repriced the subscription at Stripe with always_invoice — billing the proration delta straight away — before it claimed the plan transition in the database. Two overlapping immediate changes, a double-submitted upgrade or a retry racing the original, each observed the pre-change plan and each pushed to Stripe, so Stripe issued two proration invoices and charged the customer twice for one upgrade; the change that lost the local transition also left an executed history row carrying charge and credit amounts for a transition that never settled. The transition is now claimed before anything is sent to the provider, and only the winner reprices; the loser is voided with no charge, matching the non-Stripe path and the scheduled-change path. · *A plan change can no longer start while the previous one is still settling at the provider* — Claiming the transition on the plan alone was not sufficient. The claim publishes the new plan while its own provider call is still in flight, so a following change could read that new plan, win its own claim, and put a second repricing on the wire. The two then settled in whatever order the provider happened to finish them, which could leave Stripe resting on the earlier plan while the shop displayed the later one, with the proration invoices cut against the wrong transition. A subscription now carries the identity of the transition that has not settled yet, and the claim requires that field to be empty, so two provider calls for one subscription can never overlap. A change attempted during that window is refused and recorded as such rather than being applied. · *Repeating a plan transition inside one billing period bills correctly* — The provider call's idempotency key was derived from the shape of the transition — the subscription, the outgoing and incoming prices, and the current period. Moving A to B, back to A, then to B again inside one billing period produced an identical key for the first and third moves, so the provider replayed the third as the first: the plan moved locally but the always_invoice proration was never charged. The key is now the claimed change row's own identity, which is unique per transition, so a genuine repeat bills while a retry of the same unsettled transition still dedupes. · *An unanswered provider call no longer rolls back a repricing that may have happened* — Any failure from the provider previously undid the local transition. A refusal and a timeout are not the same thing: a 4xx is the provider's considered answer and means nothing was billed, but a timeout or a server error means the repricing may well have landed and cut its invoice. Undoing on the latter could leave the shop showing the outgoing plan for a subscription the provider had already repriced and charged. A refusal now rolls the plan back and voids the change as before, while an unanswered call keeps the claim and leaves the change in place for the scheduler to settle. **Security:** *The vendored framework is verified against a pinned signing key before it is unpacked* — The framework is the largest body of third-party code the application ships and it arrives over the network. The refresh previously cloned a git tag, which is mutable and carries no proof of origin, and checked nothing about what it received. It now downloads the published release tarball together with its SHA3-512 digest and its ML-DSA-65 signature, verifies the digest and then the signature, and refuses to write anything into the vendored tree if either check fails. The signature is checked against a release key pinned in this repository rather than one fetched at refresh time from the same host that served the artifact, so an upstream key rotation fails closed and requires a reviewed update instead of being trusted automatically. The verification is also runnable on its own against any downloaded release. See the security policy for the pinned key's fingerprint and the verification commands. · *The vendored tree is now the published package rather than the source repository* — Because the refresh consumes the signed release artifact, the vendored tree contains what the framework publishes — its library code, executables, licence, notice and bill of materials — and no longer carries the framework's own test suite, benchmarks or CI configuration. The framework's bundled dependency manifest, which vulnerability scanners read to attribute advisories, is still present. The result is roughly a thousand fewer files to install and scan, with no change to any behaviour the application relies on.
package/README.md CHANGED
@@ -102,6 +102,7 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
102
102
  | **`lib/newsletter.js`** | Operator-collected email broadcast list — `signup({ email, source })` composes `b.guardEmail` for shape validation, `b.crypto.namespaceHash` for the dedup key, and `INSERT OR IGNORE` for idempotency. Storefront POST `/newsletter` route renders a designed thank-you card with separate copy for the `new` vs `dedup` branches. |
103
103
  | **`lib/admin.js`** | Bearer-token-gated CRUD over catalog + orders + refunds + bulk CSV import + subscription plans + review moderation + return moderation. Token compared via `b.crypto.timingSafeEqual`. Errors as RFC 9457 problem documents via `b.problemDetails`. Audit emission on every mutation. Also serves a **browser admin console**: sign in at `/admin` by pasting the API key (sealed `shop_admin` session cookie, SameSite=Strict, /admin-scoped), with a persistent nav across every signed-in page. A guided **setup wizard** at `/admin/setup` writes shop identity to config; **Products** (`/admin/products`) browses the catalog and creates / archives / restores, and each product opens a management screen that edits its fields, adds / edits / removes variants, sets a variant's price and shows its price history, and attaches / uploads / removes images — the full path to a sellable product; **Inventory** (`/admin/inventory`) lists stock per SKU (on-hand / held / available) with a low-stock filter, restocks, sets per-SKU thresholds, tracks new SKUs, and opens the low-stock alert history (`/admin/inventory/alerts`) — each alert row is written when a checkout decrement crosses a SKU's threshold, alongside an `inventory.low_stock` webhook to subscribed endpoints; **Stock locations** (`/admin/inventory/locations`) defines warehouse / retail / virtual locations with per-location stock levels (a single-location store needs no configuration — the default location stays implicit), **Receive stock** (`/admin/inventory/receive`) records reason-coded inbound stock against a location with a batched receipt history, **Transfers** (`/admin/inventory/transfers`) moves stock between locations through a dispatch → receive state machine — the source is debited at dispatch, the destination credited on receive, and a dispatch racing a checkout hold for the last unit has exactly one winner — and **Write-offs** (`/admin/inventory/writeoffs`) records reason-coded stock losses with an audit trail, refusing a write-off that would eat into stock already held for paid orders; **Orders** (`/admin/orders`) lists recent orders with status filters, opens an order's items, totals, and shipping address, and drives the lifecycle (mark paid → fulfil → ship → deliver, cancel — Refund goes through the payment provider) through the order FSM, with a rate-bounded resend of the order confirmation to an operator-supplied address (the buyer's email is stored only as a hash, so the operator types the recipient), and attaches a shipment (carrier + tracking number) with recorded shipment events that surface a public tracking link to the customer, plus a customer-service notes thread per order (internal or customer-visible, pinnable, resolvable); **Customers** (`/admin/customers`) is a read-only roster, newest first — display name, short id, join date, sign-in method (passkey count + linked OAuth providers), and order count, with the count and sign-in methods resolved by bounded aggregate queries so a page of customers costs no per-row trips (email addresses aren't stored in the clear, so they're not shown); each customer opens to an aggregated activity timeline (orders, loyalty, wishlist, reviews, support) read from the tables those primitives already populate; customer segments export their members as a streamed CSV — id, display name, join date, order count, deliberately no email column; **Returns** (`/admin/returns`) is the RMA moderation queue — filter by status, open a request's items and reason, and approve (with refund amount) → mark received → refund, or reject with a reason, over the return FSM; **Reviews** (`/admin/reviews`) is the review moderation queue — filter by status and publish, reject (with a reason), or take down each submission inline; **Q&A** (`/admin/questions`) is the question moderation queue — filter by status, open a question to its full answer thread, approve / reject the question, post the seller answer, and approve / reject / pin individual answers; **Subscriptions** (`/admin/subscription-plans`) is the recurring-offer catalog — filter active / archived, create a plan (Stripe price id, interval, amount, trial), and archive one, with archiving terminal because the mirrored Stripe price can go stale; **Collections** (`/admin/collections`) manages manual + smart product collections — filter active / archived, create a collection (manual or smart with a starter rule), and per collection edit title / description / sort strategy, manage manual members (add by product id, remove, reorder) or edit a smart collection's rule set with a live preview of the products the rules currently match, and archive; **Gift cards** (`/admin/gift-cards`) is the gift-card ledger — list issued cards (masked code, original + remaining balance, status, issued date) filtered by lifecycle status, issue a new card (the bearer code shown once, right after creation), open a card to see its full credit / debit / expire ledger, and void an active card through a confirmation step; **Webhooks** (`/admin/webhooks`) registers outbound endpoints (https:// only) with a one-time signing-secret reveal, enables / disables / deletes them, and opens an endpoint's delivery feed to retry a failed delivery — the signing secret is shown once on create and never in the list, and order transitions fan out signed deliveries to subscribed endpoints. **Operators** (`/admin/operators`) is the staff-account console — create operators with their own credential (Argon2id password and/or a per-operator API key shown once) and a least-privilege role (owner / manager / viewer), enforced at the single admin write chokepoint on every POST/PUT/DELETE rather than by hiding menu items; disable takes effect on the operator's next request, `ADMIN_API_KEY` stays the bootstrap / break-glass owner credential so an upgrade can never lock the store out, and every operator-management action plus every role-denied attempt is audited. **Email campaigns** (`/admin/campaigns`) is the consent-gated broadcast console — author a campaign (escape-by-default Markdown body), target a mailing audience, preview, test-send to an operator-supplied address, and send to the recipients who are actually reachable: the recipient set resolves at send time from the newsletter list (the only place a deliverable address exists — customer accounts keep only an email hash), every recipient is re-checked against the unsubscribe flag and the marketing suppression list at the send moment, every message carries RFC 8058 one-click unsubscribe headers plus an in-body link, and a per-recipient send ledger makes a resumed broadcast never re-mail. Sending drains in rate-bounded batches on the scheduled tick; per-campaign delivered / failed / skipped counts show on the detail screen. **Quotes** (`/admin/quotes`) is the RFQ response queue — open a request's lines and customer message, respond with per-line pricing and a validity window, or withdraw a responded quote; an accepted quote converts to an order through the storefront's normal checkout path, holds included. **Tax** (`/admin/tax-rates`), **Shipping** (`/admin/shipping`), and **Discounts** (`/admin/discounts`) configure tax rates per jurisdiction, shipping zones + rates, and automatic-discount rules — including code-unlocked rules a shopper redeems with a discount code on the cart page — + coupon-stacking policies — create / edit / archive each. **Audit** (`/admin/audit`) is a read-only activity log of every privileged action — filtered by outcome (success / failure / denied) and paginated — composed on the framework's tamper-evident `b.audit` chain; opening it is itself recorded as an `audit.read` event. **Errors** (`/admin/errors`) lists captured server-error detail — time, status, route, and a truncated message for scrubbed 500-class failures (checkout confirm, public API, admin actions) — newest-first, with the same path answering a bearer-token request with JSON so the log is one `curl` away. **Carts** (`/admin/carts`) lists abandoned carts — active, has items, idle past a tunable window (24h default) — with line counts, value at risk, and guest/signed-in attribution; a per-cart action mints a single-use, code-gated discount the operator shares through their own channel (recovery email is impossible by design: buyer addresses are stored only as hashes, and the screen says so). **Analytics** (`/admin/analytics`) is the pre-purchase view the sales report can't see — browse-to-buy funnel with conversion rate, top search terms, most-viewed products, units-ranked top SKUs, and a revenue-by-day sparkline — cross-linked with the Reports screen, read-only, every aggregate window- and limit-bounded. **Search suggestions** (`/admin/search-suggestions`) curates the storefront autocomplete — pin a featured link to a typed prefix, set its priority / status / active window, edit or remove it inline — and surfaces a read-only popular-searches report (each term's 30-day count, zero-result share, and last-seen) so an unmatched term flags a stock or naming gap. The Customers, Returns, Reviews, Q&A, Subscriptions, Collections, Gift cards, Webhooks, Tax, Shipping, Discounts, Delivery estimates, Analytics, Search suggestions, Carts, Errors, Stock locations, Receive stock, Transfers, Write-offs, Quotes, Email campaigns, and Operators links appear only when those primitives are wired. Each console path content-negotiates: a bearer-token client still gets the JSON API unchanged, a signed-in browser gets HTML. Reachable by the cookie or the bearer token. The console's styling is an external, integrity-pinned stylesheet (`themes/default/assets/css/admin.css`) with the same self-hosted typeface — no inline styles and no third-party font host, so it renders correctly under the strict `style-src 'self'` / `font-src 'self'` CSP that governs the route. |
104
104
  | **`lib/catalog-import.js`** | Bulk CSV import — `POST /admin/catalog/import` accepts a `text/csv` body, parses via `b.csv`, content-safety-filters every cell through `b.guardCsv` (formula-injection / bidi / control / dangerous-function denylist), validates exact header order, de-dupes rows by `product_slug`, returns per-row errors without aborting. Default 1 MiB / 10000 rows caps. |
105
+ | **`lib/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). |
105
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. |
106
107
 
107
108
  ### Migrations applied to D1
package/SECURITY.md CHANGED
@@ -279,6 +279,29 @@ node -e "
279
279
  an unmoderated reply can ever surface publicly. Author identity is the
280
280
  customer id (verified against the customers primitive) or a hash-only
281
281
  email — the raw address is never persisted.
282
+ - **Text one person writes and another reads is screened for invisible
283
+ characters.** A Unicode bidirectional override reverses the display
284
+ order of everything after it while itself rendering as nothing, so
285
+ stored text can be made to read on screen as something other than what
286
+ it says — the Trojan Source trick, aimed at a reader rather than a
287
+ compiler. Every field carrying free text from one party to another —
288
+ review title and body, order-rating comment and operator reply, product
289
+ question, customer note, support ticket, suggestion, knowledge-base
290
+ article, gift message — refuses the overrides, the null byte and the C0
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
298
+ tabs, line breaks and the two line separators, so a value cannot break
299
+ out of the row it is displayed in. Fields that are not free-form prose
300
+ also refuse the zero-width and invisible-formatting characters; the
301
+ prose fields deliberately do not, because the joiner that holds an
302
+ emoji sequence together belongs to that class. A link URL inside a
303
+ campaign email is screened the same way, since the plain-text part of a
304
+ message prints it for the recipient to read.
282
305
  - **Gift-card codes are bearer secrets, stored hash-only.** A gift
283
306
  card's plaintext code is shown exactly once at issuance and never
284
307
  persisted — only its `namespaceHash` digest plus a 4-character hint
package/lib/addresses.js CHANGED
@@ -51,6 +51,7 @@
51
51
  */
52
52
 
53
53
  var b = require("./vendor/blamejs");
54
+ var textGuard = require("./text-guard");
54
55
 
55
56
  var MAX_LABEL_LEN = 64;
56
57
  var MAX_RECIPIENT_NAME_LEN = 128;
@@ -63,7 +64,6 @@ var MAX_PHONE_LEN = 32;
63
64
 
64
65
  var COUNTRY_RE = /^[A-Z]{2}$/;
65
66
  var PHONE_RE = /^\+?[1-9]\d{1,14}$/;
66
- var CONTROL_BYTE_RE = /[\x00-\x1f\x7f]/;
67
67
 
68
68
  // ---- validators ---------------------------------------------------------
69
69
 
@@ -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 (CONTROL_BYTE_RE.test(trimmed)) {
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
@@ -464,12 +464,21 @@ function _parseOptionsString(s) {
464
464
 
465
465
  // ---- bearer auth --------------------------------------------------------
466
466
 
467
+ // RFC 6750 bearer extraction, via b.requestHelpers.extractBearer.
468
+ //
469
+ // It refuses two shapes the local reader let through, both trust-boundary
470
+ // cases rather than cosmetic ones: an Authorization header that arrived more
471
+ // than once and was folded into `Bearer a, Bearer b` — where the value read is
472
+ // whichever the fold happened to put first — and a token containing a space,
473
+ // which is not a token at all. Neither could authenticate, since the constant-
474
+ // time compare against the configured secret rejects them; but both reached
475
+ // the API-key verifier and its audit trail before being refused, and now stop
476
+ // at the parse.
477
+ //
478
+ // Every well-formed request reads identically: the scheme match stays
479
+ // case-insensitive and the token is still trimmed.
467
480
  function _readBearer(req) {
468
- if (!req || !req.headers) return null;
469
- var h = req.headers.authorization || req.headers.Authorization;
470
- if (!h || typeof h !== "string") return null;
471
- if (h.slice(0, 7).toLowerCase() !== "bearer ") return null;
472
- return h.slice(7).trim();
481
+ return b.requestHelpers.extractBearer(req);
473
482
  }
474
483
 
475
484
  function _authOk(token, expected) {
package/lib/affiliates.js CHANGED
@@ -116,11 +116,6 @@ var CODE_RE = /^[A-HJ-NP-Z2-9]{8}$/;
116
116
  // control / direction-override bytes are a slipping-class for header
117
117
  // injection + visual-spoofing attacks downstream. Spelled with
118
118
  // \u-escapes so ESLint's no-irregular-whitespace stays happy.
119
- var CONTROL_BYTE_STRICT_RE = /[\x00-\x1f\x7f]/;
120
- var CONTROL_BYTE_LOOSE_RE = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/;
121
- var ZERO_WIDTH_RE = new RegExp(
122
- "[\\u200B-\\u200F\\u202A-\\u202E\\u2060-\\u2064\\u2066-\\u2069\\uFEFF\\u061C]"
123
- );
124
119
 
125
120
  var ALLOWED_UPDATE_COLUMNS = Object.freeze([
126
121
  "name", "payout_method", "payout_address", "commission_kind",
@@ -129,6 +124,7 @@ var ALLOWED_UPDATE_COLUMNS = Object.freeze([
129
124
 
130
125
  // Framework handle (the vendored blamejs); index.js re-exports this as .framework.
131
126
  var b = require("./vendor/blamejs");
127
+ var textGuard = require("./text-guard");
132
128
  var C = b.constants;
133
129
 
134
130
  // ---- validators ---------------------------------------------------------
@@ -149,9 +145,7 @@ function _name(s) {
149
145
  if (s.length > MAX_NAME_LEN) {
150
146
  throw new TypeError("affiliates: name must be <= " + MAX_NAME_LEN + " characters");
151
147
  }
152
- if (CONTROL_BYTE_STRICT_RE.test(s) || ZERO_WIDTH_RE.test(s)) {
153
- throw new TypeError("affiliates: name contains control / zero-width bytes");
154
- }
148
+ textGuard.freeText(s, "affiliates: name", { singleLine: "reject", zeroWidth: "reject", bidiMarks: "allow" });
155
149
  return s;
156
150
  }
157
151
 
@@ -173,9 +167,7 @@ function _payoutAddress(s) {
173
167
  if (s.length > MAX_PAYOUT_ADDRESS_LEN) {
174
168
  throw new TypeError("affiliates: payout_address must be <= " + MAX_PAYOUT_ADDRESS_LEN + " characters");
175
169
  }
176
- if (CONTROL_BYTE_STRICT_RE.test(s) || ZERO_WIDTH_RE.test(s)) {
177
- throw new TypeError("affiliates: payout_address contains control / zero-width bytes");
178
- }
170
+ textGuard.freeText(s, "affiliates: payout_address", { singleLine: "reject", zeroWidth: "reject", bidiMarks: "allow" });
179
171
  return s;
180
172
  }
181
173
 
@@ -239,9 +231,7 @@ function _referrer(s) {
239
231
  if (s.length > MAX_REFERRER_LEN) {
240
232
  throw new TypeError("affiliates: referrer must be <= " + MAX_REFERRER_LEN + " characters");
241
233
  }
242
- if (CONTROL_BYTE_STRICT_RE.test(s) || ZERO_WIDTH_RE.test(s)) {
243
- throw new TypeError("affiliates: referrer contains control / zero-width bytes");
244
- }
234
+ textGuard.freeText(s, "affiliates: referrer", { singleLine: "reject", zeroWidth: "reject" });
245
235
  return s;
246
236
  }
247
237
 
@@ -254,9 +244,7 @@ function _reason(r) {
254
244
  if (r.length > MAX_REASON_LEN) {
255
245
  throw new TypeError("affiliates: reason must be <= " + MAX_REASON_LEN + " characters");
256
246
  }
257
- if (CONTROL_BYTE_LOOSE_RE.test(r) || ZERO_WIDTH_RE.test(r)) {
258
- throw new TypeError("affiliates: reason contains control / zero-width bytes");
259
- }
247
+ textGuard.freeText(r, "affiliates: reason", { zeroWidth: "reject", bidiMarks: "allow" });
260
248
  return r;
261
249
  }
262
250
 
@@ -271,9 +259,7 @@ function _payoutReference(s) {
271
259
  if (s.length > MAX_PAYOUT_REF_LEN) {
272
260
  throw new TypeError("affiliates: payout_reference must be <= " + MAX_PAYOUT_REF_LEN + " characters");
273
261
  }
274
- if (CONTROL_BYTE_STRICT_RE.test(s) || ZERO_WIDTH_RE.test(s)) {
275
- throw new TypeError("affiliates: payout_reference contains control / zero-width bytes");
276
- }
262
+ textGuard.freeText(s, "affiliates: payout_reference", { singleLine: "reject", zeroWidth: "reject" });
277
263
  return s;
278
264
  }
279
265
 
@@ -304,9 +290,7 @@ function _sessionId(s) {
304
290
  if (s.length > 512) {
305
291
  throw new TypeError("affiliates: visitor_session_id must be <= 512 characters");
306
292
  }
307
- if (CONTROL_BYTE_STRICT_RE.test(s) || ZERO_WIDTH_RE.test(s)) {
308
- throw new TypeError("affiliates: visitor_session_id contains control / zero-width bytes");
309
- }
293
+ textGuard.freeText(s, "affiliates: visitor_session_id", { singleLine: "reject", zeroWidth: "reject" });
310
294
  return s;
311
295
  }
312
296
 
@@ -117,13 +117,9 @@ var SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/;
117
117
  // `message` — the announcement is a single-line strip, never a
118
118
  // paragraph; an embedded \n would either break the layout or be
119
119
  // silently swallowed by CSS, both of which are surprising.
120
- var CONTROL_BYTE_LINE_RE = /[\x00-\x1f\x7f]/;
121
120
 
122
121
  // Zero-width / direction-override family — spelled with \u-escapes
123
122
  // so ESLint's no-irregular-whitespace stays happy.
124
- var ZERO_WIDTH_RE = new RegExp(
125
- "[\\u200B-\\u200F\\u202A-\\u202E\\u2060-\\u2064\\u2066-\\u2069\\uFEFF\\u061C]"
126
- );
127
123
 
128
124
  var ALLOWED_PATCH_COLUMNS = Object.freeze([
129
125
  "message",
@@ -138,6 +134,7 @@ var ALLOWED_PATCH_COLUMNS = Object.freeze([
138
134
  ]);
139
135
 
140
136
  var b = require("./vendor/blamejs");
137
+ var textGuard = require("./text-guard");
141
138
 
142
139
  // ---- monotonic clock ----------------------------------------------------
143
140
  //
@@ -170,12 +167,7 @@ function _message(s) {
170
167
  if (typeof s !== "string" || !s.length || s.length > MAX_MESSAGE_LEN) {
171
168
  throw new TypeError("announcementBar: message must be a non-empty string <= " + MAX_MESSAGE_LEN + " chars");
172
169
  }
173
- if (CONTROL_BYTE_LINE_RE.test(s)) {
174
- throw new TypeError("announcementBar: message must not contain control bytes (incl. CR/LF)");
175
- }
176
- if (ZERO_WIDTH_RE.test(s)) {
177
- throw new TypeError("announcementBar: message must not contain zero-width / direction-override characters");
178
- }
170
+ textGuard.freeText(s, "announcementBar: message", { singleLine: "reject", zeroWidth: "reject", bidiMarks: "allow" });
179
171
  return s;
180
172
  }
181
173
 
@@ -183,12 +175,7 @@ function _linkLabel(s) {
183
175
  if (typeof s !== "string" || !s.length || s.length > MAX_LINK_LABEL_LEN) {
184
176
  throw new TypeError("announcementBar: link_label must be a non-empty string <= " + MAX_LINK_LABEL_LEN + " chars");
185
177
  }
186
- if (CONTROL_BYTE_LINE_RE.test(s)) {
187
- throw new TypeError("announcementBar: link_label must not contain control bytes");
188
- }
189
- if (ZERO_WIDTH_RE.test(s)) {
190
- throw new TypeError("announcementBar: link_label must not contain zero-width / direction-override characters");
191
- }
178
+ textGuard.freeText(s, "announcementBar: link_label", { singleLine: "reject", zeroWidth: "reject", bidiMarks: "allow" });
192
179
  return s;
193
180
  }
194
181
 
@@ -243,9 +230,7 @@ function _sessionId(s) {
243
230
  if (typeof s !== "string" || !s.length || s.length > MAX_SESSION_ID_LEN) {
244
231
  throw new TypeError("announcementBar: session_id must be a non-empty string <= " + MAX_SESSION_ID_LEN + " chars");
245
232
  }
246
- if (CONTROL_BYTE_LINE_RE.test(s)) {
247
- throw new TypeError("announcementBar: session_id must not contain control bytes");
248
- }
233
+ textGuard.freeText(s, "announcementBar: session_id", { singleLine: "reject" });
249
234
  return s;
250
235
  }
251
236
 
@@ -258,9 +243,7 @@ function _linkUrl(s) {
258
243
  if (typeof s !== "string" || !s.length || s.length > MAX_LINK_URL_LEN) {
259
244
  throw new TypeError("announcementBar: link_url must be a non-empty string <= " + MAX_LINK_URL_LEN + " chars");
260
245
  }
261
- if (CONTROL_BYTE_LINE_RE.test(s) || ZERO_WIDTH_RE.test(s)) {
262
- throw new TypeError("announcementBar: link_url contains control / zero-width bytes");
263
- }
246
+ textGuard.freeText(s, "announcementBar: link_url", { singleLine: "reject", zeroWidth: "reject" });
264
247
  if (s.charCodeAt(0) === 47 /* "/" */) {
265
248
  if (s.length > 1 && s.charCodeAt(1) === 47) {
266
249
  throw new TypeError("announcementBar: link_url protocol-relative `//host/...` refused — use absolute https://");
package/lib/api-keys.js CHANGED
@@ -78,6 +78,7 @@ var TOKEN_PLAINTEXT_RE = /^[A-Za-z0-9_-]{43}$/;
78
78
 
79
79
  // Framework handle (the vendored blamejs); index.js re-exports this as .framework.
80
80
  var b = require("./vendor/blamejs");
81
+ var textGuard = require("./text-guard");
81
82
 
82
83
  var ROTATION_GRACE_MS = b.constants.TIME.days(1);
83
84
 
@@ -106,10 +107,6 @@ var SCOPE_RE = /^[a-z0-9][a-z0-9:._-]{0,127}$/;
106
107
  // reason + endpoint render in operator dashboards; embedded control
107
108
  // / direction-override bytes are a slipping-class for header
108
109
  // injection + visual-spoofing attacks downstream.
109
- var CONTROL_BYTE_STRICT_RE = /[\x00-\x1f\x7f]/;
110
- var ZERO_WIDTH_RE = new RegExp(
111
- "[\\u200B-\\u200F\\u202A-\\u202E\\u2060-\\u2064\\u2066-\\u2069\\uFEFF\\u061C]"
112
- );
113
110
 
114
111
  var ALLOWED_UPDATE_COLUMNS = Object.freeze([
115
112
  "name", "scopes", "rate_limit_per_minute",
@@ -140,9 +137,7 @@ function _name(s) {
140
137
  if (s.length > MAX_NAME_LEN) {
141
138
  throw new TypeError("apiKeys: name must be <= " + MAX_NAME_LEN + " characters");
142
139
  }
143
- if (CONTROL_BYTE_STRICT_RE.test(s) || ZERO_WIDTH_RE.test(s)) {
144
- throw new TypeError("apiKeys: name contains control / zero-width bytes");
145
- }
140
+ textGuard.freeText(s, "apiKeys: name", { singleLine: "reject", zeroWidth: "reject", bidiMarks: "allow" });
146
141
  return s;
147
142
  }
148
143
 
@@ -212,9 +207,7 @@ function _reason(r) {
212
207
  if (r.length > MAX_REASON_LEN) {
213
208
  throw new TypeError("apiKeys: reason must be <= " + MAX_REASON_LEN + " characters");
214
209
  }
215
- if (CONTROL_BYTE_STRICT_RE.test(r) || ZERO_WIDTH_RE.test(r)) {
216
- throw new TypeError("apiKeys: reason contains control / zero-width bytes");
217
- }
210
+ textGuard.freeText(r, "apiKeys: reason", { singleLine: "reject", zeroWidth: "reject", bidiMarks: "allow" });
218
211
  return r;
219
212
  }
220
213
 
@@ -229,9 +222,7 @@ function _endpoint(s) {
229
222
  if (s.length > MAX_ENDPOINT_LEN) {
230
223
  throw new TypeError("apiKeys: endpoint must be <= " + MAX_ENDPOINT_LEN + " characters");
231
224
  }
232
- if (CONTROL_BYTE_STRICT_RE.test(s) || ZERO_WIDTH_RE.test(s)) {
233
- throw new TypeError("apiKeys: endpoint contains control / zero-width bytes");
234
- }
225
+ textGuard.freeText(s, "apiKeys: endpoint", { singleLine: "reject", zeroWidth: "reject" });
235
226
  return s;
236
227
  }
237
228
 
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.5.20",
2
+ "version": "0.5.22",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-imfe0otYErcB8rr2h6KLSGTtStirysptpXETSPY4zLv3bZoIT75Lo1dOvkOav+xL",
@@ -124,17 +124,13 @@ var SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/;
124
124
  // title / hypothesis fields land in the operator dashboard, not the
125
125
  // storefront — but the discipline is the same: strings reach the UI
126
126
  // as inert text, never as live markup.
127
- var CONTROL_BYTE_LINE_RE = /[\x00-\x1f\x7f]/;
128
- var CONTROL_BYTE_BLOCK_RE = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/;
129
127
 
130
128
  // Zero-width / direction-override family — mirrors the promo-banners
131
129
  // + experiments catalogues. Spelled with \u-escapes so ESLint's
132
130
  // no-irregular-whitespace stays happy.
133
- var ZERO_WIDTH_RE = new RegExp(
134
- "[\\u200B-\\u200F\\u202A-\\u202E\\u2060-\\u2064\\u2066-\\u2069\\uFEFF\\u061C]"
135
- );
136
131
 
137
132
  var b = require("./vendor/blamejs");
133
+ var textGuard = require("./text-guard");
138
134
 
139
135
  // ---- monotonic clock ----------------------------------------------------
140
136
  //
@@ -167,10 +163,10 @@ function _line(s, label, maxLen) {
167
163
  if (typeof s !== "string" || !s.length || s.length > maxLen) {
168
164
  throw new TypeError("bannerABTests: " + label + " must be a non-empty string <= " + maxLen + " chars");
169
165
  }
170
- if (CONTROL_BYTE_LINE_RE.test(s)) {
166
+ if (textGuard.hasCodepointThreat(s, { singleLine: "reject" })) {
171
167
  throw new TypeError("bannerABTests: " + label + " contains control bytes (incl. CR/LF)");
172
168
  }
173
- if (ZERO_WIDTH_RE.test(s)) {
169
+ if (textGuard.hasCodepointThreat(s, { zeroWidth: "reject" })) {
174
170
  throw new TypeError("bannerABTests: " + label + " contains zero-width / direction-override characters");
175
171
  }
176
172
  return s;
@@ -180,10 +176,10 @@ function _block(s, label, maxLen) {
180
176
  if (typeof s !== "string" || !s.length || s.length > maxLen) {
181
177
  throw new TypeError("bannerABTests: " + label + " must be a non-empty string <= " + maxLen + " chars");
182
178
  }
183
- if (CONTROL_BYTE_BLOCK_RE.test(s)) {
179
+ if (textGuard.hasCodepointThreat(s)) {
184
180
  throw new TypeError("bannerABTests: " + label + " contains control bytes");
185
181
  }
186
- if (ZERO_WIDTH_RE.test(s)) {
182
+ if (textGuard.hasCodepointThreat(s, { zeroWidth: "reject" })) {
187
183
  throw new TypeError("bannerABTests: " + label + " contains zero-width / direction-override characters");
188
184
  }
189
185
  return s;
@@ -158,16 +158,12 @@ var PRODUCT_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/;
158
158
  // body permits LF (Markdown is line-oriented); single-line fields
159
159
  // refuse LF / CR so they can't smuggle a second line into a post
160
160
  // header / meta tag / hero-image attribute.
161
- var CONTROL_BYTE_LINE_RE = /[\x00-\x1f\x7f]/;
162
- var CONTROL_BYTE_BLOCK_RE = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/;
163
161
 
164
162
  // Zero-width / direction-override family — spelled with \u-escapes so
165
163
  // ESLint's no-irregular-whitespace stays happy.
166
- var ZERO_WIDTH_RE = new RegExp(
167
- "[\\u200B-\\u200F\\u202A-\\u202E\\u2060-\\u2064\\u2066-\\u2069\\uFEFF\\u061C]"
168
- );
169
164
 
170
165
  var b = require("./vendor/blamejs");
166
+ var textGuard = require("./text-guard");
171
167
 
172
168
  // ---- monotonic clock ---------------------------------------------------
173
169
  //
@@ -204,12 +200,7 @@ function _title(s) {
204
200
  if (typeof s !== "string" || !s.length || s.length > MAX_TITLE_LEN) {
205
201
  throw new TypeError("blogArticles: title must be a non-empty string <= " + MAX_TITLE_LEN + " chars");
206
202
  }
207
- if (CONTROL_BYTE_LINE_RE.test(s)) {
208
- throw new TypeError("blogArticles: title contains control bytes (incl. CR/LF)");
209
- }
210
- if (ZERO_WIDTH_RE.test(s)) {
211
- throw new TypeError("blogArticles: title contains zero-width / direction-override characters");
212
- }
203
+ textGuard.freeText(s, "blogArticles: title", { singleLine: "reject", zeroWidth: "reject", bidiMarks: "allow" });
213
204
  return s;
214
205
  }
215
206
 
@@ -217,12 +208,7 @@ function _body(s) {
217
208
  if (typeof s !== "string" || !s.length || s.length > MAX_BODY_LEN) {
218
209
  throw new TypeError("blogArticles: body must be a non-empty string <= " + MAX_BODY_LEN + " chars");
219
210
  }
220
- if (CONTROL_BYTE_BLOCK_RE.test(s)) {
221
- throw new TypeError("blogArticles: body contains control bytes");
222
- }
223
- if (ZERO_WIDTH_RE.test(s)) {
224
- throw new TypeError("blogArticles: body contains zero-width / direction-override characters");
225
- }
211
+ textGuard.freeText(s, "blogArticles: body", { zeroWidth: "reject", bidiMarks: "allow" });
226
212
  return s;
227
213
  }
228
214
 
@@ -306,9 +292,7 @@ function _heroImageUrl(s) {
306
292
  if (s.length > MAX_HERO_IMAGE_URL_LEN) {
307
293
  throw new TypeError("blogArticles: hero_image_url must be <= " + MAX_HERO_IMAGE_URL_LEN + " chars");
308
294
  }
309
- if (CONTROL_BYTE_LINE_RE.test(s) || ZERO_WIDTH_RE.test(s)) {
310
- throw new TypeError("blogArticles: hero_image_url contains control / zero-width bytes");
311
- }
295
+ textGuard.freeText(s, "blogArticles: hero_image_url", { singleLine: "reject", zeroWidth: "reject" });
312
296
  if (s.charCodeAt(0) === 47 /* "/" */) {
313
297
  if (s.length > 1 && s.charCodeAt(1) === 47) {
314
298
  throw new TypeError("blogArticles: hero_image_url protocol-relative `//host/...` refused — use absolute https://");
@@ -331,10 +315,10 @@ function _metaLine(s, label, maxLen) {
331
315
  if (typeof s !== "string" || s.length > maxLen) {
332
316
  throw new TypeError("blogArticles: " + label + " must be a string <= " + maxLen + " chars");
333
317
  }
334
- if (CONTROL_BYTE_LINE_RE.test(s)) {
318
+ if (textGuard.hasCodepointThreat(s, { singleLine: "reject" })) {
335
319
  throw new TypeError("blogArticles: " + label + " contains control bytes (incl. CR/LF)");
336
320
  }
337
- if (ZERO_WIDTH_RE.test(s)) {
321
+ if (textGuard.hasCodepointThreat(s, { zeroWidth: "reject" })) {
338
322
  throw new TypeError("blogArticles: " + label + " contains zero-width / direction-override characters");
339
323
  }
340
324
  return s;
@@ -367,9 +351,7 @@ function _sessionIdRaw(s) {
367
351
  if (s.length > 256) {
368
352
  throw new TypeError("blogArticles: session_id must be <= 256 characters");
369
353
  }
370
- if (CONTROL_BYTE_LINE_RE.test(s) || ZERO_WIDTH_RE.test(s)) {
371
- throw new TypeError("blogArticles: session_id contains control / zero-width bytes");
372
- }
354
+ textGuard.freeText(s, "blogArticles: session_id", { singleLine: "reject", zeroWidth: "reject" });
373
355
  return s;
374
356
  }
375
357
 
@@ -424,7 +406,7 @@ function _esc(s) {
424
406
 
425
407
  function _safeLinkUrl(url) {
426
408
  if (typeof url !== "string" || !url.length || url.length > 2048) return null;
427
- if (CONTROL_BYTE_LINE_RE.test(url) || ZERO_WIDTH_RE.test(url)) return null;
409
+ if (textGuard.hasCodepointThreat(url, { singleLine: "reject", zeroWidth: "reject" })) return null;
428
410
  if (url.charCodeAt(0) === 47 /* "/" */) {
429
411
  if (url.length > 1 && url.charCodeAt(1) === 47) return null;
430
412
  if (url.indexOf("..") !== -1) return null;