loopable / web /src /pages /pageApi.ts
fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
092334a verified
Raw
History Blame Contribute Delete
3.5 kB
// ---------------------------------------------------------------------------
// pages / pageApi.ts — EXIT wave 2 (W2-7, contract Y1).
//
// `GET /api/v1/pages/<key>` 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<PageResult> {
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<string, string> = {}
): Promise<PageResult> {
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." };
}
}