Skip to content

Research 015 — Env-selected API base URL (drop the same-origin proxy) ​

Status: complete

Step 1.5 output, written between the spec draft and the approval gate. The spec's two [NEEDS VERIFICATION] markers (V1 the live cross-origin fetch, V2 enableCors headers) have verdicts here, plus a third finding (V3) that discovery surfaced about the contract shape and that proposes a revision to spec R3/R4/SC3. No [NEEDS CLARIFICATION] remained.

Verified against, on 2026-08-14: this repo at branch 015-env-api-url (spec commit pushed); the running Render deploy of specs 012–014 (the API and web services already live); and the empirical curl evidence captured earlier this session. Framework claims cite NestJS 11 behaviour. The one genuinely on-deploy behaviour (a live cross-origin fetch) is a residual finalised on the first deploy of this spec — SC6 is observational for exactly that reason.

V1 — Does a Render Static Site rewrite forward the request path, or a literal destination path? ​

Question. Spec 014's whole same-origin design assumed a Render static-site rewrite /api/* → …/:splat would strip /api and forward the tail. This spec exists because that failed; V1 records the proof so the proxy is never re-attempted.

Verdict. Confirmed refuted (empirical). Render forwards the literal destination path; the :splat capture is not substituted. The same-origin proxy is impossible on a Render static site.

Evidence.

  • Live curl this session against the deployed site: GET https://gw2priory-l21x-7m5b.onrender.com/api/legendaries returned HTTP 404 with body {"message":"Cannot GET /:splat","error":"Not Found","statusCode":404} and header x-render-origin-server: Render. That body is the NestJS 404 for the literal path /:splat — proof the request reached the API but with the destination string forwarded verbatim, no capture substitution.
  • Cross-checks the same session: the API directly returns 200 with real data at https://gw2priory-api-l21x-lbn5.onrender.com/legendaries; the site's SPA fallback serves index.html (200) for non-/api routes. So every piece worked except the rewrite's path handling.
  • This refutes spec 014's V1/V2 ("rewrite is a same-origin proxy that strips /api via :splat"). V1-proxy was confirmed only on paper there; the R11 first-deploy curl — the make-or-break check — disproved the :splat half. Independent research this session corroborated that Render (unlike Netlify :splat / Vercel :path*) does not substitute captures in static-site rewrites.

Caveat. None needed — this is the motivating fact, directly measured. It is why 015 drops the proxy rather than patching it.

V2 — Does enableCors with an origin allow-list return the matching Access-Control-Allow-Origin (and handle preflight), with no new dependency? ​

Question. R2 rests on app.enableCors({ origin: [...] }) on the Fastify adapter returning a matching ACAO for the listed origins, handling preflight OPTIONS, and needing no added package.

Verdict. Confirmed (framework built-in). enableCors is a first-class INestApplication method that works on the Fastify adapter without an extra dependency; an array origin reflects the request's Origin when it is in the list and answers preflight automatically.

Evidence.

  • enableCors(options) is a core Nest application method (NestJS docs, "Security → CORS"); with origin as a string array, the request Origin is echoed into Access-Control-Allow-Origin only when it matches an entry, and preflight OPTIONS is handled by the same middleware. No app code beyond the one call.
  • No new dependency: @nestjs/platform-fastify (already a dep, apps/api/package.json) provides CORS support through Nest's built-in handling; nothing is added to satisfy R2/R9.
  • Our requests are same-method GETs; simple GETs need no preflight, and any that do (custom headers) are covered by the automatic OPTIONS handling above.

Caveat. The exact response headers are only finally observable on a running instance. Residual: a curl -H 'Origin: http://localhost:5173' <api>/api/legendaries (local) and the equivalent prod-origin curl on deploy, asserting the matching ACAO — recorded as SC2. Doc-confirmed now, header-observed on the instance.

V3 — What contract shape does the /api prefix take — and does openapi.json change? (proposes revising R3/R4/SC3) ​

Question. Spec R3 says "openapi.json paths become /api/*". Discovery asked whether that is the right shape, given the project's established contract pattern and how the document is generated.

Verdict. Refined — R3 as written is not the best shape. The pattern-consistent, lower-risk approach keeps openapi.json origin-relative and puts /api in the client's base URL; the API serves /api/* via a runtime global prefix. openapi.json does not change. This revises R3, R4, and SC3 (see Refuted/revised claims).

Evidence.

  • stack.md states the deliberate pattern verbatim: "The OpenAPI document's paths stay origin-relative ('/health') — this baseUrl is client-only." Today openapi.json paths are /health, /recipe-graph/{itemId}, /legendaries (measured: Object.keys(openapi.json.paths)), and orval.config.ts sets baseUrl: '/api'. The prefix lives in the client base, not the doc — by design.
  • The doc is generated by a separate app in apps/api/src/generate-openapi.ts (buildOpenApiDocument() → SwaggerModule.createDocument), distinct from main.ts's runtime app. Adding app.setGlobalPrefix('api') to main.ts only makes the runtime serve /api/* (so "every endpoint is /api/..." holds) while leaving the document origin-relative — no dependence on whether createDocument reflects a global prefix (a version-sensitive behaviour we thereby avoid entirely).
  • The consequence for the client: openapi.json is unchanged; the /api moves from the old origin-relative baseUrl: '/api' into the new absolute, env-selected base {API_BASE}/api. So the client calls {API_BASE}/api/legendaries, which the prefixed API serves. verify:contract stays green (the regenerated openapi.json is byte-identical; only the generated client changes, because its base changed — that regeneration is committed).
  • Base-injection mechanism (for the plan): orval's baseUrl is a static codegen string and cannot read Vite's import.meta.env.VITE_APP_ENV (orval runs at codegen, before vite build). So the env-selected base is supplied at runtime (build-time-inlined by Vite) via an orval custom mutator or the hand-written src/api facade, not a static baseUrl. This is an orval-supported pattern; the exact wiring is a plan decision.

Caveat. The health check path still becomes /api/health (the runtime prefix applies to it), and Swagger moves under /api/docs — those parts of R1 stand. Only the document paths stay origin-relative.

Refuted / revised claims ​

  • Spec 014's V1/V2 (same-origin :splat proxy) — refuted by V1 above (measured 404 on the literal /:splat). This spec supersedes that decision; deploy.md is updated to record why (R8).
  • Spec 015's R3 ("openapi.json paths become /api/*") + the R4/SC3 wording that follows from it — revised, not scrapped (V3). Proposed change, for the human before I edit spec.md:
    • R3 → the /api prefix is served at runtime (setGlobalPrefix('api') in main.ts); the openapi.json document stays origin-relative (unchanged), per stack.md. The client's base URL carries /api.
    • R4 → the env-selected base is {API_BASE}/api, injected at runtime via an orval mutator / the src/api facade (not a static orval baseUrl).
    • SC3 → "openapi.json is unchanged and verify:contract passes; the regenerated client reflects the new base" (rather than "paths are all /api/*"). This is a refinement toward the existing project pattern, so it does not send the spec back to step 1 — but it is the human's call to accept before the spec is edited and approved.

Graduation ​

Candidates for docs/architecture/ at step 6 (updating docs/architecture/deploy.md, superseding 014):

  • The direct-URL + CORS model replaces the same-origin proxy — why (Render static-site rewrites forward the destination path literally, no :splat — V1); the env-selected absolute base URL (VITE_APP_ENV → hardcoded, non-secret URLs); CORS with a hardcoded allow-list (V2); no /api rewrite route in the blueprint (so 014's F2 hardcoded-destination fragility is gone).
  • The contract keeps its origin-relative shape — openapi.json stays origin-relative; /api lives in the client base (now absolute), consistent with stack.md (V3). Forward pointer for any future API-consumer.