Files
frontend/src/shared/api/client.ts
T
ksv741 edd54892cd fix(auth): isolate session races and financial caches
TD-001 and TD-002: serialize cookie writes, ignore stale responses and reset financial queries on identity changes. Backend cookie lifecycle regression: ec5780d.
2026-09-21 21:09:23 +03:00

100 lines
2.8 KiB
TypeScript

import createClient from "openapi-fetch";
import createQueryClient from "openapi-react-query";
import type { paths } from "./generated/schema";
const HTTP_UNAUTHORIZED = 401;
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:3000";
type AuthHandlers = {
generation?: () => number;
accessToken: () => string | null;
refresh: () => Promise<boolean>;
clear: () => void;
};
let auth: AuthHandlers | null = null;
let hasAuthenticatedSession = false;
const authSessionListeners = new Set<() => void>();
function setAuthenticatedSession(next: boolean) {
if (next === hasAuthenticatedSession) return;
hasAuthenticatedSession = next;
for (const listener of authSessionListeners) listener();
}
function configureAuthClient(handlers: AuthHandlers | null) {
auth = handlers;
setAuthenticatedSession(Boolean(auth?.accessToken()));
}
function getAuthenticatedSessionSnapshot() {
return hasAuthenticatedSession;
}
function subscribeAuthenticatedSession(listener: () => void) {
authSessionListeners.add(listener);
return () => authSessionListeners.delete(listener);
}
async function authenticatedFetch(request: Request): Promise<Response> {
const isAuthRequest = new URL(request.url).pathname.startsWith(
"/api/v1/auth/",
);
const session = auth;
const generation = session?.generation?.();
const isCurrent = () => generation === auth?.generation?.();
const token = session?.accessToken();
const withToken = (source: Request, value: string) => {
const headers = new Headers(source.headers);
headers.set("Authorization", `Bearer ${value}`);
return new Request(source, { headers });
};
const authorized =
token && !isAuthRequest ? withToken(request, token) : request;
const retryRequest = authorized.clone();
const response = await fetch(authorized);
if (!isAuthRequest && !isCurrent())
throw new DOMException("Session changed", "AbortError");
if (
response.status !== HTTP_UNAUTHORIZED ||
isAuthRequest ||
!auth ||
(
await response
.clone()
.json()
.catch(() => null)
)?.code !== "UNAUTHORIZED"
)
return response;
if (!isCurrent()) throw new DOMException("Session changed", "AbortError");
if (!(await auth.refresh())) {
if (isCurrent()) auth.clear();
return response;
}
if (!isCurrent()) throw new DOMException("Session changed", "AbortError");
const refreshedToken = auth.accessToken();
if (!refreshedToken) return response;
const retried = await fetch(withToken(retryRequest, refreshedToken));
if (!isCurrent()) throw new DOMException("Session changed", "AbortError");
return retried;
}
const apiClient = createClient<paths>({
baseUrl: apiBaseUrl,
credentials: "include",
fetch: authenticatedFetch,
});
const apiQueryClient = createQueryClient(apiClient);
export {
apiClient,
apiQueryClient,
configureAuthClient,
getAuthenticatedSessionSnapshot,
subscribeAuthenticatedSession,
};