Spec 015 — Env-selected API base URL (drop the same-origin proxy)
Status: implemented Branch: 015-env-api-url
Status is set by the human, never by the agent. It moves draft → approved → implemented.
Problem
Spec 014 shipped the deploy on the assumption (its research V1/V2) that a Render Static Site rewrite could proxy /api/* to the separate API service, stripping the /api prefix via a :splat capture — a same-origin design that let the web client keep an origin-relative /api base and avoid CORS. On the real deploy that assumption failed: Render's static-site rewrite forwards the destination path literally (no capture substitution), so /api/* reached the API as the literal path /:splat and every data call 404'd ({"message":"Cannot GET /:splat"}, confirmed by curl this session). The proxy cannot be made to strip or forward the request path on a Render static site, so the same-origin approach is a dead end here — and it dragged in a chain of fragilities besides (a hardcoded rewrite destination that Render name-suffixing broke — 014's F2 — and no auto-redeploy on a render.yaml-only change).
This spec replaces the same-origin proxy with the standard, portable alternative: the web app calls the API's absolute base URL directly, chosen at build time from an APP_ENV flag (the base URLs are not secrets, so they are hardcoded, not injected); the API serves every endpoint under /api/* and enables CORS for the web origin; and the /api/* rewrite route is removed from the blueprint. No proxy, no :splat, no hardcoded cross-service destination — the two services are decoupled and the app loads its data. It supersedes 014's same-origin-proxy decision (deploy topology, region, checksPass, and the two services all otherwise stand).
User stories
Ordered by priority. Each story must be independently testable and shippable — if only P1 ships, there is still something usable.
P1 — The app fetches its data via a direct, env-selected API URL (locally)
As the developer, I want apps/web to call the API at an absolute base URL selected from APP_ENV (dev → the local API, prod → the deployed API), with the API serving every route under /api/* and allowing the web origin via CORS, so that the front end loads real data with no proxy — the same mechanism in dev and prod, so "works locally" predicts "works deployed".
Independent test: with VITE_APP_ENV=dev, run the API and the web dev server; the browser loads legendaries etc. by calling http://localhost:3000/api/legendaries cross-origin, the API responds 200 with Access-Control-Allow-Origin for http://localhost:5173, and the console shows no CORS or proxy error. No Vite proxy is involved.
Acceptance scenarios
- Given the API with a global
/apiprefix and CORS, when the web app requests{base}/api/legendariesfrom an allowed origin, then the API returns the data with anAccess-Control-Allow-Originheader naming that origin. - Given
VITE_APP_ENV=dev, when the web app builds/runs, then its API base resolves to the local API URL and requests go there directly (no/apisame-origin proxy). - Given
VITE_APP_ENVis unset or not one ofdev/prod, when the web app builds/starts, then it fails loudly rather than silently defaulting. - Given the committed
openapi.json, whenverify:contractruns, thenopenapi.jsonis unchanged (paths stay origin-relative, no drift) and the regenerated client — reflecting the new absolute base — matches.
P2 — The deployed app loads its data, with no proxy and no F2 fragility
As the developer, I want the deployed web app (built with VITE_APP_ENV=prod) to load data from the deployed API over CORS, with the blueprint carrying no /api/* rewrite route, so that the exact failure of 014 is gone and the deploy no longer depends on a hardcoded cross-service destination.
Independent test: on the real Render deploy, open the prod web URL; the SPA loads and populates from https://gw2priory-api-l21x-lbn5.onrender.com/api/...; curl -H 'Origin: https://gw2priory-l21x-7m5b.onrender.com' against the API's /api/legendaries returns the data with a matching Access-Control-Allow-Origin.
Acceptance scenarios
- Given the blueprint, when it is inspected, then the web service has no
/api/*rewrite route, keeps the/*→/index.htmlSPA fallback, and setsVITE_APP_ENV=prod. - Given a merge to
mainwhose CI is green, when Render deploys (stillchecksPass), then the prod web app loads its data from the API with no proxy and no CORS error.
Requirements
- R1 —
apps/apisets a global route prefix/api(app.setGlobalPrefix('api')), so every endpoint is served under/api/*(/api/health,/api/legendaries,/api/recipe-graph/:id). Swagger moves under the prefix (/api/docs); the platform health check path becomes/api/health. - R2 —
apps/apienables CORS with a hardcoded allow-list —http://localhost:5173(Vite dev) andhttps://gw2priory-l21x-7m5b.onrender.com(prod web) — via the Nest/FastifyenableCorsbuilt-in. No new dependency. - R3 — The
/apiprefix is served at runtime —app.setGlobalPrefix('api')inmain.tsonly (not the separategenerate-openapi.tsapp), soopenapi.jsonstays origin-relative and unchanged (byte-identical), perstack.md's "the document's paths stay origin-relative; baseUrl is client-only". The/apimoves into the client's base URL (now absolute, R4), not the document.apps/web's generated client is regenerated (it changes because its base changed) andverify:contractstays green (noopenapi.jsondrift). (Refined from the original "paths become/api/*" per research V3.) - R4 —
apps/webresolves its API base URL fromimport.meta.env.VITE_APP_ENV, a'dev' | 'prod'union, through a small typed const map to hardcoded base URLs:dev→http://localhost:3000,prod→https://gw2priory-api-l21x-lbn5.onrender.com. An unset/invalid value fails loudly (throws at startup), never silently defaulting. Requests target{base}/api/*. Because orval'sbaseUrlis a static codegen string that cannot readimport.meta.env, the env-selected base is injected at runtime (Vite build-time-inlined) via an orval custommutatoror the hand-writtensrc/apifacade — not a static orvalbaseUrl. (Injection mechanism per research V3.) - R5 — The Vite dev proxy is removed (
vite.config.ts): the web dev server calls the local API directly and CORS (R2) covers it; no/apirewrite remains in dev. - R6 —
render.yamlremoves the/api/*rewrite route from the web service and keeps only the/*→/index.htmlSPA fallback; addsVITE_APP_ENV=prodto the web service; keeps the two services, the region, andautoDeployTrigger: checksPassunchanged. The hardcoded cross-servicedestination(014's F2) is deleted along with the route. - R7 —
tests/deploy/render-blueprint.test.tsis updated to the new shape: it asserts the web service has the SPA-fallback route and no/api/*proxy route, and carriesVITE_APP_ENV=prod; the prior:splat/destination assertions are removed. The API service's docker/health/trigger assertions stand (health path updated to/api/health). - R8 —
docs/architecture/deploy.mdis updated: the same-origin proxy section is replaced with the direct-URL + CORS model, recording why (Render static-site rewrites forward the destination path literally — no:splat), so the next reader does not re-attempt the proxy. This supersedes 014's V1/V2. - R9 — No artifact under any
docs/superpowers/path; prior suites (including 014's) still pass, updated only where this spec changes their subject.
Mark anything unresolved inline rather than assuming an answer. Two markers, split by who can answer:
[NEEDS CLARIFICATION: specific question]— only the human can answer.[NEEDS VERIFICATION: specific question]— only reality can answer, inresearch.mdwith evidence.
No [NEEDS CLARIFICATION] remains (the design was agreed in brainstorming), and both discovery questions have verdicts in research.md — neither is still an unresolved verification marker:
- Does
enableCorswith an origin allow-list return the matchingAccess-Control-Allow-Originand handle preflight, with no new dependency? — Confirmed (research V2): a framework built-in on the Fastify adapter; an arrayoriginreflects a listed request origin and answers preflight automatically. The only residual is observational — the exact response headers are asserted on a running instance (local curl, SC2), not a blocking unknown. - On the real Render deploy, does the prod web app load its data cross-origin (matching ACAO, no CORS error) with no proxy route? — Observational (research V1/V2): the mechanism is proven; the one genuinely on-deploy behaviour is finalised on the first deploy and recorded in
deploy.md(SC6). That is why SC6 is an observational criterion, not an open claim.
Success criteria
Measurable, outcome-focused. The stack (Render, Nest, Vite) is named because the change is about that wiring, per Assumptions.
- SC1 — With
VITE_APP_ENV=dev, the web app loads real data by calling the local API at{dev-base}/api/*directly, no proxy, no CORS error. (local end-to-end smoke, recorded) - SC2 — The API serves every endpoint under
/api/*, and a request carrying an allowedOriginreceives a matchingAccess-Control-Allow-Origin. (curl with an Origin header — automatable against a running instance; recorded) - SC3 —
openapi.jsonis unchanged (paths stay origin-relative) andverify:contractpasses (no drift between the committed contract and a fresh regeneration); the regenerated client reflects the new absolute base. (CI step / test) - SC4 — An unset or invalid
VITE_APP_ENVmakes the web app fail loudly (throws), not silently pick a default. (automated test over the resolver) - SC5 — The committed
render.yamlhas no/api/*rewrite route, keeps the/*→/index.htmlfallback, setsVITE_APP_ENV=prod, and keepschecksPassand the two services — asserted by the structural test. - SC6 — On the real Render deploy, the prod web app at its URL loads its data from the API with no proxy and no CORS error. (observational — verified on the first deploy, recorded with a date)
- SC7 — The count of files under any
docs/superpowers/path stays zero, and prior specs' suites still pass (updated only where 015 changes their subject). (existing invariants) - SC8 — Every acceptance scenario and success criterion maps to a named test or a dated manual record, with no gap; the automated portion passes.
Out of scope
- A custom domain / prettier URLs. The hardcoded
onrender.comURLs (with their suffixes) are used as-is; attaching a custom domain for a stable, pretty URL is a later change (and would just update the hardcoded prod constants + the CORS allow-list). - Pipeline-driven deploys.
autoDeployTrigger: checksPassstays; moving the deploy trigger into a GitHub Actions job (deploy hook / Render API,autoDeploy: off) is deferred — the only build-time input this spec needs isVITE_APP_ENV, which the blueprint supplies. - Changing hosts. This is Render again, fixed — not a migration to Fly/DO/Vercel/etc.
- Runtime API-URL configuration. The base URL is chosen at build time from
APP_ENV; no runtime config endpoint orwindow.__ENV__shim. - Secret management for the API URL. The API URLs are not secrets; hardcoding them is deliberate, not a gap.
Assumptions
- The API's and web's real Render URLs are known and stable —
gw2priory-api-l21x-lbn5.onrender.comandgw2priory-l21x-7m5b.onrender.com(Render fixes a service's URL once created); they are hardcoded as the prod constants and CORS origin. If a service is deleted/recreated (new URL), those constants and the allow-list are updated. - The API and static site themselves work on Render — verified this session: the API returns
200at/healthand real data at/legendarieson its own URL, and the static site serves the SPA and its/*→/index.htmlfallback (200). Only the proxy route was broken; removing it is the fix. - CORS is built into the framework —
@nestjs/platform-fastify'sapp.enableCors(...)needs no new dependency (confirmed inresearch.md). - The contract pipeline stands —
openapi.json→ Orval (react-query + zod) → thesrc/apifacade (stack.md); this spec changes only the client's base URL (client-only, now absolute), not the document paths (which stay origin-relative) and not the pipeline shape, andverify:contractcontinues to guard drift. - No
[NEEDS CLARIFICATION]remains — CORS (hardcoded allow-list), the/apiprefix, theAPP_ENVflag, and keepingchecksPasswere all decided in brainstorming.
Traceability
Each acceptance scenario and success criterion maps to a named test or a dated manual-verification record — the latter only for the inherently observational criteria (a live cross-origin fetch on the real deploy). SC8 asserts no empty cell. Filled in during implementation.
| Criterion | Test / verification |
|---|---|
| P1 #1 | apps/api/src/main.bootstrap.test.ts — /api/health 200 + ACAO for the allowed origin; local curl in docs/architecture/deploy.md |
| P1 #2 | apps/web/src/api/__tests__/apiFetch.test.ts (requests target {dev-base}/api/*) + apps/web/src/api/__tests__/apiBase.test.ts (dev → local); local smoke in deploy.md |
| P1 #3 | apps/web/src/api/__tests__/apiBase.test.ts — invalid VITE_APP_ENV throws (SC4) |
| P1 #4 | verify:contract (CI) + apps/api/src/generate-openapi.test.ts (paths stay origin-relative) (SC3) |
| P2 #1 | tests/deploy/render-blueprint.test.ts — no /api route, SPA fallback, VITE_APP_ENV=prod (SC5) |
| P2 #2 | manual — deploy.md verification log (SC6) |
| SC1 | manual — deploy.md local-smoke record |
| SC2 | apps/api/src/main.bootstrap.test.ts (ACAO asserted in-process) + manual curl with Origin, recorded in deploy.md |
| SC3 | verify:contract (CI) + apps/api/src/generate-openapi.test.ts |
| SC4 | apps/web/src/api/__tests__/apiBase.test.ts |
| SC5 | tests/deploy/render-blueprint.test.ts |
| SC6 | manual — deploy.md verification log |
| SC7 | tests/workflow/repo-invariants.test.ts — docs/superpowers count + prior suites |
| SC8 | tests/deploy/render-blueprint.test.ts — this table complete + named tests exist |