@blamejs/blamejs-shop 0.5.19 → 0.5.21

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 (194) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/README.md +1 -0
  3. package/SECURITY.md +17 -0
  4. package/lib/addresses.js +2 -2
  5. package/lib/admin.js +49 -45
  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 +4 -20
  16. package/lib/cms-blocks.js +4 -7
  17. package/lib/compliance-export.js +4 -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 +25 -14
  31. package/lib/email-warmup.js +2 -7
  32. package/lib/experiments.js +6 -10
  33. package/lib/externaldb-d1.js +78 -44
  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 +3 -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/payment.js +103 -58
  54. package/lib/pixel-events.js +8 -23
  55. package/lib/plan-changes.js +2 -7
  56. package/lib/product-qa.js +4 -8
  57. package/lib/promo-banners.js +7 -15
  58. package/lib/purchase-orders.js +3 -6
  59. package/lib/push-notifications.js +7 -32
  60. package/lib/pwa-manifest.js +6 -11
  61. package/lib/quotes.js +5 -9
  62. package/lib/referrals.js +10 -6
  63. package/lib/reviews.js +4 -10
  64. package/lib/robots-config.js +6 -14
  65. package/lib/sales-tax-filings.js +2 -2
  66. package/lib/search-facets.js +5 -5
  67. package/lib/search-ranking.js +4 -7
  68. package/lib/search-synonyms.js +7 -21
  69. package/lib/security-middleware.js +125 -70
  70. package/lib/seller-signup.js +10 -26
  71. package/lib/sidebar-widgets.js +6 -15
  72. package/lib/site-redirects.js +49 -137
  73. package/lib/sitemap-generator.js +3 -7
  74. package/lib/sms-dispatcher.js +7 -23
  75. package/lib/stock-receipts.js +4 -10
  76. package/lib/storefront-dashboards.js +2 -10
  77. package/lib/storefront-forms.js +9 -22
  78. package/lib/storefront-pages.js +6 -20
  79. package/lib/storefront.js +21 -23
  80. package/lib/subscription-billing.js +3 -8
  81. package/lib/subscription-controls.js +2 -7
  82. package/lib/suggestion-box.js +9 -26
  83. package/lib/support-tickets.js +6 -24
  84. package/lib/tax-remittance.js +2 -2
  85. package/lib/tenants.js +2 -7
  86. package/lib/text-guard.js +137 -35
  87. package/lib/theme-assets.js +4 -16
  88. package/lib/translations.js +3 -6
  89. package/lib/trust-badges.js +4 -7
  90. package/lib/vendor/MANIFEST.json +107 -97
  91. package/lib/vendor/blamejs/CHANGELOG.md +180 -0
  92. package/lib/vendor/blamejs/NOTICE +14 -10
  93. package/lib/vendor/blamejs/README.md +17 -5
  94. package/lib/vendor/blamejs/index.js +2 -0
  95. package/lib/vendor/blamejs/lib/acme.js +9 -9
  96. package/lib/vendor/blamejs/lib/api-snapshot.js +35 -16
  97. package/lib/vendor/blamejs/lib/app-shutdown.js +1 -1
  98. package/lib/vendor/blamejs/lib/archive-adapters.js +1 -1
  99. package/lib/vendor/blamejs/lib/archive.js +13 -24
  100. package/lib/vendor/blamejs/lib/auth/access-lock.js +2 -1
  101. package/lib/vendor/blamejs/lib/auth/bot-challenge.js +3 -6
  102. package/lib/vendor/blamejs/lib/auth/ciba.js +32 -3
  103. package/lib/vendor/blamejs/lib/auth/oauth.js +65 -8
  104. package/lib/vendor/blamejs/lib/auth/password.js +1 -1
  105. package/lib/vendor/blamejs/lib/auth/saml.js +21 -3
  106. package/lib/vendor/blamejs/lib/auth-bot-challenge.js +3 -3
  107. package/lib/vendor/blamejs/lib/backup/index.js +5 -5
  108. package/lib/vendor/blamejs/lib/case-fold-classes.js +491 -0
  109. package/lib/vendor/blamejs/lib/codepoint-class.js +131 -0
  110. package/lib/vendor/blamejs/lib/constants.js +79 -0
  111. package/lib/vendor/blamejs/lib/db-declare-view.js +8 -2
  112. package/lib/vendor/blamejs/lib/error-page.js +20 -1
  113. package/lib/vendor/blamejs/lib/external-db.js +5 -1
  114. package/lib/vendor/blamejs/lib/flag-evaluation-context.js +24 -6
  115. package/lib/vendor/blamejs/lib/flag.js +28 -2
  116. package/lib/vendor/blamejs/lib/framework-schema.js +14 -3
  117. package/lib/vendor/blamejs/lib/guard-regex.js +2345 -89
  118. package/lib/vendor/blamejs/lib/http-client.js +217 -40
  119. package/lib/vendor/blamejs/lib/mail-bimi.js +1 -1
  120. package/lib/vendor/blamejs/lib/mail-crypto-pgp.js +3 -2
  121. package/lib/vendor/blamejs/lib/mail-dav.js +26 -26
  122. package/lib/vendor/blamejs/lib/mail-deploy.js +10 -10
  123. package/lib/vendor/blamejs/lib/mail-scan.js +3 -0
  124. package/lib/vendor/blamejs/lib/mail-store.js +7 -1
  125. package/lib/vendor/blamejs/lib/mail.js +5 -1
  126. package/lib/vendor/blamejs/lib/middleware/age-gate.js +3 -2
  127. package/lib/vendor/blamejs/lib/middleware/api-encrypt.js +1 -1
  128. package/lib/vendor/blamejs/lib/middleware/assetlinks.js +2 -1
  129. package/lib/vendor/blamejs/lib/middleware/asyncapi-serve.js +3 -2
  130. package/lib/vendor/blamejs/lib/middleware/bearer-auth.js +2 -1
  131. package/lib/vendor/blamejs/lib/middleware/compression.js +1 -1
  132. package/lib/vendor/blamejs/lib/middleware/cors.js +1 -1
  133. package/lib/vendor/blamejs/lib/middleware/csp-report.js +5 -5
  134. package/lib/vendor/blamejs/lib/middleware/csrf-protect.js +2 -1
  135. package/lib/vendor/blamejs/lib/middleware/daily-byte-quota.js +2 -2
  136. package/lib/vendor/blamejs/lib/middleware/deny-response.js +2 -1
  137. package/lib/vendor/blamejs/lib/middleware/dpop.js +2 -2
  138. package/lib/vendor/blamejs/lib/middleware/fetch-metadata.js +2 -1
  139. package/lib/vendor/blamejs/lib/middleware/flag-context.js +17 -0
  140. package/lib/vendor/blamejs/lib/middleware/idempotency-key.js +10 -8
  141. package/lib/vendor/blamejs/lib/middleware/openapi-serve.js +3 -2
  142. package/lib/vendor/blamejs/lib/middleware/require-aal.js +3 -2
  143. package/lib/vendor/blamejs/lib/middleware/require-auth.js +4 -3
  144. package/lib/vendor/blamejs/lib/middleware/require-bound-key.js +4 -3
  145. package/lib/vendor/blamejs/lib/middleware/require-content-type.js +3 -2
  146. package/lib/vendor/blamejs/lib/middleware/require-methods.js +3 -2
  147. package/lib/vendor/blamejs/lib/middleware/require-mtls.js +3 -2
  148. package/lib/vendor/blamejs/lib/middleware/security-txt.js +2 -1
  149. package/lib/vendor/blamejs/lib/middleware/web-app-manifest.js +2 -1
  150. package/lib/vendor/blamejs/lib/network-dns-resolver.js +1 -1
  151. package/lib/vendor/blamejs/lib/network-dns.js +3 -3
  152. package/lib/vendor/blamejs/lib/network-nts.js +35 -14
  153. package/lib/vendor/blamejs/lib/network-proxy.js +42 -4
  154. package/lib/vendor/blamejs/lib/network-smtp-policy.js +1 -1
  155. package/lib/vendor/blamejs/lib/network-tls.js +341 -11
  156. package/lib/vendor/blamejs/lib/nist-crosswalk.js +2 -2
  157. package/lib/vendor/blamejs/lib/ntp-check.js +30 -19
  158. package/lib/vendor/blamejs/lib/object-store/azure-blob.js +1 -1
  159. package/lib/vendor/blamejs/lib/object-store/gcs.js +1 -1
  160. package/lib/vendor/blamejs/lib/object-store/http-put.js +2 -1
  161. package/lib/vendor/blamejs/lib/object-store/sigv4-bucket-ops.js +5 -5
  162. package/lib/vendor/blamejs/lib/object-store/sigv4.js +2 -2
  163. package/lib/vendor/blamejs/lib/observability-otlp-exporter.js +4 -2
  164. package/lib/vendor/blamejs/lib/openapi.js +1 -1
  165. package/lib/vendor/blamejs/lib/pqc-agent.js +4 -7
  166. package/lib/vendor/blamejs/lib/problem-details.js +2 -1
  167. package/lib/vendor/blamejs/lib/redis-client.js +12 -1
  168. package/lib/vendor/blamejs/lib/regex-linear.js +1349 -0
  169. package/lib/vendor/blamejs/lib/render.js +531 -7
  170. package/lib/vendor/blamejs/lib/request-helpers.js +181 -5
  171. package/lib/vendor/blamejs/lib/router.js +37 -13
  172. package/lib/vendor/blamejs/lib/safe-async.js +142 -0
  173. package/lib/vendor/blamejs/lib/safe-icap.js +3 -0
  174. package/lib/vendor/blamejs/lib/safe-sql.js +15 -3
  175. package/lib/vendor/blamejs/lib/self-update.js +2 -2
  176. package/lib/vendor/blamejs/lib/testing.js +54 -7
  177. package/lib/vendor/blamejs/lib/validate-opts.js +56 -0
  178. package/lib/vendor/blamejs/lib/vendor/MANIFEST.json +62 -25
  179. package/lib/vendor/blamejs/lib/vendor/blamejs-pki.cjs +2004 -142
  180. package/lib/vendor/blamejs/lib/vendor/browser/noble-ciphers.mjs +727 -0
  181. package/lib/vendor/blamejs/lib/vendor/browser/noble-hashes.mjs +1070 -0
  182. package/lib/vendor/blamejs/lib/vendor/browser/noble-post-quantum.mjs +1090 -0
  183. package/lib/vendor/blamejs/lib/vendor/noble-post-quantum.cjs +510 -227
  184. package/lib/vendor/blamejs/lib/webhook-hmac.js +19 -2
  185. package/lib/vendor/blamejs/lib/webhook.js +1 -1
  186. package/lib/vendor/blamejs/lib/ws-client.js +24 -2
  187. package/lib/vendor/blamejs/package.json +1 -1
  188. package/lib/vendor/blamejs/sbom.cdx.json +6 -6
  189. package/lib/vendor-invoices.js +3 -6
  190. package/lib/vendors.js +5 -19
  191. package/lib/webhook-receiver.js +46 -83
  192. package/lib/webhook-subscriptions.js +3 -13
  193. package/lib/wishlist-sharing.js +5 -10
  194. package/package.json +1 -1
@@ -8,6 +8,186 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.18.x
10
10
 
11
+ - v0.18.21 (2026-08-10) — **An NTS or NTP query that cannot build its request left the datagram socket open — one leaked handle per failed query, for the life of the process.** `b.network.ntp.nts.querySingle` and `b.ntpCheck.querySingle` opened their UDP socket before building the request packet. Building it can fail — an AEAD suite the encoder does not recognise is the reachable case, and refusing it is the point — and the socket opened first had nobody left to close it once the exception left the Promise executor. The caller saw the right error either way, which is why this stayed invisible: nothing was wrong with the rejection, only with what it left behind. A scheduler polling a misconfigured time source accumulated one dead handle per attempt and never released any of them.
12
+
13
+ Both now build the request first and open the socket only once every step that can throw has succeeded, so there is nothing to leak rather than something each new failure path has to remember to undo. A synchronous refusal from `send()` routes through the same close-then-settle path.
14
+
15
+ The leak had been reachable since the primitives shipped, and was found by making the test suite's own end-of-run handle check fail closed instead of swallowing its timeout. **Changed:** *`b.apiSnapshot.compare` reports a function that gained a trailing parameter* — A parameter added to the end of a signature cannot break an existing caller, so it is not a breaking change and does not fail the gate. It was previously not reported at all — but the snapshot records each function's arity, so an unreported increase leaves the committed baseline stale while `check-api-snapshot` prints "no changes". A changed signature then reaches a release untracked, which is exactly what happened to `b.safeSql.quoteQualified` in 0.18.20.
16
+
17
+ It now lands in `additive`, the list that already means "not a failure, refresh the baseline", and `formatDiff` prints the old and new arity rather than a bare path. Removing a parameter is still breaking and still fails the gate. **Fixed:** *A time query that could not build its request leaked its UDP socket* — The socket was created at the top of the Promise executor, then the request was assembled: extension fields encoded, a nonce derived for the AEAD suite, the authenticator encrypted. Any of those can throw, and an unrecognised `aeadId` does — `nts/aead-unsupported` is a documented refusal. The throw left the executor, the promise rejected correctly, and the socket stayed open with no reference left to close it.
18
+
19
+ The cost is proportional to failures, not to traffic: a scheduler querying a server whose AEAD suite it does not support leaks a handle per poll, holds the event loop open, and eventually exhausts the process's descriptors. Nothing surfaces in the meantime, because every individual query behaves exactly as documented.
20
+
21
+ The fix is ordering rather than cleanup. Both `querySingle` implementations build the full request before opening a socket, so a build failure has no resource to strand; the acquisition happens only when it is certain to be used. A malformed destination that makes `send()` throw synchronously is routed through the same settle path that closes the socket, rather than escaping the executor.
22
+
23
+ The same reordering covers `b.ntpCheck.querySingle`, where the request nonce is drawn from the system entropy source before the socket is opened. **Detectors:** *one owner for the end-of-run open-handle drain* — The check that a test file closes everything it opened had been copied into 31 files. Twenty-nine differed only in a label; the other two differed in substance, and both differences mattered. One also waited for datagram sockets and swallowed its own timeout in a `catch`, which is what hid the leak above. One also waited for in-flight file reads, so consolidating on the majority shape would have quietly narrowed what the static-file suite checks.
24
+
25
+ `helpers.drainOpenHandles(label)` now owns it, and its resource list is the union rather than whichever type a given file happened to need: a datagram socket and a pending file read hold the event loop open exactly as a stream socket does. One ceiling, set as a verdict on a handle that never closes rather than as a guess at how long closing takes. A failure names the surviving handles with their addresses and states, or explains why none is reachable — a pending file request never appears in the process handle list, and a socket that does not appear there is already detached and mid-close. A detector fires on a re-hand-rolled drain.
26
+
27
+ - v0.18.20 (2026-08-10) — **`b.render.stream` no longer commits the response before it knows the producer can produce — an export whose query fails now reaches the error page it used to bypass — and the vendored crypto primitives ship browser builds, so a product's client half stops being a second vendoring path for the same packages.** Two reports against `b.render.stream` from a consumer that adopted it, and both are about the moment the status line goes out.
28
+
29
+ Opening an async generator runs none of its body, so "the source opened" said nothing about whether the producer could produce. A query that failed before its first row therefore failed AFTER the headers were sent, and an export attempted while the database was down arrived as a download that died partway instead of a page saying so. The first value is now fetched while the status line is still unsent. Separately, the primitive's default Content-Type replaced one the route had already set with `res.setHeader` — silently, and only for the names the defaults happen to carry, so a Content-Disposition survived while the Content-Type beside it did not. A default now steps aside for a header the response already carries, across every `b.render` entry point.
30
+
31
+ The vendored crypto bundles gain browser (ESM) builds: ML-KEM for the client half of a hybrid exchange, XChaCha20-Poly1305 for the payload envelope, and SHAKE256 / HKDF as a KDF — the last from `@noble/hashes`, which is now vendored in its own right rather than only inlined inside two other bundles. Each is built from the same npm install as its server counterpart in one refresh step, carries the same per-file hash and SBOM entry, and is covered by the vendor-currency gate. **Added:** *Browser (ESM) builds of the vendored crypto primitives* — A product with a browser surface could not use any of the vendored crypto: a browser cannot import a `.cjs` bundle, and there was no browser build in the tree. So the consumer ran a second vendoring path for the SAME upstream packages — bundling them again, tracking those versions again, carrying them in their own SBOM. Two copies of one dependency with two currency stories, drifting independently.
32
+
33
+ `lib/vendor/browser/` now carries ESM builds a consumer can serve directly: `noble-post-quantum.mjs` (ML-KEM-512 / 768 / 1024, for the client half of a hybrid exchange), `noble-ciphers.mjs` (XChaCha20-Poly1305, for the payload envelope) and `noble-hashes.mjs` (SHAKE256, SHA-3, SHA-2, HMAC, HKDF, as a KDF).
34
+
35
+ The bundling is one esbuild line and is not the point. The point is that each browser artifact is pinned, per-file SHA-256 hashed, currency-checked and SBOM-listed by the machinery its server counterpart already goes through — and that both halves are built from one npm install in a single refresh step, with one `version` field in the manifest covering both, so they cannot be pinned to different releases of the same algorithm.
36
+
37
+ The browser PQC build carries the KEM suites only: a client half encapsulates and does not sign, and the FIPS 204 / 205 signature suites beside them are 60 KB a browser would download and never call. There is no browser build of the server-side framework surface, and none of `@noble/curves`, whose consumers here are server-side. · *`@noble/hashes` is vendored in its own right* — It was declared only as a component of two other bundles — inlined by esbuild into `noble-curves.cjs` and `noble-post-quantum.cjs`, listed in the SBOM, and outside the currency gate, which walks top-level packages. A new release of it could therefore not trip that gate directly.
38
+
39
+ It is now a package of its own, so it is version-pinned, hashed and currency-checked like everything else. Browser build only: the server side reaches SHAKE256, SHA-3, HMAC and HKDF through `node:crypto`, so a `.cjs` here would be a file nothing requires. The version is the one the two parent bundles already inline, which means the hash a browser runs is the hash inside the server's PQC bundle. **Changed:** *`b.render.stream` ends a response the ROUTE committed, instead of leaving it open* — A route may send its own status line and stream into it — the documented shape, and how server-sent events are written. For such a response there is no pre-commit window to fail into: the headers are on the wire, and the only honest ending is an incomplete transfer. Every failure before the loop now goes through one exit that says so, covering a producer that cannot open at all — which held the socket, the handler and the file descriptor for the life of the process on every failed export, and did so before this release too. · *`b.safeSql.quoteQualified` takes the same opts as `quoteIdentifier`* — `quoteIdentifier` refuses a SQL reserved word by default and the query builder opts out, because quoting is exactly what makes a keyword-named identifier safe in identifier position. `quoteQualified` had no way to opt out at all, so it was the stricter of the two siblings: `b.declareView` accepted a view or schema named after a keyword at its front door and then refused it while building the statement. It now forwards `opts` to every segment.
40
+
41
+ The remaining call sites are brought into line, and a gate holds the whole class across both functions: a name written as a string literal — or an array of them — is the framework's own and needs nothing, a name built by appending a literal suffix cannot be a bare keyword either, and every other spelling must pass `{ allowReserved: true }`. It reads whole calls rather than single lines, so an argument that wraps is still seen and an `allowReserved` belonging to a neighbouring call does not vouch for it. It is a parity rule rather than a relaxation — identifier SHAPE is still enforced, so a quote, a semicolon or a null byte is refused either way. **Fixed:** *b.render.stream committed the response before it knew whether the producer could produce* — The source was opened before anything was committed, which covered a factory that throws and an object whose `Symbol.asyncIterator` throws. Both are configuration errors and both are deterministic. The one it missed was the operational failure — the query behind the cursor — because opening an async generator runs none of its body: the generator's first line does not execute until the first `next()`, so a producer certain to fail immediately opened cleanly, the status line went out, and the failure landed on the wrong side of the commit.
42
+
43
+ What an operator saw was the difference. An export attempted while the database was unavailable used to give a page saying so; it gave a download that died partway instead, and the information was gone rather than delayed.
44
+
45
+ The first value is now fetched inside the pre-commit window, so a producer that fails before yielding anything reaches the caller with `res.headersSent` still false and a response it can still render an error page on. One row is therefore read before the headers go out, which is what any implementation must do to know whether the producer can produce at all. A failure after the first row is unchanged and still a truncation: those bytes are on the wire and cannot be taken back. The iterator-protocol check moved with it, so a producer that answers wrongly from the very first step is a bad argument rather than a committed response that then has to be destroyed. · *b.render.stream replaced a Content-Type the route had already set* — `writeHead(status, headers)` merges with what `setHeader` recorded, but its own object wins on a name they share. So a route that said `res.setHeader("content-type", "text/csv; charset=utf-8")` and then called `b.render.stream` shipped `application/octet-stream`, while the `Content-Disposition` it set on the line above survived — because that name is not among the defaults. A partial failure, which is what made it hard to spot: the download still arrived with the right filename and the wrong type.
46
+
47
+ That default now steps aside for a Content-Type the response already carries. Only that one, and only in `stream`, because of what the header means in each place. `json`, `text` and `htmlString` encode the body themselves, so their Content-Type describes the bytes they just produced — inheriting an earlier one is how a JSON error body comes to be served as `text/html`, and a reflected value in it becomes markup the browser runs. `stream` does not know what its bytes are: `application/octet-stream` is a placeholder for an answer the caller has. `Cache-Control` never steps aside either — it is a security default rather than a formatting one, and one any earlier `setHeader` in the chain could relax would not be a default. `opts.headers` remains the way to say otherwise, at the call. **Detectors:** *quoted identifiers answer to the same reserved-word rule as b.db.from()* — Reads the first argument of every `quoteIdentifier` call in `lib/` as written, to the comma that ends it at depth zero. A string literal or a literal-suffixed concatenation passes; anything else must carry `allowReserved`. Proven by reintroducing the shape on a call site that had been fixed, which it named, and by running silently on the tree with no allowlist.
48
+
49
+ - v0.18.19 (2026-08-09) — **An operator's regex can now be RUN in time proportional to the subject rather than screened and hoped for — `b.regexLinear` matches without backtracking, so the patterns that hang the platform engine for a minute finish in under a millisecond.** Matching an operator-supplied pattern against request data has been guarded so far by screening the pattern: decide whether it LOOKS dangerous, and refuse it if so. That is a conservative approximation of what a backtracking engine will do, and it fails in both directions — it turns away patterns that were fine, and it cannot promise to catch every shape that is not. Every shape it misses is another case to add, and the cases do not run out.
50
+
51
+ `b.regexLinear.compile` removes the question. It simulates every path through the pattern at once, one character of the subject at a time, so no input makes the work grow faster than the subject times the pattern. `(a+)+$` on thirty characters takes the platform engine the better part of a minute; here it is under a millisecond on two thousand. Results are identical to `RegExp` — a corpus and a seeded generator check that character for character on every run — and the three things that cannot be simulated in linear time are refused by name at compile time rather than handed to the engine that can be made to hang.
52
+
53
+ `b.guardRegex` keeps a narrower job: screening patterns the platform engine must match, such as one handed to a library or to `String.prototype.replace`. Its own proofs got better in this release too — the published semver.org and WHATWG email patterns are no longer refused — and it now reports a cost it had never modelled, an unanchored pattern retried at every position in the subject. **Added:** *b.regexLinear runs an operator's pattern in time proportional to the subject, whatever the pattern is* — A pattern from an operator's configuration, matched against a request, is a denial of service with no payload: `(a+)+$` against forty `a`s and a `!` takes longer than the request that carried it, and the pattern that does it is a few characters long.
54
+
55
+ The answer so far was to screen the pattern — decide whether it LOOKS dangerous and refuse it if so. A screen has two failure modes and this one had both: it turned away patterns that were fine, and it could not promise to catch every shape that was not. Each shape it missed was another case to add, and the cases do not run out.
56
+
57
+ `b.regexLinear.compile(source, flags?)` runs the pattern instead. It simulates every path at once, one character of the subject at a time, keeping at most one live position per instruction — so a path that dies costs nothing to abandon, and no input makes the work grow faster than the length of the subject times the size of the pattern. `(a+)+$`, `(a|a)*$` and `([a-zA-Z]+)*$` each take upwards of fifty seconds on thirty characters through the platform engine; through this they are done in under a millisecond on two thousand, and a 64 KiB subject scans in single-digit milliseconds. There is nothing to tune, no budget to exhaust and no shape to special-case.
58
+
59
+ The matcher returns what the platform returns: `test(subject)`, and `exec(subject, from?)` giving the whole match at index 0, the groups after it, `index`, and named groups under `groups`. That equivalence is not asserted, it is tested — a fixed corpus and a seeded generator compare every result character for character against `RegExp`, tens of thousands of comparisons per run, and the corpus ships with the code.
60
+
61
+ What it will not do is guess. Backreferences and lookaround cannot be simulated this way, and neither can a repetition whose body can match nothing — `(a*)*`, which means what `a*` means. Each is refused at compile time under its own code, naming the construct and what to do instead, rather than being handed quietly to the engine that can be made to hang. A pattern the platform itself refuses is refused here too, so a configuration mistake cannot pass unnoticed by taking the other route.
62
+
63
+ Case-insensitive matching follows the language rather than a pair of case conversions, which get it wrong in both directions. A non-ASCII character does not fold onto an ASCII one, so `/k/i` does not match a Kelvin sign; under `u` the rule changes to case folding, so `/s/iu` does match a long s. The classes that no amount of upper- and lower-casing connects — a final sigma and an ordinary one, a micro sign and a Greek mu, the title-case digraphs — come from a table that `scripts/gen-case-fold-classes.js` DERIVES from the running Node's own case mappings rather than transcribing from a Unicode revision, and a release gate fails if the committed table stops matching the platform. Every ordered pair in every multi-member fold class, both modes, agrees with `RegExp`.
64
+
65
+ `b.guardRegex` keeps its job, which is now a narrower and more honest one: screening is for patterns the platform engine must match — one handed to a library, to `String.prototype.replace`, to anything that takes a `RegExp`. Where the framework does the matching, run it and stop screening. · *C.HTTP names every status the RFCs define, and answers which class one belongs to* — A status written as a number is a number the reader has to recognise. `C.HTTP.STATUS` names all of them — every code RFC 9110 defines, plus the registered ones from the RFCs that extend it (WebDAV's 207/208/422-424, early hints, 425, 428/429/431, 451, 511) — so `C.HTTP.STATUS.PRECONDITION_FAILED` says what `412` only implies.
66
+
67
+ The predicates carry the reasoning the numbers do not: `C.HTTP.success`, `.redirect`, `.clientError`, `.serverError`, and `.informational`. And `C.HTTP.bodiless(status)` answers the one that is easy to get wrong — every 1xx, 204, 205 and 304 carry no body, so signalling a truncation by destroying one throws away a response that was already complete. A 205 used to be destroyed for want of being on that list.
68
+
69
+ Every status inside the framework now goes through it, and a check keeps new ones from slipping back to numbers. Two places deliberately keep the literal: an ICAP status line (RFC 3507) has its own 200, 204 and 403 that merely look like HTTP's, and naming those through `C.HTTP` would assert something untrue about the wire. One retry rule had been routing a 408 through `C.BYTES.bytes()` to get a status past a check that only understood byte counts; naming it where it belongs removed both the copy and the reason for the disguise. · *b.render.stream writes an async iterable to a response, with back-pressure and an honest failure* — There was no primitive for streaming a generated download — a CSV export, an NDJSON dump, a receipt — where the source is a generator over a cursor rather than a `Readable` that could be piped. Every consumer wrote the same loop, and the obvious version is wrong in three ways that testing does not surface.
70
+
71
+ `res.write()` returning false is easy to discard, and a local client drains instantly, so a deliberately bounded-memory export becomes unbounded only under a slow client. Always awaiting `'drain'` then hangs the request forever when the peer has gone, because a closed socket never emits it — and without a disconnect check the loop keeps pulling rows from the database to write them nowhere. `b.render.stream(res, iterable, opts?)` awaits back-pressure, stops when the peer closes, and ends the response when the producer is done.
72
+
73
+ The third is the one worth fixing centrally, and it is fixed for every route rather than only for streaming ones — see the Fixed entry below.
74
+
75
+ Stopping the loop is not the same as stopping the producer. Calling `return()` on an async generator queues behind the pull it is trying to cancel, so a generator parked in `await query()` does not reach its `finally` — and the cursor, connection or file handle it holds stays open — until that query finishes on its own, long after the client has gone. Pass a function in place of the iterable and it is called with a signal that aborts when the client disconnects, the caller aborts, or the stream fails, so a producer can cancel work already in flight.
76
+
77
+ The drain-aware write is now `b.safeAsync.writeChunk(writable, chunk)`, which is what the archive writer had been carrying privately. One implementation, so the closed-peer case — the half that hangs a request rather than failing it — cannot be got right in one place and wrong in the other.
78
+
79
+ `b.testing.streamingRes` takes a `highWaterMark`. Its `write()` always returned true, which is the one thing a real socket does not do, so a consumer that discards the return value looked correct against the double and failed against a slow client. Past the mark it now reports full and emits `drain` on the next tick. · *guardRegex reports the cost of retrying an unanchored pattern at every position* — Every analysis in the screen reasons about one match attempt: whether a repetition's parts compete for the same characters, whether the boundary between two terms can float, how many ways a body can match. None of it says anything about how many attempts there are.
80
+
81
+ A pattern that is not anchored at the start is retried at every position in the subject. That costs nothing when an attempt fails at once — a leading literal is checked and rejected in constant time — but when the pattern can consume an unbounded amount BEFORE reaching something that must match, each attempt walks the rest of the input before discovering the failure. `/(\w+)\s+(\d+)/` takes 436 ms against 40,000 characters that do not match, and `/([^;]+);\s*q=([0-9.]+)/` — an Accept-header parse — 584 ms. Neither is ambiguous on a single attempt, so nothing in the screen saw them. The same bodies anchored are flat.
82
+
83
+ The rule reads the whole pattern, not its opening. A choice runs away wherever it sits — `a(?:x|a+b)` enters the expensive branch from every starting position exactly as `(?:x|a+b)` does. An assertion consumes nothing, which is not the same as costing nothing: `(?=a+b)` re-runs its body at every position and `(?!a+)` refuses at every position, each having walked what is left. A lookbehind is read in the direction it is matched, which is what separates `(?<=a+b)` from `(?<=ba+)`. And `^` is judged by the flags in force where it stands, so a scoped `(?m: ... )` re-opens what the pattern's own flags had closed.
84
+
85
+ It is equally careful about what it does NOT report, because a screen that refuses working patterns is one operators route around. A scan reached from only a bounded number of positions is not quadratic: `\.[a-f0-9]{8,}\.`, `x(?=a+b)`, `(?=x)(?=a+b)` and `(?!a)(?=a+b)` are all accepted. So is one an anchor in front of it holds — `$(?=a+b)` runs once, and `\b\w+\s+\d+` and `/^a+b/m` fire often but cannot scan past the separator that creates the firings, so the two trade off exactly. `\b.*z` and `/^[^\n]*z/m` can read straight past it and stay refused. Every verdict either way was checked against measured run times rather than reasoned about.
86
+
87
+ The finding is `regex.unanchored-scan`, under `unanchoredScanPolicy`: reject at strict, audit at balanced, allow at permissive. It is a rule of its own because the remedies are different from the backtracking classes — anchor the pattern, compile it sticky, or bound the subject length — and an operator who bounds the subject can turn it off without giving up anything else. A pattern whose first element must match, such as `/\.[a-f0-9]{8,}\./`, is unaffected: an attempt that fails immediately costs nothing to repeat. **Changed:** *b.guardRegex.assertSafe documents what it can and cannot tell you* — The docstring described the primitive as the guard for request-lifecycle code matching an operator-supplied regex against attacker-controlled input, which implied a completeness the analysis does not have. It now separates the two costs it reaches — what one match attempt costs, and how many attempts there are — says plainly that the first is conservative rather than a decision procedure, so a pattern that is in fact linear can still be refused and usually needs a small rewrite, and states that neither answers how long a particular match will take on a particular input. Where the subject is attacker-controlled, cap its length as well as screening the pattern. **Fixed:** *A failure after the first byte was dressed up as a successful, truncated response — and in one path it killed the server* — Once the status line and headers are on the wire they cannot be replaced. Four places tried anyway, each guarding on `res.writableEnded`, which is still false part-way through a response.
88
+
89
+ The error handler called `writeHead(500)`, that threw `ERR_HTTP_HEADERS_SENT`, and the catch fell through to `res.end("Internal Server Error")`. What the client received was a 200, the content type it asked for, and however many rows had been written followed by those three words. A spreadsheet or an ETL job ingests that as a complete, successful export whose final row happens to read oddly. Silent truncation presented as success is a data-integrity failure, not a cosmetic one.
90
+
91
+ The router's own fallback was worse. With no error handler registered — the default — a route that threw after writing its first byte reached `writeHead` inside a promise rejection handler, where the `ERR_HTTP_HEADERS_SENT` had nothing to catch it. The unhandled rejection ended the process, taking every other in-flight request with it. One failing database cursor on one streaming route was enough.
92
+
93
+ How to signal failure once the headers are out is now one primitive, `b.requestHelpers.failAfterHeaders`, because the answer depends on the protocol and getting any of them wrong reproduces the original bug. On HTTP/1.1 the socket is destroyed, so a chunked response ends without its terminating chunk and the client reports a failed download. On HTTP/2 — which `router.listen` serves natively — a bare destroy closes the stream with `NO_ERROR`, which a client reads as a clean end, so the stream is closed with `INTERNAL_ERROR` instead and the failure survives the protocol. A response that cannot carry a body — HEAD, 204, 304 — is already complete once its headers are sent, so it is ended normally rather than destroyed, which would have thrown away a valid response; a cache revalidation that failed in a metrics hook used to return zero bytes.
94
+
95
+ The RFC 9457 problem-details branch had the same root with a worse outcome: it called `setHeader` on a sent response, threw, and the catch swallowed the error without ending or destroying — a pinned socket and a client waiting forever. It goes through the same primitive now. · *An abort mid-stream ended the response as though the export were complete* — `b.render.stream` accepts a `signal` to stop producing. Breaking out of the loop then fell through to `res.end()`, which writes the terminating chunk: four rows of a fifty-row export arrived as a well-formed, fully terminated 200, and the caller was told nothing. That is the same truncation-as-success the primitive exists to prevent, reached through the one option offered for stopping early. An abort now ends the transfer the same way a producer failure does.
96
+
97
+ The signal was also only consulted between chunks, so it could not interrupt the two states where a stream actually gets stuck: a producer blocked on a query that never returns, and a wait for `drain` from a peer that stopped reading. Both pinned the request until an unrelated socket timeout. The signal now settles the producer and the write alike. · *The drain-aware write hung when the destination had already closed, and reported its error code as prose* — `'close'` is one-shot. If it had already fired before the write was attempted, the listener never ran; `write()` on a destroyed stream returns false without re-emitting `'error'`; and `'drain'` never comes. Nothing settled the promise. `b.archive.zip().toStream(res)` against a client that had already hung up pinned the handler, the entry list and every source stream for the life of the process. The state is now checked before any listener is attached.
98
+
99
+ The archive writer also kept two private drain-waits of its own for stream-backed entries, listening only for `'drain'` — so a destination destroyed mid-entry left the pipeline unsettled and a listener attached to a dead stream, while the buffer-entry path beside it failed correctly. Both go through the shared writer now.
100
+
101
+ Separately, the two errors it raises had their code and message the wrong way round, so `err.code` held the English sentence. Consumers branch on `e.code`, and every one of those comparisons would have failed. · *guardRegex refused patterns operators write, including the published semver and WHATWG email regexes* — Three proofs were narrower than the shapes they exist to recognise. All three refusals were `regex.nested-quantifier` at strict, balanced AND permissive, so there was no configuration in which the patterns worked, and `validate` / `sanitize` / `gate` / `assertSafe` all agreed.
102
+
103
+ **A separator had to be one atom occurring exactly once.** What makes a repeated group's boundaries fixed is that every repetition contains something none of its other parts can match — but the check demanded that be a single term appearing once, so `^[a-z]+(?:\s+[a-z]+)*$` was refused while `^[a-z]+(?:\s[a-z]+)*$` passed, and `(?:::[a-z]+)*` was refused while `(?::[a-z]+)*` passed. A separator is now a RUN at either end of the body, grown while it still shares characters with the rest, so two colons are read as one separator and a repeating one as the whole run it consumes. It still has to be able to swallow a complete run of its own characters — `\d{1,3}` caps itself at three, so a run of six digits divides among repetitions several ways and it is not a separator.
104
+
105
+ **A body could carry only one part that varies.** The rule counted varying parts rather than asking whether they can take each other's characters. `^[a-z]+(?:,\s*[a-z]+)*$` has two and they are disjoint, pinned apart by a mandatory comma; `(?:a*a*-)*` has two that are not, and it is exponential. Both were refused. The count is replaced by the question it stood in for, so a comma-space list, a query-string pair `(?:&[a-z]+=[0-9]+)*` and a Cache-Control style list are accepted while the overlapping shapes stay refused.
106
+
107
+ **Alternation branches had to differ in their first character.** Semver's numeric identifier is `0 | [1-9]\d* | \d*[a-zA-Z-][0-9a-zA-Z-]*` — all three branches can begin with a digit, so no first character decides between them, yet the third REQUIRES a letter or hyphen that neither of the others can produce and the first two are all digits. Branches are now also proven apart when one must contain a character another can never match: no string is in both, so the input decides the branch rather than the engine guessing and backtracking.
108
+
109
+ With those three, the semver.org suggested validation regex and the WHATWG `<input type=email>` pattern are both accepted — the two patterns most likely to be copied verbatim into a form field or a JSON-Schema `pattern`. So are CSS descendant selectors, space-separated token lists, namespace paths, breadcrumbs and hosts-file lines. Every shape the screen refuses for genuine ambiguity is unchanged, including the control that separates the two comma cases: `(?:&[a-z]+[a-z0-9]+)*`, whose two varying parts overlap, stays refused and is exponential. · *A boundary is pinned by the first thing after it that must match* — Deciding whether the split between two terms can float compared the earlier term against EVERY later one, so a pattern was only pinned when nothing downstream could match what the earlier term matches. An email local part is followed immediately by a mandatory `@`, which the local-part class cannot match — the run has to stop exactly there, whatever appears after the `@`. The first term that must match is now treated as a wall: everything before it has to be disjoint from the earlier term, and nothing past it can reach back across. **Security:** *The vendored PKI bundle moves to 0.4.13, binding a CMS content cipher to the container that declares it* — An `EnvelopedData` whose algorithm identifier had been changed to a same-key-length AES-GCM identifier decrypted successfully as unauthenticated AES-CBC, while reporting the AEAD algorithm in its result — so a caller reading `contentEncryptionAlgorithm` to establish that the content was authenticated was answered from a field the decryption had not honoured. The container and the cipher's mode are now checked against each other before the content-encryption key is used, and a mismatch is refused as an unsupported algorithm naming both.
110
+
111
+ The same bundle wipes an ML-KEM shared secret and the key-encryption key derived from it as soon as they stop being needed, on the failing path as well as the succeeding one — the path an attacker chooses — and performs the FIPS 203 ciphertext-length check in the crypto engine, so a caller reaching decapsulation directly is covered rather than only the CMS path. Operators upgrading get all of this by upgrading; nothing in the framework's own API changes.
112
+
113
+ - v0.18.18 (2026-08-08) — **An operator's own TLS server never applied the group preference the framework told it to apply, an anonymous caller could choose their own feature-flag bucket, and a deployment behind Cloudflare or nginx could not use the peer-gated client-IP resolver at all.** b.network.tls.applyToContext is the documented way to put the framework's TLS posture onto your own https.Server or https.Agent. It emitted the configured key shares under a key node:tls does not implement, which that library accepts and ignores, so every server and agent built through it negotiated on Node's defaults and preferredGroups.set(...) changed nothing for them. Two other places carried the same key alongside the working one. All three now emit only the name the TLS layer reads, and an override that is present but unusable is refused rather than quietly replaced with the framework's own. Separately, a handshake the outbound posture refuses arrived as a bare OpenSSL alert naming neither the peer nor the reason, and the reason was routinely misread; both alerts are now explained in place.
114
+
115
+ Three primitives stopped turning away configurations that are safe. b.requestHelpers.trustedClientIp gated X-Forwarded-For and nothing else, so a deployment behind Cloudflare or nginx could not use it without giving up the peer gate it exists for; it now reads a header family the operator names. b.auth.oauth.create() takes the HTTP client its dials are made through and the hosts they may reach, so a consumer can keep the token exchange inside the circuit breaker and SSRF pin that cover their other calls to the same upstream. b.guardRegex stopped refusing two patterns that provably cannot backtrack.
116
+
117
+ One security fix: the anonymous feature-flag targeting key was derived from a raw X-Forwarded-For read with no peer check, so an unauthenticated caller could resend with different values until a rollout served them the variant they wanted. **Added:** *b.webhookHmac.verify accepts an injectable clock* — The replay window was measured against wall time with no way to supply the time, which shut out any consumer that owns its own clock: a receiver built around an injected time source has its window check and this one disagree the moment that clock is not now, so a delivery signed at the injected time is refused however the tolerance is configured. It also blocks replaying a captured delivery for forensics, and any deterministic simulation. Such consumers were left rebuilding the scheme — header parse, replay window, key-rotation multi-signature, constant-time compare — around b.crypto.hmac by hand, and not inheriting the rotation support.
118
+
119
+ `verify()` now takes an optional `now` in epoch milliseconds, the same shape b.cwt.verify already accepts, defaulting to Date.now(). The window is still enforced, against the supplied clock rather than skipped — an escape hatch that turned the check off would hand the replay window back to the caller and fail silently when forgotten, where an omitted clock here falls back to wall time and fails closed. A non-integer, non-positive or non-finite value is refused at construction with `webhook-hmac/bad-now`. · *trustedClientIp reads a configurable forwarded-header family* — b.requestHelpers.trustedClientIp gated `X-Forwarded-For` and nothing else, which shut out two of the three most common reverse proxies. Cloudflare publishes the client address as `CF-Connecting-IP`; the usual nginx recipe (`proxy_set_header X-Real-IP $remote_addr`) publishes `X-Real-IP`. A deployment behind either had three options and all of them were worse than the primitive: read the header directly and lose the peer gate entirely, pass `clientIpResolver` and hand back the whole trust decision — the CIDR matching, the IPv4-mapped-IPv6 folding — while `peerGated` still reported true, or rewrite the header into `X-Forwarded-For` in a shim ahead of the gate.
120
+
121
+ `forwardedHeaders` names the family in preference order, and the first header PRESENT on the request is used — present, not non-empty, so a first-listed header that arrives empty says this request carries no forwarded address rather than deferring to one the client may have set: `trustedClientIp({ trustedProxies: ["10.0.0.0/8"], forwardedHeaders: ["cf-connecting-ip", "x-forwarded-for"] })`. Names are matched case-insensitively, since a name written in the vendor's documented casing has to fold to the lowercase form node delivers. Every listed header is parsed the same way, so a single-address header is simply a one-hop chain and the right-to-left walk over a multi-hop value is unchanged. The peer gate, the fail-closed default and `peerGated` are untouched, and the default stays `["x-forwarded-for"]`, so no existing caller changes behaviour. `b.requestHelpers.clientIp` takes the same option, so a caller already driving it directly is not pushed up to the resolver to get the family.
122
+
123
+ List only headers your proxy sets or overwrites on every request. The peer gate proves a request arrived through your proxy; it cannot prove your proxy authored the header, so a proxy that passes an unknown header through unchanged lets a client inject one. Named vendor profiles were considered and left out: of the headers surveyed, several are documented by their own vendor as appended rather than replaced, or as honoured when the client sets them, and shipping those as a preset would state a guarantee the vendor does not. The explicit list expresses any of them in one line. · *b.auth.oauth.create() takes the HTTP client to dial with, and the hosts it may reach* — The token exchange reached for b.httpClient directly. `opts.httpClient` is options merged into each request, not a client to make the request through, so a consumer that already routes its outbound calls through one instrumented client could not route this one: adopting `clientCredentialsManager` meant giving up whatever host pin that client enforced and moving the token endpoint outside the circuit breaker protecting every other dial to the same upstream — so the endpoint whose failure should open the circuit became the one endpoint the circuit could not see. There was no `allowedHosts` anywhere in the module either, so a mangled or attacker-influenced base URL had only the SSRF gate between it and an egress.
124
+
125
+ `create({ http: { client, allowedHosts } })` supplies both. `client` is anything exposing `request(opts)` and defaults to b.httpClient. `allowedHosts` is enforced whatever client is supplied: b.httpClient applies it directly, and an injected client is wrapped in b.httpClient.pinnedClient so the pin is checked before the dial reaches it. Both default to the previous behaviour. The pin covers discovery and JWKS as well as the token endpoint — a mangled issuer is read at discovery, so pinning only the token dial would leave the egress open where it starts — and it is applied after the `httpClient` options bag, so a stray entry there cannot widen it.
126
+
127
+ The same option lands on b.auth.ciba.client.create, which shares its client and pin with the OAuth client it composes rather than splitting a CIBA flow across two, and on b.auth.saml.fetchMdq. b.auth.openidFederation already accepts a `fetcher` override for the same purpose. The JWKS fetch in b.auth.jwtExternal still uses the framework client; its URI is operator-configured and TLS-pinned, and it gains the seam when a consumer needs the exchange inside their own client.
128
+
129
+ What b.httpClient enforced on these dials' behalf is now enforced by the primitives themselves, since a supplied client promises none of it. The OAuth endpoints — whether they came from static configuration or from a discovery document — are held to the same TLS requirement at the last point before the wire, so a client that does not check schemes cannot carry a client secret, an authorization code or an access token in cleartext while `allowHttp` is unset. The SAML metadata fetch is held to HTTPS the same way, which matters most when `trustCertPem` is omitted and there is no signature to check: over cleartext, anyone on the path could substitute the federation's signing keys. · *b.httpClient.pinnedClient holds a supplied client to what the framework client enforced* — `b.httpClient.request` enforces `allowedHosts` itself, but the contract for a client supplied to a framework primitive is a `request` method and nothing more. Such a client need not know what the framework's `allowedHosts` request property means, so handing the pin over as a request field would leave it advisory: a client that ignored the field would dial a disallowed host while the operator believed the pin was in force.
130
+
131
+ `b.httpClient.pinnedClient(client, allowedHosts)` returns a wrapper that checks the destination itself — with the same membership test `request()` applies, so an entry means the same thing either way, including `.suffix.example` and `*.suffix.example` forms — and refuses before delegating. A destination that cannot be parsed is refused rather than passed through unchecked, and an absent pin still returns a wrapper, since the redirect and egress controls do not depend on one being named. The primitives that accept an injected client route it through here, so `http.allowedHosts` on b.auth.oauth, b.auth.ciba and b.auth.saml.fetchMdq holds whatever client is supplied.
132
+
133
+ Redirects are the pin's blind spot: an allowed host can answer with a redirect to a disallowed or private one, and a client that follows it internally fetches that without the pin ever seeing it. The framework's own client re-checks every hop, so the wrapper asks for redirect following to be off on each request it forwards — `maxRedirects: 0`, `followRedirects: false` and `redirect: "manual"` — and a redirect comes back as an ordinary 3xx for the caller to act on. A client that follows redirects regardless of all three must apply the pin per hop itself; that is outside what a wrapper around someone else's transport can enforce.
134
+
135
+ The egress gate is enforced on the same footing. A cloud-metadata address is refused unconditionally, an IP-literal destination in a private, loopback, link-local or reserved range is refused, and so is a loopback NAME — the whole `localhost` TLD resolves there, so checking only literals would let `localhost` past a gate that refuses `127.0.0.1`. Both documented forms of `allowInternal` waive it: the blanket `true`, and the CIDR array, matched the way b.ssrfGuard matches it. The resolve-and-pin half is deliberately not attempted: a name checked here would be resolved again by the client, so the DNS-rebinding window belongs to whichever transport actually connects and only that transport can close it — the same reasoning b.httpClient applies when a proxy resolves on its behalf. **Changed:** *Test files carrying NUL-byte fixtures have their line endings pinned* — git classifies a file containing a NUL byte as binary and skips line-ending normalization for it, so a text-mode write on Windows commits CRLF verbatim and rewrites the whole file. Three test files were carrying that churn. They are pinned to LF in .gitattributes and renormalized. No shipped file is affected — the published package excludes test/. · *The fuzzing base image moves to the current oss-fuzz base-builder* — gcr.io/oss-fuzz-base/base-builder-javascript advances to sha256:2e218ded. The change upstream is confined to one file in the build indexer: four type-checker suppressions removed, and archive creation now follows symlinks. No layer, entrypoint, environment or file was added. · *The vendored post-quantum primitives refresh to 0.7.0* — @noble/post-quantum, which provides ML-KEM, ML-DSA and SLH-DSA, moves to 0.7.0. The release is an arithmetic and allocation rework — polynomial add and subtract correct by one conditional instead of a general modular reduction, an intermediate product is reduced before it can leave the integer range JavaScript engines handle quickly, and the encryption core reuses scratch buffers — plus the transitive @noble hashes, curves and ciphers ranges advancing to 2.3.0, which the framework already vendors. No exported name changed, no dependency was added, no install script exists, and the published package drops only its source maps.
136
+
137
+ Outputs are unchanged: ML-KEM-1024 keygen, encapsulate and decapsulate, ML-DSA-87 keygen and sign, and SLH-DSA-SHAKE-256f keygen and sign were compared byte-for-byte against the previous release across fixed seeds, and each version verifies and decapsulates what the other produced. · *The vendored PKI toolkit refreshes to 0.4.11* — @blamejs/pki, which backs the mTLS CA and the PKCS#12 engine, moves to 0.4.11. Eight releases fold in.
138
+
139
+ 0.4.4 adds RFC 3779 IP-address and AS-number delegation extensions — and their RFC 8360 v2 twins — to the OID registry and the C509 codec.
140
+
141
+ 0.4.5 makes every signing path accept a private key created outside the toolkit's own WebCrypto: a key from the platform's WebCrypto, or from a separately-installed copy, previously reached the crypto library as a key with no material behind it and failed with a type error naming an internal property. A key that genuinely cannot be reached — created non-extractable, or held behind another implementation's interface — is now refused with the reason and the ways forward. A key's permitted usages travel with it, so one marked verify-only is refused rather than signing.
142
+
143
+ 0.4.6 gives a C509 certificate one encoding where the specification defines one, refusing nine alternative spellings that rebuilt a byte-identical X.509 certificate so that a single signature covered all of them. The framework does not use the C509 codec.
144
+
145
+ 0.4.7 makes a C509 certificate render its subject and issuer the way every other parser in the toolkit does, separating components with a comma and a space, so one certificate no longer has two rendered names depending on which parser read it.
146
+
147
+ 0.4.8 adds android-safetynet WebAuthn attestation verification, off by default and requiring the caller to supply the trust anchor. The framework does not use that module.
148
+
149
+ 0.4.9 verifies the WebAuthn compound attestation format, which the toolkit previously refused as unsupported: every nested statement must verify for the attestation to verify, and each element's own verdict, attestation type and chain are reported separately so a caller applies policy to the parts rather than to a merged verdict. It also bounds how many certificates an attestation may carry — both the statement's certificate array and a JSON Web Signature certificate header capped each certificate's size but not the count, so thousands of small certificates each cost a parse and a path validation. The framework does not use that module.
150
+
151
+ 0.4.10 reports a TPM attestation's credential-key object attributes and access policy, and lets a relying party require them by name — a key bound to one TPM, generated by that TPM, not duplicable — rather than taking the attestation on trust; a mistyped policy is refused rather than silently applying nothing. It also reads a calendar date carrying no time through the same strict reader as a full timestamp, so a day that does not exist is refused instead of rolling into the following month, and a parsed date is anchored to UTC so a freshness comparison does not shift with the host's time zone.
152
+
153
+ 0.4.11 lets a WebAuthn attestation be bound to the roots the authenticator's own model registered, by reading a FIDO Metadata Service list that is verified and chained to a root the caller supplies before any of its contents are parsed. It also routes every unknown-key refusal across the signing paths through one shared check, so a name every object inherits cannot pass for a recognised option. The framework does not use the WebAuthn module.
154
+
155
+ No dependencies and no install scripts; 0.4.11 adds one file to the published package, the metadata reader itself. The certificate, CRL, PKCS#12 and key paths the framework does use are exercised against a live CA. **Fixed:** *guardRegex reads the pattern once, into a parse tree, instead of scanning its source with a regex per question* — The ReDoS screen asked several questions of a pattern — is a repetition nested, does an alternation repeat, is a boundary forced — and each was answered by its own scan of the pattern SOURCE. The scans drew token boundaries differently, and every disagreement was a way through. One could not see past a nested group, so a single pair of parentheses hid an exponential pattern: `((a|a))+` was accepted where `(a|a)+` was refused. One decided whether a `?` was a quantifier by looking at the previous CHARACTER, and after a two-character escape that character is the metacharacter itself, so `(?:a\*?\*?)+` lost the length variation it contributes. One counted the digits inside `{n,m}`, so a bound written with leading zeros — `(a|a){0000000002,}`, the same `{2,}` — read as a group carrying no quantifier at all. Only an outer quantifier with no ceiling counted as a repetition, so `(a+){2,30}` and `(a+){10}` went through. A group prefix the scans did not recognise, an ES2025 modifier group or a long name, meant the group was skipped rather than unproven. Case folding was applied after character sets were combined rather than before, which under `i` made a set exclude the very character it had just been given.
156
+
157
+ There is one parser now. It reads the pattern into a tree, and every analysis reads the tree — nothing reads the source. A construct the parser cannot represent becomes an opaque node, which every analysis treats as unproven, so failing closed is a property of the representation rather than something each check has to remember; a pattern that compiles as a RegExp but does not parse here is reported rather than waved through. Flags are resolved per node, so a modifier group's own flags reach the analysis inside it and `(?i:a|A)+` reads as one branch written twice. Character sets are backed by real sets and folded once, when they are built.
158
+
159
+ Both directions improved. Every shape listed above is refused, along with two the previous code never examined: adjacent positions repeating over characters they share, `\w*\w*!`, which divides a run between them every possible way, and the same shape with each position wrapped in a group. Patterns that were refused for want of a proof now pass — a separator that LEADS each repetition (`^[a-z0-9]+(?:-[a-z0-9]+)*$`, the everyday slug), a dotted quad with a port or a prefix length after it, and two repeated groups in a row where neither can begin on the other's characters. The screen also stopped being expensive: a kilobyte of disjoint 256-character ranges took over two seconds to analyse and now takes five milliseconds, which matters because the analysis is synchronous and the profiles' runtime cap cannot interrupt it. · *A handshake explanation named TLS 1.3 to operators whose connection was capped below it, and the framework added an extension such a connection cannot carry* — A generic handshake-failure alert says only that the peer refused; the explanation lists the usual candidates, one of which is the TLS 1.3 cipher list. An operator who capped their own dial lower — a WebSocket `tlsOpts` override, their own HTTP agent, a version-pinned `secureProtocol` — never offered TLS 1.3, so naming its cipher list sends them after a setting that had no part in the failure. Each client built the diagnostic context by copying settings out of the options its dial used, one field at a time, so the ceiling was never among them and every capped dial still read as a TLS 1.3 one. Clients now hand over the options object itself (`tlsOpts` on the context), which is also how an operator calling `b.network.tls.explainOutboundFailure` gets every setting read without tracking which ones it looks at.
160
+
161
+ Capping such a dial could also fail outright. RFC 8879 certificate compression is a TLS 1.3 extension, and Node refuses an options object carrying it when the protocol range excludes 1.3 rather than ignoring an extension it cannot use. The framework advertises the extension on every outbound dial, so a WebSocket or SMTP connection an operator capped at TLS 1.2 died with `ERR_INVALID_ARG_VALUE` before any bytes moved, over an option they never asked for. It is now left off where it cannot apply — on outbound dials and on the HTTP/2 listener, which failed the same way for an operator serving legacy clients from `listen(port, cb, { minVersion: "TLSv1.2", maxVersion: "TLSv1.2" })`. An operator who asks for the extension explicitly alongside a cap still gets the error, since those two settings contradict each other and hiding one would hide the mistake. The TLS 1.3 floor is never dropped to accommodate a cap: a `secureProtocol` naming an old method is reported as the conflict it is rather than quietly lowering what the listener will accept. · *guardRegex screened only unbounded quantifiers inside a repeated group, so a bounded one went unrefused* — A repeated group backtracks over how much its body consumes, and a body varies in length whether the quantifier doing the varying is unbounded or not. The nested-quantifier detector counted only `*`, `+` and `{n,}`, so a group whose body carried `?` or `{n,m}` was never flagged: `/^(?:\d{1,3}\.?)+$/` — a dotted-quad or version validator, and a pattern an operator would plausibly write — passed every profile, including strict, with no finding at all.
162
+
163
+ It is exponential. Against a non-matching input it runs 0.5 ms at 16 characters, 5.8 ms at 24 and 67 ms at 28, roughly eleven times worse per four characters added. Reached through `b.middleware.botGuard`, which screens its `blockedAgents` patterns with this guard and then matches them against the User-Agent of every request, a 37-byte header holds the handler for around nine seconds. `maxRuntimeMs` does not help: it bounds the gate's own inspection, never the later match. `(?:a{1,2})+b`, `(?:a?a?)+b` and `(?:\w{0,3})+` are the same shape.
164
+
165
+ The detector now treats any VARIABLE-length quantifier in a group body as what it is. `{n}` is exact and still does not count, and a group quantified `?` — which repeats at most once — is unaffected. A body that varies is not automatically ambiguous, so one more shape is proven rather than refused: when every varying atom is disjoint from the character the body must begin with, and from the other varying atoms, no repetition can hand a character to the next one and the split is decided. That is why `(?:ab?)+c` still passes — every repetition starts at an `a` and the optional `b` is not an `a` — while `(?:a{1,2})+b`, whose varying atom matches the very character a repetition begins with, does not.
166
+
167
+ This predates the release: the shape has been unrefused since the flat detector it replaced was removed. Patterns already in use that carry it will now be refused at the point they are configured, which is the intended outcome — the refusal names the shape and the pattern can be rewritten with an exact count. · *A server built with b.network.tls.applyToContext now actually offers the configured key shares* — The function supplied the group preference as a `groups` key. node:tls has no such option — it accepts the key and ignores it, whereas a malformed `ecdhCurve` throws — so nothing it returned ever pinned the preference. An operator wiring their own https.Server or https.Agent through it, which is what its documentation directs, negotiated on Node's default group order instead, and b.network.tls.preferredGroups.set(...) had no effect on those connections however it was called. The framework's own clients were unaffected: each sets `ecdhCurve` before reaching this function. The preference is now emitted as `ecdhCurve`. A base config naming its own preference still wins, under either spelling — `groups` is the name this function asked operators for, so it is translated rather than ignored.
168
+
169
+ The same key was returned alongside the working one by b.network.tls.buildOptions and set on the agent by b.pqcAgent.create. It was inert in both, and a duplicate that looks like a second handle on the preference is worse than none: narrowing it changes nothing. Neither emits it now. Reading the group list back off either result still works, under `ecdhCurve`.
170
+
171
+ A test re-establishes on the running Node which option names are ignored — a value no implementation could accept throws on an option that is honoured and is swallowed by one that is not — and fails rather than passing silently if a future Node implements one of them.
172
+
173
+ The practical consequence is that such a server now offers what the framework configures rather than what the runtime happens to default to, and negotiates the hybrid first: a client expressing no preference lands on X25519MLKEM768, and the NIST-curve and ML-KEM-1024 hybrids are each reachable by a client that asks for one. It also stops accepting groups outside the configured list, which is the point of configuring one. Every group in the shipped preference is verified against the runtime, since an unrecognised name in this option throws rather than being skipped. · *A handshake refused by the outbound posture now names the cause* — A peer the posture cannot negotiate with produced only the raw alert, most often `tlsv1 alert protocol version`, which names neither the peer nor the clause that refused it. Two different failures were arriving behind it and were routinely attributed to the same cause: a `protocol_version` alert means the peer offers no TLS 1.3, which the client requires and which has nothing to do with the group preference, while a `handshake_failure` alert against a narrowed group list means the peer shares none of the groups offered. The generic handshake-failure alert is reported as generic: a disjoint TLS 1.3 cipher list produces that same code with a key-exchange group both sides support, so it names the candidates — group, cipher suite, certificate selection — rather than sending an operator after the group list. The codes that do mean the group specifically say so.
174
+
175
+ b.network.tls.explainOutboundFailure(err, ctx?) returns a sentence naming the likely cause, or null when it has nothing to say; b.network.tls.annotateOutboundFailure(err, ctx?) rewrites the message in place and hands the same error back, so err.code, the error class and anything a caller branches on are untouched. The wording is hedged, because neither alert is exclusive to these causes, and the original error text is carried through rather than replaced. b.httpClient, b.wsClient and the rediss:// path report through it. There is no opt-out: the floor stays a floor.
176
+
177
+ A request through an HTTPS proxy is diagnosed against the settings that leg actually used. The proxy agent is built once and cached, so its recorded options describe the posture at the moment it was created; each destination handshake through the tunnel reads the posture fresh, and after a preferredGroups.set(...) those disagree. The destination leg now annotates its own failure, so an operator is told about the handshake that happened rather than the one the cached agent describes. · *An anonymous caller could choose their own feature-flag bucket* — A request with no identified user gets an anonymous targeting key, and that key decides which variant a percentage rollout serves. It was derived from a raw `X-Forwarded-For` read with no peer check and from the User-Agent — both set by the client. Resending with a different value of either produced a different key and therefore a different bucket, so a caller could walk values until one served the variant they wanted, and keep re-rolling to enter a treatment group indefinitely. On a deployment with no reverse proxy the header was still honoured, from anyone who could reach the listener.
178
+
179
+ The key is now derived from the client address alone, resolved through b.requestHelpers.trustedClientIp, so forwarded headers are honoured only from a peer the operator has declared. `b.flag.middleware` and `b.flag.context.fromRequest` accept `trustedProxies`, `forwardedHeaders` and `clientIpResolver` to declare them; the middleware builds the resolver once at construction, so a malformed CIDR fails at boot rather than on the first request.
180
+
181
+ This changes bucket assignment for anonymous callers, in two ways. The User-Agent no longer contributes, so anonymous callers sharing an address now share a bucket — the honest resolution when nothing else about them can be trusted. And behind a proxy with no `trustedProxies` configured, every anonymous caller shares the proxy's address and therefore one bucket; configure it to restore per-caller distribution, or pass `userKey` for callers whose spread matters. Identified users are unaffected: their key comes from the user id. · *guardRegex no longer refuses two patterns that cannot backtrack* — Two shapes were refused on appearance rather than behaviour, and neither can be rewritten without losing what it expresses.
182
+
183
+ An alternation under a quantifier whose branches cannot start on the same character — `(?:b|c)+` — is the character class `[bc]+` written out long: one character decides the branch at every position. The refusal named overlap as the hazard, correctly, but never tested for it.
184
+
185
+ A group body ending in a literal that none of its other atoms can match — `(?:[a-z]+-)*[a-z]+`, the standard path-segment pattern — has its repetition boundaries pinned by the occurrences of that literal, so the outer quantifier cannot re-partition the input. `(?:[^/]+/)*` and `(?:\w+\.)+` are accepted on the same ground; `(?:\w+_)*` is not, because `_` is a member of `\w`, and `(?:[a-z]+)*` is not, because nothing pins it.
186
+
187
+ Both proofs are narrow on purpose, because deciding one kind of ambiguity does not dispose of the other. Disjoint first characters decide WHICH branch matches, not HOW MUCH it consumes, so every atom in every branch must be fixed-length — `(?:ab?|b)+` is exponential despite `a` and `b` being disjoint. A pinned boundary decides where each repetition ENDS, but the paths through a match are the PRODUCT of the paths through each repetition, so the body may carry at most one variable-length atom — `(?:a*a*-)*` is exponential despite every boundary being forced. Both also decline unless the boundaries BETWEEN the pattern's terms are decided. Each proves a property of ONE group, which says nothing about how the whole pattern divides its input: five `[ab]+` runs followed by a disjoint `(?:a|b)+` are each unambiguous, while the ways to partition one run among the six are not. One variable-length term means no boundary to get wrong; beyond that the only case proven is the delimited one — a repeated group each of whose repetitions must contain a particular character, followed by terms that cannot match that character anywhere, so no later term can absorb a repetition. That is why `(?:[a-z]+-)*[a-z]+` is decided and `(?:[a-z]+-)*(?:[a-z]+-)*` is not: the second group matches `-` too, so the split between them floats. Shapes that are linear for reasons outside that argument — `(?:b|c)+(?:d|e)+`, whose classes simply do not overlap — stay refused rather than proven by an argument that does not cover them.
188
+
189
+ Anything either analysis cannot characterise exactly — a nested group, `.`, a backreference, a negated shorthand, a character range too wide to enumerate — leaves the refusal untouched, as does a pattern engineered to be expensive to reason about: the analysis spends a fixed work budget, so cost cannot buy leniency and the screener cannot be made into the denial of service it exists to prevent. A pattern accepted before is still accepted, and the canonical catastrophic set is refused unchanged. The refusal message now names a genuinely overlapping example and says what to change. · *A malformed group preference is refused instead of quietly replaced* — b.network.tls.applyToContext supplied the group preference under a key node:tls ignores; correcting that meant a base config carrying a preference is now read rather than discarded. An override that was present but not a usable list — `null`, `false`, a number, an empty string, an empty array — fell through to the framework default. node:tls rejects a non-string `ecdhCurve`, so previously such a value stopped the server at `createSecureContext`; substituting a default instead would start a listener on groups the operator did not choose, with nothing said, which is a state only visible by inspecting a live handshake. A present-but-unusable override under either spelling is now refused at construction with `tls/bad-group-preference`, naming the option and what was supplied. An absent override still takes the framework preference, and an array of group names still joins. · *A CIBA client's HTTP options reached only half its requests* — b.auth.ciba.client.create accepts `httpClientOpts` and applies it to the backchannel-authentication and token POSTs it builds itself. It handed the same bag to the OAuth client it composes under a name that client does not read, so discovery and JWKS fetches silently ignored it. An operator setting a timeout, a header or a response mode saw it honoured on some requests in a flow and dropped on others. The option now reaches the composed client. **Detectors:** *A group preference assigned to a name node:tls ignores now fails the build* — The TLS named-group preference is read as `ecdhCurve` and nothing else, but `groups`, `curves` and `namedCurves` are all accepted and silently discarded, so a list assigned to one of them reads as a pinned preference and pins nothing. That shape has now shipped twice — a `curves` list on the WebSocket client, and the context filler above. A detector matches an assignment to one of those names whose value is visibly the group preference. · *A raw X-Forwarded-For read in the framework now fails the build* — Reading the header off `req.headers` directly has no peer gate, and whatever the value keys — a rate-limit bucket, an IP-bound grant, a rollout bucket — the caller then chooses it for themselves. b.requestHelpers.trustedClientIp owns the resolution, and its `forwardedHeaders` option means needing a different header is no longer a reason to hand-roll the read. A detector matches the raw-read shape, with no exemptions.
190
+
11
191
  - v0.18.17 (2026-08-08) — **Post-quantum clients could be refused, every outbound handshake cost an extra round trip, and a connection with no forward secrecy audited as if it had post-quantum protection.** The framework kept its outbound TLS posture as a list of keys each protocol client re-typed for itself, and the copies had drifted: the Redis client pinned neither the TLS floor nor the post-quantum groups, the syslog sink and the ECH path pinned no groups, and the WebSocket client set an option node:tls does not recognize, so its groups never reached the handshake. There is now one posture object every outbound path merges. Fixing the drift surfaced two problems in what the posture itself said. The group order led with a hybrid deployed peers do not implement, which cost a HelloRetryRequest on every single handshake and made Node hand back an empty stapled OCSP response, so requireStapled refused peers that had stapled correctly. And the inbound gate refused a client whose only post-quantum group was one the framework advertises. Certificate compression is now advertised in both directions, which matters most here because the framework's own ML-DSA-87 certificates dominate a handshake. The supported Node version rises to 24.19.0. **Added:** *Certificate compression (RFC 8879) on every TLS connection the framework makes or accepts* — Inbound and outbound connections — the HTTP listener including the certificates it serves by SNI, the HTTP client, and the mail listeners and outbound SMTP alike — now advertise the compression algorithms the runtime supports, so a peer may send its Certificate message compressed and the framework compresses its own for a peer that asked. This pays more on a post-quantum deployment than a classical one: an ML-DSA-87 signature alone is about 4.6 KB before any public key or chain, so the Certificate message dominates the handshake. It is not the record-layer compression CRIME attacked — only the Certificate message is compressed, and that message is public, fixed, and not attacker-influenced, so its compressed length reveals nothing about a secret and no attacker-chosen plaintext shares a compression context with one. b.network.tls.certificateCompressionAlgorithms() reports what the runtime supports; b.network.tls.buildOptions accepts a certificateCompression array to narrow the list, or [] to advertise none, and refuses an algorithm the runtime cannot decompress at configuration time rather than at connect time. A secure context built by hand replaces the server's for handshakes that use it, so the setting belongs on the context rather than the server or the wrapping socket; a release gate now holds every context that serves a handshake to that. · *b.network.tls.outboundPosture() — the outbound TLS posture as one object* — Returns { minVersion, ecdhCurve, certificateCompression } ready to merge into any tls.connect, https.request, or https.Agent options. Every protocol client the framework ships — DNS-over-HTTPS and DNS-over-TLS, NTS-KE, Redis, syslog, WebSocket, proxy tunnels, the HTTP client, the ECH and OCSP paths — merges this rather than listing the keys itself, so raising the posture is one edit that cannot reach some outbound paths and miss others. It reads the live key-share preference, so b.network.tls.preferredGroups.set(...) takes effect on the next dial across all of them; call it per connection rather than caching the result. b.constants.TLS_CERT_COMPRESSION() exposes just the algorithm list, for the inbound side that has no key-share preference to track. **Changed:** *The supported Node version is now 24.19.0* — Node 24.19.0 is what reports the negotiated TLS group positively, which is what makes a non-ephemeral key exchange distinguishable from a post-quantum one, and it is where certificate compression is available. Operators on 24.18.x should upgrade before taking this release. The README's requirements line is updated with it, and a release gate now holds the two together — it previously kept advertising the old floor, which is installation guidance that produces an npm engine mismatch. · *Vendored bundles declare the components they carry* — noble-curves and noble-post-quantum each embed @noble/hashes, and noble-post-quantum also embeds @noble/curves and @noble/ciphers. Those were invisible to the generated software bill of materials, which listed 8 vendored components where the tarball actually ships 12. The manifest now declares them with their versions, the SBOM reports all 12, and NOTICE attributes @noble/hashes, including the fact that two versions ship because each bundle carries the one it was built against. · *Vendored crypto bundles refreshed* — @noble/ciphers moves to 2.3.0 and @blamejs/pki to 0.4.3. The ciphers release is a packaging and diagnostics refresh — unused reference implementations moved into its test tree, clearer validation messages; the XChaCha20-Poly1305 behaviour behind b.crypto is unchanged. The pki release fixes distinguished-name comparison: a NumericString attribute value no longer compares 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 b.mtlsCa. It also refuses an empty issuer name and escapes rendered distinguished-name values per RFC 4514. The pki 0.4.3 release adds RFC 8879 compressed-certificate encoding and decoding with the two-sided length bound the specification requires, SHAKE128 / SHAKE256 digests, and the ML-DSA-87 + Ed448 composite signature arm; it also refuses a CMS compressed stream carrying bytes past the end of its frame, which had given one content unboundedly many encodings. · *The README table of vendored packages is checked against what ships* — That table is where an operator looks to decide whether a published advisory applies to them, and nothing kept it in step with lib/vendor/MANIFEST.json: it advertised @blamejs/pki 0.3.25 and @simplewebauthn/server 13.3.0 while later versions shipped, and omitted @noble/curves entirely. All three are corrected, and a release gate now fails on a row whose version disagrees with the manifest or a vendored package with no row at all. The check covers embedded copies too: noble-post-quantum bundles @noble/ciphers and @noble/curves at 2.2.0 while the table's own rows for those packages show the 2.3.0 standalone copies, so an operator checking a 2.2.0 advisory against the table alone was told it was not shipped. Each parent row now names the versions it embeds. · *Outbound TLS connections are checked against the shared posture at build time* — The framework's outbound TLS posture — the TLS floor, the hybrid group preference and certificate compression — is defined once, but several shipped clients did not merge it: the Redis client and the syslog sink carried no group preference at all, the WebSocket client passed one under an option node:tls silently ignores, outbound SMTP built its own options, and the OTLP/gRPC sink negotiated on Node's defaults. All of them now merge it. A release gate fails the build when a construction that opens an outbound TLS connection does not, so a new client cannot ship without the posture; a site that legitimately applies it elsewhere carries a marker naming where. · *The wiki container image carries libatomic* — The runtime image is Chainguard's distroless Node, which ships no package manager and no libatomic; the build stage's image has it. Whether a given Node build links libatomic is upstream's decision, and the release workflow resolves the rolling base tag to a fresh digest at build time, so the base can change between builds — a build that starts needing it would otherwise produce an image that dies at startup with "error while loading shared libraries: libatomic.so.1" before any application code runs. The library is now staged in the build stage and copied into the runtime image, from a directory rather than a glob so a base that stops shipping it does not fail the build instead. **Fixed:** *The inbound post-quantum gate no longer refuses a client offering SecP256r1MLKEM768* — b.pqcGate builds its accept-set from b.constants.PQC_GROUPS, while the outbound preference list advertises SecP256r1MLKEM768 (IANA codepoint 0x11EB). The group was missing from PQC_GROUPS, so a deployment wiring the two together — the documented pairing — advertised the group, accepted it at the TLS layer, and then answered a ClientHello offering only it with a fatal handshake_failure. From the client's side that is indistinguishable from being refused for offering nothing post-quantum at all. The codepoint is now present, and a test asserts that every hybrid the framework advertises has an entry in the accept-set, so a group added to one table cannot silently miss the other. · *Outbound handshakes no longer force a HelloRetryRequest, which was emptying stapled OCSP responses* — The outbound group preference led with SecP384r1MLKEM1024, a draft hybrid that widely deployed TLS stacks do not implement. Node sends a key share for the first group only, so nearly every handshake was answered with a HelloRetryRequest and completed on the second attempt — an extra round trip on every outbound connection. The retry is invisible at the TLS surface (the connection succeeds and still reports a post-quantum hybrid), but Node delivers an empty stapled OCSP response across a retried handshake, so b.network.tls.ocsp.requireStapled refused peers that had stapled correctly. The list now leads with X25519MLKEM768, matching the b.network.tls.preferredGroups default the two lists had drifted apart from. SecP384r1MLKEM1024 remains offered, so a peer supporting only that group still negotiates it. No group was removed and none was added. · *wss:// connections now apply the framework's post-quantum group preference* — b.wsClient passed its group list as a `curves` option. node:tls has no such option — it accepts the key and ignores it, whereas a malformed `ecdhCurve` throws — so the preference never reached the handshake and every wss:// connection negotiated on Node's defaults instead, including for operators who had narrowed the list with b.network.tls.pqc.setKeyShares. The list is now passed as `ecdhCurve`. An operator value supplied in the dial's TLS options still wins. · *A hostname in absolute form is passed through as written, and a malformed one is refused* — RFC 1034 sec. 3.1 makes `example.com.` the absolute form of `example.com` — the same name, and the encrypted transports encode both to identical query bytes. The trailing dot is not decoration, though: a resolver reads it as "already fully qualified, do not apply the search list", which matters under a search domain with an elevated ndots (5 is the Kubernetes default), where dropping it would turn an absolute request for api.example.com. into a relative one that resolves and caches api.example.com.<search-domain> instead. The name now reaches the resolver exactly as written, and it is the label list that drops the root label — so the LDH validation the encrypted transports run no longer rejects the absolute form as an empty label. Separately, a name carrying an empty label that is NOT the root — "example.com..", "example..com", ".example.com" — is refused at the entry point rather than at the transport, because the system-resolver branch runs no label validation of its own and getaddrinfo reads a trailing dot as absolute: "example.com.." previously resolved, and cached, the address of a different name than the caller asked for. IP literals pass through untouched. The RFC 1035 253-character ceiling is measured on the name rather than the spelling, so a maximum-length name is accepted written either way — counting the root dot would have refused absolutely exactly the names it accepts relatively. · *Redis over TLS and the syslog sink now carry the framework TLS posture* — The rediss:// connect path assembled its TLS options from host and port alone: no TLS 1.3 floor, no post-quantum groups. A connection to a TLS Redis could negotiate TLS 1.2 with classical-only key exchange. The syslog sink over tls:// pinned the floor but likewise offered no post-quantum groups. Both now merge the same posture as every other outbound path. Operators pointing at a TLS endpoint that cannot negotiate TLS 1.3 will see the connection refused rather than silently downgraded; that is the framework's posture everywhere else and was the outlier here. · *A connection with no ephemeral key exchange is no longer audited as post-quantum* — The post-handshake observer read the negotiated key exchange and treated "nothing reported" as a post-quantum hybrid, because before Node 24.19.0 a hybrid did report nothing. Node now identifies a hybrid positively, and an empty reading means only that the key exchange was not ephemeral — no forward secrecy at all, which was the one outcome being passed over in silence. Detection is now positive, and a non-ephemeral key exchange emits its own tls.no_ephemeral_key_exchange audit action rather than being folded into the pass or confused with an observed classical group. tls.classical_downgrade is unchanged in meaning. All three consumers — b.pqcAgent, the HTTP/2 transport in b.httpClient, and b.networkProxy — inherit this from the one implementation. · *The syslog sink no longer logs a spurious warning when a connection closes after shutdown* — The socket 'close' handler read the sink's current socket rather than the one the event belonged to. close() clears that reference first, so a normal shutdown reached the handler with nothing to tear down and emitted a sock-destroy-failed warning; a reconnect could have torn down the wrong socket. The handler now holds the socket it was registered for. · *A narrowed group list now reaches the HTTP/1.1 agent, not only the HTTP/2 session* — b.pqcAgent.create() fell back to the compiled-in group order when the caller named none, while the HTTP/2 transport built its options from the live preference. Which groups an origin actually saw therefore depended on whether it spoke HTTP/2: an operator who narrowed preferredGroups had the policy applied on h2 origins and silently re-offered the removed groups on h1 ones. The agent now takes the same live list as every other outbound path. · *A resumed TLS session is no longer audited as having no ephemeral key exchange* — A session resumed from an earlier handshake performs no new key exchange, so the key-exchange reading is empty — the same reading that now means "not ephemeral". Its forward secrecy is inherited from the original handshake, and connection-pool churn makes resumption routine, so recording those would have buried the findings that matter. Node's TLS 1.3 resumption uses PSK with (EC)DHE and does still report a group, so this was not observed in practice; psk_ke resumption carries no key exchange at all, and the observer now checks for a reused session before recording anything. · *Mail TLS carries the same posture as the rest of the framework* — A mail listener builds its own secure context rather than handing options to the HTTP server's constructor, so it inherited neither the certificate compression this release adds nor, on the outbound side, the shared group preference. SMTP, submission, IMAP, POP3 and ManageSieve now compress their certificate chains — the setting belongs on the secure context, since a socket wrapping a pre-built context ignores it — and outbound SMTP merges the same posture as every other outbound client. The TLS floor stays operator-settable for SMTP alone, since an MX that speaks only TLS 1.2 is common and refusing it would mean not delivering mail. · *A group-preference change now reaches pooled connections, not only fresh ones* — b.network.tls.preferredGroups.set(...) is documented to apply from the next dial. That held only for callers that build their options fresh: an https.Agent copies its TLS options at construction and the HTTP client keeps one per origin, and an SMTP transport stored its options once and cloned them for every connection — so a pool created before the change kept offering the groups the operator removed for as long as it lived. The preference now carries a generation, the HTTP client rebuilds its per-origin transports when it moves, and SMTP reads the posture as each connection is dialled. An explicit SMTP override still wins over the shared posture.
12
192
 
13
193
  Retiring a pool does not interrupt work already running on it. An HTTP/1.1 agent stops pooling and closes its idle keep-alive sockets, so a socket still carrying a response is closed when that response finishes rather than parked in a pool nothing will revisit. An HTTP/2 session is retired by idleness — it keeps serving its open streams and is closed only once it falls quiet — because closing it outright refuses new streams and fails requests that were handed the session moments earlier. Once closed, the socket is freed when the session stops making progress, where progress is the socket's cumulative byte counters. Those never report a live transfer as stalled, which matters because that mistake destroys data, and no wall-clock limit is imposed — b.httpClient.downloadStream has none of its own, so a fixed deadline would cut a legitimate long download short. The known limit of this signal is that a peer generating connection-level traffic (PING, WINDOW_UPDATE) keeps a stalled session looking busy; the request's own timeout bounds that case. A retired session no longer evicts the replacement cached in its place when it finally closes, and b.pqcAgent's process-wide default agent is rebuilt on the change rather than serving the old groups for the life of the process — retired the same non-destructive way, so a request already running on it is not reset. · *The OTLP/gRPC log sink negotiates on the framework's TLS posture* — b.logStream's OTLP/gRPC sink built its HTTP/2 session options from the collector's CA, servername and verification flag alone, so a telemetry connection to an HTTPS collector used Node's default TLS floor and group order, advertised no certificate compression, and ignored b.network.tls.preferredGroups.set(...) even across reconnects. HTTPS sessions now merge the shared posture, read per session; cleartext h2c collectors are unchanged, since there is no TLS layer to configure.
@@ -36,20 +36,24 @@ Used for: RFC 9497 Oblivious Pseudo-Random Function (OPRF / VOPRF / POPRF)
36
36
  audited noble-curves suite.
37
37
  --------------------------------------------------------------------------------
38
38
  Component: @noble/hashes
39
- Version: 2.3.0 (bundled inside noble-curves.cjs),
40
- 2.2.0 (bundled inside noble-post-quantum.cjs)
39
+ Version: 2.3.0
41
40
  Source: https://github.com/paulmillr/noble-hashes
42
41
  License: MIT
43
42
  Copyright: Copyright (c) 2022 Paul Miller (https://paulmillr.com)
44
- Used for: The hash and extendable-output primitives that noble-curves.cjs
45
- and noble-post-quantum.cjs build on SHA-3 / SHAKE, HMAC, HKDF.
46
- It is not vendored as a file of its own; each of those bundles
47
- carries the version it was built against, which is why two
48
- versions ship. Thank you to Paul Miller for the audited
49
- noble-hashes suite.
43
+ Used for: The client half of a hybrid exchange, as a browser build
44
+ (lib/vendor/browser/noble-hashes.mjs)SHAKE256 as a KDF,
45
+ SHA-3, SHA-2, HMAC and HKDF. There is no server bundle: the
46
+ server side reaches all of these through node:crypto, so a
47
+ .cjs here would be a file nothing requires.
48
+ The same version is also carried INSIDE noble-curves.cjs and
49
+ noble-post-quantum.cjs, which bundle the parts of it they use
50
+ (SHA-2 in the first; SHA-3, SHAKE and HMAC in the second).
51
+ One version covers all three, so the hash a browser runs is
52
+ the hash inside the server's PQC bundle. Thank you to Paul
53
+ Miller for the audited noble-hashes suite.
50
54
  --------------------------------------------------------------------------------
51
55
  Component: @noble/post-quantum
52
- Version: 0.6.1
56
+ Version: 0.7.0
53
57
  Source: https://github.com/paulmillr/noble-post-quantum
54
58
  License: MIT
55
59
  Copyright: Copyright (c) 2024 Paul Miller (https://paulmillr.com)
@@ -73,7 +77,7 @@ Used for: WebAuthn / passkey registration + authentication response
73
77
  server.cjs).
74
78
  --------------------------------------------------------------------------------
75
79
  Component: @blamejs/pki
76
- Version: 0.4.3
80
+ Version: 0.4.13
77
81
  Source: https://github.com/blamejs/pki
78
82
  License: Apache-2.0
79
83
  Copyright: Copyright (c) blamejs contributors
@@ -121,7 +121,7 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
121
121
  - **Stream throttle** — shared token-bucket bandwidth limiter (RFC 2697 srTCM shape); N concurrent `node:stream` pipelines draw from one operator-configured `bytesPerSec` budget (`b.streamThrottle`)
122
122
  - **TLS-RPT receiver** — RFC 8460 inbound aggregate-report ingest; HTTPS POST handler + §4.4 schema parser with gzip-bomb / ratio-bomb / depth-bomb defenses (`b.mail.deploy.parseTlsRptReport` / `b.mail.deploy.tlsRptIngestHttp`)
123
123
  - **TLS / channel binding** — RFC 9266 TLS-Exporter token-to-session pinning (`b.tlsExporter`); RFC 9162 CT v2 inclusion-proof verification (`b.network.tls.ct.verifyInclusion`); RFC 8555 ACME + RFC 9773 ARI for 47-day certs with `{ jitter: true }` fleet-scheduling (`b.acme.renewIfDue`); draft-aaron-acme-profiles (`acme.listProfiles()` + `newOrder({ profile })`); draft-ietf-acme-dns-account-label (`acme.dnsAccount01ChallengeRecord(token, { identifier })`); RFC 8470 0-RTT inbound posture refuse / replay-cache (`b.router.create({tls0Rtt})`); RFC 9794 SecP256r1MLKEM768 in preferred-group order (`b.network.tls.preferredGroups`); RFC 6960 OCSP stapling — the cert manager (`b.cert`) fetches + validates each managed certificate's OCSP response (`b.network.tls.ocsp.fetch`) on a refresh cadence and exposes it on the served context for a TLS server's `OCSPRequest` handler to staple
124
- - **mTLS CA** — pure-JS on the zero-dependency `@blamejs/pki` toolkit, issues clientAuth / serverAuth / dual-EKU certs with SAN under a post-quantum ML-DSA-87 (FIPS 204) default that node:tls verifies in a real mutual-auth handshake on OpenSSL 3.5; pin the classical `ECDSA-P384-SHA384` bridge via `b.mtlsCa.create({ algorithm })` for peers that predate it; migrate the CA to a new algorithm without a trust outage — `rotate()` plus a retained-previous trust bundle, a `canVerifyInTls()` loopback pre-flight, and revoke-by-generation; PQC TLS gates inbound + outbound (`b.mtlsCa`, `b.pqcGate`, `b.pqcAgent`); one outbound TLS posture (`b.network.tls.outboundPosture()`) merged by every protocol client, honouring a runtime `preferredGroups.set`, with RFC 8879 certificate compression advertised both directions so an ML-DSA-87 chain stops dominating the handshake
124
+ - **mTLS CA** — pure-JS on the zero-dependency `@blamejs/pki` toolkit, issues clientAuth / serverAuth / dual-EKU certs with SAN under a post-quantum ML-DSA-87 (FIPS 204) default that node:tls verifies in a real mutual-auth handshake on OpenSSL 3.5; pin the classical `ECDSA-P384-SHA384` bridge via `b.mtlsCa.create({ algorithm })` for peers that predate it; migrate the CA to a new algorithm without a trust outage — `rotate()` plus a retained-previous trust bundle, a `canVerifyInTls()` loopback pre-flight, and revoke-by-generation; PQC TLS gates inbound + outbound (`b.mtlsCa`, `b.pqcGate`, `b.pqcAgent`); one outbound TLS posture (`b.network.tls.outboundPosture()`) merged by every protocol client, honouring a runtime `preferredGroups.set`, with RFC 8879 certificate compression advertised both directions so an ML-DSA-87 chain stops dominating the handshake; apply the same posture to your own `https.Server` / `https.Agent` with `b.network.tls.applyToContext({ base })`, and a handshake the posture refuses reports which clause refused it rather than a bare OpenSSL alert (`b.network.tls.explainOutboundFailure`)
125
125
  ### HTTP
126
126
 
127
127
  - **Router + API specs** — schema-validated routes; OpenAPI 3.1 / 3.2 publication (`b.openapi` — webhooks + `jsonSchemaDialect`) + AsyncAPI publication for event/streaming (`b.asyncapi`)
@@ -140,7 +140,8 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
140
140
  - **Outbound HTTP client** — HTTP/1.1 + HTTP/2 with SSRF gate (cloud-metadata IPs hard-denied; private / loopback / link-local overridable per call); scheme + userinfo + per-host destination allowlist; redirects, multipart, interceptors, progress, encrypted cookie jar (`b.httpClient`, `b.ssrfGuard`, `b.safeUrl`)
141
141
  - **Local-transport HTTP** — HTTP to a co-located daemon over a Unix domain socket, a Windows named pipe, or a loopback TCP peer with a bearer token — the transports the SSRF gate cannot protect, since there is no network host to steer; refuses any non-loopback host, never forwards Origin / Referer to the daemon, and bounds the response body (`b.localHttp`)
142
142
  - **Network configurability (`b.network`)** — env-driven NTP / NTS (RFC 8915), IPv4/IPv6 NTP, DNS with IPv6 / DoH / DoT (private-CA pinning) / cache / lookup timeout; local DNSSEC signature verification (RFC 4035 — `b.network.dns.dnssec.verifyRrset` over a canonicalised RRset against RSA / ECDSA P-256·P-384 / Ed25519 DNSKEYs, plus DS-digest + key-tag, plus `verifyDenial` for NSEC / NSEC3 (RFC 5155) NXDOMAIN / NODATA proofs with iteration caps + Opt-Out handling, plus `verifyChain` to validate a full root→TLD→zone delegation chain against the pinned IANA root anchors) so a resolver client can verify both positive and negative answers instead of trusting the upstream AD bit; DANE / TLSA certificate matching (RFC 6698/7671 — `b.network.dns.dane.matchCertificate`) to pin a service's key through DNSSEC instead of a public CA; TSIG transaction signatures (RFC 8945 — `b.network.dns.tsig.sign` / `verify`) for shared-key HMAC authentication of zone transfers, dynamic updates, and query/response pairs, with constant-time MAC compare + fudge-window check (verified against dnspython); outbound HTTP proxy (`HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY`); runtime DPI trust-store CA additions; application-level heartbeats; TCP socket defaults
143
- - **Error pages** — operator-rendered, no app-frame leakage (`b.errorPage`)
143
+ - **Error pages** — operator-rendered, no app-frame leakage (`b.errorPage`); a failure after the first byte destroys the connection rather than appending its message to a partial body, so a truncated download cannot be read as a complete one
144
+ - **Streamed responses** — write an async iterable to a response with back-pressure awaited, a disconnected peer stopping the loop instead of hanging it, and a mid-stream producer failure ending the transfer honestly (`b.render.stream`); the drain-aware write is available on its own as `b.safeAsync.writeChunk`
144
145
  ### Defensive parsers
145
146
 
146
147
  - **JSON / SQL / schema** — `b.safeJson` (with `maxKeys` cap defending CVE-2026-21717 V8 HashDoS), `b.safeBuffer`, `b.safeSql`, `b.safeSchema`
@@ -315,16 +316,27 @@ All runtime dependencies are committed to the repo — no transitive npm install
315
316
 
316
317
  | Package | Version | Author | Purpose |
317
318
  |---|---|---|---|
318
- | [`@noble/ciphers`](https://github.com/paulmillr/noble-ciphers) | 2.3.0 | [Paul Miller](https://github.com/paulmillr) | XChaCha20-Poly1305 AEAD |
319
+ | [`@noble/ciphers`](https://github.com/paulmillr/noble-ciphers) | 2.3.0 | [Paul Miller](https://github.com/paulmillr) | XChaCha20-Poly1305 AEAD. Ships a browser (ESM) build beside the server one, built from the same install |
320
+ | [`@noble/hashes`](https://github.com/paulmillr/noble-hashes) | 2.3.0 | [Paul Miller](https://github.com/paulmillr) | Browser (ESM) build only — SHAKE256 / SHA-3 / SHA-2 / HMAC / HKDF for the client half of a hybrid exchange. The server side reaches all of these through `node:crypto`, so there is no server bundle |
319
321
  | [`@noble/curves`](https://github.com/paulmillr/noble-curves) | 2.3.0 (bundles @noble/hashes 2.3.0) | [Paul Miller](https://github.com/paulmillr) | RFC 9497 Oblivious Pseudo-Random Function (OPRF / VOPRF / POPRF) over ristretto255 / P-256 / P-384 / P-521, behind `b.crypto.oprf` |
320
- | [`@noble/post-quantum`](https://github.com/paulmillr/noble-post-quantum) | 0.6.1 (bundles @noble/hashes, @noble/curves, @noble/ciphers 2.2.0) | [Paul Miller](https://github.com/paulmillr) | Pure-JS FIPS 203 ML-KEM (`ml_kem_512` / `ml_kem_768` / `ml_kem_1024`), FIPS 204 ML-DSA (`ml_dsa_44/65/87`), FIPS 205 SLH-DSA (`slh_dsa_*`). First-class on both server-side and client-side via `b.pqcSoftware` — security-first defaults pin to the highest cat-5 levels (ML-KEM-1024, ML-DSA-87, SLH-DSA-SHAKE-256f); interoperable with Node's built-in WebCrypto ML-KEM that `b.crypto.encrypt` / `b.middleware.apiEncrypt` use. |
322
+ | [`@noble/post-quantum`](https://github.com/paulmillr/noble-post-quantum) | 0.7.0 (bundles @noble/hashes, @noble/curves, @noble/ciphers 2.3.0) | [Paul Miller](https://github.com/paulmillr) | Pure-JS FIPS 203 ML-KEM (`ml_kem_512` / `ml_kem_768` / `ml_kem_1024`), FIPS 204 ML-DSA (`ml_dsa_44/65/87`), FIPS 205 SLH-DSA (`slh_dsa_*`). First-class on both server-side and client-side via `b.pqcSoftware` — security-first defaults pin to the highest cat-5 levels (ML-KEM-1024, ML-DSA-87, SLH-DSA-SHAKE-256f); interoperable with Node's built-in WebCrypto ML-KEM that `b.crypto.encrypt` / `b.middleware.apiEncrypt` use. A browser (ESM) build ships beside it carrying the KEM suites only — a client half encapsulates and does not sign |
321
323
  | [`@simplewebauthn/server`](https://github.com/MasterKale/SimpleWebAuthn) | 13.3.2 | [Matthew Miller](https://github.com/MasterKale) | WebAuthn / passkey verification |
322
- | [`@blamejs/pki`](https://github.com/blamejs/pki) | 0.4.3 | [blamejs](https://github.com/blamejs) | Zero-dependency pure-JS X.509 / CRL / PKCS#12 / CSR / CMS toolkit backing `b.mtlsCa` — ML-DSA-87 (FIPS 204) post-quantum + ECDSA-P384 cert signing, PBMAC1 PKCS#12 packaging, chain validation (no openssl CLI) |
324
+ | [`@blamejs/pki`](https://github.com/blamejs/pki) | 0.4.13 | [blamejs](https://github.com/blamejs) | Zero-dependency pure-JS X.509 / CRL / PKCS#12 / CSR / CMS toolkit backing `b.mtlsCa` — ML-DSA-87 (FIPS 204) post-quantum + ECDSA-P384 cert signing, PBMAC1 PKCS#12 packaging, chain validation (no openssl CLI) |
323
325
  | [`SecLists` 10k-most-common.txt](https://github.com/danielmiessler/SecLists/blob/master/Passwords/Common-Credentials/10k-most-common.txt) | master snapshot | [Daniel Miessler / SecLists contributors](https://github.com/danielmiessler/SecLists) (CC-BY-3.0) | Top-10000 common-password dictionary read by `b.auth.password.policy()` for the NIST 800-63B §5.1.1.2 "previously breached" check |
324
326
  | [`prismjs`](https://prismjs.com/) | 1.30.0 | [Lea Verou + contributors](https://github.com/PrismJS/prism) | Syntax highlighting in the example wiki's code blocks (browser-side) |
325
327
 
326
328
  These libraries are exceptional work — blamejs wouldn't exist without them. All are MIT licensed, except `@blamejs/pki` (Apache-2.0) and the SecLists password list (CC-BY-3.0). Per-package version, license, and provenance live in two manifests: [`lib/vendor/MANIFEST.json`](lib/vendor/MANIFEST.json) for the framework's server-side bundles and [`examples/wiki/public/vendor/MANIFEST.json`](examples/wiki/public/vendor/MANIFEST.json) for the wiki app's browser-side bundle. The framework's [`NOTICE`](NOTICE) file carries the upstream attributions.
327
329
 
330
+ A product with a browser surface can serve the ESM builds under [`lib/vendor/browser/`](lib/vendor/browser/) directly rather than vendoring the same upstream packages a second time:
331
+
332
+ ```js
333
+ import { ml_kem1024 } from "/vendor/blamejs/noble-post-quantum.mjs";
334
+ import { xchacha20poly1305 } from "/vendor/blamejs/noble-ciphers.mjs";
335
+ import { shake256 } from "/vendor/blamejs/noble-hashes.mjs";
336
+ ```
337
+
338
+ They are built from the same npm install as the server bundles in one `scripts/vendor-update.sh` run, and each pair shares one `version` field in the manifest — so the two halves of a hybrid exchange cannot be pinned to different releases of the same algorithm. They carry per-file SHA-256 hashes, are covered by the vendor-currency gate, and appear in the SBOM exactly as the server bundles do.
339
+
328
340
  ## Why "blamejs"
329
341
 
330
342
  Because when something breaks, `blame` should know exactly where it lives. We own the stack so you don't have to chase the fault across an ecosystem.
@@ -241,6 +241,7 @@ var guardOauth = require("./lib/guard-oauth");
241
241
  var guardGraphql = require("./lib/guard-graphql");
242
242
  var guardShell = require("./lib/guard-shell");
243
243
  var guardRegex = require("./lib/guard-regex");
244
+ var regexLinear = require("./lib/regex-linear");
244
245
  var guardJsonpath = require("./lib/guard-jsonpath");
245
246
  var guardTemplate = require("./lib/guard-template");
246
247
  var guardImage = require("./lib/guard-image");
@@ -636,6 +637,7 @@ module.exports = {
636
637
  guardGraphql: guardGraphql,
637
638
  guardShell: guardShell,
638
639
  guardRegex: guardRegex,
640
+ regexLinear: regexLinear,
639
641
  guardJsonpath: guardJsonpath,
640
642
  guardTemplate: guardTemplate,
641
643
  guardImage: guardImage,
@@ -428,7 +428,7 @@ function create(opts) {
428
428
  "_newNonce: directory must be fetched before signed requests", true);
429
429
  }
430
430
  var rsp = await _httpReq("HEAD", state.directory.newNonce, null);
431
- if (rsp.statusCode !== 200 && rsp.statusCode !== 204) {
431
+ if (rsp.statusCode !== C.HTTP.STATUS.OK && rsp.statusCode !== C.HTTP.STATUS.NO_CONTENT) {
432
432
  throw _err("acme/newnonce-failed",
433
433
  "newNonce HEAD returned " + rsp.statusCode, true, rsp.statusCode);
434
434
  }
@@ -463,7 +463,7 @@ function create(opts) {
463
463
 
464
464
  async function fetchDirectory() {
465
465
  var rsp = await _httpReq("GET", state.directoryUrl, null);
466
- if (rsp.statusCode !== 200) {
466
+ if (rsp.statusCode !== C.HTTP.STATUS.OK) {
467
467
  throw _err("acme/directory-fetch",
468
468
  "directory GET returned " + rsp.statusCode, true, rsp.statusCode);
469
469
  }
@@ -524,7 +524,7 @@ function create(opts) {
524
524
  };
525
525
  }
526
526
  var rsp = await _signedPost(state.directory.newAccount, payload, { useJwk: true });
527
- if (rsp.statusCode !== 200 && rsp.statusCode !== 201) {
527
+ if (rsp.statusCode !== C.HTTP.STATUS.OK && rsp.statusCode !== C.HTTP.STATUS.CREATED) {
528
528
  _emitAudit(audit, "acme.account.registered", "failure",
529
529
  { status: rsp.statusCode, reason: _extractProblemReason(rsp.body) });
530
530
  throw _err("acme/newaccount",
@@ -582,7 +582,7 @@ function create(opts) {
582
582
  "newOrder: profile must be a string when provided", true);
583
583
  }
584
584
  var rsp = await _signedPost(state.directory.newOrder, payload);
585
- if (rsp.statusCode !== 201) {
585
+ if (rsp.statusCode !== C.HTTP.STATUS.CREATED) {
586
586
  _emitAudit(audit, "acme.order.created", "failure",
587
587
  { status: rsp.statusCode, reason: _extractProblemReason(rsp.body) });
588
588
  throw _err("acme/neworder",
@@ -661,7 +661,7 @@ function create(opts) {
661
661
  current.url = order.url;
662
662
  }
663
663
  var certRsp = await _signedPost(current.certificate, null);
664
- if (certRsp.statusCode !== 200) {
664
+ if (certRsp.statusCode !== C.HTTP.STATUS.OK) {
665
665
  _emitAudit(audit, "acme.cert.issued", "failure",
666
666
  { orderUrl: order.url, status: certRsp.statusCode });
667
667
  throw _err("acme/cert-download",
@@ -980,7 +980,7 @@ function create(opts) {
980
980
  var certId = _b64u(ext.aki) + "." + _b64u(ext.serial);
981
981
  var ariUrl = state.directory.renewalInfo.replace(/\/+$/, "") + "/" + certId;
982
982
  var rsp = await _httpReq("GET", ariUrl, null);
983
- if (rsp.statusCode !== 200) {
983
+ if (rsp.statusCode !== C.HTTP.STATUS.OK) {
984
984
  throw _err("acme/ari-fetch",
985
985
  "ARI GET returned " + rsp.statusCode, true, rsp.statusCode);
986
986
  }
@@ -1132,7 +1132,7 @@ function create(opts) {
1132
1132
  "use account-key signing (the default, omit useCertKey)", true);
1133
1133
  }
1134
1134
  var rsp = await _signedPost(state.directory.revokeCert, payload, signedOpts);
1135
- if (rsp.statusCode !== 200) {
1135
+ if (rsp.statusCode !== C.HTTP.STATUS.OK) {
1136
1136
  _emitAudit(audit, "acme.cert.revoked", "failure",
1137
1137
  { status: rsp.statusCode, reason: _extractProblemReason(rsp.body) });
1138
1138
  throw _err("acme/revoke-failed",
@@ -1183,7 +1183,7 @@ function create(opts) {
1183
1183
  // a JSON *string* and every RFC 8555 §7.3.5 server rejects it.
1184
1184
  var innerJws = _signJws(newPrivateKey, innerProtected, innerPayload);
1185
1185
  var rsp = await _signedPost(state.directory.keyChange, innerJws);
1186
- if (rsp.statusCode !== 200) {
1186
+ if (rsp.statusCode !== C.HTTP.STATUS.OK) {
1187
1187
  _emitAudit(audit, "acme.account.key_rotated", "failure",
1188
1188
  { status: rsp.statusCode, reason: _extractProblemReason(rsp.body) });
1189
1189
  throw _err("acme/key-change-failed",
@@ -1214,7 +1214,7 @@ function create(opts) {
1214
1214
  throw _err("acme/no-account", "deactivateAccount: call newAccount() first", true);
1215
1215
  }
1216
1216
  var rsp = await _signedPost(state.accountUrl, { status: "deactivated" });
1217
- if (rsp.statusCode !== 200) {
1217
+ if (rsp.statusCode !== C.HTTP.STATUS.OK) {
1218
1218
  _emitAudit(audit, "acme.account.deactivated", "failure",
1219
1219
  { status: rsp.statusCode, reason: _extractProblemReason(rsp.body) });
1220
1220
  throw _err("acme/deactivate-failed",