@blamejs/blamejs-shop 0.5.19 → 0.5.20

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 (110) hide show
  1. package/CHANGELOG.md +2 -0
  2. package/lib/admin.js +35 -40
  3. package/lib/asset-manifest.json +1 -1
  4. package/lib/externaldb-d1.js +31 -21
  5. package/lib/payment.js +103 -58
  6. package/lib/referrals.js +10 -6
  7. package/lib/security-middleware.js +39 -12
  8. package/lib/site-redirects.js +44 -121
  9. package/lib/storefront.js +19 -21
  10. package/lib/vendor/MANIFEST.json +107 -97
  11. package/lib/vendor/blamejs/CHANGELOG.md +180 -0
  12. package/lib/vendor/blamejs/NOTICE +14 -10
  13. package/lib/vendor/blamejs/README.md +17 -5
  14. package/lib/vendor/blamejs/index.js +2 -0
  15. package/lib/vendor/blamejs/lib/acme.js +9 -9
  16. package/lib/vendor/blamejs/lib/api-snapshot.js +35 -16
  17. package/lib/vendor/blamejs/lib/app-shutdown.js +1 -1
  18. package/lib/vendor/blamejs/lib/archive-adapters.js +1 -1
  19. package/lib/vendor/blamejs/lib/archive.js +13 -24
  20. package/lib/vendor/blamejs/lib/auth/access-lock.js +2 -1
  21. package/lib/vendor/blamejs/lib/auth/bot-challenge.js +3 -6
  22. package/lib/vendor/blamejs/lib/auth/ciba.js +32 -3
  23. package/lib/vendor/blamejs/lib/auth/oauth.js +65 -8
  24. package/lib/vendor/blamejs/lib/auth/password.js +1 -1
  25. package/lib/vendor/blamejs/lib/auth/saml.js +21 -3
  26. package/lib/vendor/blamejs/lib/auth-bot-challenge.js +3 -3
  27. package/lib/vendor/blamejs/lib/backup/index.js +5 -5
  28. package/lib/vendor/blamejs/lib/case-fold-classes.js +491 -0
  29. package/lib/vendor/blamejs/lib/codepoint-class.js +131 -0
  30. package/lib/vendor/blamejs/lib/constants.js +79 -0
  31. package/lib/vendor/blamejs/lib/db-declare-view.js +8 -2
  32. package/lib/vendor/blamejs/lib/error-page.js +20 -1
  33. package/lib/vendor/blamejs/lib/external-db.js +5 -1
  34. package/lib/vendor/blamejs/lib/flag-evaluation-context.js +24 -6
  35. package/lib/vendor/blamejs/lib/flag.js +28 -2
  36. package/lib/vendor/blamejs/lib/framework-schema.js +14 -3
  37. package/lib/vendor/blamejs/lib/guard-regex.js +2345 -89
  38. package/lib/vendor/blamejs/lib/http-client.js +217 -40
  39. package/lib/vendor/blamejs/lib/mail-bimi.js +1 -1
  40. package/lib/vendor/blamejs/lib/mail-crypto-pgp.js +3 -2
  41. package/lib/vendor/blamejs/lib/mail-dav.js +26 -26
  42. package/lib/vendor/blamejs/lib/mail-deploy.js +10 -10
  43. package/lib/vendor/blamejs/lib/mail-scan.js +3 -0
  44. package/lib/vendor/blamejs/lib/mail-store.js +7 -1
  45. package/lib/vendor/blamejs/lib/mail.js +5 -1
  46. package/lib/vendor/blamejs/lib/middleware/age-gate.js +3 -2
  47. package/lib/vendor/blamejs/lib/middleware/api-encrypt.js +1 -1
  48. package/lib/vendor/blamejs/lib/middleware/assetlinks.js +2 -1
  49. package/lib/vendor/blamejs/lib/middleware/asyncapi-serve.js +3 -2
  50. package/lib/vendor/blamejs/lib/middleware/bearer-auth.js +2 -1
  51. package/lib/vendor/blamejs/lib/middleware/compression.js +1 -1
  52. package/lib/vendor/blamejs/lib/middleware/cors.js +1 -1
  53. package/lib/vendor/blamejs/lib/middleware/csp-report.js +5 -5
  54. package/lib/vendor/blamejs/lib/middleware/csrf-protect.js +2 -1
  55. package/lib/vendor/blamejs/lib/middleware/daily-byte-quota.js +2 -2
  56. package/lib/vendor/blamejs/lib/middleware/deny-response.js +2 -1
  57. package/lib/vendor/blamejs/lib/middleware/dpop.js +2 -2
  58. package/lib/vendor/blamejs/lib/middleware/fetch-metadata.js +2 -1
  59. package/lib/vendor/blamejs/lib/middleware/flag-context.js +17 -0
  60. package/lib/vendor/blamejs/lib/middleware/idempotency-key.js +10 -8
  61. package/lib/vendor/blamejs/lib/middleware/openapi-serve.js +3 -2
  62. package/lib/vendor/blamejs/lib/middleware/require-aal.js +3 -2
  63. package/lib/vendor/blamejs/lib/middleware/require-auth.js +4 -3
  64. package/lib/vendor/blamejs/lib/middleware/require-bound-key.js +4 -3
  65. package/lib/vendor/blamejs/lib/middleware/require-content-type.js +3 -2
  66. package/lib/vendor/blamejs/lib/middleware/require-methods.js +3 -2
  67. package/lib/vendor/blamejs/lib/middleware/require-mtls.js +3 -2
  68. package/lib/vendor/blamejs/lib/middleware/security-txt.js +2 -1
  69. package/lib/vendor/blamejs/lib/middleware/web-app-manifest.js +2 -1
  70. package/lib/vendor/blamejs/lib/network-dns-resolver.js +1 -1
  71. package/lib/vendor/blamejs/lib/network-dns.js +3 -3
  72. package/lib/vendor/blamejs/lib/network-nts.js +35 -14
  73. package/lib/vendor/blamejs/lib/network-proxy.js +42 -4
  74. package/lib/vendor/blamejs/lib/network-smtp-policy.js +1 -1
  75. package/lib/vendor/blamejs/lib/network-tls.js +341 -11
  76. package/lib/vendor/blamejs/lib/nist-crosswalk.js +2 -2
  77. package/lib/vendor/blamejs/lib/ntp-check.js +30 -19
  78. package/lib/vendor/blamejs/lib/object-store/azure-blob.js +1 -1
  79. package/lib/vendor/blamejs/lib/object-store/gcs.js +1 -1
  80. package/lib/vendor/blamejs/lib/object-store/http-put.js +2 -1
  81. package/lib/vendor/blamejs/lib/object-store/sigv4-bucket-ops.js +5 -5
  82. package/lib/vendor/blamejs/lib/object-store/sigv4.js +2 -2
  83. package/lib/vendor/blamejs/lib/observability-otlp-exporter.js +4 -2
  84. package/lib/vendor/blamejs/lib/openapi.js +1 -1
  85. package/lib/vendor/blamejs/lib/pqc-agent.js +4 -7
  86. package/lib/vendor/blamejs/lib/problem-details.js +2 -1
  87. package/lib/vendor/blamejs/lib/redis-client.js +12 -1
  88. package/lib/vendor/blamejs/lib/regex-linear.js +1349 -0
  89. package/lib/vendor/blamejs/lib/render.js +531 -7
  90. package/lib/vendor/blamejs/lib/request-helpers.js +181 -5
  91. package/lib/vendor/blamejs/lib/router.js +37 -13
  92. package/lib/vendor/blamejs/lib/safe-async.js +142 -0
  93. package/lib/vendor/blamejs/lib/safe-icap.js +3 -0
  94. package/lib/vendor/blamejs/lib/safe-sql.js +15 -3
  95. package/lib/vendor/blamejs/lib/self-update.js +2 -2
  96. package/lib/vendor/blamejs/lib/testing.js +54 -7
  97. package/lib/vendor/blamejs/lib/validate-opts.js +56 -0
  98. package/lib/vendor/blamejs/lib/vendor/MANIFEST.json +62 -25
  99. package/lib/vendor/blamejs/lib/vendor/blamejs-pki.cjs +2004 -142
  100. package/lib/vendor/blamejs/lib/vendor/browser/noble-ciphers.mjs +727 -0
  101. package/lib/vendor/blamejs/lib/vendor/browser/noble-hashes.mjs +1070 -0
  102. package/lib/vendor/blamejs/lib/vendor/browser/noble-post-quantum.mjs +1090 -0
  103. package/lib/vendor/blamejs/lib/vendor/noble-post-quantum.cjs +510 -227
  104. package/lib/vendor/blamejs/lib/webhook-hmac.js +19 -2
  105. package/lib/vendor/blamejs/lib/webhook.js +1 -1
  106. package/lib/vendor/blamejs/lib/ws-client.js +24 -2
  107. package/lib/vendor/blamejs/package.json +1 -1
  108. package/lib/vendor/blamejs/sbom.cdx.json +6 -6
  109. package/lib/webhook-receiver.js +43 -73
  110. 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.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
+
11
13
  - 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.
12
14
 
13
15
  - v0.5.18 (2026-07-25) — **The operator role-permission check fails closed on an unknown capability instead of throwing, and customer impersonation's capability is grantable through the RBAC system.** Fixes an authorization-control defect in operatorRoles.hasPermission and the customer-impersonation capability gate. hasPermission — the runtime authorization check that operator features gate on — routed the requested permission through the same strict validator the config-time surfaces use, which throws on any token outside the closed permission allow-list. A feature that gated on a capability the allow-list did not carry therefore made the check CRASH rather than return allow or deny: with the roles peer wired, every such check threw and the gated action was inoperable; the correct posture for an authorization check is to fail closed. hasPermission now returns false (deny) for an unknown or garbage permission token; the config-time surfaces (defineRole / patchRole) still validate their permission lists strictly. Separately, the customer-impersonation capability was a token the allow-list did not carry, so it could never be granted to a role NOR checked — impersonation could not be gated through the RBAC system at all. It is now a first-class permission (customers.impersonate) that a role can grant and the check resolves, so an operator without it is denied and one with it is allowed. The same root fix turns the two-person-approval workflow's capability check from a crash into a fail-closed deny when a workflow names a capability outside the allow-list. **Security:** *The RBAC permission check fails closed on an unknown capability instead of throwing* — operatorRoles.hasPermission validated the requested permission with the strict allow-list check the configuration surfaces use, which throws on any token outside the closed allow-list. A feature gating on a capability the allow-list did not carry (customer impersonation's capability, or a two-person-approval workflow's required capability) therefore crashed the check instead of getting an allow/deny answer — inoperable with the roles peer wired, and never the intended fail-closed behavior. hasPermission now returns false for an unknown or garbage permission token, so a caller gating on an unrecognized capability is denied rather than crashed. defineRole / patchRole still reject an unknown permission at configuration time, where a strict error is correct. · *Customer-impersonation capability is a first-class, grantable permission* — The capability that customer impersonation checks defaulted to a token the permission allow-list did not carry, so it could neither be granted to an operator role nor resolved by the check — impersonation could not be gated through the RBAC system, and composing the real roles primitive made the gate throw. The capability is now customers.impersonate, a member of the closed allow-list: a role can grant it, an operator holding that role is allowed to impersonate, and an operator without it is refused with a typed error at the primitive layer.
package/lib/admin.js CHANGED
@@ -10721,29 +10721,28 @@ function mount(router, deps) {
10721
10721
  var fromDay = _dateInputValue(win.from);
10722
10722
  var toDay = _dateInputValue(win.to - b.constants.TIME.days(1));
10723
10723
  var filename = "orders-" + (fromDay || "start") + "-to-" + (toDay || "end") + "." + ext;
10724
- res.status(200);
10725
- if (res.setHeader) {
10726
- res.setHeader("content-type", contentType);
10727
- // The filename is built from validated epoch-ms date strings
10728
- // (digits + hyphens only), so it carries no quote/CRLF that could
10729
- // break out of the header — but keep it ASCII-safe regardless.
10730
- res.setHeader("content-disposition", "attachment; filename=\"" + filename.replace(/[^A-Za-z0-9._-]/g, "") + "\"");
10731
- res.setHeader("x-content-type-options", "nosniff");
10732
- }
10733
- // Write the header first, then one chunk per batch as the primitive
10734
- // yields it — bounded memory regardless of the order count. A
10735
- // response without an incremental write() (a JSON test stub) falls
10736
- // back to buffering the date-bounded window.
10737
- if (typeof res.write === "function" && typeof res.end === "function") {
10738
- for await (var chunk of iter) {
10739
- if (chunk) res.write(chunk);
10740
- }
10741
- res.end();
10742
- } else {
10743
- var body = "";
10744
- for await (var c2 of iter) body += c2;
10745
- if (res.end) res.end(body); else res.send(body);
10746
- }
10724
+ // One chunk per batch as the primitive yields it — bounded memory
10725
+ // regardless of the order count. render.stream awaits backpressure so a
10726
+ // slow client cannot turn that back into the whole export queued in
10727
+ // memory, stops pulling rows when the peer disconnects, and breaks the
10728
+ // transfer on a mid-export failure rather than closing a half-written
10729
+ // CSV as a successful 200.
10730
+ //
10731
+ // Headers go in the call, not via setHeader beforehand: render.stream
10732
+ // writes its own defaults with the first chunk, and its default
10733
+ // Content-Type would replace one set earlier.
10734
+ //
10735
+ // The filename is built from validated epoch-ms date strings (digits +
10736
+ // hyphens only), so it carries no quote/CRLF that could break out of the
10737
+ // header but keep it ASCII-safe regardless.
10738
+ await b.render.stream(res, iter, {
10739
+ status: 200,
10740
+ headers: {
10741
+ "Content-Type": contentType,
10742
+ "Content-Disposition": "attachment; filename=\"" + filename.replace(/[^A-Za-z0-9._-]/g, "") + "\"",
10743
+ "X-Content-Type-Options": "nosniff",
10744
+ },
10745
+ });
10747
10746
  }
10748
10747
 
10749
10748
  router.get("/admin/exports", _pageOrApi(true,
@@ -13122,23 +13121,19 @@ function mount(router, deps) {
13122
13121
  // Same response on the bearer + browser surfaces (a link, not a fetch).
13123
13122
  async function _streamSegmentMembers(res, slug) {
13124
13123
  var iter = customerSegments.membersCsvForSegment(slug);
13125
- res.status(200);
13126
- if (res.setHeader) {
13127
- res.setHeader("content-type", "text/csv; charset=utf-8");
13128
- var fname = ("segment-" + slug + "-members.csv").replace(/[^A-Za-z0-9._-]/g, "");
13129
- res.setHeader("content-disposition", "attachment; filename=\"" + fname + "\"");
13130
- res.setHeader("x-content-type-options", "nosniff");
13131
- }
13132
- // Write the header chunk first, then one chunk per batch. A response
13133
- // without an incremental write() (a JSON test stub) buffers instead.
13134
- if (typeof res.write === "function" && typeof res.end === "function") {
13135
- for await (var chunk of iter) { if (chunk) res.write(chunk); }
13136
- res.end();
13137
- } else {
13138
- var body = "";
13139
- for await (var c2 of iter) body += c2;
13140
- if (res.end) res.end(body); else res.send(body);
13141
- }
13124
+ var fname = ("segment-" + slug + "-members.csv").replace(/[^A-Za-z0-9._-]/g, "");
13125
+ // Headers go in the call, not via setHeader beforehand: render.stream
13126
+ // writes its own defaults with the first chunk, and its default
13127
+ // Content-Type would replace one set earlier. Same backpressure /
13128
+ // disconnect / honest-failure handling as the other exports.
13129
+ await b.render.stream(res, iter, {
13130
+ status: 200,
13131
+ headers: {
13132
+ "Content-Type": "text/csv; charset=utf-8",
13133
+ "Content-Disposition": "attachment; filename=\"" + fname + "\"",
13134
+ "X-Content-Type-Options": "nosniff",
13135
+ },
13136
+ });
13142
13137
  }
13143
13138
 
13144
13139
  // Audit the export as a data-egress event before streaming. Hot-path
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.5.19",
2
+ "version": "0.5.20",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-imfe0otYErcB8rr2h6KLSGTtStirysptpXETSPY4zLv3bZoIT75Lo1dOvkOav+xL",
@@ -98,21 +98,31 @@ function _isWriteSql(sql) {
98
98
  return verb === "INSERT" || verb === "UPDATE" || verb === "DELETE" || verb === "CREATE" || verb === "DROP" || verb === "ALTER" || verb === "REPLACE";
99
99
  }
100
100
 
101
- function _withTimeout(promise, ms, label) {
102
- var controller = new AbortController();
103
- var timer = setTimeout(function () { controller.abort(); }, ms);
104
- return {
105
- signal: controller.signal,
106
- finish: function () {
107
- clearTimeout(timer);
108
- return promise;
109
- },
110
- timeoutErr: function () {
111
- var e = new Error("externaldbD1: " + label + " timed out after " + ms + "ms");
112
- e.code = "D1_TIMEOUT";
113
- return e;
114
- },
115
- };
101
+ // Per-query deadline. b.safeAsync.withTimeoutSignal returns an AbortSignal
102
+ // that fires after `ms`, which is what fetch already takes.
103
+ //
104
+ // This replaced a hand-rolled AbortController whose setTimeout was only
105
+ // cleared by a `finish()` nobody ever called: every query left a pending
106
+ // timer behind, and a pending timer is ref'd, so it kept the event loop
107
+ // awake until it fired. On a query path this busy that is a standing
108
+ // population of live timers and a shutdown that cannot settle until the last
109
+ // one expires. The signal the primitive returns does not hold the loop open.
110
+ function _timeoutSignal(ms) {
111
+ return b.safeAsync.withTimeoutSignal(null, ms);
112
+ }
113
+
114
+ // A deadline reached through AbortSignal.timeout rejects with a DOMException
115
+ // named TimeoutError, NOT AbortError — so a handler that recognises only the
116
+ // latter stops producing D1_TIMEOUT and reports a transport failure instead.
117
+ // Both names map here; the only abort wired to these dials is the deadline.
118
+ function _isTimeoutAbort(e) {
119
+ return !!e && (e.name === "TimeoutError" || e.name === "AbortError");
120
+ }
121
+
122
+ function _timeoutErr(ms, label) {
123
+ var e = new Error("externaldbD1: " + label + " timed out after " + ms + "ms");
124
+ e.code = "D1_TIMEOUT";
125
+ return e;
116
126
  }
117
127
 
118
128
  async function _retryingFetch(fetchImpl, url, init, attempts, label) {
@@ -179,7 +189,7 @@ function _serviceBindingQuery(opts) {
179
189
  return async function (_client, sql, params) {
180
190
  var mode = _isWriteSql(sql) ? "run" : "all";
181
191
  var body = JSON.stringify({ sql: sql, params: params || [], mode: mode });
182
- var tcap = _withTimeout(null, timeoutMs, "service-binding query");
192
+ var tsig = _timeoutSignal(timeoutMs);
183
193
  var res;
184
194
  try {
185
195
  res = await _retryingFetch(fetchImpl, url, {
@@ -190,10 +200,10 @@ function _serviceBindingQuery(opts) {
190
200
  "accept": "application/json",
191
201
  },
192
202
  body: body,
193
- signal: tcap.signal,
203
+ signal: tsig,
194
204
  }, MAX_RETRY, "service-binding query");
195
205
  } catch (e) {
196
- if (e && e.name === "AbortError") throw tcap.timeoutErr();
206
+ if (_isTimeoutAbort(e)) throw _timeoutErr(timeoutMs, "service-binding query");
197
207
  throw e;
198
208
  }
199
209
  if (!res.ok) throw _httpErr(res.status, "service-binding query");
@@ -224,7 +234,7 @@ function _restApiQuery(opts) {
224
234
  }
225
235
  return async function (_client, sql, params) {
226
236
  var body = JSON.stringify({ sql: sql, params: params || [] });
227
- var tcap = _withTimeout(null, timeoutMs, "rest-api query");
237
+ var tsig = _timeoutSignal(timeoutMs);
228
238
  var res;
229
239
  try {
230
240
  res = await _retryingFetch(fetchImpl, url, {
@@ -235,10 +245,10 @@ function _restApiQuery(opts) {
235
245
  "accept": "application/json",
236
246
  },
237
247
  body: body,
238
- signal: tcap.signal,
248
+ signal: tsig,
239
249
  }, MAX_RETRY, "rest-api query");
240
250
  } catch (e) {
241
- if (e && e.name === "AbortError") throw tcap.timeoutErr();
251
+ if (_isTimeoutAbort(e)) throw _timeoutErr(timeoutMs, "rest-api query");
242
252
  throw e;
243
253
  }
244
254
  if (!res.ok) throw _httpErr(res.status, "rest-api query");
package/lib/payment.js CHANGED
@@ -29,26 +29,21 @@
29
29
  * token can't smuggle unsigned events into the order pipeline.
30
30
  */
31
31
 
32
- var nodeHttps = require("node:https"); // allow:non-shop-require — the PSP TLS agent must be a real node https.Agent for the vendored httpClient's caller-agent (h1) path; no b.* primitive constructs one, and the framework's downgrade prescription is an explicitly-built agent in the consumer's diff
33
-
34
32
  var b = require("./vendor/blamejs");
35
33
  var C = b.constants;
36
34
 
37
- // External payment processors do not negotiate the framework's PQC-hybrid
38
- // TLS groups yet: the vendored httpClient offers ONLY ML-KEM hybrid groups
39
- // (constants.TLS_GROUP_PREFERENCE), and api.stripe.com / api-m.paypal.com
40
- // answer that ClientHello with handshake_failure (TLS alert 40) which
41
- // surfaced as a checkout failure the moment a charge was attempted. The
42
- // framework's own prescription for a peer that can't meet the PQC list is
43
- // an explicitly-constructed agent so the downgrade is visible in this
44
- // diff: TLS 1.3 minimum stays pinned, the key-exchange groups fall back to
45
- // Node's defaults (classical X25519 / P-256 today). The vendored client
46
- // honors a caller agent per request (h1 path) while keeping its SSRF-
47
- // pinned DNS lookup, retries, and response caps. Scoped to the PSP dials
48
- // only every other outbound keeps the PQC-first default. Revisit when
49
- // the processor edges negotiate ML-KEM hybrid groups.
50
- var _PSP_TLS_AGENT = new nodeHttps.Agent({ keepAlive: true, minVersion: "TLSv1.3" });
51
-
35
+ // The processor dials carry no caller-supplied TLS agent. They used to: the
36
+ // framework's outbound group list was once hybrid-only, and api.stripe.com
37
+ // answered that ClientHello with handshake_failure (TLS alert 40), so these
38
+ // three dials pinned an explicitly-built agent on Node's default groups to
39
+ // keep checkout working. That list now ends in classical X25519 as a
40
+ // last-resort floor, so the framework's own posture already negotiates with
41
+ // both processors, and the pinned agent had become a downgrade rather than a
42
+ // workaround it forced classical key exchange on a peer that offers an
43
+ // ML-KEM hybrid, and it bypassed the client's post-handshake downgrade
44
+ // auditing. Without an agent the vendored client builds its own through
45
+ // b.pqcAgent, which locks the key exchange to b.network.tls.outboundPosture()
46
+ // and therefore follows b.network.tls.preferredGroups.set(...) at runtime.
52
47
  var STRIPE_API_BASE_DEFAULT = "https://api.stripe.com/v1";
53
48
  var STRIPE_WEBHOOK_TOLERANCE = 300; // ± 5 minutes (Stripe default)
54
49
  var STRIPE_HTTP_TIMEOUT_MS = 15000;
@@ -330,7 +325,6 @@ async function _stripeCall(opts, method, path, params, idempotencyKey) {
330
325
  headers: headers,
331
326
  body: body || undefined,
332
327
  timeoutMs: opts.timeoutMs || STRIPE_HTTP_TIMEOUT_MS,
333
- agent: _PSP_TLS_AGENT,
334
328
  allowedHosts: [opts._allowedHost],
335
329
  });
336
330
  var text = res.body && res.body.toString ? res.body.toString("utf8") : "";
@@ -832,7 +826,12 @@ function stripe(opts) {
832
826
  var PAYPAL_API_BASE_LIVE = "https://api-m.paypal.com";
833
827
  var PAYPAL_API_BASE_SANDBOX = "https://api-m.sandbox.paypal.com";
834
828
  var PAYPAL_HTTP_TIMEOUT_MS = 15000;
835
- var PAYPAL_TOKEN_SKEW_MS = C.TIME.minutes(2); // refresh this far before expiry
829
+ // How far before expiry the token manager re-fetches. Two minutes rather than
830
+ // the primitive's default minute, so a token cannot expire mid-checkout on a
831
+ // slow Orders-v2 round trip.
832
+ // allow:raw-time-literal — clientCredentialsManager takes refreshSkewSec in
833
+ // SECONDS; C.TIME returns milliseconds and cannot express this argument.
834
+ var PAYPAL_TOKEN_SKEW_SEC = 120;
836
835
 
837
836
  // PayPal rejects decimal places for these currencies; everything else is
838
837
  // 2-decimal. Amounts cross the wire as decimal strings in MAJOR units.
@@ -896,43 +895,88 @@ function _headerCI(headers, name) {
896
895
  return undefined;
897
896
  }
898
897
 
899
- async function _paypalToken(opts, state) {
900
- var now = state.now();
901
- if (state.token && now < state.tokenExpiresAt) return state.token;
902
- var httpClient = opts.httpClient || b.httpClient;
903
- var basic = Buffer.from(opts.clientId + ":" + opts.secret).toString("base64");
904
- // The client-credentials token exchange is idempotent (re-asking for a
905
- // token is always safe), so it rides the breaker AND the bounded retry.
906
- var json = await _dial(opts._breaker, true, async function () {
907
- var res = await httpClient.request({
908
- method: "POST",
909
- url: _paypalApiBase(opts) + "/v1/oauth2/token",
910
- headers: {
911
- "authorization": "Basic " + basic,
912
- "accept": "application/json",
913
- "content-type": "application/x-www-form-urlencoded",
914
- "user-agent": "blamejs-shop (zero-dep)",
915
- },
916
- body: "grant_type=client_credentials",
917
- timeoutMs: opts.timeoutMs || PAYPAL_HTTP_TIMEOUT_MS,
918
- agent: _PSP_TLS_AGENT,
919
- allowedHosts: [_paypalAllowedHost(opts)],
920
- });
921
- var text = res.body && res.body.toString ? res.body.toString("utf8") : "";
922
- var parsed; try { parsed = text.length ? b.safeJson.parse(text) : {}; } catch (_e) { parsed = {}; }
923
- if (res.statusCode < 200 || res.statusCode >= 300 || !parsed.access_token) {
924
- var err = new Error("paypal: OAuth2 token exchange failed HTTP " + res.statusCode +
925
- (parsed && parsed.error_description ? " " + parsed.error_description : ""));
926
- err.code = "PAYPAL_AUTH_" + res.statusCode;
927
- err.statusCode = res.statusCode;
928
- throw err;
929
- }
930
- return parsed;
898
+ // The PayPal client-credentials bearer token, through b.auth.oauth.
899
+ //
900
+ // The manager owns the whole grant: a cached token, ONE shared in-flight fetch
901
+ // so a burst of checkouts on a cold cache mints one token rather than one
902
+ // each, a re-fetch before expiry, and a backoff window on a 429 during which
903
+ // the still-valid cached token is served instead of hammering the endpoint.
904
+ // PayPal rate-limits the token endpoint well below the rate a checkout burst
905
+ // reaches, so each of those matters.
906
+ //
907
+ // It dials through THIS adapter's client rather than reaching for its own, so
908
+ // the exchange keeps everything the Orders-v2 calls have: the per-upstream
909
+ // circuit breaker, the bounded retry, the SSRF host pin, and the injected
910
+ // opts.httpClient the tests drive. That seam is the reason this composes at
911
+ // all — without it the token endpoint would be the one dial the circuit
912
+ // protecting live checkouts could not see, which is exactly backwards, since
913
+ // its failure is the clearest signal the upstream is unhealthy.
914
+ //
915
+ // PayPal requires HTTP Basic client authentication. client_secret_basic also
916
+ // removes the secret from the request body rather than sending it twice.
917
+ function _paypalTokenManager(opts, state) {
918
+ if (state.tokenManager) return state.tokenManager;
919
+ var httpClient = opts.httpClient || b.httpClient;
920
+ var apiBase = _paypalApiBase(opts);
921
+ var allowedHost = _paypalAllowedHost(opts);
922
+
923
+ // Re-asking for a token is always safe, so the exchange takes the retry arm
924
+ // of the dial alongside the breaker. Request options the caller sets win
925
+ // over these defaults — the framework's host pin wraps this client and
926
+ // disables redirect following per request, and that must not be overwritten.
927
+ //
928
+ // The two layers want opposite things from a failed exchange, so the dial
929
+ // gives each what it needs. A breaker only counts what THROWS, and
930
+ // b.httpClient resolves a 500 as an ordinary response, so a non-2xx has to
931
+ // be raised inside the dial or the token endpoint becomes invisible to the
932
+ // circuit. The OAuth client, meanwhile, wants the response itself so it can
933
+ // build its own typed error and read the reason PayPal gave. So the throw
934
+ // drives the retry and the breaker, and the response is handed back at the
935
+ // boundary once they are done with it. `statusCode` on the raised error is
936
+ // what makes the retry skip a 4xx and ride out a 5xx, while the breaker
937
+ // counts both — a sustained stream of 4xx is its own kind of unhealthy.
938
+ var dialer = {
939
+ request: function (reqOpts) {
940
+ var received = null;
941
+ return _dial(opts._breaker, true, async function () {
942
+ var res = await httpClient.request(Object.assign({
943
+ timeoutMs: opts.timeoutMs || PAYPAL_HTTP_TIMEOUT_MS,
944
+ allowedHosts: [allowedHost],
945
+ }, reqOpts));
946
+ received = res;
947
+ if (res.statusCode < 200 || res.statusCode >= 300) {
948
+ var err = new Error("paypal: token endpoint returned HTTP " + res.statusCode);
949
+ err.statusCode = res.statusCode;
950
+ throw err;
951
+ }
952
+ return res;
953
+ }).catch(function (e) {
954
+ // A response we actually received goes back to the OAuth client, which
955
+ // turns it into auth-oauth/token-error-<status> — the code its own
956
+ // backoff classifier reads. Anything else (a transport failure, or an
957
+ // open circuit that never dialled) is a real throw and propagates.
958
+ if (received && e && e.statusCode === received.statusCode) return received;
959
+ throw e;
960
+ });
961
+ },
962
+ };
963
+
964
+ var oauth = b.auth.oauth.create({
965
+ issuer: apiBase,
966
+ clientId: opts.clientId,
967
+ clientSecret: opts.secret,
968
+ tokenEndpoint: apiBase + "/v1/oauth2/token",
969
+ tokenEndpointAuthMethod: "client_secret_basic",
970
+ http: { client: dialer, allowedHosts: [allowedHost] },
931
971
  });
932
- state.token = json.access_token;
933
- var ttlMs = (typeof json.expires_in === "number" ? json.expires_in : 0) * 1000; // allow:raw-time-literal — PayPal expires_in is a runtime seconds value; *1000 → ms
934
- state.tokenExpiresAt = now + Math.max(0, ttlMs - PAYPAL_TOKEN_SKEW_MS);
935
- return state.token;
972
+ state.tokenManager = oauth.clientCredentialsManager({
973
+ refreshSkewSec: PAYPAL_TOKEN_SKEW_SEC,
974
+ });
975
+ return state.tokenManager;
976
+ }
977
+
978
+ function _paypalToken(opts, state) {
979
+ return _paypalTokenManager(opts, state).getToken();
936
980
  }
937
981
 
938
982
  // `breaker` selects which circuit the dial rides — every payment call rides
@@ -970,7 +1014,6 @@ async function _paypalCall(opts, state, method, path, bodyObj, requestId, breake
970
1014
  headers: headers,
971
1015
  body: body,
972
1016
  timeoutMs: opts.timeoutMs || PAYPAL_HTTP_TIMEOUT_MS,
973
- agent: _PSP_TLS_AGENT,
974
1017
  allowedHosts: [allowedHost],
975
1018
  });
976
1019
  var text = res.body && res.body.toString ? res.body.toString("utf8") : "";
@@ -1021,8 +1064,10 @@ function paypal(opts) {
1021
1064
  var state = {
1022
1065
  query: opts.query || null,
1023
1066
  now: typeof opts.now === "function" ? opts.now : function () { return Date.now(); },
1024
- token: null,
1025
- tokenExpiresAt: 0,
1067
+ // Built on first use and kept for the adapter's lifetime — the token
1068
+ // cache, the shared in-flight fetch and the 429 backoff all live on it,
1069
+ // so a per-call manager would throw every one of them away.
1070
+ tokenManager: null,
1026
1071
  };
1027
1072
 
1028
1073
  function _maybeIdempotent(operation, idempotencyKey, requestObj, doCall) {
package/lib/referrals.js CHANGED
@@ -161,13 +161,17 @@ function _alphabetRe(alphabet) {
161
161
  // default alphabet has none, but an operator-supplied 32-char set
162
162
  // could legally include `]` or `-`.
163
163
  var escaped = alphabet.replace(/[\\\]^-]/g, "\\$&");
164
- // A single anchored character class (`^[…]+$`) is structurally incapable of
164
+ var source = "^[" + escaped + "]+$";
165
+ // A single anchored character class is structurally incapable of
165
166
  // catastrophic backtracking — one unbounded quantifier over a class, no
166
- // grouped repetition so it needs no ReDoS screen. (The framework's
167
- // b.guardRegex screen is deliberately not composed here: it accepts this
168
- // shape, but its text scan false-positives on `(?:…)?` group forms, so
169
- // relying on it is avoided until the upstream fix lands.)
170
- return new RegExp("^[" + escaped + "]+$");
167
+ // grouped repetition. The screen still runs: the class body is built from an
168
+ // operator-supplied alphabet, so it is the escaping above, not the shape,
169
+ // that keeps this pattern to one class. If a character ever escapes that
170
+ // replace and closes the class early, the result stops being the safe shape
171
+ // this reasoning depends on, and the screen catches it at config time rather
172
+ // than at match time.
173
+ b.guardRegex.assertSafe(source);
174
+ return new RegExp(source);
171
175
  }
172
176
 
173
177
  // ---- factory ------------------------------------------------------------
@@ -38,6 +38,10 @@
38
38
  * visitor in ONE bucket behind the fabric and let a single global limit
39
39
  * throttle the whole store.
40
40
  *
41
+ * That read goes through `b.requestHelpers.trustedClientIp`, which honours
42
+ * the forwarded header only when the socket peer sits inside the trusted
43
+ * range (see TRUSTED_PROXY_CIDRS) — a header alone never speaks for a client.
44
+ *
41
45
  * The container runs as a single instance (max_instances=1), so the
42
46
  * default in-memory rate-limit backend is correct — there is no second
43
47
  * replica to coordinate a shared counter with, and the in-memory token
@@ -274,12 +278,35 @@ function isEdgeExemptPath(pathname) {
274
278
  var PUBLIC_ORIGINS = (process.env.SHOP_PUBLIC_ORIGINS || "https://blamejs.shop")
275
279
  .split(",").map(function (s) { return s.trim(); }).filter(Boolean);
276
280
 
281
+ // Which socket peers may speak for a client other than themselves.
282
+ //
283
+ // Same topology as resolveProtocol below: the container has no public ingress
284
+ // and is reachable only through the Cloudflare Worker over the service
285
+ // binding, so every peer that can open a socket here IS the edge — which is
286
+ // what the all-addresses default states. It is written as a CIDR list rather
287
+ // than an unconditional "believe the header" so that a deployment which does
288
+ // expose the container directly has one value to narrow, and so the gate is
289
+ // the framework's rather than a second trust model living here.
290
+ //
291
+ // Narrowing it is the right move the moment the container is reachable any
292
+ // other way: an unlisted peer's forwarded headers are ignored and keying falls
293
+ // back to that peer's own address.
294
+ var TRUSTED_PROXY_CIDRS = (process.env.SHOP_TRUSTED_PROXIES || "0.0.0.0/0,::/0")
295
+ .split(",").map(function (s) { return s.trim(); }).filter(Boolean);
296
+
297
+ // The edge publishes the client address as `cf-connecting-ip` and overwrites
298
+ // any value a client sent under that name. `x-real-ip` is the usual nginx
299
+ // spelling and `x-forwarded-for` the multi-hop standard, kept for a
300
+ // self-hosted deployment behind either. First header PRESENT wins, so an empty
301
+ // leading header means "this request carries no forwarded address" rather than
302
+ // deferring to one further down the list that a client may have set.
303
+ var _trustedClientIp = b.requestHelpers.trustedClientIp({
304
+ trustedProxies: TRUSTED_PROXY_CIDRS,
305
+ forwardedHeaders: ["cf-connecting-ip", "x-real-ip", "x-forwarded-for"],
306
+ });
307
+
277
308
  /**
278
- * Resolve the real client IP for rate-limit keying. Reads the
279
- * Cloudflare-injected `cf-connecting-ip` first (the canonical single
280
- * client address behind the fabric), then `x-real-ip` (its mirror),
281
- * then falls back to the socket address via `b.requestHelpers.clientIp`
282
- * for direct connections (e2e harness, local dev). Always returns a
309
+ * Resolve the real client IP for rate-limit keying. Always returns a
283
310
  * non-empty string so two un-identifiable clients never collapse into
284
311
  * the same bucket as a real IP — request-shape reader, returns a
285
312
  * default, never throws.
@@ -292,15 +319,15 @@ var PUBLIC_ORIGINS = (process.env.SHOP_PUBLIC_ORIGINS || "https://blamejs.shop")
292
319
  * source on every request and mint unlimited buckets — walking every
293
320
  * per-IP limiter (and the captcha-IP budget) that keys off this value.
294
321
  * Keying on the /64 closes that while still distinguishing real end-sites.
322
+ *
323
+ * Resolution itself is `b.requestHelpers.trustedClientIp`, which reads the
324
+ * header family named below, folds an IPv4-mapped IPv6 peer, walks a multi-hop
325
+ * value right-to-left, and falls back to the socket address — one implementation
326
+ * shared with every other gate rather than a second, looser one here.
295
327
  */
296
328
  function clientKey(req) {
297
- var headers = (req && req.headers) || {};
298
- var cf = headers["cf-connecting-ip"];
299
- if (typeof cf === "string" && cf.length > 0) return _ipBucket(cf.trim());
300
- var real = headers["x-real-ip"];
301
- if (typeof real === "string" && real.length > 0) return _ipBucket(real.trim());
302
- var sock = b.requestHelpers.clientIp(req);
303
- return sock ? _ipBucket(sock) : "unknown";
329
+ var ip = _trustedClientIp.resolve(req);
330
+ return ip ? _ipBucket(ip) : "unknown";
304
331
  }
305
332
 
306
333
  // Collapse one resolved client IP to its per-IP bucket key. ipKey returns