An encrypted personal cloud built on the AT Protocol.
at main 395 lines 12 kB view raw
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}