morphit/packages/operator-config/scripts/operator-config-smoke.ts

370 lines
13 KiB
TypeScript

/**
* Operator-config loader — tsx smoke runner.
*
* Exercises loadOperatorConfig() against temp files. Tests
* the contract that the indexer + relay rely on:
*
* - Missing file is a no-op.
* - Override env var pointing to nothing throws (operator
* intent was clear).
* - Allowlisted keys land in process.env.
* - Pre-existing process.env values win over the file.
* - Non-allowlisted keys cause a hard boot error, naming
* the offending keys.
*
* Usage (from packages/operator-config):
* tsx scripts/operator-config-smoke.ts
*/
import { writeFileSync, mkdtempSync, rmSync, readFileSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
loadOperatorConfig,
getAllowlist
} from '../src/index.ts';
let failures = 0;
let scenarios = 0;
function scenario(name: string, fn: () => void | Promise<void>): Promise<void> {
scenarios++;
return Promise.resolve()
.then(fn)
.then(
() => {
console.log(`${name}`);
},
(err) => {
failures++;
console.log(`${name}`);
console.log(` ${err instanceof Error ? err.message : String(err)}`);
}
);
}
function assertEqual(actual: unknown, expected: unknown, label: string): void {
const a = JSON.stringify(actual);
const e = JSON.stringify(expected);
if (a !== e) {
throw new Error(`${label}: expected ${e}, got ${a}`);
}
}
function assertThrows(fn: () => void, includeMsg: string, label: string): void {
let threw = false;
let msg = '';
try {
fn();
} catch (err) {
threw = true;
msg = err instanceof Error ? err.message : String(err);
}
if (!threw) {
throw new Error(`${label}: expected throw, no throw`);
}
if (!msg.includes(includeMsg)) {
throw new Error(
`${label}: expected throw to include ${JSON.stringify(includeMsg)}, got ${JSON.stringify(msg)}`
);
}
}
/** A representative allowlisted key (the BLURT price fallback,
* which is the canonical use case). */
const KEY = 'MORPHIT_INDEXER_FEE_BASE_BLURT';
/** Another allowlisted key for multi-key scenarios. */
const KEY2 = 'MORPHIT_RELAY_SIGNUP_ENABLED';
/** Reset process.env between scenarios so they don't leak. */
function clearKeys(): void {
delete process.env[KEY];
delete process.env[KEY2];
delete process.env.MORPHIT_OPERATOR_CONFIG_FILE;
}
/** Create a fresh temp dir and return its path. Caller is
* responsible for cleanup if they want to avoid /tmp clutter,
* but the OS will eventually GC it anyway. */
function mkTempDir(): string {
return mkdtempSync(join(tmpdir(), 'morphit-cfg-'));
}
console.log('\n── Operator-config loader ──────────────────────────');
// ─── Allowlist sanity ────────────────────────────────────────
await scenario('allowlist contains the documented operator keys', () => {
const a = getAllowlist();
const expected = [
'MORPHIT_RELAY_SIGNUP_ENABLED',
'MORPHIT_INDEXER_FEE_BASE_BLURT',
'MORPHIT_INDEXER_BTC_FEE_SATOSHIS',
'MORPHIT_INDEXER_XMR_FEE_PICONERO',
'MORPHIT_INDEXER_FEATURE_FEE_BLURT_PER_HOUR',
'MORPHIT_INDEXER_PRICE_FEED_ENABLED',
// Phase 5 — emergency BLURT/USD floor, config.env-tunable
// (the doc's flagship "live feed is down" workflow depends
// on this being allowlisted; superseded BLURT_PRICE_USD).
'MORPHIT_INDEXER_PRICE_FEED_STATIC_FLOOR',
'MORPHIT_INDEXER_VERBOSE_HEALTH',
'MORPHIT_INDEXER_OPERATOR_BALANCE_RELAY_THRESHOLD_BLURT',
'MORPHIT_INDEXER_OPERATOR_BALANCE_FEES_THRESHOLD_BLURT',
// §F.37 — operator-tunable account-creation-fee fallback.
'MORPHIT_INDEXER_ACCOUNT_CREATION_FEE_BLURT',
// Phase D — per-instance branding.
'MORPHIT_INSTANCE_NAME',
'MORPHIT_INSTANCE_TAGLINE',
'MORPHIT_INSTANCE_CONTACT_URL',
'MORPHIT_INSTANCE_TOR_ADDRESS',
'MORPHIT_INSTANCE_LOKINET_ADDRESS',
'MORPHIT_INSTANCE_I2P_ADDRESS',
'MORPHIT_INSTANCE_I2P_B32_ADDRESS',
'MORPHIT_INSTANCE_I2P_NAME_ADDRESS',
'MORPHIT_INSTANCE_NOSTR_PUBKEY',
'MORPHIT_INSTANCE_ORIGIN'
];
for (const k of expected) {
if (!a.has(k)) {
throw new Error(`allowlist missing expected key: ${k}`);
}
}
});
await scenario('allowlist deliberately excludes spam-economic constants', () => {
const a = getAllowlist();
const mustNotBeAllowed = [
'STRANGER_FEE_BASE_BLURT',
'STRANGER_FEE_MAX_DOUBLINGS',
'STRANGER_FEE_WINDOW_MINUTES',
// Federation-uniformity invariant: these stay in code.
'MORPHIT_INDEXER_DATABASE_URL',
'MORPHIT_INDEXER_CHAIN_ID',
'MORPHIT_INDEXER_OFFICIAL_POSTING_PUBKEY',
// Phase D — fees account is critical infra (typo →
// fees flow to wrong account). Set via OS env, not
// the allowlist.
'MORPHIT_INDEXER_FEE_RECIPIENT',
// Same for relay account name — typo here misroutes
// signup chain ops.
'MORPHIT_INDEXER_RELAY_ACCOUNT',
'MORPHIT_RELAY_ACCOUNT'
];
for (const k of mustNotBeAllowed) {
if (a.has(k)) {
throw new Error(`allowlist must NOT contain ${k} but it does`);
}
}
});
// ─── Template parity ─────────────────────────────────────────
// OPERATIONS.md §23 tells operators to `cp morphit.config.env.example
// morphit.config.env`, so that template MUST exist and MUST list
// exactly the allowlisted keys — no more (an operator would
// uncomment a key that hard-errors at boot), no fewer (a tunable
// knob nobody can discover). This guards the exact drift that let
// the defunct BLURT_PRICE_USD survive in the docs.
await scenario('morphit.config.env.example exists and matches the allowlist exactly', () => {
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
const examplePath = join(repoRoot, 'morphit.config.env.example');
if (!existsSync(examplePath)) {
throw new Error(
'morphit.config.env.example is missing — OPERATIONS.md §23 tells operators to copy it'
);
}
const text = readFileSync(examplePath, 'utf8');
// Every MORPHIT_* token that appears as a key line (commented or
// not) in the template.
const inExample = new Set(
Array.from(text.matchAll(/^#?\s*(MORPHIT_[A-Z0-9_]+)=/gm)).map((m) => m[1])
);
const allow = getAllowlist();
const notAllowlisted = [...inExample].filter((k) => !allow.has(k));
if (notAllowlisted.length > 0) {
throw new Error(
`template lists keys NOT in the allowlist (would hard-error at boot): ${notAllowlisted.join(', ')}`
);
}
const missingFromExample = [...allow].filter((k) => !inExample.has(k));
if (missingFromExample.length > 0) {
throw new Error(
`allowlisted keys absent from the template (undiscoverable): ${missingFromExample.join(', ')}`
);
}
// And the defunct name must not creep back as a SETTABLE key
// (a comment mentioning it for context is fine and helpful).
if (/^#?\s*MORPHIT_INDEXER_BLURT_PRICE_USD=/m.test(text)) {
throw new Error('template offers the defunct MORPHIT_INDEXER_BLURT_PRICE_USD as a key');
}
});
// ─── Missing file ────────────────────────────────────────────
await scenario('returns no-op when no file exists in search paths', () => {
clearKeys();
const dir = mkTempDir();
const result = loadOperatorConfig({ searchPaths: [dir] });
assertEqual(result.file, null, 'file');
assertEqual(result.applied, 0, 'applied');
assertEqual(result.skipped, [], 'skipped');
rmSync(dir, { recursive: true });
});
await scenario('throws when override env var points to nonexistent file', () => {
clearKeys();
process.env.MORPHIT_OPERATOR_CONFIG_FILE = '/tmp/morphit-does-not-exist-' +
Math.random().toString(36).slice(2);
assertThrows(
() => loadOperatorConfig({ searchPaths: [] }),
'no file exists there',
'override-throws'
);
clearKeys();
});
// ─── Allowlisted-key application ─────────────────────────────
await scenario('applies allowlisted key when not already in env', () => {
clearKeys();
const dir = mkTempDir();
writeFileSync(join(dir, 'morphit.config.env'), `${KEY}=0.005\n`);
const result = loadOperatorConfig({ searchPaths: [dir] });
assertEqual(result.applied, 1, 'applied');
assertEqual(result.skipped, [], 'skipped');
assertEqual(process.env[KEY], '0.005', 'process.env value');
rmSync(dir, { recursive: true });
clearKeys();
});
await scenario('skips key already set in env (OS wins)', () => {
clearKeys();
process.env[KEY] = '0.002'; // already set by "OS"
const dir = mkTempDir();
writeFileSync(join(dir, 'morphit.config.env'), `${KEY}=0.005\n`);
const result = loadOperatorConfig({ searchPaths: [dir] });
assertEqual(result.applied, 0, 'applied');
assertEqual(result.skipped, [KEY], 'skipped');
assertEqual(process.env[KEY], '0.002', 'unchanged');
rmSync(dir, { recursive: true });
clearKeys();
});
await scenario('treats empty-string env value as not set', () => {
clearKeys();
process.env[KEY] = ''; // some shells set empty rather than unset
const dir = mkTempDir();
writeFileSync(join(dir, 'morphit.config.env'), `${KEY}=0.005\n`);
const result = loadOperatorConfig({ searchPaths: [dir] });
assertEqual(result.applied, 1, 'applied');
assertEqual(process.env[KEY], '0.005', 'value applied');
rmSync(dir, { recursive: true });
clearKeys();
});
// ─── Non-allowlisted keys ────────────────────────────────────
await scenario('throws on non-allowlisted key, naming the offender', () => {
clearKeys();
const dir = mkTempDir();
writeFileSync(
join(dir, 'morphit.config.env'),
`${KEY}=0.005\nMORPHIT_INDEXER_DATABASE_URL=postgres://hax/y\n`
);
assertThrows(
() => loadOperatorConfig({ searchPaths: [dir] }),
'MORPHIT_INDEXER_DATABASE_URL',
'rejects-database-url'
);
rmSync(dir, { recursive: true });
clearKeys();
});
await scenario('rejects entire file (not partial-apply) when offender present', () => {
// If we partial-applied, an operator who pasted a bad file
// could end up with some keys taking effect and others not —
// hard to debug. Atomic rejection is the better failure mode.
clearKeys();
const dir = mkTempDir();
writeFileSync(
join(dir, 'morphit.config.env'),
`${KEY}=0.005\nUNKNOWN_KEY=whatever\n`
);
try {
loadOperatorConfig({ searchPaths: [dir] });
throw new Error('expected throw but got success');
} catch {
// expected
}
// KEY must NOT have been applied because the load failed.
assertEqual(process.env[KEY], undefined, 'no partial apply');
rmSync(dir, { recursive: true });
clearKeys();
});
// ─── Mixed apply + skip ──────────────────────────────────────
await scenario('mixes applied + skipped correctly', () => {
clearKeys();
process.env[KEY] = '0.002'; // pre-set, will be skipped
const dir = mkTempDir();
writeFileSync(
join(dir, 'morphit.config.env'),
`${KEY}=0.005\n${KEY2}=false\n`
);
const result = loadOperatorConfig({ searchPaths: [dir] });
assertEqual(result.applied, 1, 'applied count');
assertEqual(result.skipped, [KEY], 'skipped list');
assertEqual(process.env[KEY], '0.002', 'KEY unchanged');
assertEqual(process.env[KEY2], 'false', 'KEY2 applied');
rmSync(dir, { recursive: true });
clearKeys();
});
// ─── Search-path order ───────────────────────────────────────
await scenario('uses first matching dir when multiple search paths given', () => {
clearKeys();
const dirA = mkTempDir();
const dirB = mkTempDir();
// Only dirB has a file. dirA is searched first but missing.
writeFileSync(join(dirB, 'morphit.config.env'), `${KEY}=0.007\n`);
const result = loadOperatorConfig({ searchPaths: [dirA, dirB] });
assertEqual(result.applied, 1, 'applied');
assertEqual(process.env[KEY], '0.007', 'value');
rmSync(dirA, { recursive: true });
rmSync(dirB, { recursive: true });
clearKeys();
});
await scenario('comments and blank lines are ignored', () => {
clearKeys();
const dir = mkTempDir();
writeFileSync(
join(dir, 'morphit.config.env'),
[
'# This is a comment',
'',
'# Another one',
`${KEY}=0.009`,
'',
''
].join('\n')
);
const result = loadOperatorConfig({ searchPaths: [dir] });
assertEqual(result.applied, 1, 'applied');
assertEqual(process.env[KEY], '0.009', 'value');
rmSync(dir, { recursive: true });
clearKeys();
});
console.log(`\n${'─'.repeat(54)}`);
if (failures === 0) {
console.log(`✓ all ${scenarios} scenarios passed`);
process.exit(0);
} else {
console.log(`${failures}/${scenarios} scenarios failed`);
process.exit(1);
}