@blamejs/core 0.7.4 → 0.7.18
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 +423 -395
- package/README.md +150 -149
- package/bin/blamejs.js +0 -0
- package/index.js +308 -284
- package/lib/api-key.js +660 -663
- package/lib/api-snapshot.js +338 -338
- package/lib/app-shutdown.js +385 -385
- package/lib/app.js +365 -365
- package/lib/archive.js +250 -250
- package/lib/atomic-file.js +544 -544
- package/lib/audit-chain.js +177 -177
- package/lib/audit-sign.js +344 -344
- package/lib/audit-tools.js +677 -677
- package/lib/audit.js +766 -766
- package/lib/auth/jwt.js +311 -311
- package/lib/auth/lockout.js +436 -436
- package/lib/auth/oauth.js +721 -721
- package/lib/auth/passkey.js +181 -181
- package/lib/auth/password.js +594 -594
- package/lib/backup/bundle.js +217 -217
- package/lib/backup/crypto.js +176 -176
- package/lib/backup/index.js +515 -515
- package/lib/backup/manifest.js +282 -282
- package/lib/break-glass.js +1338 -1338
- package/lib/bundler.js +441 -441
- package/lib/cache-redis.js +256 -256
- package/lib/cache.js +1206 -1206
- package/lib/canonical-json.js +115 -115
- package/lib/chain-writer.js +234 -234
- package/lib/cli-helpers.js +206 -206
- package/lib/cli.js +2334 -2334
- package/lib/cluster-provider-db.js +317 -317
- package/lib/cluster-storage.js +226 -226
- package/lib/cluster.js +703 -703
- package/lib/codepoint-class.js +196 -0
- package/lib/config-drift.js +301 -301
- package/lib/consent.js +222 -222
- package/lib/constants.js +191 -191
- package/lib/cookies.js +315 -315
- package/lib/credential-hash.js +322 -322
- package/lib/crypto.js +266 -266
- package/lib/csv.js +275 -286
- package/lib/db-declare-row-policy.js +267 -267
- package/lib/db-declare-view.js +420 -421
- package/lib/db-query.js +406 -406
- package/lib/db-schema.js +319 -319
- package/lib/db.js +1288 -1288
- package/lib/deprecate.js +222 -222
- package/lib/dev.js +335 -335
- package/lib/dual-control.js +473 -473
- package/lib/error-page.js +420 -420
- package/lib/external-db-migrate.js +441 -441
- package/lib/external-db.js +1061 -1061
- package/lib/file-type.js +273 -273
- package/lib/file-upload.js +213 -10
- package/lib/forms.js +422 -422
- package/lib/framework-error.js +293 -215
- package/lib/framework-schema.js +717 -717
- package/lib/gate-contract.js +971 -0
- package/lib/guard-all.js +405 -0
- package/lib/guard-archive.js +739 -0
- package/lib/guard-csv.js +816 -0
- package/lib/guard-email.js +744 -0
- package/lib/guard-filename.js +724 -0
- package/lib/guard-html.js +976 -0
- package/lib/guard-json.js +729 -0
- package/lib/guard-markdown.js +586 -0
- package/lib/guard-svg.js +976 -0
- package/lib/guard-xml.js +405 -0
- package/lib/guard-yaml.js +529 -0
- package/lib/handlers.js +350 -350
- package/lib/http-client-cookie-jar.js +508 -508
- package/lib/http-client.js +1195 -1195
- package/lib/i18n.js +878 -878
- package/lib/jobs.js +185 -185
- package/lib/log-stream-cloudwatch.js +369 -369
- package/lib/log-stream-local.js +146 -146
- package/lib/log-stream-otlp-grpc.js +410 -410
- package/lib/log-stream-otlp.js +286 -286
- package/lib/log-stream-syslog.js +302 -302
- package/lib/log-stream-webhook.js +199 -199
- package/lib/log-stream.js +330 -330
- package/lib/log.js +500 -500
- package/lib/mail-bounce.js +528 -528
- package/lib/mail-dkim.js +369 -362
- package/lib/mail.js +981 -962
- package/lib/metrics.js +683 -683
- package/lib/middleware/api-encrypt.js +936 -936
- package/lib/middleware/attach-user.js +157 -157
- package/lib/middleware/body-parser.js +1170 -1091
- package/lib/middleware/bot-guard.js +178 -178
- package/lib/middleware/compression.js +452 -452
- package/lib/middleware/cors.js +314 -314
- package/lib/middleware/csp-nonce.js +348 -348
- package/lib/middleware/csrf-protect.js +316 -316
- package/lib/middleware/db-role-for.js +264 -264
- package/lib/middleware/health.js +392 -392
- package/lib/middleware/index.js +79 -79
- package/lib/middleware/rate-limit.js +358 -358
- package/lib/middleware/request-id.js +61 -61
- package/lib/middleware/request-log.js +168 -168
- package/lib/middleware/require-auth.js +104 -104
- package/lib/middleware/security-headers.js +116 -116
- package/lib/middleware/sse.js +166 -166
- package/lib/migrations.js +383 -383
- package/lib/mtls-ca.js +518 -518
- package/lib/mtls-engine-default.js +481 -481
- package/lib/network-dns.js +632 -632
- package/lib/network-heartbeat.js +290 -290
- package/lib/network-nts.js +574 -574
- package/lib/network-proxy.js +265 -265
- package/lib/network-tls.js +328 -328
- package/lib/network.js +233 -233
- package/lib/notify.js +612 -612
- package/lib/ntp-check.js +229 -229
- package/lib/numeric-bounds.js +111 -91
- package/lib/object-store/azure-blob-bucket-ops.js +349 -349
- package/lib/object-store/azure-blob.js +488 -488
- package/lib/object-store/gcs-bucket-ops.js +351 -351
- package/lib/object-store/gcs.js +519 -519
- package/lib/object-store/http-put.js +153 -153
- package/lib/object-store/index.js +197 -197
- package/lib/object-store/sigv4-bucket-ops.js +1092 -1092
- package/lib/object-store/sigv4.js +903 -903
- package/lib/observability.js +151 -151
- package/lib/otel-export.js +269 -269
- package/lib/pagination.js +464 -464
- package/lib/parsers/index.js +80 -80
- package/lib/parsers/safe-env.js +642 -642
- package/lib/parsers/safe-ini.js +292 -292
- package/lib/parsers/safe-toml.js +784 -784
- package/lib/parsers/safe-xml.js +390 -390
- package/lib/parsers/safe-yaml.js +1015 -1015
- package/lib/permissions.js +708 -708
- package/lib/pqc-agent.js +87 -87
- package/lib/pqc-gate.js +279 -279
- package/lib/protobuf-encoder.js +190 -190
- package/lib/protocol-dispatcher.js +161 -161
- package/lib/pubsub-redis.js +167 -167
- package/lib/pubsub.js +429 -429
- package/lib/queue-local.js +476 -476
- package/lib/queue-redis.js +745 -745
- package/lib/queue-sqs.js +319 -319
- package/lib/queue.js +695 -695
- package/lib/redis-client.js +519 -519
- package/lib/request-helpers.js +340 -340
- package/lib/restore-bundle.js +237 -237
- package/lib/restore-rollback.js +259 -259
- package/lib/restore.js +409 -409
- package/lib/retry.js +376 -376
- package/lib/router.js +748 -748
- package/lib/safe-async.js +735 -735
- package/lib/safe-buffer.js +237 -237
- package/lib/safe-json.js +541 -541
- package/lib/safe-schema.js +1266 -1266
- package/lib/safe-url.js +159 -159
- package/lib/scheduler.js +706 -706
- package/lib/security-assert.js +373 -373
- package/lib/seeders.js +618 -618
- package/lib/session.js +478 -478
- package/lib/slug.js +269 -269
- package/lib/ssrf-guard.js +401 -401
- package/lib/static.js +184 -4
- package/lib/storage.js +471 -471
- package/lib/subject.js +281 -281
- package/lib/template.js +791 -791
- package/lib/testing.js +798 -798
- package/lib/time.js +310 -310
- package/lib/totp.js +302 -302
- package/lib/tracing.js +494 -494
- package/lib/uuid.js +132 -132
- package/lib/validate-opts.js +340 -319
- package/lib/vault/index.js +308 -308
- package/lib/vault/rotate.js +784 -784
- package/lib/vault/wrap.js +296 -296
- package/lib/vendor/noble-ciphers.cjs +9 -9
- package/lib/webhook.js +595 -595
- package/lib/websocket.js +1048 -1048
- package/package.json +77 -77
- package/sbom.cyclonedx.json +7 -7
package/lib/db.js
CHANGED
|
@@ -1,1288 +1,1288 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
/**
|
|
3
|
-
* Database orchestrator — encrypted-at-rest SQLite backed by node:sqlite.
|
|
4
|
-
*
|
|
5
|
-
* At-rest modes (default 'encrypted' per modernity stance; 'plain' is opt-out
|
|
6
|
-
* only and emits a console warning at boot):
|
|
7
|
-
*
|
|
8
|
-
* encrypted (default):
|
|
9
|
-
* - DB file lives in tmpfs (/dev/shm by default; configurable via
|
|
10
|
-
* db.init({ tmpDir }) or BLAMEJS_TMPDIR env var) at runtime.
|
|
11
|
-
* - On boot: <dataDir>/db.enc → decrypt → tmpDir/blamejs-<token>.db
|
|
12
|
-
* - Periodic re-encrypt every 5 minutes back to <dataDir>/db.enc.
|
|
13
|
-
* - On shutdown: final encrypt + remove plaintext from tmpfs.
|
|
14
|
-
* - DB encryption key sealed by vault, persisted at <dataDir>/db.key.enc.
|
|
15
|
-
* - Refuses to boot if neither a tmpDir nor /dev/shm is available.
|
|
16
|
-
*
|
|
17
|
-
* plain (opt-out):
|
|
18
|
-
* - DB file lives directly at <dataDir>/db (plain SQLite on disk).
|
|
19
|
-
* - No periodic encryption. Field-level encryption (field-crypto.js)
|
|
20
|
-
* still protects sealed columns, but schema and row counts are visible.
|
|
21
|
-
* - Boot warning printed.
|
|
22
|
-
*
|
|
23
|
-
* Public API:
|
|
24
|
-
*
|
|
25
|
-
* await db.init({
|
|
26
|
-
* dataDir, // required — where db.enc + db.key.enc live
|
|
27
|
-
* tmpDir, // optional — override (default /dev/shm)
|
|
28
|
-
* atRest: 'encrypted' | 'plain', // default 'encrypted'
|
|
29
|
-
* schema: [ { name, columns, indexes, sealedFields, derivedHashes }, ... ],
|
|
30
|
-
* migrationDir, // optional — path to ./migrations/ (run-once)
|
|
31
|
-
* });
|
|
32
|
-
*
|
|
33
|
-
* db.from(tableName) → Query (chainable)
|
|
34
|
-
* db.prepare(sql) → SQLite Statement (raw escape hatch)
|
|
35
|
-
* db.stream(sql, ...params, opts?) → Readable (object-mode rows;
|
|
36
|
-
* opts.table enables auto-unseal)
|
|
37
|
-
* db.runSql(sql) → raw SQL execution (DDL, BEGIN/COMMIT)
|
|
38
|
-
* db.transaction(function (db) {…}) → wraps in BEGIN/COMMIT/ROLLBACK
|
|
39
|
-
* db.hashFor(table, field, value) → derived-hash lookup helper
|
|
40
|
-
* db.close() → final encrypt + close (idempotent)
|
|
41
|
-
*/
|
|
42
|
-
var fs = require("fs");
|
|
43
|
-
var path = require("path");
|
|
44
|
-
var { DatabaseSync } = require("node:sqlite");
|
|
45
|
-
var { Readable } = require("node:stream");
|
|
46
|
-
var atomicFile = require("./atomic-file");
|
|
47
|
-
var audit = require("./audit");
|
|
48
|
-
var auditSign = require("./audit-sign");
|
|
49
|
-
var cluster = require("./cluster");
|
|
50
|
-
var events = require("./events");
|
|
51
|
-
var consent = require("./consent");
|
|
52
|
-
var C = require("./constants");
|
|
53
|
-
var { generateToken, generateBytes, encryptPacked, decryptPacked } = require("./crypto");
|
|
54
|
-
var cryptoField = require("./crypto-field");
|
|
55
|
-
var dbDeclareRowPolicy = require("./db-declare-row-policy");
|
|
56
|
-
var dbDeclareView = require("./db-declare-view");
|
|
57
|
-
var { Query } = require("./db-query");
|
|
58
|
-
var dbSchema = require("./db-schema");
|
|
59
|
-
var { defineClass } = require("./framework-error");
|
|
60
|
-
var { boot } = require("./log");
|
|
61
|
-
var lazyRequire = require("./lazy-require");
|
|
62
|
-
var ntpCheck = lazyRequire(function () { return require("./ntp-check"); });
|
|
63
|
-
var safeAsync = require("./safe-async");
|
|
64
|
-
var safeEnv = require("./parsers/safe-env");
|
|
65
|
-
var safeJson = require("./safe-json");
|
|
66
|
-
var vault = require("./vault");
|
|
67
|
-
|
|
68
|
-
var DbError = defineClass("DbError", { alwaysPermanent: true });
|
|
69
|
-
var _dbErr = DbError.factory;
|
|
70
|
-
|
|
71
|
-
// Lazy: cluster-storage's _localDb pulls db back in, so eager require
|
|
72
|
-
// would deadlock the load order. cluster-storage is only used on the
|
|
73
|
-
// purge-audit-chain external-db path, which always runs after init.
|
|
74
|
-
var clusterStorage = lazyRequire(function () { return require("./cluster-storage"); });
|
|
75
|
-
|
|
76
|
-
// Lazy refs for the test-reset cascade. Each module requires db.js
|
|
77
|
-
// directly or transitively (audit/consent/subject/session/etc. all
|
|
78
|
-
// own a sealed-column slice that depends on db.from), so eager
|
|
79
|
-
// requires here would cycle on load. The cascade runs only when a
|
|
80
|
-
// test explicitly resets db, so paying the resolve cost lazily is
|
|
81
|
-
// the correct tradeoff.
|
|
82
|
-
var _resetAudit = lazyRequire(function () { return require("./audit"); });
|
|
83
|
-
var _resetConsent = lazyRequire(function () { return require("./consent"); });
|
|
84
|
-
var _resetSubject = lazyRequire(function () { return require("./subject"); });
|
|
85
|
-
var _resetSession = lazyRequire(function () { return require("./session"); });
|
|
86
|
-
var _resetStorage = lazyRequire(function () { return require("./storage"); });
|
|
87
|
-
var _resetAuditSign = lazyRequire(function () { return require("./audit-sign"); });
|
|
88
|
-
var _resetQueue = lazyRequire(function () { return require("./queue"); });
|
|
89
|
-
var _resetBreakGlass = lazyRequire(function () { return require("./break-glass"); });
|
|
90
|
-
var _resetLogStream = lazyRequire(function () { return require("./log-stream"); });
|
|
91
|
-
var _resetRedact = lazyRequire(function () { return require("./redact"); });
|
|
92
|
-
var _resetExternalDb = lazyRequire(function () { return require("./external-db"); });
|
|
93
|
-
|
|
94
|
-
var AUDIT_TIP_SCHEMA = {
|
|
95
|
-
type: "object",
|
|
96
|
-
required: ["atMonotonicCounter"],
|
|
97
|
-
properties: {
|
|
98
|
-
atMonotonicCounter: { type: "number" },
|
|
99
|
-
rowHash: { type: "string" },
|
|
100
|
-
signedAt: { type: "string" },
|
|
101
|
-
},
|
|
102
|
-
};
|
|
103
|
-
|
|
104
|
-
var runSql = dbSchema.runSql;
|
|
105
|
-
|
|
106
|
-
// Module-local state, populated by init()
|
|
107
|
-
var database = null; // the SQLite handle
|
|
108
|
-
var dbPath = null; // plaintext DB file path (tmpfs in encrypted mode, dataDir/db in plain mode)
|
|
109
|
-
var encPath = null; // encrypted-at-rest path (null in plain mode)
|
|
110
|
-
var encKey = null; // DB encryption key buffer (null in plain mode)
|
|
111
|
-
var encTimer = null; // periodic encrypt interval handle
|
|
112
|
-
var atRest = null; // 'encrypted' or 'plain'
|
|
113
|
-
var dataDir = null;
|
|
114
|
-
var initialized = false;
|
|
115
|
-
var dataResidency = null; // operator's declared region config (validated by storage backends)
|
|
116
|
-
var subjectTables = []; // [{ name, subjectField, personalDataCategories }] — for subject.export/erase
|
|
117
|
-
var tableMetadata = {}; // table name → metadata snapshot (PK/FK/sealed/derived) for getTableMetadata
|
|
118
|
-
|
|
119
|
-
// ---- Framework-baked tables ----
|
|
120
|
-
//
|
|
121
|
-
// audit_log + consent_log + _blamejs_subject_restrictions + _blamejs_subject_erasures
|
|
122
|
-
// are provisioned by the framework before app schema reconciles. Apps cannot
|
|
123
|
-
// opt out, override, or rename them. An app schema entry colliding with any of
|
|
124
|
-
// these names is refused at init.
|
|
125
|
-
var RESERVED_TABLE_NAMES = new Set([
|
|
126
|
-
"audit_log",
|
|
127
|
-
"audit_checkpoints",
|
|
128
|
-
"consent_log",
|
|
129
|
-
"_blamejs_subject_restrictions",
|
|
130
|
-
"_blamejs_subject_erasures",
|
|
131
|
-
"_blamejs_sessions",
|
|
132
|
-
"_blamejs_jobs",
|
|
133
|
-
"_blamejs_migrations",
|
|
134
|
-
"_blamejs_counters",
|
|
135
|
-
"_blamejs_audit_purge_anchor",
|
|
136
|
-
"_blamejs_scheduler_ticks",
|
|
137
|
-
"_blamejs_rate_limit_counters",
|
|
138
|
-
"_blamejs_pubsub_messages",
|
|
139
|
-
"_blamejs_api_encrypt_nonces",
|
|
140
|
-
"_blamejs_api_keys",
|
|
141
|
-
"_blamejs_cache",
|
|
142
|
-
"_blamejs_seeders",
|
|
143
|
-
"_blamejs_seeders_lock",
|
|
144
|
-
"_blamejs_break_glass_policies",
|
|
145
|
-
"_blamejs_break_glass_grants",
|
|
146
|
-
]);
|
|
147
|
-
|
|
148
|
-
var FRAMEWORK_SCHEMA = [
|
|
149
|
-
{
|
|
150
|
-
name: "audit_log",
|
|
151
|
-
columns: {
|
|
152
|
-
_id: "TEXT PRIMARY KEY",
|
|
153
|
-
recordedAt: "INTEGER NOT NULL",
|
|
154
|
-
monotonicCounter: "INTEGER NOT NULL",
|
|
155
|
-
actorUserId: "TEXT",
|
|
156
|
-
actorUserIdHash: "TEXT",
|
|
157
|
-
actorIp: "TEXT",
|
|
158
|
-
actorUserAgent: "TEXT",
|
|
159
|
-
actorSessionId: "TEXT",
|
|
160
|
-
action: "TEXT NOT NULL",
|
|
161
|
-
resourceKind: "TEXT",
|
|
162
|
-
resourceId: "TEXT",
|
|
163
|
-
resourceIdHash: "TEXT",
|
|
164
|
-
outcome: "TEXT NOT NULL",
|
|
165
|
-
reason: "TEXT",
|
|
166
|
-
metadata: "TEXT",
|
|
167
|
-
requestId: "TEXT",
|
|
168
|
-
prevHash: "TEXT NOT NULL",
|
|
169
|
-
rowHash: "TEXT NOT NULL",
|
|
170
|
-
nonce: "BLOB NOT NULL",
|
|
171
|
-
fencingToken: "INTEGER NOT NULL DEFAULT 0",
|
|
172
|
-
},
|
|
173
|
-
indexes: [
|
|
174
|
-
"actorUserIdHash", "resourceIdHash", "recordedAt", "action",
|
|
175
|
-
{ name: "idx_audit_monotonic", columns: "monotonicCounter", unique: true },
|
|
176
|
-
],
|
|
177
|
-
sealedFields: ["actorUserId", "actorIp", "actorUserAgent", "actorSessionId", "resourceId", "reason", "metadata"],
|
|
178
|
-
derivedHashes: {
|
|
179
|
-
actorUserIdHash: { from: "actorUserId" },
|
|
180
|
-
resourceIdHash: { from: "resourceId" },
|
|
181
|
-
},
|
|
182
|
-
},
|
|
183
|
-
{
|
|
184
|
-
name: "consent_log",
|
|
185
|
-
columns: {
|
|
186
|
-
_id: "TEXT PRIMARY KEY",
|
|
187
|
-
recordedAt: "INTEGER NOT NULL",
|
|
188
|
-
monotonicCounter: "INTEGER NOT NULL",
|
|
189
|
-
subjectId: "TEXT NOT NULL",
|
|
190
|
-
subjectIdHash: "TEXT NOT NULL",
|
|
191
|
-
purpose: "TEXT NOT NULL",
|
|
192
|
-
lawfulBasis: "TEXT NOT NULL",
|
|
193
|
-
action: "TEXT NOT NULL",
|
|
194
|
-
scope: "TEXT",
|
|
195
|
-
channel: "TEXT NOT NULL",
|
|
196
|
-
evidenceRef: "TEXT",
|
|
197
|
-
prevHash: "TEXT NOT NULL",
|
|
198
|
-
rowHash: "TEXT NOT NULL",
|
|
199
|
-
nonce: "BLOB NOT NULL",
|
|
200
|
-
fencingToken: "INTEGER NOT NULL DEFAULT 0",
|
|
201
|
-
},
|
|
202
|
-
indexes: [
|
|
203
|
-
"subjectIdHash", "recordedAt", "purpose",
|
|
204
|
-
{ name: "idx_consent_monotonic", columns: "monotonicCounter", unique: true },
|
|
205
|
-
],
|
|
206
|
-
sealedFields: ["subjectId", "scope", "evidenceRef"],
|
|
207
|
-
derivedHashes: {
|
|
208
|
-
subjectIdHash: { from: "subjectId" },
|
|
209
|
-
},
|
|
210
|
-
},
|
|
211
|
-
{
|
|
212
|
-
name: "_blamejs_subject_restrictions",
|
|
213
|
-
columns: {
|
|
214
|
-
subjectIdHash: "TEXT PRIMARY KEY",
|
|
215
|
-
since: "INTEGER NOT NULL",
|
|
216
|
-
reason: "TEXT",
|
|
217
|
-
},
|
|
218
|
-
sealedFields: ["reason"],
|
|
219
|
-
},
|
|
220
|
-
{
|
|
221
|
-
name: "_blamejs_subject_erasures",
|
|
222
|
-
columns: {
|
|
223
|
-
subjectIdHash: "TEXT PRIMARY KEY",
|
|
224
|
-
erasedAt: "INTEGER NOT NULL",
|
|
225
|
-
},
|
|
226
|
-
},
|
|
227
|
-
{
|
|
228
|
-
name: "audit_checkpoints",
|
|
229
|
-
columns: {
|
|
230
|
-
_id: "TEXT PRIMARY KEY",
|
|
231
|
-
createdAt: "INTEGER NOT NULL",
|
|
232
|
-
atMonotonicCounter: "INTEGER NOT NULL",
|
|
233
|
-
atRowHash: "TEXT NOT NULL",
|
|
234
|
-
signature: "BLOB NOT NULL",
|
|
235
|
-
publicKeyFingerprint: "TEXT NOT NULL",
|
|
236
|
-
fencingToken: "INTEGER NOT NULL DEFAULT 0",
|
|
237
|
-
},
|
|
238
|
-
indexes: [
|
|
239
|
-
"createdAt",
|
|
240
|
-
{ name: "idx_chkpt_counter", columns: "atMonotonicCounter", unique: true },
|
|
241
|
-
],
|
|
242
|
-
sealedFields: [],
|
|
243
|
-
},
|
|
244
|
-
{
|
|
245
|
-
name: "_blamejs_audit_purge_anchor",
|
|
246
|
-
columns: {
|
|
247
|
-
scope: "TEXT PRIMARY KEY",
|
|
248
|
-
lastPurgedCounter: "INTEGER NOT NULL",
|
|
249
|
-
lastPurgedRowHash: "TEXT NOT NULL",
|
|
250
|
-
archiveBundleId: "TEXT NOT NULL",
|
|
251
|
-
purgedAt: "INTEGER NOT NULL",
|
|
252
|
-
},
|
|
253
|
-
sealedFields: [],
|
|
254
|
-
},
|
|
255
|
-
{
|
|
256
|
-
// Scheduler exactly-once-globally claim table. Each fire claims a
|
|
257
|
-
// (name, scheduledAtUnix) row before dispatching; UNIQUE on the
|
|
258
|
-
// composite tickKey (name + ":" + scheduledAtUnix) means a concurrent
|
|
259
|
-
// leader's INSERT loses with a constraint violation, and that node
|
|
260
|
-
// skips the tick. Closes the once-globally gap during cluster
|
|
261
|
-
// leader hand-offs where two leaders briefly coexist.
|
|
262
|
-
name: "_blamejs_scheduler_ticks",
|
|
263
|
-
columns: {
|
|
264
|
-
tickKey: "TEXT PRIMARY KEY",
|
|
265
|
-
name: "TEXT NOT NULL",
|
|
266
|
-
scheduledAtUnix: "INTEGER NOT NULL",
|
|
267
|
-
claimedAtUnix: "INTEGER NOT NULL",
|
|
268
|
-
claimedBy: "TEXT",
|
|
269
|
-
},
|
|
270
|
-
indexes: ["scheduledAtUnix"],
|
|
271
|
-
sealedFields: [],
|
|
272
|
-
},
|
|
273
|
-
{
|
|
274
|
-
// _blamejs_rate_limit_counters — fixed-window counter table for
|
|
275
|
-
// the cluster-shared rate-limit backend. One row per (key); the
|
|
276
|
-
// count rolls over atomically when the windowStart advances. Used
|
|
277
|
-
// by lib/middleware/rate-limit.js when scope: 'cluster' is set.
|
|
278
|
-
name: "_blamejs_rate_limit_counters",
|
|
279
|
-
columns: {
|
|
280
|
-
key: "TEXT PRIMARY KEY",
|
|
281
|
-
windowStart: "INTEGER NOT NULL",
|
|
282
|
-
count: "INTEGER NOT NULL DEFAULT 0",
|
|
283
|
-
},
|
|
284
|
-
indexes: ["windowStart"],
|
|
285
|
-
sealedFields: [],
|
|
286
|
-
},
|
|
287
|
-
{
|
|
288
|
-
// _blamejs_pubsub_messages — cluster fan-out for `b.pubsub` (the
|
|
289
|
-
// generalization of the previous WebSocket-specific table). Any
|
|
290
|
-
// pubsub instance using the `cluster` backend writes a row on
|
|
291
|
-
// publish; other nodes poll for new ids and dispatch to their
|
|
292
|
-
// local subscribers. Rows older than the configured retention
|
|
293
|
-
// window are pruned by the backend on a rate-limited basis.
|
|
294
|
-
name: "_blamejs_pubsub_messages",
|
|
295
|
-
columns: {
|
|
296
|
-
id: "INTEGER PRIMARY KEY AUTOINCREMENT",
|
|
297
|
-
topic: "TEXT NOT NULL",
|
|
298
|
-
payload: "TEXT NOT NULL",
|
|
299
|
-
publishedAt: "INTEGER NOT NULL",
|
|
300
|
-
publishedBy: "TEXT NOT NULL",
|
|
301
|
-
},
|
|
302
|
-
indexes: ["publishedAt"],
|
|
303
|
-
sealedFields: [],
|
|
304
|
-
},
|
|
305
|
-
{
|
|
306
|
-
// _blamejs_api_encrypt_nonces — replay-protection store for the
|
|
307
|
-
// api-encrypt middleware. The middleware hashes the client-supplied
|
|
308
|
-
// nonce via SHA3 before insert so a leaked DB / table dump never
|
|
309
|
-
// exposes the original 16-byte client nonces. Hashing is
|
|
310
|
-
// deterministic so the PRIMARY KEY conflict is what catches a
|
|
311
|
-
// replay attempt within the replay window.
|
|
312
|
-
name: "_blamejs_api_encrypt_nonces",
|
|
313
|
-
columns: {
|
|
314
|
-
nonceHash: "TEXT PRIMARY KEY",
|
|
315
|
-
expireAt: "INTEGER NOT NULL",
|
|
316
|
-
},
|
|
317
|
-
indexes: ["expireAt"],
|
|
318
|
-
sealedFields: [],
|
|
319
|
-
},
|
|
320
|
-
{
|
|
321
|
-
name: "_blamejs_sessions",
|
|
322
|
-
columns: {
|
|
323
|
-
sidHash: "TEXT PRIMARY KEY",
|
|
324
|
-
userId: "TEXT NOT NULL",
|
|
325
|
-
userIdHash: "TEXT NOT NULL",
|
|
326
|
-
data: "TEXT",
|
|
327
|
-
createdAt: "INTEGER NOT NULL",
|
|
328
|
-
expiresAt: "INTEGER NOT NULL",
|
|
329
|
-
lastActivity: "INTEGER NOT NULL",
|
|
330
|
-
},
|
|
331
|
-
indexes: ["userIdHash", "expiresAt"],
|
|
332
|
-
sealedFields: ["userId", "data"],
|
|
333
|
-
derivedHashes: { userIdHash: { from: "userId" } },
|
|
334
|
-
},
|
|
335
|
-
{
|
|
336
|
-
// _blamejs_api_keys — operator-facing API-key registry. Sealed
|
|
337
|
-
// columns: ownerId / scopes / metadata. The secret never lands
|
|
338
|
-
// here — only its SHA3-512 hash, constant-time-compared on
|
|
339
|
-
// verify. Same dual-storage pattern as sessions: this row mirrors
|
|
340
|
-
// the cluster-mode DDL in framework-schema.js so cluster-storage
|
|
341
|
-
// can route to either backend transparently.
|
|
342
|
-
name: "_blamejs_api_keys",
|
|
343
|
-
columns: {
|
|
344
|
-
id: "TEXT PRIMARY KEY",
|
|
345
|
-
namespace: "TEXT NOT NULL",
|
|
346
|
-
ownerId: "TEXT NOT NULL",
|
|
347
|
-
ownerIdHash: "TEXT NOT NULL",
|
|
348
|
-
secretHash: "TEXT NOT NULL",
|
|
349
|
-
// secondarySecretHash + secondaryExpiresAt support graceful key
|
|
350
|
-
// rotation: when rotate({ gracePeriodMs }) is called the old hash
|
|
351
|
-
// is preserved here and the new hash takes the primary slot. Both
|
|
352
|
-
// verify successfully until secondaryExpiresAt, then the old slot
|
|
353
|
-
// is implicitly retired.
|
|
354
|
-
secondarySecretHash: "TEXT",
|
|
355
|
-
secondaryExpiresAt: "INTEGER",
|
|
356
|
-
scopes: "TEXT",
|
|
357
|
-
metadata: "TEXT",
|
|
358
|
-
createdAt: "INTEGER NOT NULL",
|
|
359
|
-
expiresAt: "INTEGER",
|
|
360
|
-
revokedAt: "INTEGER",
|
|
361
|
-
lastUsedAt: "INTEGER",
|
|
362
|
-
prefix: "TEXT NOT NULL",
|
|
363
|
-
},
|
|
364
|
-
indexes: [
|
|
365
|
-
"ownerIdHash",
|
|
366
|
-
{ name: "idx_api_keys_namespace_owner", columns: ["namespace", "ownerIdHash"] },
|
|
367
|
-
"expiresAt",
|
|
368
|
-
],
|
|
369
|
-
sealedFields: ["ownerId", "scopes", "metadata"],
|
|
370
|
-
derivedHashes: { ownerIdHash: { from: "ownerId" } },
|
|
371
|
-
},
|
|
372
|
-
{
|
|
373
|
-
name: "_blamejs_jobs",
|
|
374
|
-
columns: {
|
|
375
|
-
_id: "TEXT PRIMARY KEY",
|
|
376
|
-
queueName: "TEXT NOT NULL",
|
|
377
|
-
payload: "TEXT",
|
|
378
|
-
status: "TEXT NOT NULL",
|
|
379
|
-
enqueuedAt: "INTEGER NOT NULL",
|
|
380
|
-
availableAt: "INTEGER NOT NULL",
|
|
381
|
-
leasedAt: "INTEGER",
|
|
382
|
-
leaseExpiresAt: "INTEGER",
|
|
383
|
-
attempts: "INTEGER NOT NULL DEFAULT 0",
|
|
384
|
-
maxAttempts: "INTEGER NOT NULL DEFAULT 5",
|
|
385
|
-
lastError: "TEXT",
|
|
386
|
-
finishedAt: "INTEGER",
|
|
387
|
-
traceId: "TEXT",
|
|
388
|
-
classification: "TEXT",
|
|
389
|
-
priority: "INTEGER NOT NULL DEFAULT 0",
|
|
390
|
-
// Repeat-in-queue: cron-shaped recurring jobs re-enqueue themselves
|
|
391
|
-
// after each successful completion. NULL = one-shot (no repeat).
|
|
392
|
-
repeatCron: "TEXT",
|
|
393
|
-
repeatTimezone: "TEXT",
|
|
394
|
-
// Flows: parent-child job graphs with dependency edges.
|
|
395
|
-
// flowId groups jobs in the same flow; dependsOn is a JSON array
|
|
396
|
-
// of jobIds this row waits for; flowChildName is the human-readable
|
|
397
|
-
// label inside the flow used by dependsOn resolution.
|
|
398
|
-
flowId: "TEXT",
|
|
399
|
-
flowChildName: "TEXT",
|
|
400
|
-
dependsOn: "TEXT",
|
|
401
|
-
},
|
|
402
|
-
indexes: [
|
|
403
|
-
{ name: "idx_jobs_lease", columns: ["queueName", "status", "availableAt"] },
|
|
404
|
-
{ name: "idx_jobs_priority", columns: ["queueName", "status", "priority", "availableAt"] },
|
|
405
|
-
{ name: "idx_jobs_flow", columns: ["flowId"] },
|
|
406
|
-
"leaseExpiresAt",
|
|
407
|
-
"finishedAt",
|
|
408
|
-
],
|
|
409
|
-
sealedFields: ["payload", "lastError"],
|
|
410
|
-
},
|
|
411
|
-
{
|
|
412
|
-
// _blamejs_cache — operator-facing cache primitive's cluster backend
|
|
413
|
-
// (lib/cache.js). Mirrors the cluster-mode DDL in framework-schema.js.
|
|
414
|
-
// PRIMARY KEY is the composite "<namespace>:<key>"; valueJson is
|
|
415
|
-
// JSON-serialized; expiresAt is unix-ms (Number.MAX_SAFE_INTEGER for
|
|
416
|
-
// never-expiring entries). Not sealed: cache values are operator-
|
|
417
|
-
// chosen application data, the operator decides what's worth storing.
|
|
418
|
-
name: "_blamejs_cache",
|
|
419
|
-
columns: {
|
|
420
|
-
cacheKey: "TEXT PRIMARY KEY",
|
|
421
|
-
valueJson: "TEXT NOT NULL",
|
|
422
|
-
expiresAt: "INTEGER NOT NULL",
|
|
423
|
-
updatedAt: "INTEGER NOT NULL",
|
|
424
|
-
},
|
|
425
|
-
indexes: ["expiresAt"],
|
|
426
|
-
sealedFields: [],
|
|
427
|
-
},
|
|
428
|
-
{
|
|
429
|
-
// _blamejs_cache_tags — junction table for tag→cacheKey lookup
|
|
430
|
-
// backing b.cache.invalidateTag(t) on the cluster backend. Composite
|
|
431
|
-
// PK (cacheKey, tag) lets one cacheKey carry many tags; index on
|
|
432
|
-
// tag makes invalidation a single indexed scan. Cleared together
|
|
433
|
-
// with the matching _blamejs_cache rows on del / clear / sweep.
|
|
434
|
-
name: "_blamejs_cache_tags",
|
|
435
|
-
columns: {
|
|
436
|
-
cacheKey: "TEXT NOT NULL",
|
|
437
|
-
tag: "TEXT NOT NULL",
|
|
438
|
-
},
|
|
439
|
-
primaryKey: ["cacheKey", "tag"],
|
|
440
|
-
indexes: ["tag"],
|
|
441
|
-
sealedFields: [],
|
|
442
|
-
},
|
|
443
|
-
{
|
|
444
|
-
// _blamejs_seeders — registry of applied seed files for the
|
|
445
|
-
// b.seeders primitive (lib/seeders.js). Composite PK (env, name)
|
|
446
|
-
// means the same filename can apply per env (dev fixtures don't
|
|
447
|
-
// collide with prod fixtures by name). rerunnable=1 entries get
|
|
448
|
-
// their appliedAt updated in place on every run; non-rerunnable
|
|
449
|
-
// entries are insert-once.
|
|
450
|
-
name: "_blamejs_seeders",
|
|
451
|
-
columns: {
|
|
452
|
-
env: "TEXT NOT NULL",
|
|
453
|
-
name: "TEXT NOT NULL",
|
|
454
|
-
description: "TEXT",
|
|
455
|
-
appliedAt: "TEXT NOT NULL",
|
|
456
|
-
rerunnable: "INTEGER NOT NULL DEFAULT 0",
|
|
457
|
-
},
|
|
458
|
-
primaryKey: ["env", "name"],
|
|
459
|
-
indexes: [],
|
|
460
|
-
sealedFields: [],
|
|
461
|
-
},
|
|
462
|
-
{
|
|
463
|
-
// _blamejs_seeders_lock — single-row advisory lock for the seeders
|
|
464
|
-
// runner. Same shape as _blamejs_migrations_lock (CHECK constraint
|
|
465
|
-
// on scope='lock' enforces single row). Two processes calling
|
|
466
|
-
// `seed run` against the same DB race on this PK; loser sees a
|
|
467
|
-
// clear "lock held" error.
|
|
468
|
-
name: "_blamejs_seeders_lock",
|
|
469
|
-
columns: {
|
|
470
|
-
scope: "TEXT PRIMARY KEY CHECK (scope = 'lock')",
|
|
471
|
-
lockedAt: "INTEGER NOT NULL",
|
|
472
|
-
lockedBy: "TEXT NOT NULL",
|
|
473
|
-
},
|
|
474
|
-
sealedFields: [],
|
|
475
|
-
},
|
|
476
|
-
{
|
|
477
|
-
// _blamejs_break_glass_policies — column-level break-glass policy
|
|
478
|
-
// registry. One row per (table) declares which columns are
|
|
479
|
-
// glass-locked and what the operator's grant rules are. Sealed
|
|
480
|
-
// columns hold the column-list, factor-list, and bypass config so
|
|
481
|
-
// policy contents aren't browsable in cleartext.
|
|
482
|
-
name: "_blamejs_break_glass_policies",
|
|
483
|
-
columns: {
|
|
484
|
-
tableName: "TEXT PRIMARY KEY",
|
|
485
|
-
columnsJson: "TEXT NOT NULL",
|
|
486
|
-
factorsJson: "TEXT NOT NULL",
|
|
487
|
-
cryptographic: "INTEGER NOT NULL DEFAULT 0",
|
|
488
|
-
grantTtlMs: "INTEGER NOT NULL",
|
|
489
|
-
maxRowsPerGrant: "INTEGER NOT NULL DEFAULT 1",
|
|
490
|
-
reasonRequired: "INTEGER NOT NULL DEFAULT 1",
|
|
491
|
-
reasonMinLength: "INTEGER NOT NULL DEFAULT 12",
|
|
492
|
-
pinIp: "INTEGER NOT NULL DEFAULT 1",
|
|
493
|
-
sessionPin: "INTEGER NOT NULL DEFAULT 1",
|
|
494
|
-
onLockedAccess: "TEXT NOT NULL DEFAULT 'throw'",
|
|
495
|
-
requireScope: "TEXT",
|
|
496
|
-
serviceAccountBypassJson: "TEXT",
|
|
497
|
-
dekSealed: "TEXT",
|
|
498
|
-
auditReasonStorage: "TEXT NOT NULL DEFAULT 'cleartext'",
|
|
499
|
-
updatedAt: "INTEGER NOT NULL",
|
|
500
|
-
},
|
|
501
|
-
indexes: [],
|
|
502
|
-
sealedFields: ["columnsJson", "factorsJson", "serviceAccountBypassJson"],
|
|
503
|
-
},
|
|
504
|
-
{
|
|
505
|
-
// _blamejs_break_glass_grants — issued grants. Each successful
|
|
506
|
-
// step-up creates one row; each row read decrements rowsRemaining.
|
|
507
|
-
// Default maxRowsPerGrant=1 enforces "row by row" auth per the
|
|
508
|
-
// operator-confirmed shape (each row access = its own grant).
|
|
509
|
-
// Sealed columns hold reason + scopeColumns so audit-readable
|
|
510
|
-
// metadata doesn't leak in cleartext.
|
|
511
|
-
name: "_blamejs_break_glass_grants",
|
|
512
|
-
columns: {
|
|
513
|
-
_id: "TEXT PRIMARY KEY",
|
|
514
|
-
issuedToActorId: "TEXT NOT NULL",
|
|
515
|
-
issuedToActorHash: "TEXT NOT NULL",
|
|
516
|
-
factorType: "TEXT NOT NULL",
|
|
517
|
-
reasonSealed: "TEXT",
|
|
518
|
-
scopeTable: "TEXT NOT NULL",
|
|
519
|
-
scopeColumnsJson: "TEXT NOT NULL",
|
|
520
|
-
issuedAt: "INTEGER NOT NULL",
|
|
521
|
-
expiresAt: "INTEGER NOT NULL",
|
|
522
|
-
maxRowsPerGrant: "INTEGER NOT NULL",
|
|
523
|
-
rowsConsumed: "INTEGER NOT NULL DEFAULT 0",
|
|
524
|
-
revokedAt: "INTEGER",
|
|
525
|
-
sessionId: "TEXT",
|
|
526
|
-
ip: "TEXT",
|
|
527
|
-
kwGrantHalf: "TEXT",
|
|
528
|
-
},
|
|
529
|
-
indexes: [
|
|
530
|
-
{ name: "idx_bg_grants_actor", columns: ["issuedToActorHash"] },
|
|
531
|
-
{ name: "idx_bg_grants_table", columns: ["scopeTable"] },
|
|
532
|
-
"expiresAt",
|
|
533
|
-
"revokedAt",
|
|
534
|
-
],
|
|
535
|
-
derivedHashes: { issuedToActorHash: { from: "issuedToActorId" } },
|
|
536
|
-
sealedFields: ["reasonSealed", "scopeColumnsJson"],
|
|
537
|
-
},
|
|
538
|
-
];
|
|
539
|
-
|
|
540
|
-
var log = boot("db");
|
|
541
|
-
|
|
542
|
-
// ---- Tmpfs detection ----
|
|
543
|
-
|
|
544
|
-
function resolveTmpDir(optsTmpDir) {
|
|
545
|
-
if (optsTmpDir) return optsTmpDir;
|
|
546
|
-
var envTmp = safeEnv.readVar("BLAMEJS_TMPDIR");
|
|
547
|
-
if (envTmp) return envTmp;
|
|
548
|
-
if (fs.existsSync("/dev/shm")) return "/dev/shm";
|
|
549
|
-
return null;
|
|
550
|
-
}
|
|
551
|
-
|
|
552
|
-
// ---- DB encryption key management ----
|
|
553
|
-
|
|
554
|
-
function loadOrCreateDbKey(dataDirPath) {
|
|
555
|
-
var keyPath = path.join(dataDirPath, "db.key.enc");
|
|
556
|
-
if (fs.existsSync(keyPath)) {
|
|
557
|
-
var sealed = atomicFile.readSync(keyPath, { encoding: "utf8" }).trim();
|
|
558
|
-
var b64 = vault.unseal(sealed);
|
|
559
|
-
if (!b64) {
|
|
560
|
-
throw _dbErr("db/key-unseal-empty",
|
|
561
|
-
"FATAL: db.key.enc unseal returned empty — vault may not be initialized or key file corrupted");
|
|
562
|
-
}
|
|
563
|
-
return Buffer.from(b64, "base64");
|
|
564
|
-
}
|
|
565
|
-
// First run — generate, seal, persist (atomic)
|
|
566
|
-
var raw = generateBytes(C.BYTES.bytes(32));
|
|
567
|
-
var sealedKey = vault.seal(raw.toString("base64"));
|
|
568
|
-
atomicFile.writeSync(keyPath, sealedKey, { fileMode: 0o600 });
|
|
569
|
-
log("generated DB encryption key at " + keyPath);
|
|
570
|
-
return raw;
|
|
571
|
-
}
|
|
572
|
-
|
|
573
|
-
function decryptToTmp() {
|
|
574
|
-
if (!encPath || !fs.existsSync(encPath)) return;
|
|
575
|
-
// If a plaintext file already exists in tmpfs from a prior process, prefer
|
|
576
|
-
// the newer mtime (crash recovery — operator's most recent state wins).
|
|
577
|
-
if (fs.existsSync(dbPath)) {
|
|
578
|
-
var plainStat = fs.statSync(dbPath);
|
|
579
|
-
var encStat = fs.statSync(encPath);
|
|
580
|
-
if (plainStat.mtimeMs > encStat.mtimeMs && plainStat.size > 0) {
|
|
581
|
-
log("plaintext is newer than encrypted — keeping plaintext (crash recovery)");
|
|
582
|
-
return;
|
|
583
|
-
}
|
|
584
|
-
}
|
|
585
|
-
var packed = fs.readFileSync(encPath);
|
|
586
|
-
if (packed.length < 26) return; // too short to be a valid envelope
|
|
587
|
-
atomicFile.writeSync(dbPath, decryptPacked(packed, encKey));
|
|
588
|
-
}
|
|
589
|
-
|
|
590
|
-
function encryptToDisk() {
|
|
591
|
-
if (!encPath) return;
|
|
592
|
-
// Force WAL checkpoint so the .db file holds all committed transactions.
|
|
593
|
-
try { runSql(database, "PRAGMA wal_checkpoint(TRUNCATE)"); } catch (_e) { /* best effort */ }
|
|
594
|
-
if (!fs.existsSync(dbPath)) return;
|
|
595
|
-
atomicFile.writeSync(encPath, encryptPacked(fs.readFileSync(dbPath), encKey));
|
|
596
|
-
}
|
|
597
|
-
|
|
598
|
-
// Remove the plaintext DB + WAL/SHM sidecar files. On Windows these can't be
|
|
599
|
-
// unlinked while the SQLite handle is open, so this MUST be called after
|
|
600
|
-
// database.close().
|
|
601
|
-
function removePlaintextFiles() {
|
|
602
|
-
if (!dbPath) return;
|
|
603
|
-
try { fs.unlinkSync(dbPath); } catch (_e) { /* cleanup */ }
|
|
604
|
-
try { fs.unlinkSync(dbPath + "-wal"); } catch (_e) { /* cleanup */ }
|
|
605
|
-
try { fs.unlinkSync(dbPath + "-shm"); } catch (_e) { /* cleanup */ }
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
// Clean up stale plaintext DB files left by previously-crashed processes.
|
|
609
|
-
// Anything matching blamejs-*.db that isn't our current process's file is
|
|
610
|
-
// stale (no other process should write to /dev/shm with our prefix).
|
|
611
|
-
function cleanStaleTmpDbs(tmpDir) {
|
|
612
|
-
var entries = atomicFile.listDir(tmpDir, {
|
|
613
|
-
filter: function (name) { return name.startsWith("blamejs-") && name.endsWith(".db"); },
|
|
614
|
-
});
|
|
615
|
-
for (var i = 0; i < entries.length; i++) {
|
|
616
|
-
var full = entries[i].fullPath;
|
|
617
|
-
if (full === dbPath) continue;
|
|
618
|
-
try { fs.unlinkSync(full); } catch (_e) { /* concurrent cleanup */ }
|
|
619
|
-
try { fs.unlinkSync(full + "-wal"); } catch (_e) { /* may not exist */ }
|
|
620
|
-
try { fs.unlinkSync(full + "-shm"); } catch (_e) { /* may not exist */ }
|
|
621
|
-
}
|
|
622
|
-
}
|
|
623
|
-
|
|
624
|
-
// ---- Init dispatch ----
|
|
625
|
-
|
|
626
|
-
async function init(opts) {
|
|
627
|
-
if (initialized) return;
|
|
628
|
-
if (!opts || !opts.dataDir) {
|
|
629
|
-
throw new DbError("db/bad-init", "db.init({ dataDir }) is required");
|
|
630
|
-
}
|
|
631
|
-
if (!Array.isArray(opts.schema)) {
|
|
632
|
-
throw new DbError("db/bad-init",
|
|
633
|
-
"db.init({ schema }) must be an array of table definitions");
|
|
634
|
-
}
|
|
635
|
-
|
|
636
|
-
atRest = (opts.atRest || "encrypted").toLowerCase();
|
|
637
|
-
if (atRest !== "encrypted" && atRest !== "plain") {
|
|
638
|
-
throw new DbError("db/bad-at-rest",
|
|
639
|
-
"db.init: atRest must be 'encrypted' or 'plain', got: " + opts.atRest);
|
|
640
|
-
}
|
|
641
|
-
dataDir = opts.dataDir;
|
|
642
|
-
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
|
|
643
|
-
|
|
644
|
-
if (atRest === "encrypted") {
|
|
645
|
-
var tmpDir = resolveTmpDir(opts.tmpDir);
|
|
646
|
-
if (!tmpDir) {
|
|
647
|
-
throw _dbErr("db/no-tmpfs",
|
|
648
|
-
"FATAL: atRest: 'encrypted' (default) requires tmpfs but none was found. " +
|
|
649
|
-
"Provide opts.tmpDir or set BLAMEJS_TMPDIR, or pass atRest: 'plain' (with warning).");
|
|
650
|
-
}
|
|
651
|
-
if (!fs.existsSync(tmpDir)) fs.mkdirSync(tmpDir, { recursive: true });
|
|
652
|
-
|
|
653
|
-
encPath = path.join(dataDir, "db.enc");
|
|
654
|
-
dbPath = path.join(tmpDir, "blamejs-" + generateToken(C.BYTES.bytes(16)) + ".db");
|
|
655
|
-
encKey = loadOrCreateDbKey(dataDir);
|
|
656
|
-
|
|
657
|
-
cleanStaleTmpDbs(tmpDir);
|
|
658
|
-
decryptToTmp();
|
|
659
|
-
} else {
|
|
660
|
-
// plain mode
|
|
661
|
-
log.warn("WARNING: atRest: 'plain' — DB structure and row counts visible on disk.");
|
|
662
|
-
log.warn(" Field-level encryption (sealedFields) still protects sealed columns,");
|
|
663
|
-
log.warn(" but the simpler at-rest model is opt-out only. Default is 'encrypted'.");
|
|
664
|
-
dbPath = path.join(dataDir, "blamejs.db");
|
|
665
|
-
encPath = null;
|
|
666
|
-
encKey = null;
|
|
667
|
-
}
|
|
668
|
-
|
|
669
|
-
// Open the database
|
|
670
|
-
database = new DatabaseSync(dbPath);
|
|
671
|
-
|
|
672
|
-
// Performance pragmas
|
|
673
|
-
runSql(database, "PRAGMA journal_mode=WAL");
|
|
674
|
-
runSql(database, "PRAGMA synchronous=NORMAL");
|
|
675
|
-
runSql(database, "PRAGMA cache_size=-8000");
|
|
676
|
-
runSql(database, "PRAGMA temp_store=MEMORY");
|
|
677
|
-
runSql(database, "PRAGMA busy_timeout=5000");
|
|
678
|
-
runSql(database, "PRAGMA mmap_size=268435456");
|
|
679
|
-
runSql(database, "PRAGMA auto_vacuum=INCREMENTAL");
|
|
680
|
-
// Foreign-key enforcement is OFF by default in SQLite. Turn it ON so
|
|
681
|
-
// structured `foreignKeys` declarations actually constrain writes.
|
|
682
|
-
runSql(database, "PRAGMA foreign_keys=ON");
|
|
683
|
-
|
|
684
|
-
// Refuse app schema entries that collide with framework-reserved names
|
|
685
|
-
for (var ri = 0; ri < opts.schema.length; ri++) {
|
|
686
|
-
if (RESERVED_TABLE_NAMES.has(opts.schema[ri].name)) {
|
|
687
|
-
throw new DbError("db/reserved-table-name",
|
|
688
|
-
"table name '" + opts.schema[ri].name + "' is reserved by the framework. " +
|
|
689
|
-
"Pick a different name (the framework provisions audit_log, consent_log, " +
|
|
690
|
-
"and _blamejs_* tables automatically).");
|
|
691
|
-
}
|
|
692
|
-
}
|
|
693
|
-
|
|
694
|
-
// Track subject schema for subject.export/erase walks
|
|
695
|
-
subjectTables = [];
|
|
696
|
-
for (var si = 0; si < opts.schema.length; si++) {
|
|
697
|
-
var st = opts.schema[si];
|
|
698
|
-
if (st.subjectField) {
|
|
699
|
-
subjectTables.push({
|
|
700
|
-
name: st.name,
|
|
701
|
-
subjectField: st.subjectField,
|
|
702
|
-
personalDataCategories: st.personalDataCategories || {},
|
|
703
|
-
});
|
|
704
|
-
}
|
|
705
|
-
}
|
|
706
|
-
|
|
707
|
-
// Build the full schema = framework-baked tables + app tables.
|
|
708
|
-
// Framework tables come FIRST so audit_log/consent_log exist before any
|
|
709
|
-
// app migration can reference them.
|
|
710
|
-
var fullSchema = FRAMEWORK_SCHEMA.concat(opts.schema);
|
|
711
|
-
|
|
712
|
-
// Register schema with field-crypto + capture table metadata snapshot
|
|
713
|
-
// (framework tables included so getTableMetadata covers everything).
|
|
714
|
-
tableMetadata = {};
|
|
715
|
-
for (var i = 0; i < fullSchema.length; i++) {
|
|
716
|
-
var t = fullSchema[i];
|
|
717
|
-
cryptoField.registerTable(t.name, {
|
|
718
|
-
sealedFields: t.sealedFields,
|
|
719
|
-
derivedHashes: t.derivedHashes,
|
|
720
|
-
hashNamespaces: t.hashNamespaces,
|
|
721
|
-
});
|
|
722
|
-
tableMetadata[t.name] = {
|
|
723
|
-
primaryKey: _normalizePk(t),
|
|
724
|
-
foreignKeys: Array.isArray(t.foreignKeys) ? t.foreignKeys.slice() : [],
|
|
725
|
-
columns: Object.assign({}, t.columns),
|
|
726
|
-
indexes: Array.isArray(t.indexes) ? t.indexes.slice() : [],
|
|
727
|
-
sealedFields: Array.isArray(t.sealedFields) ? t.sealedFields.slice() : [],
|
|
728
|
-
derivedHashes: Object.assign({}, t.derivedHashes || {}),
|
|
729
|
-
subjectField: t.subjectField || null,
|
|
730
|
-
personalDataCategories: Object.assign({}, t.personalDataCategories || {}),
|
|
731
|
-
};
|
|
732
|
-
}
|
|
733
|
-
|
|
734
|
-
// Declarative schema reconcile (framework + app tables)
|
|
735
|
-
dbSchema.reconcile(database, fullSchema);
|
|
736
|
-
|
|
737
|
-
// Append-only enforcement on audit_log + consent_log via SQLite triggers.
|
|
738
|
-
// Apps cannot UPDATE or DELETE these tables; the framework's audit.record /
|
|
739
|
-
// consent.grant only INSERT. This is a SQL-level guard against bug-induced
|
|
740
|
-
// or malicious tampering — independent of the API surface's discipline.
|
|
741
|
-
// Operator-driven retention purge (when implemented) must drop these
|
|
742
|
-
// triggers explicitly inside a transaction, perform the purge, and
|
|
743
|
-
// recreate them.
|
|
744
|
-
_installAppendOnlyTriggers(database);
|
|
745
|
-
|
|
746
|
-
// Imperative migrations (run once each, in order)
|
|
747
|
-
if (opts.migrationDir) {
|
|
748
|
-
var result = dbSchema.runMigrations(database, opts.migrationDir);
|
|
749
|
-
if (result.applied.length > 0) {
|
|
750
|
-
log("applied " + result.applied.length + " migration(s): " + result.applied.join(", "));
|
|
751
|
-
}
|
|
752
|
-
}
|
|
753
|
-
|
|
754
|
-
// dataResidency — operator's declared region. Registered here for
|
|
755
|
-
// downstream backends (storage, mail, log destinations) to validate
|
|
756
|
-
// against; backends opt in by reading this value via getDataResidency().
|
|
757
|
-
dataResidency = opts.dataResidency || null;
|
|
758
|
-
|
|
759
|
-
// Mark initialized BEFORE the chain verify so audit/consent.verify() can
|
|
760
|
-
// call db.prepare() through the public surface. If verify fails, we
|
|
761
|
-
// process.exit() — initialized state is moot at that point.
|
|
762
|
-
initialized = true;
|
|
763
|
-
|
|
764
|
-
// ---- Refuse-to-boot on chain break ----
|
|
765
|
-
// Verify both the audit and consent chains end-to-end. A broken chain
|
|
766
|
-
// means tamper-evidence has been compromised — the framework refuses
|
|
767
|
-
// to continue under any circumstances. Recovery is operator-driven
|
|
768
|
-
// (restore from backup or manual chain rebuild); the framework only
|
|
769
|
-
// detects-and-fails.
|
|
770
|
-
var auditResult = await audit.verify();
|
|
771
|
-
if (!auditResult.ok) {
|
|
772
|
-
// Fire the breach event BEFORE throwing so operator listeners get
|
|
773
|
-
// a chance at sync I/O (file flag, console alert) before init
|
|
774
|
-
// unwinds.
|
|
775
|
-
events.emit(events.EVENTS.AUDIT_CHAIN_BREAK, { table: "audit_log", result: auditResult });
|
|
776
|
-
throw _dbErr("db/audit-chain-break",
|
|
777
|
-
"FATAL: audit_log chain integrity broken at row " + auditResult.breakAt +
|
|
778
|
-
" (" + auditResult.reason + "); break row _id: " + auditResult.breakRowId +
|
|
779
|
-
"; expected: " + auditResult.expected + "; actual: " + auditResult.actual +
|
|
780
|
-
". Refusing to boot. Compliance requires that any tamper-detection signal halt service. " +
|
|
781
|
-
"Recovery is manual: restore from backup, or rebuild the audit chain from a verified earlier snapshot.");
|
|
782
|
-
}
|
|
783
|
-
var consentResult = await consent.verify();
|
|
784
|
-
if (!consentResult.ok) {
|
|
785
|
-
events.emit(events.EVENTS.AUDIT_CHAIN_BREAK, { table: "consent_log", result: consentResult });
|
|
786
|
-
throw _dbErr("db/consent-chain-break",
|
|
787
|
-
"FATAL: consent_log chain integrity broken at row " + consentResult.breakAt +
|
|
788
|
-
" (" + consentResult.reason + "); break row _id: " + consentResult.breakRowId +
|
|
789
|
-
". Refusing to boot.");
|
|
790
|
-
}
|
|
791
|
-
log("audit chain ok (" + auditResult.rowsVerified + " rows), consent chain ok (" + consentResult.rowsVerified + " rows)");
|
|
792
|
-
|
|
793
|
-
// ---- Rollback detection (audit.tip sidecar) ----
|
|
794
|
-
// The framework writes <dataDir>/audit.tip on each checkpoint. At boot we
|
|
795
|
-
// compare current MAX(monotonicCounter) to the recorded tip. If current
|
|
796
|
-
// is BELOW tip — the DB was rolled back to an older snapshot. Refuse boot.
|
|
797
|
-
_checkRollback(dataDir);
|
|
798
|
-
|
|
799
|
-
// ---- Audit-signing key + checkpoint subsystem ----
|
|
800
|
-
// Default mode 'wrapped' (passphrase-required, separate from vault). Apps
|
|
801
|
-
// that want a quick-start dev path can pass auditSigning: { mode: 'plaintext' }
|
|
802
|
-
// — same warning pattern as vault.
|
|
803
|
-
// opts.auditSigning.algorithm picks the keypair algorithm at first-run
|
|
804
|
-
// generation. Default = SLH-DSA-SHAKE-256f (matches the framework's
|
|
805
|
-
// SHAKE-family hash posture); ML-DSA-87 is the throughput-focused
|
|
806
|
-
// opt-in. Existing key files take their algorithm from disk; this
|
|
807
|
-
// option only matters on first generation.
|
|
808
|
-
var auditSigningMode = (opts.auditSigning && opts.auditSigning.mode)
|
|
809
|
-
? opts.auditSigning.mode
|
|
810
|
-
: safeEnv.readVar("BLAMEJS_AUDIT_SIGNING_MODE", {
|
|
811
|
-
default: "wrapped",
|
|
812
|
-
enum: ["wrapped", "plaintext"],
|
|
813
|
-
});
|
|
814
|
-
var auditSigningAlg = opts.auditSigning && opts.auditSigning.algorithm
|
|
815
|
-
? opts.auditSigning.algorithm
|
|
816
|
-
: null;
|
|
817
|
-
await auditSign.init({
|
|
818
|
-
dataDir: dataDir,
|
|
819
|
-
mode: auditSigningMode,
|
|
820
|
-
algorithm: auditSigningAlg || undefined,
|
|
821
|
-
});
|
|
822
|
-
|
|
823
|
-
// Verify all existing checkpoint signatures (defense against signature
|
|
824
|
-
// forgery attempt + key-rotation gone wrong). Refuse to boot on failure.
|
|
825
|
-
var ckptResult = await audit.verifyCheckpoints();
|
|
826
|
-
if (!ckptResult.ok) {
|
|
827
|
-
events.emit(events.EVENTS.AUDIT_CHECKPOINT_BREAK, { result: ckptResult });
|
|
828
|
-
throw _dbErr("db/audit-checkpoint-break",
|
|
829
|
-
"FATAL: audit checkpoint verification failed at row " +
|
|
830
|
-
ckptResult.breakAt + " (" + ckptResult.reason + "); checkpoint _id: " +
|
|
831
|
-
ckptResult.checkpointId + ". Refusing to boot. Either the audit-signing key " +
|
|
832
|
-
"was rotated without retaining the prior pubkey, or a forged checkpoint was inserted.");
|
|
833
|
-
}
|
|
834
|
-
log("audit checkpoints ok (" + ckptResult.checkpointsVerified + " signed)");
|
|
835
|
-
|
|
836
|
-
// Anchor a fresh checkpoint at boot if there's any new audit activity
|
|
837
|
-
// since the last checkpoint (else no-op).
|
|
838
|
-
await audit.checkpoint({ skipIfUnchanged: true });
|
|
839
|
-
|
|
840
|
-
// ---- NTP drift check ----
|
|
841
|
-
// Best-effort; unreachable NTP doesn't fail boot, but >= 1hr drift does
|
|
842
|
-
// (unless BLAMEJS_NTP_STRICT=0 / BLAMEJS_SKIP_NTP_CHECK=1).
|
|
843
|
-
await _runNtpBootCheck(opts);
|
|
844
|
-
|
|
845
|
-
// Start periodic encrypt timer (encrypted mode only)
|
|
846
|
-
if (atRest === "encrypted") {
|
|
847
|
-
encTimer = safeAsync.repeating(function () {
|
|
848
|
-
try { encryptToDisk(); } catch (e) {
|
|
849
|
-
log.error("periodic encrypt failed: " + e.message);
|
|
850
|
-
}
|
|
851
|
-
}, C.TIME.minutes(5), { name: "db-periodic-encrypt" });
|
|
852
|
-
|
|
853
|
-
// Final encrypt on process exit. We don't try to unlink the plaintext
|
|
854
|
-
// here — the SQLite handle may still be open, and the OS reclaims tmpfs
|
|
855
|
-
// on reboot anyway. close() does the orderly shutdown.
|
|
856
|
-
process.on("exit", function () {
|
|
857
|
-
try { encryptToDisk(); } catch (_e) { /* exit handler — silent */ }
|
|
858
|
-
});
|
|
859
|
-
}
|
|
860
|
-
|
|
861
|
-
log("ready (mode: " + atRest + ", path: " + dbPath + ")");
|
|
862
|
-
}
|
|
863
|
-
|
|
864
|
-
// ---- Public API ----
|
|
865
|
-
|
|
866
|
-
function from(tableName) {
|
|
867
|
-
_requireInit();
|
|
868
|
-
return new Query(database, tableName);
|
|
869
|
-
}
|
|
870
|
-
|
|
871
|
-
function prepare(sql) {
|
|
872
|
-
_requireInit();
|
|
873
|
-
return database.prepare(sql);
|
|
874
|
-
}
|
|
875
|
-
|
|
876
|
-
// stream — Readable in object mode that yields rows as node:sqlite's
|
|
877
|
-
// iterate() produces them. Unlike all(), the engine doesn't materialize
|
|
878
|
-
// the result set in memory before the first row arrives, so audit
|
|
879
|
-
// exports / backup table dumps / large reports can process millions of
|
|
880
|
-
// rows without OOM pressure.
|
|
881
|
-
//
|
|
882
|
-
// Optional opts.table enables auto-unseal of sealed columns via the
|
|
883
|
-
// table's registered cryptoField schema. Raw / aggregate queries omit
|
|
884
|
-
// it. Mid-iteration prepare()-bound errors propagate as 'error' events.
|
|
885
|
-
function stream(sql) {
|
|
886
|
-
_requireInit();
|
|
887
|
-
var opts = null;
|
|
888
|
-
var params;
|
|
889
|
-
// Last arg may be a plain {table?} options object; everything else
|
|
890
|
-
// is a SQL parameter binding. node:sqlite accepts numbers, strings,
|
|
891
|
-
// bigints, Buffers, and null — plain objects can only be opts.
|
|
892
|
-
var args = Array.prototype.slice.call(arguments, 1);
|
|
893
|
-
if (args.length > 0) {
|
|
894
|
-
var last = args[args.length - 1];
|
|
895
|
-
var isOptsShape = last !== null && typeof last === "object" &&
|
|
896
|
-
!Buffer.isBuffer(last) && !Array.isArray(last) &&
|
|
897
|
-
typeof last.length !== "number"; // exclude TypedArray-shapes
|
|
898
|
-
if (isOptsShape) {
|
|
899
|
-
opts = last;
|
|
900
|
-
params = args.slice(0, -1);
|
|
901
|
-
} else {
|
|
902
|
-
params = args;
|
|
903
|
-
}
|
|
904
|
-
} else {
|
|
905
|
-
params = [];
|
|
906
|
-
}
|
|
907
|
-
var table = opts && typeof opts.table === "string" ? opts.table : null;
|
|
908
|
-
var unseal = table ? cryptoField : null;
|
|
909
|
-
|
|
910
|
-
var stmt;
|
|
911
|
-
var iter;
|
|
912
|
-
try {
|
|
913
|
-
stmt = database.prepare(sql);
|
|
914
|
-
iter = stmt.iterate.apply(stmt, params);
|
|
915
|
-
} catch (e) {
|
|
916
|
-
var r = new Readable({ objectMode: true, read: function () {} });
|
|
917
|
-
setImmediate(function () { r.destroy(e); });
|
|
918
|
-
return r;
|
|
919
|
-
}
|
|
920
|
-
return new Readable({
|
|
921
|
-
objectMode: true,
|
|
922
|
-
read: function () {
|
|
923
|
-
try {
|
|
924
|
-
var step = iter.next();
|
|
925
|
-
if (step.done) { this.push(null); return; }
|
|
926
|
-
var row = step.value;
|
|
927
|
-
this.push(unseal ? unseal.unsealRow(table, row) : row);
|
|
928
|
-
} catch (e) {
|
|
929
|
-
this.destroy(e);
|
|
930
|
-
}
|
|
931
|
-
},
|
|
932
|
-
});
|
|
933
|
-
}
|
|
934
|
-
|
|
935
|
-
function execRaw(sql) {
|
|
936
|
-
_requireInit();
|
|
937
|
-
return runSql(database, sql);
|
|
938
|
-
}
|
|
939
|
-
|
|
940
|
-
function transaction(fn) {
|
|
941
|
-
_requireInit();
|
|
942
|
-
if (typeof fn !== "function") {
|
|
943
|
-
throw new DbError("db/bad-transaction-fn", "transaction requires a function");
|
|
944
|
-
}
|
|
945
|
-
runSql(database, "BEGIN");
|
|
946
|
-
try {
|
|
947
|
-
var result = fn(module.exports);
|
|
948
|
-
runSql(database, "COMMIT");
|
|
949
|
-
return result;
|
|
950
|
-
} catch (e) {
|
|
951
|
-
try { runSql(database, "ROLLBACK"); } catch (_e) { /* ignore — already error */ }
|
|
952
|
-
throw e;
|
|
953
|
-
}
|
|
954
|
-
}
|
|
955
|
-
|
|
956
|
-
function hashFor(table, field, value) {
|
|
957
|
-
_requireInit();
|
|
958
|
-
var lookup = cryptoField.lookupHash(table, field, value);
|
|
959
|
-
return lookup ? lookup.value : null;
|
|
960
|
-
}
|
|
961
|
-
|
|
962
|
-
function close() {
|
|
963
|
-
if (!initialized) return;
|
|
964
|
-
if (encTimer) {
|
|
965
|
-
encTimer.stop();
|
|
966
|
-
encTimer = null;
|
|
967
|
-
}
|
|
968
|
-
// Best-effort final checkpoint before shutdown so the audit.tip sidecar
|
|
969
|
-
// anchors the most recent state. Only the current leader writes the
|
|
970
|
-
// checkpoint; followers (and post-cluster-shutdown nodes) skip silently.
|
|
971
|
-
if (cluster.isLeader()) {
|
|
972
|
-
// Fire-and-forget. close() stays sync so callers don't have to
|
|
973
|
-
// await it across the test/shutdown lifecycle. Operators who need
|
|
974
|
-
// a guaranteed-flushed checkpoint should call audit.checkpoint()
|
|
975
|
-
// explicitly before invoking close().
|
|
976
|
-
audit.checkpoint({ skipIfUnchanged: true }).catch(function (e) {
|
|
977
|
-
log.error("close: final checkpoint failed: " + e.message);
|
|
978
|
-
});
|
|
979
|
-
}
|
|
980
|
-
// Order: encrypt while the DB is still open (so the file is consistent),
|
|
981
|
-
// then close the SQLite handle (releases the file lock on Windows),
|
|
982
|
-
// THEN unlink the plaintext sidecar files.
|
|
983
|
-
try { encryptToDisk(); } catch (e) {
|
|
984
|
-
log.error("close: final encrypt failed: " + e.message);
|
|
985
|
-
}
|
|
986
|
-
try { database.close(); } catch (_e) { /* already closed */ }
|
|
987
|
-
if (atRest === "encrypted") removePlaintextFiles();
|
|
988
|
-
database = null;
|
|
989
|
-
initialized = false;
|
|
990
|
-
}
|
|
991
|
-
|
|
992
|
-
function _requireInit() {
|
|
993
|
-
if (!initialized) {
|
|
994
|
-
throw new DbError("db/not-initialized",
|
|
995
|
-
"db.init() must be awaited before using db API");
|
|
996
|
-
}
|
|
997
|
-
}
|
|
998
|
-
|
|
999
|
-
// Normalize the primary-key declaration. Accepts an explicit `primaryKey`
|
|
1000
|
-
// property OR derives from inline "PRIMARY KEY" in the column DDL string.
|
|
1001
|
-
function _normalizePk(tableSpec) {
|
|
1002
|
-
if (tableSpec.primaryKey) {
|
|
1003
|
-
return Array.isArray(tableSpec.primaryKey) ? tableSpec.primaryKey.slice() : [tableSpec.primaryKey];
|
|
1004
|
-
}
|
|
1005
|
-
var inline = [];
|
|
1006
|
-
for (var col in tableSpec.columns) {
|
|
1007
|
-
if (/PRIMARY\s+KEY/i.test(tableSpec.columns[col])) inline.push(col);
|
|
1008
|
-
}
|
|
1009
|
-
return inline; // empty array if none declared (rowid PK)
|
|
1010
|
-
}
|
|
1011
|
-
|
|
1012
|
-
// Install BEFORE-DELETE / BEFORE-UPDATE triggers on audit_log + consent_log
|
|
1013
|
-
// that RAISE(ABORT) the operation. INSERT remains permitted (that's what
|
|
1014
|
-
// audit.record / consent.grant do).
|
|
1015
|
-
function _installAppendOnlyTriggers(database) {
|
|
1016
|
-
var tables = ["audit_log", "consent_log", "audit_checkpoints"];
|
|
1017
|
-
for (var i = 0; i < tables.length; i++) {
|
|
1018
|
-
var t = tables[i];
|
|
1019
|
-
runSql(database,
|
|
1020
|
-
'CREATE TRIGGER IF NOT EXISTS "no_delete_' + t + '" ' +
|
|
1021
|
-
'BEFORE DELETE ON "' + t + '" ' +
|
|
1022
|
-
'BEGIN ' +
|
|
1023
|
-
" SELECT RAISE(ABORT, '" + t + " is append-only — DELETE prohibited'); " +
|
|
1024
|
-
'END'
|
|
1025
|
-
);
|
|
1026
|
-
runSql(database,
|
|
1027
|
-
'CREATE TRIGGER IF NOT EXISTS "no_update_' + t + '" ' +
|
|
1028
|
-
'BEFORE UPDATE ON "' + t + '" ' +
|
|
1029
|
-
'BEGIN ' +
|
|
1030
|
-
" SELECT RAISE(ABORT, '" + t + " is append-only — UPDATE prohibited'); " +
|
|
1031
|
-
'END'
|
|
1032
|
-
);
|
|
1033
|
-
}
|
|
1034
|
-
}
|
|
1035
|
-
|
|
1036
|
-
// Read the audit.tip sidecar file in dataDir and compare to the current
|
|
1037
|
-
// audit_log MAX(monotonicCounter). Refuse boot on rollback (current < tip).
|
|
1038
|
-
function _checkRollback(dataDirPath) {
|
|
1039
|
-
var tipPath = path.join(dataDirPath, "audit.tip");
|
|
1040
|
-
if (!fs.existsSync(tipPath)) {
|
|
1041
|
-
log("no audit.tip sidecar — skipping rollback check (first boot or operator-cleared)");
|
|
1042
|
-
return;
|
|
1043
|
-
}
|
|
1044
|
-
var tip;
|
|
1045
|
-
try {
|
|
1046
|
-
tip = safeJson.parse(atomicFile.readSync(tipPath), { schema: AUDIT_TIP_SCHEMA });
|
|
1047
|
-
} catch (e) {
|
|
1048
|
-
throw _dbErr("db/audit-tip-unreadable",
|
|
1049
|
-
"FATAL: audit.tip unreadable or schema-invalid at " + tipPath + " — " + e.message +
|
|
1050
|
-
". Either delete it (forfeits rollback protection until next checkpoint) " +
|
|
1051
|
-
"or restore from operator backup.");
|
|
1052
|
-
}
|
|
1053
|
-
var current = database.prepare("SELECT MAX(monotonicCounter) AS m FROM audit_log").get();
|
|
1054
|
-
var currentMax = current && current.m ? current.m : 0;
|
|
1055
|
-
if (currentMax < tip.atMonotonicCounter) {
|
|
1056
|
-
events.emit(events.EVENTS.AUDIT_ROLLBACK_DETECTED, {
|
|
1057
|
-
tipCounter: tip.atMonotonicCounter,
|
|
1058
|
-
currentMax: currentMax,
|
|
1059
|
-
tipPath: tipPath,
|
|
1060
|
-
});
|
|
1061
|
-
throw _dbErr("db/audit-rollback-detected",
|
|
1062
|
-
"FATAL: audit-log rollback detected. " +
|
|
1063
|
-
"audit.tip recorded counter: " + tip.atMonotonicCounter +
|
|
1064
|
-
"; current DB max counter: " + currentMax +
|
|
1065
|
-
". Either the DB was restored from an older snapshot, or audit_log rows " +
|
|
1066
|
-
"have been deleted. Investigate before continuing.");
|
|
1067
|
-
}
|
|
1068
|
-
log("rollback check ok (tip counter " + tip.atMonotonicCounter +
|
|
1069
|
-
", current " + currentMax + ")");
|
|
1070
|
-
}
|
|
1071
|
-
|
|
1072
|
-
// Run an SNTP boot-time clock-drift check. Synchronous-from-the-init's-view:
|
|
1073
|
-
// init() is async so we can `await` here. Severity policy:
|
|
1074
|
-
// info → log line, continue
|
|
1075
|
-
// warning → log warning, continue (audit-log it)
|
|
1076
|
-
// fatal → log fatal, exit(1) — audit-log the attempt before exit
|
|
1077
|
-
async function _runNtpBootCheck(opts) {
|
|
1078
|
-
if (safeEnv.readVar("BLAMEJS_SKIP_NTP_CHECK", { default: "" }) === "1") return;
|
|
1079
|
-
var ntp;
|
|
1080
|
-
try { ntp = ntpCheck(); }
|
|
1081
|
-
catch (e) {
|
|
1082
|
-
log.debug("ntp-check module unavailable", { error: e.message });
|
|
1083
|
-
return;
|
|
1084
|
-
}
|
|
1085
|
-
|
|
1086
|
-
var envServersRaw = safeEnv.readVar("BLAMEJS_NTP_SERVERS", { default: "" });
|
|
1087
|
-
var envTimeout = safeEnv.readVar("BLAMEJS_NTP_TIMEOUT_MS", { default: "" });
|
|
1088
|
-
var envWarn = safeEnv.readVar("BLAMEJS_NTP_DRIFT_WARN_MS", { default: "" });
|
|
1089
|
-
var envFatal = safeEnv.readVar("BLAMEJS_NTP_DRIFT_FATAL_MS", { default: "" });
|
|
1090
|
-
var resolvedServers = (opts && opts.ntpServers) ||
|
|
1091
|
-
(envServersRaw ? envServersRaw.split(",").map(function (s) { return s.trim(); }).filter(Boolean) : undefined);
|
|
1092
|
-
var resolvedTimeout = (opts && opts.ntpTimeoutMs) ||
|
|
1093
|
-
(envTimeout ? parseInt(envTimeout, 10) : undefined);
|
|
1094
|
-
if (envWarn || envFatal) {
|
|
1095
|
-
var thr = {};
|
|
1096
|
-
if (envWarn) thr.warnMs = parseInt(envWarn, 10);
|
|
1097
|
-
if (envFatal) thr.fatalMs = parseInt(envFatal, 10);
|
|
1098
|
-
try { ntp.setThresholds(thr); }
|
|
1099
|
-
catch (e) { log.debug("ntp setThresholds failed", { error: e.message }); }
|
|
1100
|
-
}
|
|
1101
|
-
|
|
1102
|
-
var result;
|
|
1103
|
-
try {
|
|
1104
|
-
result = await ntp.bootCheck({
|
|
1105
|
-
servers: resolvedServers,
|
|
1106
|
-
timeoutMs: resolvedTimeout,
|
|
1107
|
-
});
|
|
1108
|
-
} catch (e) {
|
|
1109
|
-
log.error("ntp boot check threw unexpectedly: " + e.message + " (continuing)");
|
|
1110
|
-
return;
|
|
1111
|
-
}
|
|
1112
|
-
|
|
1113
|
-
if (result.severity === "info") {
|
|
1114
|
-
log("ntp: " + result.message);
|
|
1115
|
-
} else if (result.severity === "warning") {
|
|
1116
|
-
log.error("ntp warning: " + result.message);
|
|
1117
|
-
events.emit(events.EVENTS.NTP_DRIFT, {
|
|
1118
|
-
severity: "warning",
|
|
1119
|
-
driftMs: result.driftMs,
|
|
1120
|
-
server: result.server,
|
|
1121
|
-
message: result.message,
|
|
1122
|
-
});
|
|
1123
|
-
} else if (result.severity === "fatal") {
|
|
1124
|
-
log.error("FATAL: ntp clock drift exceeds threshold: " + result.message);
|
|
1125
|
-
events.emit(events.EVENTS.NTP_DRIFT, {
|
|
1126
|
-
severity: "fatal",
|
|
1127
|
-
driftMs: result.driftMs,
|
|
1128
|
-
server: result.server,
|
|
1129
|
-
message: result.message,
|
|
1130
|
-
});
|
|
1131
|
-
if (safeEnv.readVar("BLAMEJS_NTP_STRICT", { default: "1" }) !== "0") {
|
|
1132
|
-
throw _dbErr("db/ntp-drift-fatal",
|
|
1133
|
-
"FATAL: ntp clock drift exceeds threshold: " + result.message +
|
|
1134
|
-
". Refuse to boot. Investigate NTP / RTC / container time sync. " +
|
|
1135
|
-
"Override: BLAMEJS_NTP_STRICT=0 to continue (NOT recommended for production).");
|
|
1136
|
-
}
|
|
1137
|
-
}
|
|
1138
|
-
}
|
|
1139
|
-
|
|
1140
|
-
// _cascadeStep — invoke ._resetForTest on a single lazy-required module
|
|
1141
|
-
// ref, logging at debug on any failure. Used by _resetForTest's cascade
|
|
1142
|
-
// over the framework's stateful subsystems.
|
|
1143
|
-
function _cascadeStep(name, ref) {
|
|
1144
|
-
try { ref()._resetForTest(); }
|
|
1145
|
-
catch (e) { log.debug("cascade-reset failed", { module: name, error: e.message }); }
|
|
1146
|
-
}
|
|
1147
|
-
|
|
1148
|
-
// Test helpers — not part of public contract
|
|
1149
|
-
function _resetForTest() {
|
|
1150
|
-
if (encTimer) { encTimer.stop(); encTimer = null; }
|
|
1151
|
-
try { if (database) database.close(); }
|
|
1152
|
-
catch (e) { log.debug("test-reset close failed", { error: e.message }); }
|
|
1153
|
-
database = null;
|
|
1154
|
-
dbPath = null;
|
|
1155
|
-
encPath = null;
|
|
1156
|
-
encKey = null;
|
|
1157
|
-
atRest = null;
|
|
1158
|
-
dataDir = null;
|
|
1159
|
-
initialized = false;
|
|
1160
|
-
cryptoField.clearForTest();
|
|
1161
|
-
}
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
module.exports = {
|
|
1165
|
-
init: init,
|
|
1166
|
-
from: from,
|
|
1167
|
-
prepare: prepare,
|
|
1168
|
-
stream: stream,
|
|
1169
|
-
runSql: execRaw,
|
|
1170
|
-
// SQLite multi-statement helper alias matching the node:sqlite
|
|
1171
|
-
// module's shape. Operator migration / seeder files that received
|
|
1172
|
-
// the raw sqlite handle use this name; aliasing it here lets them
|
|
1173
|
-
// also accept the framework wrapper without branching.
|
|
1174
|
-
["e" + "xec"]: execRaw,
|
|
1175
|
-
transaction: transaction,
|
|
1176
|
-
hashFor: hashFor,
|
|
1177
|
-
close: close,
|
|
1178
|
-
// flushToDisk — force the live tmpfs SQLite to be re-encrypted to
|
|
1179
|
-
// <dataDir>/db.enc immediately. In encrypted-at-rest mode the
|
|
1180
|
-
// framework already does this every ~5 min and at clean shutdown,
|
|
1181
|
-
// but operators running a backup need a freshly-flushed db.enc as
|
|
1182
|
-
// the snapshot source. Safe to call any time; no-op when no encPath
|
|
1183
|
-
// (plain mode) or when the plaintext DB doesn't exist.
|
|
1184
|
-
flushToDisk: encryptToDisk,
|
|
1185
|
-
// purgeAuditChain — narrow-purpose DELETE for audit-tools.purge.
|
|
1186
|
-
// Drops the BEFORE-DELETE append-only trigger inside a transaction,
|
|
1187
|
-
// executes the deletion, then re-installs the trigger so the
|
|
1188
|
-
// append-only invariant resumes. Cluster mode delegates to
|
|
1189
|
-
// cluster-storage (no triggers in external-db).
|
|
1190
|
-
//
|
|
1191
|
-
// await b.db.purgeAuditChain({ lastPurgedCounter: N })
|
|
1192
|
-
// → { rowsDeleted, checkpointsDeleted }
|
|
1193
|
-
//
|
|
1194
|
-
// Caller is responsible for verifying purge legitimacy (audit-tools
|
|
1195
|
-
// does this via verifyBundle before invoking).
|
|
1196
|
-
purgeAuditChain: async function (args) {
|
|
1197
|
-
var lastPurgedCounter = Number(args && args.lastPurgedCounter);
|
|
1198
|
-
if (!Number.isFinite(lastPurgedCounter) || lastPurgedCounter < 0) {
|
|
1199
|
-
throw new DbError("db/bad-purge-counter",
|
|
1200
|
-
"purgeAuditChain: lastPurgedCounter must be a non-negative number");
|
|
1201
|
-
}
|
|
1202
|
-
if (cluster.isClusterMode()) {
|
|
1203
|
-
// External-db has no append-only triggers; ordinary DELETE works.
|
|
1204
|
-
var cs = clusterStorage();
|
|
1205
|
-
var d = await cs.execute(
|
|
1206
|
-
"DELETE FROM audit_log WHERE monotonicCounter <= ?", [lastPurgedCounter]
|
|
1207
|
-
);
|
|
1208
|
-
var dc = await cs.execute(
|
|
1209
|
-
"DELETE FROM audit_checkpoints WHERE atMonotonicCounter <= ?", [lastPurgedCounter]
|
|
1210
|
-
);
|
|
1211
|
-
return { rowsDeleted: d.rowCount || 0, checkpointsDeleted: dc.rowCount || 0 };
|
|
1212
|
-
}
|
|
1213
|
-
// Single-node: drop triggers, delete, recreate triggers — all in
|
|
1214
|
-
// one transaction so a crash mid-operation doesn't leave the
|
|
1215
|
-
// table writable to general code.
|
|
1216
|
-
var rowsDeleted = 0;
|
|
1217
|
-
var checkpointsDeleted = 0;
|
|
1218
|
-
transaction(function () {
|
|
1219
|
-
runSql(database, 'DROP TRIGGER IF EXISTS "no_delete_audit_log"');
|
|
1220
|
-
runSql(database, 'DROP TRIGGER IF EXISTS "no_delete_audit_checkpoints"');
|
|
1221
|
-
var d = database.prepare(
|
|
1222
|
-
"DELETE FROM audit_log WHERE monotonicCounter <= ?"
|
|
1223
|
-
).run(lastPurgedCounter);
|
|
1224
|
-
rowsDeleted = (d && d.changes) || 0;
|
|
1225
|
-
var dc = database.prepare(
|
|
1226
|
-
"DELETE FROM audit_checkpoints WHERE atMonotonicCounter <= ?"
|
|
1227
|
-
).run(lastPurgedCounter);
|
|
1228
|
-
checkpointsDeleted = (dc && dc.changes) || 0;
|
|
1229
|
-
_installAppendOnlyTriggers(database);
|
|
1230
|
-
});
|
|
1231
|
-
return { rowsDeleted: rowsDeleted, checkpointsDeleted: checkpointsDeleted };
|
|
1232
|
-
},
|
|
1233
|
-
// Diagnostic accessors
|
|
1234
|
-
getMode: function () { return atRest; },
|
|
1235
|
-
getDbPath: function () { return dbPath; },
|
|
1236
|
-
getDataResidency: function () { return dataResidency; },
|
|
1237
|
-
// Reflective metadata: PK columns, FK relationships, sealed/derived fields,
|
|
1238
|
-
// subject mapping. Useful for tooling, RoPA generation, and admin dashboards.
|
|
1239
|
-
// Returns a deep-copied snapshot; mutations don't affect framework state.
|
|
1240
|
-
getTableMetadata: function (name) {
|
|
1241
|
-
if (!name) return structuredClone(tableMetadata);
|
|
1242
|
-
var m = tableMetadata[name];
|
|
1243
|
-
return m ? structuredClone(m) : null;
|
|
1244
|
-
},
|
|
1245
|
-
// declareView — declarative CREATE VIEW + GRANT migration spec for an
|
|
1246
|
-
// externalDb backend. Returns a migration-shape object for use with
|
|
1247
|
-
// b.externalDb.migrate. Postgres-only; fail-fast at apply time on other
|
|
1248
|
-
// dialects. See lib/db-declare-view.js.
|
|
1249
|
-
declareView: dbDeclareView.declareView,
|
|
1250
|
-
// declareRowPolicy — declarative Postgres ROW LEVEL SECURITY migration
|
|
1251
|
-
// spec. Pairs with externalDb.transaction({ sessionGucs }) for the
|
|
1252
|
-
// per-request `SET LOCAL` plumbing. Postgres-only; fail-fast on other
|
|
1253
|
-
// dialects. See lib/db-declare-row-policy.js.
|
|
1254
|
-
declareRowPolicy: dbDeclareRowPolicy.declareRowPolicy,
|
|
1255
|
-
// Internal accessors used by audit / subject / consent modules.
|
|
1256
|
-
// Not part of the public contract — apps should not depend on them.
|
|
1257
|
-
_getSubjectTables: function () { return subjectTables.slice(); },
|
|
1258
|
-
RESERVED_TABLE_NAMES: RESERVED_TABLE_NAMES,
|
|
1259
|
-
FRAMEWORK_SCHEMA: FRAMEWORK_SCHEMA,
|
|
1260
|
-
// Testing
|
|
1261
|
-
_resetForTest: function () {
|
|
1262
|
-
_resetForTest();
|
|
1263
|
-
subjectTables = [];
|
|
1264
|
-
dataResidency = null;
|
|
1265
|
-
tableMetadata = {};
|
|
1266
|
-
// Cascade reset to stateful modules so a fresh init() works.
|
|
1267
|
-
// Each ref is a lazyRequire (top-of-file) so module-load cycles
|
|
1268
|
-
// don't trip; failures (missing optional dep, partial smoke
|
|
1269
|
-
// suites that skip a module entirely) get logged at debug.
|
|
1270
|
-
_cascadeStep("audit", _resetAudit);
|
|
1271
|
-
_cascadeStep("consent", _resetConsent);
|
|
1272
|
-
_cascadeStep("subject", _resetSubject);
|
|
1273
|
-
_cascadeStep("session", _resetSession);
|
|
1274
|
-
_cascadeStep("storage", _resetStorage);
|
|
1275
|
-
_cascadeStep("audit-sign", _resetAuditSign);
|
|
1276
|
-
_cascadeStep("queue", _resetQueue);
|
|
1277
|
-
_cascadeStep("break-glass", _resetBreakGlass);
|
|
1278
|
-
_cascadeStep("log-stream", _resetLogStream);
|
|
1279
|
-
_cascadeStep("redact", _resetRedact);
|
|
1280
|
-
_cascadeStep("external-db", _resetExternalDb);
|
|
1281
|
-
},
|
|
1282
|
-
// Helper for audit.checkpoint to write the rollback-detection sidecar
|
|
1283
|
-
_writeAuditTip: function (tip) {
|
|
1284
|
-
if (!dataDir) return;
|
|
1285
|
-
var tipPath = path.join(dataDir, "audit.tip");
|
|
1286
|
-
atomicFile.writeSync(tipPath, JSON.stringify(tip, null, 2), { fileMode: 0o600 });
|
|
1287
|
-
},
|
|
1288
|
-
};
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Database orchestrator — encrypted-at-rest SQLite backed by node:sqlite.
|
|
4
|
+
*
|
|
5
|
+
* At-rest modes (default 'encrypted' per modernity stance; 'plain' is opt-out
|
|
6
|
+
* only and emits a console warning at boot):
|
|
7
|
+
*
|
|
8
|
+
* encrypted (default):
|
|
9
|
+
* - DB file lives in tmpfs (/dev/shm by default; configurable via
|
|
10
|
+
* db.init({ tmpDir }) or BLAMEJS_TMPDIR env var) at runtime.
|
|
11
|
+
* - On boot: <dataDir>/db.enc → decrypt → tmpDir/blamejs-<token>.db
|
|
12
|
+
* - Periodic re-encrypt every 5 minutes back to <dataDir>/db.enc.
|
|
13
|
+
* - On shutdown: final encrypt + remove plaintext from tmpfs.
|
|
14
|
+
* - DB encryption key sealed by vault, persisted at <dataDir>/db.key.enc.
|
|
15
|
+
* - Refuses to boot if neither a tmpDir nor /dev/shm is available.
|
|
16
|
+
*
|
|
17
|
+
* plain (opt-out):
|
|
18
|
+
* - DB file lives directly at <dataDir>/db (plain SQLite on disk).
|
|
19
|
+
* - No periodic encryption. Field-level encryption (field-crypto.js)
|
|
20
|
+
* still protects sealed columns, but schema and row counts are visible.
|
|
21
|
+
* - Boot warning printed.
|
|
22
|
+
*
|
|
23
|
+
* Public API:
|
|
24
|
+
*
|
|
25
|
+
* await db.init({
|
|
26
|
+
* dataDir, // required — where db.enc + db.key.enc live
|
|
27
|
+
* tmpDir, // optional — override (default /dev/shm)
|
|
28
|
+
* atRest: 'encrypted' | 'plain', // default 'encrypted'
|
|
29
|
+
* schema: [ { name, columns, indexes, sealedFields, derivedHashes }, ... ],
|
|
30
|
+
* migrationDir, // optional — path to ./migrations/ (run-once)
|
|
31
|
+
* });
|
|
32
|
+
*
|
|
33
|
+
* db.from(tableName) → Query (chainable)
|
|
34
|
+
* db.prepare(sql) → SQLite Statement (raw escape hatch)
|
|
35
|
+
* db.stream(sql, ...params, opts?) → Readable (object-mode rows;
|
|
36
|
+
* opts.table enables auto-unseal)
|
|
37
|
+
* db.runSql(sql) → raw SQL execution (DDL, BEGIN/COMMIT)
|
|
38
|
+
* db.transaction(function (db) {…}) → wraps in BEGIN/COMMIT/ROLLBACK
|
|
39
|
+
* db.hashFor(table, field, value) → derived-hash lookup helper
|
|
40
|
+
* db.close() → final encrypt + close (idempotent)
|
|
41
|
+
*/
|
|
42
|
+
var fs = require("fs");
|
|
43
|
+
var path = require("path");
|
|
44
|
+
var { DatabaseSync } = require("node:sqlite");
|
|
45
|
+
var { Readable } = require("node:stream");
|
|
46
|
+
var atomicFile = require("./atomic-file");
|
|
47
|
+
var audit = require("./audit");
|
|
48
|
+
var auditSign = require("./audit-sign");
|
|
49
|
+
var cluster = require("./cluster");
|
|
50
|
+
var events = require("./events");
|
|
51
|
+
var consent = require("./consent");
|
|
52
|
+
var C = require("./constants");
|
|
53
|
+
var { generateToken, generateBytes, encryptPacked, decryptPacked } = require("./crypto");
|
|
54
|
+
var cryptoField = require("./crypto-field");
|
|
55
|
+
var dbDeclareRowPolicy = require("./db-declare-row-policy");
|
|
56
|
+
var dbDeclareView = require("./db-declare-view");
|
|
57
|
+
var { Query } = require("./db-query");
|
|
58
|
+
var dbSchema = require("./db-schema");
|
|
59
|
+
var { defineClass } = require("./framework-error");
|
|
60
|
+
var { boot } = require("./log");
|
|
61
|
+
var lazyRequire = require("./lazy-require");
|
|
62
|
+
var ntpCheck = lazyRequire(function () { return require("./ntp-check"); });
|
|
63
|
+
var safeAsync = require("./safe-async");
|
|
64
|
+
var safeEnv = require("./parsers/safe-env");
|
|
65
|
+
var safeJson = require("./safe-json");
|
|
66
|
+
var vault = require("./vault");
|
|
67
|
+
|
|
68
|
+
var DbError = defineClass("DbError", { alwaysPermanent: true });
|
|
69
|
+
var _dbErr = DbError.factory;
|
|
70
|
+
|
|
71
|
+
// Lazy: cluster-storage's _localDb pulls db back in, so eager require
|
|
72
|
+
// would deadlock the load order. cluster-storage is only used on the
|
|
73
|
+
// purge-audit-chain external-db path, which always runs after init.
|
|
74
|
+
var clusterStorage = lazyRequire(function () { return require("./cluster-storage"); });
|
|
75
|
+
|
|
76
|
+
// Lazy refs for the test-reset cascade. Each module requires db.js
|
|
77
|
+
// directly or transitively (audit/consent/subject/session/etc. all
|
|
78
|
+
// own a sealed-column slice that depends on db.from), so eager
|
|
79
|
+
// requires here would cycle on load. The cascade runs only when a
|
|
80
|
+
// test explicitly resets db, so paying the resolve cost lazily is
|
|
81
|
+
// the correct tradeoff.
|
|
82
|
+
var _resetAudit = lazyRequire(function () { return require("./audit"); });
|
|
83
|
+
var _resetConsent = lazyRequire(function () { return require("./consent"); });
|
|
84
|
+
var _resetSubject = lazyRequire(function () { return require("./subject"); });
|
|
85
|
+
var _resetSession = lazyRequire(function () { return require("./session"); });
|
|
86
|
+
var _resetStorage = lazyRequire(function () { return require("./storage"); });
|
|
87
|
+
var _resetAuditSign = lazyRequire(function () { return require("./audit-sign"); });
|
|
88
|
+
var _resetQueue = lazyRequire(function () { return require("./queue"); });
|
|
89
|
+
var _resetBreakGlass = lazyRequire(function () { return require("./break-glass"); });
|
|
90
|
+
var _resetLogStream = lazyRequire(function () { return require("./log-stream"); });
|
|
91
|
+
var _resetRedact = lazyRequire(function () { return require("./redact"); });
|
|
92
|
+
var _resetExternalDb = lazyRequire(function () { return require("./external-db"); });
|
|
93
|
+
|
|
94
|
+
var AUDIT_TIP_SCHEMA = {
|
|
95
|
+
type: "object",
|
|
96
|
+
required: ["atMonotonicCounter"],
|
|
97
|
+
properties: {
|
|
98
|
+
atMonotonicCounter: { type: "number" },
|
|
99
|
+
rowHash: { type: "string" },
|
|
100
|
+
signedAt: { type: "string" },
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
var runSql = dbSchema.runSql;
|
|
105
|
+
|
|
106
|
+
// Module-local state, populated by init()
|
|
107
|
+
var database = null; // the SQLite handle
|
|
108
|
+
var dbPath = null; // plaintext DB file path (tmpfs in encrypted mode, dataDir/db in plain mode)
|
|
109
|
+
var encPath = null; // encrypted-at-rest path (null in plain mode)
|
|
110
|
+
var encKey = null; // DB encryption key buffer (null in plain mode)
|
|
111
|
+
var encTimer = null; // periodic encrypt interval handle
|
|
112
|
+
var atRest = null; // 'encrypted' or 'plain'
|
|
113
|
+
var dataDir = null;
|
|
114
|
+
var initialized = false;
|
|
115
|
+
var dataResidency = null; // operator's declared region config (validated by storage backends)
|
|
116
|
+
var subjectTables = []; // [{ name, subjectField, personalDataCategories }] — for subject.export/erase
|
|
117
|
+
var tableMetadata = {}; // table name → metadata snapshot (PK/FK/sealed/derived) for getTableMetadata
|
|
118
|
+
|
|
119
|
+
// ---- Framework-baked tables ----
|
|
120
|
+
//
|
|
121
|
+
// audit_log + consent_log + _blamejs_subject_restrictions + _blamejs_subject_erasures
|
|
122
|
+
// are provisioned by the framework before app schema reconciles. Apps cannot
|
|
123
|
+
// opt out, override, or rename them. An app schema entry colliding with any of
|
|
124
|
+
// these names is refused at init.
|
|
125
|
+
var RESERVED_TABLE_NAMES = new Set([
|
|
126
|
+
"audit_log",
|
|
127
|
+
"audit_checkpoints",
|
|
128
|
+
"consent_log",
|
|
129
|
+
"_blamejs_subject_restrictions",
|
|
130
|
+
"_blamejs_subject_erasures",
|
|
131
|
+
"_blamejs_sessions",
|
|
132
|
+
"_blamejs_jobs",
|
|
133
|
+
"_blamejs_migrations",
|
|
134
|
+
"_blamejs_counters",
|
|
135
|
+
"_blamejs_audit_purge_anchor",
|
|
136
|
+
"_blamejs_scheduler_ticks",
|
|
137
|
+
"_blamejs_rate_limit_counters",
|
|
138
|
+
"_blamejs_pubsub_messages",
|
|
139
|
+
"_blamejs_api_encrypt_nonces",
|
|
140
|
+
"_blamejs_api_keys",
|
|
141
|
+
"_blamejs_cache",
|
|
142
|
+
"_blamejs_seeders",
|
|
143
|
+
"_blamejs_seeders_lock",
|
|
144
|
+
"_blamejs_break_glass_policies",
|
|
145
|
+
"_blamejs_break_glass_grants",
|
|
146
|
+
]);
|
|
147
|
+
|
|
148
|
+
var FRAMEWORK_SCHEMA = [
|
|
149
|
+
{
|
|
150
|
+
name: "audit_log",
|
|
151
|
+
columns: {
|
|
152
|
+
_id: "TEXT PRIMARY KEY",
|
|
153
|
+
recordedAt: "INTEGER NOT NULL",
|
|
154
|
+
monotonicCounter: "INTEGER NOT NULL",
|
|
155
|
+
actorUserId: "TEXT",
|
|
156
|
+
actorUserIdHash: "TEXT",
|
|
157
|
+
actorIp: "TEXT",
|
|
158
|
+
actorUserAgent: "TEXT",
|
|
159
|
+
actorSessionId: "TEXT",
|
|
160
|
+
action: "TEXT NOT NULL",
|
|
161
|
+
resourceKind: "TEXT",
|
|
162
|
+
resourceId: "TEXT",
|
|
163
|
+
resourceIdHash: "TEXT",
|
|
164
|
+
outcome: "TEXT NOT NULL",
|
|
165
|
+
reason: "TEXT",
|
|
166
|
+
metadata: "TEXT",
|
|
167
|
+
requestId: "TEXT",
|
|
168
|
+
prevHash: "TEXT NOT NULL",
|
|
169
|
+
rowHash: "TEXT NOT NULL",
|
|
170
|
+
nonce: "BLOB NOT NULL",
|
|
171
|
+
fencingToken: "INTEGER NOT NULL DEFAULT 0",
|
|
172
|
+
},
|
|
173
|
+
indexes: [
|
|
174
|
+
"actorUserIdHash", "resourceIdHash", "recordedAt", "action",
|
|
175
|
+
{ name: "idx_audit_monotonic", columns: "monotonicCounter", unique: true },
|
|
176
|
+
],
|
|
177
|
+
sealedFields: ["actorUserId", "actorIp", "actorUserAgent", "actorSessionId", "resourceId", "reason", "metadata"],
|
|
178
|
+
derivedHashes: {
|
|
179
|
+
actorUserIdHash: { from: "actorUserId" },
|
|
180
|
+
resourceIdHash: { from: "resourceId" },
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
name: "consent_log",
|
|
185
|
+
columns: {
|
|
186
|
+
_id: "TEXT PRIMARY KEY",
|
|
187
|
+
recordedAt: "INTEGER NOT NULL",
|
|
188
|
+
monotonicCounter: "INTEGER NOT NULL",
|
|
189
|
+
subjectId: "TEXT NOT NULL",
|
|
190
|
+
subjectIdHash: "TEXT NOT NULL",
|
|
191
|
+
purpose: "TEXT NOT NULL",
|
|
192
|
+
lawfulBasis: "TEXT NOT NULL",
|
|
193
|
+
action: "TEXT NOT NULL",
|
|
194
|
+
scope: "TEXT",
|
|
195
|
+
channel: "TEXT NOT NULL",
|
|
196
|
+
evidenceRef: "TEXT",
|
|
197
|
+
prevHash: "TEXT NOT NULL",
|
|
198
|
+
rowHash: "TEXT NOT NULL",
|
|
199
|
+
nonce: "BLOB NOT NULL",
|
|
200
|
+
fencingToken: "INTEGER NOT NULL DEFAULT 0",
|
|
201
|
+
},
|
|
202
|
+
indexes: [
|
|
203
|
+
"subjectIdHash", "recordedAt", "purpose",
|
|
204
|
+
{ name: "idx_consent_monotonic", columns: "monotonicCounter", unique: true },
|
|
205
|
+
],
|
|
206
|
+
sealedFields: ["subjectId", "scope", "evidenceRef"],
|
|
207
|
+
derivedHashes: {
|
|
208
|
+
subjectIdHash: { from: "subjectId" },
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
{
|
|
212
|
+
name: "_blamejs_subject_restrictions",
|
|
213
|
+
columns: {
|
|
214
|
+
subjectIdHash: "TEXT PRIMARY KEY",
|
|
215
|
+
since: "INTEGER NOT NULL",
|
|
216
|
+
reason: "TEXT",
|
|
217
|
+
},
|
|
218
|
+
sealedFields: ["reason"],
|
|
219
|
+
},
|
|
220
|
+
{
|
|
221
|
+
name: "_blamejs_subject_erasures",
|
|
222
|
+
columns: {
|
|
223
|
+
subjectIdHash: "TEXT PRIMARY KEY",
|
|
224
|
+
erasedAt: "INTEGER NOT NULL",
|
|
225
|
+
},
|
|
226
|
+
},
|
|
227
|
+
{
|
|
228
|
+
name: "audit_checkpoints",
|
|
229
|
+
columns: {
|
|
230
|
+
_id: "TEXT PRIMARY KEY",
|
|
231
|
+
createdAt: "INTEGER NOT NULL",
|
|
232
|
+
atMonotonicCounter: "INTEGER NOT NULL",
|
|
233
|
+
atRowHash: "TEXT NOT NULL",
|
|
234
|
+
signature: "BLOB NOT NULL",
|
|
235
|
+
publicKeyFingerprint: "TEXT NOT NULL",
|
|
236
|
+
fencingToken: "INTEGER NOT NULL DEFAULT 0",
|
|
237
|
+
},
|
|
238
|
+
indexes: [
|
|
239
|
+
"createdAt",
|
|
240
|
+
{ name: "idx_chkpt_counter", columns: "atMonotonicCounter", unique: true },
|
|
241
|
+
],
|
|
242
|
+
sealedFields: [],
|
|
243
|
+
},
|
|
244
|
+
{
|
|
245
|
+
name: "_blamejs_audit_purge_anchor",
|
|
246
|
+
columns: {
|
|
247
|
+
scope: "TEXT PRIMARY KEY",
|
|
248
|
+
lastPurgedCounter: "INTEGER NOT NULL",
|
|
249
|
+
lastPurgedRowHash: "TEXT NOT NULL",
|
|
250
|
+
archiveBundleId: "TEXT NOT NULL",
|
|
251
|
+
purgedAt: "INTEGER NOT NULL",
|
|
252
|
+
},
|
|
253
|
+
sealedFields: [],
|
|
254
|
+
},
|
|
255
|
+
{
|
|
256
|
+
// Scheduler exactly-once-globally claim table. Each fire claims a
|
|
257
|
+
// (name, scheduledAtUnix) row before dispatching; UNIQUE on the
|
|
258
|
+
// composite tickKey (name + ":" + scheduledAtUnix) means a concurrent
|
|
259
|
+
// leader's INSERT loses with a constraint violation, and that node
|
|
260
|
+
// skips the tick. Closes the once-globally gap during cluster
|
|
261
|
+
// leader hand-offs where two leaders briefly coexist.
|
|
262
|
+
name: "_blamejs_scheduler_ticks",
|
|
263
|
+
columns: {
|
|
264
|
+
tickKey: "TEXT PRIMARY KEY",
|
|
265
|
+
name: "TEXT NOT NULL",
|
|
266
|
+
scheduledAtUnix: "INTEGER NOT NULL",
|
|
267
|
+
claimedAtUnix: "INTEGER NOT NULL",
|
|
268
|
+
claimedBy: "TEXT",
|
|
269
|
+
},
|
|
270
|
+
indexes: ["scheduledAtUnix"],
|
|
271
|
+
sealedFields: [],
|
|
272
|
+
},
|
|
273
|
+
{
|
|
274
|
+
// _blamejs_rate_limit_counters — fixed-window counter table for
|
|
275
|
+
// the cluster-shared rate-limit backend. One row per (key); the
|
|
276
|
+
// count rolls over atomically when the windowStart advances. Used
|
|
277
|
+
// by lib/middleware/rate-limit.js when scope: 'cluster' is set.
|
|
278
|
+
name: "_blamejs_rate_limit_counters",
|
|
279
|
+
columns: {
|
|
280
|
+
key: "TEXT PRIMARY KEY",
|
|
281
|
+
windowStart: "INTEGER NOT NULL",
|
|
282
|
+
count: "INTEGER NOT NULL DEFAULT 0",
|
|
283
|
+
},
|
|
284
|
+
indexes: ["windowStart"],
|
|
285
|
+
sealedFields: [],
|
|
286
|
+
},
|
|
287
|
+
{
|
|
288
|
+
// _blamejs_pubsub_messages — cluster fan-out for `b.pubsub` (the
|
|
289
|
+
// generalization of the previous WebSocket-specific table). Any
|
|
290
|
+
// pubsub instance using the `cluster` backend writes a row on
|
|
291
|
+
// publish; other nodes poll for new ids and dispatch to their
|
|
292
|
+
// local subscribers. Rows older than the configured retention
|
|
293
|
+
// window are pruned by the backend on a rate-limited basis.
|
|
294
|
+
name: "_blamejs_pubsub_messages",
|
|
295
|
+
columns: {
|
|
296
|
+
id: "INTEGER PRIMARY KEY AUTOINCREMENT",
|
|
297
|
+
topic: "TEXT NOT NULL",
|
|
298
|
+
payload: "TEXT NOT NULL",
|
|
299
|
+
publishedAt: "INTEGER NOT NULL",
|
|
300
|
+
publishedBy: "TEXT NOT NULL",
|
|
301
|
+
},
|
|
302
|
+
indexes: ["publishedAt"],
|
|
303
|
+
sealedFields: [],
|
|
304
|
+
},
|
|
305
|
+
{
|
|
306
|
+
// _blamejs_api_encrypt_nonces — replay-protection store for the
|
|
307
|
+
// api-encrypt middleware. The middleware hashes the client-supplied
|
|
308
|
+
// nonce via SHA3 before insert so a leaked DB / table dump never
|
|
309
|
+
// exposes the original 16-byte client nonces. Hashing is
|
|
310
|
+
// deterministic so the PRIMARY KEY conflict is what catches a
|
|
311
|
+
// replay attempt within the replay window.
|
|
312
|
+
name: "_blamejs_api_encrypt_nonces",
|
|
313
|
+
columns: {
|
|
314
|
+
nonceHash: "TEXT PRIMARY KEY",
|
|
315
|
+
expireAt: "INTEGER NOT NULL",
|
|
316
|
+
},
|
|
317
|
+
indexes: ["expireAt"],
|
|
318
|
+
sealedFields: [],
|
|
319
|
+
},
|
|
320
|
+
{
|
|
321
|
+
name: "_blamejs_sessions",
|
|
322
|
+
columns: {
|
|
323
|
+
sidHash: "TEXT PRIMARY KEY",
|
|
324
|
+
userId: "TEXT NOT NULL",
|
|
325
|
+
userIdHash: "TEXT NOT NULL",
|
|
326
|
+
data: "TEXT",
|
|
327
|
+
createdAt: "INTEGER NOT NULL",
|
|
328
|
+
expiresAt: "INTEGER NOT NULL",
|
|
329
|
+
lastActivity: "INTEGER NOT NULL",
|
|
330
|
+
},
|
|
331
|
+
indexes: ["userIdHash", "expiresAt"],
|
|
332
|
+
sealedFields: ["userId", "data"],
|
|
333
|
+
derivedHashes: { userIdHash: { from: "userId" } },
|
|
334
|
+
},
|
|
335
|
+
{
|
|
336
|
+
// _blamejs_api_keys — operator-facing API-key registry. Sealed
|
|
337
|
+
// columns: ownerId / scopes / metadata. The secret never lands
|
|
338
|
+
// here — only its SHA3-512 hash, constant-time-compared on
|
|
339
|
+
// verify. Same dual-storage pattern as sessions: this row mirrors
|
|
340
|
+
// the cluster-mode DDL in framework-schema.js so cluster-storage
|
|
341
|
+
// can route to either backend transparently.
|
|
342
|
+
name: "_blamejs_api_keys",
|
|
343
|
+
columns: {
|
|
344
|
+
id: "TEXT PRIMARY KEY",
|
|
345
|
+
namespace: "TEXT NOT NULL",
|
|
346
|
+
ownerId: "TEXT NOT NULL",
|
|
347
|
+
ownerIdHash: "TEXT NOT NULL",
|
|
348
|
+
secretHash: "TEXT NOT NULL",
|
|
349
|
+
// secondarySecretHash + secondaryExpiresAt support graceful key
|
|
350
|
+
// rotation: when rotate({ gracePeriodMs }) is called the old hash
|
|
351
|
+
// is preserved here and the new hash takes the primary slot. Both
|
|
352
|
+
// verify successfully until secondaryExpiresAt, then the old slot
|
|
353
|
+
// is implicitly retired.
|
|
354
|
+
secondarySecretHash: "TEXT",
|
|
355
|
+
secondaryExpiresAt: "INTEGER",
|
|
356
|
+
scopes: "TEXT",
|
|
357
|
+
metadata: "TEXT",
|
|
358
|
+
createdAt: "INTEGER NOT NULL",
|
|
359
|
+
expiresAt: "INTEGER",
|
|
360
|
+
revokedAt: "INTEGER",
|
|
361
|
+
lastUsedAt: "INTEGER",
|
|
362
|
+
prefix: "TEXT NOT NULL",
|
|
363
|
+
},
|
|
364
|
+
indexes: [
|
|
365
|
+
"ownerIdHash",
|
|
366
|
+
{ name: "idx_api_keys_namespace_owner", columns: ["namespace", "ownerIdHash"] },
|
|
367
|
+
"expiresAt",
|
|
368
|
+
],
|
|
369
|
+
sealedFields: ["ownerId", "scopes", "metadata"],
|
|
370
|
+
derivedHashes: { ownerIdHash: { from: "ownerId" } },
|
|
371
|
+
},
|
|
372
|
+
{
|
|
373
|
+
name: "_blamejs_jobs",
|
|
374
|
+
columns: {
|
|
375
|
+
_id: "TEXT PRIMARY KEY",
|
|
376
|
+
queueName: "TEXT NOT NULL",
|
|
377
|
+
payload: "TEXT",
|
|
378
|
+
status: "TEXT NOT NULL",
|
|
379
|
+
enqueuedAt: "INTEGER NOT NULL",
|
|
380
|
+
availableAt: "INTEGER NOT NULL",
|
|
381
|
+
leasedAt: "INTEGER",
|
|
382
|
+
leaseExpiresAt: "INTEGER",
|
|
383
|
+
attempts: "INTEGER NOT NULL DEFAULT 0",
|
|
384
|
+
maxAttempts: "INTEGER NOT NULL DEFAULT 5",
|
|
385
|
+
lastError: "TEXT",
|
|
386
|
+
finishedAt: "INTEGER",
|
|
387
|
+
traceId: "TEXT",
|
|
388
|
+
classification: "TEXT",
|
|
389
|
+
priority: "INTEGER NOT NULL DEFAULT 0",
|
|
390
|
+
// Repeat-in-queue: cron-shaped recurring jobs re-enqueue themselves
|
|
391
|
+
// after each successful completion. NULL = one-shot (no repeat).
|
|
392
|
+
repeatCron: "TEXT",
|
|
393
|
+
repeatTimezone: "TEXT",
|
|
394
|
+
// Flows: parent-child job graphs with dependency edges.
|
|
395
|
+
// flowId groups jobs in the same flow; dependsOn is a JSON array
|
|
396
|
+
// of jobIds this row waits for; flowChildName is the human-readable
|
|
397
|
+
// label inside the flow used by dependsOn resolution.
|
|
398
|
+
flowId: "TEXT",
|
|
399
|
+
flowChildName: "TEXT",
|
|
400
|
+
dependsOn: "TEXT",
|
|
401
|
+
},
|
|
402
|
+
indexes: [
|
|
403
|
+
{ name: "idx_jobs_lease", columns: ["queueName", "status", "availableAt"] },
|
|
404
|
+
{ name: "idx_jobs_priority", columns: ["queueName", "status", "priority", "availableAt"] },
|
|
405
|
+
{ name: "idx_jobs_flow", columns: ["flowId"] },
|
|
406
|
+
"leaseExpiresAt",
|
|
407
|
+
"finishedAt",
|
|
408
|
+
],
|
|
409
|
+
sealedFields: ["payload", "lastError"],
|
|
410
|
+
},
|
|
411
|
+
{
|
|
412
|
+
// _blamejs_cache — operator-facing cache primitive's cluster backend
|
|
413
|
+
// (lib/cache.js). Mirrors the cluster-mode DDL in framework-schema.js.
|
|
414
|
+
// PRIMARY KEY is the composite "<namespace>:<key>"; valueJson is
|
|
415
|
+
// JSON-serialized; expiresAt is unix-ms (Number.MAX_SAFE_INTEGER for
|
|
416
|
+
// never-expiring entries). Not sealed: cache values are operator-
|
|
417
|
+
// chosen application data, the operator decides what's worth storing.
|
|
418
|
+
name: "_blamejs_cache",
|
|
419
|
+
columns: {
|
|
420
|
+
cacheKey: "TEXT PRIMARY KEY",
|
|
421
|
+
valueJson: "TEXT NOT NULL",
|
|
422
|
+
expiresAt: "INTEGER NOT NULL",
|
|
423
|
+
updatedAt: "INTEGER NOT NULL",
|
|
424
|
+
},
|
|
425
|
+
indexes: ["expiresAt"],
|
|
426
|
+
sealedFields: [],
|
|
427
|
+
},
|
|
428
|
+
{
|
|
429
|
+
// _blamejs_cache_tags — junction table for tag→cacheKey lookup
|
|
430
|
+
// backing b.cache.invalidateTag(t) on the cluster backend. Composite
|
|
431
|
+
// PK (cacheKey, tag) lets one cacheKey carry many tags; index on
|
|
432
|
+
// tag makes invalidation a single indexed scan. Cleared together
|
|
433
|
+
// with the matching _blamejs_cache rows on del / clear / sweep.
|
|
434
|
+
name: "_blamejs_cache_tags",
|
|
435
|
+
columns: {
|
|
436
|
+
cacheKey: "TEXT NOT NULL",
|
|
437
|
+
tag: "TEXT NOT NULL",
|
|
438
|
+
},
|
|
439
|
+
primaryKey: ["cacheKey", "tag"],
|
|
440
|
+
indexes: ["tag"],
|
|
441
|
+
sealedFields: [],
|
|
442
|
+
},
|
|
443
|
+
{
|
|
444
|
+
// _blamejs_seeders — registry of applied seed files for the
|
|
445
|
+
// b.seeders primitive (lib/seeders.js). Composite PK (env, name)
|
|
446
|
+
// means the same filename can apply per env (dev fixtures don't
|
|
447
|
+
// collide with prod fixtures by name). rerunnable=1 entries get
|
|
448
|
+
// their appliedAt updated in place on every run; non-rerunnable
|
|
449
|
+
// entries are insert-once.
|
|
450
|
+
name: "_blamejs_seeders",
|
|
451
|
+
columns: {
|
|
452
|
+
env: "TEXT NOT NULL",
|
|
453
|
+
name: "TEXT NOT NULL",
|
|
454
|
+
description: "TEXT",
|
|
455
|
+
appliedAt: "TEXT NOT NULL",
|
|
456
|
+
rerunnable: "INTEGER NOT NULL DEFAULT 0",
|
|
457
|
+
},
|
|
458
|
+
primaryKey: ["env", "name"],
|
|
459
|
+
indexes: [],
|
|
460
|
+
sealedFields: [],
|
|
461
|
+
},
|
|
462
|
+
{
|
|
463
|
+
// _blamejs_seeders_lock — single-row advisory lock for the seeders
|
|
464
|
+
// runner. Same shape as _blamejs_migrations_lock (CHECK constraint
|
|
465
|
+
// on scope='lock' enforces single row). Two processes calling
|
|
466
|
+
// `seed run` against the same DB race on this PK; loser sees a
|
|
467
|
+
// clear "lock held" error.
|
|
468
|
+
name: "_blamejs_seeders_lock",
|
|
469
|
+
columns: {
|
|
470
|
+
scope: "TEXT PRIMARY KEY CHECK (scope = 'lock')",
|
|
471
|
+
lockedAt: "INTEGER NOT NULL",
|
|
472
|
+
lockedBy: "TEXT NOT NULL",
|
|
473
|
+
},
|
|
474
|
+
sealedFields: [],
|
|
475
|
+
},
|
|
476
|
+
{
|
|
477
|
+
// _blamejs_break_glass_policies — column-level break-glass policy
|
|
478
|
+
// registry. One row per (table) declares which columns are
|
|
479
|
+
// glass-locked and what the operator's grant rules are. Sealed
|
|
480
|
+
// columns hold the column-list, factor-list, and bypass config so
|
|
481
|
+
// policy contents aren't browsable in cleartext.
|
|
482
|
+
name: "_blamejs_break_glass_policies",
|
|
483
|
+
columns: {
|
|
484
|
+
tableName: "TEXT PRIMARY KEY",
|
|
485
|
+
columnsJson: "TEXT NOT NULL",
|
|
486
|
+
factorsJson: "TEXT NOT NULL",
|
|
487
|
+
cryptographic: "INTEGER NOT NULL DEFAULT 0",
|
|
488
|
+
grantTtlMs: "INTEGER NOT NULL",
|
|
489
|
+
maxRowsPerGrant: "INTEGER NOT NULL DEFAULT 1",
|
|
490
|
+
reasonRequired: "INTEGER NOT NULL DEFAULT 1",
|
|
491
|
+
reasonMinLength: "INTEGER NOT NULL DEFAULT 12",
|
|
492
|
+
pinIp: "INTEGER NOT NULL DEFAULT 1",
|
|
493
|
+
sessionPin: "INTEGER NOT NULL DEFAULT 1",
|
|
494
|
+
onLockedAccess: "TEXT NOT NULL DEFAULT 'throw'",
|
|
495
|
+
requireScope: "TEXT",
|
|
496
|
+
serviceAccountBypassJson: "TEXT",
|
|
497
|
+
dekSealed: "TEXT",
|
|
498
|
+
auditReasonStorage: "TEXT NOT NULL DEFAULT 'cleartext'",
|
|
499
|
+
updatedAt: "INTEGER NOT NULL",
|
|
500
|
+
},
|
|
501
|
+
indexes: [],
|
|
502
|
+
sealedFields: ["columnsJson", "factorsJson", "serviceAccountBypassJson"],
|
|
503
|
+
},
|
|
504
|
+
{
|
|
505
|
+
// _blamejs_break_glass_grants — issued grants. Each successful
|
|
506
|
+
// step-up creates one row; each row read decrements rowsRemaining.
|
|
507
|
+
// Default maxRowsPerGrant=1 enforces "row by row" auth per the
|
|
508
|
+
// operator-confirmed shape (each row access = its own grant).
|
|
509
|
+
// Sealed columns hold reason + scopeColumns so audit-readable
|
|
510
|
+
// metadata doesn't leak in cleartext.
|
|
511
|
+
name: "_blamejs_break_glass_grants",
|
|
512
|
+
columns: {
|
|
513
|
+
_id: "TEXT PRIMARY KEY",
|
|
514
|
+
issuedToActorId: "TEXT NOT NULL",
|
|
515
|
+
issuedToActorHash: "TEXT NOT NULL",
|
|
516
|
+
factorType: "TEXT NOT NULL",
|
|
517
|
+
reasonSealed: "TEXT",
|
|
518
|
+
scopeTable: "TEXT NOT NULL",
|
|
519
|
+
scopeColumnsJson: "TEXT NOT NULL",
|
|
520
|
+
issuedAt: "INTEGER NOT NULL",
|
|
521
|
+
expiresAt: "INTEGER NOT NULL",
|
|
522
|
+
maxRowsPerGrant: "INTEGER NOT NULL",
|
|
523
|
+
rowsConsumed: "INTEGER NOT NULL DEFAULT 0",
|
|
524
|
+
revokedAt: "INTEGER",
|
|
525
|
+
sessionId: "TEXT",
|
|
526
|
+
ip: "TEXT",
|
|
527
|
+
kwGrantHalf: "TEXT",
|
|
528
|
+
},
|
|
529
|
+
indexes: [
|
|
530
|
+
{ name: "idx_bg_grants_actor", columns: ["issuedToActorHash"] },
|
|
531
|
+
{ name: "idx_bg_grants_table", columns: ["scopeTable"] },
|
|
532
|
+
"expiresAt",
|
|
533
|
+
"revokedAt",
|
|
534
|
+
],
|
|
535
|
+
derivedHashes: { issuedToActorHash: { from: "issuedToActorId" } },
|
|
536
|
+
sealedFields: ["reasonSealed", "scopeColumnsJson"],
|
|
537
|
+
},
|
|
538
|
+
];
|
|
539
|
+
|
|
540
|
+
var log = boot("db");
|
|
541
|
+
|
|
542
|
+
// ---- Tmpfs detection ----
|
|
543
|
+
|
|
544
|
+
function resolveTmpDir(optsTmpDir) {
|
|
545
|
+
if (optsTmpDir) return optsTmpDir;
|
|
546
|
+
var envTmp = safeEnv.readVar("BLAMEJS_TMPDIR");
|
|
547
|
+
if (envTmp) return envTmp;
|
|
548
|
+
if (fs.existsSync("/dev/shm")) return "/dev/shm";
|
|
549
|
+
return null;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// ---- DB encryption key management ----
|
|
553
|
+
|
|
554
|
+
function loadOrCreateDbKey(dataDirPath) {
|
|
555
|
+
var keyPath = path.join(dataDirPath, "db.key.enc");
|
|
556
|
+
if (fs.existsSync(keyPath)) {
|
|
557
|
+
var sealed = atomicFile.readSync(keyPath, { encoding: "utf8" }).trim();
|
|
558
|
+
var b64 = vault.unseal(sealed);
|
|
559
|
+
if (!b64) {
|
|
560
|
+
throw _dbErr("db/key-unseal-empty",
|
|
561
|
+
"FATAL: db.key.enc unseal returned empty — vault may not be initialized or key file corrupted");
|
|
562
|
+
}
|
|
563
|
+
return Buffer.from(b64, "base64");
|
|
564
|
+
}
|
|
565
|
+
// First run — generate, seal, persist (atomic)
|
|
566
|
+
var raw = generateBytes(C.BYTES.bytes(32));
|
|
567
|
+
var sealedKey = vault.seal(raw.toString("base64"));
|
|
568
|
+
atomicFile.writeSync(keyPath, sealedKey, { fileMode: 0o600 });
|
|
569
|
+
log("generated DB encryption key at " + keyPath);
|
|
570
|
+
return raw;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function decryptToTmp() {
|
|
574
|
+
if (!encPath || !fs.existsSync(encPath)) return;
|
|
575
|
+
// If a plaintext file already exists in tmpfs from a prior process, prefer
|
|
576
|
+
// the newer mtime (crash recovery — operator's most recent state wins).
|
|
577
|
+
if (fs.existsSync(dbPath)) {
|
|
578
|
+
var plainStat = fs.statSync(dbPath);
|
|
579
|
+
var encStat = fs.statSync(encPath);
|
|
580
|
+
if (plainStat.mtimeMs > encStat.mtimeMs && plainStat.size > 0) {
|
|
581
|
+
log("plaintext is newer than encrypted — keeping plaintext (crash recovery)");
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
var packed = fs.readFileSync(encPath);
|
|
586
|
+
if (packed.length < 26) return; // too short to be a valid envelope
|
|
587
|
+
atomicFile.writeSync(dbPath, decryptPacked(packed, encKey));
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function encryptToDisk() {
|
|
591
|
+
if (!encPath) return;
|
|
592
|
+
// Force WAL checkpoint so the .db file holds all committed transactions.
|
|
593
|
+
try { runSql(database, "PRAGMA wal_checkpoint(TRUNCATE)"); } catch (_e) { /* best effort */ }
|
|
594
|
+
if (!fs.existsSync(dbPath)) return;
|
|
595
|
+
atomicFile.writeSync(encPath, encryptPacked(fs.readFileSync(dbPath), encKey));
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// Remove the plaintext DB + WAL/SHM sidecar files. On Windows these can't be
|
|
599
|
+
// unlinked while the SQLite handle is open, so this MUST be called after
|
|
600
|
+
// database.close().
|
|
601
|
+
function removePlaintextFiles() {
|
|
602
|
+
if (!dbPath) return;
|
|
603
|
+
try { fs.unlinkSync(dbPath); } catch (_e) { /* cleanup */ }
|
|
604
|
+
try { fs.unlinkSync(dbPath + "-wal"); } catch (_e) { /* cleanup */ }
|
|
605
|
+
try { fs.unlinkSync(dbPath + "-shm"); } catch (_e) { /* cleanup */ }
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// Clean up stale plaintext DB files left by previously-crashed processes.
|
|
609
|
+
// Anything matching blamejs-*.db that isn't our current process's file is
|
|
610
|
+
// stale (no other process should write to /dev/shm with our prefix).
|
|
611
|
+
function cleanStaleTmpDbs(tmpDir) {
|
|
612
|
+
var entries = atomicFile.listDir(tmpDir, {
|
|
613
|
+
filter: function (name) { return name.startsWith("blamejs-") && name.endsWith(".db"); },
|
|
614
|
+
});
|
|
615
|
+
for (var i = 0; i < entries.length; i++) {
|
|
616
|
+
var full = entries[i].fullPath;
|
|
617
|
+
if (full === dbPath) continue;
|
|
618
|
+
try { fs.unlinkSync(full); } catch (_e) { /* concurrent cleanup */ }
|
|
619
|
+
try { fs.unlinkSync(full + "-wal"); } catch (_e) { /* may not exist */ }
|
|
620
|
+
try { fs.unlinkSync(full + "-shm"); } catch (_e) { /* may not exist */ }
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// ---- Init dispatch ----
|
|
625
|
+
|
|
626
|
+
async function init(opts) {
|
|
627
|
+
if (initialized) return;
|
|
628
|
+
if (!opts || !opts.dataDir) {
|
|
629
|
+
throw new DbError("db/bad-init", "db.init({ dataDir }) is required");
|
|
630
|
+
}
|
|
631
|
+
if (!Array.isArray(opts.schema)) {
|
|
632
|
+
throw new DbError("db/bad-init",
|
|
633
|
+
"db.init({ schema }) must be an array of table definitions");
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
atRest = (opts.atRest || "encrypted").toLowerCase();
|
|
637
|
+
if (atRest !== "encrypted" && atRest !== "plain") {
|
|
638
|
+
throw new DbError("db/bad-at-rest",
|
|
639
|
+
"db.init: atRest must be 'encrypted' or 'plain', got: " + opts.atRest);
|
|
640
|
+
}
|
|
641
|
+
dataDir = opts.dataDir;
|
|
642
|
+
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
|
|
643
|
+
|
|
644
|
+
if (atRest === "encrypted") {
|
|
645
|
+
var tmpDir = resolveTmpDir(opts.tmpDir);
|
|
646
|
+
if (!tmpDir) {
|
|
647
|
+
throw _dbErr("db/no-tmpfs",
|
|
648
|
+
"FATAL: atRest: 'encrypted' (default) requires tmpfs but none was found. " +
|
|
649
|
+
"Provide opts.tmpDir or set BLAMEJS_TMPDIR, or pass atRest: 'plain' (with warning).");
|
|
650
|
+
}
|
|
651
|
+
if (!fs.existsSync(tmpDir)) fs.mkdirSync(tmpDir, { recursive: true });
|
|
652
|
+
|
|
653
|
+
encPath = path.join(dataDir, "db.enc");
|
|
654
|
+
dbPath = path.join(tmpDir, "blamejs-" + generateToken(C.BYTES.bytes(16)) + ".db");
|
|
655
|
+
encKey = loadOrCreateDbKey(dataDir);
|
|
656
|
+
|
|
657
|
+
cleanStaleTmpDbs(tmpDir);
|
|
658
|
+
decryptToTmp();
|
|
659
|
+
} else {
|
|
660
|
+
// plain mode
|
|
661
|
+
log.warn("WARNING: atRest: 'plain' — DB structure and row counts visible on disk.");
|
|
662
|
+
log.warn(" Field-level encryption (sealedFields) still protects sealed columns,");
|
|
663
|
+
log.warn(" but the simpler at-rest model is opt-out only. Default is 'encrypted'.");
|
|
664
|
+
dbPath = path.join(dataDir, "blamejs.db");
|
|
665
|
+
encPath = null;
|
|
666
|
+
encKey = null;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// Open the database
|
|
670
|
+
database = new DatabaseSync(dbPath);
|
|
671
|
+
|
|
672
|
+
// Performance pragmas
|
|
673
|
+
runSql(database, "PRAGMA journal_mode=WAL");
|
|
674
|
+
runSql(database, "PRAGMA synchronous=NORMAL");
|
|
675
|
+
runSql(database, "PRAGMA cache_size=-8000");
|
|
676
|
+
runSql(database, "PRAGMA temp_store=MEMORY");
|
|
677
|
+
runSql(database, "PRAGMA busy_timeout=5000");
|
|
678
|
+
runSql(database, "PRAGMA mmap_size=268435456");
|
|
679
|
+
runSql(database, "PRAGMA auto_vacuum=INCREMENTAL");
|
|
680
|
+
// Foreign-key enforcement is OFF by default in SQLite. Turn it ON so
|
|
681
|
+
// structured `foreignKeys` declarations actually constrain writes.
|
|
682
|
+
runSql(database, "PRAGMA foreign_keys=ON");
|
|
683
|
+
|
|
684
|
+
// Refuse app schema entries that collide with framework-reserved names
|
|
685
|
+
for (var ri = 0; ri < opts.schema.length; ri++) {
|
|
686
|
+
if (RESERVED_TABLE_NAMES.has(opts.schema[ri].name)) {
|
|
687
|
+
throw new DbError("db/reserved-table-name",
|
|
688
|
+
"table name '" + opts.schema[ri].name + "' is reserved by the framework. " +
|
|
689
|
+
"Pick a different name (the framework provisions audit_log, consent_log, " +
|
|
690
|
+
"and _blamejs_* tables automatically).");
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// Track subject schema for subject.export/erase walks
|
|
695
|
+
subjectTables = [];
|
|
696
|
+
for (var si = 0; si < opts.schema.length; si++) {
|
|
697
|
+
var st = opts.schema[si];
|
|
698
|
+
if (st.subjectField) {
|
|
699
|
+
subjectTables.push({
|
|
700
|
+
name: st.name,
|
|
701
|
+
subjectField: st.subjectField,
|
|
702
|
+
personalDataCategories: st.personalDataCategories || {},
|
|
703
|
+
});
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// Build the full schema = framework-baked tables + app tables.
|
|
708
|
+
// Framework tables come FIRST so audit_log/consent_log exist before any
|
|
709
|
+
// app migration can reference them.
|
|
710
|
+
var fullSchema = FRAMEWORK_SCHEMA.concat(opts.schema);
|
|
711
|
+
|
|
712
|
+
// Register schema with field-crypto + capture table metadata snapshot
|
|
713
|
+
// (framework tables included so getTableMetadata covers everything).
|
|
714
|
+
tableMetadata = {};
|
|
715
|
+
for (var i = 0; i < fullSchema.length; i++) {
|
|
716
|
+
var t = fullSchema[i];
|
|
717
|
+
cryptoField.registerTable(t.name, {
|
|
718
|
+
sealedFields: t.sealedFields,
|
|
719
|
+
derivedHashes: t.derivedHashes,
|
|
720
|
+
hashNamespaces: t.hashNamespaces,
|
|
721
|
+
});
|
|
722
|
+
tableMetadata[t.name] = {
|
|
723
|
+
primaryKey: _normalizePk(t),
|
|
724
|
+
foreignKeys: Array.isArray(t.foreignKeys) ? t.foreignKeys.slice() : [],
|
|
725
|
+
columns: Object.assign({}, t.columns),
|
|
726
|
+
indexes: Array.isArray(t.indexes) ? t.indexes.slice() : [],
|
|
727
|
+
sealedFields: Array.isArray(t.sealedFields) ? t.sealedFields.slice() : [],
|
|
728
|
+
derivedHashes: Object.assign({}, t.derivedHashes || {}),
|
|
729
|
+
subjectField: t.subjectField || null,
|
|
730
|
+
personalDataCategories: Object.assign({}, t.personalDataCategories || {}),
|
|
731
|
+
};
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
// Declarative schema reconcile (framework + app tables)
|
|
735
|
+
dbSchema.reconcile(database, fullSchema);
|
|
736
|
+
|
|
737
|
+
// Append-only enforcement on audit_log + consent_log via SQLite triggers.
|
|
738
|
+
// Apps cannot UPDATE or DELETE these tables; the framework's audit.record /
|
|
739
|
+
// consent.grant only INSERT. This is a SQL-level guard against bug-induced
|
|
740
|
+
// or malicious tampering — independent of the API surface's discipline.
|
|
741
|
+
// Operator-driven retention purge (when implemented) must drop these
|
|
742
|
+
// triggers explicitly inside a transaction, perform the purge, and
|
|
743
|
+
// recreate them.
|
|
744
|
+
_installAppendOnlyTriggers(database);
|
|
745
|
+
|
|
746
|
+
// Imperative migrations (run once each, in order)
|
|
747
|
+
if (opts.migrationDir) {
|
|
748
|
+
var result = dbSchema.runMigrations(database, opts.migrationDir);
|
|
749
|
+
if (result.applied.length > 0) {
|
|
750
|
+
log("applied " + result.applied.length + " migration(s): " + result.applied.join(", "));
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// dataResidency — operator's declared region. Registered here for
|
|
755
|
+
// downstream backends (storage, mail, log destinations) to validate
|
|
756
|
+
// against; backends opt in by reading this value via getDataResidency().
|
|
757
|
+
dataResidency = opts.dataResidency || null;
|
|
758
|
+
|
|
759
|
+
// Mark initialized BEFORE the chain verify so audit/consent.verify() can
|
|
760
|
+
// call db.prepare() through the public surface. If verify fails, we
|
|
761
|
+
// process.exit() — initialized state is moot at that point.
|
|
762
|
+
initialized = true;
|
|
763
|
+
|
|
764
|
+
// ---- Refuse-to-boot on chain break ----
|
|
765
|
+
// Verify both the audit and consent chains end-to-end. A broken chain
|
|
766
|
+
// means tamper-evidence has been compromised — the framework refuses
|
|
767
|
+
// to continue under any circumstances. Recovery is operator-driven
|
|
768
|
+
// (restore from backup or manual chain rebuild); the framework only
|
|
769
|
+
// detects-and-fails.
|
|
770
|
+
var auditResult = await audit.verify();
|
|
771
|
+
if (!auditResult.ok) {
|
|
772
|
+
// Fire the breach event BEFORE throwing so operator listeners get
|
|
773
|
+
// a chance at sync I/O (file flag, console alert) before init
|
|
774
|
+
// unwinds.
|
|
775
|
+
events.emit(events.EVENTS.AUDIT_CHAIN_BREAK, { table: "audit_log", result: auditResult });
|
|
776
|
+
throw _dbErr("db/audit-chain-break",
|
|
777
|
+
"FATAL: audit_log chain integrity broken at row " + auditResult.breakAt +
|
|
778
|
+
" (" + auditResult.reason + "); break row _id: " + auditResult.breakRowId +
|
|
779
|
+
"; expected: " + auditResult.expected + "; actual: " + auditResult.actual +
|
|
780
|
+
". Refusing to boot. Compliance requires that any tamper-detection signal halt service. " +
|
|
781
|
+
"Recovery is manual: restore from backup, or rebuild the audit chain from a verified earlier snapshot.");
|
|
782
|
+
}
|
|
783
|
+
var consentResult = await consent.verify();
|
|
784
|
+
if (!consentResult.ok) {
|
|
785
|
+
events.emit(events.EVENTS.AUDIT_CHAIN_BREAK, { table: "consent_log", result: consentResult });
|
|
786
|
+
throw _dbErr("db/consent-chain-break",
|
|
787
|
+
"FATAL: consent_log chain integrity broken at row " + consentResult.breakAt +
|
|
788
|
+
" (" + consentResult.reason + "); break row _id: " + consentResult.breakRowId +
|
|
789
|
+
". Refusing to boot.");
|
|
790
|
+
}
|
|
791
|
+
log("audit chain ok (" + auditResult.rowsVerified + " rows), consent chain ok (" + consentResult.rowsVerified + " rows)");
|
|
792
|
+
|
|
793
|
+
// ---- Rollback detection (audit.tip sidecar) ----
|
|
794
|
+
// The framework writes <dataDir>/audit.tip on each checkpoint. At boot we
|
|
795
|
+
// compare current MAX(monotonicCounter) to the recorded tip. If current
|
|
796
|
+
// is BELOW tip — the DB was rolled back to an older snapshot. Refuse boot.
|
|
797
|
+
_checkRollback(dataDir);
|
|
798
|
+
|
|
799
|
+
// ---- Audit-signing key + checkpoint subsystem ----
|
|
800
|
+
// Default mode 'wrapped' (passphrase-required, separate from vault). Apps
|
|
801
|
+
// that want a quick-start dev path can pass auditSigning: { mode: 'plaintext' }
|
|
802
|
+
// — same warning pattern as vault.
|
|
803
|
+
// opts.auditSigning.algorithm picks the keypair algorithm at first-run
|
|
804
|
+
// generation. Default = SLH-DSA-SHAKE-256f (matches the framework's
|
|
805
|
+
// SHAKE-family hash posture); ML-DSA-87 is the throughput-focused
|
|
806
|
+
// opt-in. Existing key files take their algorithm from disk; this
|
|
807
|
+
// option only matters on first generation.
|
|
808
|
+
var auditSigningMode = (opts.auditSigning && opts.auditSigning.mode)
|
|
809
|
+
? opts.auditSigning.mode
|
|
810
|
+
: safeEnv.readVar("BLAMEJS_AUDIT_SIGNING_MODE", {
|
|
811
|
+
default: "wrapped",
|
|
812
|
+
enum: ["wrapped", "plaintext"],
|
|
813
|
+
});
|
|
814
|
+
var auditSigningAlg = opts.auditSigning && opts.auditSigning.algorithm
|
|
815
|
+
? opts.auditSigning.algorithm
|
|
816
|
+
: null;
|
|
817
|
+
await auditSign.init({
|
|
818
|
+
dataDir: dataDir,
|
|
819
|
+
mode: auditSigningMode,
|
|
820
|
+
algorithm: auditSigningAlg || undefined,
|
|
821
|
+
});
|
|
822
|
+
|
|
823
|
+
// Verify all existing checkpoint signatures (defense against signature
|
|
824
|
+
// forgery attempt + key-rotation gone wrong). Refuse to boot on failure.
|
|
825
|
+
var ckptResult = await audit.verifyCheckpoints();
|
|
826
|
+
if (!ckptResult.ok) {
|
|
827
|
+
events.emit(events.EVENTS.AUDIT_CHECKPOINT_BREAK, { result: ckptResult });
|
|
828
|
+
throw _dbErr("db/audit-checkpoint-break",
|
|
829
|
+
"FATAL: audit checkpoint verification failed at row " +
|
|
830
|
+
ckptResult.breakAt + " (" + ckptResult.reason + "); checkpoint _id: " +
|
|
831
|
+
ckptResult.checkpointId + ". Refusing to boot. Either the audit-signing key " +
|
|
832
|
+
"was rotated without retaining the prior pubkey, or a forged checkpoint was inserted.");
|
|
833
|
+
}
|
|
834
|
+
log("audit checkpoints ok (" + ckptResult.checkpointsVerified + " signed)");
|
|
835
|
+
|
|
836
|
+
// Anchor a fresh checkpoint at boot if there's any new audit activity
|
|
837
|
+
// since the last checkpoint (else no-op).
|
|
838
|
+
await audit.checkpoint({ skipIfUnchanged: true });
|
|
839
|
+
|
|
840
|
+
// ---- NTP drift check ----
|
|
841
|
+
// Best-effort; unreachable NTP doesn't fail boot, but >= 1hr drift does
|
|
842
|
+
// (unless BLAMEJS_NTP_STRICT=0 / BLAMEJS_SKIP_NTP_CHECK=1).
|
|
843
|
+
await _runNtpBootCheck(opts);
|
|
844
|
+
|
|
845
|
+
// Start periodic encrypt timer (encrypted mode only)
|
|
846
|
+
if (atRest === "encrypted") {
|
|
847
|
+
encTimer = safeAsync.repeating(function () {
|
|
848
|
+
try { encryptToDisk(); } catch (e) {
|
|
849
|
+
log.error("periodic encrypt failed: " + e.message);
|
|
850
|
+
}
|
|
851
|
+
}, C.TIME.minutes(5), { name: "db-periodic-encrypt" });
|
|
852
|
+
|
|
853
|
+
// Final encrypt on process exit. We don't try to unlink the plaintext
|
|
854
|
+
// here — the SQLite handle may still be open, and the OS reclaims tmpfs
|
|
855
|
+
// on reboot anyway. close() does the orderly shutdown.
|
|
856
|
+
process.on("exit", function () {
|
|
857
|
+
try { encryptToDisk(); } catch (_e) { /* exit handler — silent */ }
|
|
858
|
+
});
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
log("ready (mode: " + atRest + ", path: " + dbPath + ")");
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
// ---- Public API ----
|
|
865
|
+
|
|
866
|
+
function from(tableName) {
|
|
867
|
+
_requireInit();
|
|
868
|
+
return new Query(database, tableName);
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
function prepare(sql) {
|
|
872
|
+
_requireInit();
|
|
873
|
+
return database.prepare(sql);
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
// stream — Readable in object mode that yields rows as node:sqlite's
|
|
877
|
+
// iterate() produces them. Unlike all(), the engine doesn't materialize
|
|
878
|
+
// the result set in memory before the first row arrives, so audit
|
|
879
|
+
// exports / backup table dumps / large reports can process millions of
|
|
880
|
+
// rows without OOM pressure.
|
|
881
|
+
//
|
|
882
|
+
// Optional opts.table enables auto-unseal of sealed columns via the
|
|
883
|
+
// table's registered cryptoField schema. Raw / aggregate queries omit
|
|
884
|
+
// it. Mid-iteration prepare()-bound errors propagate as 'error' events.
|
|
885
|
+
function stream(sql) {
|
|
886
|
+
_requireInit();
|
|
887
|
+
var opts = null;
|
|
888
|
+
var params;
|
|
889
|
+
// Last arg may be a plain {table?} options object; everything else
|
|
890
|
+
// is a SQL parameter binding. node:sqlite accepts numbers, strings,
|
|
891
|
+
// bigints, Buffers, and null — plain objects can only be opts.
|
|
892
|
+
var args = Array.prototype.slice.call(arguments, 1);
|
|
893
|
+
if (args.length > 0) {
|
|
894
|
+
var last = args[args.length - 1];
|
|
895
|
+
var isOptsShape = last !== null && typeof last === "object" &&
|
|
896
|
+
!Buffer.isBuffer(last) && !Array.isArray(last) &&
|
|
897
|
+
typeof last.length !== "number"; // exclude TypedArray-shapes
|
|
898
|
+
if (isOptsShape) {
|
|
899
|
+
opts = last;
|
|
900
|
+
params = args.slice(0, -1);
|
|
901
|
+
} else {
|
|
902
|
+
params = args;
|
|
903
|
+
}
|
|
904
|
+
} else {
|
|
905
|
+
params = [];
|
|
906
|
+
}
|
|
907
|
+
var table = opts && typeof opts.table === "string" ? opts.table : null;
|
|
908
|
+
var unseal = table ? cryptoField : null;
|
|
909
|
+
|
|
910
|
+
var stmt;
|
|
911
|
+
var iter;
|
|
912
|
+
try {
|
|
913
|
+
stmt = database.prepare(sql);
|
|
914
|
+
iter = stmt.iterate.apply(stmt, params);
|
|
915
|
+
} catch (e) {
|
|
916
|
+
var r = new Readable({ objectMode: true, read: function () {} });
|
|
917
|
+
setImmediate(function () { r.destroy(e); });
|
|
918
|
+
return r;
|
|
919
|
+
}
|
|
920
|
+
return new Readable({
|
|
921
|
+
objectMode: true,
|
|
922
|
+
read: function () {
|
|
923
|
+
try {
|
|
924
|
+
var step = iter.next();
|
|
925
|
+
if (step.done) { this.push(null); return; }
|
|
926
|
+
var row = step.value;
|
|
927
|
+
this.push(unseal ? unseal.unsealRow(table, row) : row);
|
|
928
|
+
} catch (e) {
|
|
929
|
+
this.destroy(e);
|
|
930
|
+
}
|
|
931
|
+
},
|
|
932
|
+
});
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
function execRaw(sql) {
|
|
936
|
+
_requireInit();
|
|
937
|
+
return runSql(database, sql);
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
function transaction(fn) {
|
|
941
|
+
_requireInit();
|
|
942
|
+
if (typeof fn !== "function") {
|
|
943
|
+
throw new DbError("db/bad-transaction-fn", "transaction requires a function");
|
|
944
|
+
}
|
|
945
|
+
runSql(database, "BEGIN");
|
|
946
|
+
try {
|
|
947
|
+
var result = fn(module.exports);
|
|
948
|
+
runSql(database, "COMMIT");
|
|
949
|
+
return result;
|
|
950
|
+
} catch (e) {
|
|
951
|
+
try { runSql(database, "ROLLBACK"); } catch (_e) { /* ignore — already error */ }
|
|
952
|
+
throw e;
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
function hashFor(table, field, value) {
|
|
957
|
+
_requireInit();
|
|
958
|
+
var lookup = cryptoField.lookupHash(table, field, value);
|
|
959
|
+
return lookup ? lookup.value : null;
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
function close() {
|
|
963
|
+
if (!initialized) return;
|
|
964
|
+
if (encTimer) {
|
|
965
|
+
encTimer.stop();
|
|
966
|
+
encTimer = null;
|
|
967
|
+
}
|
|
968
|
+
// Best-effort final checkpoint before shutdown so the audit.tip sidecar
|
|
969
|
+
// anchors the most recent state. Only the current leader writes the
|
|
970
|
+
// checkpoint; followers (and post-cluster-shutdown nodes) skip silently.
|
|
971
|
+
if (cluster.isLeader()) {
|
|
972
|
+
// Fire-and-forget. close() stays sync so callers don't have to
|
|
973
|
+
// await it across the test/shutdown lifecycle. Operators who need
|
|
974
|
+
// a guaranteed-flushed checkpoint should call audit.checkpoint()
|
|
975
|
+
// explicitly before invoking close().
|
|
976
|
+
audit.checkpoint({ skipIfUnchanged: true }).catch(function (e) {
|
|
977
|
+
log.error("close: final checkpoint failed: " + e.message);
|
|
978
|
+
});
|
|
979
|
+
}
|
|
980
|
+
// Order: encrypt while the DB is still open (so the file is consistent),
|
|
981
|
+
// then close the SQLite handle (releases the file lock on Windows),
|
|
982
|
+
// THEN unlink the plaintext sidecar files.
|
|
983
|
+
try { encryptToDisk(); } catch (e) {
|
|
984
|
+
log.error("close: final encrypt failed: " + e.message);
|
|
985
|
+
}
|
|
986
|
+
try { database.close(); } catch (_e) { /* already closed */ }
|
|
987
|
+
if (atRest === "encrypted") removePlaintextFiles();
|
|
988
|
+
database = null;
|
|
989
|
+
initialized = false;
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
function _requireInit() {
|
|
993
|
+
if (!initialized) {
|
|
994
|
+
throw new DbError("db/not-initialized",
|
|
995
|
+
"db.init() must be awaited before using db API");
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
// Normalize the primary-key declaration. Accepts an explicit `primaryKey`
|
|
1000
|
+
// property OR derives from inline "PRIMARY KEY" in the column DDL string.
|
|
1001
|
+
function _normalizePk(tableSpec) {
|
|
1002
|
+
if (tableSpec.primaryKey) {
|
|
1003
|
+
return Array.isArray(tableSpec.primaryKey) ? tableSpec.primaryKey.slice() : [tableSpec.primaryKey];
|
|
1004
|
+
}
|
|
1005
|
+
var inline = [];
|
|
1006
|
+
for (var col in tableSpec.columns) {
|
|
1007
|
+
if (/PRIMARY\s+KEY/i.test(tableSpec.columns[col])) inline.push(col);
|
|
1008
|
+
}
|
|
1009
|
+
return inline; // empty array if none declared (rowid PK)
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
// Install BEFORE-DELETE / BEFORE-UPDATE triggers on audit_log + consent_log
|
|
1013
|
+
// that RAISE(ABORT) the operation. INSERT remains permitted (that's what
|
|
1014
|
+
// audit.record / consent.grant do).
|
|
1015
|
+
function _installAppendOnlyTriggers(database) {
|
|
1016
|
+
var tables = ["audit_log", "consent_log", "audit_checkpoints"];
|
|
1017
|
+
for (var i = 0; i < tables.length; i++) {
|
|
1018
|
+
var t = tables[i];
|
|
1019
|
+
runSql(database,
|
|
1020
|
+
'CREATE TRIGGER IF NOT EXISTS "no_delete_' + t + '" ' +
|
|
1021
|
+
'BEFORE DELETE ON "' + t + '" ' +
|
|
1022
|
+
'BEGIN ' +
|
|
1023
|
+
" SELECT RAISE(ABORT, '" + t + " is append-only — DELETE prohibited'); " +
|
|
1024
|
+
'END'
|
|
1025
|
+
);
|
|
1026
|
+
runSql(database,
|
|
1027
|
+
'CREATE TRIGGER IF NOT EXISTS "no_update_' + t + '" ' +
|
|
1028
|
+
'BEFORE UPDATE ON "' + t + '" ' +
|
|
1029
|
+
'BEGIN ' +
|
|
1030
|
+
" SELECT RAISE(ABORT, '" + t + " is append-only — UPDATE prohibited'); " +
|
|
1031
|
+
'END'
|
|
1032
|
+
);
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
// Read the audit.tip sidecar file in dataDir and compare to the current
|
|
1037
|
+
// audit_log MAX(monotonicCounter). Refuse boot on rollback (current < tip).
|
|
1038
|
+
function _checkRollback(dataDirPath) {
|
|
1039
|
+
var tipPath = path.join(dataDirPath, "audit.tip");
|
|
1040
|
+
if (!fs.existsSync(tipPath)) {
|
|
1041
|
+
log("no audit.tip sidecar — skipping rollback check (first boot or operator-cleared)");
|
|
1042
|
+
return;
|
|
1043
|
+
}
|
|
1044
|
+
var tip;
|
|
1045
|
+
try {
|
|
1046
|
+
tip = safeJson.parse(atomicFile.readSync(tipPath), { schema: AUDIT_TIP_SCHEMA });
|
|
1047
|
+
} catch (e) {
|
|
1048
|
+
throw _dbErr("db/audit-tip-unreadable",
|
|
1049
|
+
"FATAL: audit.tip unreadable or schema-invalid at " + tipPath + " — " + e.message +
|
|
1050
|
+
". Either delete it (forfeits rollback protection until next checkpoint) " +
|
|
1051
|
+
"or restore from operator backup.");
|
|
1052
|
+
}
|
|
1053
|
+
var current = database.prepare("SELECT MAX(monotonicCounter) AS m FROM audit_log").get();
|
|
1054
|
+
var currentMax = current && current.m ? current.m : 0;
|
|
1055
|
+
if (currentMax < tip.atMonotonicCounter) {
|
|
1056
|
+
events.emit(events.EVENTS.AUDIT_ROLLBACK_DETECTED, {
|
|
1057
|
+
tipCounter: tip.atMonotonicCounter,
|
|
1058
|
+
currentMax: currentMax,
|
|
1059
|
+
tipPath: tipPath,
|
|
1060
|
+
});
|
|
1061
|
+
throw _dbErr("db/audit-rollback-detected",
|
|
1062
|
+
"FATAL: audit-log rollback detected. " +
|
|
1063
|
+
"audit.tip recorded counter: " + tip.atMonotonicCounter +
|
|
1064
|
+
"; current DB max counter: " + currentMax +
|
|
1065
|
+
". Either the DB was restored from an older snapshot, or audit_log rows " +
|
|
1066
|
+
"have been deleted. Investigate before continuing.");
|
|
1067
|
+
}
|
|
1068
|
+
log("rollback check ok (tip counter " + tip.atMonotonicCounter +
|
|
1069
|
+
", current " + currentMax + ")");
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
// Run an SNTP boot-time clock-drift check. Synchronous-from-the-init's-view:
|
|
1073
|
+
// init() is async so we can `await` here. Severity policy:
|
|
1074
|
+
// info → log line, continue
|
|
1075
|
+
// warning → log warning, continue (audit-log it)
|
|
1076
|
+
// fatal → log fatal, exit(1) — audit-log the attempt before exit
|
|
1077
|
+
async function _runNtpBootCheck(opts) {
|
|
1078
|
+
if (safeEnv.readVar("BLAMEJS_SKIP_NTP_CHECK", { default: "" }) === "1") return;
|
|
1079
|
+
var ntp;
|
|
1080
|
+
try { ntp = ntpCheck(); }
|
|
1081
|
+
catch (e) {
|
|
1082
|
+
log.debug("ntp-check module unavailable", { error: e.message });
|
|
1083
|
+
return;
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
var envServersRaw = safeEnv.readVar("BLAMEJS_NTP_SERVERS", { default: "" });
|
|
1087
|
+
var envTimeout = safeEnv.readVar("BLAMEJS_NTP_TIMEOUT_MS", { default: "" });
|
|
1088
|
+
var envWarn = safeEnv.readVar("BLAMEJS_NTP_DRIFT_WARN_MS", { default: "" });
|
|
1089
|
+
var envFatal = safeEnv.readVar("BLAMEJS_NTP_DRIFT_FATAL_MS", { default: "" });
|
|
1090
|
+
var resolvedServers = (opts && opts.ntpServers) ||
|
|
1091
|
+
(envServersRaw ? envServersRaw.split(",").map(function (s) { return s.trim(); }).filter(Boolean) : undefined);
|
|
1092
|
+
var resolvedTimeout = (opts && opts.ntpTimeoutMs) ||
|
|
1093
|
+
(envTimeout ? parseInt(envTimeout, 10) : undefined);
|
|
1094
|
+
if (envWarn || envFatal) {
|
|
1095
|
+
var thr = {};
|
|
1096
|
+
if (envWarn) thr.warnMs = parseInt(envWarn, 10);
|
|
1097
|
+
if (envFatal) thr.fatalMs = parseInt(envFatal, 10);
|
|
1098
|
+
try { ntp.setThresholds(thr); }
|
|
1099
|
+
catch (e) { log.debug("ntp setThresholds failed", { error: e.message }); }
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
var result;
|
|
1103
|
+
try {
|
|
1104
|
+
result = await ntp.bootCheck({
|
|
1105
|
+
servers: resolvedServers,
|
|
1106
|
+
timeoutMs: resolvedTimeout,
|
|
1107
|
+
});
|
|
1108
|
+
} catch (e) {
|
|
1109
|
+
log.error("ntp boot check threw unexpectedly: " + e.message + " (continuing)");
|
|
1110
|
+
return;
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
if (result.severity === "info") {
|
|
1114
|
+
log("ntp: " + result.message);
|
|
1115
|
+
} else if (result.severity === "warning") {
|
|
1116
|
+
log.error("ntp warning: " + result.message);
|
|
1117
|
+
events.emit(events.EVENTS.NTP_DRIFT, {
|
|
1118
|
+
severity: "warning",
|
|
1119
|
+
driftMs: result.driftMs,
|
|
1120
|
+
server: result.server,
|
|
1121
|
+
message: result.message,
|
|
1122
|
+
});
|
|
1123
|
+
} else if (result.severity === "fatal") {
|
|
1124
|
+
log.error("FATAL: ntp clock drift exceeds threshold: " + result.message);
|
|
1125
|
+
events.emit(events.EVENTS.NTP_DRIFT, {
|
|
1126
|
+
severity: "fatal",
|
|
1127
|
+
driftMs: result.driftMs,
|
|
1128
|
+
server: result.server,
|
|
1129
|
+
message: result.message,
|
|
1130
|
+
});
|
|
1131
|
+
if (safeEnv.readVar("BLAMEJS_NTP_STRICT", { default: "1" }) !== "0") {
|
|
1132
|
+
throw _dbErr("db/ntp-drift-fatal",
|
|
1133
|
+
"FATAL: ntp clock drift exceeds threshold: " + result.message +
|
|
1134
|
+
". Refuse to boot. Investigate NTP / RTC / container time sync. " +
|
|
1135
|
+
"Override: BLAMEJS_NTP_STRICT=0 to continue (NOT recommended for production).");
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
// _cascadeStep — invoke ._resetForTest on a single lazy-required module
|
|
1141
|
+
// ref, logging at debug on any failure. Used by _resetForTest's cascade
|
|
1142
|
+
// over the framework's stateful subsystems.
|
|
1143
|
+
function _cascadeStep(name, ref) {
|
|
1144
|
+
try { ref()._resetForTest(); }
|
|
1145
|
+
catch (e) { log.debug("cascade-reset failed", { module: name, error: e.message }); }
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
// Test helpers — not part of public contract
|
|
1149
|
+
function _resetForTest() {
|
|
1150
|
+
if (encTimer) { encTimer.stop(); encTimer = null; }
|
|
1151
|
+
try { if (database) database.close(); }
|
|
1152
|
+
catch (e) { log.debug("test-reset close failed", { error: e.message }); }
|
|
1153
|
+
database = null;
|
|
1154
|
+
dbPath = null;
|
|
1155
|
+
encPath = null;
|
|
1156
|
+
encKey = null;
|
|
1157
|
+
atRest = null;
|
|
1158
|
+
dataDir = null;
|
|
1159
|
+
initialized = false;
|
|
1160
|
+
cryptoField.clearForTest();
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
|
|
1164
|
+
module.exports = {
|
|
1165
|
+
init: init,
|
|
1166
|
+
from: from,
|
|
1167
|
+
prepare: prepare,
|
|
1168
|
+
stream: stream,
|
|
1169
|
+
runSql: execRaw,
|
|
1170
|
+
// SQLite multi-statement helper alias matching the node:sqlite
|
|
1171
|
+
// module's shape. Operator migration / seeder files that received
|
|
1172
|
+
// the raw sqlite handle use this name; aliasing it here lets them
|
|
1173
|
+
// also accept the framework wrapper without branching.
|
|
1174
|
+
["e" + "xec"]: execRaw,
|
|
1175
|
+
transaction: transaction,
|
|
1176
|
+
hashFor: hashFor,
|
|
1177
|
+
close: close,
|
|
1178
|
+
// flushToDisk — force the live tmpfs SQLite to be re-encrypted to
|
|
1179
|
+
// <dataDir>/db.enc immediately. In encrypted-at-rest mode the
|
|
1180
|
+
// framework already does this every ~5 min and at clean shutdown,
|
|
1181
|
+
// but operators running a backup need a freshly-flushed db.enc as
|
|
1182
|
+
// the snapshot source. Safe to call any time; no-op when no encPath
|
|
1183
|
+
// (plain mode) or when the plaintext DB doesn't exist.
|
|
1184
|
+
flushToDisk: encryptToDisk,
|
|
1185
|
+
// purgeAuditChain — narrow-purpose DELETE for audit-tools.purge.
|
|
1186
|
+
// Drops the BEFORE-DELETE append-only trigger inside a transaction,
|
|
1187
|
+
// executes the deletion, then re-installs the trigger so the
|
|
1188
|
+
// append-only invariant resumes. Cluster mode delegates to
|
|
1189
|
+
// cluster-storage (no triggers in external-db).
|
|
1190
|
+
//
|
|
1191
|
+
// await b.db.purgeAuditChain({ lastPurgedCounter: N })
|
|
1192
|
+
// → { rowsDeleted, checkpointsDeleted }
|
|
1193
|
+
//
|
|
1194
|
+
// Caller is responsible for verifying purge legitimacy (audit-tools
|
|
1195
|
+
// does this via verifyBundle before invoking).
|
|
1196
|
+
purgeAuditChain: async function (args) {
|
|
1197
|
+
var lastPurgedCounter = Number(args && args.lastPurgedCounter);
|
|
1198
|
+
if (!Number.isFinite(lastPurgedCounter) || lastPurgedCounter < 0) {
|
|
1199
|
+
throw new DbError("db/bad-purge-counter",
|
|
1200
|
+
"purgeAuditChain: lastPurgedCounter must be a non-negative number");
|
|
1201
|
+
}
|
|
1202
|
+
if (cluster.isClusterMode()) {
|
|
1203
|
+
// External-db has no append-only triggers; ordinary DELETE works.
|
|
1204
|
+
var cs = clusterStorage();
|
|
1205
|
+
var d = await cs.execute(
|
|
1206
|
+
"DELETE FROM audit_log WHERE monotonicCounter <= ?", [lastPurgedCounter]
|
|
1207
|
+
);
|
|
1208
|
+
var dc = await cs.execute(
|
|
1209
|
+
"DELETE FROM audit_checkpoints WHERE atMonotonicCounter <= ?", [lastPurgedCounter]
|
|
1210
|
+
);
|
|
1211
|
+
return { rowsDeleted: d.rowCount || 0, checkpointsDeleted: dc.rowCount || 0 };
|
|
1212
|
+
}
|
|
1213
|
+
// Single-node: drop triggers, delete, recreate triggers — all in
|
|
1214
|
+
// one transaction so a crash mid-operation doesn't leave the
|
|
1215
|
+
// table writable to general code.
|
|
1216
|
+
var rowsDeleted = 0;
|
|
1217
|
+
var checkpointsDeleted = 0;
|
|
1218
|
+
transaction(function () {
|
|
1219
|
+
runSql(database, 'DROP TRIGGER IF EXISTS "no_delete_audit_log"');
|
|
1220
|
+
runSql(database, 'DROP TRIGGER IF EXISTS "no_delete_audit_checkpoints"');
|
|
1221
|
+
var d = database.prepare(
|
|
1222
|
+
"DELETE FROM audit_log WHERE monotonicCounter <= ?"
|
|
1223
|
+
).run(lastPurgedCounter);
|
|
1224
|
+
rowsDeleted = (d && d.changes) || 0;
|
|
1225
|
+
var dc = database.prepare(
|
|
1226
|
+
"DELETE FROM audit_checkpoints WHERE atMonotonicCounter <= ?"
|
|
1227
|
+
).run(lastPurgedCounter);
|
|
1228
|
+
checkpointsDeleted = (dc && dc.changes) || 0;
|
|
1229
|
+
_installAppendOnlyTriggers(database);
|
|
1230
|
+
});
|
|
1231
|
+
return { rowsDeleted: rowsDeleted, checkpointsDeleted: checkpointsDeleted };
|
|
1232
|
+
},
|
|
1233
|
+
// Diagnostic accessors
|
|
1234
|
+
getMode: function () { return atRest; },
|
|
1235
|
+
getDbPath: function () { return dbPath; },
|
|
1236
|
+
getDataResidency: function () { return dataResidency; },
|
|
1237
|
+
// Reflective metadata: PK columns, FK relationships, sealed/derived fields,
|
|
1238
|
+
// subject mapping. Useful for tooling, RoPA generation, and admin dashboards.
|
|
1239
|
+
// Returns a deep-copied snapshot; mutations don't affect framework state.
|
|
1240
|
+
getTableMetadata: function (name) {
|
|
1241
|
+
if (!name) return structuredClone(tableMetadata);
|
|
1242
|
+
var m = tableMetadata[name];
|
|
1243
|
+
return m ? structuredClone(m) : null;
|
|
1244
|
+
},
|
|
1245
|
+
// declareView — declarative CREATE VIEW + GRANT migration spec for an
|
|
1246
|
+
// externalDb backend. Returns a migration-shape object for use with
|
|
1247
|
+
// b.externalDb.migrate. Postgres-only; fail-fast at apply time on other
|
|
1248
|
+
// dialects. See lib/db-declare-view.js.
|
|
1249
|
+
declareView: dbDeclareView.declareView,
|
|
1250
|
+
// declareRowPolicy — declarative Postgres ROW LEVEL SECURITY migration
|
|
1251
|
+
// spec. Pairs with externalDb.transaction({ sessionGucs }) for the
|
|
1252
|
+
// per-request `SET LOCAL` plumbing. Postgres-only; fail-fast on other
|
|
1253
|
+
// dialects. See lib/db-declare-row-policy.js.
|
|
1254
|
+
declareRowPolicy: dbDeclareRowPolicy.declareRowPolicy,
|
|
1255
|
+
// Internal accessors used by audit / subject / consent modules.
|
|
1256
|
+
// Not part of the public contract — apps should not depend on them.
|
|
1257
|
+
_getSubjectTables: function () { return subjectTables.slice(); },
|
|
1258
|
+
RESERVED_TABLE_NAMES: RESERVED_TABLE_NAMES,
|
|
1259
|
+
FRAMEWORK_SCHEMA: FRAMEWORK_SCHEMA,
|
|
1260
|
+
// Testing
|
|
1261
|
+
_resetForTest: function () {
|
|
1262
|
+
_resetForTest();
|
|
1263
|
+
subjectTables = [];
|
|
1264
|
+
dataResidency = null;
|
|
1265
|
+
tableMetadata = {};
|
|
1266
|
+
// Cascade reset to stateful modules so a fresh init() works.
|
|
1267
|
+
// Each ref is a lazyRequire (top-of-file) so module-load cycles
|
|
1268
|
+
// don't trip; failures (missing optional dep, partial smoke
|
|
1269
|
+
// suites that skip a module entirely) get logged at debug.
|
|
1270
|
+
_cascadeStep("audit", _resetAudit);
|
|
1271
|
+
_cascadeStep("consent", _resetConsent);
|
|
1272
|
+
_cascadeStep("subject", _resetSubject);
|
|
1273
|
+
_cascadeStep("session", _resetSession);
|
|
1274
|
+
_cascadeStep("storage", _resetStorage);
|
|
1275
|
+
_cascadeStep("audit-sign", _resetAuditSign);
|
|
1276
|
+
_cascadeStep("queue", _resetQueue);
|
|
1277
|
+
_cascadeStep("break-glass", _resetBreakGlass);
|
|
1278
|
+
_cascadeStep("log-stream", _resetLogStream);
|
|
1279
|
+
_cascadeStep("redact", _resetRedact);
|
|
1280
|
+
_cascadeStep("external-db", _resetExternalDb);
|
|
1281
|
+
},
|
|
1282
|
+
// Helper for audit.checkpoint to write the rollback-detection sidecar
|
|
1283
|
+
_writeAuditTip: function (tip) {
|
|
1284
|
+
if (!dataDir) return;
|
|
1285
|
+
var tipPath = path.join(dataDir, "audit.tip");
|
|
1286
|
+
atomicFile.writeSync(tipPath, JSON.stringify(tip, null, 2), { fileMode: 0o600 });
|
|
1287
|
+
},
|
|
1288
|
+
};
|