seahaven-ap/src/api/client.ts
Adam Moussa 864531b51e
feat(api): portal contract, cookie auth, and domain stubs (AP-51) (#64)
* feat(api): serve portal-shaped health and error envelope

Move liveness to GET /api/health { stage, sha } with a Node 24 image on 8080 so ALB probes and deploy verify do not need auth or a database ping.

* feat(api): switch live auth to host cookie BFF

Replace Bearer as the documented session path with Cognito hosted UI plus __Host-ap_* cookies so the SPA can call /api with credentials include.

* 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.

* feat(api): add master-data OpenAPI and Hono stubs

* feat(api): add invoice, line, and document stubs

* feat(api): add approval policy, inbox, and activity stubs

* test(web): fix SPA client fetch mock types

* test(web): cast fetch mock call args for tsc

* fix(api): do not default DEV_AUTH_BYPASS outside local migrate

* fix(api): replace invoice lines in a single transaction

* fix(api): create invoices and lines in one transaction

* fix(api): inline GIT_SHA from the image build arg

* fix(api): stop PATCH from skipping the approval workflow

* fix(api): address review feedback

* fix(ci): format upsert-user test

* fix(api): document only the auth statuses the routes return

* fix(api): drop health 400 responses the routes never return
2026-09-25 22:43:44 +00:00

87 lines
2.4 KiB
TypeScript

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