@blamejs/core 0.17.23 → 0.18.0
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 +11 -5
- package/NOTICE +12 -13
- package/README.md +3 -3
- package/lib/audit.js +69 -1
- package/lib/auth/oauth.js +57 -0
- package/lib/cli.js +15 -5
- package/lib/daemon.js +192 -2
- package/lib/guard-tenant-id.js +12 -1
- package/lib/mtls-ca.js +173 -18
- package/lib/mtls-engine-default.js +339 -314
- package/lib/outbox.js +43 -13
- package/lib/redact.js +39 -6
- package/lib/safe-json.js +9 -1
- package/lib/self-update.js +215 -24
- package/lib/vendor/MANIFEST.json +27 -30
- package/lib/vendor/blamejs-pki.cjs +28419 -0
- package/lib/webhook-dispatcher.js +55 -24
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
- package/lib/vendor/pki.cjs +0 -39718
package/CHANGELOG.md
CHANGED
|
@@ -6,8 +6,14 @@ Pre-1.0 the surface is intentionally evolving — every release may
|
|
|
6
6
|
change something operators depend on. Read each entry before
|
|
7
7
|
upgrading across more than a few patches at a time.
|
|
8
8
|
|
|
9
|
+
## v0.18.x
|
|
10
|
+
|
|
11
|
+
- v0.18.0 (2026-07-26) — **The mTLS CA engine moves to the zero-dependency @blamejs/pki toolkit and issues post-quantum ML-DSA-87 certificates by default; the @peculiar/x509 + pkijs bundle is removed.** b.mtlsCa's default certificate engine is rebuilt on the vendored, zero-runtime-dependency @blamejs/pki toolkit, replacing the @peculiar/x509 + pkijs meta-bundle (which is removed entirely). CA and client certificates are now issued under ML-DSA-87 (FIPS 204) by default: node:tls verifies ML-DSA certificate chains and the CertificateVerify signature on the supported Node LTS (OpenSSL 3.5), so a post-quantum client certificate completes a real mutual-auth handshake -- not just issuance. Operators whose mTLS peers are not yet on OpenSSL 3.5 pass b.mtlsCa.create({ algorithm: "ECDSA-P384-SHA384" }) for a universally-interoperable classical CA -- the pin covers both the CA and every leaf it issues, and never changes the default other CAs use; SLH-DSA is intentionally not offered for TLS. A PKCS#12 export from the PQC default carries a PBMAC1 integrity MAC (RFC 9579); a classical-bridge export uses the traditional RFC 7292 MacData so a pre-OpenSSL-3.5 peer -- the reason to choose the bridge -- can still verify it. Alongside the engine, this release closes a batch of primitive-hardening issues and adds a public PKCE generator; the vendor-currency gate now hard-fails a stale or unverifiable @blamejs/pki so the vendored copy can never lag the latest published release. **Added:** *b.auth.oauth.generatePkce -- a public PKCE (RFC 7636) generator* — b.auth.oauth.generatePkce() returns a { verifier, challenge } pair for a hand-rolled authorization-code flow that does not go through create(). The verifier is 43 base64url characters (32 CSPRNG bytes) and the code_challenge_method is always S256 (the challenge is base64url of SHA-256 over the verifier). · *b.audit.useStore redirect mode* — b.audit.useStore({ record, replaceChain: true }) routes framework audit events directly into a consumer-supplied store, skipping the b.db tamper-evident chain append. A consumer with no initialized b.db can now receive audit events (via record() and emit()/safeEmit()) instead of getting a db-not-initialized error or having events silently dropped. The default remains shadow-replication with the framework chain authoritative. **Changed:** *The default mTLS CA engine is @blamejs/pki with an ML-DSA-87 post-quantum default* — lib/mtls-engine-default.js (b.mtlsCa's default engine) is rebuilt on the vendored zero-dependency @blamejs/pki toolkit. CA and leaf certificates are issued under ML-DSA-87 by default; node:tls completes a real mutual-auth handshake with them on the supported Node LTS. Operators pin the classical bridge with b.mtlsCa.create({ algorithm: "ECDSA-P384-SHA384" }) when a peer predates OpenSSL 3.5; the pin flows into both CA generation and leaf issuance, and is per-call -- it never downgrades the default other CAs use. A pin that disagrees with an already-stored CA is refused (mtls-ca/algorithm-mismatch) -- including a stored EC CA on the wrong curve (a P-256/P-521 CA under the ECDSA-P384 pin, which every ECDSA label would otherwise satisfy by key type alone) -- rather than issuing a leaf the mismatched CA would sign into an unverifiable chain -- rotate to a fresh CA to change algorithms. A per-issuance opts.algorithm that conflicts with the CA's resolved algorithm is likewise refused (mtls-ca/algorithm-conflict) rather than issuing a leaf the pinned CA can't back for its intended peers. When no algorithm is pinned, leaves (and their PKCS#12 MAC tier) follow the stored CA's OWN algorithm under the bundled engine, not the ML-DSA-87 default -- so a deployment that upgrades with an existing classical CA and never sets algorithm keeps issuing classical leaves its established peers can verify. That inference is the bundled engine's alone: a custom b.mtlsCa.create({ engine }) resolves its own leaf algorithm from its own key and is never handed the bundled ECDSA-P384-SHA384 label (which would break a custom engine running a P-256/P-521 CA or its own label set). The classical bridge signs the CA, every leaf, and every CRL with ECDSA-with-SHA-384 (matching its label and the pre-flip release), not the toolkit's EC default of SHA-256. blamejs mtls init --algorithm ECDSA-P384-SHA384 reaches the same bridge from the CLI. Issued certificates carry a structured subject DN: the CA's common name and its OU=CAv{N} generation tag are distinct RDN attributes, and a leaf's subject is exactly CN=<cn> -- so mTLS authorization that maps the certificate CN to an identity reads the bare cn (not a doubled CN), and a policy inspecting the OU=CAv{N} generation attribute finds a real OU. A bare IPv6 SAN now encodes as an iPAddress GeneralName (it was previously mis-encoded as a DNS name). b.mtlsCa's revoke() refuses the removeFromCRL reason (RFC 5280 code 8, a delta-CRL un-revocation directive) -- accepting it would persist a code-8 entry the toolkit rejects in a full CRL, making every later generateCrl() fail and blocking all revocation publishing. A legacy code-8 entry already sitting in a registry (from a pre-release build or a hand-edited store) is dropped when a full CRL is signed -- the serial stays revoked, but the invalid reason no longer blocks publishing every other revocation. PKCS#12 packaging keeps the AES-256-CBC + PBKDF2-HMAC-SHA-512 @ 2,000,000-iteration bag protection; the outer integrity MAC follows the cert tier -- PBMAC1 (RFC 9579) for the PQC default, and the traditional RFC 7292 HMAC MacData for a classical-bridge (ECDSA-P384) export so a peer predating OpenSSL 3.5 can verify the file that PBMAC1 would leave unreadable. · *The vendor-currency gate hard-fails a stale or unverifiable @blamejs/pki* — @blamejs/pki is now always-strict in the vendored-dependency currency check: a stale copy fails the gate (as before) and a registry error while checking it is also a hard failure, rather than staying advisory. The required Vendor currency CI check therefore refuses to pass unless it can prove the vendored @blamejs/pki equals the latest published release, keeping the fast-moving toolkit current on every build. **Removed:** *The vendored @peculiar/x509 + pkijs bundle* — lib/vendor/pki.cjs (the @peculiar/x509 + pkijs + reflect-metadata + ASN.1 meta-bundle) has no remaining consumer after the engine and the test fixtures moved to @blamejs/pki, and is removed along with its MANIFEST, NOTICE, and vendor tooling entries. The framework's PKI now runs entirely on the single zero-dependency @blamejs/pki toolkit. **Fixed:** *b.outbox and b.webhook quote operator-supplied table names* — Operator-supplied table names in b.outbox and b.webhook were emitted unquoted, so a reserved-word or case-sensitive operator table broke the generated SQL. Both now quote operator table names with the same allowReserved parity b.db.from() already provides. On PostgreSQL a bare mixed-case name is folded to lowercase before quoting, so a deployment upgrading from an unquoted-name build keeps resolving to the same folded table PostgreSQL already created (a mixed-case table config does not strand its existing rows in a new case-sensitive table). · *b.selfUpdate.rollback replaces a running Windows executable and honors a maxBytes override* — b.selfUpdate.rollback could not replace a locked/running Windows image (unlike swap) and had no way to raise the 64 MiB copy cap for a large binary. rollback now moves the outgoing file aside via a rename before restoring the backup, and accepts a maxBytes override. If the restore copy then fails (over maxBytes, unreadable source, write error), the moved-aside image is put back over the target so a failed rollback never leaves the target absent -- no next-launch outage; and a backupTo that aliases the reserved quarantine path -- including via a symlink or a symlinked parent dir (resolved with realpath), or by letter case on a case-insensitive volume -- is refused before any file is touched (it would otherwise delete the backup and restore the bad binary). Separately, selfUpdate.poll pairs a detached signature to the asset it actually signs: it recognises both the appended (asset.bin -> asset.bin.sig) and -- when the asset is the SOLE artifact with its extension-stripped stem -- the extension-replacing (asset.bin -> asset.sig) one-signature conventions, and fails closed on an ambiguous sidecar (a lone asset.sig shared by asset.bin and asset.exe) or one whose name is unrelated to the asset rather than mispairing it. **Security:** *b.safeJson.parse no longer leaks a window of input bytes in its error message* — b.safeJson.parse interpolated V8's raw SyntaxError message into the thrown error, which could echo a short window of the parsed input -- including secret bytes -- to any caller that logs the error (CWE-532). It now emits only the stable error code and the numeric position, never the input substring. · *b.redact.redactText strips underscore-joined bearer tokens in free-text messages* — The message-path redactor missed id_token / refresh_token assignments (an underscore-joined name a bare-token word boundary could not match) and vault-sealed / empty-username connection-string credentials, so a credential embedded in a log message could reach a file or SIEM sink verbatim while the structured meta path stripped it. redactText now covers those shapes -- including the JSON / quoted forms ({"refresh_token":"..."}), where the opaque value sits behind a quote the value class had excluded -- matching the meta path. · *b.guardTenantId's reserved-name check is prototype-safe* — The reserved-tenant-id lookup used a bare property access, so a tenant id equal to an Object.prototype member (a prototype key) spuriously matched a reserved name. It now uses an own-property test, and separately refuses the prototype-pollution key names (__proto__ / constructor / prototype) via the framework's shared poisoned-key guard -- a tenant id used to key a plain-object store must never be one of those. · *b.daemon detached start reports a boot-time death instead of unconditional success* — b.daemon.start in detached mode reported success even when the spawned child died at boot, leaving a stale pidfile a later daemon.stop could misread. A one-shot exit handler now reaps the stale pidfile (only when it still records that child's pid) and audits a spawn failure ONLY for a boot death -- an abnormal exit (non-zero code or a terminating signal) within a boot window while the pidfile is still the child's. A clean exit or an operator stop() is no longer mislabeled as a spawn failure. The parent keeps its event loop alive through the boot window (a ref'd timer, cleared the instant the child exits) so even a short-lived launcher that spawns the daemon and returns still observes a boot death -- child.unref() alone would let it exit first and strand the pidfile. A stop() within the window -- from the starting process or a different one (e.g. a `daemon stop` CLI) -- is not misread as a boot death: stop() publishes a short-lived `<pidFile>.stopping` marker (holding the pid it is stopping) that the boot-death handler consults, so no contradictory spawn-failed audit lands before the stop record. The window is tunable via daemon.start({ bootDeathWindowMs }) (default 5s, bounded to the setTimeout 32-bit maximum; 0 opts out for immediate fire-and-forget). The .stopping marker is read through the hardened pid-sidecar reader (symlink-refused, size-capped) and cleared on a fresh start so a stale marker cannot suppress a pid-reused child's boot death. The reap itself atomically claims the sidecar (renaming it aside) before verifying ownership, so a fast operator restart that rewrites the pidfile between the check and the removal can never make the boot-death handler delete the NEW daemon's pidfile. The same discipline covers the asynchronous spawn-error path: a no-pid launch failure throws synchronously before any pidfile is written, so its late 'error' callback no longer deletes a pidfile that a caught-and-retried start has since written.
|
|
12
|
+
|
|
9
13
|
## v0.17.x
|
|
10
14
|
|
|
15
|
+
- v0.17.24 (2026-07-25) — **The coverage-guided fuzz harness pins its `tar` dependency to 7.5.22, clearing a recursion-based denial-of-service advisory in dev-only tooling.** A maintenance update to development tooling with no change to the framework's library or runtime code. The fuzz harness (jazzer.js, under fuzz/) pulled tar transitively at 7.5.19, which is affected by an advisory: uncontrolled recursion in tar's mapHas / filesFilter path lets a crafted entry set trigger an uncatchable stack overflow (fixed upstream in 7.5.21; 7.5.22 is the current release, which caps mapHas recursion at 100 levels and hardens the decompressor teardown). The harness now pins tar to ^7.5.22 via an npm override so the resolved version cannot regress below the patched line. The fuzz harness is not part of the @blamejs/core tarball, so no shipped library or runtime code changes and operators need take no action; the update exists to keep the repository's development dependencies free of known advisories. **Security:** *Fuzz harness pins tar to 7.5.22 (dev-only tooling; not in the published package)* — The coverage-guided fuzz harness (fuzz/, jazzer.js) resolved tar transitively at 7.5.19, affected by an uncontrolled-recursion advisory in the mapHas / filesFilter path that permits an uncatchable stack overflow. An npm override pins tar to ^7.5.22 (the patched line caps recursion depth and hardens decompressor teardown), so the harness lockfile cannot resolve a vulnerable tar. The fuzz harness is not included in the published @blamejs/core package, so this changes only development tooling -- no shipped library or runtime code is affected.
|
|
16
|
+
|
|
11
17
|
- v0.17.23 (2026-07-25) — **`b.session` refresh paths enforce the idle/absolute timeout floor, and a strict fingerprint policy without a request fails closed.** Two session-security fixes and one audit-signing robustness fix. b.session.rotate and b.session.updateData reset a session's activity clock but did not re-check the idle/absolute timeout floor that verify() and touch() enforce, so a session already past its idle timeout (default 30 min) -- which verify() would reject -- could be resurrected with a fresh idle window by rotating it or writing session data, defeating the idle timeout (OWASP ASVS 5.0 §3.3 / NIST SP 800-63B-4). Both now enforce the same floor and fail closed. Separately, verify() gated its strict fingerprint controls (requireFingerprintMatch / maxAnomalyScore) on a request being supplied, so passing the strict flag WITHOUT a req silently skipped the check and admitted the session from any device; a strict policy with no way to compute the device fingerprint now fails closed. Finally, b.auditSign.verify honored its documented never-throw contract only for well-formed keys -- a malformed publicKeyPem made it throw; it now returns false. **Changed:** *Vendored Public Suffix List refreshed to the current snapshot* — The bundled Public Suffix List that backs domain classification (registrable-domain and public-suffix checks across URL, cookie, and SSRF handling) is updated to the latest upstream publicsuffix.org snapshot, so newly delegated public suffixes and updated private-domain entries are recognized. **Fixed:** *b.auditSign.verify returns false on a malformed public key instead of throwing* — verify(payload, signature, publicKeyPem) documents that it never throws -- callers branch on the boolean, and a verifier-only consumer (b.backupManifest.verifyBytes) may pass an untrusted publicKeyPem taken from a signature block. A malformed / non-PEM key made node:crypto throw (ERR_OSSL_UNSUPPORTED) while building the key object, crashing such a caller instead of cleanly rejecting. verify() now catches that and returns false, honoring the contract. As a result, b.auditSign.reSignAll now treats an entry whose old key is unverifiable as skipped (unverifiable) rather than as an error. **Security:** *b.session.rotate and b.session.updateData enforce the idle/absolute timeout floor* — A session read path enforces two floors independent of the operator's ttl: an idle timeout (default 30 min) and an absolute timeout (default 12 h). verify() and touch() enforced them, but rotate() and updateData() only checked that the operator ttl had not elapsed while resetting lastActivity -- so a session past its idle floor (which verify() would expire and delete) could be revived with a fresh idle window by rotating it or writing to it. Both now route through the same floor check and fail closed (return null / false and best-effort delete) on breach, so no refresh path can resurrect a session verify() would reject. touch() also now emits the idle/absolute-expiry audit signal on breach, matching verify(). · *A strict fingerprint policy without a request now fails closed* — verify()'s strict device-binding controls -- requireFingerprintMatch: true and maxAnomalyScore -- were only applied when the caller also passed a req to compute the current device fingerprint. Setting a strict flag WITHOUT a req therefore skipped the binding check entirely and admitted the session unchecked from any device -- a fail-open of a control the operator asked to be strict. A strict policy with no req to compare against now fails closed (returns null), the same discipline already applied when the stored binding is unreadable or absent.
|
|
12
18
|
|
|
13
19
|
- v0.17.22 (2026-07-25) — **`b.network.tls.checkServerIdentity9525` parses subjectAltName quote-aware, closing a hostname-verification bypass.** The strict RFC 9525 identity verifier parsed Node's subjectAltName string with a naive comma split, ignoring the JSON-quoted encoding Node emits for a SAN value that itself contains separators (the CVE-2021-44531/44532 remediation). A certificate whose single dNSName is a quoted blob such as `DNS:"x, DNS:victim.com, y"` was broken into pieces by the split, smuggling a clean `victim.com` token the certificate never asserts -- so checkServerIdentity9525 ACCEPTED a host that Node's own tls.checkServerIdentity REJECTS (ERR_TLS_CERT_ALTNAME_INVALID). The verifier now splits SAN entries on commas outside quotes and JSON-decodes quoted spans, matching Node's splitEscapedAltNames, so it asserts only the certificate's actual dNSName / IP values and is at least as strict as the function it advertises replacing. This path gates peer identity in b.crypto.spkiPinVerifier. **Security:** *checkServerIdentity9525 no longer accepts a hostname smuggled through a quoted subjectAltName* — Node renders a subjectAltName value that contains commas or control characters as a JSON-quoted string, so a single dNSName whose value is `x, DNS:victim.com, y` appears in cert.subjectaltname as `DNS:"x, DNS:victim.com, y"`. The verifier's SAN parser split that raw string on every comma, turning one entry into several and extracting a `DNS:victim.com` token the certificate does not actually present -- a hostname-verification bypass, since a strict RFC 9525 drop-in must be no weaker than the Node function it replaces (which rejects this cert). The parser now mirrors Node's splitEscapedAltNames: it splits only on commas outside quotes and JSON-decodes quoted spans, so each parsed dNSName is exactly one GeneralName and a smuggled name can no longer match. Malformed SAN quoting fails closed (asserts no identifiers). Operators using b.network.tls.checkServerIdentity9525 (directly or via b.crypto.spkiPinVerifier) should upgrade.
|
|
@@ -224,9 +230,9 @@ upgrading across more than a few patches at a time.
|
|
|
224
230
|
|
|
225
231
|
- v0.15.28 (2026-06-25) — **`b.network.dns` and `b.network.tls` errors now carry a usable terminal-vs-transient signal on `err.permanent`, so a caller's retry loop re-attempts only the failures a retry can fix.** DnsError was declared always-transient and NetworkTlsError always-permanent, so a consumer driving its own retry loop got the wrong signal in both directions: it would retry a permanent DNS failure (a bad host, bad options, an unsupported query, an NXDOMAIN-style no-result, or a caller-shape/config error such as an unconfigured transport or an invalid resolver list) forever, and it would refuse to retry a transient TLS-over-network failure (an ECH connection failure, a handshake timeout, DNS momentarily unavailable). err.permanent now reflects each error's actual transience, derived from its code and failing closed (an unknown code is permanent, so a hopeless target is not retried indefinitely): for DnsError only a failed network round-trip (a lookup timeout, a resolve/reverse query that failed on the wire, a failed DoH/DoT exchange, a DDR discovery that found nothing) is transient — config, input, and environment errors raised before any network work are permanent; for NetworkTlsError only the network-layer ECH failures are transient. TlsTrustError remains always-permanent by design — a trust-verification failure (bad CA, fingerprint mismatch, OCSP not-good, CT violation, an unreachable OCSP responder) must never be silently retried past the trust decision. **Fixed:** *DnsError / NetworkTlsError expose a correct terminal/transient signal; TlsTrustError stays terminal* — b.network.dns's DnsError was always-transient and b.network.tls's NetworkTlsError always-permanent, so err.permanent misled a consumer's retry loop in both directions — retrying a permanent DNS error (bad host / options / unsupported type / no-result) indefinitely, and never retrying a transient TLS network error (ECH connect failure, handshake timeout, DNS unavailable). err.permanent now reflects each error's actual transience by code and fails closed (unknown codes are permanent): DnsError is transient only for a failed network round-trip (dns/lookup-timeout, dns/resolve-failed, dns/reverse-failed, dns/doh-failed, dns/dot-failed, dns/dot-handshake-failed, dns/ddr-not-discovered, dns/system-failed), and permanent otherwise — including the caller-shape / environment errors raised before any network work (dns/transport-unavailable for an unconfigured transport, dns/dnr-no-resolvers for an empty/invalid resolver list, dns/setservers-failed for an invalid address, dns/no-system-resolvers when none are configured). NetworkTlsError is transient only for the network-layer ECH failures (tls/ech-connect-failed, tls/ech-timeout, tls/ech-dns-unavailable), permanent otherwise. TlsTrustError remains always-permanent so a trust-verification failure is never silently retried.
|
|
226
232
|
|
|
227
|
-
- v0.15.27 (2026-06-25) — **Internal test-harness reliability only —
|
|
233
|
+
- v0.15.27 (2026-06-25) — **Internal test-harness reliability only — no shipped library or runtime code changed from 0.15.26.** The legacy single-layer smoke files used fixed-duration setTimeout sleeps to wait for asynchronous conditions (a job processed, a lease TTL lapsing, an audit flush completing). On a contended CI runner a fixed sleep is both flake-prone (too short under load) and slow (it always burns the full budget). Those condition-waits are converted to the harness's polling helpers — waitUntil for observable predicates, passiveObserve for deliberate real-time elapses, withTestTimeout for hang guards — which exit early on fast platforms and give contended platforms the full budget. No shipped framework code changed; for operators, no shipped library or runtime code differs from 0.15.26. **Fixed:** *Smoke layer files poll for conditions instead of fixed setTimeout sleeps* — The single-layer smoke files waited on asynchronous conditions with fixed-duration setTimeout sleeps, which flake under SMOKE_PARALLEL load and always burn their full budget. The condition-waits are converted to the polling helpers (waitUntil / passiveObserve / withTestTimeout) so they exit early on fast platforms and stay robust on contended ones; non-wait timers (abort triggers, child/socket watchdogs, simulated-latency mocks) are unchanged. This is test-harness reliability only — no shipped framework behavior changed.
|
|
228
234
|
|
|
229
|
-
- v0.15.26 (2026-06-25) — **Internal test-harness correctness only —
|
|
235
|
+
- v0.15.26 (2026-06-25) — **Internal test-harness correctness only — no shipped library or runtime code changed from 0.15.25.** The smoke runner requires each test module and awaits its exported run(). Several tests were instead written as a top-level (async function () {...})() IIFE that runs detached at require-time, so the runner measured and reported the test's result before the IIFE's post-await assertions executed — those checks silently never ran (one parser test exercised 4 of its 26 assertions, and a failure after the first await would have gone unseen as a false pass). Those tests are converted to the exported-run form so the runner awaits their full assertion set, and a codebase-patterns detector now refuses a top-level async IIFE in a test file so the pattern cannot return. No shipped framework code changed; for operators, no shipped library or runtime code differs from 0.15.25. **Fixed:** *Detached-IIFE tests now run their full assertion set under the smoke runner* — A test written as a top-level (async function () {...})() IIFE runs detached when the runner requires it: the runner only awaits an exported run(), so it reported the file's result before the IIFE's awaited assertions executed, and every check after the first await silently did not count (one parsers test ran 4 of 26). Such tests are rewritten to define async function run(), export it, and invoke it under if (require.main === module), so the runner awaits the complete set; a detector refuses a re-introduced top-level async IIFE in a test file. This is test-harness correctness only — no shipped framework behavior changed.
|
|
230
236
|
|
|
231
237
|
- v0.15.25 (2026-06-25) — **`b.sandbox.run` no longer leaks the worker thread's MessagePort — it now waits for the worker to terminate before resolving.** b.sandbox.run spawned a worker thread to execute untrusted code and called worker.terminate() on both the result and timeout paths, but it settled the caller's promise BEFORE the asynchronous terminate() completed — leaving the worker's MessagePort alive past the resolve. In a long-lived process that runs sandboxed code repeatedly, each call leaked a MessagePort handle, keeping the event loop populated and slowing graceful shutdown. The call now awaits worker.terminate() before settling, so the handle is released when the promise resolves. Behaviour and return values are unchanged. **Fixed:** *b.sandbox.run releases the worker MessagePort before resolving* — b.sandbox.run resolved (or rejected) the caller's promise as soon as the worker reported a result or the timeout fired, while worker.terminate() — which is asynchronous — was still in flight, so the worker thread's MessagePort outlived the call and lingered as an open handle. Repeated sandbox runs in a long-lived process accumulated leaked MessagePorts that kept the event loop alive and delayed shutdown. The result and timeout paths now defer settling until worker.terminate() resolves; the error and exit paths already imply the worker is gone. The return value, audit events, and timeout semantics are unchanged.
|
|
232
238
|
|
|
@@ -234,13 +240,13 @@ upgrading across more than a few patches at a time.
|
|
|
234
240
|
|
|
235
241
|
- v0.15.23 (2026-06-24) — **`b.wsClient` error objects now carry a usable terminal-vs-transient signal (and the client's auto-reconnect, previously dead, works again), and `b.safePath` resolves cross-platform containment with the target platform's path semantics — fixing both a false refusal of in-base paths and a backslash-traversal escape when validating for Windows on a POSIX host.** Two correctness fixes. b.wsClient marked every WsClientError permanent, so a consumer driving its own reconnect loop could not tell a terminal handshake failure (a bad URL, a 4xx rejection, an accept-mismatch, a protocol-violation frame) from a transient one (a 5xx handshake rejection, a pong/handshake timeout, a dropped socket) and had to re-derive the taxonomy from error codes. err.permanent now reflects the actual transience of each error, the single bad-status code is split by the carried HTTP status (4xx terminal, 5xx transient) with the status exposed as err.statusCode, and — because the client's own auto-reconnect keyed off the same always-true flag — auto-reconnect was silently disabled for every transient failure and now fires correctly. Separately, b.safePath.resolve / resolveOrNull / validate performed its lexical containment with the runtime path module while the per-segment naming walk used opts.platform. The two disagreeing broke cross-platform validation both ways: every legitimate in-base path was refused with safe-path/escapes-base when opts.platform differed from the host, and — more seriously — a POSIX host validating opts.platform: "windows" accepted a backslash traversal (ok\..\..\outside), because the runtime resolver treats \ as an ordinary filename character and never collapsed the .. segments, so the path escaped the base once a Windows consumer read the backslashes. The lexical resolve and containment boundary now use the target platform's path module (node:path.win32 / node:path.posix), matching the segment walk, so in-base paths resolve and cross-platform traversals are refused under any opts.platform override; the realpath check, which touches the live filesystem, keeps its runtime resolve. **Fixed:** *b.wsClient: WsClientError carries a real terminal/transient signal, and auto-reconnect works again* — WsClientError was declared always-permanent, so err.permanent was true for every error — a consumer's reconnect loop could not distinguish a terminal handshake failure (bad URL, bad subprotocol, malformed handshake header, accept-mismatch, bad upgrade, bad status line, a 4xx rejection, an oversized/protocol-violation frame) from a transient one (a 5xx handshake rejection, a pong- or handshake-timeout, a dropped socket) and had to maintain its own error-code list tracking the framework's taxonomy. err.permanent now reflects each error's actual transience, derived from its code (a new/unknown code defaults to terminal, so it fails closed rather than redialing a hopeless target forever); the single ws-client/bad-status code is split by the response status (4xx terminal, 5xx transient) and the status is exposed as err.statusCode (the existing err.status alias is preserved). The same always-permanent flag also drove the client's built-in auto-reconnect, which therefore never retried any framework-surfaced transient failure (a 5xx handshake or a keepalive timeout); reconnect now fires for transient failures and still skips terminal ones. · *b.safePath: cross-platform containment resolves with the target platform's path semantics* — b.safePath.resolve / resolveOrNull / validate performed its lexical containment (resolve rel under base, then bound the result with a separator slice) using the runtime path module, while the per-segment naming walk used opts.platform. When the two disagreed, validation broke both ways. Benign direction: a Linux service validating server-origin names against the stricter Windows ruleset (the recommended cross-platform pattern) had every legitimate in-base path refused with safe-path/escapes-base, because the runtime-separated resolved path could never match a Windows-separator boundary. Security direction: a POSIX host validating opts.platform: "windows" accepted a backslash traversal such as ok\..\..\outside — the segment walk splits on \ for Windows, but the runtime resolver on POSIX treats \ as an ordinary filename character and never collapsed the .. segments, so the path passed containment and resolved to <base>/ok\..\..\outside, which escapes the base once a Windows consumer interprets the backslashes. The lexical resolve and the containment boundary now use the target platform's path module (node:path.win32 when validating for Windows, node:path.posix otherwise), matching the segment walk, so in-base paths resolve correctly and a genuine traversal is refused under any opts.platform override. The realpath check, which resolves symlinks on the live filesystem, keeps a separate runtime resolve because a foreign-platform path cannot be symlink-resolved on the host. opts.platform continues to gate the per-segment naming rules (reserved names, trailing dot/space, NTFS ADS colon).
|
|
236
242
|
|
|
237
|
-
- v0.15.22 (2026-06-24) — **Internal test-harness reliability only —
|
|
243
|
+
- v0.15.22 (2026-06-24) — **Internal test-harness reliability only — no shipped library or runtime code changed from 0.15.21.** This release changes only the repository's own test harness and test files; nothing under the shipped tarball (index.js, bin/, lib/) is touched, so the API and behavior are unchanged from 0.15.21 and operators have nothing to do. The smoke worker now attributes a fire-and-forget failure — an unawaited promise or an unreleased handle (a retry timer, a shutdown wait, a watcher/reload restart) that throws AFTER a test's assertions already passed — to the exact test that caused it and retries it once, instead of the prior unattributable, intermittent failure that only surfaced on a resource-starved CI runner. Five test files that invoked their run() at module scope (re-running and, in some cases, exiting the worker before it could report a result) are corrected to run only under require.main === module, and a test-discipline check keeps the pattern from returning. An opt-in handle-leak audit (SMOKE_AUDIT_HANDLES) surfaces tests that hold a timer / socket / server / worker past completion, for a follow-up cleanup pass. **Changed:** *Smoke test harness attributes late-async-error failures and fixes module-level test self-execution* — The forked smoke worker installs unhandledRejection / uncaughtException handlers and a settle tick so a failure that fires after a test's assertions pass — a leaked handle's callback or an unawaited promise from retry / shutdown / reload logic — is reported against the test that caused it and retried once (a transient passes, a persistent one fails again and names the bug), replacing the prior intermittent, unattributable 'fork failed' that only appeared on a starved CI runner. Five test files (two integration, three layer-0, plus two wiki-suite harnesses) that called run() at module scope are corrected to execute only under require.main === module so they no longer double-run or exit the worker prematurely when required, and a codebase-patterns test-discipline detector prevents the unguarded-module-level-run shape from recurring. A SMOKE_AUDIT_HANDLES opt-in reports per-file handle leaks for a tracked cleanup. None of this is in the published package.
|
|
238
244
|
|
|
239
245
|
- v0.15.21 (2026-06-24) — **Adds a portable compare-and-swap UPDATE, an IPv6 /64 rate-limit key, a verify-only legacy-TOTP path, request-id AsyncLocalStorage scoping, schema-agnostic backup signing, side-effect-free FSM transition resolution, and the public codepoint-threat catalog — and fixes an FSM state transition that committed without an audit record when its entry hook threw.** A batch of additive primitives that close gaps operators hit composing the framework: b.sql.guardedUpdate builds the cross-instance-safe compare-and-swap UPDATE (the conditional-INSERT sibling) with b.sql.casWon reading the won/lost result; b.requestHelpers.ipKey (and a rateLimit ipKeyMode) key an IPv6 client by its routing-significant /64 so one end-site can't rotate the low 64 bits to evade a per-IP limit; b.auth.totp gains an opt-in verify-only SHA-1 path for one-final-login legacy-secret migration while generation stays SHA-512-only; b.middleware.requestId can bind the id into an AsyncLocalStorage scope that survives the awaited route chain; b.backupManifest.signBytes/verifyBytes authenticate a consumer's own canonical bytes with the framework signing key; b.fsm gains a side-effect-free instance.target() and a way to defer its transition audit when composed with an external claim; and the codepoint-threat catalog the guard family composes is exposed as b.codepointClass. A correctness fix: an FSM transition whose onEnter hook threw left the state committed but emitted no audit record — the record now always fires (stamped a failure outcome with the hook error) so a state change is never unaudited. **Added:** *b.sql.guardedUpdate + b.sql.casWon — portable compare-and-swap UPDATE* — b.sql.guardedUpdate(table) builds an UPDATE whose required guardWhere(col, expected) fence makes it land only when the row is still in the expected value — the cross-instance-safe way to advance a status or version on a single-statement-per-request backend (a D1-over-HTTP bridge or any autocommit-only adapter without interactive transactions), and the conditional-UPDATE sibling of b.sql.insertSelectWhere. It refuses to render without a fence (an unfenced guardedUpdate is just a plain update). b.sql.casWon(result) reads the won/lost verdict from the affected-row count, normalizing the rowCount / changes / affectedRows field-name divergence across adapters and throwing on an indeterminate result so a phantom win can't ship. Standard SQL across SQLite / Postgres / MySQL; guardWhereOp(col, op, expected) supports a non-equality fence (an optimistic-version or balance guard), and a null fence renders IS NULL. · *b.requestHelpers.ipKey + rateLimit ipKeyMode — IPv6 /64 keying* — b.requestHelpers.ipKey(ip, { ipv6Bits }) derives a stable rate-limit / blocklist key: an IPv4 address verbatim (one IPv4 is one host) but an IPv6 address collapsed to its routing-significant /64 prefix. A single IPv6 end-site is allocated a whole /64 (RFC 6177 / RFC 4291) and freely rotates the low 64 bits, so keying on the full 128-bit address lets one site mint unlimited fresh keys and walk a per-IP throttle or evade an exact-address block; keying on the /64 closes that while still distinguishing real end-sites. b.middleware.rateLimit gains ipKeyMode: "prefix64" to apply this to its default key (the audit record still logs the full client IP). · *b.auth.totp verify-only SHA-1 path for legacy-secret migration* — b.auth.totp.verify(secret, code, { algorithm: "sha1", verifyOnly: true }) authenticates a single legacy code during a re-enrollment flow, so a consumer migrating pre-existing RFC-6238-default (SHA-1) secrets can reuse the maintained verifier — separator stripping, 64-bit counter, drift / replay semantics — instead of hand-rolling a parallel HOTP. SHA-1 is still refused on every generation path (compute / generate / uri), so new-enrollment posture stays SHA-512-only, and the flag is honored only by verify(); each such verification emits an auth.totp.legacy_sha1_verify audit signal. · *b.middleware.requestId AsyncLocalStorage scoping* — b.middleware.requestId({ asyncContext: true }) binds the request id into the framework's AsyncLocalStorage scope so b.log.getRequestId() (and every b.log.create-built logger) returns it inside awaited route-handler code, not just on req.requestId. Because the b.router dispatch model runs the route handler after the middleware returns, the binding uses AsyncLocalStorage.enterWith (which persists forward across the awaited chain) rather than a callback wrap that would close before the handler runs; each request runs in its own async context so the binding stays request-scoped. The underlying b.log.enterRequestId(id) is exposed for callers wiring their own middleware. · *b.backupManifest.signBytes / verifyBytes — schema-agnostic signing* — b.backupManifest.signBytes(canonicalBytes) and verifyBytes(canonicalBytes, signatureBlock, { expectedFingerprint }) sign and verify a consumer's own canonical bytes with the same audit-sign keypair and fingerprint pinning as the v1-manifest sign() / verifySignature(), without adopting the framework's manifest schema — so a bespoke backup-header format can authenticate itself against the framework signing key. Fingerprint pinning is bound to the key the signature actually verifies under: the pin is checked against the fingerprint recomputed from the signature block's own public key (the new b.auditSign.fingerprintOf(publicKeyPem) helper), not the block's self-asserted fingerprint field, so a block can't claim a trusted fingerprint while being signed by a different key. The same binding now also applies to the v1-manifest verifySignature(). b.auditSign.verify additionally no longer requires init() when given an explicit public key, so a downstream verifier that holds only a trusted public key can check a detached signature. · *b.fsm side-effect-free transition resolution + deferrable audit* — instance.target(event) resolves a transition's destination state side-effect-free — the same edge and guard check as can() but returning the to-state (or null when the edge is illegal or guard-refused) — so a consumer composing an external compare-and-swap (b.sql.guardedUpdate on an autocommit-only substrate) can build the SET status = <to> claim without calling transition(), which would mutate state and emit an audit before the cross-instance claim is known to land. transition(event, { audit: false }) suppresses the built-in emit so that composition can emit its own enriched record once the claim resolves. · *b.codepointClass — the codepoint-threat catalog on the public surface* — The Unicode bidi-override / C0-control / zero-width / null-byte / Unicode-Tags tables and the UTS #39 confusable-script detector that the b.guard* family composes internally are now exposed as b.codepointClass, so a consumer can build a custom unconstrained-free-text screen — detectCharThreats / assertNoCharThreats / applyCharStripPolicies / scriptFor / detectMixedScripts plus the compiled regexes — without re-rolling the regexes (where the zero-width class is mistyped and the astral Unicode-Tags block forgotten) or coupling to an internal module path. For a ready-made free-text guard, b.guardText remains the first-class entry point. **Fixed:** *An FSM transition committed its state change without an audit record when the entry hook threw* — b.fsm commits the new state and pushes the history entry before running the destination state's onEnter hook. When that hook threw, control skipped the audit emission entirely — leaving a committed state transition with no audit record, a compliance gap for any regime that requires state changes to be auditable. The transition audit now always fires even when onEnter throws: it records the committed transition stamped with a failure outcome and the hook error, and still re-raises the error to the caller (the documented contract that an onEnter throw surfaces so the operator can roll back is unchanged).
|
|
240
246
|
|
|
241
247
|
- v0.15.20 (2026-06-24) — **Vendored SBOM version fields are derived from the bundle so they cannot drift, and the Public Suffix List + @simplewebauthn/server bundles are refreshed.** The vendor manifest recorded each bundled package's version in two scanner-facing places that were hand-maintained and could drift from the code actually shipped — the structured components[].version (the CycloneDX component versions) and the cpe string. Two had drifted: peculiar-pki's @peculiar/x509 component read 1.13.0 while the bundle shipped 2.0.0, and @noble/curves' cpe read 0.0.0 while the bundle shipped 2.2.0. A CVE scanner (Trivy / Grype / a CycloneDX export, or a consumer mirroring the manifest into its own SBOM) keys on those structured fields, so an advisory was matched against the wrong version — a false negative on a real fix or a false positive on a patched one. Both fields are corrected, the vendor-bundle script now derives them from the actually-installed package versions at bundle time so they cannot drift again, and a manifest gate fails the build if they ever disagree. Separately, the vendored Mozilla Public Suffix List is refreshed to the current upstream revision and @simplewebauthn/server is refreshed to 13.3.2. **Changed:** *Vendored Public Suffix List and @simplewebauthn/server refreshed* — The vendored Mozilla Public Suffix List is refreshed to the current upstream revision (used by b.publicSuffix for DMARC / BIMI / cookie-scope / same-site domain classification). @simplewebauthn/server is refreshed from 13.3.1 to 13.3.2, which improves WebAuthn attestation certificate-path validation; the published-tarball diff was reviewed (no install scripts, no network/eval, a self-contained code change) before re-vendoring. **Fixed:** *Vendored SBOM version metadata is bundle-derived, not hand-maintained* — lib/vendor/MANIFEST.json recorded each package's version in two places a CVE/SBOM scanner reads — the structured components[].version sub-object and the cpe string — both hand-maintained alongside the human version string, so they could (and did) drift from the bundled code. @peculiar/x509's component version read 1.13.0 while the bundle shipped 2.0.0; @noble/curves' cpe read 0.0.0 while the bundle shipped 2.2.0. Either drift makes a scanner match advisories against the wrong version. Both are corrected to the shipped versions, and the durable fix is structural: scripts/vendor-update.sh now writes both the structured component versions and the cpe version from the ACTUALLY-INSTALLED package versions captured at bundle time, so a maintainer can no longer update one field and forget the other. A smoke-time manifest gate additionally fails the build if any component or cpe version disagrees with the package version, catching a manual drift before it ships.
|
|
242
248
|
|
|
243
|
-
- v0.15.19 (2026-06-22) — **Restores the wiki container build by keeping its base image on the continuously-patched rolling tag.** A follow-up to 0.15.18. The framework code is unchanged from 0.15.18 — this patch reverts one part of that release's supply-chain pass: the example wiki container's Chainguard base images had been pinned to specific digests, but Chainguard rebuilds those images continuously to ship CVE fixes, so the frozen digest fell behind an upstream fix within hours and the container's Trivy CRITICAL/HIGH release gate rejected the build (a fixed npm/undici denial-of-service the rolling tag already carried). For a deployed, Trivy-gated image, tracking the rolling, always-patched tag is the correct posture; the wiki Dockerfile is back on it. The ClusterFuzzLite fuzz base (not deployed, not release-gated) stays digest-pinned with Dependabot keeping it current. **Fixed:** *Wiki container builds again on a continuously-patched base* — 0.15.18 digest-pinned the example wiki container's Chainguard base images (runtime + builder). Because Chainguard rebuilds those images continuously to ship CVE fixes, the pinned digest went stale within hours and the container's Trivy CRITICAL/HIGH release gate rejected the build over an already-fixed npm/undici DoS (CVE-2026-12151) the rolling tag carried. The wiki Dockerfile tracks the rolling tag again, so the deployed image is always CVE-current and the build passes the release gate. To keep the Trivy-scanned image identical to the multi-arch image that is published (they are built separately), the release workflow resolves the rolling tag to a digest once at build time and feeds it to both builds via build-args — scan-equals-publish without a committed pin that goes stale. (This trades the OSSF Scorecard PinnedDependencies signal for CVE currency on a deployed, gate-scanned image — the right call here; the non-deployed ClusterFuzzLite fuzz base remains digest-pinned with Dependabot bumping it.) The framework
|
|
249
|
+
- v0.15.19 (2026-06-22) — **Restores the wiki container build by keeping its base image on the continuously-patched rolling tag.** A follow-up to 0.15.18. The framework code is unchanged from 0.15.18 — this patch reverts one part of that release's supply-chain pass: the example wiki container's Chainguard base images had been pinned to specific digests, but Chainguard rebuilds those images continuously to ship CVE fixes, so the frozen digest fell behind an upstream fix within hours and the container's Trivy CRITICAL/HIGH release gate rejected the build (a fixed npm/undici denial-of-service the rolling tag already carried). For a deployed, Trivy-gated image, tracking the rolling, always-patched tag is the correct posture; the wiki Dockerfile is back on it. The ClusterFuzzLite fuzz base (not deployed, not release-gated) stays digest-pinned with Dependabot keeping it current. **Fixed:** *Wiki container builds again on a continuously-patched base* — 0.15.18 digest-pinned the example wiki container's Chainguard base images (runtime + builder). Because Chainguard rebuilds those images continuously to ship CVE fixes, the pinned digest went stale within hours and the container's Trivy CRITICAL/HIGH release gate rejected the build over an already-fixed npm/undici DoS (CVE-2026-12151) the rolling tag carried. The wiki Dockerfile tracks the rolling tag again, so the deployed image is always CVE-current and the build passes the release gate. To keep the Trivy-scanned image identical to the multi-arch image that is published (they are built separately), the release workflow resolves the rolling tag to a digest once at build time and feeds it to both builds via build-args — scan-equals-publish without a committed pin that goes stale. (This trades the OSSF Scorecard PinnedDependencies signal for CVE currency on a deployed, gate-scanned image — the right call here; the non-deployed ClusterFuzzLite fuzz base remains digest-pinned with Dependabot bumping it.) The framework's library and runtime code are unchanged from 0.15.18.
|
|
244
250
|
|
|
245
251
|
- v0.15.18 (2026-06-22) — **OCSP response-freshness enforcement is restored, DPoP request-URI reconstruction peer-gates forwarded headers, and container/tool supply-chain pinning is tightened.** Three hardening fixes. The stapled-OCSP evaluator parsed each response's thisUpdate / nextUpdate with Date.parse, but those fields are already numeric (unix-ms), so Date.parse returned NaN — the freshness guard then rejected every signature-valid response, fresh or stale, with a misleading "missing thisUpdate", and the real future-dated / past-nextUpdate window checks (RFC 6960 §4.2.2.1) were unreachable dead code. b.network.tls.ocsp.evaluate / requireGood now read the numeric fields directly: a stale or future-dated response is refused and a fresh "good" response is accepted, so OCSP stapling validation works again. b.middleware.dpop reconstructed the absolute request URI — the cryptographically-bound htu — trusting X-Forwarded-Proto / X-Forwarded-Host from any caller whenever trustForwardedHeaders was set, so a direct attacker could forge the scheme or authority and make a proof signed for one origin validate against another; both now resolve through the peer-gated b.requestHelpers.trustedProtocol / trustedHost (honored only from a declared trusted-proxy peer), matching csrf-protect / security-headers / cors, and the bare trustForwardedHeaders boolean is refused. The supply-chain pass pins the wiki container and ClusterFuzzLite fuzz base images to digests (Dependabot keeps them current) and the npm-publish bundle tools to exact versions. **Added:** *b.requestHelpers.requestHost and b.requestHelpers.trustedHost* — Peer-gated request-authority resolvers — the host companions to requestProtocol / trustedProtocol. trustedHost(opts) returns { resolve(req) => string|null, peerGated }: with trustedProxies (CIDRs) X-Forwarded-Host is honored only from a trusted-proxy peer; with hostResolver(req) the operator owns it; with neither, only the request's own Host header is used and a forged X-Forwarded-Host is ignored. requestHost(req, opts?) is the low-level resolver (default Host-only; a peer predicate gates the forwarded header). For reconstructing an absolute request URL (a DPoP htu, an origin/issuer string, a redirect base) behind a proxy without trusting a forgeable header. **Changed:** *Container and bundle-tool supply-chain pinning tightened* — The wiki container's base images (cgr.dev/chainguard/node runtime + builder stages) and the ClusterFuzzLite fuzz base (gcr.io/oss-fuzz-base/base-builder-javascript) are pinned to image digests; Dependabot's docker ecosystem keeps both current, so the pin is reproducible without freezing CVE patches. The npm-publish workflow's bundle tools (esbuild, postject) are pinned to exact versions, matching CI. The OSS-Fuzz project-submission Dockerfile is intentionally left tracking the upstream base-builder, per OSS-Fuzz convention. **Security:** *OCSP response-freshness enforcement restored (RFC 6960 §4.2.2.1)* — The stapled-OCSP evaluator computed thisUpdate / nextUpdate via Date.parse(), but the parser hands those fields back as unix-ms NUMBERS (Date.UTC(...)). Date.parse() coerces its argument to a string and a bare-integer string is not a recognized date, so the result was always NaN: the !isFinite guard then rejected every signature-valid response — fresh OR stale — with a misleading "missing thisUpdate", and the genuine staleness checks (future-dated thisUpdate, past nextUpdate) sat behind it as unreachable dead code, with the stale-rejection branch latently fail-open. b.network.tls.ocsp.evaluate / requireGood now read the numeric fields directly: a stale (past-nextUpdate) or future-dated response is refused, and a fresh "good" response is accepted — OCSP stapling validation, and its replay defense, work again. · *DPoP htu reconstruction peer-gates X-Forwarded-Proto and X-Forwarded-Host* — b.middleware.dpop rebuilds the absolute request URI (scheme + authority + path) that the proof's cryptographically-bound htu claim (RFC 9449 §4.3) is verified against. When the legacy trustForwardedHeaders: true was set it derived the scheme and host from X-Forwarded-Proto / X-Forwarded-Host trusted from ANY caller, with no immediate-peer check — a direct attacker could forge X-Forwarded-Proto: https or a victim X-Forwarded-Host and make a proof signed for one origin validate against another (htu confusion). Both now resolve through the peer-gated b.requestHelpers.trustedProtocol / trustedHost, which honor the forwarded headers only when the immediate connection is a declared trusted proxy — the same fail-closed model csrf-protect (Secure cookie), security-headers (HSTS), cors (same-origin), and bot-guard (secure context) already use. A codebase-patterns detector now flags any further middleware that reads X-Forwarded-Proto / -Host directly for a scheme/authority decision. **Migration:** *DPoP: trustForwardedHeaders is replaced by trustedProxies* — b.middleware.dpop no longer honors the bare trustForwardedHeaders: true boolean — it trusted forgeable X-Forwarded-Proto / X-Forwarded-Host from any caller. Behind a reverse proxy, declare your proxy CIDRs via trustedProxies: ["10.0.0.0/8", …] (peer-gates both headers for the htu reconstruction), or own the reconstruction via protocolResolver(req) / hostResolver(req) / getHtu(req). A bare trustForwardedHeaders: true now throws at create() with that guidance. Apps not behind a proxy need no change — the default already derives the scheme from the TLS socket and the host from the request's Host header.
|
|
246
252
|
|
|
@@ -1472,7 +1478,7 @@ EmailSubmission (RFC 8621 §7) was already in the JMAP method catalogue at `lib/
|
|
|
1472
1478
|
|
|
1473
1479
|
- v0.4.3 (2026-04-30) — **`b.ssrfGuard` primitive default-on in `b.httpClient` + wiki posture auto-detect.** Outbound HTTP requests now pass through an SSRF guard that refuses requests to loopback / link-local / RFC 1918 / metadata-service targets unless the operator opts in explicitly. The wiki also auto-detects its TLS / bot-guard posture instead of requiring hand-wired flags. **Added:** *`b.ssrfGuard` — default-on guard wired into `b.httpClient`* — Every outbound request the framework makes is screened against the SSRF guard before the socket opens. Targets matching loopback (`127.0.0.0/8`, `::1`), link-local (`169.254.0.0/16`, `fe80::/10`), private (RFC 1918 + `fc00::/7`), and cloud-metadata-service IPs (`169.254.169.254`, `fd00:ec2::254`) are refused unless the operator passes `allowPrivateTargets: true` for the specific call. The guard composes with the existing DoH resolver so DNS rebinding can't shift the target post-resolve. · *Wiki posture auto-detect* — The wiki example app now derives its TLS / bot-guard / CSP posture from the environment instead of requiring operators to hand-wire each flag — running behind a proxy detects the X-Forwarded-Proto chain; running standalone enables the framework defaults.
|
|
1474
1480
|
|
|
1475
|
-
- v0.4.2 (2026-04-30) — **`package.json` keywords refresh.** Operator-facing `package.json` keywords expanded so the package surfaces in `npm search` for the concerns it actually covers (server framework, PQC, security defaults). No code changes. **Changed:** *`package.json` keywords expanded* — The `keywords` array now lists the security / PQC / server-framework terms operators actually search for.
|
|
1481
|
+
- v0.4.2 (2026-04-30) — **`package.json` keywords refresh.** Operator-facing `package.json` keywords expanded so the package surfaces in `npm search` for the concerns it actually covers (server framework, PQC, security defaults). No code changes. **Changed:** *`package.json` keywords expanded* — The `keywords` array now lists the security / PQC / server-framework terms operators actually search for. This is a package-metadata-only release; no library or runtime code changed.
|
|
1476
1482
|
|
|
1477
1483
|
- v0.4.1 (2026-04-29) — **Wiki bot-guard skips `/healthz` so post-publish smoke check passes.** **Fixed:** *Wiki bot-guard exempts `/healthz`* — The reference wiki's bot-guard middleware now allows `/healthz` through unchallenged so the post-publish smoke check can verify the deployed wiki is responding without tripping the bot-detection refusal path.
|
|
1478
1484
|
|
package/NOTICE
CHANGED
|
@@ -59,19 +59,18 @@ Used for: WebAuthn / passkey registration + authentication response
|
|
|
59
59
|
verification (lib/passkey.js, via lib/vendor/simplewebauthn-
|
|
60
60
|
server.cjs).
|
|
61
61
|
--------------------------------------------------------------------------------
|
|
62
|
-
Component: @
|
|
63
|
-
Version:
|
|
64
|
-
Source: https://github.com/
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
(PBES2 + AES-256-CBC + PBKDF2-HMAC-SHA-512,
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
chain).
|
|
62
|
+
Component: @blamejs/pki
|
|
63
|
+
Version: 0.3.25
|
|
64
|
+
Source: https://github.com/blamejs/pki
|
|
65
|
+
License: Apache-2.0
|
|
66
|
+
Copyright: Copyright (c) blamejs contributors
|
|
67
|
+
Used for: Pure-JS mTLS CA (default engine). Self-signed CA generation
|
|
68
|
+
(ML-DSA-87 by default, ECDSA P-384 bridge on opt-in), leaf-cert
|
|
69
|
+
signing for client certificates, X.509 CRL generation, and
|
|
70
|
+
PKCS#12 packaging (PBES2 + AES-256-CBC + PBKDF2-HMAC-SHA-512,
|
|
71
|
+
2,000,000 iterations; PBMAC1 integrity MAC). Zero runtime
|
|
72
|
+
dependencies. Wired into b.mtlsCa via lib/mtls-engine-default.js,
|
|
73
|
+
backed by the bundle lib/vendor/blamejs-pki.cjs.
|
|
75
74
|
--------------------------------------------------------------------------------
|
|
76
75
|
Component: SecLists — 10k-most-common.txt
|
|
77
76
|
Version: master snapshot (bundled 2026-05-02)
|
package/README.md
CHANGED
|
@@ -120,7 +120,7 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
|
|
|
120
120
|
- **Stream throttle** — shared token-bucket bandwidth limiter (RFC 2697 srTCM shape); N concurrent `node:stream` pipelines draw from one operator-configured `bytesPerSec` budget (`b.streamThrottle`)
|
|
121
121
|
- **TLS-RPT receiver** — RFC 8460 inbound aggregate-report ingest; HTTPS POST handler + §4.4 schema parser with gzip-bomb / ratio-bomb / depth-bomb defenses (`b.mail.deploy.parseTlsRptReport` / `b.mail.deploy.tlsRptIngestHttp`)
|
|
122
122
|
- **TLS / channel binding** — RFC 9266 TLS-Exporter token-to-session pinning (`b.tlsExporter`); RFC 9162 CT v2 inclusion-proof verification (`b.network.tls.ct.verifyInclusion`); RFC 8555 ACME + RFC 9773 ARI for 47-day certs with `{ jitter: true }` fleet-scheduling (`b.acme.renewIfDue`); draft-aaron-acme-profiles (`acme.listProfiles()` + `newOrder({ profile })`); draft-ietf-acme-dns-account-label (`acme.dnsAccount01ChallengeRecord(token, { identifier })`); RFC 8470 0-RTT inbound posture refuse / replay-cache (`b.router.create({tls0Rtt})`); RFC 9794 SecP256r1MLKEM768 in preferred-group order (`b.network.tls.preferredGroups`); RFC 6960 OCSP stapling — the cert manager (`b.cert`) fetches + validates each managed certificate's OCSP response (`b.network.tls.ocsp.fetch`) on a refresh cadence and exposes it on the served context for a TLS server's `OCSPRequest` handler to staple
|
|
123
|
-
- **mTLS CA** — pure-JS, issues clientAuth / serverAuth / dual-EKU certs with SAN
|
|
123
|
+
- **mTLS CA** — pure-JS on the zero-dependency `@blamejs/pki` toolkit, issues clientAuth / serverAuth / dual-EKU certs with SAN under a post-quantum ML-DSA-87 (FIPS 204) default that node:tls verifies in a real mutual-auth handshake on OpenSSL 3.5; pin the classical `ECDSA-P384-SHA384` bridge via `b.mtlsCa.create({ algorithm })` for peers that predate it; PQC TLS gates inbound + outbound (`b.mtlsCa`, `b.pqcGate`, `b.pqcAgent`)
|
|
124
124
|
### HTTP
|
|
125
125
|
|
|
126
126
|
- **Router + API specs** — schema-validated routes; OpenAPI 3.1 / 3.2 publication (`b.openapi` — webhooks + `jsonSchemaDialect`) + AsyncAPI publication for event/streaming (`b.asyncapi`)
|
|
@@ -316,11 +316,11 @@ All runtime dependencies are committed to the repo — no transitive npm install
|
|
|
316
316
|
| [`@noble/ciphers`](https://github.com/paulmillr/noble-ciphers) | 2.2.0 | [Paul Miller](https://github.com/paulmillr) | XChaCha20-Poly1305 AEAD |
|
|
317
317
|
| [`@noble/post-quantum`](https://github.com/paulmillr/noble-post-quantum) | 0.6.1 | [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. |
|
|
318
318
|
| [`@simplewebauthn/server`](https://github.com/MasterKale/SimpleWebAuthn) | 13.3.0 | [Matthew Miller](https://github.com/MasterKale) | WebAuthn / passkey verification |
|
|
319
|
-
| [`@
|
|
319
|
+
| [`@blamejs/pki`](https://github.com/blamejs/pki) | 0.3.25 | [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) |
|
|
320
320
|
| [`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 |
|
|
321
321
|
| [`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) |
|
|
322
322
|
|
|
323
|
-
These libraries are exceptional work — blamejs wouldn't exist without them. All are MIT licensed (the SecLists password list
|
|
323
|
+
These libraries are exceptional work — blamejs wouldn't exist without them. All are MIT licensed, except `@blamejs/pki` (Apache-2.0) and the SecLists password list (CC-BY-3.0). Per-package version, license, and provenance live in two manifests: [`lib/vendor/MANIFEST.json`](lib/vendor/MANIFEST.json) for the framework's server-side bundles and [`examples/wiki/public/vendor/MANIFEST.json`](examples/wiki/public/vendor/MANIFEST.json) for the wiki app's browser-side bundle. The framework's [`NOTICE`](NOTICE) file carries the upstream attributions.
|
|
324
324
|
|
|
325
325
|
## Why "blamejs"
|
|
326
326
|
|
package/lib/audit.js
CHANGED
|
@@ -99,6 +99,15 @@ var EXTERNAL_STORE_TIMEOUT_MS = C.TIME.seconds(30);
|
|
|
99
99
|
// verifyChain still works against the framework store.
|
|
100
100
|
var _externalStore = null;
|
|
101
101
|
|
|
102
|
+
// Mode of the registered external store: "shadow" (default) replicates
|
|
103
|
+
// each framework chain.append to the store AFTER the authoritative b.db
|
|
104
|
+
// write; "redirect" (useStore({ record, replaceChain: true })) routes the
|
|
105
|
+
// shaped event STRAIGHT to the store and SKIPS the b.db chain append — for
|
|
106
|
+
// a consumer that owns its own DB + tamper-evident audit layer and has no
|
|
107
|
+
// b.db, so a chain append would only throw db/not-initialized on every
|
|
108
|
+
// emit. Reset to "shadow" whenever the store is unregistered.
|
|
109
|
+
var _externalStoreMode = "shadow";
|
|
110
|
+
|
|
102
111
|
// Per-operation timeout for framework-state SQL. A misbehaving
|
|
103
112
|
// external-db driver hanging on a query shouldn't hang audit forever.
|
|
104
113
|
// 30s is generous for genuinely slow networks while still bounding
|
|
@@ -621,6 +630,24 @@ async function record(event) {
|
|
|
621
630
|
metadata: event.metadata ? JSON.stringify(event.metadata) : null,
|
|
622
631
|
requestId: event.requestId || null,
|
|
623
632
|
};
|
|
633
|
+
// Redirect mode: the consumer store is authoritative (it owns the DB
|
|
634
|
+
// + tamper-evident audit layer; there is no b.db to append to). Hand
|
|
635
|
+
// the shaped logical event straight to the operator's record() and
|
|
636
|
+
// SKIP the framework chain append entirely — no db/not-initialized.
|
|
637
|
+
// The 30s timeout bounds a stalled callback so a hung consumer store
|
|
638
|
+
// can't wedge the audit critical path; a genuine store failure
|
|
639
|
+
// PROPAGATES (record() is the await-durability surface — a direct
|
|
640
|
+
// caller must see it), while the emit()/safeEmit() handler-flush path
|
|
641
|
+
// catches it drop-silent so the request that emitted can't be crashed.
|
|
642
|
+
if (_externalStore && _externalStoreMode === "redirect" &&
|
|
643
|
+
typeof _externalStore.record === "function") {
|
|
644
|
+
await safeAsync.withTimeout(
|
|
645
|
+
Promise.resolve().then(function () { return _externalStore.record(logical); }),
|
|
646
|
+
EXTERNAL_STORE_TIMEOUT_MS,
|
|
647
|
+
{ name: "audit.redirectRecord" }
|
|
648
|
+
);
|
|
649
|
+
return logical;
|
|
650
|
+
}
|
|
624
651
|
var appended = await _chainWriter.append(logical);
|
|
625
652
|
// Operator-registered shadow store: replicate the fully-formed
|
|
626
653
|
// row to an immutable external destination. Drop-silent on
|
|
@@ -705,8 +732,29 @@ async function record(event) {
|
|
|
705
732
|
* Pass `null` (or `{ record: null }`) to unregister and revert to
|
|
706
733
|
* chain-only mode.
|
|
707
734
|
*
|
|
735
|
+
* Redirect mode — `useStore({ record, replaceChain: true })`: a
|
|
736
|
+
* consumer that owns its own database + audit layer and does NOT use
|
|
737
|
+
* `b.db` has no chain to shadow. In shadow mode every `record()` /
|
|
738
|
+
* `emit()` / `safeEmit()` still tries the `b.db` chain append first,
|
|
739
|
+
* which throws `db/not-initialized` on every emit (or silently drops
|
|
740
|
+
* from the emit handler). With `replaceChain: true` the shaped audit
|
|
741
|
+
* event is handed STRAIGHT to `record(event)` and the `b.db` chain
|
|
742
|
+
* append is skipped, so framework audit events (an SMTP insecure-TLS
|
|
743
|
+
* escape-hatch, mTLS negotiation at boot) land in the consumer's own
|
|
744
|
+
* tamper-evident log instead of erroring. In redirect mode the
|
|
745
|
+
* consumer store is authoritative: `record()`'s 30s timeout bounds a
|
|
746
|
+
* stalled callback, but a genuine store failure PROPAGATES to the
|
|
747
|
+
* caller (record() is the await-durability surface), while the
|
|
748
|
+
* `emit()` / `safeEmit()` handler-flush path drop-silent-catches it.
|
|
749
|
+
* The event passed to `record` is the shaped logical event
|
|
750
|
+
* (`{ action, outcome, actorUserId, actorIp, resourceKind, resourceId,
|
|
751
|
+
* reason, metadata, requestId, ... }`) — no framework `_id` /
|
|
752
|
+
* `monotonicCounter` / `prevHash` / `rowHash`, since there is no
|
|
753
|
+
* framework chain to hash against.
|
|
754
|
+
*
|
|
708
755
|
* @opts
|
|
709
|
-
* record:
|
|
756
|
+
* record: async function (row), // operator's persistence callback
|
|
757
|
+
* replaceChain: boolean, // default: false (shadow). true → redirect (skip the b.db chain)
|
|
710
758
|
*
|
|
711
759
|
* @example
|
|
712
760
|
* var b = require("@blamejs/core");
|
|
@@ -732,6 +780,7 @@ async function record(event) {
|
|
|
732
780
|
function useStore(store) {
|
|
733
781
|
if (store === null || store === undefined) {
|
|
734
782
|
_externalStore = null;
|
|
783
|
+
_externalStoreMode = "shadow";
|
|
735
784
|
return;
|
|
736
785
|
}
|
|
737
786
|
if (typeof store !== "object") {
|
|
@@ -740,12 +789,19 @@ function useStore(store) {
|
|
|
740
789
|
// `{ record: null }` unregisters explicitly (mirrors the null arg path).
|
|
741
790
|
if (store.record === null || store.record === undefined) {
|
|
742
791
|
_externalStore = null;
|
|
792
|
+
_externalStoreMode = "shadow";
|
|
743
793
|
return;
|
|
744
794
|
}
|
|
745
795
|
if (typeof store.record !== "function") {
|
|
746
796
|
throw new Error("audit.useStore: store.record must be an async function (row) => void");
|
|
747
797
|
}
|
|
798
|
+
// Boot-time config input: a bad replaceChain flag is an operator typo —
|
|
799
|
+
// throw loudly rather than silently coerce it.
|
|
800
|
+
if (store.replaceChain !== undefined && typeof store.replaceChain !== "boolean") {
|
|
801
|
+
throw new Error("audit.useStore: store.replaceChain must be a boolean (true routes events to record() and skips the b.db chain)");
|
|
802
|
+
}
|
|
748
803
|
_externalStore = store;
|
|
804
|
+
_externalStoreMode = store.replaceChain === true ? "redirect" : "shadow";
|
|
749
805
|
}
|
|
750
806
|
|
|
751
807
|
// ---- Query ----
|
|
@@ -824,6 +880,7 @@ async function query(criteria) {
|
|
|
824
880
|
// In single-node mode the query builder gives us field-crypto unsealing
|
|
825
881
|
// for free. In cluster mode we read raw rows from external-db and
|
|
826
882
|
// unseal manually.
|
|
883
|
+
/* c8 ignore next 3 -- cluster-mode topology (configured externalDb backend); single-node query() always takes the else path. Cluster read is exercised in test/integration/audit-stack-{postgres,mysql}. */
|
|
827
884
|
if (cluster.isClusterMode()) {
|
|
828
885
|
return await _queryCluster(criteria);
|
|
829
886
|
}
|
|
@@ -1057,6 +1114,7 @@ async function checkpoint(opts) {
|
|
|
1057
1114
|
// null. If the fence passes (a same-node concurrent race), it is idempotent.
|
|
1058
1115
|
// Any non-duplicate error rethrows.
|
|
1059
1116
|
if (_isDuplicateCheckpointCounter(e)) {
|
|
1117
|
+
/* c8 ignore next 3 -- cluster-only fence: single-node dup-anchor is idempotent and returns null below; the cluster fencing-token step-down is exercised in test/integration/audit-stack-{postgres,mysql}. */
|
|
1060
1118
|
if (cluster.isClusterMode()) {
|
|
1061
1119
|
await _upsertAuditTip(counter, tip.rowHash, String(createdAt), fencingToken);
|
|
1062
1120
|
}
|
|
@@ -1080,6 +1138,7 @@ async function checkpoint(opts) {
|
|
|
1080
1138
|
// also means the caller can audit the leader-lost transition and
|
|
1081
1139
|
// step down. Other audit-tip errors (network blip, transient DB)
|
|
1082
1140
|
// also surface so the operator can react.
|
|
1141
|
+
/* c8 ignore next 2 -- cluster-mode audit-tip upsert; single-node takes the else (durable-tip sidecar) branch below. Cluster path exercised in test/integration/audit-stack-{postgres,mysql}. */
|
|
1083
1142
|
if (cluster.isClusterMode()) {
|
|
1084
1143
|
await _upsertAuditTip(counter, tip.rowHash, String(createdAt), fencingToken);
|
|
1085
1144
|
} else {
|
|
@@ -1175,6 +1234,7 @@ async function verifyCheckpoints() {
|
|
|
1175
1234
|
};
|
|
1176
1235
|
}
|
|
1177
1236
|
var payload = _checkpointPayload(Number(c.atMonotonicCounter), c.atRowHash, Number(c.createdAt));
|
|
1237
|
+
/* c8 ignore next -- the isBuffer arm is pg/mysql-only (those drivers return a Buffer for the signature BLOB); node:sqlite single-node returns a Uint8Array, so the Buffer.from arm is the one taken here. */
|
|
1178
1238
|
var sigBuf = Buffer.isBuffer(c.signature) ? c.signature : Buffer.from(c.signature);
|
|
1179
1239
|
if (!auditSign.verify(payload, sigBuf, pub)) {
|
|
1180
1240
|
return {
|
|
@@ -1261,6 +1321,7 @@ async function verify(opts) {
|
|
|
1261
1321
|
function (sql, params) {
|
|
1262
1322
|
return safeAsync.withTimeout(
|
|
1263
1323
|
safeAsync.asyncRetry(function () {
|
|
1324
|
+
/* c8 ignore next -- auditChain.verifyChain always invokes this reader with a params array from the sql builder; the `|| []` is a defensive fallback that is never taken. */
|
|
1264
1325
|
return clusterStorage.executeAll(sql, params || []);
|
|
1265
1326
|
}),
|
|
1266
1327
|
FRAMEWORK_SQL_TIMEOUT_MS,
|
|
@@ -1277,6 +1338,7 @@ async function verify(opts) {
|
|
|
1277
1338
|
function _resetForTest() {
|
|
1278
1339
|
registeredNamespaces = new Set(FRAMEWORK_NAMESPACES);
|
|
1279
1340
|
_externalStore = null;
|
|
1341
|
+
_externalStoreMode = "shadow";
|
|
1280
1342
|
db.reset();
|
|
1281
1343
|
_chainWriter._resetForTest();
|
|
1282
1344
|
// Drop pending buffered emits and cancel the age-flush timer on the
|
|
@@ -1292,6 +1354,7 @@ function _resetForTest() {
|
|
|
1292
1354
|
// instead of writing the rest of the batch to the new database.
|
|
1293
1355
|
if (_auditHandler) {
|
|
1294
1356
|
try { _auditHandler.shutdownSync("audit._resetForTest"); }
|
|
1357
|
+
/* c8 ignore next -- shutdownSync only splices the buffer + cancels a timer; it has no input-driven throw path, so this defensive catch never runs. */
|
|
1295
1358
|
catch (e) { log.debug("reset-handler-shutdown-failed: " + (e && e.message || e)); }
|
|
1296
1359
|
_auditHandler = null;
|
|
1297
1360
|
}
|
|
@@ -1336,6 +1399,7 @@ function _ensureHandler() {
|
|
|
1336
1399
|
var firstDropAction = null;
|
|
1337
1400
|
var firstDropMessage = null;
|
|
1338
1401
|
for (var i = 0; i < batch.length; i++) {
|
|
1402
|
+
/* c8 ignore next -- mid-drain shutdown early-exit: fires only when the handler is shut down (test reset) while a batch is in flight, a timing race not deterministically forceable from the public API. */
|
|
1339
1403
|
if (ctx && ctx.isShutdown && ctx.isShutdown()) return;
|
|
1340
1404
|
try { await record(batch[i]); }
|
|
1341
1405
|
catch (e) {
|
|
@@ -1353,6 +1417,7 @@ function _ensureHandler() {
|
|
|
1353
1417
|
// representative sample without per-line log spam.
|
|
1354
1418
|
if (firstDropAction === null) {
|
|
1355
1419
|
firstDropAction = (batch[i] && batch[i].action) || null;
|
|
1420
|
+
/* c8 ignore next -- record() only ever rejects with an Error carrying a non-empty message, so the String(e) fallback (and the `e` falsy short-circuit) is unreachable. */
|
|
1356
1421
|
firstDropMessage = (e && e.message) ? e.message : String(e);
|
|
1357
1422
|
}
|
|
1358
1423
|
}
|
|
@@ -1443,6 +1508,7 @@ function _normalizeOutcome(o) {
|
|
|
1443
1508
|
// before the first dot) is left strict — namespaces are
|
|
1444
1509
|
// operator-registered and should be plain identifiers.
|
|
1445
1510
|
function _normalizeAction(action) {
|
|
1511
|
+
/* c8 ignore next -- only caller is safeEmit(), which returns early unless event.action is a string, so action is always a string here. */
|
|
1446
1512
|
if (typeof action !== "string") return action;
|
|
1447
1513
|
return action.replace(/-/g, "_");
|
|
1448
1514
|
}
|
|
@@ -1636,6 +1702,7 @@ async function flush() {
|
|
|
1636
1702
|
// when they boot under sox-404 / soc2 / pci-dss posture so a non-
|
|
1637
1703
|
// framework writer can't INSERT rows under a different role.
|
|
1638
1704
|
function _checkActorBinding(actorId, eventActorId, opts) {
|
|
1705
|
+
/* c8 ignore next -- only callers are bindActor()'s bound wrappers, and bindActor() throws unless actorId is a non-empty string, so actorId is always truthy here. */
|
|
1639
1706
|
if (!actorId) return true; // unbound — no enforcement
|
|
1640
1707
|
if (!eventActorId) {
|
|
1641
1708
|
return { ok: false, reason: "event missing actor.userId — refused under bound emit" };
|
|
@@ -1709,6 +1776,7 @@ function bindActor(actorId, opts) {
|
|
|
1709
1776
|
actor: { userId: actorId },
|
|
1710
1777
|
metadata: { attemptedAction: eventAction, reason: reason },
|
|
1711
1778
|
});
|
|
1779
|
+
/* c8 ignore next -- handlers' emit() never throws (it routes bad input to onError/dead-letter internally) and the violation event carries no throwing accessors, so this defensive catch never runs. */
|
|
1712
1780
|
} catch (_e) { /* drop-silent — never break the caller */ }
|
|
1713
1781
|
}
|
|
1714
1782
|
function boundSafeEmit(event) {
|