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(): Promise<void> {
331 apiLog("POST", `${this.baseUrl}/xrpc/com.atproto.server.deactivateAccount`);
332 const start = Date.now();
333 try {
334 await this.xrpc("com.atproto.server.deactivateAccount", {
335 httpMethod: "POST",
336 });
337 apiLog(
338 "POST",
339 `${this.baseUrl}/xrpc/com.atproto.server.deactivateAccount COMPLETE`,
340 {
341 durationMs: Date.now() - start,
342 success: true,
343 },
344 );
345 } catch (e) {
346 const err = e as Error & { error?: string; status?: number };
347 apiLog(
348 "POST",
349 `${this.baseUrl}/xrpc/com.atproto.server.deactivateAccount FAILED`,
350 {
351 durationMs: Date.now() - start,
352 error: err.message,
353 errorCode: err.error,
354 status: err.status,
355 },
356 );
357 throw e;
358 }
359 }
360
361 async checkAccountStatus(): Promise<AccountStatus> {
362 return this.xrpc("com.atproto.server.checkAccountStatus");
363 }
364
365 async getMigrationStatus(): Promise<{
366 did: string;
367 didType: string;
368 migrated: boolean;
369 migratedToPds?: string;
370 migratedAt?: string;
371 }> {
372 return this.xrpc("com.tranquil.account.getMigrationStatus");
373 }
374
375 async updateMigrationForwarding(pdsUrl: string): Promise<{
376 success: boolean;
377 migratedToPds: string;
378 migratedAt: string;
379 }> {
380 return this.xrpc("com.tranquil.account.updateMigrationForwarding", {
381 httpMethod: "POST",
382 body: { pdsUrl },
383 });
384 }
385
386 async clearMigrationForwarding(): Promise<{ success: boolean }> {
387 return this.xrpc("com.tranquil.account.clearMigrationForwarding", {
388 httpMethod: "POST",
389 });
390 }
391
392 async resolveHandle(handle: string): Promise<{ did: string }> {
393 return this.xrpc("com.atproto.identity.resolveHandle", {
394 params: { handle },
395 });
396 }
397
398 async loginDeactivated(
399 identifier: string,
400 password: string,
401 ): Promise<Session> {
402 const session = await this.xrpc<Session>(
403 "com.atproto.server.createSession",
404 {
405 httpMethod: "POST",
406 body: { identifier, password, allowDeactivated: true },
407 },
408 );
409 this.accessToken = session.accessJwt;
410 return session;
411 }
412
413 async verifyToken(
414 token: string,
415 identifier: string,
416 ): Promise<
417 { success: boolean; did: string; purpose: string; channel: string }
418 > {
419 return this.xrpc("com.tranquil.account.verifyToken", {
420 httpMethod: "POST",
421 body: { token, identifier },
422 });
423 }
424
425 async resendMigrationVerification(): Promise<void> {
426 await this.xrpc("com.atproto.server.resendMigrationVerification", {
427 httpMethod: "POST",
428 });
429 }
430}
431
432export async function resolveDidDocument(did: string): Promise<DidDocument> {
433 if (did.startsWith("did:plc:")) {
434 const res = await fetch(`https://plc.directory/${did}`);
435 if (!res.ok) {
436 throw new Error(`Failed to resolve DID: ${res.statusText}`);
437 }
438 return res.json();
439 }
440
441 if (did.startsWith("did:web:")) {
442 const domain = did.slice(8).replace(/%3A/g, ":");
443 const url = domain.includes("/")
444 ? `https://${domain}/did.json`
445 : `https://${domain}/.well-known/did.json`;
446
447 const res = await fetch(url);
448 if (!res.ok) {
449 throw new Error(`Failed to resolve DID: ${res.statusText}`);
450 }
451 return res.json();
452 }
453
454 throw new Error(`Unsupported DID method: ${did}`);
455}
456
457export async function resolvePdsUrl(
458 handleOrDid: string,
459): Promise<{ did: string; pdsUrl: string }> {
460 let did: string;
461
462 if (handleOrDid.startsWith("did:")) {
463 did = handleOrDid;
464 } else {
465 const handle = handleOrDid.replace(/^@/, "");
466
467 if (handle.endsWith(".bsky.social")) {
468 const res = await fetch(
469 `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${
470 encodeURIComponent(handle)
471 }`,
472 );
473 if (!res.ok) {
474 throw new Error(`Failed to resolve handle: ${res.statusText}`);
475 }
476 const data = await res.json();
477 did = data.did;
478 } else {
479 const dnsRes = await fetch(
480 `https://dns.google/resolve?name=_atproto.${handle}&type=TXT`,
481 );
482 if (dnsRes.ok) {
483 const dnsData = await dnsRes.json();
484 const txtRecords = dnsData.Answer ?? [];
485 for (const record of txtRecords) {
486 const txt = record.data?.replace(/"/g, "") ?? "";
487 if (txt.startsWith("did=")) {
488 did = txt.slice(4);
489 break;
490 }
491 }
492 }
493
494 if (!did) {
495 const wellKnownRes = await fetch(
496 `https://${handle}/.well-known/atproto-did`,
497 );
498 if (wellKnownRes.ok) {
499 did = (await wellKnownRes.text()).trim();
500 }
501 }
502
503 if (!did) {
504 throw new Error(`Could not resolve handle: ${handle}`);
505 }
506 }
507 }
508
509 const didDoc = await resolveDidDocument(did);
510
511 const pdsService = didDoc.service?.find(
512 (s: { type: string }) => s.type === "AtprotoPersonalDataServer",
513 );
514
515 if (!pdsService) {
516 throw new Error("No PDS service found in DID document");
517 }
518
519 return { did, pdsUrl: pdsService.serviceEndpoint };
520}
521
522export function createLocalClient(): AtprotoClient {
523 return new AtprotoClient(window.location.origin);
524}