@blamejs/core 0.18.40 → 0.18.42

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.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,34 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.18.x
10
10
 
11
+ - v0.18.42 (2026-08-20) — **`b.middleware.botGuard` no longer refuses a request for omitting Accept-Language, which was returning 403 to every search-engine crawler.** `b.middleware.botGuard` treated a missing `Accept-Language` header as grounds for a 403 in its default blocking mode. Google documents that Googlebot sends requests without setting that header, and bingbot behaves the same, so every content page of a site running the default middleware chain answered 403 to a crawler while a browser was served normally. The header's absence is now an advisory signal, matching how the same middleware already treats a missing `Sec-Fetch-Mode`. **Changed:** *Vendored `@blamejs/pki` 0.5.16 to 0.5.17* — A failed integrity check now destroys the plaintext it had recovered rather than returning it, `AuthEnvelopedData` validates the attributes it is asked to authenticate, and the refusal for a content cipher in the wrong container cites the rule it actually applies — RFC 5083 §2 defines `AuthEnvelopedData` in terms of authenticated encryption and never names GCM, so the previous message sent operators to argue with a specification that says the opposite. **Fixed:** *A missing `Accept-Language` tags a request instead of refusing it* — The check was `if (!headers["accept-language"]) return "missing-accept-language"`, and `mode` defaults to `"block"`, so the request was answered 403. Whole client families omit the header — every major search-engine crawler, and with them uptime monitors, link previewers and feed readers — which made a site unreachable to all of them. On a deployment fronted by a cache this is easy to miss from the outside: the cached home page still answers 200, so the site looks partly indexed while every other URL is refused.
12
+
13
+ It now sets `req.suspectedBot` in `mode: "tag"` and never blocks, which is exactly how the middleware already treats a missing `Sec-Fetch-Mode` — absent for Safari before 16.4 and for every plain-HTTP non-localhost origin — and how `b.middleware.fetchMetadata` already treats a missing `Sec-Fetch-Site` through its `allowMissing` default. A header a whole client family omits is not evidence of automation.
14
+
15
+ Requests are still refused on positive evidence: the `User-Agent` deny-list (curl, wget, python-requests, axios, Go-http-client and the rest) is unchanged, and operators can extend it with `blockedAgents`. Separating a crawler from an abuser is a rate-limiting question rather than a header one, and `b.rateLimit` answers it.
16
+
17
+ If you were relying on the old behaviour, `mode: "tag"` surfaces the same signal on `req.suspectedBot` for you to act on. **Detectors:** *No single absent header may block, asserted against the middleware* — `bot-guard.test.js` now starts from a complete browser request and removes one header at a time, asserting that no single omission refuses the request. It covers the headers the middleware inspects — `User-Agent`, `Accept`, `Accept-Language`, `Accept-Encoding` and the three `Sec-Fetch-*` — so the rule holds for whichever one a future change reaches for, not only the one that was wrong.
18
+
19
+ It is a behavioural check rather than a source-pattern one deliberately. Written as a pattern check first, it was wrong six different ways: anchored to the first expression in the condition, bounded at the first `)`, bounded to a single line, defeated by a statement before the return, by a guard that was OR'd rather than AND'd, and by a `)` inside a string literal. Each fix was a step further into parsing JavaScript with text matching. Running the middleware decides all six, because it asks what the code does rather than how it is written. **References:** [How Google crawls locale-adaptive pages — Googlebot and Accept-Language](https://developers.google.com/search/docs/specialty/international/locale-adaptive-pages)
20
+
21
+ - v0.18.41 (2026-08-20) — **Logout can now clear the session cookie on a plain-HTTP origin, and every Set-Cookie the framework writes goes through one validating writer.** `b.session.logout` built its expiry cookie by string concatenation with `Secure` hardcoded. A browser refuses a `Secure` cookie that arrives over plain HTTP, so on a cleartext deployment the header was discarded and the session cookie the logout existed to clear stayed in the jar. `b.middleware.csrfProtect` had its own cookie formatter with the same shape and a different consequence: it interpolated the configured `cookie.path` into the response header with no CRLF scrub. Both now compose `b.cookies`, which is the framework's single Set-Cookie writer. **Added:** *`b.cookies.assertAppendable(res)`* — Throws unless a response can carry an appended `Set-Cookie` — the same check `appendSetCookie` performs, exposed so a caller can run it before doing something it cannot undo. A response has to be readable as well as writable to be appended to: without `appendHeader` the merge happens in the framework and needs to see what is already queued, and a response carrying only `setHeader` would have its existing cookies silently replaced. `b.session.logout` calls it up front, because it revokes the session row before queueing the expiry cookie — a refusal at queue time would leave the session destroyed, the browser still holding its cookie, and the request failing. · *`b.cookies.appendSetCookie(res, header)`* — Queues one `Set-Cookie` header without discarding the ones already queued. `res.setHeader("Set-Cookie", value)` replaces the header, so a route that issues a session cookie and then a CSRF cookie sends only the second; `Set-Cookie` is the one response header that is legitimately repeated. The appender uses `res.appendHeader` where the runtime offers it and array-merges where it does not.
22
+
23
+ It pairs with `b.cookies.serialize`, which validates and builds the header string. `b.cookies.create().write()` and `.clear()` compose both; reach for the two separately when you need to build the header at one point in a flow and queue it at another — validating early, emitting only once the side effect it accompanies has succeeded, which is what `b.session.logout` does with the session row. **Changed:** *`b.session.logout` queues its expiry cookie rather than overwriting the header* — It called `res.setHeader("Set-Cookie", ...)`, which discarded any cookie the route had already queued — a rotated CSRF token, a locale. It now appends. Code reading `res.getHeader("Set-Cookie")` after a logout sees an array of header strings rather than a single string; Node accepts either form on the way out.
24
+
25
+ `logout` also validates its options object, so a misspelled key is reported instead of silently ignored, and the expiry cookie is now built before the session row is revoked. Building it can fail — a `__Host-` name without `Secure` is refused — and a failure after the revoke would leave the session destroyed with the browser still holding its cookie. · *esbuild 0.28.1 to 0.28.2 (build tooling only)* — esbuild builds the single-file executable the bundler-output gate checks; it is a devDependency and does not ship. The published tarballs differ only in version strings, one new documented API option (`logStyle`), and the refreshed per-platform binary hash map, with a byte-identical `install.js` and an unchanged `postinstall` script. The reviewed SHA-256 for the two platforms the build runs on — CI's `linux-x64` and the maintainer host's `win32-x64` — are recorded in `scripts/esbuild-binary-pin.json` and verified against the binaries npm actually served. **Security:** *`b.session.logout` resolves the `Secure` attribute instead of hardcoding it* — The expiry cookie was emitted as `sid=; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=0`, with `Secure` present unconditionally. A user agent rejects a `Secure` cookie set from a non-secure origin, so on a plain-HTTP deployment neither this header nor the `Clear-Site-Data` queued beside it (also gated on a secure context) could clear the session cookie. The server-side row was already destroyed, so the revoked token was never usable — what remained was a cookie the user could not get rid of by logging out, sent on every later request.
26
+
27
+ `logout` now takes the transport as input. Pass `req` and the scheme is resolved through `b.requestHelpers.trustedProtocol`, which honours a forwarded scheme only from a peer you have declared trusted; pass `secure` to state it outright. With neither, the cookie is `Secure` — the previous behaviour, and still the default.
28
+
29
+ The cookie's scope is now describable too. A browser matches an expiry cookie on name, path and domain, so a session cookie written with `Domain=example.com` or `Path=/app` could not be cleared by an expiry cookie that named neither. `logout` accepts `path`, `domain` and `sameSite`. · *A configured CSRF cookie path can no longer split the response header* — `b.middleware.csrfProtect` formatted its own `Set-Cookie` and interpolated `cookie.path` into it directly. A configured path containing a bare CR or LF ended the header there and let whatever followed be read as a header of its own. The attributes now pass through `b.cookies.serialize`, which scrubs CR and NUL from `Domain` and `Path` before they reach the wire, validates the cookie name as an RFC 6265 token, and refuses CRLF, NUL, semicolon or comma in the value.
30
+
31
+ One further consequence of routing through it: a CSRF cookie configured `sameSite: "None"` now always carries `Secure`, which the SameSite specification requires and which the middleware's own formatter did not add. A `SameSite=None` cookie without `Secure` is rejected by browsers, so this affects a configuration that could not have been working. · *A `__Host-` or `__Secure-` CSRF cookie name now requires an explicit `cookie.secure: true`* — Those prefixes are a promise to the browser that the cookie is always `Secure`, and RFC 6265bis §4.1.3 has user agents drop a cookie that carries the prefix without it. Leaving `cookie.secure` to per-request auto-detection breaks that promise on any cleartext request: the cookie went out prefixed and without `Secure`, the browser discarded it, and the double-submit token silently never persisted — every request then looked like a first visit. The existing boot check could not see this, because the decision was made per request rather than at configuration time.
32
+
33
+ Configuring a prefixed name now fails at boot unless `cookie.secure: true` is set. A cookie name with no prefix keeps auto-detection, so the default configuration is unchanged. **Detectors:** *`Set-Cookie` may only be written by `b.cookies`* — A `codebase-patterns` entry refuses `setHeader`/`appendHeader` for `Set-Cookie` anywhere outside `lib/cookies.js`. Both defects above came from a file building the header itself and each losing a different guarantee, and one of them carried a comment recording that it did not route through `b.cookies.serialize`. · *A local may not reuse the name of a required module binding* — An eslint rule using scope analysis reports a `var`, `let` or `const` that takes the name of a module the file requires. `var` hoists, so such a local owns the name for the whole enclosing function — including the lines above its own declaration — and a call further down reads the local with no clue nearby that it is no longer the module. This is not hypothetical: it happened while making the change above, and surfaced as `cookies.serialize is not a function` where `cookies` was a parsed request jar.
34
+
35
+ Parameters are deliberately out of scope. They shadow the same way but are part of the signature the reader has just read, and naming a SQL-string parameter `sql` or a connection-handle parameter `db` is the clearest name available.
36
+
37
+ Seventeen further instances across `lib/` are resolved with it. Six were an inline `require` of a module the file already required at the top, and are removed rather than renamed. **References:** [RFC 6265bis §4.1.3 — Cookie Name Prefixes](https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis) · [W3C Clear-Site-Data](https://www.w3.org/TR/clear-site-data/)
38
+
11
39
  - v0.18.40 (2026-08-19) — **DMARC policy discovery now walks the DNS tree, so a policy at an intermediate label is no longer missed.** `b.mailAuth` resolved DMARC policy with a two-step lookup — the Author Domain, then the organizational domain from the Public Suffix List. RFC 9989 §4.10 specifies a tree walk that queries every ancestor in turn, and the difference is not academic: for `a.b.example.com` a `p=reject` published at `_dmarc.b.example.com` was never queried, evaluated as `none`, and mail the domain owner intended to reject was delivered.
12
40
 
13
41
  The walk is now implemented, including the spec's denial-of-service bound and its rules for choosing the Organizational Domain.
package/NOTICE CHANGED
@@ -68,7 +68,7 @@ Used for: FIPS 203 ML-KEM (ml_kem_512 / ml_kem_768 / ml_kem_1024),
68
68
  reference implementation.
69
69
  --------------------------------------------------------------------------------
70
70
  Component: @blamejs/pki
71
- Version: 0.5.16
71
+ Version: 0.5.17
72
72
  Source: https://github.com/blamejs/pki
73
73
  License: Apache-2.0
74
74
  Copyright: Copyright (c) blamejs contributors
package/README.md CHANGED
@@ -88,7 +88,7 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
88
88
  - Opaque-userId anonymous sessions via `create({ anonymous: true })`
89
89
  - Idle / absolute timeouts, fingerprint drift detection + anomaly scoring, brute-force lockout
90
90
  - Session-fixation rotation (`b.session.rotate`) re-keys the sid-bound device fingerprint to the new id — pass the same `{ req, fingerprintFields }` used at `create` (a fingerprint-bound session rotated without `req` is refused, so the binding can never silently break or false-drift)
91
- - One-call secure logout (`b.session.logout(res, token)`) destroys the session AND wipes client-side state — emits an W3C Clear-Site-Data header (cookies + storage + cache) and expires the session cookie before deleting the row
91
+ - One-call secure logout (`b.session.logout(res, token, { req })`) destroys the session AND wipes client-side state — revokes the row first, then emits a W3C Clear-Site-Data header (cookies + storage + cache) and an expiry cookie beside it. Pass `req` and the `Secure` attribute follows the request's scheme through `b.requestHelpers.trustedProtocol`, so the expiry cookie is not discarded by a browser on a plain-HTTP origin; `path` / `domain` / `sameSite` describe the cookie being cleared when it was written with a narrower scope
92
92
  - **Authorization** — RBAC + per-role DB binding + role-spec `requireMfa` + per-route MFA freshness window + ABAC predicate registry (`b.permissions`); API keys with rotation (`b.apiKey`)
93
93
  - **Workflow gates** — break-glass column gates with second-factor + audit (`b.breakGlass`); two-person-rule m-of-n approval with cooling-off lock + cancellation (`b.dualControl`)
94
94
  - **Financial / Open Banking** — FAPI 2.0 Final composite posture (PAR + PKCE-S256 + DPoP-or-mTLS + RFC 9207); runtime enforcement helpers `b.fapi2.assertCallback` (refuses missing iss + bare-param under message-signing) and `b.fapi2.assertAuthzRequest` (refuses non-JAR); CFPB §1033 / FDX 6.0 consumer-financial-data-sharing wrapper (`b.fdx`)
@@ -322,7 +322,7 @@ All runtime dependencies are committed to the repo — no transitive npm install
322
322
  | [`@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 |
323
323
  | [`@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` |
324
324
  | [`@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 |
325
- | [`@blamejs/pki`](https://github.com/blamejs/pki) | 0.5.16 | [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) — and the WebAuthn attestation / assertion verification behind `b.auth.passkey` |
325
+ | [`@blamejs/pki`](https://github.com/blamejs/pki) | 0.5.17 | [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) — and the WebAuthn attestation / assertion verification behind `b.auth.passkey` |
326
326
  | [`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 |
327
327
  | [`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) |
328
328
 
package/lib/acme.js CHANGED
@@ -100,16 +100,16 @@ function _publicJwkFromKeyObject(keyObject) {
100
100
  if (!keyObject || typeof keyObject.export !== "function") {
101
101
  throw _err("acme/bad-account-key", "accountKey must expose a Node KeyObject (export)", true);
102
102
  }
103
- var jwk;
104
- try { jwk = keyObject.export({ format: "jwk" }); }
103
+ var exported;
104
+ try { exported = keyObject.export({ format: "jwk" }); }
105
105
  catch (e) { throw _err("acme/bad-account-key", "accountKey export(jwk) failed: " + e.message, true); }
106
- if (!jwk || jwk.kty !== "EC" || jwk.crv !== "P-256") {
106
+ if (!exported || exported.kty !== "EC" || exported.crv !== "P-256") {
107
107
  throw _err("acme/bad-account-key",
108
108
  "accountKey must be a P-256 EC keypair (RFC 8555 §6.2 ES256); got kty=" +
109
- (jwk && jwk.kty) + " crv=" + (jwk && jwk.crv), true);
109
+ (exported && exported.kty) + " crv=" + (exported && exported.crv), true);
110
110
  }
111
111
  // RFC 7638 thumbprint inputs MUST be sorted alphabetically + minimal-JSON.
112
- return Object.freeze({ crv: jwk.crv, kty: jwk.kty, x: jwk.x, y: jwk.y });
112
+ return Object.freeze({ crv: exported.crv, kty: exported.kty, x: exported.x, y: exported.y });
113
113
  }
114
114
 
115
115
  function _jwkThumbprint(publicJwk) {
@@ -1247,7 +1247,6 @@ function create(opts) {
1247
1247
  throw _err("acme/bad-token", "tlsAlpn01KeyAuthorization: token must be a non-empty string", true);
1248
1248
  }
1249
1249
  var keyAuth = token + "." + _jwkThumbprint(publicJwk);
1250
- var nodeCrypto = require("node:crypto");
1251
1250
  return nodeCrypto.createHash("sha256").update(keyAuth, "utf8").digest();
1252
1251
  }
1253
1252
 
@@ -1348,7 +1347,6 @@ function create(opts) {
1348
1347
  throw _err("acme/bad-ttl",
1349
1348
  "dnsAccount01ChallengeRecord: ttl must be a positive integer <= 86400 seconds", true);
1350
1349
  }
1351
- var nodeCrypto = require("node:crypto");
1352
1350
  // Account label: lowercase base32 of first 10 bytes of SHA-256(accountUrl)
1353
1351
  // (per draft-ietf-acme-dns-account-label §3.1 — 80-bit truncated label).
1354
1352
  var hash = nodeCrypto.createHash("sha256").update(state.accountUrl, "utf8").digest();
package/lib/audit.js CHANGED
@@ -1963,8 +1963,8 @@ function generateActorBindingTriggerSql(opts) {
1963
1963
  */
1964
1964
  async function assertSegregation(opts) {
1965
1965
  opts = opts || {};
1966
- var db = opts.db || null;
1967
- if (!db || typeof db.query !== "function") {
1966
+ var externalDb = opts.db || null;
1967
+ if (!externalDb || typeof externalDb.query !== "function") {
1968
1968
  throw new AuditSegregationError("audit/segregation-no-db",
1969
1969
  "audit.assertSegregation: opts.db with a query() method is required");
1970
1970
  }
@@ -1976,11 +1976,11 @@ async function assertSegregation(opts) {
1976
1976
  // Operator-DB system-catalog introspection (Postgres pg_proc / pg_trigger,
1977
1977
  // $N-native, against the operator-supplied db.query) — not a framework
1978
1978
  // table, so b.sql's verb builders don't apply.
1979
- var fnRes = await db.query(
1979
+ var fnRes = await externalDb.query(
1980
1980
  "SELECT 1 FROM pg_proc WHERE proname = $1 LIMIT 1", [fnName] // allow:hand-rolled-sql
1981
1981
  );
1982
1982
  var fnPresent = !!(fnRes && fnRes.rows && fnRes.rows.length > 0);
1983
- var trigRes = await db.query(
1983
+ var trigRes = await externalDb.query(
1984
1984
  "SELECT 1 FROM pg_trigger WHERE tgname = $1 LIMIT 1", [trigName] // allow:hand-rolled-sql
1985
1985
  );
1986
1986
  var trigPresent = !!(trigRes && trigRes.rows && trigRes.rows.length > 0);
package/lib/auth/dpop.js CHANGED
@@ -219,16 +219,16 @@ async function buildProof(opts) {
219
219
  "alg '" + alg + "' is not supported by DPoP");
220
220
  }
221
221
 
222
- var jwk = opts.jwk || _publicJwkFromPrivate(key);
222
+ var proofKey = opts.jwk || _publicJwkFromPrivate(key);
223
223
  // Strip private parts from the embedded jwk if the operator passed a
224
224
  // private JWK by accident — ONLY public components belong in the proof.
225
225
  var pubJwk;
226
- if (jwk.kty === "EC") pubJwk = { kty: "EC", crv: jwk.crv, x: jwk.x, y: jwk.y };
227
- else if (jwk.kty === "OKP") pubJwk = { kty: "OKP", crv: jwk.crv, x: jwk.x };
228
- else if (jwk.kty === "RSA") pubJwk = { kty: "RSA", e: jwk.e, n: jwk.n };
229
- else if (jwk.kty === "AKP") pubJwk = { kty: "AKP", alg: jwk.alg, pub: jwk.pub };
226
+ if (proofKey.kty === "EC") pubJwk = { kty: "EC", crv: proofKey.crv, x: proofKey.x, y: proofKey.y };
227
+ else if (proofKey.kty === "OKP") pubJwk = { kty: "OKP", crv: proofKey.crv, x: proofKey.x };
228
+ else if (proofKey.kty === "RSA") pubJwk = { kty: "RSA", e: proofKey.e, n: proofKey.n };
229
+ else if (proofKey.kty === "AKP") pubJwk = { kty: "AKP", alg: proofKey.alg, pub: proofKey.pub };
230
230
  else throw new AuthError("auth-dpop/refused-kty",
231
- "jwk.kty='" + jwk.kty + "' is not allowed");
231
+ "jwk.kty='" + proofKey.kty + "' is not allowed");
232
232
 
233
233
  var jti = opts.jti || _b64urlEncode(nodeCrypto.randomBytes(C.BYTES.bytes(16)));
234
234
  var nowMs = (typeof opts.iat === "number" ? opts.iat * C.TIME.seconds(1) : Date.now());
package/lib/cert.js CHANGED
@@ -514,22 +514,22 @@ function create(opts) {
514
514
  }
515
515
 
516
516
  async function _issueCert(certManifest) {
517
- var acme = _bootAcme();
517
+ var acmeClient = _bootAcme();
518
518
  // 1. Fetch directory + ensure ACME account exists.
519
- await acme.fetchDirectory();
520
- await acme.newAccount({
519
+ await acmeClient.fetchDirectory();
520
+ await acmeClient.newAccount({
521
521
  contact: opts.acme.contactEmail ? ["mailto:" + opts.acme.contactEmail] : undefined,
522
522
  termsOfServiceAgreed: true,
523
523
  });
524
524
  // 2. Create the order.
525
- var order = await acme.newOrder({
525
+ var order = await acmeClient.newOrder({
526
526
  identifiers: certManifest.domains.map(function (d) {
527
527
  return { type: "dns", value: d };
528
528
  }),
529
529
  });
530
530
  // 3. For each authorization, solve the operator-supplied challenge.
531
531
  for (var ai = 0; ai < order.authorizations.length; ai += 1) {
532
- var auth = await acme.fetchAuthorization(order.authorizations[ai]);
532
+ var auth = await acmeClient.fetchAuthorization(order.authorizations[ai]);
533
533
  if (auth.status === "valid") continue;
534
534
  var challenge = auth.challenges.find(function (ch) {
535
535
  return ch.type === certManifest.challenge.type;
@@ -541,8 +541,8 @@ function create(opts) {
541
541
  }
542
542
  // tls-alpn-01 has a different key-authorization shape (RFC 8737).
543
543
  var keyAuth = certManifest.challenge.type === "tls-alpn-01"
544
- ? acme.tlsAlpn01KeyAuthorization(challenge.token)
545
- : acme.keyAuthorization(challenge.token);
544
+ ? acmeClient.tlsAlpn01KeyAuthorization(challenge.token)
545
+ : acmeClient.keyAuthorization(challenge.token);
546
546
  var provisionParams = {
547
547
  domain: auth.identifier.value,
548
548
  type: challenge.type,
@@ -551,8 +551,8 @@ function create(opts) {
551
551
  };
552
552
  await certManifest.challenge.provision(provisionParams);
553
553
  try {
554
- await acme.notifyChallengeReady(challenge.url);
555
- await acme.waitForAuthorization(order.authorizations[ai]);
554
+ await acmeClient.notifyChallengeReady(challenge.url);
555
+ await acmeClient.waitForAuthorization(order.authorizations[ai]);
556
556
  } finally {
557
557
  try { await certManifest.challenge.cleanup(provisionParams); }
558
558
  catch (cleanupErr) {
@@ -568,13 +568,13 @@ function create(opts) {
568
568
  }
569
569
  // 4. Generate leaf keypair + CSR + finalize.
570
570
  var leafPair = _generateLeafKeypair(certManifest.keyAlg);
571
- var csrPem = acme.buildCsr({
571
+ var csrPem = acmeClient.buildCsr({
572
572
  privateKey: leafPair.privateKey,
573
573
  publicKey: leafPair.publicKey,
574
574
  domains: certManifest.domains,
575
575
  });
576
- var finalized = await acme.finalize(order, csrPem);
577
- var certPem = await acme.retrieveCert(finalized);
576
+ var finalized = await acmeClient.finalize(order, csrPem);
577
+ var certPem = await acmeClient.retrieveCert(finalized);
578
578
  var privPem = leafPair.privateKey.export({ type: "pkcs8", format: "pem" });
579
579
  return { certPem: certPem, keyPem: privPem };
580
580
  }
@@ -200,15 +200,15 @@ function parseUn1267Entry(entry) {
200
200
  if (!entry || typeof entry !== "object") return null;
201
201
  var name = entry.NAME || entry.name || entry.FIRST_NAME || "";
202
202
  if (!name) return null;
203
- var aliases = [];
204
- if (Array.isArray(entry.ALIASES)) aliases = entry.ALIASES.slice();
203
+ var entryAliases = [];
204
+ if (Array.isArray(entry.ALIASES)) entryAliases = entry.ALIASES.slice();
205
205
  else if (typeof entry.ALIAS_NAMES === "string") {
206
- aliases = entry.ALIAS_NAMES.split(";").map(function (s) { return s.trim(); }).filter(Boolean);
206
+ entryAliases = entry.ALIAS_NAMES.split(";").map(function (s) { return s.trim(); }).filter(Boolean);
207
207
  }
208
208
  return {
209
209
  id: "UN-1267-" + String(entry.REFERENCE_NUMBER || entry.DATAID || ""),
210
210
  primaryName: String(name).trim(),
211
- aliases: aliases,
211
+ aliases: entryAliases,
212
212
  type: entry.NAME_TYPE === "Entity" ? "entity" : "individual",
213
213
  programs: ["UN-1267"],
214
214
  country: entry.COUNTRY || entry.NATIONALITY || null,
package/lib/cookies.js CHANGED
@@ -324,18 +324,100 @@ function serialize(name, value, attrs) {
324
324
  return parts.join("; ");
325
325
  }
326
326
 
327
- // Append a Set-Cookie header preserving any already on the response.
328
- function _appendSetCookie(res, header) {
327
+ /**
328
+ * @primitive b.cookies.appendSetCookie
329
+ * @signature b.cookies.appendSetCookie(res, header)
330
+ * @since 0.18.41
331
+ * @status stable
332
+ * @related b.cookies.serialize, b.cookies.create
333
+ *
334
+ * Queue one Set-Cookie header on a response without discarding the ones
335
+ * already queued. `res.setHeader("Set-Cookie", value)` REPLACES the header,
336
+ * so a second cookie written that way silently drops the first — a route
337
+ * that issues a session cookie and then a CSRF cookie ends up sending only
338
+ * the CSRF one. Set-Cookie is the one response header that is legitimately
339
+ * repeated, and this is the framework's single appender for it: it uses
340
+ * `res.appendHeader` where the runtime offers it, and falls back to reading
341
+ * the current value and array-merging where it doesn't.
342
+ *
343
+ * The header string must already be serialized — pair it with
344
+ * `b.cookies.serialize`, which validates the name, value and attributes.
345
+ * `b.cookies.create().write()` / `.clear()` compose both for you; reach for
346
+ * this directly when you need to build the header at one point in a flow and
347
+ * queue it at another (validating early, emitting only after the side effect
348
+ * it accompanies has succeeded).
349
+ *
350
+ * @example
351
+ * var header = b.cookies.serialize("sid", "", {
352
+ * httpOnly: true, secure: true, sameSite: "Strict", path: "/", maxAge: 0,
353
+ * });
354
+ * b.cookies.appendSetCookie(res, header);
355
+ * // → res now carries this expiry cookie alongside any already queued
356
+ */
357
+ /**
358
+ * @primitive b.cookies.assertAppendable
359
+ * @signature b.cookies.assertAppendable(res)
360
+ * @since 0.18.41
361
+ * @status stable
362
+ * @related b.cookies.appendSetCookie, b.session.logout
363
+ *
364
+ * Throw unless `res` can carry an appended `Set-Cookie`. This is the same
365
+ * check `b.cookies.appendSetCookie` performs, exposed so a caller can run it
366
+ * BEFORE doing something it cannot undo.
367
+ *
368
+ * A response has to be readable as well as writable to be appended to: without
369
+ * `appendHeader`, the merge is done here and needs to see what is already
370
+ * queued. Discovering that late is the problem this exists to prevent —
371
+ * `b.session.logout` revokes the session row before it queues the expiry
372
+ * cookie, so a throw at queue time would leave the session destroyed, the
373
+ * client still holding its cookie, and the request failing. The response's
374
+ * shape is fixed for the life of the process and owned by the caller, not by
375
+ * the request, so it can and should be asserted up front.
376
+ *
377
+ * @example
378
+ * b.cookies.assertAppendable(res); // throws on a write-only response
379
+ * await doSomethingIrreversible();
380
+ * b.cookies.appendSetCookie(res, header);
381
+ * // → the append cannot fail for a reason that was knowable earlier
382
+ */
383
+ function assertAppendable(res) {
329
384
  if (!res || typeof res.setHeader !== "function") {
330
385
  throw new CookieError("cookies/no-set-header",
331
386
  "response object has no setHeader (not a Node http.ServerResponse?)");
332
387
  }
333
- var existing;
334
- if (typeof res.getHeader === "function") existing = res.getHeader("Set-Cookie");
388
+ // Without appendHeader the merge has to be done here, which means reading
389
+ // what is already queued. A response that can be written but not READ — a
390
+ // thin adapter or a test double carrying only setHeader — cannot be appended
391
+ // to at all: treating the unreadable value as absent would overwrite a cookie
392
+ // the route had already queued, which is the precise loss the appender exists
393
+ // to prevent. Refuse instead of silently doing the damage.
394
+ if (typeof res.appendHeader !== "function" && typeof res.getHeader !== "function") {
395
+ throw new CookieError("cookies/unreadable-response",
396
+ "response exposes setHeader but neither appendHeader nor getHeader, so an " +
397
+ "already-queued Set-Cookie cannot be read and would be replaced. Give the " +
398
+ "response a getHeader (or appendHeader) implementation.");
399
+ }
400
+ }
401
+
402
+ function appendSetCookie(res, header) {
403
+ assertAppendable(res);
404
+ if (typeof header !== "string" || header.length === 0) {
405
+ throw new CookieError("cookies/invalid-header",
406
+ "appendSetCookie: header must be a non-empty serialized Set-Cookie string");
407
+ }
408
+ // Node >= 18 exposes appendHeader, which handles the multi-value merge
409
+ // itself; prefer it so the response's own bookkeeping stays authoritative.
410
+ if (typeof res.appendHeader === "function") {
411
+ res.appendHeader("Set-Cookie", header);
412
+ return;
413
+ }
414
+ // assertAppendable has already established that getHeader exists when
415
+ // appendHeader does not, so the merge below can read the response.
416
+ var existing = res.getHeader("Set-Cookie");
335
417
  var arr;
336
- if (Array.isArray(existing)) arr = existing.slice();
337
- else if (existing !== undefined) arr = [existing];
338
- else arr = [];
418
+ if (Array.isArray(existing)) arr = existing.slice();
419
+ else if (existing !== undefined && existing !== null) arr = [existing];
420
+ else arr = [];
339
421
  arr.push(header);
340
422
  res.setHeader("Set-Cookie", arr);
341
423
  }
@@ -402,7 +484,7 @@ function create(opts) {
402
484
 
403
485
  function read(req, name) { return _readCookieFromReq(req, name); }
404
486
  function write(res, name, value, attrs) {
405
- _appendSetCookie(res, serialize(name, value, _mergeAttrs(attrs)));
487
+ appendSetCookie(res, serialize(name, value, _mergeAttrs(attrs)));
406
488
  }
407
489
  function clear(res, name, attrs) {
408
490
  // Expire-now cookie. Domain + Path must match the original write
@@ -410,7 +492,7 @@ function create(opts) {
410
492
  // attrs they used on write (or rely on the same defaults).
411
493
  var attrsExp = Object.assign({}, _mergeAttrs(attrs), { maxAge: 0 });
412
494
  delete attrsExp.expires;
413
- _appendSetCookie(res, serialize(name, "", attrsExp));
495
+ appendSetCookie(res, serialize(name, "", attrsExp));
414
496
  }
415
497
 
416
498
  function _requireVault() {
@@ -575,9 +657,11 @@ function parseSafe(cookieHeader, opts) {
575
657
  }
576
658
 
577
659
  module.exports = {
578
- create: create,
579
- parse: parse,
580
- parseSafe: parseSafe,
581
- serialize: serialize,
582
- CookieError: CookieError,
660
+ create: create,
661
+ parse: parse,
662
+ parseSafe: parseSafe,
663
+ serialize: serialize,
664
+ appendSetCookie: appendSetCookie,
665
+ assertAppendable: assertAppendable,
666
+ CookieError: CookieError,
583
667
  };
@@ -851,9 +851,7 @@ function _attachJarCookie(headers, jar, url) {
851
851
  function _buildMultipartBody(spec) {
852
852
  var boundary = "----blamejs-mp-" + bCrypto.generateToken(C.BYTES.bytes(16));
853
853
  var CRLF = "\r\n";
854
- var nodeFs = require("node:fs"); // allow:inline-require — only on multipart paths that touch the filesystem
855
- var path = require("node:path"); // allow:inline-require — same
856
- var nodeStream = require("node:stream"); // allow:inline-require — Readable subclass only when streaming
854
+ var path = require("node:path"); // allow:inline-require — only on multipart paths that touch the filesystem
857
855
 
858
856
  // Each entry is { headerBytes, source } where source is one of:
859
857
  // { kind: "buffer", buf: Buffer }
package/lib/mail-auth.js CHANGED
@@ -334,11 +334,11 @@ function _ipv6Expand(ip) {
334
334
 
335
335
  function _ipv6InCidr(ip, cidr) {
336
336
  var slash = cidr.indexOf("/");
337
- var net = slash === -1 ? cidr : cidr.slice(0, slash);
337
+ var networkAddr = slash === -1 ? cidr : cidr.slice(0, slash);
338
338
  var mask = slash === -1 ? 128 : parseInt(cidr.slice(slash + 1), 10); // IPv6 max prefix
339
339
  if (!isFinite(mask) || mask < 0 || mask > 128) return false; // IPv6 max prefix
340
340
  var ipGroups = _ipv6Expand(ip);
341
- var netGroups = _ipv6Expand(net);
341
+ var netGroups = _ipv6Expand(networkAddr);
342
342
  if (!ipGroups || !netGroups) return false;
343
343
  if (mask === 0) return true;
344
344
  // Compare group-by-group up to the prefix boundary.
@@ -356,11 +356,11 @@ function _ipv6InCidr(ip, cidr) {
356
356
 
357
357
  function _ipv4InCidr(ip, cidr) {
358
358
  var slash = cidr.indexOf("/");
359
- var net = slash === -1 ? cidr : cidr.slice(0, slash);
359
+ var networkAddr = slash === -1 ? cidr : cidr.slice(0, slash);
360
360
  var mask = slash === -1 ? 32 : parseInt(cidr.slice(slash + 1), 10); // IPv4 max prefix
361
361
  if (!isFinite(mask) || mask < 0 || mask > 32) return false; // IPv4 max prefix
362
362
  var ipInt = _ipv4ToInt(ip);
363
- var netInt = _ipv4ToInt(net);
363
+ var netInt = _ipv4ToInt(networkAddr);
364
364
  if (ipInt === null || netInt === null) return false;
365
365
  if (mask === 0) return true;
366
366
  var bits = 32 - mask; // IPv4 max prefix
@@ -2395,7 +2395,6 @@ function _pemFromB64KeyMaterial(b64) {
2395
2395
  }
2396
2396
 
2397
2397
  function _runVerify(signedString, sigB64, algorithm, keyB64, label) {
2398
- var nodeCrypto = require("node:crypto");
2399
2398
  var pem = _pemFromB64KeyMaterial(keyB64);
2400
2399
  var keyObj;
2401
2400
  try { keyObj = nodeCrypto.createPublicKey(pem); }
package/lib/mail.js CHANGED
@@ -248,10 +248,9 @@ async function reverseDns(ip) {
248
248
  // the original input. RFC 8601 §3 says the forward query must use
249
249
  // the same family as the source; mismatched families don't count
250
250
  // as confirmation.
251
- var net = require("node:net");
252
251
  var forwardAddrs = [];
253
252
  try {
254
- if (net.isIPv6(ip)) {
253
+ if (net().isIPv6(ip)) {
255
254
  forwardAddrs = await dns.resolveAaaa(ptrName);
256
255
  } else {
257
256
  forwardAddrs = await dns.resolve4(ptrName);
@@ -7,7 +7,13 @@
7
7
  * authentication, but catches drive-by scrapers and most low-effort bots.
8
8
  *
9
9
  * Heuristics (all combined):
10
- * - Missing Accept-Language header (real browsers always send one)
10
+ * - Missing Accept-Language header ADVISORY ONLY (never blocks). Tagged
11
+ * in mode:"tag". It cannot block because the header is absent for entire
12
+ * client families: every major search-engine crawler omits it (Google
13
+ * documents that Googlebot "sends HTTP requests without setting
14
+ * Accept-Language"), as do uptime monitors, link previewers and feed
15
+ * readers — a 403 on it alone made a site's every page unreachable to
16
+ * Googlebot while a browser sailed through.
11
17
  * - Missing Sec-Fetch-Mode header — ADVISORY ONLY (never blocks). Tagged
12
18
  * in mode:"tag" on secure-context HTML GETs where a modern browser
13
19
  * would have sent it. It cannot block because the header is absent for
@@ -90,13 +96,15 @@ function _coerceAgentPattern(r, where) {
90
96
  * Cheap fingerprint-based detection of obviously-non-browser requests.
91
97
  * Constructed via `b.middleware.botGuard(opts)`; the resulting
92
98
  * middleware has the `(req, res, next)` shape shown above.
93
- * Two blocking heuristicsmissing `Accept-Language` and a User-Agent
94
- * regex match against a default list (curl / wget / python-requests /
95
- * axios / etc.) plus one advisory signal: a missing `Sec-Fetch-Mode`
96
- * on a secure-context HTML GET sets `req.suspectedBot` in `mode: "tag"`
97
- * but NEVER blocks (the header is absent for Safari < 16.4 and every
98
- * plain-HTTP non-localhost origin, so blocking on it refuses real
99
- * users). Not
99
+ * One blocking heuristica User-Agent regex match against a default
100
+ * list (curl / wget / python-requests / axios / etc.) — plus two
101
+ * advisory signals that set `req.suspectedBot` in `mode: "tag"` and
102
+ * NEVER block: a missing `Accept-Language` (absent from every major
103
+ * search-engine crawler, so blocking on it makes a site unreachable to
104
+ * them) and a missing `Sec-Fetch-Mode` on a secure-context HTML GET
105
+ * (absent for Safari < 16.4 and every plain-HTTP non-localhost origin).
106
+ * A header a whole client family omits is not evidence of automation.
107
+ * Not
100
108
  * a substitute for proper authentication — catches drive-by scrapers
101
109
  * and low-effort bots. In `mode: "block"` (default) the request is
102
110
  * refused; in `mode: "tag"` `req.suspectedBot = true` is set and the
@@ -206,14 +214,29 @@ function create(opts) {
206
214
  // Skip browser-fingerprint checks for API routes
207
215
  return null;
208
216
  }
209
- if (!headers["accept-language"]) return "missing-accept-language";
217
+ // Missing Accept-Language NEVER blocks, for the reason the Sec-Fetch-Mode
218
+ // check below does not either: the header is absent for entire CLIENT
219
+ // families, so a 403 on it alone refuses them wholesale. Every major
220
+ // search-engine crawler omits it — Google documents that Googlebot "sends
221
+ // HTTP requests without setting Accept-Language in the request header",
222
+ // and bingbot behaves the same — so blocking on it made every content page
223
+ // of a blamejs site answer 403 to a crawler while a browser sailed
224
+ // through. Measured on a live deployment: a 337-URL sitemap of which only
225
+ // the cached homepage was reachable. Uptime monitors, link previewers and
226
+ // feed readers are refused the same way.
227
+ //
228
+ // It survives as an advisory TAG, so an operator can still rate-limit or
229
+ // log on it. Automation libraries remain blocked by the User-Agent
230
+ // deny-list, which is what actually distinguishes them; separating a
231
+ // crawler from an abuser is a rate-limiting question, not a header one.
232
+ if (mode === "tag" && !headers["accept-language"]) return "missing-accept-language";
210
233
  // Missing Sec-Fetch-Mode NEVER blocks: the header is absent for entire
211
234
  // browser families (Safari < 16.4 omits Fetch Metadata even over HTTPS)
212
235
  // and for every plain-HTTP non-localhost origin (Umbrel, LAN / *.local
213
236
  // reverse proxies), so a 403 on it alone refuses real users. It survives
214
237
  // only as an advisory TAG in mode:"tag", and even then only in a secure
215
238
  // context where a modern browser would have sent it. Drive-by bots are
216
- // still blocked by missing Accept-Language + the User-Agent deny-list.
239
+ // still blocked by the User-Agent deny-list.
217
240
  if (mode === "tag" && req.method === "GET" && _isSecureContext(req) && !headers["sec-fetch-mode"]) return "missing-sec-fetch-mode";
218
241
  return null;
219
242
  }