/** * Typed fetch wrappers for the Task-3 harness HTTP contract the chat selection * UI depends on. Every response body is untrusted and is normalized through the * runtime guards before it reaches state — a 404 (catalog) and a 422 (selection) * are mapped to typed, body-free error results so a raw gateway body is never * rendered, and the caller's requested tuple is preserved verbatim on failure. * * This module talks ONLY to the harness/chat-preferences endpoints. It never * calls `/api/providers*` — provider identity lives inside the harness catalog. */ import { asHarnessCatalog, asHarnessSelection, asHarnessSummaries } from './runtime-guards'; import type { HarnessCatalog, HarnessSelection, HarnessSummary } from '@/lib/types'; /** A catalog fetch either yields the typed catalog or a typed unavailability — * never a thrown raw body. */ export type CatalogResult = | { ok: true; catalog: HarnessCatalog } | { ok: false; code: 'catalog_unavailable'; harnessId: string; message: string }; export type SelectionErrorCode = 'selection_invalid' | 'model_unavailable'; /** A persist either confirms the stored tuple or reports a typed domain failure * that echoes back the exact tuple the caller requested. */ export type SelectionPersistResult = | { ok: true; selection: HarnessSelection } | { ok: false; code: SelectionErrorCode; message: string; requested: HarnessSelection }; /** A safe, generic message for an unavailable catalog — the raw 404 body is * never surfaced. */ const CATALOG_UNAVAILABLE_MESSAGE = 'This harness catalog is currently unavailable.'; /** A safe, generic message for a rejected selection. The untrusted 422 body's * own `message` is deliberately NEVER surfaced — only this fixed copy — so a * raw gateway detail can never leak into the UI. Only the closed `code` enum is * read from the body. */ const SELECTION_REJECTED_MESSAGE = 'This selection was rejected.'; async function readJson(response: Response): Promise { return response.json().catch(() => null); } function safeSelectionCode(body: unknown): SelectionErrorCode { if (typeof body === 'object' && body !== null && 'code' in body) { const code = (body as { code: unknown }).code; if (code === 'selection_invalid' || code === 'model_unavailable') return code; } // Default to the more conservative "invalid" classification for anything // unrecognized rather than guessing "model_unavailable". return 'selection_invalid'; } /** `GET /api/harnesses` → the list of harness summaries. A non-OK response * normalizes to an empty list (the UI then has no harness to select). */ export async function fetchHarnesses(): Promise { const response = await fetch('/api/harnesses', { credentials: 'include', headers: { Accept: 'application/json' }, }); if (!response.ok) return []; return asHarnessSummaries(await readJson(response)); } /** `GET /api/harnesses/:harnessId/catalog` → the harness-scoped catalog. A 404 * (or any non-OK) becomes a typed `catalog_unavailable` result rather than a * fallback catalog or a rendered raw body. */ export async function fetchCatalog(harnessId: string): Promise { const response = await fetch(`/api/harnesses/${encodeURIComponent(harnessId)}/catalog`, { credentials: 'include', headers: { Accept: 'application/json' }, }); if (!response.ok) { return { ok: false, code: 'catalog_unavailable', // Scoped to the requested harness id, never the untrusted body's echo. harnessId, message: CATALOG_UNAVAILABLE_MESSAGE, }; } return { ok: true, catalog: asHarnessCatalog(await readJson(response), harnessId) }; } /** `GET /api/chat/preferences/selection` → the persisted tuple, or null when * unset or malformed. */ export async function fetchPersistedSelection(): Promise { const response = await fetch('/api/chat/preferences/selection', { credentials: 'include', headers: { Accept: 'application/json' }, }); if (!response.ok) return null; const body = await readJson(response); if (typeof body !== 'object' || body === null) return null; return asHarnessSelection((body as { selection?: unknown }).selection); } /** `PUT /api/chat/preferences/selection` with the structured tuple as the body. * On success returns the confirmed selection; on a typed domain failure (422) * or validation error, returns a typed result carrying the EXACT requested * tuple — never the body's echo — and never the raw body text. */ export async function persistSelection( selection: HarnessSelection, ): Promise { const requested: HarnessSelection = { harnessId: selection.harnessId, providerId: selection.providerId, modelId: selection.modelId, }; const response = await fetch('/api/chat/preferences/selection', { method: 'PUT', credentials: 'include', headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, body: JSON.stringify(requested), }); if (!response.ok) { const body = await readJson(response); return { ok: false, code: safeSelectionCode(body), // Fixed copy only — the untrusted body's message is never surfaced. message: SELECTION_REJECTED_MESSAGE, requested, }; } const body = await readJson(response); const confirmed = typeof body === 'object' && body !== null ? asHarnessSelection((body as { selection?: unknown }).selection) : null; // A malformed 2xx body is treated as a confirmation of exactly what we sent — // the server accepted the tuple, so the requested tuple is the source of truth. return { ok: true, selection: confirmed ?? requested }; }