/api/backend/*, and a single server-side route handler forwards it to the real backend. This page documents that handler in full — src/app/api/backend/[...path]/route.ts — plus the one sibling route that intentionally bypasses it, src/app/api/workspaces/route.ts.
Why a proxy at all
Three constraints shape this design, all verifiable in the route file itself:- No CORS surface. Because the browser’s
fetch()calls always land on the same origin serving the page (/api/backend/...), the product API never needs a CORS policy, and there is no second origin an attacker’s page could point a credentialed request at. - No exposed API origin.
BACKEND_URL(src/lib/backendUrl.ts) is read fromprocess.env.CAUSELOOP_API_URLon the server only. It is never sent to the browser, never appears in a bundle, and never appears in a URL bar. A tenant admin inspecting network traffic sees only/api/backend/*paths against the frontend’s own domain. - Server-held session, httpOnly cookies. The two session cookies —
cl_admin_sessionandcl_tenant_session(src/lib/authCookies.ts) — arehttponly,samesite=lax(set by the backend; see Authentication). Client-side JavaScript cannot read them. The only thing the browser can read is a third, deliberately non-httpOnly cookie,cl_csrf, which exists solely so same-origin JS can echo it back as a header (the CSRF double-submit pattern — see below).
Because the proxy runs as a Next.js Route Handler on the server, it has access to the incoming request’s cookies without any extra plumbing —
fetch() on the server forwards headers exactly like any other outbound request. This is what makes “forward the browser’s own cookies to the same-origin proxy” a non-issue rather than a vulnerability: the proxy is not a third party relaying credentials across origins, it’s the same origin the browser already trusts.The catch-all route
src/app/api/backend/[...path]/route.ts exports GET, POST, PATCH, PUT, and DELETE, all of which call the same internal proxy() function. There is no OPTIONS handler — because every call is same-origin, the browser never issues a CORS preflight.
Path construction
The dynamic segment[...path] captures everything after /api/backend/. buildTargetUrl re-joins it with encodeURIComponent per segment and appends the original query string:
/api/backend/remediation/caps?target_type=cluster becomes ${BACKEND_URL}/remediation/caps?target_type=cluster — a 1:1 mapping onto the product API’s own route names (see API reference overview).
The session gate
Before any forwarding happens, the handler checks for a real session cookie:PUBLIC_PATHS is a small, explicit allowlist of operations that must work before any session exists:
get_current_employee / get_current_tenant_user) — a request that slips past a bug in this gate still hits a real 401/403 at the backend.
Header handling
The proxy rebuilds the outbound header set from scratch rather than passingrequest.headers through unmodified, for one specific reason: it must guarantee the backend never sees a client-supplied X-CauseLoop-* header.
After stripping, the proxy injects exactly one header of its own, computed server-side from the trusted ingress chain:
clientIpFromTrustedIngress reads the last (right-most) entry of the incoming x-forwarded-for chain — the hop closest to the frontend’s own edge, i.e. the one the frontend’s own hosting platform appended and can be trusted — and validates it’s a real IP (with node:net’s isIP, including the bracketed-IPv6-with-port case) before forwarding it. This is what lets the backend’s login rate limiter (services/api/auth/rate_limit.py) key on a real client IP without ever trusting a client-supplied header directly.
Body streaming
For any method other thanGET/HEAD, the request body is streamed straight through rather than buffered:
duplex: "half" is required by undici (Node’s fetch implementation) whenever the request body is a ReadableStream rather than a buffered value — without it, streaming a multipart file upload (see Admin portal wiring and Tenant console wiring for the upload call sites) would throw. redirect: "manual" stops fetch from silently following a redirect the backend might send — any redirect response is passed straight back to the browser as-is instead of being resolved server-side.
Response handling
Two things happen on the way back:- Backend-unreachable → stable 502. If
fetch()itself throws (connection refused, DNS failure, TLS error), the handler catches it and returnsNextResponse.json({ detail: "backend unavailable" }, { status: 502 })instead of letting Next.js turn an unhandled exception into an opaque 500. - Header filtering, with one deliberate exception.
HOP_BY_HOP_RESPONSE_HEADERS(connection,content-length,content-encoding,transfer-encoding) are dropped —fetch()transparently decompresses a gzip/br body while leaving the stalecontent-encodingheader in place, and forwarding it verbatim would make the browser try to re-decompress an already-decoded body. Every other header is appended, not set:
.append matters specifically for set-cookie: a login response carries two separate Set-Cookie headers (the session cookie and the CSRF cookie — see Authentication), and the Fetch API’s Headers.forEach iterates each one as its own entry rather than comma-joining them the way it does for every other header name. Using .set here would silently keep only the last cookie and drop the other — exactly the kind of bug that would show up as “CSRF token missing” errors on every mutating request right after login.
Error passthrough
The proxy does not reshape error bodies. A409, 422, or 401 from the backend passes through with its original status code and JSON body untouched — { "detail": ... } in every case, since every product router raises HTTPException(status_code=..., detail=...). The frontend’s own API client layer (src/lib/api/client.ts) is what turns that into a typed ApiError:
CSRF: the one thing that doesn’t route through here
The proxy forwards whateverx-csrf-token header the caller already set — it does not add one itself. Every mutating call site is responsible for reading the cookie and attaching the header, via one of two small helpers that both do the same thing:
adminSend()insrc/lib/api/adminAuth.ts(staff portal mutations)tenantSend()insrc/lib/api/tenantAuth.ts(tenant console mutations)
readCsrfToken() (src/lib/authCookies.ts) reads document.cookie directly — this is the one cookie in the whole system a browser script is allowed to read, by design (see Authentication for the backend’s double-submit verification).
/api/workspaces: the one route that isn’t proxied
src/app/api/workspaces/route.ts is a second, unrelated Next.js route — not a case inside the catch-all. The login page (src/app/login/page.tsx) fetches it before any session exists at all, so it can’t go through the session-gated proxy; instead this route calls the backend directly and reshapes the response defensively. (The fetched list is currently stored in the login page’s workspaces state and not rendered anywhere — the Organization field is a plain text input defaulting to "yc-demo", not a picker.)
{ workspaces: [], degraded: true } rather than surfacing an error to a not-yet-authenticated visitor. The backend endpoint it calls, GET /workspaces, is itself unauthenticated and deliberately minimal: it serializes each live tenant as a WorkspaceSummary (client_id, company, industry, status — populated from the tenant’s slug/name/industry with status hardcoded to "live"), nothing an anonymous visitor shouldn’t see. This is a different route from the staff-authenticated tenant registry (GET /admin/tenants, see Admin portal wiring), which returns full tenant detail behind a session.
Sequence: one authenticated request end to end
The following traces a single call —RemediationView fetching the tenant’s CAP list — from render to response.
Every hop after the browser’s own request is server-to-server: the frontend’s Node process talking to the backend’s origin directly, over whatever private network the two share in the deployed environment (see Environments). Nothing about the backend’s real hostname, port, or TLS configuration is ever visible to the browser.