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