import { z } from 'zod'; /** * Environment schemas live here so api, collab, and tooling validate their * configuration the same way. Every service calls `parseEnv` once at startup * and crashes with a readable list of problems instead of failing later at * first use. */ /** Fields every service configures the same way; keep these in sync. */ const nodeEnv = z.enum(['development', 'test', 'production']).default('development'); const logLevel = z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'); /** Version shown in health responses; injected at image build time. */ const appVersion = z.string().default('0.0.0-dev'); /** PostgreSQL connection string — required, there is no sensible default. */ const databaseUrl = z .string() .min(1) .refine((url) => url.startsWith('postgresql://') || url.startsWith('postgres://'), { message: 'must be a postgresql:// connection string', }); /** * Symmetric secret shared by the api (which signs) and the collab server * (which verifies) for the short-lived collaboration tokens (issue #34, * ADR 0007). The dev default only keeps native dev/test/CI running without * extra setup; every real instance MUST set its own identical value in the * `.env` of both services (the stage setup and the M8 wizard do this). */ const collabTokenSecret = z.string().min(16).default('dev-insecure-collab-token-secret-change-me'); export const apiEnvSchema = z.object({ NODE_ENV: nodeEnv, PORT: z.coerce.number().int().min(1).max(65535).default(3000), LOG_LEVEL: logLevel, APP_VERSION: appVersion, DATABASE_URL: databaseUrl, COLLAB_TOKEN_SECRET: collabTokenSecret, /** Set to "false" to skip `prisma migrate deploy` at startup (tests, tooling). */ MIGRATE_ON_START: z .enum(['true', 'false']) .default('true') .transform((value) => value === 'true'), /** Public base URL of this instance — used in e-mail links. */ APP_BASE_URL: z.string().url().default('http://localhost:5173'), /** * SMTP delivery. Defaults match the Mailpit container from the dev * overlay; production instances configure their real relay here (the * M8 setup wizard writes these). */ SMTP_HOST: z.string().default('localhost'), SMTP_PORT: z.coerce.number().int().default(1025), SMTP_SECURE: z .enum(['true', 'false']) .default('false') .transform((value) => value === 'true'), SMTP_USER: z.string().optional(), SMTP_PASS: z.string().optional(), SMTP_FROM: z.string().default('Dorfteich '), /** * Filesystem root for uploaded files (ADR 0011). The compose stack * mounts the `uploads` volume at `/data/uploads` and sets this * explicitly; the relative default only serves native (non-Docker) * dev/test runs. */ UPLOADS_DIR: z.string().min(1).default('./data/uploads'), }); export type ApiEnv = z.infer; /** * Configuration for the collaboration server (Hocuspocus, ADR 0003). It is a * thin real-time front-end to the same PostgreSQL database as the api; it does * not run migrations (the api owns the schema) and needs no SMTP or uploads. */ export const collabEnvSchema = z.object({ NODE_ENV: nodeEnv, PORT: z.coerce.number().int().min(1).max(65535).default(3000), LOG_LEVEL: logLevel, APP_VERSION: appVersion, DATABASE_URL: databaseUrl, COLLAB_TOKEN_SECRET: collabTokenSecret, }); export type CollabEnv = z.infer; export function parseEnv( schema: Schema, env: Record, ): z.infer { const result = schema.safeParse(env); if (!result.success) { const problems = result.error.issues .map((issue) => ` - ${issue.path.join('.') || '(root)'}: ${issue.message}`) .join('\n'); throw new Error(`Invalid environment configuration:\n${problems}`); } return result.data; }