@blamejs/blamejs-shop 0.2.15 → 0.2.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +5 -1
- package/README.md +2 -2
- package/lib/admin.js +81 -3
- package/lib/asset-manifest.json +5 -1
- package/lib/customers.js +71 -4
- package/lib/storefront.js +462 -0
- package/lib/vendor/MANIFEST.json +2 -2
- package/lib/vendor/blamejs/CHANGELOG.md +20 -0
- package/lib/vendor/blamejs/api-snapshot.json +6 -2
- package/lib/vendor/blamejs/examples/wiki/docker-compose.prod.yml +29 -0
- package/lib/vendor/blamejs/examples/wiki/docker-compose.yml +7 -0
- package/lib/vendor/blamejs/lib/agent-idempotency.js +15 -3
- package/lib/vendor/blamejs/lib/agent-orchestrator.js +39 -0
- package/lib/vendor/blamejs/lib/app-shutdown.js +32 -0
- package/lib/vendor/blamejs/lib/bounded-map.js +102 -0
- package/lib/vendor/blamejs/lib/cache.js +184 -23
- package/lib/vendor/blamejs/lib/cert.js +68 -11
- package/lib/vendor/blamejs/lib/cluster-storage.js +114 -10
- package/lib/vendor/blamejs/lib/db.js +205 -15
- package/lib/vendor/blamejs/lib/dual-control.js +139 -143
- package/lib/vendor/blamejs/lib/i18n.js +10 -1
- package/lib/vendor/blamejs/lib/mail-crypto-smime.js +43 -9
- package/lib/vendor/blamejs/lib/network-dns.js +10 -2
- package/lib/vendor/blamejs/lib/nonce-store.js +39 -12
- package/lib/vendor/blamejs/lib/queue-local.js +2 -2
- package/lib/vendor/blamejs/lib/redis-client.js +14 -1
- package/lib/vendor/blamejs/lib/subject.js +8 -1
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.13.33.json +26 -0
- package/lib/vendor/blamejs/release-notes/v0.13.34.json +48 -0
- package/lib/vendor/blamejs/release-notes/v0.13.35.json +35 -0
- package/lib/vendor/blamejs/release-notes/v0.13.36.json +27 -0
- package/lib/vendor/blamejs/release-notes/v0.13.37.json +18 -0
- package/lib/vendor/blamejs/release-notes/v0.13.38.json +27 -0
- package/lib/vendor/blamejs/release-notes/v0.13.39.json +27 -0
- package/lib/vendor/blamejs/release-notes/v0.13.40.json +22 -0
- package/lib/vendor/blamejs/release-notes/v0.13.41.json +27 -0
- package/lib/vendor/blamejs/release-notes/v0.13.42.json +18 -0
- package/lib/vendor/blamejs/test/20-db.js +191 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/agent-idempotency.test.js +20 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/agent-orchestrator.test.js +33 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/api-encrypt.test.js +35 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/app-shutdown.test.js +64 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/bounded-map.test.js +87 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/cache.test.js +48 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/cert.test.js +170 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/cluster-storage.test.js +125 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +92 -1
- package/lib/vendor/blamejs/test/layer-0-primitives/dual-control.test.js +32 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/mail-crypto-smime.test.js +31 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/redis-client.test.js +14 -0
- package/package.json +1 -1
|
@@ -90,6 +90,40 @@ async function testNonceStoreMemoryPurge() {
|
|
|
90
90
|
store.close();
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
async function testNonceStoreMemoryCapacityFailsClosed() {
|
|
94
|
+
// A replay-protection store must bound its memory against a nonce flood,
|
|
95
|
+
// but must NOT evict a LIVE nonce to admit a new one — that reopens a
|
|
96
|
+
// replay window for the evicted nonce. So at capacity it fails CLOSED:
|
|
97
|
+
// the new (un-recordable) request is refused, not admitted unprotected.
|
|
98
|
+
var store = b.nonceStore.create({ backend: "memory", maxEntries: 3 });
|
|
99
|
+
var future = Date.now() + 60_000;
|
|
100
|
+
check("cap: first 3 live nonces admitted",
|
|
101
|
+
(await store.checkAndInsert("n1", future)) === true &&
|
|
102
|
+
(await store.checkAndInsert("n2", future)) === true &&
|
|
103
|
+
(await store.checkAndInsert("n3", future)) === true);
|
|
104
|
+
check("cap: store holds exactly maxEntries", store._size() === 3);
|
|
105
|
+
// 4th distinct LIVE nonce — store is full of live entries, so fail closed.
|
|
106
|
+
check("cap: 4th live nonce fails closed (refused)",
|
|
107
|
+
(await store.checkAndInsert("n4", future)) === false);
|
|
108
|
+
check("cap: refused nonce was NOT stored", store._size() === 3 && !(store._size() > 3));
|
|
109
|
+
check("cap: capacity rejection counted", store._capacityRejects() >= 1);
|
|
110
|
+
// A previously-seen live nonce still reports as replay (not capacity).
|
|
111
|
+
check("cap: existing nonce still detected as replay",
|
|
112
|
+
(await store.checkAndInsert("n1", future)) === false);
|
|
113
|
+
store.close();
|
|
114
|
+
|
|
115
|
+
// When the ceiling is hit but EXPIRED entries exist, the inline purge
|
|
116
|
+
// reclaims room so a legitimate new nonce is admitted (not falsely
|
|
117
|
+
// refused). Fill with expired nonces, then a fresh one must succeed.
|
|
118
|
+
var store2 = b.nonceStore.create({ backend: "memory", maxEntries: 2 });
|
|
119
|
+
var past = Date.now() - 1000;
|
|
120
|
+
await store2.checkAndInsert("old1", past);
|
|
121
|
+
await store2.checkAndInsert("old2", past);
|
|
122
|
+
check("cap: full of EXPIRED entries → fresh nonce admitted (purge reclaims)",
|
|
123
|
+
(await store2.checkAndInsert("freshOne", Date.now() + 60_000)) === true);
|
|
124
|
+
store2.close();
|
|
125
|
+
}
|
|
126
|
+
|
|
93
127
|
async function testNonceStoreClusterBasics() {
|
|
94
128
|
var tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "blamejs-ns-"));
|
|
95
129
|
try {
|
|
@@ -1030,6 +1064,7 @@ async function run() {
|
|
|
1030
1064
|
await testNonceStoreMemoryBasics();
|
|
1031
1065
|
await testNonceStoreMemoryRejectsBadInput();
|
|
1032
1066
|
await testNonceStoreMemoryPurge();
|
|
1067
|
+
await testNonceStoreMemoryCapacityFailsClosed();
|
|
1033
1068
|
await testNonceStoreClusterBasics();
|
|
1034
1069
|
await testNonceStoreCustomBackend();
|
|
1035
1070
|
await testNonceStoreUnknownBackend();
|
|
@@ -275,6 +275,10 @@ async function testAppShutdownConfigValidation() {
|
|
|
275
275
|
} catch (e) { threw = e; }
|
|
276
276
|
check("create: bad-shaped phase rejected", threw && threw.code === "app-shutdown/bad-phase");
|
|
277
277
|
|
|
278
|
+
threw = null;
|
|
279
|
+
try { b.appShutdown.create({ forceExitMarginMs: -1 }); } catch (e) { threw = e; }
|
|
280
|
+
check("create: bad forceExitMarginMs rejected", threw && threw.code === "app-shutdown/bad-force-exit-margin-ms");
|
|
281
|
+
|
|
278
282
|
threw = null;
|
|
279
283
|
var o = b.appShutdown.create({ phases: [] });
|
|
280
284
|
try { o.addPhase({ name: "x" }); } catch (e) { threw = e; }
|
|
@@ -282,6 +286,65 @@ async function testAppShutdownConfigValidation() {
|
|
|
282
286
|
o._resetForTest();
|
|
283
287
|
}
|
|
284
288
|
|
|
289
|
+
async function testAppShutdownWatchdogForcesExitOnHang() {
|
|
290
|
+
// A shutdown phase that never settles must NOT hold the process open
|
|
291
|
+
// until the supervisor SIGKILLs it (losing the final DB flush). When the
|
|
292
|
+
// operator delegates lifecycle via installSignalHandlers, a watchdog
|
|
293
|
+
// forces a clean exit graceMs + forceExitMarginMs after the signal so
|
|
294
|
+
// exit handlers (the DB re-encrypt) still run. Verified in a child
|
|
295
|
+
// process — the watchdog calls process.exit, which would kill the runner.
|
|
296
|
+
if (process.platform === "win32") {
|
|
297
|
+
// Node can't deliver SIGTERM to a JS handler on Windows (the OS
|
|
298
|
+
// terminates the process), so the graceful signal path the watchdog
|
|
299
|
+
// guards doesn't exist here. This defends a Linux-container SIGTERM
|
|
300
|
+
// deployment; the container smoke leg exercises it for real.
|
|
301
|
+
check("watchdog test skipped on win32 (no deliverable SIGTERM)", true);
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
var cp = require("node:child_process");
|
|
305
|
+
var repoRoot = require("node:path").resolve(__dirname, "..", "..");
|
|
306
|
+
var script =
|
|
307
|
+
"var b = require(" + JSON.stringify(repoRoot) + ");" +
|
|
308
|
+
"b.appShutdown.create({" +
|
|
309
|
+
" graceMs: 100, forceExitMarginMs: 150, installSignalHandlers: true," +
|
|
310
|
+
" phases: [{ name: 'hang', run: function () { return new Promise(function () {}); } }]" +
|
|
311
|
+
"});" +
|
|
312
|
+
"setInterval(function () {}, 60000);" + // keep the loop alive until the signal
|
|
313
|
+
"process.stdout.write('READY\\n');";
|
|
314
|
+
var child = cp.spawn(process.execPath, ["-e", script], { stdio: ["ignore", "pipe", "pipe"] });
|
|
315
|
+
|
|
316
|
+
// Single exit observer + bounded waits so a misbehaving child can never
|
|
317
|
+
// hang the test (it fails loudly via waitUntil's timeout instead).
|
|
318
|
+
var sentAt = null;
|
|
319
|
+
var exited = null;
|
|
320
|
+
var stderr = "";
|
|
321
|
+
child.stderr.on("data", function (d) { stderr += d.toString(); });
|
|
322
|
+
child.on("exit", function (code, signal) {
|
|
323
|
+
exited = { code: code, signal: signal, ms: sentAt === null ? 0 : Date.now() - sentAt };
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
var sawReady = false;
|
|
327
|
+
child.stdout.on("data", function (d) { if (d.toString().indexOf("READY") !== -1) sawReady = true; });
|
|
328
|
+
try {
|
|
329
|
+
await helpers.waitUntil(function () { return sawReady || exited !== null; },
|
|
330
|
+
{ timeoutMs: 6000, label: "app-shutdown watchdog: child reached READY" });
|
|
331
|
+
} catch (_e) { /* fall through to the check below */ }
|
|
332
|
+
check("watchdog child reached READY (stderr: " + stderr.slice(0, 160).replace(/\n/g, " ") + ")",
|
|
333
|
+
sawReady && exited === null);
|
|
334
|
+
if (!sawReady || exited !== null) { try { child.kill("SIGKILL"); } catch (_e2) { /* gone */ } return; }
|
|
335
|
+
|
|
336
|
+
sentAt = Date.now();
|
|
337
|
+
child.kill("SIGTERM");
|
|
338
|
+
// The watchdog fires at graceMs(100) + forceExitMarginMs(150) = 250ms.
|
|
339
|
+
await helpers.waitUntil(function () { return exited !== null; },
|
|
340
|
+
{ timeoutMs: 6000, label: "app-shutdown watchdog: child forced exit after SIGTERM" });
|
|
341
|
+
// It EXITS (not hangs) and does so on its own — a forced process.exit
|
|
342
|
+
// (numeric code, no kill signal), not because the OS killed it.
|
|
343
|
+
check("hung shutdown forced to exit by watchdog (not hung)", exited && exited.ms < 4000);
|
|
344
|
+
check("watchdog exit was a clean process.exit (not a kill signal)",
|
|
345
|
+
exited && exited.signal === null && typeof exited.code === "number");
|
|
346
|
+
}
|
|
347
|
+
|
|
285
348
|
async function run() {
|
|
286
349
|
testAppShutdownSurface();
|
|
287
350
|
await testAppShutdownEmpty();
|
|
@@ -299,6 +362,7 @@ async function run() {
|
|
|
299
362
|
await testAppShutdownStandardPhasesOmitsAbsentComponents();
|
|
300
363
|
await testAppShutdownSignalHandlersInstall();
|
|
301
364
|
await testAppShutdownConfigValidation();
|
|
365
|
+
await testAppShutdownWatchdogForcesExitOnHang();
|
|
302
366
|
}
|
|
303
367
|
|
|
304
368
|
module.exports = { run: run };
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* bounded-map — entry-count-capped Map facade.
|
|
4
|
+
*
|
|
5
|
+
* Run standalone: `node test/layer-0-primitives/bounded-map.test.js`
|
|
6
|
+
* Or via smoke: `node test/smoke.js`
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
var helpers = require("../helpers");
|
|
10
|
+
var check = helpers.check;
|
|
11
|
+
var { boundedMap, BoundedMapError } = require("../../lib/bounded-map");
|
|
12
|
+
|
|
13
|
+
function testEvictOldest() {
|
|
14
|
+
var evicted = [];
|
|
15
|
+
var m = boundedMap({ maxEntries: 3, onEvict: function (k, v) { evicted.push(k + "=" + v); } });
|
|
16
|
+
m.set("a", 1); m.set("b", 2); m.set("c", 3);
|
|
17
|
+
check("evict-oldest: at capacity, all present", m.size === 3 && m.has("a") && m.has("c"));
|
|
18
|
+
m.set("d", 4);
|
|
19
|
+
check("evict-oldest: size stays at cap", m.size === 3);
|
|
20
|
+
check("evict-oldest: oldest (a) evicted", !m.has("a") && m.has("d"));
|
|
21
|
+
m.set("e", 5);
|
|
22
|
+
check("evict-oldest: next oldest (b) evicted", !m.has("b") && m.has("e"));
|
|
23
|
+
check("evict-oldest: onEvict fired for a,b", evicted.join(",") === "a=1,b=2");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function testUpdateDoesNotGrowOrEvict() {
|
|
27
|
+
var evicted = [];
|
|
28
|
+
var m = boundedMap({ maxEntries: 2, onEvict: function (k) { evicted.push(k); } });
|
|
29
|
+
m.set("x", 1); m.set("y", 1);
|
|
30
|
+
var r = m.set("x", 99); // update existing — must not evict y
|
|
31
|
+
check("update returns true", r === true);
|
|
32
|
+
check("update keeps size at 2", m.size === 2);
|
|
33
|
+
check("update did not evict", m.has("y") && evicted.length === 0);
|
|
34
|
+
check("update changed the value", m.get("x") === 99);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function testRejectPolicy() {
|
|
38
|
+
var m = boundedMap({ maxEntries: 2, policy: "reject" });
|
|
39
|
+
check("reject: first two stored", m.set("a", 1) === true && m.set("b", 1) === true);
|
|
40
|
+
check("reject: third refused", m.set("c", 1) === false);
|
|
41
|
+
check("reject: new key NOT stored", !m.has("c") && m.size === 2);
|
|
42
|
+
check("reject: live entries untouched", m.has("a") && m.has("b"));
|
|
43
|
+
// Updating an existing key is still allowed at capacity (no growth).
|
|
44
|
+
check("reject: update existing allowed", m.set("a", 2) === true && m.get("a") === 2);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function testMapFacade() {
|
|
48
|
+
var m = boundedMap({ maxEntries: 10 });
|
|
49
|
+
m.set("k1", "v1"); m.set("k2", "v2");
|
|
50
|
+
check("get", m.get("k1") === "v1");
|
|
51
|
+
check("has", m.has("k2") && !m.has("nope"));
|
|
52
|
+
check("delete", m.delete("k1") === true && !m.has("k1") && m.size === 1);
|
|
53
|
+
var iter = [];
|
|
54
|
+
for (var e of m) iter.push(e[0] + "=" + e[1]);
|
|
55
|
+
check("iterable yields entries", iter.join(",") === "k2=v2");
|
|
56
|
+
var keys = []; var it = m.keys(); var n; while (!(n = it.next()).done) keys.push(n.value);
|
|
57
|
+
check("keys()", keys.join(",") === "k2");
|
|
58
|
+
var fe = []; m.forEach(function (v, k) { fe.push(k + "=" + v); });
|
|
59
|
+
check("forEach", fe.join(",") === "k2=v2");
|
|
60
|
+
m.clear();
|
|
61
|
+
check("clear", m.size === 0);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function testValidation() {
|
|
65
|
+
function threw(fn) { try { fn(); return null; } catch (e) { return e; } }
|
|
66
|
+
check("maxEntries 0 → throws", threw(function () { boundedMap({ maxEntries: 0 }); }) instanceof BoundedMapError);
|
|
67
|
+
check("maxEntries -1 → throws", threw(function () { boundedMap({ maxEntries: -1 }); }));
|
|
68
|
+
check("maxEntries 1.5 → throws", threw(function () { boundedMap({ maxEntries: 1.5 }); }));
|
|
69
|
+
check("maxEntries missing → throws", threw(function () { boundedMap({}); }));
|
|
70
|
+
var e = threw(function () { boundedMap({ maxEntries: 5, policy: "nope" }); });
|
|
71
|
+
check("bad policy → throws bad-policy", e && e.code === "bounded-map/bad-policy");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function run() {
|
|
75
|
+
testEvictOldest();
|
|
76
|
+
testUpdateDoesNotGrowOrEvict();
|
|
77
|
+
testRejectPolicy();
|
|
78
|
+
testMapFacade();
|
|
79
|
+
testValidation();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
module.exports = { run: run };
|
|
83
|
+
|
|
84
|
+
if (require.main === module) {
|
|
85
|
+
try { run(); console.log("[bounded-map] OK — " + helpers.getChecks() + " checks passed"); }
|
|
86
|
+
catch (e) { console.error("FAIL:", e.stack || e); process.exit(1); }
|
|
87
|
+
}
|
|
@@ -639,6 +639,52 @@ function _newCache(extra) {
|
|
|
639
639
|
return b.cache.create(Object.assign({ namespace: "v411", backend: "memory" }, extra || {}));
|
|
640
640
|
}
|
|
641
641
|
|
|
642
|
+
async function testUpdateMemory() {
|
|
643
|
+
var c = _newCache({ namespace: "upd-mem" });
|
|
644
|
+
var r1 = await c.update("n", function (cur) { return { value: (cur || 0) + 1 }; });
|
|
645
|
+
await c.update("n", function (cur) { return { value: cur + 1 }; });
|
|
646
|
+
check("update memory: increments atomically", (await c.get("n")) === 2 && r1.value === 1);
|
|
647
|
+
var ab = await c.update("n", function () { return { abort: { why: "nope" } }; });
|
|
648
|
+
check("update memory: abort leaves value + returns aborted",
|
|
649
|
+
ab.aborted && ab.aborted.why === "nope" && (await c.get("n")) === 2);
|
|
650
|
+
var dl = await c.update("n", function () { return { delete: true }; });
|
|
651
|
+
check("update memory: delete removes the entry",
|
|
652
|
+
dl.deleted === true && (await c.get("n")) === undefined);
|
|
653
|
+
// update on an absent key sees null and may create.
|
|
654
|
+
var cr = await c.update("fresh", function (cur) { return { value: cur === null ? "created" : "x" }; });
|
|
655
|
+
check("update memory: absent key seen as null", cr.value === "created" && (await c.get("fresh")) === "created");
|
|
656
|
+
await c.close();
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
async function testUpdateClusterCas() {
|
|
660
|
+
var tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "blamejs-cache-upd-"));
|
|
661
|
+
try {
|
|
662
|
+
await setupTestDb(tmpDir);
|
|
663
|
+
var c = b.cache.create({ namespace: "upd-clu", backend: "cluster", ttlMs: b.constants.TIME.minutes(5) });
|
|
664
|
+
await c.update("set", function (cur) { return { value: { items: (cur ? cur.items : []).concat("a") } }; });
|
|
665
|
+
await c.update("set", function (cur) { return { value: { items: cur.items.concat("b") } }; });
|
|
666
|
+
// Concurrent appends: the compare-and-set + retry must preserve BOTH
|
|
667
|
+
// (no lost update) — the cluster race the get/set version dropped.
|
|
668
|
+
await Promise.all([
|
|
669
|
+
c.update("set", function (cur) { return { value: { items: cur.items.concat("x") } }; }),
|
|
670
|
+
c.update("set", function (cur) { return { value: { items: cur.items.concat("y") } }; }),
|
|
671
|
+
]);
|
|
672
|
+
var fin = await c.get("set");
|
|
673
|
+
check("update cluster: concurrent CAS loses no write", fin.items.length === 4);
|
|
674
|
+
check("update cluster: all appends present",
|
|
675
|
+
fin.items.indexOf("a") !== -1 && fin.items.indexOf("b") !== -1 &&
|
|
676
|
+
fin.items.indexOf("x") !== -1 && fin.items.indexOf("y") !== -1);
|
|
677
|
+
var ab = await c.update("set", function () { return { abort: { stop: 1 } }; });
|
|
678
|
+
check("update cluster: abort returns aborted + leaves value",
|
|
679
|
+
ab.aborted && ab.aborted.stop === 1 && (await c.get("set")).items.length === 4);
|
|
680
|
+
await c.update("set", function () { return { delete: true }; });
|
|
681
|
+
check("update cluster: delete removes the entry", (await c.get("set")) === undefined);
|
|
682
|
+
await c.close();
|
|
683
|
+
} finally {
|
|
684
|
+
await teardownTestDb(tmpDir);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
642
688
|
async function testMaxBytesEvictsLru() {
|
|
643
689
|
// Cache size budget: 30 bytes. Each entry ~12 bytes JSON ("aaaaaaaa" → 10).
|
|
644
690
|
var c = _newCache({
|
|
@@ -847,6 +893,8 @@ async function run() {
|
|
|
847
893
|
await testClusterBackendBasics();
|
|
848
894
|
await testClusterNamespaceIsolation();
|
|
849
895
|
await testClusterTtlExpiration();
|
|
896
|
+
await testUpdateMemory();
|
|
897
|
+
await testUpdateClusterCas();
|
|
850
898
|
|
|
851
899
|
// v0.4.11
|
|
852
900
|
await testValidationNewOpts();
|
|
@@ -49,6 +49,30 @@ function _ephemeralVault() {
|
|
|
49
49
|
};
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
function _authVault() {
|
|
53
|
+
// Unlike the XOR _ephemeralVault (which silently produces garbage on a
|
|
54
|
+
// tampered blob), this unseal THROWS on corruption — mirroring the real
|
|
55
|
+
// XChaCha20-Poly1305 vault's authenticated decrypt. Needed to exercise
|
|
56
|
+
// the manager's corrupt-sealed-file recovery path, which keys off an
|
|
57
|
+
// unseal throw.
|
|
58
|
+
var key = crypto.randomBytes(32);
|
|
59
|
+
return {
|
|
60
|
+
seal: function (buf) {
|
|
61
|
+
var tag = crypto.createHmac("sha3-512", key).update(buf).digest();
|
|
62
|
+
return Buffer.concat([tag, Buffer.from(buf)]);
|
|
63
|
+
},
|
|
64
|
+
unseal: function (sealed) {
|
|
65
|
+
var tag = sealed.subarray(0, 64);
|
|
66
|
+
var body = sealed.subarray(64);
|
|
67
|
+
var expect = crypto.createHmac("sha3-512", key).update(body).digest();
|
|
68
|
+
if (tag.length !== expect.length || !crypto.timingSafeEqual(tag, expect)) {
|
|
69
|
+
throw new Error("vault: authentication tag mismatch");
|
|
70
|
+
}
|
|
71
|
+
return Buffer.from(body);
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
52
76
|
async function _selfSignedCert(domains, validityDays) {
|
|
53
77
|
// Generate a parseable self-signed X.509 cert via the vendored
|
|
54
78
|
// @peculiar/x509 bundle. The cert manager only parses cert PEM (to
|
|
@@ -529,6 +553,149 @@ function testAcmeBuildCsrRoundtrip() {
|
|
|
529
553
|
|
|
530
554
|
// ---- Run ----
|
|
531
555
|
|
|
556
|
+
// ---- Corrupt-sealed-state recovery (no boot crash loop) ----
|
|
557
|
+
|
|
558
|
+
async function testCorruptSealedCertReissues() {
|
|
559
|
+
// A corrupt sealed cert/key is RECOVERABLE state — the CA re-issues. The
|
|
560
|
+
// manager must treat an unreadable sealed file like an absent one and
|
|
561
|
+
// re-issue, NOT let a raw unseal/decrypt error escape out of start(): on
|
|
562
|
+
// a managed restart the same corrupt file is read on every boot, so a
|
|
563
|
+
// throw here is an unrecoverable crash loop. (Same shape as the
|
|
564
|
+
// encrypted-DB tmpfs working-copy recovery.)
|
|
565
|
+
var tmp = _tmpDir();
|
|
566
|
+
var vault = _authVault();
|
|
567
|
+
var pem = await _selfSignedCert(["example.com"], 90);
|
|
568
|
+
|
|
569
|
+
fs.mkdirSync(path.join(tmp, "main"), { recursive: true });
|
|
570
|
+
// Cache-fresh cert + meta, so the ONLY reason start() would reach ACME
|
|
571
|
+
// is the corruption — not expiry.
|
|
572
|
+
fs.writeFileSync(path.join(tmp, "main", "cert.pem.sealed"), vault.seal(Buffer.from(pem.certPem)));
|
|
573
|
+
fs.writeFileSync(path.join(tmp, "main", "key.pem.sealed"), vault.seal(Buffer.from(pem.keyPem)));
|
|
574
|
+
fs.writeFileSync(path.join(tmp, "main", "meta.json"), JSON.stringify({
|
|
575
|
+
expiresAt: Date.now() + 90 * 86400000, issuedAt: Date.now(),
|
|
576
|
+
fingerprintSha256: "ff", subject: "CN=example.com",
|
|
577
|
+
lastRenewedAt: Date.now(), keyAlg: "ecdsa-p256",
|
|
578
|
+
}));
|
|
579
|
+
// Corrupt the sealed cert in place so unseal throws an auth error.
|
|
580
|
+
var blob = fs.readFileSync(path.join(tmp, "main", "cert.pem.sealed"));
|
|
581
|
+
blob[blob.length - 1] ^= 0xff;
|
|
582
|
+
fs.writeFileSync(path.join(tmp, "main", "cert.pem.sealed"), blob);
|
|
583
|
+
|
|
584
|
+
var mgr = b.cert.create({
|
|
585
|
+
storage: { type: "sealed-disk", rootDir: tmp, vault: vault },
|
|
586
|
+
acme: { directory: "https://example/", accountKey: "auto" },
|
|
587
|
+
certs: [{ name: "main", domains: ["example.com"],
|
|
588
|
+
challenge: { type: "http-01", provision: async function () {}, cleanup: async function () {} } }],
|
|
589
|
+
audit: false,
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
var threw = null;
|
|
593
|
+
try { await mgr.start(); } catch (e) { threw = e; }
|
|
594
|
+
var msg = (threw && (threw.message || String(threw))) || "";
|
|
595
|
+
// With no reachable CA the re-issue fails with a NETWORK error — which
|
|
596
|
+
// proves start() recovered PAST the corrupt file (rather than crashing
|
|
597
|
+
// on the unseal error before it ever reached the issue path).
|
|
598
|
+
check("corrupt sealed cert → no raw unseal crash",
|
|
599
|
+
threw && !/authentication tag|unseal|decrypt|malformed/i.test(msg));
|
|
600
|
+
check("corrupt sealed cert → routed to ACME re-issue",
|
|
601
|
+
threw && /dns lookup|EAI_AGAIN|ENOTFOUND|getaddrinfo|failed|fetch/i.test(msg));
|
|
602
|
+
try { await mgr.stop(); } catch (_e) { /* never started cleanly */ }
|
|
603
|
+
|
|
604
|
+
// Corrupt meta.json (derived index) must ALSO route to re-issue, not
|
|
605
|
+
// throw cert/bad-meta out of start().
|
|
606
|
+
var tmp2 = _tmpDir();
|
|
607
|
+
var v2 = _authVault();
|
|
608
|
+
fs.mkdirSync(path.join(tmp2, "main"), { recursive: true });
|
|
609
|
+
fs.writeFileSync(path.join(tmp2, "main", "cert.pem.sealed"), v2.seal(Buffer.from(pem.certPem)));
|
|
610
|
+
fs.writeFileSync(path.join(tmp2, "main", "key.pem.sealed"), v2.seal(Buffer.from(pem.keyPem)));
|
|
611
|
+
fs.writeFileSync(path.join(tmp2, "main", "meta.json"), "{ this is not json ");
|
|
612
|
+
var mgr2 = b.cert.create({
|
|
613
|
+
storage: { type: "sealed-disk", rootDir: tmp2, vault: v2 },
|
|
614
|
+
acme: { directory: "https://example/", accountKey: "auto" },
|
|
615
|
+
certs: [{ name: "main", domains: ["example.com"],
|
|
616
|
+
challenge: { type: "http-01", provision: async function () {}, cleanup: async function () {} } }],
|
|
617
|
+
audit: false,
|
|
618
|
+
});
|
|
619
|
+
var threw2 = null;
|
|
620
|
+
try { await mgr2.start(); } catch (e) { threw2 = e; }
|
|
621
|
+
// With a VALID sealed cert present, a corrupt meta.json is advisory:
|
|
622
|
+
// expiry + fingerprint are re-derived from the cert itself, so start()
|
|
623
|
+
// loads the cert cleanly — no cert/bad-meta throw, and no needless
|
|
624
|
+
// re-issue (a corrupt derived index must not force a network round-trip).
|
|
625
|
+
check("corrupt meta.json → no cert/bad-meta throw",
|
|
626
|
+
!threw2 || threw2.code !== "cert/bad-meta");
|
|
627
|
+
check("corrupt meta.json → cert still loads from the sealed cert",
|
|
628
|
+
!threw2 && mgr2.getContext("main") && typeof mgr2.getContext("main").cert === "string");
|
|
629
|
+
try { await mgr2.stop(); } catch (_e) {}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
async function testStaleMetaDoesNotServeExpiringCert() {
|
|
633
|
+
// The renewal decision must trust the SEALED cert's own notAfter, not the
|
|
634
|
+
// plaintext meta.json index. A meta.expiresAt that disagrees with the cert
|
|
635
|
+
// — drifted, or tampered far-future over an actually-expiring cert — must
|
|
636
|
+
// NOT let start() short-circuit and serve a cert that is in fact due for
|
|
637
|
+
// renewal.
|
|
638
|
+
var tmp = _tmpDir();
|
|
639
|
+
var vault = _authVault();
|
|
640
|
+
var pem = await _selfSignedCert(["example.com"], 5); // real notAfter ~5d out (< 14-day renew window)
|
|
641
|
+
|
|
642
|
+
fs.mkdirSync(path.join(tmp, "main"), { recursive: true });
|
|
643
|
+
fs.writeFileSync(path.join(tmp, "main", "cert.pem.sealed"), vault.seal(Buffer.from(pem.certPem)));
|
|
644
|
+
fs.writeFileSync(path.join(tmp, "main", "key.pem.sealed"), vault.seal(Buffer.from(pem.keyPem)));
|
|
645
|
+
// meta claims a FAR-FUTURE expiry, disagreeing with the real cert.
|
|
646
|
+
fs.writeFileSync(path.join(tmp, "main", "meta.json"), JSON.stringify({
|
|
647
|
+
expiresAt: Date.now() + 90 * 86400000, issuedAt: Date.now(),
|
|
648
|
+
fingerprintSha256: "stale", subject: "CN=example.com",
|
|
649
|
+
lastRenewedAt: Date.now(), keyAlg: "ecdsa-p256",
|
|
650
|
+
}));
|
|
651
|
+
|
|
652
|
+
var mgr = b.cert.create({
|
|
653
|
+
storage: { type: "sealed-disk", rootDir: tmp, vault: vault },
|
|
654
|
+
acme: { directory: "https://example/", accountKey: "auto" },
|
|
655
|
+
certs: [{ name: "main", domains: ["example.com"],
|
|
656
|
+
challenge: { type: "http-01", provision: async function () {}, cleanup: async function () {} } }],
|
|
657
|
+
audit: false,
|
|
658
|
+
});
|
|
659
|
+
var threw = null;
|
|
660
|
+
try { await mgr.start(); } catch (e) { threw = e; }
|
|
661
|
+
var msg = (threw && (threw.message || String(threw))) || "";
|
|
662
|
+
// The real cert is inside the 14-day renewal window, so start() must
|
|
663
|
+
// attempt renewal (network-fail here) rather than trusting the stale
|
|
664
|
+
// far-future meta and serving the expiring cert.
|
|
665
|
+
check("stale far-future meta does not skip renewal of an expiring cert",
|
|
666
|
+
threw && /dns lookup|EAI_AGAIN|ENOTFOUND|getaddrinfo|failed|fetch/i.test(msg));
|
|
667
|
+
try { await mgr.stop(); } catch (_e) {}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
async function testCorruptAccountKeyClearError() {
|
|
671
|
+
// The ACME account key binds order history, so a corrupt one is NOT
|
|
672
|
+
// auto-regenerated (that would silently abandon the account). It must
|
|
673
|
+
// fail with an actionable cert/account-key-unreadable error, not a raw
|
|
674
|
+
// decrypt throw.
|
|
675
|
+
var tmp = _tmpDir();
|
|
676
|
+
var vault = _authVault();
|
|
677
|
+
fs.mkdirSync(path.join(tmp, "account"), { recursive: true });
|
|
678
|
+
// A well-formed sealed account key, then corrupted so unseal throws.
|
|
679
|
+
var realJwk = JSON.stringify({ kty: "EC", crv: "P-256", x: "a", y: "b",
|
|
680
|
+
privatePem: "p", publicPem: "q" });
|
|
681
|
+
var sealed = vault.seal(Buffer.from(realJwk));
|
|
682
|
+
sealed[sealed.length - 1] ^= 0xff;
|
|
683
|
+
fs.writeFileSync(path.join(tmp, "account", "jwk.json.sealed"), sealed);
|
|
684
|
+
|
|
685
|
+
var mgr = b.cert.create({
|
|
686
|
+
storage: { type: "sealed-disk", rootDir: tmp, vault: vault },
|
|
687
|
+
acme: { directory: "https://example/", accountKey: "auto" },
|
|
688
|
+
certs: [{ name: "main", domains: ["example.com"],
|
|
689
|
+
challenge: { type: "http-01", provision: async function () {}, cleanup: async function () {} } }],
|
|
690
|
+
audit: false,
|
|
691
|
+
});
|
|
692
|
+
var threw = null;
|
|
693
|
+
try { await mgr.start(); } catch (e) { threw = e; }
|
|
694
|
+
check("corrupt account key → actionable cert/account-key-unreadable",
|
|
695
|
+
threw && threw.code === "cert/account-key-unreadable");
|
|
696
|
+
try { await mgr.stop(); } catch (_e) {}
|
|
697
|
+
}
|
|
698
|
+
|
|
532
699
|
async function run() {
|
|
533
700
|
testSurface();
|
|
534
701
|
testFactoryRefusesBadOpts();
|
|
@@ -538,6 +705,9 @@ async function run() {
|
|
|
538
705
|
await testRefreshForcesIssue();
|
|
539
706
|
await testKeyEscrow();
|
|
540
707
|
testAcmeBuildCsrRoundtrip();
|
|
708
|
+
await testCorruptSealedCertReissues();
|
|
709
|
+
await testStaleMetaDoesNotServeExpiringCert();
|
|
710
|
+
await testCorruptAccountKeyClearError();
|
|
541
711
|
}
|
|
542
712
|
|
|
543
713
|
module.exports = { run: run };
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* cluster-storage — cluster-aware framework-state SQL dispatch.
|
|
4
|
+
*
|
|
5
|
+
* Focus: the transaction() primitive (added v0.13.38) — atomic commit,
|
|
6
|
+
* rollback-on-throw, and single-node serialization so a concurrent
|
|
7
|
+
* execute() can't interleave a statement into an open transaction on the
|
|
8
|
+
* shared SQLite connection.
|
|
9
|
+
*
|
|
10
|
+
* Run standalone: `node test/layer-0-primitives/cluster-storage.test.js`
|
|
11
|
+
* Or via smoke: `node test/smoke.js`
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
var fs = require("node:fs");
|
|
15
|
+
var os = require("node:os");
|
|
16
|
+
var path = require("node:path");
|
|
17
|
+
var helpers = require("../helpers");
|
|
18
|
+
var b = helpers.b;
|
|
19
|
+
var check = helpers.check;
|
|
20
|
+
var setupTestDb = helpers.setupTestDb;
|
|
21
|
+
var teardownTestDb = helpers.teardownTestDb;
|
|
22
|
+
|
|
23
|
+
var SCHEMA = [{ name: "cs_tx_t", columns: { k: "TEXT PRIMARY KEY", v: "INTEGER" } }];
|
|
24
|
+
|
|
25
|
+
function testSurface() {
|
|
26
|
+
check("clusterStorage namespace", typeof b.clusterStorage === "object");
|
|
27
|
+
check("clusterStorage.transaction fn", typeof b.clusterStorage.transaction === "function");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function testTransactionCommits() {
|
|
31
|
+
var tmp = fs.mkdtempSync(path.join(os.tmpdir(), "cs-tx-commit-"));
|
|
32
|
+
try {
|
|
33
|
+
await setupTestDb(tmp, SCHEMA);
|
|
34
|
+
var cs = b.clusterStorage;
|
|
35
|
+
await cs.transaction(async function (tx) {
|
|
36
|
+
await tx.execute("INSERT INTO cs_tx_t (k, v) VALUES (?, ?)", ["a", 1]);
|
|
37
|
+
await tx.execute("INSERT INTO cs_tx_t (k, v) VALUES (?, ?)", ["b", 2]);
|
|
38
|
+
var seen = await tx.executeOne("SELECT COUNT(*) AS n FROM cs_tx_t");
|
|
39
|
+
check("tx: rows visible inside the transaction", seen.n === 2);
|
|
40
|
+
});
|
|
41
|
+
var after = await cs.executeOne("SELECT COUNT(*) AS n FROM cs_tx_t");
|
|
42
|
+
check("tx: commit persisted both rows", after.n === 2);
|
|
43
|
+
} finally {
|
|
44
|
+
b.db._resetForTest();
|
|
45
|
+
await teardownTestDb(tmp);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function testTransactionRollsBackOnThrow() {
|
|
50
|
+
var tmp = fs.mkdtempSync(path.join(os.tmpdir(), "cs-tx-rollback-"));
|
|
51
|
+
try {
|
|
52
|
+
await setupTestDb(tmp, SCHEMA);
|
|
53
|
+
var cs = b.clusterStorage;
|
|
54
|
+
await cs.execute("INSERT INTO cs_tx_t (k, v) VALUES (?, ?)", ["keep", 1]);
|
|
55
|
+
var threw = null;
|
|
56
|
+
try {
|
|
57
|
+
await cs.transaction(async function (tx) {
|
|
58
|
+
await tx.execute("INSERT INTO cs_tx_t (k, v) VALUES (?, ?)", ["gone", 2]);
|
|
59
|
+
throw new Error("boom");
|
|
60
|
+
});
|
|
61
|
+
} catch (e) { threw = e; }
|
|
62
|
+
check("tx: throw propagates to caller", threw && threw.message === "boom");
|
|
63
|
+
var rows = await cs.executeAll("SELECT k FROM cs_tx_t ORDER BY k");
|
|
64
|
+
check("tx: rolled-back row absent", rows.length === 1 && rows[0].k === "keep");
|
|
65
|
+
} finally {
|
|
66
|
+
b.db._resetForTest();
|
|
67
|
+
await teardownTestDb(tmp);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function testTransactionSerializesExecute() {
|
|
72
|
+
// A concurrent execute() must NOT interleave a statement into an open
|
|
73
|
+
// single-node transaction on the shared connection — it waits until the
|
|
74
|
+
// transaction commits.
|
|
75
|
+
var tmp = fs.mkdtempSync(path.join(os.tmpdir(), "cs-tx-serial-"));
|
|
76
|
+
try {
|
|
77
|
+
await setupTestDb(tmp, SCHEMA);
|
|
78
|
+
var cs = b.clusterStorage;
|
|
79
|
+
var order = [];
|
|
80
|
+
var txP = cs.transaction(async function (tx) {
|
|
81
|
+
order.push("tx-begin");
|
|
82
|
+
await tx.execute("INSERT INTO cs_tx_t (k, v) VALUES (?, ?)", ["d", 4]);
|
|
83
|
+
await helpers.passiveObserve(40, "cluster-storage tx: hold the transaction open");
|
|
84
|
+
order.push("tx-end");
|
|
85
|
+
});
|
|
86
|
+
var exP = cs.execute("INSERT INTO cs_tx_t (k, v) VALUES (?, ?)", ["e", 5])
|
|
87
|
+
.then(function () { order.push("exec-done"); });
|
|
88
|
+
await Promise.all([txP, exP]);
|
|
89
|
+
check("tx: concurrent execute waited for commit (no mid-tx interleave)",
|
|
90
|
+
order.join(",") === "tx-begin,tx-end,exec-done");
|
|
91
|
+
var n = await cs.executeOne("SELECT COUNT(*) AS n FROM cs_tx_t");
|
|
92
|
+
check("tx: both writes landed", n.n === 2);
|
|
93
|
+
} finally {
|
|
94
|
+
b.db._resetForTest();
|
|
95
|
+
await teardownTestDb(tmp);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function testTransactionRejectsBadArg() {
|
|
100
|
+
var threw = null;
|
|
101
|
+
try { await b.clusterStorage.transaction("not-a-fn"); } catch (e) { threw = e; }
|
|
102
|
+
check("tx: non-function arg rejected", threw && threw.code === "cluster-storage/bad-arg");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function run() {
|
|
106
|
+
testSurface();
|
|
107
|
+
await testTransactionCommits();
|
|
108
|
+
await testTransactionRollsBackOnThrow();
|
|
109
|
+
await testTransactionSerializesExecute();
|
|
110
|
+
await testTransactionRejectsBadArg();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
module.exports = { run: run };
|
|
114
|
+
|
|
115
|
+
if (require.main === module) {
|
|
116
|
+
run().then(
|
|
117
|
+
function () { console.log("[cluster-storage] OK — " + helpers.getChecks() + " checks passed"); },
|
|
118
|
+
// Rethrow rather than console.error(e.stack): this test seeds a vault
|
|
119
|
+
// passphrase via setupTestDb, and logging the error object trips
|
|
120
|
+
// CodeQL's clear-text-logging taint (passphrase -> error -> log). The
|
|
121
|
+
// rethrow lets Node print the uncaught error + stack itself and exit
|
|
122
|
+
// non-zero, with no logging sink for the taint to reach.
|
|
123
|
+
function (e) { process.exitCode = 1; throw e; }
|
|
124
|
+
);
|
|
125
|
+
}
|