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:
So CORS_ORIGINS in the prod .env is set correctly for drb.cusano.net.
Most likely causes (confirm with the console line):
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.
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>.
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.
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`.
Fixed on branch fix/110-c2-core-cors (local commit d60fef6, pending push/PR — no push auth from this worktree).
Root cause refinement:CORSMiddlewarewas 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.
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.
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.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
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/searchCORS is correctly configured in prod. Verified from an external host:So
CORS_ORIGINSin the prod.envis set correctly fordrb.cusano.net.Most likely causes (confirm with the console line):
fetch()es the recording from astorage.googleapis.comsigned URL (for a waveform / blob), GCS sends noAccess-Control-Allow-Originunless the recordings bucket has a CORS policy → shows as a CORS error. Fix: set a bucket CORS config allowing GET fromhttps://drb.cusano.net, or route audio through themedia.pyendpoint.media.pyserves audio withAccept-Ranges: none(already a DEFERRED item); check it also emits CORS headers if the playerfetch()es it rather than using a plain<audio src>./calls/searchneeding 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.Root cause confirmed (live audit 2026-09-07, fetch hook on the Archive page).
Failing request:
Content-Type: application/jsonon a bodyless GET makes it a non-simple CORS request -> browser sends anOPTIONSpreflight.OPTIONS https://api.drb.cusano.net/calls/search-> 405, zeroAccess-Control-Allow-*headers.GET https://api.drb.cusano.net/health-> 200 but also noAccess-Control-Allow-Origin./calls/searchis the sole surface that calls the REST API from the browser.Fix (real): add
CORSMiddlewaretoc2-core(allow_origins=["https://drb.cusano.net"],allow_methodsincl. GET/POST,allow_headers=["authorization","content-type"]) and ensureOPTIONSis answered before the 405.Fix (band-aid): drop the
Content-Typeheader from the/calls/searchGET indrb-frontend/lib— but it still preflights onAuthorizationalone, so the server fix is required regardless.Not a CORS misconfiguration — CORS was never set up. See
Version 5C/UI_AUDIT_0907b.md.Fixed on branch
fix/110-c2-core-cors(local commitd60fef6, pending push/PR — no push auth from this worktree).Root cause refinement:
CORSMiddlewarewas already mounted indrb-c2-core/app/main.py, butsettings.cors_originsdefaulted to["*"]and the live VM'sdrb-c2-core/.envhas no workingCORS_ORIGINSfor the browser origin (either unset, or a staleapp.value from the incident described in theinfra/ansible/.../c2-core.env.j2comments). With no matching origin Starlette emits zeroAccess-Control-*headers, so the preflight looked like there was no CORS middleware at all.Fix:
config.py:cors_originsdefault["*"]→["https://drb.cusano.net"]so a deploy that never received the env var is still correct.main.pymiddleware 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: documentedCORS_ORIGINS.Origin list =
["https://drb.cusano.net"], only that. Verified againstinfra/:outputs.tfandansible/.../Caddyfile.j2both state the frontend is served on the bare domain — "only drb. and api. have public DNS records", noapp.vhost.ansible/.../c2-core.env.j2already templatesCORS_ORIGINS=["https://{{ domain }}"], confirming intent. There is noapp.drb.cusano.netfrontend to add.Tests: new
tests/test_cors.py(4 tests) drives the real app viaTestClient: preflightOPTIONS /calls/searchfromhttps://drb.cusano.net→ 200 withaccess-control-allow-originechoed and no-allow-credentials; a disallowed origin gets no allow-origin header;GET /healthfrom the app origin carries the header. Updatedtest_cors_policy.pyto 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"]indrb-c2-core/.envon the VM (ansible already templates this; a plaingit pulldeploy does not). If the live.envcurrently 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.Deployed in
bc3251e8(PR #120). Verified fixed in prod.OPTIONS https://api.drb.cusano.net/calls/searchwithOrigin: https://drb.cusano.netnow returns 200 withaccess-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:
CORSMiddlewarewas already mounted; the defect wassettings.cors_originsdefaulting 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.
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.