# 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: (url) => Promise post: (url, body?) => Promise put: (url, body?) => Promise patch: (url, body?) => Promise delete: (url) => Promise upload: (url, formData: FormData) => Promise } ``` ## Internal logic - Module-level mutable variable `let accessToken: string | null` holds the current bearer token. - `request(url, options)`: 1. Build a `Headers` from `options.headers`, inject `Authorization: Bearer ` 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`. - `handleResponse(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 /api/admin/auth/refresh` with `credentials: 'include'`. On 200, expects `{ token: string }` and stores it. Returns boolean. ## Dependencies - **Internal**: none. - **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 **string literals** at every call site (`/api/admin/...`, `/api/flights?...`, etc.). There is no base-URL constant. The `vite.config.ts` dev proxy and `nginx.conf` production rules forward `/api/*` to per-service backends. A `VITE_API_BASE_URL` env-var fix is the canonical Step 4 testability candidate (workspace `README.md` calls this out). ## 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 `` 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.