File
Blob: src/client/lib/api.ts
| 1 | import { useAuthStore } from "@/client/stores/auth-store"; |
| 2 | import { SESSION_MODES, STORAGE_KEYS } from "@/client/lib/constants"; |
| 3 | import { readStorageString, writeStorageString } from "@/client/lib/storage"; |
| 4 | import { D1_BOOKMARK_HEADER } from "@/shared/bookmark"; |
| 5 | import type { |
| 6 | User, |
| 7 | Workspace, |
| 8 | WorkspaceMembershipSummary, |
| 9 | Page, |
| 10 | PageKind, |
| 11 | GetPageResponse, |
| 12 | WorkspaceMember, |
| 13 | ApiError, |
| 14 | InvitePreview, |
| 15 | SearchResult, |
| 16 | PageShare, |
| 17 | SharedPageInfo, |
| 18 | SharedPagesResponse, |
| 19 | GetPageAncestorsResponse, |
| 20 | PageSnapshotResponse, |
| 21 | PageRouteBootstrapResponse, |
| 22 | ResolvePageMentionsResponse, |
| 23 | PublishedPageWithMeta, |
| 24 | SitePageStatus, |
| 25 | SiteSlugAvailability, |
| 26 | WorkspaceSiteResponse, |
| 27 | WorkspaceSiteUpdateRequest, |
| 28 | ArchivedPage, |
| 29 | } from "@/shared/types"; |
| 30 | |
| 31 | const API_BASE = "/api/v1"; |
| 32 | const AUTH_REFRESH_PATH = "/auth/refresh"; |
| 33 | let pendingSessionRefresh: Promise< |
| 34 | { ok: true; data: { user: User; accessToken: string } } | { ok: false; reason: "rejected" | "network" } |
| 35 | > | null = null; |
| 36 | |
| 37 | export function toApiError(err: unknown): ApiError { |
| 38 | if (err && typeof err === "object" && "message" in err) { |
| 39 | return err as ApiError; |
| 40 | } |
| 41 | return { error: "unknown", message: err instanceof Error ? err.message : "An unexpected error occurred" }; |
| 42 | } |
| 43 | |
| 44 | export function requestSessionRefresh(): Promise<Response> { |
| 45 | return fetch(`${API_BASE}${AUTH_REFRESH_PATH}`, { |
| 46 | method: "POST", |
| 47 | credentials: "include", |
| 48 | headers: { "Content-Type": "application/json" }, |
| 49 | }); |
| 50 | } |
| 51 | |
| 52 | export function refreshSession(): Promise< |
| 53 | { ok: true; data: { user: User; accessToken: string } } | { ok: false; reason: "rejected" | "network" } |
| 54 | > { |
| 55 | if (pendingSessionRefresh) return pendingSessionRefresh; |
| 56 | |
| 57 | useAuthStore.getState().setRefreshState("refreshing"); |
| 58 | |
| 59 | pendingSessionRefresh = (async () => { |
| 60 | try { |
| 61 | const res = await requestSessionRefresh(); |
| 62 | if (!res.ok) { |
| 63 | const state = useAuthStore.getState(); |
| 64 | if (state.user) { |
| 65 | state.markExpired(); |
| 66 | } else { |
| 67 | useAuthStore.setState({ |
| 68 | accessToken: null, |
| 69 | user: null, |
| 70 | sessionMode: SESSION_MODES.ANONYMOUS, |
| 71 | }); |
| 72 | } |
| 73 | return { ok: false as const, reason: "rejected" as const }; |
| 74 | } |
| 75 | |
| 76 | // ADR: refresh is the first authenticated read after an OIDC callback, |
| 77 | // and the callback may have just written a new identity/user/workspace |
| 78 | // row. Persist the response bookmark so subsequent GETs observe it. |
| 79 | persistBookmark(res); |
| 80 | const data = (await res.json()) as { user: User; accessToken: string }; |
| 81 | useAuthStore.getState().setAuth(data.accessToken, data.user); |
| 82 | return { ok: true as const, data }; |
| 83 | } catch { |
| 84 | const state = useAuthStore.getState(); |
| 85 | if (state.user) { |
| 86 | state.markLocalOnly(); |
| 87 | } |
| 88 | return { ok: false as const, reason: "network" as const }; |
| 89 | } finally { |
| 90 | useAuthStore.getState().setRefreshState("idle"); |
| 91 | pendingSessionRefresh = null; |
| 92 | } |
| 93 | })(); |
| 94 | |
| 95 | return pendingSessionRefresh; |
| 96 | } |
| 97 | |
| 98 | function buildApiHeaders(options?: RequestInit, accessToken?: string | null): Headers { |
| 99 | const headers = new Headers(options?.headers); |
| 100 | if (options?.body !== undefined && !headers.has("Content-Type")) { |
| 101 | headers.set("Content-Type", "application/json"); |
| 102 | } |
| 103 | if (accessToken) { |
| 104 | headers.set("Authorization", `Bearer ${accessToken}`); |
| 105 | } |
| 106 | const bookmark = readStorageString(STORAGE_KEYS.D1_BOOKMARK); |
| 107 | if (bookmark) { |
| 108 | headers.set(D1_BOOKMARK_HEADER, bookmark); |
| 109 | } |
| 110 | return headers; |
| 111 | } |
| 112 | |
| 113 | function persistBookmark(response: Response): void { |
| 114 | const returnedBookmark = response.headers.get(D1_BOOKMARK_HEADER); |
| 115 | if (returnedBookmark) { |
| 116 | writeStorageString(STORAGE_KEYS.D1_BOOKMARK, returnedBookmark); |
| 117 | } |
| 118 | } |
| 119 | |
| 120 | async function parseApiError(response: Response): Promise<ApiError> { |
| 121 | return (await response.json().catch(() => ({ |
| 122 | error: "request_failed", |
| 123 | message: `Request failed with status ${response.status}`, |
| 124 | }))) as ApiError; |
| 125 | } |
| 126 | |
| 127 | export async function sendApiRequest( |
| 128 | path: string, |
| 129 | options?: RequestInit, |
| 130 | basePrefix: string = API_BASE, |
| 131 | ): Promise<Response> { |
| 132 | const token = useAuthStore.getState().accessToken; |
| 133 | const headers = buildApiHeaders(options, token); |
| 134 | const res = await fetch(`${basePrefix}${path}`, { |
| 135 | ...options, |
| 136 | credentials: "include", |
| 137 | headers, |
| 138 | }); |
| 139 | persistBookmark(res); |
| 140 | |
| 141 | if (!res.ok) { |
| 142 | const err = await parseApiError(res); |
| 143 | |
| 144 | // Auto-refresh on 401, or the local-dev 403 workaround for unauthorized responses. |
| 145 | // OIDC start/callback are top-level navigations, not JSON API calls, so they |
| 146 | // never reach `sendApiRequest` and need no exclusion here. |
| 147 | if ((res.status === 401 || (res.status === 403 && err.error === "unauthorized")) && path !== AUTH_REFRESH_PATH) { |
| 148 | const refreshResult = await refreshSession(); |
| 149 | if (!refreshResult.ok) { |
| 150 | throw err; |
| 151 | } |
| 152 | |
| 153 | const retryHeaders = buildApiHeaders(options, refreshResult.data.accessToken); |
| 154 | const retry = await fetch(`${basePrefix}${path}`, { |
| 155 | ...options, |
| 156 | credentials: "include", |
| 157 | headers: retryHeaders, |
| 158 | }); |
| 159 | persistBookmark(retry); |
| 160 | if (retry.ok) { |
| 161 | return retry; |
| 162 | } |
| 163 | throw await parseApiError(retry); |
| 164 | } |
| 165 | throw err; |
| 166 | } |
| 167 | |
| 168 | return res; |
| 169 | } |
| 170 | |
| 171 | async function apiFetch<T>(path: string, options?: RequestInit): Promise<T> { |
| 172 | const res = await sendApiRequest(path, options); |
| 173 | |
| 174 | if (res.status === 204) { |
| 175 | return undefined as T; |
| 176 | } |
| 177 | |
| 178 | return res.json(); |
| 179 | } |
| 180 | |
| 181 | export const api = { |
| 182 | auth: { |
| 183 | refresh: async () => { |
| 184 | const res = await apiFetch<{ user: User; accessToken: string }>(AUTH_REFRESH_PATH, { |
| 185 | method: "POST", |
| 186 | }); |
| 187 | return res; |
| 188 | }, |
| 189 | logout: () => apiFetch<{ ok: boolean }>("/auth/logout", { method: "POST" }), |
| 190 | me: async () => { |
| 191 | const res = await apiFetch<{ user: User }>("/auth/me"); |
| 192 | return res.user; |
| 193 | }, |
| 194 | }, |
| 195 | |
| 196 | workspaces: { |
| 197 | list: async (): Promise<WorkspaceMembershipSummary[]> => { |
| 198 | const res = await apiFetch<{ workspaces: WorkspaceMembershipSummary[] }>("/workspaces"); |
| 199 | return res.workspaces; |
| 200 | }, |
| 201 | create: async (data: { name: string; slug: string; icon?: string }) => { |
| 202 | const res = await apiFetch<{ workspace: Workspace }>("/workspaces", { |
| 203 | method: "POST", |
| 204 | body: JSON.stringify(data), |
| 205 | }); |
| 206 | return res.workspace; |
| 207 | }, |
| 208 | update: async (id: string, data: Partial<{ name: string; icon: string | null }>) => { |
| 209 | const res = await apiFetch<{ workspace: Workspace }>(`/workspaces/${id}`, { |
| 210 | method: "PATCH", |
| 211 | body: JSON.stringify(data), |
| 212 | }); |
| 213 | return res.workspace; |
| 214 | }, |
| 215 | delete: (id: string) => apiFetch<{ ok: boolean }>(`/workspaces/${id}`, { method: "DELETE" }), |
| 216 | members: async (id: string) => { |
| 217 | const res = await apiFetch<{ members: WorkspaceMember[] }>(`/workspaces/${id}/members`); |
| 218 | return res.members; |
| 219 | }, |
| 220 | updateMemberRole: async (workspaceId: string, userId: string, role: string) => { |
| 221 | return apiFetch<{ ok: boolean }>(`/workspaces/${workspaceId}/members/${userId}`, { |
| 222 | method: "PATCH", |
| 223 | body: JSON.stringify({ role }), |
| 224 | }); |
| 225 | }, |
| 226 | removeMember: async (workspaceId: string, userId: string) => { |
| 227 | return apiFetch<{ ok: boolean }>(`/workspaces/${workspaceId}/members/${userId}`, { |
| 228 | method: "DELETE", |
| 229 | }); |
| 230 | }, |
| 231 | }, |
| 232 | |
| 233 | pages: { |
| 234 | list: async (workspaceId: string) => { |
| 235 | const res = await apiFetch<{ pages: Page[] }>(`/workspaces/${workspaceId}/pages`); |
| 236 | return res.pages; |
| 237 | }, |
| 238 | get: async (workspaceId: string, pageId: string, shareToken?: string) => { |
| 239 | const qs = shareToken ? `?share=${encodeURIComponent(shareToken)}` : ""; |
| 240 | const res = await apiFetch<GetPageResponse>(`/workspaces/${workspaceId}/pages/${pageId}${qs}`); |
| 241 | return res; |
| 242 | }, |
| 243 | create: async ( |
| 244 | workspaceId: string, |
| 245 | data: { kind?: PageKind; title?: string; parent_id?: string; icon?: string }, |
| 246 | ) => { |
| 247 | const res = await apiFetch<{ page: Page }>(`/workspaces/${workspaceId}/pages`, { |
| 248 | method: "POST", |
| 249 | body: JSON.stringify(data), |
| 250 | }); |
| 251 | return res.page; |
| 252 | }, |
| 253 | update: async ( |
| 254 | workspaceId: string, |
| 255 | pageId: string, |
| 256 | data: Partial<{ icon: string | null; parent_id: string | null; position: number; cover_url: string | null }>, |
| 257 | ) => { |
| 258 | const res = await apiFetch<{ page: Page }>(`/workspaces/${workspaceId}/pages/${pageId}`, { |
| 259 | method: "PATCH", |
| 260 | body: JSON.stringify(data), |
| 261 | }); |
| 262 | return res.page; |
| 263 | }, |
| 264 | delete: (workspaceId: string, pageId: string) => |
| 265 | apiFetch<{ ok: boolean; archived_page_ids: string[] }>(`/workspaces/${workspaceId}/pages/${pageId}`, { |
| 266 | method: "DELETE", |
| 267 | }), |
| 268 | restore: async (workspaceId: string, pageId: string) => { |
| 269 | const res = await apiFetch<{ ok: boolean; pages: Page[] }>(`/workspaces/${workspaceId}/pages/${pageId}/restore`, { |
| 270 | method: "POST", |
| 271 | }); |
| 272 | return res.pages; |
| 273 | }, |
| 274 | archived: async (workspaceId: string) => { |
| 275 | const res = await apiFetch<{ pages: ArchivedPage[] }>(`/workspaces/${workspaceId}/pages/archived`); |
| 276 | return res.pages; |
| 277 | }, |
| 278 | children: async (workspaceId: string, pageId: string, shareToken?: string) => { |
| 279 | const qs = shareToken ? `?share=${encodeURIComponent(shareToken)}` : ""; |
| 280 | const res = await apiFetch<{ pages: Page[] }>(`/workspaces/${workspaceId}/pages/${pageId}/children${qs}`); |
| 281 | return res.pages; |
| 282 | }, |
| 283 | ancestors: async (workspaceId: string, pageId: string, shareToken?: string) => { |
| 284 | const qs = shareToken ? `?share=${encodeURIComponent(shareToken)}` : ""; |
| 285 | const res = await apiFetch<GetPageAncestorsResponse>(`/workspaces/${workspaceId}/pages/${pageId}/ancestors${qs}`); |
| 286 | return res.ancestors; |
| 287 | }, |
| 288 | snapshot: async ( |
| 289 | workspaceId: string, |
| 290 | pageId: string, |
| 291 | shareToken?: string, |
| 292 | signal?: AbortSignal, |
| 293 | ): Promise<PageSnapshotResponse> => { |
| 294 | const qs = shareToken ? `?share=${encodeURIComponent(shareToken)}` : ""; |
| 295 | const res = await sendApiRequest(`/workspaces/${workspaceId}/pages/${pageId}/snapshot${qs}`, { signal }); |
| 296 | if (res.status === 204) { |
| 297 | return { kind: "missing" }; |
| 298 | } |
| 299 | return { |
| 300 | kind: "found", |
| 301 | snapshot: await res.arrayBuffer(), |
| 302 | }; |
| 303 | }, |
| 304 | context: async (pageId: string) => { |
| 305 | const res = await apiFetch<PageRouteBootstrapResponse>(`/pages/${pageId}/context`); |
| 306 | return res; |
| 307 | }, |
| 308 | }, |
| 309 | |
| 310 | uploads: { |
| 311 | presign: async ( |
| 312 | workspaceId: string, |
| 313 | data: { filename: string; content_type: string; size_bytes: number; page_id?: string | null }, |
| 314 | shareToken?: string, |
| 315 | ) => { |
| 316 | const qs = shareToken ? `?share=${encodeURIComponent(shareToken)}` : ""; |
| 317 | const res = await apiFetch<{ upload: { id: string; upload_url: string; url: string } }>( |
| 318 | `/workspaces/${workspaceId}/uploads/presign${qs}`, |
| 319 | { method: "POST", body: JSON.stringify(data) }, |
| 320 | ); |
| 321 | return res.upload; |
| 322 | }, |
| 323 | uploadData: async (uploadUrl: string, file: File, shareToken?: string) => { |
| 324 | const url = shareToken ? `${uploadUrl}?share=${encodeURIComponent(shareToken)}` : uploadUrl; |
| 325 | const res = await sendApiRequest(url, { method: "PUT", body: file, headers: { "Content-Type": file.type } }, ""); |
| 326 | return res.json(); |
| 327 | }, |
| 328 | }, |
| 329 | |
| 330 | search: async (workspaceId: string, query: string) => { |
| 331 | const res = await apiFetch<{ results: SearchResult[] }>( |
| 332 | `/workspaces/${workspaceId}/search?q=${encodeURIComponent(query)}`, |
| 333 | ); |
| 334 | return res.results; |
| 335 | }, |
| 336 | |
| 337 | pageMentions: { |
| 338 | resolve: async (workspaceId: string, pageIds: string[], shareToken?: string) => { |
| 339 | const qs = shareToken ? `?share=${encodeURIComponent(shareToken)}` : ""; |
| 340 | return apiFetch<ResolvePageMentionsResponse>(`/workspaces/${workspaceId}/page-mentions/resolve${qs}`, { |
| 341 | method: "POST", |
| 342 | body: JSON.stringify({ page_ids: pageIds }), |
| 343 | }); |
| 344 | }, |
| 345 | }, |
| 346 | |
| 347 | shares: { |
| 348 | list: async (pageId: string) => { |
| 349 | const res = await apiFetch<{ shares: PageShare[] }>(`/pages/${pageId}/share`); |
| 350 | return res.shares; |
| 351 | }, |
| 352 | create: async ( |
| 353 | pageId: string, |
| 354 | data: { grantee_type: "user" | "link"; grantee_id?: string; grantee_email?: string; permission: "view" | "edit" }, |
| 355 | ) => { |
| 356 | const res = await apiFetch<{ share: PageShare }>(`/pages/${pageId}/share`, { |
| 357 | method: "POST", |
| 358 | body: JSON.stringify(data), |
| 359 | }); |
| 360 | return res.share; |
| 361 | }, |
| 362 | delete: (pageId: string, shareId: string) => |
| 363 | apiFetch<{ ok: boolean }>(`/pages/${pageId}/share/${shareId}`, { method: "DELETE" }), |
| 364 | resolve: async (token: string) => { |
| 365 | const res = await apiFetch<SharedPageInfo>(`/share/${token}`); |
| 366 | return res; |
| 367 | }, |
| 368 | sharedWithMe: async () => { |
| 369 | return apiFetch<SharedPagesResponse>("/me/shared-pages"); |
| 370 | }, |
| 371 | }, |
| 372 | |
| 373 | profile: { |
| 374 | update: async (data: { name?: string; avatar_url?: string | null }) => { |
| 375 | const res = await apiFetch<{ user: User }>("/auth/me", { |
| 376 | method: "PATCH", |
| 377 | body: JSON.stringify(data), |
| 378 | }); |
| 379 | return res.user; |
| 380 | }, |
| 381 | }, |
| 382 | |
| 383 | site: { |
| 384 | get: (workspaceId: string) => apiFetch<WorkspaceSiteResponse>(`/workspaces/${workspaceId}/site`), |
| 385 | update: (workspaceId: string, body: WorkspaceSiteUpdateRequest) => |
| 386 | apiFetch<WorkspaceSiteResponse>(`/workspaces/${workspaceId}/site`, { |
| 387 | method: "PATCH", |
| 388 | body: JSON.stringify(body), |
| 389 | }), |
| 390 | slugAvailability: (workspaceId: string, slug: string) => |
| 391 | apiFetch<SiteSlugAvailability>( |
| 392 | `/workspaces/${workspaceId}/site/slug-availability?slug=${encodeURIComponent(slug)}`, |
| 393 | ), |
| 394 | listRoots: async (workspaceId: string) => { |
| 395 | const res = await apiFetch<{ published_roots: PublishedPageWithMeta[] }>(`/workspaces/${workspaceId}/site/pages`); |
| 396 | return res.published_roots; |
| 397 | }, |
| 398 | addRoot: (workspaceId: string, pageId: string) => |
| 399 | apiFetch<{ ok: boolean }>(`/workspaces/${workspaceId}/site/pages/${pageId}`, { method: "POST" }), |
| 400 | removeRoot: (workspaceId: string, pageId: string) => |
| 401 | apiFetch<{ ok: boolean }>(`/workspaces/${workspaceId}/site/pages/${pageId}`, { method: "DELETE" }), |
| 402 | pageStatus: (workspaceId: string, pageId: string) => |
| 403 | apiFetch<SitePageStatus>(`/workspaces/${workspaceId}/site/pages/${pageId}/status`), |
| 404 | }, |
| 405 | |
| 406 | invites: { |
| 407 | get: async (token: string) => { |
| 408 | const res = await apiFetch<{ invite: InvitePreview }>(`/invite/${token}`); |
| 409 | return res.invite; |
| 410 | }, |
| 411 | accept: async (token: string) => { |
| 412 | const res = await apiFetch<{ user: User; workspace_id: string; accessToken: string }>(`/invite/${token}/accept`, { |
| 413 | method: "POST", |
| 414 | body: JSON.stringify({}), |
| 415 | }); |
| 416 | return res; |
| 417 | }, |
| 418 | create: async (workspaceId: string, data: { email?: string; role?: "admin" | "member" | "guest" }) => { |
| 419 | const res = await apiFetch<{ |
| 420 | invite: { |
| 421 | id: string; |
| 422 | token: string; |
| 423 | role: string; |
| 424 | email: string | null; |
| 425 | expires_at: string; |
| 426 | invite_link: string; |
| 427 | }; |
| 428 | }>(`/workspaces/${workspaceId}/invite`, { |
| 429 | method: "POST", |
| 430 | body: JSON.stringify(data), |
| 431 | }); |
| 432 | return res.invite; |
| 433 | }, |
| 434 | }, |
| 435 | }; |