const TOKEN_KEY = 'archnest_token' export function getToken(): string | null { return localStorage.getItem(TOKEN_KEY) } export function setToken(token: string | null) { if (token) localStorage.setItem(TOKEN_KEY, token) else localStorage.removeItem(TOKEN_KEY) } export class ApiError extends Error { status: number constructor(status: number, message: string) { super(message) this.status = status } } export async function apiFetch(path: string, options: RequestInit = {}): Promise { const token = getToken() const headers: Record = { ...(options.body ? { 'Content-Type': 'application/json' } : {}), ...(options.headers as Record | undefined), } if (token) headers.Authorization = `Bearer ${token}` const res = await fetch(`/api${path}`, { ...options, headers }) if (!res.ok) { let message = res.statusText try { const body = await res.json() message = body.error ?? message } catch { // ignore non-JSON error bodies } throw new ApiError(res.status, message) } if (res.status === 204) return undefined as T return res.json() as Promise } export const api = { getSetupStatus: () => apiFetch<{ needsSetup: boolean }>('/system/setup-status'), setup: (username: string, password: string) => apiFetch<{ token: string }>('/setup', { method: 'POST', body: JSON.stringify({ username, password }) }), login: (username: string, password: string) => apiFetch<{ token: string }>('/auth/login', { method: 'POST', body: JSON.stringify({ username, password }) }), me: () => apiFetch<{ user: AuthUser }>('/auth/me'), updateMe: (data: Partial<{ displayName: string | null; email: string | null; avatarDataUrl: string | null }>) => apiFetch<{ user: AuthUser }>('/auth/me', { method: 'PUT', body: JSON.stringify(data) }), listIntegrations: () => apiFetch<{ integrations: Integration[] }>('/integrations'), createIntegration: (data: { type: string; name: string; config?: Record; secrets?: Record }) => apiFetch<{ integration: Integration }>('/integrations', { method: 'POST', body: JSON.stringify(data) }), updateIntegration: (id: number, data: Partial<{ name: string; config: Record; secrets: Record }>) => apiFetch<{ integration: Integration }>(`/integrations/${id}`, { method: 'PUT', body: JSON.stringify(data) }), deleteIntegration: (id: number) => apiFetch(`/integrations/${id}`, { method: 'DELETE' }), testIntegration: (id: number) => apiFetch<{ ok: boolean; message: string }>(`/integrations/${id}/test`, { method: 'POST' }), listBookmarks: () => apiFetch<{ bookmarks: Bookmark[] }>('/bookmarks'), listBookmarkCategories: () => apiFetch<{ categories: BookmarkCategory[] }>('/bookmarks/categories'), createBookmarkCategory: (data: { name: string; icon?: string; sortOrder?: number }) => apiFetch<{ id: number }>('/bookmarks/categories', { method: 'POST', body: JSON.stringify(data) }), createBookmark: (data: { categoryId?: number | null; title: string; url: string; icon?: string; favorite?: boolean }) => apiFetch<{ id: number }>('/bookmarks', { method: 'POST', body: JSON.stringify(data) }), updateBookmark: (id: number, data: Partial<{ categoryId: number | null; title: string; url: string; icon: string; favorite: boolean }>) => apiFetch<{ ok: boolean }>(`/bookmarks/${id}`, { method: 'PUT', body: JSON.stringify(data) }), deleteBookmark: (id: number) => apiFetch(`/bookmarks/${id}`, { method: 'DELETE' }), listEvents: (limit = 20) => apiFetch<{ events: Event[] }>(`/events?limit=${limit}`), listResources: () => apiFetch<{ resources: Resource[] }>('/integrations/resources'), listTunnels: () => apiFetch<{ tunnels: Tunnel[] }>('/tunnels'), createTunnel: (data: { name: string integrationId: number mode: 'local' | 'remote' | 'dynamic' sourcePort: number endpointHost?: string endpointPort?: number autoStart?: boolean maxRetries?: number retryIntervalMs?: number }) => apiFetch<{ tunnel: Tunnel }>('/tunnels', { method: 'POST', body: JSON.stringify(data) }), deleteTunnel: (id: number) => apiFetch(`/tunnels/${id}`, { method: 'DELETE' }), connectTunnel: (id: number) => apiFetch<{ ok: boolean; status: string; error: string | null; retryCount: number }>(`/tunnels/${id}/connect`, { method: 'POST' }), disconnectTunnel: (id: number) => apiFetch<{ ok: boolean; status: string; error: string | null; retryCount: number }>(`/tunnels/${id}/disconnect`, { method: 'POST' }), listFiles: (integrationId: number, path: string) => apiFetch<{ path: string; entries: FileEntry[] }>(`/files/${integrationId}/list?path=${encodeURIComponent(path)}`), readFile: (integrationId: number, path: string) => apiFetch<{ content: string; encoding: 'utf8' | 'base64'; size: number; mode: number }>( `/files/${integrationId}/content?path=${encodeURIComponent(path)}`, ), writeFile: (integrationId: number, path: string, content: string, encoding: 'utf8' | 'base64' = 'utf8') => apiFetch<{ ok: boolean }>(`/files/${integrationId}/content`, { method: 'PUT', body: JSON.stringify({ path, content, encoding }) }), mkdir: (integrationId: number, path: string) => apiFetch<{ ok: boolean }>(`/files/${integrationId}/mkdir`, { method: 'POST', body: JSON.stringify({ path }) }), renameFile: (integrationId: number, from: string, to: string) => apiFetch<{ ok: boolean }>(`/files/${integrationId}/rename`, { method: 'POST', body: JSON.stringify({ from, to }) }), deleteFile: (integrationId: number, path: string, isDirectory = false) => apiFetch<{ ok: boolean }>(`/files/${integrationId}/delete`, { method: 'POST', body: JSON.stringify({ path, isDirectory }) }), chmodFile: (integrationId: number, path: string, mode: string) => apiFetch<{ ok: boolean }>(`/files/${integrationId}/chmod`, { method: 'POST', body: JSON.stringify({ path, mode }) }), downloadFileUrl: (integrationId: number, path: string) => `/api/files/${integrationId}/download?path=${encodeURIComponent(path)}`, uploadFile: async (integrationId: number, path: string, file: File) => { const form = new FormData() form.append('path', path) form.append('file', file) const token = getToken() const res = await fetch(`/api/files/${integrationId}/upload`, { method: 'POST', headers: token ? { Authorization: `Bearer ${token}` } : undefined, body: form, }) if (!res.ok) { let message = res.statusText try { message = (await res.json()).error ?? message } catch { // ignore non-JSON error bodies } throw new ApiError(res.status, message) } return res.json() as Promise<{ ok: boolean; path: string }> }, } export interface AuthUser { id: number username: string display_name: string | null email: string | null avatar_data_url: string | null } export interface Integration { id: number type: string name: string enabled: boolean status: string config: Record lastCheckedAt: string | null createdAt: string } export interface Tunnel { id: number name: string integrationId: number mode: 'local' | 'remote' | 'dynamic' sourcePort: number endpointHost: string endpointPort: number autoStart: boolean maxRetries: number retryIntervalMs: number createdAt: string status: 'stopped' | 'connecting' | 'connected' | 'retrying' | 'error' error: string | null retryCount: number } export interface Bookmark { id: number category_id: number | null title: string url: string icon: string | null favorite: number status: string last_checked_at: string | null created_at: string } export interface BookmarkCategory { id: number name: string icon: string | null sort_order: number } export interface Event { id: number type: string title: string source: string | null created_at: string } export interface FileEntry { name: string isDirectory: boolean isSymlink: boolean size: number mode: number mtime: number } export interface Resource { name: string status: 'healthy' | 'warning' | 'critical' | 'unknown' detail?: string integration: string }