this repo has no description
1import type { 2 AccountStatus, 3 BlobRef, 4 CreateAccountParams, 5 DidCredentials, 6 DidDocument, 7 MigrationError, 8 PlcOperation, 9 Preferences, 10 ServerDescription, 11 Session, 12} from "./types"; 13 14function apiLog( 15 method: string, 16 endpoint: string, 17 data?: Record<string, unknown>, 18) { 19 const timestamp = new Date().toISOString(); 20 const msg = `[API ${timestamp}] ${method} ${endpoint}`; 21 if (data) { 22 console.log(msg, JSON.stringify(data, null, 2)); 23 } else { 24 console.log(msg); 25 } 26} 27 28export class AtprotoClient { 29 private baseUrl: string; 30 private accessToken: string | null = null; 31 32 constructor(pdsUrl: string) { 33 this.baseUrl = pdsUrl.replace(/\/$/, ""); 34 } 35 36 setAccessToken(token: string | null) { 37 this.accessToken = token; 38 } 39 40 getAccessToken(): string | null { 41 return this.accessToken; 42 } 43 44 private async xrpc<T>( 45 method: string, 46 options?: { 47 httpMethod?: "GET" | "POST"; 48 params?: Record<string, string>; 49 body?: unknown; 50 authToken?: string; 51 rawBody?: Uint8Array | Blob; 52 contentType?: string; 53 }, 54 ): Promise<T> { 55 const { 56 httpMethod = "GET", 57 params, 58 body, 59 authToken, 60 rawBody, 61 contentType, 62 } = options ?? {}; 63 64 let url = `${this.baseUrl}/xrpc/${method}`; 65 if (params) { 66 const searchParams = new URLSearchParams(params); 67 url += `?${searchParams}`; 68 } 69 70 const headers: Record<string, string> = {}; 71 const token = authToken ?? this.accessToken; 72 if (token) { 73 headers["Authorization"] = `Bearer ${token}`; 74 } 75 76 let requestBody: BodyInit | undefined; 77 if (rawBody) { 78 headers["Content-Type"] = contentType ?? "application/octet-stream"; 79 requestBody = rawBody; 80 } else if (body) { 81 headers["Content-Type"] = "application/json"; 82 requestBody = JSON.stringify(body); 83 } else if (httpMethod === "POST") { 84 headers["Content-Type"] = "application/json"; 85 } 86 87 const res = await fetch(url, { 88 method: httpMethod, 89 headers, 90 body: requestBody, 91 }); 92 93 if (!res.ok) { 94 const err = await res.json().catch(() => ({ 95 error: "Unknown", 96 message: res.statusText, 97 })); 98 const error = new Error(err.message) as Error & { 99 status: number; 100 error: string; 101 }; 102 error.status = res.status; 103 error.error = err.error; 104 throw error; 105 } 106 107 const responseContentType = res.headers.get("content-type") ?? ""; 108 if (responseContentType.includes("application/json")) { 109 return res.json(); 110 } 111 return res.arrayBuffer().then((buf) => new Uint8Array(buf)) as T; 112 } 113 114 async login( 115 identifier: string, 116 password: string, 117 authFactorToken?: string, 118 ): Promise<Session> { 119 const body: Record<string, string> = { identifier, password }; 120 if (authFactorToken) { 121 body.authFactorToken = authFactorToken; 122 } 123 124 const session = await this.xrpc<Session>( 125 "com.atproto.server.createSession", 126 { 127 httpMethod: "POST", 128 body, 129 }, 130 ); 131 132 this.accessToken = session.accessJwt; 133 return session; 134 } 135 136 async refreshSession(refreshJwt: string): Promise<Session> { 137 const session = await this.xrpc<Session>( 138 "com.atproto.server.refreshSession", 139 { 140 httpMethod: "POST", 141 authToken: refreshJwt, 142 }, 143 ); 144 this.accessToken = session.accessJwt; 145 return session; 146 } 147 148 async describeServer(): Promise<ServerDescription> { 149 return this.xrpc<ServerDescription>("com.atproto.server.describeServer"); 150 } 151 152 async getServiceAuth( 153 aud: string, 154 lxm?: string, 155 ): Promise<{ token: string }> { 156 const params: Record<string, string> = { aud }; 157 if (lxm) { 158 params.lxm = lxm; 159 } 160 return this.xrpc("com.atproto.server.getServiceAuth", { params }); 161 } 162 163 async getRepo(did: string): Promise<Uint8Array> { 164 return this.xrpc("com.atproto.sync.getRepo", { 165 params: { did }, 166 }); 167 } 168 169 async listBlobs( 170 did: string, 171 cursor?: string, 172 limit = 100, 173 ): Promise<{ cids: string[]; cursor?: string }> { 174 const params: Record<string, string> = { did, limit: String(limit) }; 175 if (cursor) { 176 params.cursor = cursor; 177 } 178 return this.xrpc("com.atproto.sync.listBlobs", { params }); 179 } 180 181 async getBlob(did: string, cid: string): Promise<Uint8Array> { 182 return this.xrpc("com.atproto.sync.getBlob", { 183 params: { did, cid }, 184 }); 185 } 186 187 async uploadBlob( 188 data: Uint8Array, 189 mimeType: string, 190 ): Promise<{ blob: BlobRef }> { 191 return this.xrpc("com.atproto.repo.uploadBlob", { 192 httpMethod: "POST", 193 rawBody: data, 194 contentType: mimeType, 195 }); 196 } 197 198 async getPreferences(): Promise<Preferences> { 199 return this.xrpc("app.bsky.actor.getPreferences"); 200 } 201 202 async putPreferences(preferences: Preferences): Promise<void> { 203 await this.xrpc("app.bsky.actor.putPreferences", { 204 httpMethod: "POST", 205 body: preferences, 206 }); 207 } 208 209 async createAccount( 210 params: CreateAccountParams, 211 serviceToken?: string, 212 ): Promise<Session> { 213 const headers: Record<string, string> = { 214 "Content-Type": "application/json", 215 }; 216 if (serviceToken) { 217 headers["Authorization"] = `Bearer ${serviceToken}`; 218 } 219 220 const res = await fetch( 221 `${this.baseUrl}/xrpc/com.atproto.server.createAccount`, 222 { 223 method: "POST", 224 headers, 225 body: JSON.stringify(params), 226 }, 227 ); 228 229 if (!res.ok) { 230 const err = await res.json().catch(() => ({ 231 error: "Unknown", 232 message: res.statusText, 233 })); 234 const error = new Error(err.message) as Error & { 235 status: number; 236 error: string; 237 }; 238 error.status = res.status; 239 error.error = err.error; 240 throw error; 241 } 242 243 const session = (await res.json()) as Session; 244 this.accessToken = session.accessJwt; 245 return session; 246 } 247 248 async importRepo(car: Uint8Array): Promise<void> { 249 await this.xrpc("com.atproto.repo.importRepo", { 250 httpMethod: "POST", 251 rawBody: car, 252 contentType: "application/vnd.ipld.car", 253 }); 254 } 255 256 async listMissingBlobs( 257 cursor?: string, 258 limit = 100, 259 ): Promise< 260 { blobs: Array<{ cid: string; recordUri: string }>; cursor?: string } 261 > { 262 const params: Record<string, string> = { limit: String(limit) }; 263 if (cursor) { 264 params.cursor = cursor; 265 } 266 return this.xrpc("com.atproto.repo.listMissingBlobs", { params }); 267 } 268 269 async requestPlcOperationSignature(): Promise<void> { 270 await this.xrpc("com.atproto.identity.requestPlcOperationSignature", { 271 httpMethod: "POST", 272 }); 273 } 274 275 async signPlcOperation(params: { 276 token?: string; 277 rotationKeys?: string[]; 278 alsoKnownAs?: string[]; 279 verificationMethods?: { atproto?: string }; 280 services?: { atproto_pds?: { type: string; endpoint: string } }; 281 }): Promise<{ operation: PlcOperation }> { 282 return this.xrpc("com.atproto.identity.signPlcOperation", { 283 httpMethod: "POST", 284 body: params, 285 }); 286 } 287 288 async submitPlcOperation(operation: PlcOperation): Promise<void> { 289 apiLog( 290 "POST", 291 `${this.baseUrl}/xrpc/com.atproto.identity.submitPlcOperation`, 292 { 293 operationType: operation.type, 294 operationPrev: operation.prev, 295 }, 296 ); 297 const start = Date.now(); 298 await this.xrpc("com.atproto.identity.submitPlcOperation", { 299 httpMethod: "POST", 300 body: { operation }, 301 }); 302 apiLog( 303 "POST", 304 `${this.baseUrl}/xrpc/com.atproto.identity.submitPlcOperation COMPLETE`, 305 { 306 durationMs: Date.now() - start, 307 }, 308 ); 309 } 310 311 async getRecommendedDidCredentials(): Promise<DidCredentials> { 312 return this.xrpc("com.atproto.identity.getRecommendedDidCredentials"); 313 } 314 315 async activateAccount(): Promise<void> { 316 apiLog("POST", `${this.baseUrl}/xrpc/com.atproto.server.activateAccount`); 317 const start = Date.now(); 318 await this.xrpc("com.atproto.server.activateAccount", { 319 httpMethod: "POST", 320 }); 321 apiLog( 322 "POST", 323 `${this.baseUrl}/xrpc/com.atproto.server.activateAccount COMPLETE`, 324 { 325 durationMs: Date.now() - start, 326 }, 327 ); 328 } 329 330 async deactivateAccount(migratingTo?: string): Promise<void> { 331 apiLog("POST", `${this.baseUrl}/xrpc/com.atproto.server.deactivateAccount`, { 332 migratingTo, 333 }); 334 const start = Date.now(); 335 try { 336 const body: { migratingTo?: string } = {}; 337 if (migratingTo) { 338 body.migratingTo = migratingTo; 339 } 340 await this.xrpc("com.atproto.server.deactivateAccount", { 341 httpMethod: "POST", 342 body, 343 }); 344 apiLog( 345 "POST", 346 `${this.baseUrl}/xrpc/com.atproto.server.deactivateAccount COMPLETE`, 347 { 348 durationMs: Date.now() - start, 349 success: true, 350 migratingTo, 351 }, 352 ); 353 } catch (e) { 354 const err = e as Error & { error?: string; status?: number }; 355 apiLog( 356 "POST", 357 `${this.baseUrl}/xrpc/com.atproto.server.deactivateAccount FAILED`, 358 { 359 durationMs: Date.now() - start, 360 error: err.message, 361 errorCode: err.error, 362 status: err.status, 363 migratingTo, 364 }, 365 ); 366 throw e; 367 } 368 } 369 370 async checkAccountStatus(): Promise<AccountStatus> { 371 return this.xrpc("com.atproto.server.checkAccountStatus"); 372 } 373 374 async getMigrationStatus(): Promise<{ 375 did: string; 376 didType: string; 377 migrated: boolean; 378 migratedToPds?: string; 379 migratedAt?: string; 380 }> { 381 return this.xrpc("com.tranquil.account.getMigrationStatus"); 382 } 383 384 async updateMigrationForwarding(pdsUrl: string): Promise<{ 385 success: boolean; 386 migratedToPds: string; 387 migratedAt: string; 388 }> { 389 return this.xrpc("com.tranquil.account.updateMigrationForwarding", { 390 httpMethod: "POST", 391 body: { pdsUrl }, 392 }); 393 } 394 395 async clearMigrationForwarding(): Promise<{ success: boolean }> { 396 return this.xrpc("com.tranquil.account.clearMigrationForwarding", { 397 httpMethod: "POST", 398 }); 399 } 400 401 async resolveHandle(handle: string): Promise<{ did: string }> { 402 return this.xrpc("com.atproto.identity.resolveHandle", { 403 params: { handle }, 404 }); 405 } 406 407 async loginDeactivated( 408 identifier: string, 409 password: string, 410 ): Promise<Session> { 411 const session = await this.xrpc<Session>( 412 "com.atproto.server.createSession", 413 { 414 httpMethod: "POST", 415 body: { identifier, password, allowDeactivated: true }, 416 }, 417 ); 418 this.accessToken = session.accessJwt; 419 return session; 420 } 421 422 async verifyToken( 423 token: string, 424 identifier: string, 425 ): Promise< 426 { success: boolean; did: string; purpose: string; channel: string } 427 > { 428 return this.xrpc("com.tranquil.account.verifyToken", { 429 httpMethod: "POST", 430 body: { token, identifier }, 431 }); 432 } 433 434 async resendMigrationVerification(): Promise<void> { 435 await this.xrpc("com.atproto.server.resendMigrationVerification", { 436 httpMethod: "POST", 437 }); 438 } 439} 440 441export async function resolveDidDocument(did: string): Promise<DidDocument> { 442 if (did.startsWith("did:plc:")) { 443 const res = await fetch(`https://plc.directory/${did}`); 444 if (!res.ok) { 445 throw new Error(`Failed to resolve DID: ${res.statusText}`); 446 } 447 return res.json(); 448 } 449 450 if (did.startsWith("did:web:")) { 451 const domain = did.slice(8).replace(/%3A/g, ":"); 452 const url = domain.includes("/") 453 ? `https://${domain}/did.json` 454 : `https://${domain}/.well-known/did.json`; 455 456 const res = await fetch(url); 457 if (!res.ok) { 458 throw new Error(`Failed to resolve DID: ${res.statusText}`); 459 } 460 return res.json(); 461 } 462 463 throw new Error(`Unsupported DID method: ${did}`); 464} 465 466export async function resolvePdsUrl( 467 handleOrDid: string, 468): Promise<{ did: string; pdsUrl: string }> { 469 let did: string; 470 471 if (handleOrDid.startsWith("did:")) { 472 did = handleOrDid; 473 } else { 474 const handle = handleOrDid.replace(/^@/, ""); 475 476 if (handle.endsWith(".bsky.social")) { 477 const res = await fetch( 478 `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${ 479 encodeURIComponent(handle) 480 }`, 481 ); 482 if (!res.ok) { 483 throw new Error(`Failed to resolve handle: ${res.statusText}`); 484 } 485 const data = await res.json(); 486 did = data.did; 487 } else { 488 const dnsRes = await fetch( 489 `https://dns.google/resolve?name=_atproto.${handle}&type=TXT`, 490 ); 491 if (dnsRes.ok) { 492 const dnsData = await dnsRes.json(); 493 const txtRecords = dnsData.Answer ?? []; 494 for (const record of txtRecords) { 495 const txt = record.data?.replace(/"/g, "") ?? ""; 496 if (txt.startsWith("did=")) { 497 did = txt.slice(4); 498 break; 499 } 500 } 501 } 502 503 if (!did) { 504 const wellKnownRes = await fetch( 505 `https://${handle}/.well-known/atproto-did`, 506 ); 507 if (wellKnownRes.ok) { 508 did = (await wellKnownRes.text()).trim(); 509 } 510 } 511 512 if (!did) { 513 throw new Error(`Could not resolve handle: ${handle}`); 514 } 515 } 516 } 517 518 const didDoc = await resolveDidDocument(did); 519 520 const pdsService = didDoc.service?.find( 521 (s: { type: string }) => s.type === "AtprotoPersonalDataServer", 522 ); 523 524 if (!pdsService) { 525 throw new Error("No PDS service found in DID document"); 526 } 527 528 return { did, pdsUrl: pdsService.serviceEndpoint }; 529} 530 531export function createLocalClient(): AtprotoClient { 532 return new AtprotoClient(window.location.origin); 533}