@blamejs/core 0.14.21 → 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.
@@ -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 AND the per-row-key registry so a test fixture can
1340
- * re-declare both between cases. Operator code never calls this —
1341
- * production declarations come from `b.db.init({ schema })` once at
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