@blamejs/blamejs-shop 0.2.15 → 0.2.18
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 -1
- package/README.md +2 -2
- package/lib/admin.js +81 -3
- package/lib/asset-manifest.json +5 -1
- package/lib/customers.js +71 -4
- package/lib/storefront.js +462 -0
- package/lib/vendor/MANIFEST.json +2 -2
- package/lib/vendor/blamejs/CHANGELOG.md +20 -0
- package/lib/vendor/blamejs/api-snapshot.json +6 -2
- package/lib/vendor/blamejs/examples/wiki/docker-compose.prod.yml +29 -0
- package/lib/vendor/blamejs/examples/wiki/docker-compose.yml +7 -0
- package/lib/vendor/blamejs/lib/agent-idempotency.js +15 -3
- package/lib/vendor/blamejs/lib/agent-orchestrator.js +39 -0
- package/lib/vendor/blamejs/lib/app-shutdown.js +32 -0
- package/lib/vendor/blamejs/lib/bounded-map.js +102 -0
- package/lib/vendor/blamejs/lib/cache.js +184 -23
- package/lib/vendor/blamejs/lib/cert.js +68 -11
- package/lib/vendor/blamejs/lib/cluster-storage.js +114 -10
- package/lib/vendor/blamejs/lib/db.js +205 -15
- package/lib/vendor/blamejs/lib/dual-control.js +139 -143
- package/lib/vendor/blamejs/lib/i18n.js +10 -1
- package/lib/vendor/blamejs/lib/mail-crypto-smime.js +43 -9
- package/lib/vendor/blamejs/lib/network-dns.js +10 -2
- package/lib/vendor/blamejs/lib/nonce-store.js +39 -12
- package/lib/vendor/blamejs/lib/queue-local.js +2 -2
- package/lib/vendor/blamejs/lib/redis-client.js +14 -1
- package/lib/vendor/blamejs/lib/subject.js +8 -1
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.13.33.json +26 -0
- package/lib/vendor/blamejs/release-notes/v0.13.34.json +48 -0
- package/lib/vendor/blamejs/release-notes/v0.13.35.json +35 -0
- package/lib/vendor/blamejs/release-notes/v0.13.36.json +27 -0
- package/lib/vendor/blamejs/release-notes/v0.13.37.json +18 -0
- package/lib/vendor/blamejs/release-notes/v0.13.38.json +27 -0
- package/lib/vendor/blamejs/release-notes/v0.13.39.json +27 -0
- package/lib/vendor/blamejs/release-notes/v0.13.40.json +22 -0
- package/lib/vendor/blamejs/release-notes/v0.13.41.json +27 -0
- package/lib/vendor/blamejs/release-notes/v0.13.42.json +18 -0
- package/lib/vendor/blamejs/test/20-db.js +191 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/agent-idempotency.test.js +20 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/agent-orchestrator.test.js +33 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/api-encrypt.test.js +35 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/app-shutdown.test.js +64 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/bounded-map.test.js +87 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/cache.test.js +48 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/cert.test.js +170 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/cluster-storage.test.js +125 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +92 -1
- package/lib/vendor/blamejs/test/layer-0-primitives/dual-control.test.js +32 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/mail-crypto-smime.test.js +31 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/redis-client.test.js +14 -0
- package/package.json +1 -1
|
@@ -54,6 +54,7 @@ var atomicFile = require("./atomic-file");
|
|
|
54
54
|
var safeJson = require("./safe-json");
|
|
55
55
|
var { defineClass } = require("./framework-error");
|
|
56
56
|
var C = require("./constants");
|
|
57
|
+
var { boot } = require("./log");
|
|
57
58
|
|
|
58
59
|
var acme = lazyRequire(function () { return require("./acme"); });
|
|
59
60
|
var vault = lazyRequire(function () { return require("./vault"); });
|
|
@@ -62,6 +63,7 @@ var networkTls = lazyRequire(function () { return require("./network-tls"); }
|
|
|
62
63
|
var bCrypto = lazyRequire(function () { return require("./crypto"); });
|
|
63
64
|
|
|
64
65
|
var CertError = defineClass("CertError");
|
|
66
|
+
var log = boot("cert");
|
|
65
67
|
|
|
66
68
|
var DEFAULT_RENEW_INTERVAL_MS = C.TIME.hours(6);
|
|
67
69
|
var DEFAULT_MIN_DAYS_BEFORE_EXPIRY = 14;
|
|
@@ -140,8 +142,13 @@ function _createSealedDiskStorage(opts) {
|
|
|
140
142
|
if (!nodeFs.existsSync(p)) return null;
|
|
141
143
|
try { return safeJson.parse(nodeFs.readFileSync(p, "utf8"), { maxBytes: C.BYTES.kib(16) }); }
|
|
142
144
|
catch (e) {
|
|
143
|
-
|
|
144
|
-
|
|
145
|
+
// meta.json is a derived index (expiry + fingerprint), not a
|
|
146
|
+
// source of truth — the sealed cert is. A corrupt meta must not
|
|
147
|
+
// block renewal: treat it as absent so _ensureCert re-derives it
|
|
148
|
+
// from a fresh issue rather than throwing out of start().
|
|
149
|
+
log.warn("cert: meta.json for '" + certName + "' unreadable (" +
|
|
150
|
+
e.message + ") — treating as absent, will re-derive");
|
|
151
|
+
return null;
|
|
145
152
|
}
|
|
146
153
|
},
|
|
147
154
|
|
|
@@ -413,8 +420,21 @@ function create(opts) {
|
|
|
413
420
|
? nodeFs.readFileSync(nodePath.join(storage.rootDir, "account/jwk.json.sealed"))
|
|
414
421
|
: null;
|
|
415
422
|
if (sealedBuf) {
|
|
416
|
-
var
|
|
417
|
-
|
|
423
|
+
var jwk;
|
|
424
|
+
try {
|
|
425
|
+
var plain = (opts.storage.vault || vault().getDefaultStore()).unseal(sealedBuf);
|
|
426
|
+
jwk = safeJson.parse(plain.toString("utf8"), { maxBytes: C.BYTES.kib(64) });
|
|
427
|
+
} catch (e) {
|
|
428
|
+
// The ACME account key binds existing order + authorization
|
|
429
|
+
// history, so it is NOT auto-regenerated on corruption (unlike a
|
|
430
|
+
// re-issuable cert) — that would silently abandon the account.
|
|
431
|
+
// Fail with an actionable error naming the file + recovery instead
|
|
432
|
+
// of letting a raw decrypt/parse error escape out of start().
|
|
433
|
+
throw new CertError("cert/account-key-unreadable",
|
|
434
|
+
"cert: ACME account key 'account/jwk.json.sealed' is unreadable (" +
|
|
435
|
+
e.message + "). Restore it from backup, or delete it to register " +
|
|
436
|
+
"a fresh ACME account (this abandons prior order history).");
|
|
437
|
+
}
|
|
418
438
|
return _accountKeyFromJwk(jwk);
|
|
419
439
|
}
|
|
420
440
|
var pair = nodeCrypto.generateKeyPairSync("ec", { namedCurve: "P-256" });
|
|
@@ -555,18 +575,55 @@ function create(opts) {
|
|
|
555
575
|
// for emergency reissue / key rollover when the existing cert is
|
|
556
576
|
// structurally fine but operationally compromised (suspected key
|
|
557
577
|
// disclosure, CA misissuance investigation, posture-driven rotation).
|
|
578
|
+
// A corrupt sealed cert/key is RECOVERABLE state — the CA re-issues on
|
|
579
|
+
// demand. Treat an unreadable sealed file like a missing one (log +
|
|
580
|
+
// re-issue) rather than letting a raw unseal/decrypt error escape out of
|
|
581
|
+
// start(): on a managed restart the same corrupt file is read on every
|
|
582
|
+
// boot, so throwing here is an unrecoverable crash loop, and a corrupt
|
|
583
|
+
// file must never be handled worse than an absent one (which already
|
|
584
|
+
// falls through to issue). The ACME account key is the one piece NOT
|
|
585
|
+
// auto-recovered this way — see _loadOrGenerateAccountKey.
|
|
586
|
+
async function _readSealedOrReissue(relPath, certName) {
|
|
587
|
+
try {
|
|
588
|
+
return await storage.readSealed(relPath);
|
|
589
|
+
} catch (e) {
|
|
590
|
+
log.warn("cert: sealed '" + relPath + "' unreadable (" + e.message +
|
|
591
|
+
") — re-issuing as if absent");
|
|
592
|
+
_emitAudit("cert.sealed.corrupt", "recovered", { path: relPath, name: certName });
|
|
593
|
+
return null;
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
558
597
|
async function _ensureCert(certManifest, forceIssue) {
|
|
559
598
|
var meta = await storage.readMeta(certManifest.name);
|
|
560
|
-
var certBuf = await
|
|
561
|
-
var keyBuf = await
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
599
|
+
var certBuf = await _readSealedOrReissue(certManifest.name + "/cert.pem", certManifest.name);
|
|
600
|
+
var keyBuf = await _readSealedOrReissue(certManifest.name + "/key.pem", certManifest.name);
|
|
601
|
+
// Base the renewal decision on the SEALED cert's OWN notAfter, not the
|
|
602
|
+
// plaintext meta.json index. meta is a derived convenience copy; if it
|
|
603
|
+
// drifts from — or is tampered relative to — the actual cert (a far-
|
|
604
|
+
// future meta.expiresAt over an actually-expiring cert), trusting it
|
|
605
|
+
// would skip renewal and serve an expired cert. Re-derive expiry +
|
|
606
|
+
// fingerprint from the cert itself; if it won't parse, treat it as a
|
|
607
|
+
// corrupt sealed cert and re-issue (same recovery as an unreadable one).
|
|
608
|
+
var actual = null;
|
|
609
|
+
if (certBuf) {
|
|
610
|
+
try { actual = _certMeta(certBuf.toString("utf8")); }
|
|
611
|
+
catch (e) {
|
|
612
|
+
log.warn("cert: sealed cert for '" + certManifest.name + "' will not parse (" +
|
|
613
|
+
e.message + ") — re-issuing");
|
|
614
|
+
_emitAudit("cert.sealed.corrupt", "recovered",
|
|
615
|
+
{ path: certManifest.name + "/cert.pem", name: certManifest.name });
|
|
616
|
+
certBuf = null;
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
if (!forceIssue && actual && certBuf && keyBuf &&
|
|
620
|
+
actual.expiresAt > Date.now() + minDaysBeforeExpiry * C.TIME.days(1)) {
|
|
621
|
+
// Cached, and the cert's own notAfter is comfortably in the future.
|
|
565
622
|
loadedContexts[certManifest.name] = {
|
|
566
623
|
cert: certBuf.toString("utf8"),
|
|
567
624
|
key: keyBuf.toString("utf8"),
|
|
568
|
-
expiresAt:
|
|
569
|
-
fingerprintSha256:
|
|
625
|
+
expiresAt: actual.expiresAt,
|
|
626
|
+
fingerprintSha256: actual.fingerprintSha256,
|
|
570
627
|
sniNames: certManifest.domains.slice(),
|
|
571
628
|
};
|
|
572
629
|
return loadedContexts[certManifest.name];
|
|
@@ -261,6 +261,33 @@ function placeholderize(sql, dialect) {
|
|
|
261
261
|
* );
|
|
262
262
|
* // → { rows: [ { counter: 43, row_hash: "..." } ], rowCount: 1 }
|
|
263
263
|
*/
|
|
264
|
+
// Single-node transaction serialization. node:sqlite is synchronous and
|
|
265
|
+
// the framework shares ONE local connection, so a SQLite transaction is
|
|
266
|
+
// connection-global: any statement that runs between this connection's
|
|
267
|
+
// BEGIN and COMMIT lands INSIDE the transaction. `_activeTx` is a promise
|
|
268
|
+
// held for the duration of a single-node transaction(); execute() waits it
|
|
269
|
+
// out before running so a concurrent statement can't interleave into the
|
|
270
|
+
// open transaction on the shared connection. It is null in cluster mode
|
|
271
|
+
// (the pool gives each transaction its own connection, so the DB enforces
|
|
272
|
+
// isolation and no global lock is needed).
|
|
273
|
+
var _activeTx = null;
|
|
274
|
+
|
|
275
|
+
// Raw local exec — synchronous, no transaction-lock wait. Used by execute()
|
|
276
|
+
// AFTER the lock wait and by transaction() for its own statements (which
|
|
277
|
+
// must NOT wait on the lock they themselves hold). Because node:sqlite is
|
|
278
|
+
// synchronous this runs atomically to completion with no interleaving.
|
|
279
|
+
function _localExec(sql, params) {
|
|
280
|
+
var stmt = _localDb().prepare(sql);
|
|
281
|
+
// Heuristic: if the statement returns rows (SELECT or has RETURNING),
|
|
282
|
+
// use .all(); otherwise .run() and report changes as rowCount.
|
|
283
|
+
if (/^\s*SELECT\b/i.test(sql) || /\bRETURNING\b/i.test(sql)) {
|
|
284
|
+
var rows = stmt.all.apply(stmt, params || []);
|
|
285
|
+
return { rows: rows, rowCount: rows.length };
|
|
286
|
+
}
|
|
287
|
+
var info = stmt.run.apply(stmt, params || []);
|
|
288
|
+
return { rows: [], rowCount: info.changes };
|
|
289
|
+
}
|
|
290
|
+
|
|
264
291
|
async function execute(sql, params) {
|
|
265
292
|
if (typeof sql !== "string") {
|
|
266
293
|
throw new ClusterStorageError("sql must be a string", "cluster-storage/bad-arg");
|
|
@@ -275,17 +302,93 @@ async function execute(sql, params) {
|
|
|
275
302
|
return result;
|
|
276
303
|
}
|
|
277
304
|
|
|
278
|
-
// Local SQLite path.
|
|
279
|
-
//
|
|
280
|
-
|
|
281
|
-
//
|
|
282
|
-
//
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
305
|
+
// Local SQLite path. Wait out any open single-node transaction so this
|
|
306
|
+
// statement can't interleave into it on the shared connection. The loop
|
|
307
|
+
// re-checks after each wait (a new transaction may have started while we
|
|
308
|
+
// waited); once it exits, `_localExec` runs synchronously to completion,
|
|
309
|
+
// so no transaction can begin between the check and the statement.
|
|
310
|
+
while (_activeTx) { try { await _activeTx; } catch (_e) { /* tx failed — proceed */ } }
|
|
311
|
+
return _localExec(sql, params);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* @primitive b.clusterStorage.transaction
|
|
316
|
+
* @signature b.clusterStorage.transaction(fn)
|
|
317
|
+
* @since 0.13.38
|
|
318
|
+
* @status stable
|
|
319
|
+
* @related b.clusterStorage.execute
|
|
320
|
+
*
|
|
321
|
+
* Run `fn` inside an atomic transaction against the active backend, so a
|
|
322
|
+
* multi-statement read-modify-write commits all-or-nothing. `fn` receives a
|
|
323
|
+
* transaction handle exposing the same `execute` / `executeOne` /
|
|
324
|
+
* `executeAll` surface as the module — but scoped to the open transaction.
|
|
325
|
+
* Use the handle's methods inside `fn`; calling the module-level
|
|
326
|
+
* `b.clusterStorage.execute` from within `fn` would deadlock single-node
|
|
327
|
+
* (it waits for the very transaction `fn` is running).
|
|
328
|
+
*
|
|
329
|
+
* Cluster mode dispatches to the external DB's transaction (its own pooled
|
|
330
|
+
* connection + deadlock retry). Single-node serializes against other
|
|
331
|
+
* transactions and against `execute` on the shared SQLite connection.
|
|
332
|
+
*
|
|
333
|
+
* @example
|
|
334
|
+
* await b.clusterStorage.transaction(async function (tx) {
|
|
335
|
+
* var row = await tx.executeOne("SELECT v FROM t WHERE k = ?", ["x"]);
|
|
336
|
+
* await tx.execute("UPDATE t SET v = ? WHERE k = ?", [row.v + 1, "x"]);
|
|
337
|
+
* });
|
|
338
|
+
*/
|
|
339
|
+
async function transaction(fn) {
|
|
340
|
+
if (typeof fn !== "function") {
|
|
341
|
+
throw new ClusterStorageError("transaction requires a function", "cluster-storage/bad-arg");
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (cluster.isClusterMode()) {
|
|
345
|
+
var dialect = cluster.dialect();
|
|
346
|
+
return await externalDb.transaction(async function (txClient) {
|
|
347
|
+
function txExec(sql, params) {
|
|
348
|
+
var translated = placeholderize(resolveTables(sql), dialect);
|
|
349
|
+
return txClient.query(translated, params || []);
|
|
350
|
+
}
|
|
351
|
+
var txHandle = {
|
|
352
|
+
execute: txExec,
|
|
353
|
+
executeOne: async function (sql, params) {
|
|
354
|
+
var r = await txExec(sql, params); return r.rows.length > 0 ? r.rows[0] : null;
|
|
355
|
+
},
|
|
356
|
+
executeAll: async function (sql, params) {
|
|
357
|
+
var r = await txExec(sql, params); return r.rows;
|
|
358
|
+
},
|
|
359
|
+
};
|
|
360
|
+
return await fn(txHandle);
|
|
361
|
+
}, { backend: cluster.externalDbBackend() });
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Single-node: serialize this transaction behind any other open one, then
|
|
365
|
+
// hold `_activeTx` so concurrent execute()/transaction() calls wait.
|
|
366
|
+
while (_activeTx) { try { await _activeTx; } catch (_e) { /* prior tx failed */ } }
|
|
367
|
+
var releaseTx;
|
|
368
|
+
_activeTx = new Promise(function (resolve) { releaseTx = resolve; });
|
|
369
|
+
function txExecLocal(sql, params) { return Promise.resolve(_localExec(sql, params)); }
|
|
370
|
+
var localHandle = {
|
|
371
|
+
execute: txExecLocal,
|
|
372
|
+
executeOne: async function (sql, params) {
|
|
373
|
+
var r = await txExecLocal(sql, params); return r.rows.length > 0 ? r.rows[0] : null;
|
|
374
|
+
},
|
|
375
|
+
executeAll: async function (sql, params) {
|
|
376
|
+
var r = await txExecLocal(sql, params); return r.rows;
|
|
377
|
+
},
|
|
378
|
+
};
|
|
379
|
+
try {
|
|
380
|
+
_localExec("BEGIN", []);
|
|
381
|
+
try {
|
|
382
|
+
var result = await fn(localHandle);
|
|
383
|
+
_localExec("COMMIT", []);
|
|
384
|
+
return result;
|
|
385
|
+
} catch (e) {
|
|
386
|
+
try { _localExec("ROLLBACK", []); } catch (_e) { /* already errored */ }
|
|
387
|
+
throw e;
|
|
388
|
+
}
|
|
389
|
+
} finally {
|
|
390
|
+
var r = releaseTx; _activeTx = null; r();
|
|
286
391
|
}
|
|
287
|
-
var info = stmt.run.apply(stmt, params);
|
|
288
|
-
return { rows: [], rowCount: info.changes };
|
|
289
392
|
}
|
|
290
393
|
|
|
291
394
|
// Convenience wrappers for the two common patterns.
|
|
@@ -344,6 +447,7 @@ module.exports = {
|
|
|
344
447
|
execute: execute,
|
|
345
448
|
executeOne: executeOne,
|
|
346
449
|
executeAll: executeAll,
|
|
450
|
+
transaction: transaction,
|
|
347
451
|
tableName: tableName,
|
|
348
452
|
resolveTables: resolveTables,
|
|
349
453
|
placeholderize: placeholderize,
|
|
@@ -132,6 +132,22 @@ var encPath = null; // encrypted-at-rest path (null in plain mode)
|
|
|
132
132
|
var encKey = null; // DB encryption key buffer (null in plain mode)
|
|
133
133
|
var encTimer = null; // periodic encrypt interval handle
|
|
134
134
|
var atRest = null; // 'encrypted' or 'plain'
|
|
135
|
+
// Tmpfs free-space guard (encrypted mode). The working copy lives on a
|
|
136
|
+
// bounded tmpfs (Docker /dev/shm defaults to 64 MiB); if it fills, SQLite
|
|
137
|
+
// hits ENOSPC and corrupts the working copy. A periodic probe refuses
|
|
138
|
+
// growth writes (INSERT/UPDATE/REPLACE) before that happens — fail-clear
|
|
139
|
+
// instead of corrupt-then-recover. DELETE + reads stay available so
|
|
140
|
+
// retention can reclaim space and the app can keep serving.
|
|
141
|
+
var storageProbeTimer = null; // periodic free-space probe handle
|
|
142
|
+
var writesRefused = false; // true when free space < minFreeBytes
|
|
143
|
+
var minFreeBytes = 0; // refuse growth writes below this (0 = guard off)
|
|
144
|
+
var statfsProbe = null; // free-space reader (fs.statfsSync; injectable for tests)
|
|
145
|
+
// The process-exit final-flush handler is registered ONCE at first
|
|
146
|
+
// encrypted init. Re-registering per init() leaked an 'exit' listener on
|
|
147
|
+
// every init/close cycle (MaxListenersExceeded in long test runs / hot
|
|
148
|
+
// reload); the flag makes it idempotent. The handler reads live module
|
|
149
|
+
// state at exit time, so a later re-init is still covered.
|
|
150
|
+
var _exitHandlerRegistered = false;
|
|
135
151
|
var dataDir = null;
|
|
136
152
|
var initialized = false;
|
|
137
153
|
var dataResidency = null; // operator's declared region config (validated by storage backends)
|
|
@@ -672,13 +688,30 @@ function loadOrCreateDbKey(dataDirPath, keyPathOverride) {
|
|
|
672
688
|
function decryptToTmp() {
|
|
673
689
|
if (!encPath || !nodeFs.existsSync(encPath)) return;
|
|
674
690
|
// If a plaintext file already exists in tmpfs from a prior process, prefer
|
|
675
|
-
// the newer mtime (crash recovery — operator's most recent state wins)
|
|
691
|
+
// the newer mtime (crash recovery — operator's most recent state wins) —
|
|
692
|
+
// but ONLY if it is a readable SQLite file. A working copy corrupted by
|
|
693
|
+
// an unclean shutdown or a full tmpfs (e.g. Docker's 64 MiB /dev/shm
|
|
694
|
+
// default overflowing) would otherwise be kept on every boot, and
|
|
695
|
+
// db.init's integrity gate would fail identically forever — an
|
|
696
|
+
// unrecoverable crash loop. When the newer plaintext fails a fast
|
|
697
|
+
// integrity probe, discard it and fall through to re-decrypt the
|
|
698
|
+
// last-good db.enc snapshot. db.enc itself is never modified here, so
|
|
699
|
+
// this only ever rolls back to the persistent encrypted copy; if THAT
|
|
700
|
+
// is also corrupt, db.init still fails loudly (no silent data loss).
|
|
676
701
|
if (nodeFs.existsSync(dbPath)) {
|
|
677
702
|
var plainStat = nodeFs.statSync(dbPath);
|
|
678
703
|
var encStat = nodeFs.statSync(encPath);
|
|
679
704
|
if (plainStat.mtimeMs > encStat.mtimeMs && plainStat.size > 0) {
|
|
680
|
-
|
|
681
|
-
|
|
705
|
+
if (_tmpWorkingCopyIsHealthy(dbPath)) {
|
|
706
|
+
log("plaintext is newer than encrypted — keeping plaintext (crash recovery)");
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
log("newer tmpfs working copy failed its integrity probe (corrupt — likely an " +
|
|
710
|
+
"unclean shutdown or a full /dev/shm); discarding it and re-decrypting from " +
|
|
711
|
+
"db.enc (auto-recovery to the last-good encrypted snapshot)");
|
|
712
|
+
try { nodeFs.unlinkSync(dbPath); } catch (_e) { /* fall through to overwrite */ }
|
|
713
|
+
try { nodeFs.unlinkSync(dbPath + "-wal"); } catch (_e) { /* may not exist */ }
|
|
714
|
+
try { nodeFs.unlinkSync(dbPath + "-shm"); } catch (_e) { /* may not exist */ }
|
|
682
715
|
}
|
|
683
716
|
}
|
|
684
717
|
var packed = nodeFs.readFileSync(encPath);
|
|
@@ -696,10 +729,90 @@ function decryptToTmp() {
|
|
|
696
729
|
}
|
|
697
730
|
}
|
|
698
731
|
|
|
732
|
+
// Fast "is this a usable SQLite file" probe for the crash-recovery path.
|
|
733
|
+
// Opens the candidate working copy and runs PRAGMA quick_check(1) (far
|
|
734
|
+
// cheaper than full integrity_check — header + page-structure sanity,
|
|
735
|
+
// enough to catch a "database disk image is malformed" / truncated /
|
|
736
|
+
// non-DB file). Any throw (malformed image, not-a-DB) or non-"ok" result
|
|
737
|
+
// is unhealthy. The probe handle is always closed so it never holds the
|
|
738
|
+
// tmpfs file open against the subsequent real open.
|
|
739
|
+
function _tmpWorkingCopyIsHealthy(p) {
|
|
740
|
+
var probe = null;
|
|
741
|
+
try {
|
|
742
|
+
probe = new DatabaseSync(p);
|
|
743
|
+
var rows = probe.prepare("PRAGMA quick_check(1)").all();
|
|
744
|
+
return rows.length >= 1 && rows[0] && rows[0].quick_check === "ok";
|
|
745
|
+
} catch (_e) {
|
|
746
|
+
return false;
|
|
747
|
+
} finally {
|
|
748
|
+
if (probe) { try { probe.close(); } catch (_e2) { /* already gone */ } }
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
|
|
699
752
|
function _dbEncAad(dir) {
|
|
700
753
|
return Buffer.from("blamejs.db-enc.v1\0" + (dir || ""), "utf8");
|
|
701
754
|
}
|
|
702
755
|
|
|
756
|
+
// Probe free space on the tmpfs holding the working copy and flip the
|
|
757
|
+
// write-refusal flag. Encrypted mode only (the bounded-tmpfs surface);
|
|
758
|
+
// guard disabled when minFreeBytes is 0. A probe failure leaves the flag
|
|
759
|
+
// unchanged — we never refuse writes on a stat error (that would be a
|
|
760
|
+
// self-inflicted outage). Growth writes (INSERT/UPDATE/REPLACE) are gated
|
|
761
|
+
// by the prepare() wrapper installed in init(); DELETE + reads always pass
|
|
762
|
+
// so retention can reclaim space and the app keeps serving.
|
|
763
|
+
function _probeStorageHeadroom() {
|
|
764
|
+
if (atRest !== "encrypted" || !minFreeBytes || !dbPath || !statfsProbe) return;
|
|
765
|
+
var free;
|
|
766
|
+
try {
|
|
767
|
+
var st = statfsProbe(nodePath.dirname(dbPath));
|
|
768
|
+
free = st.bavail * st.bsize;
|
|
769
|
+
if (!isFinite(free)) return;
|
|
770
|
+
} catch (_e) { return; }
|
|
771
|
+
if (free < minFreeBytes && !writesRefused) {
|
|
772
|
+
writesRefused = true;
|
|
773
|
+
log.error("storage low: " + free + " bytes free on the tmpfs working-copy mount (< " +
|
|
774
|
+
minFreeBytes + ") — refusing growth writes (INSERT/UPDATE/REPLACE) until space " +
|
|
775
|
+
"recovers. Raise shm_size / --shm-size, or let retention prune. DELETE + reads still serve.");
|
|
776
|
+
try {
|
|
777
|
+
audit.safeEmit({ action: "db.storage.low", outcome: "failure",
|
|
778
|
+
metadata: { freeBytes: free, minFreeBytes: minFreeBytes } });
|
|
779
|
+
} catch (_e2) { /* drop-silent — observability */ }
|
|
780
|
+
} else if (free >= minFreeBytes && writesRefused) {
|
|
781
|
+
writesRefused = false;
|
|
782
|
+
log("storage recovered: " + free + " bytes free — growth writes re-enabled");
|
|
783
|
+
try {
|
|
784
|
+
audit.safeEmit({ action: "db.storage.recovered", outcome: "success",
|
|
785
|
+
metadata: { freeBytes: free } });
|
|
786
|
+
} catch (_e3) { /* drop-silent */ }
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
// Install the growth-write gate on the SQLite handle: shadow prepare() so
|
|
791
|
+
// INSERT/UPDATE/REPLACE statements throw db/storage-low when the tmpfs is
|
|
792
|
+
// critically low, instead of proceeding into an ENOSPC corruption. Reads,
|
|
793
|
+
// DELETE, PRAGMA, and DDL pass through ungated. Called once in init() after
|
|
794
|
+
// schema setup so init's own writes are never gated (writesRefused is false
|
|
795
|
+
// until the first probe anyway).
|
|
796
|
+
function _installWriteGate() {
|
|
797
|
+
var rawPrepare = database.prepare.bind(database);
|
|
798
|
+
database.prepare = function (sql) {
|
|
799
|
+
var stmt = rawPrepare(sql);
|
|
800
|
+
if (/^\s*(?:INSERT|UPDATE|REPLACE)\b/i.test(sql)) {
|
|
801
|
+
var rawRun = stmt.run.bind(stmt);
|
|
802
|
+
stmt.run = function () {
|
|
803
|
+
if (writesRefused) {
|
|
804
|
+
throw _dbErr("db/storage-low",
|
|
805
|
+
"db: refusing write — the encrypted-mode working copy is on a tmpfs with less than " +
|
|
806
|
+
minFreeBytes + " bytes free (Docker /dev/shm defaults to 64 MiB). Raise shm_size / " +
|
|
807
|
+
"--shm-size, or let retention prune expired rows. DELETE and reads remain available.");
|
|
808
|
+
}
|
|
809
|
+
return rawRun.apply(stmt, arguments);
|
|
810
|
+
};
|
|
811
|
+
}
|
|
812
|
+
return stmt;
|
|
813
|
+
};
|
|
814
|
+
}
|
|
815
|
+
|
|
703
816
|
function encryptToDisk() {
|
|
704
817
|
if (!encPath) return;
|
|
705
818
|
// Force WAL checkpoint so the .db file holds all committed transactions.
|
|
@@ -878,11 +991,11 @@ async function init(opts) {
|
|
|
878
991
|
if (!nodeFs.existsSync(tmpDir)) nodeFs.mkdirSync(tmpDir, { recursive: true });
|
|
879
992
|
|
|
880
993
|
// D-H7 — if the resolved tmpDir is NOT actually tmpfs, the
|
|
881
|
-
// plaintext DB file lives on persistent storage.
|
|
882
|
-
//
|
|
883
|
-
// resolves under /dev/shm or /run/shm as a heuristic. On other
|
|
994
|
+
// plaintext DB file lives on persistent storage. We check that tmpDir
|
|
995
|
+
// resolves under /dev/shm or /run/shm on Linux as a heuristic; on other
|
|
884
996
|
// platforms we warn that the operator must verify tmpfs binding
|
|
885
|
-
// out-of-band.
|
|
997
|
+
// out-of-band. (Free-space headroom is enforced separately via
|
|
998
|
+
// fs.statfsSync in the storage guard below.)
|
|
886
999
|
if (process.platform === "linux") {
|
|
887
1000
|
var realTmp = "";
|
|
888
1001
|
try { realTmp = nodeFs.realpathSync(tmpDir); } catch (_e) { /* stat best-effort */ }
|
|
@@ -904,6 +1017,21 @@ async function init(opts) {
|
|
|
904
1017
|
dbPath = nodePath.join(tmpDir, "blamejs-" + generateToken(C.BYTES.bytes(16)) + ".db");
|
|
905
1018
|
encKey = loadOrCreateDbKey(dataDir, opts.dbKeyPath);
|
|
906
1019
|
|
|
1020
|
+
// Tmpfs free-space guard. Default headroom is 16 MiB below which growth
|
|
1021
|
+
// writes are refused (fail-clear) before the working copy fills its
|
|
1022
|
+
// bounded tmpfs and corrupts. opts.minFreeBytes tunes it; 0 disables.
|
|
1023
|
+
// opts._statfsForTest injects a free-space reader for tests.
|
|
1024
|
+
if (opts.minFreeBytes !== undefined) {
|
|
1025
|
+
require("./numeric-bounds").requireNonNegativeFiniteIntIfPresent(
|
|
1026
|
+
opts.minFreeBytes, "db.init: opts.minFreeBytes", DbError, "db/bad-min-free-bytes");
|
|
1027
|
+
minFreeBytes = opts.minFreeBytes;
|
|
1028
|
+
} else {
|
|
1029
|
+
minFreeBytes = C.BYTES.mib(16);
|
|
1030
|
+
}
|
|
1031
|
+
statfsProbe = typeof opts._statfsForTest === "function"
|
|
1032
|
+
? opts._statfsForTest
|
|
1033
|
+
: (typeof nodeFs.statfsSync === "function" ? nodeFs.statfsSync : null);
|
|
1034
|
+
|
|
907
1035
|
cleanStaleTmpDbs(tmpDir);
|
|
908
1036
|
decryptToTmp();
|
|
909
1037
|
} else {
|
|
@@ -961,7 +1089,25 @@ async function init(opts) {
|
|
|
961
1089
|
// the freshly-decrypted-into-tmpfs file (<1 second on a typical
|
|
962
1090
|
// multi-MB DB) and the result is "ok" or a list of issues.
|
|
963
1091
|
if (opts.skipBootIntegrityCheck !== true) {
|
|
964
|
-
var ic
|
|
1092
|
+
var ic;
|
|
1093
|
+
try {
|
|
1094
|
+
ic = database.prepare("PRAGMA integrity_check").all();
|
|
1095
|
+
} catch (corruptErr) {
|
|
1096
|
+
// SQLite throws "database disk image is malformed" / "file is not a
|
|
1097
|
+
// database" when the file is too corrupt to even run the check.
|
|
1098
|
+
// Translate the raw native error into an actionable one — the most
|
|
1099
|
+
// common operational cause in encrypted mode is a too-small tmpfs.
|
|
1100
|
+
throw new DbError("db/integrity-check-failed",
|
|
1101
|
+
"database is corrupt at boot — SQLite: " +
|
|
1102
|
+
((corruptErr && corruptErr.message) || String(corruptErr)) + ". " +
|
|
1103
|
+
(atRest === "encrypted"
|
|
1104
|
+
? "Encrypted mode runs the live DB as a tmpfs working copy (" + dbPath +
|
|
1105
|
+
"); a recurring failure here usually means the tmpfs is too small " +
|
|
1106
|
+
"(Docker's /dev/shm defaults to 64 MiB — raise it via shm_size / " +
|
|
1107
|
+
"--shm-size), or db.enc itself is corrupt (restore <dataDir>/db.enc " +
|
|
1108
|
+
"from backup)."
|
|
1109
|
+
: "Restore the database file (" + dbPath + ") from backup."));
|
|
1110
|
+
}
|
|
965
1111
|
var icIssues = ic.map(function (r) { return r && r.integrity_check; })
|
|
966
1112
|
.filter(function (s) { return s && s !== "ok"; });
|
|
967
1113
|
if (icIssues.length > 0) {
|
|
@@ -1282,12 +1428,30 @@ async function init(opts) {
|
|
|
1282
1428
|
}
|
|
1283
1429
|
}, C.TIME.minutes(5), { name: "db-periodic-encrypt" });
|
|
1284
1430
|
|
|
1431
|
+
// Tmpfs free-space guard. Install the growth-write gate now (after all
|
|
1432
|
+
// of init's own writes), then probe on a short interval so the
|
|
1433
|
+
// refuse-writes flag tracks a fast-filling tmpfs (the 5-minute encrypt
|
|
1434
|
+
// cadence is far too coarse to catch a fill in time). The guard is a
|
|
1435
|
+
// no-op when minFreeBytes is 0 or no statfs reader is available.
|
|
1436
|
+
if (minFreeBytes && statfsProbe) {
|
|
1437
|
+
_installWriteGate();
|
|
1438
|
+
_probeStorageHeadroom(); // seed the flag from current free space
|
|
1439
|
+
storageProbeTimer = safeAsync.repeating(_probeStorageHeadroom,
|
|
1440
|
+
C.TIME.seconds(10), { name: "db-storage-probe" });
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1285
1443
|
// Final encrypt on process exit. We don't try to unlink the plaintext
|
|
1286
1444
|
// here — the SQLite handle may still be open, and the OS reclaims tmpfs
|
|
1287
|
-
// on reboot anyway. close() does the orderly shutdown.
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1445
|
+
// on reboot anyway. close() does the orderly shutdown. Registered ONCE
|
|
1446
|
+
// (guarded by the module flag) — re-registering per init() leaked an
|
|
1447
|
+
// 'exit' listener on every init/close cycle. The handler reads live
|
|
1448
|
+
// module state, so it still flushes whatever DB is open at exit.
|
|
1449
|
+
if (!_exitHandlerRegistered) {
|
|
1450
|
+
_exitHandlerRegistered = true;
|
|
1451
|
+
process.on("exit", function () {
|
|
1452
|
+
try { if (atRest === "encrypted") encryptToDisk(); } catch (_e) { /* exit handler — silent */ }
|
|
1453
|
+
});
|
|
1454
|
+
}
|
|
1291
1455
|
}
|
|
1292
1456
|
|
|
1293
1457
|
log("ready (mode: " + atRest + ", path: " + dbPath + ")");
|
|
@@ -1924,6 +2088,11 @@ function close() {
|
|
|
1924
2088
|
encTimer.stop();
|
|
1925
2089
|
encTimer = null;
|
|
1926
2090
|
}
|
|
2091
|
+
if (storageProbeTimer) {
|
|
2092
|
+
storageProbeTimer.stop();
|
|
2093
|
+
storageProbeTimer = null;
|
|
2094
|
+
}
|
|
2095
|
+
writesRefused = false;
|
|
1927
2096
|
// Drop prepared-statement cache so the underlying Statement handles
|
|
1928
2097
|
// release ahead of database.close().
|
|
1929
2098
|
_prepareCache.clear();
|
|
@@ -1942,11 +2111,19 @@ function close() {
|
|
|
1942
2111
|
// Order: encrypt while the DB is still open (so the file is consistent),
|
|
1943
2112
|
// then close the SQLite handle (releases the file lock on Windows),
|
|
1944
2113
|
// THEN unlink the plaintext sidecar files.
|
|
1945
|
-
|
|
1946
|
-
|
|
2114
|
+
var encryptOk = false;
|
|
2115
|
+
try { encryptToDisk(); encryptOk = true; } catch (e) {
|
|
2116
|
+
log.error("close: final encrypt failed: " + e.message +
|
|
2117
|
+
" — keeping the plaintext working copy so the next boot can recover " +
|
|
2118
|
+
"the latest writes (db.enc still holds the prior snapshot)");
|
|
1947
2119
|
}
|
|
1948
2120
|
try { database.close(); } catch (_e) { /* already closed */ }
|
|
1949
|
-
|
|
2121
|
+
// Only discard the plaintext working copy once it has been safely
|
|
2122
|
+
// re-encrypted. If the final encrypt failed (full /dev/shm, disk-full),
|
|
2123
|
+
// the working copy is the ONLY carrier of writes since the last periodic
|
|
2124
|
+
// flush — keep it so decryptToTmp's newer-mtime recovery picks it up next
|
|
2125
|
+
// boot (integrity-probed, falling back to db.enc if it is itself corrupt).
|
|
2126
|
+
if (atRest === "encrypted" && encryptOk) removePlaintextFiles();
|
|
1950
2127
|
database = null;
|
|
1951
2128
|
initialized = false;
|
|
1952
2129
|
}
|
|
@@ -2440,6 +2617,7 @@ function _cascadeStep(name, ref) {
|
|
|
2440
2617
|
// Test helpers — not part of public contract
|
|
2441
2618
|
function _resetForTest() {
|
|
2442
2619
|
if (encTimer) { encTimer.stop(); encTimer = null; }
|
|
2620
|
+
if (storageProbeTimer) { storageProbeTimer.stop(); storageProbeTimer = null; }
|
|
2443
2621
|
try { if (database) database.close(); }
|
|
2444
2622
|
catch (e) { log.debug("test-reset close failed", { error: e.message }); }
|
|
2445
2623
|
database = null;
|
|
@@ -2448,10 +2626,20 @@ function _resetForTest() {
|
|
|
2448
2626
|
encKey = null;
|
|
2449
2627
|
atRest = null;
|
|
2450
2628
|
dataDir = null;
|
|
2629
|
+
minFreeBytes = 0;
|
|
2630
|
+
statfsProbe = null;
|
|
2631
|
+
writesRefused = false;
|
|
2451
2632
|
initialized = false;
|
|
2452
2633
|
cryptoField.clearForTest();
|
|
2453
2634
|
}
|
|
2454
2635
|
|
|
2636
|
+
// Test seam — force a storage-headroom probe synchronously (the production
|
|
2637
|
+
// path runs it on a 10s timer) and read the resulting refuse-writes flag.
|
|
2638
|
+
function _probeStorageForTest() {
|
|
2639
|
+
_probeStorageHeadroom();
|
|
2640
|
+
return { writesRefused: writesRefused, minFreeBytes: minFreeBytes };
|
|
2641
|
+
}
|
|
2642
|
+
|
|
2455
2643
|
|
|
2456
2644
|
/**
|
|
2457
2645
|
* @primitive b.db.vacuumAfterErase
|
|
@@ -3102,6 +3290,8 @@ module.exports = {
|
|
|
3102
3290
|
_cascadeStep("redact", _resetRedact);
|
|
3103
3291
|
_cascadeStep("external-db", _resetExternalDb);
|
|
3104
3292
|
},
|
|
3293
|
+
// Test seam for the tmpfs free-space guard — force a probe + read the flag.
|
|
3294
|
+
_probeStorageForTest: _probeStorageForTest,
|
|
3105
3295
|
// Helper for audit.checkpoint to write the rollback-detection sidecar
|
|
3106
3296
|
_writeAuditTip: function (tip) {
|
|
3107
3297
|
if (!dataDir) return;
|