@blamejs/core 0.14.27 → 0.15.1

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.
Files changed (134) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +2 -2
  3. package/index.js +4 -0
  4. package/lib/ai-content-detect.js +9 -10
  5. package/lib/api-key.js +158 -77
  6. package/lib/atomic-file.js +29 -1
  7. package/lib/audit-chain.js +47 -11
  8. package/lib/audit-sign.js +77 -2
  9. package/lib/audit-tools.js +79 -51
  10. package/lib/audit.js +228 -100
  11. package/lib/backup/index.js +13 -10
  12. package/lib/break-glass.js +202 -144
  13. package/lib/cache.js +174 -105
  14. package/lib/chain-writer.js +38 -16
  15. package/lib/cli.js +19 -14
  16. package/lib/cluster-provider-db.js +130 -104
  17. package/lib/cluster-storage.js +119 -22
  18. package/lib/cluster.js +119 -71
  19. package/lib/compliance.js +22 -0
  20. package/lib/consent.js +82 -29
  21. package/lib/constants.js +16 -11
  22. package/lib/crypto-field.js +387 -91
  23. package/lib/db-declare-row-policy.js +35 -22
  24. package/lib/db-file-lifecycle.js +3 -2
  25. package/lib/db-query.js +517 -256
  26. package/lib/db-schema.js +209 -44
  27. package/lib/db.js +202 -95
  28. package/lib/external-db-migrate.js +229 -139
  29. package/lib/external-db.js +25 -15
  30. package/lib/framework-error.js +11 -0
  31. package/lib/framework-files.js +73 -0
  32. package/lib/framework-schema.js +695 -394
  33. package/lib/gate-contract.js +596 -1
  34. package/lib/guard-agent-registry.js +26 -44
  35. package/lib/guard-all.js +1 -0
  36. package/lib/guard-auth.js +42 -112
  37. package/lib/guard-cidr.js +33 -154
  38. package/lib/guard-csv.js +46 -113
  39. package/lib/guard-domain.js +34 -157
  40. package/lib/guard-dsn.js +27 -43
  41. package/lib/guard-email.js +47 -69
  42. package/lib/guard-envelope.js +19 -32
  43. package/lib/guard-event-bus-payload.js +24 -42
  44. package/lib/guard-event-bus-topic.js +25 -43
  45. package/lib/guard-filename.js +42 -106
  46. package/lib/guard-graphql.js +42 -123
  47. package/lib/guard-html.js +53 -108
  48. package/lib/guard-idempotency-key.js +24 -42
  49. package/lib/guard-image.js +46 -103
  50. package/lib/guard-imap-command.js +18 -32
  51. package/lib/guard-jmap.js +16 -30
  52. package/lib/guard-json.js +38 -108
  53. package/lib/guard-jsonpath.js +38 -171
  54. package/lib/guard-jwt.js +49 -179
  55. package/lib/guard-list-id.js +25 -41
  56. package/lib/guard-list-unsubscribe.js +27 -43
  57. package/lib/guard-mail-compose.js +24 -42
  58. package/lib/guard-mail-move.js +26 -44
  59. package/lib/guard-mail-query.js +28 -46
  60. package/lib/guard-mail-reply.js +24 -42
  61. package/lib/guard-mail-sieve.js +24 -42
  62. package/lib/guard-managesieve-command.js +17 -31
  63. package/lib/guard-markdown.js +37 -104
  64. package/lib/guard-message-id.js +26 -45
  65. package/lib/guard-mime.js +39 -151
  66. package/lib/guard-oauth.js +54 -135
  67. package/lib/guard-pdf.js +45 -101
  68. package/lib/guard-pop3-command.js +21 -31
  69. package/lib/guard-posture-chain.js +24 -42
  70. package/lib/guard-regex.js +33 -107
  71. package/lib/guard-saga-config.js +24 -42
  72. package/lib/guard-shell.js +42 -172
  73. package/lib/guard-smtp-command.js +48 -54
  74. package/lib/guard-snapshot-envelope.js +24 -42
  75. package/lib/guard-sql.js +1491 -0
  76. package/lib/guard-stream-args.js +24 -43
  77. package/lib/guard-svg.js +47 -65
  78. package/lib/guard-template.js +35 -172
  79. package/lib/guard-tenant-id.js +26 -45
  80. package/lib/guard-time.js +32 -154
  81. package/lib/guard-trace-context.js +25 -44
  82. package/lib/guard-uuid.js +32 -153
  83. package/lib/guard-xml.js +38 -113
  84. package/lib/guard-yaml.js +51 -163
  85. package/lib/http-client.js +14 -0
  86. package/lib/inbox.js +120 -107
  87. package/lib/legal-hold.js +107 -50
  88. package/lib/log-stream-cloudwatch.js +47 -31
  89. package/lib/log-stream-otlp.js +32 -18
  90. package/lib/mail-crypto-smime.js +2 -6
  91. package/lib/mail-greylist.js +2 -6
  92. package/lib/mail-helo.js +2 -6
  93. package/lib/mail-journal.js +85 -64
  94. package/lib/mail-rbl.js +2 -6
  95. package/lib/mail-scan.js +2 -6
  96. package/lib/mail-spam-score.js +2 -6
  97. package/lib/mail-store.js +293 -154
  98. package/lib/middleware/fetch-metadata.js +17 -7
  99. package/lib/middleware/idempotency-key.js +54 -38
  100. package/lib/middleware/rate-limit.js +102 -32
  101. package/lib/middleware/security-headers.js +21 -5
  102. package/lib/migrations.js +108 -66
  103. package/lib/network-heartbeat.js +7 -0
  104. package/lib/nonce-store.js +31 -9
  105. package/lib/object-store/azure-blob-bucket-ops.js +9 -4
  106. package/lib/object-store/azure-blob.js +31 -3
  107. package/lib/object-store/sigv4.js +10 -0
  108. package/lib/outbox.js +136 -82
  109. package/lib/pqc-agent.js +44 -0
  110. package/lib/pubsub-cluster.js +42 -20
  111. package/lib/queue-local.js +202 -139
  112. package/lib/queue-redis.js +9 -1
  113. package/lib/queue-sqs.js +6 -0
  114. package/lib/retention.js +82 -39
  115. package/lib/safe-dns.js +29 -45
  116. package/lib/safe-ical.js +18 -33
  117. package/lib/safe-icap.js +27 -43
  118. package/lib/safe-sieve.js +21 -40
  119. package/lib/safe-sql.js +124 -3
  120. package/lib/safe-vcard.js +18 -33
  121. package/lib/scheduler.js +35 -12
  122. package/lib/seeders.js +122 -74
  123. package/lib/session-stores.js +42 -14
  124. package/lib/session.js +116 -72
  125. package/lib/sql.js +3885 -0
  126. package/lib/static.js +45 -7
  127. package/lib/subject.js +89 -49
  128. package/lib/vault/index.js +3 -2
  129. package/lib/vault/passphrase-ops.js +3 -2
  130. package/lib/vault/rotate.js +104 -64
  131. package/lib/vendor-data.js +2 -0
  132. package/lib/websocket.js +16 -0
  133. package/package.json +1 -1
  134. package/sbom.cdx.json +6 -6
package/CHANGELOG.md CHANGED
@@ -6,6 +6,12 @@ Pre-1.0 the surface is intentionally evolving — every release may
6
6
  change something operators depend on. Read each entry before
7
7
  upgrading across more than a few patches at a time.
8
8
 
9
+ ## v0.15.x
10
+
11
+ - v0.15.1 (2026-06-11) — **Sealed-column lookups find rows written before the v0.15.0 hash change, and API-key secrets re-hash to the active algorithm on verify.** v0.15.0 changed the default derived hash — the blind index a sealed column is looked up by — from an unkeyed salted hash to a keyed MAC, and promised a transparent migration via a dual read. But no lookup path actually performed the dual read, so on a deployment that already held data, a lookup by a sealed column (a session's user id, an API key's owner, an audit actor or resource, a consent or data-subject id, a mail thread) computed only the new keyed digest and missed every row written before the upgrade. This release wires the dual read into every lookup: a sealed-column equality now matches both the active keyed digest and the legacy salted digest, so pre-upgrade rows are found again. That restores two correctness guarantees the gap had quietly broken — revoking all of a user's sessions no longer skips sessions created before the upgrade, and a subject erasure no longer leaves pre-upgrade rows behind. Separately, the framework's API-key store now re-hashes a stored secret to the active hash algorithm on the next successful verify, the transparent rotation the credential-hash primitive documented but the store had never performed. **Fixed:** *Sealed-column lookups match rows written before the v0.15.0 keyed-MAC change* — After v0.15.0 flipped the default derived-hash mode to a keyed MAC, a lookup by a sealed column computed only the new keyed digest, so rows written under the previous salted-hash default were no longer found — a silent index miss on every existing deployment. Every framework lookup path now dual-reads: `b.db.from(...).where(sealedField, value)` and the framework's own api-key, session, audit, consent, data-subject, and mail-thread lookups match the column against both the active keyed digest and the legacy salted digest (`b.db.hashCandidatesFor` exposes the candidate list for operator code). No migration or operator action is required; rows re-hash to the keyed form on read over time and the candidate set collapses back to a single value. Two correctness consequences are restored: revoking all of a user's sessions now also revokes sessions created before the upgrade, and a data-subject erasure now also deletes (and crypto-shreds) the subject's pre-upgrade rows. · *API-key secret hashes upgrade to the active algorithm on verify* — The framework's API-key store now re-hashes a stored secret to the configured hash algorithm on the next successful verify (leader-gated, best-effort, primary-match only), emitting an `apikey.secret_rehash` audit and observability event. This is the transparent rotation `b.credentialHash` documents — a key stored under an older algorithm or parameter set silently moves to the current one as it is used, with no change to the verify result or the returned record.
12
+
13
+ - v0.15.0 (2026-06-08) — **A chainable SQL builder, MySQL as a first-class data backend, and a keyed lookup-hash default — the data layer goes tri-dialect.** This release makes the framework's data layer dialect-portable. `b.sql` is a new chainable query builder that quotes every identifier by construction, binds every value as a placeholder, and emits dialect-correct SQL for SQLite, Postgres, and MySQL; `b.guardSql` validates result rows against NUL bytes, quote-jump sequences, and per-column / total size boundaries. The entire framework data layer — the signed audit chain, cluster leadership and fencing, sessions, break-glass, the local queue, cache, scheduler, migrations, consent, and mail storage — is rebuilt on `b.sql`, which makes MySQL a supported external-database backend alongside Postgres and SQLite. Running the data layer on a real Postgres server surfaced two latent correctness bugs that only a non-SQLite backend exposes: identifiers were emitted unquoted at DDL time but read back camelCase (Postgres folds unquoted names to lowercase, silently breaking the audit chain, consent chain, cluster fencing, and vault-key consistency), and sealed columns coerced Buffer / object payloads through `String()` before encryption (corrupting non-string ciphertext); both are fixed by quoting every identifier and coercing per the column's declared type. Derived-hash columns — the blind-index lookup columns registered through `registerTable` — now default to a keyed MAC (SHAKE256 under the vault's per-deployment MAC key) instead of an unkeyed salted hash, so an exfiltrated database can no longer be brute-forced or rainbow-tabled to recover the indexed values; existing salted-hash indexes are read through a dual-read path and migrated forward, so the change is non-breaking. Audit-signing key rotation now preserves every historical checkpoint (rotation previously stranded checkpoints signed under the prior key). New cross-border data-residency postures (appi-jp, pdpa-sg, uk-gdpr) enforce a mandatory storage vacuum after erasure. Outbound TLS appends classical X25519 to its key-exchange preference so it can complete a handshake with a peer that advertises no post-quantum hybrid — previously the hybrids-only preference failed those handshakes outright — and emits a `tls.classical_downgrade` audit event whenever a connection lands on a classical group. Outbound HTTP/2 negotiation falls back to HTTP/1.1 against servers that only speak h1, the network heartbeat honors a target's permitted protocols instead of pinning cleartext targets down, a WebSocket connection closes cleanly on a peer's TCP half-close instead of wedging open, and the Azure blob backend encodes object-key path segments correctly. **Added:** *b.sql — a dialect-aware chainable SQL builder* — `b.sql` composes SELECT / INSERT / UPDATE / UPSERT / DELETE / DDL from a fluent chain. Every identifier is validated and quoted by construction (`"name"` on SQLite/Postgres, `` `name` `` on MySQL), every value binds as a placeholder rather than interpolating, and the emitted statement is validated as a single balanced, single-statement query before it leaves the builder. Pass `{ dialect: "postgres" | "mysql" | "sqlite" }` (default SQLite) to target a backend; `upsert` emits the dialect-final conflict syntax. It composes `b.safeSql` for the identifier and placeholder primitives, so a SQL string can no longer be assembled by hand inside the framework. · *b.guardSql — result-row output validation* — `b.guardSql` gates query result rows against embedded NUL bytes, quote-jump sequences, and configurable per-column and total-size boundaries, with the standard guard profiles (strict / balanced / permissive) and compliance postures (hipaa / pci-dss / gdpr / soc2). It is the output-side complement to the input-side `b.safeSql`. · *MySQL is a first-class data backend* — MySQL joins Postgres and SQLite as a supported external-database backend. The framework's schema reconciler emits MySQL DDL, and the full data layer — signed audit chain (append + verify), cluster leadership and lease fencing, sessions, break-glass, the local queue, cache, scheduler, migrations, and consent — runs against a real MySQL server. Select the backend at `b.cluster.init` / `b.externalDb` configuration via the `dialect` option; `b.clusterStorage.dialect()` exposes the configured backend dialect to composing code. · *Cross-border erasure postures: appi-jp, pdpa-sg, uk-gdpr* — Three data-residency compliance postures are added (Japan APPI, Singapore PDPA, UK GDPR). Each requires a mandatory storage vacuum after erasure (so deleted rows are reclaimed from the page store, not just tombstoned), a signed audit chain, encrypted backups, and a minimum TLS version. Pin one with `b.compliance.set`. **Fixed:** *Cross-border erasure performs the mandatory vacuum* — Erasure under the uk-gdpr, appi-jp, and pdpa-sg residency postures now runs the storage vacuum the posture mandates, reclaiming erased rows from the page store rather than leaving them recoverable as free-list tombstones. **Security:** *Lookup-hash columns default to a keyed MAC* — Derived-hash (blind-index) columns registered through `registerTable` now default to `derivedHashMode: "hmac-shake256"` — SHAKE256 keyed with the vault's per-deployment MAC key — instead of the previous unkeyed `salted-sha3`. The index value is no longer recomputable from the indexed plaintext alone, so an attacker who exfiltrates the database cannot brute-force or rainbow-table a lookup column (for example a subject-email index) without also holding the vault MAC key (CWE-916 / CWE-759). Existing `salted-sha3` indexes are read through a dual-read path and re-derived on write, so deployments upgrade without re-indexing up front. · *Postgres identifier casing no longer breaks the audit and cluster chains* — Identifiers were written unquoted in DDL but read back in camelCase. On Postgres (which folds an unquoted identifier to lowercase) this silently desynchronized the signed audit chain, the consent chain, cluster leadership and fencing, and vault-key consistency — each reads a column the server had stored under a lowercased name. Every framework identifier is now quoted at both DDL and query time so the stored and read names match on every dialect (CWE-670). SQLite deployments were unaffected and remain byte-compatible. · *Sealed columns preserve non-string payloads* — A sealed column coerced its value through `String()` before encryption, corrupting Buffer and object payloads (a Buffer became `"[object Object]"`-class garbage, an object its `toString`). Sealed values are now encoded per the column's declared type before the seal, so binary and structured payloads round-trip intact (CWE-704). · *Audit-signing key rotation preserves historical checkpoints* — Rotating the audit-signing key stranded every checkpoint signed under the prior key — `verifyCheckpoints` ignored the per-fingerprint history file the rotation writes, so post-rotation verification failed on otherwise-valid historical checkpoints. Verification now resolves each checkpoint's signing key by fingerprint (`getPublicKeyByFingerprint`) across the rotation history, so the full chain verifies after a key rotation. · *Outbound TLS reaches classical-only peers and audits the downgrade* — The framework's outbound TLS offered only ML-KEM hybrid key-exchange groups, so a handshake with a peer that does not advertise a post-quantum hybrid — most of today's internet — failed outright (`handshake_failure`), leaving outbound connections to webhooks, OAuth providers, ACME directories, object stores, and DoT/DoH/SMTP/Redis-over-TLS unable to complete. Classical X25519 is now appended to the group preference so the hybrid is still negotiated whenever the peer supports it, and the connection completes over classical X25519 when it does not. Every connection that lands on a classical group rather than the post-quantum hybrid emits a `tls.classical_downgrade` audit event (carrying the negotiated group) so operators can observe and alert on which peers are not yet post-quantum-capable. · *Transport reachability and correctness* — Outbound HTTP/2 negotiation now falls back to HTTP/1.1 when a TLS server offers only h1 (an ALPN protocol_version alert no longer fails the request). The network heartbeat honors a target's permitted protocols instead of dropping non-default ones, so a cleartext `http://` health target is no longer reported permanently down. A WebSocket connection closes cleanly when a peer half-closes its TCP socket (a bare FIN) instead of wedging the connection open. The Azure blob backend percent-encodes each object-key path segment, so a key containing reserved characters can no longer corrupt the request URL. **Migration:** *Derived-hash default change is non-breaking; MySQL is opt-in* — Lookup-hash columns default to the keyed MAC on new writes and migrate existing rows on access through the dual-read path — no upfront re-indexing, no operator action required. To pin the previous unkeyed index (for example to keep a column byte-compatible with an external system), pass `derivedHashMode: "salted-sha3"` to `registerTable`. MySQL as a data backend is opt-in: existing SQLite and Postgres deployments are unchanged unless you set `dialect: "mysql"`. The Postgres identifier-casing and sealed-column-coercion fixes change the emitted DDL and the at-rest encoding of non-string sealed values; a Postgres deployment created before this release reconciles its schema to the quoted identifiers on the next schema-ensure pass.
14
+
9
15
  ## v0.14.x
10
16
 
11
17
  - v0.14.27 (2026-06-06) — **Security-hardening sweep: exclusive temp creation, request path confinement, prototype-safe parsing, cross-tenant authentication, telemetry and error redaction, and posture enforcement floors.** A broad security-hardening release closing classes of bug across the request, storage, identity, and data-governance surfaces. Files that stage data before an atomic rename (the atomic-write substrate, the HTTP client's downloads, the static file server's cache) now create those files exclusively and refuse to follow a symlink, so a same-user pre-planted file or symlink can no longer be truncated or written through. The static file server re-confines every request-derived path to its served root at the filesystem call, and its content-safety read is anchored to a single no-follow descriptor. The request body parser, the WebSocket extension parser, and the cookie parser build their maps from key/value pairs instead of attacker-named computed writes, so a field named after an Object prototype member can neither pollute the prototype nor surface inherited members. The SSRF guard compares cloud-metadata addresses canonically; OIDC federation derives a subordinate's policy and keys from the superior-signed statement rather than self-published config; the JMAP listener rejects a cross-tenant account id with accountNotFound before dispatch; the agent event bus authenticates each envelope's tenant and posture with a vault-keyed MAC; and the audit query no longer under-logs concurrent reads. Telemetry attributes exported over OTLP and the 5xx error record written to the signed audit chain are now redacted, closing two egress paths that previously shipped secrets in plaintext. Regulated compliance postures enforce a minimum seal-envelope strength at table registration, warn when a pinned posture has no content-safety overlay or no outbound DLP wired, and expose region-tag normalization helpers. The local queue's Redis backend no longer wedges a caller on a failed connect or double-schedules reconnects, file uploads audit every content-safety skip, the Azure blob backend percent-encodes object keys, the XML parser rejects prototype-poisoning names, and router path-scoped middleware (`use(path, mw)`) works as documented instead of dropping the gate. **Security:** *Staged writes are created exclusively and refuse symlinks* — The atomic-write substrate (`b.atomicFile`), the HTTP client's download staging, and the static file server's pre-serve read now open files with `O_CREAT | O_EXCL | O_NOFOLLOW` at owner-only `0o600` (the vault-rotation pipeline adopted the same in v0.14.26). A pre-planted file or a symlink at a staging path is now a hard failure rather than a truncated or followed write (CWE-377 / CWE-379 / CWE-59). The temp+rename atomicity and download streaming semantics are unchanged. · *Static file server re-confines request paths and reads through one no-follow descriptor* — Every request-derived path is re-resolved under the served root and refused if it escapes (`startsWith(root + sep)`) at the filesystem call, not only upstream, and the content-safety gate reads size and bytes from a single `O_NOFOLLOW` descriptor so a final-component symlink swap between stat and read cannot redirect it (CWE-22 / CWE-367). Percent-encoded traversal, NUL bytes, and absolute/drive-letter paths are refused before any filesystem access. · *Body, WebSocket, and cookie parsers build maps without attacker-named writes* — The request body parser (Content-Type/Content-Disposition params, multipart headers and fields), the WebSocket `Sec-WebSocket-Extensions` parser, and the CSRF cookie parser now collect key/value pairs and materialize them via `Object.fromEntries` onto a null-prototype accumulator with prototype-poisoning keys dropped, instead of a request-keyed computed write. A field or parameter named `__proto__` / `constructor` / `prototype` can no longer pollute the prototype or surface inherited members (CWE-915 / CWE-1321). Parsed object shapes are unchanged for all legitimate input. · *Prototype-safe XML parsing* — `b.safeXml` builds its element, attribute, and grouping accumulators with `Object.create(null)` and rejects element/attribute names `__proto__` / `constructor` / `prototype` with `xml/forbidden-name` (CWE-1321), matching the TOML/YAML/INI parsers. The returned tree has a null prototype; consumers using bracket/dot access, `Object.keys`, or `JSON.stringify` are unaffected. · *SSRF guard compares cloud-metadata addresses canonically* — The SSRF guard now canonicalizes addresses before comparing against the cloud metadata endpoints, so a non-canonical encoding of a link-local / metadata address can no longer slip past the block to reach an instance metadata service (CWE-918). · *OIDC federation trusts the superior-signed statement, not self-config* — A subordinate entity's `metadata_policy` is now read from the superior's signed entity statement, and trust-chain building verifies each subordinate against attested keys in a two-phase walk rather than the subordinate's self-published JWKS, closing a trust-substitution gap (RFC 9068 / OpenID Federation). A subordinate that publishes no attested keys is refused. · *Cross-tenant authentication on the JMAP listener and agent event bus* — The JMAP listener rejects a client-supplied `accountId` outside the actor's permitted set with `accountNotFound` before any method or blob handler runs (RFC 8620 §3.6.1). The agent event bus authenticates each envelope's tenant id and posture with a vault-keyed HMAC and drops a forged or tampered envelope before routing. The audit query no longer suppresses its own `audit.read` self-log under concurrency, so concurrent reads are each recorded (PCI-DSS 10.2). · *Telemetry and error records are redacted before egress* — Span and metric attributes exported over OTLP now pass through a redactor (`b.observability.setRedactor`, defaulting to `b.redact`) before leaving the process, and the 5xx error record written to the durable, signed audit chain now uses the redacting `audit.safeEmit` rather than the raw `audit.emit` — closing two egress paths that previously shipped a secret embedded in a span attribute or an exception message/stack in plaintext (CWE-532). The error response itself is byte-identical. · *Regulated postures enforce a seal-envelope floor* — Compliance postures now carry a `sealEnvelopeFloor`; `hipaa` and `pci-dss` require at least AEAD additional-data binding. Registering a table that seals columns under a weaker envelope while such a posture is pinned is refused at configuration time, so a regulated deployment can no longer register a copy-paste-vulnerable plain-sealed table (CWE-311 / CWE-326; 45 CFR 164.312, PCI-DSS v4 Req. 3.5). · *Redis backend connect/reconnect robustness* — The local queue's Redis backend always settles its connect promise (resolve on ready, reject on error/timeout) so a caller can no longer wedge on a failed connect, schedules at most one reconnect per failure (a lost socket fires both error and close), and gives up idempotently — fixing a caller hang and a reconnect storm on a flapping backend (CWE-833 / CWE-400). · *File-upload skip auditing and Azure object-key encoding* — Every content-safety skip path in `b.fileUpload` now emits a `content_safety_skipped` audit naming why the scan was bypassed (opt-out / no gate for the extension / over the reassembly cap), so an unscanned upload is visible in the audit log (CWE-778). The Azure blob backend percent-encodes each object-key path segment before URL interpolation, so a key containing `?` / `#` / spaces can no longer corrupt the request or escape its container path (CWE-20). · *Router path-scoped middleware works as documented* — `router.use(pathPrefix, middleware)` — documented across the security middleware but previously unimplemented (the path string was pushed as the middleware, 500-ing every request, or the gate silently never ran) — now mounts the middleware scoped to a segment-boundary path prefix, preserving registration order and refusing a non-string prefix or non-function middleware at configuration time (CWE-670). **Detectors:** *Recurrence guards for the fixed shapes* — The pattern catalog gains detectors for the exclusive-temp-create (atomic-file, http-client, static), request-path confinement (static serve + gate), request-keyed map writes (body-parser, WebSocket), prototype-safe XML accumulators, Azure object-key encoding, file-upload skip auditing, OTLP attribute redaction, JMAP account gating, the seal-envelope floor, and the router path-scoped mount. The data-flow and timing shapes (SSRF canonicalization, federation trust-chain, audit self-log concurrency, event-bus envelope MAC, Redis reconnect scheduling, error-record redaction, posture-overlay warnings) are guarded by request-driven tests where a precise regex would false-positive. **Migration:** *Behavior changes to review* — Several hardening changes alter behavior, all fail-closed: (1) the agent event bus now requires a shared vault to authenticate envelopes and refuses to publish/deliver without one — single-process callers without a vault must pass `requireMac: false`; (2) OIDC federation subordinates must publish attested keys (a subordinate relying on self-published JWKS is refused); (3) `b.safeXml` throws `xml/forbidden-name` on element/attribute names `__proto__`/`constructor`/`prototype`; (4) registering a table that seals columns below the pinned regulated posture's seal-envelope floor throws at configuration time — add `{ aad: true }` or a per-row key; (5) OTLP telemetry attributes are now redacted by default — install a domain redactor via `b.observability.setRedactor` if you need different handling (it cannot be disabled outright); (6) `router.use(path, mw)` now path-scopes instead of applying globally or 500-ing. New advisory audit rows appear when a pinned posture has no content-safety overlay or no outbound DLP wired; `b.compliance.set` does not auto-install outbound DLP (call `b.redact.installForPosture` with your client/mail/webhook handles).
package/README.md CHANGED
@@ -64,7 +64,7 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
64
64
  - **Chainable query builder** — atomic `.increment(col, delta)`, closure-form `.whereGroup` / top-level `.orWhere` OR composition, `.search(fields, term)` LIKE-OR with safe `%`/`_` ESCAPE handling, `.paginate(opts)` returning `{ items, total, page, totalPages }`; a column-membership gate (`db.init({ columnGate })`, default reject) fails a query closed when it names a column the table never declared, and `whereRaw` refuses an embedded string literal so values bind through placeholders
65
65
  - **Mongo-style document-store facade** — `b.db.collection(name, opts?)` with `$set` / `$inc` / `$unset` / `$eq` / `$ne` / `$gt` / `$gte` / `$lt` / `$lte` / `$in` / `$like`; schemaless-document opts via `overflow: "<col>"` (folds unknown fields into a JSON-text column; rewrites `WHERE` on virtual fields to `JSON_EXTRACT`), `jsonColumns: [...]` (auto-stringify on write + parse via `b.safeJson` on read), `sealedFields: { email: "emailHash" }` (co-locates a `b.cryptoField` sealed-column / derived-hash declaration so plaintext lookups auto-rewrite to hash-column lookups)
66
66
  - **DB lifecycle** — in-memory encrypted snapshot via `b.db.snapshot()`; standalone encrypted-DB-file lifecycle (`b.db.fileLifecycle({ dataDir, vault })` — decrypt-to-tmpfs, periodic re-encrypt flush, graceful shutdown — same envelope as `b.db`, no schema/audit-chain coupling); `db.init` opt-outs `frameworkTables: false` / `auditSigning: false` and path overrides `encryptedDbPath` / `encryptedDbName` / `dbKeyPath`
67
- - **External RDBMS** — bring-your-own Postgres / MySQL with pool tuning + role-aware connect + read-replica routing (`b.externalDb`); declarative role-narrowed views and Postgres row-level-security migrations (`b.db.declareView`, `b.db.declareRowPolicy`); an opt-in `requireTls` transport posture refuses a non-TLS backend at boot, and query / transaction / read traces carry OpenTelemetry `db.*` attributes
67
+ - **External RDBMS** — bring-your-own Postgres / MySQL with pool tuning + role-aware connect + read-replica routing (`b.externalDb`); declarative role-narrowed views and Postgres row-level-security migrations (`b.db.declareView`, `b.db.declareRowPolicy`); an opt-in `requireTls` transport posture refuses a non-TLS backend at boot, and query / transaction / read traces carry OpenTelemetry `db.*` attributes. The framework's own data layer — the signed audit chain, cluster leadership and lease fencing, sessions, break-glass, and the local queue / cache / scheduler — is composed through the dialect-aware `b.sql` builder (every identifier quoted by construction, every value bound as a placeholder, dialect-correct SQLite / Postgres / MySQL output), so the framework's tables run on a Postgres or MySQL backend, not only local SQLite; `b.guardSql` validates result rows against NUL bytes, quote-jump sequences, and per-column / total-size boundaries
68
68
  - **Object store** — S3 / R2 / B2 / GCS / Azure with multipart upload + SSE + bucket-ops (create / delete / list / lifecycle / CORS); S3 Object Lock + per-object retention + legal hold for write-once-read-many compliance workloads (`b.storage`, `b.objectStore`)
69
69
  - **Queues + cache** — durable queue with priority + cron + flows on local SQLite, shared Redis, OR AWS SQS via SigV4 + AWSJsonProtocol_1.0 (`b.queue`, `b.jobs`) — the local backend can target an operator-supplied database / table / schema; cluster-shared cache (`b.cache`)
70
70
  ### Identity & access
@@ -97,7 +97,7 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
97
97
 
98
98
  - **At-rest envelope** — envelope-versioned PQC (ML-KEM-1024 + P-384 hybrid, XChaCha20-Poly1305, SHAKE256); vault sealing (`b.crypto`, `b.vault`)
99
99
  - **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 (`K_row = HKDF(K_table, rowId)`) so erasing the per-row key makes WAL / replica residuals undecryptable (`b.cryptoField.declareColumnResidency`, `b.cryptoField.declarePerRowResidency`, `b.cryptoField.declarePerRowKey`)
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`)
101
101
  - **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
102
  - **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
103
  - **Signed webhooks + API encryption** — SLH-DSA-SHAKE-256f default; ML-DSA-65 opt-in; ECIES API encryption (`b.webhook`, `b.crypto`)
package/index.js CHANGED
@@ -120,6 +120,7 @@ var clusterStorage = require("./lib/cluster-storage");
120
120
  var safeAsync = require("./lib/safe-async");
121
121
  var handlers = require("./lib/handlers");
122
122
  var safeSql = require("./lib/safe-sql");
123
+ var sql = require("./lib/sql");
123
124
  var chainWriter = require("./lib/chain-writer");
124
125
  var safeBuffer = require("./lib/safe-buffer");
125
126
  var safeDecompress = require("./lib/safe-decompress").safeDecompress;
@@ -181,6 +182,7 @@ var guardSvg = require("./lib/guard-svg");
181
182
  var guardFilename = require("./lib/guard-filename");
182
183
  var guardMessageId = require("./lib/guard-message-id");
183
184
  var guardSmtpCommand = require("./lib/guard-smtp-command");
185
+ var guardSql = require("./lib/guard-sql");
184
186
  var guardImapCommand = require("./lib/guard-imap-command");
185
187
  var guardPop3Command = require("./lib/guard-pop3-command");
186
188
  var guardManageSieveCommand = require("./lib/guard-managesieve-command");
@@ -520,6 +522,7 @@ module.exports = {
520
522
  safeAsync: safeAsync,
521
523
  handlers: handlers,
522
524
  safeSql: safeSql,
525
+ sql: sql,
523
526
  chainWriter: chainWriter,
524
527
  safeBuffer: safeBuffer,
525
528
  safeDecompress: safeDecompress,
@@ -569,6 +572,7 @@ module.exports = {
569
572
  guardFilename: guardFilename,
570
573
  guardMessageId: guardMessageId,
571
574
  guardSmtpCommand: guardSmtpCommand,
575
+ guardSql: guardSql,
572
576
  guardImapCommand: guardImapCommand,
573
577
  guardPop3Command: guardPop3Command,
574
578
  guardManageSieveCommand: guardManageSieveCommand,
@@ -43,6 +43,7 @@ var lazyRequire = require("./lazy-require");
43
43
  var contentCredentials = lazyRequire(function () { return require("./content-credentials"); });
44
44
  var audit = require("./audit");
45
45
  var { defineClass } = require("./framework-error");
46
+ var gateContract = require("./gate-contract");
46
47
 
47
48
  var AiContentDetectError = defineClass("AiContentDetectError", { alwaysPermanent: true });
48
49
 
@@ -64,16 +65,14 @@ var COMPLIANCE_POSTURES = Object.freeze({
64
65
  "nist-ai-rmf": "balanced",
65
66
  });
66
67
 
67
- function _resolveProfile(opts) {
68
- if (opts && typeof opts.posture === "string") {
69
- var profile = COMPLIANCE_POSTURES[opts.posture];
70
- if (profile) return PROFILES[profile];
71
- }
72
- if (opts && typeof opts.profile === "string" && PROFILES[opts.profile]) {
73
- return PROFILES[opts.profile];
74
- }
75
- return PROFILES[DEFAULT_PROFILE];
76
- }
68
+ var _resolveProfile = gateContract.makeProfileResolver({
69
+ profiles: PROFILES,
70
+ postures: COMPLIANCE_POSTURES,
71
+ defaults: DEFAULT_PROFILE,
72
+ errorClass: AiContentDetectError,
73
+ codePrefix: "ai-content-detect",
74
+ byObject: true,
75
+ });
77
76
 
78
77
  /**
79
78
  * @primitive b.ai.aiContentDetect.report
package/lib/api-key.js CHANGED
@@ -43,6 +43,7 @@ var cluster = require("./cluster");
43
43
  var cryptoField = require("./crypto-field");
44
44
  var requestHelpers = require("./request-helpers");
45
45
  var validateOpts = require("./validate-opts");
46
+ var sql = require("./sql");
46
47
  var C = require("./constants");
47
48
  var numericChecks = require("./numeric-checks");
48
49
  var { ApiKeyError } = require("./framework-error");
@@ -53,16 +54,28 @@ function _emitEvent(n, v, l) { observability().safeEvent(n, v, l || {}); }
53
54
 
54
55
  var _err = ApiKeyError.factory;
55
56
 
56
- var TABLE = "_blamejs_api_keys";
57
- // Pre-quoted form for SQL interpolation. Defense-in-depth: even though
58
- // our constant is bare-identifier-shaped, every interpolation site uses
59
- // the wrapped form so a future rename to a reserved-word or
60
- // whitespace-bearing name would still resolve correctly.
61
- var Q_TABLE = '"' + TABLE + '"';
62
-
63
- // Column order used for INSERT kept as a constant so the placeholders
64
- // list and the values list stay in sync. Must match _blamejs_api_keys'
65
- // schema in db.js (single-node) and framework-schema.js (cluster mode).
57
+ // Logical framework table name. Self-mapped in LOCAL_TO_EXTERNAL, so it is
58
+ // passed BARE to b.sql: clusterStorage.execute rewrites it to the configured
59
+ // prefix and placeholderizes the `?` markers, so one query text runs against
60
+ // the local SQLite single-node backend and the operator's external DB in
61
+ // cluster mode.
62
+ var TABLE = "_blamejs_api_keys"; // allow:hand-rolled-sql bare logical name, passed to b.sql for clusterStorage rewrite
63
+
64
+ // b.sql opts for every _blamejs_api_keys statement: thread the ACTIVE backend
65
+ // dialect (clusterStorage.dialect() "sqlite" single-node, "postgres" |
66
+ // "mysql" in cluster mode) so the emitted identifier quoting + dialect idioms
67
+ // match the backend the SQL dispatches to. Defaulting to "sqlite" works on
68
+ // Postgres only by accident (both double-quote identifiers) and emits the
69
+ // wrong quoting on MySQL, so this is the canonical resolver threaded into
70
+ // b.sql. clusterStorage.execute still rewrites the bare table name +
71
+ // translates `?` placeholders at dispatch; this controls only the builder-
72
+ // side quoting + idiom selection. The table name stays BARE (no quoteName)
73
+ // so clusterStorage's prefix rewrite still fires.
74
+ function _sqlOpts() { return { dialect: clusterStorage.dialect() }; }
75
+
76
+ // Column order used for INSERT — kept as a constant so the column list and
77
+ // the row object stay in sync. Must match _blamejs_api_keys' schema in
78
+ // db.js (single-node) and framework-schema.js (cluster mode).
66
79
  var COLS = [
67
80
  "id", "namespace", "ownerId", "ownerIdHash", "secretHash",
68
81
  "secondarySecretHash", "secondaryExpiresAt",
@@ -305,10 +318,11 @@ function create(opts) {
305
318
  );
306
319
  }
307
320
 
308
- function _selectAll() {
309
- return "SELECT id, namespace, ownerId, ownerIdHash, secretHash, " +
310
- "secondarySecretHash, secondaryExpiresAt, " +
311
- "scopes, metadata, createdAt, expiresAt, revokedAt, lastUsedAt, prefix FROM " + Q_TABLE;
321
+ // Fresh SELECT builder over the full column set. BARE logical table name
322
+ // (_blamejs_api_keys) clusterStorage rewrites it to the configured
323
+ // prefix and placeholderizes. Callers chain the WHERE family + .toSql().
324
+ function _selectBuilder() {
325
+ return sql.select(TABLE, _sqlOpts()).columns(COLS); // allow:hand-rolled-sql — bare logical name for clusterStorage rewrite
312
326
  }
313
327
 
314
328
  function _scrubRecord(row) {
@@ -369,14 +383,13 @@ function create(opts) {
369
383
  lastUsedAt: null,
370
384
  prefix: prefix,
371
385
  });
372
- var values = COLS.map(function (c) { return sealed[c]; });
373
- var placeholders = COLS.map(function () { return "?"; }).join(", ");
374
- var quoted = COLS.map(function (c) { return '"' + c + '"'; }).join(", ");
375
-
376
- await clusterStorage.execute(
377
- "INSERT INTO " + Q_TABLE + " (" + quoted + ") VALUES (" + placeholders + ")",
378
- values
379
- );
386
+ var insertRow = {};
387
+ for (var ci = 0; ci < COLS.length; ci++) insertRow[COLS[ci]] = sealed[COLS[ci]];
388
+ var insertBuilt = sql.insert(TABLE, _sqlOpts()) // allow:hand-rolled-sql bare logical name for clusterStorage rewrite
389
+ .columns(COLS)
390
+ .values(insertRow)
391
+ .toSql();
392
+ await clusterStorage.execute(insertBuilt.sql, insertBuilt.params);
380
393
 
381
394
  _emit("apikey.issue", {
382
395
  actor: _actor(issueOpts, issueOpts.ownerId),
@@ -402,10 +415,8 @@ function create(opts) {
402
415
  if (parsed.prefix !== prefix || parsed.namespace !== namespace) return null;
403
416
 
404
417
  var compositeId = _composedId(namespace, parsed.idHex);
405
- var row = await clusterStorage.executeOne(
406
- _selectAll() + " WHERE id = ?",
407
- [compositeId]
408
- );
418
+ var verifyBuilt = _selectBuilder().where("id", compositeId).toSql();
419
+ var row = await clusterStorage.executeOne(verifyBuilt.sql, verifyBuilt.params);
409
420
  if (!row) {
410
421
  if (auditFailures) {
411
422
  _emit("apikey.verify", {
@@ -472,13 +483,55 @@ function create(opts) {
472
483
  return null;
473
484
  }
474
485
 
475
- if (trackLastUsedAt && cluster.isLeader()) {
476
- try {
477
- await clusterStorage.execute(
478
- "UPDATE " + Q_TABLE + " SET lastUsedAt = ? WHERE id = ?",
479
- [nowMs, compositeId]
480
- );
481
- } catch (_e) { /* best-effort; verify success not blocked by lastUsed update */ }
486
+ // Leader-gated best-effort writes on a successful verify: bump
487
+ // lastUsedAt when tracked, and transparently re-hash the stored secret
488
+ // when its envelope no longer matches the active algorithm — the
489
+ // rotate-on-next-verify that credentialHash documents but, until now,
490
+ // no consumer wired. Primary match only: the secondary (graceful-
491
+ // rotation) slot is not the active secret, so it must not overwrite
492
+ // secretHash. The whole block is best-effort the credential already
493
+ // verified under the stored hash and stays valid even if the write
494
+ // fails; the row re-upgrades on the next leader verify.
495
+ if (cluster.isLeader()) {
496
+ var touchFields = trackLastUsedAt ? { lastUsedAt: nowMs } : null;
497
+ var didRehash = false;
498
+ if (primaryMatch && credentialHash.needsRehash(row.secretHash, { algo: hashAlgo })) {
499
+ try {
500
+ var freshSecretHash = await credentialHash.hash(parsed.secretHex, { algo: hashAlgo });
501
+ touchFields = touchFields || {};
502
+ touchFields.secretHash = freshSecretHash;
503
+ didRehash = true;
504
+ } catch (_e) { /* re-hash is best-effort; verify success stands */ }
505
+ }
506
+ if (touchFields) {
507
+ try {
508
+ var touchQb = sql.update(TABLE, _sqlOpts()) // allow:hand-rolled-sql — bare logical name for clusterStorage rewrite
509
+ .set(touchFields)
510
+ .where("id", compositeId);
511
+ if (didRehash) {
512
+ // Compare-and-swap on the exact hash we verified against: only land
513
+ // the re-hash if the stored primary is STILL that value. A verify
514
+ // that races rotate()/hardRotate (which already installed a new
515
+ // secretHash) must not clobber the rotated secret back to the old
516
+ // one — the predicate then matches no rows and the upgrade no-ops,
517
+ // which is correct because the row is already on a fresh hash.
518
+ touchQb.where("secretHash", row.secretHash);
519
+ }
520
+ var touchBuilt = touchQb.toSql();
521
+ var touchResult = await clusterStorage.execute(touchBuilt.sql, touchBuilt.params);
522
+ // Only record the migration when the CAS actually swapped a row (a
523
+ // rowCount of 0 means a concurrent rotation won the race).
524
+ if (didRehash && !(touchResult && touchResult.rowCount === 0)) {
525
+ _emitEvent("apikey.secret_rehash", 1, { namespace: namespace, algo: hashAlgo });
526
+ _emit("apikey.secret_rehash", {
527
+ actor: _actor(verifyOpts, rowOwnerId),
528
+ resource: { kind: "apikey", id: compositeId },
529
+ outcome: "success",
530
+ metadata: { algo: hashAlgo },
531
+ });
532
+ }
533
+ } catch (_e) { /* best-effort; verify success not blocked by the write */ }
534
+ }
482
535
  }
483
536
 
484
537
  if (auditSuccess) {
@@ -501,10 +554,12 @@ function create(opts) {
501
554
  if (typeof idHex !== "string" || idHex.length === 0) return false;
502
555
  var compositeId = _composedId(namespace, idHex);
503
556
  var nowMs = clock();
504
- var result = await clusterStorage.execute(
505
- "UPDATE " + Q_TABLE + " SET revokedAt = ? WHERE id = ? AND revokedAt IS NULL",
506
- [nowMs, compositeId]
507
- );
557
+ var revokeBuilt = sql.update(TABLE, _sqlOpts()) // allow:hand-rolled-sql — bare logical name for clusterStorage rewrite
558
+ .set({ revokedAt: nowMs })
559
+ .where("id", compositeId)
560
+ .whereNull("revokedAt")
561
+ .toSql();
562
+ var result = await clusterStorage.execute(revokeBuilt.sql, revokeBuilt.params);
508
563
  var changed = (result.rowCount || 0) > 0;
509
564
  if (changed) {
510
565
  _emit("apikey.revoke", {
@@ -542,10 +597,8 @@ function create(opts) {
542
597
  }
543
598
 
544
599
  var compositeId = _composedId(namespace, idHex);
545
- var existing = await clusterStorage.executeOne(
546
- _selectAll() + " WHERE id = ?",
547
- [compositeId]
548
- );
600
+ var rotateSelBuilt = _selectBuilder().where("id", compositeId).toSql();
601
+ var existing = await clusterStorage.executeOne(rotateSelBuilt.sql, rotateSelBuilt.params);
549
602
  if (!existing) {
550
603
  throw _err("NOT_FOUND", "apiKey.rotate: id '" + idHex + "' not found in namespace '" + namespace + "'");
551
604
  }
@@ -558,19 +611,27 @@ function create(opts) {
558
611
 
559
612
  if (gracePeriodMs > 0) {
560
613
  // Move current hash → secondary slot, install new hash as primary.
561
- await clusterStorage.execute(
562
- "UPDATE " + Q_TABLE + " SET secretHash = ?, " +
563
- "secondarySecretHash = ?, secondaryExpiresAt = ? WHERE id = ?",
564
- [newHash, existing.secretHash, nowMs + gracePeriodMs, compositeId]
565
- );
614
+ var graceBuilt = sql.update(TABLE, _sqlOpts()) // allow:hand-rolled-sql — bare logical name for clusterStorage rewrite
615
+ .set({
616
+ secretHash: newHash,
617
+ secondarySecretHash: existing.secretHash,
618
+ secondaryExpiresAt: nowMs + gracePeriodMs,
619
+ })
620
+ .where("id", compositeId)
621
+ .toSql();
622
+ await clusterStorage.execute(graceBuilt.sql, graceBuilt.params);
566
623
  } else {
567
624
  // Hard cutover — old secret stops working immediately. Clears
568
- // any prior secondary slot too.
569
- await clusterStorage.execute(
570
- "UPDATE " + Q_TABLE + " SET secretHash = ?, " +
571
- "secondarySecretHash = NULL, secondaryExpiresAt = NULL WHERE id = ?",
572
- [newHash, compositeId]
573
- );
625
+ // any prior secondary slot too (bound NULL via the set map).
626
+ var cutoverBuilt = sql.update(TABLE, _sqlOpts()) // allow:hand-rolled-sql — bare logical name for clusterStorage rewrite
627
+ .set({
628
+ secretHash: newHash,
629
+ secondarySecretHash: null,
630
+ secondaryExpiresAt: null,
631
+ })
632
+ .where("id", compositeId)
633
+ .toSql();
634
+ await clusterStorage.execute(cutoverBuilt.sql, cutoverBuilt.params);
574
635
  }
575
636
 
576
637
  _emit("apikey.rotate", {
@@ -598,17 +659,30 @@ function create(opts) {
598
659
  var lookup = cryptoField.lookupHash(TABLE, "ownerId", ownerId);
599
660
  if (!lookup) {
600
661
  throw _err("MISCONFIGURED",
601
- "_blamejs_api_keys schema is missing the ownerIdHash derived hash — framework misconfigured");
662
+ TABLE + " schema is missing the ownerIdHash derived hash — framework misconfigured");
602
663
  }
603
- var sql = _selectAll() + " WHERE namespace = ? AND ownerIdHash = ?";
604
- var params = [namespace, lookup.value];
605
- if (!includeRevoked) sql += " AND revokedAt IS NULL";
664
+ // Dual-read across the keyed-MAC flip: match the active digest AND the
665
+ // legacy salted-sha3 digest a pre-v0.15.0 row carries (whereIn with a
666
+ // single value emits `IN (?)`, equivalent to `=`).
667
+ var ownerHashes = [lookup.value];
668
+ if (lookup.legacyValue != null && lookup.legacyValue !== lookup.value) {
669
+ ownerHashes.push(lookup.legacyValue);
670
+ }
671
+ var listQb = _selectBuilder()
672
+ .where("namespace", namespace)
673
+ .whereIn("ownerIdHash", ownerHashes);
674
+ if (!includeRevoked) listQb.whereNull("revokedAt");
606
675
  if (!includeExpired) {
607
- sql += " AND (expiresAt IS NULL OR expiresAt >= ?)";
608
- params.push(clock());
676
+ var nowForExpiry = clock();
677
+ // (expiresAt IS NULL OR expiresAt >= now) — an OR group ANDed onto
678
+ // the chain so the optional clause keeps its own precedence.
679
+ listQb.whereGroup(function (g) {
680
+ g.whereNull("expiresAt").orWhereOp("expiresAt", ">=", nowForExpiry);
681
+ });
609
682
  }
610
- sql += " ORDER BY createdAt DESC";
611
- var rows = await clusterStorage.execute(sql, params);
683
+ listQb.orderBy("createdAt", "desc");
684
+ var listBuilt = listQb.toSql();
685
+ var rows = await clusterStorage.execute(listBuilt.sql, listBuilt.params);
612
686
  var list = (rows.rows || []).map(_scrubRecord);
613
687
  _emitEvent("apikey.list", 1, { namespace: namespace, count: list.length });
614
688
  // Read-access audit: "who listed whose keys at time T" — gated by
@@ -635,10 +709,8 @@ function create(opts) {
635
709
  async function getById(idHex, getOpts) {
636
710
  if (typeof idHex !== "string" || idHex.length === 0) return null;
637
711
  var compositeId = _composedId(namespace, idHex);
638
- var row = await clusterStorage.executeOne(
639
- _selectAll() + " WHERE id = ?",
640
- [compositeId]
641
- );
712
+ var getBuilt = _selectBuilder().where("id", compositeId).toSql();
713
+ var row = await clusterStorage.executeOne(getBuilt.sql, getBuilt.params);
642
714
  var record = _scrubRecord(row);
643
715
  _emitEvent("apikey.get", 1,
644
716
  { namespace: namespace, found: record !== null });
@@ -659,13 +731,24 @@ function create(opts) {
659
731
  // Compliance auditors expect "key X was purged at time T" — a count-
660
732
  // only audit is too coarse for forensic reconstruction. Cost is one
661
733
  // extra round-trip per purge call which runs on a schedule (not
662
- // request-rate), so the cost is irrelevant.
663
- var idRows = await clusterStorage.execute(
664
- "SELECT id FROM " + Q_TABLE + " WHERE namespace = ? AND " +
665
- "((revokedAt IS NOT NULL AND revokedAt < ?) OR " +
666
- " (expiresAt IS NOT NULL AND expiresAt < ?))",
667
- [namespace, threshold, threshold]
668
- );
734
+ // request-rate), so the cost is irrelevant. The purge predicate
735
+ // (namespace match + an OR of the two "past-threshold" age groups) is
736
+ // applied identically to the SELECT and the DELETE via _applyPurgeWhere.
737
+ function _applyPurgeWhere(qb) {
738
+ return qb
739
+ .where("namespace", namespace)
740
+ .whereGroup(function (g) {
741
+ g.whereGroup(function (a) {
742
+ a.whereNotNull("revokedAt").where("revokedAt", "<", threshold);
743
+ }).orWhereGroup(function (b2) {
744
+ b2.whereNotNull("expiresAt").where("expiresAt", "<", threshold);
745
+ });
746
+ });
747
+ }
748
+ var purgeSelBuilt = _applyPurgeWhere(
749
+ sql.select(TABLE, _sqlOpts()).columns(["id"]) // allow:hand-rolled-sql — bare logical name for clusterStorage rewrite
750
+ ).toSql();
751
+ var idRows = await clusterStorage.execute(purgeSelBuilt.sql, purgeSelBuilt.params);
669
752
  var purgedCompositeIds = (idRows.rows || []).map(function (r) { return r.id; });
670
753
 
671
754
  if (purgedCompositeIds.length === 0) {
@@ -673,12 +756,10 @@ function create(opts) {
673
756
  return 0;
674
757
  }
675
758
 
676
- var result = await clusterStorage.execute(
677
- "DELETE FROM " + Q_TABLE + " WHERE namespace = ? AND " +
678
- "((revokedAt IS NOT NULL AND revokedAt < ?) OR " +
679
- " (expiresAt IS NOT NULL AND expiresAt < ?))",
680
- [namespace, threshold, threshold]
681
- );
759
+ var purgeDelBuilt = _applyPurgeWhere(
760
+ sql.delete(TABLE, _sqlOpts()) // allow:hand-rolled-sql bare logical name for clusterStorage rewrite
761
+ ).toSql();
762
+ var result = await clusterStorage.execute(purgeDelBuilt.sql, purgeDelBuilt.params);
682
763
  var count = result.rowCount || purgedCompositeIds.length;
683
764
 
684
765
  _emit("apikey.purge", {
@@ -416,6 +416,34 @@ function conflictPath(originalPath, opts) {
416
416
  * );
417
417
  * // → { bytesWritten: 7, hash: "<sha3-512 hex>" }
418
418
  */
419
+ // Synchronous bounded sleep (writeSync is a sync primitive, so no await).
420
+ // Uses Atomics.wait on a throwaway shared buffer; falls back to a short spin
421
+ // if SharedArrayBuffer is unavailable.
422
+ function _sleepSync(ms) {
423
+ try { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); return; }
424
+ catch (_e) { /* fall through to spin */ }
425
+ var end = Date.now() + ms;
426
+ while (Date.now() < end) { /* spin */ }
427
+ }
428
+
429
+ // Atomic rename with a bounded retry on Windows-transient lock errors. On
430
+ // Windows a rename target is briefly held by AV / the search indexer / a
431
+ // file-sync client (Dropbox, OneDrive), surfacing as EPERM / EACCES / EBUSY
432
+ // even though the freshly-written temp file is fine; the lock clears in a few
433
+ // ms. POSIX rename is atomic and never hits this, so the first attempt
434
+ // succeeds there. Surface the error if it is not transient or persists.
435
+ function _renameWithRetry(from, to) {
436
+ var delays = [0, 5, 15, 40, 100];
437
+ for (var i = 0; i < delays.length; i += 1) {
438
+ if (delays[i] > 0) _sleepSync(delays[i]);
439
+ try { nodeFs.renameSync(from, to); return; }
440
+ catch (e) {
441
+ var transient = e && (e.code === "EPERM" || e.code === "EACCES" || e.code === "EBUSY");
442
+ if (!transient || i === delays.length - 1) throw e;
443
+ }
444
+ }
445
+ }
446
+
419
447
  function writeSync(filepath, data, opts) {
420
448
  opts = Object.assign({}, DEFAULTS, opts || {});
421
449
  var buf = safeBuffer.toBuffer(data, {
@@ -440,7 +468,7 @@ function writeSync(filepath, data, opts) {
440
468
  } finally {
441
469
  try { nodeFs.closeSync(fd); } catch (_e) { /* already closed? */ }
442
470
  }
443
- nodeFs.renameSync(tmpPath, filepath);
471
+ _renameWithRetry(tmpPath, filepath);
444
472
  renamed = true;
445
473
  _fsyncDir(dir);
446
474
  } finally {
@@ -35,8 +35,23 @@
35
35
  */
36
36
  var canonicalJson = require("./canonical-json");
37
37
  var C = require("./constants");
38
+ var clusterStorage = require("./cluster-storage");
39
+ var frameworkSchema = require("./framework-schema");
40
+ var sql = require("./sql");
38
41
  var { sha3Hash } = require("./crypto");
39
42
 
43
+ // b.sql opts for the chain read SQL these primitives compose. The reader
44
+ // (queryAllAsync / queryOneAsync, normally clusterStorage.execute*) rewrites
45
+ // the bare framework table name + translates `?` placeholders at dispatch,
46
+ // but the IDENTIFIER QUOTING + ORDER-BY column reference are baked into the
47
+ // b.sql output at build time — so they must carry the ACTIVE backend dialect
48
+ // (clusterStorage.dialect() — "sqlite" single-node, "postgres" | "mysql" in
49
+ // cluster mode). Defaulting to "sqlite" double-quotes `monotonicCounter`,
50
+ // which MySQL reads as a STRING LITERAL: `ORDER BY '<constant>'` imposes no
51
+ // ordering, so verifyChain walks the rows out of order and falsely reports a
52
+ // chain break. Backtick-quoting on MySQL makes it an identifier again.
53
+ function _sqlOpts() { return { dialect: clusterStorage.dialect() }; }
54
+
40
55
  // SHA3-512 outputs 64 bytes; routed through C.BYTES so the file's byte
41
56
  // arithmetic has one source of truth. Hex-encoded width is twice the
42
57
  // byte count.
@@ -140,11 +155,20 @@ function computeRowHash(prevHash, rowFields, nonce) {
140
155
  * // → { prevHash: "<128-char hex>", counter: 4217 }
141
156
  */
142
157
  async function getChainTip(queryOneAsync, tableName) {
143
- var row = await queryOneAsync(
144
- 'SELECT rowHash, monotonicCounter FROM "' + tableName + '" ' +
145
- "ORDER BY monotonicCounter DESC LIMIT 1"
146
- );
158
+ // Emit a BARE logical table name — the operator-supplied reader routes
159
+ // through clusterStorage, which rewrites bare framework names to the
160
+ // configured-prefix form and placeholderizes. b.sql quotes the camelCase
161
+ // columns + runs the output validator.
162
+ var built = sql.select(tableName, _sqlOpts())
163
+ .columns(["rowHash", "monotonicCounter"])
164
+ .orderBy("monotonicCounter", "desc")
165
+ .limit(1)
166
+ .toSql();
167
+ var row = await queryOneAsync(built.sql, built.params);
147
168
  if (!row) return { prevHash: ZERO_HASH, counter: 0 };
169
+ // Normalize driver shape (Postgres returns BIGINT monotonicCounter as a
170
+ // string) so callers get a numeric counter on every backend.
171
+ frameworkSchema.coerceRow(row);
148
172
  return { prevHash: row.rowHash, counter: row.monotonicCounter };
149
173
  }
150
174
 
@@ -186,10 +210,15 @@ async function verifyChain(queryAllAsync, tableName, opts) {
186
210
  if (tableName === "audit_log") {
187
211
  var anchor;
188
212
  try {
189
- anchor = await queryAllAsync(
190
- "SELECT lastPurgedCounter, lastPurgedRowHash FROM _blamejs_audit_purge_anchor " +
191
- "WHERE scope = 'audit'"
192
- );
213
+ // External-only table whose LOGICAL name IS the `_blamejs_`-prefixed
214
+ // name (self-mapped in LOCAL_TO_EXTERNAL), passed bare so the reader's
215
+ // clusterStorage rewrites it; the 'audit' scope binds as a ? param.
216
+ // allow:hand-rolled-sql — bare logical key.
217
+ var anchorBuilt = sql.select("_blamejs_audit_purge_anchor", _sqlOpts()) // allow:hand-rolled-sql
218
+ .columns(["lastPurgedCounter", "lastPurgedRowHash"])
219
+ .where("scope", "audit")
220
+ .toSql();
221
+ anchor = await queryAllAsync(anchorBuilt.sql, anchorBuilt.params);
193
222
  } catch (_e) {
194
223
  // Anchor table may not exist on a deployment that has never been
195
224
  // through a purge. Treat as no anchor.
@@ -201,9 +230,16 @@ async function verifyChain(queryAllAsync, tableName, opts) {
201
230
  }
202
231
  }
203
232
 
204
- var rows = await queryAllAsync(
205
- 'SELECT * FROM "' + tableName + '" ORDER BY monotonicCounter ASC'
206
- );
233
+ var rowsBuilt = sql.select(tableName, _sqlOpts())
234
+ .orderBy("monotonicCounter", "asc")
235
+ .toSql();
236
+ var rows = await queryAllAsync(rowsBuilt.sql, rowsBuilt.params);
237
+ // Normalize driver shape before hashing: node-postgres returns BIGINT
238
+ // columns (recordedAt / monotonicCounter) as strings, which would hash
239
+ // differently from the numbers the chain-writer signed — the chain only
240
+ // verified on SQLite without this. coerceRow makes the recompute
241
+ // type-stable across backends (no-op on already-numeric SQLite rows).
242
+ rows = frameworkSchema.coerceRows(rows);
207
243
  if (skipBeforeCounter > 0) {
208
244
  rows = rows.filter(function (r) {
209
245
  return Number(r.monotonicCounter) > skipBeforeCounter;