@blamejs/core 0.15.4 → 0.15.6

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,10 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.15.x
10
10
 
11
+ - v0.15.6 (2026-06-12) — **Closes SAML and OIDC assertion-replay windows, bounds SSE memory under a slow client, restores at-least-once delivery for a crashed outbox publisher, makes sealed-column membership queries work, ships JOSE-conformant SD-JWT signatures, and adds a URL canonicalizer for SSRF-safe comparison.** A security and correctness release. On the identity surface: a SAML Response whose Bearer (or Holder-of-Key) SubjectConfirmation omits NotOnOrAfter is now rejected instead of accepted as fresh-forever, and the OIDC ID-token verifier no longer lets a caller disable expiry validation on a normal token - the exp bypass is restricted to back-channel-logout tokens and bounded by an issued-at freshness floor. SD-JWT-VC ES256/ES384 signatures are now emitted as JOSE raw r||s, so credentials this issuer signs verify in conformant wallets and verifiers. A new b.safeUrl.canonicalize (and b.ssrfGuard.canonicalizeHost) collapses obfuscated host and IP forms to one canonical string so allowlist and SSRF comparisons can't be bypassed by encoding tricks. On the reliability side: server-sent-event channels now cap their per-connection outbound buffer and evict a stalled client instead of growing the heap without bound; the outbox reclaims a job left in-flight by a publisher that crashed mid-delivery; the background worker pool no longer drops a task queued behind one that timed out; a retention preview no longer rewrites the whole database file; an equality / membership query on an encrypted column now hashes each candidate (membership queries previously failed outright); and the on-read re-hash of a legacy lookup digest now runs on Postgres and MySQL handles, not only SQLite. **Fixed:** *SD-JWT-VC ES256 / ES384 signatures are JOSE-conformant* — `b.auth.sdJwtVc` signed and verified ES256 / ES384 credentials with node:crypto's default DER ECDSA encoding instead of the raw r||s (`ieee-p1363`) form JOSE and EUDI wallets require, so a credential this issuer signed was rejected by conformant verifiers and the library rejected conformant holders' key-binding JWTs. The issuer JWT and the holder KB-JWT now both sign and verify with `ieee-p1363`, matching the rest of the framework's JOSE signers. · *Membership queries on an encrypted column now work* — Querying an encrypted (sealed) column with `IN` / `$in` - `b.db.from(table).whereIn("email", [...])` or `b.db.collection(table).find({ email: { $in: [...] } })` - threw, because the sealed-field-to-derived-hash rewrite passed the whole candidate array to the hash lookup as a single value. Each candidate is now hashed individually and matched against both its active keyed digest and its legacy digest, so membership queries on an encrypted column return the right rows, including rows written before the lookup-hash default changed. · *A timing-out background task no longer drops the task queued behind it* — When a `b.workerPool` task timed out or its worker errored, the slot was returned to the idle pool and drained one moment before it was marked for recycling, so a task queued behind it could be dispatched to the worker about to be terminated - and came back as `workerpool/worker-exit` (or hung) instead of running. The slot is now marked recycling before the queue is drained, so the queued task waits for the replacement worker. · *The outbox recovers a job stranded by a crashed publisher* — `b.outbox` claims a row by flipping it to in-flight, but the claim scan only selected pending rows, so a publisher that crashed between claiming a row and recording delivery left that row in-flight forever - the event was silently dropped, breaking at-least-once delivery. The outbox now stamps each claim with a timestamp and, at the start of every poll, returns any in-flight row older than the claim lease (`claimReclaimMs`, default 5 minutes) to the pending pool so it is delivered. An existing outbox table gains the new column automatically. · *A retention preview no longer rewrites the whole database* — Previewing a retention rule with `b.retention` (`run(name, { dryRun: true })` or the `retention preview` command) ran a full database VACUUM for every candidate row under a regulated posture (gdpr / hipaa / and similar), because the per-row erase - which schedules the vacuum - ran before the dry-run check. A preview now computes what it would erase without touching the database; the vacuum runs only on a real erase. · *On-read lookup-digest upgrade runs on Postgres and MySQL* — When `b.cryptoField.unsealRow` re-hashed a legacy lookup digest to the current keyed form and persisted it, the UPDATE was always built for SQLite, so on a Postgres or MySQL handle the durable rewrite quoted identifiers for the wrong dialect and silently no-opped - the legacy digest stayed on disk and the migration never completed off SQLite. The rewrite now uses the handle's own dialect. **Security:** *SAML SubjectConfirmation without NotOnOrAfter is rejected* — `b.auth.saml` SP response verification treated the `NotOnOrAfter` attribute on a Bearer SubjectConfirmationData as optional: a confirmation that omitted it was accepted with no upper bound on the assertion's freshness - a captured assertion replayable indefinitely. SAML 2.0 Web Browser SSO Profile §4.1.4.2 requires Bearer confirmations to carry NotOnOrAfter. `verifyResponse` now rejects a confirmation that is missing NotOnOrAfter, has an unparseable value, or is expired; the Holder-of-Key path (Profile §3.1) is hardened the same way, including the previously-accepted unparseable-value case. · *ID-token expiry can no longer be disabled on a normal token* — `b.auth.oauth`'s `verifyIdToken` honored a `skipExpCheck` option with no constraint, so any caller could verify an expired - or replayed - ID token. That option exists only for OIDC Back-Channel Logout tokens, which carry no `exp`. It is now self-guarding: `verifyIdToken` rejects `skipExpCheck` (`auth-oauth/skip-exp-check-not-allowed`) on any token that lacks the back-channel-logout event claim, and even for a logout token it enforces an `iat` freshness floor (`auth-oauth/logout-token-stale`). The internal logout path is unaffected. · *Server-sent-event channels bound their outbound buffer* — An SSE channel wrote to the response with no regard for backpressure and no cap on buffered bytes, so a single stalled client could make the server buffer events until the heap was exhausted (a memory-exhaustion denial of service). Each channel now tracks its unflushed-byte count and, past a per-channel cap (`maxBufferedBytes`, default 1 MiB), closes the connection and throws `sse/backpressure` - evicting the slow consumer instead of buffering without limit. A client that keeps up is never affected. · *URL and host canonicalizer for SSRF-safe comparison* — New `b.safeUrl.canonicalize(url, opts?)` and `b.ssrfGuard.canonicalizeHost(host)` return the canonical, comparable form of a URL or host: scheme and host lowercased, IDN hosts emitted as their punycode A-label (a confusable / mixed-script host is rejected), every base of an IP literal (decimal, octal, hex, dotted, IPv4-mapped and zero-compressed IPv6) collapsed to one canonical address, default ports stripped, trailing-dot hosts normalized, and path percent-encoding normalized per RFC 3986. Use it to build host allowlists, deduplicate URLs, or compare a fetch target so an allowlist check can't be bypassed by encoding the same address a different way.
12
+
13
+ - v0.15.5 (2026-06-12) — **Legal-hold and subject-restriction PII is sealed at rest, and a guard's compliance-posture forensic and runtime caps are applied on its default gate.** This release closes two data-protection gaps. The legal-hold registry and the subject-restriction flag stored their free-text fields - the legal basis, custodian, ticket citation, and restriction reason that link a data subject to a legal matter - in clear, because those local tables were written through a raw SQL path that bypassed the structured builder's at-rest sealing. Those columns are now sealed on write and unsealed on read, the same way the DSR ticket store already seals subject identifiers. Separately, a content guard built on the standard gate contract and gated with a compliance posture (for example b.guardCidr.gate({ compliancePosture: "hipaa" })) silently dropped that posture's forensic-snapshot cap and the profile's runtime cap, because the default gate passed the caller's raw options straight to the gate builder instead of resolving the profile and posture first - so a regulated-posture refusal carried no forensic evidence. The default gate now resolves the profile and posture before building the gate, matching the hand-written guard gates. **Security:** *Legal-hold and subject-restriction PII is sealed at rest* — `b.legalHold`'s `_blamejs_legal_hold` registry stored the hold reason, custodian, placed-by, and citation in clear, and `b.subject.restrict`'s `_blamejs_subject_restrictions` stored the restriction reason in clear - free text that ties a data subject to a litigation hold or an Art. 18 processing restriction. Those rows were written through a raw `sql.insert` + `prepare().run()` path that bypassed the structured builder's automatic field sealing (the subject-restrictions table even declared the field as sealed, but the raw write never applied it). Both now seal those columns on write and unseal on read through `cryptoField`, so the legal-basis and custodian text is encrypted at rest under the deployment's vault key. Pre-existing plaintext rows continue to read correctly (the unseal path passes through an already-plaintext value). · *A guard's default gate applies its compliance-posture forensic and runtime caps* — A guard built on `b.gateContract.defineGuard` with the standard gate (no bespoke gate) and gated with a compliance posture dropped that posture's `forensicSnippetBytes` cap and the profile's `maxRuntimeMs` cap: the default gate passed the caller's raw options straight to the gate builder, which reads those caps directly, but the values live on the resolved profile and posture, not the raw options. The effect was that a regulated-posture refusal captured no forensic snapshot (the cap defaulted to 0, i.e. disabled) and the check ran without the profile's runtime bound. The default gate now resolves the profile and posture before building the gate - matching the hand-written guard gates - so `gate({ compliancePosture: "hipaa" })` applies the posture's forensic cap and the profile's runtime cap as documented.
14
+
11
15
  - v0.15.4 (2026-06-12) — **Telemetry attribute values are redacted before they leave the process, per-row data residency is enforced on every write and export path, DDL routes through the single-statement gate, the DPoP middleware requires its replay store, and session rotation re-keys the device binding.** This release closes a set of egress, data-residency, and session-binding gaps. OTLP span, span-event, and resource attributes are now scrubbed through the telemetry redactor before serialization, on both the JSON and protobuf paths, matching the metric exporter - an attribute value holding a bearer token, password, or API key no longer reaches the collector verbatim. Per-row data residency, previously enforced only at the structured query builder, is now enforced on the three paths that bypassed it: raw SQL writes, read-replica fan-out, and backup export. Every CREATE TABLE / ALTER TABLE the schema reconciler and the DSR store emit now passes through the same single-statement gate the query builder uses. The DPoP middleware now requires its replay store at mount time instead of silently mounting a proof-of-possession gate that performed no replay check. And session rotation re-keys the device fingerprint to the new session id, so a rotated session stays bound to its device instead of falsely reporting drift on the next request. **Fixed:** *Session rotation re-keys the device fingerprint to the new session id* — A session's optional device fingerprint is keyed to its session id, so that a stolen database cannot replay the binding. `b.session.rotate` moved the session id but left the stored fingerprint keyed to the old id, so the next `verify` recomputed the fingerprint against the new id and mismatched - reporting a false `fingerprintDrift` (which destroys the session under strict operators, logging the user out on every rotation) or silently breaking the binding. Rotation now re-keys the fingerprint to the new session id from the live request: pass the same `{ req, fingerprintFields }` to `rotate`. A fingerprint-bound session rotated without `req` now throws, because the binding cannot otherwise follow the new id; unbound sessions are unaffected. **Security:** *OTLP exporter redacts span, event, and resource attribute values before egress* — Span, span-event, and resource attributes were serialized to the OTLP collector verbatim on both the JSON and protobuf encodings - the metric exporter scrubbed its attributes through the telemetry redactor, but the span exporter did not. An attribute value carrying a secret or PII (a bearer token in `authorization`, a `password`, an `api_key`) was therefore shipped in clear to whatever collector the deployment points at (CWE-532). Every attribute-map encoder now runs each value through `b.observability.redactAttrs` (default composes `b.redact.redact`, dropping any attribute whose redactor throws) before the wire payload, so telemetry is redacted like the log and audit egress paths. The new `b.observability.redactAttrs(attrs)` is available for operators building custom exporters. · *Per-row data residency is enforced on raw writes, read replicas, and backups* — Per-row residency was enforced only at the structured query builder. Three paths reached storage or left the deployment without it: raw SQL writes (`b.db.runSql` / `b.db.prepare().run()`, INSERT and UPDATE forms) bypassed the local residency check entirely, so a cross-border row could be written under a regulated posture with no refusal; read-replica fan-out dropped the row-residency tag, routing a regulated read with no row region identified to a residency-tagged, non-cross-border replica with no check; and `b.backup.create`'s residency check compared only the single deployment region to the destination, blind to a per-row-residency table that admits rows from several regions. Raw writes now parse the target table and residency value and apply the same gate the builder does; the replica read now fails closed when the row region is unidentified; and backup now emits a per-row cross-border advisory for any declared residency table whose admitted regions differ from the backup destination. · *Schema and DSR DDL routes through the single-statement gate* — The CREATE TABLE / ALTER TABLE statements emitted by the schema reconciler and the DSR ticket store were assembled and run without the single-statement / NUL / unterminated-quote / unbalanced-paren gate that every query the builder emits already passes. That gate is now a shared check both the builder's catalog emitter and the schema/DSR DDL path call, so a terminator, comment marker, or unbalanced quote that reached a DDL fragment is refused at emit time on every backend. · *DPoP middleware requires its replay store at mount time* — `b.middleware.dpop` documented `replayStore` as required, but the factory read it optionally and gated the jti-replay check behind its presence - omitting it mounted a proof-of-possession gate that performed no replay check, so a captured DPoP proof could be replayed indefinitely (RFC 9449 §11.1). The middleware now requires the store at config time: a missing store, or a store lacking `checkAndInsert`, throws when the middleware is created instead of failing open at request time. The low-level `b.auth.dpop.verify` primitive keeps `replayStore` optional for advanced callers that track jti themselves.
12
16
 
13
17
  - v0.15.3 (2026-06-12) — **DDL hardening in b.sql, schema-confined column introspection on Postgres and MySQL, and a classical-downgrade audit on proxy-tunneled TLS.** This release hardens the data layer and closes a transport audit gap. The b.sql builder refuses an unrecognised column type that carries a statement terminator, quote, or comment marker - the one position in an otherwise quote-by-construction DDL builder where a verbatim string reached the emitted statement - and routes the finished CREATE TABLE through the same single-statement gate every other verb uses. The schema reconciler's column introspection is now confined to the schema or database the bare-named CREATE TABLE actually writes into (current_schema() on Postgres, DATABASE() on MySQL), so a same-named table in another schema no longer pollutes the column set, silently skipping an ADD COLUMN or fabricating false schema drift that refuses a regulated-posture boot. Two further builder gaps are fixed: a column-level primary key combined with a composite primaryKey now fails at build time with a clear error instead of producing invalid DDL, and a MySQL upsert read-back keyed by a cast or a server-evaluated function now renders the cast (or refuses the function) instead of binding an internal wrapper. Finally, an HTTPS request sent through a configured proxy now emits the tls.classical_downgrade audit when the handshake falls back to a classical group, the same as a direct connection. **Fixed:** *Schema reconciliation reads columns from the right schema on Postgres and MySQL* — The reconciler's column introspection queried information_schema with no schema filter, so on a Postgres instance or MySQL server hosting more than one schema/database with a same-named table, the live column set was the union across schemas. That could silently skip an ADD COLUMN the table needed, or report false drift that refuses a boot under a pinned regulated posture. Introspection is now confined to current_schema() (Postgres) / DATABASE() (MySQL) - the schema the bare-named CREATE TABLE lands in. SQLite (PRAGMA, per-file) is unchanged. · *createTable rejects a contradictory primary-key declaration at build time* — Declaring both a column-level primary key (primaryKey / autoIncrement / serial) and a composite opts.primaryKey emitted two PRIMARY KEY clauses, which every dialect rejects at the driver mid-migration. The builder now refuses the contradiction at build time with a clear error; a single column PK, or a composite primaryKey with no column-level PK, is unaffected. · *MySQL upsert read-back resolves a cast or function conflict key instead of binding a wrapper* — On MySQL, an upsert whose conflict key was a b.sql.cast(...) or b.sql.fn(...) built a read-back SELECT that bound the wrapper object, so the read-back matched no rows. A cast conflict key now renders as CAST(? AS type) binding the inner value; a server-evaluated function conflict key (which has no stable read-back identity) is refused with a clear error. Plain scalar conflict keys are unchanged. · *Proxy-tunneled TLS emits the classical-downgrade audit* — An HTTPS upstream reached through a configured proxy performed its TLS handshake without emitting the tls.classical_downgrade audit on a classical-group fallback, leaving the post-quantum-readiness inventory incomplete for proxied requests. Both the upstream handshake and the proxy-leg handshake now emit the audit on a classical fallback, matching the direct connection path. The handshake itself is unchanged (still hybrid-preferred TLSv1.3). **Security:** *b.sql refuses an injection-bearing verbatim column type and gates every CREATE TABLE* — An unrecognised column type passed to b.sql.createTable / alterTable was emitted into the DDL verbatim - the single raw-emission position in a builder that otherwise quotes every identifier and guards every constraint fragment. A type such as "text); DROP TABLE secrets; --" could therefore smuggle a stacked statement. The builder now refuses, at build time, a verbatim type carrying a statement terminator or comment marker, and routes the finished CREATE TABLE / ALTER TABLE statement through the same single-statement / NUL / unterminated-quote / unbalanced-paren gate every SELECT / INSERT / UPDATE / DELETE / UPSERT already used - so an unbalanced quote is caught there. Legitimate types are unaffected: VARCHAR(255), NUMERIC(10,2), DOUBLE PRECISION, TIMESTAMP WITH TIME ZONE, and MySQL ENUM('a','b') / SET(...) (which need balanced quotes) all still pass.
package/MIGRATING.md CHANGED
@@ -14,6 +14,49 @@ The framework has no `deprecate()`-marked surface awaiting removal.
14
14
 
15
15
  Listed newest-first.
16
16
 
17
+ ### v0.15.6 — `b.auth.sdJwtVc — ES256 / ES384 signatures are now JOSE raw r||s, not DER`
18
+
19
+ `b.auth.sdJwtVc` now signs and verifies ES256 / ES384 with `dsaEncoding: "ieee-p1363"` (raw r||s), the encoding JOSE / JWS and EUDI wallets require. Previously it used node:crypto's default DER ECDSA encoding, so a credential this issuer signed was rejected by conformant verifiers and the library rejected conformant holders' key-binding JWTs. The signature bytes change shape (64 bytes for ES256, 96 for ES384, no leading `0x30` SEQUENCE tag).
20
+
21
+ No code change is needed — interop with conformant JOSE / wallet verifiers now works where it previously failed. Two things to re-check if you integrated with the OLD output:
22
+
23
+ - A previously-issued ES256 / ES384 SD-JWT-VC signed by an earlier version is DER-encoded; re-issue it (signatures are not portable across the encodings). Tokens are short-lived, so this clears on the next issuance cycle.
24
+ - If you pinned, cached, or asserted on the raw signature bytes of this library's ES256 / ES384 output, update the fixture — the bytes are now `ieee-p1363`. EdDSA / ML-DSA signatures are unchanged.
25
+
26
+ ### v0.15.6 — `b.auth.oauth verifyIdToken — skipExpCheck is restricted to logout tokens`
27
+
28
+ `verifyIdToken`'s `skipExpCheck` option now throws (`auth-oauth/skip-exp-check-not-allowed`) on any token that is not an OIDC Back-Channel-Logout token (no `http://schemas.openid.net/event/backchannel-logout` event claim), and enforces an `iat` freshness floor on logout tokens (`auth-oauth/logout-token-stale`). Previously any caller could pass `skipExpCheck: true` to verify an expired — or replayed — ID token. The option was undocumented and only used internally by the back-channel-logout path, which is unaffected.
29
+
30
+ No change for normal ID-token verification or for the framework's back-channel-logout handling. If you called `verifyIdToken(token, { skipExpCheck: true })` directly on a non-logout token (an undocumented use), it now throws: drop the option so expiry is validated, or verify the token through the back-channel-logout path if it really is a logout token.
31
+
32
+ ### v0.15.4 — `b.middleware.dpop — replayStore now required at mount`
33
+
34
+ `b.middleware.dpop` now REQUIRES a `replayStore` at mount time and throws (`auth-dpop/replay-store-required`) if it is omitted or lacks `checkAndInsert`. Previously the jti-replay check was gated behind store presence, so omitting it silently mounted a DPoP gate with NO replay defense — a captured proof could be replayed indefinitely (RFC 9449 §11.1).
35
+
36
+ Operators mounting `b.middleware.dpop` without a `replayStore`:
37
+
38
+ ```js
39
+ b.middleware.dpop({
40
+ replayStore: b.nonceStore.create({ backend: "memory" }), // shared backend on multi-process
41
+ // ...other opts
42
+ });
43
+ ```
44
+
45
+ Use a process-shared `replayStore` backend (not `"memory"`) on a multi-process / multi-node deployment so a proof replayed against a different worker is still caught. The low-level `b.auth.dpop.verify` primitive keeps `replayStore` optional for advanced callers that track `jti` themselves.
46
+
47
+ ### v0.15.4 — `b.session.rotate — { req } required for a fingerprint-bound session`
48
+
49
+ Rotating a session created with a device fingerprint (`{ req, fingerprintFields }`) now requires the same `{ req, fingerprintFields }` at `b.session.rotate()`; a bound session rotated without `req` throws (`ROTATE_FINGERPRINT_REQ_REQUIRED`). The fingerprint is keyed to the session id, so rotation must re-key it to the new id from the live request — previously rotation left the old-id-keyed hash in place, which made the next `verify` false-drift (logging the user out under strict operators) or silently break the binding. Unbound sessions are unaffected.
50
+
51
+ Operators who rotate fingerprint-bound sessions (login / MFA / role-change transitions):
52
+
53
+ ```js
54
+ // Pass the same { req, fingerprintFields } used at create():
55
+ await b.session.rotate(oldToken, { req, fingerprintFields: ["clientIp", "userAgent"], reason: "mfa" });
56
+ ```
57
+
58
+ If you rotate a bound session from a context without the request, you must supply `req` so the binding can follow to the new session id. Sessions created WITHOUT a fingerprint need no change.
59
+
17
60
  ### v0.9.15 — `b.middleware.idempotencyKey.dbStore — table schema`
18
61
 
19
62
  Single `v` JSON-envelope column split into discrete `fingerprint` / `status_code` / `headers` / `body` / `expires_at` columns; `headers` + `body` are sealed via `b.cryptoField.sealRow` when vault is initialized; `k` column carries the sha3-512 namespace-hash of the operator-supplied key.
package/README.md CHANGED
@@ -85,10 +85,12 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
85
85
  - Pluggable storage via `b.session.useStore` + first-party `b.session.stores.localDbThin` (tmpfs-fast)
86
86
  - Opaque-userId anonymous sessions via `create({ anonymous: true })`
87
87
  - Idle / absolute timeouts, fingerprint drift detection + anomaly scoring, brute-force lockout
88
+ - 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)
88
89
  - **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`)
89
90
  - **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`)
90
91
  - **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`)
91
- - **Data-subject coordination** — cross-table export / rectify / erase / restrict / objection (`b.subject`, `b.subject.eraseHard`); subject-level legal-hold registry consulted by erase + retention paths (FRCP Rule 26/37(e), GDPR Art 17(3)(e), SEC Rule 17a-4, HIPAA §164.530(j)(2)) (`b.legalHold`)
92
+ - **DPoP proof-of-possession** — RFC 9449 sender-constrained tokens; `b.middleware.dpop` requires a `replayStore` (a `b.nonceStore`-shaped `{ checkAndInsert }`) at mount so single-use jti-replay defense is always enforced mounting it without the store throws at config time rather than failing open at request time
93
+ - **Data-subject coordination** — cross-table export / rectify / erase / restrict / objection (`b.subject`, `b.subject.eraseHard`); subject-level legal-hold registry consulted by erase + retention paths (FRCP Rule 26/37(e), GDPR Art 17(3)(e), SEC Rule 17a-4, HIPAA §164.530(j)(2)) (`b.legalHold`). The legal-hold reason / custodian / citation and the Art. 18 restriction reason — free text that ties a subject to a legal matter — are sealed at rest under the vault key, not stored in clear
92
94
  - **WORM retention** — write-once-read-many records over any backing store (`b.worm.create`): `compliance` / `governance` Object-Lock modes, extend-only `retainUntil`, legal holds, and a tamper-evident SHA3-512 digest verified on read — the store-agnostic application-level companion to `b.objectStore`'s S3 Object Lock, for sealed-DB / filesystem / non-S3 backends (SEC 17a-4(f), CFTC 1.31, FINRA 4511)
93
95
  - **Account safety** — adaptive bot-challenge staircase (`b.authBotChallenge`); session-to-device-posture binding with fail-closed verify (`b.sessionDeviceBinding`)
94
96
  - **Anonymous authorization** — Privacy Pass origin side (RFC 9577/9578 — `b.privacyPass`): issue a `WWW-Authenticate: PrivateToken` challenge and verify a presented Blind-RSA (type 0x0002) token against the issuer public key, with no issuer callback and no client identity
@@ -97,7 +99,7 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
97
99
 
98
100
  - **At-rest envelope** — envelope-versioned PQC (ML-KEM-1024 + P-384 hybrid, XChaCha20-Poly1305, SHAKE256); vault sealing (`b.crypto`, `b.vault`)
99
101
  - **Power-on self-test** — `b.crypto.selfTest()` runs FIPS 140-3-style integrity checks: NIST FIPS 202 known-answer tests (SHA3-256/512, SHAKE256), AEAD round-trip + tamper-detect, and ML-KEM-1024 / ML-DSA-87 / SLH-DSA-SHAKE-256f pairwise-consistency + negative tests; fails closed (throws) on any mismatch
100
- - **Field-level + crypto-shred** — `b.cryptoField.eraseRow`; per-column and per-row data residency tagging enforced at the write boundary (cross-border DML refused under GDPR / UK-GDPR / DPDP / PIPL / LGPD / APPI / PDPA postures) + per-row keys (each row's key derives from a CSPRNG row-secret sealed under the vault root, never from an on-disk value) so destroying a row's wrapped secret leaves its WAL / replica / backup residual ciphertext undecryptable even with the vault root key (`b.cryptoField.declareColumnResidency`, `b.cryptoField.declarePerRowResidency`, `b.cryptoField.declarePerRowKey`)
102
+ - **Field-level + crypto-shred** — `b.cryptoField.eraseRow`; per-column and per-row data residency tagging enforced at the write boundary (cross-border DML refused under GDPR / UK-GDPR / DPDP / PIPL / LGPD / APPI / PDPA postures) on the structured builder, on raw SQL writes (`b.db.runSql` / `b.db.prepare().run()`, parsed quote-aware and failing closed when unparseable), on read-replica fan-out (a regulated read with no row region identified is refused), and surfaced by `b.backup.create` for any per-row-residency table whose admitted regions differ from the backup destination — plus per-row keys (each row's key derives from a CSPRNG row-secret sealed under the vault root, never from an on-disk value) so destroying a row's wrapped secret leaves its WAL / replica / backup residual ciphertext undecryptable even with the vault root key (`b.cryptoField.declareColumnResidency`, `b.cryptoField.declarePerRowResidency`, `b.cryptoField.listPerRowResidency`, `b.cryptoField.declarePerRowKey`)
101
103
  - **AAD-bound sealed columns** — AEAD tag tied to `(table, rowId, column, schemaVersion)`; copy-paste between rows or schema-version replay surfaces as refused decrypt (`b.vault.aad`). The database encryption key is sealed the same way — bound to its purpose, data directory, and key path — so a relocated key file fails to unseal; an older unbound key upgrades itself on first load. A vault-key rotation re-seals every AAD-bound cell, the database key, and tenant archives under the new keypair and refuses rather than silently orphaning a store it cannot reach (`b.vaultRotate`, `b.vault.aad.resealRoot`, `b.archive.rewrapTenant`)
102
104
  - **Keyed lookup hashes** — sealed-column equality-lookup hashes default to salted SHA3-512 and can opt into a keyed `hmac-shake256` MAC off a per-deployment key (`cryptoField.registerTable({ derivedHashMode })`, `b.vault.getDerivedHashMacKey`), making the lookup hash unforgeable and un-correlatable across deployments
103
105
  - **Signed webhooks + API encryption** — SLH-DSA-SHAKE-256f default; ML-DSA-65 opt-in; ECIES API encryption (`b.webhook`, `b.crypto`)
@@ -138,7 +140,7 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
138
140
  ### Defensive parsers
139
141
 
140
142
  - **JSON / SQL / schema** — `b.safeJson` (with `maxKeys` cap defending CVE-2026-21717 V8 HashDoS), `b.safeBuffer`, `b.safeSql`, `b.safeSchema`
141
- - **URL + path** — `b.safeUrl` (IDN mixed-script / homograph refuse); `b.safeJsonPath` (refuses filter `?(...)`, deep-scan `$..`, script-shape `(@.x)` for safe Postgres JSONB ops)
143
+ - **URL + path** — `b.safeUrl` (IDN mixed-script / homograph refuse; `canonicalize` collapses obfuscated host + IP forms — decimal / octal / hex / IPv4-mapped / zero-compressed IPv6, IDN → punycode, default-port, trailing-dot, percent-encoding — to one comparable string so SSRF allowlist / dedup checks can't be bypassed by re-encoding the same address, with `b.ssrfGuard.canonicalizeHost` for the host-only path); `b.safeJsonPath` (refuses filter `?(...)`, deep-scan `$..`, script-shape `(@.x)` for safe Postgres JSONB ops)
142
144
  - **Binary codec** — `b.cbor` bounded deterministic CBOR (RFC 8949 §4.2): depth/size caps, indefinite-length + reserved-info + tag + duplicate-key refusal, `requireDeterministic` canonical-form check; the in-tree substrate under COSE / CWT / SCITT / WebAuthn attestation
143
145
  - **COSE messages** — `b.cose` the full RFC 9052 message-type set over `b.cbor`: COSE_Sign1 sign/verify (attached or detached payload), COSE_Encrypt0 single-recipient AEAD, COSE_Mac0 shared-key HMAC (mac0/macVerify0), plus `importKey` (COSE_Key → KeyObject) and `exportKey` (KeyObject → COSE_Key, the inverse — ship a verification key as RFC 9052 §7 bytes). Signatures use classical ES256/384/512 + EdDSA (final COSE ids, interoperable today) plus ML-DSA-87 (PQC-forward, draft id); bounded + alg-allowlisted + crit-bypass-checked verification; AEAD ChaCha20/Poly1305 default (AES-GCM opt-in); the signed-statement substrate under SCITT / CWT / mdoc / C2PA
144
146
  - **CBOR Web Token** — `b.cwt` CWT sign/verify (RFC 8392) over `b.cose`: standard-claim mapping (iss/sub/aud/exp/nbf/iat/cti) + `exp`/`nbf` clock-skew enforcement + `iss`/`aud` matching; the CBOR-native JWT for constrained / IoT / FIDO / verifiable-credential contexts
@@ -218,7 +220,7 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
218
220
  ### Observability
219
221
 
220
222
  - **Audit chain** — tamper-evident, SLH-DSA-signed checkpoints; CADF (ISO/IEC 19395:2017) envelope export for federated SIEM (`b.audit`, `b.audit.export({ format: "cadf" })`)
221
- - **Metrics + tracing** — `b.metrics`, `b.tracing` (OTel pass-through); OTLP/HTTP-JSON exporter for traces + metrics (`b.otelExport`)
223
+ - **Metrics + tracing** — `b.metrics`, `b.tracing` (OTel pass-through); OTLP/HTTP-JSON exporter for traces + metrics (`b.otelExport`). Span / metric / resource attribute **values** are scrubbed through the telemetry redactor before egress (`b.observability.redactAttrs`, default composes `b.redact.redact`) so a secret or PII in an attribute value never reaches the collector verbatim (CWE-532); operators building a custom exporter apply the same gate
222
224
  - **Log-stream sinks** — local file rotation, generic webhook, OTLP/HTTP-JSON OR OTLP/gRPC, AWS CloudWatch Logs via SigV4 with optional autoCreate, RFC 5424 syslog over UDP/TCP/TLS (`b.logStream`)
223
225
  - **PII redaction** — `b.redact`
224
226
  - **Decoy detection** — canary-credential / decoy-record framework auditing every positive lookup as `honeytoken.tripped` (`b.honeytoken`)
package/lib/auth/oauth.js CHANGED
@@ -1833,10 +1833,30 @@ function create(opts) {
1833
1833
  var now = Math.floor(Date.now() / C.TIME.seconds(1));
1834
1834
  var skewSec = Math.floor(clockSkewMs / C.TIME.seconds(1));
1835
1835
  // OIDC Back-Channel Logout 1.0 §2.4 — logout tokens have no `exp`
1836
- // claim; freshness comes from `iat` + jti-replay window. Operators
1837
- // verifying logout tokens pass `skipExpCheck: true`. ID tokens
1838
- // never set this and continue to require `exp`.
1839
- if (!vopts.skipExpCheck) {
1836
+ // claim; freshness comes from `iat` + jti-replay window. `skipExpCheck`
1837
+ // bypasses the exp gate for that path ONLY. It is a public-API option,
1838
+ // so it must be self-guarding: refuse it on any token that is not a
1839
+ // logout token (no back-channel-logout event), or a caller could verify
1840
+ // an expired/replayed id_token clean. Logout tokens then carry no exp,
1841
+ // so `iat` is the only freshness bound — enforce a max-age floor.
1842
+ if (vopts.skipExpCheck) {
1843
+ if (!payload.events || typeof payload.events !== "object" ||
1844
+ !payload.events["http://schemas.openid.net/event/backchannel-logout"]) {
1845
+ throw new OAuthError("auth-oauth/skip-exp-check-not-allowed",
1846
+ "skipExpCheck is only valid for back-channel-logout tokens " +
1847
+ "(OIDC Back-Channel Logout 1.0 §2.4); this token carries no logout event claim");
1848
+ }
1849
+ // Honor the operator's configured replay window. verifyBackchannelLogoutToken
1850
+ // exposes vopts.maxAgeSec (default 5 min) and passes it through here; a
1851
+ // deployment that widened the window must not have this freshness floor
1852
+ // reject a token between the default and its configured max age.
1853
+ var logoutMaxAgeSec = (typeof vopts.maxAgeSec === "number" && isFinite(vopts.maxAgeSec) &&
1854
+ vopts.maxAgeSec > 0) ? vopts.maxAgeSec : DEFAULT_LOGOUT_TOKEN_MAX_AGE_SEC;
1855
+ if (typeof payload.iat !== "number" || payload.iat + logoutMaxAgeSec + skewSec < now) {
1856
+ throw new OAuthError("auth-oauth/logout-token-stale",
1857
+ "logout token iat is older than " + logoutMaxAgeSec + "s (no exp; iat is the freshness bound)");
1858
+ }
1859
+ } else {
1840
1860
  if (typeof payload.exp !== "number" || payload.exp + skewSec < now) {
1841
1861
  throw new OAuthError("auth-oauth/expired", "ID token expired (exp=" + payload.exp + ", now=" + now + ")");
1842
1862
  }
@@ -2212,6 +2232,10 @@ function create(opts) {
2212
2232
  // Logout tokens have no exp claim per OIDC Back-Channel Logout
2213
2233
  // §2.4 — the freshness gate is iat + jti-replay window.
2214
2234
  skipExpCheck: true,
2235
+ // Pass the operator's configured replay window through so verifyIdToken's
2236
+ // iat freshness floor uses it, not the 5-min default (the wrapper's own
2237
+ // maxAgeSec check below stays as a belt-and-suspenders bound).
2238
+ maxAgeSec: vopts.maxAgeSec,
2215
2239
  });
2216
2240
  var claims = verified.claims;
2217
2241
 
package/lib/auth/saml.js CHANGED
@@ -684,10 +684,11 @@ function create(opts) {
684
684
  // as Bearer (Profile §3.1 incorporates §3 by reference).
685
685
  var nbHok = _attr(scdHok, "NotBefore");
686
686
  var noaHok = _attr(scdHok, "NotOnOrAfter");
687
+ if (!noaHok) continue; // §3.1 (incorporates §3) — time-bound required
688
+ var noaHokSec = Date.parse(noaHok) / 1000; // ms→s
689
+ if (!isFinite(noaHokSec) || noaHokSec < nowSec - clockSkewSec) continue; // unparseable or expired
687
690
  if (nbHok && isFinite(Date.parse(nbHok) / 1000) && // ms→s
688
691
  Date.parse(nbHok) / 1000 > nowSec + clockSkewSec) continue; // ms→s
689
- if (noaHok && isFinite(Date.parse(noaHok) / 1000) && // ms→s
690
- Date.parse(noaHok) / 1000 < nowSec - clockSkewSec) continue; // ms→s
691
692
  var recipHok = _attr(scdHok, "Recipient");
692
693
  if (recipHok && recipHok !== opts.assertionConsumerServiceUrl) continue;
693
694
  hokOk = true;
@@ -697,12 +698,9 @@ function create(opts) {
697
698
  var scd = _findChild(sc, "SubjectConfirmationData", SAML_NS.assertion);
698
699
  if (!scd) continue;
699
700
  var notOnOrAfter = _attr(scd, "NotOnOrAfter");
700
- if (notOnOrAfter) {
701
- var t = Date.parse(notOnOrAfter) / 1000; // ms→s
702
- if (!isFinite(t) || t < nowSec - clockSkewSec) {
703
- continue; // expired confirmation — try next
704
- }
705
- }
701
+ if (!notOnOrAfter) continue; // §4.1.4.2 — Bearer requires NotOnOrAfter (no unbounded freshness)
702
+ var t = Date.parse(notOnOrAfter) / 1000; // ms→s
703
+ if (!isFinite(t) || t < nowSec - clockSkewSec) continue; // unparseable or expired confirmation — try next
706
704
  var notBefore = _attr(scd, "NotBefore");
707
705
  if (notBefore) {
708
706
  var nb = Date.parse(notBefore) / 1000; // ms→s
@@ -126,12 +126,25 @@ function _hashDisclosure(disclosureStr, hashAlg) {
126
126
  return h.digest().toString("base64url");
127
127
  }
128
128
 
129
+ // JOSE/JWS — and EUDI wallets — require ECDSA signatures as raw r||s
130
+ // ("ieee-p1363"); node:crypto defaults to DER (ASN.1 SEQUENCE). Wrap the EC
131
+ // key so ES256/ES384 sign + verify emit/expect the JOSE encoding. Without it
132
+ // a token this library signs is rejected by every conformant verifier (and
133
+ // the library rejects their raw-r||s KB-JWTs). EdDSA / ML-DSA keys pass
134
+ // through unchanged. Mirrors oauth.js / dpop.js / jwt-external.js.
135
+ function _ecKeyParam(algorithm, key) {
136
+ if (algorithm === "ES256" || algorithm === "ES384") {
137
+ return { key: key, dsaEncoding: "ieee-p1363" };
138
+ }
139
+ return key;
140
+ }
141
+
129
142
  function _signJwt(header, payload, privateKey, algorithm) {
130
143
  var headerStr = _b64uEncode(safeJson.stringify(header));
131
144
  var payloadStr = _b64uEncode(safeJson.stringify(payload));
132
145
  var signingInput = headerStr + "." + payloadStr;
133
146
  var sigAlgo = _resolveSigAlgo(algorithm);
134
- var sig = nodeCrypto.sign(sigAlgo, Buffer.from(signingInput, "ascii"), privateKey);
147
+ var sig = nodeCrypto.sign(sigAlgo, Buffer.from(signingInput, "ascii"), _ecKeyParam(algorithm, privateKey));
135
148
  return signingInput + "." + sig.toString("base64url");
136
149
  }
137
150
 
@@ -144,7 +157,7 @@ function _verifyJwt(token, publicKey, algorithm) {
144
157
  var signingInput = parts[0] + "." + parts[1];
145
158
  var sig = _b64uDecodeBuf(parts[2]);
146
159
  var sigAlgo = _resolveSigAlgo(algorithm);
147
- var ok = nodeCrypto.verify(sigAlgo, Buffer.from(signingInput, "ascii"), publicKey, sig);
160
+ var ok = nodeCrypto.verify(sigAlgo, Buffer.from(signingInput, "ascii"), _ecKeyParam(algorithm, publicKey), sig);
148
161
  if (!ok) {
149
162
  throw new AuthError("auth-sd-jwt-vc/bad-signature",
150
163
  "JWT signature verification failed");
@@ -271,7 +271,30 @@ function escapeRegExp(s) {
271
271
  // lives once.
272
272
  var HEX_PAIR_RE = /^[0-9A-Fa-f]{2}$/;
273
273
 
274
+ // isAsciiAlnum — codepoint is an ASCII letter or digit (A-Z / a-z / 0-9).
275
+ // The ASCII-alphanumeric range test recurs across every byte-class parser
276
+ // (URL unreserved, XML name chars, header tokens); centralized so the three
277
+ // range literals live once rather than as a re-rolled `cc>=0x41&&...` chain.
278
+ function isAsciiAlnum(cc) {
279
+ return (cc >= 0x41 && cc <= 0x5a) || // A-Z
280
+ (cc >= 0x61 && cc <= 0x7a) || // a-z
281
+ (cc >= 0x30 && cc <= 0x39); // 0-9
282
+ }
283
+
284
+ // isUnreserved — codepoint is in the RFC 3986 §2.3 unreserved set:
285
+ // ALPHA / DIGIT / "-" / "." / "_" / "~". A percent-escape of an unreserved
286
+ // character is over-encoding the URI spec says SHOULD be decoded (§6.2.2.3).
287
+ function isUnreserved(cc) {
288
+ return isAsciiAlnum(cc) ||
289
+ cc === 0x2d || // -
290
+ cc === 0x2e || // .
291
+ cc === 0x5f || // _
292
+ cc === 0x7e; // ~
293
+ }
294
+
274
295
  module.exports = {
296
+ isAsciiAlnum: isAsciiAlnum,
297
+ isUnreserved: isUnreserved,
275
298
  hex4: hex4,
276
299
  charClass: charClass,
277
300
  fromCp: fromCp,
@@ -1348,7 +1348,16 @@ function _upgradeDerivedHashesOnRead(s, table, out, dbHandle) {
1348
1348
  : _resolveLocalDbHandle();
1349
1349
  if (!handle) return;
1350
1350
  try {
1351
- var updBuilt = sql.update(table, { dialect: "sqlite", quoteName: true })
1351
+ // The rewrite runs on whatever handle resolved the read. The local b.db is
1352
+ // node:sqlite; a caller-supplied external handle declares its dialect on
1353
+ // handle.dialect ("postgres" | "mysql"), so the UPDATE must quote
1354
+ // identifiers for THAT dialect — a sqlite-quoted UPDATE ("users") is parsed
1355
+ // as a string literal by MySQL (which expects `users`) and the durable
1356
+ // re-hash silently no-ops. Resolve the dialect the way db-query._dialect()
1357
+ // does (validated set, sqlite default).
1358
+ var handleDialect = (handle.dialect === "postgres" || handle.dialect === "mysql" ||
1359
+ handle.dialect === "sqlite") ? handle.dialect : "sqlite";
1360
+ var updBuilt = sql.update(table, { dialect: handleDialect, quoteName: true })
1352
1361
  .set(upgrades)
1353
1362
  .where("_id", rowId)
1354
1363
  .toSql();
package/lib/db-query.js CHANGED
@@ -421,26 +421,51 @@ class Query {
421
421
  }
422
422
  // Sealed-field translation: rewrite predicate to use derived hash if available
423
423
  if (this._isSealedField(field)) {
424
- var lookup = cryptoField.lookupHash(this._cryptoFieldKey(), field, value);
425
- if (!lookup) {
426
- throw new Error(
427
- "cannot query sealed column '" + this._cryptoFieldKey() + "." + field +
428
- "' without a derived hash. Declare derivedHashes: { <name>: { from: '" + field + "' } } " +
429
- "in the table's schema config."
430
- );
431
- }
432
- field = lookup.field;
433
- if (op === "=" && lookup.legacyValue != null && lookup.legacyValue !== lookup.value) {
434
- // Dual-read across the v0.15.0 keyed-MAC default flip: a row written
435
- // before the flip carries the legacy salted-sha3 digest, so an
436
- // equality lookup on a sealed field must match BOTH the active
437
- // keyed-MAC digest and the legacy one otherwise the flip silently
438
- // drops every un-migrated row from the result. b.sql expands the
439
- // IN-list to (?, ?) and binds each digest.
440
- op = "IN";
441
- value = [lookup.value, lookup.legacyValue];
424
+ var missingHashMsg =
425
+ "cannot query sealed column '" + this._cryptoFieldKey() + "." + field +
426
+ "' without a derived hash. Declare derivedHashes: { <name>: { from: '" + field + "' } } " +
427
+ "in the table's schema config.";
428
+ if (op === "IN") {
429
+ // Membership query on a sealed column: each candidate plaintext maps
430
+ // to its own derived hash. Hashing the whole array as one value (the
431
+ // scalar path below) never matches — whereIn/$in on a sealed column
432
+ // would throw or silently miss. Expand to the per-element hash set,
433
+ // and for each element ALSO include the legacy salted-sha3 digest so
434
+ // membership dual-reads across the v0.15.0 keyed-MAC flip exactly as
435
+ // the "=" path does (un-migrated rows must still be found).
436
+ if (!Array.isArray(value) || value.length === 0) {
437
+ throw new Error("where IN on sealed column '" + this._cryptoFieldKey() +
438
+ "." + field + "' requires a non-empty array of values");
439
+ }
440
+ var sealedField = null;
441
+ var hashedValues = [];
442
+ for (var inI = 0; inI < value.length; inI++) {
443
+ var elemLookup = cryptoField.lookupHash(this._cryptoFieldKey(), field, value[inI]);
444
+ if (!elemLookup) throw new Error(missingHashMsg);
445
+ sealedField = elemLookup.field;
446
+ hashedValues.push(elemLookup.value);
447
+ if (elemLookup.legacyValue != null && elemLookup.legacyValue !== elemLookup.value) {
448
+ hashedValues.push(elemLookup.legacyValue);
449
+ }
450
+ }
451
+ field = sealedField;
452
+ value = hashedValues;
442
453
  } else {
443
- value = lookup.value;
454
+ var lookup = cryptoField.lookupHash(this._cryptoFieldKey(), field, value);
455
+ if (!lookup) throw new Error(missingHashMsg);
456
+ field = lookup.field;
457
+ if (op === "=" && lookup.legacyValue != null && lookup.legacyValue !== lookup.value) {
458
+ // Dual-read across the v0.15.0 keyed-MAC default flip: a row written
459
+ // before the flip carries the legacy salted-sha3 digest, so an
460
+ // equality lookup on a sealed field must match BOTH the active
461
+ // keyed-MAC digest and the legacy one — otherwise the flip silently
462
+ // drops every un-migrated row from the result. b.sql expands the
463
+ // IN-list to (?, ?) and binds each digest.
464
+ op = "IN";
465
+ value = [lookup.value, lookup.legacyValue];
466
+ } else {
467
+ value = lookup.value;
468
+ }
444
469
  }
445
470
  }
446
471
  _validateField(field);
package/lib/db.js CHANGED
@@ -304,6 +304,10 @@ var FRAMEWORK_SCHEMA = [
304
304
  citation: "TEXT",
305
305
  retainUntil: "INTEGER",
306
306
  },
307
+ // The legal-basis / custodian / ticket-citation free text links a data
308
+ // subject to a legal matter — PII at rest. Sealed like the DSR ticket store
309
+ // (b.legalHold seals on write + unseals on read through cryptoField).
310
+ sealedFields: ["reason", "placedBy", "custodian", "citation"],
307
311
  indexes: ["placedAt"],
308
312
  },
309
313
  {
@@ -1992,12 +1992,22 @@ function defineGuard(spec) {
1992
1992
  // to the right ctx field by KIND (or spec.ctxFields). Guards with a bespoke
1993
1993
  // gate pass spec.gate; guards whose gate is the standard chain take this
1994
1994
  // default.
1995
- // Raw opts pass straight through to spec.validate and buildGuardGate
1996
- // matching the hand-written gates, whose validate resolves profile/posture
1997
- // internally (validate calls its own _resolveOpts). Pre-resolving here
1998
- // would double-resolve.
1999
- function defaultGate(opts) {
2000
- opts = opts || {};
1995
+ // Resolve the profile + posture BEFORE buildGuardGate reads its runtime /
1996
+ // forensic caps: forensicSnippetBytes lives on the posture and maxRuntimeMs
1997
+ // on the profile, NOT on the raw caller opts. Passing raw opts through dropped
1998
+ // a regulated posture's forensic cap to 0 (no forensic snapshot on a refusal)
1999
+ // and the profile's runtime cap to uncapped — the hand-written gates resolve
2000
+ // in their own gate(), and the default gate must match. resolveProfileAndPosture
2001
+ // is idempotent over an already-resolved opts, so spec.validate's internal
2002
+ // resolution stays correct.
2003
+ function defaultGate(rawOpts) {
2004
+ var opts = resolveProfileAndPosture(rawOpts || {}, {
2005
+ profiles: profiles,
2006
+ compliancePostures: postures,
2007
+ defaults: defaults,
2008
+ errorClass: ErrorClass,
2009
+ errCodePrefix: prefix,
2010
+ });
2001
2011
  var perCtx = spec.defaultGateCheck || function (ctx) {
2002
2012
  var value = _ctxValueForKind(spec.kind, ctx, ctxFields);
2003
2013
  if (!value) return { ok: true, action: "serve" };
package/lib/legal-hold.js CHANGED
@@ -64,6 +64,7 @@ var lazyRequire = require("./lazy-require");
64
64
  var safeJson = require("./safe-json");
65
65
  var validateOpts = require("./validate-opts");
66
66
  var sql = require("./sql");
67
+ var cryptoField = require("./crypto-field");
67
68
  var { defineClass } = require("./framework-error");
68
69
 
69
70
  // Local-SQLite framework table names. These run against the b.db() handle
@@ -185,6 +186,16 @@ function create(opts) {
185
186
  ], SQL_OPTS);
186
187
  fn(ddl.sql);
187
188
  }
189
+ // Register the table's PII columns for at-rest sealing. The framework
190
+ // SQLite path also registers this via db.init() FRAMEWORK_SCHEMA, but the
191
+ // external-db / cluster path and a post-_resetForTest registry need this
192
+ // idempotent guard so sealRow/unsealRow below are never no-ops (which would
193
+ // store the legal-basis / custodian / citation free text in clear).
194
+ if (!cryptoField.getSchema(HOLD_TABLE)) {
195
+ cryptoField.registerTable(HOLD_TABLE, {
196
+ sealedFields: ["reason", "placedBy", "custodian", "citation"],
197
+ });
198
+ }
188
199
  }
189
200
 
190
201
  function place(subjectId, args) {
@@ -222,7 +233,7 @@ function create(opts) {
222
233
  }
223
234
  var nowMs = Date.now();
224
235
  var placeInsBuilt = sql.insert(HOLD_TABLE, SQL_OPTS)
225
- .values({
236
+ .values(cryptoField.sealRow(HOLD_TABLE, {
226
237
  subjectIdHash: hash,
227
238
  placedAt: nowMs,
228
239
  placedBy: args.placedBy || null,
@@ -230,7 +241,7 @@ function create(opts) {
230
241
  custodian: args.custodian || null,
231
242
  citation: args.citation || null,
232
243
  retainUntil: args.retainUntil || null,
233
- })
244
+ }))
234
245
  .toSql();
235
246
  var placeInsStmt = db.prepare(placeInsBuilt.sql);
236
247
  placeInsStmt.run.apply(placeInsStmt, placeInsBuilt.params);
@@ -268,6 +279,7 @@ function create(opts) {
268
279
  "denied");
269
280
  return { error: "not-held" };
270
281
  }
282
+ existing = cryptoField.unsealRow(HOLD_TABLE, existing);
271
283
  var relDelBuilt = sql.delete(HOLD_TABLE, SQL_OPTS)
272
284
  .where("subjectIdHash", hash)
273
285
  .toSql();
@@ -312,6 +324,7 @@ function create(opts) {
312
324
  var getStmt = db.prepare(getBuilt.sql);
313
325
  var row = getStmt.get.apply(getStmt, getBuilt.params);
314
326
  if (!row) return null;
327
+ row = cryptoField.unsealRow(HOLD_TABLE, row);
315
328
  return {
316
329
  subjectId: sid,
317
330
  placedAt: row.placedAt,
@@ -334,6 +347,7 @@ function create(opts) {
334
347
  var rows = listStmt.all.apply(listStmt, listBuilt.params);
335
348
  var nowMs = Date.now();
336
349
  return rows.map(function (r) {
350
+ r = cryptoField.unsealRow(HOLD_TABLE, r);
337
351
  return {
338
352
  subjectIdHash: r.subjectIdHash,
339
353
  placedAt: r.placedAt,
package/lib/outbox.js CHANGED
@@ -85,6 +85,12 @@ var DEFAULT_MAX_ATTEMPTS = 10;
85
85
  var DEFAULT_BACKOFF_INITIAL = C.TIME.seconds(1);
86
86
  var DEFAULT_BACKOFF_MAX = C.TIME.minutes(5);
87
87
  var DEFAULT_BACKOFF_FACTOR = 2; // multiplier, not bytes
88
+ // Lease after which an in-flight row is treated as stranded by a crashed
89
+ // publisher and reclaimed to 'pending'. Must exceed the longest expected
90
+ // publish so a slow-but-live publish isn't reclaimed mid-flight (a reclaim
91
+ // then re-publish is a duplicate, which at-least-once tolerates, but a tight
92
+ // lease makes duplicates routine). Default 5 min, matching backoff.maxMs.
93
+ var DEFAULT_CLAIM_RECLAIM_MS = C.TIME.minutes(5);
88
94
  var TOPIC_MAX_LEN = C.BYTES.bytes(255);
89
95
  var KEY_MAX_LEN = C.BYTES.bytes(255);
90
96
 
@@ -197,7 +203,7 @@ function create(opts) {
197
203
  validateOpts.requireObject(opts, "outbox", OutboxError);
198
204
  validateOpts(opts, [
199
205
  "externalDb", "table", "publisher",
200
- "pollIntervalMs", "batchSize", "maxAttempts",
206
+ "pollIntervalMs", "batchSize", "maxAttempts", "claimReclaimMs",
201
207
  "retryBackoff", "audit", "name",
202
208
  "envelope", "connectorName", "connectorVersion", "dbName",
203
209
  ], "outbox.create");
@@ -223,10 +229,13 @@ function create(opts) {
223
229
  "outbox.create: batchSize", OutboxError, "outbox/bad-opts");
224
230
  validateOpts.optionalPositiveFinite(opts.maxAttempts,
225
231
  "outbox.create: maxAttempts", OutboxError, "outbox/bad-opts");
232
+ validateOpts.optionalPositiveFinite(opts.claimReclaimMs,
233
+ "outbox.create: claimReclaimMs", OutboxError, "outbox/bad-opts");
226
234
 
227
235
  var pollIntervalMs = opts.pollIntervalMs || DEFAULT_POLL_MS;
228
236
  var batchSize = opts.batchSize || DEFAULT_BATCH_SIZE;
229
237
  var maxAttempts = opts.maxAttempts || DEFAULT_MAX_ATTEMPTS;
238
+ var claimReclaimMs = opts.claimReclaimMs || DEFAULT_CLAIM_RECLAIM_MS;
230
239
  var name = opts.name || "outbox";
231
240
 
232
241
  var backoff = opts.retryBackoff || {};
@@ -355,6 +364,7 @@ function create(opts) {
355
364
  { name: "enqueued_at", type: tsType, notNull: true },
356
365
  { name: "next_attempt_at", type: tsType, notNull: true },
357
366
  { name: "published_at", type: tsType },
367
+ { name: "claimed_at", type: tsType },
358
368
  { name: "attempts", type: "INTEGER", notNull: true, default: 0 },
359
369
  { name: "last_error", type: "TEXT" },
360
370
  { name: "status", type: "VARCHAR(16)", notNull: true, default: "pending" },
@@ -366,6 +376,17 @@ function create(opts) {
366
376
  ["next_attempt_at"], { dialect: dialect, where: "status = 'pending'" }), dialect);
367
377
  await target.query(ddl.sql, ddl.params);
368
378
  await target.query(idx.sql, idx.params);
379
+ // Back-compat: an outbox table created before the claimed_at column
380
+ // existed predates the stale-in-flight reaper. CREATE TABLE above is
381
+ // IF NOT EXISTS, so it won't add the column to an existing table — add it
382
+ // idempotently here (every dialect errors if the column already exists,
383
+ // which a fresh table from the CREATE will; swallow that). Without
384
+ // claimed_at the reaper can't tell a stranded claim from a live one.
385
+ try {
386
+ var alter = sql.toExternalSql(sql.alterTable(opts.table,
387
+ { addColumn: { name: "claimed_at", type: tsType } }, { dialect: dialect }), dialect);
388
+ await target.query(alter.sql, alter.params);
389
+ } catch (_e) { /* column already present — idempotent add */ }
369
390
  }
370
391
 
371
392
  // ---- Publisher worker ----
@@ -428,7 +449,7 @@ function create(opts) {
428
449
  // on postgres (the whole id set as one bound array) / expanded
429
450
  // `IN (?, ?, ...)` on mysql.
430
451
  var claimUpdate = sql.update(opts.table, { dialect: dialect })
431
- .set({ status: "in-flight" })
452
+ .set({ status: "in-flight", claimed_at: _utcNowExpr(externalDb) })
432
453
  .whereInArray("id", ids)
433
454
  .toExternalSql(dialect);
434
455
  await xdb.query(claimUpdate.sql, claimUpdate.params);
@@ -440,7 +461,7 @@ function create(opts) {
440
461
  // another publisher beat us to are skipped. whereInArray expands
441
462
  // to an `IN (?, ?, ...)` placeholder list on sqlite.
442
463
  var markUpdate = sql.update(opts.table, { dialect: dialect })
443
- .set({ status: "in-flight" })
464
+ .set({ status: "in-flight", claimed_at: _utcNowExpr(externalDb) })
444
465
  .whereRaw("status = 'pending'", [], { allowLiterals: true })
445
466
  .whereInArray("id", ids)
446
467
  .toExternalSql(dialect);
@@ -466,6 +487,28 @@ function create(opts) {
466
487
  });
467
488
  }
468
489
 
490
+ // Reclaim rows stranded in 'in-flight' by a crashed publisher. A claim
491
+ // flips status pending → in-flight and stamps claimed_at; if the process
492
+ // dies before the row is marked published / retry / dead, it sits in-flight
493
+ // forever, because the claim path only selects status='pending'. That
494
+ // silently drops the event and violates the at-least-once guarantee. Reset
495
+ // any in-flight row whose claim is older than the lease — or that predates
496
+ // the claimed_at column (NULL) — back to 'pending' so the next poll
497
+ // re-publishes it. The lease bounds how long a legitimately slow publish is
498
+ // protected from reclaim; a reclaim+re-publish is a duplicate, which
499
+ // at-least-once tolerates. Best-effort: a failed sweep retries next poll.
500
+ async function _reapStaleInflight() {
501
+ var dialect = _sqlDialect(externalDb);
502
+ var cutoff = new Date(Date.now() - claimReclaimMs);
503
+ var stmt = sql.update(opts.table, { dialect: dialect })
504
+ .set({ status: "pending", claimed_at: null })
505
+ .whereRaw("status = 'in-flight'", [], { allowLiterals: true })
506
+ .whereRaw("(claimed_at IS NULL OR claimed_at <= ?)", [cutoff])
507
+ .toExternalSql(dialect);
508
+ var res = await externalDb.query(stmt.sql, stmt.params);
509
+ return res;
510
+ }
511
+
469
512
  async function _markPublished(id) {
470
513
  var dialect = _sqlDialect(externalDb);
471
514
  var stmt = sql.update(opts.table, { dialect: dialect })
@@ -506,6 +549,10 @@ function create(opts) {
506
549
  }
507
550
 
508
551
  async function _processOnce() {
552
+ // Reclaim crashed-publisher rows before claiming new work, so a stranded
553
+ // in-flight row re-enters the pending pool and is published this cycle.
554
+ try { await _reapStaleInflight(); }
555
+ catch (_e) { /* drop-silent — reaper retries next poll */ }
509
556
  var batch = await _claimBatch();
510
557
  if (batch.length === 0) return 0;
511
558
  for (var i = 0; i < batch.length; i++) {
package/lib/retention.js CHANGED
@@ -282,7 +282,6 @@ function create(opts) {
282
282
  }
283
283
 
284
284
  function _erase(table, row, dryRun) {
285
- var erased = cryptoField.eraseRow(table, row);
286
285
  var sealedFields = cryptoField.getSealedFields(table) || [];
287
286
  var hashFields = [];
288
287
  var schema = cryptoField.getSchema(table);
@@ -294,6 +293,15 @@ function create(opts) {
294
293
  return _hardDelete(table, row._id, dryRun);
295
294
  }
296
295
  if (dryRun) return { wouldErase: 1, sealedFieldCount: sealedFields.length };
296
+ // eraseRow produces the in-memory tombstone AND drives the posture-gated
297
+ // side effects: under a regime whose POSTURE_DEFAULTS sets
298
+ // requireVacuumAfterErase it schedules db.vacuumAfterErase({ mode: "full" })
299
+ // and emits cryptofield.erase.row. Both belong only to the COMMITTING path
300
+ // — running them above the dry-run gate made a preview VACUUM the whole
301
+ // database file per candidate row (#120). Its return value is unused (the
302
+ // UPDATE below NULLs the columns directly); we keep the call for the
303
+ // tombstone bucketing + posture vacuum on the real path.
304
+ var erased = cryptoField.eraseRow(table, row);
297
305
  // NULL every sealed column + its derived-hash sibling. b.sql binds each
298
306
  // null as a placeholder (the set map preserves the column ordering).
299
307
  var eraseSet = {};
package/lib/safe-url.js CHANGED
@@ -53,6 +53,11 @@ var nodeUrl = require("node:url");
53
53
  var { URL } = require("node:url");
54
54
 
55
55
  var audit = lazyRequire(function () { return require("./audit"); });
56
+ // ssrf-guard requires safe-url at top of file (the SSRF gate parses through
57
+ // the framework's defensive URL parser); lazyRequire breaks that cycle so the
58
+ // canonicalizer can reuse ssrf-guard's IP-literal canonicalization without an
59
+ // at-load circular require.
60
+ var ssrfGuard = lazyRequire(function () { return require("./ssrf-guard"); });
56
61
 
57
62
  /**
58
63
  * @primitive b.safeUrl.ALLOW_HTTP_TLS
@@ -168,9 +173,9 @@ var ALLOW_ANY = Object.freeze(["http:", "https:", "ws:", "wss:"]);
168
173
  * Extends `FrameworkError`. Carries a stable `.code`:
169
174
  * `safe-url/missing` / `safe-url/too-long` / `safe-url/malformed` /
170
175
  * `safe-url/protocol-disallowed` / `safe-url/userinfo-disallowed` /
171
- * `safe-url/idn-homograph` / `safe-url/bad-opt`. HTTP middleware
172
- * inspects `.code` to translate the throw into a 400 without
173
- * leaking parser internals.
176
+ * `safe-url/idn-homograph` / `safe-url/uncanonicalizable` /
177
+ * `safe-url/bad-opt`. HTTP middleware inspects `.code` to translate
178
+ * the throw into a 400 without leaking parser internals.
174
179
  *
175
180
  * @example
176
181
  * var b = require("blamejs");
@@ -205,6 +210,38 @@ function _makeError(errorClass, code, message) {
205
210
  // payloads) override via opts.maxUrlLength.
206
211
  var DEFAULT_MAX_URL_LENGTH = C.BYTES.kib(8);
207
212
 
213
+ // RFC 3986 §6.2.2 percent-encoding normalization applied CONSERVATIVELY to a
214
+ // path segment string: uppercase the two hex digits of every valid escape
215
+ // (§6.2.2.2) and decode an escape of an unreserved character to its literal
216
+ // (§6.2.2.3). A malformed escape (`%`, `%g`, `%4`) is passed through verbatim
217
+ // so no information is invented. Query and fragment are NOT touched —
218
+ // reordering / re-decoding there can change application semantics (a `%26`
219
+ // inside a value is NOT the same as a literal `&`).
220
+ function _normalizePctPath(path) {
221
+ if (typeof path !== "string" || path.indexOf("%") === -1) return path;
222
+ var out = "";
223
+ for (var i = 0; i < path.length; i += 1) {
224
+ var ch = path.charAt(i);
225
+ if (ch === "%") {
226
+ // The escape's two hex digits — sliced to a fixed two-char window so
227
+ // the regex test runs on a length-bounded string (no ReDoS surface).
228
+ var pair = path.slice(i + 1, i + 3);
229
+ if (pair.length === 2 && codepointClass.HEX_PAIR_RE.test(pair)) {
230
+ var cc = parseInt(pair, 16);
231
+ if (codepointClass.isUnreserved(cc)) {
232
+ out += String.fromCharCode(cc);
233
+ } else {
234
+ out += "%" + pair.toUpperCase();
235
+ }
236
+ i += 2;
237
+ continue;
238
+ }
239
+ }
240
+ out += ch;
241
+ }
242
+ return out;
243
+ }
244
+
208
245
  /**
209
246
  * @primitive b.safeUrl.parse
210
247
  * @signature b.safeUrl.parse(url, opts?)
@@ -416,9 +453,139 @@ function format(url) {
416
453
  }
417
454
  }
418
455
 
456
+ /**
457
+ * @primitive b.safeUrl.canonicalize
458
+ * @signature b.safeUrl.canonicalize(input, opts?)
459
+ * @since 0.15.6
460
+ * @status stable
461
+ * @related b.safeUrl.parse, b.ssrfGuard.canonicalizeHost, b.ssrfGuard.checkUrl
462
+ *
463
+ * Return the single canonical, comparable form of a URL so two
464
+ * spellings of the same destination compare equal as strings. The use
465
+ * cases are host allowlists, dedup / cache keys, and SSRF pre-checks —
466
+ * exactly the places an attacker reaches for an obfuscated host
467
+ * (`http://0177.0.0.1/`, `http://2130706433/`, `http://[::ffff:7f00:1]/`,
468
+ * an IDN homograph, a trailing-dot or default-port variation) to slip
469
+ * past a naive `===` allowlist. Routing every comparison through one
470
+ * audited canonicalizer closes that class instead of leaving each
471
+ * caller to re-derive normalization (which is how the bypasses happen).
472
+ *
473
+ * The canonical form is built from `parse`'s defensive gates plus the
474
+ * security-relevant normalization set:
475
+ *
476
+ * - Scheme and host lowercased (the WHATWG URL parser does this).
477
+ * - Host IDN labels emitted as their punycode `xn--` A-label; a
478
+ * mixed-script / confusable host label THROWS exactly as
479
+ * `parse` does (a homograph is never silently passed) unless the
480
+ * caller opts in via `allowMixedScript` / `allowedScripts`.
481
+ * - A trailing dot on the host is removed (`example.com.` →
482
+ * `example.com`) — DNS-equivalent but breaks string comparison.
483
+ * - An IP-literal host in ANY notation collapses to one canonical
484
+ * string via `b.ssrfGuard.canonicalizeHost` (the SAME byte parser
485
+ * the SSRF classifier matches on): IPv4 decimal / octal / hex /
486
+ * shorthand → dotted-quad; IPv6 (incl. IPv4-mapped + any
487
+ * zero-compression) → RFC 5952 lower-hex, bracketed.
488
+ * - The default port for the scheme is stripped (`:80` http/ws,
489
+ * `:443` https/wss — the parser does this).
490
+ * - Path `.` / `..` segments resolved (WHATWG), then RFC 3986 §6.2.2
491
+ * percent-normalization applied to the path: hex digits uppercased,
492
+ * escapes of unreserved characters decoded. Query and fragment are
493
+ * left BYTE-FOR-BYTE as parsed — reordering or re-decoding there can
494
+ * change application semantics.
495
+ *
496
+ * Throws `SafeUrlError` (or `opts.errorClass`): the `parse` codes for a
497
+ * missing / too-long / malformed / disallowed-scheme / userinfo /
498
+ * homograph input, plus `safe-url/uncanonicalizable` when a parsed URL
499
+ * cannot be reduced to a safe canonical form. This is a config /
500
+ * entry-point validator — it THROWS on bad input, it does NOT return a
501
+ * best-effort string.
502
+ *
503
+ * @opts
504
+ * allowedSchemes: string[], // default ALLOW_ANY (http/https/ws/wss); canonicalize is a compare tool, not a fetch gate
505
+ * allowUserinfo: boolean, // default false; opt-in to keep user:pass@ (still discouraged)
506
+ * allowMixedScript: boolean, // default false; opt-in to mixed-script host labels
507
+ * allowedScripts: string[], // narrow mixed-script allowlist (e.g. ["latin","cyrillic"])
508
+ * maxUrlLength: number, // default 8192 (RFC 7230 §3.1.1)
509
+ * errorClass: Function, // throw this instead of SafeUrlError
510
+ *
511
+ * @example
512
+ * var b = require("blamejs");
513
+ *
514
+ * // Every obfuscated loopback spelling collapses to one string.
515
+ * b.safeUrl.canonicalize("http://0177.0.0.1/"); // → "http://127.0.0.1/"
516
+ * b.safeUrl.canonicalize("http://2130706433/"); // → "http://127.0.0.1/"
517
+ * b.safeUrl.canonicalize("http://127.1/"); // → "http://127.0.0.1/"
518
+ *
519
+ * // Case, default port, trailing dot, and `..` all normalize.
520
+ * b.safeUrl.canonicalize("https://Example.COM:443/a/../b");
521
+ * // → "https://example.com/b"
522
+ *
523
+ * // A disallowed scheme throws SafeUrlError.
524
+ * try { b.safeUrl.canonicalize("ftp://example.com/"); }
525
+ * catch (e) { e.code; } // → "safe-url/protocol-disallowed"
526
+ */
527
+ function canonicalize(input, opts) {
528
+ opts = opts || {};
529
+ var allowedSchemes = Array.isArray(opts.allowedSchemes) && opts.allowedSchemes.length > 0
530
+ ? opts.allowedSchemes
531
+ : ALLOW_ANY;
532
+
533
+ // Route through parse — it owns the length cap, the scheme allowlist,
534
+ // userinfo refusal, and the IDN-homograph defense. A parse throw (with
535
+ // its stable .code) propagates unchanged so callers branch on the same
536
+ // codes parse documents.
537
+ var parsed = parse(input, {
538
+ allowedProtocols: allowedSchemes,
539
+ maxUrlLength: opts.maxUrlLength,
540
+ allowUserinfo: opts.allowUserinfo,
541
+ allowMixedScript: opts.allowMixedScript,
542
+ allowedScripts: opts.allowedScripts,
543
+ errorClass: opts.errorClass,
544
+ });
545
+
546
+ try {
547
+ // The parser already lowercased the scheme + host, stripped the
548
+ // default port, emitted IDN as punycode, and resolved `.`/`..`. The
549
+ // canonicalizer adds: IP-literal collapse (shared with the SSRF
550
+ // classifier), trailing-dot removal, and path percent-normalization.
551
+ var scheme = parsed.protocol; // e.g. "https:"
552
+ var rawHost = parsed.hostname; // already lowercase / punycode / default-port-free
553
+ var canonHost = ssrfGuard().canonicalizeHost(rawHost);
554
+ // canonicalizeHost returns IPv6 UNbracketed; a URL authority needs the
555
+ // brackets back. net.isIP via the colon is a sufficient discriminator
556
+ // (a DNS label can't contain a colon).
557
+ var host = canonHost.indexOf(":") !== -1 ? "[" + canonHost + "]" : canonHost;
558
+
559
+ // Userinfo (`user:pass@`) is deliberately DROPPED from the canonical form.
560
+ // The canonical string is built to be compared, used as a dedup / cache
561
+ // key, or logged; carrying a credential into any of those leaks it, and
562
+ // the username/password are not part of the resource's target identity for
563
+ // an allowlist / SSRF decision. parse() already refuses userinfo unless
564
+ // allowUserinfo:true; even then, the canonical output omits it (never reads
565
+ // parsed.password), so two URLs that differ only in credentials canonicalize
566
+ // equal.
567
+
568
+ var port = parsed.port !== "" ? ":" + parsed.port : "";
569
+ var path = _normalizePctPath(parsed.pathname);
570
+ // Query + fragment: byte-for-byte as the parser emitted them. Reordering
571
+ // a query or re-decoding a value changes semantics — out of scope for a
572
+ // safe canonicalizer.
573
+ var query = parsed.search;
574
+ var fragment = parsed.hash;
575
+
576
+ return scheme + "//" + host + port + path + query + fragment;
577
+ } catch (e) {
578
+ if (e && e.isSafeUrlError) throw e;
579
+ throw _makeError(opts.errorClass, "safe-url/uncanonicalizable",
580
+ "safeUrl.canonicalize could not reduce the URL to a canonical form: " +
581
+ ((e && e.message) || String(e)));
582
+ }
583
+ }
584
+
419
585
  module.exports = {
420
586
  parse: parse,
421
587
  format: format,
588
+ canonicalize: canonicalize,
422
589
  SafeUrlError: SafeUrlError,
423
590
  ALLOW_HTTP_TLS: ALLOW_HTTP_TLS,
424
591
  ALLOW_HTTP_ALL: ALLOW_HTTP_ALL,
package/lib/sse.js CHANGED
@@ -247,6 +247,21 @@ function create(req, res, opts) {
247
247
  // proxyBuffer: false to suppress the nginx-specific header.
248
248
  var proxyBuffer = opts.proxyBuffer !== false;
249
249
 
250
+ // Slow-consumer bound. SSE is server-push: when a client stalls, res.write()
251
+ // returns false but the app keeps pushing, so Node buffers the unsent bytes
252
+ // in res.writableLength without limit — one stuck connection grows the heap
253
+ // until exhaustion (memory-exhaustion DoS). Cap the per-channel buffer and
254
+ // evict the slow consumer past it. A healthy client (writableLength ~0) is
255
+ // never affected. Config-time input → throw on a bad value.
256
+ var maxBufferedBytes = opts.maxBufferedBytes;
257
+ if (maxBufferedBytes === undefined) maxBufferedBytes = C.BYTES.mib(1);
258
+ if (typeof maxBufferedBytes !== "number" || !isFinite(maxBufferedBytes) ||
259
+ maxBufferedBytes <= 0 || Math.floor(maxBufferedBytes) !== maxBufferedBytes) {
260
+ throw errorClass.factory("sse/bad-opts",
261
+ "sse.create: maxBufferedBytes must be a positive integer byte count (got " +
262
+ JSON.stringify(maxBufferedBytes) + ")");
263
+ }
264
+
250
265
  var lastEventId = _readLastEventId(req);
251
266
 
252
267
  // Headers. text/event-stream is the contract; Cache-Control: no-cache
@@ -275,6 +290,17 @@ function create(req, res, opts) {
275
290
  "sse.send: channel closed");
276
291
  }
277
292
  res.write(s);
293
+ // res.writableLength is the count of bytes Node has buffered but not yet
294
+ // flushed to the socket. A healthy client drains it (≈0); a stalled one
295
+ // lets it climb. Past the per-channel cap, evict the slow consumer rather
296
+ // than buffer without bound. h2 streams + h1 responses both expose it.
297
+ var buffered = (typeof res.writableLength === "number") ? res.writableLength : 0;
298
+ if (buffered > maxBufferedBytes) {
299
+ close({ reason: "backpressure-exceeded" });
300
+ throw errorClass.factory("sse/backpressure",
301
+ "sse.send: client too slow — buffered " + buffered +
302
+ " bytes exceeds maxBufferedBytes " + maxBufferedBytes + "; channel closed");
303
+ }
278
304
  }
279
305
 
280
306
  function send(eventOpts) {
package/lib/ssrf-guard.js CHANGED
@@ -247,6 +247,105 @@ function _expandIpv6(ip) {
247
247
  return left.concat(fill).concat(right);
248
248
  }
249
249
 
250
+ function _ipv6BytesToString(bytes) {
251
+ // RFC 5952 §4 canonical textual form from 16 canonical bytes: lower-hex,
252
+ // no leading zeros per group, the LONGEST run of two-or-more zero groups
253
+ // compressed to "::" (leftmost run on a length tie — §4.2.3), and the
254
+ // shortened-but-not-IPv4-dotted form (the framework keeps IPv4-mapped as
255
+ // pure hex so every mapped spelling collapses to one string). Driven off
256
+ // the same 16-byte buffer classify() matches on, so the emitted string and
257
+ // the security verdict can never disagree about which address this is.
258
+ var groups = [];
259
+ for (var i = 0; i < IPV6_GROUPS; i++) {
260
+ groups.push(((bytes[i * 2] << 8) | bytes[i * 2 + 1]) & 0xffff);
261
+ }
262
+ var bestStart = -1, bestLen = 0, curStart = -1, curLen = 0;
263
+ for (var g = 0; g < IPV6_GROUPS; g++) {
264
+ if (groups[g] === 0) {
265
+ if (curStart === -1) { curStart = g; curLen = 1; } else { curLen += 1; }
266
+ if (curLen > bestLen) { bestLen = curLen; bestStart = curStart; }
267
+ } else {
268
+ curStart = -1;
269
+ curLen = 0;
270
+ }
271
+ }
272
+ // A single zero group is NOT compressed (RFC 5952 §4.2.2).
273
+ if (bestLen < 2) bestStart = -1;
274
+ var parts = [];
275
+ for (var k = 0; k < IPV6_GROUPS; k++) {
276
+ if (bestStart !== -1 && k === bestStart) {
277
+ parts.push("");
278
+ k += bestLen - 1;
279
+ // A run reaching the final group needs a trailing empty part so the
280
+ // join yields the "::"-terminated form (e.g. fe80:: not fe80:).
281
+ if (k === IPV6_GROUPS - 1) parts.push("");
282
+ continue;
283
+ }
284
+ parts.push(groups[k].toString(HEX_RADIX));
285
+ }
286
+ var out = parts.join(":");
287
+ // A run starting at group 0 needs a leading empty part ("::1", "::").
288
+ if (bestStart === 0) out = ":" + out;
289
+ return out;
290
+ }
291
+
292
+ /**
293
+ * @primitive b.ssrfGuard.canonicalizeHost
294
+ * @signature b.ssrfGuard.canonicalizeHost(host)
295
+ * @since 0.15.6
296
+ * @status stable
297
+ * @related b.ssrfGuard.classify, b.safeUrl.canonicalize
298
+ *
299
+ * Canonicalize a bare host string to its single comparable form for
300
+ * host allowlists, dedup keys, and SSRF pre-checks. A `net.isIP`-valid
301
+ * IP literal collapses to one canonical string: a dotted-quad IPv4
302
+ * stays dotted-quad; IPv6 in any zero-compression / mixed-case /
303
+ * IPv4-mapped spelling (`[0:0:0:0:0:ffff:7f00:1]`, `::FFFF:7F00:1`)
304
+ * becomes the RFC 5952 lower-hex compressed form. The IP bytes are
305
+ * parsed by the SAME routines `classify` matches on, so the canonical
306
+ * string and the SSRF verdict can never disagree about which address a
307
+ * host is.
308
+ *
309
+ * The numeric-base IPv4 decode (octal `0177.0.0.1`, hex `0x7f000001`,
310
+ * single-integer `2130706433`, shorthand `127.1`) is the WHATWG URL
311
+ * parser's job — `b.safeUrl.canonicalize` runs that FIRST and hands this
312
+ * primitive the already-decoded dotted-quad. This is the IP-byte + case
313
+ * layer, not the base decoder.
314
+ *
315
+ * A DNS name (not an IP literal) is lowercased and any trailing dot is
316
+ * stripped — `Example.COM.` → `example.com`. IDN A-label / U-label
317
+ * normalization is NOT done here (the WHATWG URL parser owns that via
318
+ * `b.safeUrl.canonicalize`).
319
+ * `[...]`-bracketed IPv6 input is accepted (brackets stripped); the
320
+ * returned IPv6 string is UNbracketed (the URL layer re-adds brackets).
321
+ *
322
+ * @example
323
+ * var b = require("blamejs");
324
+ * b.ssrfGuard.canonicalizeHost("[0:0:0:0:0:0:0:1]"); // → "::1"
325
+ * b.ssrfGuard.canonicalizeHost("::FFFF:7F00:1"); // → "::ffff:7f00:1"
326
+ * b.ssrfGuard.canonicalizeHost("Example.COM."); // → "example.com"
327
+ */
328
+ function canonicalizeHost(host) {
329
+ if (typeof host !== "string" || host.length === 0) return host;
330
+ var bare = host.replace(/^\[|\]$/g, "");
331
+ var family = net.isIP(bare);
332
+ if (family === 4) {
333
+ var v4 = _ipv4ToBytes(bare);
334
+ if (v4) return v4[0] + "." + v4[1] + "." + v4[2] + "." + v4[3];
335
+ return bare.toLowerCase();
336
+ }
337
+ if (family === 6) {
338
+ return _ipv6BytesToString(_ipv6ToBytes(bare));
339
+ }
340
+ // Not an IP literal — DNS name. Lowercase + strip a single trailing dot
341
+ // (the root-label dot is DNS-equivalent but breaks string comparison).
342
+ var name = bare.toLowerCase();
343
+ if (name.length > 1 && name.charAt(name.length - 1) === ".") {
344
+ name = name.slice(0, name.length - 1);
345
+ }
346
+ return name;
347
+ }
348
+
250
349
  function _cidrIpv4Match(cidr, ip) {
251
350
  var slash = cidr.indexOf("/");
252
351
  if (slash === -1) return false;
@@ -826,6 +925,7 @@ function checkUrlTextual(url, opts) {
826
925
 
827
926
  module.exports = {
828
927
  classify: classify,
928
+ canonicalizeHost: canonicalizeHost,
829
929
  cidrContains: cidrContains,
830
930
  checkUrl: checkUrl,
831
931
  checkUrlTextual: checkUrlTextual,
package/lib/subject.js CHANGED
@@ -561,12 +561,19 @@ function restrict(subjectId, opts) {
561
561
 
562
562
  if (opts.on) {
563
563
  if (!existing) {
564
+ // The restriction `reason` is a ticket reference / legal basis — PII at
565
+ // rest. db.js declares sealedFields:["reason"] on this table, but the raw
566
+ // write path bypasses the structured builder's auto-seal, so seal here
567
+ // explicitly (idempotent registration guard covers a reset registry).
568
+ if (!cryptoField.getSchema(RESTRICTIONS_TABLE)) {
569
+ cryptoField.registerTable(RESTRICTIONS_TABLE, { sealedFields: ["reason"] });
570
+ }
564
571
  var restrictInsBuilt = sql.insert(RESTRICTIONS_TABLE, { dialect: "sqlite", quoteName: true })
565
- .values({
572
+ .values(cryptoField.sealRow(RESTRICTIONS_TABLE, {
566
573
  subjectIdHash: _subjectHash(subjectId),
567
574
  since: Date.now(),
568
575
  reason: opts.reason || null,
569
- })
576
+ }))
570
577
  .toSql();
571
578
  var restrictInsStmt = db().prepare(restrictInsBuilt.sql);
572
579
  restrictInsStmt.run.apply(restrictInsStmt, restrictInsBuilt.params);
@@ -287,6 +287,11 @@ function create(scriptPath, opts) {
287
287
  reason: "workerpool/worker-error",
288
288
  message: (err && err.message) || String(err),
289
289
  });
290
+ // Mark the slot dying BEFORE _finishTask. _finishTask sets slot.busy =
291
+ // false and drains the queue, so an unmarked slot would be handed a
292
+ // freshly-queued task that then dies with this same worker. _recycleWorker
293
+ // re-asserts the flag idempotently.
294
+ slot.recycling = true;
290
295
  if (failingTask) {
291
296
  _finishTask(slot, true,
292
297
  new WorkerPoolError("workerpool/worker-error",
@@ -331,6 +336,12 @@ function create(scriptPath, opts) {
331
336
  workerId: slot.id, taskId: taskId, taskTimeoutMs: taskTimeoutMs,
332
337
  });
333
338
  var failingTask = slot.currentTask;
339
+ // Mark the slot dying BEFORE _finishTask drains the queue, so the drain
340
+ // skips this about-to-be-terminated slot instead of dispatching a queued
341
+ // task onto it (which would die with the worker on terminate, surfacing as
342
+ // workerpool/worker-exit on a task that never ran). _recycleWorker
343
+ // re-asserts the flag idempotently.
344
+ slot.recycling = true;
334
345
  if (failingTask) {
335
346
  _finishTask(slot, true,
336
347
  new WorkerPoolError("workerpool/timeout",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.15.4",
3
+ "version": "0.15.6",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
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:58fea242-8071-4e16-965b-d1ea85e285ba",
5
+ "serialNumber": "urn:uuid:05cef77c-2364-4c63-8af3-3dd59f5dd56f",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-06-13T02:42:24.654Z",
8
+ "timestamp": "2026-06-13T09:12:01.883Z",
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.15.4",
22
+ "bom-ref": "@blamejs/core@0.15.6",
23
23
  "type": "application",
24
24
  "name": "blamejs",
25
- "version": "0.15.4",
25
+ "version": "0.15.6",
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.15.4",
29
+ "purl": "pkg:npm/%40blamejs/core@0.15.6",
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.15.4",
57
+ "ref": "@blamejs/core@0.15.6",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]