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}