mirror of
https://github.com/Sea-Haven-Industries/seahaven-ap.git
synced 2026-10-01 20:13:22 +00:00
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:
parent
bbfa6e4a3c
commit
1c295d854d
3 changed files with 169 additions and 0 deletions
54
src/api/client.test.ts
Normal file
54
src/api/client.test.ts
Normal 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
87
src/api/client.ts
Normal 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
28
src/api/types.ts
Normal 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 };
|
||||
};
|
||||
};
|
||||
Loading…
Add table
Reference in a new issue