@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.
- package/CHANGELOG.md +5 -0
- package/README.md +2 -2
- package/index.js +11 -0
- package/lib/audit.js +1 -0
- package/lib/auth/ciba.js +530 -0
- package/lib/auth/oauth.js +199 -11
- package/lib/auth/oid4vci.js +588 -0
- package/lib/auth/oid4vp.js +514 -0
- package/lib/auth/openid-federation.js +523 -0
- package/lib/auth/saml.js +636 -0
- package/lib/auth/sd-jwt-vc-holder.js +30 -8
- package/lib/auth/sd-jwt-vc.js +61 -7
- package/lib/db-collection.js +402 -105
- package/lib/db-file-lifecycle.js +333 -0
- package/lib/session-stores.js +138 -0
- package/lib/session.js +307 -20
- package/lib/validate-opts.js +41 -0
- package/lib/xml-c14n.js +499 -0
- package/package.json +1 -1
- package/sbom.cyclonedx.json +6 -6
|
@@ -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
|
+
};
|