import 'dotenv/config' import { z } from 'zod' const configSchema = z.object({ DATABASE_URL: z.string().url(), PORT: z.coerce.number().int().positive().default(3000), NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), LOG_LEVEL: z.string().default('info'), /** * Logs a line for every request when true. Off by default: the client * evaluation endpoint is polled on a timer by every SDK instance, so a line * per request buries the events an operator actually needs and fills the * disk of whoever is self-hosting. Failed and slow requests are logged * either way. Parsed explicitly because z.coerce.boolean treats any * non-empty string as true. */ REQUEST_LOG: z .string() .optional() .transform((v) => v === 'true'), CACHE_TTL_SECONDS: z.coerce.number().int().positive().default(30), RATE_LIMIT_MAX: z.coerce.number().int().positive().default(100), RATE_LIMIT_WINDOW_MS: z.coerce.number().int().positive().default(60_000), /** Login and invite routes: they run argon2, so guessing is also a CPU drain. */ AUTH_RATE_LIMIT_MAX: z.coerce.number().int().positive().default(20), AUTH_RATE_LIMIT_WINDOW_MS: z.coerce.number().int().positive().default(900_000), /** Off by default: trusting this header with no proxy in front skips the rate limit. */ TRUST_PROXY: z .string() .optional() .transform((value): boolean | number | string => { if (value === undefined || value.trim() === '' || value === 'false') return false if (value === 'true') return true /** 0 is a hop count too -- it is how an operator spells "trust nothing". */ const hops = Number(value) return Number.isInteger(hops) && hops >= 0 ? hops : value }), /** Applies pending migrations at startup so a fresh container just works. */ RUN_MIGRATIONS: z .string() .optional() .transform((v) => v !== 'false'), JWT_SECRET: z.string().min(32), DEFAULT_ADMIN_EMAIL: z.string().email().default('admin@flagraft.local'), DEFAULT_ADMIN_PASSWORD: z.string().min(8).default('flagraft-admin'), DEFAULT_ADMIN_NAME: z.string().default('Admin'), DEFAULT_PROJECT_NAME: z.string().default('Default'), DEFAULT_PROJECT_SLUG: z.string().default('default'), /** * SMTP settings for emailing user invites. All optional: when SMTP_HOST is * unset, email is disabled and invites fall back to manual password sharing. */ SMTP_HOST: z.string().optional(), SMTP_PORT: z.coerce.number().int().positive().default(587), /** z.coerce.boolean treats any non-empty string as true, so parse explicitly. */ SMTP_SECURE: z .string() .optional() .transform((v) => v === 'true'), SMTP_USER: z.string().optional(), SMTP_PASS: z.string().optional(), SMTP_FROM: z.string().optional(), /** Public URL of the admin UI, used to build the sign-in link in invite emails. */ APP_BASE_URL: z.string().url().optional(), }) export type AppConfig = Readonly> export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig { const result = configSchema.safeParse(env) if (!result.success) { const details = result.error.issues .map((issue) => `${issue.path.join('.')}: ${issue.message}`) .join('; ') throw new Error(`Invalid configuration: ${details}`) } /** * The fallback admin password is publicly documented, so a production * deployment must never run with it. Refuse to boot instead of seeding an * owner account anyone can log into. */ if ( result.data.NODE_ENV === 'production' && result.data.DEFAULT_ADMIN_PASSWORD === 'flagraft-admin' ) { throw new Error( 'Invalid configuration: DEFAULT_ADMIN_PASSWORD must be set to a strong, non-default value in production', ) } return Object.freeze(result.data) }