@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.
- package/CHANGELOG.md +6 -0
- package/README.md +2 -2
- package/index.js +4 -0
- package/lib/agent-envelope-mac.js +104 -0
- package/lib/agent-event-bus.js +105 -4
- package/lib/agent-posture-chain.js +8 -42
- package/lib/ai-content-detect.js +9 -10
- package/lib/api-key.js +107 -74
- package/lib/atomic-file.js +62 -4
- 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 +249 -123
- package/lib/auth/openid-federation.js +108 -47
- 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 +169 -4
- package/lib/consent.js +73 -24
- package/lib/constants.js +16 -11
- package/lib/crypto-field.js +474 -92
- 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/error-page.js +14 -1
- package/lib/external-db-migrate.js +229 -139
- package/lib/external-db.js +25 -15
- package/lib/file-upload.js +52 -7
- package/lib/framework-error.js +14 -1
- package/lib/framework-files.js +73 -0
- package/lib/framework-schema.js +695 -394
- package/lib/gate-contract.js +649 -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 +37 -9
- 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-server-jmap.js +117 -12
- package/lib/mail-spam-score.js +2 -6
- package/lib/mail-store.js +287 -154
- package/lib/middleware/body-parser.js +71 -25
- package/lib/middleware/csrf-protect.js +19 -8
- 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 +57 -3
- package/lib/object-store/sigv4.js +10 -0
- package/lib/observability.js +87 -0
- package/lib/otel-export.js +25 -1
- package/lib/outbox.js +136 -82
- package/lib/parsers/safe-xml.js +47 -7
- 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/redact.js +68 -11
- package/lib/redis-client.js +160 -31
- package/lib/retention.js +82 -39
- package/lib/router.js +212 -5
- 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/ssrf-guard.js +51 -4
- package/lib/static.js +177 -34
- 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 +35 -5
- 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
|
@@ -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`,
|
|
1361
|
-
* `requireVacuumAfterErase` — the floors
|
|
1362
|
-
* `b.audit`, the TLS minimum-version gate,
|
|
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
|
-
|
|
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 +
|