Phase B cycle 1 was a structural refactor only: F4 (barrel imports + STC-ARCH-01) and F7 (endpoint builders + STC-ARCH-02). This commit brings docs in line with source after the cycle, no code changes. Module docs (12 consumers): swap every /api/<service>/... literal in code snippets and integration tables for the matching endpoints.* builder; note the barrel import migration in Dependencies. New module doc: src__api__endpoints.md (public surface, F4 barrel re-export note, STC-ARCH-02 enforcement, contract-test reference). Architecture compliance baseline: mark F4 + F7 CLOSED with commit hashes (23746ec,8a461a2). 01_api-transport component description: add endpoints.ts + barrel to Internal Interfaces, close the F7 caveat, extend Module Inventory. ripple_log_cycle1.md: Task Step 0.5 reverse-dep analysis records the import-graph closure (no extra docs needed beyond the direct set). Carry-over reports landed alongside the docs: - test_run_report_phase_b_cycle1.md (Step 11 outcome) - implementation_report_refactor_phase_b_cycle1.md (cycle summary) State file: trimmed to the autodev <30-line target; Steps 14 + 15 recorded as SKIPPED with rationale (no security or perf surface changed in this cycle); pointer moved to Step 16 (Deploy). Co-authored-by: Cursor <cursoragent@cursor.com>
6.0 KiB
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:
export function setToken(token: string | null): void
export function getToken(): string | null
HTTP API:
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 | nullholds the current bearer token. request<T>(url, options):- Build a
Headersfromoptions.headers, injectAuthorization: Bearer <token>if present. - If
options.bodyis astring, setContent-Type: application/json. (Crucial:upload()passes aFormDatabody, which is not a string, soContent-Typeis left to the browser to set with the multipart boundary.) fetch(url, ...).- On
401and a present token: callrefreshToken(). On success, set the new bearer and retry the same request once. On failure, clear the token andwindow.location.href = '/login', then throw "Session expired". - Hand off to
handleResponse<T>.
- Build a
handleResponse<T>(res):204→undefined as T.!res.ok→ thrownew 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) withcredentials: 'include'. On 200, expects{ token: string }and stores it. Returns boolean. (Path produced by theendpointsbuilder; closes F7.)
Dependencies
- Internal:
./endpoints—endpoints.admin.authRefresh()used by the internalrefreshToken()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.tsxsrc/features/settings/SettingsPage.tsxsrc/features/dataset/DatasetPage.tsxsrc/features/flights/FlightsPage.tsxsrc/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 | nullat module scope). Survives in-tab navigations but not full reloads. The refresh path (POST /api/admin/auth/refreshwithcredentials: 'include') implies the refresh token rides in an HttpOnly cookie set by theadmin/service. The bearer access token is therefore short-lived and never persisted tolocalStorage. Acceptable XSS posture. - 401 handling: redirects to
/loginviawindow.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 withadmin/service contract during Step 6security_approach.md.
Tests
None.
Notes / open questions
- The
Authorizationheader is set BEFORErefreshToken()in the retry path, butrefreshToken()mutates the module-levelaccessTokenand the retry thenheaders.set('Authorization', \Bearer ${accessToken}`)` reads the NEW token. Correct, but worth a comment. requestis 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 setContent-Type, allowing the browser to compute the multipart boundary. This is intentional and correct.