import { fileURLToPath } from 'node:url'; import path from 'node:path'; import { DEFAULT_RELAYS } from '@cashumints/shared'; import type { Dialect } from './db-driver.ts'; import { redact } from './db-postgres.ts'; const here = path.dirname(fileURLToPath(import.meta.url)); const apiRoot = path.resolve(here, '..'); function int(name: string, fallback: number): number { const raw = process.env[name]; if (!raw) return fallback; const n = Number.parseInt(raw, 10); return Number.isFinite(n) && n > 0 ? n : fallback; } export interface DbConfig { dialect: Dialect; /** SQLite file. Empty when the dialect is postgres. */ file: string; /** libpq connection string. Empty when the dialect is sqlite. */ url: string; /** Postgres connections held open. Ignored by SQLite, which has one. */ poolMax: number; /** Safe to log: a Postgres password is replaced with `***`. */ label: string; } export const DEFAULT_DB_FILE = path.join(apiRoot, 'data', 'cashumints.db'); /** * Read one connection target. * * Accepts what a person is likely to type or paste: * * postgres://user:pw@host:5432/cashumints postgres * postgresql://… postgres * sqlite:/var/lib/cashumints/cashumints.db sqlite, absolute * sqlite://./data/cashumints.db sqlite, relative to the working directory * file:./data/cashumints.db sqlite * /var/lib/cashumints/cashumints.db sqlite, a bare path * :memory: sqlite, throwaway * * Throws on anything else rather than guessing, because the wrong guess here is a * second empty database that looks like data loss. */ export function parseDbTarget(raw: string, poolMax = 10): DbConfig { const value = raw.trim(); if (!value) throw new Error('Empty database target.'); const sqlite = (file: string): DbConfig => { if (!file) throw new Error(`Database target has no path after the scheme: ${value}`); const resolved = file === ':memory:' ? file : path.resolve(file); return { dialect: 'sqlite', file: resolved, url: '', poolMax, label: `sqlite:${resolved}` }; }; // A scheme is checked before anything else, so an unsupported one is an error rather // than a filename. Left to a "does it look like a path?" heuristic, `mysql://db/x` // reads as a relative path and the API starts on a brand new empty SQLite file — data // loss that announces itself as a successful boot. const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(value)?.[1]?.toLowerCase(); switch (scheme) { case undefined: return sqlite(value); // A bare path, absolute or relative. case 'postgres': case 'postgresql': return { dialect: 'postgres', file: '', url: value, poolMax, label: redact(value) }; case 'sqlite': case 'sqlite3': case 'file': return sqlite(value.replace(/^[a-z0-9+.-]+:(?:\/\/)?/i, '')); default: // `:memory:` has no scheme by this reading — the regex needs a letter first. if (value === ':memory:') return sqlite(value); throw new Error( `Cannot tell what database "${value}" means. Use a postgres:// URL, ` + 'a sqlite: path, or a filesystem path.', ); } } /** * Where this process keeps its data. * * `DATABASE_URL` decides the backend. Without it the API stays on SQLite at `DB_PATH`, * which is what every existing deployment already has, so adding Postgres support * changed nothing for anyone who does not ask for it. */ export function resolveDbConfig(env: NodeJS.ProcessEnv = process.env): DbConfig { const poolMax = int('DB_POOL_MAX', 10); const url = env['DATABASE_URL']?.trim(); if (url) return parseDbTarget(url, poolMax); const file = path.resolve(env['DB_PATH'] ?? DEFAULT_DB_FILE); return { dialect: 'sqlite', file, url: '', poolMax, label: `sqlite:${file}` }; } let dbConfig: DbConfig | null = null; export const config = { port: int('PORT', 8787), /** * Resolved on first use rather than at import, so a process can still redirect itself * — `test:offline` points at a throwaway copy by setting DATABASE_URL before anything * opens a connection, and would otherwise rug the live database instead. */ get db(): DbConfig { dbConfig ??= resolveDbConfig(); return dbConfig; }, iconDir: process.env['ICON_DIR'] ?? path.join(apiRoot, 'data', 'icons'), relays: (process.env['RELAYS']?.split(',').map((r) => r.trim()).filter(Boolean) ?? [...DEFAULT_RELAYS]) as string[], /** * The floor a backfill cycle has to clear before it counts as a real read. * * For about a year this deployment's RELAYS list did not include the relay carrying * the kind 38000/38172 archive. Every backfill returned about thirty events, wrote * them, reported ok=true, and the index sat at eight mints while every health signal * stayed green. A backfill asks five relays for the whole history of four kinds; on a * working relay set it comes back with thousands. Anything under this is not a quiet * network, it is a misconfigured one, and it says so in the log and on /api/health. * * Raise it on a deployment that genuinely has more history, lower it for a local * test relay. It is deliberately not zero-able: set it to 1 if you mean "off". */ backfillMinEvents: int('BACKFILL_MIN_EVENTS', 200), probeIntervalMin: int('PROBE_INTERVAL_MIN', 10), discoveryIntervalMin: int('DISCOVERY_INTERVAL_MIN', 60), probeConcurrency: int('PROBE_CONCURRENCY', 8), probeTimeoutMs: int('PROBE_TIMEOUT_MS', 5000), userAgent: 'cashumints.space-indexer/1.0', } as const; /** * Publishing `kind:38174` announcements, which is **off unless two switches are set**. * * One switch would be enough to make it work and is not enough to make it safe. This * writes signed events to public relays, permanently and under this site's name, so * turning it on has to be a thing somebody did on purpose rather than a thing that * happened because a key was left in an env file from a test run: * * ANNOUNCE_LNURL=true the intent * ANNOUNCE_KEY=nsec1… the identity it will be signed with * ANNOUNCE_RELAYS=wss://… where, explicitly — there is deliberately no default * * The missing default on ANNOUNCE_RELAYS is the important one. Falling back to the * site's read pool would mean the difference between a CI run against a local test relay * and a permanent write to five public ones was a single unset variable. * * Returns null — never throws — when any of the three is missing, because a * misconfigured publisher must not stop an indexer that still has a directory to serve. * See the README section "Publishing LNURL mint announcements". */ export function announceConfig( env: NodeJS.ProcessEnv = process.env, ): { secretKey: string; relays: string[] } | null { if (env['ANNOUNCE_LNURL']?.trim().toLowerCase() !== 'true') return null; const secretKey = env['ANNOUNCE_KEY']?.trim() ?? ''; const relays = (env['ANNOUNCE_RELAYS'] ?? '') .split(',') .map((relay) => relay.trim()) .filter((relay) => relay.startsWith('ws://') || relay.startsWith('wss://')); if (!secretKey || relays.length === 0) return null; return { secretKey, relays }; } export const startedAt = Math.floor(Date.now() / 1000);