/** * Control-plane ADMIN API client. Each method takes a freshly-minted `manage` bearer token and calls * the loopback CP admin surface. Responses are Zod-validated at the boundary (the CP is trusted for * auth but its payloads are still external data → validate, never `as`). Non-2xx ⇒ `CpClientError`. * * CP contract (control-plane/src/api/provision.ts, mounted at root, `Authorization: Bearer `): * - GET /accounts/:id/hosts → 200 [HostRecord...] (agentPubkey base64) * - POST /accounts/:id/pairing-codes → 201 { code, expiresAt } * - DELETE /hosts/:hostId → 204 * * We expose only a curated host view to the SPA (never leak agentPubkey/enrollFpr). */ import { z } from 'zod' export interface HostView { readonly hostId: string readonly subdomain: string readonly status: string readonly lastSeen: string | undefined readonly createdAt: string | undefined readonly revokedAt: string | null | undefined /** Cert expiry, if the CP ever includes one (not in today's HostRecord) — surfaced when present. */ readonly notAfter: string | undefined } export interface IssuedPairing { readonly code: string readonly expiresAt: string } export interface CpClient { listHosts(accountId: string, token: string): Promise createPairingCode(accountId: string, token: string): Promise deleteHost(hostId: string, token: string): Promise } /** Upstream failure — carries an HTTP-ish status for the route layer to map (kept generic; no CP body leaked). */ export class CpClientError extends Error { readonly status: number constructor(status: number, message: string) { super(message) this.name = 'CpClientError' this.status = status } } // Lenient: require the fields we render; passthrough-tolerate everything else the CP adds/removes. const HostRecordSchema = z .object({ hostId: z.string(), subdomain: z.string(), status: z.string(), lastSeen: z.string().optional(), createdAt: z.string().optional(), revokedAt: z.string().nullable().optional(), notAfter: z.string().optional(), }) .passthrough() const HostListSchema = z.array(HostRecordSchema) const IssuedPairingSchema = z.object({ code: z.string().min(1), expiresAt: z.string().min(1) }) function toHostView(rec: z.infer): HostView { return { hostId: rec.hostId, subdomain: rec.subdomain, status: rec.status, lastSeen: rec.lastSeen, createdAt: rec.createdAt, revokedAt: rec.revokedAt, notAfter: rec.notAfter, } } type FetchFn = typeof fetch interface CpClientDeps { readonly cpUrl: string readonly fetchFn?: FetchFn } async function readJson(res: Response): Promise { try { return await res.json() } catch { throw new CpClientError(502, 'control-plane returned a non-JSON response') } } export function createCpClient(deps: CpClientDeps): CpClient { const doFetch: FetchFn = deps.fetchFn ?? fetch const authHeaders = (token: string): Record => ({ authorization: `Bearer ${token}`, accept: 'application/json' }) const call = async (path: string, init: RequestInit): Promise => { try { return await doFetch(`${deps.cpUrl}${path}`, init) } catch { // Network/DNS/connection failure — the CP is unreachable. throw new CpClientError(502, 'control-plane unreachable') } } return { async listHosts(accountId, token) { const res = await call(`/accounts/${encodeURIComponent(accountId)}/hosts`, { headers: authHeaders(token) }) if (!res.ok) throw new CpClientError(res.status, `list hosts failed (${res.status})`) const parsed = HostListSchema.parse(await readJson(res)) return parsed.map(toHostView) }, async createPairingCode(accountId, token) { const res = await call(`/accounts/${encodeURIComponent(accountId)}/pairing-codes`, { method: 'POST', headers: { ...authHeaders(token), 'content-type': 'application/json' }, body: '{}', }) if (!res.ok) throw new CpClientError(res.status, `issue pairing code failed (${res.status})`) const parsed = IssuedPairingSchema.parse(await readJson(res)) return { code: parsed.code, expiresAt: parsed.expiresAt } }, async deleteHost(hostId, token) { const res = await call(`/hosts/${encodeURIComponent(hostId)}`, { method: 'DELETE', headers: authHeaders(token) }) if (!res.ok && res.status !== 204) throw new CpClientError(res.status, `revoke host failed (${res.status})`) }, } }