Files
stack/apps/web/src/lib/api/client.ts
Jason Woltje af299abdaf
All checks were successful
ci/woodpecker/push/infra Pipeline was successful
ci/woodpecker/push/orchestrator Pipeline was successful
ci/woodpecker/push/api Pipeline was successful
ci/woodpecker/push/web Pipeline was successful
debug(auth): log session cookie source
2026-02-18 21:36:01 -06:00

404 lines
10 KiB
TypeScript

/**
* 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<T> {
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<string> {
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<string> {
if (!csrfToken) {
return fetchCsrfToken();
}
return csrfToken;
}
/**
* Make an authenticated API request
*/
export async function apiRequest<T>(endpoint: string, options: ApiRequestOptions = {}): Promise<T> {
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<typeof setTimeout> | 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<string, string> | undefined) ?? {};
const headers: Record<string, string> = {
"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<T>(endpoint, { ...options, _isRetry: true });
}
throw new Error(error.message);
}
return await (response.json() as Promise<T>);
} 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<T>(endpoint: string, workspaceId?: string): Promise<T> {
const options: ApiRequestOptions = { method: "GET" };
if (workspaceId !== undefined) {
options.workspaceId = workspaceId;
}
return apiRequest<T>(endpoint, options);
}
/**
* POST request helper
*/
export async function apiPost<T>(
endpoint: string,
data?: unknown,
workspaceId?: string
): Promise<T> {
const options: ApiRequestOptions = {
method: "POST",
};
if (data !== undefined) {
options.body = JSON.stringify(data);
}
if (workspaceId !== undefined) {
options.workspaceId = workspaceId;
}
return apiRequest<T>(endpoint, options);
}
/**
* PATCH request helper
*/
export async function apiPatch<T>(
endpoint: string,
data: unknown,
workspaceId?: string
): Promise<T> {
const options: ApiRequestOptions = {
method: "PATCH",
body: JSON.stringify(data),
};
if (workspaceId !== undefined) {
options.workspaceId = workspaceId;
}
return apiRequest<T>(endpoint, options);
}
/**
* DELETE request helper
*/
export async function apiDelete<T>(endpoint: string, workspaceId?: string): Promise<T> {
const options: ApiRequestOptions = { method: "DELETE" };
if (workspaceId !== undefined) {
options.workspaceId = workspaceId;
}
return apiRequest<T>(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<T>(
endpoint: string,
formData: FormData,
workspaceId?: string,
timeoutMs?: number
): Promise<T> {
const url = `${API_BASE_URL}${endpoint}`;
const headers: Record<string, string> = {};
// Set up abort controller for timeout
const timeout = timeoutMs ?? DEFAULT_API_TIMEOUT_MS;
const controller = new AbortController();
let timeoutId: ReturnType<typeof setTimeout> | 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<T>(endpoint, formData, workspaceId, timeoutMs);
}
throw new Error(error.message);
}
return await (response.json() as Promise<T>);
} 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);
}
}
}