@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.
Files changed (52) hide show
  1. package/CHANGELOG.md +5 -1
  2. package/README.md +2 -2
  3. package/lib/admin.js +81 -3
  4. package/lib/asset-manifest.json +5 -1
  5. package/lib/customers.js +71 -4
  6. package/lib/storefront.js +462 -0
  7. package/lib/vendor/MANIFEST.json +2 -2
  8. package/lib/vendor/blamejs/CHANGELOG.md +20 -0
  9. package/lib/vendor/blamejs/api-snapshot.json +6 -2
  10. package/lib/vendor/blamejs/examples/wiki/docker-compose.prod.yml +29 -0
  11. package/lib/vendor/blamejs/examples/wiki/docker-compose.yml +7 -0
  12. package/lib/vendor/blamejs/lib/agent-idempotency.js +15 -3
  13. package/lib/vendor/blamejs/lib/agent-orchestrator.js +39 -0
  14. package/lib/vendor/blamejs/lib/app-shutdown.js +32 -0
  15. package/lib/vendor/blamejs/lib/bounded-map.js +102 -0
  16. package/lib/vendor/blamejs/lib/cache.js +184 -23
  17. package/lib/vendor/blamejs/lib/cert.js +68 -11
  18. package/lib/vendor/blamejs/lib/cluster-storage.js +114 -10
  19. package/lib/vendor/blamejs/lib/db.js +205 -15
  20. package/lib/vendor/blamejs/lib/dual-control.js +139 -143
  21. package/lib/vendor/blamejs/lib/i18n.js +10 -1
  22. package/lib/vendor/blamejs/lib/mail-crypto-smime.js +43 -9
  23. package/lib/vendor/blamejs/lib/network-dns.js +10 -2
  24. package/lib/vendor/blamejs/lib/nonce-store.js +39 -12
  25. package/lib/vendor/blamejs/lib/queue-local.js +2 -2
  26. package/lib/vendor/blamejs/lib/redis-client.js +14 -1
  27. package/lib/vendor/blamejs/lib/subject.js +8 -1
  28. package/lib/vendor/blamejs/package.json +1 -1
  29. package/lib/vendor/blamejs/release-notes/v0.13.33.json +26 -0
  30. package/lib/vendor/blamejs/release-notes/v0.13.34.json +48 -0
  31. package/lib/vendor/blamejs/release-notes/v0.13.35.json +35 -0
  32. package/lib/vendor/blamejs/release-notes/v0.13.36.json +27 -0
  33. package/lib/vendor/blamejs/release-notes/v0.13.37.json +18 -0
  34. package/lib/vendor/blamejs/release-notes/v0.13.38.json +27 -0
  35. package/lib/vendor/blamejs/release-notes/v0.13.39.json +27 -0
  36. package/lib/vendor/blamejs/release-notes/v0.13.40.json +22 -0
  37. package/lib/vendor/blamejs/release-notes/v0.13.41.json +27 -0
  38. package/lib/vendor/blamejs/release-notes/v0.13.42.json +18 -0
  39. package/lib/vendor/blamejs/test/20-db.js +191 -0
  40. package/lib/vendor/blamejs/test/layer-0-primitives/agent-idempotency.test.js +20 -0
  41. package/lib/vendor/blamejs/test/layer-0-primitives/agent-orchestrator.test.js +33 -0
  42. package/lib/vendor/blamejs/test/layer-0-primitives/api-encrypt.test.js +35 -0
  43. package/lib/vendor/blamejs/test/layer-0-primitives/app-shutdown.test.js +64 -0
  44. package/lib/vendor/blamejs/test/layer-0-primitives/bounded-map.test.js +87 -0
  45. package/lib/vendor/blamejs/test/layer-0-primitives/cache.test.js +48 -0
  46. package/lib/vendor/blamejs/test/layer-0-primitives/cert.test.js +170 -0
  47. package/lib/vendor/blamejs/test/layer-0-primitives/cluster-storage.test.js +125 -0
  48. package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +92 -1
  49. package/lib/vendor/blamejs/test/layer-0-primitives/dual-control.test.js +32 -0
  50. package/lib/vendor/blamejs/test/layer-0-primitives/mail-crypto-smime.test.js +31 -0
  51. package/lib/vendor/blamejs/test/layer-0-primitives/redis-client.test.js +14 -0
  52. 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
- throw new CertError("cert/bad-meta",
144
- "cert: meta.json for '" + certName + "' is corrupt: " + e.message);
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 plain = (opts.storage.vault || vault().getDefaultStore()).unseal(sealedBuf);
417
- var jwk = safeJson.parse(plain.toString("utf8"), { maxBytes: C.BYTES.kib(64) });
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 storage.readSealed(certManifest.name + "/cert.pem");
561
- var keyBuf = await storage.readSealed(certManifest.name + "/key.pem");
562
- if (!forceIssue && meta && certBuf && keyBuf &&
563
- meta.expiresAt > Date.now() + minDaysBeforeExpiry * C.TIME.days(1)) {
564
- // Cached, not due for renewal yet.
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: meta.expiresAt,
569
- fingerprintSha256: meta.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. node:sqlite is sync wrap in a resolved Promise
279
- // so callers always see the same shape regardless of mode.
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 };
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
- log("plaintext is newer than encrypted — keeping plaintext (crash recovery)");
681
- return;
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. statvfs/statfs
882
- // isn't in stable Node, but on Linux we can check that tmpDir
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 = database.prepare("PRAGMA integrity_check").all();
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
- process.on("exit", function () {
1289
- try { encryptToDisk(); } catch (_e) { /* exit handler silent */ }
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
- try { encryptToDisk(); } catch (e) {
1946
- log.error("close: final encrypt failed: " + e.message);
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
- if (atRest === "encrypted") removePlaintextFiles();
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;