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