profileShare

rasmusjy / roundtable

Read-only snapshot

No repository description.

main default branch 181 files Expires Sep 13, 2026, 9:06 AM
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