@blamejs/core 0.14.22 → 0.14.24
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 +4 -0
- package/README.md +2 -2
- package/lib/compliance.js +37 -0
- package/lib/crypto-field.js +111 -5
- package/lib/db-query.js +123 -0
- package/lib/external-db-migrate.js +19 -7
- package/lib/external-db.js +508 -20
- package/lib/framework-error.js +6 -0
- package/lib/mail-auth.js +236 -0
- package/lib/mail-dkim.js +1 -0
- package/lib/mail-server-mx.js +276 -7
- package/lib/mail.js +8 -4
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.14.x
|
|
10
10
|
|
|
11
|
+
- v0.14.24 (2026-06-05) — **Per-row data residency enforced at the write boundary, and the long-advertised column-residency gate is wired.** Rows can now carry their own residency tag, and the database write paths enforce it: under a cross-border regulated posture (GDPR / UK-GDPR / DPDP / PIPL / LGPD / APPI / PDPA) a row tagged for one region is refused before it lands on a backend in another. `b.cryptoField.declarePerRowResidency` declares which column carries the tag; local SQLite writes check it against the deployment's `dataResidency` region set, and `b.externalDb.query` / `transaction` DML to a residency-tagged backend takes the tag per call via `rowResidencyTag`. The column-scoped gate (`assertColumnResidency`) — exported and documented since v0.7.27 but never composed into any write path — is now enforced on the same boundary. Unregulated deployments are unaffected: every gate passes with an advisory audit instead of a refusal, so operators can observe before adopting a posture. Note: v0.14.23 was tagged but not published to npm (its publish run timed out in validation); its changes — the MX DATA-phase SPF/DKIM/DMARC gate and the inbound mail authentication pipeline — are included in this package, and the publish-validation timeout is fixed here. **Added:** *`b.cryptoField.declarePerRowResidency` / `getPerRowResidency` — row-scoped residency tags* — Declares the plaintext column carrying each row's residency tag plus the whitelist of valid tag values (`{ residencyColumn, allowedTags }`). On INSERT to a declared table the tag is required and must be in `allowedTags`; rows tagged `global` or `unrestricted` pass any backend. The declaration registry mirrors `declareColumnResidency`, and `clearResidencyForTest` now clears both. The tag comes from application logic (the user's declared region) — the framework never infers it from request metadata. · *Local write gate on `b.db.from(...).insertOne` / `update`* — Runs on the plaintext row before sealing, so the tag column stays inspectable when sibling columns seal. Under a cross-border regulated posture, a row whose tag falls outside the deployment's region set (`dataResidency.region` plus `allowedStorageRegions`) is refused with `db-query/row-residency-local-mismatch`; a missing or out-of-whitelist tag refuses with `db-query/row-residency-tag-missing` / `-tag-invalid` regardless of posture. UPDATEs gate when the change set touches the residency column — an update that doesn't move residency is not a transfer. Refusals are typed `DbQueryError`s (new error class) and land in the audit chain (`db.residency.gate.rejected`); unregulated postures emit `db.residency.gate.advisory` and pass. · *External write gate — `rowResidencyTag` on `b.externalDb.query` / `transaction`* — External-db takes raw SQL, not row objects, so the row's tag travels as `opts.rowResidencyTag` (validated at transaction entry too). Under a regulated posture, a write to a residency-tagged backend requires the tag (`RESIDENCY_GATE_REQUIRED`) and refuses a mismatch (`RESIDENCY_TAG_MISMATCH`) before the statement reaches the wire. The gate classifies by what a statement does, not its leading keyword: it resolves the effective verb behind a `WITH` (CTE) or `EXPLAIN ANALYZE` prefix and treats `INSERT`/`UPDATE`/`DELETE`/`MERGE`/`REPLACE`, `CALL`/`EXECUTE`/`DO`, and `COPY ... FROM` as writes — only recognized pure reads (`SELECT`, `SHOW`/`DESCRIBE`/`PRAGMA`, a `COPY ... TO` export, plan-only `EXPLAIN`, and session/transaction statements) pass untagged. A statement whose class can't be resolved, or a multi-statement string that hides a trailing write behind a harmless prefix, is refused (`STATEMENT_UNRESOLVED_REFUSED` / `MULTI_STATEMENT_REFUSED`). Inside `transaction()`, the transaction-level tag applies to every statement and a per-call override on `tx.query(sql, params, opts)` wins for that statement; a refusal rolls the transaction back. Replica reads that carry a tag refuse routing to an incompatible replica (`REPLICA_RESIDENCY_INCOMPATIBLE`) unless the replica is configured `allowCrossBorder: true`, which is audited (`db.residency.replica.cross_border` at read time, `db.residency.replica.cross_border_allowed` at config time). Unrestricted backends are not gated, and the migration runner's own tracking writes are region-neutral so migrations run unaffected on a residency-tagged backend. · *`b.compliance.isCrossBorderRegulated` — shared posture vocabulary* — The cross-border regulated posture set (gdpr, uk-gdpr, dpdp, pipl-cn, lgpd-br, appi-jp, pdpa-sg) now lives on `b.compliance` (`CROSS_BORDER_REGULATED_POSTURES` + the membership helper), one source of truth shared by the local gate, the external gate, and the existing replica-topology boot check. **Fixed:** *`assertColumnResidency` is now actually enforced* — `b.cryptoField.declareColumnResidency` / `assertColumnResidency` shipped in v0.7.27 documenting a write-time gate, but no write path ever called the assertion — column tags were recorded and never checked. The local write paths now run it against the deployment region: a mismatch refuses with `db-query/column-residency-mismatch` under a regulated posture and emits an advisory otherwise. Operators tag columns with the region value their `dataResidency` declares. · *Cross-border-allowed replica audit event is now recorded* — The config-time audit event for a consciously-permitted cross-border replica used a malformed action name that the audit validator silently dropped, so a compliance reviewer saw no record of the `allowCrossBorder` decision in the audit chain. It now emits as `db.residency.replica.cross_border_allowed` and lands like every other audit row. · *Publish-validation timeout that blocked the v0.14.23 npm release* — The v0.14.23 publish run timed out in release validation — the pattern-catalog self-test crossed a per-file watchdog budget as the codebase grew, and the same self-test, which scans the whole library and runs far longer on the slower macOS CI runner, hit the same wall on this package's first CI run. The validation budgets are corrected so a genuinely stuck file still fails fast while the catalog completes. v0.14.23 exists as a signed tag; npm goes 0.14.22 → 0.14.24 carrying both releases' changes. **Detectors:** *db-query-write-without-residency-gate* — Every local write method that seals a row must run the residency gates on the plaintext first — a future write path (upsert, bulk) inherits the requirement automatically. · *Residency-gates-wired catalog check* — The pattern catalog now pins the wiring itself: the local write methods call the gate, the external query/transaction paths call theirs, and `assertColumnResidency` has a real caller — the declared-but-never-enforced class cannot silently reappear. **Migration:** *No action required unless you adopt the gates* — Tables without a residency declaration, deployments without a `dataResidency` region, and unregulated postures behave exactly as before (advisory audit events at most). Adopting: declare per-row residency on mixed-region tables, pass `rowResidencyTag` on external DML, and set a cross-border posture (`b.compliance.set("gdpr")`) to turn refusals on.
|
|
12
|
+
|
|
13
|
+
- v0.14.23 (2026-06-05) — **Inbound mail authentication: a DATA-phase SPF/DKIM/DMARC gate on the MX listener and the one-call receiver pipeline.** The MX listener can now refuse policy-failing mail at the wire instead of asking operators to verify after delivery. `b.mail.inbound.verify` is the receiver pipeline — SPF on the envelope identity, DKIM on the message bytes, DMARC policy + alignment on the From-header domain, and the RFC 8601 Authentication-Results header — and the listener's new `guardEnvelope` opt runs it at DATA completion: when the sender's published DMARC policy says reject, the message is refused with 550 before it reaches the agent handoff; accepted mail carries the verdict to the agent and gains the receiver's Authentication-Results header, with any sender-forged header carrying the receiver's name stripped first. Monitor mode annotates without refusing, so operators can observe verdicts on live traffic before enforcing. **Added:** *`b.mail.inbound.verify` — one-call receiver authentication pipeline* — Runs the inbound authentication set on a full RFC 5322 message (string or Buffer): SPF (RFC 7208) on the MAIL FROM identity with HELO fallback for the null reverse-path, DKIM (RFC 6376) on every signature, From-header extraction, DMARC (RFC 7489 / DMARCbis) policy discovery + alignment, and — when an `authservId` is supplied — the RFC 8601 Authentication-Results header. Returns `{ spf, dkim, from, dmarc, authResults }` with `dmarc.recommendedAction` carrying the policy disposition. From-header discipline per RFC 7489 §6.6.1: a message with zero From fields, several From fields, or several author addresses in one field returns `permerror` with a reject recommendation instead of picking one of the authors — the header-duplication shape behind the CVE-2024-7208 / CVE-2024-7209 hosted-relay spoofing class. The author parser is quote-aware: a literal `<` or comma inside a quoted display-name (`"Doe, John" <j@example.com>`) is one author, not two. A fail verdict computed while SPF or DKIM returned temperror surfaces as temperror (RFC 7489 §6.6.2) — the transiently-failed lookup could have produced the aligned pass, so the caller defers and the sender retries instead of being permanently refused during a DNS blip. · *MX listener `guardEnvelope` — the DATA-phase authentication gate* — `b.mail.server.mx.create({ guardEnvelope: true })` (or a config object: `mode`, `onTemperror`, `authservId`, `dnsLookup`, `maxSignatures`, `clockSkewMs`, `minRsaBits`, `timeoutMs`) runs the pipeline after the SIZE check and before the agent handoff. Enforce mode refuses with 550 5.7.26 (RFC 7372 — multiple authentication checks failed) when DMARC evaluates to fail under a reject policy, 550 5.7.1 on the multi-From shape, and 451 4.7.0 on DNS temperror or pipeline timeout — `onTemperror: "accept"` admits unauthenticated mail instead when availability wins. The whole pipeline runs under a wall-clock ceiling (`timeoutMs`, default 20s) so a message stuffed with signatures pointing at slow resolvers cannot pin the connection slot. Accepted messages reach the agent handoff with the verdict as `auth` (including `quarantine: true` when the sender's policy says quarantine — the agent owns foldering) and gain the receiver's Authentication-Results header; any sender-attached header forging the receiver's authserv-id is stripped first (RFC 8601 §5). Monitor mode (the default under the permissive profile) annotates and audits without refusing. New audit events: `mail.server.mx.envelope_verdict` and `mail.server.mx.envelope_error`. **Detectors:** *ar-header-prepend-without-forged-strip* — Any code path that prepends an emitted Authentication-Results header must first strip sender-attached instances claiming the same authserv-id (RFC 8601 §5) — a forged pre-attached verdict would otherwise shadow the computed one for downstream consumers. **Migration:** *No action required; the gate is opt-in* — `guardEnvelope` is off unless wired — an unwired gate is skipped like the sibling HELO / RBL / greylist gates, and existing listeners behave exactly as before. The agent handoff context gains an `auth` field (null when the gate is off). Start with `guardEnvelope: { mode: "monitor" }` to observe verdicts on live traffic before switching to enforce.
|
|
14
|
+
|
|
11
15
|
- v0.14.22 (2026-06-04) — **RFC 9101 signed request objects: a JAR request-object builder, a classical JWS signer for external interop, and pushed authorization requests that carry `request=`.** The framework can now mint JWT-Secured Authorization Requests, completing the JAR surface whose verify side (`b.auth.jar.parse`) shipped in v0.12.31 with the builder documented as waiting on a classical signer. `b.auth.jws.sign` is that signer — a compact-JWS producer for RS / PS / ES / EdDSA keys that exists strictly for interop with external ecosystems (authorization servers and relying parties that require classical algorithms); the framework's own tokens stay on the PQC-first signer. `b.auth.jar.build` mints the RFC 9101 request object on top of it, and `pushAuthorizationRequest` composes both so a pushed authorization request can carry the signed `request=` parameter — the FAPI 2.0 message-signing client shape. The OAuth client-attestation builder now composes the same promoted signer internally, with identical wire output. **Added:** *`b.auth.jar.build` — RFC 9101 request-object builder* — Mints the JWT-Secured Authorization Request: header `typ: oauth-authz-req+jwt` (RFC 9101 §10.8), `iss` = the client_id and `aud` = the authorization server (§5), `response_type` and `client_id` required as claims (§4), and every authorization parameter carried as a claim. A params object containing `request` or `request_uri` is refused (§4 forbids nesting), reserved-claim collisions are refused, and a `params.client_id` that disagrees with `opts.clientId` is refused. The JWT carries a short `exp` (default 5 minutes, `expiresInMs`-overridable), `nbf`, and a random `jti` for FAPI 2.0 message signing. The signing algorithm derives from the supplied key; `none` is impossible. Round-trips against the existing `b.auth.jar.parse` verifier. · *`b.auth.jws.sign` — classical compact-JWS signer for external interop* — Signs a compact JWS with an RS / PS / ES (P-256/P-384/P-521) / EdDSA key, deriving the algorithm from the key per RFC 7518 §3.1 and refusing `none`, HMAC, and algorithm/key mismatches; a caller-supplied `header.alg` cannot override the derived algorithm (algorithm-substitution closed). This primitive exists for interop with external ecosystems that require classical JOSE — JAR request objects, attestation JWTs, and similar cross-vendor surfaces. It is never the framework-internal token default: `b.auth.jwt` remains the PQC-first signer for the framework's own tokens. · *Pushed authorization requests can carry a signed request object (RFC 9126 §3)* — `pushAuthorizationRequest` accepts a `signedRequestObject` option (`{ key, alg?, kid?, audience?, expiresInMs? }`). When present, the authorization parameters are minted into a JAR request object and the PAR body carries `request=<jwt>` plus only the client-authentication material RFC 9126 allows alongside it; the bare authorization parameters are not duplicated in the form. Absent, the existing plain-form path sends the same key/value set as before. · *`validateOpts.assignOwnEnumerable` — shared prototype-safe claim merge* — Consolidates the own-enumerable key merge with prototype-pollution and reserved-key guards that the request-object builder, the classical signer, and the client-attestation builder all need. Existing call sites compose it; behavior is unchanged. **Changed:** *OAuth client-attestation signing composes the promoted classical signer* — The attestation builder's private JWS assembly moved to the shared `b.auth.jws.sign` internals. Wire output is identical — same headers, claim order, algorithm selection, and accepted-algorithm set — and the `auth-oauth/attestation-*` error codes are preserved, so operators routing alerts on those codes see no change. · *Object key-copy sites compose the prototype-safe merge* — The long-running-operation status reader, the deny-path response-header merge, the HTTP client's cross-origin redirect header strip, and the trace-log logger wrapper now copy keys through `validateOpts.assignOwnEnumerable` instead of raw bracket-assign loops, so a `__proto__`/`constructor`/`prototype` key in the source object can never graft onto the copy. Behavior is otherwise unchanged. **Removed:** *Maintainer planning note removed from the repository* — `memory/specs/node-26-map-getorinsert-migration.md` — a maintainer-local planning note that had been committed since v0.11.2 — is gone from the repository (it was never part of the npm package). The Node 26 detector allowlists in the pattern catalog now carry their per-site annotations standalone, and `SECURITY.md` / `.pinact.yaml` no longer reference maintainer-local note paths. **Security:** *`jar.parse` returns prototype-safe authorization params (CWE-1321)* — A verified request object whose payload carried a `__proto__` claim (JSON.parse materializes it as an own key) previously grafted that claim's value onto the returned `params` object's prototype chain — a signature from a registered-but-malicious client was sufficient. The params object is now built through the prototype-safe merge; `__proto__`/`constructor`/`prototype` claim names are inert and are not copied. · *`jws.sign` refuses `b64` and `crit` protected-header members* — RFC 7797 `b64: false` changes the JWS signing input (the payload is signed raw, not base64url-encoded) and RFC 7515 §4.1.11 `crit` promises the producer implements every extension it names. The signer always base64url-encodes the payload and implements no header extensions, so passing either member through minted a JWS whose header advertised semantics its signature was not computed under — a compliant verifier derives a different signing input or refuses the critical header. Both members are now refused with `auth-jwt-external/sign-unsupported-header`; unencoded-payload support would land as an explicit feature, not a header pass-through. **Detectors:** *raw-key-copy-loop-bypasses-assign-own-enumerable* — Refuses raw `out[keys[i]] = src[keys[i]]` bracket-assign copy loops in `lib/` — the shape behind the `jar.parse` finding. Key-copy sites compose `validateOpts.assignOwnEnumerable`; the two genuinely-different bodies (audit-chain hash canonicalization, schema-shape transforms) carry allowlist entries with structural reasons. · *jose-header-passthrough-without-b64-crit-refusal* — Any caller-supplied JOSE protected-header pass-through must name-refuse `b64`/`crit` before signing. · *no-tracked-internal-notes gate* — The pattern catalog now refuses any tracked file under `memory/`, `notes/`, or `.scratch*` paths at commit time. **Migration:** *No action required; everything is additive* — The JAR builder, the classical signer, the PAR `signedRequestObject` option, and the shared merge helper are new surface. Existing `jar.parse` callers, attestation flows, and plain PAR requests behave exactly as before.
|
|
12
16
|
|
|
13
17
|
- v0.14.21 (2026-06-04) — **SCIM Bulk forward references, an atomic api-encrypt replay gate, OID4VCI `x5c` proofs, HEAD responses without bodies, and a sweep that makes every accepted option do what its documentation says.** This release closes correctness and conformance gaps across recently shipped standards surfaces, plus a framework-wide sweep for options that were accepted but never read. SCIM `/Bulk` resolves `bulkId` cross-references regardless of operation order; the `apiEncrypt` middleware closes a concurrent-replay window on multi-replica session stores and validates its numeric options at boot; OID4VCI accepts `x5c` holder-key binding; `problemDetails` spreads its documented `extensions` object as RFC 9457 sibling members; `openapiServe` / `asyncapiServe` answer HEAD without a body; and a batch of entry points now throw on mistyped numeric options instead of silently defaulting. Options whose documented behavior was never implemented are now wired; options that could never do anything are removed and refuse as unknown. **Removed:** *Options that could never do anything now refuse as unknown* — The sweep removed accepted-but-impossible option keys: the `securityTxt` / `traceLogCorrelation` / tracer / TLS-RPT `audit` keys (these surfaces emit no audit rows), TLS-RPT `reportingMta` (not an RFC 8460 report field), `dsr.create` `observability` and create-time `verifyContext` (the per-call `process()` option of the same name is unchanged), `breakGlass.init` `now` (a single init-time timestamp cannot coherently override later time reads), WCAG `checkAll`, mail-deploy `compliance`, and bucket-ops `ca` (TLS trust is owned by the PQC agent — use `NODE_EXTRA_CA_CERTS` or `opts.agent`). Passing one of these now throws the standard unknown-option error. **Fixed:** *SCIM `/Bulk` resolves `bulkId` cross-references regardless of operation order (RFC 7644 §3.7.2)* — Forward references (an operation referencing a resource a later operation creates — the shape Okta and Entra emit) now execute in dependency order; circular references are refused with status 409; a reference to an undeclared `bulkId`, or to an operation that failed, fails that operation with `invalidValue`. References are resolved on BOTH surfaces the spec allows: operation data (`"value": "bulkId:u1"`) and the operation path (`PATCH /Groups/bulkId:g1` targeting a group created in the same request) — path references order, substitute, and fail exactly like data references. Previously an unresolvable reference passed the literal `bulkId:<id>` token through to your resource adapter as if it were a real id. The Bulk response keeps results in original request order, and `failOnErrors` still short-circuits. · *OID4VCI `x5c` holder-key binding implemented (RFC 7515 §4.1.6; OID4VCI §8.2.1.1)* — The proof-JWT verifier named `x5c` as a valid holder-key binding in its own error message but always refused `x5c`-only proofs. The certificate chain is now shape-validated (standard base64 DER, leaf first), the leaf certificate's public key becomes the holder key at the same self-asserted trust level as an inline `jwk`, and a new optional `validateX5c(chainDerBuffers, header)` hook lets the issuer enforce chain trust (PKI anchoring, EKU checks, attestation-CA allowlists) before the key is accepted. · *OID4VCI expired `c_nonce` refuses with a typed error* — A wallet whose access token outlived the shorter `c_nonce` TTL hit an untyped `TypeError` from the nonce comparison; issuance now refuses with `auth-oid4vci/c-nonce-expired` so handlers keying on typed codes respond correctly. The refusal direction is unchanged — no credential was ever minted on this path. · *OID4VP DCQL numeric claim-path segments must be non-negative integers (OpenID4VP 1.0 §7.1.1)* — A query carrying `-1`, `1.5`, `NaN`, or `Infinity` as an array-index segment previously validated and then silently never matched; it now throws at build time, surfacing the malformed query to the verifier author instead of degrading to a silent non-match. · *`problemDetails` `extensions` spread as sibling members (RFC 9457 §3.2)* — `send` and `create` documented an `extensions` object as the way to attach extension members, but emitted it as a literal nested `extensions` member instead. The keys now land as top-level siblings; reserved fields (`type` / `title` / `status` / `detail` / `instance`) cannot be overridden by an extension key, prototype-pollution-shaped keys are dropped, and a direct top-level key wins on collision. · *`cspReport` honors `audit: false`* — The documented audit knob was accepted but never read; the `csp.violation` audit row fired unconditionally for every report. `audit: false` now suppresses the row while reports are still normalized and delivered to `onReport`. The default (audit on) is unchanged, and `maxBytes` now throws at config time on a non-positive-integer value instead of silently reverting to 64 KiB. · *`openapiServe` / `asyncapiServe` HEAD responses carry no body (RFC 9110 §9.3.2)* — Both middlewares advertised GET/HEAD but answered HEAD with the full JSON / YAML document as a body. HEAD now returns the GET headers (including `Content-Length`) with an empty body, matching the rest of the framework's document-serving middlewares. · *Config-time numeric options throw on bad input across entry points* — A mistyped numeric option now throws at `create()` instead of silently becoming the default or garbage downstream: `scimServer` `maxPageSize` (a non-number propagated `NaN` into your `impl.list({ count })` and `ServiceProviderConfig`), `mail.send.deliver` `retry.maxAttempts` / `timeouts.mxLookupMs` / `timeouts.perHostMs`, the `redis` client `db` / `connectTimeoutMs` / `commandTimeoutMs` / `maxReconnectAttempts` (a non-numeric value made the reconnect-cap check false and silently disabled the bound entirely), `pubsub` cluster `pollIntervalMs` / `retentionMs` / `pruneEveryMs`, and SQS queue `visibilityTimeoutSec` / `waitTimeSec` (`0` short-polling stays valid). · *Accepted-but-unread options now do what their documentation says* — The db-backed `config` reloader's `audit` knob gates its `config.reload.*` rows; `honeytoken` honors the documented injectable audit sink (`{ audit: yourSink }`) instead of always emitting to the global sink; `dora`'s `observability` knob gates its report counter, and that counter now actually emits (it previously called a method the observability module doesn't export, and the failure was swallowed); flag evaluation-context `tenantKey` sets the tenant axis; the WCAG `aria` / `forms` / `tables` sub-scanners stamp `scopeUrl` on every finding so direct callers can correlate findings to a source document; `safeRedirect`'s documented `base` lets a same-origin absolute URL pass without an explicit allowlist (cross-origin still refused); and object-store bucket operations honor a per-call `actor` override on audit rows. **Security:** *api-encrypt concurrent-replay window closed (CWE-367)* — On a multi-replica session store, two concurrent requests carrying the same valid counter could both pass the monotonic replay check and execute twice — an attacker who captured one encrypted request could replay it concurrently and have a non-idempotent route run twice. The per-session path now claims each `(session, counter)` tuple through the same atomic nonce store the bootstrap path uses; exactly one concurrent request wins and the loser is refused with the standard rejection shape. The claim lives until the session expires (not just the staleness window), so a failed best-effort session write cannot re-open the tuple for late replay. The bootstrap response counter is also persisted correctly on serializing session stores, fixing a response-replay false positive on the second request of a session. · *api-encrypt envelope metadata is authenticated (AEAD-bound)* — The envelope's plaintext fields — `_ts`, `_nonce`, `_sid`, `_ctr` — were not bound into the ciphertext, so a captured request could be replayed past the staleness window with a rewritten `_ts`, and a captured response could be replayed to the client under a bumped `_ctr` (the client's monotonic check reads the plaintext field). Every request and response envelope now binds its metadata as AEAD associated data on both protocol halves; any rewrite fails authenticated decryption and is refused (server: standard rejection; client: typed `CLIENT_RESPONSE_TAMPERED`). The client also advances its response counter only after authenticated decryption, so a refused forgery cannot poison the monotonic check and block subsequent genuine responses. · *api-encrypt numeric options validated at boot* — A mistyped `replayWindowMs` (for example the string `"5m"`) made the timestamp-staleness comparison always false and silently disabled that replay defense. `replayWindowMs`, `maxDecryptedBytes`, and `pruneIntervalMs` now throw at config time across `create`, `client`, and `httpClient.encrypted`. **Detectors:** *Three new codebase-pattern gates* — An option key accepted by a validation allowlist must be read by the file that accepts it (an accepted-but-never-read key is an advertised knob with no implementation); entry-point numeric options must validate rather than coerce-or-default (`Number(opts.x) || DEFAULT` swallows exactly the typo the config-time tier exists to surface); and a dispatcher that admits HEAD must suppress the response body somewhere in the same file. **Migration:** *Delete removed option keys; everything else is a behavioral fix or additive* — If you pass one of the removed keys listed under Removed, delete it — it did nothing before and now throws the standard unknown-option error. Callers passing valid options see conforming behavior with no code change; the new `validateX5c` hook and the per-finding `scopeUrl` stamp are additive. · *apiEncrypt middleware and client must upgrade together* — Binding the envelope metadata into the AEAD changes what the ciphertext authenticates, so a pre-0.14.21 client cannot talk to a 0.14.21 middleware or vice versa — mixed-version peers fail authenticated decryption and are refused. Both halves ship in this package; a single service upgrading normally is unaffected. If separate services pin different framework versions and speak apiEncrypt to each other, upgrade them together.
|
package/README.md
CHANGED
|
@@ -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 data residency tagging + 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.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 (`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`)
|
|
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`)
|
|
@@ -168,7 +168,7 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
|
|
|
168
168
|
- **Mail (outbound)** — multipart + attachments + DKIM + calendar invites; bounce intake (`b.mail`, `b.mailBounce`)
|
|
169
169
|
- **Mail (outbound delivery)** — turnkey MX-lookup → MTA-STS-fetch → DANE-TLSA → REQUIRETLS handshake → SMTP wire layer → RFC 3464 DSN-on-permanent-failure → deferred-retry scheduling, all wired once (`b.mail.send.deliver`)
|
|
170
170
|
- **Mail (inbound auth)** — SPF / DMARC / ARC verify + ARC chain signing for relays, plus DMARC aggregate (RUA) + forensic (RUF) report parsing (`b.mail.spf`, `b.mail.dmarc`, `b.mail.arc`)
|
|
171
|
-
- **Mail server listeners** — RFC 5321 MX inbound with connection-level gate cascade (HELO identity / DNS blocklist / greylisting) (`b.mail.server.mx`), RFC 6409 submission with SASL + identity-binding (`b.mail.server.submission`), RFC 9051 IMAP4rev2 with CONDSTORE / QRESYNC / NOTIFY / METADATA / CATENATE (`b.mail.server.imap`), RFC 8620 + RFC 8621 JMAP Core + Mail over HTTP/SSE/WebSocket (`b.mail.server.jmap`), POP3 (`b.mail.server.pop3`), ManageSieve (`b.mail.server.managesieve`)
|
|
171
|
+
- **Mail server listeners** — RFC 5321 MX inbound with connection-level gate cascade (HELO identity / DNS blocklist / greylisting) and a DATA-phase SPF/DKIM/DMARC gate that refuses policy-failing mail before storage (`b.mail.server.mx`), RFC 6409 submission with SASL + identity-binding (`b.mail.server.submission`), RFC 9051 IMAP4rev2 with CONDSTORE / QRESYNC / NOTIFY / METADATA / CATENATE (`b.mail.server.imap`), RFC 8620 + RFC 8621 JMAP Core + Mail over HTTP/SSE/WebSocket (`b.mail.server.jmap`), POP3 (`b.mail.server.pop3`), ManageSieve (`b.mail.server.managesieve`)
|
|
172
172
|
- **JMAP EmailSubmission reference** — composes `b.mail.send.deliver` to land the RFC 8621 §7.5 surface end-to-end (`b.mail.server.jmap.emailSubmissionSetHandler`)
|
|
173
173
|
- **Mail crypto** — PQC-first S/MIME via CMS (`b.mail.crypto.cms`) + OpenPGP encrypt/decrypt + WKD key discovery with IDN-homograph defense (`b.mail.crypto.pgp`)
|
|
174
174
|
- **Mail-stack agent** — multi-threaded worker pool + queue dispatch + sealed mail-store backed by SQLite FTS5 (`b.mail.agent`, `b.mailStore`)
|
package/lib/compliance.js
CHANGED
|
@@ -1580,9 +1580,46 @@ function fipsMode(enable) {
|
|
|
1580
1580
|
return STATE.fipsMode;
|
|
1581
1581
|
}
|
|
1582
1582
|
|
|
1583
|
+
// Postures whose jurisdictions restrict cross-border data transfer
|
|
1584
|
+
// (GDPR Art 44-46 / UK-GDPR / DPDP §16 / PIPL Art 38 / LGPD Art 33 /
|
|
1585
|
+
// APPI Art 28 / PDPA §26). The residency write gates (db-query local,
|
|
1586
|
+
// external-db backend/replica) refuse mismatched writes under these;
|
|
1587
|
+
// other postures observe-and-audit only.
|
|
1588
|
+
var CROSS_BORDER_REGULATED_POSTURES = Object.freeze([
|
|
1589
|
+
"gdpr", "uk-gdpr", "dpdp", "pipl-cn", "lgpd-br", "appi-jp", "pdpa-sg",
|
|
1590
|
+
]);
|
|
1591
|
+
|
|
1592
|
+
/**
|
|
1593
|
+
* @primitive b.compliance.isCrossBorderRegulated
|
|
1594
|
+
* @signature b.compliance.isCrossBorderRegulated(posture)
|
|
1595
|
+
* @since 0.14.24
|
|
1596
|
+
* @compliance gdpr
|
|
1597
|
+
* @related b.compliance.current, b.cryptoField.declarePerRowResidency
|
|
1598
|
+
*
|
|
1599
|
+
* Returns true when `posture` is one of the cross-border regulated
|
|
1600
|
+
* postures (gdpr / uk-gdpr / dpdp / pipl-cn / lgpd-br / appi-jp /
|
|
1601
|
+
* pdpa-sg) — the jurisdictions whose transfer restrictions flip the
|
|
1602
|
+
* data-residency write gates from advisory to refusing. The set
|
|
1603
|
+
* itself is exported as `CROSS_BORDER_REGULATED_POSTURES`; this
|
|
1604
|
+
* helper is the membership test the local (`b.db.from`) and external
|
|
1605
|
+
* (`b.externalDb.query`) gates share. Non-string and unknown postures
|
|
1606
|
+
* return false.
|
|
1607
|
+
*
|
|
1608
|
+
* @example
|
|
1609
|
+
* b.compliance.isCrossBorderRegulated("gdpr"); // → true
|
|
1610
|
+
* b.compliance.isCrossBorderRegulated("soc2"); // → false
|
|
1611
|
+
* b.compliance.isCrossBorderRegulated(null); // → false
|
|
1612
|
+
*/
|
|
1613
|
+
function isCrossBorderRegulated(posture) {
|
|
1614
|
+
if (typeof posture !== "string" || posture.length === 0) return false;
|
|
1615
|
+
return CROSS_BORDER_REGULATED_POSTURES.indexOf(posture) !== -1;
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1583
1618
|
module.exports = {
|
|
1584
1619
|
set: set,
|
|
1585
1620
|
current: current,
|
|
1621
|
+
isCrossBorderRegulated: isCrossBorderRegulated,
|
|
1622
|
+
CROSS_BORDER_REGULATED_POSTURES: CROSS_BORDER_REGULATED_POSTURES,
|
|
1586
1623
|
assert: assert,
|
|
1587
1624
|
clear: clear,
|
|
1588
1625
|
describe: describe,
|
package/lib/crypto-field.js
CHANGED
|
@@ -132,6 +132,11 @@ var schemas = Object.create(null);
|
|
|
132
132
|
//
|
|
133
133
|
// { tableName: { columnName: "eu" | "us" | "global" | <tag> } }
|
|
134
134
|
var columnResidency = Object.create(null);
|
|
135
|
+
// Per-ROW residency registry — table → { residencyColumn, allowedTags }.
|
|
136
|
+
// The row-level sibling of columnResidency: one plaintext column on each
|
|
137
|
+
// row carries that row's residency tag; write gates refuse a tagged row
|
|
138
|
+
// landing on an incompatible backend.
|
|
139
|
+
var perRowResidency = Object.create(null);
|
|
135
140
|
|
|
136
141
|
// Per-row key declaration registry. For tables that opt
|
|
137
142
|
// into per-row keying, b.subject.eraseHard deletes the wrapped K_row
|
|
@@ -1105,7 +1110,7 @@ function getColumnResidency(table) {
|
|
|
1105
1110
|
* @signature b.cryptoField.assertColumnResidency(table, row, args)
|
|
1106
1111
|
* @since 0.7.27
|
|
1107
1112
|
* @compliance gdpr
|
|
1108
|
-
* @related b.cryptoField.declareColumnResidency
|
|
1113
|
+
* @related b.cryptoField.declareColumnResidency, b.cryptoField.declarePerRowResidency
|
|
1109
1114
|
*
|
|
1110
1115
|
* Storage-write gate. Storage backends call this with the proposed
|
|
1111
1116
|
* row before the SQL hits the wire; refusal under regulated postures
|
|
@@ -1161,6 +1166,104 @@ function assertColumnResidency(table, row, args) {
|
|
|
1161
1166
|
return null;
|
|
1162
1167
|
}
|
|
1163
1168
|
|
|
1169
|
+
/**
|
|
1170
|
+
* @primitive b.cryptoField.declarePerRowResidency
|
|
1171
|
+
* @signature b.cryptoField.declarePerRowResidency(table, opts)
|
|
1172
|
+
* @since 0.14.24
|
|
1173
|
+
* @compliance gdpr
|
|
1174
|
+
* @related b.cryptoField.getPerRowResidency, b.cryptoField.declareColumnResidency
|
|
1175
|
+
*
|
|
1176
|
+
* Declares per-ROW data residency for `table`: one plaintext column on
|
|
1177
|
+
* each row carries that row's residency tag, and the write gates
|
|
1178
|
+
* refuse a tagged row landing on an incompatible backend. The sibling
|
|
1179
|
+
* of `declareColumnResidency` — columns answer "which fields are
|
|
1180
|
+
* region-bound", rows answer "which region does THIS record belong
|
|
1181
|
+
* to" (an EU user's row next to a US user's row in the same table).
|
|
1182
|
+
* Local writes (`b.db.from(...).insertOne` / `.update`) enforce the
|
|
1183
|
+
* tag against the deployment's `dataResidency` region set under
|
|
1184
|
+
* cross-border regulated postures; external writes
|
|
1185
|
+
* (`b.externalDb.query`) take the tag per call via
|
|
1186
|
+
* `opts.rowResidencyTag` because raw SQL carries no row object. Rows
|
|
1187
|
+
* tagged "global" or "unrestricted" pass any backend. Throws on bad
|
|
1188
|
+
* input (config-time fail-loud).
|
|
1189
|
+
*
|
|
1190
|
+
* @opts
|
|
1191
|
+
* residencyColumn: string, // plaintext column carrying the row's tag
|
|
1192
|
+
* allowedTags: string[], // whitelist of valid tag values ("eu", "us", "global", region names)
|
|
1193
|
+
*
|
|
1194
|
+
* @example
|
|
1195
|
+
* b.cryptoField.declarePerRowResidency("users", {
|
|
1196
|
+
* residencyColumn: "dataRegion",
|
|
1197
|
+
* allowedTags: ["eu-west-1", "us-east-1", "global"],
|
|
1198
|
+
* });
|
|
1199
|
+
* var spec = b.cryptoField.getPerRowResidency("users");
|
|
1200
|
+
* spec.residencyColumn; // → "dataRegion"
|
|
1201
|
+
*/
|
|
1202
|
+
function declarePerRowResidency(table, opts) {
|
|
1203
|
+
validateOpts.requireNonEmptyString(table, "declarePerRowResidency: table",
|
|
1204
|
+
CryptoFieldError, "crypto-field/per-row-residency-table-empty");
|
|
1205
|
+
validateOpts.requireObject(opts, "declarePerRowResidency",
|
|
1206
|
+
CryptoFieldError, "crypto-field/per-row-residency-opts-not-object");
|
|
1207
|
+
validateOpts(opts, ["residencyColumn", "allowedTags"], "cryptoField.declarePerRowResidency");
|
|
1208
|
+
validateOpts.requireNonEmptyString(opts.residencyColumn,
|
|
1209
|
+
"declarePerRowResidency: opts.residencyColumn",
|
|
1210
|
+
CryptoFieldError, "crypto-field/per-row-residency-column-invalid");
|
|
1211
|
+
if (!Array.isArray(opts.allowedTags) || opts.allowedTags.length === 0) {
|
|
1212
|
+
throw new CryptoFieldError("crypto-field/per-row-residency-tags-invalid",
|
|
1213
|
+
"declarePerRowResidency: opts.allowedTags must be a non-empty array of tag strings");
|
|
1214
|
+
}
|
|
1215
|
+
validateOpts.optionalNonEmptyStringArray(opts.allowedTags,
|
|
1216
|
+
"declarePerRowResidency: opts.allowedTags",
|
|
1217
|
+
CryptoFieldError, "crypto-field/per-row-residency-tag-empty");
|
|
1218
|
+
// The residency tag column MUST stay plaintext — the write gate reads
|
|
1219
|
+
// it on every INSERT / UPDATE before sealRow, and reads return it
|
|
1220
|
+
// verbatim. A sealed residency column would be ciphertext the gate
|
|
1221
|
+
// can't compare and reads can't surface. Refuse the misconfiguration
|
|
1222
|
+
// at declaration time when the table's sealed-field set is already
|
|
1223
|
+
// known (registration order permitting).
|
|
1224
|
+
var sealed = getSealedFields(table);
|
|
1225
|
+
if (Array.isArray(sealed) && sealed.indexOf(opts.residencyColumn) !== -1) {
|
|
1226
|
+
throw new CryptoFieldError("crypto-field/per-row-residency-sealed-conflict",
|
|
1227
|
+
"declarePerRowResidency: residencyColumn '" + opts.residencyColumn +
|
|
1228
|
+
"' is a sealed field on table '" + table + "' — the residency tag must " +
|
|
1229
|
+
"stay plaintext so the write gate can read it. Choose a non-sealed column");
|
|
1230
|
+
}
|
|
1231
|
+
perRowResidency[table] = {
|
|
1232
|
+
residencyColumn: opts.residencyColumn,
|
|
1233
|
+
allowedTags: opts.allowedTags.slice(),
|
|
1234
|
+
};
|
|
1235
|
+
return {
|
|
1236
|
+
table: table,
|
|
1237
|
+
residencyColumn: opts.residencyColumn,
|
|
1238
|
+
allowedTags: opts.allowedTags.slice(),
|
|
1239
|
+
};
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
/**
|
|
1243
|
+
* @primitive b.cryptoField.getPerRowResidency
|
|
1244
|
+
* @signature b.cryptoField.getPerRowResidency(table)
|
|
1245
|
+
* @since 0.14.24
|
|
1246
|
+
* @related b.cryptoField.declarePerRowResidency
|
|
1247
|
+
*
|
|
1248
|
+
* Returns the per-row residency spec declared for `table`
|
|
1249
|
+
* (`{ residencyColumn, allowedTags }`), or null when the table has no
|
|
1250
|
+
* declaration. Read-only — storage backends call this at the write
|
|
1251
|
+
* boundary to decide whether the row-residency gate applies.
|
|
1252
|
+
*
|
|
1253
|
+
* @example
|
|
1254
|
+
* b.cryptoField.declarePerRowResidency("users", {
|
|
1255
|
+
* residencyColumn: "dataRegion",
|
|
1256
|
+
* allowedTags: ["eu-west-1", "global"],
|
|
1257
|
+
* });
|
|
1258
|
+
* b.cryptoField.getPerRowResidency("users").allowedTags; // → ["eu-west-1", "global"]
|
|
1259
|
+
* b.cryptoField.getPerRowResidency("unknown"); // → null
|
|
1260
|
+
*/
|
|
1261
|
+
function getPerRowResidency(table) {
|
|
1262
|
+
var spec = perRowResidency[table];
|
|
1263
|
+
if (!spec) return null;
|
|
1264
|
+
return { residencyColumn: spec.residencyColumn, allowedTags: spec.allowedTags.slice() };
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1164
1267
|
/**
|
|
1165
1268
|
* @primitive b.cryptoField.declarePerRowKey
|
|
1166
1269
|
* @signature b.cryptoField.declarePerRowKey(table, opts)
|
|
@@ -1336,10 +1439,10 @@ function destroyPerRowKey(table, rowId, dbHandle) {
|
|
|
1336
1439
|
* @related b.cryptoField.declareColumnResidency, b.cryptoField.declarePerRowKey
|
|
1337
1440
|
*
|
|
1338
1441
|
* Test-only helper. Drops every entry from the per-column residency
|
|
1339
|
-
* registry
|
|
1340
|
-
* re-declare
|
|
1341
|
-
*
|
|
1342
|
-
* boot.
|
|
1442
|
+
* registry, the per-row residency registry, and the per-row-key
|
|
1443
|
+
* registry so a test fixture can re-declare them between cases.
|
|
1444
|
+
* Operator code never calls this — production declarations come from
|
|
1445
|
+
* `b.db.init({ schema })` once at boot.
|
|
1343
1446
|
*
|
|
1344
1447
|
* @example
|
|
1345
1448
|
* b.cryptoField.declareColumnResidency("users", {
|
|
@@ -1351,6 +1454,7 @@ function destroyPerRowKey(table, rowId, dbHandle) {
|
|
|
1351
1454
|
function clearResidencyForTest() {
|
|
1352
1455
|
for (var t in columnResidency) delete columnResidency[t];
|
|
1353
1456
|
for (var u in perRowKeyTables) delete perRowKeyTables[u];
|
|
1457
|
+
for (var v in perRowResidency) delete perRowResidency[v];
|
|
1354
1458
|
}
|
|
1355
1459
|
|
|
1356
1460
|
module.exports = {
|
|
@@ -1382,6 +1486,8 @@ module.exports = {
|
|
|
1382
1486
|
declareColumnResidency: declareColumnResidency,
|
|
1383
1487
|
getColumnResidency: getColumnResidency,
|
|
1384
1488
|
assertColumnResidency: assertColumnResidency,
|
|
1489
|
+
declarePerRowResidency: declarePerRowResidency,
|
|
1490
|
+
getPerRowResidency: getPerRowResidency,
|
|
1385
1491
|
declarePerRowKey: declarePerRowKey,
|
|
1386
1492
|
hasPerRowKey: hasPerRowKey,
|
|
1387
1493
|
materializePerRowKey: materializePerRowKey,
|
package/lib/db-query.js
CHANGED
|
@@ -34,6 +34,122 @@ var safeJson = require("./safe-json");
|
|
|
34
34
|
var safeJsonPath = require("./safe-jsonpath");
|
|
35
35
|
var safeSql = require("./safe-sql");
|
|
36
36
|
var audit = require("./audit");
|
|
37
|
+
var lazyRequire = require("./lazy-require");
|
|
38
|
+
var { DbQueryError } = require("./framework-error");
|
|
39
|
+
|
|
40
|
+
// Circular load — db.js requires db-query at module scope, so the
|
|
41
|
+
// residency gate reaches back for getDataResidency() lazily.
|
|
42
|
+
var db = lazyRequire(function () { return require("./db"); });
|
|
43
|
+
|
|
44
|
+
// Cross-border regulated postures live on b.compliance
|
|
45
|
+
// (CROSS_BORDER_REGULATED_POSTURES — one vocabulary shared with
|
|
46
|
+
// external-db's gate): under these, a residency mismatch REFUSES the
|
|
47
|
+
// write; under anything else the gates emit an advisory audit and
|
|
48
|
+
// pass (backward-compatible).
|
|
49
|
+
function _postureState() {
|
|
50
|
+
try {
|
|
51
|
+
var compliance = require("./compliance"); // allow:inline-require — defensive against optional load
|
|
52
|
+
var posture = compliance.current();
|
|
53
|
+
return { posture: posture, regulated: compliance.isCrossBorderRegulated(posture) };
|
|
54
|
+
} catch (_e) { return { posture: null, regulated: false }; }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Local-SQLite write-residency gate (GDPR Art 44-46 / PIPL Art 38 /
|
|
58
|
+
// DPDP §16 cross-border transfer restrictions). Runs on the PLAINTEXT
|
|
59
|
+
// row before sealRow so the tag column is readable even when other
|
|
60
|
+
// columns seal. Two layers:
|
|
61
|
+
//
|
|
62
|
+
// 1. Per-ROW tag (declarePerRowResidency): on INSERT the declared
|
|
63
|
+
// column must be present and within allowedTags; under a
|
|
64
|
+
// regulated posture a tag outside the deployment's region set
|
|
65
|
+
// ({ region } + allowedStorageRegions from db.init's
|
|
66
|
+
// dataResidency) refuses the write. UPDATEs gate only when the
|
|
67
|
+
// change set touches the residency column (an update that does
|
|
68
|
+
// not move residency is not a transfer).
|
|
69
|
+
// 2. Per-COLUMN tags (declareColumnResidency): the long-advertised
|
|
70
|
+
// assertColumnResidency gate, enforced here against the
|
|
71
|
+
// deployment region. Operators tag columns with the region
|
|
72
|
+
// value their dataResidency declares.
|
|
73
|
+
//
|
|
74
|
+
// Unregulated postures audit (drop-silent) and pass; tables with no
|
|
75
|
+
// declaration are untouched.
|
|
76
|
+
function _assertLocalResidency(table, plaintextRow, op) {
|
|
77
|
+
var spec = cryptoField.getPerRowResidency(table);
|
|
78
|
+
var colMap = cryptoField.getColumnResidency(table);
|
|
79
|
+
if (!spec && !colMap) return;
|
|
80
|
+
|
|
81
|
+
var residency = null;
|
|
82
|
+
try { residency = db().getDataResidency(); } catch (_e) { residency = null; }
|
|
83
|
+
var region = residency && residency.region ? residency.region : null;
|
|
84
|
+
var allowedRegions = region
|
|
85
|
+
? [region].concat(Array.isArray(residency.allowedStorageRegions)
|
|
86
|
+
? residency.allowedStorageRegions : [])
|
|
87
|
+
: null;
|
|
88
|
+
var state = _postureState();
|
|
89
|
+
var posture = state.posture;
|
|
90
|
+
var regulated = state.regulated;
|
|
91
|
+
|
|
92
|
+
if (spec) {
|
|
93
|
+
var tag = plaintextRow[spec.residencyColumn];
|
|
94
|
+
var tagPresent = tag !== undefined && tag !== null;
|
|
95
|
+
var colInChangeSet = Object.prototype.hasOwnProperty.call(plaintextRow, spec.residencyColumn);
|
|
96
|
+
if (op === "insert" && !tagPresent) {
|
|
97
|
+
throw new DbQueryError("db-query/row-residency-tag-missing",
|
|
98
|
+
op + ": table '" + table + "' declares per-row residency on column '" +
|
|
99
|
+
spec.residencyColumn + "' — every inserted row must carry a tag from [" +
|
|
100
|
+
spec.allowedTags.join(", ") + "]", true);
|
|
101
|
+
}
|
|
102
|
+
// An UPDATE that explicitly sets the residency column to null /
|
|
103
|
+
// undefined would clear the row's region binding (INSERT refuses a
|
|
104
|
+
// missing tag; the same row must not be nullable into an untagged
|
|
105
|
+
// state on update). UPDATEs that don't touch the column pass.
|
|
106
|
+
if (op === "update" && colInChangeSet && !tagPresent) {
|
|
107
|
+
throw new DbQueryError("db-query/row-residency-tag-missing",
|
|
108
|
+
op + ": table '" + table + "' residency column '" + spec.residencyColumn +
|
|
109
|
+
"' cannot be cleared — set a tag from [" + spec.allowedTags.join(", ") + "]", true);
|
|
110
|
+
}
|
|
111
|
+
if (tagPresent) {
|
|
112
|
+
if (typeof tag !== "string" || spec.allowedTags.indexOf(tag) === -1) {
|
|
113
|
+
throw new DbQueryError("db-query/row-residency-tag-invalid",
|
|
114
|
+
op + ": table '" + table + "' residency tag '" + tag +
|
|
115
|
+
"' is not in allowedTags [" + spec.allowedTags.join(", ") + "]", true);
|
|
116
|
+
}
|
|
117
|
+
if (tag !== "global" && tag !== "unrestricted" && allowedRegions &&
|
|
118
|
+
allowedRegions.indexOf(tag) === -1) {
|
|
119
|
+
if (regulated) {
|
|
120
|
+
audit.safeEmit({ action: "db.residency.gate.rejected", outcome: "denied",
|
|
121
|
+
metadata: { table: table, rowTag: tag, region: region, posture: posture,
|
|
122
|
+
operation: op, scope: "local" } });
|
|
123
|
+
throw new DbQueryError("db-query/row-residency-local-mismatch",
|
|
124
|
+
op + ": row residency tag '" + tag + "' is outside this deployment's " +
|
|
125
|
+
"region set [" + allowedRegions.join(", ") + "] under '" + posture +
|
|
126
|
+
"' posture (cross-border transfer refused)", true);
|
|
127
|
+
}
|
|
128
|
+
audit.safeEmit({ action: "db.residency.gate.advisory", outcome: "info",
|
|
129
|
+
metadata: { table: table, rowTag: tag, region: region, posture: posture || null,
|
|
130
|
+
operation: op, scope: "local" } });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (colMap && region) {
|
|
136
|
+
var refusal = cryptoField.assertColumnResidency(table, plaintextRow, { backendTag: region });
|
|
137
|
+
if (refusal) {
|
|
138
|
+
if (regulated) {
|
|
139
|
+
audit.safeEmit({ action: "db.column_residency.gate.rejected", outcome: "denied",
|
|
140
|
+
metadata: { table: refusal.table, column: refusal.column, want: refusal.want,
|
|
141
|
+
got: refusal.got, posture: posture, operation: op, scope: "local" } });
|
|
142
|
+
throw new DbQueryError("db-query/column-residency-mismatch",
|
|
143
|
+
op + ": column '" + refusal.column + "' on table '" + refusal.table +
|
|
144
|
+
"' is bound to residency '" + refusal.want + "' but this deployment's " +
|
|
145
|
+
"region is '" + refusal.got + "' under '" + posture + "' posture", true);
|
|
146
|
+
}
|
|
147
|
+
audit.safeEmit({ action: "db.residency.gate.advisory", outcome: "info",
|
|
148
|
+
metadata: { table: refusal.table, column: refusal.column, want: refusal.want,
|
|
149
|
+
got: refusal.got, posture: posture || null, operation: op, scope: "local" } });
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
37
153
|
|
|
38
154
|
// "@>" / "?" / "?|" / "?&" are JSONB containment + key-existence
|
|
39
155
|
// operators. Routed through safeJsonPath validation before binding so
|
|
@@ -477,6 +593,9 @@ class Query {
|
|
|
477
593
|
if (withId._id === undefined || withId._id === null) {
|
|
478
594
|
withId._id = generateToken(C.BYTES.bytes(16));
|
|
479
595
|
}
|
|
596
|
+
// Residency gates read the PLAINTEXT row (the tag column must be
|
|
597
|
+
// inspectable even when sibling columns seal below).
|
|
598
|
+
_assertLocalResidency(this._cryptoFieldKey(), withId, "insert");
|
|
480
599
|
var sealed = cryptoField.sealRow(this._cryptoFieldKey(), withId);
|
|
481
600
|
var cols = Object.keys(sealed);
|
|
482
601
|
var placeholders = cols.map(function () { return "?"; }).join(", ");
|
|
@@ -514,6 +633,10 @@ class Query {
|
|
|
514
633
|
if (this._where.length === 0) {
|
|
515
634
|
throw new Error("refusing unconditional update — call where(...) first");
|
|
516
635
|
}
|
|
636
|
+
// Residency gates on the plaintext change set — an UPDATE that
|
|
637
|
+
// touches the residency tag (or a region-bound column) is a
|
|
638
|
+
// transfer and goes through the same refusal matrix as INSERT.
|
|
639
|
+
_assertLocalResidency(this._cryptoFieldKey(), changes, "update");
|
|
517
640
|
var sealed = cryptoField.sealRow(this._cryptoFieldKey(), changes);
|
|
518
641
|
var setKeys = Object.keys(sealed);
|
|
519
642
|
if (setKeys.length === 0) {
|
|
@@ -75,6 +75,16 @@ var Q_TRACKING = '"' + TRACKING_TABLE + '"';
|
|
|
75
75
|
var Q_LOCK = '"' + LOCK_TABLE + '"';
|
|
76
76
|
var Q_HISTORY = '"' + HISTORY_TABLE + '"';
|
|
77
77
|
|
|
78
|
+
// The migration tracking / history / lock tables hold framework
|
|
79
|
+
// bookkeeping ("migration X ran at time T"), not region-bound personal
|
|
80
|
+
// data, so their writes carry the residency-neutral "unrestricted" tag
|
|
81
|
+
// — the per-row residency write gate (b.externalDb.query) refuses DML
|
|
82
|
+
// to a residency-tagged backend under a cross-border regulated posture
|
|
83
|
+
// unless a compatible rowResidencyTag is supplied. Operator migration
|
|
84
|
+
// DML (mod.up) stays subject to the gate; only these internal writes
|
|
85
|
+
// are exempt. Passed as the per-statement opts override on the txClient.
|
|
86
|
+
var FRAMEWORK_METADATA_OPTS = Object.freeze({ rowResidencyTag: "unrestricted" });
|
|
87
|
+
|
|
78
88
|
// Bytes that get signed for one history row. Stable forever — changing
|
|
79
89
|
// it invalidates every prior signature.
|
|
80
90
|
var HISTORY_SIGNATURE_FORMAT = "blamejs-schema-history-v1";
|
|
@@ -187,7 +197,8 @@ async function _writeHistoryRow(xdb, row) {
|
|
|
187
197
|
row.schemaIntrospectionHash,
|
|
188
198
|
row.signature,
|
|
189
199
|
row.publicKeyFingerprint,
|
|
190
|
-
]
|
|
200
|
+
],
|
|
201
|
+
FRAMEWORK_METADATA_OPTS
|
|
191
202
|
);
|
|
192
203
|
}
|
|
193
204
|
|
|
@@ -221,7 +232,7 @@ async function _acquireLock(xdb, opts) {
|
|
|
221
232
|
try {
|
|
222
233
|
await xdb.query(
|
|
223
234
|
"INSERT INTO " + Q_LOCK + " (scope, lockedAt, lockedBy) VALUES ('lock', $1, $2)",
|
|
224
|
-
[nowMs, holder]
|
|
235
|
+
[nowMs, holder], FRAMEWORK_METADATA_OPTS
|
|
225
236
|
);
|
|
226
237
|
return { holder: holder, takeoverFrom: null, takeoverAgeMs: 0 };
|
|
227
238
|
} catch (_e) {
|
|
@@ -235,7 +246,7 @@ async function _acquireLock(xdb, opts) {
|
|
|
235
246
|
try {
|
|
236
247
|
await xdb.query(
|
|
237
248
|
"INSERT INTO " + Q_LOCK + " (scope, lockedAt, lockedBy) VALUES ('lock', $1, $2)",
|
|
238
|
-
[nowMs, holder]
|
|
249
|
+
[nowMs, holder], FRAMEWORK_METADATA_OPTS
|
|
239
250
|
);
|
|
240
251
|
return { holder: holder, takeoverFrom: null, takeoverAgeMs: 0 };
|
|
241
252
|
} catch (e2) {
|
|
@@ -250,11 +261,11 @@ async function _acquireLock(xdb, opts) {
|
|
|
250
261
|
var prevHolder = existing.lockedby || existing.lockedBy;
|
|
251
262
|
await xdb.query(
|
|
252
263
|
"DELETE FROM " + Q_LOCK + " WHERE scope = 'lock' AND lockedAt = $1",
|
|
253
|
-
[Number(existing.lockedat || existing.lockedAt)]
|
|
264
|
+
[Number(existing.lockedat || existing.lockedAt)], FRAMEWORK_METADATA_OPTS
|
|
254
265
|
);
|
|
255
266
|
await xdb.query(
|
|
256
267
|
"INSERT INTO " + Q_LOCK + " (scope, lockedAt, lockedBy) VALUES ('lock', $1, $2)",
|
|
257
|
-
[nowMs, holder]
|
|
268
|
+
[nowMs, holder], FRAMEWORK_METADATA_OPTS
|
|
258
269
|
);
|
|
259
270
|
return { holder: holder, takeoverFrom: prevHolder, takeoverAgeMs: ageMs };
|
|
260
271
|
}
|
|
@@ -269,7 +280,7 @@ async function _releaseLock(xdb, holder) {
|
|
|
269
280
|
try {
|
|
270
281
|
await xdb.query(
|
|
271
282
|
"DELETE FROM " + Q_LOCK + " WHERE scope = 'lock' AND lockedBy = $1",
|
|
272
|
-
[holder]
|
|
283
|
+
[holder], FRAMEWORK_METADATA_OPTS
|
|
273
284
|
);
|
|
274
285
|
} catch (_e) {
|
|
275
286
|
// best-effort release; operator can DELETE manually.
|
|
@@ -441,7 +452,8 @@ function create(opts) {
|
|
|
441
452
|
await xdb.query(
|
|
442
453
|
"INSERT INTO " + Q_TRACKING +
|
|
443
454
|
" (name, description, appliedAt) VALUES ($1, $2, $3)",
|
|
444
|
-
[file, mod.description || "", ranAt]
|
|
455
|
+
[file, mod.description || "", ranAt],
|
|
456
|
+
FRAMEWORK_METADATA_OPTS
|
|
445
457
|
);
|
|
446
458
|
// Schema-version history with signature. Sign post-INSERT
|
|
447
459
|
// so the introspection hash reflects the row that just
|