/** * Typed API client built on openapi-fetch + generated schema.d.ts. * * Middleware contract (orchestrator-decisions.md §11): * 1. Always send cookies (credentials: "include"; same-origin auto-sends but explicit is clear). * 2. Non-GET/HEAD requests inject X-CSRF-Token from the csrf holder. * Exception: POST /api/auth/login skips injection (unauthenticated endpoint). * 3. 401 responses → clear session state + navigate to /login. * 4. Other non-2xx responses → throw an ApiError carrying the parsed JSON body, * so callers (e.g. SMTP test) can inspect body.result. */ import createClient, { type Middleware } from 'openapi-fetch' import type { paths } from './schema.d.ts' import { getCsrfToken } from './csrf' // --------------------------------------------------------------------------- // Error type // --------------------------------------------------------------------------- /** Error thrown for non-2xx, non-401 responses. Carries the parsed JSON body. */ export class ApiError extends Error { constructor( public readonly status: number, // eslint-disable-next-line @typescript-eslint/no-explicit-any public readonly body: any, ) { super(`API error ${status}`) this.name = 'ApiError' } } // --------------------------------------------------------------------------- // Internal navigation helper (avoids React-router import at module level) // --------------------------------------------------------------------------- let _navigateToLogin: (() => void) | null = null /** * Register a callback that the middleware calls on 401. * SessionProvider calls this during its setup. */ export function registerLoginRedirect(fn: () => void): void { _navigateToLogin = fn } // --------------------------------------------------------------------------- // CSRF middleware // --------------------------------------------------------------------------- const WRITE_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']) const LOGIN_PATH = '/api/auth/login' /** * Endpoints where a 401 is an EXPECTED, locally-handled outcome and must NOT * trigger the global login redirect: * - GET /api/session — the session probe; 401 means "not logged in", handled * by SessionProvider's queryFn (returns null → unauthenticated state). * - POST /api/auth/login — bad-credentials check; 401 handled by LoginPage. * * Redirecting on these would invalidate the session query, which refetches * /api/session, which 401s, which redirects again → an infinite loop that * floods GET /api/session after logout and on the login page. */ const SESSION_PATH = '/api/session' const NO_REDIRECT_ON_401 = new Set([SESSION_PATH, LOGIN_PATH]) export const csrfMiddleware: Middleware = { async onRequest({ request }) { // Always include cookies (same-origin; explicit for clarity) // Note: credentials is set at client level; this is belt-and-suspenders doc. const method = request.method.toUpperCase() const url = new URL(request.url) if (WRITE_METHODS.has(method) && url.pathname !== LOGIN_PATH) { const token = getCsrfToken() if (token) { request.headers.set('X-CSRF-Token', token) } } return request }, async onResponse({ schemaPath, response }) { if (response.status === 401) { // The session probe and the login endpoint own their 401s (see // NO_REDIRECT_ON_401). For any OTHER endpoint, a 401 means the session // expired mid-use → redirect to /login. Crucially, NOT redirecting on the // session probe breaks the refetch→401→redirect→refetch flood loop. if (!NO_REDIRECT_ON_401.has(schemaPath) && _navigateToLogin) { _navigateToLogin() } // Return the original response so callers can handle 401 if needed. return response } if (!response.ok) { // Parse body and throw; caller can catch ApiError and read .body let body: unknown try { body = await response.clone().json() } catch { body = null } throw new ApiError(response.status, body) } return response }, } // --------------------------------------------------------------------------- // Client instance // --------------------------------------------------------------------------- const apiClient = createClient({ baseUrl: '/', credentials: 'include', }) apiClient.use(csrfMiddleware) export default apiClient