@blamejs/core 0.15.13 → 0.15.15

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.
Files changed (208) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/index.js +2 -0
  3. package/lib/a2a-tasks.js +38 -6
  4. package/lib/agent-event-bus.js +13 -0
  5. package/lib/agent-idempotency.js +5 -1
  6. package/lib/agent-snapshot.js +32 -2
  7. package/lib/ai-aedt-bias-audit.js +2 -1
  8. package/lib/ai-content-detect.js +1 -3
  9. package/lib/ai-frontier-protocol.js +1 -1
  10. package/lib/ai-model-manifest.js +1 -1
  11. package/lib/ai-output.js +16 -7
  12. package/lib/api-snapshot.js +4 -1
  13. package/lib/app-shutdown.js +7 -1
  14. package/lib/archive-gz.js +9 -0
  15. package/lib/archive-read.js +9 -7
  16. package/lib/archive-tar-read.js +51 -8
  17. package/lib/archive.js +4 -2
  18. package/lib/asn1-der.js +70 -22
  19. package/lib/atomic-file.js +204 -2
  20. package/lib/audit-chain.js +54 -5
  21. package/lib/audit-daily-review.js +12 -2
  22. package/lib/audit-sign.js +2 -1
  23. package/lib/audit-tools.js +108 -23
  24. package/lib/audit.js +7 -2
  25. package/lib/auth/access-lock.js +2 -2
  26. package/lib/auth/bot-challenge.js +1 -1
  27. package/lib/auth/ciba.js +71 -8
  28. package/lib/auth/dpop.js +15 -1
  29. package/lib/auth/fido-mds3.js +30 -13
  30. package/lib/auth/jwt.js +21 -5
  31. package/lib/auth/lockout.js +18 -2
  32. package/lib/auth/oauth.js +8 -2
  33. package/lib/auth/passkey.js +1 -1
  34. package/lib/auth/password.js +1 -1
  35. package/lib/auth/saml.js +42 -12
  36. package/lib/auth/sd-jwt-vc.js +24 -4
  37. package/lib/auth/status-list.js +14 -2
  38. package/lib/auth/step-up.js +9 -1
  39. package/lib/auth-bot-challenge.js +21 -2
  40. package/lib/backup/bundle.js +7 -2
  41. package/lib/backup/crypto.js +23 -8
  42. package/lib/backup/index.js +41 -20
  43. package/lib/backup/manifest.js +7 -1
  44. package/lib/break-glass.js +41 -22
  45. package/lib/cbor.js +34 -11
  46. package/lib/cdn-cache-control.js +7 -3
  47. package/lib/cert.js +5 -3
  48. package/lib/cli.js +5 -1
  49. package/lib/cloud-events.js +3 -2
  50. package/lib/cluster-storage.js +7 -3
  51. package/lib/codepoint-class.js +17 -0
  52. package/lib/compliance-eaa.js +1 -1
  53. package/lib/compliance-sanctions.js +9 -7
  54. package/lib/compliance.js +1 -1
  55. package/lib/config-drift.js +22 -8
  56. package/lib/content-credentials.js +11 -8
  57. package/lib/content-digest.js +11 -4
  58. package/lib/cookies.js +10 -2
  59. package/lib/cose.js +20 -0
  60. package/lib/crdt.js +2 -1
  61. package/lib/crypto-field.js +48 -24
  62. package/lib/crypto.js +18 -4
  63. package/lib/csp.js +13 -0
  64. package/lib/daemon.js +4 -1
  65. package/lib/data-act.js +27 -4
  66. package/lib/db-file-lifecycle.js +14 -6
  67. package/lib/db-query.js +102 -11
  68. package/lib/db.js +32 -15
  69. package/lib/dora.js +43 -13
  70. package/lib/dr-runbook.js +1 -1
  71. package/lib/dsa.js +2 -1
  72. package/lib/dsr.js +22 -8
  73. package/lib/early-hints.js +19 -0
  74. package/lib/eat.js +5 -1
  75. package/lib/external-db.js +60 -4
  76. package/lib/fda-21cfr11.js +30 -5
  77. package/lib/flag-providers.js +6 -2
  78. package/lib/forms.js +1 -1
  79. package/lib/gate-contract.js +46 -5
  80. package/lib/gdpr-ropa.js +18 -9
  81. package/lib/graphql-federation.js +17 -4
  82. package/lib/guard-all.js +2 -2
  83. package/lib/guard-dsn.js +1 -1
  84. package/lib/guard-envelope.js +1 -1
  85. package/lib/guard-html.js +9 -11
  86. package/lib/guard-imap-command.js +1 -1
  87. package/lib/guard-jmap.js +1 -1
  88. package/lib/guard-json.js +14 -6
  89. package/lib/guard-mail-move.js +1 -1
  90. package/lib/guard-managesieve-command.js +1 -1
  91. package/lib/guard-pop3-command.js +1 -1
  92. package/lib/guard-smtp-command.js +1 -1
  93. package/lib/guard-svg.js +8 -9
  94. package/lib/html-balance.js +7 -3
  95. package/lib/http-client-cookie-jar.js +33 -12
  96. package/lib/http-client.js +225 -53
  97. package/lib/iab-tcf.js +3 -2
  98. package/lib/importmap-integrity.js +41 -1
  99. package/lib/incident-report.js +9 -6
  100. package/lib/json-patch.js +1 -1
  101. package/lib/json-path.js +24 -3
  102. package/lib/jtd.js +2 -2
  103. package/lib/legal-hold.js +24 -8
  104. package/lib/log.js +2 -2
  105. package/lib/mail-agent.js +2 -2
  106. package/lib/mail-arf.js +1 -1
  107. package/lib/mail-auth.js +27 -4
  108. package/lib/mail-bimi.js +16 -16
  109. package/lib/mail-bounce.js +3 -3
  110. package/lib/mail-crypto-smime.js +71 -6
  111. package/lib/mail-deploy.js +9 -5
  112. package/lib/mail-dkim.js +20 -7
  113. package/lib/mail-greylist.js +2 -4
  114. package/lib/mail-helo.js +2 -4
  115. package/lib/mail-journal.js +11 -8
  116. package/lib/mail-mdn.js +8 -4
  117. package/lib/mail-rbl.js +2 -4
  118. package/lib/mail-scan.js +3 -5
  119. package/lib/mail-server-jmap.js +4 -1
  120. package/lib/mail-server-registry.js +1 -1
  121. package/lib/mail-server-tls.js +9 -2
  122. package/lib/mail-spam-score.js +2 -4
  123. package/lib/mail-store-fts.js +1 -1
  124. package/lib/mail.js +22 -2
  125. package/lib/markup-tokenizer.js +24 -0
  126. package/lib/mcp.js +6 -4
  127. package/lib/mdoc.js +26 -3
  128. package/lib/metrics.js +14 -3
  129. package/lib/middleware/api-encrypt.js +2 -2
  130. package/lib/middleware/body-parser.js +10 -4
  131. package/lib/middleware/bot-guard.js +26 -18
  132. package/lib/middleware/clear-site-data.js +5 -1
  133. package/lib/middleware/compose-pipeline.js +39 -5
  134. package/lib/middleware/compression.js +9 -0
  135. package/lib/middleware/cors.js +32 -23
  136. package/lib/middleware/csrf-protect.js +60 -21
  137. package/lib/middleware/daily-byte-quota.js +6 -4
  138. package/lib/middleware/fetch-metadata.js +28 -4
  139. package/lib/middleware/network-allowlist.js +61 -30
  140. package/lib/middleware/rate-limit.js +25 -16
  141. package/lib/middleware/scim-server.js +2 -1
  142. package/lib/middleware/security-headers.js +24 -6
  143. package/lib/middleware/speculation-rules.js +6 -3
  144. package/lib/middleware/tus-upload.js +2 -2
  145. package/lib/money.js +1 -1
  146. package/lib/mtls-ca.js +10 -6
  147. package/lib/network-dns-resolver.js +1 -1
  148. package/lib/network-dns.js +9 -2
  149. package/lib/network-dnssec.js +2 -1
  150. package/lib/network-smtp-policy.js +23 -5
  151. package/lib/network-tls.js +27 -5
  152. package/lib/network-tsig.js +2 -2
  153. package/lib/nis2-report.js +1 -1
  154. package/lib/nist-crosswalk.js +1 -1
  155. package/lib/ntp-check.js +28 -0
  156. package/lib/numeric-bounds.js +9 -0
  157. package/lib/object-store/azure-blob.js +1 -2
  158. package/lib/object-store/gcs-bucket-ops.js +4 -2
  159. package/lib/object-store/gcs.js +6 -4
  160. package/lib/object-store/http-put.js +1 -2
  161. package/lib/object-store/http-request.js +30 -1
  162. package/lib/object-store/local.js +37 -17
  163. package/lib/object-store/sigv4.js +1 -2
  164. package/lib/observability-otlp-exporter.js +20 -4
  165. package/lib/outbox.js +11 -4
  166. package/lib/parsers/safe-xml.js +1 -1
  167. package/lib/parsers/safe-yaml.js +21 -3
  168. package/lib/pipl-cn.js +11 -8
  169. package/lib/queue-local.js +10 -3
  170. package/lib/redact.js +7 -3
  171. package/lib/request-helpers.js +347 -36
  172. package/lib/resource-access-lock.js +3 -3
  173. package/lib/restore-bundle.js +46 -18
  174. package/lib/restore-rollback.js +10 -4
  175. package/lib/restore.js +19 -0
  176. package/lib/retention.js +20 -4
  177. package/lib/router.js +17 -4
  178. package/lib/safe-ical.js +2 -2
  179. package/lib/safe-icap.js +1 -1
  180. package/lib/safe-json.js +70 -0
  181. package/lib/safe-sieve.js +1 -1
  182. package/lib/safe-vcard.js +1 -1
  183. package/lib/sandbox-worker.js +6 -0
  184. package/lib/sandbox.js +1 -1
  185. package/lib/scheduler.js +17 -1
  186. package/lib/self-update-standalone-verifier.js +16 -0
  187. package/lib/session.js +62 -120
  188. package/lib/sql.js +25 -3
  189. package/lib/static.js +65 -13
  190. package/lib/template.js +7 -5
  191. package/lib/tenant-quota.js +52 -19
  192. package/lib/tsa.js +5 -2
  193. package/lib/vault/index.js +5 -0
  194. package/lib/vault/passphrase-ops.js +22 -26
  195. package/lib/vault/passphrase-source.js +8 -3
  196. package/lib/vault/rotate.js +13 -18
  197. package/lib/vault/seal-pem-file.js +4 -1
  198. package/lib/vc.js +1 -1
  199. package/lib/vendor/MANIFEST.json +10 -10
  200. package/lib/vendor/public-suffix-list.dat +23 -10
  201. package/lib/vendor/public-suffix-list.data.js +5498 -5494
  202. package/lib/webhook.js +16 -1
  203. package/lib/websocket.js +1 -1
  204. package/lib/worm.js +1 -1
  205. package/lib/ws-client.js +83 -46
  206. package/lib/x509-chain.js +91 -0
  207. package/package.json +1 -1
  208. package/sbom.cdx.json +6 -6
@@ -62,7 +62,6 @@ var vaultAad = require("./vault-aad");
62
62
  var validateOpts = require("./validate-opts");
63
63
  var numericBounds = require("./numeric-bounds");
64
64
  var safeJson = require("./safe-json");
65
- var frameworkSchema = require("./framework-schema");
66
65
  var sql = require("./sql");
67
66
  var { defineClass } = require("./framework-error");
68
67
  var { sha3Hash, kdf, generateBytes, encryptPacked, decryptPacked, generateToken } = require("./crypto");
@@ -226,14 +225,19 @@ var PER_ROW_KEYS_TABLE = "_blamejs_per_row_keys"; // allow:hand-rolled-sql
226
225
  var PER_ROW_KEYS_COLUMN = "wrappedKey";
227
226
  var PER_ROW_KEYS_SCHEMA_VERSION = "1";
228
227
 
229
- // The per-row-key registry is read/written against the LOCAL db() / dbHandle
230
- // handle directly (not clusterStorage), so SQL composed for it uses the
231
- // RESOLVED name (prefix-aware via frameworkSchema.tableName) and quoteName so
232
- // b.sql emits the quoted identifier the single-node path expects the same
233
- // shape db-query.js's _sqlOpts and db.js's own local-handle b.sql calls use.
228
+ // The per-row-key registry ALWAYS lives in the LOCAL sqlite reconcile creates
229
+ // it under its RAW schema name, and per-row keys are never dispatched to an
230
+ // external backend (see _kRowOnce's db() fallback). It is read/written against
231
+ // the local db() / dbHandle directly, NOT through clusterStorage, so it must use
232
+ // the RAW table name. frameworkSchema.tableName() resolves to the cluster-mode
233
+ // EXTERNAL prefixed name (the prefix applies only to the external backend, via
234
+ // resolveTables — local DDL stays raw); under a custom tablePrefix that names a
235
+ // table that does not exist locally, which SILENTLY breaks crypto-shred:
236
+ // destroyPerRowKey deletes 0 rows and sealed cells stay decryptable after
237
+ // eraseHard. quoteName so b.sql emits the quoted identifier the local path expects.
234
238
  var _PER_ROW_SQL_OPTS = { dialect: "sqlite", quoteName: true };
235
239
  function _perRowKeysTableName() {
236
- return frameworkSchema.tableName(PER_ROW_KEYS_TABLE);
240
+ return PER_ROW_KEYS_TABLE;
237
241
  }
238
242
 
239
243
  // Build the canonical AAD parts for a row-secret wrap in
@@ -1271,19 +1275,19 @@ function unsealRow(table, row, actor, dbHandle) {
1271
1275
  unsealed = out[field];
1272
1276
  }
1273
1277
  } catch (e) {
1274
- // A DB-write attacker who can write `vault:<crafted>` /
1275
- // `vault.aad:<crafted>` payloads to sealed columns can force
1276
- // KEM decapsulation / AEAD verify on attacker-controlled
1277
- // bytes via this read path. Surface the failure as a chain
1278
- // row so operators alert on burst patterns; null the field
1279
- // so downstream code sees "no value" instead of crashing the
1280
- // request. AAD-shape failures additionally indicate cross-
1281
- // row copy attempts the audit metadata flags the shape so
1282
- // operators can write alert rules.
1278
+ // A crypto-shredded (or never-materialized) per-row key is an EXPECTED
1279
+ // absence, not a decryption-oracle attack: the wrapped secret is gone,
1280
+ // so there is no oracle to brute-force. Reading such a row must read as
1281
+ // "no value" WITHOUT counting against the rate cap — otherwise a bulk
1282
+ // read over a table with many erased rows (GDPR eraseHard) trips the
1283
+ // cap and DoS's the live rows (self-DoS, CWE-307 mis-applied). It is
1284
+ // audited under a distinct, non-failure action so operators don't alert
1285
+ // on routine post-erasure reads as forged-ciphertext bursts.
1286
+ var _shredded = e && e.code === "crypto-field/row-key-unavailable";
1283
1287
  try {
1284
1288
  audit().safeEmit({
1285
- action: "system.crypto.unseal_failed",
1286
- outcome: "failure",
1289
+ action: _shredded ? "system.crypto.shredded_read" : "system.crypto.unseal_failed",
1290
+ outcome: _shredded ? "success" : "failure",
1287
1291
  metadata: {
1288
1292
  table: table,
1289
1293
  field: field,
@@ -1293,11 +1297,13 @@ function unsealRow(table, row, actor, dbHandle) {
1293
1297
  },
1294
1298
  });
1295
1299
  } catch (_e) { /* drop-silent */ }
1296
- // Default-on rate cap: account this failure against the (actor,
1297
- // table, column) tuple. When it trips the threshold, arm the
1298
- // cooldown + emit the distinct rate-exceeded audit once on the
1299
- // transition. No-op when the cap is disabled.
1300
- if (_rateNoteFailure(capActor, table, field)) {
1300
+ // Default-on rate cap: account a genuine decryption / AEAD-verify /
1301
+ // AAD-downgrade failure (a possible forged-ciphertext attack) against
1302
+ // the (actor, table, column) tuple. A shredded-key read is exempt (see
1303
+ // above). When the cap trips the threshold, arm the cooldown + emit the
1304
+ // distinct rate-exceeded audit once on the transition. No-op when the
1305
+ // cap is disabled.
1306
+ if (!_shredded && _rateNoteFailure(capActor, table, field)) {
1301
1307
  _emitRateAudit({
1302
1308
  table: table, field: field, actor: capActor, shape: shape,
1303
1309
  threshold: _rateCap.threshold, windowMs: _rateCap.windowMs, cooldownMs: _rateCap.cooldownMs,
@@ -1741,9 +1747,27 @@ function assertColumnResidency(table, row, args) {
1741
1747
  var entry = columnResidency[table];
1742
1748
  if (!entry || !row || !args) return null;
1743
1749
  var backendTag = args.backendTag || "unrestricted";
1750
+ // SQL unquoted identifiers are case-insensitive; a raw-SQL-parsed row keeps
1751
+ // the column token's case, so resolve each mapped column case-insensitively
1752
+ // (a case-sensitive `row[col]` let a differently-cased column skip the gate —
1753
+ // CWE-178). Lazily index the row's keys by lowercase; exact match wins first.
1754
+ var rowLcIndex = null;
1755
+ function _rowVal(c) {
1756
+ if (Object.prototype.hasOwnProperty.call(row, c)) return row[c];
1757
+ if (rowLcIndex === null) {
1758
+ rowLcIndex = {};
1759
+ var ks = Object.keys(row);
1760
+ for (var i = 0; i < ks.length; i++) {
1761
+ var lk = ks[i].toLowerCase();
1762
+ if (!Object.prototype.hasOwnProperty.call(rowLcIndex, lk)) rowLcIndex[lk] = row[ks[i]];
1763
+ }
1764
+ }
1765
+ return rowLcIndex[String(c).toLowerCase()];
1766
+ }
1744
1767
  for (var col in entry) {
1745
1768
  var want = entry[col];
1746
- if (row[col] === undefined || row[col] === null) continue;
1769
+ var cellVal = _rowVal(col);
1770
+ if (cellVal === undefined || cellVal === null) continue;
1747
1771
  if (want === "global" || want === "unrestricted") continue;
1748
1772
  if (backendTag === "unrestricted") continue;
1749
1773
  if (backendTag !== want) {
package/lib/crypto.js CHANGED
@@ -963,7 +963,7 @@ var SRI_ALGORITHMS = { "sha256": "sha256", "sha384": "sha384", "sha512": "sha512
963
963
  function sri(content, opts) {
964
964
  opts = opts || {};
965
965
  var algorithm = (opts.algorithm || "sha384").toLowerCase();
966
- if (!SRI_ALGORITHMS[algorithm]) {
966
+ if (!Object.prototype.hasOwnProperty.call(SRI_ALGORITHMS, algorithm)) {
967
967
  throw new Error("crypto.sri: unsupported algorithm '" + algorithm +
968
968
  "' (W3C SRI 1.0 §3.2 supports sha256/sha384/sha512)");
969
969
  }
@@ -1356,7 +1356,21 @@ function decrypt(ciphertext, privateKeys, opts) {
1356
1356
  opts && opts.raw === true ? { raw: true } : undefined);
1357
1357
  }
1358
1358
 
1359
+ // Bounds-checked 2-byte length read — a bare _envU16(packed, pos) on a
1360
+ // truncated envelope throws a raw Node RangeError that escapes the documented
1361
+ // "Invalid envelope: ..." error contract (untrusted ciphertext must fail with
1362
+ // the typed envelope error, not a stack-trace-leaking RangeError).
1363
+ function _envU16(buf, at) {
1364
+ if (at + 2 > buf.length) {
1365
+ throw new Error("Invalid envelope: truncated (expected a 2-byte length at offset " + at + ")");
1366
+ }
1367
+ return buf.readUInt16BE(at);
1368
+ }
1369
+
1359
1370
  function decryptEnvelope(packed, privateKeys, internalOpts) {
1371
+ if (!Buffer.isBuffer(packed) || packed.length < 4) {
1372
+ throw new Error("Invalid envelope: too short (need at least the 4-byte suite header)");
1373
+ }
1360
1374
  var kemId = packed[1], cipherId = packed[2], kdfId = packed[3], pos = 4;
1361
1375
 
1362
1376
  // The legacy 0xE1 envelope predates the FixedInfo / suite-binding
@@ -1372,7 +1386,7 @@ function decryptEnvelope(packed, privateKeys, internalOpts) {
1372
1386
  throw new Error("Invalid envelope: unsupported KDF (only SHAKE256 supported)");
1373
1387
  }
1374
1388
 
1375
- var kemCtLen = packed.readUInt16BE(pos); pos += 2;
1389
+ var kemCtLen = _envU16(packed, pos); pos += 2;
1376
1390
  var kemCt = packed.subarray(pos, pos + kemCtLen); pos += kemCtLen;
1377
1391
 
1378
1392
  var mlkemPriv = nodeCrypto.createPrivateKey(
@@ -1383,7 +1397,7 @@ function decryptEnvelope(packed, privateKeys, internalOpts) {
1383
1397
  var fixedInfo = omitFixedInfo ? Buffer.alloc(0) : _suiteFixedInfo(kemId, cipherId, kdfId);
1384
1398
 
1385
1399
  if (kemId === C.KEM_IDS.ML_KEM_1024_P384) {
1386
- var ecEphLen = packed.readUInt16BE(pos); pos += 2;
1400
+ var ecEphLen = _envU16(packed, pos); pos += 2;
1387
1401
  var ecEphDer = packed.subarray(pos, pos + ecEphLen); pos += ecEphLen;
1388
1402
  var ecPrivPem = typeof privateKeys === "string" ? null : privateKeys.ecPrivateKey;
1389
1403
  if (!ecPrivPem) throw new Error("Hybrid KEM requires EC private key");
@@ -1400,7 +1414,7 @@ function decryptEnvelope(packed, privateKeys, internalOpts) {
1400
1414
  // the correct keypair via privateKeys when the envelope was sealed
1401
1415
  // with this algorithm. Same length-prefixed shape as the P-384
1402
1416
  // hybrid: 2-byte ec-eph-len + DER X25519 pubkey + nonce + ct.
1403
- var x25519EphLen = packed.readUInt16BE(pos); pos += 2;
1417
+ var x25519EphLen = _envU16(packed, pos); pos += 2;
1404
1418
  var x25519EphDer = packed.subarray(pos, pos + x25519EphLen); pos += x25519EphLen;
1405
1419
  var x25519PrivPem = typeof privateKeys === "string" ? null : privateKeys.x25519PrivateKey;
1406
1420
  if (!x25519PrivPem) throw new Error("ML-KEM-768 + X25519 hybrid envelope requires x25519PrivateKey");
package/lib/csp.js CHANGED
@@ -210,6 +210,15 @@ function build(directives, opts) {
210
210
  throw new CspError("csp/header-injection",
211
211
  "csp.build: source '" + src + "' contains CR/LF/NUL");
212
212
  }
213
+ // A CSP source-expression is a single non-whitespace token. The emitter
214
+ // space-joins sources within a directive and ';'-joins directives, so a
215
+ // source containing ';' or ASCII whitespace would inject a brand-new live
216
+ // directive (e.g. "https://x; script-src https://evil") — refuse both.
217
+ if (/[\s;]/.test(src)) {
218
+ throw new CspError("csp/bad-source",
219
+ "csp.build: source '" + src + "' contains whitespace or ';' — a CSP source " +
220
+ "must be a single token (directive-injection defense)");
221
+ }
213
222
  if (!acknowledgeUnsafe && SCRIPT_DIRECTIVES.indexOf(name) !== -1 &&
214
223
  UNSAFE_KEYWORDS.indexOf(src) !== -1) {
215
224
  throw new CspError("csp/unsafe-keyword",
@@ -416,6 +425,10 @@ function mergeDirectives(base, additions, opts) {
416
425
  ? directives[name].slice()
417
426
  : (directives["default-src"] ? directives["default-src"].slice() : ["'self'"]);
418
427
  var added = additions[name];
428
+ // A directive value of 'none' MUST stand alone (CSP3 §2.3.1) — appending a
429
+ // host to it emits the malformed "'none' https://x". When merging real
430
+ // sources in, the added sources supersede 'none', so drop it first.
431
+ if (added.length && existing.length === 1 && existing[0] === "'none'") existing = [];
419
432
  for (var si = 0; si < added.length; si += 1) {
420
433
  if (existing.indexOf(added[si]) === -1) existing.push(added[si]);
421
434
  }
package/lib/daemon.js CHANGED
@@ -73,7 +73,10 @@ function _isLivePid(pid) {
73
73
 
74
74
  function _readPidFile(pidFile) {
75
75
  try {
76
- var raw = nodeFs.readFileSync(pidFile, "utf8");
76
+ // Same fd-safe + capped + symlink-refusing read as app-shutdown's lockfile
77
+ // reader (one shape): a PID file is never a legit symlink mount, so
78
+ // refuseSymlink is safe; any throw → null ("nothing live there").
79
+ var raw = atomicFile.fdSafeReadSync(pidFile, { maxBytes: C.BYTES.kib(1), refuseSymlink: true, encoding: "utf8" });
77
80
  var pid = parseInt(String(raw).trim(), 10);
78
81
  return isFinite(pid) && pid > 0 ? pid : null;
79
82
  } catch (_e) { return null; }
package/lib/data-act.js CHANGED
@@ -89,6 +89,32 @@ var DESIGNATED_GATEKEEPERS = Object.freeze([
89
89
  "booking",
90
90
  ]);
91
91
 
92
+ // Is `recipient` a designated DMA gatekeeper? Match the gatekeeper token as a
93
+ // DNS LABEL of the recipient's host authority — any position, including the
94
+ // FINAL label. A substring `indexOf(g + ".")` test missed a gatekeeper that is
95
+ // the last label (e.g. "blog.google" / "drive.google" on the real ".google"
96
+ // TLD, or "data.amazon"), letting an Art 32 §1 third-party share through — and
97
+ // also false-flagged a non-gatekeeper whose name merely contained the token
98
+ // ("notgoogle.com"). Label matching closes the bypass and drops the false
99
+ // positive. A bare name ("Google") still matches exactly. URL / userinfo / port
100
+ // forms are normalized to the host authority first.
101
+ function _isGatekeeperRecipient(recipient) {
102
+ var s = String(recipient).toLowerCase().trim();
103
+ var scheme = s.indexOf("://");
104
+ if (scheme !== -1) s = s.slice(scheme + 3);
105
+ s = s.split("/")[0].split("?")[0].split("#")[0]; // drop path / query / fragment
106
+ var at = s.lastIndexOf("@");
107
+ if (at !== -1) s = s.slice(at + 1); // drop userinfo
108
+ s = s.split(":")[0]; // drop port
109
+ if (s.length === 0) return false;
110
+ var labels = s.split(".");
111
+ return DESIGNATED_GATEKEEPERS.some(function (g) {
112
+ if (s === g) return true;
113
+ for (var i = 0; i < labels.length; i++) { if (labels[i] === g) return true; }
114
+ return false;
115
+ });
116
+ }
117
+
92
118
  /**
93
119
  * @primitive b.dataAct.declareProduct
94
120
  * @signature b.dataAct.declareProduct(opts)
@@ -229,10 +255,7 @@ function shareWithThirdParty(opts) {
229
255
  acceptGatekeeper: "optional-plain-object",
230
256
  }, "dataAct.shareWithThirdParty", DataActError, "dataact/bad-opts");
231
257
 
232
- var recipientLower = opts.recipient.toLowerCase();
233
- var isGatekeeper = DESIGNATED_GATEKEEPERS.some(function (g) {
234
- return recipientLower === g || recipientLower.indexOf(g + ".") !== -1;
235
- });
258
+ var isGatekeeper = _isGatekeeperRecipient(opts.recipient);
236
259
  if (isGatekeeper) {
237
260
  if (!opts.acceptGatekeeper || typeof opts.acceptGatekeeper !== "object" ||
238
261
  typeof opts.acceptGatekeeper.reason !== "string" ||
@@ -172,8 +172,11 @@ function fileLifecycle(opts) {
172
172
  : nodePath.join(opts.dataDir, frameworkFiles.fileName("dbKeyEnc"));
173
173
  var flushIntervalMs = opts.flushIntervalMs || DEFAULT_FLUSH_INTERVAL_MS;
174
174
  var tmpDir = _resolveTmpDir(opts.tmpDir, opts.allowDiskFallback === true);
175
- if (!nodeFs.existsSync(tmpDir)) nodeFs.mkdirSync(tmpDir, { recursive: true });
176
- if (!nodeFs.existsSync(opts.dataDir)) nodeFs.mkdirSync(opts.dataDir, { recursive: true });
175
+ // 0o700 these hold the DB encryption tmpfs scratch + the data dir
176
+ // (db.key.enc, the decrypted DB while open); a default-mode mkdir leaves
177
+ // them group/other-traversable.
178
+ if (!nodeFs.existsSync(tmpDir)) nodeFs.mkdirSync(tmpDir, { recursive: true, mode: 0o700 });
179
+ if (!nodeFs.existsSync(opts.dataDir)) nodeFs.mkdirSync(opts.dataDir, { recursive: true, mode: 0o700 });
177
180
 
178
181
  var dbPath = null;
179
182
  var encKey = null;
@@ -183,7 +186,9 @@ function fileLifecycle(opts) {
183
186
  function _loadOrGenerateKey() {
184
187
  if (encKey) return encKey;
185
188
  if (nodeFs.existsSync(keyPath)) {
186
- var sealedKey = nodeFs.readFileSync(keyPath, "utf8");
189
+ // Cap + fd-bound. NO refuseSymlink: dbKeyPath may be an operator-absolute
190
+ // KMS-fronted volume that legitimately symlinks the sealed key.
191
+ var sealedKey = atomicFile.fdSafeReadSync(keyPath, { maxBytes: C.BYTES.kib(64), encoding: "utf8" });
187
192
  var keyB64;
188
193
  try { keyB64 = opts.vault.unseal(sealedKey); }
189
194
  catch (e) {
@@ -212,7 +217,10 @@ function fileLifecycle(opts) {
212
217
  dbPath = nodePath.join(tmpDir, "blamejs-fl-" + label + "-" +
213
218
  generateToken(TMP_NAME_BYTES) + ".db");
214
219
  if (nodeFs.existsSync(encPath)) {
215
- var packed = nodeFs.readFileSync(encPath);
220
+ // Cap + fd-bound (deployment-tunable ceiling). NO refuseSymlink: the data
221
+ // dir may be a symlink-mounted volume (k8s PVC). db.enc is AEAD-checked
222
+ // downstream, so the cap is the OOM-before-cap defense.
223
+ var packed = atomicFile.fdSafeReadSync(encPath, { maxBytes: (opts.maxDbBytes) || C.BYTES.gib(2) });
216
224
  if (packed.length < 26) { // minimum envelope length
217
225
  throw new DbFileLifecycleError("db-file-lifecycle/short-envelope",
218
226
  "fileLifecycle: " + encPath + " too short to be a valid envelope (" + packed.length + " bytes)");
@@ -248,7 +256,7 @@ function fileLifecycle(opts) {
248
256
  catch (_e) { /* best-effort — operators on read-only handles or pre-init still flush */ }
249
257
  }
250
258
  if (!nodeFs.existsSync(dbPath)) return null;
251
- var plain = nodeFs.readFileSync(dbPath);
259
+ var plain = atomicFile.fdSafeReadSync(dbPath, { maxBytes: (opts.maxDbBytes) || C.BYTES.gib(2) });
252
260
  var packed = encryptPacked(plain, encKey, _aad(opts.dataDir, label));
253
261
  atomicFile.writeSync(encPath, packed);
254
262
  _emitAudit("flushed", "success", { label: label, bytes: plain.length });
@@ -269,7 +277,7 @@ function fileLifecycle(opts) {
269
277
  throw new DbFileLifecycleError("db-file-lifecycle/no-source",
270
278
  "fileLifecycle.snapshot: " + dbPath + " is missing");
271
279
  }
272
- var plain = nodeFs.readFileSync(dbPath);
280
+ var plain = atomicFile.fdSafeReadSync(dbPath, { maxBytes: (opts.maxDbBytes) || C.BYTES.gib(2) });
273
281
  return encryptPacked(plain, encKey, _aad(opts.dataDir, label));
274
282
  }
275
283
 
package/lib/db-query.js CHANGED
@@ -89,6 +89,19 @@ function _postureState() {
89
89
  //
90
90
  // Unregulated postures audit (drop-silent) and pass; tables with no
91
91
  // declaration are untouched.
92
+ // Resolve a column in a raw-SQL-parsed row case-insensitively (SQL unquoted
93
+ // identifiers fold case; the parser preserves the token). Exact match wins
94
+ // first so a structured-builder row (keys already canonical) is unaffected.
95
+ function _ciColumn(row, col) {
96
+ if (Object.prototype.hasOwnProperty.call(row, col)) return { present: true, value: row[col] };
97
+ var lc = String(col).toLowerCase();
98
+ var keys = Object.keys(row);
99
+ for (var i = 0; i < keys.length; i++) {
100
+ if (keys[i].toLowerCase() === lc) return { present: true, value: row[keys[i]] };
101
+ }
102
+ return { present: false, value: undefined };
103
+ }
104
+
92
105
  function _assertLocalResidency(table, plaintextRow, op) {
93
106
  var spec = cryptoField.getPerRowResidency(table);
94
107
  var colMap = cryptoField.getColumnResidency(table);
@@ -106,9 +119,16 @@ function _assertLocalResidency(table, plaintextRow, op) {
106
119
  var regulated = state.regulated;
107
120
 
108
121
  if (spec) {
109
- var tag = plaintextRow[spec.residencyColumn];
122
+ // SQL unquoted identifiers are case-insensitive, and the raw-SQL parser
123
+ // preserves the column token's case — so resolve the residency column
124
+ // case-insensitively. A case-sensitive lookup let `UPDATE t SET REGION=...`
125
+ // miss a `residencyColumn: "region"` declaration, skipping the gate and
126
+ // admitting a cross-border write (CWE-178 / CWE-863). Fail-safe: any
127
+ // spelling that could be the residency column engages the gate.
128
+ var resolved = _ciColumn(plaintextRow, spec.residencyColumn);
129
+ var tag = resolved.value;
110
130
  var tagPresent = tag !== undefined && tag !== null;
111
- var colInChangeSet = Object.prototype.hasOwnProperty.call(plaintextRow, spec.residencyColumn);
131
+ var colInChangeSet = resolved.present;
112
132
  if (op === "insert" && !tagPresent) {
113
133
  throw new DbQueryError("db-query/row-residency-tag-missing",
114
134
  op + ": table '" + table + "' declares per-row residency on column '" +
@@ -368,6 +388,17 @@ class Query {
368
388
  return this.where(field, "IN", values);
369
389
  }
370
390
 
391
+ // whereNull / whereNotNull — explicit NULL predicates (IS NULL / IS NOT
392
+ // NULL). `where({ field: null })` / `where(field, "=", null)` is refused
393
+ // because `col = NULL` is UNKNOWN in SQL (never true); these are the
394
+ // intended way to test a column for NULL.
395
+ whereNull(field) {
396
+ return this.where(field, "IS", null);
397
+ }
398
+ whereNotNull(field) {
399
+ return this.where(field, "IS NOT", null);
400
+ }
401
+
371
402
  // Resolve a (field, op, value) predicate through the framework gates
372
403
  // (JSONB value guard, sealed-field → derived-hash rewrite, column
373
404
  // membership) and return the post-rewrite { field, op, value } that
@@ -1378,12 +1409,62 @@ function _unquoteIdent(s) {
1378
1409
  return s;
1379
1410
  }
1380
1411
 
1412
+ // Strip LEADING SQL comments + whitespace so the ^-anchored write-detection
1413
+ // regexes see the real statement head. Without this, a residency write smuggled
1414
+ // behind a leading "/* x */" or "-- x\n" comment is NOT recognized as a write →
1415
+ // the residency gate is skipped entirely (a cross-border-write BYPASS). The
1416
+ // executed SQL is unchanged; this normalized copy is only for the gate's parse.
1417
+ // Each replace is ^-anchored single-pass; the loop terminates when nothing more
1418
+ // is stripped (an unterminated /* leaves the head intact → write still detected
1419
+ // or fails closed downstream).
1420
+ function _stripLeadingSqlComments(sql) {
1421
+ var s = String(sql), prev;
1422
+ do {
1423
+ prev = s;
1424
+ s = s.replace(/^\s+/, ""); // allow:regex-no-length-cap — anchored, single leading run
1425
+ s = s.replace(/^--[^\n]*\r?\n?/, ""); // allow:regex-no-length-cap — anchored leading line comment
1426
+ s = s.replace(/^\/\*[\s\S]*?\*\//, ""); // allow:regex-no-length-cap — anchored leading block comment (lazy, single scan)
1427
+ } while (s !== prev);
1428
+ return s;
1429
+ }
1430
+
1431
+ // Non-anchored write-target scan for the writable-CTE / EXPLAIN-prefixed case.
1432
+ // A SQLite `WITH c AS (...) INSERT INTO residents ...` / `WITH ... UPDATE residents
1433
+ // SET ...` is a real write, but its effective verb is hidden behind the prefix so
1434
+ // the ^-anchored _RAW_WRITE_KEYWORD_RE misses it. This matches every INSERT/REPLACE
1435
+ // INTO, MERGE INTO, and UPDATE ... SET target token anywhere in the statement and
1436
+ // returns the first that names a residency table — so such a write still ENGAGES
1437
+ // the residency gate (_assertRawWriteResidency then fails CLOSED: the ^-anchored
1438
+ // body parsers can't read a CTE body, so it throws row-residency-raw-unparseable
1439
+ // directing the operator to b.db.from().insertOne/.updateOne). DELETE moves no
1440
+ // residency value across a border, so it is not a residency write. Linear scan.
1441
+ var _CTE_WRITE_TARGET_RE = /(?:\b(?:INSERT|REPLACE)\s+(?:OR\s+[A-Za-z]+\s+)?INTO|\bMERGE\s+INTO)\s+(?:[\x22\x27\x60]?[A-Za-z_]\w*[\x22\x27\x60]?\s*\.\s*){0,3}[\x22\x27\x60]?([A-Za-z_]\w*)[\x22\x27\x60]?|\bUPDATE\s+(?:[\x22\x27\x60]?[A-Za-z_]\w*[\x22\x27\x60]?\s*\.\s*){0,3}[\x22\x27\x60]?([A-Za-z_]\w*)[\x22\x27\x60]?\s+SET\b/ig; // allow:regex-no-length-cap — alternation, no nested quantifiers; linear
1442
+ function _firstResidencyWriteTarget(s) {
1443
+ _CTE_WRITE_TARGET_RE.lastIndex = 0;
1444
+ var m;
1445
+ while ((m = _CTE_WRITE_TARGET_RE.exec(s)) !== null) { // allow:regex-no-length-cap
1446
+ var t = _unquoteIdent(m[1] || m[2]);
1447
+ if (t && (cryptoField.getPerRowResidency(t) || cryptoField.getColumnResidency(t))) return t;
1448
+ }
1449
+ return null;
1450
+ }
1451
+
1381
1452
  function _rawWriteTable(sql) {
1382
- // Both regexes are ^-anchored (leading write keyword + table head): they scan
1383
- // only the statement head, so they are constant-time regardless of SQL length.
1384
- if (typeof sql !== "string" || !_RAW_WRITE_KEYWORD_RE.test(sql)) return null; // allow:regex-no-length-cap
1385
- var m = _RAW_TABLE_RE.exec(sql); // allow:regex-no-length-cap
1386
- return m ? _unquoteIdent(m[1] || m[2]) : null;
1453
+ // The ^-anchored regexes scan only the statement head (constant-time). Strip
1454
+ // leading comments first so a commented-out head can't hide the write.
1455
+ if (typeof sql !== "string") return null;
1456
+ var s = _stripLeadingSqlComments(sql);
1457
+ if (_RAW_WRITE_KEYWORD_RE.test(s)) { // allow:regex-no-length-cap
1458
+ var m = _RAW_TABLE_RE.exec(s); // allow:regex-no-length-cap
1459
+ return m ? _unquoteIdent(m[1] || m[2]) : null;
1460
+ }
1461
+ // Writable-CTE / EXPLAIN-prefixed write: the effective write verb is hidden
1462
+ // behind the prefix the ^-anchored test misses. If it writes a residency table,
1463
+ // return it so the gate engages and then fails closed on the unparseable body.
1464
+ if (/^\s*(?:WITH|EXPLAIN)\b/i.test(s)) { // allow:regex-no-length-cap
1465
+ return _firstResidencyWriteTarget(s);
1466
+ }
1467
+ return null;
1387
1468
  }
1388
1469
 
1389
1470
  // Cheap prepare-time pre-check so only writes to a residency table get wrapped.
@@ -1463,17 +1544,23 @@ function _assertRawWriteResidency(sql, boundParams) {
1463
1544
  if (!cryptoField.getPerRowResidency(table) && !cryptoField.getColumnResidency(table)) return;
1464
1545
  boundParams = _flattenRunParams(boundParams);
1465
1546
 
1547
+ // Parse the comment-stripped head: a leading "/* x */" / "-- x" comment must
1548
+ // not hide the INSERT/UPDATE body from the ^-anchored regexes below (that would
1549
+ // let a residency-restricted write through the gate). The executed SQL is
1550
+ // unchanged; this normalized copy is only for residency parsing.
1551
+ var norm = _stripLeadingSqlComments(sql);
1552
+
1466
1553
  // The INSERT/UPDATE body regexes below scan with [\s\S]+; bound the input
1467
1554
  // first and fail CLOSED on an over-long statement - a residency write the
1468
1555
  // framework cannot safely parse must be refused, never let past the gate.
1469
- if (sql.length > 100000) {
1556
+ if (norm.length > 100000) {
1470
1557
  throw new DbQueryError("db-query/row-residency-raw-unparseable",
1471
1558
  "raw write to residency table '" + table + "' exceeds the parse limit (" +
1472
- sql.length + " chars) - use b.db.from(\"" + table + "\") so residency is validated", true);
1559
+ norm.length + " chars) - use b.db.from(\"" + table + "\") so residency is validated", true);
1473
1560
  }
1474
1561
 
1475
- var mi = _RAW_INSERT_RE.exec(sql); // allow:regex-no-length-cap — input length-capped above
1476
- var mu = mi ? null : _RAW_UPDATE_RE.exec(sql); // allow:regex-no-length-cap — input length-capped above
1562
+ var mi = _RAW_INSERT_RE.exec(norm); // allow:regex-no-length-cap — input length-capped above
1563
+ var mu = mi ? null : _RAW_UPDATE_RE.exec(norm); // allow:regex-no-length-cap — input length-capped above
1477
1564
  if (!mi && !mu) {
1478
1565
  throw new DbQueryError("db-query/row-residency-raw-unparseable",
1479
1566
  "raw write to residency table '" + table + "' cannot be parsed to validate its " +
@@ -1509,4 +1596,8 @@ module.exports = {
1509
1596
  Query: Query,
1510
1597
  _isRawWriteToResidencyTable: _isRawWriteToResidencyTable,
1511
1598
  _assertRawWriteResidency: _assertRawWriteResidency,
1599
+ // Shared leading-comment stripper so db.js's storage-low write-gate sees the
1600
+ // real statement head (a `/* x */ INSERT` / WITH-prefixed write must not slip
1601
+ // the ENOSPC gate the way it slipped the residency gate).
1602
+ _stripLeadingSqlComments: _stripLeadingSqlComments,
1512
1603
  };
package/lib/db.js CHANGED
@@ -57,7 +57,7 @@ var { generateToken, generateBytes, encryptPacked, decryptPacked, sha3Hash } = r
57
57
  var cryptoField = require("./crypto-field");
58
58
  var dbDeclareRowPolicy = require("./db-declare-row-policy");
59
59
  var dbDeclareView = require("./db-declare-view");
60
- var { Query, _isRawWriteToResidencyTable, _assertRawWriteResidency } = require("./db-query");
60
+ var { Query, _isRawWriteToResidencyTable, _assertRawWriteResidency, _stripLeadingSqlComments } = require("./db-query");
61
61
  var dbSchema = require("./db-schema");
62
62
  var { defineClass } = require("./framework-error");
63
63
  var frameworkFiles = require("./framework-files");
@@ -748,7 +748,7 @@ function loadOrCreateDbKey(dataDirPath, keyPathOverride) {
748
748
  var keyPath = keyPathOverride || nodePath.join(dataDirPath, frameworkFiles.fileName("dbKeyEnc"));
749
749
  var aad = _dbKeyAad(dataDirPath, keyPath);
750
750
  if (nodeFs.existsSync(keyPath)) {
751
- var sealed = atomicFile.readSync(keyPath, { encoding: "utf8" }).trim();
751
+ var sealed = atomicFile.readSync(keyPath, { encoding: "utf8", maxBytes: C.BYTES.kib(64) }).trim();
752
752
  var b64;
753
753
  // isAadSealed is checked FIRST and is load-bearing: AAD_PREFIX
754
754
  // ("vault.aad:") is NOT a prefix of VAULT_PREFIX ("vault:"), so a
@@ -811,7 +811,10 @@ function decryptToTmp() {
811
811
  try { nodeFs.unlinkSync(dbPath + "-shm"); } catch (_e) { /* may not exist */ }
812
812
  }
813
813
  }
814
- var packed = nodeFs.readFileSync(encPath);
814
+ // Cap + fd-bound db.enc read at db.init. NO refuseSymlink: the data dir may be
815
+ // a symlink-mounted volume (k8s PVC). db.enc is AEAD-verified downstream, so
816
+ // the cap is the OOM-before-cap defense (was an uncapped read).
817
+ var packed = atomicFile.fdSafeReadSync(encPath, { maxBytes: C.BYTES.gib(2) });
815
818
  if (packed.length < 26) return; // too short to be a valid envelope
816
819
  // AAD binds the envelope to this deployment's data dir so two
817
820
  // installs sharing the same operator passphrase can't swap each
@@ -890,11 +893,27 @@ function _probeStorageHeadroom() {
890
893
  // DELETE, PRAGMA, and DDL pass through ungated. Called once in init() after
891
894
  // schema setup so init's own writes are never gated (writesRefused is false
892
895
  // until the first probe anyway).
896
+ // A growth write for the storage-low gate: INSERT/UPDATE/REPLACE at the head
897
+ // AFTER stripping leading comments, OR a writable-CTE / EXPLAIN-prefixed write
898
+ // (`WITH c AS (...) INSERT INTO t ...`) whose effective verb places rows. The
899
+ // leading-keyword-only test missed both a `/* x */ INSERT` and a WITH-prefixed
900
+ // growth write, letting them proceed into a near-full tmpfs (the ENOSPC the gate
901
+ // exists to prevent). Matches write SYNTAX (INTO / UPDATE..SET) so a WITH..SELECT
902
+ // carrying the word "insert" in a value is not refused.
903
+ function _isGrowthWrite(sql) {
904
+ if (typeof sql !== "string") return false;
905
+ var s = _stripLeadingSqlComments(sql);
906
+ if (/^\s*(?:INSERT|UPDATE|REPLACE)\b/i.test(s)) return true;
907
+ if (/^\s*(?:WITH|EXPLAIN)\b/i.test(s) &&
908
+ /\b(?:(?:INSERT|REPLACE|MERGE)\s+(?:OR\s+[A-Za-z]+\s+)?INTO|UPDATE\s+[\w".`]+\s+SET)\b/i.test(s)) return true;
909
+ return false;
910
+ }
911
+
893
912
  function _installWriteGate() {
894
913
  var rawPrepare = database.prepare.bind(database);
895
914
  database.prepare = function (sql) {
896
915
  var stmt = rawPrepare(sql);
897
- if (/^\s*(?:INSERT|UPDATE|REPLACE)\b/i.test(sql)) {
916
+ if (_isGrowthWrite(sql)) {
898
917
  var rawRun = stmt.run.bind(stmt);
899
918
  stmt.run = function () {
900
919
  if (writesRefused) {
@@ -915,7 +934,7 @@ function encryptToDisk() {
915
934
  // Force WAL checkpoint so the .db file holds all committed transactions.
916
935
  try { runSql(database, "PRAGMA wal_checkpoint(TRUNCATE)"); } catch (_e) { /* best effort */ }
917
936
  if (!nodeFs.existsSync(dbPath)) return;
918
- atomicFile.writeSync(encPath, encryptPacked(nodeFs.readFileSync(dbPath), encKey, _dbEncAad(dataDir)));
937
+ atomicFile.writeSync(encPath, encryptPacked(atomicFile.fdSafeReadSync(dbPath, { maxBytes: C.BYTES.gib(2) }), encKey, _dbEncAad(dataDir)));
919
938
  }
920
939
 
921
940
  /**
@@ -952,7 +971,7 @@ function snapshot() {
952
971
  throw _dbErr("db/snapshot-no-source",
953
972
  "snapshot: plaintext DB at " + dbPath + " is missing — did init complete?");
954
973
  }
955
- var plain = nodeFs.readFileSync(dbPath);
974
+ var plain = atomicFile.fdSafeReadSync(dbPath, { maxBytes: C.BYTES.gib(2) });
956
975
  if (!encPath || !encKey) {
957
976
  // atRest: 'plain' — return the raw bytes. Operators wanting an
958
977
  // encrypted snapshot under plain mode wrap with their own
@@ -2611,14 +2630,12 @@ function declareRequireDualControl(args) {
2611
2630
  }
2612
2631
  var m = args.m === undefined ? 2 : args.m;
2613
2632
  var n = args.n === undefined ? Math.max(2, m) : args.n;
2614
- if (typeof m !== "number" || !isFinite(m) || m < 2 || Math.floor(m) !== m) {
2615
- throw new DbError("db/dual-control-bad-quorum",
2616
- "declareRequireDualControl: m must be an integer >= 2");
2617
- }
2618
- if (typeof n !== "number" || !isFinite(n) || n < m || Math.floor(n) !== n) {
2619
- throw new DbError("db/dual-control-bad-quorum",
2620
- "declareRequireDualControl: n must be an integer >= m");
2621
- }
2633
+ // m >= 2, n >= m, both integers via numericBounds (matches db.js's existing
2634
+ // inline numeric-bounds usage; throws DbError("db/dual-control-bad-quorum")).
2635
+ require("./numeric-bounds").requirePositiveFiniteInt(m,
2636
+ "declareRequireDualControl: m", DbError, "db/dual-control-bad-quorum", { min: 2 });
2637
+ require("./numeric-bounds").requirePositiveFiniteInt(n,
2638
+ "declareRequireDualControl: n", DbError, "db/dual-control-bad-quorum", { min: m });
2622
2639
  if (args.posture !== undefined && args.posture !== null &&
2623
2640
  (typeof args.posture !== "string" || args.posture.length === 0)) {
2624
2641
  throw new DbError("db/dual-control-bad-posture",
@@ -2785,7 +2802,7 @@ function _checkRollback(dataDirPath) {
2785
2802
  }
2786
2803
  var tip;
2787
2804
  try {
2788
- tip = safeJson.parse(atomicFile.readSync(tipPath), { schema: AUDIT_TIP_SCHEMA });
2805
+ tip = safeJson.parse(atomicFile.readSync(tipPath, { maxBytes: C.BYTES.kib(64) }), { schema: AUDIT_TIP_SCHEMA });
2789
2806
  } catch (e) {
2790
2807
  throw _dbErr("db/audit-tip-unreadable",
2791
2808
  "FATAL: audit.tip unreadable or schema-invalid at " + tipPath + " — " + e.message +