@blamejs/core 0.14.26 → 0.15.0

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 (150) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +2 -2
  3. package/index.js +4 -0
  4. package/lib/agent-envelope-mac.js +104 -0
  5. package/lib/agent-event-bus.js +105 -4
  6. package/lib/agent-posture-chain.js +8 -42
  7. package/lib/ai-content-detect.js +9 -10
  8. package/lib/api-key.js +107 -74
  9. package/lib/atomic-file.js +62 -4
  10. package/lib/audit-chain.js +47 -11
  11. package/lib/audit-sign.js +77 -2
  12. package/lib/audit-tools.js +79 -51
  13. package/lib/audit.js +249 -123
  14. package/lib/auth/openid-federation.js +108 -47
  15. package/lib/backup/index.js +13 -10
  16. package/lib/break-glass.js +202 -144
  17. package/lib/cache.js +174 -105
  18. package/lib/chain-writer.js +38 -16
  19. package/lib/cli.js +19 -14
  20. package/lib/cluster-provider-db.js +130 -104
  21. package/lib/cluster-storage.js +119 -22
  22. package/lib/cluster.js +119 -71
  23. package/lib/compliance.js +169 -4
  24. package/lib/consent.js +73 -24
  25. package/lib/constants.js +16 -11
  26. package/lib/crypto-field.js +474 -92
  27. package/lib/db-declare-row-policy.js +35 -22
  28. package/lib/db-file-lifecycle.js +3 -2
  29. package/lib/db-query.js +497 -255
  30. package/lib/db-schema.js +209 -44
  31. package/lib/db.js +176 -95
  32. package/lib/error-page.js +14 -1
  33. package/lib/external-db-migrate.js +229 -139
  34. package/lib/external-db.js +25 -15
  35. package/lib/file-upload.js +52 -7
  36. package/lib/framework-error.js +14 -1
  37. package/lib/framework-files.js +73 -0
  38. package/lib/framework-schema.js +695 -394
  39. package/lib/gate-contract.js +649 -1
  40. package/lib/guard-agent-registry.js +26 -44
  41. package/lib/guard-all.js +1 -0
  42. package/lib/guard-auth.js +42 -112
  43. package/lib/guard-cidr.js +33 -154
  44. package/lib/guard-csv.js +46 -113
  45. package/lib/guard-domain.js +34 -157
  46. package/lib/guard-dsn.js +27 -43
  47. package/lib/guard-email.js +47 -69
  48. package/lib/guard-envelope.js +19 -32
  49. package/lib/guard-event-bus-payload.js +24 -42
  50. package/lib/guard-event-bus-topic.js +25 -43
  51. package/lib/guard-filename.js +42 -106
  52. package/lib/guard-graphql.js +42 -123
  53. package/lib/guard-html.js +53 -108
  54. package/lib/guard-idempotency-key.js +24 -42
  55. package/lib/guard-image.js +46 -103
  56. package/lib/guard-imap-command.js +18 -32
  57. package/lib/guard-jmap.js +16 -30
  58. package/lib/guard-json.js +38 -108
  59. package/lib/guard-jsonpath.js +38 -171
  60. package/lib/guard-jwt.js +49 -179
  61. package/lib/guard-list-id.js +25 -41
  62. package/lib/guard-list-unsubscribe.js +27 -43
  63. package/lib/guard-mail-compose.js +24 -42
  64. package/lib/guard-mail-move.js +26 -44
  65. package/lib/guard-mail-query.js +28 -46
  66. package/lib/guard-mail-reply.js +24 -42
  67. package/lib/guard-mail-sieve.js +24 -42
  68. package/lib/guard-managesieve-command.js +17 -31
  69. package/lib/guard-markdown.js +37 -104
  70. package/lib/guard-message-id.js +26 -45
  71. package/lib/guard-mime.js +39 -151
  72. package/lib/guard-oauth.js +54 -135
  73. package/lib/guard-pdf.js +45 -101
  74. package/lib/guard-pop3-command.js +21 -31
  75. package/lib/guard-posture-chain.js +24 -42
  76. package/lib/guard-regex.js +33 -107
  77. package/lib/guard-saga-config.js +24 -42
  78. package/lib/guard-shell.js +42 -172
  79. package/lib/guard-smtp-command.js +48 -54
  80. package/lib/guard-snapshot-envelope.js +24 -42
  81. package/lib/guard-sql.js +1491 -0
  82. package/lib/guard-stream-args.js +24 -43
  83. package/lib/guard-svg.js +47 -65
  84. package/lib/guard-template.js +35 -172
  85. package/lib/guard-tenant-id.js +26 -45
  86. package/lib/guard-time.js +32 -154
  87. package/lib/guard-trace-context.js +25 -44
  88. package/lib/guard-uuid.js +32 -153
  89. package/lib/guard-xml.js +38 -113
  90. package/lib/guard-yaml.js +51 -163
  91. package/lib/http-client.js +37 -9
  92. package/lib/inbox.js +120 -107
  93. package/lib/legal-hold.js +107 -50
  94. package/lib/log-stream-cloudwatch.js +47 -31
  95. package/lib/log-stream-otlp.js +32 -18
  96. package/lib/mail-crypto-smime.js +2 -6
  97. package/lib/mail-greylist.js +2 -6
  98. package/lib/mail-helo.js +2 -6
  99. package/lib/mail-journal.js +85 -64
  100. package/lib/mail-rbl.js +2 -6
  101. package/lib/mail-scan.js +2 -6
  102. package/lib/mail-server-jmap.js +117 -12
  103. package/lib/mail-spam-score.js +2 -6
  104. package/lib/mail-store.js +287 -154
  105. package/lib/middleware/body-parser.js +71 -25
  106. package/lib/middleware/csrf-protect.js +19 -8
  107. package/lib/middleware/fetch-metadata.js +17 -7
  108. package/lib/middleware/idempotency-key.js +54 -38
  109. package/lib/middleware/rate-limit.js +102 -32
  110. package/lib/middleware/security-headers.js +21 -5
  111. package/lib/migrations.js +108 -66
  112. package/lib/network-heartbeat.js +7 -0
  113. package/lib/nonce-store.js +31 -9
  114. package/lib/object-store/azure-blob-bucket-ops.js +9 -4
  115. package/lib/object-store/azure-blob.js +57 -3
  116. package/lib/object-store/sigv4.js +10 -0
  117. package/lib/observability.js +87 -0
  118. package/lib/otel-export.js +25 -1
  119. package/lib/outbox.js +136 -82
  120. package/lib/parsers/safe-xml.js +47 -7
  121. package/lib/pqc-agent.js +44 -0
  122. package/lib/pubsub-cluster.js +42 -20
  123. package/lib/queue-local.js +202 -139
  124. package/lib/queue-redis.js +9 -1
  125. package/lib/queue-sqs.js +6 -0
  126. package/lib/redact.js +68 -11
  127. package/lib/redis-client.js +160 -31
  128. package/lib/retention.js +82 -39
  129. package/lib/router.js +212 -5
  130. package/lib/safe-dns.js +29 -45
  131. package/lib/safe-ical.js +18 -33
  132. package/lib/safe-icap.js +27 -43
  133. package/lib/safe-sieve.js +21 -40
  134. package/lib/safe-sql.js +124 -3
  135. package/lib/safe-vcard.js +18 -33
  136. package/lib/scheduler.js +35 -12
  137. package/lib/seeders.js +122 -74
  138. package/lib/session-stores.js +42 -14
  139. package/lib/session.js +109 -72
  140. package/lib/sql.js +3885 -0
  141. package/lib/ssrf-guard.js +51 -4
  142. package/lib/static.js +177 -34
  143. package/lib/subject.js +55 -17
  144. package/lib/vault/index.js +3 -2
  145. package/lib/vault/passphrase-ops.js +3 -2
  146. package/lib/vault/rotate.js +104 -64
  147. package/lib/vendor-data.js +2 -0
  148. package/lib/websocket.js +35 -5
  149. package/package.json +1 -1
  150. package/sbom.cdx.json +6 -6
package/lib/cluster.js CHANGED
@@ -50,16 +50,88 @@ var lazyRequire = require("./lazy-require");
50
50
  var { boot } = require("./log");
51
51
  var safeAsync = require("./safe-async");
52
52
  var safeJson = require("./safe-json");
53
- var safeSql = require("./safe-sql");
54
53
  var safeUrl = require("./safe-url");
55
54
  var validateOpts = require("./validate-opts");
56
55
  var { FrameworkError, ClusterError } = require("./framework-error");
57
56
 
57
+ // The external-DB schema quotes every column identifier, so Postgres
58
+ // stores them case-preserving. The boot-time chain-tip + vault-key-
59
+ // consistency statements compose through b.sql, which quotes every
60
+ // identifier by construction (double-quote on Postgres / SQLite, backtick
61
+ // on MySQL) so an unquoted fold-to-lowercase reference can't miss the
62
+ // column.
63
+
58
64
  // Lazy: vault → db → cluster forms a load-time chain, and external-db is
59
65
  // loaded before its init has run; both are safe to call once cluster
60
66
  // reaches runtime, but eager require here would deadlock the load order.
61
67
  var externalDb = lazyRequire(function () { return require("./external-db"); });
62
68
  var vault = lazyRequire(function () { return require("./vault"); });
69
+ // b.sql builder + the `?`->`$N` placeholderizer + the framework-table
70
+ // name resolver. clusterStorage requires cluster, so these are lazy to
71
+ // stay clear of the load cycle; resolved at runtime when the boot-time
72
+ // rollback / vault-key-consistency checks run.
73
+ var sql = lazyRequire(function () { return require("./sql"); });
74
+ var clusterStorage = lazyRequire(function () { return require("./cluster-storage"); });
75
+ var frameworkSchema = lazyRequire(function () { return require("./framework-schema"); });
76
+
77
+ // b.sql speaks postgres | sqlite | mysql; the cluster's configuredDialect
78
+ // is one of those (validated at init). Used so the boot-time chain-tip /
79
+ // vault-key-consistency statements emit the right identifier quoting.
80
+ function _bDialect() {
81
+ return configuredDialect === "mysql" ? "mysql"
82
+ : configuredDialect === "sqlite" ? "sqlite" : "postgres";
83
+ }
84
+
85
+ // Emit a b.sql builder + run it against the configured external-DB
86
+ // backend. b.sql emits `?` placeholders; the externalDb driver receives
87
+ // the SQL verbatim, so translate to `$N` for Postgres (passthrough for
88
+ // SQLite / MySQL).
89
+ function _runClusterQuery(builder) {
90
+ var built = builder.toSql();
91
+ return externalDb().query(
92
+ clusterStorage().placeholderize(built.sql, configuredDialect),
93
+ built.params,
94
+ { backend: configuredExternalDbBackend }
95
+ );
96
+ }
97
+
98
+ // "The framework-internal table this check needs does not exist yet" —
99
+ // the signal that a gates-only cluster (leader election wired, but
100
+ // framework state still resident in per-node SQLite without
101
+ // frameworkSchema.ensureSchema) should SKIP the boot-time rollback /
102
+ // vault-key-consistency check instead of FATAL-refusing boot. Each
103
+ // backend phrases the missing-relation fault differently, and not all
104
+ // drivers carry a stable structured code, so this matches BOTH the
105
+ // driver phrasing AND the portable code/SQLSTATE when present:
106
+ //
107
+ // - SQLite: "no such table: X"
108
+ // - Postgres: "relation "X" does not exist" (SQLSTATE 42P01)
109
+ // - MySQL: "Table 'db.X' doesn't exist" (errno 1146, SQLSTATE 42S02)
110
+ //
111
+ // The earlier message-only test recognized the SQLite/Postgres phrasing
112
+ // ("no such table" / "does not exist") but NOT MySQL's "doesn't exist"
113
+ // (the apostrophe-contracted form), so a gates-only MySQL cluster boot
114
+ // mis-fired the skip and surfaced ER_NO_SUCH_TABLE instead of completing.
115
+ function _isMissingTableError(e) {
116
+ if (!e) return false;
117
+ // Structured code / SQLSTATE first — driver-stable, locale-independent.
118
+ // mysql2-shape: e.errno === 1146 / e.code === "ER_NO_SUCH_TABLE";
119
+ // the docker-exec shim + ANSI drivers surface SQLSTATE 42S02 (MySQL) /
120
+ // 42P01 (Postgres) on e.code / e.sqlState.
121
+ var code = (e.code != null) ? String(e.code) : "";
122
+ var sqlState = (e.sqlState != null) ? String(e.sqlState) : "";
123
+ if (e.errno === 1146) return true;
124
+ if (code === "ER_NO_SUCH_TABLE" || code === "42S02" || code === "42P01" ||
125
+ sqlState === "42S02" || sqlState === "42P01") {
126
+ return true;
127
+ }
128
+ // Driver phrasing fallback. "doesn't exist" (MySQL apostrophe form) is
129
+ // covered alongside "does not exist" (Postgres) and "no such table"
130
+ // (SQLite). The MySQL message embeds the table name in quotes, so the
131
+ // bare "doesn't exist" substring is the portable anchor.
132
+ var msg = e.message || "";
133
+ return /no such table|does not exist|doesn't exist|relation .* does not exist/i.test(msg);
134
+ }
63
135
 
64
136
  var DEFAULT_LEASE_TTL = C.TIME.seconds(30);
65
137
  var DEFAULT_HEARTBEAT = C.TIME.seconds(10);
@@ -324,8 +396,16 @@ async function init(opts) {
324
396
  // framework state — the operator owns rollback detection in that
325
397
  // case.
326
398
  if (configuredExternalDbBackend) {
327
- await _checkChainTipRollback("audit", "_blamejs_audit_log", "_blamejs_audit_tip");
328
- await _checkChainTipRollback("consent", "_blamejs_consent_log", "_blamejs_consent_tip");
399
+ // Resolve the chain + tip table names through frameworkSchema so the
400
+ // configurable framework-table prefix is honored (the names are
401
+ // `_blamejs_`-prefixed and self-mapped, so the resolve is a no-op under
402
+ // the default prefix).
403
+ await _checkChainTipRollback("audit",
404
+ frameworkSchema().tableName("audit_log"), // allow:hand-rolled-sql — logical-name reference
405
+ frameworkSchema().tableName("_blamejs_audit_tip")); // allow:hand-rolled-sql — logical-name reference
406
+ await _checkChainTipRollback("consent",
407
+ frameworkSchema().tableName("consent_log"), // allow:hand-rolled-sql — logical-name reference
408
+ frameworkSchema().tableName("_blamejs_consent_tip")); // allow:hand-rolled-sql — logical-name reference
329
409
  // Vault-key consistency: every node in a cluster must hold the
330
410
  // SAME vault key. A node booting with a different key would seal
331
411
  // new writes under a key the rest of the cluster can't unseal,
@@ -373,26 +453,16 @@ async function init(opts) {
373
453
  // hash → FATAL via process.exit(1). Same posture as the
374
454
  // single-node audit.tip sidecar rollback check.
375
455
  async function _checkChainTipRollback(chainName, logTable, tipTable) {
376
- // Both tables are framework-internal constants from the call sites
377
- // (`_blamejs_audit_log`, `_blamejs_consent_log`, etc.). Validate +
378
- // quote per the framework's identifier-quoting convention so a
379
- // future rename can't silently break the query.
380
- safeSql.validateIdentifier(logTable, { allowReserved: true });
381
- safeSql.validateIdentifier(tipTable, { allowReserved: true });
382
- var qLogTable = safeSql.quoteIdentifier(logTable);
383
- var qTipTable = safeSql.quoteIdentifier(tipTable);
384
-
456
+ // Both tables are framework-internal constants resolved at the call
457
+ // sites through frameworkSchema. b.sql quotes every identifier by
458
+ // construction; the dialect-final SQL is placeholderized to `$N` for
459
+ // Postgres (passthrough for SQLite).
385
460
  var tipRows;
386
461
  try {
387
- tipRows = await externalDb().query(
388
- "SELECT atMonotonicCounter, rowHash FROM " + qTipTable +
389
- " WHERE scope = " + (configuredDialect === "postgres" ? "$1" : "?"),
390
- [chainName],
391
- { backend: configuredExternalDbBackend }
392
- );
462
+ tipRows = await _runClusterQuery(sql().select(tipTable, { dialect: _bDialect() })
463
+ .columns(["atMonotonicCounter", "rowHash"]).where("scope", chainName));
393
464
  } catch (e) {
394
- var msg = (e && e.message) || "";
395
- if (/no such table|does not exist|relation .* does not exist/i.test(msg)) {
465
+ if (_isMissingTableError(e)) {
396
466
  log(chainName + "-tip table not present — skipping rollback check (cluster gates-only mode)");
397
467
  return;
398
468
  }
@@ -406,11 +476,8 @@ async function _checkChainTipRollback(chainName, logTable, tipTable) {
406
476
  var tipCounter = Number(tip.atMonotonicCounter);
407
477
  var tipHash = tip.rowHash;
408
478
 
409
- var currentRows = await externalDb().query(
410
- "SELECT MAX(monotonicCounter) AS m FROM " + qLogTable,
411
- [],
412
- { backend: configuredExternalDbBackend }
413
- );
479
+ var currentRows = await _runClusterQuery(sql().select(logTable, { dialect: _bDialect() })
480
+ .max("monotonicCounter", "m"));
414
481
  var currentMax = (currentRows.rows && currentRows.rows[0] && currentRows.rows[0].m)
415
482
  ? Number(currentRows.rows[0].m)
416
483
  : 0;
@@ -426,12 +493,8 @@ async function _checkChainTipRollback(chainName, logTable, tipTable) {
426
493
  }
427
494
 
428
495
  if (tipHash) {
429
- var hashRows = await externalDb().query(
430
- "SELECT rowHash FROM " + qLogTable + " WHERE monotonicCounter = " +
431
- (configuredDialect === "postgres" ? "$1" : "?"),
432
- [tipCounter],
433
- { backend: configuredExternalDbBackend }
434
- );
496
+ var hashRows = await _runClusterQuery(sql().select(logTable, { dialect: _bDialect() })
497
+ .columns(["rowHash"]).where("monotonicCounter", tipCounter));
435
498
  if (hashRows.rows && hashRows.rows.length > 0) {
436
499
  var rowAtTip = hashRows.rows[0].rowHash;
437
500
  if (rowAtTip !== tipHash) {
@@ -492,12 +555,13 @@ function _vaultKeyFingerprint() {
492
555
  // non-constant, so the column is nullable and treated as epoch 0 when
493
556
  // absent on legacy rows.
494
557
  async function _ensureRotationEpochColumn() {
558
+ var stateTable = frameworkSchema().tableName("_blamejs_cluster_state"); // allow:hand-rolled-sql — logical-name reference
495
559
  try {
496
- await externalDb().query(
497
- "ALTER TABLE _blamejs_cluster_state ADD COLUMN rotationEpoch BIGINT",
498
- [],
499
- { backend: configuredExternalDbBackend }
500
- );
560
+ var alter = sql().alterTable(stateTable,
561
+ { addColumn: { name: "rotationEpoch", type: "BIGINT" } },
562
+ { dialect: _bDialect() }).sql;
563
+ await externalDb().query(clusterStorage().placeholderize(alter, configuredDialect), [],
564
+ { backend: configuredExternalDbBackend });
501
565
  } catch (_e) { /* column already exists (or table absent — caught upstream) */ }
502
566
  }
503
567
 
@@ -533,29 +597,23 @@ async function _checkVaultKeyConsistency() {
533
597
  return;
534
598
  }
535
599
  var nowMs = Date.now();
536
- var ph = configuredDialect === "postgres";
600
+ var stateTable = frameworkSchema().tableName("_blamejs_cluster_state"); // allow:hand-rolled-sql — logical-name reference
537
601
 
538
602
  // First boot: try to record THIS node's fingerprint. ON CONFLICT DO
539
603
  // NOTHING means the FIRST node to boot wins; subsequent nodes
540
604
  // observe whatever's already there. Every node then SELECTs and
541
605
  // compares — any mismatch (including ours after a losing race)
542
- // surfaces the drift.
606
+ // surfaces the drift. The scope value binds like any other param; b.sql
607
+ // folds DO NOTHING to the MySQL `scope = scope` no-op automatically.
543
608
  try {
544
- await externalDb().query(
545
- "INSERT INTO _blamejs_cluster_state " +
546
- " (scope, vaultKeyFp, recordedAt, recordedByNode) " +
547
- "VALUES ('state', " +
548
- (ph ? "$1, $2, $3" : "?, ?, ?") + ") " +
549
- "ON CONFLICT (scope) DO NOTHING",
550
- [localFp, nowMs, nodeId],
551
- { backend: configuredExternalDbBackend }
552
- );
609
+ await _runClusterQuery(sql().upsert(stateTable, { dialect: _bDialect() })
610
+ .values({ scope: "state", vaultKeyFp: localFp, recordedAt: nowMs, recordedByNode: nodeId })
611
+ .onConflict(["scope"]).doNothing());
553
612
  } catch (e) {
554
613
  // Table missing → the cluster-provider-db ensureSchema didn't run
555
614
  // (custom provider that doesn't create _blamejs_cluster_state).
556
615
  // Skip silently — same defensive posture as the audit-tip check.
557
- var msg = (e && e.message) || "";
558
- if (/no such table|does not exist|relation .* does not exist/i.test(msg)) {
616
+ if (_isMissingTableError(e)) {
559
617
  log("cluster-state table not present — skipping vault-key consistency check (custom provider)");
560
618
  return;
561
619
  }
@@ -569,12 +627,9 @@ async function _checkVaultKeyConsistency() {
569
627
 
570
628
  // Read whatever fingerprint is canonical (ours if first boot,
571
629
  // someone else's if we lost the race or are joining an existing cluster).
572
- var rows = await externalDb().query(
573
- "SELECT vaultKeyFp, recordedByNode, recordedAt, rotationEpoch FROM _blamejs_cluster_state " +
574
- "WHERE scope = 'state'",
575
- [],
576
- { backend: configuredExternalDbBackend }
577
- );
630
+ var rows = await _runClusterQuery(sql().select(stateTable, { dialect: _bDialect() })
631
+ .columns(["vaultKeyFp", "recordedByNode", "recordedAt", "rotationEpoch"])
632
+ .where("scope", "state"));
578
633
  if (!rows.rows || rows.rows.length === 0) {
579
634
  // Should never happen — we just INSERTed. Surface as fatal so the
580
635
  // condition isn't silently ignored.
@@ -628,27 +683,20 @@ async function _checkVaultKeyConsistency() {
628
683
  var priorEpoch = (canonical.rotationEpoch != null) ? Number(canonical.rotationEpoch) : 0;
629
684
  if (!isFinite(priorEpoch) || priorEpoch < 0) priorEpoch = 0;
630
685
  var nextEpoch = priorEpoch + 1;
631
- await externalDb().query(
632
- "UPDATE _blamejs_cluster_state SET " +
633
- " vaultKeyFp = " + (ph ? "$1" : "?") + ", " +
634
- " recordedAt = " + (ph ? "$2" : "?") + ", " +
635
- " recordedByNode = " + (ph ? "$3" : "?") + ", " +
636
- " rotationEpoch = " + (ph ? "$4" : "?") + " " +
637
- "WHERE scope = 'state' AND vaultKeyFp = " + (ph ? "$5" : "?"),
638
- [localFp, nowMs, nodeId, nextEpoch, canonical.vaultKeyFp],
639
- { backend: configuredExternalDbBackend }
640
- );
686
+ await _runClusterQuery(sql().update(stateTable, { dialect: _bDialect() })
687
+ .set({
688
+ vaultKeyFp: localFp, recordedAt: nowMs,
689
+ recordedByNode: nodeId, rotationEpoch: nextEpoch,
690
+ })
691
+ .where("scope", "state").where("vaultKeyFp", canonical.vaultKeyFp));
641
692
  // Re-read so the post-adopt state reflects whoever actually won the
642
693
  // advance (this node, or a peer that adopted the SAME rotated key a
643
694
  // beat earlier). A surviving mismatch here means the row now carries a
644
695
  // fingerprint that is neither the old one nor ours — a real drift that
645
696
  // the rotation declaration does not cover, so fail closed.
646
- var after = await externalDb().query(
647
- "SELECT vaultKeyFp, recordedByNode, rotationEpoch FROM _blamejs_cluster_state " +
648
- "WHERE scope = 'state'",
649
- [],
650
- { backend: configuredExternalDbBackend }
651
- );
697
+ var after = await _runClusterQuery(sql().select(stateTable, { dialect: _bDialect() })
698
+ .columns(["vaultKeyFp", "recordedByNode", "rotationEpoch"])
699
+ .where("scope", "state"));
652
700
  var post = (after.rows && after.rows[0]) || canonical;
653
701
  if (post.vaultKeyFp !== localFp) {
654
702
  throw _err("VAULT_KEY_DRIFT",
package/lib/compliance.js CHANGED
@@ -50,6 +50,18 @@ var audit = lazyRequire(function () { return require("./audit"); });
50
50
  var retentionMod = lazyRequire(function () { return require("./retention"); });
51
51
  var db = lazyRequire(function () { return require("./db"); });
52
52
  var cryptoField = lazyRequire(function () { return require("./crypto-field"); });
53
+ var redact = lazyRequire(function () { return require("./redact"); });
54
+
55
+ // Postures whose floor implies an outbound-DLP gate (b.redact's
56
+ // classifier presets cover exactly these regimes). Pinning one of these
57
+ // does NOT auto-install outbound DLP — the compliance coordinator holds
58
+ // no httpClient / mail / webhook handles — so set() emits a one-time
59
+ // `compliance.posture.outbound_dlp_unwired` warning when none is wired,
60
+ // so the gap is grep-able in the audit chain instead of a silent paper-
61
+ // compliance hole (CWE-200 / CWE-201 outbound data exposure).
62
+ var OUTBOUND_DLP_FLOOR_POSTURES = Object.freeze([
63
+ "hipaa", "pci-dss", "gdpr", "soc2", "fapi-2.0", "fapi-2.0-message-signing",
64
+ ]);
53
65
 
54
66
  // Recognised posture names. Aligns with the compliance-posture
55
67
  // vocabulary every guard / retention floor / etc. accepts. Operators
@@ -445,6 +457,24 @@ function set(posture) {
445
457
  "warning");
446
458
  }
447
459
  }
460
+
461
+ // Outbound-DLP wiring signal. A posture whose floor implies an
462
+ // outbound-DLP gate is being pinned, but set() cannot install the
463
+ // interceptors itself (no httpClient / mail / webhook handles). Warn
464
+ // once when nothing is wired so the gap is visible in the audit chain
465
+ // rather than a silent paper-compliance hole. Fires at most once per
466
+ // pin (set() is idempotent for the same posture).
467
+ if (OUTBOUND_DLP_FLOOR_POSTURES.indexOf(posture) !== -1) {
468
+ var dlpInstalled = false;
469
+ try { dlpInstalled = redact().isOutboundDlpInstalled() === true; }
470
+ catch (_e) { dlpInstalled = false; }
471
+ if (!dlpInstalled) {
472
+ _emitAudit("compliance.posture.outbound_dlp_unwired",
473
+ { posture: posture,
474
+ recommendation: "compliance.set does not auto-install outbound DLP — it holds no httpClient / mail / webhook handles. Call b.redact.installForPosture('" + posture + "', { httpClient, mail, webhook }) with your primitive instances so outbound payloads are classified (CWE-200 / CWE-201)." },
475
+ "warning");
476
+ }
477
+ }
448
478
  }
449
479
 
450
480
  // _applyPostureCascade — walks every primitive that
@@ -948,6 +978,25 @@ function describe(posture) {
948
978
  // + DPDP §12 + LGPD-BR Art. 18 + PIPL-CN
949
979
  // Art. 47 all require effective erasure;
950
980
  // leftover index residue defeats it.
981
+ // sealEnvelopeFloor — minimum field-level seal envelope a
982
+ // sealed-column table may declare under
983
+ // this posture: "plain" (vault.seal, no
984
+ // AAD), "aad" (AEAD-bound to table/row/
985
+ // column via b.vault.aad), or "per-row-key"
986
+ // (K_row crypto-shred). cryptoField.
987
+ // registerTable refuses a table whose
988
+ // declared envelope is below the floor when
989
+ // this posture is the globally-pinned one.
990
+ // PCI-DSS Req. 3.5/3.6 (PAN render
991
+ // unreadable, key-management binding) and
992
+ // HIPAA 45 CFR 164.312(a)(2)(iv) +
993
+ // 164.312(e)(2)(ii) (encryption that
994
+ // resists ciphertext relocation, CWE-311 /
995
+ // CWE-326) need an AAD-bound envelope at
996
+ // minimum so a DB-write attacker cannot
997
+ // copy a sealed cell between rows. Absent
998
+ // on a posture → no floor (back-compat;
999
+ // plain envelopes keep registering).
951
1000
  //
952
1001
  // This table is the single source-of-truth — duplicating values into
953
1002
  // per-primitive defaults would drift the moment a regulator updates.
@@ -957,12 +1006,22 @@ var POSTURE_DEFAULTS = Object.freeze({
957
1006
  auditChainSignedRequired: true,
958
1007
  tlsMinVersion: "TLSv1.3",
959
1008
  requireVacuumAfterErase: true,
1009
+ // 45 CFR 164.312(a)(2)(iv) + (e)(2)(ii) — ePHI encryption must
1010
+ // resist ciphertext relocation; a plain vault.seal cell can be
1011
+ // copied between rows undetected (CWE-311 / CWE-326). AAD-bound
1012
+ // envelope is the floor.
1013
+ sealEnvelopeFloor: "aad",
960
1014
  }),
961
1015
  "pci-dss": Object.freeze({
962
1016
  backupEncryptionRequired: true,
963
1017
  auditChainSignedRequired: true,
964
1018
  tlsMinVersion: "TLSv1.3",
965
1019
  requireVacuumAfterErase: false,
1020
+ // PCI-DSS v4 Req. 3.5 (PAN unreadable) + Req. 3.6 (key-management
1021
+ // binding) — the seal must bind cardholder data to its storage
1022
+ // location so a relocated ciphertext fails to verify. AAD-bound
1023
+ // envelope is the floor.
1024
+ sealEnvelopeFloor: "aad",
966
1025
  }),
967
1026
  "gdpr": Object.freeze({
968
1027
  backupEncryptionRequired: false, // GDPR Art. 32 says "appropriate" — not mandatory floor
@@ -1006,6 +1065,28 @@ var POSTURE_DEFAULTS = Object.freeze({
1006
1065
  tlsMinVersion: "TLSv1.3",
1007
1066
  requireVacuumAfterErase: true,
1008
1067
  }),
1068
+ // UK GDPR (DPA 2018 + retained EU GDPR) — Art. 17 right to erasure
1069
+ // applies identically to GDPR, including residual B-tree pages.
1070
+ "uk-gdpr": Object.freeze({
1071
+ backupEncryptionRequired: false,
1072
+ auditChainSignedRequired: true,
1073
+ tlsMinVersion: "TLSv1.3",
1074
+ requireVacuumAfterErase: true,
1075
+ }),
1076
+ // Japan APPI — deletion/cessation right with residue-cleanup floor.
1077
+ "appi-jp": Object.freeze({
1078
+ backupEncryptionRequired: false,
1079
+ auditChainSignedRequired: true,
1080
+ tlsMinVersion: "TLSv1.3",
1081
+ requireVacuumAfterErase: true,
1082
+ }),
1083
+ // Singapore PDPA — right to erasure with effectiveness floor.
1084
+ "pdpa-sg": Object.freeze({
1085
+ backupEncryptionRequired: false,
1086
+ auditChainSignedRequired: true,
1087
+ tlsMinVersion: "TLSv1.3",
1088
+ requireVacuumAfterErase: true,
1089
+ }),
1009
1090
  // v0.8.70 — 2026 effective deadlines
1010
1091
  "modpa": Object.freeze({
1011
1092
  // Maryland Online Data Privacy Act (effective 2026-10-01) —
@@ -1357,10 +1438,13 @@ var POSTURE_DEFAULTS = Object.freeze({
1357
1438
  * where `set()` would over-pin the process.
1358
1439
  *
1359
1440
  * Recognised keys per posture include `backupEncryptionRequired`,
1360
- * `auditChainSignedRequired`, `tlsMinVersion`, and
1361
- * `requireVacuumAfterErase` — the floors enforced by `b.backup`,
1362
- * `b.audit`, the TLS minimum-version gate, and `b.cryptoField`'s
1363
- * residual-erasure pass.
1441
+ * `auditChainSignedRequired`, `tlsMinVersion`,
1442
+ * `requireVacuumAfterErase`, and `sealEnvelopeFloor` — the floors
1443
+ * enforced by `b.backup`, `b.audit`, the TLS minimum-version gate,
1444
+ * `b.cryptoField`'s residual-erasure pass, and `b.cryptoField`'s
1445
+ * field-level seal-envelope gate. Keys not declared for a posture
1446
+ * return `null` (no floor), so reading `sealEnvelopeFloor` for a
1447
+ * posture that doesn't pin one is the back-compat no-op signal.
1364
1448
  *
1365
1449
  * @example
1366
1450
  * b.compliance.postureDefault("hipaa", "tlsMinVersion");
@@ -1615,10 +1699,91 @@ function isCrossBorderRegulated(posture) {
1615
1699
  return CROSS_BORDER_REGULATED_POSTURES.indexOf(posture) !== -1;
1616
1700
  }
1617
1701
 
1702
+ // Region-tag wildcards. Both spellings mean "no residency constraint"
1703
+ // across the framework — the external-db gate uses "unrestricted" as
1704
+ // its default + wildcard, while the local db-query / external-db row
1705
+ // gates also accept "global" as the region-neutral row tag. Normalizing
1706
+ // folds both to "unrestricted" so callers reason about one wildcard.
1707
+ var _REGION_WILDCARDS = Object.freeze(["global", "unrestricted", "any", "*"]);
1708
+
1709
+ /**
1710
+ * @primitive b.compliance.normalizeRegionTag
1711
+ * @signature b.compliance.normalizeRegionTag(tag)
1712
+ * @since 0.14.27
1713
+ * @compliance gdpr
1714
+ * @related b.compliance.isRegionCompatible, b.compliance.isCrossBorderRegulated
1715
+ *
1716
+ * Canonicalize an operator-supplied residency region tag so the same
1717
+ * region declared as `"EU"`, `"eu"`, or `" Eu "` compares equal. Lower-
1718
+ * cases and trims the tag; folds the no-constraint wildcards
1719
+ * (`"global"` / `"unrestricted"` / `"any"` / `"*"`) to `"unrestricted"`.
1720
+ * Returns `null` for non-string / empty input.
1721
+ *
1722
+ * This is an ADDITIVE helper composed OVER the residency write gates
1723
+ * (`b.db.from` local, `b.externalDb.query` backend/replica) — it does
1724
+ * not change the gate internals. Callers normalize their tags with it
1725
+ * BEFORE handing them to the gate so case / wildcard drift (`"EU"` vs
1726
+ * `"eu"` vs `"global"`) doesn't read as a region mismatch.
1727
+ *
1728
+ * @example
1729
+ * b.compliance.normalizeRegionTag("EU"); // → "eu"
1730
+ * b.compliance.normalizeRegionTag(" eu "); // → "eu"
1731
+ * b.compliance.normalizeRegionTag("global"); // → "unrestricted"
1732
+ * b.compliance.normalizeRegionTag("unrestricted"); // → "unrestricted"
1733
+ * b.compliance.normalizeRegionTag(null); // → null
1734
+ */
1735
+ function normalizeRegionTag(tag) {
1736
+ if (typeof tag !== "string") return null;
1737
+ var t = tag.trim().toLowerCase();
1738
+ if (t.length === 0) return null;
1739
+ if (_REGION_WILDCARDS.indexOf(t) !== -1) return "unrestricted";
1740
+ return t;
1741
+ }
1742
+
1743
+ /**
1744
+ * @primitive b.compliance.isRegionCompatible
1745
+ * @signature b.compliance.isRegionCompatible(a, b)
1746
+ * @since 0.14.27
1747
+ * @compliance gdpr
1748
+ * @related b.compliance.normalizeRegionTag, b.compliance.isCrossBorderRegulated
1749
+ *
1750
+ * Returns `true` when two residency region tags are compatible for a
1751
+ * same-region write/replication after normalization: identical
1752
+ * normalized regions are compatible, and a wildcard (`"global"` /
1753
+ * `"unrestricted"`) on EITHER side is compatible. Different concrete
1754
+ * regions (`"eu"` vs `"us"`) are NOT compatible — a cross-border
1755
+ * transfer the operator must opt into explicitly at the gate.
1756
+ *
1757
+ * Mirrors the residency gate's compatibility rule (identical-or-
1758
+ * wildcard) but over NORMALIZED tags, so it is case- and wildcard-drift
1759
+ * insensitive. ADDITIVE helper composed over the gate — it does not
1760
+ * change `_residencyCompatible` in db-query.js / external-db.js.
1761
+ * Missing/non-string tags on either side normalize to `null`, treated
1762
+ * as "no constraint" → compatible (matches the gate's
1763
+ * `!primaryTag || !replicaTag` short-circuit).
1764
+ *
1765
+ * @example
1766
+ * b.compliance.isRegionCompatible("EU", "eu"); // → true
1767
+ * b.compliance.isRegionCompatible("eu", "global"); // → true
1768
+ * b.compliance.isRegionCompatible("unrestricted", "us"); // → true
1769
+ * b.compliance.isRegionCompatible("eu", "us"); // → false
1770
+ * b.compliance.isRegionCompatible("EU", null); // → true
1771
+ */
1772
+ function isRegionCompatible(a, b) {
1773
+ var na = normalizeRegionTag(a);
1774
+ var nb = normalizeRegionTag(b);
1775
+ if (na === null || nb === null) return true; // no constraint either side
1776
+ if (na === nb) return true; // identical region (post-normalize)
1777
+ if (na === "unrestricted" || nb === "unrestricted") return true; // wildcard either side
1778
+ return false;
1779
+ }
1780
+
1618
1781
  module.exports = {
1619
1782
  set: set,
1620
1783
  current: current,
1621
1784
  isCrossBorderRegulated: isCrossBorderRegulated,
1785
+ normalizeRegionTag: normalizeRegionTag,
1786
+ isRegionCompatible: isRegionCompatible,
1622
1787
  CROSS_BORDER_REGULATED_POSTURES: CROSS_BORDER_REGULATED_POSTURES,
1623
1788
  assert: assert,
1624
1789
  clear: clear,
package/lib/consent.js CHANGED
@@ -46,6 +46,8 @@ var cluster = require("./cluster");
46
46
  var clusterStorage = require("./cluster-storage");
47
47
  var chainWriter = require("./chain-writer");
48
48
  var safeAsync = require("./safe-async");
49
+ var safeSql = require("./safe-sql");
50
+ var sql = require("./sql");
49
51
  var lazyRequire = require("./lazy-require");
50
52
  var C = require("./constants");
51
53
  var { ClusterError } = require("./framework-error");
@@ -304,10 +306,18 @@ function isGranted(opts) {
304
306
  if (!hash) {
305
307
  throw new Error("consent_log subjectId is missing a derived hash — schema misconfigured");
306
308
  }
307
- var row = db().prepare(
308
- "SELECT action FROM consent_log WHERE subjectIdHash = ? AND purpose = ? " +
309
- "ORDER BY monotonicCounter DESC LIMIT 1"
310
- ).get(hash, opts.purpose);
309
+ // Local db() handle: emit the LOCAL table name (consent_log) quoted so
310
+ // the camelCase subjectIdHash / monotonicCounter columns resolve, and
311
+ // run the built { sql, params } against the prepared statement.
312
+ var isGrantedBuilt = sql.select("consent_log", { dialect: "sqlite", quoteName: true })
313
+ .columns(["action"])
314
+ .where("subjectIdHash", hash)
315
+ .where("purpose", opts.purpose)
316
+ .orderBy("monotonicCounter", "desc")
317
+ .limit(1)
318
+ .toSql();
319
+ var isGrantedStmt = db().prepare(isGrantedBuilt.sql);
320
+ var row = isGrantedStmt.get.apply(isGrantedStmt, isGrantedBuilt.params);
311
321
  if (!row) return false;
312
322
  return row.action === "granted";
313
323
  }
@@ -409,30 +419,69 @@ async function _appendConsentRow(fields) {
409
419
  }
410
420
 
411
421
  async function _upsertConsentTip(counter, rowHash, signedAt, fencingToken) {
412
- // Single atomic INSERT … ON CONFLICT DO UPDATE … WHERE … RETURNING.
413
- // Same canonical fencing-token guard as _blamejs_audit_tip: the
414
- // WHERE clause enforces monotonic-non-decreasing fencingToken at
415
- // the DB level so a partitioned old leader can't overwrite the tip
416
- // even if its application-layer cluster.requireLeader() gate let
417
- // the call through.
422
+ // Single atomic INSERT … ON CONFLICT(scope) DO UPDATE … WHERE … RETURNING
423
+ // via b.sql. Same canonical fencing-token guard as _blamejs_audit_tip: the
424
+ // fenced WHERE enforces monotonic-non-decreasing fencingToken at the DB
425
+ // level so a partitioned old leader can't overwrite the tip even if its
426
+ // application-layer cluster.requireLeader() gate let the call through. On
427
+ // rejection RETURNING produces 0 rows.
428
+ //
429
+ // The consent-tip is external-only; its LOGICAL name IS the
430
+ // `_blamejs_`-prefixed name (self-mapped in LOCAL_TO_EXTERNAL), passed
431
+ // bare to b.sql so clusterStorage rewrites it (and the same bare name
432
+ // inside the guarded fence) to the configured prefix and placeholderizes.
433
+ //
434
+ // Dialect is the ACTIVE backend (clusterStorage.dialect()) so the fence's
435
+ // identifier quoting + conflict-expression idiom match the server the SQL
436
+ // dispatches to. The fence text itself is dialect-specific because the
437
+ // builder folds it verbatim: on Postgres / SQLite the upsert keeps a
438
+ // `WHERE "<table>"."fencingToken" <= EXCLUDED."fencingToken"` guard (and a
439
+ // RETURNING row that signals fenced-out via 0 rows); on MySQL there is no
440
+ // WHERE and no EXCLUDED, so the builder folds the same guard into per-column
441
+ // `IF(<table>.`fencingToken` <= VALUES(`fencingToken`), VALUES(col), col)`
442
+ // — the fence must therefore reference `VALUES(...)` with backticks. The
443
+ // bare table qualifier (no quoteName) lets clusterStorage rewrite the
444
+ // logical `_blamejs_consent_tip` to the configured prefix inside the fence
445
+ // exactly as it does for the table name.
446
+ var d = clusterStorage.dialect();
447
+ var qFence = safeSql.quoteIdentifier("fencingToken", d);
448
+ var tipFence = d === "mysql"
449
+ ? "_blamejs_consent_tip." + qFence + " <= VALUES(" + qFence + ")" // allow:hand-rolled-sql — bare logical name for clusterStorage rewrite
450
+ : "_blamejs_consent_tip." + qFence + " <= EXCLUDED." + qFence; // allow:hand-rolled-sql — bare logical name for clusterStorage rewrite
451
+ var tipBuilt = sql.upsert("_blamejs_consent_tip", { dialect: d }) // allow:hand-rolled-sql — bare logical name for clusterStorage rewrite
452
+ .columns(["scope", "atMonotonicCounter", "rowHash", "signedAt", "fencingToken"])
453
+ .values({
454
+ scope: "consent",
455
+ atMonotonicCounter: counter,
456
+ rowHash: rowHash,
457
+ signedAt: signedAt,
458
+ fencingToken: fencingToken,
459
+ })
460
+ .onConflict(["scope"])
461
+ .doUpdateFromExcluded(["atMonotonicCounter", "rowHash", "signedAt", "fencingToken"])
462
+ // guardColumn pins fencingToken LAST in the MySQL SET list so every
463
+ // other column's IF() evaluates the guard against the PRE-UPDATE token
464
+ // (MySQL evaluates SET left-to-right; a later assignment would otherwise
465
+ // see fencingToken already overwritten). Ignored on Postgres / SQLite,
466
+ // which apply the WHERE atomically.
467
+ .conflictWhere(tipFence, [], { guardColumn: "fencingToken" })
468
+ .returning(["fencingToken"])
469
+ .toSql();
418
470
  var result = await safeAsync.withTimeout(
419
- clusterStorage.execute(
420
- "INSERT INTO _blamejs_consent_tip " +
421
- " (scope, atMonotonicCounter, rowHash, signedAt, fencingToken) " +
422
- "VALUES ('consent', ?, ?, ?, ?) " +
423
- "ON CONFLICT (scope) DO UPDATE SET " +
424
- " atMonotonicCounter = EXCLUDED.atMonotonicCounter, " +
425
- " rowHash = EXCLUDED.rowHash, " +
426
- " signedAt = EXCLUDED.signedAt, " +
427
- " fencingToken = EXCLUDED.fencingToken " +
428
- "WHERE _blamejs_consent_tip.fencingToken <= EXCLUDED.fencingToken " +
429
- "RETURNING fencingToken",
430
- [counter, rowHash, signedAt, fencingToken]
431
- ),
471
+ clusterStorage.execute(tipBuilt.sql, tipBuilt.params),
432
472
  FRAMEWORK_SQL_TIMEOUT_MS,
433
473
  { name: "consent.upsertConsentTip" }
434
474
  );
435
- if (!result.rows || result.rows.length === 0) {
475
+ // MySQL upsert has no RETURNING — the builder emits a readback SELECT
476
+ // alongside, but a fenced-out lower-token write still SUCCEEDS as a no-op
477
+ // INSERT…ON DUPLICATE KEY UPDATE (the IF() keeps the stored values), so
478
+ // there is no 0-rows signal to detect. The DB-level fence still PRESERVES
479
+ // the tip (the security property); the FENCED_OUT throw is the
480
+ // Postgres/SQLite RETURNING-0-rows path only. On MySQL clusterStorage is
481
+ // not a supported framework backend, so the consent-tip never dispatches
482
+ // there in production — the threaded dialect makes the SAME builders emit
483
+ // valid MySQL for operators driving these shapes against MySQL directly.
484
+ if (d !== "mysql" && (!result.rows || result.rows.length === 0)) {
436
485
  throw new ClusterError(
437
486
  "FENCED_OUT",
438
487
  "consent-tip update rejected: incoming fencingToken=" + fencingToken +