batchCooking/apps/api/src/config/env.ts
Nicolas bbd9afe8aa
Some checks failed
CI / lint (push) Failing after 23s
CI / build (push) Successful in 3m11s
CI / e2e (push) Failing after 8m16s
CI / intent-service-test (push) Successful in 12m49s
CI / test (push) Successful in 21m34s
refactor(admin): fusionne apps/admin-web dans apps/web sous /admin/*
L'admin etait une 2e app front Vite independante (apps/admin-web, port 5174,
Dockerfile nginx, service compose dedie, job CI propre) non demandee. Toute
l'UI passe dans apps/web sous le prefixe /admin ; seul le frontend est
fusionne, l'authentification admin reste entierement separee.

Front (apps/web/src) :
- pages -> pages/admin/{login,dashboard,monitoring,corrections,catalog}/,
  layout -> layouts/AdminLayout.tsx, contexte + garde -> features/admin/.
- client API -> api/admin-client.ts : classe AdminApiError (evite la
  collision avec ApiError), lit VITE_API_URL (plus de VITE_ADMIN_API_URL).
- routes /admin/* dans App.tsx, enveloppees d'AdminAuthProvider +
  RequireAdmin -> le probe GET /admin/auth/me ne tourne que sous /admin.
- reutilise l'i18n, lib/zod-errors, services/error-message.service et le
  theme SCSS de apps/web ; bloc i18n admin.* fusionne dans la locale fr
  (les cles errors etaient deja toutes presentes).
- corrige une race dans CatalogPage (reponse d'un onglet precedent qui
  ecrasait l'onglet courant, exposee par le double-mount StrictMode) via
  un ref requestSeq.

Auth admin inchangee : table AdminUser, cookie admin_session,
ADMIN_JWT_SECRET, script create-admin.ts.

Infra :
- docker-compose : service admin-web + ADMIN_WEB_PORT supprimes (l'app
  `app` sert deja le front construit).
- ADMIN_CORS_ORIGIN retire (meme origine) : env.ts, app.ts, .env.example.
- job CI "Run admin-web E2E tests" supprime ; les specs admin-* tournent
  dans le job web (apps/web/cypress/e2e/admin-*.{cy.ts,feature}).
- apps/api/.env.example : ajout ADMIN_JWT_SECRET / ADMIN_INITIAL_*.
- recharts ajoute a apps/web ; pnpm-lock regenere.
- specs/backend-architecture.md : section admin mise a jour.

Verifie : biome + tsc -b (web/api) + pnpm -r build verts ; Cypress web
102/103 (l'unique echec est le flake pre-existant recipe-form.feature
"Preloads ..." de clipping headless, sans rapport) ; 16/16 specs admin ;
45/45 composants.

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

115 lines
6.4 KiB
TypeScript

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"),
/**
* Secret used to sign/verify the **admin** session JWT (`lib/admin-jwt.ts`)
* — entirely separate from `JWT_SECRET`, so an end-user session token can
* never be replayed against `/admin/*` and vice versa. `.optional()`
* (unlike `JWT_SECRET`): an instance that doesn't run the admin app at
* all never needs it — but `requireAdmin` (`middlewares/require-admin.ts`)
* rejects every request outright when it's unset, so the surface fails
* closed, same posture as `INTERNAL_WORKER_SECRET`.
*/
ADMIN_JWT_SECRET: z
.string()
.min(32, "ADMIN_JWT_SECRET must be at least 32 characters")
.optional(),
/** Name of the httpOnly cookie carrying the admin session JWT — must differ from `AUTH_COOKIE_NAME` so the two sessions coexist in one browser. */
ADMIN_COOKIE_NAME: z.string().default("admin_session"),
/** Optional seed values read by `src/scripts/create-admin.ts` when its `--email`/`--password`/`--name` flags are omitted — never used by the running server. */
ADMIN_INITIAL_EMAIL: z.string().optional(),
ADMIN_INITIAL_PASSWORD: z.string().optional(),
ADMIN_INITIAL_NAME: z.string().optional(),
});
/** Parsed, validated environment — import this instead of reading `process.env` directly anywhere else. */
export const env = envSchema.parse(process.env);