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.
This commit is contained in:
Adam Moussa 2026-09-22 12:39:44 -04:00
parent bbfa6e4a3c
commit 1c295d854d
No known key found for this signature in database
3 changed files with 169 additions and 0 deletions

54
src/api/client.test.ts Normal file
View file

@ -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");
});
});

87
src/api/client.ts Normal file
View file

@ -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<boolean> | null = null;
async function refreshSession(): Promise<boolean> {
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<Response> {
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<T>(response: Response): Promise<T> {
const text = await response.text();
let body: T & Partial<ErrorEnvelope>;
try {
body = JSON.parse(text) as T & Partial<ErrorEnvelope>;
} 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;
}

28
src/api/types.ts Normal file
View file

@ -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 };
};
};