Mount every route under /api, matching what is deployed - #283
Conversation
`ENVIRONMENT` was never set anywhere in the repo, so `settings.environment` always fell back to its "development" default. `is_production` was therefore always False and `is_development` always True, in every environment — which on a real deploy means the refresh cookie ships without Secure and /docs and /redoc are publicly reachable. `is_testing` was always False, leaving the testing branch of the logging config unreachable. Two mechanisms had grown up around one fact: APP_ENV picked the Settings subclass, ENVIRONMENT drove the is_* properties, and the subclasses never set `environment`. Collapse them onto one `Environment` enum read from APP_ENV. Pydantic now rejects an unrecognized value, so a typo fails at startup instead of silently meaning development — the old `get_settings()` ended in a catch-all `else` that returned DevelopmentSettings for anything it didn't recognize. Delete DevelopmentSettings, ProductionSettings and TestingSettings: they existed only to set `debug` and `testing`, which nothing in the backend reads, and TestingSettings still carried a `mongodb_url` from the starter code. The logging config becomes a match with assert_never, so adding an environment without deciding how it logs is a mypy error rather than a silent fall-through. Verified by temporarily adding a STAGING member. models/__init__.py, migrations/env.py and seed_database.py were each parsing APP_ENV themselves; they now read settings.environment, so there is one parse. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The app, Alembic and the seed script each had their own copy of get_database_url, and they had already drifted: the seed script always used POSTGRES_DB_DEV, ignoring APP_ENV, so seeding under testing wrote to the development database. Replace all three with app/database_url.py. Read the credentials from Settings rather than os.getenv. Every one of them is already declared as a Settings field, so the os.getenv reads were a second config mechanism sitting alongside the first — the same shape as the APP_ENV/ENVIRONMENT split. It also matters for deployment: on Cloud Run the config arrives as a mounted secrets file, which os.getenv cannot see. FIREBASE_WEB_API_KEY was not a Settings field at all; firebase_rest_client interpolated os.getenv straight into the sign-in URL, so an unset key sent "?key=None" to Firebase. Promote it to a field alongside the others. Fail on missing credentials, naming all of them at once, instead of building postgresql+asyncpg://None:None@None:5432/None and failing at connect time. Build the URL with URL.create so the password is escaped. The f-string interpolation this replaces mangled any password containing URL syntax — an "@" ends the userinfo section early and connects to the wrong host. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every entry in allow_origins is trusted with credentialed requests, so two things in the list were worth removing. The default carried uw-blueprint-starter-code.firebaseapp.com and .web.app, inherited from the starter code. Those are Hosting sites for a different project; whoever controls it could make credentialed cross-origin requests against this API. The preview-deploy entry never worked. allow_origins compares the Origin header by exact string, so the appended pattern matched nothing — a dead entry that reads like a working one. Patterns belong in allow_origin_regex. A working replacement is deliberately left out: production already serves the API through the Hosting /api/** rewrite, so a preview channel may be same-origin and need no entry at all. That belongs with the /api prefix work. cors_origins now defaults to empty. A deployed origin belongs to a specific deployment and is configured there; the default cannot know it, and failing closed is the right way to be wrong. Development still adds its localhost entries in create_app(). The two tests asserting a 500 and a 422 keep their CORS headers were relying on that default to supply localhost. They pin middleware ordering, so they now configure the origin they need instead of inheriting whichever ones ship enabled. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Cloud Run service already serves the API under /api — /api/locations/ answers and /locations/ 404s against revision 00054. That came from feat/deployment-setup (#210), whose author is away indefinitely, so production and main currently disagree about the shape of the API and nobody can reproduce what is deployed from main. This makes main match. The prefix is what lets Firebase Hosting rewrite /api/** to Cloud Run and put the SPA and the API on one origin, which is what keeps the refresh cookie SameSite=strict. Hosting forwards the matched path verbatim rather than stripping it, so the routes carry the prefix themselves. axiosClient issues the session refresh directly rather than through the generated client — it must not re-enter the 401 interceptor — so nothing would have added the prefix for it and every refresh would have 404ed. That failure is quiet: AuthProvider fires a refresh on load and a logged-out visit already produces a failing one, so it would have looked normal. The existing tests matched with endsWith and could not tell a prefixed path from an unprefixed one; one now asserts the exact URL. The auth coverage guard strips API_PREFIX before comparing rather than carrying it in fifty table keys, since where the API is mounted is a deployment concern and not an auth one. That leaves the guard blind to the prefix disappearing, so test_api_prefix.py pins it separately. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ReviewReviewed the diff introduced by this PR (the "Mount every route under /api" commit; the rest of the shown diff is the stacked #280–#282 content per the PR description, so I've focused on this commit's own changes). SummaryThis is a clean, well-scoped fix. The core change ( Things I verified and did not find issues with
Minor/nit
Test coverageStrong — No blocking issues found. |
JIRA Ticket
N/A — closes the gap between
mainand what is actually deployed.Implementation Description
This is not a proposal, it is reconciliation. The deployed Cloud Run service (
backend-service, revision 00054) already serves every route under/api—/api/locations/returns 401 and/locations/returns 404 against it right now. That code came fromfeat/deployment-setup(#210), whose author is away indefinitely. So production andmaincurrently disagree about the shape of the API, and nobody can reproduce what is deployed frommain. This makesmainmatch.Why the prefix exists at all. Firebase Hosting rewrites
/api/**to the Cloud Run service, which puts the SPA and the API on one origin. That is what lets the refresh cookie staySameSite=strict; a separate API domain would forceSameSite=None, which is weaker and squarely in the path of third-party cookie restrictions. Hosting forwards the matched path verbatim rather than stripping the prefix, so the routes have to carry it themselves.What changed
app/routers/__init__.pymounts every router underAPI_PREFIX = "/api".frontend/openapi.jsonand the generated client regenerated — 50 paths, all prefixed.boot-smoke.yml's authed curls moved to/api. The/openapi.jsonreadiness probe stays at the root, where FastAPI serves it regardless of router prefixes.create_app()now usebase_url=".../api". Tests that mount their own bareFastAPI(test_auth_middleware) and the/_proberouters added directly to the app are untouched — they were never under the prefix.Two bugs this turned up
1.
POST /auth/refreshwould have 404'd.axiosClient.tsissues the session refresh directly rather than through the generated client, because it must not re-enter the 401 interceptor — so nothing would have added the prefix for it. This is the quiet kind:AuthProviderfires a refresh on every load and a logged-out visit already produces a failing one, so a 404 would have read as business as usual while every session restore broke.isRefreshRequestmatches withendsWith, so it still works.The existing tests would not have caught it — they match on
endsWith('/auth/refresh'), which cannot tell a prefixed path from an unprefixed one. There is now a test asserting the exact URL.2. The auth coverage guard would have stayed green if the prefix vanished.
test_every_exposed_route_is_classifiedcompares the OpenAPI schema againstROUTE_POLICIES. Rather than prefix ~50 table entries — where the API is mounted is a deployment concern, not an auth one — it stripsAPI_PREFIXbefore comparing. That is the right call for that test, but it means the guard is now blind to the prefix disappearing, which would 404 every request through the rewrite without failing anything.tests/test_api_prefix.pyis the test that would not stay green: every exposed path carries the prefix, a prefixed path reaches a route, the unprefixed path is gone,/openapi.jsonstays at the root, and a guard against the whole file passing vacuously on an empty schema.Note on /docs and /redoc
They stay at the root, so once Hosting rewrites only
/api/**they are reachable on the Cloud Run URL but not through the app domain. Deliberate, and pinned by a test — moving them under the prefix would publish them on the public site. It does not resolve the separate problem that they are currently public on the Cloud Run URL, because the service runsAPP_ENV=development; that needs the secret-mount work before it can be flipped.Steps to Test
docker exec -e APP_ENV=testing f4k_backend python -m pytest -qcd frontend && pnpm test && npx tsc -bWhat Should Reviewers Focus On?
axiosClient.ts's refresh,TestImageUpload.tsx) by grepping forfetch(and URL literals. A third would fail at runtime, not at build time, so a second pair of eyes on that search is worth more than on the mechanical parts.TestReachabilityassertions are "not 404" rather than a status code. Without the lifespan a handler that reaches for the database raises, so the status reflects the harness rather than the auth gate. What that gate returns istest_auth_integration.py's job.Checklist
Verification
ruff check/ruff format --checkclean,mypy .— no issues in 157 source files, full suite belowpnpm test65 passed,tsc -bclean,eslint src --max-warnings=0cleanAPI_PREFIX = ""fails three of six backend cases, and revertingREFRESH_PATHgivesexpected '/auth/refresh' to be '/api/auth/refresh'🤖 Generated with Claude Code