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