Files
DockMV/web/src/api.ts
T
kawaandClaude Sonnet 5 8d20d9be84
Sync Gitea releases to GitHub / sync-releases (push) Canceled after 0s
Feature 1: Local accounts, replacing shared token
- Local accounts with bcrypt password hashing; first-run setup via POST /api/setup
- Personal API tokens (dmv_<48hex>, SHA-256 hashed at rest) for scripted access
- Server-side in-memory sessions with 32-byte secure cookie (dockmv_session, 7-day TTL, sliding renewal)
- Login rate limiting (exponential backoff 1s–30s cap) per IP
- Refuse to bind non-loopback while no account exists, unless DOCKMV_TRUST_ADDR=1 (for Docker's port mapping)
- Every account can manage every other account (no roles in v1)
- Auth middleware: public-path allowlist (/api/setup, /api/login, /api/logout, /api/me, /api/health) + session cookie check + API token (X-Auth-Token or Authorization: Bearer) check
- Frontend AuthGate gates app on GET /api/me; shows setup screen or login form or app tree as needed
- Account tab for personal token management; sign-out button in topbar
- Break: removed --token flag, DOCKMV_TOKEN env var, ?token= query param, /api/health no longer auto-responds when unauthenticated

Verified: go build/vet clean, frontend tsc+vite clean. Sandbox cannot execute binaries to test setup→login→session→token flow at runtime; recommend manual pass before merge.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 15:13:54 +02:00

112 lines
5.4 KiB
TypeScript

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<string, unknown>
constructor(status: number, message: string, body: Record<string, unknown> = {}) {
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<T>(path: string, init?: RequestInit): Promise<T> {
const headers: Record<string, string> = { ...(init?.headers as Record<string, string>) }
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<string, unknown> = {}
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 = <T>(path: string, body?: unknown) =>
request<T>(path, { method: 'POST', body: body === undefined ? undefined : JSON.stringify(body) })
export const api = {
health: () => request<Health>('/api/health'),
source: () => request<SourceResponse>('/api/source'),
volumeSizes: () => request<{ volumes: Record<string, number> }>('/api/source/sizes'),
me: () => request<Me>('/api/me'),
setup: (username: string, password: string) => post<Me>('/api/setup', { username, password }),
login: (username: string, password: string) => post<Me>('/api/login', { username, password }),
logout: () => request<void>('/api/logout', { method: 'POST' }),
users: () => request<User[]>('/api/users'),
createUser: (username: string, password: string) => post<User>('/api/users', { username, password }),
deleteUser: (id: string) => request<void>(`/api/users/${id}`, { method: 'DELETE' }),
tokens: () => request<APIToken[]>('/api/tokens'),
createToken: (name: string) => post<APIToken & { token: string }>('/api/tokens', { name }),
revokeToken: (id: string) => request<void>(`/api/tokens/${id}`, { method: 'DELETE' }),
sources: () => request<SourcesResponse>('/api/sources'),
saveSource: (s: Partial<Source>) => post<Source>('/api/sources', s),
deleteSource: (id: string) => request<void>(`/api/sources/${id}`, { method: 'DELETE' }),
selectSource: (id: string) => post<SourceStatus>(`/api/sources/${id}/select`),
probeSource: (id: string) => post<HostKeyInfo>(`/api/sources/${id}/probe`),
trustSource: (id: string, fingerprint: string) =>
post<{ trusted: boolean }>(`/api/sources/${id}/trust`, { fingerprint }),
connections: () => request<Connection[]>('/api/connections'),
saveConnection: (c: Partial<Connection>) => post<Connection>('/api/connections', c),
deleteConnection: (id: string) => request<void>(`/api/connections/${id}`, { method: 'DELETE' }),
probe: (id: string) => post<HostKeyInfo>(`/api/connections/${id}/probe`),
trust: (id: string, fingerprint: string) => post<{ trusted: boolean }>(`/api/connections/${id}/trust`, { fingerprint }),
testConnection: (id: string) => post<Preflight>(`/api/connections/${id}/test`),
targetInventory: (id: string) => request<TargetInventory>(`/api/connections/${id}/inventory`),
preview: (plan: Plan) => post<PreviewResponse>('/api/plan/preview', plan),
migrateSSH: (connectionId: string, plan: Plan) => post<JobSnapshot>('/api/migrate/ssh', { connectionId, plan }),
buildPackage: (plan: Plan, format: 'tar' | 'dir') => post<JobSnapshot>('/api/migrate/package', { plan, format }),
jobs: () => request<JobSnapshot[]>('/api/jobs'),
job: (id: string) => request<JobSnapshot>(`/api/jobs/${id}`),
cancelJob: (id: string) => post<{ canceled: boolean }>(`/api/jobs/${id}/cancel`),
deleteJob: (id: string) => request<void>(`/api/jobs/${id}`, { method: 'DELETE' }),
packages: () => request<PackageInfo[]>('/api/packages'),
deletePackage: (name: string) => request<void>(`/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`),
}