Repository Public
1/**2 * Typed client for the same-origin browser API (/api/v1). Authentication is an HttpOnly3 * cookie; the CSRF token lives in memory only (never localStorage/sessionStorage).4 */5import '@/lib/zod-config';6import { ApiError as ApiErrorSchema, AuthStatus } from '@cr/shared';7import { confirmBrandingFromServer } from '@/stores/branding';89export const API_BASE = '/api/v1';1011export class ApiRequestError extends Error {12 constructor(13 readonly status: number,14 readonly code: string,15 message: string,16 readonly details?: unknown,17 readonly retryAfterSec?: number,18 ) {19 super(message);20 this.name = 'ApiRequestError';21 }22}2324let csrfToken: string | null = null;25let statusInflight: Promise<AuthStatus> | null = null;2627export function fetchAuthStatus(): Promise<AuthStatus> {28 statusInflight ??= (async () => {29 try {30 const res = await fetch(`${API_BASE}/auth/status`, { credentials: 'same-origin', cache: 'no-store' });31 if (!res.ok) throw await toError(res);32 const status = AuthStatus.parse(await res.json());33 csrfToken = status.csrfToken;34 confirmBrandingFromServer(status.branding); // the instance's product: wins over the cached value35 return status;36 } finally {37 statusInflight = null;38 }39 })();40 return statusInflight;41}4243async function toError(res: Response): Promise<ApiRequestError> {44 let code = `http_${res.status}`;45 let message = res.statusText || `Request failed (${res.status})`;46 let details: unknown;47 try {48 const parsed = ApiErrorSchema.safeParse(await res.json());49 if (parsed.success) ({ code, message, details } = parsed.data.error);50 } catch {51 /* non-JSON error body */52 }53 let retry: number | undefined;54 const d = details as { retryAfterSec?: unknown } | undefined;55 if (d && typeof d.retryAfterSec === 'number') retry = d.retryAfterSec;56 const h = Number(res.headers.get('Retry-After'));57 if (retry === undefined && Number.isFinite(h) && h > 0) retry = h;58 return new ApiRequestError(res.status, code, message, details, retry);59}6061export function isAuthPage(): boolean {62 return typeof location !== 'undefined' && /^\/(login|setup)\/?$/.test(location.pathname);63}64let redirecting = false;65/** Navigates to the login page at most once per page load (parallel 401s must not loop). */66export function redirectToLogin(): void {67 if (typeof location === 'undefined' || isAuthPage() || redirecting) return;68 redirecting = true;69 const next = location.pathname + location.search;70 location.assign(`/login/?next=${encodeURIComponent(next)}`);71}72/** Only same-origin absolute paths are accepted as a post-login destination. */73export function safeNext(raw: string | null): string {74 return raw && raw.startsWith('/') && !raw.startsWith('//') && !raw.startsWith('/login') && !raw.startsWith('/setup') ? raw : '/'; // `/` picks Home (phone) or the workspace (desktop)75}7677interface RequestOpts {78 signal?: AbortSignal;79 /** Do not navigate to /login/ on 401 (auth endpoints handle it themselves). */80 noAuthRedirect?: boolean;81}82const MUTATING = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);8384async function raw(method: string, path: string, body: unknown, opts: RequestOpts, retried = false): Promise<Response> {85 const headers: Record<string, string> = { Accept: 'application/json' };86 if (MUTATING.has(method)) {87 if (csrfToken === null) await fetchAuthStatus().catch(() => undefined);88 if (csrfToken) headers['X-CSRF-Token'] = csrfToken;89 }90 let payload: BodyInit | undefined;91 if (body instanceof Blob || body instanceof ArrayBuffer) {92 payload = body;93 headers['Content-Type'] = 'application/octet-stream';94 } else if (body !== undefined) {95 payload = JSON.stringify(body);96 headers['Content-Type'] = 'application/json';97 }98 const res = await fetch(API_BASE + path, { method, headers, body: payload, credentials: 'same-origin', cache: 'no-store', signal: opts.signal });99 if (res.ok) return res;100 const err = await toError(res);101 if (res.status === 403 && err.code.includes('csrf') && !retried) {102 csrfToken = null;103 await fetchAuthStatus().catch(() => undefined);104 return raw(method, path, body, opts, true);105 }106 if (res.status === 401 && !opts.noAuthRedirect && !path.startsWith('/auth/')) redirectToLogin();107 throw err;108}109110async function json<T>(method: string, path: string, body?: unknown, opts: RequestOpts = {}): Promise<T> {111 const res = await raw(method, path, body, opts);112 if (res.status === 204) return undefined as T;113 const text = await res.text();114 return (text ? JSON.parse(text) : undefined) as T;115}116117export const api = {118 get: <T>(path: string, opts?: RequestOpts) => json<T>('GET', path, undefined, opts),119 post: <T>(path: string, body?: unknown, opts?: RequestOpts) => json<T>('POST', path, body ?? {}, opts),120 put: <T>(path: string, body?: unknown, opts?: RequestOpts) => json<T>('PUT', path, body ?? {}, opts),121 patch: <T>(path: string, body?: unknown, opts?: RequestOpts) => json<T>('PATCH', path, body ?? {}, opts),122 delete: <T>(path: string, opts?: RequestOpts) => json<T>('DELETE', path, undefined, opts),123 /** Raw response (binary logs, downloads). */124 raw: (path: string, opts?: RequestOpts) => raw('GET', path, undefined, opts ?? {}),125 csrf: () => csrfToken,126 clearCsrf: () => {127 csrfToken = null;128 },129};130131/** Some list endpoints may be wrapped (`{items: [...]}`); accept both shapes. */132export function asList<T>(data: unknown): T[] {133 if (Array.isArray(data)) return data as T[];134 if (data && typeof data === 'object') {135 for (const v of Object.values(data)) if (Array.isArray(v)) return v as T[];136 }137 return [];138}139140export function qs(params: Record<string, string | number | boolean | null | undefined>): string {141 const sp = new URLSearchParams();142 for (const [k, v] of Object.entries(params)) if (v !== null && v !== undefined && v !== '') sp.set(k, String(v));143 const s = sp.toString();144 return s ? `?${s}` : '';145}146147/** WebSocket URL: same origin in production; NEXT_PUBLIC_DEV_WS_ORIGIN only exists in `next dev`. */148export function wsUrl(path: string): string {149 const dev = process.env.NEXT_PUBLIC_DEV_WS_ORIGIN;150 const origin = dev || `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}`;151 return `${origin}${API_BASE}${path}`;152}153154/** Upload with progress (XHR: fetch has no upload progress). Body is the raw file. */155export function uploadFile(projectId: string, destPath: string, file: Blob, onProgress?: (fraction: number) => void, overwrite = false): Promise<void> {156 return new Promise((resolve, reject) => {157 const xhr = new XMLHttpRequest();158 xhr.open('POST', `${API_BASE}/projects/${projectId}/upload${qs({ path: destPath, overwrite: overwrite ? 1 : undefined })}`);159 xhr.withCredentials = true;160 if (csrfToken) xhr.setRequestHeader('X-CSRF-Token', csrfToken);161 xhr.setRequestHeader('Content-Type', 'application/octet-stream');162 xhr.upload.onprogress = (e) => e.lengthComputable && onProgress?.(e.loaded / e.total);163 xhr.onload = () => {164 if (xhr.status >= 200 && xhr.status < 300) return resolve();165 if (xhr.status === 401) redirectToLogin();166 let message = `Upload failed (${xhr.status})`;167 let code = `http_${xhr.status}`;168 try {169 const p = ApiErrorSchema.safeParse(JSON.parse(xhr.responseText));170 if (p.success) ({ code, message } = p.data.error);171 } catch {172 /* keep generic message */173 }174 reject(new ApiRequestError(xhr.status, code, message));175 };176 xhr.onerror = () => reject(new ApiRequestError(0, 'network', 'Network error during upload'));177 xhr.send(file);178 });179}180export const downloadUrl = (projectId: string, path: string): string => `${API_BASE}/projects/${projectId}/download${qs({ path })}`;181182export function errorMessage(e: unknown): string {183 if (e instanceof ApiRequestError) return e.message;184 if (e instanceof Error) return e.message;185 return 'Unexpected error';186}187