// --------------------------------------------------------------------------- // pages / pageApi.ts — EXIT wave 2 (W2-7, contract Y1). // // `GET /api/v1/pages/` and nothing else. One function, because Y1's whole // point is that Collections and Procurement arrive as a different `key` — if // this file ever grows a per-page branch, the envelope has stopped paying for // itself and that should be argued in the doc, not worked around here. // // ⚠ NO `import.meta` — the module compiles and runs under bare node so the // verify gates can exercise it (the `shell/session.ts` convention). // --------------------------------------------------------------------------- import { API_V1, CREDENTIALS } from "../apiContract"; import type { PageEnvelope } from "../ui/types"; export type PageResult = | { ok: true; page: PageEnvelope } | { ok: false; status: number; code: string; message: string }; /** * Turn a non-2xx into something a human can act on. * * Y1 rule 8 fixes the codes: **401** no session · **403** no grant (including * an unpermitted `bu`) · **400** a malformed parameter · **404** an unknown page * key. The body is always `{"error":{"code","message"}}` — and **never an empty * 200**, because an empty table reads as "you have no data" rather than "you * were not allowed to see this". * * ⚠ A 5xx message is NOT surfaced verbatim. A server stack trace on a screen * behind a login is still a leak, and the shell's own gate has a negative * control for exactly that (`NC 5xx-message-leaked`). */ async function asError(res: Response): Promise { let code = ""; let message = ""; try { const body = (await res.json()) as { error?: { code?: string; message?: string } }; code = String(body?.error?.code ?? ""); message = String(body?.error?.message ?? ""); } catch { /* a non-JSON body is not a reason to show a blank screen */ } if (res.status >= 500) { return { ok: false, status: res.status, code: code || "server_error", message: "Something went wrong on our side. Try again in a moment." }; } const fallback = res.status === 401 ? "Your session has ended. Sign in again." : res.status === 403 ? "This account does not have access to that." : res.status === 404 ? "That page does not exist." : "That request could not be completed."; return { ok: false, status: res.status, code: code || String(res.status), message: message || fallback }; } export async function fetchPage( key: string, params: Record = {} ): Promise { const q = new URLSearchParams(params).toString(); const url = `${API_V1}/pages/${encodeURIComponent(key)}${q ? `?${q}` : ""}`; try { const res = await fetch(url, { credentials: CREDENTIALS }); if (!res.ok) return asError(res); const page = (await res.json()) as PageEnvelope; // A 200 whose body is not an envelope is a broken contract, not an empty // page — say so rather than rendering a title-less blank. if (!page || typeof page !== "object" || !Array.isArray(page.blocks)) { return { ok: false, status: 200, code: "bad_envelope", message: "The server sent a page this client could not read." }; } return { ok: true, page }; } catch { // status 0 = transport, the encoding session.ts and nav.ts already use. return { ok: false, status: 0, code: "offline", message: "The server could not be reached." }; } }