@blamejs/core 0.14.27 → 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.
- package/CHANGELOG.md +4 -0
- package/README.md +2 -2
- package/index.js +4 -0
- package/lib/ai-content-detect.js +9 -10
- package/lib/api-key.js +107 -74
- package/lib/atomic-file.js +29 -1
- package/lib/audit-chain.js +47 -11
- package/lib/audit-sign.js +77 -2
- package/lib/audit-tools.js +79 -51
- package/lib/audit.js +218 -100
- package/lib/backup/index.js +13 -10
- package/lib/break-glass.js +202 -144
- package/lib/cache.js +174 -105
- package/lib/chain-writer.js +38 -16
- package/lib/cli.js +19 -14
- package/lib/cluster-provider-db.js +130 -104
- package/lib/cluster-storage.js +119 -22
- package/lib/cluster.js +119 -71
- package/lib/compliance.js +22 -0
- package/lib/consent.js +73 -24
- package/lib/constants.js +16 -11
- package/lib/crypto-field.js +387 -91
- package/lib/db-declare-row-policy.js +35 -22
- package/lib/db-file-lifecycle.js +3 -2
- package/lib/db-query.js +497 -255
- package/lib/db-schema.js +209 -44
- package/lib/db.js +176 -95
- package/lib/external-db-migrate.js +229 -139
- package/lib/external-db.js +25 -15
- package/lib/framework-error.js +11 -0
- package/lib/framework-files.js +73 -0
- package/lib/framework-schema.js +695 -394
- package/lib/gate-contract.js +596 -1
- package/lib/guard-agent-registry.js +26 -44
- package/lib/guard-all.js +1 -0
- package/lib/guard-auth.js +42 -112
- package/lib/guard-cidr.js +33 -154
- package/lib/guard-csv.js +46 -113
- package/lib/guard-domain.js +34 -157
- package/lib/guard-dsn.js +27 -43
- package/lib/guard-email.js +47 -69
- package/lib/guard-envelope.js +19 -32
- package/lib/guard-event-bus-payload.js +24 -42
- package/lib/guard-event-bus-topic.js +25 -43
- package/lib/guard-filename.js +42 -106
- package/lib/guard-graphql.js +42 -123
- package/lib/guard-html.js +53 -108
- package/lib/guard-idempotency-key.js +24 -42
- package/lib/guard-image.js +46 -103
- package/lib/guard-imap-command.js +18 -32
- package/lib/guard-jmap.js +16 -30
- package/lib/guard-json.js +38 -108
- package/lib/guard-jsonpath.js +38 -171
- package/lib/guard-jwt.js +49 -179
- package/lib/guard-list-id.js +25 -41
- package/lib/guard-list-unsubscribe.js +27 -43
- package/lib/guard-mail-compose.js +24 -42
- package/lib/guard-mail-move.js +26 -44
- package/lib/guard-mail-query.js +28 -46
- package/lib/guard-mail-reply.js +24 -42
- package/lib/guard-mail-sieve.js +24 -42
- package/lib/guard-managesieve-command.js +17 -31
- package/lib/guard-markdown.js +37 -104
- package/lib/guard-message-id.js +26 -45
- package/lib/guard-mime.js +39 -151
- package/lib/guard-oauth.js +54 -135
- package/lib/guard-pdf.js +45 -101
- package/lib/guard-pop3-command.js +21 -31
- package/lib/guard-posture-chain.js +24 -42
- package/lib/guard-regex.js +33 -107
- package/lib/guard-saga-config.js +24 -42
- package/lib/guard-shell.js +42 -172
- package/lib/guard-smtp-command.js +48 -54
- package/lib/guard-snapshot-envelope.js +24 -42
- package/lib/guard-sql.js +1491 -0
- package/lib/guard-stream-args.js +24 -43
- package/lib/guard-svg.js +47 -65
- package/lib/guard-template.js +35 -172
- package/lib/guard-tenant-id.js +26 -45
- package/lib/guard-time.js +32 -154
- package/lib/guard-trace-context.js +25 -44
- package/lib/guard-uuid.js +32 -153
- package/lib/guard-xml.js +38 -113
- package/lib/guard-yaml.js +51 -163
- package/lib/http-client.js +14 -0
- package/lib/inbox.js +120 -107
- package/lib/legal-hold.js +107 -50
- package/lib/log-stream-cloudwatch.js +47 -31
- package/lib/log-stream-otlp.js +32 -18
- package/lib/mail-crypto-smime.js +2 -6
- package/lib/mail-greylist.js +2 -6
- package/lib/mail-helo.js +2 -6
- package/lib/mail-journal.js +85 -64
- package/lib/mail-rbl.js +2 -6
- package/lib/mail-scan.js +2 -6
- package/lib/mail-spam-score.js +2 -6
- package/lib/mail-store.js +287 -154
- package/lib/middleware/fetch-metadata.js +17 -7
- package/lib/middleware/idempotency-key.js +54 -38
- package/lib/middleware/rate-limit.js +102 -32
- package/lib/middleware/security-headers.js +21 -5
- package/lib/migrations.js +108 -66
- package/lib/network-heartbeat.js +7 -0
- package/lib/nonce-store.js +31 -9
- package/lib/object-store/azure-blob-bucket-ops.js +9 -4
- package/lib/object-store/azure-blob.js +31 -3
- package/lib/object-store/sigv4.js +10 -0
- package/lib/outbox.js +136 -82
- package/lib/pqc-agent.js +44 -0
- package/lib/pubsub-cluster.js +42 -20
- package/lib/queue-local.js +202 -139
- package/lib/queue-redis.js +9 -1
- package/lib/queue-sqs.js +6 -0
- package/lib/retention.js +82 -39
- package/lib/safe-dns.js +29 -45
- package/lib/safe-ical.js +18 -33
- package/lib/safe-icap.js +27 -43
- package/lib/safe-sieve.js +21 -40
- package/lib/safe-sql.js +124 -3
- package/lib/safe-vcard.js +18 -33
- package/lib/scheduler.js +35 -12
- package/lib/seeders.js +122 -74
- package/lib/session-stores.js +42 -14
- package/lib/session.js +109 -72
- package/lib/sql.js +3885 -0
- package/lib/static.js +45 -7
- package/lib/subject.js +55 -17
- package/lib/vault/index.js +3 -2
- package/lib/vault/passphrase-ops.js +3 -2
- package/lib/vault/rotate.js +104 -64
- package/lib/vendor-data.js +2 -0
- package/lib/websocket.js +16 -0
- package/package.json +1 -1
- 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
|
-
|
|
328
|
-
|
|
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
|
|
377
|
-
//
|
|
378
|
-
//
|
|
379
|
-
//
|
|
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
|
|
388
|
-
"
|
|
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
|
-
|
|
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
|
|
410
|
-
"
|
|
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
|
|
430
|
-
"
|
|
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
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
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
|
|
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
|
|
545
|
-
"
|
|
546
|
-
"
|
|
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
|
-
|
|
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
|
|
573
|
-
"
|
|
574
|
-
"
|
|
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
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
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
|
|
647
|
-
"
|
|
648
|
-
"
|
|
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");
|
|
@@ -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
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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
|
|
415
|
-
//
|
|
416
|
-
//
|
|
417
|
-
//
|
|
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
|
-
|
|
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 +
|
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
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
// advertises
|
|
163
|
-
//
|
|
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
|
-
//
|
|
166
|
-
//
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
//
|
|
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(":");
|