Skip to content
File

Blob: src/client/lib/api.ts

typescript436 lines
1import { useAuthStore } from "@/client/stores/auth-store";
2import { SESSION_MODES, STORAGE_KEYS } from "@/client/lib/constants";
3import { readStorageString, writeStorageString } from "@/client/lib/storage";
4import { D1_BOOKMARK_HEADER } from "@/shared/bookmark";
5import 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 
31const API_BASE = "/api/v1";
32const AUTH_REFRESH_PATH = "/auth/refresh";
33let pendingSessionRefresh: Promise<
34 { ok: true; data: { user: User; accessToken: string } } | { ok: false; reason: "rejected" | "network" }
35> | null = null;
36 
37export 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 
44export 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 
52export 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 
98function 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 
113function 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 
120async 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 
127export 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 
171async 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 
181export 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};