@blamejs/blamejs-shop 0.2.14 → 0.2.17
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 +4 -0
- package/README.md +2 -2
- package/lib/admin.js +970 -1
- 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
|
@@ -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;
|
|
@@ -301,27 +301,27 @@ function create(opts) {
|
|
|
301
301
|
|
|
302
302
|
async function cancel(args) {
|
|
303
303
|
if (!args || typeof args !== "object") throw _err("BAD_ARG", "cancel: args required");
|
|
304
|
-
var record = await _load(args.grantId);
|
|
305
|
-
if (!record) return { error: "grant-not-found", grantId: args.grantId };
|
|
306
|
-
if (record.consumedAt !== null) return { error: "grant-already-consumed", grantId: record.grantId };
|
|
307
|
-
if (record.revokedAt !== null) return { error: "grant-revoked", grantId: record.grantId };
|
|
308
|
-
if (record.cancelledAt !== null) return { error: "grant-already-cancelled", grantId: record.grantId };
|
|
309
304
|
var actorId = _actorIdOf(args.cancelledBy);
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
return { error: "
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
305
|
+
var outcome = await cache.update(_key(args.grantId), function (record) {
|
|
306
|
+
if (!record) return { abort: { response: { error: "grant-not-found", grantId: args.grantId } } };
|
|
307
|
+
if (record.consumedAt !== null) return { abort: { response: { error: "grant-already-consumed", grantId: record.grantId } } };
|
|
308
|
+
if (record.revokedAt !== null) return { abort: { response: { error: "grant-revoked", grantId: record.grantId } } };
|
|
309
|
+
if (record.cancelledAt !== null) return { abort: { response: { error: "grant-already-cancelled", grantId: record.grantId } } };
|
|
310
|
+
// Cancellation by anyone other than the requester is a revoke, not a
|
|
311
|
+
// cancel — surface explicitly.
|
|
312
|
+
if (actorId !== record.requestedBy) {
|
|
313
|
+
return { abort: { response: { error: "only-requester-can-cancel", grantId: record.grantId, requestedBy: record.requestedBy } } };
|
|
314
|
+
}
|
|
315
|
+
record.cancelledAt = Date.now();
|
|
316
|
+
record.cancelledReason = args.reason || null;
|
|
317
|
+
return { value: record, expiresAt: record.expiresAt };
|
|
318
|
+
}, { ttlMs: ttlMs });
|
|
319
|
+
if (outcome.aborted) return outcome.aborted.response;
|
|
320
|
+
var rec = outcome.value;
|
|
320
321
|
_emit("dual.grant.cancelled",
|
|
321
|
-
{ grantId:
|
|
322
|
-
cancelledBy: actorId, reason: args.reason || null },
|
|
322
|
+
{ grantId: rec.grantId, action: rec.action, cancelledBy: actorId, reason: args.reason || null },
|
|
323
323
|
"success", args.req);
|
|
324
|
-
return { grantId:
|
|
324
|
+
return { grantId: rec.grantId, status: "cancelled" };
|
|
325
325
|
}
|
|
326
326
|
|
|
327
327
|
async function _load(grantId) {
|
|
@@ -334,156 +334,152 @@ function create(opts) {
|
|
|
334
334
|
|
|
335
335
|
async function approve(args) {
|
|
336
336
|
if (!args || typeof args !== "object") throw _err("BAD_ARG", "approve: args required");
|
|
337
|
-
var record = await _load(args.grantId);
|
|
338
|
-
if (!record) {
|
|
339
|
-
return { error: "grant-not-found", grantId: args.grantId };
|
|
340
|
-
}
|
|
341
|
-
if (record.consumedAt !== null) {
|
|
342
|
-
return { error: "grant-already-consumed", grantId: record.grantId };
|
|
343
|
-
}
|
|
344
|
-
if (record.revokedAt !== null) {
|
|
345
|
-
return { error: "grant-revoked", grantId: record.grantId, revokedReason: record.revokedReason };
|
|
346
|
-
}
|
|
347
|
-
if (record.cancelledAt !== null) {
|
|
348
|
-
return { error: "grant-cancelled", grantId: record.grantId };
|
|
349
|
-
}
|
|
350
|
-
if (record.expiresAt < Date.now()) {
|
|
351
|
-
_emit("dual.grant.expired", { grantId: record.grantId, action: record.action },
|
|
352
|
-
"failure", args.req);
|
|
353
|
-
await cache.del(_key(record.grantId));
|
|
354
|
-
return { error: "grant-expired", grantId: record.grantId };
|
|
355
|
-
}
|
|
356
337
|
var approverId = _actorIdOf(args.approver);
|
|
357
338
|
if (!approverId) throw _err("BAD_ARG", "approve: args.approver must be an actor with a stable id");
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
"denied", args.req);
|
|
362
|
-
return { error: "self-approval-forbidden", grantId: record.grantId };
|
|
363
|
-
}
|
|
364
|
-
if (!_approverRoleOk(args.approver)) {
|
|
365
|
-
_emit("dual.grant.role_denied",
|
|
366
|
-
{ grantId: record.grantId, action: record.action, approver: approverId,
|
|
367
|
-
requiredRoles: approverRoles,
|
|
368
|
-
actorRoles: (args.approver && Array.isArray(args.approver.roles)) ? args.approver.roles : [] },
|
|
369
|
-
"denied", args.req);
|
|
370
|
-
return { error: "approver-role-required", grantId: record.grantId,
|
|
371
|
-
requiredRoles: approverRoles };
|
|
372
|
-
}
|
|
373
|
-
if (record.approvedBy.indexOf(approverId) !== -1) {
|
|
374
|
-
return { error: "already-approved-by-this-actor", grantId: record.grantId,
|
|
375
|
-
approvedBy: record.approvedBy };
|
|
376
|
-
}
|
|
339
|
+
// Pre-compute the pure, record-independent checks so the mutator stays
|
|
340
|
+
// side-effect-free (it may re-run under cluster CAS contention).
|
|
341
|
+
var roleOk = _approverRoleOk(args.approver);
|
|
377
342
|
var reasonProblem = _checkReason(args.reason, "approve");
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
record.
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
if (
|
|
343
|
+
var roleHits = (approverRoles && args.approver && Array.isArray(args.approver.roles))
|
|
344
|
+
? args.approver.roles.filter(function (r) { return approverRoles.indexOf(r) !== -1; })
|
|
345
|
+
: null;
|
|
346
|
+
|
|
347
|
+
// Atomic read-modify-write: the duplicate-approver guard and the
|
|
348
|
+
// approvedBy append commit together, so two concurrent approvals (or a
|
|
349
|
+
// retried one) cannot each read a stale snapshot and append the same
|
|
350
|
+
// approver twice — the quorum-bypass the get/set version allowed.
|
|
351
|
+
var outcome = await cache.update(_key(args.grantId), function (record) {
|
|
352
|
+
if (!record) return { abort: { response: { error: "grant-not-found", grantId: args.grantId } } };
|
|
353
|
+
if (record.consumedAt !== null) return { abort: { response: { error: "grant-already-consumed", grantId: record.grantId } } };
|
|
354
|
+
if (record.revokedAt !== null) return { abort: { response: { error: "grant-revoked", grantId: record.grantId, revokedReason: record.revokedReason } } };
|
|
355
|
+
if (record.cancelledAt !== null) return { abort: { response: { error: "grant-cancelled", grantId: record.grantId } } };
|
|
356
|
+
if (record.expiresAt < Date.now()) {
|
|
357
|
+
return { abort: { response: { error: "grant-expired", grantId: record.grantId },
|
|
358
|
+
event: "dual.grant.expired", meta: { grantId: record.grantId, action: record.action }, outcome: "failure" } };
|
|
359
|
+
}
|
|
360
|
+
if (forbidSelfApprove && approverId === record.requestedBy) {
|
|
361
|
+
return { abort: { response: { error: "self-approval-forbidden", grantId: record.grantId },
|
|
362
|
+
event: "dual.grant.self_approval_denied",
|
|
363
|
+
meta: { grantId: record.grantId, action: record.action, approver: approverId }, outcome: "denied" } };
|
|
364
|
+
}
|
|
365
|
+
if (!roleOk) {
|
|
366
|
+
return { abort: { response: { error: "approver-role-required", grantId: record.grantId, requiredRoles: approverRoles },
|
|
367
|
+
event: "dual.grant.role_denied",
|
|
368
|
+
meta: { grantId: record.grantId, action: record.action, approver: approverId, requiredRoles: approverRoles,
|
|
369
|
+
actorRoles: (args.approver && Array.isArray(args.approver.roles)) ? args.approver.roles : [] }, outcome: "denied" } };
|
|
370
|
+
}
|
|
371
|
+
if (record.approvedBy.indexOf(approverId) !== -1) {
|
|
372
|
+
return { abort: { response: { error: "already-approved-by-this-actor", grantId: record.grantId, approvedBy: record.approvedBy.slice() } } };
|
|
373
|
+
}
|
|
374
|
+
if (reasonProblem) return { abort: { response: Object.assign({ grantId: record.grantId }, reasonProblem) } };
|
|
375
|
+
record.approvedBy.push(approverId);
|
|
376
|
+
record.approvalsAt.push(Date.now());
|
|
377
|
+
record.approvalReasons.push(args.reason || null);
|
|
378
|
+
// Record which required role satisfied the approval — an audit
|
|
379
|
+
// reviewer can confirm the actor approved as e.g. security-officer.
|
|
380
|
+
if (roleHits) record.approverRoleHits.push(roleHits);
|
|
381
|
+
if (record.approvedBy.length >= record.minApprovers && record.quorumReachedAt === null) {
|
|
382
|
+
record.quorumReachedAt = Date.now();
|
|
383
|
+
}
|
|
384
|
+
return { value: record, expiresAt: record.expiresAt };
|
|
385
|
+
}, { ttlMs: ttlMs });
|
|
386
|
+
|
|
387
|
+
if (outcome.aborted) {
|
|
388
|
+
var ab = outcome.aborted;
|
|
389
|
+
if (ab.event) _emit(ab.event, ab.meta, ab.outcome, args.req);
|
|
390
|
+
return ab.response;
|
|
396
391
|
}
|
|
397
|
-
var
|
|
398
|
-
|
|
392
|
+
var rec = outcome.value;
|
|
393
|
+
var status = (rec.approvedBy.length >= rec.minApprovers) ? "approved" : "pending";
|
|
394
|
+
var consumeUnlockAt = rec.quorumReachedAt !== null ? rec.quorumReachedAt + rec.consumeLockMs : null;
|
|
399
395
|
_emit("dual.grant.approved",
|
|
400
|
-
{ grantId:
|
|
401
|
-
approverCount:
|
|
402
|
-
status: status, reason: args.reason || null,
|
|
403
|
-
consumeUnlockAt: record.quorumReachedAt !== null
|
|
404
|
-
? record.quorumReachedAt + record.consumeLockMs : null },
|
|
396
|
+
{ grantId: rec.grantId, action: rec.action, approver: approverId,
|
|
397
|
+
approverCount: rec.approvedBy.length, needs: rec.minApprovers,
|
|
398
|
+
status: status, reason: args.reason || null, consumeUnlockAt: consumeUnlockAt },
|
|
405
399
|
"success", args.req);
|
|
406
400
|
return {
|
|
407
|
-
grantId:
|
|
401
|
+
grantId: rec.grantId,
|
|
408
402
|
status: status,
|
|
409
|
-
approvedBy:
|
|
410
|
-
needs:
|
|
411
|
-
expiresAt:
|
|
412
|
-
consumeUnlockAt:
|
|
413
|
-
? record.quorumReachedAt + record.consumeLockMs : null,
|
|
403
|
+
approvedBy: rec.approvedBy.slice(),
|
|
404
|
+
needs: rec.minApprovers,
|
|
405
|
+
expiresAt: rec.expiresAt,
|
|
406
|
+
consumeUnlockAt: consumeUnlockAt,
|
|
414
407
|
};
|
|
415
408
|
}
|
|
416
409
|
|
|
417
410
|
async function revoke(args) {
|
|
418
411
|
if (!args || typeof args !== "object") throw _err("BAD_ARG", "revoke: args required");
|
|
419
|
-
var
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
return { error: "grant-already-consumed", grantId: record.grantId };
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
412
|
+
var revokedById = _actorIdOf(args.revokedBy);
|
|
413
|
+
var outcome = await cache.update(_key(args.grantId), function (record) {
|
|
414
|
+
if (!record) return { abort: { response: { error: "grant-not-found", grantId: args.grantId } } };
|
|
415
|
+
if (record.consumedAt !== null) return { abort: { response: { error: "grant-already-consumed", grantId: record.grantId } } };
|
|
416
|
+
record.revokedAt = Date.now();
|
|
417
|
+
record.revokedReason = args.reason || null;
|
|
418
|
+
return { value: record, expiresAt: record.expiresAt };
|
|
419
|
+
}, { ttlMs: ttlMs });
|
|
420
|
+
if (outcome.aborted) return outcome.aborted.response;
|
|
421
|
+
var rec = outcome.value;
|
|
428
422
|
_emit("dual.grant.denied",
|
|
429
|
-
{ grantId:
|
|
430
|
-
revokedBy: _actorIdOf(args.revokedBy), reason: args.reason || null },
|
|
423
|
+
{ grantId: rec.grantId, action: rec.action, revokedBy: revokedById, reason: args.reason || null },
|
|
431
424
|
"denied", args.req);
|
|
432
|
-
return { grantId:
|
|
425
|
+
return { grantId: rec.grantId, status: "revoked" };
|
|
433
426
|
}
|
|
434
427
|
|
|
435
428
|
async function consume(grantId, args) {
|
|
436
429
|
args = args || {};
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
return { ready: false, reason: "
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
return { ready: false, reason: "already-consumed" };
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
"failure", args.req);
|
|
451
|
-
await cache.del(_key(record.grantId));
|
|
452
|
-
return { ready: false, reason: "expired" };
|
|
453
|
-
}
|
|
454
|
-
if (record.approvedBy.length < record.minApprovers) {
|
|
455
|
-
return { ready: false, reason: "not-enough-approvers",
|
|
456
|
-
approvedBy: record.approvedBy.slice(), needs: record.minApprovers };
|
|
457
|
-
}
|
|
458
|
-
// Cooling-off lock: ANY approval-quorum-reached grant can't consume
|
|
459
|
-
// until consumeLockMs has passed since the final approval. Defends
|
|
460
|
-
// against rapid-burst compromise of requester+approver.
|
|
461
|
-
if ((record.consumeLockMs || 0) > 0 && record.quorumReachedAt !== null) {
|
|
462
|
-
var unlockAt = record.quorumReachedAt + record.consumeLockMs;
|
|
463
|
-
if (Date.now() < unlockAt) {
|
|
464
|
-
_emit("dual.grant.consume_locked",
|
|
465
|
-
{ grantId: record.grantId, action: record.action,
|
|
466
|
-
unlockAt: unlockAt, waitMs: unlockAt - Date.now() },
|
|
467
|
-
"denied", args.req);
|
|
468
|
-
return { ready: false, reason: "consume-locked", unlockAt: unlockAt,
|
|
469
|
-
waitMs: unlockAt - Date.now() };
|
|
430
|
+
// Atomic read-modify-write: the consumedAt check and the consumedAt set
|
|
431
|
+
// commit together (compare-and-set), so two concurrent consumes cannot
|
|
432
|
+
// both observe consumedAt === null and both proceed — exactly one wins
|
|
433
|
+
// and runs the destructive operation. The get/set version let a
|
|
434
|
+
// single-use grant be consumed twice.
|
|
435
|
+
var outcome = await cache.update(_key(grantId), function (record) {
|
|
436
|
+
if (!record) return { abort: { response: { ready: false, reason: "grant-not-found" } } };
|
|
437
|
+
if (record.revokedAt !== null) return { abort: { response: { ready: false, reason: "revoked" } } };
|
|
438
|
+
if (record.cancelledAt !== null) return { abort: { response: { ready: false, reason: "cancelled" } } };
|
|
439
|
+
if (record.consumedAt !== null) return { abort: { response: { ready: false, reason: "already-consumed" } } };
|
|
440
|
+
if (record.expiresAt < Date.now()) {
|
|
441
|
+
return { abort: { response: { ready: false, reason: "expired" }, del: true,
|
|
442
|
+
event: "dual.grant.expired", meta: { grantId: record.grantId, action: record.action }, outcome: "failure" } };
|
|
470
443
|
}
|
|
444
|
+
if (record.approvedBy.length < record.minApprovers) {
|
|
445
|
+
return { abort: { response: { ready: false, reason: "not-enough-approvers",
|
|
446
|
+
approvedBy: record.approvedBy.slice(), needs: record.minApprovers } } };
|
|
447
|
+
}
|
|
448
|
+
// Cooling-off lock: a quorum-reached grant can't consume until
|
|
449
|
+
// consumeLockMs has passed since the final approval — defends against
|
|
450
|
+
// rapid-burst compromise of requester + approver.
|
|
451
|
+
if ((record.consumeLockMs || 0) > 0 && record.quorumReachedAt !== null) {
|
|
452
|
+
var unlockAt = record.quorumReachedAt + record.consumeLockMs;
|
|
453
|
+
if (Date.now() < unlockAt) {
|
|
454
|
+
return { abort: { response: { ready: false, reason: "consume-locked", unlockAt: unlockAt, waitMs: unlockAt - Date.now() },
|
|
455
|
+
event: "dual.grant.consume_locked",
|
|
456
|
+
meta: { grantId: record.grantId, action: record.action, unlockAt: unlockAt, waitMs: unlockAt - Date.now() }, outcome: "denied" } };
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
record.consumedAt = Date.now();
|
|
460
|
+
return { value: record, expiresAt: record.expiresAt };
|
|
461
|
+
}, { ttlMs: ttlMs });
|
|
462
|
+
|
|
463
|
+
if (outcome.aborted) {
|
|
464
|
+
var ab = outcome.aborted;
|
|
465
|
+
if (ab.event) _emit(ab.event, ab.meta, ab.outcome, args.req);
|
|
466
|
+
if (ab.del) { try { await cache.del(_key(grantId)); } catch (_e) { /* sweep handles it */ } }
|
|
467
|
+
return ab.response;
|
|
471
468
|
}
|
|
472
|
-
|
|
473
|
-
//
|
|
474
|
-
await cache.del(_key(
|
|
469
|
+
var rec = outcome.value;
|
|
470
|
+
// Single-use by design — drop the (now consumed) grant from the cache.
|
|
471
|
+
await cache.del(_key(rec.grantId));
|
|
475
472
|
_emit("dual.grant.consumed",
|
|
476
|
-
{ grantId:
|
|
477
|
-
approvedBy:
|
|
478
|
-
approvalReasons: record.approvalReasons.slice() },
|
|
473
|
+
{ grantId: rec.grantId, action: rec.action,
|
|
474
|
+
approvedBy: rec.approvedBy.slice(), approvalReasons: rec.approvalReasons.slice() },
|
|
479
475
|
"success", args.req);
|
|
480
476
|
return {
|
|
481
477
|
ready: true,
|
|
482
|
-
grantId:
|
|
483
|
-
action:
|
|
484
|
-
resource:
|
|
485
|
-
approvedBy:
|
|
486
|
-
requestedBy:
|
|
478
|
+
grantId: rec.grantId,
|
|
479
|
+
action: rec.action,
|
|
480
|
+
resource: rec.resource,
|
|
481
|
+
approvedBy: rec.approvedBy.slice(),
|
|
482
|
+
requestedBy:rec.requestedBy,
|
|
487
483
|
};
|
|
488
484
|
}
|
|
489
485
|
|