/** * T2 — Zod-validated control-plane HTTP client. * * INV3 (owned): the request builders have NO parameter for account/tenant; every call carries only * the same-origin cookie/passkey session (`credentials: 'include'`). The account is derived * server-side. Every response is parsed through a Zod schema before return; a parse failure throws * a typed {@link ApiError} rather than returning a torn object. */ import { z } from 'zod' import type { RelayWebConfig } from './config' import { ApiError } from './errors' import { CapabilityTokenResponseSchema, HostListWireSchema, HostRecordWireSchema, HostStatusResponseSchema, PairingCodeResponseSchema, type CapabilityRight, type HostRecord, type HostStatus, } from './api-schemas' /** v0.8 surface — ships against the flat SQLite table + cookie session. No token issuance. */ export interface ApiClientV08 { listHosts(): Promise getHost(hostId: string): Promise requestPairingCode(): Promise<{ readonly code: string; readonly expiresAt: string }> hostStatus(hostId: string): Promise } /** v0.9+ surface — GROWS once P5 capability-token issuance exists (INDEX §1). */ export interface ApiClient extends ApiClientV08 { issueCapabilityToken(hostId: string, rights: readonly CapabilityRight[]): Promise } const JSON_HEADERS: Readonly> = { Accept: 'application/json' } /** Map a non-OK HTTP status to a typed ApiError kind (no silent swallow). */ function errorForStatus(status: number, path: string): ApiError { if (status === 401) return new ApiError('unauthenticated', `unauthenticated at ${path}`, status) if (status === 403) return new ApiError('forbidden', `forbidden at ${path}`, status) return new ApiError('http', `request to ${path} failed with status ${status}`, status) } export function createApiClient(cfg: RelayWebConfig, fetchImpl: typeof fetch = fetch): ApiClient { const base = cfg.apiBase async function requestJson(path: string, init?: RequestInit): Promise { let res: Response try { res = await fetchImpl(`${base}${path}`, { credentials: 'include', // cookie/passkey session — the ONLY identity (INV3) headers: JSON_HEADERS, ...init, }) } catch (cause) { throw new ApiError('network', `network error calling ${path}`, null) } if (!res.ok) throw errorForStatus(res.status, path) try { return await res.json() } catch { throw new ApiError('parse', `invalid JSON body from ${path}`, res.status) } } /** Parse `body` with `schema`, converting a ZodError into a typed ApiError('parse'). */ function validate(schema: S, body: unknown, path: string): z.infer { const parsed = schema.safeParse(body) if (!parsed.success) { throw new ApiError('parse', `malformed response from ${path}: ${parsed.error.message}`) } return parsed.data } function postJson(path: string, payload: unknown): Promise { return requestJson(path, { method: 'POST', headers: { ...JSON_HEADERS, 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }) } return { async listHosts(): Promise { const path = '/api/hosts' return validate(HostListWireSchema, await requestJson(path), path) }, async getHost(hostId: string): Promise { const path = `/api/hosts/${encodeURIComponent(hostId)}` return validate(HostRecordWireSchema, await requestJson(path), path) }, async requestPairingCode(): Promise<{ readonly code: string; readonly expiresAt: string }> { const path = '/api/pairing-codes' // Body is empty: the account is derived from the session, NEVER sent (INV3). return validate(PairingCodeResponseSchema, await postJson(path, {}), path) }, async hostStatus(hostId: string): Promise { const path = `/api/hosts/${encodeURIComponent(hostId)}/status` const parsed = validate(HostStatusResponseSchema, await requestJson(path), path) return parsed.status }, async issueCapabilityToken( hostId: string, rights: readonly CapabilityRight[], ): Promise { const path = `/api/hosts/${encodeURIComponent(hostId)}/capability-token` // Only the rights subset is sent; host+account scope is bound server-side from the session. const parsed = validate(CapabilityTokenResponseSchema, await postJson(path, { rights }), path) return parsed.token }, } }