239 lines
10 KiB
TypeScript
239 lines
10 KiB
TypeScript
/**
|
||
* rpc-endpoint-canon-smoke (beta5 item D).
|
||
*
|
||
* Pins the canonical Blurt RPC endpoint set (the single source of truth
|
||
* in @morphit/operator-config) against the copies that CANNOT import it:
|
||
* - the frontend's DEFAULT_RPC_ENDPOINTS (browser bundle — can't pull
|
||
* in a node package). cp268: this is now the browser-CORS-clean
|
||
* SUBSET of canon (a browser can only use nodes that return a valid
|
||
* single Access-Control-Allow-Origin), so it is checked as a non-empty
|
||
* SUBSET with no stray node, NOT set-equal.
|
||
* - the env examples (ops/env/indexer.env.example,
|
||
* ops/env/relay.env.example). These are SERVER-side (no CORS), so they
|
||
* stay set-EQUAL to canon.
|
||
*
|
||
* The node-side consumers (indexer config, relay config, ops-cli
|
||
* chainCheck/chainErrors/steps) all import the constant directly, so
|
||
* TypeScript already guarantees they can't drift; this smoke covers the
|
||
* non-importing copies. Order-independent (the pool learns fastest-first),
|
||
* so a cosmetic reorder is fine but a stray/missing endpoint fails.
|
||
*
|
||
* This is the guard that would have caught the beta5 firefight's root
|
||
* config bug: the wizard's list contained `rpc.blurt.world` and was
|
||
* missing two endpoints the rest of the app used.
|
||
*/
|
||
|
||
import { readFileSync } from 'node:fs';
|
||
import { join, dirname } from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
import { DEFAULT_BLURT_RPC_ENDPOINTS } from '@morphit/operator-config';
|
||
|
||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||
const REPO = join(HERE, '..', '..', '..');
|
||
|
||
let pass = 0;
|
||
let fail = 0;
|
||
const ok = (m: string) => {
|
||
pass++;
|
||
console.log(` \u2713 ${m}`);
|
||
};
|
||
const bad = (m: string, detail = '') => {
|
||
fail++;
|
||
console.log(` \u2717 ${m}`);
|
||
if (detail) console.log(` ${detail}`);
|
||
};
|
||
|
||
const canon = new Set(DEFAULT_BLURT_RPC_ENDPOINTS);
|
||
const setEq = (a: Set<string>, b: Set<string>) =>
|
||
a.size === b.size && [...a].every((x) => b.has(x));
|
||
const show = (s: Iterable<string>) => [...s].sort().join(', ');
|
||
|
||
// ── canonical sanity ────────────────────────────────────────────────
|
||
if (DEFAULT_BLURT_RPC_ENDPOINTS.length >= 3) ok(`canonical set has ${DEFAULT_BLURT_RPC_ENDPOINTS.length} endpoints (>=3 for redundancy)`);
|
||
else bad('canonical set has fewer than 3 endpoints — too little RPC redundancy', show(canon));
|
||
|
||
if (DEFAULT_BLURT_RPC_ENDPOINTS.every((u) => u.startsWith('https://'))) ok('canonical endpoints are all https://');
|
||
else bad('a canonical endpoint is not https://', show(canon));
|
||
|
||
// Regression guard for the exact firefight artefact.
|
||
if (!canon.has('https://rpc.blurt.world')) ok('canonical set does not contain the un-attributed rpc.blurt.world (firefight regression)');
|
||
else bad('rpc.blurt.world is back in the canonical set — confirm it is a real, attributed node first');
|
||
|
||
// ── frontend literal (browser-CORS-clean SUBSET of canon) ───────────
|
||
// cp268: the browser list is the CORS-clean SUBSET of the canonical
|
||
// pool — a browser can only use a node that returns a single valid
|
||
// Access-Control-Allow-Origin, and three canonical nodes fail browser
|
||
// CORS (beblurt double-value; blurt.one + dagobert missing header). So
|
||
// the frontend list is NOT set-equal to canon; it must be a non-empty
|
||
// subset with NO stray node (drift guard) + enough nodes for failover.
|
||
{
|
||
const src = readFileSync(join(REPO, 'apps', 'web', 'src', 'lib', 'net', 'config.ts'), 'utf8');
|
||
const m = /DEFAULT_RPC_ENDPOINTS[^=]*=\s*\[([\s\S]*?)\]/.exec(src);
|
||
if (!m) {
|
||
bad('could not find DEFAULT_RPC_ENDPOINTS in apps/web/src/lib/net/config.ts');
|
||
} else {
|
||
const urls = new Set(Array.from(m[1]!.matchAll(/'(https:\/\/[^']+)'/g)).map((x) => x[1]!));
|
||
const stray = [...urls].filter((u) => !canon.has(u));
|
||
if (stray.length === 0)
|
||
ok('frontend DEFAULT_RPC_ENDPOINTS is a subset of the canonical pool (no stray node)');
|
||
else
|
||
bad(
|
||
'frontend DEFAULT_RPC_ENDPOINTS contains node(s) not in the canonical pool',
|
||
`stray=[${show(stray)}] canon=[${show(canon)}]`
|
||
);
|
||
if (urls.size >= 2) ok(`frontend browser pool has ${urls.size} endpoints (>=2 for failover)`);
|
||
else bad('frontend browser pool has fewer than 2 endpoints — too little browser failover', show(urls));
|
||
if ([...urls].every((u) => u.startsWith('https://'))) ok('frontend endpoints are all https://');
|
||
else bad('a frontend endpoint is not https://', show(urls));
|
||
|
||
// cp(post-beta28): SERVER_ONLY_CANONICAL_RPC_ENDPOINTS — the
|
||
// CORS-omitted canonical nodes the settings panel shows READ-ONLY
|
||
// so the operator sees the complete 6-node pool. Must be a subset
|
||
// of canon, DISJOINT from the browser list, and together with it
|
||
// cover the WHOLE canonical pool (the "show all 6" guarantee).
|
||
const soM = /SERVER_ONLY_CANONICAL_RPC_ENDPOINTS[^=]*=\s*\[([\s\S]*?)\]/.exec(src);
|
||
if (!soM) {
|
||
bad('could not find SERVER_ONLY_CANONICAL_RPC_ENDPOINTS in apps/web/src/lib/net/config.ts');
|
||
} else {
|
||
const serverOnly = new Set(
|
||
Array.from(soM[1]!.matchAll(/'(https:\/\/[^']+)'/g)).map((x) => x[1]!)
|
||
);
|
||
const soStray = [...serverOnly].filter((u) => !canon.has(u));
|
||
if (soStray.length === 0)
|
||
ok('SERVER_ONLY_CANONICAL_RPC_ENDPOINTS is a subset of the canonical pool (no stray node)');
|
||
else
|
||
bad(
|
||
'SERVER_ONLY_CANONICAL_RPC_ENDPOINTS contains node(s) not in the canonical pool',
|
||
`stray=[${show(soStray)}]`
|
||
);
|
||
|
||
const overlap = [...serverOnly].filter((u) => urls.has(u));
|
||
if (overlap.length === 0) ok('browser DEFAULT and SERVER_ONLY endpoint lists are disjoint');
|
||
else
|
||
bad(
|
||
'an endpoint is in BOTH the browser DEFAULT and SERVER_ONLY lists',
|
||
`overlap=[${show(overlap)}]`
|
||
);
|
||
|
||
const union = new Set([...urls, ...serverOnly]);
|
||
if (setEq(union, canon))
|
||
ok('browser DEFAULT ∪ SERVER_ONLY === the full canonical pool (settings panel shows every canonical node)');
|
||
else
|
||
bad(
|
||
'browser DEFAULT ∪ SERVER_ONLY does not equal the canonical pool',
|
||
`union=[${show(union)}] canon=[${show(canon)}]`
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── env examples ────────────────────────────────────────────────────
|
||
function envListVar(path: string, varName: string): Set<string> | null {
|
||
const src = readFileSync(join(REPO, path), 'utf8');
|
||
const line = src.split('\n').find((l) => l.startsWith(`${varName}=`));
|
||
if (line === undefined) return null;
|
||
const val = line.slice(varName.length + 1).trim();
|
||
return new Set(
|
||
val
|
||
.split(',')
|
||
.map((u) => u.trim())
|
||
.filter((u) => u !== '')
|
||
);
|
||
}
|
||
|
||
for (const [path, varName] of [
|
||
['ops/env/indexer.env.example', 'MORPHIT_INDEXER_RPC_ENDPOINTS'],
|
||
['ops/env/relay.env.example', 'MORPHIT_RELAY_BLURT_RPC']
|
||
] as const) {
|
||
const urls = envListVar(path, varName);
|
||
if (urls === null) {
|
||
bad(`could not find ${varName} in ${path}`);
|
||
} else if (setEq(urls, canon)) {
|
||
ok(`${path} ${varName} matches the canonical set`);
|
||
} else {
|
||
bad(`${path} ${varName} differs from canonical`, `example=[${show(urls)}] canon=[${show(canon)}]`);
|
||
}
|
||
}
|
||
|
||
// ── Ansible deploy defaults (cp328) ─────────────────────────────────
|
||
// The env examples above were guarded, but the Ansible group_vars copy
|
||
// was NOT — and that is exactly where the dead rpc.blurt.world node
|
||
// survived (group_vars/all.yml pinned the indexer to it as "primary",
|
||
// and the egress allowlist opened it while BLOCKING the real six). Pin
|
||
// the Ansible indexer-endpoint default to canon too, and keep the
|
||
// decommissioned node out of the whole file (endpoints AND allowlist).
|
||
function yamlScalarList(path: string, key: string): Set<string> | null {
|
||
const src = readFileSync(join(REPO, path), 'utf8');
|
||
const line = src.split('\n').find((l) => l.trimStart().startsWith(`${key}:`));
|
||
if (line === undefined) return null;
|
||
let val = line.slice(line.indexOf(':') + 1).trim();
|
||
if (
|
||
(val.startsWith('"') && val.endsWith('"')) ||
|
||
(val.startsWith("'") && val.endsWith("'"))
|
||
) {
|
||
val = val.slice(1, -1);
|
||
}
|
||
return new Set(
|
||
val
|
||
.split(',')
|
||
.map((u) => u.trim())
|
||
.filter((u) => u !== '')
|
||
);
|
||
}
|
||
|
||
{
|
||
const ansiblePath = 'ops/ansible/group_vars/all.yml';
|
||
const urls = yamlScalarList(ansiblePath, 'morphit_indexer_blurt_rpc_endpoints');
|
||
if (urls === null) {
|
||
bad(`could not find morphit_indexer_blurt_rpc_endpoints in ${ansiblePath}`);
|
||
} else if (setEq(urls, canon)) {
|
||
ok(`${ansiblePath} morphit_indexer_blurt_rpc_endpoints matches the canonical set`);
|
||
} else {
|
||
bad(
|
||
`${ansiblePath} indexer RPC endpoints differ from canonical`,
|
||
`ansible=[${show(urls)}] canon=[${show(canon)}]`
|
||
);
|
||
}
|
||
|
||
const ay = readFileSync(join(REPO, ansiblePath), 'utf8');
|
||
if (!ay.includes('rpc.blurt.world')) {
|
||
ok(`${ansiblePath} is free of the dead rpc.blurt.world node (endpoints + egress allowlist)`);
|
||
} else {
|
||
bad(
|
||
`rpc.blurt.world reappeared in ${ansiblePath} — decommissioned node; use the canonical set`
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── verify-download.mjs (cp560: this copy rotted — it still pinned the
|
||
// dead rpc.blurt.world and a downloader hit "could not reach the
|
||
// chain" during the v1.8.15 ceremony). It's a standalone Node script
|
||
// (no browser CORS), so its DEFAULT_RPCS must equal the WHOLE 6-node
|
||
// canonical pool, and must never contain the decommissioned node. ──
|
||
{
|
||
const src = readFileSync(join(REPO, 'scripts', 'verify-download.mjs'), 'utf8');
|
||
const m = /DEFAULT_RPCS\s*=\s*\[([\s\S]*?)\]/.exec(src);
|
||
if (!m) {
|
||
bad('could not find DEFAULT_RPCS in scripts/verify-download.mjs');
|
||
} else {
|
||
const urls = new Set(Array.from(m[1]!.matchAll(/'(https:\/\/[^']+)'/g)).map((x) => x[1]!));
|
||
if (setEq(urls, canon))
|
||
ok('verify-download.mjs DEFAULT_RPCS === the full canonical pool');
|
||
else
|
||
bad(
|
||
'verify-download.mjs DEFAULT_RPCS differs from the canonical pool',
|
||
`script=[${show(urls)}] canon=[${show(canon)}]`
|
||
);
|
||
}
|
||
if (!/rpc\.blurt\.world/.test(src))
|
||
ok('verify-download.mjs is free of the dead rpc.blurt.world node (list + usage + error suggestion)');
|
||
else bad('rpc.blurt.world reappeared in scripts/verify-download.mjs — decommissioned node');
|
||
}
|
||
|
||
console.log('');
|
||
console.log(`${pass} passed, ${fail} failed`);
|
||
if (fail > 0) {
|
||
console.log('\u2717 rpc-endpoint-canon smoke FAILED');
|
||
process.exit(1);
|
||
}
|
||
console.log(`\u2713 all ${pass} rpc-endpoint-canon scenarios passed`);
|