404 lines
10 KiB
TypeScript
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);
|
|
}
|
|
}
|
|
}
|