import { z } from 'zod' const boolFromEnv = (v: string | undefined, fallback: boolean) => { if (v === undefined || v === '') return fallback return v === '1' || v.toLowerCase() === 'true' } export const configSchema = z.object({ databaseUrl: z.string().default('sqlite:data/app.db'), jwtSecret: z.string().min(8), jwtTtlHours: z.coerce.number().positive().default(1), refreshTtlDays: z.coerce.number().positive().default(14), issuer: z.string().url().default('https://auth.shnt.top'), /** OIDC issuer URL; defaults to `issuer`. */ oidcIssuer: z.string().url().optional(), /** Optional PEM PKCS8 RSA private key; otherwise auto-generated & persisted. */ oidcRsaPrivateKey: z.string().optional(), adminEmail: z.string().email().default('admin@shnt.top'), adminPassword: z.string().default('admin'), adminName: z.string().default('Admin'), returnToAllowlist: z.string().default('.shnt.top,localhost,private'), serverPort: z.coerce.number().int().positive().default(8080), staticDir: z.string().optional(), logLevel: z.string().default('info'), isProd: z.boolean(), auditIngestSecret: z.string().min(8).optional(), webauthnRpID: z.string().min(1), webauthnRpName: z.string().min(1).default('Auth Portal'), webauthnOrigins: z.array(z.string().url()).min(1), }) export type AppConfig = z.infer export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig { const isProd = env.NODE_ENV === 'production' const jwtSecret = env.JWT_SECRET ?? (isProd ? '' : 'dev-secret-change-me') const auditIngestSecret = env.AUDIT_INGEST_SECRET ?? (isProd ? undefined : 'dev-audit-ingest-secret') const issuer = env.ISSUER ?? 'https://auth.shnt.top' const { rpID, origins } = webauthnFromIssuer(issuer, env, isProd) return configSchema.parse({ databaseUrl: env.DATABASE_URL ?? 'sqlite:data/app.db', jwtSecret, jwtTtlHours: env.JWT_TTL_HOURS ?? 1, refreshTtlDays: env.REFRESH_TTL_DAYS ?? 14, issuer, oidcIssuer: env.OIDC_ISSUER || issuer, oidcRsaPrivateKey: env.OIDC_RSA_PRIVATE_KEY || undefined, adminEmail: env.ADMIN_EMAIL ?? 'admin@shnt.top', adminPassword: env.ADMIN_PASSWORD ?? 'admin', adminName: env.ADMIN_NAME ?? 'Admin', returnToAllowlist: env.RETURN_TO_ALLOWLIST ?? '.shnt.top,localhost,private', serverPort: env.SERVER_PORT ?? 8080, staticDir: env.STATIC_DIR || undefined, logLevel: env.LOG_LEVEL ?? 'info', isProd: boolFromEnv(env.NODE_ENV === 'production' ? 'true' : undefined, false) || isProd, auditIngestSecret, webauthnRpID: rpID, webauthnRpName: env.WEBAUTHN_RP_NAME || 'Auth Portal', webauthnOrigins: origins, }) } function webauthnFromIssuer( issuer: string, env: NodeJS.ProcessEnv, isProd: boolean, ): { rpID: string; origins: string[] } { let issuerUrl: URL try { issuerUrl = new URL(issuer) } catch { issuerUrl = new URL('https://auth.shnt.top') } const rpID = (env.WEBAUTHN_RP_ID || issuerUrl.hostname).trim() const origins = new Set() origins.add(issuerUrl.origin) const extra = env.WEBAUTHN_ORIGINS ?? '' for (const raw of extra.split(',')) { const value = raw.trim().replace(/\/$/, '') if (!value) continue try { origins.add(new URL(value).origin) } catch { /* skip invalid */ } } if (!isProd) { origins.add('http://localhost:5173') origins.add('http://localhost:8080') } return { rpID, origins: [...origins] } } export function oidcIssuerFromConfig(config: AppConfig): string { return (config.oidcIssuer ?? config.issuer).replace(/\/$/, '') }