@blamejs/core 0.15.4 → 0.15.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.15.x
10
10
 
11
+ - v0.15.5 (2026-06-12) — **Legal-hold and subject-restriction PII is sealed at rest, and a guard's compliance-posture forensic and runtime caps are applied on its default gate.** This release closes two data-protection gaps. The legal-hold registry and the subject-restriction flag stored their free-text fields - the legal basis, custodian, ticket citation, and restriction reason that link a data subject to a legal matter - in clear, because those local tables were written through a raw SQL path that bypassed the structured builder's at-rest sealing. Those columns are now sealed on write and unsealed on read, the same way the DSR ticket store already seals subject identifiers. Separately, a content guard built on the standard gate contract and gated with a compliance posture (for example b.guardCidr.gate({ compliancePosture: "hipaa" })) silently dropped that posture's forensic-snapshot cap and the profile's runtime cap, because the default gate passed the caller's raw options straight to the gate builder instead of resolving the profile and posture first - so a regulated-posture refusal carried no forensic evidence. The default gate now resolves the profile and posture before building the gate, matching the hand-written guard gates. **Security:** *Legal-hold and subject-restriction PII is sealed at rest* — `b.legalHold`'s `_blamejs_legal_hold` registry stored the hold reason, custodian, placed-by, and citation in clear, and `b.subject.restrict`'s `_blamejs_subject_restrictions` stored the restriction reason in clear - free text that ties a data subject to a litigation hold or an Art. 18 processing restriction. Those rows were written through a raw `sql.insert` + `prepare().run()` path that bypassed the structured builder's automatic field sealing (the subject-restrictions table even declared the field as sealed, but the raw write never applied it). Both now seal those columns on write and unseal on read through `cryptoField`, so the legal-basis and custodian text is encrypted at rest under the deployment's vault key. Pre-existing plaintext rows continue to read correctly (the unseal path passes through an already-plaintext value). · *A guard's default gate applies its compliance-posture forensic and runtime caps* — A guard built on `b.gateContract.defineGuard` with the standard gate (no bespoke gate) and gated with a compliance posture dropped that posture's `forensicSnippetBytes` cap and the profile's `maxRuntimeMs` cap: the default gate passed the caller's raw options straight to the gate builder, which reads those caps directly, but the values live on the resolved profile and posture, not the raw options. The effect was that a regulated-posture refusal captured no forensic snapshot (the cap defaulted to 0, i.e. disabled) and the check ran without the profile's runtime bound. The default gate now resolves the profile and posture before building the gate - matching the hand-written guard gates - so `gate({ compliancePosture: "hipaa" })` applies the posture's forensic cap and the profile's runtime cap as documented.
12
+
11
13
  - v0.15.4 (2026-06-12) — **Telemetry attribute values are redacted before they leave the process, per-row data residency is enforced on every write and export path, DDL routes through the single-statement gate, the DPoP middleware requires its replay store, and session rotation re-keys the device binding.** This release closes a set of egress, data-residency, and session-binding gaps. OTLP span, span-event, and resource attributes are now scrubbed through the telemetry redactor before serialization, on both the JSON and protobuf paths, matching the metric exporter - an attribute value holding a bearer token, password, or API key no longer reaches the collector verbatim. Per-row data residency, previously enforced only at the structured query builder, is now enforced on the three paths that bypassed it: raw SQL writes, read-replica fan-out, and backup export. Every CREATE TABLE / ALTER TABLE the schema reconciler and the DSR store emit now passes through the same single-statement gate the query builder uses. The DPoP middleware now requires its replay store at mount time instead of silently mounting a proof-of-possession gate that performed no replay check. And session rotation re-keys the device fingerprint to the new session id, so a rotated session stays bound to its device instead of falsely reporting drift on the next request. **Fixed:** *Session rotation re-keys the device fingerprint to the new session id* — A session's optional device fingerprint is keyed to its session id, so that a stolen database cannot replay the binding. `b.session.rotate` moved the session id but left the stored fingerprint keyed to the old id, so the next `verify` recomputed the fingerprint against the new id and mismatched - reporting a false `fingerprintDrift` (which destroys the session under strict operators, logging the user out on every rotation) or silently breaking the binding. Rotation now re-keys the fingerprint to the new session id from the live request: pass the same `{ req, fingerprintFields }` to `rotate`. A fingerprint-bound session rotated without `req` now throws, because the binding cannot otherwise follow the new id; unbound sessions are unaffected. **Security:** *OTLP exporter redacts span, event, and resource attribute values before egress* — Span, span-event, and resource attributes were serialized to the OTLP collector verbatim on both the JSON and protobuf encodings - the metric exporter scrubbed its attributes through the telemetry redactor, but the span exporter did not. An attribute value carrying a secret or PII (a bearer token in `authorization`, a `password`, an `api_key`) was therefore shipped in clear to whatever collector the deployment points at (CWE-532). Every attribute-map encoder now runs each value through `b.observability.redactAttrs` (default composes `b.redact.redact`, dropping any attribute whose redactor throws) before the wire payload, so telemetry is redacted like the log and audit egress paths. The new `b.observability.redactAttrs(attrs)` is available for operators building custom exporters. · *Per-row data residency is enforced on raw writes, read replicas, and backups* — Per-row residency was enforced only at the structured query builder. Three paths reached storage or left the deployment without it: raw SQL writes (`b.db.runSql` / `b.db.prepare().run()`, INSERT and UPDATE forms) bypassed the local residency check entirely, so a cross-border row could be written under a regulated posture with no refusal; read-replica fan-out dropped the row-residency tag, routing a regulated read with no row region identified to a residency-tagged, non-cross-border replica with no check; and `b.backup.create`'s residency check compared only the single deployment region to the destination, blind to a per-row-residency table that admits rows from several regions. Raw writes now parse the target table and residency value and apply the same gate the builder does; the replica read now fails closed when the row region is unidentified; and backup now emits a per-row cross-border advisory for any declared residency table whose admitted regions differ from the backup destination. · *Schema and DSR DDL routes through the single-statement gate* — The CREATE TABLE / ALTER TABLE statements emitted by the schema reconciler and the DSR ticket store were assembled and run without the single-statement / NUL / unterminated-quote / unbalanced-paren gate that every query the builder emits already passes. That gate is now a shared check both the builder's catalog emitter and the schema/DSR DDL path call, so a terminator, comment marker, or unbalanced quote that reached a DDL fragment is refused at emit time on every backend. · *DPoP middleware requires its replay store at mount time* — `b.middleware.dpop` documented `replayStore` as required, but the factory read it optionally and gated the jti-replay check behind its presence - omitting it mounted a proof-of-possession gate that performed no replay check, so a captured DPoP proof could be replayed indefinitely (RFC 9449 §11.1). The middleware now requires the store at config time: a missing store, or a store lacking `checkAndInsert`, throws when the middleware is created instead of failing open at request time. The low-level `b.auth.dpop.verify` primitive keeps `replayStore` optional for advanced callers that track jti themselves.
12
14
 
13
15
  - v0.15.3 (2026-06-12) — **DDL hardening in b.sql, schema-confined column introspection on Postgres and MySQL, and a classical-downgrade audit on proxy-tunneled TLS.** This release hardens the data layer and closes a transport audit gap. The b.sql builder refuses an unrecognised column type that carries a statement terminator, quote, or comment marker - the one position in an otherwise quote-by-construction DDL builder where a verbatim string reached the emitted statement - and routes the finished CREATE TABLE through the same single-statement gate every other verb uses. The schema reconciler's column introspection is now confined to the schema or database the bare-named CREATE TABLE actually writes into (current_schema() on Postgres, DATABASE() on MySQL), so a same-named table in another schema no longer pollutes the column set, silently skipping an ADD COLUMN or fabricating false schema drift that refuses a regulated-posture boot. Two further builder gaps are fixed: a column-level primary key combined with a composite primaryKey now fails at build time with a clear error instead of producing invalid DDL, and a MySQL upsert read-back keyed by a cast or a server-evaluated function now renders the cast (or refuses the function) instead of binding an internal wrapper. Finally, an HTTPS request sent through a configured proxy now emits the tls.classical_downgrade audit when the handshake falls back to a classical group, the same as a direct connection. **Fixed:** *Schema reconciliation reads columns from the right schema on Postgres and MySQL* — The reconciler's column introspection queried information_schema with no schema filter, so on a Postgres instance or MySQL server hosting more than one schema/database with a same-named table, the live column set was the union across schemas. That could silently skip an ADD COLUMN the table needed, or report false drift that refuses a boot under a pinned regulated posture. Introspection is now confined to current_schema() (Postgres) / DATABASE() (MySQL) - the schema the bare-named CREATE TABLE lands in. SQLite (PRAGMA, per-file) is unchanged. · *createTable rejects a contradictory primary-key declaration at build time* — Declaring both a column-level primary key (primaryKey / autoIncrement / serial) and a composite opts.primaryKey emitted two PRIMARY KEY clauses, which every dialect rejects at the driver mid-migration. The builder now refuses the contradiction at build time with a clear error; a single column PK, or a composite primaryKey with no column-level PK, is unaffected. · *MySQL upsert read-back resolves a cast or function conflict key instead of binding a wrapper* — On MySQL, an upsert whose conflict key was a b.sql.cast(...) or b.sql.fn(...) built a read-back SELECT that bound the wrapper object, so the read-back matched no rows. A cast conflict key now renders as CAST(? AS type) binding the inner value; a server-evaluated function conflict key (which has no stable read-back identity) is refused with a clear error. Plain scalar conflict keys are unchanged. · *Proxy-tunneled TLS emits the classical-downgrade audit* — An HTTPS upstream reached through a configured proxy performed its TLS handshake without emitting the tls.classical_downgrade audit on a classical-group fallback, leaving the post-quantum-readiness inventory incomplete for proxied requests. Both the upstream handshake and the proxy-leg handshake now emit the audit on a classical fallback, matching the direct connection path. The handshake itself is unchanged (still hybrid-preferred TLSv1.3). **Security:** *b.sql refuses an injection-bearing verbatim column type and gates every CREATE TABLE* — An unrecognised column type passed to b.sql.createTable / alterTable was emitted into the DDL verbatim - the single raw-emission position in a builder that otherwise quotes every identifier and guards every constraint fragment. A type such as "text); DROP TABLE secrets; --" could therefore smuggle a stacked statement. The builder now refuses, at build time, a verbatim type carrying a statement terminator or comment marker, and routes the finished CREATE TABLE / ALTER TABLE statement through the same single-statement / NUL / unterminated-quote / unbalanced-paren gate every SELECT / INSERT / UPDATE / DELETE / UPSERT already used - so an unbalanced quote is caught there. Legitimate types are unaffected: VARCHAR(255), NUMERIC(10,2), DOUBLE PRECISION, TIMESTAMP WITH TIME ZONE, and MySQL ENUM('a','b') / SET(...) (which need balanced quotes) all still pass.
package/lib/db.js CHANGED
@@ -304,6 +304,10 @@ var FRAMEWORK_SCHEMA = [
304
304
  citation: "TEXT",
305
305
  retainUntil: "INTEGER",
306
306
  },
307
+ // The legal-basis / custodian / ticket-citation free text links a data
308
+ // subject to a legal matter — PII at rest. Sealed like the DSR ticket store
309
+ // (b.legalHold seals on write + unseals on read through cryptoField).
310
+ sealedFields: ["reason", "placedBy", "custodian", "citation"],
307
311
  indexes: ["placedAt"],
308
312
  },
309
313
  {
@@ -1992,12 +1992,22 @@ function defineGuard(spec) {
1992
1992
  // to the right ctx field by KIND (or spec.ctxFields). Guards with a bespoke
1993
1993
  // gate pass spec.gate; guards whose gate is the standard chain take this
1994
1994
  // default.
1995
- // Raw opts pass straight through to spec.validate and buildGuardGate
1996
- // matching the hand-written gates, whose validate resolves profile/posture
1997
- // internally (validate calls its own _resolveOpts). Pre-resolving here
1998
- // would double-resolve.
1999
- function defaultGate(opts) {
2000
- opts = opts || {};
1995
+ // Resolve the profile + posture BEFORE buildGuardGate reads its runtime /
1996
+ // forensic caps: forensicSnippetBytes lives on the posture and maxRuntimeMs
1997
+ // on the profile, NOT on the raw caller opts. Passing raw opts through dropped
1998
+ // a regulated posture's forensic cap to 0 (no forensic snapshot on a refusal)
1999
+ // and the profile's runtime cap to uncapped — the hand-written gates resolve
2000
+ // in their own gate(), and the default gate must match. resolveProfileAndPosture
2001
+ // is idempotent over an already-resolved opts, so spec.validate's internal
2002
+ // resolution stays correct.
2003
+ function defaultGate(rawOpts) {
2004
+ var opts = resolveProfileAndPosture(rawOpts || {}, {
2005
+ profiles: profiles,
2006
+ compliancePostures: postures,
2007
+ defaults: defaults,
2008
+ errorClass: ErrorClass,
2009
+ errCodePrefix: prefix,
2010
+ });
2001
2011
  var perCtx = spec.defaultGateCheck || function (ctx) {
2002
2012
  var value = _ctxValueForKind(spec.kind, ctx, ctxFields);
2003
2013
  if (!value) return { ok: true, action: "serve" };
package/lib/legal-hold.js CHANGED
@@ -64,6 +64,7 @@ var lazyRequire = require("./lazy-require");
64
64
  var safeJson = require("./safe-json");
65
65
  var validateOpts = require("./validate-opts");
66
66
  var sql = require("./sql");
67
+ var cryptoField = require("./crypto-field");
67
68
  var { defineClass } = require("./framework-error");
68
69
 
69
70
  // Local-SQLite framework table names. These run against the b.db() handle
@@ -185,6 +186,16 @@ function create(opts) {
185
186
  ], SQL_OPTS);
186
187
  fn(ddl.sql);
187
188
  }
189
+ // Register the table's PII columns for at-rest sealing. The framework
190
+ // SQLite path also registers this via db.init() FRAMEWORK_SCHEMA, but the
191
+ // external-db / cluster path and a post-_resetForTest registry need this
192
+ // idempotent guard so sealRow/unsealRow below are never no-ops (which would
193
+ // store the legal-basis / custodian / citation free text in clear).
194
+ if (!cryptoField.getSchema(HOLD_TABLE)) {
195
+ cryptoField.registerTable(HOLD_TABLE, {
196
+ sealedFields: ["reason", "placedBy", "custodian", "citation"],
197
+ });
198
+ }
188
199
  }
189
200
 
190
201
  function place(subjectId, args) {
@@ -222,7 +233,7 @@ function create(opts) {
222
233
  }
223
234
  var nowMs = Date.now();
224
235
  var placeInsBuilt = sql.insert(HOLD_TABLE, SQL_OPTS)
225
- .values({
236
+ .values(cryptoField.sealRow(HOLD_TABLE, {
226
237
  subjectIdHash: hash,
227
238
  placedAt: nowMs,
228
239
  placedBy: args.placedBy || null,
@@ -230,7 +241,7 @@ function create(opts) {
230
241
  custodian: args.custodian || null,
231
242
  citation: args.citation || null,
232
243
  retainUntil: args.retainUntil || null,
233
- })
244
+ }))
234
245
  .toSql();
235
246
  var placeInsStmt = db.prepare(placeInsBuilt.sql);
236
247
  placeInsStmt.run.apply(placeInsStmt, placeInsBuilt.params);
@@ -268,6 +279,7 @@ function create(opts) {
268
279
  "denied");
269
280
  return { error: "not-held" };
270
281
  }
282
+ existing = cryptoField.unsealRow(HOLD_TABLE, existing);
271
283
  var relDelBuilt = sql.delete(HOLD_TABLE, SQL_OPTS)
272
284
  .where("subjectIdHash", hash)
273
285
  .toSql();
@@ -312,6 +324,7 @@ function create(opts) {
312
324
  var getStmt = db.prepare(getBuilt.sql);
313
325
  var row = getStmt.get.apply(getStmt, getBuilt.params);
314
326
  if (!row) return null;
327
+ row = cryptoField.unsealRow(HOLD_TABLE, row);
315
328
  return {
316
329
  subjectId: sid,
317
330
  placedAt: row.placedAt,
@@ -334,6 +347,7 @@ function create(opts) {
334
347
  var rows = listStmt.all.apply(listStmt, listBuilt.params);
335
348
  var nowMs = Date.now();
336
349
  return rows.map(function (r) {
350
+ r = cryptoField.unsealRow(HOLD_TABLE, r);
337
351
  return {
338
352
  subjectIdHash: r.subjectIdHash,
339
353
  placedAt: r.placedAt,
package/lib/subject.js CHANGED
@@ -561,12 +561,19 @@ function restrict(subjectId, opts) {
561
561
 
562
562
  if (opts.on) {
563
563
  if (!existing) {
564
+ // The restriction `reason` is a ticket reference / legal basis — PII at
565
+ // rest. db.js declares sealedFields:["reason"] on this table, but the raw
566
+ // write path bypasses the structured builder's auto-seal, so seal here
567
+ // explicitly (idempotent registration guard covers a reset registry).
568
+ if (!cryptoField.getSchema(RESTRICTIONS_TABLE)) {
569
+ cryptoField.registerTable(RESTRICTIONS_TABLE, { sealedFields: ["reason"] });
570
+ }
564
571
  var restrictInsBuilt = sql.insert(RESTRICTIONS_TABLE, { dialect: "sqlite", quoteName: true })
565
- .values({
572
+ .values(cryptoField.sealRow(RESTRICTIONS_TABLE, {
566
573
  subjectIdHash: _subjectHash(subjectId),
567
574
  since: Date.now(),
568
575
  reason: opts.reason || null,
569
- })
576
+ }))
570
577
  .toSql();
571
578
  var restrictInsStmt = db().prepare(restrictInsBuilt.sql);
572
579
  restrictInsStmt.run.apply(restrictInsStmt, restrictInsBuilt.params);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.15.4",
3
+ "version": "0.15.5",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
package/sbom.cdx.json CHANGED
@@ -2,10 +2,10 @@
2
2
  "$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
3
3
  "bomFormat": "CycloneDX",
4
4
  "specVersion": "1.5",
5
- "serialNumber": "urn:uuid:58fea242-8071-4e16-965b-d1ea85e285ba",
5
+ "serialNumber": "urn:uuid:f343f20a-874a-4162-93cd-56f8b7369526",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-06-13T02:42:24.654Z",
8
+ "timestamp": "2026-06-13T04:56:48.866Z",
9
9
  "lifecycles": [
10
10
  {
11
11
  "phase": "build"
@@ -19,14 +19,14 @@
19
19
  }
20
20
  ],
21
21
  "component": {
22
- "bom-ref": "@blamejs/core@0.15.4",
22
+ "bom-ref": "@blamejs/core@0.15.5",
23
23
  "type": "application",
24
24
  "name": "blamejs",
25
- "version": "0.15.4",
25
+ "version": "0.15.5",
26
26
  "scope": "required",
27
27
  "author": "blamejs contributors",
28
28
  "description": "The Node framework that owns its stack.",
29
- "purl": "pkg:npm/%40blamejs/core@0.15.4",
29
+ "purl": "pkg:npm/%40blamejs/core@0.15.5",
30
30
  "properties": [],
31
31
  "externalReferences": [
32
32
  {
@@ -54,7 +54,7 @@
54
54
  "components": [],
55
55
  "dependencies": [
56
56
  {
57
- "ref": "@blamejs/core@0.15.4",
57
+ "ref": "@blamejs/core@0.15.5",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]