Captures the full output of autodev existing-code Phase A through Step 4 (Code Testability Revision) for the Azaion UI workspace: - Step 1 Document: _docs/02_document/ (FINAL_report, architecture, glossary, components/, modules/, diagrams/, system-flows, module-layout) plus _docs/00_problem/ + _docs/01_solution/ + _docs/legacy/ + _docs/how_to_test + README. - Step 2 Architecture Baseline: architecture_compliance_baseline.md. - Step 3 Test Spec: _docs/02_document/tests/ (environment, test-data, blackbox/performance/resilience/security/ resource-limit tests, traceability-matrix), enum_spec_snapshot, expected_results/results_report.md (98 rows), plus the run-tests.sh + run-performance-tests.sh runners. - Step 4 Code Testability Revision: 01-testability-refactoring/ run dir (list-of-changes C01-C07, deferred_to_refactor, analysis/research_findings + refactoring_roadmap) and the 7 child task specs AZ-448..AZ-454 under _docs/02_tasks/todo/ plus _dependencies_table.md. - _docs/_autodev_state.md pins the cursor at Step 4 / refactor Phase 4 entry so /autodev resumes cleanly. Epic AZ-447 (UI testability gates) tracks the 7 child tasks that will land in subsequent commits. Co-authored-by: Cursor <cursoragent@cursor.com>
5.4 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 /api/admin/auth/refreshwithcredentials: '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.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 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 | 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.