import { z } from "zod"; const BoolFromEnv = z .union([z.boolean(), z.string()]) .transform((val) => { if (typeof val === "boolean") return val; const normalized = val.trim().toLowerCase(); return normalized === "true" || normalized === "1"; }); function isLoopbackOrPrivateHostname(hostname: string): boolean { const normalized = hostname.trim().toLowerCase().replace(/^\[|\]$/g, ""); if (!normalized) return true; if ( normalized === "localhost" || normalized === "::1" || normalized === "0.0.0.0" || normalized.endsWith(".local") ) { return true; } if (normalized.startsWith("127.")) return true; if (normalized.startsWith("10.")) return true; if (normalized.startsWith("192.168.")) return true; if (normalized.startsWith("169.254.")) return true; const parts = normalized.split("."); if (parts.length === 4 && parts.every((part) => /^\d+$/.test(part))) { const octets = parts.map((part) => Number(part)); if (octets.some((n) => n < 0 || n > 255)) return true; if (octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31) return true; return false; } return false; } const Env = z.object({ NODE_ENV: z.enum(["development", "test", "production"]).default("development"), PORT: z.coerce.number().int().positive().default(8080), HOST: z.string().default("0.0.0.0"), DATABASE_URL: z.string().min(1), CORS_ORIGIN: z.string().optional(), CORS_ORIGINS: z.string().optional(), RATE_LIMIT_MAX: z.coerce.number().int().positive().default(200), RATE_LIMIT_WINDOW_MS: z.coerce.number().int().positive().default(60_000), JWT_SECRET: z.string().min(32), JWT_ISSUER: z.string().min(1).default("skymoney-api"), JWT_AUDIENCE: z.string().min(1).default("skymoney-web"), COOKIE_SECRET: z.string().min(32), COOKIE_DOMAIN: z.string().optional(), EMAIL_VERIFY_DEV_CODE_EXPOSE: BoolFromEnv.default(false), BREAK_GLASS_VERIFY_ENABLED: BoolFromEnv.default(false), BREAK_GLASS_VERIFY_CODE: z.string().min(32).optional(), AUTH_DISABLED: BoolFromEnv.optional().default(false), ALLOW_INSECURE_AUTH_FOR_DEV: BoolFromEnv.optional().default(false), SEED_DEFAULT_BUDGET: BoolFromEnv.default(true), SESSION_TIMEOUT_MINUTES: z.coerce.number().int().positive().default(30), AUTH_MAX_FAILED_ATTEMPTS: z.coerce.number().int().positive().default(5), AUTH_LOCKOUT_WINDOW_MS: z.coerce.number().int().positive().default(15 * 60_000), PASSWORD_RESET_TTL_MINUTES: z.coerce.number().int().positive().default(30), PASSWORD_RESET_RATE_LIMIT_PER_MINUTE: z.coerce.number().int().positive().default(5), PASSWORD_RESET_CONFIRM_RATE_LIMIT_PER_MINUTE: z.coerce.number().int().positive().default(10), APP_ORIGIN: z.string().min(1).default("http://localhost:5173"), UPDATE_NOTICE_VERSION: z.coerce.number().int().nonnegative().default(0), UPDATE_NOTICE_TITLE: z.string().min(1).default("SkyMoney Updated"), UPDATE_NOTICE_BODY: z .string() .min(1) .default("We shipped improvements and fixes. Please review the latest changes."), SMTP_HOST: z.string().optional(), SMTP_PORT: z.coerce.number().int().positive().optional(), SMTP_REQUIRE_TLS: BoolFromEnv.default(true), SMTP_TLS_REJECT_UNAUTHORIZED: BoolFromEnv.default(true), SMTP_USER: z.string().optional(), SMTP_PASS: z.string().optional(), EMAIL_FROM: z.string().min(1).default("SkyMoney "), EMAIL_BOUNCE_FROM: z.string().optional(), EMAIL_REPLY_TO: z.string().optional(), }); const rawEnv = { NODE_ENV: process.env.NODE_ENV, PORT: process.env.PORT, HOST: process.env.HOST, DATABASE_URL: process.env.DATABASE_URL, CORS_ORIGIN: process.env.CORS_ORIGIN, CORS_ORIGINS: process.env.CORS_ORIGINS, RATE_LIMIT_MAX: process.env.RATE_LIMIT_MAX, RATE_LIMIT_WINDOW_MS: process.env.RATE_LIMIT_WINDOW_MS, JWT_SECRET: process.env.JWT_SECRET ?? "dev-jwt-secret-change-me", JWT_ISSUER: process.env.JWT_ISSUER, JWT_AUDIENCE: process.env.JWT_AUDIENCE, COOKIE_SECRET: process.env.COOKIE_SECRET ?? "dev-cookie-secret-change-me", COOKIE_DOMAIN: process.env.COOKIE_DOMAIN, EMAIL_VERIFY_DEV_CODE_EXPOSE: process.env.EMAIL_VERIFY_DEV_CODE_EXPOSE, BREAK_GLASS_VERIFY_ENABLED: process.env.BREAK_GLASS_VERIFY_ENABLED, BREAK_GLASS_VERIFY_CODE: process.env.BREAK_GLASS_VERIFY_CODE, AUTH_DISABLED: process.env.AUTH_DISABLED, ALLOW_INSECURE_AUTH_FOR_DEV: process.env.ALLOW_INSECURE_AUTH_FOR_DEV, SEED_DEFAULT_BUDGET: process.env.SEED_DEFAULT_BUDGET, SESSION_TIMEOUT_MINUTES: process.env.SESSION_TIMEOUT_MINUTES, AUTH_MAX_FAILED_ATTEMPTS: process.env.AUTH_MAX_FAILED_ATTEMPTS, AUTH_LOCKOUT_WINDOW_MS: process.env.AUTH_LOCKOUT_WINDOW_MS, PASSWORD_RESET_TTL_MINUTES: process.env.PASSWORD_RESET_TTL_MINUTES, PASSWORD_RESET_RATE_LIMIT_PER_MINUTE: process.env.PASSWORD_RESET_RATE_LIMIT_PER_MINUTE, PASSWORD_RESET_CONFIRM_RATE_LIMIT_PER_MINUTE: process.env.PASSWORD_RESET_CONFIRM_RATE_LIMIT_PER_MINUTE, APP_ORIGIN: process.env.APP_ORIGIN, UPDATE_NOTICE_VERSION: process.env.UPDATE_NOTICE_VERSION, UPDATE_NOTICE_TITLE: process.env.UPDATE_NOTICE_TITLE, UPDATE_NOTICE_BODY: process.env.UPDATE_NOTICE_BODY, SMTP_HOST: process.env.SMTP_HOST, SMTP_PORT: process.env.SMTP_PORT, SMTP_REQUIRE_TLS: process.env.SMTP_REQUIRE_TLS, SMTP_TLS_REJECT_UNAUTHORIZED: process.env.SMTP_TLS_REJECT_UNAUTHORIZED, SMTP_USER: process.env.SMTP_USER, SMTP_PASS: process.env.SMTP_PASS, EMAIL_FROM: process.env.EMAIL_FROM, EMAIL_BOUNCE_FROM: process.env.EMAIL_BOUNCE_FROM, EMAIL_REPLY_TO: process.env.EMAIL_REPLY_TO, }; const parsed = Env.parse(rawEnv); if (parsed.NODE_ENV === "production") { if (!parsed.CORS_ORIGIN && !parsed.CORS_ORIGINS) { throw new Error("CORS_ORIGIN or CORS_ORIGINS must be set in production."); } if (rawEnv.AUTH_DISABLED && parsed.AUTH_DISABLED) { throw new Error("AUTH_DISABLED cannot be enabled in production."); } if (parsed.SEED_DEFAULT_BUDGET) { throw new Error("SEED_DEFAULT_BUDGET must be disabled in production."); } if (parsed.JWT_SECRET.includes("dev-jwt-secret-change-me")) { throw new Error("JWT_SECRET must be set to a strong value in production."); } if (parsed.COOKIE_SECRET.includes("dev-cookie-secret-change-me")) { throw new Error("COOKIE_SECRET must be set to a strong value in production."); } if (!parsed.APP_ORIGIN) { throw new Error("APP_ORIGIN must be set in production."); } if (!parsed.APP_ORIGIN.startsWith("https://")) { throw new Error("APP_ORIGIN must use https:// in production."); } let appOriginUrl: URL; try { appOriginUrl = new URL(parsed.APP_ORIGIN); } catch { throw new Error("APP_ORIGIN must be a valid URL in production."); } if (isLoopbackOrPrivateHostname(appOriginUrl.hostname)) { throw new Error( "APP_ORIGIN must not point to localhost or private network hosts in production." ); } } if (parsed.AUTH_DISABLED && parsed.NODE_ENV !== "test" && !parsed.ALLOW_INSECURE_AUTH_FOR_DEV) { throw new Error( "AUTH_DISABLED requires ALLOW_INSECURE_AUTH_FOR_DEV=true outside test environments." ); } if (parsed.BREAK_GLASS_VERIFY_ENABLED && !parsed.BREAK_GLASS_VERIFY_CODE) { throw new Error( "BREAK_GLASS_VERIFY_ENABLED=true requires BREAK_GLASS_VERIFY_CODE (32+ chars)." ); } export const env = parsed;