Skip to content

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

  1. Given the API with a global /api prefix and CORS, when the web app requests {base}/api/legendaries from an allowed origin, then the API returns the data with an Access-Control-Allow-Origin header naming that origin.
  2. 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 /api same-origin proxy).
  3. Given VITE_APP_ENV is unset or not one of dev/prod, when the web app builds/starts, then it fails loudly rather than silently defaulting.
  4. Given the committed openapi.json, when verify:contract runs, then openapi.json is 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

  1. Given the blueprint, when it is inspected, then the web service has no /api/* rewrite route, keeps the /*→/index.html SPA fallback, and sets VITE_APP_ENV=prod.
  2. Given a merge to main whose CI is green, when Render deploys (still checksPass), then the prod web app loads its data from the API with no proxy and no CORS error.

Requirements ​

  • R1 — apps/api sets 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/api enables CORS with a hardcoded allow-list — http://localhost:5173 (Vite dev) and https://gw2priory-l21x-7m5b.onrender.com (prod web) — via the Nest/Fastify enableCors built-in. No new dependency.
  • R3 — The /api prefix is served at runtime — app.setGlobalPrefix('api') in main.ts only (not the separate generate-openapi.ts app), so openapi.json stays origin-relative and unchanged (byte-identical), per stack.md's "the document's paths stay origin-relative; baseUrl is client-only". The /api moves 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) and verify:contract stays green (no openapi.json drift). (Refined from the original "paths become /api/*" per research V3.)
  • R4 — apps/web resolves its API base URL from import.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's baseUrl is a static codegen string that cannot read import.meta.env, the env-selected base is injected at runtime (Vite build-time-inlined) via an orval custom mutator or the hand-written src/api facade — not a static orval baseUrl. (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 /api rewrite remains in dev.
  • R6 — render.yaml removes the /api/* rewrite route from the web service and keeps only the /*→/index.html SPA fallback; adds VITE_APP_ENV=prod to the web service; keeps the two services, the region, and autoDeployTrigger: checksPass unchanged. The hardcoded cross-service destination (014's F2) is deleted along with the route.
  • R7 — tests/deploy/render-blueprint.test.ts is updated to the new shape: it asserts the web service has the SPA-fallback route and no /api/* proxy route, and carries VITE_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.md is 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, in research.md with 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 enableCors with an origin allow-list return the matching Access-Control-Allow-Origin and handle preflight, with no new dependency? — Confirmed (research V2): a framework built-in on the Fastify adapter; an array origin reflects 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 allowed Origin receives a matching Access-Control-Allow-Origin. (curl with an Origin header — automatable against a running instance; recorded)
  • SC3 — openapi.json is unchanged (paths stay origin-relative) and verify:contract passes (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_ENV makes the web app fail loudly (throws), not silently pick a default. (automated test over the resolver)
  • SC5 — The committed render.yaml has no /api/* rewrite route, keeps the /*→/index.html fallback, sets VITE_APP_ENV=prod, and keeps checksPass and 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.com URLs (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: checksPass stays; 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 is VITE_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 or window.__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.com and gw2priory-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 200 at /health and real data at /legendaries on its own URL, and the static site serves the SPA and its /*→/index.html fallback (200). Only the proxy route was broken; removing it is the fix.
  • CORS is built into the framework — @nestjs/platform-fastify's app.enableCors(...) needs no new dependency (confirmed in research.md).
  • The contract pipeline stands — openapi.json → Orval (react-query + zod) → the src/api facade (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, and verify:contract continues to guard drift.
  • No [NEEDS CLARIFICATION] remains — CORS (hardcoded allow-list), the /api prefix, the APP_ENV flag, and keeping checksPass were 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.

CriterionTest / verification
P1 #1apps/api/src/main.bootstrap.test.ts — /api/health 200 + ACAO for the allowed origin; local curl in docs/architecture/deploy.md
P1 #2apps/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 #3apps/web/src/api/__tests__/apiBase.test.ts — invalid VITE_APP_ENV throws (SC4)
P1 #4verify:contract (CI) + apps/api/src/generate-openapi.test.ts (paths stay origin-relative) (SC3)
P2 #1tests/deploy/render-blueprint.test.ts — no /api route, SPA fallback, VITE_APP_ENV=prod (SC5)
P2 #2manual — deploy.md verification log (SC6)
SC1manual — deploy.md local-smoke record
SC2apps/api/src/main.bootstrap.test.ts (ACAO asserted in-process) + manual curl with Origin, recorded in deploy.md
SC3verify:contract (CI) + apps/api/src/generate-openapi.test.ts
SC4apps/web/src/api/__tests__/apiBase.test.ts
SC5tests/deploy/render-blueprint.test.ts
SC6manual — deploy.md verification log
SC7tests/workflow/repo-invariants.test.ts — docs/superpowers count + prior suites
SC8tests/deploy/render-blueprint.test.ts — this table complete + named tests exist