# CORS Policy

This document describes how the receipt-scanner app decides which browser
origins may call its API, and how to configure additional ones.

## Policy in one sentence

**Only origins on an explicit allowlist may call the API cross-origin — the
app never responds with `Access-Control-Allow-Origin: *`.**

A wildcard is not an option here for two reasons:

1. The app authenticates with cookies (guest sessions, the `app.<domain>` /
   `admin.<domain>` subdomains). Browsers **refuse**
   `Access-Control-Allow-Origin: *` for
   credentialed requests, so a wildcard would simply break the feature it
   claims to enable.
2. A wildcard would let *any* website drive a user's browser into the API with
   their cookies (classic CSRF/CORS-abuse surface). Locking the list down to
   known origins is the point of this change.

## How it works

All enforcement lives in the Edge middleware (`src/middleware.ts` →
`src/lib/http/cors.ts`) so it runs before any route handler, on every request
the matcher covers (including `/api/*`; static assets and `demo/` are excluded
by the existing matcher). `next.config.ts` is unchanged.

| Request | Origin header | Result |
| --- | --- | --- |
| Preflight (`OPTIONS` + `Access-Control-Request-Method`) | allowed | `204` with `Access-Control-Allow-Origin` (exact echo), `Vary: Origin`, `Access-Control-Allow-Credentials: true`, `Access-Control-Allow-Methods: GET,POST,PUT,PATCH,DELETE,OPTIONS`, bounded echo of `Access-Control-Allow-Headers`, `Access-Control-Max-Age: 600` |
| Preflight | disallowed / absent | `403` JSON `{ "error": "cors_origin_not_allowed" }` |
| Any request | disallowed | `403` JSON `{ "error": "cors_origin_not_allowed" }` |
| Any request | none (same-origin fetch, curl, server-to-server) | allowed, request continues untouched |
| Any request | allowed | allowed, request continues untouched |

### Matching rules

- The allowlist is `CORS_ORIGINS` (comma-separated) **plus**
  `NEXT_PUBLIC_APP_URL`, which is always allowed (it is the app's own origin).
- Comparison is exact on **host + port**; trailing slashes are stripped,
  hostnames are lower-cased, and the `http://`/`https://` scheme prefix is
  ignored for the comparison (so `https://App.example.com/` and
  `https://app.example.com` are the same entry). The **full origin from the
  request** is echoed back in the header — never a wildcard, never a prefix,
  never a substring match.
- The literal origin `"null"` (sandboxed iframes, `file://` pages) is always
  rejected.
- No `Origin` header = not a browser cross-origin request = allowed.

### Credentials

Because the app uses cookies, every allowed CORS response advertises
`Access-Control-Allow-Credentials: true`. This is safe precisely because the
allow-origin value is always an exact echo of an allowlisted origin, never `*`.
Browser CORS will only accept the response when both conditions hold.

### Non-preflight responses

The middleware enforces and answers preflights; actual (non-preflight)
responses pass through untouched. Code that deliberately serves the API to
cross-origin callers should attach CORS headers from
`corsHeadersFor(request.headers.get("origin"))` (see `src/lib/http/cors.ts`).
Same-origin callers (the default: pages and `/api/*` share one origin) never
need any of this.

## Configuration

1. In `.env` (or the container environment), list every additional origin,
   comma-separated, full scheme://host[:port] form:

   ```env
   NEXT_PUBLIC_APP_URL=https://app.example.com
   CORS_ORIGINS=https://admin.example.com,https://partner.example.com
   ```

   `NEXT_PUBLIC_APP_URL` must not be repeated in `CORS_ORIGINS`.
2. `docker-compose.yml` already forwards `CORS_ORIGINS` into the app container
   (`- CORS_ORIGINS=${CORS_ORIGINS:-}`). If you deploy without Docker
   Compose, pass it as a runtime env var to the standalone server the same way
   you pass `ADMIN_EMAILS`.
3. `app.<domain>` / `admin.<domain>` requests are routed back into this app by
   the middleware, so their `/api/*` calls are **same-origin** and need no
   `CORS_ORIGINS` entry. Only add an origin when a page on one host genuinely
   calls the API of another host (e.g. `https://admin.example.com`).
4. Leave `CORS_ORIGINS` empty for **same-origin-only** access — the secure
   default: any cross-origin browser request is refused.

> Production note: `NEXT_PUBLIC_APP_URL` must be an `https://` URL (the app
> sends HSTS), so `CORS_ORIGINS` entries should be `https://` too.

## Verification

`scripts/verify_cors.ts` (run with `npx tsx scripts/verify_cors.ts`) exercises
the pure helpers against simulated requests: preflight 204/403, disallowed GET
403, no-origin pass-through, allowlist normalization, and asserts that no
header value ever contains `*`.

## Files

- `src/lib/http/cors.ts` — the policy (Edge-runtime compatible, pure + testable)
- `src/middleware.ts` — enforcement hook
- `.env.example` — documented `CORS_ORIGINS` variable
- `scripts/verify_cors.ts` — test harness
