@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.
Files changed (180) hide show
  1. package/CHANGELOG.md +423 -395
  2. package/README.md +150 -149
  3. package/bin/blamejs.js +0 -0
  4. package/index.js +308 -284
  5. package/lib/api-key.js +660 -663
  6. package/lib/api-snapshot.js +338 -338
  7. package/lib/app-shutdown.js +385 -385
  8. package/lib/app.js +365 -365
  9. package/lib/archive.js +250 -250
  10. package/lib/atomic-file.js +544 -544
  11. package/lib/audit-chain.js +177 -177
  12. package/lib/audit-sign.js +344 -344
  13. package/lib/audit-tools.js +677 -677
  14. package/lib/audit.js +766 -766
  15. package/lib/auth/jwt.js +311 -311
  16. package/lib/auth/lockout.js +436 -436
  17. package/lib/auth/oauth.js +721 -721
  18. package/lib/auth/passkey.js +181 -181
  19. package/lib/auth/password.js +594 -594
  20. package/lib/backup/bundle.js +217 -217
  21. package/lib/backup/crypto.js +176 -176
  22. package/lib/backup/index.js +515 -515
  23. package/lib/backup/manifest.js +282 -282
  24. package/lib/break-glass.js +1338 -1338
  25. package/lib/bundler.js +441 -441
  26. package/lib/cache-redis.js +256 -256
  27. package/lib/cache.js +1206 -1206
  28. package/lib/canonical-json.js +115 -115
  29. package/lib/chain-writer.js +234 -234
  30. package/lib/cli-helpers.js +206 -206
  31. package/lib/cli.js +2334 -2334
  32. package/lib/cluster-provider-db.js +317 -317
  33. package/lib/cluster-storage.js +226 -226
  34. package/lib/cluster.js +703 -703
  35. package/lib/codepoint-class.js +196 -0
  36. package/lib/config-drift.js +301 -301
  37. package/lib/consent.js +222 -222
  38. package/lib/constants.js +191 -191
  39. package/lib/cookies.js +315 -315
  40. package/lib/credential-hash.js +322 -322
  41. package/lib/crypto.js +266 -266
  42. package/lib/csv.js +275 -286
  43. package/lib/db-declare-row-policy.js +267 -267
  44. package/lib/db-declare-view.js +420 -421
  45. package/lib/db-query.js +406 -406
  46. package/lib/db-schema.js +319 -319
  47. package/lib/db.js +1288 -1288
  48. package/lib/deprecate.js +222 -222
  49. package/lib/dev.js +335 -335
  50. package/lib/dual-control.js +473 -473
  51. package/lib/error-page.js +420 -420
  52. package/lib/external-db-migrate.js +441 -441
  53. package/lib/external-db.js +1061 -1061
  54. package/lib/file-type.js +273 -273
  55. package/lib/file-upload.js +213 -10
  56. package/lib/forms.js +422 -422
  57. package/lib/framework-error.js +293 -215
  58. package/lib/framework-schema.js +717 -717
  59. package/lib/gate-contract.js +971 -0
  60. package/lib/guard-all.js +405 -0
  61. package/lib/guard-archive.js +739 -0
  62. package/lib/guard-csv.js +816 -0
  63. package/lib/guard-email.js +744 -0
  64. package/lib/guard-filename.js +724 -0
  65. package/lib/guard-html.js +976 -0
  66. package/lib/guard-json.js +729 -0
  67. package/lib/guard-markdown.js +586 -0
  68. package/lib/guard-svg.js +976 -0
  69. package/lib/guard-xml.js +405 -0
  70. package/lib/guard-yaml.js +529 -0
  71. package/lib/handlers.js +350 -350
  72. package/lib/http-client-cookie-jar.js +508 -508
  73. package/lib/http-client.js +1195 -1195
  74. package/lib/i18n.js +878 -878
  75. package/lib/jobs.js +185 -185
  76. package/lib/log-stream-cloudwatch.js +369 -369
  77. package/lib/log-stream-local.js +146 -146
  78. package/lib/log-stream-otlp-grpc.js +410 -410
  79. package/lib/log-stream-otlp.js +286 -286
  80. package/lib/log-stream-syslog.js +302 -302
  81. package/lib/log-stream-webhook.js +199 -199
  82. package/lib/log-stream.js +330 -330
  83. package/lib/log.js +500 -500
  84. package/lib/mail-bounce.js +528 -528
  85. package/lib/mail-dkim.js +369 -362
  86. package/lib/mail.js +981 -962
  87. package/lib/metrics.js +683 -683
  88. package/lib/middleware/api-encrypt.js +936 -936
  89. package/lib/middleware/attach-user.js +157 -157
  90. package/lib/middleware/body-parser.js +1170 -1091
  91. package/lib/middleware/bot-guard.js +178 -178
  92. package/lib/middleware/compression.js +452 -452
  93. package/lib/middleware/cors.js +314 -314
  94. package/lib/middleware/csp-nonce.js +348 -348
  95. package/lib/middleware/csrf-protect.js +316 -316
  96. package/lib/middleware/db-role-for.js +264 -264
  97. package/lib/middleware/health.js +392 -392
  98. package/lib/middleware/index.js +79 -79
  99. package/lib/middleware/rate-limit.js +358 -358
  100. package/lib/middleware/request-id.js +61 -61
  101. package/lib/middleware/request-log.js +168 -168
  102. package/lib/middleware/require-auth.js +104 -104
  103. package/lib/middleware/security-headers.js +116 -116
  104. package/lib/middleware/sse.js +166 -166
  105. package/lib/migrations.js +383 -383
  106. package/lib/mtls-ca.js +518 -518
  107. package/lib/mtls-engine-default.js +481 -481
  108. package/lib/network-dns.js +632 -632
  109. package/lib/network-heartbeat.js +290 -290
  110. package/lib/network-nts.js +574 -574
  111. package/lib/network-proxy.js +265 -265
  112. package/lib/network-tls.js +328 -328
  113. package/lib/network.js +233 -233
  114. package/lib/notify.js +612 -612
  115. package/lib/ntp-check.js +229 -229
  116. package/lib/numeric-bounds.js +111 -91
  117. package/lib/object-store/azure-blob-bucket-ops.js +349 -349
  118. package/lib/object-store/azure-blob.js +488 -488
  119. package/lib/object-store/gcs-bucket-ops.js +351 -351
  120. package/lib/object-store/gcs.js +519 -519
  121. package/lib/object-store/http-put.js +153 -153
  122. package/lib/object-store/index.js +197 -197
  123. package/lib/object-store/sigv4-bucket-ops.js +1092 -1092
  124. package/lib/object-store/sigv4.js +903 -903
  125. package/lib/observability.js +151 -151
  126. package/lib/otel-export.js +269 -269
  127. package/lib/pagination.js +464 -464
  128. package/lib/parsers/index.js +80 -80
  129. package/lib/parsers/safe-env.js +642 -642
  130. package/lib/parsers/safe-ini.js +292 -292
  131. package/lib/parsers/safe-toml.js +784 -784
  132. package/lib/parsers/safe-xml.js +390 -390
  133. package/lib/parsers/safe-yaml.js +1015 -1015
  134. package/lib/permissions.js +708 -708
  135. package/lib/pqc-agent.js +87 -87
  136. package/lib/pqc-gate.js +279 -279
  137. package/lib/protobuf-encoder.js +190 -190
  138. package/lib/protocol-dispatcher.js +161 -161
  139. package/lib/pubsub-redis.js +167 -167
  140. package/lib/pubsub.js +429 -429
  141. package/lib/queue-local.js +476 -476
  142. package/lib/queue-redis.js +745 -745
  143. package/lib/queue-sqs.js +319 -319
  144. package/lib/queue.js +695 -695
  145. package/lib/redis-client.js +519 -519
  146. package/lib/request-helpers.js +340 -340
  147. package/lib/restore-bundle.js +237 -237
  148. package/lib/restore-rollback.js +259 -259
  149. package/lib/restore.js +409 -409
  150. package/lib/retry.js +376 -376
  151. package/lib/router.js +748 -748
  152. package/lib/safe-async.js +735 -735
  153. package/lib/safe-buffer.js +237 -237
  154. package/lib/safe-json.js +541 -541
  155. package/lib/safe-schema.js +1266 -1266
  156. package/lib/safe-url.js +159 -159
  157. package/lib/scheduler.js +706 -706
  158. package/lib/security-assert.js +373 -373
  159. package/lib/seeders.js +618 -618
  160. package/lib/session.js +478 -478
  161. package/lib/slug.js +269 -269
  162. package/lib/ssrf-guard.js +401 -401
  163. package/lib/static.js +184 -4
  164. package/lib/storage.js +471 -471
  165. package/lib/subject.js +281 -281
  166. package/lib/template.js +791 -791
  167. package/lib/testing.js +798 -798
  168. package/lib/time.js +310 -310
  169. package/lib/totp.js +302 -302
  170. package/lib/tracing.js +494 -494
  171. package/lib/uuid.js +132 -132
  172. package/lib/validate-opts.js +340 -319
  173. package/lib/vault/index.js +308 -308
  174. package/lib/vault/rotate.js +784 -784
  175. package/lib/vault/wrap.js +296 -296
  176. package/lib/vendor/noble-ciphers.cjs +9 -9
  177. package/lib/webhook.js +595 -595
  178. package/lib/websocket.js +1048 -1048
  179. package/package.json +77 -77
  180. 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
+ };