/** * API Client for Mosaic Stack * Handles authenticated requests to the backend API */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ import { API_BASE_URL, IS_MOCK_AUTH_MODE } 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 } const MOCK_ACTIVE_PROJECTS_RESPONSE = [ { id: "project-dev-1", name: "Mosaic Stack FE Go-Live", status: "active", lastActivity: new Date().toISOString(), taskCount: 7, eventCount: 2, color: "#3B82F6", }, { id: "project-dev-2", name: "Auth Flow Remediation", status: "in-progress", lastActivity: new Date(Date.now() - 12 * 60_000).toISOString(), taskCount: 4, eventCount: 0, color: "#F59E0B", }, ] as const; const MOCK_AGENT_CHAINS_RESPONSE = [ { id: "agent-session-dev-1", sessionKey: "dev-session-1", label: "UI Validator Agent", channel: "codex", agentName: "jarvis-agent", agentStatus: "WORKING", status: "active", startedAt: new Date(Date.now() - 42 * 60_000).toISOString(), lastMessageAt: new Date(Date.now() - 20_000).toISOString(), runtimeMs: 42 * 60_000, messageCount: 27, contextSummary: "Validating dashboard, tasks, and auth-bypass UX for local development flow.", }, { id: "agent-session-dev-2", sessionKey: "dev-session-2", label: "Telemetry Stub Agent", channel: "codex", agentName: "jarvis-agent", agentStatus: "TERMINATED", status: "ended", startedAt: new Date(Date.now() - 3 * 60 * 60_000).toISOString(), lastMessageAt: new Date(Date.now() - 2 * 60 * 60_000).toISOString(), runtimeMs: 63 * 60_000, messageCount: 41, contextSummary: "Generated telemetry mock payloads for usage and widget rendering.", }, ] as const; function getMockApiResponse(endpoint: string, method: string): unknown { if (!IS_MOCK_AUTH_MODE || process.env.NODE_ENV !== "development") { return undefined; } if (method === "POST" && endpoint === "/api/widgets/data/active-projects") { return [...MOCK_ACTIVE_PROJECTS_RESPONSE]; } if (method === "POST" && endpoint === "/api/widgets/data/agent-chains") { return [...MOCK_AGENT_CHAINS_RESPONSE]; } return undefined; } /** * 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; const method = (fetchOptions.method ?? "GET").toUpperCase(); const mockResponse = getMockApiResponse(endpoint, method); if (mockResponse !== undefined) { return mockResponse as T; } // 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 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); } } }