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 const binary = Array.from(bytes, (byte) => String.fromCharCode(byte)).join('')
604 return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(
605 /=+$/,
606 "",
607 );
608}
609
610export function base64UrlDecode(base64url: string): Uint8Array {
611 const base64 = base64url.replace(/-/g, "+").replace(/_/g, "/");
612 const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4);
613 const binary = atob(padded);
614 return Uint8Array.from(binary, (char) => char.charCodeAt(0));
615}
616
617export function prepareWebAuthnCreationOptions(
618 options: { publicKey: Record<string, unknown> },
619): PublicKeyCredentialCreationOptions {
620 const pk = options.publicKey;
621 return {
622 ...pk,
623 challenge: base64UrlDecode(pk.challenge as string),
624 user: {
625 ...(pk.user as Record<string, unknown>),
626 id: base64UrlDecode((pk.user as Record<string, unknown>).id as string),
627 },
628 excludeCredentials:
629 ((pk.excludeCredentials as Array<Record<string, unknown>>) ?? []).map(
630 (cred) => ({
631 ...cred,
632 id: base64UrlDecode(cred.id as string),
633 }),
634 ),
635 } as PublicKeyCredentialCreationOptions;
636}
637
638async function computeAccessTokenHash(accessToken: string): Promise<string> {
639 const encoder = new TextEncoder();
640 const data = encoder.encode(accessToken);
641 const hash = await crypto.subtle.digest("SHA-256", data);
642 return base64UrlEncode(new Uint8Array(hash));
643}
644
645export function generateOAuthState(): string {
646 const array = new Uint8Array(16);
647 crypto.getRandomValues(array);
648 return base64UrlEncode(array);
649}
650
651export function buildOAuthAuthorizationUrl(
652 metadata: OAuthServerMetadata,
653 params: {
654 clientId: string;
655 redirectUri: string;
656 codeChallenge: string;
657 state: string;
658 scope?: string;
659 dpopJkt?: string;
660 loginHint?: string;
661 },
662): string {
663 const url = new URL(metadata.authorization_endpoint);
664 url.searchParams.set("response_type", "code");
665 url.searchParams.set("client_id", params.clientId);
666 url.searchParams.set("redirect_uri", params.redirectUri);
667 url.searchParams.set("code_challenge", params.codeChallenge);
668 url.searchParams.set("code_challenge_method", "S256");
669 url.searchParams.set("state", params.state);
670 url.searchParams.set("scope", params.scope ?? "atproto");
671 if (params.dpopJkt) {
672 url.searchParams.set("dpop_jkt", params.dpopJkt);
673 }
674 if (params.loginHint) {
675 url.searchParams.set("login_hint", params.loginHint);
676 }
677 return url.toString();
678}
679
680export async function initiateOAuthWithPAR(
681 metadata: OAuthServerMetadata,
682 params: {
683 clientId: string;
684 redirectUri: string;
685 codeChallenge: string;
686 state: string;
687 scope?: string;
688 dpopJkt?: string;
689 loginHint?: string;
690 },
691): Promise<string> {
692 if (!metadata.pushed_authorization_request_endpoint) {
693 return buildOAuthAuthorizationUrl(metadata, params);
694 }
695
696 const body = new URLSearchParams({
697 response_type: "code",
698 client_id: params.clientId,
699 redirect_uri: params.redirectUri,
700 code_challenge: params.codeChallenge,
701 code_challenge_method: "S256",
702 state: params.state,
703 scope: params.scope ?? "atproto",
704 });
705
706 if (params.dpopJkt) {
707 body.set("dpop_jkt", params.dpopJkt);
708 }
709 if (params.loginHint) {
710 body.set("login_hint", params.loginHint);
711 }
712
713 const res = await fetch(metadata.pushed_authorization_request_endpoint, {
714 method: "POST",
715 headers: { "Content-Type": "application/x-www-form-urlencoded" },
716 body: body.toString(),
717 });
718
719 if (!res.ok) {
720 const err = await res.json().catch(() => ({
721 error: "par_error",
722 error_description: res.statusText,
723 }));
724 throw new Error(err.error_description || err.error || "PAR request failed");
725 }
726
727 const { request_uri } = await res.json();
728
729 const authUrl = new URL(metadata.authorization_endpoint);
730 authUrl.searchParams.set("client_id", params.clientId);
731 authUrl.searchParams.set("request_uri", request_uri);
732 return authUrl.toString();
733}
734
735export async function exchangeOAuthCode(
736 metadata: OAuthServerMetadata,
737 params: {
738 code: string;
739 codeVerifier: string;
740 clientId: string;
741 redirectUri: string;
742 dpopKeyPair?: DPoPKeyPair;
743 },
744): Promise<OAuthTokenResponse> {
745 const body = new URLSearchParams({
746 grant_type: "authorization_code",
747 code: params.code,
748 code_verifier: params.codeVerifier,
749 client_id: params.clientId,
750 redirect_uri: params.redirectUri,
751 });
752
753 const makeRequest = async (nonce?: string): Promise<Response> => {
754 const headers: Record<string, string> = {
755 "Content-Type": "application/x-www-form-urlencoded",
756 };
757
758 if (params.dpopKeyPair) {
759 const dpopProof = await createDPoPProof(
760 params.dpopKeyPair,
761 "POST",
762 metadata.token_endpoint,
763 nonce,
764 );
765 headers["DPoP"] = dpopProof;
766 }
767
768 return fetch(metadata.token_endpoint, {
769 method: "POST",
770 headers,
771 body: body.toString(),
772 });
773 };
774
775 let res = await makeRequest();
776
777 if (!res.ok) {
778 const err = await res.json().catch(() => ({
779 error: "token_error",
780 error_description: res.statusText,
781 }));
782
783 if (err.error === "use_dpop_nonce" && params.dpopKeyPair) {
784 const dpopNonce = res.headers.get("DPoP-Nonce");
785 if (dpopNonce) {
786 res = await makeRequest(dpopNonce);
787 if (!res.ok) {
788 const retryErr = await res.json().catch(() => ({
789 error: "token_error",
790 error_description: res.statusText,
791 }));
792 throw new Error(
793 retryErr.error_description || retryErr.error ||
794 "Token exchange failed",
795 );
796 }
797 return res.json();
798 }
799 }
800
801 throw new Error(
802 err.error_description || err.error || "Token exchange failed",
803 );
804 }
805
806 return res.json();
807}
808
809export async function resolveDidDocument(did: string): Promise<DidDocument> {
810 if (did.startsWith("did:plc:")) {
811 const res = await fetch(`https://plc.directory/${did}`);
812 if (!res.ok) {
813 throw new Error(`Failed to resolve DID: ${res.statusText}`);
814 }
815 return res.json();
816 }
817
818 if (did.startsWith("did:web:")) {
819 const domain = did.slice(8).replace(/%3A/g, ":");
820 const url = domain.includes("/")
821 ? `https://${domain}/did.json`
822 : `https://${domain}/.well-known/did.json`;
823
824 const res = await fetch(url);
825 if (!res.ok) {
826 throw new Error(`Failed to resolve DID: ${res.statusText}`);
827 }
828 return res.json();
829 }
830
831 throw new Error(`Unsupported DID method: ${did}`);
832}
833
834export async function resolvePdsUrl(
835 handleOrDid: string,
836): Promise<{ did: string; pdsUrl: string }> {
837 let did: string | undefined;
838
839 if (handleOrDid.startsWith("did:")) {
840 did = handleOrDid;
841 } else {
842 const handle = handleOrDid.replace(/^@/, "");
843
844 if (handle.endsWith(".bsky.social")) {
845 const res = await fetch(
846 `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${
847 encodeURIComponent(handle)
848 }`,
849 );
850 if (!res.ok) {
851 throw new Error(`Failed to resolve handle: ${res.statusText}`);
852 }
853 const data = await res.json();
854 did = data.did;
855 } else {
856 const dnsRes = await fetch(
857 `https://dns.google/resolve?name=_atproto.${handle}&type=TXT`,
858 );
859 if (dnsRes.ok) {
860 const dnsData = await dnsRes.json();
861 const txtRecords: Array<{ data?: string }> = dnsData.Answer ?? [];
862 const didRecord = txtRecords
863 .map((record) => record.data?.replace(/"/g, "") ?? "")
864 .find((txt) => txt.startsWith("did="));
865 if (didRecord) {
866 did = didRecord.slice(4);
867 }
868 }
869
870 if (!did) {
871 const wellKnownRes = await fetch(
872 `https://${handle}/.well-known/atproto-did`,
873 );
874 if (wellKnownRes.ok) {
875 did = (await wellKnownRes.text()).trim();
876 }
877 }
878
879 if (!did) {
880 throw new Error(`Could not resolve handle: ${handle}`);
881 }
882 }
883 }
884
885 if (!did) {
886 throw new Error("Could not resolve DID");
887 }
888
889 const didDoc = await resolveDidDocument(did);
890
891 const pdsService = didDoc.service?.find(
892 (s: { type: string }) => s.type === "AtprotoPersonalDataServer",
893 );
894
895 if (!pdsService) {
896 throw new Error("No PDS service found in DID document");
897 }
898
899 return { did, pdsUrl: pdsService.serviceEndpoint };
900}
901
902export function createLocalClient(): AtprotoClient {
903 return new AtprotoClient(globalThis.location.origin);
904}
905
906export function getMigrationOAuthClientId(): string {
907 return `${globalThis.location.origin}/oauth/client-metadata.json`;
908}
909
910export function getMigrationOAuthRedirectUri(): string {
911 return `${globalThis.location.origin}/app/migrate`;
912}
913
914export interface DPoPKeyPair {
915 privateKey: CryptoKey;
916 publicKey: CryptoKey;
917 jwk: JsonWebKey;
918 thumbprint: string;
919}
920
921const DPOP_KEY_STORAGE = "migration_dpop_key";
922const DPOP_KEY_MAX_AGE_MS = 24 * 60 * 60 * 1000;
923
924export async function generateDPoPKeyPair(): Promise<DPoPKeyPair> {
925 const keyPair = await crypto.subtle.generateKey(
926 {
927 name: "ECDSA",
928 namedCurve: "P-256",
929 },
930 true,
931 ["sign", "verify"],
932 );
933
934 const publicJwk = await crypto.subtle.exportKey("jwk", keyPair.publicKey);
935 const thumbprint = await computeJwkThumbprint(publicJwk);
936
937 return {
938 privateKey: keyPair.privateKey,
939 publicKey: keyPair.publicKey,
940 jwk: publicJwk,
941 thumbprint,
942 };
943}
944
945async function computeJwkThumbprint(jwk: JsonWebKey): Promise<string> {
946 const thumbprintInput = JSON.stringify({
947 crv: jwk.crv,
948 kty: jwk.kty,
949 x: jwk.x,
950 y: jwk.y,
951 });
952
953 const encoder = new TextEncoder();
954 const data = encoder.encode(thumbprintInput);
955 const hash = await crypto.subtle.digest("SHA-256", data);
956 return base64UrlEncode(new Uint8Array(hash));
957}
958
959export async function saveDPoPKey(keyPair: DPoPKeyPair): Promise<void> {
960 const privateJwk = await crypto.subtle.exportKey("jwk", keyPair.privateKey);
961 const stored = {
962 privateJwk,
963 publicJwk: keyPair.jwk,
964 thumbprint: keyPair.thumbprint,
965 createdAt: Date.now(),
966 };
967 localStorage.setItem(DPOP_KEY_STORAGE, JSON.stringify(stored));
968}
969
970export async function loadDPoPKey(): Promise<DPoPKeyPair | null> {
971 const stored = localStorage.getItem(DPOP_KEY_STORAGE);
972 if (!stored) return null;
973
974 try {
975 const { privateJwk, publicJwk, thumbprint, createdAt } = JSON.parse(stored);
976
977 if (createdAt && Date.now() - createdAt > DPOP_KEY_MAX_AGE_MS) {
978 localStorage.removeItem(DPOP_KEY_STORAGE);
979 return null;
980 }
981
982 const privateKey = await crypto.subtle.importKey(
983 "jwk",
984 privateJwk,
985 { name: "ECDSA", namedCurve: "P-256" },
986 true,
987 ["sign"],
988 );
989
990 const publicKey = await crypto.subtle.importKey(
991 "jwk",
992 publicJwk,
993 { name: "ECDSA", namedCurve: "P-256" },
994 true,
995 ["verify"],
996 );
997
998 return { privateKey, publicKey, jwk: publicJwk, thumbprint };
999 } catch {
1000 localStorage.removeItem(DPOP_KEY_STORAGE);
1001 return null;
1002 }
1003}
1004
1005export function clearDPoPKey(): void {
1006 localStorage.removeItem(DPOP_KEY_STORAGE);
1007}
1008
1009export async function createDPoPProof(
1010 keyPair: DPoPKeyPair,
1011 httpMethod: string,
1012 httpUri: string,
1013 nonce?: string,
1014 accessTokenHash?: string,
1015): Promise<string> {
1016 const header = {
1017 typ: "dpop+jwt",
1018 alg: "ES256",
1019 jwk: {
1020 kty: keyPair.jwk.kty,
1021 crv: keyPair.jwk.crv,
1022 x: keyPair.jwk.x,
1023 y: keyPair.jwk.y,
1024 },
1025 };
1026
1027 const payload: Record<string, unknown> = {
1028 jti: crypto.randomUUID(),
1029 htm: httpMethod,
1030 htu: httpUri,
1031 iat: Math.floor(Date.now() / 1000),
1032 };
1033
1034 if (nonce) {
1035 payload.nonce = nonce;
1036 }
1037
1038 if (accessTokenHash) {
1039 payload.ath = accessTokenHash;
1040 }
1041
1042 const headerB64 = base64UrlEncode(
1043 new TextEncoder().encode(JSON.stringify(header)),
1044 );
1045 const payloadB64 = base64UrlEncode(
1046 new TextEncoder().encode(JSON.stringify(payload)),
1047 );
1048
1049 const signingInput = `${headerB64}.${payloadB64}`;
1050 const signature = await crypto.subtle.sign(
1051 { name: "ECDSA", hash: "SHA-256" },
1052 keyPair.privateKey,
1053 new TextEncoder().encode(signingInput),
1054 );
1055
1056 const signatureB64 = base64UrlEncode(new Uint8Array(signature));
1057 return `${headerB64}.${payloadB64}.${signatureB64}`;
1058}