@blamejs/core 0.18.2 → 0.18.4

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.18.x
10
10
 
11
+ - v0.18.4 (2026-08-01) — **`b.session.updateData({ merge: true })` now merges an inner object one level deep instead of discarding the existing nested keys, alongside error- and edge-path tests for the queue, encrypted-field, session, and CalDAV/CardDAV primitives.** b.session.updateData(token, data, { merge: true }) documents a one-level-deep merge, but an inner object value shallow-replaced the whole nested object -- so updating one nested key silently dropped the operator's other nested keys. It now merges an inner plain object into the existing one (its keys survive); an array or non-object value still replaces. Separately, error, edge, and adversarial branches of b.queue (init and backend resolution, consume success/retry/dead-letter, enqueueFlow validation and dependency ordering, dead-letter list/retry/size, shutdown drain, bootFromEnv), b.cryptoField (seal/unseal envelope-floor enforcement, derived-hash modes, per-row-key unseal rate cooldown, data residency, read-time hash upgrade, erase-row vacuum cascade, hash lookup), b.session (fingerprint-drift scoring and fail-closed binding, anonymous mint, rotate carry-forward and re-key, updateData merge guards, validFrom scheduling), and b.mail.dav (CalDAV/CardDAV request and response branches) are now under test. **Changed:** *Error- and edge-path tests for the queue, encrypted-field, session, and CalDAV/CardDAV primitives* — b.queue (init idempotency and the missing-backends / unknown-backend refusals, consume success/retry/dead-letter-write/rate-limit-wait/lease-extend/progress paths, enqueueFlow validation and cycle/dependency ordering, dead-letter list/retry/size with the SQS-unsupported refusals, shutdown pre-init no-op and in-flight drain, bootFromEnv config resolution), b.cryptoField (seal-envelope-floor enforcement, derived-hash keyed/legacy modes and truncation, per-row-key unseal rate cooldown and its audit, data residency, read-time derived-hash upgrade, the erase-row vacuum cascade, and hash lookup), b.session (fingerprint-drift scoring with clamp and fail-closed admission, strict-binding missing/unreadable refusal, anonymous mint and the anonymous-plus-userId refusal, rotate data-replace / carry-forward / bound re-key, updateData token and merge guards, and validFrom read-back with check admit/revoke), and b.mail.dav's request and response branches now have explicit tests for their error and boundary paths. Behaviour is unchanged for these -- the additions pin guarantees the primitives already met. · *Vendored @blamejs/pki refreshed to 0.3.28* — The bundled PKI toolkit (lib/vendor/blamejs-pki.cjs, the mTLS CA engine's certificate/CMS backend) is refreshed from 0.3.27 to 0.3.28 so the vendored copy matches the latest published release. It adds no runtime dependency and no public framework API; the upstream release adds HTTP Digest authentication (RFC 7616) to the EST client transport. **Fixed:** *b.session.updateData({ merge: true }) merges an inner object one level deep instead of discarding existing nested keys* — updateData(token, data, { merge: true }) is documented to merge one level deep -- inner objects merge, arrays replace. It merged only the top-level keys: an inner object value was assigned wholesale (next[k] = data[k]), so updateData(token, { prefs: { theme: "dark" } }, { merge: true }) against a stored { prefs: { locale: "en" } } dropped prefs.locale, leaving only { prefs: { theme: "dark" } }. An inner PLAIN-object value now merges into the existing plain object (its keys survive, then the incoming keys apply); an array, a non-plain object (a Date, Buffer, or class instance -- stored as its own JSON form rather than mangled into the retained old object or byte-index keys), or a non-object value replaces, and merging beyond one level replaces. Operators who relied on the previous top-level-only behaviour, or who intend to replace a whole nested object, can pass the full nested value they want stored. **Detectors:** *session.updateData({ merge: true }) must merge an inner object one level deep* — A source check asserts the merge path merges an inner value only when BOTH are PLAIN objects (a prototype-based _isPlainObject) as Object.assign({}, existing[k], data[k]), never a bare next[k] = data[k] shallow replace, so the nested-key data-loss regression -- and a Date/Buffer being merged instead of replaced -- cannot return unnoticed.
12
+
13
+ - v0.18.3 (2026-07-30) — **`b.mtlsCa` gains non-breaking CA algorithm-migration primitives -- rotate the CA, keep the superseded root trusted during re-enrollment, self-test that the runtime verifies the algorithm over TLS, and revoke by CA generation -- and `b.db.collection`'s `$like` operator now honours SQL `%` / `_` wildcards instead of matching them as literal characters.** b.mtlsCa can now migrate a certificate authority to a new signature algorithm without an outage. status() reports the stored CA's algorithm and key type; rotate({ generation, algorithm }) generates and atomically commits a new CA and returns the superseded certificate, without the algorithm-mismatch initCA() raises once a CA already exists; await commit({ retainPrevious: true }) (the public commit is the locked primitive — it resolves to a promise, takes the rotation lock so a direct caller cannot race a concurrent rotate/init, and refreshes the handle's algorithm pin to the committed CA like rotate() so a pinned handle that migrates algorithms via commit() stays usable) together with await loadTrustBundle() and dropRetained() keep the old root trusted through a re-enrollment grace window; canVerifyInTls() runs a loopback mutual-TLS self-test proving node:tls verifies the CA's algorithm on the running Node; and revokeGeneration(n) revokes every certificate the issuance ledger recorded under a CA generation below n. Separately, b.db.collection's $like operator emitted an escaped LIKE, so the caller's % and _ were matched as literal characters and $like collapsed to an exact match; it now emits a caller-controlled-wildcard LIKE, so find({ name: { $like: "Al%" } }) matches as SQL LIKE documents. The release also brings error and adversarial branches of the scheduler, session device-binding, body parser, notifier, and document collection under test. **Added:** *b.mtlsCa non-breaking CA algorithm-migration primitives* — A CA handle can now carry an operator through a signature-algorithm migration (for example a classical-to-post-quantum move) with no trust outage. status() reports the stored CA's algorithm and keyType (null before initCA(), and null for an EC CA whose curve is not the framework's P-384 OR whose signature digest is not SHA-384, so a custom P-256/P-521 or SHA-256/SHA-512-signed CA is not mislabeled ECDSA-P384-SHA384). An unpinned rotate({ generation }) preserves the stored CA's algorithm (it does not silently adopt the engine's post-quantum default), so advancing a generation on a classical ECDSA CA keeps issuing classical chains; changing algorithm stays explicit via rotate({ algorithm }). rotate({ generation, algorithm }) generates a new CA at a strictly higher integer generation and atomically commits it -- returning { caCertPem, previousCaCertPem, generation, algorithm } -- so an operator does not hit the algorithm-mismatch initCA() raises when a CA is already present, and the handle keeps issuing under the new algorithm afterward. A rotation also invalidates a persisted CRL (it was signed by the superseded CA), so regenerate it under the new CA with generateCrl(). The commit path gained retainPrevious (defaulted on for rotate): the outgoing certificate is snapshotted to ca.prev.crt, and await loadTrustBundle() returns [current, ...retained] so both roots are trusted while clients re-enroll (it resolves to a promise, taking the rotation lock so its snapshot is serialized with rotation and dropRetained()); dropRetained(), or rotate({ retainPrevious: false }), ends that window. There is one retained window at a time: a second retained rotation while a root is still retained is refused (mtls-ca/retained-root-exists) rather than silently overwriting the retained root and rejecting clients still enrolled under it -- end the window first, and a plain commit() that omits retainPrevious while a window is open is likewise refused (mtls-ca/retention-intent-required) rather than replacing the active CA while leaving the old root, which would drop trust for the just-superseded generation. A rotation also aborts if it cannot capture the existing key for its rollback journal, and loadTrustBundle() still returns a retained root a crashed rotation left only in that journal. canVerifyInTls(algorithm?) performs a loopback mutual-TLS handshake with a freshly issued leaf and resolves true only if node:tls actually authenticates the client against a CA on the running runtime -- pass the prospective algorithm to pre-flight a migration (canVerifyInTls("ML-DSA-87") probes the target chain, not the current CA) before rotating to an algorithm the deployed Node cannot verify; with no argument it probes the stored CA -- and requires an explicit algorithm when the stored CA's algorithm cannot be derived (a custom-engine CA this runtime does not classify, with no create-time pin), rather than probing an undeterminable one that the engine could resolve to a different algorithm. revokeGeneration(n) walks an issuance ledger (recorded automatically as certificates are issued, or supplied via the new issuanceStore option) and revokes every certificate issued under a CA generation below n -- with an undeterminable generation (a custom engine whose CA certificate this runtime cannot parse, such as an opaque post-quantum cert) recorded as null and skipped by that sweep, still revocable by serial or fingerprint, rather than misfiled as generation 0 (which would sweep current leaves and self-revoke future issuances); issuance fails closed if the ledger cannot be written, so no untracked credential escapes revocation, and importIssuance(entries) backfills leaf identities the ledger does not have (certificates from a pre-upgrade dataDir or issued out of band) so revokeGeneration can sweep them too. Rotation is crash-atomic: because the CA key, certificate, and retained root are separate files that cannot be swapped in a single rename, a rotation that is interrupted between them (a crash or power loss) is reconciled the next time the CA is opened -- the prior key AND the prior retained root are journaled durably before either is overwritten, so an interrupted rotation rolls back to the still-usable previous CA (and the trust bundle its retained root still serves) instead of stranding it as an unrecoverable new-key/old-certificate pair or dropping trust for clients enrolled under the formerly-retained generation. A rollback journal that is unreadable or is not a valid manifest fails the open closed (mtls-ca/rollback-journal-corrupt) so an unresolved rotation marker is never overwritten, rather than being silently discarded and losing the prior key. Establishing the requested retention is part of the commit: a rotation that cannot write the retained root (for example an unwritable ca.prev.crt directory) aborts and rolls back rather than publishing a new CA that silently omits the outgoing root, and a rotate({ retainPrevious: false }) that cannot remove the old root aborts rather than leaving a root the operator asked to hard-cut still trusted. The engine interface adds canVerifyInTls(algorithm) for pluggable engines. · *b.middleware.requireMtls enforces a live revocation source* — requireMtls({ revocationSource: caHandle }) consults a b.mtlsCa handle (or any { isRevoked(fingerprintHex) } object) on every request, refusing a peer whose fingerprint the CA reports revoked. This enforces revoke() and revokeGeneration() at the gate without mirroring the registry into denyList, and it is CA-generation-independent (fingerprint-keyed) -- so a revoked superseded-generation cert is refused even though a CRL signed by the new CA could not cover the old cohort. The lookup fails closed: a source that throws refuses the request. **Changed:** *b.mtlsCa revoke() and revokeGeneration() return a promise* — The revocation registry's read-modify-write now runs under a cross-process file lock so concurrent revocations against the same data directory cannot lose each other's entry. b.mtlsCa revoke() -- previously synchronous -- and revokeGeneration() therefore return a promise resolving to their result; input validation still throws synchronously. Callers must await them: await ca.revoke(serial) / const { revoked } = await ca.revokeGeneration(n). isRevoked() stays synchronous and now reads an in-memory index kept fresh against the store, so a revocationSource-wired require-mtls gate does O(1) per-request lookups. An issuance whose signing straddles a rotate()+revokeGeneration() for its own generation -- or a rotate({ retainPrevious: false }) / dropRetained() that removes the root it was signed under -- is now refused (mtls-ca/issuance-superseded) rather than returned as a credential the sweep missed or a leaf that chains to a root no longer trusted; a clustered revocation store (shared across hosts with per-host dataDirs) can share that supersede watermark by additionally exposing { readGenerationWatermark(), bumpGenerationWatermark(n) }. A clustered revocation store now also requires a shared issuanceStore -- constructing one with the default per-host issuance ledger is refused, since revokeGeneration() would otherwise miss certificates issued on another host and leave them accepted by the shared revocation gate. loadTrustBundle() reads a stable snapshot of the current certificate (re-reading and retrying if a retained rotation publishes mid-read), so it never returns a bundle that omits the newly active root, and it deduplicates an identical retained root. · *Error- and edge-path tests for the scheduler, session device-binding, body parser, notifier, and document collection* — The scheduler's register / schedule validation, start / stop / status lifecycle, and task fire-and-failure recording; b.session device-binding storage and its store-error path; the body parser across its supported content types; the notifier's transport surface; and b.db.collection's query operators now have explicit tests for their error and boundary branches. Behaviour is unchanged -- these pin guarantees the primitives already met. · *Vendored @blamejs/pki refreshed to 0.3.27* — The bundled PKI toolkit (lib/vendor/blamejs-pki.cjs, the mTLS CA engine's certificate/CMS backend) is refreshed from 0.3.25 to 0.3.27. It adds no runtime dependency and no public framework API; the upstream releases add CMP certificate-enrollment protocol support (pki.cmp.verify checks a PKIMessage's signature/PBMAC1 protection; pki.cmp.session drives a full RFC 9810 ir/cr/kur/p10cr enrollment exchange) and fixes across the X.509 / CMS / PKCS#12 surface (subjectAltName IP-address string GeneralNames, RSASSA-PKCS1-v1_5 WebCrypto algorithm-name casing, public-key-integrity PKCS#12, CMS countersignature verification, cross-origin SNI/pin retention in the EST/ACME clients). **Fixed:** *b.db.collection $like honours SQL % / _ wildcards instead of matching them literally* — find({ field: { $like: "Al%" } }) routed the pattern through the query builder's escaping LIKE comparator, which escapes % and _ so a bound value cannot smuggle wildcards. For $like -- whose contract is a SQL LIKE pattern -- that neutralised the caller's wildcards and collapsed the operator to an exact match ($like: "Al%" matched only the literal string "Al%", never "Alice"). $like now emits a caller-controlled-wildcard LIKE, so %, _, prefix, suffix, infix, and single-character patterns all resolve as SQL LIKE defines. Exact patterns with no wildcard are unchanged. Callers that relied on the previous literal-match behaviour should pass the field value directly ({ field: value }) or escape their own wildcards. · *b.mtlsCa fails closed on a schema-corrupt issuance ledger instead of silently emptying it* — A present issuance ledger that was valid JSON but carried no `issued` array (an accidental `{}`, or a truncated / externally-rewritten file) was treated as an empty ledger. The next issuance then rewrote the file with only its own entry, permanently dropping every prior certificate from the sole index revokeGeneration() consults -- so those certificates would survive a later generation revocation. A present ledger with a missing or non-array `issued` now fails closed (mtls-ca/issuance-corrupt), exactly as malformed JSON already did; the operator restores or removes the file. An absent ledger still reads as empty. **Detectors:** *b.db.collection $like must use the verbatim caller-wildcard LIKE* — A source check asserts the collection's $like operator routes through the caller-controlled-wildcard LIKE and never the value-escaping LIKE comparator, so the wildcard-escaping regression cannot return unnoticed. · *b.mtlsCa issuance fingerprint must hash the certificate DER* — A source check asserts the issued-certificate fingerprint is computed as the DER hash the require-mtls gate pins, not the PEM-text hash -- a mismatch would silently make revoke() / revokeGeneration() by fingerprint unenforceable at the gate. · *b.mtlsCa CA rotation must journal the prior key before overwriting it* — A source check asserts the commit path persists a durable rollback copy of the prior CA key before the rename that overwrites it, and that both the read and the rotate paths reconcile a leftover journal on open -- so an interrupted rotation can never regress to leaving the new key beside the old certificate with the prior key lost.
14
+
11
15
  - v0.18.2 (2026-07-30) — **`b.sql` andWhere() now accepts the 2-argument andWhere(field, value) shorthand it previously rejected; the SQL builder, i18n, and static file server also gain error/adversarial-path tests.** b.sql query builders support two call shapes for their WHERE helpers -- where(field, value) and where(field, op, value), distinguished by argument count. orWhere honoured both, but andWhere re-passed three fixed positional arguments internally, so a two-argument andWhere(field, value) was misread as three: the value became the operator and the call threw sql-builder/bad-operator. andWhere now forwards its arguments like where/orWhere, so andWhere("col", value) appends `AND "col" = ?` as documented. The three- and object-argument forms are unchanged. Alongside the fix, the error, boundary, and adversarial branches of the SQL builder, the i18n primitive, and the static file server are brought under test; no other defects surfaced. **Changed:** *Error-path tests for the SQL builder, i18n, and the static file server* — The b.sql builder's dialect / cast / raw-SQL guards, its emitted-SQL size and encoding guards, and the full set of chained WHERE / HAVING / JOIN delegators now have explicit tests; b.i18n's option-validation and interpolation error contracts are pinned to their exact typed error codes; and b.staticServe's path-traversal, symlink-escape, reserved-name, content-disposition, conditional-request, and HEAD-sanitize branches are asserted through the wired middleware. Behaviour is unchanged -- these assert guarantees the primitives already met (a served-traversal or wrong content-type would be a real defect; none were found) -- so no migration is needed. **Fixed:** *b.sql andWhere() honours the 2-argument andWhere(field, value) shorthand* — A chained builder call such as b.sql.select("t").where("a", 1).andWhere("b", 2) previously threw sql-builder/bad-operator because andWhere forwarded three fixed positional parameters to where(), defeating where()'s argument-count check for the two-argument shorthand (the value was interpreted as the operator). andWhere now forwards its arguments verbatim, matching where() and orWhere(); andWhere("b", 2) emits `AND "b" = ?`. The three-argument andWhere(field, op, value) and object andWhere({ field: value }) forms are unaffected.
12
16
 
13
17
  - v0.18.1 (2026-07-30) — **The error and adversarial paths of nine more primitives -- pagination, CloudEvents, JSONPath, structured fields, the CSV guard, async control, schema validation, incident reporting, and the S3 SigV4 signer -- are now under test.** This release adds no behaviour change. The fail-closed error paths, boundary conditions, and adversarial-input handling of b.pagination, b.cloudEvents, b.jsonPath, b.structuredFields, b.guardCsv, b.safeAsync, b.safeSchema, b.incident.report, and the object-store S3 SigV4 request signer -- previously exercised mostly on their happy paths -- are now asserted, verifying that each rejects malformed input, hostile payloads, and the edge cases the documented contract already promised. No defects were found; the primitives behaved as specified. Genuinely-unreachable defensive fallbacks (a base64 decode Node never throws on, a String method always present on the supported Node LTS, exhaustive switch defaults over a fixed token set) are documented rather than contorted into coverage. **Changed:** *Verified error-path behaviour for pagination, CloudEvents, JSONPath, structured fields, the CSV guard, async control, schema validation, incident reporting, and S3 SigV4* — b.pagination's cursor tamper/overflow/legacy-shape decode and orderBy validation; b.cloudEvents' envelope, spec-version, data-conflict, and HTTP binary-mode decode branches; b.jsonPath's filter-expression parser, function-argument literals, slice bounds, and node-cap guards; b.structuredFields' RFC 8941/9651 parse and serialize error branches; b.guardCsv's formula-injection, homoglyph, BOM, schema-validation, and serialize size-cap branches; b.safeAsync's bad-argument, abort-signal, and release-on-unheld paths across withTimeout / Mutex / Semaphore / the batching sinks; b.safeSchema's construction-time misuse throws and validation-failure arms across every builder; b.incident.report's deadline math, drop-silent persist/notify catches, and validation throws; and the S3 SigV4 signer's config validation, presigned-URL and POST-policy generation, and key-sanitization branches now have explicit tests for their failure and boundary behaviour. Behaviour is unchanged -- these assert guarantees the primitives already met -- so no migration is needed; the value is regression protection for the fail-closed paths of security-relevant primitives.
package/NOTICE CHANGED
@@ -60,7 +60,7 @@ Used for: WebAuthn / passkey registration + authentication response
60
60
  server.cjs).
61
61
  --------------------------------------------------------------------------------
62
62
  Component: @blamejs/pki
63
- Version: 0.3.25
63
+ Version: 0.3.28
64
64
  Source: https://github.com/blamejs/pki
65
65
  License: Apache-2.0
66
66
  Copyright: Copyright (c) blamejs contributors
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 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`)
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; migrate the CA to a new algorithm without a trust outage — `rotate()` plus a retained-previous trust bundle, a `canVerifyInTls()` loopback pre-flight, and revoke-by-generation; PQC TLS gates inbound + outbound (`b.mtlsCa`, `b.pqcGate`, `b.pqcAgent`)
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`)
@@ -396,7 +396,31 @@ function collection(name, opts) {
396
396
  if (typeof val !== "string") {
397
397
  throw new TypeError("collection: $like requires a string");
398
398
  }
399
- builder.where(k, "LIKE", val);
399
+ // Consult the GLOBAL registry (via the builder's crypto-field key),
400
+ // not just this collection instance's opts.sealedFields — the field
401
+ // may have been sealed through b.db.init, cryptoField.registerTable,
402
+ // or an earlier collection instance, and Query rewrites on that same
403
+ // registry. Checking only local opts would send a globally-sealed
404
+ // field through the raw LIKE (against ciphertext).
405
+ if (cryptoField.getSealedFields(builder._cryptoFieldKey()).indexOf(k) !== -1) {
406
+ // A sealed field is stored as a derived HASH, so a wildcard LIKE
407
+ // cannot be represented (a hash has no substructure to match).
408
+ // An EXACT pattern (no % / _) routes through the equality path so
409
+ // the sealed-field rewrite maps it to the hash column and matches
410
+ // like $eq did before; a wildcard pattern is refused.
411
+ if (/[%_]/.test(val)) {
412
+ throw new TypeError("collection: $like with a wildcard is not supported on the sealed " +
413
+ "field '" + k + "' (stored as a derived hash) — use an exact value, or query the hash column directly");
414
+ }
415
+ builder.where(k, "=", val);
416
+ } else {
417
+ // $like is a SQL LIKE: the caller's % / _ are wildcards. Route
418
+ // through the verbatim caller-wildcard LIKE (WhereBuilder.like,
419
+ // no _escapeLike) — the general builder's LIKE comparator escapes
420
+ // the value, which would neutralize the wildcards and collapse
421
+ // $like to an exact match.
422
+ builder.whereGroup(function (g) { g.like(k, val); });
423
+ }
400
424
  break;
401
425
  default:
402
426
  throw new TypeError("collection: unsupported query operator '" + op +
@@ -47,6 +47,7 @@
47
47
  * allow-list ALSO requires the fingerprint to match.
48
48
  */
49
49
 
50
+ var nodeCrypto = require("node:crypto");
50
51
  var defineClass = require("../framework-error").defineClass;
51
52
  var lazyRequire = require("../lazy-require");
52
53
  var validateOpts = require("../validate-opts");
@@ -76,16 +77,25 @@ function _normalizeFingerprintEntry(entry) {
76
77
  * client certificate. Refuses with HTTP 401 when no peer cert is
77
78
  * presented, when the TLS layer marks `req.client.authorized ===
78
79
  * false`, when the SHA3-512 fingerprint isn't on the operator
79
- * allowlist, or when it appears on the denylist. Allowlist of
80
+ * allowlist, when it appears on the denylist, or when a
81
+ * `revocationSource` reports it revoked. Allowlist of
80
82
  * null / empty means "any peer cert authorized at the TLS layer
81
83
  * is fine"; non-empty allowlist additionally requires fingerprint
82
84
  * match. Pair with `b.app({ tlsOptions: { requestCert: true, ca:
83
85
  * [...] } })` so the TLS layer captures the client cert.
84
86
  *
87
+ * Pass `revocationSource: caHandle` (a `b.mtlsCa` handle, or any
88
+ * `{ isRevoked(fingerprintHex) }` object) to enforce the CA's live
89
+ * revocation registry at the gate — `revoke()` and `revokeGeneration()`
90
+ * take effect without mirroring the registry into `denyList`, and the
91
+ * check is CA-generation-independent (fingerprint-keyed). The lookup
92
+ * fails closed: a source that throws refuses the request.
93
+ *
85
94
  * @opts
86
95
  * {
87
96
  * fingerprintAllowList: string[],
88
97
  * denyList: string[],
98
+ * revocationSource: object, // { isRevoked(fingerprintHex): boolean } — e.g. a b.mtlsCa handle; enforces revoke()/revokeGeneration() live (fail-closed)
89
99
  * onAuthenticated: function(req, res, next): void,
90
100
  * onDeny: function(req, res, info): void, // own the refusal (mirrors onAuthenticated); info = { status, reason, ...metadata }
91
101
  * problemDetails: boolean, // default false — emit RFC 9457 application/problem+json instead of the default JSON envelope
@@ -104,7 +114,7 @@ function _normalizeFingerprintEntry(entry) {
104
114
  function create(opts) {
105
115
  opts = opts || {};
106
116
  validateOpts(opts, [
107
- "fingerprintAllowList", "denyList",
117
+ "fingerprintAllowList", "denyList", "revocationSource",
108
118
  "onAuthenticated", "onDeny", "problemDetails", "audit",
109
119
  "auditAction", "errorMessage",
110
120
  ], "middleware.requireMtls");
@@ -113,6 +123,22 @@ function create(opts) {
113
123
  ? opts.fingerprintAllowList.map(_normalizeFingerprintEntry) : null;
114
124
  var denyList = Array.isArray(opts.denyList)
115
125
  ? opts.denyList.map(_normalizeFingerprintEntry) : [];
126
+ // Live revocation source — any object exposing isRevoked(fingerprintHex),
127
+ // e.g. a b.mtlsCa handle. The static denyList catches a fixed set; this makes
128
+ // the CA's revocation registry (revoke() / revokeGeneration()) enforced at the
129
+ // gate WITHOUT the operator mirroring it into denyList, and it is CA-generation
130
+ // -independent (fingerprint-keyed), so revoking a superseded generation is
131
+ // honored even though a CRL signed by the new CA could not cover the old cohort.
132
+ // Only omission (undefined) or an explicit null means "no source". A falsy
133
+ // non-object (false, 0, "" from a mis-derived env var) is NOT silently
134
+ // dropped via `|| null` — it flows into validation so invalid security
135
+ // configuration fails at construction instead of quietly disabling the check.
136
+ var revocationSource = (opts.revocationSource === undefined || opts.revocationSource === null)
137
+ ? null : opts.revocationSource;
138
+ if (revocationSource !== null) {
139
+ validateOpts.requireMethods(revocationSource, ["isRevoked"],
140
+ "requireMtls: opts.revocationSource", RequireMtlsError, "require-mtls/bad-revocation-source");
141
+ }
116
142
  var onAuthenticated = typeof opts.onAuthenticated === "function" ? opts.onAuthenticated : null;
117
143
  var onDeny = typeof opts.onDeny === "function" ? opts.onDeny : null;
118
144
  var problemMode = opts.problemDetails === true;
@@ -195,6 +221,56 @@ function create(opts) {
195
221
  subject: (peerCert.subject && peerCert.subject.CN) || null,
196
222
  });
197
223
  }
224
+ // Live revocation registry (e.g. a b.mtlsCa handle). Fail CLOSED: a source
225
+ // that throws denies rather than silently admitting a possibly-revoked cert.
226
+ // Checked before the allow-list so a revoked-but-allowlisted cert is denied.
227
+ if (revocationSource) {
228
+ var revoked;
229
+ try {
230
+ // The revocationSource contract is a SYNCHRONOUS boolean. A non-boolean result — a Promise
231
+ // from an async / DB-backed source, undefined, or any other garbage — is NEVER === true, so
232
+ // silently treating it as not-revoked would ADMIT a possibly-revoked peer. This gate is
233
+ // documented fail-CLOSED, so refuse the request on any non-boolean result (front an async
234
+ // source with a synchronous cache).
235
+ var byFp = revocationSource.isRevoked(fp.hex);
236
+ if (typeof byFp !== "boolean") {
237
+ return _refuse(req, res, "revocation-source-invalid",
238
+ { method: "isRevoked", type: (byFp === null ? "null" : typeof byFp) });
239
+ }
240
+ revoked = byFp;
241
+ // A revoke(serial) / serial-only entry carries fingerprint:null and can't
242
+ // match by fingerprint. Check the peer certificate's serial number ONLY
243
+ // when the source opts in with isSerialRevoked() — a documented
244
+ // fingerprint-only isRevoked(fingerprintHex) that strictly length-validates
245
+ // its input would throw on a shorter serial and fail-close every request.
246
+ // A b.mtlsCa handle exposes isSerialRevoked. When serial enforcement is enabled the serial MUST
247
+ // be checkable: a cert whose serial cannot be extracted (raw unparseable — e.g. prevalidated by
248
+ // a TLS-terminating proxy, or an algorithm the local X.509 parser rejects) cannot be cleared
249
+ // against the serial-only revoke(serial) path, so admitting it would break the fail-closed
250
+ // contract. Refuse rather than silently skip the lookup.
251
+ if (!revoked && typeof revocationSource.isSerialRevoked === "function") {
252
+ var serial = null;
253
+ try { serial = new nodeCrypto.X509Certificate(peerCert.raw).serialNumber; }
254
+ catch (_se) { serial = null; }
255
+ if (!serial) {
256
+ return _refuse(req, res, "serial-unresolved",
257
+ { detail: "certificate serial could not be extracted for the serial-revocation check" });
258
+ }
259
+ var bySerial = revocationSource.isSerialRevoked(serial);
260
+ if (typeof bySerial !== "boolean") {
261
+ return _refuse(req, res, "revocation-source-invalid",
262
+ { method: "isSerialRevoked", type: (bySerial === null ? "null" : typeof bySerial) });
263
+ }
264
+ revoked = bySerial;
265
+ }
266
+ } catch (e) { return _refuse(req, res, "revocation-check-failed", { error: (e && e.message) || String(e) }); }
267
+ if (revoked) {
268
+ return _refuse(req, res, "fingerprint-revoked", {
269
+ fingerprint: fp.colon,
270
+ subject: (peerCert.subject && peerCert.subject.CN) || null,
271
+ });
272
+ }
273
+ }
198
274
  if (allowList && allowList.length > 0 && !bCrypto().isCertRevoked(peerCert.raw, allowList)) {
199
275
  return _refuse(req, res, "fingerprint-not-allowed", {
200
276
  fingerprint: fp.colon,