179 lines
6.9 KiB
TypeScript
179 lines
6.9 KiB
TypeScript
#!/usr/bin/env tsx
|
|
/*
|
|
* indexer-url-composition — cp202 guard (routing-topology fix).
|
|
*
|
|
* The frontend reaches the indexer at `<origin>/v1/...` (REST + SSE)
|
|
* and `<origin>/rss/...` (feeds). The ONLY correct way to build those
|
|
* URLs is:
|
|
*
|
|
* new URL('/v1/...', resolveOrigin(MORPHIT_INDEXER_ORIGIN))
|
|
*
|
|
* A root-absolute first arg makes `new URL()` discard whatever path the
|
|
* configured origin carries, so the request always lands on
|
|
* `<origin>/v1/...` regardless of the constant's value — correct for
|
|
* both the colocated single-host topology (same origin) and a split
|
|
* deployment (absolute override).
|
|
*
|
|
* STRING-CONCATENATING the origin with a path is the bug this guards
|
|
* against. `${MORPHIT_INDEXER_ORIGIN}/v1/...` or
|
|
* `${resolveOrigin(MORPHIT_INDEXER_ORIGIN)}/v1/...` (or via a local var)
|
|
* RETAIN the origin's path, so the historic `'/api/indexer'` default
|
|
* produced `/api/indexer/v1/...` — a path the colocated nginx never
|
|
* proxies, silently breaking live SSE (orderbook/chat/instances), the
|
|
* order viewcount endpoints, and the RSS feeds on every single-host
|
|
* deploy. (The REST client always used new URL, which is why it slipped
|
|
* past: only the concat builders were wrong.)
|
|
*
|
|
* NOTE: the RELAY is different — it is intentionally reached at
|
|
* `<origin>/relay/v1/...` via `${resolveOrigin(MORPHIT_RELAY_ORIGIN)}/v1/...`
|
|
* (the `/relay` prefix is real and nginx strips it). So this sentinel
|
|
* only forbids concatenation onto the INDEXER origin, never the relay's.
|
|
*/
|
|
|
|
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
|
import { join, resolve, dirname, relative } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
const webRoot = resolve(here, '..'); // apps/web
|
|
const srcRoot = join(webRoot, 'src');
|
|
const configPath = join(srcRoot, 'lib', 'net', 'config.ts');
|
|
|
|
let pass = 0;
|
|
let fail = 0;
|
|
function ok(msg: string): void {
|
|
pass++;
|
|
console.log(` ✓ ${msg}`);
|
|
}
|
|
function bad(label: string, msg: string): void {
|
|
fail++;
|
|
console.log(` ✗ ${label}: ${msg}`);
|
|
}
|
|
|
|
function walk(dir: string): string[] {
|
|
const out: string[] = [];
|
|
for (const entry of readdirSync(dir)) {
|
|
if (entry === 'node_modules' || entry.startsWith('.')) continue;
|
|
const p = join(dir, entry);
|
|
if (statSync(p).isDirectory()) out.push(...walk(p));
|
|
else if (/\.(svelte|ts)$/.test(entry) && !/\.test\.ts$/.test(entry)) out.push(p);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
const files = walk(srcRoot);
|
|
const rel = (f: string) => relative(webRoot, f);
|
|
|
|
// ── Scenario 1: the constant carries no path ──────────────────────
|
|
// '' (same origin) or an absolute URL with no path are fine; a path
|
|
// like '/api/indexer' or even '/v1' is the trap (it'd be retained by
|
|
// any future concat builder and misleads operators + nginx config).
|
|
try {
|
|
const cfg = readFileSync(configPath, 'utf8');
|
|
const m = cfg.match(/export const MORPHIT_INDEXER_ORIGIN\s*=\s*'([^']*)'/);
|
|
if (!m) {
|
|
bad('config.ts', 'could not find the MORPHIT_INDEXER_ORIGIN declaration');
|
|
} else {
|
|
const val = m[1];
|
|
if (val.startsWith('/')) {
|
|
bad(
|
|
'config.ts',
|
|
`MORPHIT_INDEXER_ORIGIN = '${val}' carries a path — must be '' (same origin) or a bare absolute URL; a path prefix breaks /v1 + /rss routing`
|
|
);
|
|
} else {
|
|
ok(`MORPHIT_INDEXER_ORIGIN = '${val}' carries no path prefix`);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
bad('config.ts', `unreadable: ${(e as Error).message}`);
|
|
}
|
|
|
|
// ── Scenarios 2-4: no concatenation of the indexer origin + a path ─
|
|
const rawConcat: { file: string; text: string }[] = [];
|
|
const inlineResolveConcat: { file: string; text: string }[] = [];
|
|
const varConcat: { file: string; text: string }[] = [];
|
|
|
|
// `${MORPHIT_INDEXER_ORIGIN}/...` (raw const, Style 3)
|
|
const RAW = /\$\{\s*MORPHIT_INDEXER_ORIGIN\s*\}\//;
|
|
// `${resolveOrigin(MORPHIT_INDEXER_ORIGIN)}/...` (inline, Style 2 one-liner)
|
|
const INLINE = /\$\{\s*resolveOrigin\(\s*MORPHIT_INDEXER_ORIGIN\s*\)\s*\}\//;
|
|
// const X = resolveOrigin(MORPHIT_INDEXER_ORIGIN) → then `${X}/...` (Style 2 via var)
|
|
const ASSIGN =
|
|
/(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*resolveOrigin\(\s*MORPHIT_INDEXER_ORIGIN\s*\)/g;
|
|
|
|
for (const f of files) {
|
|
const src = readFileSync(f, 'utf8');
|
|
|
|
if (RAW.test(src)) {
|
|
const line = src.split('\n').find((l) => RAW.test(l)) ?? '';
|
|
rawConcat.push({ file: rel(f), text: line.trim() });
|
|
}
|
|
if (INLINE.test(src)) {
|
|
const line = src.split('\n').find((l) => INLINE.test(l)) ?? '';
|
|
inlineResolveConcat.push({ file: rel(f), text: line.trim() });
|
|
}
|
|
|
|
// Collect local vars that hold the resolved indexer origin, then
|
|
// forbid `${thatVar}/` (concatenation) anywhere in the same file.
|
|
const names = new Set<string>();
|
|
for (const mm of src.matchAll(ASSIGN)) names.add(mm[1]);
|
|
for (const name of names) {
|
|
const concat = new RegExp('\\$\\{\\s*' + name + '\\s*\\}\\/');
|
|
if (concat.test(src)) {
|
|
const line = src.split('\n').find((l) => concat.test(l)) ?? '';
|
|
varConcat.push({ file: rel(f), text: line.trim() });
|
|
}
|
|
}
|
|
}
|
|
|
|
if (rawConcat.length === 0) ok('no `${MORPHIT_INDEXER_ORIGIN}/…` raw-const concatenation');
|
|
else for (const o of rawConcat) bad(o.file, `raw indexer-origin concat — use new URL(): ${o.text}`);
|
|
|
|
if (inlineResolveConcat.length === 0)
|
|
ok('no `${resolveOrigin(MORPHIT_INDEXER_ORIGIN)}/…` inline concatenation');
|
|
else
|
|
for (const o of inlineResolveConcat)
|
|
bad(o.file, `inline resolveOrigin concat — use new URL(): ${o.text}`);
|
|
|
|
if (varConcat.length === 0)
|
|
ok('no resolved-indexer-origin variable is string-concatenated with a path');
|
|
else for (const o of varConcat) bad(o.file, `resolveOrigin var concat — use new URL(): ${o.text}`);
|
|
|
|
// ── Scenarios 5-8: the SSE/view builders use new URL + resolveOrigin ─
|
|
// These four files build indexer URLs that previously concatenated.
|
|
// Anchor them so a regression can't quietly revert to string concat
|
|
// (which would also trip the checks above once the prefix returns).
|
|
const builders = [
|
|
join(srcRoot, 'lib', 'orders', 'views.ts'),
|
|
join(srcRoot, 'lib', 'chat', 'stream.ts'),
|
|
join(srcRoot, 'lib', 'orderbook', 'stream.ts'),
|
|
join(srcRoot, 'routes', '[lang]', 'instances', '+page.svelte')
|
|
];
|
|
for (const b of builders) {
|
|
let src = '';
|
|
try {
|
|
src = readFileSync(b, 'utf8');
|
|
} catch {
|
|
bad(rel(b), 'builder file missing — sentinel target gone');
|
|
continue;
|
|
}
|
|
const usesNewUrl = /new URL\(/.test(src);
|
|
const usesResolve = /resolveOrigin\(\s*MORPHIT_INDEXER_ORIGIN\s*\)/.test(src);
|
|
const hasV1 = /\/v1\//.test(src);
|
|
if (usesNewUrl && usesResolve && hasV1)
|
|
ok(`${rel(b)} builds indexer URLs via new URL(+resolveOrigin)`);
|
|
else
|
|
bad(
|
|
rel(b),
|
|
`indexer URL builder must use new URL('/v1/…', resolveOrigin(MORPHIT_INDEXER_ORIGIN)) [newURL=${usesNewUrl} resolve=${usesResolve} v1=${hasV1}]`
|
|
);
|
|
}
|
|
|
|
console.log('\n' + '─'.repeat(56));
|
|
if (fail === 0) {
|
|
console.log(`✓ all ${pass} scenarios passed`);
|
|
process.exit(0);
|
|
} else {
|
|
console.log(`✗ ${fail} of ${pass + fail} scenarios FAILED`);
|
|
process.exit(1);
|
|
}
|