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