@blamejs/blamejs-shop 0.2.15 → 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 +3 -1
- package/README.md +1 -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
|
@@ -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
|
+
}
|
|
@@ -6871,8 +6871,17 @@ var KNOWN_ANTIPATTERNS = [
|
|
|
6871
6871
|
// shared runSql; re-routing through runInTransaction would change
|
|
6872
6872
|
// semantics (passing module.exports vs database). Keep as-is.
|
|
6873
6873
|
"lib/db.js",
|
|
6874
|
+
// clusterStorage.transaction(fn) is the cluster-aware ASYNC
|
|
6875
|
+
// transaction primitive (v0.13.38). dbSchema.runInTransaction is
|
|
6876
|
+
// synchronous (BEGIN -> fn() -> COMMIT) and cannot wrap the async fn
|
|
6877
|
+
// the atomic-RMW callers (cache, dual-control) need. The cluster path
|
|
6878
|
+
// delegates to externalDb.transaction; the single-node path issues
|
|
6879
|
+
// BEGIN/COMMIT/ROLLBACK around an awaited fn with shared-connection
|
|
6880
|
+
// serialization (no other statement may interleave). Same legitimate-
|
|
6881
|
+
// primitive justification as db.js.
|
|
6882
|
+
"lib/cluster-storage.js",
|
|
6874
6883
|
],
|
|
6875
|
-
reason: "Extracted to dbSchema.runInTransaction. Replaces the inline BEGIN / COMMIT / ROLLBACK try/catch boilerplate in migrations / seeders / db-schema. Handles both raw better-sqlite3 and b.db framework wrapper handles via runSqlOnHandle.",
|
|
6884
|
+
reason: "Extracted to dbSchema.runInTransaction. Replaces the inline BEGIN / COMMIT / ROLLBACK try/catch boilerplate in migrations / seeders / db-schema. Handles both raw better-sqlite3 and b.db framework wrapper handles via runSqlOnHandle. db.js + cluster-storage.js are allowlisted transaction primitives (sync public + async cluster-aware) that can't route through the sync helper.",
|
|
6876
6885
|
},
|
|
6877
6886
|
{
|
|
6878
6887
|
id: "inline-numeric-bounds-cascade",
|
|
@@ -9787,6 +9796,86 @@ function testWikiPortAgreesAcrossArtifacts() {
|
|
|
9787
9796
|
bad);
|
|
9788
9797
|
}
|
|
9789
9798
|
|
|
9799
|
+
// v0.13.34 — the wiki compose stop_grace_period MUST exceed the app
|
|
9800
|
+
// shutdown orchestrator's total grace budget (graceMs) plus the forced-
|
|
9801
|
+
// exit watchdog margin. Otherwise `docker stop` / a rolling redeploy
|
|
9802
|
+
// SIGKILLs the container before the DB re-encrypt phase finishes, losing
|
|
9803
|
+
// every write since the last periodic flush — the encrypted-DB data-loss
|
|
9804
|
+
// class. Cross-artifact guard so raising graceMs in lib/app-shutdown.js
|
|
9805
|
+
// without bumping the compose (or dropping the setting) can't silently
|
|
9806
|
+
// reopen the hole.
|
|
9807
|
+
function testWikiStopGraceExceedsShutdownBudget() {
|
|
9808
|
+
var bad = [];
|
|
9809
|
+
var shutdownSrc;
|
|
9810
|
+
try { shutdownSrc = fs.readFileSync("lib/app-shutdown.js", "utf8"); }
|
|
9811
|
+
catch (_e) { return; }
|
|
9812
|
+
var graceM = /DEFAULT_GRACE_MS\s*=\s*C\.TIME\.seconds\((\d+)\)/.exec(shutdownSrc);
|
|
9813
|
+
if (!graceM) return;
|
|
9814
|
+
var marginM = /FORCE_EXIT_MARGIN_MS\s*=\s*C\.TIME\.seconds\((\d+)\)/.exec(shutdownSrc);
|
|
9815
|
+
var graceS = parseInt(graceM[1], 10);
|
|
9816
|
+
var marginS = marginM ? parseInt(marginM[1], 10) : 0;
|
|
9817
|
+
var minGrace = graceS + marginS;
|
|
9818
|
+
var composeFiles = ["examples/wiki/docker-compose.yml", "examples/wiki/docker-compose.prod.yml"];
|
|
9819
|
+
for (var i = 0; i < composeFiles.length; i += 1) {
|
|
9820
|
+
var cf = composeFiles[i];
|
|
9821
|
+
var text;
|
|
9822
|
+
try { text = fs.readFileSync(cf, "utf8"); }
|
|
9823
|
+
catch (_e) { continue; }
|
|
9824
|
+
var m = /stop_grace_period:\s*'?(\d+)\s*s'?/.exec(text);
|
|
9825
|
+
if (!m) {
|
|
9826
|
+
bad.push({ file: cf, line: 1,
|
|
9827
|
+
content: cf + " declares no stop_grace_period — Docker's 10s default SIGKILLs before " +
|
|
9828
|
+
"the " + graceS + "s shutdown budget finishes the DB re-encrypt. Set " +
|
|
9829
|
+
"stop_grace_period to at least " + minGrace + "s." });
|
|
9830
|
+
continue;
|
|
9831
|
+
}
|
|
9832
|
+
var graceSet = parseInt(m[1], 10);
|
|
9833
|
+
if (graceSet < minGrace) {
|
|
9834
|
+
bad.push({ file: cf, line: 1,
|
|
9835
|
+
content: cf + " stop_grace_period is " + graceSet + "s but the shutdown budget (graceMs " +
|
|
9836
|
+
graceS + "s + watchdog margin " + marginS + "s) needs at least " + minGrace +
|
|
9837
|
+
"s, or the DB re-encrypt is SIGKILLed mid-flush." });
|
|
9838
|
+
}
|
|
9839
|
+
}
|
|
9840
|
+
bad = _filterMarkers(bad, "wiki-stop-grace-below-shutdown-budget");
|
|
9841
|
+
_report("wiki compose stop_grace_period exceeds the app shutdown grace budget " +
|
|
9842
|
+
"(v0.13.34 — no SIGKILL-before-DB-re-encrypt data loss on docker stop / redeploy)",
|
|
9843
|
+
bad);
|
|
9844
|
+
}
|
|
9845
|
+
|
|
9846
|
+
// v0.13.41 — the agent-orchestrator registry-read paths (_list / _lookup)
|
|
9847
|
+
// MUST consult the tenant gate (_tenantAllows) so an actor can't enumerate
|
|
9848
|
+
// or acquire a handle to another tenant's agent when tenant scoping is on.
|
|
9849
|
+
// agent-event-bus enforces this on subscribe/delivery; the orchestrator now
|
|
9850
|
+
// mirrors it. Encoded so a refactor can't silently drop the gate from
|
|
9851
|
+
// either read path.
|
|
9852
|
+
function testOrchestratorRegistryReadsTenantScoped() {
|
|
9853
|
+
var bad = [];
|
|
9854
|
+
var src;
|
|
9855
|
+
try { src = fs.readFileSync("lib/agent-orchestrator.js", "utf8"); }
|
|
9856
|
+
catch (_e) { return; }
|
|
9857
|
+
["_list", "_lookup"].forEach(function (fn) {
|
|
9858
|
+
var start = src.indexOf("function " + fn + "(");
|
|
9859
|
+
if (start === -1) {
|
|
9860
|
+
bad.push({ file: "lib/agent-orchestrator.js", line: 1,
|
|
9861
|
+
content: fn + " not found — tenant-scope detector can't verify it" });
|
|
9862
|
+
return;
|
|
9863
|
+
}
|
|
9864
|
+
// Body = from this function to the next top-level function declaration.
|
|
9865
|
+
var rest = src.slice(start + 1);
|
|
9866
|
+
var next = rest.search(/\nasync function |\nfunction /);
|
|
9867
|
+
var body = next === -1 ? rest : rest.slice(0, next);
|
|
9868
|
+
if (body.indexOf("_tenantAllows") === -1) {
|
|
9869
|
+
bad.push({ file: "lib/agent-orchestrator.js", line: 1,
|
|
9870
|
+
content: fn + " does not consult _tenantAllows — registry reads must be tenant-scoped " +
|
|
9871
|
+
"(cross-tenant enumeration / handle acquisition leak)" });
|
|
9872
|
+
}
|
|
9873
|
+
});
|
|
9874
|
+
bad = _filterMarkers(bad, "orchestrator-registry-tenant-scope");
|
|
9875
|
+
_report("agent-orchestrator _list/_lookup consult the tenant gate " +
|
|
9876
|
+
"(v0.13.41 — no cross-tenant registry enumeration / handle acquisition)", bad);
|
|
9877
|
+
}
|
|
9878
|
+
|
|
9790
9879
|
// v0.13.19 — a CI job that runs the long test suites (smoke / wiki
|
|
9791
9880
|
// e2e) MUST declare `timeout-minutes`. Without it a hung child (a
|
|
9792
9881
|
// leaked timer / socket / fs.watch handle — the macOS smoke-hang
|
|
@@ -10372,6 +10461,8 @@ async function run() {
|
|
|
10372
10461
|
// WIKI_PORT default must match the release-container.yml smoke
|
|
10373
10462
|
// step's port mapping + curl host.
|
|
10374
10463
|
testWikiPortAgreesAcrossArtifacts();
|
|
10464
|
+
testWikiStopGraceExceedsShutdownBudget();
|
|
10465
|
+
testOrchestratorRegistryReadsTenantScoped();
|
|
10375
10466
|
// v0.13.19 CI hang backstop: every workflow job that runs the test
|
|
10376
10467
|
// suite must declare timeout-minutes so a hung child can't ride
|
|
10377
10468
|
// GitHub's 6-hour default.
|
|
@@ -83,6 +83,38 @@ async function run() {
|
|
|
83
83
|
});
|
|
84
84
|
check("approve: revoked grant rejected", revokedApprove.error === "grant-revoked");
|
|
85
85
|
|
|
86
|
+
// ---- Concurrency: the quorum-bypass + double-consume races ----
|
|
87
|
+
// The get/set version read a snapshot, mutated, and wrote back with an
|
|
88
|
+
// await in between, so two concurrent operations could each act on stale
|
|
89
|
+
// state. cache.update makes the read-modify-write atomic. These pin the
|
|
90
|
+
// invariants that broke before.
|
|
91
|
+
var reqC = await approvals.request({ action: "<test>.concurrent", requestedBy: { id: "alice" } });
|
|
92
|
+
|
|
93
|
+
// (a) The SAME approver firing two approvals in parallel must count ONCE.
|
|
94
|
+
// A stale-snapshot double-append would reach the 2-of-2 quorum with one
|
|
95
|
+
// human — the dual-control bypass.
|
|
96
|
+
var dupResults = await Promise.all([
|
|
97
|
+
approvals.approve({ grantId: reqC.grantId, approver: { id: "bob" } }),
|
|
98
|
+
approvals.approve({ grantId: reqC.grantId, approver: { id: "bob" } }),
|
|
99
|
+
]);
|
|
100
|
+
var stC = await approvals.status(reqC.grantId);
|
|
101
|
+
check("concurrent same-approver: counted once (no quorum bypass)",
|
|
102
|
+
stC.approvedBy.length === 1 && stC.status === "pending");
|
|
103
|
+
var okN = dupResults.filter(function (r) { return !r.error; }).length;
|
|
104
|
+
var dupN = dupResults.filter(function (r) { return r.error === "already-approved-by-this-actor"; }).length;
|
|
105
|
+
check("concurrent same-approver: exactly one succeeds, one rejected duplicate",
|
|
106
|
+
okN === 1 && dupN === 1);
|
|
107
|
+
|
|
108
|
+
// (b) Two parallel consumes of a quorum-reached grant: exactly ONE wins
|
|
109
|
+
// ready:true — never two (single-use).
|
|
110
|
+
await approvals.approve({ grantId: reqC.grantId, approver: { id: "carol" } }); // 2/2 → approved
|
|
111
|
+
var consumeResults = await Promise.all([
|
|
112
|
+
approvals.consume(reqC.grantId),
|
|
113
|
+
approvals.consume(reqC.grantId),
|
|
114
|
+
]);
|
|
115
|
+
var readyN = consumeResults.filter(function (r) { return r.ready === true; }).length;
|
|
116
|
+
check("concurrent consume: exactly one ready (no double-consume)", readyN === 1);
|
|
117
|
+
|
|
86
118
|
// ---- Validation ----
|
|
87
119
|
var threwBadCache = null;
|
|
88
120
|
try { b.dualControl.create({ namespace: "x", cache: {} }); }
|
|
@@ -269,8 +269,39 @@ function testSmimeDocBlockCitations() {
|
|
|
269
269
|
|
|
270
270
|
// ---- Run ----
|
|
271
271
|
|
|
272
|
+
// ---- trust-chain leaf is bound to the verified signer key ----
|
|
273
|
+
//
|
|
274
|
+
// _verifyTrustChain must select the chain leaf as the cert whose public
|
|
275
|
+
// key matches signerPublicKey (the key that actually verified the
|
|
276
|
+
// signature) — not chain[0] unconditionally. Otherwise a validly-chained
|
|
277
|
+
// cert for a DIFFERENT identity passes chain validation. _certKeyMatches
|
|
278
|
+
// is that binding decision; a mock { publicKey } is all it touches.
|
|
279
|
+
function testSmimeCertKeyBinding() {
|
|
280
|
+
var kpA = nodeCrypto.generateKeyPairSync("ec", { namedCurve: "P-256" });
|
|
281
|
+
var kpB = nodeCrypto.generateKeyPairSync("ec", { namedCurve: "P-256" });
|
|
282
|
+
var certA = { publicKey: kpA.publicKey };
|
|
283
|
+
var spkiA = Buffer.from(kpA.publicKey.export({ format: "der", type: "spki" }));
|
|
284
|
+
check("smime binding: cert matches its own full SPKI key",
|
|
285
|
+
smime._certKeyMatches(certA, spkiA) === true);
|
|
286
|
+
// Production form: signerPublicKey is the raw subjectPublicKey (the
|
|
287
|
+
// uncompressed EC point 0x04||x||y), which is the SPKI's suffix.
|
|
288
|
+
var jwkA = kpA.publicKey.export({ format: "jwk" });
|
|
289
|
+
var rawA = Buffer.concat([Buffer.from([0x04]),
|
|
290
|
+
Buffer.from(jwkA.x, "base64url"), Buffer.from(jwkA.y, "base64url")]);
|
|
291
|
+
check("smime binding: cert matches its raw subjectPublicKey (suffix)",
|
|
292
|
+
smime._certKeyMatches(certA, rawA) === true);
|
|
293
|
+
// The gap this closes: a DIFFERENT key must not match.
|
|
294
|
+
var spkiB = Buffer.from(kpB.publicKey.export({ format: "der", type: "spki" }));
|
|
295
|
+
check("smime binding: cert does NOT match a different cert's key",
|
|
296
|
+
smime._certKeyMatches(certA, spkiB) === false);
|
|
297
|
+
// Unextractable / absent key → no match (caller fails closed).
|
|
298
|
+
check("smime binding: unextractable key → no match",
|
|
299
|
+
smime._certKeyMatches({ publicKey: { export: function () { throw new Error("nope"); } } }, spkiA) === false);
|
|
300
|
+
}
|
|
301
|
+
|
|
272
302
|
function run() {
|
|
273
303
|
testSmimeSurface();
|
|
304
|
+
testSmimeCertKeyBinding();
|
|
274
305
|
testSmimeSignVerifyRoundtrip();
|
|
275
306
|
testSmimeVerifyTamperRefused();
|
|
276
307
|
testSmimeVerifyWrongKeyRefused();
|
|
@@ -118,6 +118,20 @@ async function run() {
|
|
|
118
118
|
var efv = redis._frameToValue(redis._parseFrame(_bytes("-ERR boom\r\n"), 0));
|
|
119
119
|
check("frameToValue: error becomes _redisError marker",
|
|
120
120
|
efv && efv._redisError === true && efv.message === "ERR boom");
|
|
121
|
+
|
|
122
|
+
// ---- close()/reconnect leak guard (v0.13.40) ----
|
|
123
|
+
// After close(), a reconnect timer scheduled during backoff must be
|
|
124
|
+
// cancelled and _connect() must refuse to re-open — otherwise a post-
|
|
125
|
+
// close reconnect leaks a fresh socket (and the un-unref'd backoff timer
|
|
126
|
+
// would hold the event loop alive). We test the closing guard directly:
|
|
127
|
+
// connect() after close() is a no-op, so no socket is opened. (The timer
|
|
128
|
+
// is also tracked + unref'd + cleared in close(), mirroring ws-client.)
|
|
129
|
+
var client = redis.create({ url: "redis://127.0.0.1:1/0" }); // port 1 — nothing listens
|
|
130
|
+
await client.close();
|
|
131
|
+
check("redis: closing flag set after close()", client._state().closing === true);
|
|
132
|
+
try { await client.connect(); } catch (_e) { /* closing guard should prevent any connect attempt */ }
|
|
133
|
+
check("redis: connect() after close is a no-op (closing guard — no socket opened)",
|
|
134
|
+
client._state().connected === false);
|
|
121
135
|
}
|
|
122
136
|
|
|
123
137
|
module.exports = { run: run };
|
package/package.json
CHANGED