@blamejs/core 0.14.27 → 0.15.1

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 (134) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +2 -2
  3. package/index.js +4 -0
  4. package/lib/ai-content-detect.js +9 -10
  5. package/lib/api-key.js +158 -77
  6. package/lib/atomic-file.js +29 -1
  7. package/lib/audit-chain.js +47 -11
  8. package/lib/audit-sign.js +77 -2
  9. package/lib/audit-tools.js +79 -51
  10. package/lib/audit.js +228 -100
  11. package/lib/backup/index.js +13 -10
  12. package/lib/break-glass.js +202 -144
  13. package/lib/cache.js +174 -105
  14. package/lib/chain-writer.js +38 -16
  15. package/lib/cli.js +19 -14
  16. package/lib/cluster-provider-db.js +130 -104
  17. package/lib/cluster-storage.js +119 -22
  18. package/lib/cluster.js +119 -71
  19. package/lib/compliance.js +22 -0
  20. package/lib/consent.js +82 -29
  21. package/lib/constants.js +16 -11
  22. package/lib/crypto-field.js +387 -91
  23. package/lib/db-declare-row-policy.js +35 -22
  24. package/lib/db-file-lifecycle.js +3 -2
  25. package/lib/db-query.js +517 -256
  26. package/lib/db-schema.js +209 -44
  27. package/lib/db.js +202 -95
  28. package/lib/external-db-migrate.js +229 -139
  29. package/lib/external-db.js +25 -15
  30. package/lib/framework-error.js +11 -0
  31. package/lib/framework-files.js +73 -0
  32. package/lib/framework-schema.js +695 -394
  33. package/lib/gate-contract.js +596 -1
  34. package/lib/guard-agent-registry.js +26 -44
  35. package/lib/guard-all.js +1 -0
  36. package/lib/guard-auth.js +42 -112
  37. package/lib/guard-cidr.js +33 -154
  38. package/lib/guard-csv.js +46 -113
  39. package/lib/guard-domain.js +34 -157
  40. package/lib/guard-dsn.js +27 -43
  41. package/lib/guard-email.js +47 -69
  42. package/lib/guard-envelope.js +19 -32
  43. package/lib/guard-event-bus-payload.js +24 -42
  44. package/lib/guard-event-bus-topic.js +25 -43
  45. package/lib/guard-filename.js +42 -106
  46. package/lib/guard-graphql.js +42 -123
  47. package/lib/guard-html.js +53 -108
  48. package/lib/guard-idempotency-key.js +24 -42
  49. package/lib/guard-image.js +46 -103
  50. package/lib/guard-imap-command.js +18 -32
  51. package/lib/guard-jmap.js +16 -30
  52. package/lib/guard-json.js +38 -108
  53. package/lib/guard-jsonpath.js +38 -171
  54. package/lib/guard-jwt.js +49 -179
  55. package/lib/guard-list-id.js +25 -41
  56. package/lib/guard-list-unsubscribe.js +27 -43
  57. package/lib/guard-mail-compose.js +24 -42
  58. package/lib/guard-mail-move.js +26 -44
  59. package/lib/guard-mail-query.js +28 -46
  60. package/lib/guard-mail-reply.js +24 -42
  61. package/lib/guard-mail-sieve.js +24 -42
  62. package/lib/guard-managesieve-command.js +17 -31
  63. package/lib/guard-markdown.js +37 -104
  64. package/lib/guard-message-id.js +26 -45
  65. package/lib/guard-mime.js +39 -151
  66. package/lib/guard-oauth.js +54 -135
  67. package/lib/guard-pdf.js +45 -101
  68. package/lib/guard-pop3-command.js +21 -31
  69. package/lib/guard-posture-chain.js +24 -42
  70. package/lib/guard-regex.js +33 -107
  71. package/lib/guard-saga-config.js +24 -42
  72. package/lib/guard-shell.js +42 -172
  73. package/lib/guard-smtp-command.js +48 -54
  74. package/lib/guard-snapshot-envelope.js +24 -42
  75. package/lib/guard-sql.js +1491 -0
  76. package/lib/guard-stream-args.js +24 -43
  77. package/lib/guard-svg.js +47 -65
  78. package/lib/guard-template.js +35 -172
  79. package/lib/guard-tenant-id.js +26 -45
  80. package/lib/guard-time.js +32 -154
  81. package/lib/guard-trace-context.js +25 -44
  82. package/lib/guard-uuid.js +32 -153
  83. package/lib/guard-xml.js +38 -113
  84. package/lib/guard-yaml.js +51 -163
  85. package/lib/http-client.js +14 -0
  86. package/lib/inbox.js +120 -107
  87. package/lib/legal-hold.js +107 -50
  88. package/lib/log-stream-cloudwatch.js +47 -31
  89. package/lib/log-stream-otlp.js +32 -18
  90. package/lib/mail-crypto-smime.js +2 -6
  91. package/lib/mail-greylist.js +2 -6
  92. package/lib/mail-helo.js +2 -6
  93. package/lib/mail-journal.js +85 -64
  94. package/lib/mail-rbl.js +2 -6
  95. package/lib/mail-scan.js +2 -6
  96. package/lib/mail-spam-score.js +2 -6
  97. package/lib/mail-store.js +293 -154
  98. package/lib/middleware/fetch-metadata.js +17 -7
  99. package/lib/middleware/idempotency-key.js +54 -38
  100. package/lib/middleware/rate-limit.js +102 -32
  101. package/lib/middleware/security-headers.js +21 -5
  102. package/lib/migrations.js +108 -66
  103. package/lib/network-heartbeat.js +7 -0
  104. package/lib/nonce-store.js +31 -9
  105. package/lib/object-store/azure-blob-bucket-ops.js +9 -4
  106. package/lib/object-store/azure-blob.js +31 -3
  107. package/lib/object-store/sigv4.js +10 -0
  108. package/lib/outbox.js +136 -82
  109. package/lib/pqc-agent.js +44 -0
  110. package/lib/pubsub-cluster.js +42 -20
  111. package/lib/queue-local.js +202 -139
  112. package/lib/queue-redis.js +9 -1
  113. package/lib/queue-sqs.js +6 -0
  114. package/lib/retention.js +82 -39
  115. package/lib/safe-dns.js +29 -45
  116. package/lib/safe-ical.js +18 -33
  117. package/lib/safe-icap.js +27 -43
  118. package/lib/safe-sieve.js +21 -40
  119. package/lib/safe-sql.js +124 -3
  120. package/lib/safe-vcard.js +18 -33
  121. package/lib/scheduler.js +35 -12
  122. package/lib/seeders.js +122 -74
  123. package/lib/session-stores.js +42 -14
  124. package/lib/session.js +116 -72
  125. package/lib/sql.js +3885 -0
  126. package/lib/static.js +45 -7
  127. package/lib/subject.js +89 -49
  128. package/lib/vault/index.js +3 -2
  129. package/lib/vault/passphrase-ops.js +3 -2
  130. package/lib/vault/rotate.js +104 -64
  131. package/lib/vendor-data.js +2 -0
  132. package/lib/websocket.js +16 -0
  133. package/package.json +1 -1
  134. 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
@@ -1065,6 +1065,28 @@ var POSTURE_DEFAULTS = Object.freeze({
1065
1065
  tlsMinVersion: "TLSv1.3",
1066
1066
  requireVacuumAfterErase: true,
1067
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
+ }),
1068
1090
  // v0.8.70 — 2026 effective deadlines
1069
1091
  "modpa": Object.freeze({
1070
1092
  // Maryland Online Data Privacy Act (effective 2026-10-01) —
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");
@@ -300,14 +302,24 @@ function isGranted(opts) {
300
302
  }
301
303
  // Find the most recent consent row for this (subjectId, purpose).
302
304
  // subjectId is sealed → look up via subjectIdHash (derived).
303
- var hash = db().hashFor("consent_log", "subjectId", opts.subjectId);
304
- if (!hash) {
305
+ var subjectCand = db().hashCandidatesFor("consent_log", "subjectId", opts.subjectId);
306
+ if (!subjectCand) {
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. whereIn
312
+ // dual-reads across the keyed-MAC flip so a row written under the legacy
313
+ // salted-sha3 subjectIdHash is still matched.
314
+ var isGrantedBuilt = sql.select("consent_log", { dialect: "sqlite", quoteName: true })
315
+ .columns(["action"])
316
+ .whereIn("subjectIdHash", subjectCand.values)
317
+ .where("purpose", opts.purpose)
318
+ .orderBy("monotonicCounter", "desc")
319
+ .limit(1)
320
+ .toSql();
321
+ var isGrantedStmt = db().prepare(isGrantedBuilt.sql);
322
+ var row = isGrantedStmt.get.apply(isGrantedStmt, isGrantedBuilt.params);
311
323
  if (!row) return false;
312
324
  return row.action === "granted";
313
325
  }
@@ -334,12 +346,14 @@ function isGranted(opts) {
334
346
  */
335
347
  function history(subjectId) {
336
348
  if (!subjectId) throw new Error("consent.history requires a subjectId");
337
- var hash = db().hashFor("consent_log", "subjectId", subjectId);
338
- if (!hash) {
349
+ var subjectCand = db().hashCandidatesFor("consent_log", "subjectId", subjectId);
350
+ if (!subjectCand) {
339
351
  throw new Error("consent_log subjectId is missing a derived hash — schema misconfigured");
340
352
  }
353
+ // whereIn dual-reads across the keyed-MAC flip so the subject's pre-flip
354
+ // (legacy salted-sha3) consent rows still appear in the access response.
341
355
  var rows = db().from("consent_log")
342
- .where({ subjectIdHash: hash })
356
+ .whereIn(subjectCand.field, subjectCand.values)
343
357
  .orderBy("monotonicCounter", "asc")
344
358
  .all();
345
359
  return rows;
@@ -409,30 +423,69 @@ async function _appendConsentRow(fields) {
409
423
  }
410
424
 
411
425
  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.
426
+ // Single atomic INSERT … ON CONFLICT(scope) DO UPDATE … WHERE … RETURNING
427
+ // via b.sql. Same canonical fencing-token guard as _blamejs_audit_tip: the
428
+ // fenced WHERE enforces monotonic-non-decreasing fencingToken at the DB
429
+ // level so a partitioned old leader can't overwrite the tip even if its
430
+ // application-layer cluster.requireLeader() gate let the call through. On
431
+ // rejection RETURNING produces 0 rows.
432
+ //
433
+ // The consent-tip is external-only; its LOGICAL name IS the
434
+ // `_blamejs_`-prefixed name (self-mapped in LOCAL_TO_EXTERNAL), passed
435
+ // bare to b.sql so clusterStorage rewrites it (and the same bare name
436
+ // inside the guarded fence) to the configured prefix and placeholderizes.
437
+ //
438
+ // Dialect is the ACTIVE backend (clusterStorage.dialect()) so the fence's
439
+ // identifier quoting + conflict-expression idiom match the server the SQL
440
+ // dispatches to. The fence text itself is dialect-specific because the
441
+ // builder folds it verbatim: on Postgres / SQLite the upsert keeps a
442
+ // `WHERE "<table>"."fencingToken" <= EXCLUDED."fencingToken"` guard (and a
443
+ // RETURNING row that signals fenced-out via 0 rows); on MySQL there is no
444
+ // WHERE and no EXCLUDED, so the builder folds the same guard into per-column
445
+ // `IF(<table>.`fencingToken` <= VALUES(`fencingToken`), VALUES(col), col)`
446
+ // — the fence must therefore reference `VALUES(...)` with backticks. The
447
+ // bare table qualifier (no quoteName) lets clusterStorage rewrite the
448
+ // logical `_blamejs_consent_tip` to the configured prefix inside the fence
449
+ // exactly as it does for the table name.
450
+ var d = clusterStorage.dialect();
451
+ var qFence = safeSql.quoteIdentifier("fencingToken", d);
452
+ var tipFence = d === "mysql"
453
+ ? "_blamejs_consent_tip." + qFence + " <= VALUES(" + qFence + ")" // allow:hand-rolled-sql — bare logical name for clusterStorage rewrite
454
+ : "_blamejs_consent_tip." + qFence + " <= EXCLUDED." + qFence; // allow:hand-rolled-sql — bare logical name for clusterStorage rewrite
455
+ var tipBuilt = sql.upsert("_blamejs_consent_tip", { dialect: d }) // allow:hand-rolled-sql — bare logical name for clusterStorage rewrite
456
+ .columns(["scope", "atMonotonicCounter", "rowHash", "signedAt", "fencingToken"])
457
+ .values({
458
+ scope: "consent",
459
+ atMonotonicCounter: counter,
460
+ rowHash: rowHash,
461
+ signedAt: signedAt,
462
+ fencingToken: fencingToken,
463
+ })
464
+ .onConflict(["scope"])
465
+ .doUpdateFromExcluded(["atMonotonicCounter", "rowHash", "signedAt", "fencingToken"])
466
+ // guardColumn pins fencingToken LAST in the MySQL SET list so every
467
+ // other column's IF() evaluates the guard against the PRE-UPDATE token
468
+ // (MySQL evaluates SET left-to-right; a later assignment would otherwise
469
+ // see fencingToken already overwritten). Ignored on Postgres / SQLite,
470
+ // which apply the WHERE atomically.
471
+ .conflictWhere(tipFence, [], { guardColumn: "fencingToken" })
472
+ .returning(["fencingToken"])
473
+ .toSql();
418
474
  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
- ),
475
+ clusterStorage.execute(tipBuilt.sql, tipBuilt.params),
432
476
  FRAMEWORK_SQL_TIMEOUT_MS,
433
477
  { name: "consent.upsertConsentTip" }
434
478
  );
435
- if (!result.rows || result.rows.length === 0) {
479
+ // MySQL upsert has no RETURNING — the builder emits a readback SELECT
480
+ // alongside, but a fenced-out lower-token write still SUCCEEDS as a no-op
481
+ // INSERT…ON DUPLICATE KEY UPDATE (the IF() keeps the stored values), so
482
+ // there is no 0-rows signal to detect. The DB-level fence still PRESERVES
483
+ // the tip (the security property); the FENCED_OUT throw is the
484
+ // Postgres/SQLite RETURNING-0-rows path only. On MySQL clusterStorage is
485
+ // not a supported framework backend, so the consent-tip never dispatches
486
+ // there in production — the threaded dialect makes the SAME builders emit
487
+ // valid MySQL for operators driving these shapes against MySQL directly.
488
+ if (d !== "mysql" && (!result.rows || result.rows.length === 0)) {
436
489
  throw new ClusterError(
437
490
  "FENCED_OUT",
438
491
  "consent-tip update rejected: incoming fencingToken=" + fencingToken +
package/lib/constants.js CHANGED
@@ -156,22 +156,27 @@ var PQC_GROUPS = Object.freeze({
156
156
  SecP384r1MLKEM1024: 0x11ED,
157
157
  });
158
158
 
159
- // Highest-first preference list. Node TLS picks the first mutually-
160
- // supported group during the handshake, so SecP384r1MLKEM1024
161
- // (P-384 + ML-KEM-1024) is what we always use when the peer also
162
- // advertises it. X25519MLKEM768 is the only fallback both are
163
- // PQC hybrids with current standardized parameter sets.
159
+ // Highest-first preference list for OUTBOUND TLS (clients only the
160
+ // server's accept-groups are configured separately). Node TLS picks the
161
+ // first mutually-supported group during the handshake, so a peer that
162
+ // advertises SecP384r1MLKEM1024 (P-384 + ML-KEM-1024) gets it, then the
163
+ // X25519 / SecP256r1 ML-KEM hybrids. X25519 (classical) is the LAST-RESORT
164
+ // fallback for peers that support no ML-KEM hybrid yet — still most of the
165
+ // public TLS surface in 2026 (webhooks, OAuth/OIDC, ACME, third-party APIs).
164
166
  //
165
- // Weaker hybrids (e.g. P-256 + ML-KEM-768) are deliberately excluded
166
- // from the framework's default preference. An operator integrating
167
- // with a peer that only supports a weaker PQC group constructs their
168
- // own https.Agent outside lib/pqc-agent so the downgrade is visible
169
- // in the diff the framework primitive cannot be coaxed into
170
- // negotiating below this list.
167
+ // The framework always PREFERS a hybrid on every handshake; classical
168
+ // X25519 is only negotiated when the peer offers none of the hybrids. When
169
+ // a connection lands on classical instead, the outbound path emits a
170
+ // `tls.classical_downgrade` audit event (lib/pqc-agent.js) so operators can
171
+ // see which peers forced a non-PQC negotiation and track their
172
+ // dependencies' PQC readiness. Weaker non-hybrid classical groups
173
+ // (P-256 / P-384) are deliberately NOT offered — the fallback floor is the
174
+ // X25519 group.
171
175
  var TLS_GROUP_PREFERENCE = Object.freeze([
172
176
  "SecP384r1MLKEM1024",
173
177
  "X25519MLKEM768",
174
178
  "SecP256r1MLKEM768",
179
+ "X25519",
175
180
  ]);
176
181
 
177
182
  var TLS_GROUP_CURVE_STR = TLS_GROUP_PREFERENCE.join(":");