because I got bored of customising my CV for every job
at main 136 lines 4.6 kB view raw
1import { z } from "zod/v4"; 2 3const expiryFormat = z 4 .string() 5 .regex( 6 /^(\d+)([smhd])$/, 7 'Must be in format: number + unit (s=seconds, m=minutes, h=hours, d=days). Example: "15m", "7d"', 8 ); 9 10const optionalUrl = z.string().url().optional(); 11 12export const envSchema = z 13 .object({ 14 // Database Configuration 15 POSTGRES_USER: z.string().default("cv"), 16 POSTGRES_PASSWORD: z.string().default("cv"), 17 POSTGRES_DB: z.string().default("cv"), 18 DATABASE_URL: z 19 .string() 20 .url() 21 .default("postgresql://cv:cv@localhost:5432/cv"), 22 23 // Server Configuration 24 PORT: z.coerce.number().default(3000), 25 SERVER_PORT: z.coerce.number().default(3000), 26 NODE_ENV: z 27 .enum(["development", "production", "test"]) 28 .default("development"), 29 30 // JWT Configuration 31 JWT_SECRET: z 32 .string() 33 .min(16, "JWT_SECRET must be at least 16 characters long for security") 34 .default("dev-jwt-secret-change-in-production"), 35 JWT_ACCESS_TOKEN_EXPIRY: expiryFormat.default("15m"), 36 JWT_REFRESH_TOKEN_EXPIRY: expiryFormat.default("7d"), 37 38 // Prisma Configuration 39 PRISMA_ENABLE_TRACING: z.coerce.boolean().default(false), 40 41 // Client Configuration (optional for server) 42 CLIENT_PORT: z.coerce.number().optional(), 43 VITE_SERVER_URL: optionalUrl, 44 GRAPHQL_SCHEMA_URL: optionalUrl, 45 CLIENT_ORIGIN: optionalUrl, 46 DOCS_ORIGIN: optionalUrl, 47 ALLOWED_ORIGINS: z.string().optional(), 48 49 // UI Configuration (optional for server) 50 UI_PORT: z.coerce.number().optional(), 51 52 // Database Port 53 DB_PORT: z.coerce.number().default(5432), 54 55 // Resend Configuration (optional in dev - emails will be logged to console) 56 RESEND_API_KEY: z.string().default(""), 57 58 // Email Configuration 59 EMAIL_FROM_ADDRESS: z.string().email().optional(), 60 EMAIL_FROM_NAME: z.string().optional(), 61 CLIENT_URL: optionalUrl, 62 EMAIL_VERIFICATION_TOKEN_EXPIRY: expiryFormat.default("24h"), 63 PASSWORD_RESET_TOKEN_EXPIRY: expiryFormat.default("1h"), 64 65 // Encryption Configuration 66 ENCRYPTION_KEY: z 67 .string() 68 .min( 69 32, 70 "ENCRYPTION_KEY must be at least 32 characters long for security", 71 ) 72 .default("dev-encryption-key-32-chars-long!"), 73 74 // AI Provider Configuration 75 AI_PROVIDER: z 76 .enum(["llama-cpp", "openai", "anthropic"]) 77 .default("llama-cpp"), 78 AI_TEMPERATURE: z.coerce.number().min(0).max(2).default(0.1), 79 AI_MAX_TOKENS: z.coerce.number().int().min(1).default(8192), 80 AI_TIMEOUT: z.coerce.number().int().min(1000).default(300000), 81 82 // Llama.cpp 83 LLAMA_URL: z.string().url().default("http://localhost:8080"), 84 85 // OpenAI (conditionally required via superRefine) 86 OPENAI_API_KEY: z.string().optional().default(""), 87 OPENAI_BASE_URL: z.string().url().default("https://api.openai.com"), 88 OPENAI_MODEL: z.string().default("gpt-4o-mini"), 89 90 // Anthropic (conditionally required via superRefine) 91 ANTHROPIC_API_KEY: z.string().optional().default(""), 92 ANTHROPIC_BASE_URL: z.string().url().default("https://api.anthropic.com"), 93 ANTHROPIC_MODEL: z.string().default("claude-sonnet-4-5-20250929"), 94 95 // File Storage 96 FILE_STORAGE_DRIVER: z.enum(["disk", "r2"]).default("disk"), 97 PDF_OUTPUT_DIR: z.string().default("./pdf-output"), 98 R2_ENDPOINT: z.string().optional().default(""), 99 R2_ACCESS_KEY_ID: z.string().optional().default(""), 100 R2_SECRET_ACCESS_KEY: z.string().optional().default(""), 101 R2_BUCKET: z.string().optional().default(""), 102 }) 103 .superRefine((data, ctx) => { 104 if (data.AI_PROVIDER === "openai" && !data.OPENAI_API_KEY) { 105 ctx.addIssue({ 106 code: z.ZodIssueCode.custom, 107 message: "OPENAI_API_KEY is required when AI_PROVIDER=openai", 108 path: ["OPENAI_API_KEY"], 109 }); 110 } 111 112 if (data.AI_PROVIDER === "anthropic" && !data.ANTHROPIC_API_KEY) { 113 ctx.addIssue({ 114 code: z.ZodIssueCode.custom, 115 message: "ANTHROPIC_API_KEY is required when AI_PROVIDER=anthropic", 116 path: ["ANTHROPIC_API_KEY"], 117 }); 118 } 119 120 if (data.FILE_STORAGE_DRIVER === "r2") { 121 for (const key of ["R2_ENDPOINT", "R2_ACCESS_KEY_ID", "R2_SECRET_ACCESS_KEY", "R2_BUCKET"] as const) { 122 if (!data[key]) { 123 ctx.addIssue({ 124 code: z.ZodIssueCode.custom, 125 message: `${key} is required when FILE_STORAGE_DRIVER=r2`, 126 path: [key], 127 }); 128 } 129 } 130 } 131 }); 132 133export type Env = z.infer<typeof envSchema>; 134 135export const validate = (config: Record<string, unknown>): Env => 136 envSchema.parse(config);