/** * API Client for Mosaic Stack * Handles authenticated requests to the backend API */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ import { API_BASE_URL } from "../config"; /** * In-memory CSRF token storage * Using module-level variable instead of localStorage for security */ let csrfToken: string | undefined; export interface ApiError { code: string; message: string; details?: unknown; } export interface ApiResponse { data: T; meta?: { total?: number; page?: number; limit?: number; }; } /** Default timeout for API requests in milliseconds (30 seconds) */ export const DEFAULT_API_TIMEOUT_MS = 30_000; /** * Options for API requests with workspace context */ export interface ApiRequestOptions extends RequestInit { workspaceId?: string; /** Request timeout in milliseconds. Defaults to 30000 (30s). Set to 0 to disable. */ timeoutMs?: number; _isRetry?: boolean; // Internal flag to prevent infinite retry loops } /** * Fetch CSRF token from the API * Token is stored in an httpOnly cookie and returned in response body */ export async function fetchCsrfToken(): Promise { const url = `${API_BASE_URL}/api/v1/csrf/token`; const response = await fetch(url, { method: "GET", credentials: "include", }); if (!response.ok) { const error: ApiError = await response.json().catch( (): ApiError => ({ code: "UNKNOWN_ERROR", message: response.statusText || "Failed to fetch CSRF token", }) ); throw new Error(error.message); } const data = (await response.json()) as { token: string }; csrfToken = data.token; return data.token; } /** * Get the current CSRF token from memory */ export function getCsrfToken(): string | undefined { return csrfToken; } /** * Clear the CSRF token from memory * Useful for testing or after logout */ export function clearCsrfToken(): void { csrfToken = undefined; } /** * Ensure CSRF token is available for state-changing requests */ async function ensureCsrfToken(): Promise { if (!csrfToken) { return fetchCsrfToken(); } return csrfToken; } /** * Make an authenticated API request */ export async function apiRequest(endpoint: string, options: ApiRequestOptions = {}): Promise { const url = `${API_BASE_URL}${endpoint}`; const { workspaceId, timeoutMs, _isRetry, ...fetchOptions } = options; // Set up abort controller for timeout const timeout = timeoutMs ?? DEFAULT_API_TIMEOUT_MS; const controller = new AbortController(); let timeoutId: ReturnType | undefined; if (timeout > 0) { timeoutId = setTimeout(() => { controller.abort(); }, timeout); } // Merge with any caller-provided signal const callerSignal = fetchOptions.signal; if (callerSignal) { callerSignal.addEventListener("abort", () => { controller.abort(); }); } try { // Build headers with workspace ID if provided const baseHeaders = (fetchOptions.headers as Record | undefined) ?? {}; const headers: Record = { "Content-Type": "application/json", ...baseHeaders, }; // Add workspace ID header if provided (recommended over query string) if (workspaceId) { headers["X-Workspace-Id"] = workspaceId; } // Add CSRF token for state-changing requests (POST, PUT, PATCH, DELETE) const method = (fetchOptions.method ?? "GET").toUpperCase(); const isStateChanging = ["POST", "PUT", "PATCH", "DELETE"].includes(method); if (isStateChanging) { const token = await ensureCsrfToken(); headers["X-CSRF-Token"] = token; } const response = await fetch(url, { ...fetchOptions, headers, credentials: "include", // Include cookies for session signal: controller.signal, }); if (!response.ok) { const error: ApiError = await response.json().catch( (): ApiError => ({ code: "UNKNOWN_ERROR", message: response.statusText || "An unknown error occurred", }) ); // Handle CSRF token mismatch - refresh token and retry once if ( response.status === 403 && (error.code === "CSRF_ERROR" || error.message.includes("CSRF")) && !_isRetry ) { // Refresh CSRF token await fetchCsrfToken(); // Retry the request with new token return await apiRequest(endpoint, { ...options, _isRetry: true }); } throw new Error(error.message); } return await (response.json() as Promise); } catch (err: unknown) { if (err instanceof DOMException && err.name === "AbortError") { throw new Error(`Request to ${endpoint} timed out after ${String(timeout)}ms`); } throw err; } finally { if (timeoutId !== undefined) { clearTimeout(timeoutId); } } } /** * GET request helper */ export async function apiGet(endpoint: string, workspaceId?: string): Promise { const options: ApiRequestOptions = { method: "GET" }; if (workspaceId !== undefined) { options.workspaceId = workspaceId; } return apiRequest(endpoint, options); } /** * POST request helper */ export async function apiPost( endpoint: string, data?: unknown, workspaceId?: string ): Promise { const options: ApiRequestOptions = { method: "POST", }; if (data !== undefined) { options.body = JSON.stringify(data); } if (workspaceId !== undefined) { options.workspaceId = workspaceId; } return apiRequest(endpoint, options); } /** * PATCH request helper */ export async function apiPatch( endpoint: string, data: unknown, workspaceId?: string ): Promise { const options: ApiRequestOptions = { method: "PATCH", body: JSON.stringify(data), }; if (workspaceId !== undefined) { options.workspaceId = workspaceId; } return apiRequest(endpoint, options); } /** * DELETE request helper */ export async function apiDelete(endpoint: string, workspaceId?: string): Promise { const options: ApiRequestOptions = { method: "DELETE" }; if (workspaceId !== undefined) { options.workspaceId = workspaceId; } return apiRequest(endpoint, options); } /** * POST request helper for FormData uploads * Note: This does not set Content-Type header to allow browser to set multipart/form-data boundary */ export async function apiPostFormData( endpoint: string, formData: FormData, workspaceId?: string, timeoutMs?: number ): Promise { const url = `${API_BASE_URL}${endpoint}`; const headers: Record = {}; // Set up abort controller for timeout const timeout = timeoutMs ?? DEFAULT_API_TIMEOUT_MS; const controller = new AbortController(); let timeoutId: ReturnType | undefined; if (timeout > 0) { timeoutId = setTimeout(() => { controller.abort(); }, timeout); } try { // Add workspace ID header if provided if (workspaceId) { headers["X-Workspace-Id"] = workspaceId; } // Add CSRF token for state-changing request const token = await ensureCsrfToken(); headers["X-CSRF-Token"] = token; const response = await fetch(url, { method: "POST", headers, body: formData, credentials: "include", signal: controller.signal, }); if (!response.ok) { const error: ApiError = await response.json().catch( (): ApiError => ({ code: "UNKNOWN_ERROR", message: response.statusText || "An unknown error occurred", }) ); // Handle CSRF token mismatch - refresh token and retry once if ( response.status === 403 && (error.code === "CSRF_ERROR" || error.message.includes("CSRF")) ) { // Refresh CSRF token await fetchCsrfToken(); // Retry the request with new token (recursive call) return await apiPostFormData(endpoint, formData, workspaceId, timeoutMs); } throw new Error(error.message); } return await (response.json() as Promise); } catch (err: unknown) { if (err instanceof DOMException && err.name === "AbortError") { throw new Error(`Request to ${endpoint} timed out after ${String(timeout)}ms`); } throw err; } finally { if (timeoutId !== undefined) { clearTimeout(timeoutId); } } }