@blamejs/core 0.8.42 → 0.8.49

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 (222) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/README.md +10 -10
  3. package/index.js +52 -0
  4. package/lib/a2a.js +159 -34
  5. package/lib/acme.js +762 -0
  6. package/lib/ai-pref.js +166 -43
  7. package/lib/api-key.js +108 -47
  8. package/lib/api-snapshot.js +157 -40
  9. package/lib/app-shutdown.js +113 -77
  10. package/lib/archive.js +337 -40
  11. package/lib/arg-parser.js +697 -0
  12. package/lib/asyncapi.js +99 -55
  13. package/lib/atomic-file.js +465 -104
  14. package/lib/audit-chain.js +123 -34
  15. package/lib/audit-daily-review.js +389 -0
  16. package/lib/audit-sign.js +302 -56
  17. package/lib/audit-tools.js +412 -63
  18. package/lib/audit.js +656 -35
  19. package/lib/auth/jwt-external.js +17 -0
  20. package/lib/auth/oauth.js +7 -0
  21. package/lib/auth-bot-challenge.js +505 -0
  22. package/lib/auth-header.js +92 -25
  23. package/lib/backup/bundle.js +26 -0
  24. package/lib/backup/index.js +512 -89
  25. package/lib/backup/manifest.js +168 -7
  26. package/lib/break-glass.js +415 -39
  27. package/lib/budr.js +103 -30
  28. package/lib/bundler.js +86 -66
  29. package/lib/cache.js +192 -72
  30. package/lib/chain-writer.js +65 -40
  31. package/lib/circuit-breaker.js +56 -33
  32. package/lib/cli-helpers.js +106 -75
  33. package/lib/cli.js +6 -30
  34. package/lib/cloud-events.js +99 -32
  35. package/lib/cluster-storage.js +162 -37
  36. package/lib/cluster.js +340 -49
  37. package/lib/codepoint-class.js +66 -0
  38. package/lib/compliance.js +424 -24
  39. package/lib/config-drift.js +111 -46
  40. package/lib/config.js +94 -40
  41. package/lib/consent.js +165 -18
  42. package/lib/constants.js +1 -0
  43. package/lib/content-credentials.js +153 -48
  44. package/lib/cookies.js +154 -62
  45. package/lib/credential-hash.js +133 -61
  46. package/lib/crypto-field.js +702 -18
  47. package/lib/crypto-hpke.js +256 -0
  48. package/lib/crypto.js +744 -22
  49. package/lib/csv.js +178 -35
  50. package/lib/daemon.js +456 -0
  51. package/lib/dark-patterns.js +186 -55
  52. package/lib/db-query.js +79 -2
  53. package/lib/db.js +1431 -60
  54. package/lib/ddl-change-control.js +523 -0
  55. package/lib/deprecate.js +195 -40
  56. package/lib/dev.js +82 -39
  57. package/lib/dora.js +67 -48
  58. package/lib/dr-runbook.js +368 -0
  59. package/lib/dsr.js +142 -11
  60. package/lib/dual-control.js +91 -56
  61. package/lib/events.js +120 -41
  62. package/lib/external-db-migrate.js +192 -2
  63. package/lib/external-db.js +795 -50
  64. package/lib/fapi2.js +122 -1
  65. package/lib/fda-21cfr11.js +395 -0
  66. package/lib/fdx.js +132 -2
  67. package/lib/file-type.js +87 -0
  68. package/lib/file-upload.js +93 -0
  69. package/lib/flag.js +82 -20
  70. package/lib/forms.js +132 -29
  71. package/lib/framework-error.js +169 -0
  72. package/lib/framework-schema.js +163 -35
  73. package/lib/gate-contract.js +849 -175
  74. package/lib/graphql-federation.js +68 -7
  75. package/lib/guard-all.js +172 -55
  76. package/lib/guard-archive.js +286 -124
  77. package/lib/guard-auth.js +194 -21
  78. package/lib/guard-cidr.js +190 -28
  79. package/lib/guard-csv.js +397 -51
  80. package/lib/guard-domain.js +213 -91
  81. package/lib/guard-email.js +236 -29
  82. package/lib/guard-filename.js +307 -75
  83. package/lib/guard-graphql.js +263 -30
  84. package/lib/guard-html.js +310 -116
  85. package/lib/guard-image.js +243 -30
  86. package/lib/guard-json.js +260 -54
  87. package/lib/guard-jsonpath.js +235 -23
  88. package/lib/guard-jwt.js +284 -30
  89. package/lib/guard-markdown.js +204 -22
  90. package/lib/guard-mime.js +190 -26
  91. package/lib/guard-oauth.js +277 -28
  92. package/lib/guard-pdf.js +251 -27
  93. package/lib/guard-regex.js +226 -18
  94. package/lib/guard-shell.js +229 -26
  95. package/lib/guard-svg.js +177 -10
  96. package/lib/guard-template.js +232 -21
  97. package/lib/guard-time.js +195 -29
  98. package/lib/guard-uuid.js +189 -30
  99. package/lib/guard-xml.js +259 -36
  100. package/lib/guard-yaml.js +241 -44
  101. package/lib/honeytoken.js +63 -27
  102. package/lib/html-balance.js +83 -0
  103. package/lib/http-client.js +486 -59
  104. package/lib/http-message-signature.js +582 -0
  105. package/lib/i18n.js +102 -49
  106. package/lib/iab-mspa.js +112 -32
  107. package/lib/iab-tcf.js +107 -2
  108. package/lib/inbox.js +90 -52
  109. package/lib/keychain.js +865 -0
  110. package/lib/legal-hold.js +374 -0
  111. package/lib/local-db-thin.js +320 -0
  112. package/lib/log-stream.js +281 -51
  113. package/lib/log.js +184 -86
  114. package/lib/mail-bounce.js +107 -62
  115. package/lib/mail.js +295 -58
  116. package/lib/mcp.js +108 -27
  117. package/lib/metrics.js +98 -89
  118. package/lib/middleware/age-gate.js +36 -0
  119. package/lib/middleware/ai-act-disclosure.js +37 -0
  120. package/lib/middleware/api-encrypt.js +45 -0
  121. package/lib/middleware/assetlinks.js +40 -0
  122. package/lib/middleware/asyncapi-serve.js +35 -0
  123. package/lib/middleware/attach-user.js +40 -0
  124. package/lib/middleware/bearer-auth.js +40 -0
  125. package/lib/middleware/body-parser.js +230 -0
  126. package/lib/middleware/bot-disclose.js +34 -0
  127. package/lib/middleware/bot-guard.js +39 -0
  128. package/lib/middleware/compression.js +37 -0
  129. package/lib/middleware/cookies.js +32 -0
  130. package/lib/middleware/cors.js +40 -0
  131. package/lib/middleware/csp-nonce.js +40 -0
  132. package/lib/middleware/csp-report.js +34 -0
  133. package/lib/middleware/csrf-protect.js +43 -0
  134. package/lib/middleware/daily-byte-quota.js +53 -85
  135. package/lib/middleware/db-role-for.js +40 -0
  136. package/lib/middleware/dpop.js +40 -0
  137. package/lib/middleware/error-handler.js +37 -14
  138. package/lib/middleware/fetch-metadata.js +39 -0
  139. package/lib/middleware/flag-context.js +34 -0
  140. package/lib/middleware/gpc.js +33 -0
  141. package/lib/middleware/headers.js +35 -0
  142. package/lib/middleware/health.js +46 -0
  143. package/lib/middleware/host-allowlist.js +30 -0
  144. package/lib/middleware/network-allowlist.js +38 -0
  145. package/lib/middleware/openapi-serve.js +34 -0
  146. package/lib/middleware/rate-limit.js +160 -18
  147. package/lib/middleware/request-id.js +36 -18
  148. package/lib/middleware/request-log.js +37 -0
  149. package/lib/middleware/require-aal.js +29 -0
  150. package/lib/middleware/require-auth.js +32 -0
  151. package/lib/middleware/require-bound-key.js +41 -0
  152. package/lib/middleware/require-content-type.js +32 -0
  153. package/lib/middleware/require-methods.js +27 -0
  154. package/lib/middleware/require-mtls.js +33 -0
  155. package/lib/middleware/require-step-up.js +37 -0
  156. package/lib/middleware/security-headers.js +44 -0
  157. package/lib/middleware/security-txt.js +38 -0
  158. package/lib/middleware/span-http-server.js +37 -0
  159. package/lib/middleware/sse.js +36 -0
  160. package/lib/middleware/trace-log-correlation.js +33 -0
  161. package/lib/middleware/trace-propagate.js +32 -0
  162. package/lib/middleware/tus-upload.js +90 -0
  163. package/lib/middleware/web-app-manifest.js +53 -0
  164. package/lib/mtls-ca.js +100 -70
  165. package/lib/network-byte-quota.js +308 -0
  166. package/lib/network-heartbeat.js +135 -0
  167. package/lib/network-tls.js +534 -4
  168. package/lib/network.js +103 -0
  169. package/lib/notify.js +114 -43
  170. package/lib/ntp-check.js +192 -51
  171. package/lib/observability.js +145 -47
  172. package/lib/openapi.js +90 -44
  173. package/lib/outbox.js +99 -1
  174. package/lib/pagination.js +168 -86
  175. package/lib/parsers/index.js +16 -5
  176. package/lib/permissions.js +93 -40
  177. package/lib/pqc-agent.js +84 -8
  178. package/lib/pqc-software.js +94 -60
  179. package/lib/process-spawn.js +95 -21
  180. package/lib/pubsub.js +96 -66
  181. package/lib/queue.js +375 -54
  182. package/lib/redact.js +793 -21
  183. package/lib/render.js +139 -47
  184. package/lib/request-helpers.js +485 -121
  185. package/lib/restore-bundle.js +142 -39
  186. package/lib/restore-rollback.js +136 -45
  187. package/lib/retention.js +178 -50
  188. package/lib/retry.js +116 -33
  189. package/lib/router.js +475 -23
  190. package/lib/safe-async.js +543 -94
  191. package/lib/safe-buffer.js +337 -41
  192. package/lib/safe-json.js +467 -62
  193. package/lib/safe-jsonpath.js +285 -0
  194. package/lib/safe-schema.js +631 -87
  195. package/lib/safe-sql.js +221 -59
  196. package/lib/safe-url.js +278 -46
  197. package/lib/sandbox-worker.js +135 -0
  198. package/lib/sandbox.js +358 -0
  199. package/lib/scheduler.js +135 -70
  200. package/lib/self-update.js +647 -0
  201. package/lib/session-device-binding.js +431 -0
  202. package/lib/session.js +259 -49
  203. package/lib/slug.js +138 -26
  204. package/lib/ssrf-guard.js +316 -56
  205. package/lib/storage.js +433 -70
  206. package/lib/subject.js +405 -23
  207. package/lib/template.js +148 -8
  208. package/lib/tenant-quota.js +545 -0
  209. package/lib/testing.js +440 -53
  210. package/lib/time.js +291 -23
  211. package/lib/tls-exporter.js +239 -0
  212. package/lib/tracing.js +90 -74
  213. package/lib/uuid.js +97 -22
  214. package/lib/vault/index.js +284 -22
  215. package/lib/vault/seal-pem-file.js +66 -0
  216. package/lib/watcher.js +368 -0
  217. package/lib/webhook.js +196 -63
  218. package/lib/websocket.js +393 -68
  219. package/lib/wiki-concepts.js +338 -0
  220. package/lib/worker-pool.js +464 -0
  221. package/package.json +3 -3
  222. package/sbom.cyclonedx.json +7 -7
package/lib/db.js CHANGED
@@ -1,43 +1,45 @@
1
1
  "use strict";
2
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
- * });
3
+ * @module b.db
4
+ * @featured true
5
+ * @nav Data
6
+ * @title Db
7
+ *
8
+ * @intro
9
+ * Database core SQLite (node:sqlite) wrapped in encrypted-at-rest
10
+ * storage, sealed-column field-level crypto, append-only audit-chain
11
+ * integration, declarative schema reconcile, and run-once
12
+ * migrations. Default at-rest posture is `encrypted`: the live `.db`
13
+ * lives in tmpfs (/dev/shm), is decrypted from `<dataDir>/db.enc` at
14
+ * boot, periodically re-encrypted every five minutes, and re-
15
+ * encrypted again at shutdown. The DB encryption key is sealed by
16
+ * `b.vault` at `<dataDir>/db.key.enc`. Operators who want a plain
17
+ * on-disk SQLite file pass `atRest: "plain"` and accept a boot
18
+ * warning sealed columns still protect PII, but schema and row
19
+ * counts are visible to a forensic disk image.
20
+ *
21
+ * Beyond the storage shell, the module owns the framework's data
22
+ * contract: `audit_log` / `consent_log` / `audit_checkpoints` and
23
+ * the `_blamejs_*` reserved tables are provisioned before any
24
+ * operator schema reconciles, append-only triggers refuse
25
+ * UPDATE/DELETE on the chain tables, and boot refuses to continue
26
+ * on chain breakage, checkpoint signature failure, audit-log
27
+ * rollback, or PRAGMA integrity_check corruption. WORM
28
+ * declarations (`declareWorm`) and dual-control gates
29
+ * (`declareRequireDualControl`) layer SEC 17a-4(f) / FINRA 4511 /
30
+ * 21 CFR Part 11 §11.10(c) record-preservation invariants on
31
+ * operator tables.
32
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)
33
+ * The query surface is `db.from(table)` (chainable), `db.prepare`
34
+ * (LRU-cached node:sqlite Statement), `db.stream` (object-mode
35
+ * Readable for million-row exports with auto-unseal), and
36
+ * `db.transaction` (BEGIN/COMMIT/ROLLBACK around a callback).
37
+ * Postgres-only declarative migrations (`declareView` /
38
+ * `declareRowPolicy`) emit migration-shape objects consumed by
39
+ * `b.externalDb.migrate`.
40
+ *
41
+ * @card
42
+ * Database core — SQLite (node:sqlite) wrapped in encrypted-at-rest storage, sealed-column field-level crypto, append-only audit-chain integration, declarative schema reconcile, and run-once migrations.
41
43
  */
42
44
  var fs = require("fs");
43
45
  var path = require("path");
@@ -47,10 +49,11 @@ var atomicFile = require("./atomic-file");
47
49
  var audit = require("./audit");
48
50
  var auditSign = require("./audit-sign");
49
51
  var cluster = require("./cluster");
52
+ var csv = require("./csv");
50
53
  var events = require("./events");
51
54
  var consent = require("./consent");
52
55
  var C = require("./constants");
53
- var { generateToken, generateBytes, encryptPacked, decryptPacked } = require("./crypto");
56
+ var { generateToken, generateBytes, encryptPacked, decryptPacked, sha3Hash } = require("./crypto");
54
57
  var cryptoField = require("./crypto-field");
55
58
  var dbDeclareRowPolicy = require("./db-declare-row-policy");
56
59
  var dbDeclareView = require("./db-declare-view");
@@ -64,9 +67,27 @@ var ntpCheck = lazyRequire(function () { return require("./ntp-check"); });
64
67
  var safeAsync = require("./safe-async");
65
68
  var safeEnv = require("./parsers/safe-env");
66
69
  var safeJson = require("./safe-json");
70
+ var safeSql = require("./safe-sql");
71
+ var validateOpts = require("./validate-opts");
67
72
  var vault = require("./vault");
68
73
 
69
74
  var DbError = defineClass("DbError", { alwaysPermanent: true });
75
+ var WormViolationError = require("./framework-error").WormViolationError;
76
+ var _wormErr = WormViolationError.factory;
77
+
78
+ // Lazy: compliance and dual-control read state at runtime; both are
79
+ // non-load-time deps so a top-of-file require would not cycle, but
80
+ // they're only needed on declareWorm / declareRequireDualControl /
81
+ // eraseHard. Lazy keeps the load graph minimal.
82
+ var compliance = lazyRequire(function () { return require("./compliance"); });
83
+
84
+ // Postures that REQUIRE row-level WORM on operator-named business-
85
+ // record tables. Audit_log / consent_log / audit_checkpoints are
86
+ // already WORM-by-default; this set covers operator tables.
87
+ // sec-17a-4 — SEC Rule 17a-4(f) broker-dealer record preservation
88
+ // finra-4511 — FINRA Rule 4511 books-and-records
89
+ // fda-21cfr11 — 21 CFR Part 11 §11.10(c) protect record integrity
90
+ var WORM_POSTURES = Object.freeze(["sec-17a-4", "finra-4511", "fda-21cfr11"]);
70
91
  var _dbErr = DbError.factory;
71
92
 
72
93
  // Lazy: cluster-storage's _localDb pulls db back in, so eager require
@@ -116,6 +137,12 @@ var initialized = false;
116
137
  var dataResidency = null; // operator's declared region config (validated by storage backends)
117
138
  var subjectTables = []; // [{ name, subjectField, personalDataCategories }] — for subject.export/erase
118
139
  var tableMetadata = {}; // table name → metadata snapshot (PK/FK/sealed/derived) for getTableMetadata
140
+ // D-M5 — streamLimit ceiling. db.stream() / Query.stream() consult this
141
+ // (overridden per-call via opts.streamLimit). Default cap matches a
142
+ // generous-but-bounded 1M rows so an accidentally-unbounded export
143
+ // surfaces a thrown error instead of OOM. v0.7.67's maxRowsPerQuery
144
+ // bounds .all() / .first() — this is its streaming counterpart.
145
+ var streamLimit = C.BYTES.bytes(1000000); // allow:raw-byte-literal — row-count ceiling, not bytes
119
146
 
120
147
  // ---- Framework-baked tables ----
121
148
  //
@@ -225,6 +252,67 @@ var FRAMEWORK_SCHEMA = [
225
252
  erasedAt: "INTEGER NOT NULL",
226
253
  },
227
254
  },
255
+ {
256
+ // Subject-level legal hold registry. Operators register a hold
257
+ // via b.legalHold.place(subjectId, ...) — b.subject.erase and
258
+ // b.retention consult b.legalHold.isHeld(subjectId) before
259
+ // accepting any deletion. Per FRCP Rule 26/37(e), GDPR Art
260
+ // 17(3)(e), SEC Rule 17a-4, HIPAA §164.530(j)(2).
261
+ name: "_blamejs_legal_hold",
262
+ columns: {
263
+ subjectIdHash: "TEXT PRIMARY KEY",
264
+ placedAt: "INTEGER NOT NULL",
265
+ placedBy: "TEXT",
266
+ reason: "TEXT NOT NULL",
267
+ custodian: "TEXT",
268
+ citation: "TEXT",
269
+ retainUntil: "INTEGER",
270
+ },
271
+ indexes: ["placedAt"],
272
+ },
273
+ {
274
+ // Per-row crypto-erasure key registry — F-RTBF-3 per-row keys.
275
+ // Each entry holds a sealed wrapped K_row keyed by (table,
276
+ // rowId). b.subject.eraseHard deletes the entry, leaving WAL /
277
+ // replica residuals undecryptable.
278
+ name: "_blamejs_per_row_keys",
279
+ columns: {
280
+ tableName: "TEXT NOT NULL",
281
+ rowId: "TEXT NOT NULL",
282
+ wrappedKey: "BLOB NOT NULL",
283
+ createdAt: "INTEGER NOT NULL",
284
+ },
285
+ primaryKey: ["tableName", "rowId"],
286
+ indexes: [],
287
+ },
288
+ {
289
+ // Operator-declared WORM (write-once-read-many) registry. Each
290
+ // entry pairs an operator-named table with the posture that
291
+ // demanded the WORM declaration; boot-time assertions iterate
292
+ // this registry to verify triggers are installed under the
293
+ // current b.compliance.current() posture.
294
+ name: "_blamejs_worm_tables",
295
+ columns: {
296
+ tableName: "TEXT PRIMARY KEY",
297
+ posture: "TEXT",
298
+ declaredAt: "INTEGER NOT NULL",
299
+ },
300
+ },
301
+ {
302
+ // Operator-declared dual-control gate registry. b.db.delete /
303
+ // b.subject.erase / b.audit.purge consult this table on
304
+ // destructive ops; under the named posture the framework refuses
305
+ // execution unless the caller passes a consumed dual-control
306
+ // grant.
307
+ name: "_blamejs_dual_control_gates",
308
+ columns: {
309
+ tableName: "TEXT PRIMARY KEY",
310
+ posture: "TEXT",
311
+ m: "INTEGER NOT NULL",
312
+ n: "INTEGER NOT NULL",
313
+ declaredAt:"INTEGER NOT NULL",
314
+ },
315
+ },
228
316
  {
229
317
  name: "audit_checkpoints",
230
318
  columns: {
@@ -643,6 +731,61 @@ function cleanStaleTmpDbs(tmpDir) {
643
731
 
644
732
  // ---- Init dispatch ----
645
733
 
734
+ /**
735
+ * @primitive b.db.init
736
+ * @signature b.db.init(opts)
737
+ * @since 0.1.0
738
+ * @status stable
739
+ * @related b.db.close, b.db.from, b.db.declareWorm
740
+ *
741
+ * Boot the database. Provisions the framework-baked tables
742
+ * (`audit_log` / `consent_log` / `audit_checkpoints` /
743
+ * `_blamejs_*`), reconciles the operator schema, installs append-
744
+ * only triggers on chain tables, runs any pending file-based
745
+ * migrations, verifies the audit + consent chains end-to-end,
746
+ * verifies every audit checkpoint signature, runs PRAGMA
747
+ * integrity_check, performs a rollback-detection check against
748
+ * `audit.tip`, and runs a best-effort SNTP boot drift check. Refuses
749
+ * to boot on any chain breakage, signature mismatch, or rollback —
750
+ * compliance posture demands fail-closed at the earliest signal.
751
+ *
752
+ * @opts
753
+ * dataDir: string, // required — where db.enc + db.key.enc live
754
+ * schema: Array, // required — [{ name, columns, indexes, sealedFields, derivedHashes, foreignKeys, primaryKey, subjectField, personalDataCategories }, ...]
755
+ * atRest: "encrypted"|"plain", // default "encrypted"
756
+ * tmpDir: string, // override the encrypted-mode tmpfs path (default /dev/shm or BLAMEJS_TMPDIR)
757
+ * migrationDir: string, // optional — path to ./migrations/ (run-once each)
758
+ * streamLimit: number, // default 1_000_000 — db.stream row ceiling
759
+ * skipBootIntegrityCheck: boolean, // default false — skip PRAGMA integrity_check
760
+ * skipIntegrityCheck: boolean, // default false — alias
761
+ * auditSigning: { mode, algorithm }, // default { mode: "wrapped" }
762
+ * ntpServers: string[], // override NTP server list
763
+ * ntpTimeoutMs: number, // override NTP timeout
764
+ * dataResidency: object, // operator's region declaration
765
+ *
766
+ * @example
767
+ * var b = require("blamejs");
768
+ * await b.db.init({
769
+ * dataDir: "/var/lib/myapp",
770
+ * atRest: "encrypted",
771
+ * schema: [
772
+ * {
773
+ * name: "orders",
774
+ * columns: {
775
+ * _id: "TEXT PRIMARY KEY",
776
+ * customerId: "TEXT NOT NULL",
777
+ * totalCents: "INTEGER NOT NULL",
778
+ * note: "TEXT",
779
+ * createdAt: "INTEGER NOT NULL",
780
+ * },
781
+ * indexes: ["customerId"],
782
+ * sealedFields: ["note"],
783
+ * derivedHashes: { customerIdHash: { from: "customerId" } },
784
+ * subjectField: "customerId",
785
+ * },
786
+ * ],
787
+ * });
788
+ */
646
789
  async function init(opts) {
647
790
  if (initialized) return;
648
791
  // Drop any prepared-statement cache leftover from a prior init/close
@@ -661,6 +804,18 @@ async function init(opts) {
661
804
  throw new DbError("db/bad-at-rest",
662
805
  "db.init: atRest must be 'encrypted' or 'plain', got: " + opts.atRest);
663
806
  }
807
+ // D-M5 — operator-tunable streamLimit ceiling. Throw at config-time
808
+ // on bad shape so a typo surfaces at boot rather than as an
809
+ // unbounded stream at first export.
810
+ if (opts.streamLimit !== undefined) {
811
+ if (typeof opts.streamLimit !== "number" || !isFinite(opts.streamLimit) ||
812
+ opts.streamLimit <= 0 || Math.floor(opts.streamLimit) !== opts.streamLimit) {
813
+ throw new DbError("db/bad-init",
814
+ "db.init: streamLimit must be a positive finite integer; got " +
815
+ JSON.stringify(opts.streamLimit));
816
+ }
817
+ streamLimit = opts.streamLimit;
818
+ }
664
819
  dataDir = opts.dataDir;
665
820
  if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
666
821
 
@@ -956,6 +1111,18 @@ async function init(opts) {
956
1111
  // is BELOW tip — the DB was rolled back to an older snapshot. Refuse boot.
957
1112
  _checkRollback(dataDir);
958
1113
 
1114
+ // ---- F-RET-2 — WORM posture assertion ----
1115
+ // Under sec-17a-4 / finra-4511 / fda-21cfr11 postures the operator
1116
+ // MUST have declared row-level WORM on at least one business-record
1117
+ // table. Refuse boot otherwise so missing-declaration drift is
1118
+ // surfaced at start-up, not on the first delete.
1119
+ try { _assertWormUnderPosture(); }
1120
+ catch (e) {
1121
+ // The assertion throws under regulated postures; let it
1122
+ // propagate. Outside regulated postures it's a no-op.
1123
+ throw e;
1124
+ }
1125
+
959
1126
  // ---- Audit-signing key + checkpoint subsystem ----
960
1127
  // Default mode 'wrapped' (passphrase-required, separate from vault). Apps
961
1128
  // that want a quick-start dev path can pass auditSigning: { mode: 'plaintext' }
@@ -1023,6 +1190,36 @@ async function init(opts) {
1023
1190
 
1024
1191
  // ---- Public API ----
1025
1192
 
1193
+ /**
1194
+ * @primitive b.db.from
1195
+ * @signature b.db.from(tableName)
1196
+ * @since 0.1.0
1197
+ * @status stable
1198
+ * @related b.db.prepare, b.db.transaction, b.db.stream
1199
+ *
1200
+ * Open a chainable Query against a registered table. Sealed columns
1201
+ * auto-encrypt on insert/update and auto-decrypt on read; derived-
1202
+ * hash columns auto-populate from their source field on insert.
1203
+ * Identifier safety, parameter binding, row-policy gates, and
1204
+ * audit-emission are wired into the chain so operator code never
1205
+ * concatenates SQL by hand.
1206
+ *
1207
+ * @example
1208
+ * var b = require("blamejs");
1209
+ * await b.db.init({ dataDir: "/tmp/data", schema: [
1210
+ * { name: "orders",
1211
+ * columns: { _id: "TEXT PRIMARY KEY", customerId: "TEXT NOT NULL", totalCents: "INTEGER NOT NULL" },
1212
+ * sealedFields: ["customerId"] },
1213
+ * ] });
1214
+ *
1215
+ * b.db.from("orders").insert({
1216
+ * _id: b.uuid.v7(), customerId: "cust_123", totalCents: 4999,
1217
+ * });
1218
+ *
1219
+ * var rows = b.db.from("orders").where({ customerId: "cust_123" }).all();
1220
+ * rows.length;
1221
+ * // → 1
1222
+ */
1026
1223
  function from(tableName) {
1027
1224
  _requireInit();
1028
1225
  return new Query(database, tableName);
@@ -1038,6 +1235,33 @@ function from(tableName) {
1038
1235
  var PREPARE_CACHE_MAX = 256; // allow:raw-byte-literal — distinct-statement cache cap
1039
1236
  var _prepareCache = new Map(); // sql → Statement (insertion order = LRU)
1040
1237
 
1238
+ /**
1239
+ * @primitive b.db.prepare
1240
+ * @signature b.db.prepare(sql)
1241
+ * @since 0.1.0
1242
+ * @status stable
1243
+ * @related b.db.from, b.db.runSql, b.db.stream
1244
+ *
1245
+ * Raw-escape-hatch wrapper around `node:sqlite`'s `Statement`
1246
+ * preparation, with an LRU cache keyed by SQL string (cap 256
1247
+ * distinct shapes). Reuse of the same SQL returns the cached
1248
+ * Statement so a hot path doesn't churn file descriptors. Use
1249
+ * `b.db.from(table)` for the typical chainable surface; `prepare` is
1250
+ * for the rare cases where the chainable Query doesn't cover the
1251
+ * shape.
1252
+ *
1253
+ * @example
1254
+ * var b = require("blamejs");
1255
+ * await b.db.init({ dataDir: "/tmp/data", schema: [
1256
+ * { name: "orders",
1257
+ * columns: { _id: "TEXT PRIMARY KEY", totalCents: "INTEGER NOT NULL" } },
1258
+ * ] });
1259
+ *
1260
+ * var stmt = b.db.prepare("SELECT SUM(totalCents) AS total FROM orders");
1261
+ * var row = stmt.get();
1262
+ * typeof row.total;
1263
+ * // → "object"
1264
+ */
1041
1265
  function prepare(sql) {
1042
1266
  _requireInit();
1043
1267
  if (_prepareCache.has(sql)) {
@@ -1056,15 +1280,43 @@ function prepare(sql) {
1056
1280
  return stmt;
1057
1281
  }
1058
1282
 
1059
- // stream — Readable in object mode that yields rows as node:sqlite's
1060
- // iterate() produces them. Unlike all(), the engine doesn't materialize
1061
- // the result set in memory before the first row arrives, so audit
1062
- // exports / backup table dumps / large reports can process millions of
1063
- // rows without OOM pressure.
1064
- //
1065
- // Optional opts.table enables auto-unseal of sealed columns via the
1066
- // table's registered cryptoField schema. Raw / aggregate queries omit
1067
- // it. Mid-iteration prepare()-bound errors propagate as 'error' events.
1283
+ /**
1284
+ * @primitive b.db.stream
1285
+ * @signature b.db.stream(sql)
1286
+ * @since 0.4.0
1287
+ * @status stable
1288
+ * @related b.db.from, b.db.prepare, b.db.exportCsv
1289
+ *
1290
+ * Object-mode `Readable` that yields rows as `node:sqlite`'s
1291
+ * `iterate()` produces them. Unlike `.all()`, the engine never
1292
+ * materializes the full result set, so audit exports, backup table
1293
+ * dumps, and million-row reports finish without OOM pressure.
1294
+ * Variadic: positional parameter bindings come after `sql`; an
1295
+ * optional final plain-object argument carries `opts.table` (enables
1296
+ * sealed-column auto-unseal) and `opts.streamLimit` (per-call row
1297
+ * ceiling override). Default ceiling is the module-level
1298
+ * `streamLimit` (1_000_000); the stream destroys with a
1299
+ * `db/stream-limit-exceeded` error past the cap rather than
1300
+ * accumulating unboundedly.
1301
+ *
1302
+ * @example
1303
+ * var b = require("blamejs");
1304
+ * await b.db.init({ dataDir: "/tmp/data", schema: [
1305
+ * { name: "events",
1306
+ * columns: { _id: "TEXT PRIMARY KEY", payload: "TEXT" },
1307
+ * sealedFields: ["payload"] },
1308
+ * ] });
1309
+ *
1310
+ * var count = 0;
1311
+ * var s = b.db.stream("SELECT * FROM events", { table: "events" });
1312
+ * await new Promise(function (resolve, reject) {
1313
+ * s.on("data", function (_row) { count += 1; });
1314
+ * s.on("end", resolve);
1315
+ * s.on("error", reject);
1316
+ * });
1317
+ * count >= 0;
1318
+ * // → true
1319
+ */
1068
1320
  function stream(sql) {
1069
1321
  _requireInit();
1070
1322
  var opts = null;
@@ -1090,6 +1342,20 @@ function stream(sql) {
1090
1342
  var table = opts && typeof opts.table === "string" ? opts.table : null;
1091
1343
  var unseal = table ? cryptoField : null;
1092
1344
 
1345
+ // D-M5 — streamLimit ceiling. Per-call opts.streamLimit overrides
1346
+ // the module-level default; bad shape throws at call time so the
1347
+ // typo surfaces instead of an unbounded stream.
1348
+ var perCallLimit = streamLimit;
1349
+ if (opts && opts.streamLimit !== undefined) {
1350
+ if (typeof opts.streamLimit !== "number" || !isFinite(opts.streamLimit) ||
1351
+ opts.streamLimit <= 0 || Math.floor(opts.streamLimit) !== opts.streamLimit) {
1352
+ throw new DbError("db/bad-stream-limit",
1353
+ "db.stream: opts.streamLimit must be a positive finite integer; got " +
1354
+ JSON.stringify(opts.streamLimit));
1355
+ }
1356
+ perCallLimit = opts.streamLimit;
1357
+ }
1358
+
1093
1359
  var stmt;
1094
1360
  var iter;
1095
1361
  try {
@@ -1100,12 +1366,21 @@ function stream(sql) {
1100
1366
  setImmediate(function () { r.destroy(e); });
1101
1367
  return r;
1102
1368
  }
1369
+ var emitted = 0;
1103
1370
  return new Readable({
1104
1371
  objectMode: true,
1105
1372
  read: function () {
1106
1373
  try {
1374
+ if (emitted >= perCallLimit) {
1375
+ this.destroy(new DbError("db/stream-limit-exceeded",
1376
+ "db.stream: emitted " + emitted + " rows, exceeding streamLimit " +
1377
+ perCallLimit + ". Pass opts.streamLimit higher OR raise via " +
1378
+ "db.init({ streamLimit }) after auditing the export path."));
1379
+ return;
1380
+ }
1107
1381
  var step = iter.next();
1108
1382
  if (step.done) { this.push(null); return; }
1383
+ emitted += 1;
1109
1384
  var row = step.value;
1110
1385
  this.push(unseal ? unseal.unsealRow(table, row) : row);
1111
1386
  } catch (e) {
@@ -1120,6 +1395,38 @@ function stream(sql) {
1120
1395
  // review can reconstruct schema evolution from the chain alone (D-M1).
1121
1396
  var DDL_RE = /^\s*(CREATE|DROP|ALTER|TRUNCATE|RENAME|ATTACH|DETACH|REINDEX)\b/i;
1122
1397
 
1398
+ // D-L7 — slow-query observability buckets for the local SQLite path.
1399
+ // Highest matched bucket wins so the per-query emit is single-shot;
1400
+ // operators dashboard on the `bucket` label.
1401
+ var _SLOW_QUERY_BUCKETS_LOCAL = Object.freeze([
1402
+ { ms: C.TIME.seconds(30), label: "30s" },
1403
+ { ms: C.TIME.seconds(5), label: "5s" },
1404
+ { ms: C.TIME.seconds(1), label: "1s" },
1405
+ ]);
1406
+ var _STATEMENT_CLASS_RE_LOCAL = /^\s*(?:\/\*[\s\S]*?\*\/\s*|--[^\n]*\n\s*)*([A-Za-z]+)/;
1407
+ function _classifyStatementLocal(sql) {
1408
+ if (typeof sql !== "string" || sql.length === 0) return "UNKNOWN";
1409
+ var m = _STATEMENT_CLASS_RE_LOCAL.exec(sql);
1410
+ return m ? m[1].toUpperCase() : "UNKNOWN";
1411
+ }
1412
+ function _reportSlowSqlite(durationMs, statement) {
1413
+ if (typeof durationMs !== "number" || !isFinite(durationMs)) return;
1414
+ for (var i = 0; i < _SLOW_QUERY_BUCKETS_LOCAL.length; i++) {
1415
+ var bucket = _SLOW_QUERY_BUCKETS_LOCAL[i];
1416
+ if (durationMs >= bucket.ms) {
1417
+ try {
1418
+ observability.event("db.query.slow", durationMs, {
1419
+ backend: "sqlite",
1420
+ bucket: bucket.label,
1421
+ statementClass: _classifyStatementLocal(statement),
1422
+ "db.statement": String(statement || "").slice(0, 256), // allow:raw-byte-literal — log-truncation length, not bytes
1423
+ });
1424
+ } catch (_e) { /* hot-path observability sink — drop-silent by design */ }
1425
+ return;
1426
+ }
1427
+ }
1428
+ }
1429
+
1123
1430
  function execRaw(sql) {
1124
1431
  _requireInit();
1125
1432
  var startedAt = Date.now();
@@ -1129,6 +1436,8 @@ function execRaw(sql) {
1129
1436
  var isDdl = typeof sql === "string" && DDL_RE.test(sql); // allow:regex-no-length-cap — leading-keyword anchor; constant-time test
1130
1437
  try {
1131
1438
  var result = runSql(database, sql);
1439
+ var durationMs = Date.now() - startedAt;
1440
+ _reportSlowSqlite(durationMs, sql);
1132
1441
  if (isDdl && auditMod) {
1133
1442
  auditMod.safeEmit({
1134
1443
  action: "db.ddl.executed",
@@ -1140,12 +1449,14 @@ function execRaw(sql) {
1140
1449
  "db.system": "sqlite",
1141
1450
  "db.operation": String(sql).match(DDL_RE)[1].toUpperCase(),
1142
1451
  "db.statement": String(sql).slice(0, 256), // allow:raw-byte-literal — log-truncation length, not bytes
1143
- durationMs: Date.now() - startedAt,
1452
+ durationMs: durationMs,
1144
1453
  },
1145
1454
  });
1146
1455
  }
1147
1456
  return result;
1148
1457
  } catch (e) {
1458
+ var failureMs = Date.now() - startedAt;
1459
+ _reportSlowSqlite(failureMs, sql);
1149
1460
  if (isDdl && auditMod) {
1150
1461
  auditMod.safeEmit({
1151
1462
  action: "db.ddl.executed",
@@ -1155,7 +1466,7 @@ function execRaw(sql) {
1155
1466
  "db.system": "sqlite",
1156
1467
  "db.operation": String(sql).match(DDL_RE)[1].toUpperCase(),
1157
1468
  "db.statement": String(sql).slice(0, 256), // allow:raw-byte-literal — log-truncation length, not bytes
1158
- durationMs: Date.now() - startedAt,
1469
+ durationMs: failureMs,
1159
1470
  },
1160
1471
  });
1161
1472
  }
@@ -1163,6 +1474,39 @@ function execRaw(sql) {
1163
1474
  }
1164
1475
  }
1165
1476
 
1477
+ /**
1478
+ * @primitive b.db.transaction
1479
+ * @signature b.db.transaction(fn)
1480
+ * @since 0.1.0
1481
+ * @status stable
1482
+ * @related b.db.from, b.db.eraseHard
1483
+ *
1484
+ * Run `fn(db)` inside a `BEGIN ... COMMIT` block; any throw inside
1485
+ * `fn` triggers `ROLLBACK` and re-propagates the error. Returns the
1486
+ * value `fn` returned. Transactions compose with the chainable
1487
+ * Query surface and with audit-chain emissions inside the body — the
1488
+ * audit row's chain hash is computed from the value at COMMIT time,
1489
+ * so a rolled-back transaction never leaves a phantom row in
1490
+ * `audit_log`.
1491
+ *
1492
+ * @example
1493
+ * var b = require("blamejs");
1494
+ * await b.db.init({ dataDir: "/tmp/data", schema: [
1495
+ * { name: "ledger",
1496
+ * columns: { _id: "TEXT PRIMARY KEY", balanceCents: "INTEGER NOT NULL" } },
1497
+ * ] });
1498
+ *
1499
+ * b.db.from("ledger").insert({ _id: "acct_1", balanceCents: 100 });
1500
+ * b.db.from("ledger").insert({ _id: "acct_2", balanceCents: 0 });
1501
+ *
1502
+ * b.db.transaction(function (db) {
1503
+ * db.from("ledger").where({ _id: "acct_1" }).update({ balanceCents: 50 });
1504
+ * db.from("ledger").where({ _id: "acct_2" }).update({ balanceCents: 50 });
1505
+ * });
1506
+ *
1507
+ * b.db.from("ledger").where({ _id: "acct_2" }).first().balanceCents;
1508
+ * // → 50
1509
+ */
1166
1510
  function transaction(fn) {
1167
1511
  _requireInit();
1168
1512
  if (typeof fn !== "function") {
@@ -1179,12 +1523,296 @@ function transaction(fn) {
1179
1523
  }
1180
1524
  }
1181
1525
 
1526
+ /**
1527
+ * @primitive b.db.hashFor
1528
+ * @signature b.db.hashFor(table, field, value)
1529
+ * @since 0.1.0
1530
+ * @status stable
1531
+ * @related b.db.from
1532
+ *
1533
+ * Look up the deterministic SHA3 hash a sealed-source field maps to
1534
+ * via the table's registered `derivedHashes`. Used to query a sealed
1535
+ * column without unsealing every row — operator code passes the
1536
+ * cleartext, the framework hashes it through the same namespaced
1537
+ * derivation, and a `WHERE <hashColumn> = ?` lookup returns the
1538
+ * matching rows. Returns `null` when the field has no derived-hash
1539
+ * declaration on the table.
1540
+ *
1541
+ * @example
1542
+ * var b = require("blamejs");
1543
+ * await b.db.init({ dataDir: "/tmp/data", schema: [
1544
+ * { name: "users",
1545
+ * columns: { _id: "TEXT PRIMARY KEY", email: "TEXT", emailHash: "TEXT" },
1546
+ * sealedFields: ["email"],
1547
+ * derivedHashes: { emailHash: { from: "email" } } },
1548
+ * ] });
1549
+ *
1550
+ * b.db.from("users").insert({ _id: "u1", email: "alice@example.com" });
1551
+ *
1552
+ * var h = b.db.hashFor("users", "email", "alice@example.com");
1553
+ * typeof h;
1554
+ * // → "string"
1555
+ */
1182
1556
  function hashFor(table, field, value) {
1183
1557
  _requireInit();
1184
1558
  var lookup = cryptoField.lookupHash(table, field, value);
1185
1559
  return lookup ? lookup.value : null;
1186
1560
  }
1187
1561
 
1562
+ // _ddlToJsonSchemaType — best-effort SQL→JSON Schema type mapping.
1563
+ // SQLite is dynamically typed but the framework's DDL syntax pins
1564
+ // concrete types; we map them here. Operator-supplied custom types
1565
+ // (rare) fall back to "string" so the schema remains usable.
1566
+ function _ddlToJsonSchemaType(ddl) {
1567
+ if (typeof ddl !== "string" || ddl.length === 0) return { type: "string" };
1568
+ var head = ddl.split(/\s+/)[0].toUpperCase();
1569
+ if (head === "INTEGER" || head === "INT" || head === "BIGINT") return { type: "integer" };
1570
+ if (head === "REAL" || head === "FLOAT" || head === "DOUBLE" || head === "NUMERIC") return { type: "number" };
1571
+ if (head === "BOOLEAN" || head === "BOOL") return { type: "boolean" };
1572
+ if (head === "BLOB") return { type: "string", contentEncoding: "base64" };
1573
+ if (head === "TEXT" || head === "VARCHAR" || head === "CHAR") return { type: "string" };
1574
+ return { type: "string" };
1575
+ }
1576
+
1577
+ // _tableToJsonSchema2020 — emit a JSON Schema 2020-12 description of
1578
+ // the named table. Sealed columns get an `x-blamejs-sealed: true`
1579
+ // annotation so consumers know the value is encrypted at rest;
1580
+ // derived-hash columns gain `x-blamejs-derived-from`. The schema's
1581
+ // `$schema` URI explicitly names the 2020-12 dialect so generated
1582
+ // validators round-trip.
1583
+ function _tableToJsonSchema2020(tableName, meta) {
1584
+ var properties = {};
1585
+ var required = [];
1586
+ var cols = (meta && meta.columns) || {};
1587
+ var colKeys = Object.keys(cols);
1588
+ for (var i = 0; i < colKeys.length; i++) {
1589
+ var col = colKeys[i];
1590
+ var ddl = cols[col];
1591
+ var schema = _ddlToJsonSchemaType(ddl);
1592
+ if (typeof ddl === "string" && /\bNOT\s+NULL\b/i.test(ddl)) {
1593
+ required.push(col);
1594
+ } else {
1595
+ // Nullable column — JSON Schema 2020-12 expresses this as a
1596
+ // type union with "null".
1597
+ schema = { anyOf: [schema, { type: "null" }] };
1598
+ }
1599
+ if (meta.sealedFields && meta.sealedFields.indexOf(col) !== -1) {
1600
+ schema["x-blamejs-sealed"] = true;
1601
+ }
1602
+ if (meta.derivedHashes &&
1603
+ Object.prototype.hasOwnProperty.call(meta.derivedHashes, col)) {
1604
+ schema["x-blamejs-derived-from"] = meta.derivedHashes[col].from;
1605
+ }
1606
+ properties[col] = schema;
1607
+ }
1608
+ return {
1609
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
1610
+ "$id": "blamejs:table:" + tableName,
1611
+ title: tableName,
1612
+ type: "object",
1613
+ properties: properties,
1614
+ required: required,
1615
+ additionalProperties: false,
1616
+ };
1617
+ }
1618
+
1619
+ /**
1620
+ * @primitive b.db.exportCsv
1621
+ * @signature b.db.exportCsv(opts)
1622
+ * @since 0.7.0
1623
+ * @status stable
1624
+ * @related b.db.from, b.auditSign.getPublicKey
1625
+ *
1626
+ * RFC 4180 strict CSV export of a single registered table, with
1627
+ * sealed-column auto-unseal (rides the chainable Query), optional
1628
+ * WHERE filter, optional column projection, optional UTF-8 BOM,
1629
+ * ISO-8601 cast for declared timestamp fields, SHA3-512 manifest of
1630
+ * the byte stream, and an optional detached signature via any
1631
+ * `b.auditSign`-shaped signer. Refuses unknown table names, refuses
1632
+ * arbitrary column strings (every column must belong to the table),
1633
+ * and emits a `db.export.csv` audit row.
1634
+ *
1635
+ * @opts
1636
+ * table: string, // required — registered table name
1637
+ * columns: string[], // optional column projection (default: all)
1638
+ * where: object, // optional Query.where(...) filter
1639
+ * bom: boolean, // default false; emit U+FEFF prefix
1640
+ * format: "rfc4180", // default "rfc4180" (only supported value)
1641
+ * timestampFields: string[], // ms-int columns to cast to ISO-8601
1642
+ * signWith: object, // signer with sign / getPublicKey / getAlgorithm / getPublicKeyFingerprint
1643
+ *
1644
+ * @example
1645
+ * var b = require("blamejs");
1646
+ * await b.db.init({ dataDir: "/tmp/data", schema: [
1647
+ * { name: "orders",
1648
+ * columns: { _id: "TEXT PRIMARY KEY", totalCents: "INTEGER NOT NULL", createdAt: "INTEGER NOT NULL" } },
1649
+ * ] });
1650
+ * b.db.from("orders").insert({ _id: "o1", totalCents: 4999, createdAt: Date.now() });
1651
+ *
1652
+ * var out = b.db.exportCsv({
1653
+ * table: "orders",
1654
+ * columns: ["_id", "totalCents", "createdAt"],
1655
+ * bom: true,
1656
+ * timestampFields: ["createdAt"],
1657
+ * });
1658
+ * typeof out.sha3_512;
1659
+ * // → "string"
1660
+ * out.rowCount >= 1;
1661
+ * // → true
1662
+ */
1663
+ function exportCsv(opts) {
1664
+ _requireInit();
1665
+ if (!opts || typeof opts !== "object") {
1666
+ throw new DbError("db/bad-export-opts", "exportCsv: opts object is required");
1667
+ }
1668
+ validateOpts.requireNonEmptyString(opts.table, "exportCsv: opts.table", DbError, "db/bad-export-table");
1669
+ // Quote-validate the table identifier — refuses anything with embedded
1670
+ // quotes, schema-qualified names valid via dot-separated parts.
1671
+ safeSql.quoteIdentifier(opts.table);
1672
+ var meta = tableMetadata[opts.table];
1673
+ if (!meta) {
1674
+ throw new DbError("db/unknown-table",
1675
+ "exportCsv: '" + opts.table + "' is not a registered table");
1676
+ }
1677
+ var allCols = Object.keys(meta.columns || {});
1678
+ var columns = Array.isArray(opts.columns) && opts.columns.length > 0
1679
+ ? opts.columns.slice()
1680
+ : allCols;
1681
+ // Validate every column belongs to the table (refuses arbitrary
1682
+ // operator strings becoming SQL identifiers).
1683
+ for (var ci = 0; ci < columns.length; ci++) {
1684
+ if (allCols.indexOf(columns[ci]) === -1) {
1685
+ throw new DbError("db/bad-export-column",
1686
+ "exportCsv: column '" + columns[ci] + "' is not in '" + opts.table + "'");
1687
+ }
1688
+ }
1689
+ var bom = opts.bom === true;
1690
+ var format = opts.format || "rfc4180";
1691
+ if (format !== "rfc4180") {
1692
+ throw new DbError("db/bad-export-format",
1693
+ "exportCsv: format must be 'rfc4180', got " + JSON.stringify(format));
1694
+ }
1695
+ var timestampFields = Array.isArray(opts.timestampFields) ? opts.timestampFields : [];
1696
+
1697
+ // Build the query through Query so sealed columns auto-unseal.
1698
+ var q = from(opts.table).select(columns);
1699
+ if (opts.where && typeof opts.where === "object") {
1700
+ q = q.where(opts.where);
1701
+ }
1702
+ var rows = q.all();
1703
+
1704
+ // Project rows into an array-of-arrays in the declared column order,
1705
+ // casting timestamp fields from ms-int → ISO-8601 string.
1706
+ var headerRow = columns.slice();
1707
+ var bodyRows = new Array(rows.length);
1708
+ for (var ri = 0; ri < rows.length; ri++) {
1709
+ var src = rows[ri];
1710
+ var out = new Array(columns.length);
1711
+ for (var cj = 0; cj < columns.length; cj++) {
1712
+ var col = columns[cj];
1713
+ var v = src[col];
1714
+ if (timestampFields.indexOf(col) !== -1 && typeof v === "number" && isFinite(v)) {
1715
+ out[cj] = new Date(v).toISOString();
1716
+ } else if (Buffer.isBuffer(v)) {
1717
+ out[cj] = v.toString("base64");
1718
+ } else if (v === null || v === undefined) {
1719
+ out[cj] = "";
1720
+ } else {
1721
+ out[cj] = String(v);
1722
+ }
1723
+ }
1724
+ bodyRows[ri] = out;
1725
+ }
1726
+
1727
+ var csvBody = csv.stringify([headerRow].concat(bodyRows), { eol: "\r\n" });
1728
+ var fullText = bom ? ("" + csvBody) : csvBody;
1729
+ var bytes = Buffer.from(fullText, "utf8");
1730
+
1731
+ var sha3hex = sha3Hash(bytes).toString("hex");
1732
+
1733
+ var manifest = {
1734
+ version: 1,
1735
+ framework: "blamejs",
1736
+ table: opts.table,
1737
+ columns: columns,
1738
+ rowCount: rows.length,
1739
+ bom: bom,
1740
+ format: format,
1741
+ bytesWritten: bytes.length,
1742
+ sha3_512: sha3hex,
1743
+ exportedAt: new Date().toISOString(),
1744
+ };
1745
+
1746
+ var signature = null;
1747
+ if (opts.signWith) {
1748
+ if (typeof opts.signWith.sign !== "function" ||
1749
+ typeof opts.signWith.getPublicKey !== "function" ||
1750
+ typeof opts.signWith.getAlgorithm !== "function" ||
1751
+ typeof opts.signWith.getPublicKeyFingerprint !== "function") {
1752
+ throw new DbError("db/bad-signer",
1753
+ "exportCsv: signWith must expose sign / getPublicKey / getAlgorithm / getPublicKeyFingerprint");
1754
+ }
1755
+ var sigBuf;
1756
+ try { sigBuf = opts.signWith.sign(bytes); }
1757
+ catch (e) {
1758
+ throw new DbError("db/sign-failed",
1759
+ "exportCsv: sign threw: " + ((e && e.message) || String(e)));
1760
+ }
1761
+ signature = {
1762
+ algorithm: opts.signWith.getAlgorithm(),
1763
+ publicKey: opts.signWith.getPublicKey(),
1764
+ fingerprint: opts.signWith.getPublicKeyFingerprint(),
1765
+ value: sigBuf.toString("base64"),
1766
+ signedAt: new Date().toISOString(),
1767
+ };
1768
+ manifest.signature = signature;
1769
+ }
1770
+
1771
+ audit.safeEmit({
1772
+ action: "db.export.csv",
1773
+ outcome: "success",
1774
+ metadata: {
1775
+ table: opts.table,
1776
+ rowCount: rows.length,
1777
+ sha3_512: sha3hex,
1778
+ bytes: bytes.length,
1779
+ signed: !!signature,
1780
+ },
1781
+ });
1782
+
1783
+ return {
1784
+ csv: fullText,
1785
+ bytes: bytes,
1786
+ bytesWritten: bytes.length,
1787
+ sha3_512: sha3hex,
1788
+ signature: signature,
1789
+ manifest: manifest,
1790
+ rowCount: rows.length,
1791
+ };
1792
+ }
1793
+
1794
+ /**
1795
+ * @primitive b.db.close
1796
+ * @signature b.db.close()
1797
+ * @since 0.1.0
1798
+ * @status stable
1799
+ * @related b.db.init, b.db.flushToDisk
1800
+ *
1801
+ * Idempotent shutdown. Stops the periodic encrypt timer, fires a
1802
+ * best-effort final audit checkpoint when the local node is the
1803
+ * cluster leader, re-encrypts the live tmpfs database back to
1804
+ * `<dataDir>/db.enc`, closes the SQLite handle (releasing the file
1805
+ * lock on Windows), then unlinks the plaintext sidecar files in
1806
+ * tmpfs. Safe to call multiple times — no-ops after the first
1807
+ * successful close.
1808
+ *
1809
+ * @example
1810
+ * var b = require("blamejs");
1811
+ * await b.db.init({ dataDir: "/tmp/data", schema: [] });
1812
+ * b.db.close();
1813
+ * b.db.close();
1814
+ * // → undefined
1815
+ */
1188
1816
  function close() {
1189
1817
  if (!initialized) return;
1190
1818
  if (encTimer) {
@@ -1262,6 +1890,336 @@ function _installAppendOnlyTriggers(database) {
1262
1890
  }
1263
1891
  }
1264
1892
 
1893
+ // Install row-level WORM (write-once-read-many) triggers on
1894
+ // operator-named tables. Per SEC Rule 17a-4(f), FINRA Rule 4511,
1895
+ // and 21 CFR Part 11 §11.10(c). Idempotent (CREATE TRIGGER IF
1896
+ // NOT EXISTS); registers the entry in _blamejs_worm_tables so the
1897
+ // boot-time assertion under WORM_POSTURES catches operators who
1898
+ // set the posture without declaring tables.
1899
+ function _installWormTriggers(database, tableName) {
1900
+ safeSql.validateIdentifier(tableName);
1901
+ runSql(database,
1902
+ 'CREATE TRIGGER IF NOT EXISTS "worm_no_delete_' + tableName + '" ' +
1903
+ 'BEFORE DELETE ON "' + tableName + '" ' +
1904
+ 'BEGIN ' +
1905
+ " SELECT RAISE(ABORT, '" + tableName + " is WORM (write-once-read-many) - DELETE prohibited'); " +
1906
+ 'END'
1907
+ );
1908
+ runSql(database,
1909
+ 'CREATE TRIGGER IF NOT EXISTS "worm_no_update_' + tableName + '" ' +
1910
+ 'BEFORE UPDATE ON "' + tableName + '" ' +
1911
+ 'BEGIN ' +
1912
+ " SELECT RAISE(ABORT, '" + tableName + " is WORM (write-once-read-many) - UPDATE prohibited'); " +
1913
+ 'END'
1914
+ );
1915
+ }
1916
+
1917
+ /**
1918
+ * @primitive b.db.declareWorm
1919
+ * @signature b.db.declareWorm(args)
1920
+ * @since 0.8.0
1921
+ * @status stable
1922
+ * @compliance 21-cfr-11
1923
+ * @related b.db.declareRequireDualControl, b.db.eraseHard
1924
+ *
1925
+ * Install row-level WORM (write-once-read-many) triggers on
1926
+ * operator-named business-record tables. Per SEC Rule 17a-4(f),
1927
+ * FINRA Rule 4511, and 21 CFR Part 11 §11.10(c). UPDATE and DELETE
1928
+ * are refused at the SQLite-trigger level, independent of the
1929
+ * application's discipline. Each declared table is registered in
1930
+ * `_blamejs_worm_tables`; under `sec-17a-4` / `finra-4511` /
1931
+ * `fda-21cfr11` postures the boot-time assertion refuses to start
1932
+ * if the registry is empty. Cluster mode (external-db) refuses the
1933
+ * call — operators install WORM via `b.externalDb.migrate` instead.
1934
+ *
1935
+ * @opts
1936
+ * tables: string[], // required — non-empty array of operator table names
1937
+ * posture: string, // optional — posture label recorded on each row
1938
+ *
1939
+ * @example
1940
+ * var b = require("blamejs");
1941
+ * await b.db.init({ dataDir: "/tmp/data", schema: [
1942
+ * { name: "trade_blotter",
1943
+ * columns: { _id: "TEXT PRIMARY KEY", symbol: "TEXT NOT NULL", qty: "INTEGER NOT NULL" } },
1944
+ * ] });
1945
+ *
1946
+ * var declared = b.db.declareWorm({
1947
+ * tables: ["trade_blotter"],
1948
+ * posture: "sec-17a-4",
1949
+ * });
1950
+ * declared.tables;
1951
+ * // → ["trade_blotter"]
1952
+ */
1953
+ function declareWorm(args) {
1954
+ _requireInit();
1955
+ args = args || {};
1956
+ if (args.tables === undefined || args.tables === null) {
1957
+ throw _wormErr("BAD_OPT",
1958
+ "declareWorm: args.tables is required (array of table names)");
1959
+ }
1960
+ validateOpts.optionalNonEmptyStringArray(args.tables,
1961
+ "declareWorm: args.tables", WormViolationError, "BAD_OPT");
1962
+ if (args.tables.length === 0) {
1963
+ throw _wormErr("BAD_OPT", "declareWorm: args.tables must be non-empty");
1964
+ }
1965
+ for (var i = 0; i < args.tables.length; i++) {
1966
+ safeSql.validateIdentifier(args.tables[i]);
1967
+ }
1968
+ if (args.posture !== undefined && args.posture !== null &&
1969
+ (typeof args.posture !== "string" || args.posture.length === 0)) {
1970
+ throw _wormErr("BAD_OPT", "declareWorm: args.posture must be a non-empty string or null");
1971
+ }
1972
+ if (cluster.isClusterMode()) {
1973
+ throw _wormErr("UNSUPPORTED",
1974
+ "declareWorm: cluster mode (external-db) installs WORM via b.externalDb.migrate; " +
1975
+ "the SQLite trigger primitive is single-node only");
1976
+ }
1977
+ var nowMs = Date.now();
1978
+ var ins = database.prepare(
1979
+ 'INSERT OR REPLACE INTO "_blamejs_worm_tables" (tableName, posture, declaredAt) VALUES (?, ?, ?)'
1980
+ );
1981
+ for (var j = 0; j < args.tables.length; j++) {
1982
+ var t = args.tables[j];
1983
+ if (t === "audit_log" || t === "consent_log" || t === "audit_checkpoints") {
1984
+ throw _wormErr("RESERVED",
1985
+ "declareWorm: '" + t + "' is a framework-managed append-only table; " +
1986
+ "use audit-tools.purge for sanctioned deletions");
1987
+ }
1988
+ _installWormTriggers(database, t);
1989
+ ins.run(t, args.posture || null, nowMs);
1990
+ audit.safeEmit({
1991
+ action: "db.worm.declared",
1992
+ outcome: "success",
1993
+ metadata: { tableName: t, posture: args.posture || null, declaredAt: nowMs },
1994
+ });
1995
+ }
1996
+ return { tables: args.tables.slice(), posture: args.posture || null };
1997
+ }
1998
+
1999
+ function _assertWormUnderPosture() {
2000
+ var posture;
2001
+ try { posture = compliance().current(); } catch (_e) { posture = null; }
2002
+ if (!posture || WORM_POSTURES.indexOf(posture) === -1) return;
2003
+ if (cluster.isClusterMode()) return;
2004
+ var rows;
2005
+ try {
2006
+ rows = database.prepare(
2007
+ 'SELECT tableName FROM "_blamejs_worm_tables"'
2008
+ ).all();
2009
+ } catch (_e) { rows = []; }
2010
+ if (!rows || rows.length === 0) {
2011
+ throw _wormErr("POSTURE_VIOLATION",
2012
+ "FATAL: compliance posture '" + posture + "' requires row-level WORM " +
2013
+ "on business-record tables (per SEC 17a-4(f) / FINRA 4511 / 21 CFR Part 11). " +
2014
+ "Call b.db.declareWorm({ tables: [...], posture: '" + posture + "' }) at boot.");
2015
+ }
2016
+ }
2017
+
2018
+ /**
2019
+ * @primitive b.db.declareRequireDualControl
2020
+ * @signature b.db.declareRequireDualControl(args)
2021
+ * @since 0.8.0
2022
+ * @status stable
2023
+ * @related b.db.declareWorm, b.db.eraseHard
2024
+ *
2025
+ * Gate destructive operations (`b.db.eraseHard`, retention sweeps,
2026
+ * audit purges) on operator-named tables behind an m-of-n dual-
2027
+ * control grant. Each declared table is registered in
2028
+ * `_blamejs_dual_control_gates` with its quorum tuple `(m, n)`; the
2029
+ * gate consult on `eraseHard` refuses execution unless the caller
2030
+ * passes `opts.dualControlGrant` returned by `b.dualControl.consume()`.
2031
+ *
2032
+ * @opts
2033
+ * tables: string[], // required — non-empty array of table names
2034
+ * m: number, // default 2 — minimum approvals
2035
+ * n: number, // default max(2, m) — total approver pool
2036
+ * posture: string, // optional — posture label recorded with the gate
2037
+ *
2038
+ * @example
2039
+ * var b = require("blamejs");
2040
+ * await b.db.init({ dataDir: "/tmp/data", schema: [
2041
+ * { name: "patient_records",
2042
+ * columns: { _id: "TEXT PRIMARY KEY", chartJson: "TEXT" } },
2043
+ * ] });
2044
+ *
2045
+ * var gate = b.db.declareRequireDualControl({
2046
+ * tables: ["patient_records"],
2047
+ * m: 2,
2048
+ * n: 3,
2049
+ * posture: "hipaa",
2050
+ * });
2051
+ * gate.m;
2052
+ * // → 2
2053
+ */
2054
+ function declareRequireDualControl(args) {
2055
+ _requireInit();
2056
+ args = args || {};
2057
+ validateOpts.optionalNonEmptyStringArray(args.tables,
2058
+ "declareRequireDualControl: args.tables", DbError, "db/dual-control-bad-tables");
2059
+ if (!Array.isArray(args.tables) || args.tables.length === 0) {
2060
+ throw new DbError("db/dual-control-bad-tables",
2061
+ "declareRequireDualControl: args.tables must be a non-empty array of table names");
2062
+ }
2063
+ for (var i = 0; i < args.tables.length; i++) {
2064
+ safeSql.validateIdentifier(args.tables[i]);
2065
+ }
2066
+ var m = args.m === undefined ? 2 : args.m;
2067
+ var n = args.n === undefined ? Math.max(2, m) : args.n;
2068
+ if (typeof m !== "number" || !isFinite(m) || m < 2 || Math.floor(m) !== m) {
2069
+ throw new DbError("db/dual-control-bad-quorum",
2070
+ "declareRequireDualControl: m must be an integer >= 2");
2071
+ }
2072
+ if (typeof n !== "number" || !isFinite(n) || n < m || Math.floor(n) !== n) {
2073
+ throw new DbError("db/dual-control-bad-quorum",
2074
+ "declareRequireDualControl: n must be an integer >= m");
2075
+ }
2076
+ if (args.posture !== undefined && args.posture !== null &&
2077
+ (typeof args.posture !== "string" || args.posture.length === 0)) {
2078
+ throw new DbError("db/dual-control-bad-posture",
2079
+ "declareRequireDualControl: args.posture must be a non-empty string or null");
2080
+ }
2081
+ var nowMs = Date.now();
2082
+ var ins = database.prepare(
2083
+ 'INSERT OR REPLACE INTO "_blamejs_dual_control_gates" ' +
2084
+ '(tableName, posture, m, n, declaredAt) VALUES (?, ?, ?, ?, ?)'
2085
+ );
2086
+ for (var j = 0; j < args.tables.length; j++) {
2087
+ ins.run(args.tables[j], args.posture || null, m, n, nowMs);
2088
+ audit.safeEmit({
2089
+ action: "db.dual_control.declared",
2090
+ outcome: "success",
2091
+ metadata: { tableName: args.tables[j], posture: args.posture || null, m: m, n: n },
2092
+ });
2093
+ }
2094
+ return { tables: args.tables.slice(), m: m, n: n, posture: args.posture || null };
2095
+ }
2096
+
2097
+ function _checkDualControlGate(tableName) {
2098
+ if (!initialized) return null;
2099
+ if (cluster.isClusterMode()) return null;
2100
+ var row;
2101
+ try {
2102
+ row = database.prepare(
2103
+ 'SELECT tableName, posture, m, n FROM "_blamejs_dual_control_gates" WHERE tableName = ?'
2104
+ ).get(tableName);
2105
+ } catch (_e) { return null; }
2106
+ return row || null;
2107
+ }
2108
+
2109
+ /**
2110
+ * @primitive b.db.eraseHard
2111
+ * @signature b.db.eraseHard(tableName, rowId, opts)
2112
+ * @since 0.8.0
2113
+ * @status stable
2114
+ * @compliance gdpr, hipaa
2115
+ * @related b.db.declareRequireDualControl, b.subject.erase, b.legalHold
2116
+ *
2117
+ * Crypto-erase one row plus a `REINDEX` on the table so freed B-tree
2118
+ * pages can't reconstruct the deleted row's index entries. Closes
2119
+ * the F-RTBF B-tree-residual class on a per-row basis. Consults the
2120
+ * legal-hold registry (refuses on `subjectId` held) and the dual-
2121
+ * control gate registry (refuses unless `opts.dualControlGrant` is a
2122
+ * consumed grant); emits a `db.erase_hard` audit row on success or a
2123
+ * `db.erase_hard.denied` audit row on either gate refusal.
2124
+ *
2125
+ * @opts
2126
+ * reason: string, // required — non-empty rationale recorded in audit
2127
+ * subjectId: string, // optional — consults legal-hold registry
2128
+ * dualControlGrant: object, // required when the table is gated; from b.dualControl.consume()
2129
+ *
2130
+ * @example
2131
+ * var b = require("blamejs");
2132
+ * await b.db.init({ dataDir: "/tmp/data", schema: [
2133
+ * { name: "stale_pii",
2134
+ * columns: { _id: "TEXT PRIMARY KEY", ssn: "TEXT" },
2135
+ * sealedFields: ["ssn"] },
2136
+ * ] });
2137
+ * b.db.from("stale_pii").insert({ _id: "row1", ssn: "123-45-6789" });
2138
+ *
2139
+ * var result = b.db.eraseHard("stale_pii", "row1", {
2140
+ * reason: "subject erasure under GDPR Art 17",
2141
+ * });
2142
+ * result.rowsDeleted;
2143
+ * // → 1
2144
+ */
2145
+ function eraseHard(tableName, rowId, opts) {
2146
+ _requireInit();
2147
+ opts = opts || {};
2148
+ safeSql.validateIdentifier(tableName);
2149
+ validateOpts.requireNonEmptyString(rowId, "eraseHard: rowId", DbError, "db/erase-hard-bad-row-id");
2150
+ validateOpts.requireNonEmptyString(opts.reason, "eraseHard: opts.reason", DbError, "db/erase-hard-no-reason");
2151
+ if (opts.subjectId) {
2152
+ var legalHoldMod;
2153
+ try { legalHoldMod = require("./legal-hold"); } // allow:inline-require — circular-load defense (legal-hold transitively requires db)
2154
+ catch (_e) { legalHoldMod = null; }
2155
+ var holds = legalHoldMod && legalHoldMod._getSingleton();
2156
+ if (holds && holds.isHeld(opts.subjectId)) {
2157
+ audit.safeEmit({
2158
+ action: "db.erase_hard.denied",
2159
+ outcome: "denied",
2160
+ metadata: { tableName: tableName, rowId: rowId,
2161
+ reason: "legal-hold-active", subjectId: opts.subjectId },
2162
+ });
2163
+ throw new DbError("db/erase-hard-legal-hold",
2164
+ "eraseHard: subject '" + opts.subjectId + "' is on legal hold; " +
2165
+ "release the hold before erasure");
2166
+ }
2167
+ }
2168
+ var gate = _checkDualControlGate(tableName);
2169
+ if (gate && !opts.dualControlGrant) {
2170
+ audit.safeEmit({
2171
+ action: "db.erase_hard.denied",
2172
+ outcome: "denied",
2173
+ metadata: { tableName: tableName, rowId: rowId,
2174
+ reason: "dual-control-required", gate: gate },
2175
+ });
2176
+ throw new DbError("db/erase-hard-dual-control-required",
2177
+ "eraseHard: '" + tableName + "' is gated by dual-control (m=" +
2178
+ gate.m + ", n=" + gate.n + "). Pass opts.dualControlGrant from " +
2179
+ "b.dualControl.consume() to proceed.");
2180
+ }
2181
+ if (gate && opts.dualControlGrant) {
2182
+ var grant = opts.dualControlGrant;
2183
+ if (!grant || grant.ready !== true) {
2184
+ throw new DbError("db/erase-hard-grant-not-ready",
2185
+ "eraseHard: opts.dualControlGrant.ready must be true (consumed grant)");
2186
+ }
2187
+ }
2188
+ var t0 = Date.now();
2189
+ var deleted = 0;
2190
+ transaction(function () {
2191
+ var row = database.prepare(
2192
+ 'SELECT * FROM "' + tableName + '" WHERE _id = ?'
2193
+ ).get(rowId);
2194
+ if (row) {
2195
+ try { cryptoField.eraseRow(tableName, row); } catch (_e) { /* table may have no sealed cols */ }
2196
+ }
2197
+ var del = database.prepare(
2198
+ 'DELETE FROM "' + tableName + '" WHERE _id = ?'
2199
+ );
2200
+ var result = del.run(rowId);
2201
+ deleted = (result && result.changes) || 0;
2202
+ // REINDEX rebuilds every index on the table from scratch,
2203
+ // dropping the B-tree pages that held the deleted row's index
2204
+ // entries.
2205
+ runSql(database, 'REINDEX "' + tableName + '"');
2206
+ });
2207
+ audit.safeEmit({
2208
+ action: "db.erase_hard",
2209
+ outcome: "success",
2210
+ reason: opts.reason,
2211
+ metadata: {
2212
+ tableName: tableName,
2213
+ rowId: rowId,
2214
+ rowsDeleted: deleted,
2215
+ durationMs: Date.now() - t0,
2216
+ subjectId: opts.subjectId || null,
2217
+ dualControlConsumed: !!(gate && opts.dualControlGrant),
2218
+ },
2219
+ });
2220
+ return { rowsDeleted: deleted, durationMs: Date.now() - t0 };
2221
+ }
2222
+
1265
2223
  // Read the audit.tip sidecar file in dataDir and compare to the current
1266
2224
  // audit_log MAX(monotonicCounter). Refuse boot on rollback (current < tip).
1267
2225
  function _checkRollback(dataDirPath) {
@@ -1390,13 +2348,32 @@ function _resetForTest() {
1390
2348
  }
1391
2349
 
1392
2350
 
1393
- // F-RTBF-1 — operator-callable vacuum. Run after a large-scale erase
1394
- // (b.subject.erase batch, b.retention sweep) so freed pages don't
1395
- // linger with sealed-column ciphertext readable from a forensic
1396
- // disk image.
1397
- //
1398
- // await b.db.vacuumAfterErase({ mode: "incremental", pages: 1000 });
1399
- // await b.db.vacuumAfterErase({ mode: "full" });
2351
+ /**
2352
+ * @primitive b.db.vacuumAfterErase
2353
+ * @signature b.db.vacuumAfterErase(opts)
2354
+ * @since 0.8.0
2355
+ * @status stable
2356
+ * @compliance gdpr, hipaa
2357
+ * @related b.db.eraseHard, b.subject.erase
2358
+ *
2359
+ * Run after a large-scale erase (`b.subject.erase` batch,
2360
+ * `b.retention` sweep) so SQLite's freed pages don't linger with
2361
+ * sealed-column ciphertext that a forensic disk image could
2362
+ * recover. `incremental` mode runs `PRAGMA incremental_vacuum(N)`
2363
+ * (default 1000 pages) — fast, doesn't rewrite the whole file.
2364
+ * `full` mode runs `VACUUM` — rewrites every page; the database is
2365
+ * locked for the duration.
2366
+ *
2367
+ * @opts
2368
+ * mode: "incremental"|"full", // default "incremental"
2369
+ * pages: number, // incremental only; default 1000
2370
+ *
2371
+ * @example
2372
+ * var b = require("blamejs");
2373
+ * await b.db.init({ dataDir: "/tmp/data", schema: [] });
2374
+ * b.db.vacuumAfterErase({ mode: "incremental", pages: 500 });
2375
+ * // → undefined
2376
+ */
1400
2377
  function vacuumAfterErase(opts) {
1401
2378
  opts = opts || {};
1402
2379
  var mode = opts.mode || "incremental";
@@ -1431,12 +2408,364 @@ function vacuumAfterErase(opts) {
1431
2408
  } catch (_e) { /* audit best-effort */ }
1432
2409
  }
1433
2410
 
2411
+ // F-POSTURE-1 — cascade-installed posture name. b.compliance.set(p)
2412
+ // calls applyPosture(p) which records the posture; the downstream
2413
+ // cryptoField.eraseRow path consults this via getActivePosture() to
2414
+ // auto-vacuum under postures whose POSTURE_DEFAULTS sets
2415
+ // requireVacuumAfterErase: true.
2416
+ var _activePosture = null;
2417
+
2418
+ /**
2419
+ * @primitive b.db.applyPosture
2420
+ * @signature b.db.applyPosture(posture)
2421
+ * @since 0.8.0
2422
+ * @status stable
2423
+ * @related b.compliance.set, b.db.getActivePosture
2424
+ *
2425
+ * Record the active compliance posture for the database subsystem.
2426
+ * Called by `b.compliance.set(p)` during posture cascade so the
2427
+ * downstream `cryptoField.eraseRow` path can consult
2428
+ * `getActivePosture()` and auto-vacuum under postures whose defaults
2429
+ * set `requireVacuumAfterErase: true`. Returns `null` for empty
2430
+ * input; otherwise `{ posture, dbInitialized }`.
2431
+ *
2432
+ * @example
2433
+ * var b = require("blamejs");
2434
+ * var result = b.db.applyPosture("hipaa");
2435
+ * result.posture;
2436
+ * // → "hipaa"
2437
+ */
2438
+ function applyPosture(posture) {
2439
+ if (typeof posture !== "string" || posture.length === 0) return null;
2440
+ _activePosture = posture;
2441
+ return { posture: posture, dbInitialized: !!database };
2442
+ }
2443
+ /**
2444
+ * @primitive b.db.getActivePosture
2445
+ * @signature b.db.getActivePosture()
2446
+ * @since 0.8.0
2447
+ * @status stable
2448
+ * @related b.db.applyPosture, b.compliance.set
2449
+ *
2450
+ * Read the posture last installed via `applyPosture`. Used by
2451
+ * downstream subsystems (`cryptoField.eraseRow`, retention sweeps)
2452
+ * to branch on posture-driven defaults. Returns `null` before any
2453
+ * posture has been set.
2454
+ *
2455
+ * @example
2456
+ * var b = require("blamejs");
2457
+ * b.db.applyPosture("pci-dss");
2458
+ * b.db.getActivePosture();
2459
+ * // → "pci-dss"
2460
+ */
2461
+ function getActivePosture() { return _activePosture; }
2462
+
2463
+ /**
2464
+ * @primitive b.db.runSql
2465
+ * @signature b.db.runSql(sql)
2466
+ * @since 0.1.0
2467
+ * @status stable
2468
+ * @related b.db.prepare, b.db.transaction
2469
+ *
2470
+ * Execute a raw SQL string with no result-set return — DDL
2471
+ * (`CREATE TABLE` / `DROP TABLE` / `ALTER` / etc.), DML where the
2472
+ * caller doesn't need rows back, and `BEGIN` / `COMMIT` / `ROLLBACK`
2473
+ * outside of `transaction()`. Slow-query observability buckets fire
2474
+ * on every call. DDL statements emit a `db.ddl.executed` audit row
2475
+ * with the leading keyword extracted so a forensic review can
2476
+ * reconstruct schema evolution from the audit chain alone.
2477
+ *
2478
+ * @example
2479
+ * var b = require("blamejs");
2480
+ * await b.db.init({ dataDir: "/tmp/data", schema: [] });
2481
+ * b.db.runSql("CREATE TABLE IF NOT EXISTS scratch (id INTEGER PRIMARY KEY)");
2482
+ * // → undefined
2483
+ */
2484
+
2485
+ /**
2486
+ * @primitive b.db.flushToDisk
2487
+ * @signature b.db.flushToDisk()
2488
+ * @since 0.4.0
2489
+ * @status stable
2490
+ * @related b.db.close, b.db.init
2491
+ *
2492
+ * Force the live tmpfs SQLite to be re-encrypted to
2493
+ * `<dataDir>/db.enc` immediately. The framework already does this
2494
+ * every five minutes and at clean shutdown; operators running a
2495
+ * backup workflow call `flushToDisk()` first so the snapshot source
2496
+ * reflects the most recent committed state. No-op in `atRest:
2497
+ * "plain"` mode (no `db.enc` exists).
2498
+ *
2499
+ * @example
2500
+ * var b = require("blamejs");
2501
+ * await b.db.init({ dataDir: "/tmp/data", atRest: "encrypted", schema: [] });
2502
+ * b.db.flushToDisk();
2503
+ * // → undefined
2504
+ */
2505
+
2506
+ /**
2507
+ * @primitive b.db.getStreamLimit
2508
+ * @signature b.db.getStreamLimit()
2509
+ * @since 0.7.67
2510
+ * @status stable
2511
+ * @related b.db.stream, b.db.init
2512
+ *
2513
+ * Read the module-level `streamLimit` ceiling (default
2514
+ * `1_000_000`). Per-call `opts.streamLimit` on `db.stream` overrides
2515
+ * this; `db.init({ streamLimit })` raises or lowers it for the
2516
+ * process.
2517
+ *
2518
+ * @example
2519
+ * var b = require("blamejs");
2520
+ * await b.db.init({ dataDir: "/tmp/data", schema: [] });
2521
+ * b.db.getStreamLimit() > 0;
2522
+ * // → true
2523
+ */
2524
+
2525
+ /**
2526
+ * @primitive b.db.integrityCheck
2527
+ * @signature b.db.integrityCheck()
2528
+ * @since 0.8.0
2529
+ * @status stable
2530
+ * @related b.db.integrityMonitor, b.db.init
2531
+ *
2532
+ * Run `PRAGMA integrity_check` on the live database. Returns the
2533
+ * string `"ok"` on a clean check or an array of corruption
2534
+ * descriptions otherwise. Operators wire this into a `/healthz`
2535
+ * handler or a periodic monitor.
2536
+ *
2537
+ * @example
2538
+ * var b = require("blamejs");
2539
+ * await b.db.init({ dataDir: "/tmp/data", schema: [] });
2540
+ * b.db.integrityCheck();
2541
+ * // → "ok"
2542
+ */
2543
+
2544
+ /**
2545
+ * @primitive b.db.integrityMonitor
2546
+ * @signature b.db.integrityMonitor(opts)
2547
+ * @since 0.8.0
2548
+ * @status stable
2549
+ * @related b.db.integrityCheck
2550
+ *
2551
+ * Periodic `PRAGMA integrity_check` runner. Returns a handle with
2552
+ * `.stop()` for graceful shutdown. Emits `system.db.integrity_ok` /
2553
+ * `system.db.integrity_corrupt` audit rows and matching
2554
+ * observability counters on every check. Operators pass
2555
+ * `onCorruption` to receive the issues array on detection (alerts,
2556
+ * page outs, kill-switches).
2557
+ *
2558
+ * @opts
2559
+ * intervalMs: number, // default C.TIME.hours(24)
2560
+ * audit: boolean, // default true; emit audit rows on every check
2561
+ * onCorruption: Function, // (issues) => void; fires on corruption
2562
+ *
2563
+ * @example
2564
+ * var b = require("blamejs");
2565
+ * await b.db.init({ dataDir: "/tmp/data", schema: [] });
2566
+ * var mon = b.db.integrityMonitor({
2567
+ * intervalMs: 60000,
2568
+ * onCorruption: function (_issues) { },
2569
+ * });
2570
+ * mon.stop();
2571
+ */
2572
+
2573
+ /**
2574
+ * @primitive b.db.purgeAuditChain
2575
+ * @signature b.db.purgeAuditChain(args)
2576
+ * @since 0.8.0
2577
+ * @status stable
2578
+ * @related b.audit, b.db.eraseHard
2579
+ *
2580
+ * Narrow-purpose `DELETE` against `audit_log` + `audit_checkpoints`
2581
+ * for use by `audit-tools.purge`. Drops the BEFORE-DELETE append-
2582
+ * only triggers inside a transaction, executes the deletion against
2583
+ * rows with `monotonicCounter <= lastPurgedCounter`, then re-
2584
+ * installs the triggers so the append-only invariant resumes.
2585
+ * Cluster mode delegates to `cluster-storage` (no triggers in
2586
+ * external-db). The caller is responsible for verifying purge
2587
+ * legitimacy via `audit-tools.verifyBundle` before invoking.
2588
+ *
2589
+ * @opts
2590
+ * lastPurgedCounter: number, // required — non-negative; rows at or below this counter are deleted
2591
+ *
2592
+ * @example
2593
+ * var b = require("blamejs");
2594
+ * await b.db.init({ dataDir: "/tmp/data", schema: [] });
2595
+ * var result = await b.db.purgeAuditChain({ lastPurgedCounter: 0 });
2596
+ * typeof result.rowsDeleted;
2597
+ * // → "number"
2598
+ */
2599
+
2600
+ /**
2601
+ * @primitive b.db.getMode
2602
+ * @signature b.db.getMode()
2603
+ * @since 0.1.0
2604
+ * @status stable
2605
+ * @related b.db.init, b.db.getDbPath
2606
+ *
2607
+ * Diagnostic accessor — returns the active at-rest posture
2608
+ * (`"encrypted"` or `"plain"`) chosen at `init` time.
2609
+ *
2610
+ * @example
2611
+ * var b = require("blamejs");
2612
+ * await b.db.init({ dataDir: "/tmp/data", atRest: "plain", schema: [] });
2613
+ * b.db.getMode();
2614
+ * // → "plain"
2615
+ */
2616
+
2617
+ /**
2618
+ * @primitive b.db.getDbPath
2619
+ * @signature b.db.getDbPath()
2620
+ * @since 0.1.0
2621
+ * @status stable
2622
+ * @related b.db.getMode
2623
+ *
2624
+ * Diagnostic accessor — returns the absolute path of the live
2625
+ * SQLite file. In encrypted mode this is a tmpfs path
2626
+ * (e.g. `/dev/shm/blamejs-<token>.db`); in plain mode it's
2627
+ * `<dataDir>/blamejs.db`.
2628
+ *
2629
+ * @example
2630
+ * var b = require("blamejs");
2631
+ * await b.db.init({ dataDir: "/tmp/data", atRest: "plain", schema: [] });
2632
+ * typeof b.db.getDbPath();
2633
+ * // → "string"
2634
+ */
2635
+
2636
+ /**
2637
+ * @primitive b.db.getDataResidency
2638
+ * @signature b.db.getDataResidency()
2639
+ * @since 0.7.0
2640
+ * @status stable
2641
+ * @related b.db.init
2642
+ *
2643
+ * Read the operator's declared data-residency configuration (passed
2644
+ * via `db.init({ dataResidency })`). Storage / mail / log
2645
+ * destinations consult this to refuse cross-region writes.
2646
+ *
2647
+ * @example
2648
+ * var b = require("blamejs");
2649
+ * await b.db.init({
2650
+ * dataDir: "/tmp/data",
2651
+ * dataResidency: { region: "eu-west-1" },
2652
+ * schema: [],
2653
+ * });
2654
+ * b.db.getDataResidency().region;
2655
+ * // → "eu-west-1"
2656
+ */
2657
+
2658
+ /**
2659
+ * @primitive b.db.getTableMetadata
2660
+ * @signature b.db.getTableMetadata(nameOrOpts)
2661
+ * @since 0.7.0
2662
+ * @status stable
2663
+ * @related b.db.from, b.db.init
2664
+ *
2665
+ * Reflective metadata for one or every registered table — primary-
2666
+ * key columns, foreign keys, sealed-field list, derived-hash
2667
+ * declarations, subject mapping, personal-data categories. Returns
2668
+ * a deep-copied snapshot; mutations don't affect framework state.
2669
+ * Two-arg form supports format dispatch:
2670
+ * `getTableMetadata({ table, format: "json-schema-2020-12" })`
2671
+ * emits a JSON Schema 2020-12 document with sealed columns
2672
+ * annotated `x-blamejs-sealed: true` and derived-hash columns
2673
+ * annotated `x-blamejs-derived-from: "<source>"`.
2674
+ *
2675
+ * @example
2676
+ * var b = require("blamejs");
2677
+ * await b.db.init({ dataDir: "/tmp/data", schema: [
2678
+ * { name: "users",
2679
+ * columns: { _id: "TEXT PRIMARY KEY", email: "TEXT" },
2680
+ * sealedFields: ["email"] },
2681
+ * ] });
2682
+ *
2683
+ * var meta = b.db.getTableMetadata("users");
2684
+ * meta.sealedFields;
2685
+ * // → ["email"]
2686
+ *
2687
+ * var schema = b.db.getTableMetadata({
2688
+ * table: "users",
2689
+ * format: "json-schema-2020-12",
2690
+ * });
2691
+ * schema.properties.email["x-blamejs-sealed"];
2692
+ * // → true
2693
+ */
2694
+
2695
+ /**
2696
+ * @primitive b.db.declareView
2697
+ * @signature b.db.declareView(opts)
2698
+ * @since 0.8.0
2699
+ * @status stable
2700
+ * @related b.db.declareRowPolicy, b.externalDb.init
2701
+ *
2702
+ * Declarative `CREATE VIEW` + `GRANT` migration spec for a
2703
+ * Postgres-backed `b.externalDb` deployment. Returns a migration-
2704
+ * shape object consumed by `b.externalDb.migrate`. Postgres-only;
2705
+ * fail-fast at apply time on other dialects.
2706
+ *
2707
+ * @opts
2708
+ * name: string, // required — view identifier
2709
+ * select: string, // required — view body
2710
+ * grants: object, // optional — { role: ["SELECT", ...] }
2711
+ * schema: string, // optional — schema-qualified namespace
2712
+ *
2713
+ * @example
2714
+ * var b = require("blamejs");
2715
+ * var spec = b.db.declareView({
2716
+ * name: "active_users",
2717
+ * select: "SELECT id, email FROM users WHERE deleted_at IS NULL",
2718
+ * grants: { app_reader: ["SELECT"] },
2719
+ * });
2720
+ * spec.kind;
2721
+ * // → "view"
2722
+ */
2723
+
2724
+ /**
2725
+ * @primitive b.db.declareRowPolicy
2726
+ * @signature b.db.declareRowPolicy(opts)
2727
+ * @since 0.8.0
2728
+ * @status stable
2729
+ * @related b.db.declareView, b.externalDb.init
2730
+ *
2731
+ * Declarative Postgres ROW LEVEL SECURITY migration spec. Pairs
2732
+ * with `b.externalDb.transaction({ sessionGucs })` for the per-
2733
+ * request `SET LOCAL` plumbing that scopes the policy. Returns a
2734
+ * migration-shape object consumed by `b.externalDb.migrate`.
2735
+ * Postgres-only; fail-fast on other dialects.
2736
+ *
2737
+ * @opts
2738
+ * table: string, // required — target table
2739
+ * name: string, // required — policy identifier
2740
+ * command: string, // optional — "SELECT" | "INSERT" | "UPDATE" | "DELETE" | "ALL"
2741
+ * using: string, // optional — USING expression
2742
+ * withCheck:string, // optional — WITH CHECK expression
2743
+ * roles: string[], // optional — TO role list
2744
+ *
2745
+ * @example
2746
+ * var b = require("blamejs");
2747
+ * var spec = b.db.declareRowPolicy({
2748
+ * table: "orders",
2749
+ * name: "tenant_isolation",
2750
+ * command: "ALL",
2751
+ * using: "tenant_id = current_setting('app.tenant_id')::uuid",
2752
+ * roles: ["app_user"],
2753
+ * });
2754
+ * spec.kind;
2755
+ * // → "row-policy"
2756
+ */
2757
+
1434
2758
  module.exports = {
1435
2759
  init: init,
2760
+ applyPosture: applyPosture,
2761
+ getActivePosture: getActivePosture,
1436
2762
  vacuumAfterErase: vacuumAfterErase,
1437
2763
  from: from,
1438
2764
  prepare: prepare,
1439
2765
  stream: stream,
2766
+ // D-M5 — runtime read-only accessor so Query.stream picks up the
2767
+ // configured ceiling without re-importing module state.
2768
+ getStreamLimit: function () { return streamLimit; },
1440
2769
  runSql: execRaw,
1441
2770
  // SQLite multi-statement helper alias matching the node:sqlite
1442
2771
  // module's shape. Operator migration / seeder files that received
@@ -1582,11 +2911,37 @@ module.exports = {
1582
2911
  // Reflective metadata: PK columns, FK relationships, sealed/derived fields,
1583
2912
  // subject mapping. Useful for tooling, RoPA generation, and admin dashboards.
1584
2913
  // Returns a deep-copied snapshot; mutations don't affect framework state.
1585
- getTableMetadata: function (name) {
1586
- if (!name) return structuredClone(tableMetadata);
1587
- var m = tableMetadata[name];
1588
- return m ? structuredClone(m) : null;
2914
+ //
2915
+ // Two-arg form supports format dispatch:
2916
+ // getTableMetadata({ table: "orders", format: "json-schema-2020-12" })
2917
+ // emits a JSON Schema 2020-12 representation of the table — every
2918
+ // column types out per its DDL, sealed fields gain an "x-blamejs-
2919
+ // sealed" annotation, derived-hash columns gain "x-blamejs-derived-
2920
+ // from", and the schema's $schema URI points at JSON Schema 2020-12.
2921
+ getTableMetadata: function (nameOrOpts) {
2922
+ if (!nameOrOpts) return structuredClone(tableMetadata);
2923
+ if (typeof nameOrOpts === "string") {
2924
+ var m = tableMetadata[nameOrOpts];
2925
+ return m ? structuredClone(m) : null;
2926
+ }
2927
+ if (typeof nameOrOpts !== "object") return null;
2928
+ var tableName = nameOrOpts.table;
2929
+ if (typeof tableName !== "string" || tableName.length === 0) {
2930
+ throw new DbError("db/bad-table-arg",
2931
+ "getTableMetadata: opts.table must be a non-empty string");
2932
+ }
2933
+ var meta = tableMetadata[tableName];
2934
+ if (!meta) return null;
2935
+ var format = nameOrOpts.format || "blamejs";
2936
+ if (format === "blamejs") return structuredClone(meta);
2937
+ if (format === "json-schema-2020-12") {
2938
+ return _tableToJsonSchema2020(tableName, meta);
2939
+ }
2940
+ throw new DbError("db/bad-format",
2941
+ "getTableMetadata: format must be 'blamejs' or 'json-schema-2020-12', got " +
2942
+ JSON.stringify(format));
1589
2943
  },
2944
+ exportCsv: exportCsv,
1590
2945
  // declareView — declarative CREATE VIEW + GRANT migration spec for an
1591
2946
  // externalDb backend. Returns a migration-shape object for use with
1592
2947
  // b.externalDb.migrate. Postgres-only; fail-fast at apply time on other
@@ -1597,6 +2952,22 @@ module.exports = {
1597
2952
  // per-request `SET LOCAL` plumbing. Postgres-only; fail-fast on other
1598
2953
  // dialects. See lib/db-declare-row-policy.js.
1599
2954
  declareRowPolicy: dbDeclareRowPolicy.declareRowPolicy,
2955
+ // declareWorm — install row-level WORM (write-once-read-many) on
2956
+ // operator-named business-record tables. Per SEC Rule 17a-4(f),
2957
+ // FINRA Rule 4511, 21 CFR Part 11 §11.10(c). Boot-time assertion
2958
+ // refuses to continue under sec-17a-4 / finra-4511 / fda-21cfr11
2959
+ // postures unless at least one table is declared.
2960
+ declareWorm: declareWorm,
2961
+ // declareRequireDualControl — gate destructive ops (erase / purge /
2962
+ // physical delete) on operator-named tables behind an m-of-n
2963
+ // dual-control grant from b.dualControl.consume(). Caller passes
2964
+ // the consumed grant via opts.dualControlGrant on b.db.eraseHard.
2965
+ declareRequireDualControl: declareRequireDualControl,
2966
+ // eraseHard — full crypto-erase + REINDEX for one row, with
2967
+ // legal-hold + dual-control gate consult. Closes the F-RTBF
2968
+ // B-tree residual class on a per-row basis.
2969
+ eraseHard: eraseHard,
2970
+ _assertWormUnderPosture: _assertWormUnderPosture,
1600
2971
  // Internal accessors used by audit / subject / consent modules.
1601
2972
  // Not part of the public contract — apps should not depend on them.
1602
2973
  _getSubjectTables: function () { return subjectTables.slice(); },