Skip to content

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:

  1. API serves under /api and allows the web origin (R1, R2). main.ts gains app.setGlobalPrefix('api') so every route moves to /api/* (/api/health, /api/legendaries, /api/recipe-graph/:id), and app.enableCors({ origin: [<dev>, <prod>] }) with a hardcoded allow-list — a framework built-in, no new dependency (research V2). The Swagger UI moves from /api-docs to /api/docs. Crucially, the prefix is added only to the runtime app in main.ts — generate-openapi.ts (the separate doc-emitting app) is left untouched, so openapi.json stays origin-relative and byte-identical (research V3).

  2. Contract keeps its shape; the client's base becomes absolute + env-selected (R3, R4).openapi.json does not change. Orval keeps emitting /api/*-prefixed URLs (its baseUrl: '/api' stays), but the requests are now routed through an orval custom mutator that prepends the env-selected origin (http://localhost:3000 in dev, the deployed API URL in prod) before delegating to fetch. The origin is resolved by a small typed resolveApiOrigin(env) over a const map that throws on an unset/invalid VITE_APP_ENV (SC4) — no silent default. The generated client is regenerated (it changes because it now imports the mutator) and committed; verify:contract stays green because openapi.json is unchanged and a fresh regen is byte-identical.

  3. Dev proxy removed (R5). vite.config.ts's server.proxy block is deleted — the dev server calls the local API directly at http://localhost:3000/api/* and CORS (R2) covers the cross-origin call.

  4. Blueprint decoupled (R6, R7, R8). render.yaml drops the web service's /api/* rewrite route (and with it 014's F2 hardcoded destination), keeps only /*→/index.html, adds VITE_APP_ENV=prod, and moves the API's healthCheckPath to /api/health. The blueprint test and deploy.md are 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/health

Boundaries: 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.setGlobalPrefix and app.enableCors are core INestApplication methods — 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.mutator for 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. Use unknown plus 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-error without 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" and baseUrl are removed in TS 7. Use "nodenext" (or "bundler" for Vite/bundler-resolved code) plus paths: { "*": ["./*"] } in place of baseUrl.
  • TypeScript's default lib includes DOM. A base tsconfig shared by non-DOM and DOM profiles must pin a non-DOM floor explicitly ("lib": ["ES2023"]), or document/window resolve everywhere, silently defeating profile isolation. A profile that needs DOM adds "DOM", "DOM.Iterable" on top of that floor.
  • experimentalDecorators and emitDecoratorMetadata are supported for typecheck only — tsc --noEmit accepts decorator syntax and resolves metadata types, but --noEmit never emits design:paramtypes at runtime regardless of these options. A decorator-consuming runtime (e.g. NestJS DI) needs a separate emitting compiler for that metadata; see stack.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, 429 on 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.

PathChangeResponsibility
apps/api/src/config/cors.tsnewThe 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.tsmodifiedAdd 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.tsmodifiedMirror 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.tsnewresolveApiOrigin(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.tsnewThe 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.tsnewAugment ImportMetaEnv with readonly VITE_APP_ENV?: 'dev' | 'prod' so the env read is typed, not any.
apps/web/orval.config.tsmodifiedAdd 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.tsmodifiedDelete the server.proxy block; update the comment. (R5)
apps/web/src/api/__tests__/apiBase.test.tsnew`resolveApiOrigin('dev'
apps/web/src/api/__tests__/apiFetch.test.tsnewThe mutator prepends API_ORIGIN to /api/* and calls fetch with the absolute URL (P1 #2).
render.yamlmodifiedWeb: 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.tsmodifiedReplace 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.mdmodifiedReplace 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 existing apps/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; map dev → http://localhost:3000, prod → https://gw2priory-api-l21x-lbn5.onrender.com; any other input throws Error("Invalid VITE_APP_ENV: …").
  • Request URL shape: generated URL builder yields /api/<path> (orval baseUrl); apiFetch prepends API_ORIGIN, so the wire request is {API_ORIGIN}/api/<path>, which the prefixed API serves.

Test strategy ​

CriterionHow it becomes a test
P1 #1, SC2main.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 #2apiFetch.test.ts — mutator prepends API_ORIGIN and fetch is called with the absolute URL.
P1 #3, SC4apiBase.test.ts — unset and invalid VITE_APP_ENV throw; dev/prod resolve to the two origins.
P1 #4, SC3verify:contract (CI) stays green — openapi.json byte-identical, client regenerated; generate-openapi.test.ts proves paths stay origin-relative.
P2 #1, SC5render-blueprint.test.ts — no /api/* route, SPA fallback present, VITE_APP_ENV=prod, API health /api/health.
SC8render-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.
SC7existing tests/workflow/repo-invariants.test.ts — docs/superpowers/ count stays zero; prior suites pass.
SC1, SC6, P2 #2Not 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 /api into openapi.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 on SwaggerModule.createDocument reflecting a global prefix — version-sensitive behaviour the runtime-prefix approach avoids entirely.
  • A static orval baseUrl set to the absolute URL. Impossible: baseUrl is a codegen-time string and cannot read import.meta.env; the value must be chosen at Vite build time. Hence the mutator.
  • Inject the base in the hand-written src/api facade (custom queryFn). 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 Response or 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 failing apiFetch.test.ts. Not a spec question; no spike survives into the tree. Fallback if the fetch-mutator shape proves awkward: the facade-queryFn alternative above.
  • CORS headers only fully observable on a running instance (research V2 caveat). Mitigated by the automated app.inject assertion in main.bootstrap.test.ts (doc-confirmed now, header-asserted in-process), with the live prod-origin curl as the dated deploy.md residual (SC2/SC6).
  • A regenerated generated/** that isn't committed would fail verify: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 in deploy.md (SC6/SC2) — this is why they are observational, not a gap the plan must close before implementation.