From 1c295d854ddd2ba23ab970f3a53d5caa16e56585 Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Tue, 22 Sep 2026 12:39:44 -0400 Subject: [PATCH] feat(web): add unused cookie SPA API client Land a credentials-include fetch helper and hand-synced health/me types without wiring pages or domain hooks, so mocks stay the default data path. --- src/api/client.test.ts | 54 ++++++++++++++++++++++++++ src/api/client.ts | 87 ++++++++++++++++++++++++++++++++++++++++++ src/api/types.ts | 28 ++++++++++++++ 3 files changed, 169 insertions(+) create mode 100644 src/api/client.test.ts create mode 100644 src/api/client.ts create mode 100644 src/api/types.ts diff --git a/src/api/client.test.ts b/src/api/client.test.ts new file mode 100644 index 0000000..57ad53e --- /dev/null +++ b/src/api/client.test.ts @@ -0,0 +1,54 @@ +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { apiFetch, readApiJson } from "@/api/client"; + +function walk(dir: string): string[] { + const entries = readdirSync(dir); + const files: string[] = []; + for (const entry of entries) { + const full = join(dir, entry); + const stat = statSync(full); + if (stat.isDirectory()) { + files.push(...walk(full)); + } else if (full.endsWith(".ts") || full.endsWith(".tsx")) { + files.push(full); + } + } + return files; +} + +describe("unused SPA API client", () => { + it("sends credentials and never sets Authorization", async () => { + const fetchMock = vi.fn(async () => new Response(JSON.stringify({ stage: "local", sha: "x" }))); + vi.stubGlobal("fetch", fetchMock); + await apiFetch("/api/health", { headers: { Authorization: "Bearer leaked" } }); + const init = fetchMock.mock.calls[0]?.[1] as RequestInit; + expect(init.credentials).toBe("include"); + const headers = new Headers(init.headers); + expect(headers.get("Authorization")).toBeNull(); + expect(headers.get("Accept")).toBe("application/json"); + vi.unstubAllGlobals(); + }); + + it("is not imported from pages, domain, or mocks", () => { + const roots = ["src/pages", "src/domain", "src/mocks"].map((dir) => join(process.cwd(), dir)); + const hits: string[] = []; + for (const root of roots) { + for (const file of walk(root)) { + const text = readFileSync(file, "utf8"); + if (text.includes("@/api/client") || text.includes("src/api/client")) { + hits.push(file); + } + } + } + expect(hits).toEqual([]); + }); + + it("parses JSON success bodies", async () => { + const body = await readApiJson<{ stage: string }>( + new Response(JSON.stringify({ stage: "local" }), { status: 200 }), + ); + expect(body.stage).toBe("local"); + }); +}); diff --git a/src/api/client.ts b/src/api/client.ts new file mode 100644 index 0000000..6467cda --- /dev/null +++ b/src/api/client.ts @@ -0,0 +1,87 @@ +import type { ErrorEnvelope } from "@/api/types"; + +function pathnameOf(input: RequestInfo | URL): string { + const raw = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + try { + return new URL(raw, "http://local.invalid").pathname; + } catch { + return raw.split("?")[0] ?? raw; + } +} + +function skipRefresh(input: RequestInfo | URL): boolean { + const path = pathnameOf(input); + return ( + path === "/api/auth/login" || + path === "/api/auth/callback" || + path === "/api/auth/refresh" || + path === "/api/auth/logout" + ); +} + +let refreshInFlight: Promise | null = null; + +async function refreshSession(): Promise { + if (!refreshInFlight) { + refreshInFlight = fetch("/api/auth/refresh", { + method: "POST", + credentials: "include", + headers: { Accept: "application/json" }, + }) + .then((response) => response.status === 204) + .catch(() => false) + .finally(() => { + refreshInFlight = null; + }); + } + return refreshInFlight; +} + +export async function apiFetch( + input: RequestInfo | URL, + init: RequestInit = {}, +): Promise { + const headers = new Headers(init.headers); + if (!headers.has("Accept")) headers.set("Accept", "application/json"); + headers.delete("Authorization"); + const requestInit: RequestInit = { ...init, credentials: "include", headers }; + const response = await fetch(input, requestInit); + if ((response.status !== 401 && response.status !== 403) || skipRefresh(input)) { + return response; + } + const refreshed = await refreshSession(); + if (refreshed) return fetch(input, requestInit); + return response; +} + +export class ApiError extends Error { + readonly status: number; + readonly code?: string; + + constructor(message: string, status: number, code?: string) { + super(message); + this.name = "ApiError"; + this.status = status; + this.code = code; + } +} + +export async function readApiJson(response: Response): Promise { + const text = await response.text(); + let body: T & Partial; + try { + body = JSON.parse(text) as T & Partial; + } catch { + throw response.ok + ? new Error("Invalid JSON from API") + : new ApiError(`HTTP ${response.status}`, response.status); + } + if (!response.ok) { + throw new ApiError( + body.error?.message || `HTTP ${response.status}`, + response.status, + body.error?.code, + ); + } + return body; +} diff --git a/src/api/types.ts b/src/api/types.ts new file mode 100644 index 0000000..e8c1837 --- /dev/null +++ b/src/api/types.ts @@ -0,0 +1,28 @@ +export type ErrorEnvelope = { + error: { + code: string; + message: string; + correlationId: string; + }; +}; + +export type HealthResponse = { + stage: string; + sha: string; +}; + +export type MeResponse = { + id: string; + email: string; + name: string; + role: "admin" | "ap_processor" | "approver" | "viewer"; +}; + +export type ApiPaths = { + "/api/health": { + get: { response: HealthResponse }; + }; + "/api/me": { + get: { response: MeResponse }; + }; +};