@blamejs/core 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (160) hide show
  1. package/CHANGELOG.md +230 -0
  2. package/LICENSE +201 -0
  3. package/LTS-CALENDAR.md +29 -0
  4. package/MIGRATING.md +7 -0
  5. package/NOTICE +59 -0
  6. package/README.md +100 -0
  7. package/bin/blamejs.js +13 -0
  8. package/index.js +253 -0
  9. package/lib/api-key.js +705 -0
  10. package/lib/api-snapshot.js +335 -0
  11. package/lib/app-shutdown.js +381 -0
  12. package/lib/app.js +364 -0
  13. package/lib/atomic-file.js +525 -0
  14. package/lib/audit-chain.js +168 -0
  15. package/lib/audit-sign.js +319 -0
  16. package/lib/audit-tools.js +682 -0
  17. package/lib/audit.js +753 -0
  18. package/lib/auth/jwt.js +280 -0
  19. package/lib/auth/oauth.js +691 -0
  20. package/lib/auth/passkey.js +185 -0
  21. package/lib/auth/password.js +139 -0
  22. package/lib/auth/totp.js +17 -0
  23. package/lib/auth-header.js +81 -0
  24. package/lib/backup/bundle.js +219 -0
  25. package/lib/backup/crypto.js +174 -0
  26. package/lib/backup/index.js +490 -0
  27. package/lib/backup/manifest.js +275 -0
  28. package/lib/bundler.js +295 -0
  29. package/lib/cache.js +819 -0
  30. package/lib/chain-writer.js +234 -0
  31. package/lib/cli-helpers.js +201 -0
  32. package/lib/cli.js +1377 -0
  33. package/lib/cluster-provider-db.js +245 -0
  34. package/lib/cluster-storage.js +166 -0
  35. package/lib/cluster.js +691 -0
  36. package/lib/consent.js +222 -0
  37. package/lib/constants.js +186 -0
  38. package/lib/cookies.js +293 -0
  39. package/lib/credential-hash.js +303 -0
  40. package/lib/crypto-field.js +159 -0
  41. package/lib/crypto.js +250 -0
  42. package/lib/db-query.js +297 -0
  43. package/lib/db-schema.js +250 -0
  44. package/lib/db.js +1054 -0
  45. package/lib/deprecate.js +226 -0
  46. package/lib/dev.js +324 -0
  47. package/lib/error-page.js +424 -0
  48. package/lib/events.js +135 -0
  49. package/lib/external-db.js +422 -0
  50. package/lib/forms.js +378 -0
  51. package/lib/framework-error.js +189 -0
  52. package/lib/framework-schema.js +604 -0
  53. package/lib/handlers.js +350 -0
  54. package/lib/html-balance.js +227 -0
  55. package/lib/http-client.js +615 -0
  56. package/lib/i18n.js +780 -0
  57. package/lib/jobs.js +181 -0
  58. package/lib/lazy-require.js +48 -0
  59. package/lib/log-stream-local.js +137 -0
  60. package/lib/log-stream-webhook.js +170 -0
  61. package/lib/log-stream.js +211 -0
  62. package/lib/log.js +355 -0
  63. package/lib/mail-bounce.js +507 -0
  64. package/lib/mail.js +701 -0
  65. package/lib/metrics.js +647 -0
  66. package/lib/middleware/api-encrypt.js +553 -0
  67. package/lib/middleware/attach-user.js +156 -0
  68. package/lib/middleware/body-parser.js +883 -0
  69. package/lib/middleware/bot-guard.js +148 -0
  70. package/lib/middleware/compression.js +436 -0
  71. package/lib/middleware/cors.js +236 -0
  72. package/lib/middleware/csp-nonce.js +332 -0
  73. package/lib/middleware/csrf-protect.js +275 -0
  74. package/lib/middleware/error-handler.js +46 -0
  75. package/lib/middleware/health.js +358 -0
  76. package/lib/middleware/index.js +52 -0
  77. package/lib/middleware/rate-limit.js +319 -0
  78. package/lib/middleware/request-id.js +53 -0
  79. package/lib/middleware/require-auth.js +95 -0
  80. package/lib/middleware/security-headers.js +91 -0
  81. package/lib/migrations.js +353 -0
  82. package/lib/mtls-ca.js +333 -0
  83. package/lib/mtls-engine-default.js +285 -0
  84. package/lib/nonce-store.js +177 -0
  85. package/lib/notify.js +643 -0
  86. package/lib/ntp-check.js +178 -0
  87. package/lib/object-store/azure-blob.js +467 -0
  88. package/lib/object-store/gcs.js +469 -0
  89. package/lib/object-store/http-put.js +153 -0
  90. package/lib/object-store/index.js +140 -0
  91. package/lib/object-store/local.js +163 -0
  92. package/lib/object-store/retry.js +15 -0
  93. package/lib/object-store/sigv4.js +535 -0
  94. package/lib/observability.js +114 -0
  95. package/lib/pagination.js +371 -0
  96. package/lib/parsers/index.js +64 -0
  97. package/lib/parsers/safe-csv.js +224 -0
  98. package/lib/parsers/safe-env.js +614 -0
  99. package/lib/parsers/safe-toml.js +745 -0
  100. package/lib/parsers/safe-xml.js +379 -0
  101. package/lib/parsers/safe-yaml.js +977 -0
  102. package/lib/permissions.js +430 -0
  103. package/lib/pqc-agent.js +85 -0
  104. package/lib/pqc-gate.js +266 -0
  105. package/lib/protocol-dispatcher.js +144 -0
  106. package/lib/queue-local.js +327 -0
  107. package/lib/queue.js +430 -0
  108. package/lib/redact.js +192 -0
  109. package/lib/render.js +193 -0
  110. package/lib/request-helpers.js +178 -0
  111. package/lib/restore-bundle.js +239 -0
  112. package/lib/restore-rollback.js +254 -0
  113. package/lib/restore.js +301 -0
  114. package/lib/retry.js +329 -0
  115. package/lib/router.js +437 -0
  116. package/lib/safe-async.js +520 -0
  117. package/lib/safe-buffer.js +162 -0
  118. package/lib/safe-json.js +532 -0
  119. package/lib/safe-schema.js +1176 -0
  120. package/lib/safe-sql.js +157 -0
  121. package/lib/safe-url.js +109 -0
  122. package/lib/scheduler.js +680 -0
  123. package/lib/seeders.js +622 -0
  124. package/lib/session.js +304 -0
  125. package/lib/slug.js +243 -0
  126. package/lib/static.js +268 -0
  127. package/lib/storage.js +470 -0
  128. package/lib/subject.js +281 -0
  129. package/lib/template.js +781 -0
  130. package/lib/testing.js +621 -0
  131. package/lib/totp.js +285 -0
  132. package/lib/tracing.js +484 -0
  133. package/lib/validate-opts.js +56 -0
  134. package/lib/vault/index.js +299 -0
  135. package/lib/vault/passphrase-ops.js +311 -0
  136. package/lib/vault/passphrase-source.js +198 -0
  137. package/lib/vault/rotate.js +761 -0
  138. package/lib/vault/wrap.js +289 -0
  139. package/lib/vendor/MANIFEST.json +84 -0
  140. package/lib/vendor/argon2/argon2.cjs +466 -0
  141. package/lib/vendor/argon2/argon2.d.cts +62 -0
  142. package/lib/vendor/argon2/package.json +1 -0
  143. package/lib/vendor/argon2/prebuilds/darwin-arm64/argon2.armv8.glibc.node +0 -0
  144. package/lib/vendor/argon2/prebuilds/darwin-x64/argon2.glibc.node +0 -0
  145. package/lib/vendor/argon2/prebuilds/freebsd-arm64/argon2.armv8.glibc.node +0 -0
  146. package/lib/vendor/argon2/prebuilds/freebsd-x64/argon2.glibc.node +0 -0
  147. package/lib/vendor/argon2/prebuilds/linux-arm/argon2.armv7.glibc.node +0 -0
  148. package/lib/vendor/argon2/prebuilds/linux-arm/argon2.armv7.musl.node +0 -0
  149. package/lib/vendor/argon2/prebuilds/linux-arm64/argon2.armv8.glibc.node +0 -0
  150. package/lib/vendor/argon2/prebuilds/linux-arm64/argon2.armv8.musl.node +0 -0
  151. package/lib/vendor/argon2/prebuilds/linux-x64/argon2.glibc.node +0 -0
  152. package/lib/vendor/argon2/prebuilds/linux-x64/argon2.musl.node +0 -0
  153. package/lib/vendor/argon2/prebuilds/win32-x64/argon2.glibc.node +0 -0
  154. package/lib/vendor/noble-ciphers.cjs +9 -0
  155. package/lib/vendor/pki.cjs +181 -0
  156. package/lib/vendor/simplewebauthn-server.cjs +328 -0
  157. package/lib/webhook.js +632 -0
  158. package/lib/websocket-channels.js +413 -0
  159. package/lib/websocket.js +833 -0
  160. package/package.json +39 -0
package/lib/db.js ADDED
@@ -0,0 +1,1054 @@
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.runSql(sql) → raw SQL execution (DDL, BEGIN/COMMIT)
36
+ * db.transaction(function (db) {…}) → wraps in BEGIN/COMMIT/ROLLBACK
37
+ * db.hashFor(table, field, value) → derived-hash lookup helper
38
+ * db.close() → final encrypt + close (idempotent)
39
+ */
40
+ var fs = require("fs");
41
+ var path = require("path");
42
+ var { DatabaseSync } = require("node:sqlite");
43
+ var atomicFile = require("./atomic-file");
44
+ var audit = require("./audit");
45
+ var auditSign = require("./audit-sign");
46
+ var cluster = require("./cluster");
47
+ var events = require("./events");
48
+ var consent = require("./consent");
49
+ var C = require("./constants");
50
+ var { generateToken, generateBytes, encryptPacked, decryptPacked } = require("./crypto");
51
+ var cryptoField = require("./crypto-field");
52
+ var { Query } = require("./db-query");
53
+ var dbSchema = require("./db-schema");
54
+ var { boot } = require("./log");
55
+ var safeEnv = require("./parsers/safe-env");
56
+ var safeJson = require("./safe-json");
57
+ var vault = require("./vault");
58
+
59
+ var AUDIT_TIP_SCHEMA = {
60
+ type: "object",
61
+ required: ["atMonotonicCounter"],
62
+ properties: {
63
+ atMonotonicCounter: { type: "number" },
64
+ rowHash: { type: "string" },
65
+ signedAt: { type: "string" },
66
+ },
67
+ };
68
+
69
+ var runSql = dbSchema.runSql;
70
+
71
+ // Module-local state, populated by init()
72
+ var database = null; // the SQLite handle
73
+ var dbPath = null; // plaintext DB file path (tmpfs in encrypted mode, dataDir/db in plain mode)
74
+ var encPath = null; // encrypted-at-rest path (null in plain mode)
75
+ var encKey = null; // 32-byte DB encryption key (null in plain mode)
76
+ var encTimer = null; // periodic encrypt interval handle
77
+ var atRest = null; // 'encrypted' or 'plain'
78
+ var dataDir = null;
79
+ var initialized = false;
80
+ var dataResidency = null; // operator's declared region config (validated by storage backends)
81
+ var subjectTables = []; // [{ name, subjectField, personalDataCategories }] — for subject.export/erase
82
+ var tableMetadata = {}; // table name → metadata snapshot (PK/FK/sealed/derived) for getTableMetadata
83
+
84
+ // ---- Framework-baked tables ----
85
+ //
86
+ // audit_log + consent_log + _blamejs_subject_restrictions + _blamejs_subject_erasures
87
+ // are provisioned by the framework before app schema reconciles. Apps cannot
88
+ // opt out, override, or rename them. An app schema entry colliding with any of
89
+ // these names is refused at init.
90
+ var RESERVED_TABLE_NAMES = new Set([
91
+ "audit_log",
92
+ "audit_checkpoints",
93
+ "consent_log",
94
+ "_blamejs_subject_restrictions",
95
+ "_blamejs_subject_erasures",
96
+ "_blamejs_sessions",
97
+ "_blamejs_jobs",
98
+ "_blamejs_migrations",
99
+ "_blamejs_counters",
100
+ "_blamejs_audit_purge_anchor",
101
+ "_blamejs_scheduler_ticks",
102
+ "_blamejs_rate_limit_counters",
103
+ "_blamejs_ws_messages",
104
+ "_blamejs_api_encrypt_nonces",
105
+ "_blamejs_api_keys",
106
+ "_blamejs_cache",
107
+ "_blamejs_seeders",
108
+ "_blamejs_seeders_lock",
109
+ ]);
110
+
111
+ var FRAMEWORK_SCHEMA = [
112
+ {
113
+ name: "audit_log",
114
+ columns: {
115
+ _id: "TEXT PRIMARY KEY",
116
+ recordedAt: "INTEGER NOT NULL",
117
+ monotonicCounter: "INTEGER NOT NULL",
118
+ actorUserId: "TEXT",
119
+ actorUserIdHash: "TEXT",
120
+ actorIp: "TEXT",
121
+ actorUserAgent: "TEXT",
122
+ actorSessionId: "TEXT",
123
+ action: "TEXT NOT NULL",
124
+ resourceKind: "TEXT",
125
+ resourceId: "TEXT",
126
+ resourceIdHash: "TEXT",
127
+ outcome: "TEXT NOT NULL",
128
+ reason: "TEXT",
129
+ metadata: "TEXT",
130
+ requestId: "TEXT",
131
+ prevHash: "TEXT NOT NULL",
132
+ rowHash: "TEXT NOT NULL",
133
+ nonce: "BLOB NOT NULL",
134
+ fencingToken: "INTEGER NOT NULL DEFAULT 0",
135
+ },
136
+ indexes: [
137
+ "actorUserIdHash", "resourceIdHash", "recordedAt", "action",
138
+ { name: "idx_audit_monotonic", columns: "monotonicCounter", unique: true },
139
+ ],
140
+ sealedFields: ["actorUserId", "actorIp", "actorUserAgent", "actorSessionId", "resourceId", "reason", "metadata"],
141
+ derivedHashes: {
142
+ actorUserIdHash: { from: "actorUserId" },
143
+ resourceIdHash: { from: "resourceId" },
144
+ },
145
+ },
146
+ {
147
+ name: "consent_log",
148
+ columns: {
149
+ _id: "TEXT PRIMARY KEY",
150
+ recordedAt: "INTEGER NOT NULL",
151
+ monotonicCounter: "INTEGER NOT NULL",
152
+ subjectId: "TEXT NOT NULL",
153
+ subjectIdHash: "TEXT NOT NULL",
154
+ purpose: "TEXT NOT NULL",
155
+ lawfulBasis: "TEXT NOT NULL",
156
+ action: "TEXT NOT NULL",
157
+ scope: "TEXT",
158
+ channel: "TEXT NOT NULL",
159
+ evidenceRef: "TEXT",
160
+ prevHash: "TEXT NOT NULL",
161
+ rowHash: "TEXT NOT NULL",
162
+ nonce: "BLOB NOT NULL",
163
+ fencingToken: "INTEGER NOT NULL DEFAULT 0",
164
+ },
165
+ indexes: [
166
+ "subjectIdHash", "recordedAt", "purpose",
167
+ { name: "idx_consent_monotonic", columns: "monotonicCounter", unique: true },
168
+ ],
169
+ sealedFields: ["subjectId", "scope", "evidenceRef"],
170
+ derivedHashes: {
171
+ subjectIdHash: { from: "subjectId" },
172
+ },
173
+ },
174
+ {
175
+ name: "_blamejs_subject_restrictions",
176
+ columns: {
177
+ subjectIdHash: "TEXT PRIMARY KEY",
178
+ since: "INTEGER NOT NULL",
179
+ reason: "TEXT",
180
+ },
181
+ sealedFields: ["reason"],
182
+ },
183
+ {
184
+ name: "_blamejs_subject_erasures",
185
+ columns: {
186
+ subjectIdHash: "TEXT PRIMARY KEY",
187
+ erasedAt: "INTEGER NOT NULL",
188
+ },
189
+ },
190
+ {
191
+ name: "audit_checkpoints",
192
+ columns: {
193
+ _id: "TEXT PRIMARY KEY",
194
+ createdAt: "INTEGER NOT NULL",
195
+ atMonotonicCounter: "INTEGER NOT NULL",
196
+ atRowHash: "TEXT NOT NULL",
197
+ signature: "BLOB NOT NULL",
198
+ publicKeyFingerprint: "TEXT NOT NULL",
199
+ fencingToken: "INTEGER NOT NULL DEFAULT 0",
200
+ },
201
+ indexes: [
202
+ "createdAt",
203
+ { name: "idx_chkpt_counter", columns: "atMonotonicCounter", unique: true },
204
+ ],
205
+ sealedFields: [],
206
+ },
207
+ {
208
+ name: "_blamejs_audit_purge_anchor",
209
+ columns: {
210
+ scope: "TEXT PRIMARY KEY",
211
+ lastPurgedCounter: "INTEGER NOT NULL",
212
+ lastPurgedRowHash: "TEXT NOT NULL",
213
+ archiveBundleId: "TEXT NOT NULL",
214
+ purgedAt: "INTEGER NOT NULL",
215
+ },
216
+ sealedFields: [],
217
+ },
218
+ {
219
+ // Scheduler exactly-once-globally claim table. Each fire claims a
220
+ // (name, scheduledAtUnix) row before dispatching; UNIQUE on the
221
+ // composite tickKey (name + ":" + scheduledAtUnix) means a concurrent
222
+ // leader's INSERT loses with a constraint violation, and that node
223
+ // skips the tick. Closes the once-globally gap during cluster
224
+ // leader hand-offs where two leaders briefly coexist.
225
+ name: "_blamejs_scheduler_ticks",
226
+ columns: {
227
+ tickKey: "TEXT PRIMARY KEY",
228
+ name: "TEXT NOT NULL",
229
+ scheduledAtUnix: "INTEGER NOT NULL",
230
+ claimedAtUnix: "INTEGER NOT NULL",
231
+ claimedBy: "TEXT",
232
+ },
233
+ indexes: ["scheduledAtUnix"],
234
+ sealedFields: [],
235
+ },
236
+ {
237
+ // _blamejs_rate_limit_counters — fixed-window counter table for
238
+ // the cluster-shared rate-limit backend. One row per (key); the
239
+ // count rolls over atomically when the windowStart advances. Used
240
+ // by lib/middleware/rate-limit.js when scope: 'cluster' is set.
241
+ name: "_blamejs_rate_limit_counters",
242
+ columns: {
243
+ key: "TEXT PRIMARY KEY",
244
+ windowStart: "INTEGER NOT NULL",
245
+ count: "INTEGER NOT NULL DEFAULT 0",
246
+ },
247
+ indexes: ["windowStart"],
248
+ sealedFields: [],
249
+ },
250
+ {
251
+ // _blamejs_ws_messages — cluster fan-out for the WebSocket
252
+ // channel hub. publish() on any node writes a row here; the
253
+ // other nodes poll for new ids and dispatch to their local
254
+ // subscribers. Rows older than the configured retention window
255
+ // are pruned by the hub on a rate-limited basis.
256
+ name: "_blamejs_ws_messages",
257
+ columns: {
258
+ id: "INTEGER PRIMARY KEY AUTOINCREMENT",
259
+ channel: "TEXT NOT NULL",
260
+ payload: "TEXT NOT NULL",
261
+ publishedAt: "INTEGER NOT NULL",
262
+ publishedBy: "TEXT NOT NULL",
263
+ },
264
+ indexes: ["publishedAt"],
265
+ sealedFields: [],
266
+ },
267
+ {
268
+ // _blamejs_api_encrypt_nonces — replay-protection store for the
269
+ // api-encrypt middleware. The middleware hashes the client-supplied
270
+ // nonce via SHA3 before insert so a leaked DB / table dump never
271
+ // exposes the original 16-byte client nonces. Hashing is
272
+ // deterministic so the PRIMARY KEY conflict is what catches a
273
+ // replay attempt within the replay window.
274
+ name: "_blamejs_api_encrypt_nonces",
275
+ columns: {
276
+ nonceHash: "TEXT PRIMARY KEY",
277
+ expireAt: "INTEGER NOT NULL",
278
+ },
279
+ indexes: ["expireAt"],
280
+ sealedFields: [],
281
+ },
282
+ {
283
+ name: "_blamejs_sessions",
284
+ columns: {
285
+ sidHash: "TEXT PRIMARY KEY",
286
+ userId: "TEXT NOT NULL",
287
+ userIdHash: "TEXT NOT NULL",
288
+ data: "TEXT",
289
+ createdAt: "INTEGER NOT NULL",
290
+ expiresAt: "INTEGER NOT NULL",
291
+ lastActivity: "INTEGER NOT NULL",
292
+ },
293
+ indexes: ["userIdHash", "expiresAt"],
294
+ sealedFields: ["userId", "data"],
295
+ derivedHashes: { userIdHash: { from: "userId" } },
296
+ },
297
+ {
298
+ // _blamejs_api_keys — operator-facing API-key registry. Sealed
299
+ // columns: ownerId / scopes / metadata. The secret never lands
300
+ // here — only its SHA3-512 hash, constant-time-compared on
301
+ // verify. Same dual-storage pattern as sessions: this row mirrors
302
+ // the cluster-mode DDL in framework-schema.js so cluster-storage
303
+ // can route to either backend transparently.
304
+ name: "_blamejs_api_keys",
305
+ columns: {
306
+ id: "TEXT PRIMARY KEY",
307
+ namespace: "TEXT NOT NULL",
308
+ ownerId: "TEXT NOT NULL",
309
+ ownerIdHash: "TEXT NOT NULL",
310
+ secretHash: "TEXT NOT NULL",
311
+ // secondarySecretHash + secondaryExpiresAt support graceful key
312
+ // rotation: when rotate({ gracePeriodMs }) is called the old hash
313
+ // is preserved here and the new hash takes the primary slot. Both
314
+ // verify successfully until secondaryExpiresAt, then the old slot
315
+ // is implicitly retired.
316
+ secondarySecretHash: "TEXT",
317
+ secondaryExpiresAt: "INTEGER",
318
+ scopes: "TEXT",
319
+ metadata: "TEXT",
320
+ createdAt: "INTEGER NOT NULL",
321
+ expiresAt: "INTEGER",
322
+ revokedAt: "INTEGER",
323
+ lastUsedAt: "INTEGER",
324
+ prefix: "TEXT NOT NULL",
325
+ },
326
+ indexes: [
327
+ "ownerIdHash",
328
+ { name: "idx_api_keys_namespace_owner", columns: ["namespace", "ownerIdHash"] },
329
+ "expiresAt",
330
+ ],
331
+ sealedFields: ["ownerId", "scopes", "metadata"],
332
+ derivedHashes: { ownerIdHash: { from: "ownerId" } },
333
+ },
334
+ {
335
+ name: "_blamejs_jobs",
336
+ columns: {
337
+ _id: "TEXT PRIMARY KEY",
338
+ queueName: "TEXT NOT NULL",
339
+ payload: "TEXT",
340
+ status: "TEXT NOT NULL",
341
+ enqueuedAt: "INTEGER NOT NULL",
342
+ availableAt: "INTEGER NOT NULL",
343
+ leasedAt: "INTEGER",
344
+ leaseExpiresAt: "INTEGER",
345
+ attempts: "INTEGER NOT NULL DEFAULT 0",
346
+ maxAttempts: "INTEGER NOT NULL DEFAULT 5",
347
+ lastError: "TEXT",
348
+ finishedAt: "INTEGER",
349
+ traceId: "TEXT",
350
+ classification: "TEXT",
351
+ },
352
+ indexes: [
353
+ { name: "idx_jobs_lease", columns: ["queueName", "status", "availableAt"] },
354
+ "leaseExpiresAt",
355
+ "finishedAt",
356
+ ],
357
+ sealedFields: ["payload", "lastError"],
358
+ },
359
+ {
360
+ // _blamejs_cache — operator-facing cache primitive's cluster backend
361
+ // (lib/cache.js). Mirrors the cluster-mode DDL in framework-schema.js.
362
+ // PRIMARY KEY is the composite "<namespace>:<key>"; valueJson is
363
+ // JSON-serialized; expiresAt is unix-ms (Number.MAX_SAFE_INTEGER for
364
+ // never-expiring entries). Not sealed: cache values are operator-
365
+ // chosen application data, the operator decides what's worth storing.
366
+ name: "_blamejs_cache",
367
+ columns: {
368
+ cacheKey: "TEXT PRIMARY KEY",
369
+ valueJson: "TEXT NOT NULL",
370
+ expiresAt: "INTEGER NOT NULL",
371
+ updatedAt: "INTEGER NOT NULL",
372
+ },
373
+ indexes: ["expiresAt"],
374
+ sealedFields: [],
375
+ },
376
+ {
377
+ // _blamejs_seeders — registry of applied seed files for the
378
+ // b.seeders primitive (lib/seeders.js). Composite PK (env, name)
379
+ // means the same filename can apply per env (dev fixtures don't
380
+ // collide with prod fixtures by name). rerunnable=1 entries get
381
+ // their appliedAt updated in place on every run; non-rerunnable
382
+ // entries are insert-once.
383
+ name: "_blamejs_seeders",
384
+ columns: {
385
+ env: "TEXT NOT NULL",
386
+ name: "TEXT NOT NULL",
387
+ description: "TEXT",
388
+ appliedAt: "TEXT NOT NULL",
389
+ rerunnable: "INTEGER NOT NULL DEFAULT 0",
390
+ },
391
+ primaryKey: ["env", "name"],
392
+ indexes: [],
393
+ sealedFields: [],
394
+ },
395
+ {
396
+ // _blamejs_seeders_lock — single-row advisory lock for the seeders
397
+ // runner. Same shape as _blamejs_migrations_lock (CHECK constraint
398
+ // on scope='lock' enforces single row). Two processes calling
399
+ // `seed run` against the same DB race on this PK; loser sees a
400
+ // clear "lock held" error.
401
+ name: "_blamejs_seeders_lock",
402
+ columns: {
403
+ scope: "TEXT PRIMARY KEY CHECK (scope = 'lock')",
404
+ lockedAt: "INTEGER NOT NULL",
405
+ lockedBy: "TEXT NOT NULL",
406
+ },
407
+ sealedFields: [],
408
+ },
409
+ ];
410
+
411
+ var log = boot("db");
412
+
413
+ // ---- Tmpfs detection ----
414
+
415
+ function resolveTmpDir(optsTmpDir) {
416
+ if (optsTmpDir) return optsTmpDir;
417
+ var envTmp = safeEnv.readVar("BLAMEJS_TMPDIR");
418
+ if (envTmp) return envTmp;
419
+ if (fs.existsSync("/dev/shm")) return "/dev/shm";
420
+ return null;
421
+ }
422
+
423
+ // ---- DB encryption key management ----
424
+
425
+ function loadOrCreateDbKey(dataDirPath) {
426
+ var keyPath = path.join(dataDirPath, "db.key.enc");
427
+ if (fs.existsSync(keyPath)) {
428
+ var sealed = atomicFile.readSync(keyPath, { encoding: "utf8" }).trim();
429
+ var b64 = vault.unseal(sealed);
430
+ if (!b64) {
431
+ log.error("FATAL: db.key.enc unseal returned empty — vault may not be initialized or key file corrupted");
432
+ process.exit(1);
433
+ }
434
+ return Buffer.from(b64, "base64");
435
+ }
436
+ // First run — generate, seal, persist (atomic)
437
+ var raw = generateBytes(32);
438
+ var sealedKey = vault.seal(raw.toString("base64"));
439
+ atomicFile.writeSync(keyPath, sealedKey, { fileMode: 0o600 });
440
+ log("generated DB encryption key at " + keyPath);
441
+ return raw;
442
+ }
443
+
444
+ function decryptToTmp() {
445
+ if (!encPath || !fs.existsSync(encPath)) return;
446
+ // If a plaintext file already exists in tmpfs from a prior process, prefer
447
+ // the newer mtime (crash recovery — operator's most recent state wins).
448
+ if (fs.existsSync(dbPath)) {
449
+ var plainStat = fs.statSync(dbPath);
450
+ var encStat = fs.statSync(encPath);
451
+ if (plainStat.mtimeMs > encStat.mtimeMs && plainStat.size > 0) {
452
+ log("plaintext is newer than encrypted — keeping plaintext (crash recovery)");
453
+ return;
454
+ }
455
+ }
456
+ var packed = fs.readFileSync(encPath);
457
+ if (packed.length < 26) return; // too short to be a valid envelope
458
+ atomicFile.writeSync(dbPath, decryptPacked(packed, encKey));
459
+ }
460
+
461
+ function encryptToDisk() {
462
+ if (!encPath) return;
463
+ // Force WAL checkpoint so the .db file holds all committed transactions.
464
+ try { runSql(database, "PRAGMA wal_checkpoint(TRUNCATE)"); } catch (_e) { /* best effort */ }
465
+ if (!fs.existsSync(dbPath)) return;
466
+ atomicFile.writeSync(encPath, encryptPacked(fs.readFileSync(dbPath), encKey));
467
+ }
468
+
469
+ // Remove the plaintext DB + WAL/SHM sidecar files. On Windows these can't be
470
+ // unlinked while the SQLite handle is open, so this MUST be called after
471
+ // database.close().
472
+ function removePlaintextFiles() {
473
+ if (!dbPath) return;
474
+ try { fs.unlinkSync(dbPath); } catch (_e) { /* cleanup */ }
475
+ try { fs.unlinkSync(dbPath + "-wal"); } catch (_e) { /* cleanup */ }
476
+ try { fs.unlinkSync(dbPath + "-shm"); } catch (_e) { /* cleanup */ }
477
+ }
478
+
479
+ // Clean up stale plaintext DB files left by previously-crashed processes.
480
+ // Anything matching blamejs-*.db that isn't our current process's file is
481
+ // stale (no other process should write to /dev/shm with our prefix).
482
+ function cleanStaleTmpDbs(tmpDir) {
483
+ var entries = atomicFile.listDir(tmpDir, {
484
+ filter: function (name) { return name.startsWith("blamejs-") && name.endsWith(".db"); },
485
+ });
486
+ for (var i = 0; i < entries.length; i++) {
487
+ var full = entries[i].fullPath;
488
+ if (full === dbPath) continue;
489
+ try { fs.unlinkSync(full); } catch (_e) { /* concurrent cleanup */ }
490
+ try { fs.unlinkSync(full + "-wal"); } catch (_e) { /* may not exist */ }
491
+ try { fs.unlinkSync(full + "-shm"); } catch (_e) { /* may not exist */ }
492
+ }
493
+ }
494
+
495
+ // ---- Init dispatch ----
496
+
497
+ async function init(opts) {
498
+ if (initialized) return;
499
+ if (!opts || !opts.dataDir) {
500
+ throw new Error("db.init({ dataDir }) is required");
501
+ }
502
+ if (!Array.isArray(opts.schema)) {
503
+ throw new Error("db.init({ schema }) must be an array of table definitions");
504
+ }
505
+
506
+ atRest = (opts.atRest || "encrypted").toLowerCase();
507
+ if (atRest !== "encrypted" && atRest !== "plain") {
508
+ throw new Error("db.init: atRest must be 'encrypted' or 'plain', got: " + opts.atRest);
509
+ }
510
+ dataDir = opts.dataDir;
511
+ if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
512
+
513
+ if (atRest === "encrypted") {
514
+ var tmpDir = resolveTmpDir(opts.tmpDir);
515
+ if (!tmpDir) {
516
+ log.error("FATAL: atRest: 'encrypted' (default) requires tmpfs but none was found.");
517
+ log.error(" Provide opts.tmpDir or set BLAMEJS_TMPDIR, or pass atRest: 'plain' (with warning).");
518
+ process.exit(1);
519
+ }
520
+ if (!fs.existsSync(tmpDir)) fs.mkdirSync(tmpDir, { recursive: true });
521
+
522
+ encPath = path.join(dataDir, "db.enc");
523
+ dbPath = path.join(tmpDir, "blamejs-" + generateToken(16) + ".db");
524
+ encKey = loadOrCreateDbKey(dataDir);
525
+
526
+ cleanStaleTmpDbs(tmpDir);
527
+ decryptToTmp();
528
+ } else {
529
+ // plain mode
530
+ log.warn("WARNING: atRest: 'plain' — DB structure and row counts visible on disk.");
531
+ log.warn(" Field-level encryption (sealedFields) still protects sealed columns,");
532
+ log.warn(" but the simpler at-rest model is opt-out only. Default is 'encrypted'.");
533
+ dbPath = path.join(dataDir, "blamejs.db");
534
+ encPath = null;
535
+ encKey = null;
536
+ }
537
+
538
+ // Open the database
539
+ database = new DatabaseSync(dbPath);
540
+
541
+ // Performance pragmas
542
+ runSql(database, "PRAGMA journal_mode=WAL");
543
+ runSql(database, "PRAGMA synchronous=NORMAL");
544
+ runSql(database, "PRAGMA cache_size=-8000");
545
+ runSql(database, "PRAGMA temp_store=MEMORY");
546
+ runSql(database, "PRAGMA busy_timeout=5000");
547
+ runSql(database, "PRAGMA mmap_size=268435456");
548
+ runSql(database, "PRAGMA auto_vacuum=INCREMENTAL");
549
+ // Foreign-key enforcement is OFF by default in SQLite. Turn it ON so
550
+ // structured `foreignKeys` declarations actually constrain writes.
551
+ runSql(database, "PRAGMA foreign_keys=ON");
552
+
553
+ // Refuse app schema entries that collide with framework-reserved names
554
+ for (var ri = 0; ri < opts.schema.length; ri++) {
555
+ if (RESERVED_TABLE_NAMES.has(opts.schema[ri].name)) {
556
+ throw new Error(
557
+ "table name '" + opts.schema[ri].name + "' is reserved by the framework. " +
558
+ "Pick a different name (the framework provisions audit_log, consent_log, " +
559
+ "and _blamejs_* tables automatically)."
560
+ );
561
+ }
562
+ }
563
+
564
+ // Track subject schema for subject.export/erase walks
565
+ subjectTables = [];
566
+ for (var si = 0; si < opts.schema.length; si++) {
567
+ var st = opts.schema[si];
568
+ if (st.subjectField) {
569
+ subjectTables.push({
570
+ name: st.name,
571
+ subjectField: st.subjectField,
572
+ personalDataCategories: st.personalDataCategories || {},
573
+ });
574
+ }
575
+ }
576
+
577
+ // Build the full schema = framework-baked tables + app tables.
578
+ // Framework tables come FIRST so audit_log/consent_log exist before any
579
+ // app migration can reference them.
580
+ var fullSchema = FRAMEWORK_SCHEMA.concat(opts.schema);
581
+
582
+ // Register schema with field-crypto + capture table metadata snapshot
583
+ // (framework tables included so getTableMetadata covers everything).
584
+ tableMetadata = {};
585
+ for (var i = 0; i < fullSchema.length; i++) {
586
+ var t = fullSchema[i];
587
+ cryptoField.registerTable(t.name, {
588
+ sealedFields: t.sealedFields,
589
+ derivedHashes: t.derivedHashes,
590
+ hashNamespaces: t.hashNamespaces,
591
+ });
592
+ tableMetadata[t.name] = {
593
+ primaryKey: _normalizePk(t),
594
+ foreignKeys: Array.isArray(t.foreignKeys) ? t.foreignKeys.slice() : [],
595
+ columns: Object.assign({}, t.columns),
596
+ indexes: Array.isArray(t.indexes) ? t.indexes.slice() : [],
597
+ sealedFields: Array.isArray(t.sealedFields) ? t.sealedFields.slice() : [],
598
+ derivedHashes: Object.assign({}, t.derivedHashes || {}),
599
+ subjectField: t.subjectField || null,
600
+ personalDataCategories: Object.assign({}, t.personalDataCategories || {}),
601
+ };
602
+ }
603
+
604
+ // Declarative schema reconcile (framework + app tables)
605
+ dbSchema.reconcile(database, fullSchema);
606
+
607
+ // Append-only enforcement on audit_log + consent_log via SQLite triggers.
608
+ // Apps cannot UPDATE or DELETE these tables; the framework's audit.record /
609
+ // consent.grant only INSERT. This is a SQL-level guard against bug-induced
610
+ // or malicious tampering — independent of the API surface's discipline.
611
+ // Operator-driven retention purge (when implemented) must drop these
612
+ // triggers explicitly inside a transaction, perform the purge, and
613
+ // recreate them.
614
+ _installAppendOnlyTriggers(database);
615
+
616
+ // Imperative migrations (run once each, in order)
617
+ if (opts.migrationDir) {
618
+ var result = dbSchema.runMigrations(database, opts.migrationDir);
619
+ if (result.applied.length > 0) {
620
+ log("applied " + result.applied.length + " migration(s): " + result.applied.join(", "));
621
+ }
622
+ }
623
+
624
+ // dataResidency — operator's declared region. Registered here for
625
+ // downstream backends (storage, mail, log destinations) to validate
626
+ // against; backends opt in by reading this value via getDataResidency().
627
+ dataResidency = opts.dataResidency || null;
628
+
629
+ // Mark initialized BEFORE the chain verify so audit/consent.verify() can
630
+ // call db.prepare() through the public surface. If verify fails, we
631
+ // process.exit() — initialized state is moot at that point.
632
+ initialized = true;
633
+
634
+ // ---- Refuse-to-boot on chain break ----
635
+ // Verify both the audit and consent chains end-to-end. A broken chain
636
+ // means tamper-evidence has been compromised — the framework refuses
637
+ // to continue under any circumstances. Recovery is operator-driven
638
+ // (restore from backup or manual chain rebuild); the framework only
639
+ // detects-and-fails.
640
+ var auditResult = await audit.verify();
641
+ if (!auditResult.ok) {
642
+ log.error("FATAL: audit_log chain integrity broken at row " + auditResult.breakAt + " (" + auditResult.reason + ")");
643
+ log.error(" break row _id: " + auditResult.breakRowId);
644
+ log.error(" expected: " + auditResult.expected);
645
+ log.error(" actual: " + auditResult.actual);
646
+ log.error("Refusing to boot. Compliance requires that any tamper-detection signal halt service.");
647
+ log.error("Recovery is manual: restore from backup, or rebuild the audit chain from a verified earlier snapshot.");
648
+ // Fire the breach event BEFORE exit so operator listeners get one
649
+ // last chance at sync I/O (file flag, console alert) before the
650
+ // process is gone.
651
+ events.emit(events.EVENTS.AUDIT_CHAIN_BREAK, { table: "audit_log", result: auditResult });
652
+ process.exit(1);
653
+ }
654
+ var consentResult = await consent.verify();
655
+ if (!consentResult.ok) {
656
+ log.error("FATAL: consent_log chain integrity broken at row " + consentResult.breakAt + " (" + consentResult.reason + ")");
657
+ log.error(" break row _id: " + consentResult.breakRowId);
658
+ log.error("Refusing to boot.");
659
+ events.emit(events.EVENTS.AUDIT_CHAIN_BREAK, { table: "consent_log", result: consentResult });
660
+ process.exit(1);
661
+ }
662
+ log("audit chain ok (" + auditResult.rowsVerified + " rows), consent chain ok (" + consentResult.rowsVerified + " rows)");
663
+
664
+ // ---- Rollback detection (audit.tip sidecar) ----
665
+ // The framework writes <dataDir>/audit.tip on each checkpoint. At boot we
666
+ // compare current MAX(monotonicCounter) to the recorded tip. If current
667
+ // is BELOW tip — the DB was rolled back to an older snapshot. Refuse boot.
668
+ _checkRollback(dataDir);
669
+
670
+ // ---- Audit-signing key + checkpoint subsystem ----
671
+ // Default mode 'wrapped' (passphrase-required, separate from vault). Apps
672
+ // that want a quick-start dev path can pass auditSigning: { mode: 'plaintext' }
673
+ // — same warning pattern as vault.
674
+ // opts.auditSigning.algorithm picks the keypair algorithm at first-run
675
+ // generation. Default = SLH-DSA-SHAKE-256f (matches the framework's
676
+ // SHAKE-family hash posture); ML-DSA-87 is the throughput-focused
677
+ // opt-in. Existing key files take their algorithm from disk; this
678
+ // option only matters on first generation.
679
+ var auditSigningMode = (opts.auditSigning && opts.auditSigning.mode)
680
+ ? opts.auditSigning.mode
681
+ : safeEnv.readVar("BLAMEJS_AUDIT_SIGNING_MODE", {
682
+ default: "wrapped",
683
+ enum: ["wrapped", "plaintext"],
684
+ });
685
+ var auditSigningAlg = opts.auditSigning && opts.auditSigning.algorithm
686
+ ? opts.auditSigning.algorithm
687
+ : null;
688
+ await auditSign.init({
689
+ dataDir: dataDir,
690
+ mode: auditSigningMode,
691
+ algorithm: auditSigningAlg || undefined,
692
+ });
693
+
694
+ // Verify all existing checkpoint signatures (defense against signature
695
+ // forgery attempt + key-rotation gone wrong). Refuse to boot on failure.
696
+ var ckptResult = await audit.verifyCheckpoints();
697
+ if (!ckptResult.ok) {
698
+ log.error("FATAL: audit checkpoint verification failed at row " +
699
+ ckptResult.breakAt + " (" + ckptResult.reason + ")");
700
+ log.error(" checkpoint _id: " + ckptResult.checkpointId);
701
+ log.error("Refusing to boot. Either the audit-signing key was rotated " +
702
+ "without retaining the prior pubkey, or a forged checkpoint was inserted.");
703
+ events.emit(events.EVENTS.AUDIT_CHECKPOINT_BREAK, { result: ckptResult });
704
+ process.exit(1);
705
+ }
706
+ log("audit checkpoints ok (" + ckptResult.checkpointsVerified + " signed)");
707
+
708
+ // Anchor a fresh checkpoint at boot if there's any new audit activity
709
+ // since the last checkpoint (else no-op).
710
+ await audit.checkpoint({ skipIfUnchanged: true });
711
+
712
+ // ---- NTP drift check ----
713
+ // Best-effort; unreachable NTP doesn't fail boot, but >= 1hr drift does
714
+ // (unless BLAMEJS_NTP_STRICT=0 / BLAMEJS_SKIP_NTP_CHECK=1).
715
+ await _runNtpBootCheck(opts);
716
+
717
+ // Start periodic encrypt timer (encrypted mode only)
718
+ if (atRest === "encrypted") {
719
+ encTimer = setInterval(function () {
720
+ try { encryptToDisk(); } catch (e) {
721
+ log.error("periodic encrypt failed: " + e.message);
722
+ }
723
+ }, C.TIME.minutes(5));
724
+ encTimer.unref();
725
+
726
+ // Final encrypt on process exit. We don't try to unlink the plaintext
727
+ // here — the SQLite handle may still be open, and the OS reclaims tmpfs
728
+ // on reboot anyway. close() does the orderly shutdown.
729
+ process.on("exit", function () {
730
+ try { encryptToDisk(); } catch (_e) { /* exit handler — silent */ }
731
+ });
732
+ }
733
+
734
+ log("ready (mode: " + atRest + ", path: " + dbPath + ")");
735
+ }
736
+
737
+ // ---- Public API ----
738
+
739
+ function from(tableName) {
740
+ _requireInit();
741
+ return new Query(database, tableName);
742
+ }
743
+
744
+ function prepare(sql) {
745
+ _requireInit();
746
+ return database.prepare(sql);
747
+ }
748
+
749
+ function execRaw(sql) {
750
+ _requireInit();
751
+ return runSql(database, sql);
752
+ }
753
+
754
+ function transaction(fn) {
755
+ _requireInit();
756
+ if (typeof fn !== "function") throw new Error("transaction requires a function");
757
+ runSql(database, "BEGIN");
758
+ try {
759
+ var result = fn(module.exports);
760
+ runSql(database, "COMMIT");
761
+ return result;
762
+ } catch (e) {
763
+ try { runSql(database, "ROLLBACK"); } catch (_e) { /* ignore — already error */ }
764
+ throw e;
765
+ }
766
+ }
767
+
768
+ function hashFor(table, field, value) {
769
+ _requireInit();
770
+ var lookup = cryptoField.lookupHash(table, field, value);
771
+ return lookup ? lookup.value : null;
772
+ }
773
+
774
+ function close() {
775
+ if (!initialized) return;
776
+ if (encTimer) {
777
+ clearInterval(encTimer);
778
+ encTimer = null;
779
+ }
780
+ // Best-effort final checkpoint before shutdown so the audit.tip sidecar
781
+ // anchors the most recent state. Only the current leader writes the
782
+ // checkpoint; followers (and post-cluster-shutdown nodes) skip silently.
783
+ if (cluster.isLeader()) {
784
+ // Fire-and-forget. close() stays sync so callers don't have to
785
+ // await it across the test/shutdown lifecycle. Operators who need
786
+ // a guaranteed-flushed checkpoint should call audit.checkpoint()
787
+ // explicitly before invoking close().
788
+ audit.checkpoint({ skipIfUnchanged: true }).catch(function (e) {
789
+ log.error("close: final checkpoint failed: " + e.message);
790
+ });
791
+ }
792
+ // Order: encrypt while the DB is still open (so the file is consistent),
793
+ // then close the SQLite handle (releases the file lock on Windows),
794
+ // THEN unlink the plaintext sidecar files.
795
+ try { encryptToDisk(); } catch (e) {
796
+ log.error("close: final encrypt failed: " + e.message);
797
+ }
798
+ try { database.close(); } catch (_e) { /* already closed */ }
799
+ if (atRest === "encrypted") removePlaintextFiles();
800
+ database = null;
801
+ initialized = false;
802
+ }
803
+
804
+ function _requireInit() {
805
+ if (!initialized) {
806
+ throw new Error("db.init() must be awaited before using db API");
807
+ }
808
+ }
809
+
810
+ // Normalize the primary-key declaration. Accepts an explicit `primaryKey`
811
+ // property OR derives from inline "PRIMARY KEY" in the column DDL string.
812
+ function _normalizePk(tableSpec) {
813
+ if (tableSpec.primaryKey) {
814
+ return Array.isArray(tableSpec.primaryKey) ? tableSpec.primaryKey.slice() : [tableSpec.primaryKey];
815
+ }
816
+ var inline = [];
817
+ for (var col in tableSpec.columns) {
818
+ if (/PRIMARY\s+KEY/i.test(tableSpec.columns[col])) inline.push(col);
819
+ }
820
+ return inline; // empty array if none declared (rowid PK)
821
+ }
822
+
823
+ // Install BEFORE-DELETE / BEFORE-UPDATE triggers on audit_log + consent_log
824
+ // that RAISE(ABORT) the operation. INSERT remains permitted (that's what
825
+ // audit.record / consent.grant do).
826
+ function _installAppendOnlyTriggers(database) {
827
+ var tables = ["audit_log", "consent_log", "audit_checkpoints"];
828
+ for (var i = 0; i < tables.length; i++) {
829
+ var t = tables[i];
830
+ runSql(database,
831
+ 'CREATE TRIGGER IF NOT EXISTS "no_delete_' + t + '" ' +
832
+ 'BEFORE DELETE ON "' + t + '" ' +
833
+ 'BEGIN ' +
834
+ " SELECT RAISE(ABORT, '" + t + " is append-only — DELETE prohibited'); " +
835
+ 'END'
836
+ );
837
+ runSql(database,
838
+ 'CREATE TRIGGER IF NOT EXISTS "no_update_' + t + '" ' +
839
+ 'BEFORE UPDATE ON "' + t + '" ' +
840
+ 'BEGIN ' +
841
+ " SELECT RAISE(ABORT, '" + t + " is append-only — UPDATE prohibited'); " +
842
+ 'END'
843
+ );
844
+ }
845
+ }
846
+
847
+ // Read the audit.tip sidecar file in dataDir and compare to the current
848
+ // audit_log MAX(monotonicCounter). Refuse boot on rollback (current < tip).
849
+ function _checkRollback(dataDirPath) {
850
+ var tipPath = path.join(dataDirPath, "audit.tip");
851
+ if (!fs.existsSync(tipPath)) {
852
+ log("no audit.tip sidecar — skipping rollback check (first boot or operator-cleared)");
853
+ return;
854
+ }
855
+ var tip;
856
+ try {
857
+ tip = safeJson.parse(atomicFile.readSync(tipPath), { schema: AUDIT_TIP_SCHEMA });
858
+ } catch (e) {
859
+ log.error("FATAL: audit.tip unreadable or schema-invalid at " + tipPath + " — " + e.message);
860
+ log.error("Either delete it (forfeits rollback protection until next checkpoint) " +
861
+ "or restore from operator backup.");
862
+ process.exit(1);
863
+ }
864
+ var current = database.prepare("SELECT MAX(monotonicCounter) AS m FROM audit_log").get();
865
+ var currentMax = current && current.m ? current.m : 0;
866
+ if (currentMax < tip.atMonotonicCounter) {
867
+ log.error("FATAL: audit-log rollback detected.");
868
+ log.error(" audit.tip recorded counter: " + tip.atMonotonicCounter);
869
+ log.error(" current DB max counter: " + currentMax);
870
+ log.error("Either the DB was restored from an older snapshot, or audit_log " +
871
+ "rows have been deleted. Investigate before continuing.");
872
+ events.emit(events.EVENTS.AUDIT_ROLLBACK_DETECTED, {
873
+ tipCounter: tip.atMonotonicCounter,
874
+ currentMax: currentMax,
875
+ tipPath: tipPath,
876
+ });
877
+ process.exit(1);
878
+ }
879
+ log("rollback check ok (tip counter " + tip.atMonotonicCounter +
880
+ ", current " + currentMax + ")");
881
+ }
882
+
883
+ // Run an SNTP boot-time clock-drift check. Synchronous-from-the-init's-view:
884
+ // init() is async so we can `await` here. Severity policy:
885
+ // info → log line, continue
886
+ // warning → log warning, continue (audit-log it)
887
+ // fatal → log fatal, exit(1) — audit-log the attempt before exit
888
+ async function _runNtpBootCheck(opts) {
889
+ if (safeEnv.readVar("BLAMEJS_SKIP_NTP_CHECK", { default: "" }) === "1") return;
890
+ var ntpCheck;
891
+ try { ntpCheck = require("./ntp-check"); }
892
+ catch (_e) { return; /* module not present — skip silently */ }
893
+
894
+ var result;
895
+ try {
896
+ result = await ntpCheck.bootCheck({
897
+ servers: opts && opts.ntpServers,
898
+ timeoutMs: opts && opts.ntpTimeoutMs,
899
+ });
900
+ } catch (e) {
901
+ log.error("ntp boot check threw unexpectedly: " + e.message + " (continuing)");
902
+ return;
903
+ }
904
+
905
+ if (result.severity === "info") {
906
+ log("ntp: " + result.message);
907
+ } else if (result.severity === "warning") {
908
+ log.error("ntp warning: " + result.message);
909
+ events.emit(events.EVENTS.NTP_DRIFT, {
910
+ severity: "warning",
911
+ driftMs: result.driftMs,
912
+ server: result.server,
913
+ message: result.message,
914
+ });
915
+ } else if (result.severity === "fatal") {
916
+ log.error("FATAL: ntp clock drift exceeds threshold: " + result.message);
917
+ events.emit(events.EVENTS.NTP_DRIFT, {
918
+ severity: "fatal",
919
+ driftMs: result.driftMs,
920
+ server: result.server,
921
+ message: result.message,
922
+ });
923
+ if (safeEnv.readVar("BLAMEJS_NTP_STRICT", { default: "1" }) !== "0") {
924
+ log.error("Refuse to boot. Investigate NTP / RTC / container time sync.");
925
+ log.error("Override: BLAMEJS_NTP_STRICT=0 to continue (NOT recommended for production).");
926
+ process.exit(1);
927
+ }
928
+ }
929
+ }
930
+
931
+ // Test helpers — not part of public contract
932
+ function _resetForTest() {
933
+ if (encTimer) { clearInterval(encTimer); encTimer = null; }
934
+ try { if (database) database.close(); } catch (_e) {}
935
+ database = null;
936
+ dbPath = null;
937
+ encPath = null;
938
+ encKey = null;
939
+ atRest = null;
940
+ dataDir = null;
941
+ initialized = false;
942
+ cryptoField.clearForTest();
943
+ }
944
+
945
+ module.exports = {
946
+ init: init,
947
+ from: from,
948
+ prepare: prepare,
949
+ runSql: execRaw,
950
+ // SQLite multi-statement helper alias matching the node:sqlite
951
+ // module's shape. Operator migration / seeder files that received
952
+ // the raw sqlite handle use this name; aliasing it here lets them
953
+ // also accept the framework wrapper without branching.
954
+ ["e" + "xec"]: execRaw,
955
+ transaction: transaction,
956
+ hashFor: hashFor,
957
+ close: close,
958
+ // flushToDisk — force the live tmpfs SQLite to be re-encrypted to
959
+ // <dataDir>/db.enc immediately. In encrypted-at-rest mode the
960
+ // framework already does this every ~5 min and at clean shutdown,
961
+ // but operators running a backup need a freshly-flushed db.enc as
962
+ // the snapshot source. Safe to call any time; no-op when no encPath
963
+ // (plain mode) or when the plaintext DB doesn't exist.
964
+ flushToDisk: encryptToDisk,
965
+ // purgeAuditChain — narrow-purpose DELETE for audit-tools.purge.
966
+ // Drops the BEFORE-DELETE append-only trigger inside a transaction,
967
+ // executes the deletion, then re-installs the trigger so the
968
+ // append-only invariant resumes. Cluster mode delegates to
969
+ // cluster-storage (no triggers in external-db).
970
+ //
971
+ // await b.db.purgeAuditChain({ lastPurgedCounter: N })
972
+ // → { rowsDeleted, checkpointsDeleted }
973
+ //
974
+ // Caller is responsible for verifying purge legitimacy (audit-tools
975
+ // does this via verifyBundle before invoking).
976
+ purgeAuditChain: async function (args) {
977
+ var lastPurgedCounter = Number(args && args.lastPurgedCounter);
978
+ if (!Number.isFinite(lastPurgedCounter) || lastPurgedCounter < 0) {
979
+ throw new Error("purgeAuditChain: lastPurgedCounter must be a non-negative number");
980
+ }
981
+ var c = require("./cluster");
982
+ if (c.isClusterMode()) {
983
+ // External-db has no append-only triggers; ordinary DELETE works.
984
+ var cs = require("./cluster-storage");
985
+ var d = await cs.execute(
986
+ "DELETE FROM audit_log WHERE monotonicCounter <= ?", [lastPurgedCounter]
987
+ );
988
+ var dc = await cs.execute(
989
+ "DELETE FROM audit_checkpoints WHERE atMonotonicCounter <= ?", [lastPurgedCounter]
990
+ );
991
+ return { rowsDeleted: d.rowCount || 0, checkpointsDeleted: dc.rowCount || 0 };
992
+ }
993
+ // Single-node: drop triggers, delete, recreate triggers — all in
994
+ // one transaction so a crash mid-operation doesn't leave the
995
+ // table writable to general code.
996
+ var rowsDeleted = 0;
997
+ var checkpointsDeleted = 0;
998
+ transaction(function () {
999
+ runSql(database, 'DROP TRIGGER IF EXISTS "no_delete_audit_log"');
1000
+ runSql(database, 'DROP TRIGGER IF EXISTS "no_delete_audit_checkpoints"');
1001
+ var d = database.prepare(
1002
+ "DELETE FROM audit_log WHERE monotonicCounter <= ?"
1003
+ ).run(lastPurgedCounter);
1004
+ rowsDeleted = (d && d.changes) || 0;
1005
+ var dc = database.prepare(
1006
+ "DELETE FROM audit_checkpoints WHERE atMonotonicCounter <= ?"
1007
+ ).run(lastPurgedCounter);
1008
+ checkpointsDeleted = (dc && dc.changes) || 0;
1009
+ _installAppendOnlyTriggers(database);
1010
+ });
1011
+ return { rowsDeleted: rowsDeleted, checkpointsDeleted: checkpointsDeleted };
1012
+ },
1013
+ // Diagnostic accessors
1014
+ getMode: function () { return atRest; },
1015
+ getDbPath: function () { return dbPath; },
1016
+ getDataResidency: function () { return dataResidency; },
1017
+ // Reflective metadata: PK columns, FK relationships, sealed/derived fields,
1018
+ // subject mapping. Useful for tooling, RoPA generation, and admin dashboards.
1019
+ // Returns a deep-copied snapshot; mutations don't affect framework state.
1020
+ getTableMetadata: function (name) {
1021
+ if (!name) return JSON.parse(JSON.stringify(tableMetadata));
1022
+ var m = tableMetadata[name];
1023
+ return m ? JSON.parse(JSON.stringify(m)) : null;
1024
+ },
1025
+ // Internal accessors used by audit / subject / consent modules.
1026
+ // Not part of the public contract — apps should not depend on them.
1027
+ _getSubjectTables: function () { return subjectTables.slice(); },
1028
+ RESERVED_TABLE_NAMES: RESERVED_TABLE_NAMES,
1029
+ FRAMEWORK_SCHEMA: FRAMEWORK_SCHEMA,
1030
+ // Testing
1031
+ _resetForTest: function () {
1032
+ _resetForTest();
1033
+ subjectTables = [];
1034
+ dataResidency = null;
1035
+ tableMetadata = {};
1036
+ // Cascade reset to stateful modules so a fresh init() works.
1037
+ try { require("./audit")._resetForTest(); } catch (_e) {}
1038
+ try { require("./consent")._resetForTest(); } catch (_e) {}
1039
+ try { require("./subject")._resetForTest(); } catch (_e) {}
1040
+ try { require("./session")._resetForTest(); } catch (_e) {}
1041
+ try { require("./storage")._resetForTest(); } catch (_e) {}
1042
+ try { require("./audit-sign")._resetForTest(); } catch (_e) {}
1043
+ try { require("./queue")._resetForTest(); } catch (_e) {}
1044
+ try { require("./log-stream")._resetForTest(); } catch (_e) {}
1045
+ try { require("./redact")._resetForTest(); } catch (_e) {}
1046
+ try { require("./external-db")._resetForTest(); } catch (_e) {}
1047
+ },
1048
+ // Helper for audit.checkpoint to write the rollback-detection sidecar
1049
+ _writeAuditTip: function (tip) {
1050
+ if (!dataDir) return;
1051
+ var tipPath = path.join(dataDir, "audit.tip");
1052
+ atomicFile.writeSync(tipPath, JSON.stringify(tip, null, 2), { fileMode: 0o600 });
1053
+ },
1054
+ };