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/legendariesreturned HTTP 404 with body{"message":"Cannot GET /:splat","error":"Not Found","statusCode":404}and headerx-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
200with real data athttps://gw2priory-api-l21x-lbn5.onrender.com/legendaries; the site's SPA fallback servesindex.html(200) for non-/apiroutes. 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
/apivia:splat"). V1-proxy was confirmed only on paper there; the R11 first-deploy curl — the make-or-break check — disproved the:splathalf. 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"); withoriginas a string array, the requestOriginis echoed intoAccess-Control-Allow-Originonly when it matches an entry, and preflightOPTIONSis 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
OPTIONShandling 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.mdstates the deliberate pattern verbatim: "The OpenAPI document's paths stay origin-relative ('/health') — this baseUrl is client-only." Todayopenapi.jsonpaths are/health,/recipe-graph/{itemId},/legendaries(measured:Object.keys(openapi.json.paths)), andorval.config.tssetsbaseUrl: '/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 frommain.ts's runtime app. Addingapp.setGlobalPrefix('api')tomain.tsonly makes the runtime serve/api/*(so "every endpoint is/api/..." holds) while leaving the document origin-relative — no dependence on whethercreateDocumentreflects a global prefix (a version-sensitive behaviour we thereby avoid entirely). - The consequence for the client:
openapi.jsonis unchanged; the/apimoves from the old origin-relativebaseUrl: '/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:contractstays green (the regeneratedopenapi.jsonis byte-identical; only the generated client changes, because its base changed — that regeneration is committed). - Base-injection mechanism (for the plan):
orval'sbaseUrlis a static codegen string and cannot read Vite'simport.meta.env.VITE_APP_ENV(orval runs at codegen, beforevite build). So the env-selected base is supplied at runtime (build-time-inlined by Vite) via an orval custommutatoror the hand-writtensrc/apifacade, not a staticbaseUrl. 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
:splatproxy) — refuted by V1 above (measured 404 on the literal/:splat). This spec supersedes that decision;deploy.mdis updated to record why (R8). - Spec 015's R3 ("
openapi.jsonpaths become/api/*") + the R4/SC3 wording that follows from it — revised, not scrapped (V3). Proposed change, for the human before I editspec.md:- R3 → the
/apiprefix is served at runtime (setGlobalPrefix('api')inmain.ts); theopenapi.jsondocument stays origin-relative (unchanged), perstack.md. The client's base URL carries/api. - R4 → the env-selected base is
{API_BASE}/api, injected at runtime via an orval mutator / thesrc/apifacade (not a static orvalbaseUrl). - SC3 → "
openapi.jsonis unchanged andverify:contractpasses; 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.
- R3 → the
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/apirewrite route in the blueprint (so 014's F2 hardcoded-destination fragility is gone). - The contract keeps its origin-relative shape —
openapi.jsonstays origin-relative;/apilives in the client base (now absolute), consistent withstack.md(V3). Forward pointer for any future API-consumer.