@blamejs/core 0.8.59 → 0.8.64

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.
@@ -0,0 +1,333 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.db.fileLifecycle
4
+ * @nav Data
5
+ * @title DB file lifecycle
6
+ * @order 215
7
+ * @card Standalone encrypted-DB-file lifecycle primitive — handles
8
+ * decrypt-to-tmpfs, periodic re-encrypt flush, in-memory
9
+ * snapshot, and graceful shutdown without taking ownership
10
+ * of the SQLite connection or schema. Lets consumers that
11
+ * keep their own `node:sqlite` handle (downstream
12
+ * frameworks, the wiki example, anyone with custom
13
+ * schema/migrations) share one tested implementation.
14
+ *
15
+ * @intro
16
+ * `b.db` owns the entire data layer — schema reconcile, audit
17
+ * chain, sealed columns, the works. That's the right tradeoff when
18
+ * the framework owns the deployment. But operators with their own
19
+ * schema management, their own migration tool, or a Mongo-style
20
+ * document model still want the framework's at-rest encryption +
21
+ * periodic re-flush + WAL-checkpoint snapshot logic without giving
22
+ * up their own connection.
23
+ *
24
+ * `b.db.fileLifecycle(opts)` packages just that slice:
25
+ *
26
+ * 1. Decrypt `<dataDir>/db.enc` (or `opts.encryptedDbPath`) to a
27
+ * random tmpfs file (`/dev/shm/<consumer>-<random>.db`).
28
+ * 2. Surface the plaintext path (`lifecycle.dbPath`) — operators
29
+ * open their own `new DatabaseSync(dbPath)`.
30
+ * 3. Periodically (every `flushIntervalMs`) re-encrypt the
31
+ * plaintext file back to `<dataDir>/db.enc`, after running
32
+ * `PRAGMA wal_checkpoint(TRUNCATE)` against the operator's
33
+ * connection so committed pages are folded in.
34
+ * 4. Provide `snapshot(db)` for backup callers — same envelope
35
+ * as the on-disk encPath, returned as a Buffer.
36
+ * 5. Provide `flushAndCleanup(db, opts)` for graceful shutdown —
37
+ * force-flush, optionally remove the plaintext sidecar.
38
+ *
39
+ * The DB encryption key is read from / created at `opts.dbKeyPath`
40
+ * (default `<dataDir>/db.key.enc`). The key file is itself
41
+ * vault-sealed (operator's `b.vault` instance) — turning the key
42
+ * into per-row data still doesn't help an attacker without the
43
+ * vault keypair.
44
+ *
45
+ * Composes:
46
+ * - `b.crypto.encryptPacked` / `decryptPacked` — same envelope
47
+ * `b.db` writes, including the deployment-bound AAD.
48
+ * - `b.atomicFile` — durable writes that don't leave a partial
49
+ * db.enc on a crashed flush.
50
+ * - operator's `b.vault` instance — seals the DB key on first
51
+ * generation and unseals it at boot.
52
+ *
53
+ * The framework does NOT touch the SQLite handle — every method
54
+ * that needs to issue SQL takes the operator's `db` argument
55
+ * explicitly. This keeps the lifecycle primitive composable with
56
+ * any sqlite-shaped layer (node:sqlite, better-sqlite3,
57
+ * bun:sqlite).
58
+ */
59
+
60
+ var fs = require("fs");
61
+ var os = require("os");
62
+ var path = require("path");
63
+ var atomicFile = require("./atomic-file");
64
+ var C = require("./constants");
65
+ var { generateBytes, generateToken, encryptPacked, decryptPacked } = require("./crypto");
66
+ var lazyRequire = require("./lazy-require");
67
+ var validateOpts = require("./validate-opts");
68
+ var { defineClass } = require("./framework-error");
69
+
70
+ var audit = lazyRequire(function () { return require("./audit"); });
71
+ var observability = lazyRequire(function () { return require("./observability"); });
72
+ var emit = validateOpts.makeNamespacedEmitters("db.fileLifecycle", { audit: audit, observability: observability });
73
+
74
+ var DbFileLifecycleError = defineClass("DbFileLifecycleError", { alwaysPermanent: true });
75
+
76
+ var DEFAULT_FLUSH_INTERVAL_MS = C.TIME.minutes(5);
77
+ var DB_ENC_KEY_BYTES = 32; // allow:raw-byte-literal — 256-bit symmetric key
78
+ var TMP_NAME_BYTES = 16; // allow:raw-byte-literal — random suffix
79
+
80
+ var _emitAudit = emit.audit;
81
+ var _emitMetric = emit.metric;
82
+
83
+ function _aad(dataDir, label) {
84
+ // Same shape as db.js — bound to the operator's data dir + a
85
+ // consumer-specific label so two consumers under the same dataDir
86
+ // can't swap envelopes.
87
+ return Buffer.from("blamejs.db-file-lifecycle.v1\0" + label + "\0" + (dataDir || ""), "utf8");
88
+ }
89
+
90
+ function _resolveTmpDir(operatorTmpDir, allowDiskFallback) {
91
+ if (operatorTmpDir) return operatorTmpDir;
92
+ // Linux: /dev/shm is the standard tmpfs mount.
93
+ if (process.platform === "linux") {
94
+ try {
95
+ var st = fs.statSync("/dev/shm");
96
+ if (st && st.isDirectory()) return "/dev/shm";
97
+ } catch (_e) { /* fall through */ }
98
+ }
99
+ // Other platforms: only fall back to os.tmpdir() when explicitly
100
+ // permitted — falling back silently lets plaintext leak into
101
+ // backup-included paths on macOS / Windows / non-tmpfs containers.
102
+ if (allowDiskFallback) return os.tmpdir();
103
+ throw new DbFileLifecycleError("db-file-lifecycle/no-tmpfs",
104
+ "fileLifecycle: no tmpfs path resolved. Set opts.tmpDir to a tmpfs mount " +
105
+ "OR set opts.allowDiskFallback: true to accept disk-backed temporary storage.");
106
+ }
107
+
108
+ /**
109
+ * @primitive b.db.fileLifecycle
110
+ * @signature b.db.fileLifecycle(opts)
111
+ * @since 0.8.62
112
+ * @status stable
113
+ * @related b.db.snapshot, b.db.flushToDisk, b.crypto.encryptPacked
114
+ *
115
+ * Returns an encrypted-DB-file lifecycle handle. Methods:
116
+ *
117
+ * - `decryptToTmp()` — decrypt the encrypted DB file to a fresh
118
+ * tmpfs path and return the path. Idempotent: subsequent calls
119
+ * return the existing path.
120
+ * - `dbPath` — the resolved plaintext-tmpfs path (set after
121
+ * `decryptToTmp()` runs).
122
+ * - `startFlushTimer(db, opts?)` — start a periodic flush timer
123
+ * against the operator's SQLite handle. Returns a stop function.
124
+ * - `flushNow(db)` — force a single re-encrypt flush (WAL
125
+ * checkpoint + write encPath atomically). Used by backup paths.
126
+ * - `snapshot(db)` — return the encrypted Buffer (same envelope
127
+ * as `flushNow` writes), without touching the disk encPath.
128
+ * - `flushAndCleanup(db, opts)` — shutdown sequence: flushNow,
129
+ * close the handle, optionally remove the plaintext file +
130
+ * WAL/SHM sidecars.
131
+ *
132
+ * @opts
133
+ * {
134
+ * dataDir: string, // operator's data dir (used as AAD)
135
+ * tmpDir?: string, // tmpfs path; default /dev/shm on Linux
136
+ * allowDiskFallback?: boolean, // permit os.tmpdir() fallback (warns)
137
+ * encryptedDbPath?: string, // default <dataDir>/db.enc
138
+ * encryptedDbName?: string, // basename under dataDir (default "db.enc")
139
+ * dbKeyPath?: string, // default <dataDir>/db.key.enc
140
+ * vault: <b.vault instance>, // for sealing the DB key
141
+ * label?: string, // AAD label (default "default")
142
+ * flushIntervalMs?: number, // default 5 minutes
143
+ * }
144
+ *
145
+ * @example
146
+ * var lc = b.db.fileLifecycle({ dataDir: "/var/lib/app", vault: b.vault });
147
+ * var dbPath = lc.decryptToTmp();
148
+ * var db = new (require("node:sqlite").DatabaseSync)(dbPath);
149
+ * var stop = lc.startFlushTimer(db);
150
+ * // ... operator runs the app ...
151
+ * process.on("exit", function () { lc.flushAndCleanup(db, { removePlaintext: true }); });
152
+ */
153
+ function fileLifecycle(opts) {
154
+ validateOpts.requireObject(opts, "db.fileLifecycle", DbFileLifecycleError);
155
+ validateOpts.requireNonEmptyString(opts.dataDir, "db.fileLifecycle: dataDir",
156
+ DbFileLifecycleError, "db-file-lifecycle/no-data-dir");
157
+ if (!opts.vault || typeof opts.vault.seal !== "function" || typeof opts.vault.unseal !== "function") {
158
+ throw new DbFileLifecycleError("db-file-lifecycle/no-vault",
159
+ "fileLifecycle: opts.vault must expose seal/unseal (use b.vault after b.vault.init resolves)");
160
+ }
161
+ validateOpts.optionalPositiveFinite(opts.flushIntervalMs, "flushIntervalMs",
162
+ DbFileLifecycleError, "db-file-lifecycle/bad-flush-interval");
163
+
164
+ var label = opts.label || "default";
165
+ var encName = opts.encryptedDbName || "db.enc";
166
+ var encPath = opts.encryptedDbPath
167
+ ? path.resolve(opts.encryptedDbPath)
168
+ : path.join(opts.dataDir, encName);
169
+ var keyPath = opts.dbKeyPath
170
+ ? path.resolve(opts.dbKeyPath)
171
+ : path.join(opts.dataDir, "db.key.enc");
172
+ var flushIntervalMs = opts.flushIntervalMs || DEFAULT_FLUSH_INTERVAL_MS;
173
+ var tmpDir = _resolveTmpDir(opts.tmpDir, opts.allowDiskFallback === true);
174
+ if (!fs.existsSync(tmpDir)) fs.mkdirSync(tmpDir, { recursive: true });
175
+ if (!fs.existsSync(opts.dataDir)) fs.mkdirSync(opts.dataDir, { recursive: true });
176
+
177
+ var dbPath = null;
178
+ var encKey = null;
179
+ var encTimer = null;
180
+ var decrypted = false;
181
+
182
+ function _loadOrGenerateKey() {
183
+ if (encKey) return encKey;
184
+ if (fs.existsSync(keyPath)) {
185
+ var sealedKey = fs.readFileSync(keyPath, "utf8");
186
+ var keyB64;
187
+ try { keyB64 = opts.vault.unseal(sealedKey); }
188
+ catch (e) {
189
+ throw new DbFileLifecycleError("db-file-lifecycle/key-unseal-failed",
190
+ "fileLifecycle: cannot unseal " + keyPath + " — vault keypair changed? " +
191
+ "(" + ((e && e.message) || String(e)) + ")");
192
+ }
193
+ encKey = Buffer.from(keyB64, "base64");
194
+ if (encKey.length !== DB_ENC_KEY_BYTES) {
195
+ throw new DbFileLifecycleError("db-file-lifecycle/bad-key-length",
196
+ "fileLifecycle: unsealed key is " + encKey.length + " bytes (expected " + DB_ENC_KEY_BYTES + ")");
197
+ }
198
+ return encKey;
199
+ }
200
+ // First boot — generate, seal, persist.
201
+ encKey = generateBytes(DB_ENC_KEY_BYTES);
202
+ var sealed = opts.vault.seal(encKey.toString("base64"));
203
+ atomicFile.writeSync(keyPath, sealed);
204
+ _emitAudit("key_generated", "success", { label: label });
205
+ return encKey;
206
+ }
207
+
208
+ function decryptToTmp() {
209
+ if (decrypted) return dbPath;
210
+ _loadOrGenerateKey();
211
+ dbPath = path.join(tmpDir, "blamejs-fl-" + label + "-" +
212
+ generateToken(TMP_NAME_BYTES) + ".db");
213
+ if (fs.existsSync(encPath)) {
214
+ var packed = fs.readFileSync(encPath);
215
+ if (packed.length < 26) { // allow:raw-byte-literal — minimum envelope length
216
+ throw new DbFileLifecycleError("db-file-lifecycle/short-envelope",
217
+ "fileLifecycle: " + encPath + " too short to be a valid envelope (" + packed.length + " bytes)");
218
+ }
219
+ var aad = _aad(opts.dataDir, label);
220
+ try {
221
+ atomicFile.writeSync(dbPath, decryptPacked(packed, encKey, aad));
222
+ } catch (e) {
223
+ throw new DbFileLifecycleError("db-file-lifecycle/decrypt-failed",
224
+ "fileLifecycle: decrypt of " + encPath + " failed: " + ((e && e.message) || String(e)));
225
+ }
226
+ }
227
+ // If encPath doesn't exist, the operator opens a fresh empty DB
228
+ // at dbPath; the first flushNow() will materialize encPath.
229
+ decrypted = true;
230
+ _emitAudit("decrypted", "success", {
231
+ label: label,
232
+ encPath: encPath,
233
+ dbPath: dbPath,
234
+ isEmpty: !fs.existsSync(encPath),
235
+ });
236
+ _emitMetric("decrypted");
237
+ return dbPath;
238
+ }
239
+
240
+ function flushNow(db) {
241
+ if (!decrypted || !dbPath) {
242
+ throw new DbFileLifecycleError("db-file-lifecycle/not-decrypted",
243
+ "fileLifecycle.flushNow: decryptToTmp() must run first");
244
+ }
245
+ if (db && typeof db.prepare === "function") {
246
+ try { db.prepare("PRAGMA wal_checkpoint(TRUNCATE)").run(); }
247
+ catch (_e) { /* best-effort — operators on read-only handles or pre-init still flush */ }
248
+ }
249
+ if (!fs.existsSync(dbPath)) return null;
250
+ var plain = fs.readFileSync(dbPath);
251
+ var packed = encryptPacked(plain, encKey, _aad(opts.dataDir, label));
252
+ atomicFile.writeSync(encPath, packed);
253
+ _emitAudit("flushed", "success", { label: label, bytes: plain.length });
254
+ _emitMetric("flushed");
255
+ return packed;
256
+ }
257
+
258
+ function snapshot(db) {
259
+ if (!decrypted || !dbPath) {
260
+ throw new DbFileLifecycleError("db-file-lifecycle/not-decrypted",
261
+ "fileLifecycle.snapshot: decryptToTmp() must run first");
262
+ }
263
+ if (db && typeof db.prepare === "function") {
264
+ try { db.prepare("PRAGMA wal_checkpoint(TRUNCATE)").run(); }
265
+ catch (_e) { /* best-effort */ }
266
+ }
267
+ if (!fs.existsSync(dbPath)) {
268
+ throw new DbFileLifecycleError("db-file-lifecycle/no-source",
269
+ "fileLifecycle.snapshot: " + dbPath + " is missing");
270
+ }
271
+ var plain = fs.readFileSync(dbPath);
272
+ return encryptPacked(plain, encKey, _aad(opts.dataDir, label));
273
+ }
274
+
275
+ function startFlushTimer(db, sopts) {
276
+ sopts = sopts || {};
277
+ if (encTimer) {
278
+ throw new DbFileLifecycleError("db-file-lifecycle/timer-already-running",
279
+ "fileLifecycle.startFlushTimer: timer already running — call stop() first");
280
+ }
281
+ var interval = sopts.intervalMs || flushIntervalMs;
282
+ encTimer = setInterval(function () { // allow:setinterval-unref — .unref() called immediately below; timer doesn't pin the event loop
283
+ try { flushNow(db); }
284
+ catch (e) {
285
+ _emitAudit("flush_failed", "failure", {
286
+ label: label,
287
+ reason: (e && e.message) || String(e),
288
+ });
289
+ }
290
+ }, interval);
291
+ if (typeof encTimer.unref === "function") encTimer.unref();
292
+ return function stop() {
293
+ if (encTimer) { clearInterval(encTimer); encTimer = null; }
294
+ };
295
+ }
296
+
297
+ function flushAndCleanup(db, fopts) {
298
+ fopts = fopts || {};
299
+ if (encTimer) { clearInterval(encTimer); encTimer = null; }
300
+ try { flushNow(db); }
301
+ catch (e) {
302
+ _emitAudit("shutdown_flush_failed", "failure", {
303
+ label: label, reason: (e && e.message) || String(e),
304
+ });
305
+ throw e;
306
+ }
307
+ if (db && typeof db.close === "function") {
308
+ try { db.close(); } catch (_e) { /* best-effort */ }
309
+ }
310
+ if (fopts.removePlaintext === true && dbPath) {
311
+ try { fs.unlinkSync(dbPath); } catch (_e) { /* best-effort */ }
312
+ try { fs.unlinkSync(dbPath + "-wal"); } catch (_e) { /* best-effort */ }
313
+ try { fs.unlinkSync(dbPath + "-shm"); } catch (_e) { /* best-effort */ }
314
+ }
315
+ _emitAudit("shutdown", "success", { label: label });
316
+ }
317
+
318
+ return {
319
+ get dbPath() { return dbPath; },
320
+ get encPath() { return encPath; },
321
+ get keyPath() { return keyPath; },
322
+ decryptToTmp: decryptToTmp,
323
+ flushNow: flushNow,
324
+ snapshot: snapshot,
325
+ startFlushTimer: startFlushTimer,
326
+ flushAndCleanup: flushAndCleanup,
327
+ };
328
+ }
329
+
330
+ module.exports = {
331
+ fileLifecycle: fileLifecycle,
332
+ DbFileLifecycleError: DbFileLifecycleError,
333
+ };
@@ -0,0 +1,138 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.session.stores
4
+ * @nav Identity
5
+ * @title Session stores
6
+ * @order 460
7
+ * @card Pluggable backends for `b.session`. Default keeps sessions in
8
+ * the framework's main DB; the `localDbThin` adapter routes
9
+ * session writes to an isolated SQLite file (typically tmpfs)
10
+ * so heavy session churn doesn't fight the main DB's WAL
11
+ * fsync + at-rest re-encryption cycle.
12
+ *
13
+ * @intro
14
+ * `b.session` writes through a pluggable storage backend. The default
15
+ * uses `b.clusterStorage`, which dispatches to the framework's main
16
+ * DB in single-node deployments and to the configured external DB in
17
+ * cluster mode. Sealed-column sealing, derived-hash lookup, and
18
+ * audit emission live in `b.session` itself, not in the store —
19
+ * adapters only need to expose the two primitives `b.session` calls
20
+ * into:
21
+ *
22
+ * execute(sql, params) -> Promise<{ rows: Row[], rowCount: number }>
23
+ * executeOne(sql, params) -> Promise<Row | null>
24
+ *
25
+ * `b.session.stores.localDbThin({ file })` ships first-party. It
26
+ * wraps `b.localDb.thin` with the matching `_blamejs_sessions`
27
+ * schema (sidHash PRIMARY KEY, userId, userIdHash, data, createdAt,
28
+ * expiresAt, lastActivity) plus the indexes session-side queries
29
+ * need (userIdHash for `destroyAllForUser`, expiresAt for
30
+ * `purgeExpired`). Operators typically point `file` at tmpfs (e.g.
31
+ * `/dev/shm/blamejs-sessions.db`) so session inserts run RAM-fast
32
+ * and don't compete with the main DB's encryption-flush cycle.
33
+ *
34
+ * Wire it once at boot, before the first session call:
35
+ *
36
+ * var sessionStore = b.session.stores.localDbThin({ file: "/dev/shm/sessions.db" });
37
+ * b.session.useStore(sessionStore);
38
+ */
39
+
40
+ var localDbThin = require("./local-db-thin");
41
+ var validateOpts = require("./validate-opts");
42
+
43
+ var SESSION_SCHEMA_SQL = [
44
+ "CREATE TABLE IF NOT EXISTS _blamejs_sessions (",
45
+ ' "sidHash" TEXT PRIMARY KEY,',
46
+ ' "userId" TEXT,',
47
+ ' "userIdHash" TEXT,',
48
+ ' "data" TEXT,',
49
+ ' "createdAt" INTEGER,',
50
+ ' "expiresAt" INTEGER,',
51
+ ' "lastActivity" INTEGER',
52
+ ");",
53
+ 'CREATE INDEX IF NOT EXISTS "_blamejs_sessions_userIdHash_idx" ON _blamejs_sessions ("userIdHash");',
54
+ 'CREATE INDEX IF NOT EXISTS "_blamejs_sessions_expiresAt_idx" ON _blamejs_sessions ("expiresAt");',
55
+ ].join("\n");
56
+
57
+ /**
58
+ * @primitive b.session.stores.localDbThin
59
+ * @signature b.session.stores.localDbThin(opts)
60
+ * @since 0.8.61
61
+ * @status stable
62
+ * @related b.session.useStore, b.localDb.thin
63
+ *
64
+ * Returns a session-store adapter backed by a dedicated `b.localDb.thin`
65
+ * SQLite file. The adapter exposes `execute(sql, params)` and
66
+ * `executeOne(sql, params)` — the contract `b.session` consumes — so
67
+ * passing it to `b.session.useStore(store)` redirects every session
68
+ * read/write to the isolated file without touching the framework's
69
+ * main DB.
70
+ *
71
+ * Typical use is to point `file` at tmpfs (`/dev/shm/sessions.db` on
72
+ * Linux, an in-memory volume on Windows) so session inserts don't
73
+ * fight the main DB's WAL fsync + encrypted-at-rest re-flush cycle.
74
+ * The adapter creates the schema on first open, so no manual migration
75
+ * is required.
76
+ *
77
+ * @opts
78
+ * {
79
+ * file: string, // required absolute path
80
+ * recovery?: "refuse" | "rename-and-recreate", // forwards to b.localDb.thin
81
+ * pragmas?: object, // extra PRAGMA overrides
82
+ * audit?: boolean, // localDb.thin audit emission
83
+ * }
84
+ *
85
+ * @example
86
+ * var b = require("@blamejs/core");
87
+ * var store = b.session.stores.localDbThin({ file: "/dev/shm/sessions.db" });
88
+ * b.session.useStore(store);
89
+ * // From here on every b.session.* call routes through the tmpfs file.
90
+ */
91
+ function localDbThinStore(opts) {
92
+ validateOpts.requireObject(opts, "session.stores.localDbThin", TypeError, "session-stores/bad-opts");
93
+ validateOpts.requireNonEmptyString(opts.file, "session.stores.localDbThin: opts.file",
94
+ TypeError, "session-stores/bad-file");
95
+ // node:sqlite is sync — open the handle eagerly so the first
96
+ // session call doesn't pay an open + integrity_check on the
97
+ // request hot path. Recovery defaults to "refuse" so a corrupt
98
+ // session file surfaces as a startup error rather than silently
99
+ // logging out every user; operators wanting clear-on-corrupt opt in.
100
+ var handle = localDbThin.thin({
101
+ file: opts.file,
102
+ schemaSql: SESSION_SCHEMA_SQL,
103
+ recovery: opts.recovery || "refuse",
104
+ pragmas: opts.pragmas,
105
+ audit: opts.audit !== false,
106
+ });
107
+
108
+ // Wrap the synchronous prepare/run/all paths in resolved Promises so
109
+ // the b.session call sites — all `await store.execute(...)` — see the
110
+ // same shape as the cluster-storage default.
111
+ function execute(sql, params) {
112
+ params = params || [];
113
+ var stmt = handle.prepare(sql);
114
+ if (/^\s*SELECT\b/i.test(sql) || /\bRETURNING\b/i.test(sql)) {
115
+ var rows = stmt.all.apply(stmt, params);
116
+ return Promise.resolve({ rows: rows, rowCount: rows.length });
117
+ }
118
+ var info = stmt.run.apply(stmt, params);
119
+ return Promise.resolve({ rows: [], rowCount: info.changes });
120
+ }
121
+
122
+ function executeOne(sql, params) {
123
+ return execute(sql, params).then(function (r) {
124
+ return r.rows.length > 0 ? r.rows[0] : null;
125
+ });
126
+ }
127
+
128
+ return {
129
+ execute: execute,
130
+ executeOne: executeOne,
131
+ close: function () { try { handle.close(); } catch (_e) { /* best-effort */ } },
132
+ file: handle.file,
133
+ };
134
+ }
135
+
136
+ module.exports = {
137
+ localDbThin: localDbThinStore,
138
+ };