220 lines
6.0 KiB
TypeScript
220 lines
6.0 KiB
TypeScript
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<string>();
|
|
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,
|
|
};
|
|
}
|