@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.
- package/CHANGELOG.md +93 -0
- package/README.md +10 -10
- package/index.js +52 -0
- package/lib/a2a.js +159 -34
- package/lib/acme.js +762 -0
- package/lib/ai-pref.js +166 -43
- package/lib/api-key.js +108 -47
- package/lib/api-snapshot.js +157 -40
- package/lib/app-shutdown.js +113 -77
- package/lib/archive.js +337 -40
- package/lib/arg-parser.js +697 -0
- package/lib/asyncapi.js +99 -55
- package/lib/atomic-file.js +465 -104
- package/lib/audit-chain.js +123 -34
- package/lib/audit-daily-review.js +389 -0
- package/lib/audit-sign.js +302 -56
- package/lib/audit-tools.js +412 -63
- package/lib/audit.js +656 -35
- package/lib/auth/jwt-external.js +17 -0
- package/lib/auth/oauth.js +7 -0
- package/lib/auth-bot-challenge.js +505 -0
- package/lib/auth-header.js +92 -25
- package/lib/backup/bundle.js +26 -0
- package/lib/backup/index.js +512 -89
- package/lib/backup/manifest.js +168 -7
- package/lib/break-glass.js +415 -39
- package/lib/budr.js +103 -30
- package/lib/bundler.js +86 -66
- package/lib/cache.js +192 -72
- package/lib/chain-writer.js +65 -40
- package/lib/circuit-breaker.js +56 -33
- package/lib/cli-helpers.js +106 -75
- package/lib/cli.js +6 -30
- package/lib/cloud-events.js +99 -32
- package/lib/cluster-storage.js +162 -37
- package/lib/cluster.js +340 -49
- package/lib/codepoint-class.js +66 -0
- package/lib/compliance.js +424 -24
- package/lib/config-drift.js +111 -46
- package/lib/config.js +94 -40
- package/lib/consent.js +165 -18
- package/lib/constants.js +1 -0
- package/lib/content-credentials.js +153 -48
- package/lib/cookies.js +154 -62
- package/lib/credential-hash.js +133 -61
- package/lib/crypto-field.js +702 -18
- package/lib/crypto-hpke.js +256 -0
- package/lib/crypto.js +744 -22
- package/lib/csv.js +178 -35
- package/lib/daemon.js +456 -0
- package/lib/dark-patterns.js +186 -55
- package/lib/db-query.js +79 -2
- package/lib/db.js +1431 -60
- package/lib/ddl-change-control.js +523 -0
- package/lib/deprecate.js +195 -40
- package/lib/dev.js +82 -39
- package/lib/dora.js +67 -48
- package/lib/dr-runbook.js +368 -0
- package/lib/dsr.js +142 -11
- package/lib/dual-control.js +91 -56
- package/lib/events.js +120 -41
- package/lib/external-db-migrate.js +192 -2
- package/lib/external-db.js +795 -50
- package/lib/fapi2.js +122 -1
- package/lib/fda-21cfr11.js +395 -0
- package/lib/fdx.js +132 -2
- package/lib/file-type.js +87 -0
- package/lib/file-upload.js +93 -0
- package/lib/flag.js +82 -20
- package/lib/forms.js +132 -29
- package/lib/framework-error.js +169 -0
- package/lib/framework-schema.js +163 -35
- package/lib/gate-contract.js +849 -175
- package/lib/graphql-federation.js +68 -7
- package/lib/guard-all.js +172 -55
- package/lib/guard-archive.js +286 -124
- package/lib/guard-auth.js +194 -21
- package/lib/guard-cidr.js +190 -28
- package/lib/guard-csv.js +397 -51
- package/lib/guard-domain.js +213 -91
- package/lib/guard-email.js +236 -29
- package/lib/guard-filename.js +307 -75
- package/lib/guard-graphql.js +263 -30
- package/lib/guard-html.js +310 -116
- package/lib/guard-image.js +243 -30
- package/lib/guard-json.js +260 -54
- package/lib/guard-jsonpath.js +235 -23
- package/lib/guard-jwt.js +284 -30
- package/lib/guard-markdown.js +204 -22
- package/lib/guard-mime.js +190 -26
- package/lib/guard-oauth.js +277 -28
- package/lib/guard-pdf.js +251 -27
- package/lib/guard-regex.js +226 -18
- package/lib/guard-shell.js +229 -26
- package/lib/guard-svg.js +177 -10
- package/lib/guard-template.js +232 -21
- package/lib/guard-time.js +195 -29
- package/lib/guard-uuid.js +189 -30
- package/lib/guard-xml.js +259 -36
- package/lib/guard-yaml.js +241 -44
- package/lib/honeytoken.js +63 -27
- package/lib/html-balance.js +83 -0
- package/lib/http-client.js +486 -59
- package/lib/http-message-signature.js +582 -0
- package/lib/i18n.js +102 -49
- package/lib/iab-mspa.js +112 -32
- package/lib/iab-tcf.js +107 -2
- package/lib/inbox.js +90 -52
- package/lib/keychain.js +865 -0
- package/lib/legal-hold.js +374 -0
- package/lib/local-db-thin.js +320 -0
- package/lib/log-stream.js +281 -51
- package/lib/log.js +184 -86
- package/lib/mail-bounce.js +107 -62
- package/lib/mail.js +295 -58
- package/lib/mcp.js +108 -27
- package/lib/metrics.js +98 -89
- package/lib/middleware/age-gate.js +36 -0
- package/lib/middleware/ai-act-disclosure.js +37 -0
- package/lib/middleware/api-encrypt.js +45 -0
- package/lib/middleware/assetlinks.js +40 -0
- package/lib/middleware/asyncapi-serve.js +35 -0
- package/lib/middleware/attach-user.js +40 -0
- package/lib/middleware/bearer-auth.js +40 -0
- package/lib/middleware/body-parser.js +230 -0
- package/lib/middleware/bot-disclose.js +34 -0
- package/lib/middleware/bot-guard.js +39 -0
- package/lib/middleware/compression.js +37 -0
- package/lib/middleware/cookies.js +32 -0
- package/lib/middleware/cors.js +40 -0
- package/lib/middleware/csp-nonce.js +40 -0
- package/lib/middleware/csp-report.js +34 -0
- package/lib/middleware/csrf-protect.js +43 -0
- package/lib/middleware/daily-byte-quota.js +53 -85
- package/lib/middleware/db-role-for.js +40 -0
- package/lib/middleware/dpop.js +40 -0
- package/lib/middleware/error-handler.js +37 -14
- package/lib/middleware/fetch-metadata.js +39 -0
- package/lib/middleware/flag-context.js +34 -0
- package/lib/middleware/gpc.js +33 -0
- package/lib/middleware/headers.js +35 -0
- package/lib/middleware/health.js +46 -0
- package/lib/middleware/host-allowlist.js +30 -0
- package/lib/middleware/network-allowlist.js +38 -0
- package/lib/middleware/openapi-serve.js +34 -0
- package/lib/middleware/rate-limit.js +160 -18
- package/lib/middleware/request-id.js +36 -18
- package/lib/middleware/request-log.js +37 -0
- package/lib/middleware/require-aal.js +29 -0
- package/lib/middleware/require-auth.js +32 -0
- package/lib/middleware/require-bound-key.js +41 -0
- package/lib/middleware/require-content-type.js +32 -0
- package/lib/middleware/require-methods.js +27 -0
- package/lib/middleware/require-mtls.js +33 -0
- package/lib/middleware/require-step-up.js +37 -0
- package/lib/middleware/security-headers.js +44 -0
- package/lib/middleware/security-txt.js +38 -0
- package/lib/middleware/span-http-server.js +37 -0
- package/lib/middleware/sse.js +36 -0
- package/lib/middleware/trace-log-correlation.js +33 -0
- package/lib/middleware/trace-propagate.js +32 -0
- package/lib/middleware/tus-upload.js +90 -0
- package/lib/middleware/web-app-manifest.js +53 -0
- package/lib/mtls-ca.js +100 -70
- package/lib/network-byte-quota.js +308 -0
- package/lib/network-heartbeat.js +135 -0
- package/lib/network-tls.js +534 -4
- package/lib/network.js +103 -0
- package/lib/notify.js +114 -43
- package/lib/ntp-check.js +192 -51
- package/lib/observability.js +145 -47
- package/lib/openapi.js +90 -44
- package/lib/outbox.js +99 -1
- package/lib/pagination.js +168 -86
- package/lib/parsers/index.js +16 -5
- package/lib/permissions.js +93 -40
- package/lib/pqc-agent.js +84 -8
- package/lib/pqc-software.js +94 -60
- package/lib/process-spawn.js +95 -21
- package/lib/pubsub.js +96 -66
- package/lib/queue.js +375 -54
- package/lib/redact.js +793 -21
- package/lib/render.js +139 -47
- package/lib/request-helpers.js +485 -121
- package/lib/restore-bundle.js +142 -39
- package/lib/restore-rollback.js +136 -45
- package/lib/retention.js +178 -50
- package/lib/retry.js +116 -33
- package/lib/router.js +475 -23
- package/lib/safe-async.js +543 -94
- package/lib/safe-buffer.js +337 -41
- package/lib/safe-json.js +467 -62
- package/lib/safe-jsonpath.js +285 -0
- package/lib/safe-schema.js +631 -87
- package/lib/safe-sql.js +221 -59
- package/lib/safe-url.js +278 -46
- package/lib/sandbox-worker.js +135 -0
- package/lib/sandbox.js +358 -0
- package/lib/scheduler.js +135 -70
- package/lib/self-update.js +647 -0
- package/lib/session-device-binding.js +431 -0
- package/lib/session.js +259 -49
- package/lib/slug.js +138 -26
- package/lib/ssrf-guard.js +316 -56
- package/lib/storage.js +433 -70
- package/lib/subject.js +405 -23
- package/lib/template.js +148 -8
- package/lib/tenant-quota.js +545 -0
- package/lib/testing.js +440 -53
- package/lib/time.js +291 -23
- package/lib/tls-exporter.js +239 -0
- package/lib/tracing.js +90 -74
- package/lib/uuid.js +97 -22
- package/lib/vault/index.js +284 -22
- package/lib/vault/seal-pem-file.js +66 -0
- package/lib/watcher.js +368 -0
- package/lib/webhook.js +196 -63
- package/lib/websocket.js +393 -68
- package/lib/wiki-concepts.js +338 -0
- package/lib/worker-pool.js +464 -0
- package/package.json +3 -3
- package/sbom.cyclonedx.json +7 -7
package/lib/db.js
CHANGED
|
@@ -1,43 +1,45 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* plain
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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(
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
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
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
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
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
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(); },
|