/archive (Archive tab) reported failing with a CORS error — needs the console line to root-cause #110

Closed
opened 2026-09-06 23:47:08 -04:00 by logan · 4 comments
Owner

Owner reported the Archive page failing with a CORS error (2026-09-07). Not root-caused — needs the exact browser console error (the blocked URL + the message text).

Ruled out: GET /calls/search CORS is correctly configured in prod. Verified from an external host:

OPTIONS https://api.drb.cusano.net/calls/search   Origin: https://drb.cusano.net
  -> 200   access-control-allow-origin: https://drb.cusano.net
           access-control-allow-credentials: true
           access-control-allow-headers: authorization
Origin: https://app.drb.cusano.net  -> 400, no ACAO   (correctly rejected)

So CORS_ORIGINS in the prod .env is set correctly for drb.cusano.net.

Most likely causes (confirm with the console line):

  1. Audio fetch to GCS. If the archive player fetch()es the recording from a storage.googleapis.com signed URL (for a waveform / blob), GCS sends no Access-Control-Allow-Origin unless the recordings bucket has a CORS policy → shows as a CORS error. Fix: set a bucket CORS config allowing GET from https://drb.cusano.net, or route audio through the media.py endpoint.
  2. media.py serves audio with Accept-Ranges: none (already a DEFERRED item); check it also emits CORS headers if the player fetch()es it rather than using a plain <audio src>.
  3. A 4xx/5xx whose missing header makes Chrome say CORS — e.g. /calls/search needing a Firestore composite index that isn't deployed (#13 / #51), or the endpoint not deployed at all (see punch-list #109 item 9).

Next: owner pastes the red console error from /archive; then pick the fix.

Owner reported the Archive page failing with a CORS error (2026-09-07). Not root-caused — needs the exact browser console error (the blocked URL + the message text). **Ruled out:** `GET /calls/search` CORS is correctly configured in prod. Verified from an external host: ``` OPTIONS https://api.drb.cusano.net/calls/search Origin: https://drb.cusano.net -> 200 access-control-allow-origin: https://drb.cusano.net access-control-allow-credentials: true access-control-allow-headers: authorization Origin: https://app.drb.cusano.net -> 400, no ACAO (correctly rejected) ``` So `CORS_ORIGINS` in the prod `.env` is set correctly for `drb.cusano.net`. **Most likely causes (confirm with the console line):** 1. **Audio fetch to GCS.** If the archive player `fetch()`es the recording from a `storage.googleapis.com` signed URL (for a waveform / blob), GCS sends no `Access-Control-Allow-Origin` unless the recordings bucket has a CORS policy → shows as a CORS error. Fix: set a bucket CORS config allowing GET from `https://drb.cusano.net`, or route audio through the `media.py` endpoint. 2. **`media.py`** serves audio with `Accept-Ranges: none` (already a DEFERRED item); check it also emits CORS headers if the player `fetch()`es it rather than using a plain `<audio src>`. 3. **A 4xx/5xx whose missing header makes Chrome *say* CORS** — e.g. `/calls/search` needing a Firestore composite index that isn't deployed (#13 / #51), or the endpoint not deployed at all (see punch-list #109 item 9). **Next:** owner pastes the red console error from `/archive`; then pick the fix.
Author
Owner

Root cause confirmed (live audit 2026-09-07, fetch hook on the Archive page).

Failing request:

GET https://api.drb.cusano.net/calls/search?limit=50&link=orphan&transcript=any
Headers: Content-Type: application/json  +  Authorization: Bearer <firebase id token>
=> TypeError: Failed to fetch
  • Content-Type: application/json on a bodyless GET makes it a non-simple CORS request -> browser sends an OPTIONS preflight.
  • OPTIONS https://api.drb.cusano.net/calls/search -> 405, zero Access-Control-Allow-* headers.
  • GET https://api.drb.cusano.net/health -> 200 but also no Access-Control-Allow-Origin.
  • Conclusion: the C2 REST API has no CORS configured for the browser origin at all. It only ever worked for the other pages because they read Firestore directly; /calls/search is the sole surface that calls the REST API from the browser.

Fix (real): add CORSMiddleware to c2-core (allow_origins=["https://drb.cusano.net"], allow_methods incl. GET/POST, allow_headers=["authorization","content-type"]) and ensure OPTIONS is answered before the 405.
Fix (band-aid): drop the Content-Type header from the /calls/search GET in drb-frontend/lib — but it still preflights on Authorization alone, so the server fix is required regardless.

Not a CORS misconfiguration — CORS was never set up. See Version 5C/UI_AUDIT_0907b.md.

**Root cause confirmed** (live audit 2026-09-07, fetch hook on the Archive page). Failing request: ``` GET https://api.drb.cusano.net/calls/search?limit=50&link=orphan&transcript=any Headers: Content-Type: application/json + Authorization: Bearer <firebase id token> => TypeError: Failed to fetch ``` - `Content-Type: application/json` on a **bodyless GET** makes it a non-simple CORS request -> browser sends an `OPTIONS` preflight. - `OPTIONS https://api.drb.cusano.net/calls/search` -> **405, zero `Access-Control-Allow-*` headers**. - `GET https://api.drb.cusano.net/health` -> 200 but also **no `Access-Control-Allow-Origin`**. - Conclusion: the C2 REST API has **no CORS configured for the browser origin at all**. It only ever worked for the other pages because they read Firestore directly; `/calls/search` is the sole surface that calls the REST API from the browser. **Fix (real):** add `CORSMiddleware` to `c2-core` (`allow_origins=["https://drb.cusano.net"]`, `allow_methods` incl. GET/POST, `allow_headers=["authorization","content-type"]`) and ensure `OPTIONS` is answered before the 405. **Fix (band-aid):** drop the `Content-Type` header from the `/calls/search` GET in `drb-frontend/lib` — but it still preflights on `Authorization` alone, so the server fix is required regardless. Not a CORS *misconfiguration* — CORS was never set up. See `Version 5C/UI_AUDIT_0907b.md`.
Author
Owner

Fixed on branch fix/110-c2-core-cors (local commit d60fef6, pending push/PR — no push auth from this worktree).

Root cause refinement: CORSMiddleware was already mounted in drb-c2-core/app/main.py, but settings.cors_origins defaulted to ["*"] and the live VM's drb-c2-core/.env has no working CORS_ORIGINS for the browser origin (either unset, or a stale app. value from the incident described in the infra/ansible/.../c2-core.env.j2 comments). With no matching origin Starlette emits zero Access-Control-* headers, so the preflight looked like there was no CORS middleware at all.

Fix:

  • config.py: cors_origins default ["*"] → ["https://drb.cusano.net"] so a deploy that never received the env var is still correct.
  • main.py middleware made explicit: allow_methods=["GET","POST","PUT","PATCH","DELETE","OPTIONS"], allow_headers=["authorization","content-type"], allow_credentials=False (auth is a Bearer header, not a cookie — keeping it False lets the explicit-origin allowlist work and makes the *+credentials footgun unrepresentable). A * entry still logs a loud ERROR.
  • .env.example: documented CORS_ORIGINS.

Origin list = ["https://drb.cusano.net"], only that. Verified against infra/: outputs.tf and ansible/.../Caddyfile.j2 both state the frontend is served on the bare domain — "only drb. and api. have public DNS records", no app. vhost. ansible/.../c2-core.env.j2 already templates CORS_ORIGINS=["https://{{ domain }}"], confirming intent. There is no app.drb.cusano.net frontend to add.

Tests: new tests/test_cors.py (4 tests) drives the real app via TestClient: preflight OPTIONS /calls/search from https://drb.cusano.net → 200 with access-control-allow-origin echoed and no -allow-credentials; a disallowed origin gets no allow-origin header; GET /health from the app origin carries the header. Updated test_cors_policy.py to the new "credentials never enabled" invariant. Sandboxed suite: 305 → 309 passing, green.

Server action needed: none strictly — the new default covers prod. Recommended: set CORS_ORIGINS=["https://drb.cusano.net"] in drb-c2-core/.env on the VM (ansible already templates this; a plain git pull deploy does not). If the live .env currently holds a wrong explicit value such as ["https://app.drb.cusano.net"], it must be corrected or removed — a stale explicit value overrides the new default and reproduces this bug.

Fixed on branch `fix/110-c2-core-cors` (local commit `d60fef6`, pending push/PR — no push auth from this worktree). **Root cause refinement:** `CORSMiddleware` *was* already mounted in `drb-c2-core/app/main.py`, but `settings.cors_origins` defaulted to `["*"]` and the live VM's `drb-c2-core/.env` has no working `CORS_ORIGINS` for the browser origin (either unset, or a stale `app.` value from the incident described in the `infra/ansible/.../c2-core.env.j2` comments). With no matching origin Starlette emits zero `Access-Control-*` headers, so the preflight looked like there was no CORS middleware at all. **Fix:** - `config.py`: `cors_origins` default `["*"]` → `["https://drb.cusano.net"]` so a deploy that never received the env var is still correct. - `main.py` middleware made explicit: `allow_methods=["GET","POST","PUT","PATCH","DELETE","OPTIONS"]`, `allow_headers=["authorization","content-type"]`, `allow_credentials=False` (auth is a Bearer header, not a cookie — keeping it False lets the explicit-origin allowlist work and makes the `*`+credentials footgun unrepresentable). A `*` entry still logs a loud ERROR. - `.env.example`: documented `CORS_ORIGINS`. **Origin list = `["https://drb.cusano.net"]`, only that.** Verified against `infra/`: `outputs.tf` and `ansible/.../Caddyfile.j2` both state the frontend is served on the bare domain — "only drb. and api. have public DNS records", no `app.` vhost. `ansible/.../c2-core.env.j2` already templates `CORS_ORIGINS=["https://{{ domain }}"]`, confirming intent. There is no `app.drb.cusano.net` frontend to add. **Tests:** new `tests/test_cors.py` (4 tests) drives the real app via `TestClient`: preflight `OPTIONS /calls/search` from `https://drb.cusano.net` → 200 with `access-control-allow-origin` echoed and no `-allow-credentials`; a disallowed origin gets no allow-origin header; `GET /health` from the app origin carries the header. Updated `test_cors_policy.py` to the new "credentials never enabled" invariant. Sandboxed suite: 305 → 309 passing, green. **Server action needed:** none strictly — the new default covers prod. Recommended: set `CORS_ORIGINS=["https://drb.cusano.net"]` in `drb-c2-core/.env` on the VM (ansible already templates this; a plain `git pull` deploy does not). If the live `.env` currently holds a wrong explicit value such as `["https://app.drb.cusano.net"]`, it must be corrected or removed — a stale explicit value overrides the new default and reproduces this bug.
logan closed this issue 2026-09-07 19:06:25 -04:00
Author
Owner

Deployed in bc3251e8 (PR #120). Verified fixed in prod. OPTIONS https://api.drb.cusano.net/calls/search with Origin: https://drb.cusano.net now returns 200 with access-control-allow-origin: https://drb.cusano.net, access-control-allow-methods: GET, POST, PUT, PATCH, DELETE, OPTIONS, access-control-allow-headers: ...authorization, content-type. The browser preflight passes.

Note the earlier root-cause note was close but not exact: CORSMiddleware was already mounted; the defect was settings.cors_origins defaulting to ["*"] with no matching live override, so Starlette emitted nothing. Default is now ["https://drb.cusano.net"].

The Archive page still fails, but it is no longer a CORS problem — see the new 503 finding below / #33. Closing the CORS half.

**Deployed in bc3251e8 (PR #120). Verified fixed in prod.** `OPTIONS https://api.drb.cusano.net/calls/search` with `Origin: https://drb.cusano.net` now returns 200 with `access-control-allow-origin: https://drb.cusano.net`, `access-control-allow-methods: GET, POST, PUT, PATCH, DELETE, OPTIONS`, `access-control-allow-headers: ...authorization, content-type`. The browser preflight passes. Note the earlier root-cause note was close but not exact: `CORSMiddleware` was already mounted; the defect was `settings.cors_origins` defaulting to `["*"]` with no matching live override, so Starlette emitted nothing. Default is now `["https://drb.cusano.net"]`. **The Archive page still fails**, but it is no longer a CORS problem — see the new 503 finding below / #33. Closing the CORS half.
Author
Owner

Confirmed fixed in prod (re-audit 2026-09-08 ~03:50Z). OPTIONS /calls/search returns 200 with correct Access-Control-Allow-* headers and the Archive page loads. Note: once CORS was fixed a second bug surfaced underneath — /calls/search was 503ing on a missing calls (org_id, started_at DESC) Firestore index (see #33). Both now resolved.

Confirmed fixed in prod (re-audit 2026-09-08 ~03:50Z). OPTIONS /calls/search returns 200 with correct Access-Control-Allow-* headers and the Archive page loads. Note: once CORS was fixed a second bug surfaced underneath — /calls/search was 503ing on a missing calls (org_id, started_at DESC) Firestore index (see #33). Both now resolved.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: logan/server-26#110