batchCooking/apps/api/src/config/env.ts
Nicolas 266f540d88 fix(api): make the session cookie's Secure flag overridable
Found on http://batch.dev.kyuno.fr/: login/signup succeeded (200/201,
profile in the body) but every subsequent request 401'd. Cause: the
session cookie is `secure: NODE_ENV === "production"`, and
docker-compose.yml sets NODE_ENV=production regardless of whether the
deployment actually has TLS in front of it. A Secure cookie is silently
never sent back by the browser over plain HTTP — no error, just a cookie
that never round-trips.

Adds COOKIE_SECURE, independent from NODE_ENV, to override the flag per
deployment. Unset (default) keeps prior behavior — secure in production.
Set COOKIE_SECURE=false only for a deployment reachable over plain HTTP
(no TLS yet), like this dev instance.

Verified locally: docker compose up with COOKIE_SECURE=false persists
and round-trips the cookie (signup -> /auth/me 200); without it, the
cookie still gets Secure as before. Full pnpm --filter api test / test:bdd
suites still pass (66 + 25 scenarios).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 23:49:05 +02:00

54 lines
2.7 KiB
TypeScript

import "dotenv/config";
import { z } from "zod";
/**
* 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")),
});
/** Parsed, validated environment — import this instead of reading `process.env` directly anywhere else. */
export const env = envSchema.parse(process.env);