An encrypted personal cloud built on the AT Protocol.
1// XRPC and AppView API helpers.
2
3import type { OAuthSession, Session } from "@/lib/storageTypes";
4import type { TokenResponse } from "@/lib/oauth";
5import type { PdsRecord } from "@/lib/pdsTypes";
6import { getOpakeWorker } from "@/lib/worker";
7import { storage } from "@/lib/indexeddbStorage";
8import { RecordRefSchema, PdsRecordSchema } from "@/lib/schemas";
9import { z } from "zod";
10
11interface ApiConfig {
12 pdsUrl: string;
13 appviewUrl: string;
14}
15
16export const DEFAULT_APPVIEW_URL =
17 (import.meta.env.VITE_APPVIEW_URL as string | undefined) ?? "https://appview.opake.app";
18
19const defaultConfig: Readonly<ApiConfig> = {
20 pdsUrl: (import.meta.env.VITE_PDS_URL as string | undefined) ?? "https://pds.sans-self.org",
21 appviewUrl: DEFAULT_APPVIEW_URL,
22};
23
24// ---------------------------------------------------------------------------
25// Unauthenticated XRPC
26// ---------------------------------------------------------------------------
27
28interface XrpcParams {
29 lexicon: string;
30 method?: "GET" | "POST";
31 body?: unknown;
32 headers?: Record<string, string>;
33}
34
35export async function xrpc(
36 params: XrpcParams,
37 config: ApiConfig = defaultConfig,
38): Promise<unknown> {
39 const { lexicon, method = "GET", body, headers = {} } = params;
40 const url = `${config.pdsUrl}/xrpc/${lexicon}`;
41
42 const response = await fetch(url, {
43 method,
44 headers: {
45 "Content-Type": "application/json",
46 ...headers,
47 },
48 body: body ? JSON.stringify(body) : undefined,
49 });
50
51 if (!response.ok) {
52 throw new Error(`XRPC ${lexicon}: ${response.status}`);
53 }
54
55 return response.json();
56}
57
58// ---------------------------------------------------------------------------
59// Authenticated requests (DPoP or Legacy) — shared retry core
60// ---------------------------------------------------------------------------
61
62interface AuthenticatedRequestParams {
63 url: string;
64 method: string;
65 headers?: Record<string, string>;
66 body?: BodyInit;
67 label: string;
68}
69
70// eslint-disable-next-line sonarjs/cognitive-complexity -- legitimate retry/nonce dance with nested conditions; splitting would obscure the flow
71async function authenticatedRequest(
72 params: AuthenticatedRequestParams,
73 session: Session,
74): Promise<Response> {
75 const { url, method, body, label } = params;
76
77 const headers: Record<string, string> = { ...params.headers };
78
79 if (session.type === "oauth") {
80 await attachDpopAuth(headers, session, method, url);
81 } else {
82 headers.Authorization = `Bearer ${session.accessJwt}`;
83 }
84
85 let response = await fetch(url, { method, headers, body });
86
87 // Always capture the latest PDS nonce — it may differ from the AS nonce.
88 if (session.type === "oauth") {
89 const nonce = response.headers.get("dpop-nonce");
90 if (nonce) session.dpopNonce = nonce;
91 }
92
93 // DPoP nonce retry — the PDS explicitly challenged us for a nonce.
94 if (session.type === "oauth" && requiresNonceRetry(response)) {
95 await attachDpopAuth(headers, session, method, url);
96 response = await fetch(url, { method, headers, body });
97
98 const nonce = response.headers.get("dpop-nonce");
99 if (nonce) session.dpopNonce = nonce;
100 }
101
102 // Token expired — refresh and retry once.
103 if (response.status === 401 && session.type === "oauth" && session.refreshToken) {
104 console.debug("[api] 401 — attempting token refresh");
105 const refreshed = await refreshAccessToken(session);
106 if (refreshed) {
107 await attachDpopAuth(headers, session, method, url);
108 response = await fetch(url, { method, headers, body });
109
110 const nonce = response.headers.get("dpop-nonce");
111 if (nonce) session.dpopNonce = nonce;
112
113 // The refreshed token might also need a nonce retry on the PDS
114 if (requiresNonceRetry(response)) {
115 await attachDpopAuth(headers, session, method, url);
116 response = await fetch(url, { method, headers, body });
117 }
118 }
119 }
120
121 if (!response.ok) {
122 const detail = await response.text().catch(() => "");
123 throw new Error(`${label}: ${response.status} ${detail}`.trim());
124 }
125
126 return response;
127}
128
129// ---------------------------------------------------------------------------
130// Authenticated XRPC (JSON)
131// ---------------------------------------------------------------------------
132
133interface AuthenticatedXrpcParams {
134 pdsUrl: string;
135 lexicon: string;
136 method?: "GET" | "POST";
137 body?: unknown;
138}
139
140export async function authenticatedXrpc(
141 params: AuthenticatedXrpcParams,
142 session: Session,
143): Promise<unknown> {
144 const { pdsUrl, lexicon, method = "GET", body } = params;
145 const url = `${pdsUrl.replace(/\/$/, "")}/xrpc/${lexicon}`;
146
147 const response = await authenticatedRequest(
148 {
149 url,
150 method,
151 headers: { "Content-Type": "application/json" },
152 body: body ? JSON.stringify(body) : undefined,
153 label: `XRPC ${lexicon}`,
154 },
155 session,
156 );
157
158 return response.json();
159}
160
161// ---------------------------------------------------------------------------
162// Authenticated blob fetch (raw bytes)
163// ---------------------------------------------------------------------------
164
165interface BlobFetchParams {
166 pdsUrl: string;
167 did: string;
168 cid: string;
169}
170
171export async function authenticatedBlobFetch(
172 params: BlobFetchParams,
173 session: Session,
174): Promise<ArrayBuffer> {
175 const { pdsUrl, did, cid } = params;
176 const url = `${pdsUrl.replace(/\/$/, "")}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(did)}&cid=${encodeURIComponent(cid)}`;
177
178 const response = await authenticatedRequest(
179 { url, method: "GET", label: `getBlob ${cid}` },
180 session,
181 );
182
183 return response.arrayBuffer();
184}
185
186// ---------------------------------------------------------------------------
187// Authenticated record update
188// ---------------------------------------------------------------------------
189
190interface RecordRef {
191 uri: string;
192 cid: string;
193}
194
195interface PutRecordParams {
196 pdsUrl: string;
197 did: string;
198 collection: string;
199 rkey: string;
200 record: unknown;
201}
202
203export async function authenticatedPutRecord(
204 params: PutRecordParams,
205 session: Session,
206): Promise<RecordRef> {
207 const { pdsUrl, did, collection, rkey, record } = params;
208 const result = await authenticatedXrpc(
209 {
210 pdsUrl,
211 lexicon: "com.atproto.repo.putRecord",
212 method: "POST",
213 body: { repo: did, collection, rkey, record: { $type: collection, ...(record as object) } },
214 },
215 session,
216 );
217 return RecordRefSchema.parse(result);
218}
219
220// ---------------------------------------------------------------------------
221// Authenticated record fetch + delete
222// ---------------------------------------------------------------------------
223
224interface GetRecordParams {
225 pdsUrl: string;
226 did: string;
227 collection: string;
228 rkey: string;
229}
230
231export async function authenticatedGetRecord<T>(
232 params: GetRecordParams,
233 session: Session,
234): Promise<PdsRecord<T>> {
235 const { pdsUrl, did, collection, rkey } = params;
236 const result = await authenticatedXrpc(
237 {
238 pdsUrl,
239 lexicon: `com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=${encodeURIComponent(collection)}&rkey=${encodeURIComponent(rkey)}`,
240 },
241 session,
242 );
243 return PdsRecordSchema(z.unknown()).parse(result) as PdsRecord<T>;
244}
245
246interface DeleteRecordParams {
247 pdsUrl: string;
248 did: string;
249 collection: string;
250 rkey: string;
251}
252
253export async function authenticatedDeleteRecord(
254 params: DeleteRecordParams,
255 session: Session,
256): Promise<void> {
257 const { pdsUrl, did, collection, rkey } = params;
258 await authenticatedXrpc(
259 {
260 pdsUrl,
261 lexicon: "com.atproto.repo.deleteRecord",
262 method: "POST",
263 body: { repo: did, collection, rkey },
264 },
265 session,
266 );
267}
268
269// ---------------------------------------------------------------------------
270// Token refresh
271// ---------------------------------------------------------------------------
272
273/** Refresh an expired OAuth access token. Mutates the session in place and persists to IndexedDB.
274 *
275 * Before attempting a refresh, re-reads the session from IndexedDB. If the
276 * tokens differ (i.e. the Service Worker already refreshed), adopts the fresh
277 * tokens and returns true without calling the token endpoint. This prevents
278 * consuming a single-use refresh token that the SW already rotated.
279 */
280async function refreshAccessToken(session: OAuthSession): Promise<boolean> {
281 // Check if the SW already refreshed for us
282 try {
283 const stored = await storage.loadSession(session.did);
284 if (stored.type === "oauth" && stored.accessToken !== session.accessToken) {
285 console.debug("[api] SW already refreshed — adopting stored tokens");
286 session.accessToken = stored.accessToken;
287 session.refreshToken = stored.refreshToken;
288 session.dpopNonce = stored.dpopNonce;
289 session.expiresAt = stored.expiresAt;
290 return true;
291 }
292 } catch (err) {
293 console.warn("[api] failed to re-read session from IndexedDB:", err);
294 }
295
296 const worker = getOpakeWorker();
297 const url = session.tokenEndpoint;
298
299 const body = new URLSearchParams({
300 grant_type: "refresh_token",
301 refresh_token: session.refreshToken,
302 client_id: session.clientId,
303 });
304
305 const timestamp = Math.floor(Date.now() / 1000);
306 const proof = await worker.createDpopProof(
307 session.dpopKey,
308 "POST",
309 url,
310 timestamp,
311 session.dpopNonce,
312 null,
313 );
314
315 const headers: Record<string, string> = {
316 "Content-Type": "application/x-www-form-urlencoded",
317 DPoP: proof,
318 };
319
320 let response = await fetch(url, { method: "POST", headers, body: body.toString() });
321 let nonce = response.headers.get("dpop-nonce") ?? session.dpopNonce;
322
323 // Nonce retry for the AS
324 if (response.status === 400) {
325 const errorBody = (await response
326 .clone()
327 .json()
328 .catch(() => null)) as {
329 error?: string;
330 } | null;
331 if (errorBody?.error === "use_dpop_nonce" && nonce) {
332 const retryProof = await worker.createDpopProof(
333 session.dpopKey,
334 "POST",
335 url,
336 timestamp,
337 nonce,
338 null,
339 );
340 headers.DPoP = retryProof;
341 response = await fetch(url, { method: "POST", headers, body: body.toString() });
342 nonce = response.headers.get("dpop-nonce") ?? nonce;
343 }
344 }
345
346 if (!response.ok) {
347 console.error("[api] token refresh failed:", response.status);
348 return false;
349 }
350
351 const tokenResponse = (await response.json()) as TokenResponse;
352 console.debug("[api] token refreshed, new expiry:", tokenResponse.expires_in);
353
354 const now = Math.floor(Date.now() / 1000);
355 session.accessToken = tokenResponse.access_token;
356 session.refreshToken = tokenResponse.refresh_token ?? session.refreshToken;
357 session.dpopNonce = nonce;
358 session.expiresAt = tokenResponse.expires_in ? now + tokenResponse.expires_in : null;
359
360 // Persist updated session
361 await storage.saveSession(session.did, session).catch((err: unknown) => {
362 console.warn("[api] failed to persist refreshed session:", err);
363 });
364
365 return true;
366}
367
368/** Check if a response is an explicit DPoP nonce challenge (WWW-Authenticate contains use_dpop_nonce). */
369function requiresNonceRetry(response: Response): boolean {
370 // The PDS always includes dpop-nonce on authenticated endpoints, so checking just
371 // header presence incorrectly treats expired-token 401s as nonce challenges.
372 // Only retry when the server explicitly says the nonce is the problem.
373 const wwwAuth = response.headers.get("www-authenticate") ?? "";
374 return wwwAuth.includes("use_dpop_nonce");
375}
376
377async function attachDpopAuth(
378 headers: Record<string, string>,
379 session: OAuthSession,
380 method: string,
381 url: string,
382): Promise<void> {
383 const worker = getOpakeWorker();
384 const timestamp = Math.floor(Date.now() / 1000);
385 const proof = await worker.createDpopProof(
386 session.dpopKey,
387 method,
388 url,
389 timestamp,
390 session.dpopNonce,
391 session.accessToken,
392 );
393 headers.Authorization = `DPoP ${session.accessToken}`;
394 headers.DPoP = proof;
395}