mirror of
https://github.com/Sea-Haven-Industries/seahaven-ap.git
synced 2026-10-07 16:18:53 +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