this repo has no description
1import type {
2 AccountStatus,
3 BlobRef,
4 CompletePasskeySetupResponse,
5 CreateAccountParams,
6 CreatePasskeyAccountParams,
7 DidCredentials,
8 DidDocument,
9 OAuthServerMetadata,
10 OAuthTokenResponse,
11 PasskeyAccountSetup,
12 PlcOperation,
13 Preferences,
14 ServerDescription,
15 Session,
16 StartPasskeyRegistrationResponse,
17} from "./types";
18
19function apiLog(
20 method: string,
21 endpoint: string,
22 data?: Record<string, unknown>,
23) {
24 const timestamp = new Date().toISOString();
25 const msg = `[API ${timestamp}] ${method} ${endpoint}`;
26 if (data) {
27 console.log(msg, JSON.stringify(data, null, 2));
28 } else {
29 console.log(msg);
30 }
31}
32
33export class AtprotoClient {
34 private baseUrl: string;
35 private accessToken: string | null = null;
36 private dpopKeyPair: DPoPKeyPair | null = null;
37 private dpopNonce: string | null = null;
38
39 constructor(pdsUrl: string) {
40 this.baseUrl = pdsUrl.replace(/\/$/, "");
41 }
42
43 setAccessToken(token: string | null) {
44 this.accessToken = token;
45 }
46
47 getAccessToken(): string | null {
48 return this.accessToken;
49 }
50
51 setDPoPKeyPair(keyPair: DPoPKeyPair | null) {
52 this.dpopKeyPair = keyPair;
53 }
54
55 private async xrpc<T>(
56 method: string,
57 options?: {
58 httpMethod?: "GET" | "POST";
59 params?: Record<string, string>;
60 body?: unknown;
61 authToken?: string;
62 rawBody?: Uint8Array | Blob;
63 contentType?: string;
64 },
65 ): Promise<T> {
66 const {
67 httpMethod = "GET",
68 params,
69 body,
70 authToken,
71 rawBody,
72 contentType,
73 } = options ?? {};
74
75 let url = `${this.baseUrl}/xrpc/${method}`;
76 if (params) {
77 const searchParams = new URLSearchParams(params);
78 url += `?${searchParams}`;
79 }
80
81 const makeRequest = async (nonce?: string): Promise<Response> => {
82 const headers: Record<string, string> = {};
83 const token = authToken ?? this.accessToken;
84 if (token) {
85 if (this.dpopKeyPair) {
86 headers["Authorization"] = `DPoP ${token}`;
87 const tokenHash = await computeAccessTokenHash(token);
88 const dpopProof = await createDPoPProof(
89 this.dpopKeyPair,
90 httpMethod,
91 url.split("?")[0],
92 nonce,
93 tokenHash,
94 );
95 headers["DPoP"] = dpopProof;
96 } else {
97 headers["Authorization"] = `Bearer ${token}`;
98 }
99 }
100
101 let requestBody: BodyInit | undefined;
102 if (rawBody) {
103 headers["Content-Type"] = contentType ?? "application/octet-stream";
104 requestBody = rawBody;
105 } else if (body) {
106 headers["Content-Type"] = "application/json";
107 requestBody = JSON.stringify(body);
108 } else if (httpMethod === "POST") {
109 headers["Content-Type"] = "application/json";
110 }
111
112 return fetch(url, {
113 method: httpMethod,
114 headers,
115 body: requestBody,
116 });
117 };
118
119 let res = await makeRequest(this.dpopNonce ?? undefined);
120
121 if (!res.ok && this.dpopKeyPair) {
122 const dpopNonce = res.headers.get("DPoP-Nonce");
123 if (dpopNonce && dpopNonce !== this.dpopNonce) {
124 this.dpopNonce = dpopNonce;
125 res = await makeRequest(dpopNonce);
126 }
127 }
128
129 if (!res.ok) {
130 const err = await res.json().catch(() => ({
131 error: "Unknown",
132 message: res.statusText,
133 }));
134 const error = new Error(err.message || err.error || res.statusText) as
135 & Error
136 & {
137 status: number;
138 error: string;
139 };
140 error.status = res.status;
141 error.error = err.error;
142 throw error;
143 }
144
145 const newNonce = res.headers.get("DPoP-Nonce");
146 if (newNonce) {
147 this.dpopNonce = newNonce;
148 }
149
150 const responseContentType = res.headers.get("content-type") ?? "";
151 if (responseContentType.includes("application/json")) {
152 return res.json();
153 }
154 return res.arrayBuffer().then((buf) => new Uint8Array(buf)) as T;
155 }
156
157 async login(
158 identifier: string,
159 password: string,
160 authFactorToken?: string,
161 ): Promise<Session> {
162 const body: Record<string, string> = { identifier, password };
163 if (authFactorToken) {
164 body.authFactorToken = authFactorToken;
165 }
166
167 const session = await this.xrpc<Session>(
168 "com.atproto.server.createSession",
169 {
170 httpMethod: "POST",
171 body,
172 },
173 );
174
175 this.accessToken = session.accessJwt;
176 return session;
177 }
178
179 async refreshSession(refreshJwt: string): Promise<Session> {
180 const session = await this.xrpc<Session>(
181 "com.atproto.server.refreshSession",
182 {
183 httpMethod: "POST",
184 authToken: refreshJwt,
185 },
186 );
187 this.accessToken = session.accessJwt;
188 return session;
189 }
190
191 describeServer(): Promise<ServerDescription> {
192 return this.xrpc<ServerDescription>("com.atproto.server.describeServer");
193 }
194
195 getServiceAuth(
196 aud: string,
197 lxm?: string,
198 ): Promise<{ token: string }> {
199 const params: Record<string, string> = { aud };
200 if (lxm) {
201 params.lxm = lxm;
202 }
203 return this.xrpc("com.atproto.server.getServiceAuth", { params });
204 }
205
206 getRepo(did: string): Promise<Uint8Array> {
207 return this.xrpc("com.atproto.sync.getRepo", {
208 params: { did },
209 });
210 }
211
212 async listBlobs(
213 did: string,
214 cursor?: string,
215 limit = 100,
216 ): Promise<{ cids: string[]; cursor?: string }> {
217 const params: Record<string, string> = { did, limit: String(limit) };
218 if (cursor) {
219 params.cursor = cursor;
220 }
221 return this.xrpc("com.atproto.sync.listBlobs", { params });
222 }
223
224 async getBlob(did: string, cid: string): Promise<Uint8Array> {
225 return this.xrpc("com.atproto.sync.getBlob", {
226 params: { did, cid },
227 });
228 }
229
230 async getBlobWithContentType(
231 did: string,
232 cid: string,
233 ): Promise<{ data: Uint8Array; contentType: string }> {
234 const url = `${this.baseUrl}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(did)}&cid=${encodeURIComponent(cid)}`;
235 const headers: Record<string, string> = {};
236 if (this.accessToken) {
237 headers["Authorization"] = `Bearer ${this.accessToken}`;
238 }
239 const res = await fetch(url, { headers });
240 if (!res.ok) {
241 const err = await res.json().catch(() => ({
242 error: "Unknown",
243 message: res.statusText,
244 }));
245 throw new Error(err.message || err.error || res.statusText);
246 }
247 const contentType = res.headers.get("content-type") || "application/octet-stream";
248 const data = new Uint8Array(await res.arrayBuffer());
249 return { data, contentType };
250 }
251
252 async uploadBlob(
253 data: Uint8Array,
254 mimeType: string,
255 ): Promise<{ blob: BlobRef }> {
256 return this.xrpc("com.atproto.repo.uploadBlob", {
257 httpMethod: "POST",
258 rawBody: data,
259 contentType: mimeType,
260 });
261 }
262
263 async getPreferences(): Promise<Preferences> {
264 return this.xrpc("app.bsky.actor.getPreferences");
265 }
266
267 async putPreferences(preferences: Preferences): Promise<void> {
268 await this.xrpc("app.bsky.actor.putPreferences", {
269 httpMethod: "POST",
270 body: preferences,
271 });
272 }
273
274 async createAccount(
275 params: CreateAccountParams,
276 serviceToken?: string,
277 ): Promise<Session> {
278 const headers: Record<string, string> = {
279 "Content-Type": "application/json",
280 };
281 if (serviceToken) {
282 headers["Authorization"] = `Bearer ${serviceToken}`;
283 }
284
285 const res = await fetch(
286 `${this.baseUrl}/xrpc/com.atproto.server.createAccount`,
287 {
288 method: "POST",
289 headers,
290 body: JSON.stringify(params),
291 },
292 );
293
294 if (!res.ok) {
295 const err = await res.json().catch(() => ({
296 error: "Unknown",
297 message: res.statusText,
298 }));
299 const error = new Error(err.message || err.error || res.statusText) as
300 & Error
301 & {
302 status: number;
303 error: string;
304 };
305 error.status = res.status;
306 error.error = err.error;
307 throw error;
308 }
309
310 const session = (await res.json()) as Session;
311 this.accessToken = session.accessJwt;
312 return session;
313 }
314
315 async importRepo(car: Uint8Array): Promise<void> {
316 await this.xrpc("com.atproto.repo.importRepo", {
317 httpMethod: "POST",
318 rawBody: car,
319 contentType: "application/vnd.ipld.car",
320 });
321 }
322
323 async listMissingBlobs(
324 cursor?: string,
325 limit = 100,
326 ): Promise<
327 { blobs: Array<{ cid: string; recordUri: string }>; cursor?: string }
328 > {
329 const params: Record<string, string> = { limit: String(limit) };
330 if (cursor) {
331 params.cursor = cursor;
332 }
333 return this.xrpc("com.atproto.repo.listMissingBlobs", { params });
334 }
335
336 async requestPlcOperationSignature(): Promise<void> {
337 await this.xrpc("com.atproto.identity.requestPlcOperationSignature", {
338 httpMethod: "POST",
339 });
340 }
341
342 async signPlcOperation(params: {
343 token?: string;
344 rotationKeys?: string[];
345 alsoKnownAs?: string[];
346 verificationMethods?: { atproto?: string };
347 services?: { atproto_pds?: { type: string; endpoint: string } };
348 }): Promise<{ operation: PlcOperation }> {
349 return this.xrpc("com.atproto.identity.signPlcOperation", {
350 httpMethod: "POST",
351 body: params,
352 });
353 }
354
355 async submitPlcOperation(operation: PlcOperation): Promise<void> {
356 apiLog(
357 "POST",
358 `${this.baseUrl}/xrpc/com.atproto.identity.submitPlcOperation`,
359 {
360 operationType: operation.type,
361 operationPrev: operation.prev,
362 },
363 );
364 const start = Date.now();
365 await this.xrpc("com.atproto.identity.submitPlcOperation", {
366 httpMethod: "POST",
367 body: { operation },
368 });
369 apiLog(
370 "POST",
371 `${this.baseUrl}/xrpc/com.atproto.identity.submitPlcOperation COMPLETE`,
372 {
373 durationMs: Date.now() - start,
374 },
375 );
376 }
377
378 async getRecommendedDidCredentials(): Promise<DidCredentials> {
379 return this.xrpc("com.atproto.identity.getRecommendedDidCredentials");
380 }
381
382 async activateAccount(): Promise<void> {
383 apiLog("POST", `${this.baseUrl}/xrpc/com.atproto.server.activateAccount`);
384 const start = Date.now();
385 await this.xrpc("com.atproto.server.activateAccount", {
386 httpMethod: "POST",
387 });
388 apiLog(
389 "POST",
390 `${this.baseUrl}/xrpc/com.atproto.server.activateAccount COMPLETE`,
391 {
392 durationMs: Date.now() - start,
393 },
394 );
395 }
396
397 async deactivateAccount(): Promise<void> {
398 apiLog(
399 "POST",
400 `${this.baseUrl}/xrpc/com.atproto.server.deactivateAccount`,
401 );
402 const start = Date.now();
403 try {
404 await this.xrpc("com.atproto.server.deactivateAccount", {
405 httpMethod: "POST",
406 });
407 apiLog(
408 "POST",
409 `${this.baseUrl}/xrpc/com.atproto.server.deactivateAccount COMPLETE`,
410 {
411 durationMs: Date.now() - start,
412 success: true,
413 },
414 );
415 } catch (e) {
416 const err = e as Error & { error?: string; status?: number };
417 apiLog(
418 "POST",
419 `${this.baseUrl}/xrpc/com.atproto.server.deactivateAccount FAILED`,
420 {
421 durationMs: Date.now() - start,
422 error: err.message,
423 errorCode: err.error,
424 status: err.status,
425 },
426 );
427 throw e;
428 }
429 }
430
431 async checkAccountStatus(): Promise<AccountStatus> {
432 return this.xrpc("com.atproto.server.checkAccountStatus");
433 }
434
435 async resolveHandle(handle: string): Promise<{ did: string }> {
436 return this.xrpc("com.atproto.identity.resolveHandle", {
437 params: { handle },
438 });
439 }
440
441 async loginDeactivated(
442 identifier: string,
443 password: string,
444 ): Promise<Session> {
445 const session = await this.xrpc<Session>(
446 "com.atproto.server.createSession",
447 {
448 httpMethod: "POST",
449 body: { identifier, password, allowDeactivated: true },
450 },
451 );
452 this.accessToken = session.accessJwt;
453 return session;
454 }
455
456 async checkEmailVerified(identifier: string): Promise<boolean> {
457 const result = await this.xrpc<{ verified: boolean }>(
458 "_checkEmailVerified",
459 {
460 httpMethod: "POST",
461 body: { identifier },
462 },
463 );
464 return result.verified;
465 }
466
467 async verifyToken(
468 token: string,
469 identifier: string,
470 ): Promise<
471 { success: boolean; did: string; purpose: string; channel: string }
472 > {
473 return this.xrpc("_account.verifyToken", {
474 httpMethod: "POST",
475 body: { token, identifier },
476 });
477 }
478
479 async resendMigrationVerification(): Promise<void> {
480 await this.xrpc("com.atproto.server.resendMigrationVerification", {
481 httpMethod: "POST",
482 });
483 }
484
485 async createPasskeyAccount(
486 params: CreatePasskeyAccountParams,
487 serviceToken?: string,
488 ): Promise<PasskeyAccountSetup> {
489 const headers: Record<string, string> = {
490 "Content-Type": "application/json",
491 };
492 if (serviceToken) {
493 headers["Authorization"] = `Bearer ${serviceToken}`;
494 }
495
496 const res = await fetch(
497 `${this.baseUrl}/xrpc/_account.createPasskeyAccount`,
498 {
499 method: "POST",
500 headers,
501 body: JSON.stringify(params),
502 },
503 );
504
505 if (!res.ok) {
506 const err = await res.json().catch(() => ({
507 error: "Unknown",
508 message: res.statusText,
509 }));
510 const error = new Error(err.message || err.error || res.statusText) as
511 & Error
512 & {
513 status: number;
514 error: string;
515 };
516 error.status = res.status;
517 error.error = err.error;
518 throw error;
519 }
520
521 return res.json();
522 }
523
524 async startPasskeyRegistrationForSetup(
525 did: string,
526 setupToken: string,
527 friendlyName?: string,
528 ): Promise<StartPasskeyRegistrationResponse> {
529 return this.xrpc("_account.startPasskeyRegistrationForSetup", {
530 httpMethod: "POST",
531 body: { did, setupToken, friendlyName },
532 });
533 }
534
535 async completePasskeySetup(
536 did: string,
537 setupToken: string,
538 passkeyCredential: unknown,
539 passkeyFriendlyName?: string,
540 ): Promise<CompletePasskeySetupResponse> {
541 return this.xrpc("_account.completePasskeySetup", {
542 httpMethod: "POST",
543 body: { did, setupToken, passkeyCredential, passkeyFriendlyName },
544 });
545 }
546}
547
548export async function getOAuthServerMetadata(
549 pdsUrl: string,
550): Promise<OAuthServerMetadata | null> {
551 try {
552 const directUrl = `${pdsUrl}/.well-known/oauth-authorization-server`;
553 const directRes = await fetch(directUrl);
554 if (directRes.ok) {
555 return directRes.json();
556 }
557
558 const protectedResourceUrl =
559 `${pdsUrl}/.well-known/oauth-protected-resource`;
560 const protectedRes = await fetch(protectedResourceUrl);
561 if (!protectedRes.ok) {
562 return null;
563 }
564
565 const protectedMetadata = await protectedRes.json();
566 const authServers = protectedMetadata.authorization_servers;
567 if (!authServers || authServers.length === 0) {
568 return null;
569 }
570
571 const authServerUrl = `${
572 authServers[0]
573 }/.well-known/oauth-authorization-server`;
574 const authServerRes = await fetch(authServerUrl);
575 if (!authServerRes.ok) {
576 return null;
577 }
578
579 return authServerRes.json();
580 } catch {
581 return null;
582 }
583}
584
585export async function generatePKCE(): Promise<{
586 codeVerifier: string;
587 codeChallenge: string;
588}> {
589 const array = new Uint8Array(32);
590 crypto.getRandomValues(array);
591 const codeVerifier = base64UrlEncode(array);
592
593 const encoder = new TextEncoder();
594 const data = encoder.encode(codeVerifier);
595 const digest = await crypto.subtle.digest("SHA-256", data);
596 const codeChallenge = base64UrlEncode(new Uint8Array(digest));
597
598 return { codeVerifier, codeChallenge };
599}
600
601export function base64UrlEncode(buffer: Uint8Array | ArrayBuffer): string {
602 const bytes = buffer instanceof ArrayBuffer ? new Uint8Array(buffer) : buffer;
603 let binary = "";
604 for (let i = 0; i < bytes.length; i++) {
605 binary += String.fromCharCode(bytes[i]);
606 }
607 return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(
608 /=+$/,
609 "",
610 );
611}
612
613export function base64UrlDecode(base64url: string): Uint8Array {
614 const base64 = base64url.replace(/-/g, "+").replace(/_/g, "/");
615 const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4);
616 const binary = atob(padded);
617 const bytes = new Uint8Array(binary.length);
618 for (let i = 0; i < binary.length; i++) {
619 bytes[i] = binary.charCodeAt(i);
620 }
621 return bytes;
622}
623
624export function prepareWebAuthnCreationOptions(
625 options: { publicKey: Record<string, unknown> },
626): PublicKeyCredentialCreationOptions {
627 const pk = options.publicKey;
628 return {
629 ...pk,
630 challenge: base64UrlDecode(pk.challenge as string),
631 user: {
632 ...(pk.user as Record<string, unknown>),
633 id: base64UrlDecode((pk.user as Record<string, unknown>).id as string),
634 },
635 excludeCredentials:
636 ((pk.excludeCredentials as Array<Record<string, unknown>>) ?? []).map(
637 (cred) => ({
638 ...cred,
639 id: base64UrlDecode(cred.id as string),
640 }),
641 ),
642 } as PublicKeyCredentialCreationOptions;
643}
644
645async function computeAccessTokenHash(accessToken: string): Promise<string> {
646 const encoder = new TextEncoder();
647 const data = encoder.encode(accessToken);
648 const hash = await crypto.subtle.digest("SHA-256", data);
649 return base64UrlEncode(new Uint8Array(hash));
650}
651
652export function generateOAuthState(): string {
653 const array = new Uint8Array(16);
654 crypto.getRandomValues(array);
655 return base64UrlEncode(array);
656}
657
658export function buildOAuthAuthorizationUrl(
659 metadata: OAuthServerMetadata,
660 params: {
661 clientId: string;
662 redirectUri: string;
663 codeChallenge: string;
664 state: string;
665 scope?: string;
666 dpopJkt?: string;
667 loginHint?: string;
668 },
669): string {
670 const url = new URL(metadata.authorization_endpoint);
671 url.searchParams.set("response_type", "code");
672 url.searchParams.set("client_id", params.clientId);
673 url.searchParams.set("redirect_uri", params.redirectUri);
674 url.searchParams.set("code_challenge", params.codeChallenge);
675 url.searchParams.set("code_challenge_method", "S256");
676 url.searchParams.set("state", params.state);
677 url.searchParams.set("scope", params.scope ?? "atproto");
678 if (params.dpopJkt) {
679 url.searchParams.set("dpop_jkt", params.dpopJkt);
680 }
681 if (params.loginHint) {
682 url.searchParams.set("login_hint", params.loginHint);
683 }
684 return url.toString();
685}
686
687export async function initiateOAuthWithPAR(
688 metadata: OAuthServerMetadata,
689 params: {
690 clientId: string;
691 redirectUri: string;
692 codeChallenge: string;
693 state: string;
694 scope?: string;
695 dpopJkt?: string;
696 loginHint?: string;
697 },
698): Promise<string> {
699 if (!metadata.pushed_authorization_request_endpoint) {
700 return buildOAuthAuthorizationUrl(metadata, params);
701 }
702
703 const body = new URLSearchParams({
704 response_type: "code",
705 client_id: params.clientId,
706 redirect_uri: params.redirectUri,
707 code_challenge: params.codeChallenge,
708 code_challenge_method: "S256",
709 state: params.state,
710 scope: params.scope ?? "atproto",
711 });
712
713 if (params.dpopJkt) {
714 body.set("dpop_jkt", params.dpopJkt);
715 }
716 if (params.loginHint) {
717 body.set("login_hint", params.loginHint);
718 }
719
720 const res = await fetch(metadata.pushed_authorization_request_endpoint, {
721 method: "POST",
722 headers: { "Content-Type": "application/x-www-form-urlencoded" },
723 body: body.toString(),
724 });
725
726 if (!res.ok) {
727 const err = await res.json().catch(() => ({
728 error: "par_error",
729 error_description: res.statusText,
730 }));
731 throw new Error(err.error_description || err.error || "PAR request failed");
732 }
733
734 const { request_uri } = await res.json();
735
736 const authUrl = new URL(metadata.authorization_endpoint);
737 authUrl.searchParams.set("client_id", params.clientId);
738 authUrl.searchParams.set("request_uri", request_uri);
739 return authUrl.toString();
740}
741
742export async function exchangeOAuthCode(
743 metadata: OAuthServerMetadata,
744 params: {
745 code: string;
746 codeVerifier: string;
747 clientId: string;
748 redirectUri: string;
749 dpopKeyPair?: DPoPKeyPair;
750 },
751): Promise<OAuthTokenResponse> {
752 const body = new URLSearchParams({
753 grant_type: "authorization_code",
754 code: params.code,
755 code_verifier: params.codeVerifier,
756 client_id: params.clientId,
757 redirect_uri: params.redirectUri,
758 });
759
760 const makeRequest = async (nonce?: string): Promise<Response> => {
761 const headers: Record<string, string> = {
762 "Content-Type": "application/x-www-form-urlencoded",
763 };
764
765 if (params.dpopKeyPair) {
766 const dpopProof = await createDPoPProof(
767 params.dpopKeyPair,
768 "POST",
769 metadata.token_endpoint,
770 nonce,
771 );
772 headers["DPoP"] = dpopProof;
773 }
774
775 return fetch(metadata.token_endpoint, {
776 method: "POST",
777 headers,
778 body: body.toString(),
779 });
780 };
781
782 let res = await makeRequest();
783
784 if (!res.ok) {
785 const err = await res.json().catch(() => ({
786 error: "token_error",
787 error_description: res.statusText,
788 }));
789
790 if (err.error === "use_dpop_nonce" && params.dpopKeyPair) {
791 const dpopNonce = res.headers.get("DPoP-Nonce");
792 if (dpopNonce) {
793 res = await makeRequest(dpopNonce);
794 if (!res.ok) {
795 const retryErr = await res.json().catch(() => ({
796 error: "token_error",
797 error_description: res.statusText,
798 }));
799 throw new Error(
800 retryErr.error_description || retryErr.error ||
801 "Token exchange failed",
802 );
803 }
804 return res.json();
805 }
806 }
807
808 throw new Error(
809 err.error_description || err.error || "Token exchange failed",
810 );
811 }
812
813 return res.json();
814}
815
816export async function resolveDidDocument(did: string): Promise<DidDocument> {
817 if (did.startsWith("did:plc:")) {
818 const res = await fetch(`https://plc.directory/${did}`);
819 if (!res.ok) {
820 throw new Error(`Failed to resolve DID: ${res.statusText}`);
821 }
822 return res.json();
823 }
824
825 if (did.startsWith("did:web:")) {
826 const domain = did.slice(8).replace(/%3A/g, ":");
827 const url = domain.includes("/")
828 ? `https://${domain}/did.json`
829 : `https://${domain}/.well-known/did.json`;
830
831 const res = await fetch(url);
832 if (!res.ok) {
833 throw new Error(`Failed to resolve DID: ${res.statusText}`);
834 }
835 return res.json();
836 }
837
838 throw new Error(`Unsupported DID method: ${did}`);
839}
840
841export async function resolvePdsUrl(
842 handleOrDid: string,
843): Promise<{ did: string; pdsUrl: string }> {
844 let did: string | undefined;
845
846 if (handleOrDid.startsWith("did:")) {
847 did = handleOrDid;
848 } else {
849 const handle = handleOrDid.replace(/^@/, "");
850
851 if (handle.endsWith(".bsky.social")) {
852 const res = await fetch(
853 `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${
854 encodeURIComponent(handle)
855 }`,
856 );
857 if (!res.ok) {
858 throw new Error(`Failed to resolve handle: ${res.statusText}`);
859 }
860 const data = await res.json();
861 did = data.did;
862 } else {
863 const dnsRes = await fetch(
864 `https://dns.google/resolve?name=_atproto.${handle}&type=TXT`,
865 );
866 if (dnsRes.ok) {
867 const dnsData = await dnsRes.json();
868 const txtRecords = dnsData.Answer ?? [];
869 for (const record of txtRecords) {
870 const txt = record.data?.replace(/"/g, "") ?? "";
871 if (txt.startsWith("did=")) {
872 did = txt.slice(4);
873 break;
874 }
875 }
876 }
877
878 if (!did) {
879 const wellKnownRes = await fetch(
880 `https://${handle}/.well-known/atproto-did`,
881 );
882 if (wellKnownRes.ok) {
883 did = (await wellKnownRes.text()).trim();
884 }
885 }
886
887 if (!did) {
888 throw new Error(`Could not resolve handle: ${handle}`);
889 }
890 }
891 }
892
893 if (!did) {
894 throw new Error("Could not resolve DID");
895 }
896
897 const didDoc = await resolveDidDocument(did);
898
899 const pdsService = didDoc.service?.find(
900 (s: { type: string }) => s.type === "AtprotoPersonalDataServer",
901 );
902
903 if (!pdsService) {
904 throw new Error("No PDS service found in DID document");
905 }
906
907 return { did, pdsUrl: pdsService.serviceEndpoint };
908}
909
910export function createLocalClient(): AtprotoClient {
911 return new AtprotoClient(globalThis.location.origin);
912}
913
914export function getMigrationOAuthClientId(): string {
915 return `${globalThis.location.origin}/oauth/client-metadata.json`;
916}
917
918export function getMigrationOAuthRedirectUri(): string {
919 return `${globalThis.location.origin}/app/migrate`;
920}
921
922export interface DPoPKeyPair {
923 privateKey: CryptoKey;
924 publicKey: CryptoKey;
925 jwk: JsonWebKey;
926 thumbprint: string;
927}
928
929const DPOP_KEY_STORAGE = "migration_dpop_key";
930const DPOP_KEY_MAX_AGE_MS = 24 * 60 * 60 * 1000;
931
932export async function generateDPoPKeyPair(): Promise<DPoPKeyPair> {
933 const keyPair = await crypto.subtle.generateKey(
934 {
935 name: "ECDSA",
936 namedCurve: "P-256",
937 },
938 true,
939 ["sign", "verify"],
940 );
941
942 const publicJwk = await crypto.subtle.exportKey("jwk", keyPair.publicKey);
943 const thumbprint = await computeJwkThumbprint(publicJwk);
944
945 return {
946 privateKey: keyPair.privateKey,
947 publicKey: keyPair.publicKey,
948 jwk: publicJwk,
949 thumbprint,
950 };
951}
952
953async function computeJwkThumbprint(jwk: JsonWebKey): Promise<string> {
954 const thumbprintInput = JSON.stringify({
955 crv: jwk.crv,
956 kty: jwk.kty,
957 x: jwk.x,
958 y: jwk.y,
959 });
960
961 const encoder = new TextEncoder();
962 const data = encoder.encode(thumbprintInput);
963 const hash = await crypto.subtle.digest("SHA-256", data);
964 return base64UrlEncode(new Uint8Array(hash));
965}
966
967export async function saveDPoPKey(keyPair: DPoPKeyPair): Promise<void> {
968 const privateJwk = await crypto.subtle.exportKey("jwk", keyPair.privateKey);
969 const stored = {
970 privateJwk,
971 publicJwk: keyPair.jwk,
972 thumbprint: keyPair.thumbprint,
973 createdAt: Date.now(),
974 };
975 localStorage.setItem(DPOP_KEY_STORAGE, JSON.stringify(stored));
976}
977
978export async function loadDPoPKey(): Promise<DPoPKeyPair | null> {
979 const stored = localStorage.getItem(DPOP_KEY_STORAGE);
980 if (!stored) return null;
981
982 try {
983 const { privateJwk, publicJwk, thumbprint, createdAt } = JSON.parse(stored);
984
985 if (createdAt && Date.now() - createdAt > DPOP_KEY_MAX_AGE_MS) {
986 localStorage.removeItem(DPOP_KEY_STORAGE);
987 return null;
988 }
989
990 const privateKey = await crypto.subtle.importKey(
991 "jwk",
992 privateJwk,
993 { name: "ECDSA", namedCurve: "P-256" },
994 true,
995 ["sign"],
996 );
997
998 const publicKey = await crypto.subtle.importKey(
999 "jwk",
1000 publicJwk,
1001 { name: "ECDSA", namedCurve: "P-256" },
1002 true,
1003 ["verify"],
1004 );
1005
1006 return { privateKey, publicKey, jwk: publicJwk, thumbprint };
1007 } catch {
1008 localStorage.removeItem(DPOP_KEY_STORAGE);
1009 return null;
1010 }
1011}
1012
1013export function clearDPoPKey(): void {
1014 localStorage.removeItem(DPOP_KEY_STORAGE);
1015}
1016
1017export async function createDPoPProof(
1018 keyPair: DPoPKeyPair,
1019 httpMethod: string,
1020 httpUri: string,
1021 nonce?: string,
1022 accessTokenHash?: string,
1023): Promise<string> {
1024 const header = {
1025 typ: "dpop+jwt",
1026 alg: "ES256",
1027 jwk: {
1028 kty: keyPair.jwk.kty,
1029 crv: keyPair.jwk.crv,
1030 x: keyPair.jwk.x,
1031 y: keyPair.jwk.y,
1032 },
1033 };
1034
1035 const payload: Record<string, unknown> = {
1036 jti: crypto.randomUUID(),
1037 htm: httpMethod,
1038 htu: httpUri,
1039 iat: Math.floor(Date.now() / 1000),
1040 };
1041
1042 if (nonce) {
1043 payload.nonce = nonce;
1044 }
1045
1046 if (accessTokenHash) {
1047 payload.ath = accessTokenHash;
1048 }
1049
1050 const headerB64 = base64UrlEncode(
1051 new TextEncoder().encode(JSON.stringify(header)),
1052 );
1053 const payloadB64 = base64UrlEncode(
1054 new TextEncoder().encode(JSON.stringify(payload)),
1055 );
1056
1057 const signingInput = `${headerB64}.${payloadB64}`;
1058 const signature = await crypto.subtle.sign(
1059 { name: "ECDSA", hash: "SHA-256" },
1060 keyPair.privateKey,
1061 new TextEncoder().encode(signingInput),
1062 );
1063
1064 const signatureB64 = base64UrlEncode(new Uint8Array(signature));
1065 return `${headerB64}.${payloadB64}.${signatureB64}`;
1066}