dev_arc_aws/src/lib/api.ts
Claude 7edf4548d9
Phase 3: remote file manager (SFTP list/edit/upload/download/rename/delete/chmod)
Ephemeral per-request SFTP connections, whole-file-in-memory view/edit
with a 50MB cap and binary detection, streaming download for files of
any size, multipart upload. No sudo/permission-elevation or
server-to-server transfer in this pass (documented gaps, matching
Termix's own scope for the latter).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BbJV5nm8KPVH1oNJYKpnoF
2026-06-19 11:56:04 +00:00

212 lines
8.1 KiB
TypeScript

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<T>(path: string, options: RequestInit = {}): Promise<T> {
const token = getToken()
const headers: Record<string, string> = {
...(options.body ? { 'Content-Type': 'application/json' } : {}),
...(options.headers as Record<string, string> | 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<T>
}
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<string, string>; secrets?: Record<string, string> }) =>
apiFetch<{ integration: Integration }>('/integrations', { method: 'POST', body: JSON.stringify(data) }),
updateIntegration: (id: number, data: Partial<{ name: string; config: Record<string, string>; secrets: Record<string, string> }>) =>
apiFetch<{ integration: Integration }>(`/integrations/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
deleteIntegration: (id: number) => apiFetch<void>(`/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<void>(`/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<void>(`/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<string, string>
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
}