import dotenv from "dotenv"; import { z } from "zod"; // Loads `.env.test` instead of `.env` when running the test suite // (NODE_ENV=test, set by `cross-env` in package.json's `test` script — // already present in `process.env` by the time this module runs, since // `cross-env` sets it before invoking node/tsx at all). Keeps // `resetDatabase()` (test-support/reset-db.ts, which TRUNCATEs almost // every table before each test) pointed at a dedicated test database, // never whatever `pnpm dev` actually uses — running the test suite once // already wiped a real local dev database this way (`.env`/`.env.test` // sharing one `DATABASE_URL`), see `.env.test.example` for how to set the // separate test database this now requires. dotenv.config({ path: process.env.NODE_ENV === "test" ? ".env.test" : ".env" }); /** * Schema for every environment variable the API reads. Parsing (below) * fails fast at startup if something required is missing/invalid, instead * of surfacing as a confusing runtime error later. */ const envSchema = z.object({ /** Runtime mode — also toggles test-only behavior (e.g. cheaper argon2 cost, see auth.service.ts). */ NODE_ENV: z.enum(["development", "test", "production"]).default("development"), /** Port the HTTP server listens on. */ PORT: z.coerce.number().int().positive().default(3000), /** Postgres connection string, consumed by Prisma. */ DATABASE_URL: z.string().url().optional(), // Auth — no default on purpose, same reasoning as docker-compose.yml's // POSTGRES_USER/PASSWORD: a secret must never have a working fallback // baked into committed code. /** Secret used to sign/verify session JWTs. Required, no default — see comment above. */ JWT_SECRET: z.string().min(32, "JWT_SECRET must be at least 32 characters"), /** JWT expiry, in `jsonwebtoken`'s duration string format (e.g. "7d"). */ JWT_EXPIRES_IN: z.string().default("7d"), /** Name of the httpOnly cookie carrying the session JWT. */ AUTH_COOKIE_NAME: z.string().default("session"), /** Origin allowed by CORS — must match wherever apps/web is served from. */ CORS_ORIGIN: z.string().default("http://localhost:5173"), /** * Absolute path to the built frontend (`apps/web/dist`), to serve * alongside the API. Optional, no default — only set inside the * production Docker image (see Dockerfile); left unset in native dev * (`pnpm dev:api`), where `pnpm dev:web`'s own Vite dev server serves * the frontend instead. */ FRONTEND_DIST_DIR: z.string().optional(), /** * Overrides whether the session cookie gets the `Secure` attribute * (HTTPS-only — see auth.routes.ts). Independent from NODE_ENV on * purpose: NODE_ENV=production doesn't imply the deployment actually * has TLS in front of it (e.g. an HTTP-only dev/staging instance), and * a `Secure` cookie is silently never sent back by the browser over * plain HTTP — every authenticated request 401s despite login * succeeding, with no error to point at the cause. Unset (the default) * falls back to NODE_ENV === "production", same as before this existed. * Empty string counts as unset too, so `${COOKIE_SECURE:-}` in * docker-compose.yml doesn't force it to `false` when not provided. */ COOKIE_SECURE: z .string() .optional() .transform((value) => (value === undefined || value === "" ? undefined : value === "true")), /** * Shared secret `services/tech-step-llm-worker` sends as an * `X-Internal-Worker-Secret` header on every call to `/internal/tech-steps/*` * (`requireInternalWorker`, `middlewares/require-internal-worker.ts`). * Optional with no default in the schema itself (unlike `JWT_SECRET`) so * an environment that doesn't run the worker at all (e.g. this repo's * existing test suite) never needs to set it — but `requireInternalWorker` * itself rejects every request outright when it's unset, so the surface * fails closed rather than open if a real deployment forgets to set it. */ INTERNAL_WORKER_SECRET: z.string().min(32).optional(), /** * Base URL of `services/tech-step-intent-service` (the spaCy-based * microservice `TechStepClassifierService` delegates NER + intent * classification to, see `lib/recipe-matching/intent-service-client.ts`). * Has a default (unlike `DATABASE_URL`/secrets below) since it isn't * secret and dev natively runs it on a fixed local port — Docker Compose * overrides it to the compose network's service name. */ INTENT_SERVICE_BASE_URL: z.string().url().default("http://localhost:8000"), /** * Shared secret sent as an `X-Intent-Service-Secret` header on every call * to `services/tech-step-intent-service`. Unlike `INTERNAL_WORKER_SECRET` * above, **required, no `.optional()`** — that service is a core * dependency (recipe save/preview can no longer detect any technique * without it), not an optional background job; an environment that * forgets to set this must fail loudly at startup, not silently run with * every technique detection request failing one at a time. */ INTENT_SERVICE_SECRET: z.string().min(32, "INTENT_SERVICE_SECRET must be at least 32 characters"), }); /** Parsed, validated environment — import this instead of reading `process.env` directly anywhere else. */ export const env = envSchema.parse(process.env);