import { and, eq } from 'drizzle-orm'; import { db } from '@/db'; import { adminProviders, adminSettings } from '@/db/schema'; import { serverLogger } from '@/lib/server/logger'; import { logDegraded } from '@/lib/server/errors/logging'; /** * Runtime config: site-wide settings that are persisted in admin_settings. * Each key has: * - a TypeScript value type * - a default used when no DB value exists * - a validator for admin/seed writes */ export type RuntimeConfigSource = 'json-seed' | 'env-seed' | 'admin'; export interface RuntimeConfigKeyDef { /** TS-level default. Used when neither DB nor env have a value. */ default: T; /** Validate an incoming admin-supplied value. */ validate(value: unknown): T | undefined; } function booleanFlag(defaultValue: boolean): RuntimeConfigKeyDef { return { default: defaultValue, validate(value) { if (typeof value === 'boolean') return value; return undefined; }, }; } function runtimeBoolean(defaultValue: boolean): RuntimeConfigKeyDef { return { default: defaultValue, validate(value) { if (typeof value === 'boolean') return value; return undefined; }, }; } function stringValue(defaultValue: string): RuntimeConfigKeyDef { return { default: defaultValue, validate(value) { if (typeof value === 'string') return value; return undefined; }, }; } function positiveIntValue(defaultValue: number): RuntimeConfigKeyDef { return { default: defaultValue, validate(value) { if (typeof value !== 'number' || !Number.isFinite(value) || value < 1) return undefined; return Math.floor(value); }, }; } export const RUNTIME_CONFIG_SCHEMA = { defaultTtsProvider: stringValue('custom-openai'), changelogFeedUrl: stringValue('https://docs.openreader.richardr.dev/changelog/manifest.json'), enableUserSignups: booleanFlag(true), restrictUserApiKeys: booleanFlag(true), // Historically the env semantics were "true unless explicitly 'false'", // i.e. the feature defaults to ON. enableTtsProvidersTab: booleanFlag(true), enableAudiobookExport: booleanFlag(true), enableDocxConversion: booleanFlag(true), enableDestructiveDeleteActions: booleanFlag(true), showAllProviderModels: runtimeBoolean(true), disableTtsRateLimit: booleanFlag(true), ttsDailyLimitAnonymous: positiveIntValue(50_000), ttsDailyLimitAuthenticated: positiveIntValue(500_000), ttsIpDailyLimitAnonymous: positiveIntValue(100_000), ttsIpDailyLimitAuthenticated: positiveIntValue(1_000_000), ttsCacheMaxSizeBytes: positiveIntValue(256 * 1024 * 1024), ttsCacheTtlMs: positiveIntValue(1000 * 60 * 30), ttsUpstreamMaxRetries: positiveIntValue(2), ttsUpstreamTimeoutMs: positiveIntValue(285_000), // Per-user throttle for expensive PDF-layout parsing. Disabled by default // (admins enable it in Settings → Admin), mirroring disableTtsRateLimit. // When enabled, the sub-limits below apply (admin-tunable, no env seed): // a short "burst" window plus a wider "sustained" window that also bounds // concurrency (the worker caps each job's duration). disableComputeRateLimit: booleanFlag(true), computeParseBurstMax: positiveIntValue(8), computeParseBurstWindowSec: positiveIntValue(60), computeParseSustainedMax: positiveIntValue(24), computeParseSustainedWindowSec: positiveIntValue(600), // Maximum size (MB) accepted for a single document upload. maxUploadMb: positiveIntValue(200), } as const satisfies Record>; export type RuntimeConfigKey = keyof typeof RUNTIME_CONFIG_SCHEMA; export type RuntimeConfig = { [K in RuntimeConfigKey]: typeof RUNTIME_CONFIG_SCHEMA[K] extends RuntimeConfigKeyDef ? T : never; }; export type RuntimeConfigEntry = { key: RuntimeConfigKey; value: RuntimeConfig[RuntimeConfigKey]; source: RuntimeConfigSource | 'default'; }; const RUNTIME_KEYS = Object.keys(RUNTIME_CONFIG_SCHEMA) as RuntimeConfigKey[]; async function resolveImplicitDefaultTtsProvider(): Promise { try { const rows = await db .select({ slug: adminProviders.slug }) .from(adminProviders) .where(and(eq(adminProviders.slug, 'default-openai'), eq(adminProviders.enabled, 1))) .limit(1); return rows[0]?.slug; } catch (error) { logDegraded(serverLogger, { event: 'admin.runtime_config.default_provider_lookup.failed', msg: 'Implicit defaultTtsProvider lookup failed', step: 'resolve_implicit_default_provider', error, }); return undefined; } } function buildDefaults(): RuntimeConfig { const out = {} as RuntimeConfig; for (const key of RUNTIME_KEYS) { (out as Record)[key] = RUNTIME_CONFIG_SCHEMA[key].default; } return out; } async function readAllRows(): Promise> { try { const rows = await db.select().from(adminSettings); const out = new Map(); for (const row of rows as Array<{ key: string; valueJson: unknown; source: string }>) { const parsed = parseStoredValue(row.valueJson); out.set(row.key, { value: parsed, source: row.source }); } return out; } catch (error) { logDegraded(serverLogger, { event: 'admin.runtime_config.read.failed', msg: 'Runtime config read failed', step: 'read_runtime_config_rows', error, }); return new Map(); } } function parseStoredValue(stored: unknown): unknown { // Postgres jsonb returns the parsed value; SQLite text stores JSON strings. if (typeof stored === 'string') { try { return JSON.parse(stored); } catch { return stored; } } return stored; } function serializeForStorage(value: unknown): unknown { // Use JSON.stringify for SQLite-friendly text storage; Postgres jsonb // accepts a JS object, but a JSON string is equally valid (Postgres parses // it). Storing as string makes the two dialects behave identically. return JSON.stringify(value); } /** Resolve the full runtime config, DB overlaid on defaults. */ export async function getRuntimeConfig(): Promise { const out = buildDefaults(); const rows = await readAllRows(); let implicitDefaultTtsProvider: string | null | undefined; for (const key of RUNTIME_KEYS) { const row = rows.get(key); if (!row) { if (key === 'defaultTtsProvider') { if (implicitDefaultTtsProvider === undefined) { implicitDefaultTtsProvider = (await resolveImplicitDefaultTtsProvider()) ?? null; } if (implicitDefaultTtsProvider) { (out as Record)[key] = implicitDefaultTtsProvider; } } continue; } const validated = RUNTIME_CONFIG_SCHEMA[key].validate(row.value); if (validated !== undefined) { (out as Record)[key] = validated; } } return out; } /** Like getRuntimeConfig() but also reports the source of each value. */ export async function getRuntimeConfigWithSources(): Promise<{ values: RuntimeConfig; sources: Record; }> { const values = buildDefaults(); const sources = {} as Record; const rows = await readAllRows(); let implicitDefaultTtsProvider: string | null | undefined; for (const key of RUNTIME_KEYS) { const row = rows.get(key); if (!row) { if (key === 'defaultTtsProvider') { if (implicitDefaultTtsProvider === undefined) { implicitDefaultTtsProvider = (await resolveImplicitDefaultTtsProvider()) ?? null; } if (implicitDefaultTtsProvider) { (values as Record)[key] = implicitDefaultTtsProvider; } } sources[key] = 'default'; continue; } const validated = RUNTIME_CONFIG_SCHEMA[key].validate(row.value); if (validated !== undefined) { (values as Record)[key] = validated; sources[key] = ( row.source === 'env-seed' || row.source === 'json-seed' ? row.source : 'admin' ); } else { sources[key] = 'default'; } } return { values, sources }; } /** Set a single runtime config key (admin write). Flips source to 'admin'. */ export async function setRuntimeConfigKey( key: K, value: RuntimeConfig[K], ): Promise { const def = RUNTIME_CONFIG_SCHEMA[key] as RuntimeConfigKeyDef; const validated = def.validate(value); if (validated === undefined) { throw new Error(`Invalid value for runtime config key "${key}"`); } const serialized = serializeForStorage(validated); const now = Date.now(); // Upsert with onConflict. const isPg = !!process.env.POSTGRES_URL; if (isPg) { await db .insert(adminSettings) .values({ key, valueJson: serialized as never, source: 'admin', updatedAt: now }) .onConflictDoUpdate({ target: adminSettings.key, set: { valueJson: serialized as never, source: 'admin', updatedAt: now }, }); } else { await db .insert(adminSettings) .values({ key, valueJson: serialized as never, source: 'admin', updatedAt: now }) .onConflictDoUpdate({ target: adminSettings.key, set: { valueJson: serialized as never, source: 'admin', updatedAt: now }, }); } } /** Delete a runtime config row (resets to default/implicit behavior). */ export async function clearRuntimeConfigKey(key: RuntimeConfigKey): Promise { await db.delete(adminSettings).where(eq(adminSettings.key, key)); } /** * First-run seed from a parsed object. Only inserts keys that are absent, * never overwriting an existing row. */ export async function seedRuntimeConfigFromValues( input: Record, source: RuntimeConfigSource = 'json-seed', ): Promise<{ seeded: RuntimeConfigKey[]; invalid: string[]; unknown: string[] }> { const seeded: RuntimeConfigKey[] = []; const invalid: string[] = []; const unknown: string[] = []; const validEntries: Array<{ key: RuntimeConfigKey; value: RuntimeConfig[RuntimeConfigKey] }> = []; const existing = await readAllRows(); const now = Date.now(); for (const [rawKey, rawValue] of Object.entries(input)) { if (!RUNTIME_KEYS.includes(rawKey as RuntimeConfigKey)) { unknown.push(rawKey); continue; } const key = rawKey as RuntimeConfigKey; const def = RUNTIME_CONFIG_SCHEMA[key] as RuntimeConfigKeyDef; const validated = def.validate(rawValue); if (validated === undefined) { invalid.push(rawKey); continue; } validEntries.push({ key, value: validated }); } if (unknown.length > 0 || invalid.length > 0) { return { seeded, invalid, unknown }; } for (const entry of validEntries) { if (existing.has(entry.key)) continue; try { await db .insert(adminSettings) .values({ key: entry.key, valueJson: serializeForStorage(entry.value) as never, source, updatedAt: now, }) .onConflictDoNothing({ target: adminSettings.key }); seeded.push(entry.key); } catch (error) { logDegraded(serverLogger, { event: 'admin.runtime_config.seed.failed', msg: 'Runtime config seed failed', step: 'seed_runtime_config_key', context: { key: entry.key, source }, error, }); } } return { seeded, invalid, unknown }; } export { RUNTIME_KEYS };