env.ts
3,627 bytes
| 1 | /** |
|---|---|
| 2 | * Boot-time environment validation. |
| 3 | * |
| 4 | * Everything the server reads from `process.env` passes through this Zod schema |
| 5 | * exactly once. A misconfigured deployment fails loudly at startup instead of |
| 6 | * mysteriously at request time. Set `SKIP_ENV_VALIDATION=1` for `next build` |
| 7 | * (no runtime secrets needed to compile) - CI does this. |
| 8 | * |
| 9 | * This module is server-only; importing it from a client component is a bug and |
| 10 | * would leak nothing useful anyway (Next strips it), but keep it server-side. |
| 11 | */ |
| 12 | import { z } from 'zod'; |
| 13 | |
| 14 | const booleanish = z |
| 15 | .enum(['0', '1', 'true', 'false']) |
| 16 | .transform((v) => v === '1' || v === 'true'); |
| 17 | |
| 18 | const envSchema = z.object({ |
| 19 | NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), |
| 20 | |
| 21 | // Persistence |
| 22 | DATABASE_URL: z.string().min(1), |
| 23 | |
| 24 | // BYOK encryption - the one secret required even for public demo mode. |
| 25 | ENCRYPTION_KEY: z |
| 26 | .string() |
| 27 | .min(1) |
| 28 | .refine((v) => decodeKeyLength(v) === 32, { |
| 29 | message: 'ENCRYPTION_KEY must decode to exactly 32 bytes (base64 or hex). Try: openssl rand -base64 32', |
| 30 | }), |
| 31 | |
| 32 | // Auth (optional - public demo works without login) |
| 33 | AUTH_SECRET: z.string().min(1).optional(), |
| 34 | AUTH_URL: z.string().url().optional(), |
| 35 | AUTH_TRUST_HOST: booleanish.default('true'), |
| 36 | AUTH_GITHUB_ID: z.string().optional(), |
| 37 | AUTH_GITHUB_SECRET: z.string().optional(), |
| 38 | |
| 39 | // OpenRouter gateway |
| 40 | OPENROUTER_BASE_URL: z.string().url().default('https://openrouter.ai/api/v1'), |
| 41 | OPENROUTER_APP_URL: z.string().url().default('http://localhost:3000'), |
| 42 | OPENROUTER_APP_TITLE: z.string().default('Roundtable'), |
| 43 | |
| 44 | // Behavior |
| 45 | MOCK_LLM: booleanish.default('0'), |
| 46 | RATE_LIMIT_DEBATES_PER_HOUR: z.coerce.number().int().positive().default(20), |
| 47 | }); |
| 48 | |
| 49 | export type Env = z.infer<typeof envSchema>; |
| 50 | |
| 51 | function decodeKeyLength(value: string): number { |
| 52 | try { |
| 53 | if (/^[0-9a-fA-F]{64}$/.test(value)) return Buffer.from(value, 'hex').length; |
| 54 | return Buffer.from(value, 'base64').length; |
| 55 | } catch { |
| 56 | return -1; |
| 57 | } |
| 58 | } |
| 59 | |
| 60 | function loadEnv(): Env { |
| 61 | if (process.env.SKIP_ENV_VALIDATION === '1') { |
| 62 | // Build-time / typecheck: trust the shape, fill defaults where trivial. |
| 63 | return { |
| 64 | NODE_ENV: (process.env.NODE_ENV as Env['NODE_ENV']) ?? 'development', |
| 65 | DATABASE_URL: process.env.DATABASE_URL ?? 'postgresql://localhost:5432/placeholder', |
| 66 | ENCRYPTION_KEY: process.env.ENCRYPTION_KEY ?? Buffer.alloc(32).toString('base64'), |
| 67 | AUTH_SECRET: process.env.AUTH_SECRET, |
| 68 | AUTH_URL: process.env.AUTH_URL, |
| 69 | AUTH_TRUST_HOST: process.env.AUTH_TRUST_HOST !== 'false', |
| 70 | AUTH_GITHUB_ID: process.env.AUTH_GITHUB_ID, |
| 71 | AUTH_GITHUB_SECRET: process.env.AUTH_GITHUB_SECRET, |
| 72 | OPENROUTER_BASE_URL: process.env.OPENROUTER_BASE_URL ?? 'https://openrouter.ai/api/v1', |
| 73 | OPENROUTER_APP_URL: process.env.OPENROUTER_APP_URL ?? 'http://localhost:3000', |
| 74 | OPENROUTER_APP_TITLE: process.env.OPENROUTER_APP_TITLE ?? 'Roundtable', |
| 75 | MOCK_LLM: process.env.MOCK_LLM === '1' || process.env.MOCK_LLM === 'true', |
| 76 | RATE_LIMIT_DEBATES_PER_HOUR: Number(process.env.RATE_LIMIT_DEBATES_PER_HOUR ?? 20), |
| 77 | }; |
| 78 | } |
| 79 | |
| 80 | const parsed = envSchema.safeParse(process.env); |
| 81 | if (!parsed.success) { |
| 82 | const issues = parsed.error.issues.map((i) => ` - ${i.path.join('.')}: ${i.message}`).join('\n'); |
| 83 | throw new Error(`Invalid environment configuration:\n${issues}`); |
| 84 | } |
| 85 | return parsed.data; |
| 86 | } |
| 87 | |
| 88 | export const env: Env = loadEnv(); |
| 89 | |
| 90 | /** GitHub auth is only usable when both client id and secret are present. */ |
| 91 | export const isGithubAuthConfigured = Boolean(env.AUTH_GITHUB_ID && env.AUTH_GITHUB_SECRET && env.AUTH_SECRET); |
| 92 | |