because I got bored of customising my CV for every job
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);