Files
stack/apps/web/src/lib/api/client.ts
T
Jason WoltjeandClaude Opus 4.6 014264c592 fix(SEC-WEB-32+34): Add input maxLength limits + API request timeout
SEC-WEB-32: Added maxLength to form inputs (names: 100, descriptions: 500,
emails: 254) in WorkspaceSettings, TeamSettings, InviteMember components.

SEC-WEB-34: Added AbortController timeout (30s default, configurable) to
apiRequest and apiPostFormData in API client.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-02-06 18:11:00 -06:00

331 lines
8.2 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 } 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
}
/**
* 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;
// 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 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<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);
}
}
}