@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.
@@ -58,38 +58,293 @@ function _emitMetric(name, value, labels) {
58
58
  catch (_e) { /* hot-path observability sink — drop silent by design */ }
59
59
  }
60
60
 
61
- // Statement-class classifier for auth-failure forensics. Inspects
62
- // the leading keyword only so an attacker-controlled trailing fragment
63
- // can't smuggle a false classification. Skips leading whitespace plus
64
- // SQL line / block comments before reading the keyword.
65
- // Linear (non-backtracking) comment/whitespace skip: each iteration of
66
- // the outer group consumes exactly one whitespace char, one complete
67
- // block comment (matched with the star-not-slash form, never a lazy
68
- // `[\s\S]*?`), or one complete line comment disjoint by first char,
69
- // so there is no ambiguous repetition for a crafted SQL string of
70
- // nested `/**/` or `*/--` runs to backtrack on (CWE-1333 ReDoS).
61
+ // Statement-class classifier for auth-failure forensics AND the
62
+ // residency write gate. Reads the leading keyword but a leading
63
+ // keyword is not the whole story: `WITH ... INSERT`, `EXPLAIN ANALYZE
64
+ // INSERT`, `CALL`/`EXECUTE`/`DO`, `COPY ... FROM`, and `REPLACE` all
65
+ // place rows while their leading (or only) keyword reads as harmless.
66
+ // _classifyStatement unwraps WITH (CTE) and EXPLAIN [ANALYZE] prefixes
67
+ // to the effective verb so the gate sees the real statement class; an
68
+ // attacker-controlled trailing fragment still can't smuggle a false
69
+ // class because the multi-statement form is refused upstream
70
+ // (_hasTrailingStatement) and an unresolvable prefix classifies
71
+ // UNKNOWN (fail-closed on the gate's enforced path).
72
+ //
73
+ // Skips leading whitespace plus SQL line / block comments before
74
+ // reading the keyword. Linear (non-backtracking) comment/whitespace
75
+ // skip: each iteration of the outer group consumes exactly one
76
+ // whitespace char, one complete block comment (star-not-slash form,
77
+ // never a lazy `[\s\S]*?`), or one complete line comment — disjoint by
78
+ // first char, so a crafted SQL string of nested `/**/` or `*/--` runs
79
+ // cannot backtrack polynomially (CWE-1333 ReDoS).
71
80
  var _STATEMENT_CLASS_RE = /^(?:\s|\/\*(?:[^*]|\*(?!\/))*\*\/|--[^\n]*\n)*([A-Za-z]+)/;
72
81
  var _STATEMENT_CLASS_MAP = Object.freeze({
73
- SELECT: "SELECT", WITH: "SELECT", VALUES: "SELECT", TABLE: "SELECT",
74
- INSERT: "DML", UPDATE: "DML", DELETE: "DML", MERGE: "DML", UPSERT: "DML",
82
+ SELECT: "SELECT", VALUES: "SELECT", TABLE: "SELECT",
83
+ SHOW: "READ_INFO", DESCRIBE: "READ_INFO", DESC: "READ_INFO",
84
+ PRAGMA: "READ_INFO", USE: "READ_INFO",
85
+ INSERT: "DML", UPDATE: "DML", DELETE: "DML", MERGE: "DML",
86
+ UPSERT: "DML", REPLACE: "DML",
75
87
  CREATE: "DDL", DROP: "DDL", ALTER: "DDL", TRUNCATE: "DDL",
76
88
  RENAME: "DDL", COMMENT: "DDL",
77
89
  GRANT: "DCL", REVOKE: "DCL",
78
90
  SET: "SESSION", RESET: "SESSION",
79
91
  BEGIN: "TX", START: "TX", COMMIT: "TX", ROLLBACK: "TX",
80
92
  SAVEPOINT: "TX", RELEASE: "TX",
81
- CALL: "ROUTINE", EXECUTE: "ROUTINE",
93
+ CALL: "ROUTINE", EXECUTE: "ROUTINE", DO: "ROUTINE",
82
94
  COPY: "BULK",
83
95
  EXPLAIN: "META", ANALYZE: "META", VACUUM: "META",
84
96
  });
85
97
 
98
+ // Main-statement keywords that may follow a WITH (CTE) clause list —
99
+ // the verb that decides the statement's effect. `WITH src AS (...)
100
+ // INSERT INTO ...` is a write; classifying it by its leading keyword
101
+ // would label it a read and wave it past the residency write gate.
102
+ var _CTE_MAIN_VERBS = Object.freeze({
103
+ SELECT: true, VALUES: true, TABLE: true,
104
+ INSERT: true, UPDATE: true, DELETE: true,
105
+ MERGE: true, UPSERT: true, REPLACE: true,
106
+ });
107
+
108
+ // SQL identifier character classes, as char-range predicates rather
109
+ // than `_isIdentChar(ch)` regex literals — faster per-char in
110
+ // the tight statement-scan loops and keeps the trivial word-char class
111
+ // out of the cross-file duplicate-regex catalog.
112
+ function _isIdentStart(ch) {
113
+ return (ch >= "a" && ch <= "z") || (ch >= "A" && ch <= "Z") || ch === "_";
114
+ }
115
+ function _isIdentChar(ch) {
116
+ return _isIdentStart(ch) || (ch >= "0" && ch <= "9");
117
+ }
118
+
119
+ // If sql[i] begins an opaque span — a string literal ('...' with
120
+ // doubled-quote escapes), a quoted identifier ("..." / `...` / [...]),
121
+ // a Postgres dollar-quoted body ($tag$...$tag$), or a SQL line / block
122
+ // comment — return the index just past its close; -1 if the span is
123
+ // unterminated; i unchanged if sql[i] does not begin a span. One
124
+ // linear scan per call, no backtracking regex (CWE-1333). Shared by
125
+ // every SQL leading-/effective-verb scanner below so the multi-
126
+ // statement, CTE, EXPLAIN, and COPY walkers all agree on what counts
127
+ // as data vs. structure across the Postgres / MySQL / SQLite dialects
128
+ // external-db targets. A doubled closing quote ('it''s') re-enters as
129
+ // a fresh empty span on the caller's next iteration, staying opaque.
130
+ function _skipOpaqueSpan(sql, i) {
131
+ var n = sql.length;
132
+ var ch = sql.charAt(i);
133
+ if (ch === "'" || ch === "\"" || ch === "`") {
134
+ var close = sql.indexOf(ch, i + 1);
135
+ return close === -1 ? -1 : close + 1;
136
+ }
137
+ if (ch === "[") { // SQLite / MSSQL bracket identifier
138
+ var rb = sql.indexOf("]", i + 1);
139
+ return rb === -1 ? -1 : rb + 1;
140
+ }
141
+ if (ch === "$") {
142
+ // $tag$ ... $tag$ dollar-quoted body; a bare $n placeholder has no
143
+ // second `$` after the run of word chars and is not a span.
144
+ var tagEnd = i + 1;
145
+ while (tagEnd < n && _isIdentChar(sql.charAt(tagEnd))) tagEnd += 1;
146
+ if (tagEnd < n && sql.charAt(tagEnd) === "$") {
147
+ var tag = sql.slice(i, tagEnd + 1);
148
+ var closeTag = sql.indexOf(tag, tagEnd + 1);
149
+ return closeTag === -1 ? -1 : closeTag + tag.length;
150
+ }
151
+ return i;
152
+ }
153
+ if (ch === "-" && sql.charAt(i + 1) === "-") {
154
+ var nl = sql.indexOf("\n", i + 2);
155
+ return nl === -1 ? -1 : nl + 1;
156
+ }
157
+ if (ch === "/" && sql.charAt(i + 1) === "*") {
158
+ var ce = sql.indexOf("*/", i + 2);
159
+ return ce === -1 ? -1 : ce + 2;
160
+ }
161
+ return i;
162
+ }
163
+
164
+ // Resolve the main-statement keyword of a WITH-prefixed (CTE)
165
+ // statement: walk past the CTE definition list to the first top-level
166
+ // main verb (opaque spans skipped via _skipOpaqueSpan, parens tracked
167
+ // by depth). `WITH src AS (...) INSERT INTO ...` is a write; the
168
+ // leading keyword would label it a read and wave it past the residency
169
+ // write gate. Returns the uppercased verb, or null when unresolvable
170
+ // (unterminated span, parenthesized main statement, stray close-paren,
171
+ // no verb found) — the gate REFUSES unresolvable statements on its
172
+ // enforced path, so a parse miss fails closed.
173
+ function _cteMainKeyword(sql, start) {
174
+ var n = sql.length;
175
+ var depth = 0;
176
+ var i = start;
177
+ while (i < n) {
178
+ var ch = sql.charAt(i);
179
+ var skipped = _skipOpaqueSpan(sql, i);
180
+ if (skipped === -1) return null;
181
+ if (skipped !== i) { i = skipped; continue; }
182
+ if (ch === "(") { depth += 1; i += 1; continue; }
183
+ if (ch === ")") { depth -= 1; i += 1; continue; }
184
+ if (_isIdentStart(ch)) {
185
+ var we = i + 1;
186
+ while (we < n && _isIdentChar(sql.charAt(we))) we += 1;
187
+ if (depth === 0) {
188
+ var word = sql.slice(i, we).toUpperCase();
189
+ if (_CTE_MAIN_VERBS[word] === true) return word;
190
+ // Top-level word that is not a main verb: CTE name, AS,
191
+ // RECURSIVE, [NOT] MATERIALIZED, or a SEARCH / CYCLE clause
192
+ // word — keep walking.
193
+ }
194
+ i = we;
195
+ continue;
196
+ }
197
+ i += 1;
198
+ }
199
+ return null;
200
+ }
201
+
202
+ // EXPLAIN option words (Postgres parenthesized + legacy bare; MySQL
203
+ // bare). These precede the inner statement and never terminate the
204
+ // option list, so the verb scanner skips them; only ANALYZE flips the
205
+ // "this EXPLAIN actually executes the statement" bit.
206
+ var _EXPLAIN_OPTION_WORDS = Object.freeze({
207
+ ANALYZE: true, VERBOSE: true, COSTS: true, BUFFERS: true,
208
+ SETTINGS: true, WAL: true, TIMING: true, SUMMARY: true,
209
+ SERIALIZE: true, MEMORY: true, GENERIC_PLAN: true, FORMAT: true,
210
+ TEXT: true, JSON: true, YAML: true, XML: true, TREE: true,
211
+ EXTENDED: true, PARTITIONS: true,
212
+ ON: true, OFF: true, TRUE: true, FALSE: true,
213
+ });
214
+
215
+ // Resolve an EXPLAIN prefix: skip the option list (a parenthesized
216
+ // `( ANALYZE, FORMAT JSON )` group and/or bare option words), noting
217
+ // whether ANALYZE is present, and return { hasAnalyze, innerStart }
218
+ // pointing at the wrapped statement's leading keyword. null when
219
+ // unresolvable (unterminated span, unbalanced option parens, no inner
220
+ // statement). EXPLAIN ANALYZE EXECUTES the wrapped statement, so an
221
+ // `EXPLAIN ANALYZE INSERT ...` is a real write the gate must see.
222
+ function _explainResolve(sql, start) {
223
+ var n = sql.length;
224
+ var hasAnalyze = false;
225
+ var i = start;
226
+ while (i < n) {
227
+ var ch = sql.charAt(i);
228
+ var skipped = _skipOpaqueSpan(sql, i);
229
+ if (skipped === -1) return null;
230
+ if (skipped !== i) { i = skipped; continue; }
231
+ if (ch === "(") {
232
+ var depth = 0;
233
+ var j = i;
234
+ while (j < n) {
235
+ var c2 = sql.charAt(j);
236
+ var s2 = _skipOpaqueSpan(sql, j);
237
+ if (s2 === -1) return null;
238
+ if (s2 !== j) { j = s2; continue; }
239
+ if (c2 === "(") { depth += 1; j += 1; continue; }
240
+ if (c2 === ")") { depth -= 1; j += 1; if (depth === 0) break; continue; }
241
+ if (_isIdentStart(c2)) {
242
+ var oe = j + 1;
243
+ while (oe < n && _isIdentChar(sql.charAt(oe))) oe += 1;
244
+ if (sql.slice(j, oe).toUpperCase() === "ANALYZE") hasAnalyze = true;
245
+ j = oe;
246
+ continue;
247
+ }
248
+ j += 1;
249
+ }
250
+ if (depth !== 0) return null;
251
+ i = j;
252
+ continue;
253
+ }
254
+ if (_isIdentStart(ch)) {
255
+ var we = i + 1;
256
+ while (we < n && _isIdentChar(sql.charAt(we))) we += 1;
257
+ var word = sql.slice(i, we).toUpperCase();
258
+ if (word === "ANALYZE") { hasAnalyze = true; i = we; continue; }
259
+ if (_EXPLAIN_OPTION_WORDS[word] === true) { i = we; continue; }
260
+ return { hasAnalyze: hasAnalyze, innerStart: i };
261
+ }
262
+ i += 1;
263
+ }
264
+ return null;
265
+ }
266
+
267
+ // COPY <target> FROM <source> LOADS rows (a write); COPY <target> /
268
+ // COPY (query) TO <dest> EXPORTS rows (a read). Find the first
269
+ // top-level FROM / TO keyword after COPY, skipping a parenthesized
270
+ // source query and opaque spans. FROM → true (load); TO → false
271
+ // (export); unresolvable or neither → true (fail-closed write).
272
+ function _copyLoadsRows(sql) {
273
+ var m = _STATEMENT_CLASS_RE.exec(sql);
274
+ if (!m) return true;
275
+ var n = sql.length;
276
+ var i = m.index + m[0].length;
277
+ while (i < n) {
278
+ var ch = sql.charAt(i);
279
+ var skipped = _skipOpaqueSpan(sql, i);
280
+ if (skipped === -1) return true;
281
+ if (skipped !== i) { i = skipped; continue; }
282
+ if (ch === "(") {
283
+ var depth = 0;
284
+ var j = i;
285
+ while (j < n) {
286
+ var c2 = sql.charAt(j);
287
+ var s2 = _skipOpaqueSpan(sql, j);
288
+ if (s2 === -1) return true;
289
+ if (s2 !== j) { j = s2; continue; }
290
+ if (c2 === "(") { depth += 1; j += 1; continue; }
291
+ if (c2 === ")") { depth -= 1; j += 1; if (depth === 0) break; continue; }
292
+ j += 1;
293
+ }
294
+ i = j;
295
+ continue;
296
+ }
297
+ if (_isIdentStart(ch)) {
298
+ var we = i + 1;
299
+ while (we < n && _isIdentChar(sql.charAt(we))) we += 1;
300
+ var word = sql.slice(i, we).toUpperCase();
301
+ if (word === "FROM") return true;
302
+ if (word === "TO") return false;
303
+ i = we;
304
+ continue;
305
+ }
306
+ i += 1;
307
+ }
308
+ return true;
309
+ }
310
+
311
+ // Forensic / gate statement class. Resolves WITH (CTE) and EXPLAIN
312
+ // [ANALYZE] prefixes to the effective verb so a write wearing a
313
+ // harmless leading keyword classifies as the write it is; unresolvable
314
+ // prefixes classify UNKNOWN (fail-closed at the gate).
86
315
  function _classifyStatement(sql) {
87
316
  if (typeof sql !== "string" || sql.length === 0) return "UNKNOWN";
88
317
  var m = _STATEMENT_CLASS_RE.exec(sql);
89
318
  if (!m) return "UNKNOWN";
90
- return _STATEMENT_CLASS_MAP[m[1].toUpperCase()] || "OTHER";
319
+ var kw = m[1].toUpperCase();
320
+ if (kw === "WITH") {
321
+ var main = _cteMainKeyword(sql, m.index + m[0].length);
322
+ return main === null ? "UNKNOWN" : (_STATEMENT_CLASS_MAP[main] || "OTHER");
323
+ }
324
+ if (kw === "EXPLAIN") {
325
+ // Plan-only EXPLAIN reads (META). EXPLAIN ANALYZE executes the
326
+ // wrapped statement, so its effective class is the inner one's.
327
+ var ex = _explainResolve(sql, m.index + m[0].length);
328
+ if (ex === null) return "UNKNOWN";
329
+ if (!ex.hasAnalyze) return "META";
330
+ return _classifyStatement(sql.slice(ex.innerStart));
331
+ }
332
+ return _STATEMENT_CLASS_MAP[kw] || "OTHER";
91
333
  }
92
334
 
335
+ // Statement classes that place no rows on the backend, so the cross-
336
+ // border residency write gate lets them pass without a row tag. Every
337
+ // other class — DML, ROUTINE (CALL / EXECUTE / DO), a COPY ... FROM
338
+ // load, or an unmapped/unresolved statement — is treated as a write
339
+ // and must carry a residency tag on the enforced path. DDL (schema
340
+ // changes) and DCL (grants) move no row data across a border; the one
341
+ // edge they don't cover, CREATE TABLE AS SELECT / SELECT INTO, is a
342
+ // documented residency limitation rather than a silent bypass.
343
+ var _RESIDENCY_READ_CLASS = Object.freeze({
344
+ SELECT: true, READ_INFO: true, SESSION: true,
345
+ TX: true, DCL: true, DDL: true, META: true,
346
+ });
347
+
93
348
  // ---- OpenTelemetry database-client semantic conventions ----
94
349
  //
95
350
  // db.* span / metric attributes on the query / transaction / read emit
@@ -738,6 +993,7 @@ function _servesClassification(b, cls) {
738
993
  * backend?: string, // explicit backend name; bypasses classification + role pick
739
994
  * classification?: string, // route to first backend whose classifications include this value
740
995
  * includeSqlInAudit?: boolean, // emit SQL text in audit metadata (off by default — may carry literal PII)
996
+ * rowResidencyTag?: string, // the row's residency region tag; required for a write (DML, CALL/EXECUTE/DO, COPY ... FROM, REPLACE, or a WITH/EXPLAIN-ANALYZE wrapping one) to a residency-tagged backend under a cross-border regulated posture (pass "global"/"unrestricted" for region-neutral rows)
741
997
  *
742
998
  * @example
743
999
  * var res = await b.externalDb.query(
@@ -754,6 +1010,14 @@ async function query(sql, params, opts) {
754
1010
  var b = _pickBackend(opts);
755
1011
  var role = dbRoleContext.getRole();
756
1012
 
1013
+ // Per-row residency write gate — refuses a cross-border write before
1014
+ // the statement reaches the wire (see _assertRowResidency).
1015
+ var _resRefusal = _assertRowResidency(sql, opts, b);
1016
+ if (_resRefusal) {
1017
+ _emit("db.residency.gate.rejected", "denied", _resRefusal.metadata, _resRefusal.code);
1018
+ throw _err(_resRefusal.code, _resRefusal.message, true);
1019
+ }
1020
+
757
1021
  var t0 = Date.now();
758
1022
  try {
759
1023
  var result = await retryHelper.withRetry(function () {
@@ -854,6 +1118,7 @@ async function query(sql, params, opts) {
854
1118
  * statementTimeoutMs?: number, // SET LOCAL statement_timeout
855
1119
  * idleInTransactionTimeoutMs?: number, // SET LOCAL idle_in_transaction_session_timeout
856
1120
  * deadlockRetries?: number, // retries for 40P01 / 40001 (default 3)
1121
+ * rowResidencyTag?: string, // residency tag applied to every statement; a per-call tx.query(sql, params, { rowResidencyTag }) overrides it for that statement
857
1122
  *
858
1123
  * @example
859
1124
  * var summary = await b.externalDb.transaction(async function (tx) {
@@ -907,10 +1172,34 @@ async function transaction(fn, opts) {
907
1172
  }
908
1173
  var maxRetries = (typeof opts.deadlockRetries === "number")
909
1174
  ? Math.floor(opts.deadlockRetries) : 3; // allow:numeric-opt-Infinity
1175
+ // Validate the transaction-level residency tag shape at entry (the
1176
+ // sessionGucs / deadlockRetries discipline) so an empty-string tag
1177
+ // fails before BEGIN rather than at the first statement.
1178
+ if (opts.rowResidencyTag !== undefined && opts.rowResidencyTag !== null &&
1179
+ (typeof opts.rowResidencyTag !== "string" || opts.rowResidencyTag.length === 0)) {
1180
+ throw _err("INVALID_OPT",
1181
+ "transaction: opts.rowResidencyTag must be a non-empty string when supplied", true);
1182
+ }
910
1183
  return await b.breaker.wrap(async function () {
911
1184
  var client = await b.pool.acquire();
912
1185
  var txClient = {
913
- query: function (sql, params) { return b.query(client, sql, params || []); },
1186
+ // Per-statement residency gate inside the transaction: a
1187
+ // transaction-level opts.rowResidencyTag applies to every
1188
+ // statement; an optional per-call third argument overrides it
1189
+ // for that statement. A refusal throws into the operator's tx
1190
+ // body, which rolls the transaction back — no partial commit of
1191
+ // a cross-border write.
1192
+ query: function (sql, params, perCallOpts) {
1193
+ var effOpts = (perCallOpts && perCallOpts.rowResidencyTag !== undefined)
1194
+ ? perCallOpts
1195
+ : { rowResidencyTag: opts.rowResidencyTag };
1196
+ var refusal = _assertRowResidency(sql, effOpts, b);
1197
+ if (refusal) {
1198
+ _emit("db.residency.gate.rejected", "denied", refusal.metadata, refusal.code);
1199
+ throw _err(refusal.code, refusal.message, true);
1200
+ }
1201
+ return b.query(client, sql, params || []);
1202
+ },
914
1203
  };
915
1204
  var committed = false;
916
1205
  var attempt = 0;
@@ -1229,9 +1518,17 @@ var REPLICA_UNHEALTHY_COOLDOWN_MS = C.TIME.seconds(30);
1229
1518
  // - "unrestricted" tag on either side: compatible (operator
1230
1519
  // declared no constraint).
1231
1520
  // - Different tags: compatible only when allowCrossBorder is true.
1232
- var CROSS_BORDER_REGULATED_POSTURES = Object.freeze([
1233
- "gdpr", "uk-gdpr", "dpdp", "pipl-cn", "lgpd-br", "appi-jp", "pdpa-sg",
1234
- ]);
1521
+ //
1522
+ // The regulated-posture set itself lives on b.compliance
1523
+ // (CROSS_BORDER_REGULATED_POSTURES) — one vocabulary shared with the
1524
+ // local db-query residency gate.
1525
+ function _crossBorderRegulated(posture) {
1526
+ if (posture === null || posture === undefined) return false;
1527
+ try {
1528
+ var compliance = require("./compliance"); // allow:inline-require — defensive against optional load
1529
+ return compliance.isCrossBorderRegulated(posture);
1530
+ } catch (_e) { return false; }
1531
+ }
1235
1532
 
1236
1533
  function _residencyCompatible(primaryTag, replicaTag) {
1237
1534
  if (!primaryTag || !replicaTag) return true;
@@ -1240,6 +1537,54 @@ function _residencyCompatible(primaryTag, replicaTag) {
1240
1537
  return false;
1241
1538
  }
1242
1539
 
1540
+ // True when `sql` carries a non-comment, non-whitespace statement after
1541
+ // the first top-level semicolon — the multi-statement shape that would
1542
+ // let a trailing DML hide behind a harmless leading keyword. A `;`
1543
+ // inside any opaque span (string literal, quoted identifier, dollar-
1544
+ // quoted body, comment) is data, not a separator: the main scan skips
1545
+ // every span via _skipOpaqueSpan, so a `;` inside `$$ ... ; ... $$` or
1546
+ // a doubled-quote run can't be mistaken for a top-level separator (and
1547
+ // can't desync the scanner into missing a real one). Single linear
1548
+ // pass, no backtracking regex (CWE-1333).
1549
+ function _hasTrailingStatement(sql) {
1550
+ if (typeof sql !== "string") return false;
1551
+ var n = sql.length;
1552
+ var i = 0;
1553
+ while (i < n) {
1554
+ var ch = sql.charAt(i);
1555
+ var skipped = _skipOpaqueSpan(sql, i);
1556
+ if (skipped === -1) return false; // unterminated span — no top-level content beyond it
1557
+ if (skipped !== i) { i = skipped; continue; }
1558
+ if (ch !== ";") { i += 1; continue; }
1559
+ // First top-level `;` decides: if everything after it is only
1560
+ // whitespace and SQL comments there is no second statement (a
1561
+ // single statement may end with `;`); any other character is one.
1562
+ // Comments-only skip here (not _skipOpaqueSpan) — a string / ident
1563
+ // after the `;` IS trailing content, so it must count, not be
1564
+ // skipped as a span.
1565
+ var j = i + 1;
1566
+ while (j < n) {
1567
+ var c = sql.charAt(j);
1568
+ if (c === " " || c === "\t" || c === "\r" || c === "\n") { j += 1; continue; }
1569
+ if (c === "/" && sql.charAt(j + 1) === "*") {
1570
+ var end = sql.indexOf("*/", j + 2);
1571
+ if (end === -1) return false; // unterminated comment — no content
1572
+ j = end + 2;
1573
+ continue;
1574
+ }
1575
+ if (c === "-" && sql.charAt(j + 1) === "-") {
1576
+ var nl = sql.indexOf("\n", j + 2);
1577
+ if (nl === -1) return false; // line comment to EOF — no content
1578
+ j = nl + 1;
1579
+ continue;
1580
+ }
1581
+ return true;
1582
+ }
1583
+ return false;
1584
+ }
1585
+ return false;
1586
+ }
1587
+
1243
1588
  function _activePosture() {
1244
1589
  try {
1245
1590
  var compliance = require("./compliance"); // allow:inline-require — defensive against optional load
@@ -1247,6 +1592,115 @@ function _activePosture() {
1247
1592
  } catch (_e) { return null; }
1248
1593
  }
1249
1594
 
1595
+ // Per-row residency write gate (GDPR Art 44-46 / PIPL Art 38 / DPDP
1596
+ // §16 cross-border transfer restrictions). External-db takes raw SQL,
1597
+ // not row objects, so the row's residency tag travels as
1598
+ // `opts.rowResidencyTag` — the operator computes it from app logic
1599
+ // (session / declared user region), never inferred from request
1600
+ // metadata. Under a cross-border regulated posture, DML to a
1601
+ // residency-tagged backend REQUIRES the tag and refuses a mismatch;
1602
+ // untagged backends, unregulated postures, and non-DML statements
1603
+ // pass (with an advisory audit when a tag was supplied anyway).
1604
+ // Returns null on pass or { code, message, metadata } — the caller
1605
+ // throws via _err so the refusal carries permanent=true.
1606
+ function _assertRowResidency(sql, opts, backend) {
1607
+ var tag = opts && opts.rowResidencyTag;
1608
+ if (tag !== undefined && tag !== null &&
1609
+ (typeof tag !== "string" || tag.length === 0)) {
1610
+ return {
1611
+ code: "INVALID_OPT",
1612
+ message: "rowResidencyTag must be a non-empty string when supplied",
1613
+ metadata: { backend: backend.name, statementClass: _classifyStatement(sql) },
1614
+ };
1615
+ }
1616
+ var backendTag = backend.residencyTag || "unrestricted";
1617
+ var posture = _activePosture();
1618
+ var regulated = _crossBorderRegulated(posture);
1619
+ // The gate only enforces on the cross-border-regulated + residency-
1620
+ // tagged-backend path. Everywhere else (unregulated posture,
1621
+ // unrestricted backend — including the framework's own coordination
1622
+ // stores) statements pass untouched; multi-statement SQL stays the
1623
+ // operator's business there.
1624
+ if (regulated && backendTag !== "unrestricted") {
1625
+ // A trailing statement after a top-level `;` could hide a write
1626
+ // behind a harmless prefix — refuse multi-statement SQL on the
1627
+ // enforced path so a residency-bound write cannot ride a SELECT.
1628
+ if (_hasTrailingStatement(sql)) {
1629
+ return {
1630
+ code: "MULTI_STATEMENT_REFUSED",
1631
+ message: "multi-statement SQL is not supported on the residency-gated " +
1632
+ "write path; pass one statement per query()",
1633
+ metadata: { backend: backend.name, backendTag: backendTag, posture: posture,
1634
+ statementClass: _classifyStatement(sql), scope: "external" },
1635
+ };
1636
+ }
1637
+ var cls = _classifyStatement(sql);
1638
+ // Fail-closed: a statement whose effective class can't be resolved
1639
+ // (an unparseable WITH / EXPLAIN prefix or pathological quoting)
1640
+ // could be hiding a write, so refuse it rather than wave it through
1641
+ // as a read.
1642
+ if (cls === "UNKNOWN") {
1643
+ return {
1644
+ code: "STATEMENT_UNRESOLVED_REFUSED",
1645
+ message: "could not resolve the effective statement class on the " +
1646
+ "residency-gated write path; pass one plain statement per " +
1647
+ "query() (an unparseable WITH/EXPLAIN prefix or quoting)",
1648
+ metadata: { backend: backend.name, backendTag: backendTag, posture: posture,
1649
+ statementClass: cls, scope: "external" },
1650
+ };
1651
+ }
1652
+ // The gate enforces on writes, not just DML: ROUTINE (CALL /
1653
+ // EXECUTE / DO), a COPY ... FROM load, and an unmapped (OTHER)
1654
+ // verb all place rows and must carry a tag. Recognized pure reads
1655
+ // (and a COPY ... TO export) place none and pass untagged.
1656
+ var isWrite = !(_RESIDENCY_READ_CLASS[cls] === true ||
1657
+ (cls === "BULK" && !_copyLoadsRows(sql)));
1658
+ if (!isWrite) return null;
1659
+ if (!tag) {
1660
+ return {
1661
+ code: "RESIDENCY_GATE_REQUIRED",
1662
+ message: "write to backend '" + backend.name + "' (residencyTag='" +
1663
+ backendTag + "') under '" + posture + "' posture requires " +
1664
+ "opts.rowResidencyTag. Pass { rowResidencyTag: \"" + backendTag +
1665
+ "\" } for rows belonging to this region, or declare per-row " +
1666
+ "residency via b.cryptoField.declarePerRowResidency for local tables",
1667
+ metadata: { backend: backend.name, backendTag: backendTag,
1668
+ rowTag: null, posture: posture, statementClass: cls,
1669
+ scope: "external" },
1670
+ };
1671
+ }
1672
+ if (tag !== "global" && tag !== "unrestricted" &&
1673
+ !_residencyCompatible(tag, backendTag)) {
1674
+ return {
1675
+ code: "RESIDENCY_TAG_MISMATCH",
1676
+ message: "row residencyTag '" + tag + "' is not compatible with backend '" +
1677
+ backend.name + "' residencyTag '" + backendTag + "' under '" + posture +
1678
+ "' posture (cross-border transfer refused)",
1679
+ metadata: { backend: backend.name, backendTag: backendTag,
1680
+ rowTag: tag, posture: posture, statementClass: cls,
1681
+ scope: "external" },
1682
+ };
1683
+ }
1684
+ return null;
1685
+ }
1686
+ if (tag) {
1687
+ // Unregulated posture or untagged backend with a tag supplied on a
1688
+ // write — pass, but surface the advisory so operators staging a
1689
+ // posture flip can see what WOULD be evaluated.
1690
+ var advisoryCls = _classifyStatement(sql);
1691
+ var advisoryWrite = advisoryCls !== "UNKNOWN" &&
1692
+ !(_RESIDENCY_READ_CLASS[advisoryCls] === true ||
1693
+ (advisoryCls === "BULK" && !_copyLoadsRows(sql)));
1694
+ if (advisoryWrite) {
1695
+ _emit("db.residency.gate.advisory", "info", {
1696
+ backend: backend.name, backendTag: backendTag, rowTag: tag,
1697
+ posture: posture || null, statementClass: advisoryCls, scope: "external",
1698
+ });
1699
+ }
1700
+ }
1701
+ return null;
1702
+ }
1703
+
1250
1704
  function _buildReplicas(backendName, cfg) {
1251
1705
  if (!cfg.replicas) return null;
1252
1706
  if (!Array.isArray(cfg.replicas) || cfg.replicas.length === 0) {
@@ -1275,7 +1729,7 @@ function _buildReplicas(backendName, cfg) {
1275
1729
  var replicaTag = r.residencyTag || "unrestricted";
1276
1730
  var allowCrossBorder = r.allowCrossBorder === true;
1277
1731
  if (!_residencyCompatible(primaryTag, replicaTag) && !allowCrossBorder) {
1278
- var underPosture = posture && CROSS_BORDER_REGULATED_POSTURES.indexOf(posture) !== -1;
1732
+ var underPosture = _crossBorderRegulated(posture);
1279
1733
  throw _err("RESIDENCY_MISMATCH",
1280
1734
  "backend '" + backendName + "': replica[" + i +
1281
1735
  "] residencyTag '" + replicaTag +
@@ -1286,7 +1740,12 @@ function _buildReplicas(backendName, cfg) {
1286
1740
  "documented legal basis (SCCs / BCRs / adequacy decision) to suppress.", true);
1287
1741
  }
1288
1742
  if (!_residencyCompatible(primaryTag, replicaTag) && allowCrossBorder) {
1289
- _emit("externalDb.replica.cross_border_allowed", "warning",
1743
+ // The action name MUST stay in the registered `db.` namespace and
1744
+ // lowercase — the audit validator refuses "externalDb.*" (the old
1745
+ // name silently dropped every cross-border-allowed event through
1746
+ // safeEmit, leaving no audit-chain record of the operator's
1747
+ // conscious opt-in). Mirrors the read-path event name below.
1748
+ _emit("db.residency.replica.cross_border_allowed", "warning",
1290
1749
  { backend: backendName, replicaIndex: i,
1291
1750
  primaryTag: primaryTag, replicaTag: replicaTag,
1292
1751
  legalBasis: r.legalBasis || null,
@@ -1344,6 +1803,35 @@ async function _readQuery(sql, params, opts) {
1344
1803
  throw _err("ALL_REPLICAS_UNHEALTHY",
1345
1804
  "backend '" + b.name + "': all replicas unhealthy and fallback disabled", true);
1346
1805
  }
1806
+ // Replica residency check — when the caller identifies the row's
1807
+ // region (opts.rowResidencyTag) under a regulated posture, a read
1808
+ // routed to an incompatible replica is refused unless the replica
1809
+ // was explicitly configured allowCrossBorder (which is audited).
1810
+ if (opts.rowResidencyTag && typeof opts.rowResidencyTag === "string") {
1811
+ var _readPosture = _activePosture();
1812
+ if (_crossBorderRegulated(_readPosture) &&
1813
+ opts.rowResidencyTag !== "global" && opts.rowResidencyTag !== "unrestricted" &&
1814
+ !_residencyCompatible(opts.rowResidencyTag, replica.residencyTag)) {
1815
+ if (replica.allowCrossBorder) {
1816
+ _emit("db.residency.replica.cross_border", "warning", {
1817
+ backend: b.name, replicaIdx: replica.index,
1818
+ rowTag: opts.rowResidencyTag, replicaTag: replica.residencyTag,
1819
+ posture: _readPosture,
1820
+ });
1821
+ } else {
1822
+ _emit("db.residency.replica.incompatible", "denied", {
1823
+ backend: b.name, replicaIdx: replica.index,
1824
+ rowTag: opts.rowResidencyTag, replicaTag: replica.residencyTag,
1825
+ posture: _readPosture,
1826
+ });
1827
+ throw _err("REPLICA_RESIDENCY_INCOMPATIBLE",
1828
+ "read for row residencyTag '" + opts.rowResidencyTag + "' routed to replica " +
1829
+ replica.index + " of backend '" + b.name + "' (residencyTag='" +
1830
+ replica.residencyTag + "') under '" + _readPosture +
1831
+ "' posture; set allowCrossBorder on the replica to permit (audited)", true);
1832
+ }
1833
+ }
1834
+ }
1347
1835
  var role = dbRoleContext.getRole();
1348
1836
  var t0 = Date.now();
1349
1837
  try {
@@ -125,6 +125,11 @@ var QueueError = defineClass("QueueError");
125
125
  // them and skips immediately rather than hammering a misconfig.
126
126
  var RedisError = defineClass("RedisError");
127
127
  var ExternalDbError = defineClass("ExternalDbError");
128
+ // DbQueryError covers the local-SQLite query-builder refusal paths
129
+ // (residency write gates, malformed-call shapes). Refusals pass the
130
+ // permanent flag explicitly — a residency mismatch never becomes valid
131
+ // on retry, while the class stays open for transient codes later.
132
+ var DbQueryError = defineClass("DbQueryError");
128
133
  var ClusterError = defineClass("ClusterError");
129
134
  var ClusterProviderError = defineClass("ClusterProviderError");
130
135
  var HandlerError = defineClass("HandlerError", { withCause: true });
@@ -653,6 +658,7 @@ module.exports = {
653
658
  QueueError: QueueError,
654
659
  RedisError: RedisError,
655
660
  ExternalDbError: ExternalDbError,
661
+ DbQueryError: DbQueryError,
656
662
  ClusterError: ClusterError,
657
663
  ClusterProviderError: ClusterProviderError,
658
664
  HandlerError: HandlerError,