> ## Documentation Index
> Fetch the complete documentation index at: https://docs.causeloop.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Proxy and sessions

> How the Next.js catch-all route bridges the browser to the product API without CORS, without exposing the API origin, and without any client-held session token.

The browser never talks to the Causeloop product API directly. Every request the console or staff portal makes goes to its own Next.js origin, at a path under `/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:

1. **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.
2. **No exposed API origin.** `BACKEND_URL` (`src/lib/backendUrl.ts`) is read from `process.env.CAUSELOOP_API_URL` on 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.
3. **Server-held session, httpOnly cookies.** The two session cookies — `cl_admin_session` and `cl_tenant_session` (`src/lib/authCookies.ts`) — are `httponly`, `samesite=lax` (set by the backend; see [Authentication](/api-reference/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).

<Note>
  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.
</Note>

## 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:

```typescript theme={null}
function buildTargetUrl(path: string[], search: string): string {
  const joinedPath = path.map(encodeURIComponent).join("/");
  return `${BACKEND_URL}/${joinedPath}${search}`;
}
```

So a browser call to `/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](/api-reference/overview)).

### The session gate

Before any forwarding happens, the handler checks for a real session cookie:

```typescript theme={null}
const hasRealSession =
  Boolean(request.cookies.get(ADMIN_SESSION_COOKIE)?.value) ||
  Boolean(request.cookies.get(TENANT_SESSION_COOKIE)?.value);

if (!hasRealSession && !PUBLIC_PATHS.has(path.join("/"))) {
  return unauthorized(); // 401 { detail: "no session" }
}
```

`PUBLIC_PATHS` is a small, explicit allowlist of operations that must work before any session exists:

```text theme={null}
admin/login, admin/password-reset/request, admin/password-reset/confirm,
health, health/live, health/ready,
login, accept-invite, password-reset/request, password-reset/confirm
```

The comment in the route file is precise about why: auth-bootstrapping routes establish a session and the three health operations expose only process/dependency readiness, so both categories need to be reachable before a session exists — an external deployment smoke check only needs the public frontend URL, not a backend session. Every other path this proxy sees requires one of the two session cookies to already be set. This check happens in the proxy, in addition to (not instead of) the backend's own per-route auth dependencies (`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.

<Warning>
  This gate only checks that *a* session cookie of the right shape is present — it does not validate the token. Validation is the backend's job on every request (session lookup, expiry, tenant/employee scoping). The proxy's check exists purely to fail fast and return a stable JSON 401 instead of forwarding an unauthenticated request that the backend would reject anyway.
</Warning>

### Header handling

The proxy rebuilds the outbound header set from scratch rather than passing `request.headers` through unmodified, for one specific reason: it must guarantee the backend never sees a client-supplied `X-CauseLoop-*` header.

```typescript theme={null}
const forwardHeaders = new Headers();
request.headers.forEach((value, key) => {
  const lower = key.toLowerCase();
  if (
    !HOP_BY_HOP_REQUEST_HEADERS.has(lower) &&
    !FORWARDING_REQUEST_HEADERS.has(lower) &&
    !lower.startsWith("x-causeloop-")
  ) {
    forwardHeaders.set(key, value);
  }
});
```

Three header sets are stripped from the inbound request before forwarding:

| Set                          | Headers                                                                                                  | Why                                                                                                                                                                                                                                                                                                                                       |
| ---------------------------- | -------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `HOP_BY_HOP_REQUEST_HEADERS` | `host`, `connection`, `content-length`                                                                   | Meaningless or wrong once re-sent to a different host/body                                                                                                                                                                                                                                                                                |
| `FORWARDING_REQUEST_HEADERS` | `forwarded`, `x-forwarded-for`, `x-forwarded-host`, `x-forwarded-port`, `x-forwarded-proto`, `x-real-ip` | The proxy computes its own trusted client-IP header instead of trusting whatever the browser sent                                                                                                                                                                                                                                         |
| any `x-causeloop-*`          | e.g. `X-CauseLoop-Role`, `X-CauseLoop-Tenant-Id`                                                         | **Spoofing prevention.** If the backend ever trusts an `X-CauseLoop-*` header for role or tenant scoping, a client that could set it directly could impersonate a different role or tenant. Stripping the entire prefix (not just the two names the proxy might inject) closes that off even for headers the proxy doesn't currently use. |

After stripping, the proxy injects exactly one header of its own, computed server-side from the trusted ingress chain:

```typescript theme={null}
const clientIp = clientIpFromTrustedIngress(request);
if (clientIp) {
  forwardHeaders.set("x-causeloop-client-ip", clientIp);
}
```

`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 than `GET`/`HEAD`, the request body is streamed straight through rather than buffered:

```typescript theme={null}
backendResponse = await fetch(targetUrl, {
  method,
  headers: forwardHeaders,
  body: hasBody ? request.body : undefined,
  ...(hasBody ? { duplex: "half" } : {}),
  redirect: "manual",
});
```

`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](/api-reference/integration/admin-portal-wiring) and [Tenant console wiring](/api-reference/integration/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:

1. **Backend-unreachable → stable 502.** If `fetch()` itself throws (connection refused, DNS failure, TLS error), the handler catches it and returns `NextResponse.json({ detail: "backend unavailable" }, { status: 502 })` instead of letting Next.js turn an unhandled exception into an opaque 500.
2. **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 stale `content-encoding` header 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:

```typescript theme={null}
backendResponse.headers.forEach((value, key) => {
  if (!HOP_BY_HOP_RESPONSE_HEADERS.has(key.toLowerCase())) {
    responseHeaders.append(key, value);
  }
});
```

`.append` matters specifically for `set-cookie`: a login response carries two separate `Set-Cookie` headers (the session cookie and the CSRF cookie — see [Authentication](/api-reference/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. A `409`, `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`:

```typescript theme={null}
async function handleResponse<T>(response: Response): Promise<T> {
  if (!response.ok) {
    const body = await parseJsonSafe(response);
    throw new ApiError(response.status, extractDetail(body));
  }
  return (await parseJsonSafe(response)) as T;
}
```

See [Errors and conventions](/api-reference/errors-and-conventions) for the full status-code contract this depends on.

## CSRF: the one thing that doesn't route through here

The proxy forwards whatever `x-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()` in `src/lib/api/adminAuth.ts` (staff portal mutations)
* `tenantSend()` in `src/lib/api/tenantAuth.ts` (tenant console mutations)

```typescript theme={null}
export async function adminSend<T>(method, path, body): Promise<T> {
  const csrf = readCsrfToken();
  return apiSend<T>(method, path, body, {
    headers: csrf ? { "x-csrf-token": csrf } : {},
  });
}
```

`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](/api-reference/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.)

```typescript theme={null}
export async function GET(): Promise<Response> {
  try {
    const response = await fetch(`${BACKEND_URL}/workspaces`, { cache: "no-store" });
    if (!response.ok) {
      return NextResponse.json({ workspaces: [], degraded: true });
    }
    const payload = (await response.json()) as BackendWorkspace[];
    const workspaces = (Array.isArray(payload) ? payload : [])
      .filter((workspace) => typeof workspace.client_id === "string")
      .map((workspace) => ({ /* ...defaulted fields... */ }));
    return NextResponse.json({ workspaces });
  } catch {
    return NextResponse.json({ workspaces: [], degraded: true });
  }
}
```

Every failure mode — a non-2xx response, a malformed payload, a thrown network error — degrades to `{ 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](/api-reference/integration/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.

```mermaid theme={null}
sequenceDiagram
    participant Browser
    participant Proxy as Next.js proxy<br/>(api/backend/[...path])
    participant API as Product API<br/>(product_app.py)

    Browser->>Browser: apiGet("/remediation/caps")<br/>fetch("/api/backend/remediation/caps")<br/>browser auto-attaches cl_tenant_session cookie
    Browser->>Proxy: GET /api/backend/remediation/caps<br/>Cookie: cl_tenant_session=...
    Proxy->>Proxy: hasRealSession? yes (cl_tenant_session present)<br/>strip x-causeloop-*, hop-by-hop, x-forwarded-* headers<br/>inject x-causeloop-client-ip from trusted x-forwarded-for
    Proxy->>API: GET /remediation/caps<br/>Cookie: cl_tenant_session=...<br/>X-CauseLoop-Client-Ip: 203.0.113.4
    API->>API: get_current_tenant_user() resolves cookie to<br/>TenantPrincipal{tenant, user_id, permissions}<br/>queries tenant-scoped Cap rows (RLS-scoped)
    API-->>Proxy: 200 OK<br/>[{ id, status, ... }, ...]
    Proxy-->>Browser: 200 OK (headers filtered,<br/>hop-by-hop dropped)
    Browser->>Browser: handleResponse() parses JSON,<br/>TanStack Query caches under<br/>["tenant-caps", undefined, undefined]
```

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](/deployment/environments)). Nothing about the backend's real hostname, port, or TLS configuration is ever visible to the browser.

## Related

* [Authentication](/api-reference/authentication)
* [Errors and conventions](/api-reference/errors-and-conventions)
* [Admin portal wiring](/api-reference/integration/admin-portal-wiring)
* [Tenant console wiring](/api-reference/integration/tenant-console-wiring)
