import type { APIToken, Connection, Health, HostKeyInfo, JobSnapshot, Me, PackageInfo, Plan, Preflight, PreviewResponse, Source, SourceResponse, SourcesResponse, SourceStatus, TargetInventory, User, } from './types' /** ApiError carries the server's message plus anything it attached to it. */ export class ApiError extends Error { status: number body: Record constructor(status: number, message: string, body: Record = {}) { super(message) this.status = status this.body = body } /** True when the target's SSH host key still has to be approved. */ get needsTrust(): boolean { return this.body.needsTrust === true } } // Paths whose own job is to report or resolve "not authenticated" — a 401 // from one of these is the expected outcome of a bad login, not a session // that dropped out from under the app, so it must not trigger the global // handler below. const authPaths = new Set(['/api/me', '/api/login', '/api/setup']) // Set by AuthGate so a 401 on any other endpoint (a session that expired or // was revoked mid-use) drops the app back to the login screen, without every // call site having to check for it. let onUnauthorized: (() => void) | null = null export function setUnauthorizedHandler(fn: (() => void) | null) { onUnauthorized = fn } async function request(path: string, init?: RequestInit): Promise { const headers: Record = { ...(init?.headers as Record) } if (init?.body) headers['Content-Type'] = 'application/json' const res = await fetch(path, { ...init, headers, credentials: 'same-origin' }) if (res.status === 401 && !authPaths.has(path)) onUnauthorized?.() if (res.status === 204) return undefined as T const text = await res.text() let body: Record = {} if (text) { try { body = JSON.parse(text) } catch { if (!res.ok) throw new ApiError(res.status, text.slice(0, 400)) } } if (!res.ok) { const msg = typeof body.error === 'string' ? body.error : `request failed (${res.status})` throw new ApiError(res.status, msg, body) } return body as T } const post = (path: string, body?: unknown) => request(path, { method: 'POST', body: body === undefined ? undefined : JSON.stringify(body) }) export const api = { health: () => request('/api/health'), source: () => request('/api/source'), volumeSizes: () => request<{ volumes: Record }>('/api/source/sizes'), me: () => request('/api/me'), setup: (username: string, password: string) => post('/api/setup', { username, password }), login: (username: string, password: string) => post('/api/login', { username, password }), logout: () => request('/api/logout', { method: 'POST' }), users: () => request('/api/users'), createUser: (username: string, password: string) => post('/api/users', { username, password }), deleteUser: (id: string) => request(`/api/users/${id}`, { method: 'DELETE' }), tokens: () => request('/api/tokens'), createToken: (name: string) => post('/api/tokens', { name }), revokeToken: (id: string) => request(`/api/tokens/${id}`, { method: 'DELETE' }), sources: () => request('/api/sources'), saveSource: (s: Partial) => post('/api/sources', s), deleteSource: (id: string) => request(`/api/sources/${id}`, { method: 'DELETE' }), selectSource: (id: string) => post(`/api/sources/${id}/select`), probeSource: (id: string) => post(`/api/sources/${id}/probe`), trustSource: (id: string, fingerprint: string) => post<{ trusted: boolean }>(`/api/sources/${id}/trust`, { fingerprint }), connections: () => request('/api/connections'), saveConnection: (c: Partial) => post('/api/connections', c), deleteConnection: (id: string) => request(`/api/connections/${id}`, { method: 'DELETE' }), probe: (id: string) => post(`/api/connections/${id}/probe`), trust: (id: string, fingerprint: string) => post<{ trusted: boolean }>(`/api/connections/${id}/trust`, { fingerprint }), testConnection: (id: string) => post(`/api/connections/${id}/test`), targetInventory: (id: string) => request(`/api/connections/${id}/inventory`), preview: (plan: Plan) => post('/api/plan/preview', plan), migrateSSH: (connectionId: string, plan: Plan) => post('/api/migrate/ssh', { connectionId, plan }), buildPackage: (plan: Plan, format: 'tar' | 'dir') => post('/api/migrate/package', { plan, format }), jobs: () => request('/api/jobs'), job: (id: string) => request(`/api/jobs/${id}`), cancelJob: (id: string) => post<{ canceled: boolean }>(`/api/jobs/${id}/cancel`), deleteJob: (id: string) => request(`/api/jobs/${id}`, { method: 'DELETE' }), packages: () => request('/api/packages'), deletePackage: (name: string) => request(`/api/packages/${encodeURIComponent(name)}`, { method: 'DELETE' }), downloadUrl: (name: string) => `/api/packages/${encodeURIComponent(name)}/download`, /** Opens the live progress stream for a job. Cookies ride along automatically: it's a same-origin request. */ jobEvents: (id: string) => new EventSource(`/api/jobs/${id}/events`), }