this repo has no description
1import type {
2 InboundMigrationState,
3 InboundStep,
4 MigrationProgress,
5 OutboundMigrationState,
6 OutboundStep,
7 PasskeyAccountSetup,
8 ServerDescription,
9 StoredMigrationState,
10} from "./types";
11import {
12 AtprotoClient,
13 buildOAuthAuthorizationUrl,
14 clearDPoPKey,
15 createLocalClient,
16 exchangeOAuthCode,
17 generateDPoPKeyPair,
18 generateOAuthState,
19 generatePKCE,
20 getMigrationOAuthClientId,
21 getMigrationOAuthRedirectUri,
22 getOAuthServerMetadata,
23 loadDPoPKey,
24 resolvePdsUrl,
25 saveDPoPKey,
26} from "./atproto-client";
27import {
28 clearMigrationState,
29 saveMigrationState,
30 updateProgress,
31 updateStep,
32} from "./storage";
33
34function migrationLog(stage: string, data?: Record<string, unknown>) {
35 const timestamp = new Date().toISOString();
36 const msg = `[MIGRATION ${timestamp}] ${stage}`;
37 if (data) {
38 console.log(msg, JSON.stringify(data, null, 2));
39 } else {
40 console.log(msg);
41 }
42}
43
44function createInitialProgress(): MigrationProgress {
45 return {
46 repoExported: false,
47 repoImported: false,
48 blobsTotal: 0,
49 blobsMigrated: 0,
50 blobsFailed: [],
51 prefsMigrated: false,
52 plcSigned: false,
53 activated: false,
54 deactivated: false,
55 currentOperation: "",
56 };
57}
58
59export function createInboundMigrationFlow() {
60 let state = $state<InboundMigrationState>({
61 direction: "inbound",
62 step: "welcome",
63 sourcePdsUrl: "",
64 sourceDid: "",
65 sourceHandle: "",
66 targetHandle: "",
67 targetEmail: "",
68 targetPassword: "",
69 inviteCode: "",
70 sourceAccessToken: null,
71 sourceRefreshToken: null,
72 serviceAuthToken: null,
73 emailVerifyToken: "",
74 plcToken: "",
75 progress: createInitialProgress(),
76 error: null,
77 targetVerificationMethod: null,
78 authMethod: "password",
79 passkeySetupToken: null,
80 oauthCodeVerifier: null,
81 generatedAppPassword: null,
82 generatedAppPasswordName: null,
83 });
84
85 let sourceClient: AtprotoClient | null = null;
86 let localClient: AtprotoClient | null = null;
87 let localServerInfo: ServerDescription | null = null;
88
89 function setStep(step: InboundStep) {
90 state.step = step;
91 state.error = null;
92 saveMigrationState(state);
93 updateStep(step);
94 }
95
96 function setError(error: string) {
97 state.error = error;
98 saveMigrationState(state);
99 }
100
101 function setProgress(updates: Partial<MigrationProgress>) {
102 state.progress = { ...state.progress, ...updates };
103 updateProgress(updates);
104 }
105
106 async function loadLocalServerInfo(): Promise<ServerDescription> {
107 if (!localClient) {
108 localClient = createLocalClient();
109 }
110 if (!localServerInfo) {
111 localServerInfo = await localClient.describeServer();
112 }
113 return localServerInfo;
114 }
115
116 async function resolveSourcePds(handle: string): Promise<void> {
117 try {
118 const { did, pdsUrl } = await resolvePdsUrl(handle);
119 state.sourcePdsUrl = pdsUrl;
120 state.sourceDid = did;
121 state.sourceHandle = handle;
122 sourceClient = new AtprotoClient(pdsUrl);
123 } catch (e) {
124 throw new Error(`Could not resolve handle: ${(e as Error).message}`);
125 }
126 }
127
128 async function initiateOAuthLogin(handle: string): Promise<void> {
129 migrationLog("initiateOAuthLogin START", { handle });
130
131 if (!state.sourcePdsUrl) {
132 await resolveSourcePds(handle);
133 }
134
135 const metadata = await getOAuthServerMetadata(state.sourcePdsUrl);
136 if (!metadata) {
137 throw new Error(
138 "Source PDS does not support OAuth. This PDS only supports OAuth-based migrations.",
139 );
140 }
141 sourceOAuthMetadata = metadata;
142
143 const { codeVerifier, codeChallenge } = await generatePKCE();
144 const oauthState = generateOAuthState();
145
146 const dpopKeyPair = await generateDPoPKeyPair();
147 await saveDPoPKey(dpopKeyPair);
148
149 localStorage.setItem("migration_oauth_state", oauthState);
150 localStorage.setItem("migration_oauth_code_verifier", codeVerifier);
151 localStorage.setItem("migration_source_pds_url", state.sourcePdsUrl);
152 localStorage.setItem("migration_source_did", state.sourceDid);
153 localStorage.setItem("migration_source_handle", state.sourceHandle);
154 localStorage.setItem("migration_oauth_issuer", metadata.issuer);
155
156 const authUrl = buildOAuthAuthorizationUrl(metadata, {
157 clientId: getMigrationOAuthClientId(),
158 redirectUri: getMigrationOAuthRedirectUri(),
159 codeChallenge,
160 state: oauthState,
161 scope: "atproto identity:* rpc:com.atproto.server.createAccount?aud=*",
162 dpopJkt: dpopKeyPair.thumbprint,
163 loginHint: state.sourceHandle,
164 });
165
166 migrationLog("initiateOAuthLogin: Redirecting to authorization", {
167 sourcePdsUrl: state.sourcePdsUrl,
168 authEndpoint: metadata.authorization_endpoint,
169 dpopJkt: dpopKeyPair.thumbprint,
170 });
171
172 state.oauthCodeVerifier = codeVerifier;
173 saveMigrationState(state);
174
175 globalThis.location.href = authUrl;
176 }
177
178 function cleanupOAuthSessionData(): void {
179 localStorage.removeItem("migration_oauth_state");
180 localStorage.removeItem("migration_oauth_code_verifier");
181 localStorage.removeItem("migration_source_pds_url");
182 localStorage.removeItem("migration_source_did");
183 localStorage.removeItem("migration_source_handle");
184 localStorage.removeItem("migration_oauth_issuer");
185 }
186
187 async function handleOAuthCallback(
188 code: string,
189 returnedState: string,
190 ): Promise<void> {
191 migrationLog("handleOAuthCallback START");
192
193 const savedState = localStorage.getItem("migration_oauth_state");
194 const codeVerifier = localStorage.getItem("migration_oauth_code_verifier");
195 const sourcePdsUrl = localStorage.getItem("migration_source_pds_url");
196 const sourceDid = localStorage.getItem("migration_source_did");
197 const sourceHandle = localStorage.getItem("migration_source_handle");
198 const oauthIssuer = localStorage.getItem("migration_oauth_issuer");
199
200 if (returnedState !== savedState) {
201 cleanupOAuthSessionData();
202 throw new Error("OAuth state mismatch - possible CSRF attack");
203 }
204
205 if (!codeVerifier || !sourcePdsUrl || !sourceDid || !sourceHandle) {
206 cleanupOAuthSessionData();
207 throw new Error("Missing OAuth session data");
208 }
209
210 const dpopKeyPair = await loadDPoPKey();
211 if (!dpopKeyPair) {
212 cleanupOAuthSessionData();
213 throw new Error("Missing DPoP key - please restart the migration");
214 }
215
216 state.sourcePdsUrl = sourcePdsUrl;
217 state.sourceDid = sourceDid;
218 state.sourceHandle = sourceHandle;
219 sourceClient = new AtprotoClient(sourcePdsUrl);
220
221 let metadata = await getOAuthServerMetadata(sourcePdsUrl);
222 if (!metadata && oauthIssuer) {
223 metadata = await getOAuthServerMetadata(oauthIssuer);
224 }
225 if (!metadata) {
226 cleanupOAuthSessionData();
227 throw new Error("Could not fetch OAuth server metadata");
228 }
229 sourceOAuthMetadata = metadata;
230
231 migrationLog("handleOAuthCallback: Exchanging code for tokens");
232
233 let tokenResponse;
234 try {
235 tokenResponse = await exchangeOAuthCode(metadata, {
236 code,
237 codeVerifier,
238 clientId: getMigrationOAuthClientId(),
239 redirectUri: getMigrationOAuthRedirectUri(),
240 dpopKeyPair,
241 });
242 } catch (err) {
243 cleanupOAuthSessionData();
244 throw err;
245 }
246
247 migrationLog("handleOAuthCallback: Got access token");
248
249 state.sourceAccessToken = tokenResponse.access_token;
250 state.sourceRefreshToken = tokenResponse.refresh_token ?? null;
251 sourceClient.setAccessToken(tokenResponse.access_token);
252 sourceClient.setDPoPKeyPair(dpopKeyPair);
253
254 cleanupOAuthSessionData();
255
256 if (state.needsReauth && state.resumeToStep) {
257 const targetStep = state.resumeToStep;
258 state.needsReauth = false;
259 state.resumeToStep = undefined;
260
261 const postEmailSteps = [
262 "plc-token",
263 "did-web-update",
264 "finalizing",
265 "app-password",
266 ];
267
268 if (postEmailSteps.includes(targetStep)) {
269 if (state.authMethod === "passkey" && state.passkeySetupToken) {
270 localClient = createLocalClient();
271 setStep("passkey-setup");
272 migrationLog(
273 "handleOAuthCallback: Resuming passkey flow at passkey-setup",
274 );
275 } else {
276 setStep("email-verify");
277 migrationLog(
278 "handleOAuthCallback: Resuming at email-verify for re-auth",
279 );
280 }
281 } else {
282 setStep(targetStep);
283 }
284 } else {
285 setStep("choose-handle");
286 }
287 saveMigrationState(state);
288 }
289
290 async function checkHandleAvailability(handle: string): Promise<boolean> {
291 if (!localClient) {
292 localClient = createLocalClient();
293 }
294 try {
295 await localClient.resolveHandle(handle);
296 return false;
297 } catch {
298 return true;
299 }
300 }
301
302 async function authenticateToLocal(
303 email: string,
304 password: string,
305 ): Promise<void> {
306 if (!localClient) {
307 localClient = createLocalClient();
308 }
309 await localClient.loginDeactivated(email, password);
310 }
311
312 let passkeySetup: PasskeyAccountSetup | null = null;
313
314 async function startMigration(): Promise<void> {
315 migrationLog("startMigration START", {
316 sourceDid: state.sourceDid,
317 sourceHandle: state.sourceHandle,
318 targetHandle: state.targetHandle,
319 sourcePdsUrl: state.sourcePdsUrl,
320 authMethod: state.authMethod,
321 });
322
323 if (!sourceClient || !state.sourceAccessToken) {
324 migrationLog("startMigration ERROR: Not authenticated to source PDS");
325 throw new Error("Not authenticated to source PDS");
326 }
327
328 if (!localClient) {
329 localClient = createLocalClient();
330 }
331
332 setStep("migrating");
333
334 try {
335 setProgress({ currentOperation: "Getting service auth token..." });
336 migrationLog("startMigration: Loading local server info");
337 const serverInfo = await loadLocalServerInfo();
338 migrationLog("startMigration: Got server info", {
339 serverDid: serverInfo.did,
340 });
341
342 migrationLog(
343 "startMigration: Getting service auth token from source PDS",
344 );
345 const { token } = await sourceClient.getServiceAuth(
346 serverInfo.did,
347 "com.atproto.server.createAccount",
348 );
349 migrationLog("startMigration: Got service auth token");
350 state.serviceAuthToken = token;
351
352 setProgress({ currentOperation: "Creating account on new PDS..." });
353
354 if (state.authMethod === "passkey") {
355 const passkeyParams = {
356 did: state.sourceDid,
357 handle: state.targetHandle,
358 email: state.targetEmail,
359 inviteCode: state.inviteCode || undefined,
360 };
361
362 migrationLog("startMigration: Creating passkey account on NEW PDS", {
363 did: passkeyParams.did,
364 handle: passkeyParams.handle,
365 inviteCode: passkeyParams.inviteCode,
366 stateInviteCode: state.inviteCode,
367 });
368 passkeySetup = await localClient.createPasskeyAccount(
369 passkeyParams,
370 token,
371 );
372 migrationLog("startMigration: Passkey account created on NEW PDS", {
373 did: passkeySetup.did,
374 hasAccessJwt: !!passkeySetup.accessJwt,
375 });
376 state.passkeySetupToken = passkeySetup.setupToken;
377 if (passkeySetup.accessJwt) {
378 localClient.setAccessToken(passkeySetup.accessJwt);
379 }
380 } else {
381 const accountParams = {
382 did: state.sourceDid,
383 handle: state.targetHandle,
384 email: state.targetEmail,
385 password: state.targetPassword,
386 inviteCode: state.inviteCode || undefined,
387 };
388
389 migrationLog("startMigration: Creating account on NEW PDS", {
390 did: accountParams.did,
391 handle: accountParams.handle,
392 });
393 const session = await localClient.createAccount(accountParams, token);
394 migrationLog("startMigration: Account created on NEW PDS", {
395 did: session.did,
396 });
397 localClient.setAccessToken(session.accessJwt);
398 }
399
400 setProgress({ currentOperation: "Exporting repository..." });
401 migrationLog("startMigration: Exporting repo from source PDS");
402 const exportStart = Date.now();
403 const car = await sourceClient.getRepo(state.sourceDid);
404 migrationLog("startMigration: Repo exported", {
405 durationMs: Date.now() - exportStart,
406 sizeBytes: car.byteLength,
407 });
408 setProgress({
409 repoExported: true,
410 currentOperation: "Importing repository...",
411 });
412
413 migrationLog("startMigration: Importing repo to NEW PDS");
414 const importStart = Date.now();
415 await localClient.importRepo(car);
416 migrationLog("startMigration: Repo imported", {
417 durationMs: Date.now() - importStart,
418 });
419 setProgress({
420 repoImported: true,
421 currentOperation: "Counting blobs...",
422 });
423
424 const accountStatus = await localClient.checkAccountStatus();
425 migrationLog("startMigration: Account status", {
426 expectedBlobs: accountStatus.expectedBlobs,
427 importedBlobs: accountStatus.importedBlobs,
428 });
429 setProgress({
430 blobsTotal: accountStatus.expectedBlobs,
431 currentOperation: "Migrating blobs...",
432 });
433
434 await migrateBlobs();
435
436 setProgress({ currentOperation: "Migrating preferences..." });
437 await migratePreferences();
438
439 migrationLog(
440 "startMigration: Initial migration complete, waiting for email verification",
441 );
442 setStep("email-verify");
443 } catch (e) {
444 const err = e as Error & { error?: string; status?: number };
445 const message = err.message || err.error ||
446 `Unknown error (status ${err.status || "unknown"})`;
447 migrationLog("startMigration FAILED", {
448 error: message,
449 errorCode: err.error,
450 status: err.status,
451 stack: err.stack,
452 });
453 setError(message);
454 setStep("error");
455 }
456 }
457
458 async function migrateBlobs(): Promise<void> {
459 if (!sourceClient || !localClient) return;
460
461 let cursor: string | undefined;
462 let migrated = 0;
463
464 do {
465 const { blobs, cursor: nextCursor } = await localClient.listMissingBlobs(
466 cursor,
467 100,
468 );
469
470 for (const blob of blobs) {
471 try {
472 setProgress({
473 currentOperation: `Migrating blob ${
474 migrated + 1
475 }/${state.progress.blobsTotal}...`,
476 });
477
478 const blobData = await sourceClient.getBlob(
479 state.sourceDid,
480 blob.cid,
481 );
482 await localClient.uploadBlob(blobData, "application/octet-stream");
483 migrated++;
484 setProgress({ blobsMigrated: migrated });
485 } catch {
486 state.progress.blobsFailed.push(blob.cid);
487 }
488 }
489
490 cursor = nextCursor;
491 } while (cursor);
492 }
493
494 async function migratePreferences(): Promise<void> {
495 if (!sourceClient || !localClient) return;
496
497 try {
498 const prefs = await sourceClient.getPreferences();
499 await localClient.putPreferences(prefs);
500 setProgress({ prefsMigrated: true });
501 } catch { /* optional, best-effort */ }
502 }
503
504 async function submitEmailVerifyToken(
505 token: string,
506 localPassword?: string,
507 ): Promise<void> {
508 if (!localClient) {
509 localClient = createLocalClient();
510 }
511
512 state.emailVerifyToken = token;
513 setError(null);
514
515 try {
516 await localClient.verifyToken(token, state.targetEmail);
517
518 if (!sourceClient) {
519 setStep("source-handle");
520 setError(
521 "Email verified! Please log in to your old account again to complete the migration.",
522 );
523 return;
524 }
525
526 if (state.authMethod === "passkey") {
527 migrationLog(
528 "submitEmailVerifyToken: Email verified, proceeding to passkey setup",
529 );
530 setStep("passkey-setup");
531 return;
532 }
533
534 if (localPassword) {
535 setProgress({ currentOperation: "Authenticating to new PDS..." });
536 await localClient.loginDeactivated(state.targetEmail, localPassword);
537 }
538
539 if (!localClient.getAccessToken()) {
540 setError("Email verified! Please enter your password to continue.");
541 return;
542 }
543
544 if (state.sourceDid.startsWith("did:web:")) {
545 const credentials = await localClient.getRecommendedDidCredentials();
546 state.targetVerificationMethod =
547 credentials.verificationMethods?.atproto || null;
548 setStep("did-web-update");
549 } else {
550 setProgress({ currentOperation: "Requesting PLC operation token..." });
551 await sourceClient.requestPlcOperationSignature();
552 setStep("plc-token");
553 }
554 } catch (e) {
555 const err = e as Error & { error?: string; status?: number };
556 const message = err.message || err.error ||
557 `Unknown error (status ${err.status || "unknown"})`;
558 setError(message);
559 }
560 }
561
562 async function resendEmailVerification(): Promise<void> {
563 if (!localClient) {
564 localClient = createLocalClient();
565 }
566 await localClient.resendMigrationVerification();
567 }
568
569 let checkingEmailVerification = false;
570
571 async function checkEmailVerifiedAndProceed(): Promise<boolean> {
572 if (checkingEmailVerification) return false;
573 if (!sourceClient || !localClient) return false;
574
575 if (state.authMethod === "passkey") {
576 return false;
577 }
578
579 checkingEmailVerification = true;
580 try {
581 await localClient.loginDeactivated(
582 state.targetEmail,
583 state.targetPassword,
584 );
585 if (state.sourceDid.startsWith("did:web:")) {
586 const credentials = await localClient.getRecommendedDidCredentials();
587 state.targetVerificationMethod =
588 credentials.verificationMethods?.atproto || null;
589 setStep("did-web-update");
590 } else {
591 await sourceClient.requestPlcOperationSignature();
592 setStep("plc-token");
593 }
594 return true;
595 } catch (e) {
596 const err = e as Error & { error?: string };
597 if (err.error === "AccountNotVerified") {
598 return false;
599 }
600 return false;
601 } finally {
602 checkingEmailVerification = false;
603 }
604 }
605
606 async function submitPlcToken(token: string): Promise<void> {
607 migrationLog("submitPlcToken START", {
608 sourceDid: state.sourceDid,
609 sourceHandle: state.sourceHandle,
610 targetHandle: state.targetHandle,
611 sourcePdsUrl: state.sourcePdsUrl,
612 });
613
614 if (!sourceClient || !localClient) {
615 migrationLog("submitPlcToken ERROR: Not connected to PDSes", {
616 hasSourceClient: !!sourceClient,
617 hasLocalClient: !!localClient,
618 });
619 throw new Error("Not connected to PDSes");
620 }
621
622 state.plcToken = token;
623 setStep("finalizing");
624 setProgress({ currentOperation: "Signing PLC operation..." });
625
626 try {
627 migrationLog("Step 1: Getting recommended DID credentials from NEW PDS");
628 const credentials = await localClient.getRecommendedDidCredentials();
629 migrationLog("Step 1 COMPLETE: Got credentials", {
630 rotationKeys: credentials.rotationKeys,
631 alsoKnownAs: credentials.alsoKnownAs,
632 verificationMethods: credentials.verificationMethods,
633 services: credentials.services,
634 });
635
636 migrationLog("Step 2: Signing PLC operation on source PDS", {
637 sourcePdsUrl: state.sourcePdsUrl,
638 });
639 const signStart = Date.now();
640 const { operation } = await sourceClient.signPlcOperation({
641 token,
642 ...credentials,
643 });
644 migrationLog("Step 2 COMPLETE: PLC operation signed", {
645 durationMs: Date.now() - signStart,
646 operationType: operation.type,
647 operationPrev: operation.prev,
648 });
649
650 setProgress({
651 plcSigned: true,
652 currentOperation: "Submitting PLC operation...",
653 });
654 migrationLog("Step 3: Submitting PLC operation to NEW PDS");
655 const submitStart = Date.now();
656 await localClient.submitPlcOperation(operation);
657 migrationLog("Step 3 COMPLETE: PLC operation submitted", {
658 durationMs: Date.now() - submitStart,
659 });
660
661 setProgress({
662 currentOperation: "Activating account (waiting for DID propagation)...",
663 });
664 migrationLog("Step 4: Activating account on NEW PDS");
665 const activateStart = Date.now();
666 await localClient.activateAccount();
667 migrationLog("Step 4 COMPLETE: Account activated on NEW PDS", {
668 durationMs: Date.now() - activateStart,
669 });
670 setProgress({ activated: true });
671
672 setProgress({ currentOperation: "Deactivating old account..." });
673 migrationLog("Step 5: Deactivating account on source PDS", {
674 sourcePdsUrl: state.sourcePdsUrl,
675 });
676 const deactivateStart = Date.now();
677 try {
678 await sourceClient.deactivateAccount();
679 migrationLog("Step 5 COMPLETE: Account deactivated on source PDS", {
680 durationMs: Date.now() - deactivateStart,
681 success: true,
682 });
683 setProgress({ deactivated: true });
684 } catch (deactivateErr) {
685 const err = deactivateErr as Error & {
686 error?: string;
687 status?: number;
688 };
689 migrationLog("Step 5 FAILED: Could not deactivate on source PDS", {
690 durationMs: Date.now() - deactivateStart,
691 error: err.message,
692 errorCode: err.error,
693 status: err.status,
694 });
695 }
696
697 migrationLog("submitPlcToken SUCCESS: Migration complete", {
698 sourceDid: state.sourceDid,
699 newHandle: state.targetHandle,
700 });
701 setStep("success");
702 clearMigrationState();
703 } catch (e) {
704 const err = e as Error & { error?: string; status?: number };
705 const message = err.message || err.error ||
706 `Unknown error (status ${err.status || "unknown"})`;
707 migrationLog("submitPlcToken FAILED", {
708 error: message,
709 errorCode: err.error,
710 status: err.status,
711 stack: err.stack,
712 });
713 state.step = "plc-token";
714 state.error = message;
715 saveMigrationState(state);
716 }
717 }
718
719 async function requestPlcToken(): Promise<void> {
720 if (!sourceClient) {
721 throw new Error("Not connected to source PDS");
722 }
723 setProgress({ currentOperation: "Requesting PLC operation token..." });
724 await sourceClient.requestPlcOperationSignature();
725 }
726
727 async function resendPlcToken(): Promise<void> {
728 if (!sourceClient) {
729 throw new Error("Not connected to source PDS");
730 }
731 await sourceClient.requestPlcOperationSignature();
732 }
733
734 async function completeDidWebMigration(): Promise<void> {
735 migrationLog("completeDidWebMigration START", {
736 sourceDid: state.sourceDid,
737 sourceHandle: state.sourceHandle,
738 targetHandle: state.targetHandle,
739 });
740
741 if (!sourceClient || !localClient) {
742 migrationLog("completeDidWebMigration ERROR: Not connected to PDSes");
743 throw new Error("Not connected to PDSes");
744 }
745
746 setStep("finalizing");
747 setProgress({ currentOperation: "Activating account..." });
748
749 try {
750 migrationLog("Activating account on NEW PDS");
751 const activateStart = Date.now();
752 await localClient.activateAccount();
753 migrationLog("Account activated", {
754 durationMs: Date.now() - activateStart,
755 });
756 setProgress({ activated: true });
757
758 setProgress({ currentOperation: "Deactivating old account..." });
759 migrationLog("Deactivating account on source PDS");
760 const deactivateStart = Date.now();
761 try {
762 await sourceClient.deactivateAccount();
763 migrationLog("Account deactivated on source PDS", {
764 durationMs: Date.now() - deactivateStart,
765 });
766 setProgress({ deactivated: true });
767 } catch (deactivateErr) {
768 const err = deactivateErr as Error & { error?: string };
769 migrationLog("Could not deactivate on source PDS", {
770 error: err.message,
771 });
772 }
773
774 migrationLog("completeDidWebMigration SUCCESS");
775 setStep("success");
776 clearMigrationState();
777 } catch (e) {
778 const err = e as Error & { error?: string; status?: number };
779 const message = err.message || err.error ||
780 `Unknown error (status ${err.status || "unknown"})`;
781 migrationLog("completeDidWebMigration FAILED", { error: message });
782 setError(message);
783 setStep("did-web-update");
784 }
785 }
786
787 async function startPasskeyRegistration(): Promise<{ options: unknown }> {
788 if (!localClient || !state.passkeySetupToken) {
789 throw new Error("Not ready for passkey registration");
790 }
791
792 migrationLog("startPasskeyRegistration START", { did: state.sourceDid });
793 const result = await localClient.startPasskeyRegistrationForSetup(
794 state.sourceDid,
795 state.passkeySetupToken,
796 );
797 migrationLog("startPasskeyRegistration: Got WebAuthn options");
798 return result;
799 }
800
801 async function completePasskeyRegistration(
802 credential: unknown,
803 friendlyName?: string,
804 ): Promise<void> {
805 if (!localClient || !state.passkeySetupToken || !sourceClient) {
806 throw new Error("Not ready for passkey registration");
807 }
808
809 migrationLog("completePasskeyRegistration START", { did: state.sourceDid });
810
811 const result = await localClient.completePasskeySetup(
812 state.sourceDid,
813 state.passkeySetupToken,
814 credential,
815 friendlyName,
816 );
817 migrationLog("completePasskeyRegistration: Passkey registered", {
818 appPassword: "***",
819 });
820
821 setProgress({ currentOperation: "Authenticating with app password..." });
822 await localClient.loginDeactivated(state.targetEmail, result.appPassword);
823 migrationLog("completePasskeyRegistration: Authenticated to new PDS");
824
825 state.generatedAppPassword = result.appPassword;
826 state.generatedAppPasswordName = result.appPasswordName;
827 setStep("app-password");
828 }
829
830 async function proceedFromAppPassword(): Promise<void> {
831 if (!sourceClient || !localClient) {
832 throw new Error("Clients not initialized");
833 }
834
835 migrationLog("proceedFromAppPassword: Starting");
836
837 if (state.sourceDid.startsWith("did:web:")) {
838 const credentials = await localClient.getRecommendedDidCredentials();
839 state.targetVerificationMethod =
840 credentials.verificationMethods?.atproto || null;
841 setStep("did-web-update");
842 } else {
843 setProgress({ currentOperation: "Requesting PLC operation token..." });
844 await sourceClient.requestPlcOperationSignature();
845 setStep("plc-token");
846 }
847 }
848
849 function reset(): void {
850 state = {
851 direction: "inbound",
852 step: "welcome",
853 sourcePdsUrl: "",
854 sourceDid: "",
855 sourceHandle: "",
856 targetHandle: "",
857 targetEmail: "",
858 targetPassword: "",
859 inviteCode: "",
860 sourceAccessToken: null,
861 sourceRefreshToken: null,
862 serviceAuthToken: null,
863 emailVerifyToken: "",
864 plcToken: "",
865 progress: createInitialProgress(),
866 error: null,
867 targetVerificationMethod: null,
868 authMethod: "password",
869 passkeySetupToken: null,
870 oauthCodeVerifier: null,
871 generatedAppPassword: null,
872 generatedAppPasswordName: null,
873 };
874 sourceClient = null;
875 passkeySetup = null;
876 sourceOAuthMetadata = null;
877 clearMigrationState();
878 clearDPoPKey();
879 }
880
881 async function resumeFromState(stored: StoredMigrationState): Promise<void> {
882 if (stored.direction !== "inbound") return;
883
884 state.sourcePdsUrl = stored.sourcePdsUrl;
885 state.sourceDid = stored.sourceDid;
886 state.sourceHandle = stored.sourceHandle;
887 state.targetHandle = stored.targetHandle;
888 state.targetEmail = stored.targetEmail;
889 state.authMethod = stored.authMethod ?? "password";
890 state.progress = {
891 ...createInitialProgress(),
892 ...stored.progress,
893 };
894
895 const stepsRequiringSourceAuth = [
896 "choose-handle",
897 "review",
898 "migrating",
899 "email-verify",
900 "plc-token",
901 "did-web-update",
902 "finalizing",
903 "app-password",
904 ];
905
906 if (stepsRequiringSourceAuth.includes(stored.step)) {
907 state.step = "source-handle";
908 state.needsReauth = true;
909 state.resumeToStep = stored.step as InboundMigrationState["step"];
910 migrationLog("resumeFromState: Requiring re-auth for step", {
911 originalStep: stored.step,
912 });
913 } else if (stored.step === "passkey-setup" && stored.passkeySetupToken) {
914 state.passkeySetupToken = stored.passkeySetupToken;
915 localClient = createLocalClient();
916 state.step = "passkey-setup";
917 migrationLog("resumeFromState: Restored passkey-setup with token");
918 } else if (stored.step === "success") {
919 state.step = "success";
920 } else if (stored.step === "error") {
921 state.step = "source-handle";
922 state.needsReauth = true;
923 migrationLog("resumeFromState: Error state, requiring re-auth");
924 } else {
925 state.step = stored.step as InboundMigrationState["step"];
926 }
927 }
928
929 function getLocalSession():
930 | { accessJwt: string; did: string; handle: string }
931 | null {
932 if (!localClient) return null;
933 const token = localClient.getAccessToken();
934 if (!token) return null;
935 return {
936 accessJwt: token,
937 did: state.sourceDid,
938 handle: state.targetHandle,
939 };
940 }
941
942 return {
943 get state() {
944 return state;
945 },
946 get passkeySetup() {
947 return passkeySetup;
948 },
949 setStep,
950 setError,
951 loadLocalServerInfo,
952 resolveSourcePds,
953 initiateOAuthLogin,
954 handleOAuthCallback,
955 authenticateToLocal,
956 checkHandleAvailability,
957 startMigration,
958 submitEmailVerifyToken,
959 resendEmailVerification,
960 checkEmailVerifiedAndProceed,
961 requestPlcToken,
962 submitPlcToken,
963 resendPlcToken,
964 completeDidWebMigration,
965 startPasskeyRegistration,
966 completePasskeyRegistration,
967 proceedFromAppPassword,
968 reset,
969 resumeFromState,
970 getLocalSession,
971
972 updateField<K extends keyof InboundMigrationState>(
973 field: K,
974 value: InboundMigrationState[K],
975 ) {
976 state[field] = value;
977 },
978 };
979}
980
981export function createOutboundMigrationFlow() {
982 let state = $state<OutboundMigrationState>({
983 direction: "outbound",
984 step: "welcome",
985 localDid: "",
986 localHandle: "",
987 targetPdsUrl: "",
988 targetPdsDid: "",
989 targetHandle: "",
990 targetEmail: "",
991 targetPassword: "",
992 inviteCode: "",
993 targetAccessToken: null,
994 targetRefreshToken: null,
995 serviceAuthToken: null,
996 plcToken: "",
997 progress: createInitialProgress(),
998 error: null,
999 targetServerInfo: null,
1000 });
1001
1002 let localClient: AtprotoClient | null = null;
1003 let targetClient: AtprotoClient | null = null;
1004
1005 function setStep(step: OutboundStep) {
1006 state.step = step;
1007 state.error = null;
1008 saveMigrationState(state);
1009 updateStep(step);
1010 }
1011
1012 function setError(error: string) {
1013 state.error = error;
1014 saveMigrationState(state);
1015 }
1016
1017 function setProgress(updates: Partial<MigrationProgress>) {
1018 state.progress = { ...state.progress, ...updates };
1019 updateProgress(updates);
1020 }
1021
1022 async function validateTargetPds(url: string): Promise<ServerDescription> {
1023 const normalizedUrl = url.replace(/\/$/, "");
1024 targetClient = new AtprotoClient(normalizedUrl);
1025
1026 try {
1027 const serverInfo = await targetClient.describeServer();
1028 state.targetPdsUrl = normalizedUrl;
1029 state.targetPdsDid = serverInfo.did;
1030 state.targetServerInfo = serverInfo;
1031 return serverInfo;
1032 } catch (e) {
1033 throw new Error(`Could not connect to PDS: ${(e as Error).message}`);
1034 }
1035 }
1036
1037 function initLocalClient(
1038 accessToken: string,
1039 did?: string,
1040 handle?: string,
1041 ): void {
1042 localClient = createLocalClient();
1043 localClient.setAccessToken(accessToken);
1044 if (did) {
1045 state.localDid = did;
1046 }
1047 if (handle) {
1048 state.localHandle = handle;
1049 }
1050 }
1051
1052 async function startMigration(currentDid: string): Promise<void> {
1053 if (!localClient || !targetClient) {
1054 throw new Error("Not connected to PDSes");
1055 }
1056
1057 setStep("migrating");
1058 setProgress({ currentOperation: "Getting service auth token..." });
1059
1060 try {
1061 const { token } = await localClient.getServiceAuth(
1062 state.targetPdsDid,
1063 "com.atproto.server.createAccount",
1064 );
1065 state.serviceAuthToken = token;
1066
1067 setProgress({ currentOperation: "Creating account on new PDS..." });
1068
1069 const accountParams = {
1070 did: currentDid,
1071 handle: state.targetHandle,
1072 email: state.targetEmail,
1073 password: state.targetPassword,
1074 inviteCode: state.inviteCode || undefined,
1075 };
1076
1077 const session = await targetClient.createAccount(accountParams, token);
1078 state.targetAccessToken = session.accessJwt;
1079 state.targetRefreshToken = session.refreshJwt;
1080 targetClient.setAccessToken(session.accessJwt);
1081
1082 setProgress({ currentOperation: "Exporting repository..." });
1083
1084 const car = await localClient.getRepo(currentDid);
1085 setProgress({
1086 repoExported: true,
1087 currentOperation: "Importing repository...",
1088 });
1089
1090 await targetClient.importRepo(car);
1091 setProgress({
1092 repoImported: true,
1093 currentOperation: "Counting blobs...",
1094 });
1095
1096 const accountStatus = await targetClient.checkAccountStatus();
1097 setProgress({
1098 blobsTotal: accountStatus.expectedBlobs,
1099 currentOperation: "Migrating blobs...",
1100 });
1101
1102 await migrateBlobs(currentDid);
1103
1104 setProgress({ currentOperation: "Migrating preferences..." });
1105 await migratePreferences();
1106
1107 setProgress({ currentOperation: "Requesting PLC operation token..." });
1108 await localClient.requestPlcOperationSignature();
1109
1110 setStep("plc-token");
1111 } catch (e) {
1112 const err = e as Error & { error?: string; status?: number };
1113 const message = err.message || err.error ||
1114 `Unknown error (status ${err.status || "unknown"})`;
1115 setError(message);
1116 setStep("error");
1117 }
1118 }
1119
1120 async function migrateBlobs(did: string): Promise<void> {
1121 if (!localClient || !targetClient) return;
1122
1123 let cursor: string | undefined;
1124 let migrated = 0;
1125
1126 do {
1127 const { blobs, cursor: nextCursor } = await targetClient.listMissingBlobs(
1128 cursor,
1129 100,
1130 );
1131
1132 for (const blob of blobs) {
1133 try {
1134 setProgress({
1135 currentOperation: `Migrating blob ${
1136 migrated + 1
1137 }/${state.progress.blobsTotal}...`,
1138 });
1139
1140 const blobData = await localClient.getBlob(did, blob.cid);
1141 await targetClient.uploadBlob(blobData, "application/octet-stream");
1142 migrated++;
1143 setProgress({ blobsMigrated: migrated });
1144 } catch {
1145 state.progress.blobsFailed.push(blob.cid);
1146 }
1147 }
1148
1149 cursor = nextCursor;
1150 } while (cursor);
1151 }
1152
1153 async function migratePreferences(): Promise<void> {
1154 if (!localClient || !targetClient) return;
1155
1156 try {
1157 const prefs = await localClient.getPreferences();
1158 await targetClient.putPreferences(prefs);
1159 setProgress({ prefsMigrated: true });
1160 } catch { /* optional, best-effort */ }
1161 }
1162
1163 async function submitPlcToken(token: string): Promise<void> {
1164 if (!localClient || !targetClient) {
1165 throw new Error("Not connected to PDSes");
1166 }
1167
1168 state.plcToken = token;
1169 setStep("finalizing");
1170 setProgress({ currentOperation: "Signing PLC operation..." });
1171
1172 try {
1173 const credentials = await targetClient.getRecommendedDidCredentials();
1174
1175 const { operation } = await localClient.signPlcOperation({
1176 token,
1177 ...credentials,
1178 });
1179
1180 setProgress({
1181 plcSigned: true,
1182 currentOperation: "Submitting PLC operation...",
1183 });
1184
1185 await targetClient.submitPlcOperation(operation);
1186
1187 setProgress({ currentOperation: "Activating account on new PDS..." });
1188 await targetClient.activateAccount();
1189 setProgress({ activated: true });
1190
1191 setProgress({ currentOperation: "Deactivating old account..." });
1192 try {
1193 await localClient.deactivateAccount(state.targetPdsUrl);
1194 setProgress({ deactivated: true });
1195 } catch { /* optional, best-effort */ }
1196
1197 setStep("success");
1198 clearMigrationState();
1199 } catch (e) {
1200 const err = e as Error & { error?: string; status?: number };
1201 const message = err.message || err.error ||
1202 `Unknown error (status ${err.status || "unknown"})`;
1203 setError(message);
1204 setStep("plc-token");
1205 }
1206 }
1207
1208 async function resendPlcToken(): Promise<void> {
1209 if (!localClient) {
1210 throw new Error("Not connected to local PDS");
1211 }
1212 await localClient.requestPlcOperationSignature();
1213 }
1214
1215 function reset(): void {
1216 state = {
1217 direction: "outbound",
1218 step: "welcome",
1219 localDid: "",
1220 localHandle: "",
1221 targetPdsUrl: "",
1222 targetPdsDid: "",
1223 targetHandle: "",
1224 targetEmail: "",
1225 targetPassword: "",
1226 inviteCode: "",
1227 targetAccessToken: null,
1228 targetRefreshToken: null,
1229 serviceAuthToken: null,
1230 plcToken: "",
1231 progress: createInitialProgress(),
1232 error: null,
1233 targetServerInfo: null,
1234 };
1235 localClient = null;
1236 targetClient = null;
1237 clearMigrationState();
1238 }
1239
1240 return {
1241 get state() {
1242 return state;
1243 },
1244 setStep,
1245 setError,
1246 validateTargetPds,
1247 initLocalClient,
1248 startMigration,
1249 submitPlcToken,
1250 resendPlcToken,
1251 reset,
1252
1253 updateField<K extends keyof OutboundMigrationState>(
1254 field: K,
1255 value: OutboundMigrationState[K],
1256 ) {
1257 state[field] = value;
1258 },
1259 };
1260}
1261
1262export type InboundMigrationFlow = ReturnType<
1263 typeof createInboundMigrationFlow
1264>;
1265export type OutboundMigrationFlow = ReturnType<
1266 typeof createOutboundMigrationFlow
1267>;