@blamejs/core 0.13.36 → 0.13.38

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 CHANGED
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.13.x
10
10
 
11
+ - v0.13.38 (2026-05-29) — **Atomic cache tag invalidation, and a clusterStorage.transaction primitive for multi-statement framework writes.** The cluster cache stored a value and its tag index with separate statements, so two concurrent writes to the same key could interleave their tag updates and leave the index out of step with the value — a later tag-based invalidation would then miss the key, letting a stale (possibly authorization-bearing) value survive a wipe. The value and tag writes now commit as one atomic unit. The enabling piece is a new b.clusterStorage.transaction primitive that runs a multi-statement read-modify-write all-or-nothing against the active backend — the external DB's pooled transaction in cluster mode, and a serialized transaction on the shared SQLite connection in single-node mode (so no concurrent statement can interleave into an open transaction). **Added:** *b.clusterStorage.transaction(fn) — atomic multi-statement framework-state writes* — Runs `fn` inside one transaction against the active backend so a multi-statement read-modify-write commits all-or-nothing. `fn` receives a transaction handle with the same `execute` / `executeOne` / `executeAll` surface, scoped to the open transaction. Cluster mode uses the external DB's pooled transaction (with its deadlock retry); single-node mode serializes against other transactions and against `execute` on the shared SQLite connection, so a concurrent statement cannot interleave into an open transaction. Use the handle's methods inside `fn` — calling the module-level `execute` from within `fn` would wait on the very transaction it is running. **Fixed:** *Cluster cache tag invalidation can no longer miss a key under concurrent writes* — The cluster cache wrote a value (`INSERT ... ON CONFLICT DO UPDATE`) and then rewrote its tag index (`DELETE` prior tags, `INSERT` new ones) as separate statements. Two concurrent `set()`s on the same key could interleave those tag statements, leaving the tag index inconsistent with the value — so a later `invalidateTag` could miss the key and a stale value would survive the wipe. The value and tag writes now run inside a single `clusterStorage.transaction`, so a concurrent writer observes either the whole prior state or the whole new state, never a mix.
12
+
13
+ - v0.13.37 (2026-05-29) — **Encrypted-mode DB refuses writes before a full tmpfs corrupts it.** In encrypted-at-rest mode the live SQLite working copy is on a tmpfs (Docker's /dev/shm defaults to 64 MiB). If that fills — an append-only audit chain and session rows grow over time — SQLite hits ENOSPC and the working copy is corrupted. Earlier releases made that corruption self-heal on the next boot by rolling back to the last encrypted snapshot, but the rollback still loses writes since the last flush. This adds the prevention side: a periodic free-space probe refuses growth writes (INSERT / UPDATE / REPLACE) with a clear db/storage-low error once free space drops below a threshold, before the tmpfs fills. DELETE and reads stay available so retention can reclaim space and the application keeps serving, and the refusal lifts automatically once free space recovers. The threshold defaults to 16 MiB of headroom and is tunable; the guard is encrypted-mode only. **Security:** *Free-space guard refuses growth writes before the tmpfs working copy fills* — `b.db` in encrypted-at-rest mode now probes free space on the tmpfs holding the working copy and, when it falls below `minFreeBytes` (default 16 MiB), refuses `INSERT` / `UPDATE` / `REPLACE` with a clear `db/storage-low` error instead of letting the write run the mount out of space and corrupt the database. `DELETE`, reads, and DDL stay available so retention can prune and the app keeps serving; the refusal clears automatically when free space recovers. The error message points at the cause (Docker's 64 MiB `/dev/shm` default) and the fix (`shm_size` / `--shm-size`, or pruning). Set `minFreeBytes` to tune the headroom, or `0` to disable. This complements the existing boot-time recovery: prevention (fail clear, keep recent writes) ahead of recovery (roll back to the last snapshot).
14
+
11
15
  - v0.13.36 (2026-05-29) — **Certificate renewal trusts the sealed cert's own expiry, not the plaintext index.** The managed-certificate renewal check decided a cached cert was still fresh by reading expiresAt from the plaintext meta.json index that sits beside the sealed cert, rather than from the certificate itself. If that index drifted from — or was tampered relative to — the actual cert (a far-future expiry recorded over a certificate that is in fact near expiry), the manager would skip renewal and keep serving a cert that was about to expire or already had. Renewal now re-derives the expiry and fingerprint from the sealed certificate itself; meta.json is treated as an advisory convenience copy. A sealed cert that no longer parses is re-issued (the same recovery as an unreadable one), and a corrupt meta.json over a valid cert now loads cleanly from the cert instead of forcing a needless re-issue. The local job queue also bounds the size of a job payload it parses back from a stored row, matching the cap the dead-letter listing already used. **Fixed:** *Local job queue bounds the size of a payload parsed back from a stored row* — When the local queue leased a job or re-enqueued a repeating one, it parsed the job payload back from its stored row without an upper size bound, unlike the dead-letter listing which already capped it. A row with an oversized payload (a corrupted or tampered store) could force an unbounded parse. Both paths now cap the parse at the same 64 MiB ceiling the dead-letter path uses. **Security:** *Cert renewal re-derives expiry from the sealed certificate, not the meta.json index* — `b.cert`'s renewal short-circuit read `expiresAt` from the plaintext `meta.json` written beside each sealed cert. Because that index can drift from the actual certificate (or be altered independently of it), a far-future value over an actually-expiring cert would suppress renewal and serve a cert past — or about to pass — its validity. The renewal decision now parses the expiry and fingerprint from the sealed certificate itself on load, so `meta.json` is advisory only. A sealed cert that will not parse is treated as corrupt and re-issued; a corrupt `meta.json` over an otherwise-valid cert loads from the cert without a needless re-issue.
12
16
 
13
17
  - v0.13.35 (2026-05-29) — **In-memory replay, idempotency, DNS, and i18n stores gain entry-count ceilings.** Several framework caches keyed on request-influenced input grew without an upper bound between their periodic sweeps, so a flood of unique keys could exhaust process memory faster than the sweep reclaimed it. Each now enforces a hard entry ceiling. The replay-protection nonce store is the security-sensitive one: rather than evict a live nonce to admit a new one — which would reopen a replay window for the evicted nonce — it purges expired entries and then fails closed at capacity, refusing the unrecordable request instead of admitting it unprotected. The idempotency, DNS, and i18n caches hold re-derivable values, so they evict the oldest entry instead (the worst case is a recomputed value or a single re-executed retry under flood). Ceilings are generous defaults that normal traffic never reaches; the nonce store and the agent idempotency in-memory backend expose options to tune them. **Fixed:** *Agent idempotency in-memory backend no longer grows without bound* — The default in-memory backend for `b.agent.idempotency` is keyed on the request-supplied idempotency key, and its garbage collector only reclaims expired rows when an operator wires a scheduler to call it — so a flood of distinct keys could grow it until the process ran out of memory. It now caps its entry count and evicts oldest-first; a dropped record just means that one key re-executes on a later retry, never a crash. A new `maxInMemoryEntries` option tunes the ceiling (default 100,000); deployments needing a hard guarantee at scale still supply a durable `store`. · *DNS resolver cache is bounded* — The positive and negative resolver caches in `b.network.dns` reclaimed an expired entry only when the same hostname was looked up again, so entries for never-requeried hostnames persisted — and hostnames reaching the resolver are request-influenced (outbound request targets, mail MX lookups). Both caches now cap their entry count and evict oldest-first; DNS simply re-resolves on the next miss. · *i18n formatter cache is bounded* — Per-instance `Intl` formatter caches in `b.i18n` are keyed on the locale plus a hash of the format options. The format-options shape is open-ended and caller-supplied, so the key space was request-influenced and uncapped. The cache now enforces an entry ceiling and evicts oldest-first — a formatter is pure-derived and re-created on the next miss. **Security:** *Replay-nonce store bounds memory and fails closed under a nonce flood* — The in-memory `b.nonceStore` backend recorded every request-supplied nonce until a periodic sweep ran, so a stream of unique nonces could exhaust memory between sweeps (a memory-amplification denial of service). It now caps its entry count. Because a replay-protection store must never evict a live nonce to make room — doing so would reopen a replay window for the evicted nonce — it instead purges expired entries inline and, if still at capacity with live nonces, fails closed: the new request is refused rather than admitted without replay protection. A new `maxEntries` option tunes the ceiling (default 1,000,000).
package/lib/cache.js CHANGED
@@ -511,32 +511,41 @@ function _clusterBackend(cfg) {
511
511
  var storedExpires = (expiresAt === Infinity) ? Number.MAX_SAFE_INTEGER : expiresAt;
512
512
  var now = clock();
513
513
  var ck = _composedKey(key);
514
- // SQLite + Postgres both honor ON CONFLICT (cacheKey) DO UPDATE.
515
- await clusterStorage.execute(
516
- "INSERT INTO _blamejs_cache (cacheKey, valueJson, expiresAt, updatedAt) " +
517
- "VALUES (?, ?, ?, ?) " +
518
- "ON CONFLICT (cacheKey) DO UPDATE SET " +
519
- "valueJson = ?, expiresAt = ?, updatedAt = ?",
520
- [ck, json, storedExpires, now, json, storedExpires, now]
521
- );
522
- // Tag handling: drop any prior tags for this key (tags can change
523
- // across sets), then INSERT the new ones. The PRIMARY KEY on
524
- // (cacheKey, tag) makes the INSERT idempotent if duplicate tags
525
- // sneak in.
526
514
  var tags = meta && Array.isArray(meta.tags) ? meta.tags : null;
527
- await clusterStorage.execute(
528
- "DELETE FROM _blamejs_cache_tags WHERE cacheKey = ?",
529
- [ck]
530
- );
531
- if (tags && tags.length > 0) {
532
- for (var i = 0; i < tags.length; i++) {
533
- await clusterStorage.execute(
534
- "INSERT INTO _blamejs_cache_tags (cacheKey, tag) VALUES (?, ?) " +
535
- "ON CONFLICT (cacheKey, tag) DO NOTHING",
536
- [ck, tags[i]]
537
- );
515
+ // The value UPSERT and the tag-index rewrite (DELETE prior tags, then
516
+ // INSERT the new set) must commit as ONE unit. Done as separate
517
+ // statements they race: two concurrent set()s on the same key can
518
+ // interleave their DELETE/INSERT pairs, leaving a tag index that no
519
+ // longer matches the value row — so a later invalidateTag misses the
520
+ // key (a stale, possibly authorization-bearing, value survives a wipe).
521
+ // Wrapping them in a transaction makes a concurrent set see either the
522
+ // whole prior state or the whole new state, never a mix.
523
+ // SQLite + Postgres both honor ON CONFLICT (cacheKey) DO UPDATE.
524
+ await clusterStorage.transaction(async function (tx) {
525
+ await tx.execute(
526
+ "INSERT INTO _blamejs_cache (cacheKey, valueJson, expiresAt, updatedAt) " +
527
+ "VALUES (?, ?, ?, ?) " +
528
+ "ON CONFLICT (cacheKey) DO UPDATE SET " +
529
+ "valueJson = ?, expiresAt = ?, updatedAt = ?",
530
+ [ck, json, storedExpires, now, json, storedExpires, now]
531
+ );
532
+ // Drop any prior tags for this key (tags can change across sets),
533
+ // then INSERT the new ones. The PRIMARY KEY on (cacheKey, tag) makes
534
+ // the INSERT idempotent if duplicate tags sneak in.
535
+ await tx.execute(
536
+ "DELETE FROM _blamejs_cache_tags WHERE cacheKey = ?",
537
+ [ck]
538
+ );
539
+ if (tags && tags.length > 0) {
540
+ for (var i = 0; i < tags.length; i++) {
541
+ await tx.execute(
542
+ "INSERT INTO _blamejs_cache_tags (cacheKey, tag) VALUES (?, ?) " +
543
+ "ON CONFLICT (cacheKey, tag) DO NOTHING",
544
+ [ck, tags[i]]
545
+ );
546
+ }
538
547
  }
539
- }
548
+ });
540
549
  }
541
550
 
542
551
  async function del(key) {
@@ -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,
package/lib/db.js CHANGED
@@ -132,6 +132,16 @@ 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)
135
145
  var dataDir = null;
136
146
  var initialized = false;
137
147
  var dataResidency = null; // operator's declared region config (validated by storage backends)
@@ -737,6 +747,66 @@ function _dbEncAad(dir) {
737
747
  return Buffer.from("blamejs.db-enc.v1\0" + (dir || ""), "utf8");
738
748
  }
739
749
 
750
+ // Probe free space on the tmpfs holding the working copy and flip the
751
+ // write-refusal flag. Encrypted mode only (the bounded-tmpfs surface);
752
+ // guard disabled when minFreeBytes is 0. A probe failure leaves the flag
753
+ // unchanged — we never refuse writes on a stat error (that would be a
754
+ // self-inflicted outage). Growth writes (INSERT/UPDATE/REPLACE) are gated
755
+ // by the prepare() wrapper installed in init(); DELETE + reads always pass
756
+ // so retention can reclaim space and the app keeps serving.
757
+ function _probeStorageHeadroom() {
758
+ if (atRest !== "encrypted" || !minFreeBytes || !dbPath || !statfsProbe) return;
759
+ var free;
760
+ try {
761
+ var st = statfsProbe(nodePath.dirname(dbPath));
762
+ free = st.bavail * st.bsize;
763
+ if (!isFinite(free)) return;
764
+ } catch (_e) { return; }
765
+ if (free < minFreeBytes && !writesRefused) {
766
+ writesRefused = true;
767
+ log.error("storage low: " + free + " bytes free on the tmpfs working-copy mount (< " +
768
+ minFreeBytes + ") — refusing growth writes (INSERT/UPDATE/REPLACE) until space " +
769
+ "recovers. Raise shm_size / --shm-size, or let retention prune. DELETE + reads still serve.");
770
+ try {
771
+ audit.safeEmit({ action: "db.storage.low", outcome: "failure",
772
+ metadata: { freeBytes: free, minFreeBytes: minFreeBytes } });
773
+ } catch (_e2) { /* drop-silent — observability */ }
774
+ } else if (free >= minFreeBytes && writesRefused) {
775
+ writesRefused = false;
776
+ log("storage recovered: " + free + " bytes free — growth writes re-enabled");
777
+ try {
778
+ audit.safeEmit({ action: "db.storage.recovered", outcome: "success",
779
+ metadata: { freeBytes: free } });
780
+ } catch (_e3) { /* drop-silent */ }
781
+ }
782
+ }
783
+
784
+ // Install the growth-write gate on the SQLite handle: shadow prepare() so
785
+ // INSERT/UPDATE/REPLACE statements throw db/storage-low when the tmpfs is
786
+ // critically low, instead of proceeding into an ENOSPC corruption. Reads,
787
+ // DELETE, PRAGMA, and DDL pass through ungated. Called once in init() after
788
+ // schema setup so init's own writes are never gated (writesRefused is false
789
+ // until the first probe anyway).
790
+ function _installWriteGate() {
791
+ var rawPrepare = database.prepare.bind(database);
792
+ database.prepare = function (sql) {
793
+ var stmt = rawPrepare(sql);
794
+ if (/^\s*(?:INSERT|UPDATE|REPLACE)\b/i.test(sql)) {
795
+ var rawRun = stmt.run.bind(stmt);
796
+ stmt.run = function () {
797
+ if (writesRefused) {
798
+ throw _dbErr("db/storage-low",
799
+ "db: refusing write — the encrypted-mode working copy is on a tmpfs with less than " +
800
+ minFreeBytes + " bytes free (Docker /dev/shm defaults to 64 MiB). Raise shm_size / " +
801
+ "--shm-size, or let retention prune expired rows. DELETE and reads remain available.");
802
+ }
803
+ return rawRun.apply(stmt, arguments);
804
+ };
805
+ }
806
+ return stmt;
807
+ };
808
+ }
809
+
740
810
  function encryptToDisk() {
741
811
  if (!encPath) return;
742
812
  // Force WAL checkpoint so the .db file holds all committed transactions.
@@ -915,11 +985,11 @@ async function init(opts) {
915
985
  if (!nodeFs.existsSync(tmpDir)) nodeFs.mkdirSync(tmpDir, { recursive: true });
916
986
 
917
987
  // D-H7 — if the resolved tmpDir is NOT actually tmpfs, the
918
- // plaintext DB file lives on persistent storage. statvfs/statfs
919
- // isn't in stable Node, but on Linux we can check that tmpDir
920
- // resolves under /dev/shm or /run/shm as a heuristic. On other
988
+ // plaintext DB file lives on persistent storage. We check that tmpDir
989
+ // resolves under /dev/shm or /run/shm on Linux as a heuristic; on other
921
990
  // platforms we warn that the operator must verify tmpfs binding
922
- // out-of-band.
991
+ // out-of-band. (Free-space headroom is enforced separately via
992
+ // fs.statfsSync in the storage guard below.)
923
993
  if (process.platform === "linux") {
924
994
  var realTmp = "";
925
995
  try { realTmp = nodeFs.realpathSync(tmpDir); } catch (_e) { /* stat best-effort */ }
@@ -941,6 +1011,21 @@ async function init(opts) {
941
1011
  dbPath = nodePath.join(tmpDir, "blamejs-" + generateToken(C.BYTES.bytes(16)) + ".db");
942
1012
  encKey = loadOrCreateDbKey(dataDir, opts.dbKeyPath);
943
1013
 
1014
+ // Tmpfs free-space guard. Default headroom is 16 MiB below which growth
1015
+ // writes are refused (fail-clear) before the working copy fills its
1016
+ // bounded tmpfs and corrupts. opts.minFreeBytes tunes it; 0 disables.
1017
+ // opts._statfsForTest injects a free-space reader for tests.
1018
+ if (opts.minFreeBytes !== undefined) {
1019
+ require("./numeric-bounds").requireNonNegativeFiniteIntIfPresent(
1020
+ opts.minFreeBytes, "db.init: opts.minFreeBytes", DbError, "db/bad-min-free-bytes");
1021
+ minFreeBytes = opts.minFreeBytes;
1022
+ } else {
1023
+ minFreeBytes = C.BYTES.mib(16);
1024
+ }
1025
+ statfsProbe = typeof opts._statfsForTest === "function"
1026
+ ? opts._statfsForTest
1027
+ : (typeof nodeFs.statfsSync === "function" ? nodeFs.statfsSync : null);
1028
+
944
1029
  cleanStaleTmpDbs(tmpDir);
945
1030
  decryptToTmp();
946
1031
  } else {
@@ -1337,6 +1422,18 @@ async function init(opts) {
1337
1422
  }
1338
1423
  }, C.TIME.minutes(5), { name: "db-periodic-encrypt" });
1339
1424
 
1425
+ // Tmpfs free-space guard. Install the growth-write gate now (after all
1426
+ // of init's own writes), then probe on a short interval so the
1427
+ // refuse-writes flag tracks a fast-filling tmpfs (the 5-minute encrypt
1428
+ // cadence is far too coarse to catch a fill in time). The guard is a
1429
+ // no-op when minFreeBytes is 0 or no statfs reader is available.
1430
+ if (minFreeBytes && statfsProbe) {
1431
+ _installWriteGate();
1432
+ _probeStorageHeadroom(); // seed the flag from current free space
1433
+ storageProbeTimer = safeAsync.repeating(_probeStorageHeadroom,
1434
+ C.TIME.seconds(10), { name: "db-storage-probe" });
1435
+ }
1436
+
1340
1437
  // Final encrypt on process exit. We don't try to unlink the plaintext
1341
1438
  // here — the SQLite handle may still be open, and the OS reclaims tmpfs
1342
1439
  // on reboot anyway. close() does the orderly shutdown.
@@ -1979,6 +2076,11 @@ function close() {
1979
2076
  encTimer.stop();
1980
2077
  encTimer = null;
1981
2078
  }
2079
+ if (storageProbeTimer) {
2080
+ storageProbeTimer.stop();
2081
+ storageProbeTimer = null;
2082
+ }
2083
+ writesRefused = false;
1982
2084
  // Drop prepared-statement cache so the underlying Statement handles
1983
2085
  // release ahead of database.close().
1984
2086
  _prepareCache.clear();
@@ -2503,6 +2605,7 @@ function _cascadeStep(name, ref) {
2503
2605
  // Test helpers — not part of public contract
2504
2606
  function _resetForTest() {
2505
2607
  if (encTimer) { encTimer.stop(); encTimer = null; }
2608
+ if (storageProbeTimer) { storageProbeTimer.stop(); storageProbeTimer = null; }
2506
2609
  try { if (database) database.close(); }
2507
2610
  catch (e) { log.debug("test-reset close failed", { error: e.message }); }
2508
2611
  database = null;
@@ -2511,10 +2614,20 @@ function _resetForTest() {
2511
2614
  encKey = null;
2512
2615
  atRest = null;
2513
2616
  dataDir = null;
2617
+ minFreeBytes = 0;
2618
+ statfsProbe = null;
2619
+ writesRefused = false;
2514
2620
  initialized = false;
2515
2621
  cryptoField.clearForTest();
2516
2622
  }
2517
2623
 
2624
+ // Test seam — force a storage-headroom probe synchronously (the production
2625
+ // path runs it on a 10s timer) and read the resulting refuse-writes flag.
2626
+ function _probeStorageForTest() {
2627
+ _probeStorageHeadroom();
2628
+ return { writesRefused: writesRefused, minFreeBytes: minFreeBytes };
2629
+ }
2630
+
2518
2631
 
2519
2632
  /**
2520
2633
  * @primitive b.db.vacuumAfterErase
@@ -3165,6 +3278,8 @@ module.exports = {
3165
3278
  _cascadeStep("redact", _resetRedact);
3166
3279
  _cascadeStep("external-db", _resetExternalDb);
3167
3280
  },
3281
+ // Test seam for the tmpfs free-space guard — force a probe + read the flag.
3282
+ _probeStorageForTest: _probeStorageForTest,
3168
3283
  // Helper for audit.checkpoint to write the rollback-detection sidecar
3169
3284
  _writeAuditTip: function (tip) {
3170
3285
  if (!dataDir) return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.13.36",
3
+ "version": "0.13.38",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
package/sbom.cdx.json CHANGED
@@ -2,10 +2,10 @@
2
2
  "$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
3
3
  "bomFormat": "CycloneDX",
4
4
  "specVersion": "1.5",
5
- "serialNumber": "urn:uuid:dcd20559-66bc-408f-8990-fd2eda10f240",
5
+ "serialNumber": "urn:uuid:fc7082b3-a639-421e-8e57-fac70e7ced00",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-05-29T13:55:36.439Z",
8
+ "timestamp": "2026-05-29T16:34:17.392Z",
9
9
  "lifecycles": [
10
10
  {
11
11
  "phase": "build"
@@ -19,14 +19,14 @@
19
19
  }
20
20
  ],
21
21
  "component": {
22
- "bom-ref": "@blamejs/core@0.13.36",
22
+ "bom-ref": "@blamejs/core@0.13.38",
23
23
  "type": "application",
24
24
  "name": "blamejs",
25
- "version": "0.13.36",
25
+ "version": "0.13.38",
26
26
  "scope": "required",
27
27
  "author": "blamejs contributors",
28
28
  "description": "The Node framework that owns its stack.",
29
- "purl": "pkg:npm/%40blamejs/core@0.13.36",
29
+ "purl": "pkg:npm/%40blamejs/core@0.13.38",
30
30
  "properties": [],
31
31
  "externalReferences": [
32
32
  {
@@ -54,7 +54,7 @@
54
54
  "components": [],
55
55
  "dependencies": [
56
56
  {
57
- "ref": "@blamejs/core@0.13.36",
57
+ "ref": "@blamejs/core@0.13.38",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]