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}