# Module: `src/api/client.ts`

> **Source**: `src/api/client.ts` (65 lines)
> **Topo batch**: B2 (leaf — no internal imports)

## Purpose

Minimal `fetch` wrapper that injects the JWT bearer token, normalises HTTP errors into `Error` throws, and transparently retries a single time after a 401 by attempting a refresh. Acts as the single HTTP entry point for every page; there is no per-service typed client.

## Public interface

Token plumbing:

```ts
export function setToken(token: string | null): void
export function getToken(): string | null
```

HTTP API:

```ts
export const api = {
  get:    <T>(url) => Promise<T>
  post:   <T>(url, body?) => Promise<T>
  put:    <T>(url, body?) => Promise<T>
  patch:  <T>(url, body?) => Promise<T>
  delete: <T>(url) => Promise<T>
  upload: <T>(url, formData: FormData) => Promise<T>
}
```

## Internal logic

- Module-level mutable variable `let accessToken: string | null` holds the current bearer token.
- `request<T>(url, options)`:
  1. Build a `Headers` from `options.headers`, inject `Authorization: Bearer <token>` if present.
  2. If `options.body` is a `string`, set `Content-Type: application/json`. (Crucial: `upload()` passes a `FormData` body, which is **not** a string, so `Content-Type` is left to the browser to set with the multipart boundary.)
  3. `fetch(url, ...)`.
  4. On `401` *and* a present token: call `refreshToken()`. On success, set the new bearer and retry the same request once. On failure, clear the token and `window.location.href = '/login'`, then throw "Session expired".
  5. Hand off to `handleResponse<T>`.
- `handleResponse<T>(res)`:
  - `204` → `undefined as T`.
  - `!res.ok` → throw `new Error(\`${status}: ${text || statusText}\`)`. Body text is read defensively (`.catch(() => '')`).
  - Otherwise → `res.json()` (no schema validation — caller types the response).
- `refreshToken()` — `POST endpoints.admin.authRefresh()` (i.e. `/api/admin/auth/refresh`) with `credentials: 'include'`. On 200, expects `{ token: string }` and stores it. Returns boolean. (Path produced by the `endpoints` builder; closes F7.)

## Dependencies

- **Internal**: `./endpoints` — `endpoints.admin.authRefresh()` used by the internal `refreshToken()` helper (since AZ-486 / F7).
- **External**: `fetch`, `Headers`, `FormData`, `Response` (browser globals). No npm runtime dependency.

## Consumers (intra-repo)

The module-level `api` object is imported by:

- `src/auth/AuthContext.tsx` (login / logout / initial refresh)
- `src/components/FlightContext.tsx` (flights list, user settings get/put)
- `src/components/DetectionClasses.tsx` (admin classes load)
- `src/features/admin/AdminPage.tsx`
- `src/features/settings/SettingsPage.tsx`
- `src/features/dataset/DatasetPage.tsx`
- `src/features/flights/FlightsPage.tsx`
- `src/features/annotations/{AnnotationsPage,AnnotationsSidebar,MediaList}.tsx`

`setToken` is imported by `AuthContext` (login / refresh / logout).
`getToken` is imported by `src/api/sse.ts` (to append the token to SSE URLs).

## Data models

None defined here. The generic `T` parameter is supplied by call sites.

## Configuration

URLs are produced by typed builders in `src/api/endpoints.ts` (see `src__api__endpoints.md`) — the F7 finding from the architecture baseline is now CLOSED. Every consumer (this module included) imports `endpoints` and calls `endpoints.<service>.<method>(...)`; the `STC-ARCH-02` static gate forbids re-introducing literal `/api/<service>/...` strings under `src/`.

There is no base-URL constant: the path strings are still relative. The `vite.config.ts` dev proxy and `nginx.conf` production rules forward `/api/*` to per-service backends. `getApiBase()` (exported from this module) supplies the host prefix at runtime where the consumer needs an absolute URL (e.g. the manual `fetch(getApiBase() + endpoints.admin.authRefresh(), ...)` call inside `refreshToken()`).

## External integrations

Every backend the SPA talks to flows through this module. See `nginx.conf` for the routing table:

| Path prefix | Backend service |
|---|---|
| `/api/admin/*` | `admin/` (.NET) |
| `/api/annotations/*` | `annotations/` (.NET) |
| `/api/flights/*` | `flights/` (.NET) |
| `/api/resource/*` | `satellite-provider/` |
| `/api/detect/*` | `detections/` (Cython) |
| `/api/loader/*` | `loader/` (Cython) |
| `/api/gps-denied-desktop/*` | `gps-denied-desktop/` |
| `/api/gps-denied-onboard/*` | `gps-denied-onboard/` |
| `/api/autopilot/*` | `autopilot/` |

## Security

- **Token storage**: in-memory only (`accessToken: string | null` at module scope). Survives in-tab navigations but not full reloads. The refresh path (`POST /api/admin/auth/refresh` with `credentials: 'include'`) implies the refresh token rides in an HttpOnly cookie set by the `admin/` service. The bearer access token is therefore short-lived and never persisted to `localStorage`. Acceptable XSS posture.
- **401 handling**: redirects to `/login` via `window.location.href` (full page reload) — clears any in-memory state including the bearer.
- **Race condition**: two parallel requests that both 401 will both call `refreshToken()` independently — one will succeed, one may receive a stale token mid-flight. Track for B3/B4 audit; minor under the current usage but should be serialised in Step 8.
- **No CSRF token**: relies on the bearer scheme only; `credentials: 'include'` is set only on `/refresh`, so other endpoints don't carry the cookie. Verify with `admin/` service contract during Step 6 `security_approach.md`.

## Tests

None.

## Notes / open questions

- The `Authorization` header is set BEFORE `refreshToken()` in the retry path, but `refreshToken()` mutates the module-level `accessToken` and the retry then `headers.set('Authorization', \`Bearer ${accessToken}\`)` reads the NEW token. Correct, but worth a comment.
- `request` is typed `<T>` and trusts callers; a runtime schema validation layer (Zod, valibot) would be the right Step 8 hardening but is too heavy for testability scope.
- `upload(url, formData)` does NOT set `Content-Type`, allowing the browser to compute the multipart boundary. This is intentional and correct.
