import { useAuthStore } from "@/client/stores/auth-store"; import { SESSION_MODES, STORAGE_KEYS } from "@/client/lib/constants"; import { readStorageString, writeStorageString } from "@/client/lib/storage"; import { D1_BOOKMARK_HEADER } from "@/shared/bookmark"; import type { User, Workspace, WorkspaceMembershipSummary, Page, PageKind, GetPageResponse, WorkspaceMember, ApiError, InvitePreview, SearchResult, PageShare, SharedPageInfo, SharedPagesResponse, GetPageAncestorsResponse, PageSnapshotResponse, PageRouteBootstrapResponse, ResolvePageMentionsResponse, PublishedPageWithMeta, SitePageStatus, SiteSlugAvailability, WorkspaceSiteResponse, WorkspaceSiteUpdateRequest, ArchivedPage, } from "@/shared/types"; const API_BASE = "/api/v1"; const AUTH_REFRESH_PATH = "/auth/refresh"; let pendingSessionRefresh: Promise< { ok: true; data: { user: User; accessToken: string } } | { ok: false; reason: "rejected" | "network" } > | null = null; export function toApiError(err: unknown): ApiError { if (err && typeof err === "object" && "message" in err) { return err as ApiError; } return { error: "unknown", message: err instanceof Error ? err.message : "An unexpected error occurred" }; } export function requestSessionRefresh(): Promise { return fetch(`${API_BASE}${AUTH_REFRESH_PATH}`, { method: "POST", credentials: "include", headers: { "Content-Type": "application/json" }, }); } export function refreshSession(): Promise< { ok: true; data: { user: User; accessToken: string } } | { ok: false; reason: "rejected" | "network" } > { if (pendingSessionRefresh) return pendingSessionRefresh; useAuthStore.getState().setRefreshState("refreshing"); pendingSessionRefresh = (async () => { try { const res = await requestSessionRefresh(); if (!res.ok) { const state = useAuthStore.getState(); if (state.user) { state.markExpired(); } else { useAuthStore.setState({ accessToken: null, user: null, sessionMode: SESSION_MODES.ANONYMOUS, }); } return { ok: false as const, reason: "rejected" as const }; } // ADR: refresh is the first authenticated read after an OIDC callback, // and the callback may have just written a new identity/user/workspace // row. Persist the response bookmark so subsequent GETs observe it. persistBookmark(res); const data = (await res.json()) as { user: User; accessToken: string }; useAuthStore.getState().setAuth(data.accessToken, data.user); return { ok: true as const, data }; } catch { const state = useAuthStore.getState(); if (state.user) { state.markLocalOnly(); } return { ok: false as const, reason: "network" as const }; } finally { useAuthStore.getState().setRefreshState("idle"); pendingSessionRefresh = null; } })(); return pendingSessionRefresh; } function buildApiHeaders(options?: RequestInit, accessToken?: string | null): Headers { const headers = new Headers(options?.headers); if (options?.body !== undefined && !headers.has("Content-Type")) { headers.set("Content-Type", "application/json"); } if (accessToken) { headers.set("Authorization", `Bearer ${accessToken}`); } const bookmark = readStorageString(STORAGE_KEYS.D1_BOOKMARK); if (bookmark) { headers.set(D1_BOOKMARK_HEADER, bookmark); } return headers; } function persistBookmark(response: Response): void { const returnedBookmark = response.headers.get(D1_BOOKMARK_HEADER); if (returnedBookmark) { writeStorageString(STORAGE_KEYS.D1_BOOKMARK, returnedBookmark); } } async function parseApiError(response: Response): Promise { return (await response.json().catch(() => ({ error: "request_failed", message: `Request failed with status ${response.status}`, }))) as ApiError; } export async function sendApiRequest( path: string, options?: RequestInit, basePrefix: string = API_BASE, ): Promise { const token = useAuthStore.getState().accessToken; const headers = buildApiHeaders(options, token); const res = await fetch(`${basePrefix}${path}`, { ...options, credentials: "include", headers, }); persistBookmark(res); if (!res.ok) { const err = await parseApiError(res); // Auto-refresh on 401, or the local-dev 403 workaround for unauthorized responses. // OIDC start/callback are top-level navigations, not JSON API calls, so they // never reach `sendApiRequest` and need no exclusion here. if ((res.status === 401 || (res.status === 403 && err.error === "unauthorized")) && path !== AUTH_REFRESH_PATH) { const refreshResult = await refreshSession(); if (!refreshResult.ok) { throw err; } const retryHeaders = buildApiHeaders(options, refreshResult.data.accessToken); const retry = await fetch(`${basePrefix}${path}`, { ...options, credentials: "include", headers: retryHeaders, }); persistBookmark(retry); if (retry.ok) { return retry; } throw await parseApiError(retry); } throw err; } return res; } async function apiFetch(path: string, options?: RequestInit): Promise { const res = await sendApiRequest(path, options); if (res.status === 204) { return undefined as T; } return res.json(); } export const api = { auth: { refresh: async () => { const res = await apiFetch<{ user: User; accessToken: string }>(AUTH_REFRESH_PATH, { method: "POST", }); return res; }, logout: () => apiFetch<{ ok: boolean }>("/auth/logout", { method: "POST" }), me: async () => { const res = await apiFetch<{ user: User }>("/auth/me"); return res.user; }, }, workspaces: { list: async (): Promise => { const res = await apiFetch<{ workspaces: WorkspaceMembershipSummary[] }>("/workspaces"); return res.workspaces; }, create: async (data: { name: string; slug: string; icon?: string }) => { const res = await apiFetch<{ workspace: Workspace }>("/workspaces", { method: "POST", body: JSON.stringify(data), }); return res.workspace; }, update: async (id: string, data: Partial<{ name: string; icon: string | null }>) => { const res = await apiFetch<{ workspace: Workspace }>(`/workspaces/${id}`, { method: "PATCH", body: JSON.stringify(data), }); return res.workspace; }, delete: (id: string) => apiFetch<{ ok: boolean }>(`/workspaces/${id}`, { method: "DELETE" }), members: async (id: string) => { const res = await apiFetch<{ members: WorkspaceMember[] }>(`/workspaces/${id}/members`); return res.members; }, updateMemberRole: async (workspaceId: string, userId: string, role: string) => { return apiFetch<{ ok: boolean }>(`/workspaces/${workspaceId}/members/${userId}`, { method: "PATCH", body: JSON.stringify({ role }), }); }, removeMember: async (workspaceId: string, userId: string) => { return apiFetch<{ ok: boolean }>(`/workspaces/${workspaceId}/members/${userId}`, { method: "DELETE", }); }, }, pages: { list: async (workspaceId: string) => { const res = await apiFetch<{ pages: Page[] }>(`/workspaces/${workspaceId}/pages`); return res.pages; }, get: async (workspaceId: string, pageId: string, shareToken?: string) => { const qs = shareToken ? `?share=${encodeURIComponent(shareToken)}` : ""; const res = await apiFetch(`/workspaces/${workspaceId}/pages/${pageId}${qs}`); return res; }, create: async ( workspaceId: string, data: { kind?: PageKind; title?: string; parent_id?: string; icon?: string }, ) => { const res = await apiFetch<{ page: Page }>(`/workspaces/${workspaceId}/pages`, { method: "POST", body: JSON.stringify(data), }); return res.page; }, update: async ( workspaceId: string, pageId: string, data: Partial<{ icon: string | null; parent_id: string | null; position: number; cover_url: string | null }>, ) => { const res = await apiFetch<{ page: Page }>(`/workspaces/${workspaceId}/pages/${pageId}`, { method: "PATCH", body: JSON.stringify(data), }); return res.page; }, delete: (workspaceId: string, pageId: string) => apiFetch<{ ok: boolean; archived_page_ids: string[] }>(`/workspaces/${workspaceId}/pages/${pageId}`, { method: "DELETE", }), restore: async (workspaceId: string, pageId: string) => { const res = await apiFetch<{ ok: boolean; pages: Page[] }>(`/workspaces/${workspaceId}/pages/${pageId}/restore`, { method: "POST", }); return res.pages; }, archived: async (workspaceId: string) => { const res = await apiFetch<{ pages: ArchivedPage[] }>(`/workspaces/${workspaceId}/pages/archived`); return res.pages; }, children: async (workspaceId: string, pageId: string, shareToken?: string) => { const qs = shareToken ? `?share=${encodeURIComponent(shareToken)}` : ""; const res = await apiFetch<{ pages: Page[] }>(`/workspaces/${workspaceId}/pages/${pageId}/children${qs}`); return res.pages; }, ancestors: async (workspaceId: string, pageId: string, shareToken?: string) => { const qs = shareToken ? `?share=${encodeURIComponent(shareToken)}` : ""; const res = await apiFetch(`/workspaces/${workspaceId}/pages/${pageId}/ancestors${qs}`); return res.ancestors; }, snapshot: async ( workspaceId: string, pageId: string, shareToken?: string, signal?: AbortSignal, ): Promise => { const qs = shareToken ? `?share=${encodeURIComponent(shareToken)}` : ""; const res = await sendApiRequest(`/workspaces/${workspaceId}/pages/${pageId}/snapshot${qs}`, { signal }); if (res.status === 204) { return { kind: "missing" }; } return { kind: "found", snapshot: await res.arrayBuffer(), }; }, context: async (pageId: string) => { const res = await apiFetch(`/pages/${pageId}/context`); return res; }, }, uploads: { presign: async ( workspaceId: string, data: { filename: string; content_type: string; size_bytes: number; page_id?: string | null }, shareToken?: string, ) => { const qs = shareToken ? `?share=${encodeURIComponent(shareToken)}` : ""; const res = await apiFetch<{ upload: { id: string; upload_url: string; url: string } }>( `/workspaces/${workspaceId}/uploads/presign${qs}`, { method: "POST", body: JSON.stringify(data) }, ); return res.upload; }, uploadData: async (uploadUrl: string, file: File, shareToken?: string) => { const url = shareToken ? `${uploadUrl}?share=${encodeURIComponent(shareToken)}` : uploadUrl; const res = await sendApiRequest(url, { method: "PUT", body: file, headers: { "Content-Type": file.type } }, ""); return res.json(); }, }, search: async (workspaceId: string, query: string) => { const res = await apiFetch<{ results: SearchResult[] }>( `/workspaces/${workspaceId}/search?q=${encodeURIComponent(query)}`, ); return res.results; }, pageMentions: { resolve: async (workspaceId: string, pageIds: string[], shareToken?: string) => { const qs = shareToken ? `?share=${encodeURIComponent(shareToken)}` : ""; return apiFetch(`/workspaces/${workspaceId}/page-mentions/resolve${qs}`, { method: "POST", body: JSON.stringify({ page_ids: pageIds }), }); }, }, shares: { list: async (pageId: string) => { const res = await apiFetch<{ shares: PageShare[] }>(`/pages/${pageId}/share`); return res.shares; }, create: async ( pageId: string, data: { grantee_type: "user" | "link"; grantee_id?: string; grantee_email?: string; permission: "view" | "edit" }, ) => { const res = await apiFetch<{ share: PageShare }>(`/pages/${pageId}/share`, { method: "POST", body: JSON.stringify(data), }); return res.share; }, delete: (pageId: string, shareId: string) => apiFetch<{ ok: boolean }>(`/pages/${pageId}/share/${shareId}`, { method: "DELETE" }), resolve: async (token: string) => { const res = await apiFetch(`/share/${token}`); return res; }, sharedWithMe: async () => { return apiFetch("/me/shared-pages"); }, }, profile: { update: async (data: { name?: string; avatar_url?: string | null }) => { const res = await apiFetch<{ user: User }>("/auth/me", { method: "PATCH", body: JSON.stringify(data), }); return res.user; }, }, site: { get: (workspaceId: string) => apiFetch(`/workspaces/${workspaceId}/site`), update: (workspaceId: string, body: WorkspaceSiteUpdateRequest) => apiFetch(`/workspaces/${workspaceId}/site`, { method: "PATCH", body: JSON.stringify(body), }), slugAvailability: (workspaceId: string, slug: string) => apiFetch( `/workspaces/${workspaceId}/site/slug-availability?slug=${encodeURIComponent(slug)}`, ), listRoots: async (workspaceId: string) => { const res = await apiFetch<{ published_roots: PublishedPageWithMeta[] }>(`/workspaces/${workspaceId}/site/pages`); return res.published_roots; }, addRoot: (workspaceId: string, pageId: string) => apiFetch<{ ok: boolean }>(`/workspaces/${workspaceId}/site/pages/${pageId}`, { method: "POST" }), removeRoot: (workspaceId: string, pageId: string) => apiFetch<{ ok: boolean }>(`/workspaces/${workspaceId}/site/pages/${pageId}`, { method: "DELETE" }), pageStatus: (workspaceId: string, pageId: string) => apiFetch(`/workspaces/${workspaceId}/site/pages/${pageId}/status`), }, invites: { get: async (token: string) => { const res = await apiFetch<{ invite: InvitePreview }>(`/invite/${token}`); return res.invite; }, accept: async (token: string) => { const res = await apiFetch<{ user: User; workspace_id: string; accessToken: string }>(`/invite/${token}/accept`, { method: "POST", body: JSON.stringify({}), }); return res; }, create: async (workspaceId: string, data: { email?: string; role?: "admin" | "member" | "guest" }) => { const res = await apiFetch<{ invite: { id: string; token: string; role: string; email: string | null; expires_at: string; invite_link: string; }; }>(`/workspaces/${workspaceId}/invite`, { method: "POST", body: JSON.stringify(data), }); return res.invite; }, }, };