import { URL } from "node:url"; import { generateSecretKey, nip19 } from "nostr-tools"; export type LogLevel = "debug" | "info" | "warn" | "error"; export interface RelayTarget { relay: string; host: string; } export interface WriteCheckConfig { enabled: boolean; kind: number; privkey?: string; } export interface AppConfig { relays: RelayTarget[]; probeIntervalMs: number; probeTimeoutMs: number; listenAddr: string; port: number; logLevel: LogLevel; probeConcurrency: number; staleAfterMs: number; writeCheck: WriteCheckConfig; warnings: string[]; } const MS_PER_SECOND = 1_000; const DEFAULT_PROBE_INTERVAL_SECONDS = 0; const DEFAULT_PROBE_TIMEOUT_SECONDS = 10; const DEFAULT_LISTEN_ADDR = "0.0.0.0"; const DEFAULT_PORT = 9464; const DEFAULT_LOG_LEVEL: LogLevel = "info"; const DEFAULT_WRITE_CHECK_ENABLED = true; const DEFAULT_WRITE_CHECK_KIND = 30078; const MIN_TIMEOUT_SECONDS = 1; const MIN_PORT = 1; const MAX_PORT = 65_535; // Kept as a constant so operators can tune by editing code. export const DEFAULT_PROBE_CONCURRENCY = 4; function parseBoolean(raw: string | undefined, fallback: boolean): boolean { if (raw === undefined || raw.trim() === "") return fallback; const value = raw.trim().toLowerCase(); if (["1", "true", "yes", "on"].includes(value)) return true; if (["0", "false", "no", "off"].includes(value)) return false; return fallback; } function parseInteger( raw: string | undefined, fallback: number, min: number, name: string, ): number { if (raw === undefined || raw.trim() === "") return fallback; const parsed = Number.parseInt(raw, 10); if (!Number.isFinite(parsed) || Number.isNaN(parsed)) { throw new Error(`${name} must be an integer`); } if (parsed < min) { throw new Error(`${name} must be >= ${min}`); } return parsed; } function parseLogLevel(raw: string | undefined): LogLevel { if (!raw) return DEFAULT_LOG_LEVEL; const normalized = raw.trim().toLowerCase(); if ( normalized === "debug" || normalized === "info" || normalized === "warn" || normalized === "error" ) { return normalized; } throw new Error("LOG_LEVEL must be one of: debug, info, warn, error"); } function parseRelay(rawRelay: string): RelayTarget | null { const relayValue = rawRelay.trim(); if (!relayValue) return null; let relayUrl: URL; try { relayUrl = new URL(relayValue); } catch { return null; } if (relayUrl.protocol !== "wss:") return null; return { relay: relayUrl.toString(), host: relayUrl.hostname, }; } function parseRelays(raw: string): { relays: RelayTarget[]; warnings: string[] } { const warnings: string[] = []; const unique = new Set(); const relays: RelayTarget[] = []; for (const chunk of raw.split(",")) { const parsed = parseRelay(chunk); if (!parsed) { const clean = chunk.trim(); if (clean) warnings.push(`Ignoring invalid relay URL: ${clean}`); continue; } if (unique.has(parsed.relay)) continue; unique.add(parsed.relay); relays.push(parsed); } return { relays, warnings }; } function isHex64(value: string): boolean { return /^[0-9a-f]{64}$/i.test(value); } function isValidWriteCheckPrivkey(raw: string): boolean { const value = raw.trim(); if (value.startsWith("nsec1")) { try { const decoded = nip19.decode(value); return decoded.type === "nsec"; } catch { return false; } } return isHex64(value); } function generateEphemeralWriteCheckPrivkey(): string { return Buffer.from(generateSecretKey()).toString("hex"); } export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig { const relaysRaw = env.RELAYS; if (!relaysRaw || relaysRaw.trim() === "") { throw new Error("RELAYS is required (comma-separated list of wss:// URLs)"); } const { relays, warnings } = parseRelays(relaysRaw); if (relays.length === 0) { throw new Error("RELAYS did not contain any valid wss:// relay URLs"); } const probeIntervalSeconds = parseInteger( env.PROBE_INTERVAL_SECONDS, DEFAULT_PROBE_INTERVAL_SECONDS, 0, "PROBE_INTERVAL_SECONDS", ); const probeTimeoutSeconds = parseInteger( env.PROBE_TIMEOUT_SECONDS, DEFAULT_PROBE_TIMEOUT_SECONDS, MIN_TIMEOUT_SECONDS, "PROBE_TIMEOUT_SECONDS", ); const probeIntervalMs = probeIntervalSeconds * MS_PER_SECOND; const probeTimeoutMs = probeTimeoutSeconds * MS_PER_SECOND; const port = parseInteger(env.PORT, DEFAULT_PORT, MIN_PORT, "PORT"); if (port > MAX_PORT) { throw new Error(`PORT must be <= ${MAX_PORT}`); } const writeEnabledFlag = parseBoolean(env.WRITE_CHECK_ENABLED, DEFAULT_WRITE_CHECK_ENABLED); const writeCheckKind = parseInteger( env.WRITE_CHECK_KIND, DEFAULT_WRITE_CHECK_KIND, 0, "WRITE_CHECK_KIND", ); const writePrivkey = env.WRITE_CHECK_PRIVKEY?.trim(); let resolvedWritePrivkey = writePrivkey; if (env.WRITE_CHECK_PUBKEY?.trim()) { warnings.push( "WRITE_CHECK_PUBKEY is ignored; write-check pubkey is always derived from WRITE_CHECK_PRIVKEY", ); } if (writeEnabledFlag) { if (!writePrivkey) { resolvedWritePrivkey = generateEphemeralWriteCheckPrivkey(); warnings.push( "WRITE_CHECK_PRIVKEY not set; generated ephemeral write-check key for this process", ); } else if (!isValidWriteCheckPrivkey(writePrivkey)) { resolvedWritePrivkey = generateEphemeralWriteCheckPrivkey(); warnings.push( "WRITE_CHECK_PRIVKEY invalid; generated ephemeral write-check key for this process", ); } } const staleAfterMs = probeIntervalMs + probeTimeoutMs + 5_000; const writeCheck: WriteCheckConfig = { enabled: writeEnabledFlag, kind: writeCheckKind, ...(resolvedWritePrivkey ? { privkey: resolvedWritePrivkey } : {}), }; return { relays, probeIntervalMs, probeTimeoutMs, listenAddr: env.LISTEN_ADDR?.trim() || DEFAULT_LISTEN_ADDR, port, logLevel: parseLogLevel(env.LOG_LEVEL), probeConcurrency: DEFAULT_PROBE_CONCURRENCY, staleAfterMs, writeCheck, warnings, }; }