@blamejs/core 0.14.22 → 0.14.25

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/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
@@ -393,7 +509,9 @@ class Query {
393
509
  this._whereClause() + this._orderLimitOffset() + " LIMIT 1";
394
510
  var stmt = this._db.prepare(sql);
395
511
  var row = stmt.get.apply(stmt, this._whereParams);
396
- return row ? cryptoField.unsealRow(this._cryptoFieldKey(), row) : null;
512
+ // 4th arg (dbHandle) lets unsealRow fetch + unwrap the row-scoped
513
+ // K_row for vault.row: cells (declarePerRowKey tables).
514
+ return row ? cryptoField.unsealRow(this._cryptoFieldKey(), row, undefined, this._db) : null;
397
515
  }
398
516
 
399
517
  all() {
@@ -403,8 +521,9 @@ class Query {
403
521
  var rows = stmt.all.apply(stmt, this._whereParams);
404
522
  var out = new Array(rows.length);
405
523
  var key = this._cryptoFieldKey();
524
+ var dbHandle = this._db;
406
525
  for (var i = 0; i < rows.length; i++) {
407
- out[i] = cryptoField.unsealRow(key, rows[i]);
526
+ out[i] = cryptoField.unsealRow(key, rows[i], undefined, dbHandle);
408
527
  }
409
528
  return out;
410
529
  }
@@ -433,6 +552,7 @@ class Query {
433
552
  }
434
553
  var stmt = this._db.prepare(sql);
435
554
  var key = this._cryptoFieldKey();
555
+ var dbHandle = this._db;
436
556
  var iter;
437
557
  try { iter = stmt.iterate.apply(stmt, this._whereParams); }
438
558
  catch (e) {
@@ -454,7 +574,7 @@ class Query {
454
574
  var step = iter.next();
455
575
  if (step.done) { this.push(null); return; }
456
576
  emitted += 1;
457
- this.push(cryptoField.unsealRow(key, step.value));
577
+ this.push(cryptoField.unsealRow(key, step.value, undefined, dbHandle));
458
578
  } catch (e) {
459
579
  this.destroy(e);
460
580
  }
@@ -477,7 +597,22 @@ class Query {
477
597
  if (withId._id === undefined || withId._id === null) {
478
598
  withId._id = generateToken(C.BYTES.bytes(16));
479
599
  }
480
- var sealed = cryptoField.sealRow(this._cryptoFieldKey(), withId);
600
+ // Residency gates read the PLAINTEXT row (the tag column must be
601
+ // inspectable even when sibling columns seal below).
602
+ _assertLocalResidency(this._cryptoFieldKey(), withId, "insert");
603
+ // Per-row-key tables (declarePerRowKey): materialize a fresh K_row
604
+ // BEFORE sealRow so sealed columns encrypt under the row-scoped key
605
+ // (vault.row: cells). rowId MUST be withId._id — the same value
606
+ // b.subject.eraseHard / b.retention destroy on, so a later shred
607
+ // makes these cells undecryptable. Materialize stores the random
608
+ // row-secret AAD-sealed in _blamejs_per_row_keys.
609
+ var sealOpts;
610
+ var cfKey = this._cryptoFieldKey();
611
+ if (cryptoField.hasPerRowKey(cfKey)) {
612
+ var kRow = cryptoField.materializePerRowKey(cfKey, withId._id, this._db);
613
+ sealOpts = { kRow: kRow, rowId: withId._id };
614
+ }
615
+ var sealed = cryptoField.sealRow(cfKey, withId, sealOpts);
481
616
  var cols = Object.keys(sealed);
482
617
  var placeholders = cols.map(function () { return "?"; }).join(", ");
483
618
  var quotedCols = cols.map(function (c) { return '"' + c + '"'; }).join(", ");
@@ -514,7 +649,20 @@ class Query {
514
649
  if (this._where.length === 0) {
515
650
  throw new Error("refusing unconditional update — call where(...) first");
516
651
  }
517
- var sealed = cryptoField.sealRow(this._cryptoFieldKey(), changes);
652
+ // Residency gates on the plaintext change set — an UPDATE that
653
+ // touches the residency tag (or a region-bound column) is a
654
+ // transfer and goes through the same refusal matrix as INSERT.
655
+ _assertLocalResidency(this._cryptoFieldKey(), changes, "update");
656
+ var cfKey = this._cryptoFieldKey();
657
+ // Per-row-key tables: sealed columns must re-encrypt under EACH
658
+ // affected row's own K_row, so a single set-based UPDATE can't seal
659
+ // one value across rows. Resolve the affected _id set, then seal +
660
+ // write each row under its row-scoped key. Idempotent materialize
661
+ // re-derives the existing K_row (created on INSERT).
662
+ if (cryptoField.hasPerRowKey(cfKey)) {
663
+ return this._updatePerRowKey(cfKey, changes, single);
664
+ }
665
+ var sealed = cryptoField.sealRow(cfKey, changes);
518
666
  var setKeys = Object.keys(sealed);
519
667
  if (setKeys.length === 0) {
520
668
  throw new Error("update changes object is empty");
@@ -543,6 +691,41 @@ class Query {
543
691
  return info.changes;
544
692
  }
545
693
 
694
+ // Per-row-key UPDATE. Sealed columns on a declarePerRowKey table are
695
+ // K_row cells (vault.row:), so each affected row must be re-sealed
696
+ // under its OWN K_row — a single set-based UPDATE can't carry per-row
697
+ // ciphertext. Resolve the affected _id set via the WHERE, then for
698
+ // each row: materialize (idempotent) its K_row, seal the change set
699
+ // under it (derived hashes computed from plaintext as usual), and
700
+ // UPDATE that single row by _id. `single` stops after the first row.
701
+ _updatePerRowKey(cfKey, changes, single) {
702
+ var whereSql = this._where.join(" AND ");
703
+ var qt = this._quotedTable();
704
+ var idStmt = this._db.prepare(
705
+ "SELECT _id FROM " + qt + " WHERE " + whereSql + (single ? " LIMIT 1" : ""));
706
+ var idRows = idStmt.all.apply(idStmt, this._whereParams);
707
+ var changed = 0;
708
+ for (var r = 0; r < idRows.length; r++) {
709
+ var rowId = idRows[r]._id;
710
+ if (rowId === undefined || rowId === null) continue;
711
+ var kRow = cryptoField.materializePerRowKey(cfKey, rowId, this._db);
712
+ var sealed = cryptoField.sealRow(cfKey, changes, { kRow: kRow, rowId: rowId });
713
+ var setKeys = Object.keys(sealed);
714
+ if (setKeys.length === 0) {
715
+ throw new Error("update changes object is empty");
716
+ }
717
+ setKeys.forEach(_validateField);
718
+ var selfUpd = this;
719
+ setKeys.forEach(function (k) { selfUpd._assertColumnMember(k, "update"); });
720
+ var setClause = setKeys.map(function (k) { return '"' + k + '" = ?'; }).join(", ");
721
+ var setValues = setKeys.map(function (k) { return sealed[k]; });
722
+ var updStmt = this._db.prepare("UPDATE " + qt + " SET " + setClause + " WHERE _id = ?");
723
+ var info = updStmt.run.apply(updStmt, setValues.concat([rowId]));
724
+ changed += (info && info.changes) || 0;
725
+ }
726
+ return changed;
727
+ }
728
+
546
729
  deleteOne() {
547
730
  return this._delete(true) > 0;
548
731
  }
package/lib/db.js CHANGED
@@ -295,11 +295,21 @@ var FRAMEWORK_SCHEMA = [
295
295
  },
296
296
  {
297
297
  // Per-row crypto-erasure key registry — per-row keys.
298
- // Each entry holds a sealed wrapped K_row keyed by (table,
299
- // rowId). b.subject.eraseHard deletes the entry, leaving WAL /
300
- // replica residuals undecryptable.
298
+ // Each entry holds the AAD-sealed random row-secret keyed by
299
+ // (tableName, rowId); the row-scoped K_row is derived from it.
300
+ // b.subject.eraseHard / b.retention destroy the entry, leaving WAL /
301
+ // replica residuals undecryptable. wrappedKey is registered as an
302
+ // AAD-bound sealed field (aad:true, rowIdField:"rowId") so a vault
303
+ // keypair rotation auto-reseals it old-root -> new-root via
304
+ // rotate._rotateColumn — without this a rotation would orphan every
305
+ // wrapped secret and brick every keyed row.
301
306
  name: "_blamejs_per_row_keys",
302
307
  columns: {
308
+ // _id is the rotation pipeline's keyset-pagination + UPDATE key
309
+ // (rotate._rotateColumn SELECTs _id and orders by it); the natural
310
+ // identity stays the composite (tableName, rowId). materialize
311
+ // populates _id with a fresh token.
312
+ _id: "TEXT",
303
313
  tableName: "TEXT NOT NULL",
304
314
  rowId: "TEXT NOT NULL",
305
315
  wrappedKey: "BLOB NOT NULL",
@@ -307,6 +317,10 @@ var FRAMEWORK_SCHEMA = [
307
317
  },
308
318
  primaryKey: ["tableName", "rowId"],
309
319
  indexes: [],
320
+ sealedFields: ["wrappedKey"],
321
+ aad: true,
322
+ rowIdField: "rowId",
323
+ schemaVersion: "1",
310
324
  },
311
325
  {
312
326
  // Operator-declared WORM (write-once-read-many) registry. Each
@@ -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