Plan 015 — Env-selected API base URL (drop the same-origin proxy)
Status: approved Written in plan mode from spec.md and research.md. Approved by the human before any code is written. Status is set by the human, never by the agent: proposed → approved.
Produced alone — tasks.md stays untouched until this plan is approved in turn.
Goal
The web app loads its data by calling the API's absolute base URL, chosen at build time from VITE_APP_ENV, with the API serving every route under /api/* and allowing the web origin via CORS — the same mechanism in dev and prod, so "works locally" predicts "works deployed". The same-origin proxy that 014 shipped (and that 404'd on the real deploy) is gone: no Vite dev proxy, no Render /api/* rewrite route, no hardcoded cross-service destination.
Approach
Four coordinated changes, each independently testable:
API serves under
/apiand allows the web origin (R1, R2).main.tsgainsapp.setGlobalPrefix('api')so every route moves to/api/*(/api/health,/api/legendaries,/api/recipe-graph/:id), andapp.enableCors({ origin: [<dev>, <prod>] })with a hardcoded allow-list — a framework built-in, no new dependency (research V2). The Swagger UI moves from/api-docsto/api/docs. Crucially, the prefix is added only to the runtime app inmain.ts—generate-openapi.ts(the separate doc-emitting app) is left untouched, soopenapi.jsonstays origin-relative and byte-identical (research V3).Contract keeps its shape; the client's base becomes absolute + env-selected (R3, R4).
openapi.jsondoes not change. Orval keeps emitting/api/*-prefixed URLs (itsbaseUrl: '/api'stays), but the requests are now routed through an orval custommutatorthat prepends the env-selected origin (http://localhost:3000in dev, the deployed API URL in prod) before delegating tofetch. The origin is resolved by a small typedresolveApiOrigin(env)over a const map that throws on an unset/invalidVITE_APP_ENV(SC4) — no silent default. The generated client is regenerated (it changes because it now imports the mutator) and committed;verify:contractstays green becauseopenapi.jsonis unchanged and a fresh regen is byte-identical.Dev proxy removed (R5).
vite.config.ts'sserver.proxyblock is deleted — the dev server calls the local API directly athttp://localhost:3000/api/*and CORS (R2) covers the cross-origin call.Blueprint decoupled (R6, R7, R8).
render.yamldrops the web service's/api/*rewrite route (and with it 014's F2 hardcoded destination), keeps only/*→/index.html, addsVITE_APP_ENV=prod, and moves the API'shealthCheckPathto/api/health. The blueprint test anddeploy.mdare updated to the new shape and record why the proxy was abandoned.
Architecture
apps/api (runtime, main.ts) apps/web (browser)
├─ setGlobalPrefix('api') ── serves /api/* resolveApiOrigin(VITE_APP_ENV)
├─ enableCors([dev, prod]) ── ACAO for origin → API_ORIGIN ('http://localhost:3000' | prod URL)
└─ Swagger at /api/docs │
apiFetch (orval mutator)
apps/api (codegen, generate-openapi.ts) prepends API_ORIGIN to '/api/…'
└─ NO prefix ── openapi.json stays origin-relative │
│ (byte-identical; verify:contract green) generated client → facade (src/api) → hooks
└────────────── orval reads openapi.json ────────────┘
fetch → {API_ORIGIN}/api/legendaries (cross-origin, CORS-allowed)
render.yaml
web: routes = [ /*→/index.html ] (NO /api/* route) env: VITE_APP_ENV=prod
api: healthCheckPath: /api/healthBoundaries: the origin is chosen once (apiBase.ts) and injected once (apiFetch.ts); nothing else in apps/web knows a URL. The /api path segment stays owned by the contract/codegen (orval baseUrl), not hand-written into the mutator. The API's runtime prefix (main.ts) is deliberately isolated from the doc emitter (generate-openapi.ts) so the contract document is unaffected.
Tech stack
apps/api— NestJS 11 on@nestjs/platform-fastify(already deps).app.setGlobalPrefixandapp.enableCorsare coreINestApplicationmethods — no new dependency (research V2 confirms CORS is a framework built-in on the Fastify adapter).apps/web— React + Vite 8 (Rolldown). Orval v8.23.0 (already a dep)override.mutatorfor base injection;import.meta.env.VITE_APP_ENV(Vite build-time inlining). No new dependency.- Blueprint — Render
render.yaml; Vitest for the structural test. No new dependency.
No new dependencies are introduced by this plan — the sole justification required by the Global Constraint below, met by "none added".
Global Constraints
Copied verbatim from the architecture docs. Every task inherits these; do not summarise or reword them — a test asserts they appear here unchanged.
From docs/architecture/typescript.md:
- No
any. Not in app code, not in tests. Useunknownplus narrowing, or model the type properly. If a third-party type forces it, isolate it behind one typed adapter and comment why. - No non-null assertions (
!) to silence the compiler. - No
@ts-expect-errorwithout a comment explaining what is expected and when it can be removed. - Validate everything crossing a boundary (GW2 API responses, HTTP input) at runtime, not just at the type level.
- Prefer pure functions for domain logic. The optimizer must be testable without a network or a database.
- Match the style of surrounding code. No new dependency without justification in the spec or plan.
moduleResolution: "node"andbaseUrlare removed in TS 7. Use"nodenext"(or"bundler"for Vite/bundler-resolved code) pluspaths: { "*": ["./*"] }in place ofbaseUrl.- TypeScript's default
libincludes DOM. A base tsconfig shared by non-DOM and DOM profiles must pin a non-DOM floor explicitly ("lib": ["ES2023"]), ordocument/windowresolve everywhere, silently defeating profile isolation. A profile that needs DOM adds"DOM","DOM.Iterable"on top of that floor. experimentalDecoratorsandemitDecoratorMetadataare supported for typecheck only —tsc --noEmitaccepts decorator syntax and resolves metadata types, but--noEmitnever emitsdesign:paramtypesat runtime regardless of these options. A decorator-consuming runtime (e.g. NestJS DI) needs a separate emitting compiler for that metadata; seestack.md's api build model.
From docs/architecture/stack.md:
- Monorepo, pnpm workspaces.
apps/api— NestJS (TypeScript).apps/web— React (TypeScript).packages/*— shared code (domain types, the curated Mystic Forge dataset) when sharing is real, not speculative.- Postgres for persistence. In-memory cache for the MVP — no Redis until the caching story earns it.
- Vitest everywhere, both apps.
- Deploy: managed PaaS (Fly.io / Railway). CI: GitHub Actions — lint + typecheck + test + build.
- Static data (items, station recipes) is immutable: cache hard.
- Prices are volatile: short TTL, recomputed live.
- GW2 API rate limit: per-IP token bucket, 300 burst, refill 5/sec,
429on overflow. Batch up to 200 ids per?ids=call. - All GW2 API access goes through the client that budgets this. Never call the GW2 API directly from a service.
- API keys are user secrets: encrypted at rest, never logged, never returned to the client.
From CLAUDE.md: typecheck clean, tests pass, every acceptance scenario and success criterion covered by a test whose name traces to it, no unexplained escape hatches, the human reviews the diff.
File Structure
Exact paths, and what each file is responsible for. A path here is a commitment; a task that touches a file not listed is a signal the plan missed something.
| Path | Change | Responsibility |
|---|---|---|
apps/api/src/config/cors.ts | new | The typed CORS allow-list constant: ['http://localhost:5173', 'https://gw2priory-l21x-7m5b.onrender.com']. Single-sourced so main.ts and its test share it (R2). |
apps/api/src/main.ts | modified | Add app.setGlobalPrefix('api') and app.enableCors({ origin: CORS_ALLOWLIST }); move Swagger setup path api-docs → api/docs. generate-openapi.ts is not touched (R1, R3). |
apps/api/src/main.bootstrap.test.ts | modified | Mirror the new bootstrap (prefix + CORS); assert Swagger now at /api/docs, GET /api/health → 200, and an allowed Origin gets a matching Access-Control-Allow-Origin (+ preflight OPTIONS). (SC2, P1 #1) |
apps/web/src/api/apiBase.ts | new | resolveApiOrigin(env: string | undefined): string over a typed { dev, prod } const map; throws on unset/invalid. Exports API_ORIGIN = resolveApiOrigin(import.meta.env.VITE_APP_ENV). (R4, SC4) |
apps/web/src/api/apiFetch.ts | new | The orval mutator: prepends API_ORIGIN to the request URL, delegates to fetch, returns orval's fetch-client shape. (R4) |
apps/web/src/vite-env.d.ts | new | Augment ImportMetaEnv with readonly VITE_APP_ENV?: 'dev' | 'prod' so the env read is typed, not any. |
apps/web/orval.config.ts | modified | Add override.mutator: { path: './src/api/apiFetch.ts', name: 'apiFetch' } to the api entry; keep baseUrl: '/api'; correct the now-stale dev-proxy comment. (R3) |
apps/web/src/api/generated/** | modified (regen) | Regenerated so calls route through apiFetch. Committed output; verify:contract re-diffs to clean. (R3) |
apps/web/vite.config.ts | modified | Delete the server.proxy block; update the comment. (R5) |
apps/web/src/api/__tests__/apiBase.test.ts | new | `resolveApiOrigin('dev' |
apps/web/src/api/__tests__/apiFetch.test.ts | new | The mutator prepends API_ORIGIN to /api/* and calls fetch with the absolute URL (P1 #2). |
render.yaml | modified | Web: remove the /api/* rewrite route (and F2 destination), keep /*→/index.html, add envVars: [{ key: VITE_APP_ENV, value: prod }]. API: healthCheckPath → /api/health. Update comments. (R6) |
tests/deploy/render-blueprint.test.ts | modified | Replace the /api/*-proxy assertions: web has no /api/* route, routes are only the SPA fallback, and VITE_APP_ENV=prod is set; API healthCheckPath is /api/health. Add a 015-traceability-completeness block. (R7, SC5, SC8) |
docs/architecture/deploy.md | modified | Replace the proxy sections (deploy shape route #1, F1, F2) with the direct-URL + CORS model, recording why (Render forwards the destination path literally — no :splat); update the verification log for 015's observational criteria. Supersedes 014's V1/V2. (R8) |
No file under any docs/superpowers/ path is created (R9, SC7).
Data & contracts
openapi.json— unchanged. Paths stay origin-relative (/health,/legendaries,/recipe-graph/{itemId}). The existingapps/api/src/generate-openapi.test.ts(which asserts those exact origin-relative keys) is the guard that the prefix did not leak into the document.CORS_ALLOWLIST(apps/api/src/config/cors.ts):readonly string[]—http://localhost:5173(Vite dev),https://gw2priory-l21x-7m5b.onrender.com(prod web).resolveApiOrigin(apps/web/src/api/apiBase.ts):(env: string | undefined) => string; mapdev → http://localhost:3000,prod → https://gw2priory-api-l21x-lbn5.onrender.com; any other input throwsError("Invalid VITE_APP_ENV: …").- Request URL shape: generated URL builder yields
/api/<path>(orvalbaseUrl);apiFetchprependsAPI_ORIGIN, so the wire request is{API_ORIGIN}/api/<path>, which the prefixed API serves.
Test strategy
| Criterion | How it becomes a test |
|---|---|
| P1 #1, SC2 | main.bootstrap.test.ts — boot the configured app; GET /api/health → 200; GET with an allowed Origin returns matching ACAO; preflight OPTIONS answered. Automates what the spec framed as curl. |
| P1 #2 | apiFetch.test.ts — mutator prepends API_ORIGIN and fetch is called with the absolute URL. |
| P1 #3, SC4 | apiBase.test.ts — unset and invalid VITE_APP_ENV throw; dev/prod resolve to the two origins. |
| P1 #4, SC3 | verify:contract (CI) stays green — openapi.json byte-identical, client regenerated; generate-openapi.test.ts proves paths stay origin-relative. |
| P2 #1, SC5 | render-blueprint.test.ts — no /api/* route, SPA fallback present, VITE_APP_ENV=prod, API health /api/health. |
| SC8 | render-blueprint.test.ts — a block reading specs/015-env-api-url/spec.md's traceability table asserts no empty cell and every cited .ts path exists. |
| SC7 | existing tests/workflow/repo-invariants.test.ts — docs/superpowers/ count stays zero; prior suites pass. |
| SC1, SC6, P2 #2 | Not unit-testable — inherently observational (a live cross-origin fetch on the real Render deploy). Stand-in: a dated manual record in deploy.md's verification log (in-browser + curl -H 'Origin: …'), exactly as the spec's traceability allows for these rows. |
Alternatives considered
- Bake
/apiintoopenapi.json(original spec R3 wording). Rejected per research V3: it's off the project's stated pattern (stack.md: "document paths stay origin-relative; baseUrl is client-only") and depends onSwaggerModule.createDocumentreflecting a global prefix — version-sensitive behaviour the runtime-prefix approach avoids entirely. - A static orval
baseUrlset to the absolute URL. Impossible:baseUrlis a codegen-time string and cannot readimport.meta.env; the value must be chosen at Vite build time. Hence the mutator. - Inject the base in the hand-written
src/apifacade (customqueryFn). Rejected: it would have to wrap every hook by hand and re-thread the generated URL builders; the orval mutator injects at one point and covers every endpoint uniformly (research V3 names both; the mutator is the lower-surface choice). - Runtime config (
window.__ENV__/ a config endpoint). Out of scope by the spec — the base is a build-time choice, and the URLs are not secrets.
Risks
- Orval's fetch-client mutator contract. The exact signature orval 8.23 expects (whether the mutator returns a
Responseor the parsed{data,status,headers}) is pinned by regenerating with a trial mutator and reading the emitted client — codegen inspection during the task, driven by the failingapiFetch.test.ts. Not a spec question; no spike survives into the tree. Fallback if the fetch-mutator shape proves awkward: the facade-queryFnalternative above. - CORS headers only fully observable on a running instance (research V2 caveat). Mitigated by the automated
app.injectassertion inmain.bootstrap.test.ts(doc-confirmed now, header-asserted in-process), with the live prod-origin curl as the dateddeploy.mdresidual (SC2/SC6). - A regenerated
generated/**that isn't committed would failverify:contract. Mitigated by making regeneration + commit an explicit step in the orval task, and CI re-diffing. - Stale Render URL if a service is recreated. The prod origin and CORS entry are hardcoded (spec Assumptions); if Render reassigns a URL, both constants update. Documented in
deploy.md.
Open questions
- None blocking. The two spec
[NEEDS VERIFICATION]residuals (live cross-origin fetch; observed CORS headers on the instance) are finalised on the first deploy and recorded indeploy.md(SC6/SC2) — this is why they are observational, not a gap the plan must close before implementation.