import { endpoints } from './endpoints' let accessToken: string | null = null /** * Single source of truth for the in-memory bearer token. * * The bearer is intentionally held in module scope (NOT in localStorage / * sessionStorage / cookies) per AC-02 / restriction O2 — the only way to * survive a page reload is through the HttpOnly refresh cookie + the * `refreshToken()` round-trip in `request()` below. * * Tests override this hook to seed a bearer without going through the full * login flow. See `_docs/02_document/tests/test-data.md` ("Stubbed bearer / * cookie in test helpers"). DO NOT delete this accessor as "dead code" — * it is reflectively used by the test harness; the import looks dead at * static-grep time but is not. */ export function setToken(token: string | null) { accessToken = token } /** Read-side companion to {@link setToken}. */ export function getToken() { return accessToken } /** * Resolve the base URL prefix for every API request. Returns `''` (no * prefix) on production builds where the SPA and the suite share the same * nginx (E2); tests + alternative deployments override via the Vite env * var `VITE_API_BASE_URL`. A trailing slash is stripped so a value of * `http://host/` does not produce `http://host//api/...`. */ export function getApiBase(): string { const raw = import.meta.env.VITE_API_BASE_URL if (!raw) return '' return raw.replace(/\/+$/, '') } export function authenticatedApiUrl(path: string): string { const url = getApiBase() + path if (!accessToken) return url const separator = url.includes('?') ? '&' : '?' return `${url}${separator}access_token=${encodeURIComponent(accessToken)}` } /** * Indirection for the failed-refresh redirect. Default impl writes * `'/login'` to `window.location.href` — the production behavior. Tests * override via {@link setNavigateToLogin} to assert "redirect was invoked" * without globally stubbing `window.location`. Must be reset by the test * harness in teardown (see `_docs/02_document/tests/test-data.md`). */ let navigateToLoginImpl: () => void = () => { window.location.href = '/login' } export function setNavigateToLogin(fn: () => void) { navigateToLoginImpl = fn } async function handleResponse(res: Response): Promise { if (res.status === 204) return undefined as T if (!res.ok) { const text = await res.text().catch(() => '') throw new Error(`${res.status}: ${text || res.statusText}`) } return res.json() } async function request(url: string, options: RequestInit = {}): Promise { const headers = new Headers(options.headers) if (accessToken) headers.set('Authorization', `Bearer ${accessToken}`) if (options.body && typeof options.body === 'string') headers.set('Content-Type', 'application/json') const fullUrl = getApiBase() + url let res = await fetch(fullUrl, { ...options, headers, credentials: 'include' }) if (res.status === 401 && accessToken) { const refreshed = await refreshToken() if (refreshed) { headers.set('Authorization', `Bearer ${accessToken}`) res = await fetch(fullUrl, { ...options, headers, credentials: 'include' }) } else { setToken(null) navigateToLoginImpl() throw new Error('Session expired') } } return handleResponse(res) } async function refreshToken(): Promise { try { const res = await fetch(getApiBase() + endpoints.admin.authRefresh(), { method: 'POST', credentials: 'include' }) if (!res.ok) return false const data = await res.json() setToken(data.token) return true } catch { return false } } export const api = { get: (url: string) => request(url), post: (url: string, body?: unknown) => request(url, { method: 'POST', body: body ? JSON.stringify(body) : undefined }), put: (url: string, body?: unknown) => request(url, { method: 'PUT', body: body ? JSON.stringify(body) : undefined }), patch: (url: string, body?: unknown) => request(url, { method: 'PATCH', body: body ? JSON.stringify(body) : undefined }), delete: (url: string) => request(url, { method: 'DELETE' }), upload: (url: string, formData: FormData) => request(url, { method: 'POST', body: formData }), }