// The one place that talks HTTP. Relative paths on purpose: the browser client is served from the // SAME ORIGIN as the platform, which is a fact and not a setting — the platform has no CORS layer // at all, so a cross-origin client is inoperable as a class. A dev server proxies `/v0` onto its // own origin (see vite.config.ts). import type { Page, Problem } from './contract'; /** The version prefix, in ONE place: the upload speaks the same surface and must not carry a copy. */ export const basePath = '/v0'; // CSRF on the cookie path. What protects is the PRESENCE of the header; the value is arbitrary and // carries no token semantics. Required on same-origin requests too — it is not a CORS mechanism. const clientHeader = 'X-TM-Client'; const clientHeaderValue = 'web'; const safeMethods = new Set(['GET', 'HEAD']); /** A failed call, with the product-level problem when the platform sent one. */ export class ApiError extends Error { readonly status: number; readonly problem: Problem | null; constructor(status: number, problem: Problem | null, message: string) { super(message); this.name = 'ApiError'; this.status = status; this.problem = problem; } } interface Options { method?: string; body?: unknown; query?: Record; } export async function request(path: string, options: Options = {}): Promise { const method = options.method ?? 'GET'; const headers: Record = { Accept: 'application/json' }; if (!safeMethods.has(method)) headers[clientHeader] = clientHeaderValue; if (options.body !== undefined) headers['Content-Type'] = 'application/json'; const query = new URLSearchParams(); for (const [name, value] of Object.entries(options.query ?? {})) { if (value !== undefined) query.set(name, String(value)); } const url = `${basePath}${path}${query.size > 0 ? `?${query.toString()}` : ''}`; let response: Response; try { response = await fetch(url, { method, headers, // Explicit although it is the default: the session is a cookie, and a silent change of the // default would log everyone out with no error to point at. credentials: 'same-origin', body: options.body === undefined ? undefined : JSON.stringify(options.body), }); } catch { // The platform was not reached at all — a different thing from a refusal, and the screen says // so differently. throw new ApiError(0, null, 'Server unreachable'); } if (!response.ok) throw new ApiError(response.status, await problemOf(response), 'Request refused'); return (await response.json()) as T; } // Errors arrive as RFC 9457 problem+json, and `detail` is already a product phrase — the engine's // own detail strings never cross the boundary. Anything else is read as "no problem body". async function problemOf(response: Response): Promise { if (!response.headers.get('Content-Type')?.includes('problem+json')) return null; try { return (await response.json()) as Problem; } catch { return null; } } // Following the cursor lives here once rather than in every read: a server MAY page any collection, // and a client that read the head and called it the whole thing would do so silently. // // The cap is not a page-size policy but a stop against a server that keeps handing out a cursor. const maxPages = 50; export async function requestAll( path: string, rowsOf: (body: Body) => Row[], query: Options['query'] = {}, ): Promise<{ page: Page; rows: Row[] }> { const rows: Row[] = []; let cursor: string | undefined; let page: Page | undefined; for (let fetched = 0; fetched < maxPages; fetched++) { const body = await request(path, { query: { ...query, cursor } }); rows.push(...rowsOf(body)); // The revision of a torn list is its OLDEST page, never its newest. Pages are read one after // another and the book's revision moves between them (contract, `NextCursor`); stamped with the // last page, a list whose head predates a frame already applied would pass the read guard and // overwrite what that frame wrote. page = { revision: page === undefined ? body.revision : Math.min(page.revision, body.revision), next_cursor: body.next_cursor, }; // Anything that is not a cursor ends the walk. The field is required by the contract, so an // absent one means a non-conformant server — and treating that as "one more page" re-requests // the identical page until the cap, which reports a server defect as a client hang. if (typeof body.next_cursor !== 'string' || body.next_cursor === '') return { page, rows }; cursor = body.next_cursor; } throw new ApiError(0, null, `The list did not end within ${String(maxPages)} pages: ${path}`); }