From 5e478188a25f73b9b9ea110f0194c7b03f8163a6 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Mon, 7 Sep 2026 08:21:13 +0200 Subject: [PATCH] fix(api): autorise l'origine WebView Capacitor dans CORS_ORIGIN MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'app Android n'arrivait a se logger/inscrire nulle part (204 + "erreur inattendue" cote client) : CORS_ORIGIN n'etait jamais defini en prod (docker-compose.yml ne le passe pas a `app`), donc il retombait sur son default `http://localhost:5173` (le serveur Vite de dev), qui ne matche evidemment jamais l'origine reelle de l'appli packagee (`https://localhost`, cf. capacitor.config.ts). Le site web n'est lui jamais touche : il est servi depuis la meme origine que l'API en prod, donc jamais de preflight CORS du tout sur /auth/*. Le symptome trompeur vient du package `cors` : un preflight OPTIONS recoit toujours 204, meme pour une origine rejetee — la difference invisible sans inspecter les headers est l'absence de Access-Control-Allow-Origin, qui fait ensuite bloquer le vrai POST cote client sans jamais atteindre le serveur. Vu depuis les logs serveur, la route a bien l'air "appelee" (le preflight matche le meme chemin) alors que /auth/login lui-meme n'est jamais reellement recu. CORS_ORIGIN devient une liste separee par des virgules (le type `ExpressServerCoreOptions.corsOrigin` acceptait deja `string | string[]`) et son default inclut desormais `https://localhost` — l'origine de l'appli packagee est fixe et non secrete (contrairement au domaine du site en prod, qui n'a de toute facon pas besoin d'etre dans cette liste puisqu'il est same-origin), donc la coder en dur dans le default couvre tout deploiement sans configuration supplementaire. Verifie en local : script ad hoc qui demarre le vrai `createServer()` et envoie un preflight OPTIONS /auth/login avec differentes origines — `https://localhost` et `http://localhost:5173` recoivent maintenant Access-Control-Allow-Origin, une origine arbitraire non listee continue de ne pas le recevoir (pas de bypass en wildcard). Egalement `pnpm --filter api exec tsc --noEmit` et `pnpm exec biome check` sur les fichiers touches. Co-Authored-By: Claude Sonnet 5 --- apps/api/.env.example | 2 +- apps/api/src/app.ts | 9 +++++---- apps/api/src/config/env.ts | 38 ++++++++++++++++++++++++++++++++++++-- 3 files changed, 42 insertions(+), 7 deletions(-) diff --git a/apps/api/.env.example b/apps/api/.env.example index ba85c71..9af71cb 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -11,7 +11,7 @@ JWT_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars # Optional — defaults shown (see src/config/env.ts) # JWT_EXPIRES_IN=7d # AUTH_COOKIE_NAME=session -# CORS_ORIGIN=http://localhost:5173 +# CORS_ORIGIN=http://localhost:5173,https://localhost # INTENT_SERVICE_BASE_URL=http://localhost:8000 # Required — services/tech-step-intent-service must be running locally (see diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 95726a9..5c93294 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -35,10 +35,11 @@ export function createServer(): ExpressServer { // pipeline (its "finish" listener still fires for a request that never // makes it past CORS/body-parsing, not just ones that reach a route). server.addMiddleware(requestLogger); - // One allowed origin: the app (`CORS_ORIGIN`). The admin surface - // (`/admin/*`) is served by this same API and consumed by `apps/web`'s - // own `/admin/*` routes — same origin as the rest of the app, so no - // extra CORS entry. + // Allowed origin(s) come from `CORS_ORIGIN` (see its own doc comment in + // config/env.ts for why the packaged Android app's origin must always be + // one of them). The admin surface (`/admin/*`) is served by this same API + // and consumed by `apps/web`'s own `/admin/*` routes — same origin as the + // rest of the app, so no extra CORS entry. server.setupCore({ corsOrigin: env.CORS_ORIGIN }); server.addRoute("get", "/health", (_req: Request, res: Response) => { diff --git a/apps/api/src/config/env.ts b/apps/api/src/config/env.ts index af65dab..3a717df 100644 --- a/apps/api/src/config/env.ts +++ b/apps/api/src/config/env.ts @@ -34,8 +34,42 @@ const envSchema = z.object({ 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"), + /** + * Origin(s) allowed by CORS, comma-separated — parsed below into a + * `string[]` for `ExpressServerCoreOptions.corsOrigin` + * (`app.ts`/`express-server.ts`, which passes it straight to the `cors` + * package) and reused as-is for the Socket.IO handshake's own CORS config + * (`server.ts`, which needs its own since the WS upgrade never runs + * through `setupCore`'s middleware). + * + * Must always include `https://localhost`: the packaged Android app's + * WebView is served from exactly that fixed origin (Capacitor's default + * for `androidScheme`/hostname — see `apps/web/capacitor.config.ts`'s own + * doc comment), and every request it makes to this API — `/auth/login` + * and `/auth/signup` included — is genuinely cross-origin, unlike + * apps/web's *browser* build, which is served from the very same origin + * as this API in production (see docker-compose.yml's `app` service) and + * so never needs an entry here at all. Missing this origin doesn't fail + * loudly: the `cors` package still answers a preflight `OPTIONS` with 204 + * for an origin it's about to reject (it only *omits* + * `Access-Control-Allow-Origin`), so the request the app's WebView logs + * looks identical to a normal one right up until the browser silently + * drops the real `POST` — surfacing client-side as a generic "unexpected + * error" with no 401/403 anywhere to point at the actual cause. Default + * covers both the Vite dev server and the packaged app out of the box; a + * real deployment's own web origin (e.g. a production domain) never needs + * adding here for the reason above, but the env var still allows + * extending/overriding this list (comma-separated) if that ever changes. + */ + CORS_ORIGIN: z + .string() + .default("http://localhost:5173,https://localhost") + .transform((value) => + value + .split(",") + .map((origin) => origin.trim()) + .filter((origin) => origin.length > 0), + ), /** * Absolute path to the built frontend (`apps/web/dist`), to serve * alongside the API. Optional, no default — only set inside the