@blamejs/core 0.18.40 → 0.18.41

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,24 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.18.x
10
10
 
11
+ - 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.
12
+
13
+ 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.
14
+
15
+ `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.
16
+
17
+ `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.
18
+
19
+ 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.
20
+
21
+ 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.
22
+
23
+ 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.
24
+
25
+ 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.
26
+
27
+ 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/)
28
+
11
29
  - 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
30
 
13
31
  The walk is now implemented, including the spec's denial-of-service bound and its rules for choosing the Organizational Domain.
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`)
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);
@@ -69,6 +69,7 @@
69
69
  * }
70
70
  */
71
71
  var C = require("../constants");
72
+ var cookies = require("../cookies");
72
73
  var lazyRequire = require("../lazy-require");
73
74
  var pick = require("../pick");
74
75
  var forms = require("../forms");
@@ -130,33 +131,6 @@ function _parseCookieHeader(header) {
130
131
  return Object.assign(Object.create(null), Object.fromEntries(pairs));
131
132
  }
132
133
 
133
- function _formatSetCookie(name, value, opts) {
134
- var parts = [name + "=" + value];
135
- parts.push("Path=" + (opts.path || "/"));
136
- parts.push("SameSite=" + (opts.sameSite || "Lax"));
137
- if (opts.httpOnly) parts.push("HttpOnly");
138
- if (opts.secure) parts.push("Secure");
139
- if (opts.maxAge != null) parts.push("Max-Age=" + opts.maxAge);
140
- return parts.join("; ");
141
- }
142
-
143
- function _appendSetCookie(res, value) {
144
- // Don't clobber other Set-Cookie headers the route may have already
145
- // queued (login session cookie, etc.). Use res.appendHeader when
146
- // available; else array-merge manually.
147
- if (typeof res.appendHeader === "function") {
148
- res.appendHeader("Set-Cookie", value);
149
- return;
150
- }
151
- var existing = typeof res.getHeader === "function" ? res.getHeader("Set-Cookie") : undefined;
152
- if (existing == null) {
153
- res.setHeader("Set-Cookie", value);
154
- } else if (Array.isArray(existing)) {
155
- res.setHeader("Set-Cookie", existing.concat(value));
156
- } else {
157
- res.setHeader("Set-Cookie", [existing, value]);
158
- }
159
- }
160
134
 
161
135
  // csrf-protect does NOT buffer or parse the request body itself.
162
136
  // Operators who use form-urlencoded POSTs MUST register
@@ -431,30 +405,44 @@ function create(opts) {
431
405
  if (["Lax", "Strict", "None"].indexOf(cookieCfg.sameSite) === -1) {
432
406
  throw new Error("middleware.csrfProtect: opts.cookie.sameSite must be Lax|Strict|None");
433
407
  }
434
- // Cookie name-prefix safety (RFC 6265bis §4.1.3). csrf-protect builds its
435
- // own Set-Cookie header rather than routing through b.cookies.serialize, so
436
- // this boot check is the only enforcement point. §5.4 requires user agents
437
- // to apply the prefix test case-INSENSITIVELY (the server-side §4.1.3
438
- // description reads "case-sensitive", but the UA is what drops the cookie),
439
- // so `__host-`/`__SECURE-` get the same browser enforcement as
440
- // `__Host-`/`__Secure-` -- case-sensitive matching was itself CVE-2024-5699.
441
- // Compare a lowercased copy so a case-variant name can't dodge the invariant
442
- // here and then be silently rejected by the browser. Catch typos at boot.
408
+ // Cookie name-prefix safety (RFC 6265bis §4.1.3). b.cookies.serialize
409
+ // enforces the same invariants when the header is built, but that is a
410
+ // per-request throw inside the middleware; catching the bad configuration
411
+ // at boot turns it into a startup error the operator can read. §5.4
412
+ // requires user agents to apply the prefix test case-INSENSITIVELY (the
413
+ // server-side §4.1.3 description reads "case-sensitive", but the UA is what
414
+ // drops the cookie), so `__host-`/`__SECURE-` get the same browser
415
+ // enforcement as `__Host-`/`__Secure-` -- case-sensitive matching was itself
416
+ // CVE-2024-5699. Compare a lowercased copy so a case-variant name can't
417
+ // dodge the invariant here and then be silently rejected by the browser.
443
418
  // __Host-* — Path must be "/", no Domain (we never set one), Secure.
444
419
  // __Secure-* — Secure.
445
420
  if (cookieCfg.name) {
446
421
  var lowerCookieName = cookieCfg.name.toLowerCase();
447
- if (lowerCookieName.indexOf("__host-") === 0) {
448
- if (cookieCfg.path !== "/") {
449
- throw new Error("middleware.csrfProtect: __Host-* cookie name requires path='/'");
450
- }
451
- if (cookieCfg.secure === false) {
452
- throw new Error("middleware.csrfProtect: __Host-* cookie name requires secure (cannot be explicit false)");
453
- }
454
- } else if (lowerCookieName.indexOf("__secure-") === 0) {
455
- if (cookieCfg.secure === false) {
456
- throw new Error("middleware.csrfProtect: __Secure-* cookie name requires secure (cannot be explicit false)");
457
- }
422
+ var isHostPrefix = lowerCookieName.indexOf("__host-") === 0;
423
+ var isSecurePrefix = lowerCookieName.indexOf("__secure-") === 0;
424
+ if (isHostPrefix && cookieCfg.path !== "/") {
425
+ throw new Error("middleware.csrfProtect: __Host-* cookie name requires path='/'");
426
+ }
427
+ if (isHostPrefix && cookieCfg.secure === false) {
428
+ throw new Error("middleware.csrfProtect: __Host-* cookie name requires secure (cannot be explicit false)");
429
+ }
430
+ if (isSecurePrefix && cookieCfg.secure === false) {
431
+ throw new Error("middleware.csrfProtect: __Secure-* cookie name requires secure (cannot be explicit false)");
432
+ }
433
+ // A prefixed name is a promise to the browser that the cookie is always
434
+ // Secure. Leaving `secure` to per-request auto-detection breaks that
435
+ // promise on any cleartext request: the cookie goes out prefixed and
436
+ // WITHOUT Secure, the browser drops it, and the double-submit token
437
+ // silently never persists. The name and the auto-detect cannot both
438
+ // stand — the operator picking the prefix is the one asserting HTTPS,
439
+ // so make them say it.
440
+ if ((isHostPrefix || isSecurePrefix) && cookieCfg.secure == null) {
441
+ throw new Error("middleware.csrfProtect: " +
442
+ (isHostPrefix ? "__Host-*" : "__Secure-*") +
443
+ " cookie name requires an explicit cookie.secure: true — " +
444
+ "auto-detected secure emits the prefix without Secure on a plain-HTTP " +
445
+ "request, which browsers reject");
458
446
  }
459
447
  }
460
448
  }
@@ -485,8 +473,8 @@ function create(opts) {
485
473
  function _issueIfNeeded(req, res) {
486
474
  if (!cookieCfg) return null;
487
475
  var cookieName = _resolveCookieName(req);
488
- var cookies = _parseCookieHeader(req.headers && req.headers.cookie);
489
- var existing = cookies[cookieName];
476
+ var requestCookies = _parseCookieHeader(req.headers && req.headers.cookie);
477
+ var existing = requestCookies[cookieName];
490
478
  // Strict 64-hex-char check matches the byte-length of every token
491
479
  // forms.generateCsrfToken() produces (CSRF_TOKEN_BYTES = 32 bytes
492
480
  // → 64 hex chars). The previous {2,} floor accepted any 2-char
@@ -522,14 +510,20 @@ function create(opts) {
522
510
  } catch (_e) { /* drop-silent */ }
523
511
  }
524
512
  var fresh = forms.generateCsrfToken();
525
- var setCookie = _formatSetCookie(cookieName, fresh, {
513
+ // b.cookies owns Set-Cookie: it validates the name as an RFC 6265 token,
514
+ // refuses CRLF / NUL in the value, scrubs the Path and Domain attributes
515
+ // before they reach a response header, and enforces the RFC 6265bis
516
+ // prefix invariants. The token is 64 hex characters, so the percent-
517
+ // encoding serialize applies to the value is a no-op on it and the cookie
518
+ // the browser echoes back still matches the double-submit compare.
519
+ var setCookie = cookies.serialize(cookieName, fresh, {
526
520
  path: cookieCfg.path,
527
521
  sameSite: cookieCfg.sameSite,
528
522
  secure: cookieCfg.secure == null ? _isHttps(req) : !!cookieCfg.secure,
529
523
  httpOnly: cookieCfg.httpOnly,
530
524
  maxAge: cookieCfg.maxAge,
531
525
  });
532
- _appendSetCookie(res, setCookie);
526
+ cookies.appendSetCookie(res, setCookie);
533
527
  req._csrfIssuedCookies[cookieName] = fresh;
534
528
  req.csrfToken = fresh;
535
529
  return fresh;
package/lib/migrations.js CHANGED
@@ -297,12 +297,12 @@ function create(opts) {
297
297
  var dir = opts.dir;
298
298
 
299
299
  function _appliedRows() {
300
- var db = _resolveDb(opts);
301
- _ensureTable(db);
302
- var q = sql.select(_migrationsTable(), _sqlOpts(db))
300
+ var conn = _resolveDb(opts);
301
+ _ensureTable(conn);
302
+ var q = sql.select(_migrationsTable(), _sqlOpts(conn))
303
303
  .columns(["name", "description", "appliedAt"])
304
304
  .orderBy("appliedAt", "asc").orderBy("name", "asc").toSql();
305
- var stmt = db.prepare(q.sql);
305
+ var stmt = conn.prepare(q.sql);
306
306
  return stmt.all.apply(stmt, q.params);
307
307
  }
308
308
 
@@ -319,11 +319,11 @@ function create(opts) {
319
319
  }
320
320
 
321
321
  function up() {
322
- var db = _resolveDb(opts);
323
- _ensureTable(db);
324
- return _withLock(db, opts, function () {
325
- var namesQ = sql.select(_migrationsTable(), _sqlOpts(db)).columns(["name"]).toSql();
326
- var namesStmt = db.prepare(namesQ.sql);
322
+ var conn = _resolveDb(opts);
323
+ _ensureTable(conn);
324
+ return _withLock(conn, opts, function () {
325
+ var namesQ = sql.select(_migrationsTable(), _sqlOpts(conn)).columns(["name"]).toSql();
326
+ var namesStmt = conn.prepare(namesQ.sql);
327
327
  var appliedSet = new Set(
328
328
  namesStmt.all.apply(namesStmt, namesQ.params)
329
329
  .map(function (r) { return r.name; })
@@ -336,12 +336,12 @@ function create(opts) {
336
336
  if (appliedSet.has(file)) { skipped.push(file); continue; }
337
337
  var mod = _loadMigration(file, dir);
338
338
  try {
339
- _txn(db, function () {
340
- mod.up(db);
341
- var insQ = sql.insert(_migrationsTable(), _sqlOpts(db))
339
+ _txn(conn, function () {
340
+ mod.up(conn);
341
+ var insQ = sql.insert(_migrationsTable(), _sqlOpts(conn))
342
342
  .values({ name: file, description: mod.description || "",
343
343
  appliedAt: new Date().toISOString() }).toSql();
344
- var insStmt = db.prepare(insQ.sql);
344
+ var insStmt = conn.prepare(insQ.sql);
345
345
  insStmt.run.apply(insStmt, insQ.params);
346
346
  });
347
347
  } catch (e) {
@@ -363,16 +363,16 @@ function create(opts) {
363
363
  "down: steps must be a positive integer (got " + opts2.steps + ")",
364
364
  true);
365
365
  }
366
- var db = _resolveDb(opts);
367
- _ensureTable(db);
368
- return _withLock(db, opts, function () {
366
+ var conn = _resolveDb(opts);
367
+ _ensureTable(conn);
368
+ return _withLock(conn, opts, function () {
369
369
  // Most-recent applied first (reverse chronological by appliedAt
370
370
  // then by name as a stable tiebreaker for fixtures with identical
371
371
  // timestamps). steps is a validated positive integer, so b.sql
372
372
  // inlines the LIMIT.
373
- var downQ = sql.select(_migrationsTable(), _sqlOpts(db)).columns(["name"])
373
+ var downQ = sql.select(_migrationsTable(), _sqlOpts(conn)).columns(["name"])
374
374
  .orderBy("appliedAt", "desc").orderBy("name", "desc").limit(steps).toSql();
375
- var downStmt = db.prepare(downQ.sql);
375
+ var downStmt = conn.prepare(downQ.sql);
376
376
  var rows = downStmt.all.apply(downStmt, downQ.params);
377
377
 
378
378
  var reverted = [];
@@ -386,10 +386,10 @@ function create(opts) {
386
386
  true);
387
387
  }
388
388
  try {
389
- _txn(db, function () {
390
- mod.down(db);
391
- var delQ = sql.delete(_migrationsTable(), _sqlOpts(db)).where("name", file).toSql();
392
- var delStmt = db.prepare(delQ.sql);
389
+ _txn(conn, function () {
390
+ mod.down(conn);
391
+ var delQ = sql.delete(_migrationsTable(), _sqlOpts(conn)).where("name", file).toSql();
392
+ var delStmt = conn.prepare(delQ.sql);
393
393
  delStmt.run.apply(delStmt, delQ.params);
394
394
  });
395
395
  } catch (e) {
package/lib/seeders.js CHANGED
@@ -452,11 +452,11 @@ function create(opts) {
452
452
  function status(callerOpts) {
453
453
  callerOpts = callerOpts || {};
454
454
  _validateEnv("seeders.status: env", callerOpts.env);
455
- var db = _resolveDb(opts);
456
- _ensureTables(db);
455
+ var conn = _resolveDb(opts);
456
+ _ensureTables(conn);
457
457
  var env = callerOpts.env;
458
458
  var loaded = _loadAllForEnv(dir, env);
459
- var applied = _appliedRows(db, env);
459
+ var applied = _appliedRows(conn, env);
460
460
  var appliedNames = new Set(applied.map(function (r) { return r.name; }));
461
461
  var pending = loaded.ordered.filter(function (n) {
462
462
  var mod = loaded.modByName[n];
@@ -491,8 +491,8 @@ function create(opts) {
491
491
  }
492
492
  }
493
493
 
494
- var db = _resolveDb(opts);
495
- _ensureTables(db);
494
+ var conn = _resolveDb(opts);
495
+ _ensureTables(conn);
496
496
 
497
497
  var loaded = _loadAllForEnv(dir, env);
498
498
 
@@ -504,11 +504,11 @@ function create(opts) {
504
504
  var startedAt = clock();
505
505
  observability().safeEvent("seeders.run.start", 1, { env: env, count: loaded.ordered.length });
506
506
 
507
- var holder = _acquireLock(db, lockStaleAfterMs, clock);
507
+ var holder = _acquireLock(conn, lockStaleAfterMs, clock);
508
508
  try {
509
- var appliedSelBuilt = sql.select(_seedersTable(), _sqlOpts(db))
509
+ var appliedSelBuilt = sql.select(_seedersTable(), _sqlOpts(conn))
510
510
  .columns(["name"]).where("env", env).toSql();
511
- var appliedSelStmt = db.prepare(appliedSelBuilt.sql);
511
+ var appliedSelStmt = conn.prepare(appliedSelBuilt.sql);
512
512
  var appliedSet = new Set(
513
513
  appliedSelStmt.all.apply(appliedSelStmt, appliedSelBuilt.params)
514
514
  .map(function (r) { return r.name; })
@@ -539,26 +539,26 @@ function create(opts) {
539
539
  // Per-seed transaction: SQLite txns are sync, but the seed's
540
540
  // run() may be async — runInTransactionAsync wraps BEGIN/COMMIT
541
541
  // around the awaited body and rolls back this seed only on failure.
542
- await dbSchema.runInTransactionAsync(db, async function () {
543
- await mod.run(db, ctx);
542
+ await dbSchema.runInTransactionAsync(conn, async function () {
543
+ await mod.run(conn, ctx);
544
544
  var nowIso = new Date(clock()).toISOString();
545
545
  var writeBuilt;
546
546
  if (alreadyApplied && mod.rerunnable) {
547
- writeBuilt = sql.update(_seedersTable(), _sqlOpts(db))
547
+ writeBuilt = sql.update(_seedersTable(), _sqlOpts(conn))
548
548
  .set({ appliedAt: nowIso, description: mod.description || "",
549
549
  rerunnable: mod.rerunnable ? 1 : 0 })
550
550
  .where("env", env).where("name", name).toSql();
551
551
  } else if (alreadyApplied && force) {
552
- writeBuilt = sql.update(_seedersTable(), _sqlOpts(db))
552
+ writeBuilt = sql.update(_seedersTable(), _sqlOpts(conn))
553
553
  .set({ appliedAt: nowIso, description: mod.description || "" })
554
554
  .where("env", env).where("name", name).toSql();
555
555
  } else {
556
- writeBuilt = sql.insert(_seedersTable(), _sqlOpts(db))
556
+ writeBuilt = sql.insert(_seedersTable(), _sqlOpts(conn))
557
557
  .values({ env: env, name: name, description: mod.description || "",
558
558
  appliedAt: nowIso, rerunnable: mod.rerunnable ? 1 : 0 })
559
559
  .toSql();
560
560
  }
561
- var writeStmt = db.prepare(writeBuilt.sql);
561
+ var writeStmt = conn.prepare(writeBuilt.sql);
562
562
  writeStmt.run.apply(writeStmt, writeBuilt.params);
563
563
  }, {
564
564
  onRollbackFail: function (rollbackErr) {
@@ -629,7 +629,7 @@ function create(opts) {
629
629
  }
630
630
  return result;
631
631
  } finally {
632
- _releaseLock(db, holder);
632
+ _releaseLock(conn, holder);
633
633
  }
634
634
  }
635
635
 
package/lib/session.js CHANGED
@@ -54,6 +54,7 @@ var validateOpts = require("./validate-opts");
54
54
  var cluster = require("./cluster");
55
55
  var clusterStorage = require("./cluster-storage");
56
56
  var C = require("./constants");
57
+ var cookies = require("./cookies");
57
58
  var { generateToken, sha3Hash } = require("./crypto");
58
59
  var cryptoField = require("./crypto-field");
59
60
  var frameworkSchema = require("./framework-schema");
@@ -410,7 +411,9 @@ function _hashFingerprint(sid, inputs) {
410
411
  * data: { roles: ["admin"] },
411
412
  * ttlMs: b.constants.TIME.hours(8),
412
413
  * });
413
- * res.setHeader("Set-Cookie", "sid=" + s.token + "; HttpOnly; Secure; SameSite=Strict");
414
+ * b.cookies.appendSetCookie(res, b.cookies.serialize("sid", s.token, {
415
+ * httpOnly: true, secure: true, sameSite: "Strict", path: "/",
416
+ * }));
414
417
  * // → { token: "9f2c…", expiresAt: 1735689600000 }
415
418
  */
416
419
  // Anonymous-session prefix. b.session.create({ anonymous: true })
@@ -770,7 +773,9 @@ async function verify(token, verifyOpts) {
770
773
  *
771
774
  * @example
772
775
  * await b.session.destroy(req.cookies.sid);
773
- * res.setHeader("Set-Cookie", "sid=; HttpOnly; Max-Age=0");
776
+ * b.cookies.appendSetCookie(res, b.cookies.serialize("sid", "", {
777
+ * httpOnly: true, sameSite: "Strict", path: "/", maxAge: 0,
778
+ * }));
774
779
  * res.end("logged out");
775
780
  * // → true
776
781
  */
@@ -798,13 +803,29 @@ async function destroy(token) {
798
803
  * this composes the secure-default logout the middleware otherwise had to be
799
804
  * mounted by hand. Returns whether a session was destroyed. Leader-only.
800
805
  *
806
+ * A browser deletes a cookie by MATCHING the expiry cookie's name, path and
807
+ * domain against the one in its jar, and it refuses a `Secure` cookie
808
+ * altogether when the response came over plain HTTP. So the expiry cookie has
809
+ * to describe the same scope the session cookie was written with, or the
810
+ * logout leaves it in place. Pass the `req` and the scheme is resolved through
811
+ * `b.requestHelpers.trustedProtocol` (a forwarded scheme counts only from a
812
+ * peer you declared trusted); pass `secure` to state it outright. With neither,
813
+ * the cookie is `Secure` — the secure default, unchanged.
814
+ *
801
815
  * @opts
802
- * cookieName: string, // default: "sid" — the session cookie to expire
803
- * types: string[], // default: the W3C Clear-Site-Data directive set
816
+ * cookieName: string, // default: "sid" — the session cookie to expire
817
+ * types: string[], // default: the W3C Clear-Site-Data directive set
818
+ * req: object, // resolve Secure from this request's scheme
819
+ * secure: boolean, // default: true — state the scheme outright
820
+ * sameSite: string, // default: "Strict" — Strict / Lax / None
821
+ * path: string, // default: "/" — must match the cookie's Path
822
+ * domain: string, // must match the cookie's Domain, if it had one
823
+ * trustedProxies: string | string[], // CIDRs, for the `req` scheme resolve
824
+ * protocolResolver: function(req), // own the scheme decision instead
804
825
  *
805
826
  * @example
806
827
  * app.post("/logout", async function (req, res) {
807
- * await b.session.logout(res, req.cookies.sid);
828
+ * await b.session.logout(res, req.cookies.sid, { req: req });
808
829
  * res.end("logged out");
809
830
  * });
810
831
  * // → emits Clear-Site-Data + expires the sid cookie + destroys the session
@@ -814,7 +835,19 @@ async function logout(res, token, opts) {
814
835
  throw new SessionError("session/bad-res",
815
836
  "b.session.logout: res must be an HTTP response with setHeader()");
816
837
  }
838
+ // The expiry cookie is queued with b.cookies.appendSetCookie, which needs to
839
+ // READ the response as well as write it. Assert that contract here, with the
840
+ // other validation, rather than discovering it at queue time: the queue
841
+ // happens after destroy(), so a throw there would leave the session revoked,
842
+ // Clear-Site-Data queued, no expiry cookie and a failed request. The
843
+ // response's shape is the caller's and fixed for the process, so it is
844
+ // knowable before any of that.
845
+ cookies.assertAppendable(res);
817
846
  opts = opts || {};
847
+ validateOpts(opts, [
848
+ "cookieName", "types", "req", "secure", "sameSite", "path", "domain",
849
+ "trustedProxies", "protocolResolver",
850
+ ], "b.session.logout");
818
851
  var cookieName = opts.cookieName === undefined ? "sid" : opts.cookieName;
819
852
  if (typeof cookieName !== "string" || cookieName.length === 0) {
820
853
  throw new SessionError("session/bad-cookie-name",
@@ -826,6 +859,19 @@ async function logout(res, token, opts) {
826
859
  // unknown directive throws here, queuing nothing.
827
860
  var clearSiteDataValue = csd.headerValue(types, "b.session.logout");
828
861
 
862
+ // Same ordering for the cookie: b.cookies.serialize validates the name, the
863
+ // attributes and the RFC 6265bis prefix invariants, so it is a throwing call
864
+ // and must run before the row is revoked — otherwise a `__Host-` typo leaves
865
+ // the session destroyed and the browser still holding its cookie.
866
+ var expiryCookie = cookies.serialize(cookieName, "", {
867
+ httpOnly: true,
868
+ secure: _logoutCookieSecure(opts, cookieName),
869
+ sameSite: opts.sameSite === undefined ? "Strict" : opts.sameSite,
870
+ path: opts.path === undefined ? "/" : opts.path,
871
+ domain: opts.domain,
872
+ maxAge: 0,
873
+ });
874
+
829
875
  // Revoke the server-side session FIRST. If destroy() throws (a follower
830
876
  // failing cluster.requireLeader(), or a store/DB error), no client-wipe
831
877
  // headers have been queued — an error response can't then expire the
@@ -836,12 +882,55 @@ async function logout(res, token, opts) {
836
882
  // Now wipe the client-side state: W3C Clear-Site-Data (cookies /
837
883
  // storage / cache) + expire the session cookie (belt-and-suspenders with the
838
884
  // "cookies" directive, and effective even if the client ignores the header).
885
+ // Append rather than set: a route that already queued a cookie of its own
886
+ // (a rotated CSRF token, a locale) keeps it.
839
887
  res.setHeader("Clear-Site-Data", clearSiteDataValue);
840
- res.setHeader("Set-Cookie",
841
- cookieName + "=; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=0");
888
+ cookies.appendSetCookie(res, expiryCookie);
842
889
  return destroyed;
843
890
  }
844
891
 
892
+ // Whether logout's expiry cookie carries Secure. Explicit `secure` wins; a
893
+ // `req` resolves through the peer-gated protocol helper; with neither the
894
+ // answer is the secure default. A browser drops a Secure cookie arriving over
895
+ // plain HTTP, so answering "true" for an HTTP deployment does not fail safe —
896
+ // it fails to clear the cookie at all.
897
+ function _logoutCookieSecure(opts, cookieName) {
898
+ if (opts.secure !== undefined) {
899
+ if (typeof opts.secure !== "boolean") {
900
+ throw new SessionError("session/bad-secure",
901
+ "b.session.logout: opts.secure must be a boolean");
902
+ }
903
+ // An explicit `false` against a `__Host-`/`__Secure-` name is a
904
+ // contradiction, and serialize() refuses it. That is deliberate: the
905
+ // operator stated both halves, it fails on the first logout in every
906
+ // environment, and no request can provoke or avoid it.
907
+ return opts.secure;
908
+ }
909
+ // A `__Host-` / `__Secure-` name is a statement about the COOKIE, not about
910
+ // this request: a browser will only ever have stored such a cookie on a
911
+ // secure origin, so on a cleartext request there is nothing of that name to
912
+ // clear. Letting the request's scheme resolve `secure` to false here would
913
+ // make serialize() refuse the name — and the cookie is built BEFORE the row
914
+ // is revoked, so that refusal would abort the logout entirely and whoever
915
+ // chose the scheme would decide whether the session died. The prefix wins.
916
+ var lowerName = cookieName.toLowerCase();
917
+ if (lowerName.indexOf("__host-") === 0 || lowerName.indexOf("__secure-") === 0) {
918
+ return true;
919
+ }
920
+ if (opts.req === undefined) return true;
921
+ if (opts.req === null || typeof opts.req !== "object") {
922
+ // trustedProtocol answers "http" for a non-request rather than throwing, so
923
+ // a mistyped `req` would quietly drop Secure. Refuse it instead.
924
+ throw new SessionError("session/bad-req",
925
+ "b.session.logout: opts.req must be an HTTP request object");
926
+ }
927
+ var resolver = requestHelpers.trustedProtocol({
928
+ trustedProxies: opts.trustedProxies,
929
+ protocolResolver: opts.protocolResolver,
930
+ });
931
+ return resolver.resolve(opts.req) === "https";
932
+ }
933
+
845
934
  async function _deleteBySidHash(sidHash) {
846
935
  var built = sql.delete(_sessionSqlTable(), _sessionSqlOpts())
847
936
  .where("sidHash", sidHash)
@@ -1057,7 +1146,9 @@ async function touch(token, opts) {
1057
1146
  * reason: "mfa",
1058
1147
  * });
1059
1148
  * if (rotated) {
1060
- * res.setHeader("Set-Cookie", "sid=" + rotated.token + "; HttpOnly; Secure; SameSite=Strict");
1149
+ * b.cookies.appendSetCookie(res, b.cookies.serialize("sid", rotated.token, {
1150
+ * httpOnly: true, secure: true, sameSite: "Strict", path: "/",
1151
+ * }));
1061
1152
  * }
1062
1153
  * // → { token: "7a1e…", expiresAt: 1735689600000 }
1063
1154
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.18.40",
3
+ "version": "0.18.41",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
@@ -75,7 +75,7 @@
75
75
  "check:vendor-currency": "node scripts/check-vendor-currency.js"
76
76
  },
77
77
  "devDependencies": {
78
- "esbuild": "0.28.1",
78
+ "esbuild": "0.28.2",
79
79
  "postject": "1.0.0-alpha.6"
80
80
  }
81
81
  }
package/sbom.cdx.json CHANGED
@@ -2,10 +2,10 @@
2
2
  "$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
3
3
  "bomFormat": "CycloneDX",
4
4
  "specVersion": "1.5",
5
- "serialNumber": "urn:uuid:8dddced8-3851-4a88-aefd-92c81c340d6b",
5
+ "serialNumber": "urn:uuid:a9c950dd-01b0-4088-b83e-2dfd36c75675",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-08-20T12:00:46.802Z",
8
+ "timestamp": "2026-08-20T14:26:30.885Z",
9
9
  "lifecycles": [
10
10
  {
11
11
  "phase": "build"
@@ -19,14 +19,14 @@
19
19
  }
20
20
  ],
21
21
  "component": {
22
- "bom-ref": "@blamejs/core@0.18.40",
22
+ "bom-ref": "@blamejs/core@0.18.41",
23
23
  "type": "application",
24
24
  "name": "blamejs",
25
- "version": "0.18.40",
25
+ "version": "0.18.41",
26
26
  "scope": "required",
27
27
  "author": "blamejs contributors",
28
28
  "description": "The Node framework that owns its stack.",
29
- "purl": "pkg:npm/%40blamejs/core@0.18.40",
29
+ "purl": "pkg:npm/%40blamejs/core@0.18.41",
30
30
  "properties": [],
31
31
  "externalReferences": [
32
32
  {
@@ -54,7 +54,7 @@
54
54
  "components": [],
55
55
  "dependencies": [
56
56
  {
57
- "ref": "@blamejs/core@0.18.40",
57
+ "ref": "@blamejs/core@0.18.41",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]