this repo has no description
1import type {
2 InboundMigrationState,
3 InboundStep,
4 MigrationProgress,
5 OutboundMigrationState,
6 OutboundStep,
7 ServerDescription,
8 StoredMigrationState,
9} from "./types";
10import {
11 AtprotoClient,
12 createLocalClient,
13 resolvePdsUrl,
14} from "./atproto-client";
15import {
16 clearMigrationState,
17 loadMigrationState,
18 saveMigrationState,
19 updateProgress,
20 updateStep,
21} from "./storage";
22
23function migrationLog(stage: string, data?: Record<string, unknown>) {
24 const timestamp = new Date().toISOString();
25 const msg = `[MIGRATION ${timestamp}] ${stage}`;
26 if (data) {
27 console.log(msg, JSON.stringify(data, null, 2));
28 } else {
29 console.log(msg);
30 }
31}
32
33function createInitialProgress(): MigrationProgress {
34 return {
35 repoExported: false,
36 repoImported: false,
37 blobsTotal: 0,
38 blobsMigrated: 0,
39 blobsFailed: [],
40 prefsMigrated: false,
41 plcSigned: false,
42 activated: false,
43 deactivated: false,
44 currentOperation: "",
45 };
46}
47
48export function createInboundMigrationFlow() {
49 let state = $state<InboundMigrationState>({
50 direction: "inbound",
51 step: "welcome",
52 sourcePdsUrl: "",
53 sourceDid: "",
54 sourceHandle: "",
55 targetHandle: "",
56 targetEmail: "",
57 targetPassword: "",
58 inviteCode: "",
59 sourceAccessToken: null,
60 sourceRefreshToken: null,
61 serviceAuthToken: null,
62 emailVerifyToken: "",
63 plcToken: "",
64 progress: createInitialProgress(),
65 error: null,
66 requires2FA: false,
67 twoFactorCode: "",
68 });
69
70 let sourceClient: AtprotoClient | null = null;
71 let localClient: AtprotoClient | null = null;
72 let localServerInfo: ServerDescription | null = null;
73
74 function setStep(step: InboundStep) {
75 state.step = step;
76 state.error = null;
77 saveMigrationState(state);
78 updateStep(step);
79 }
80
81 function setError(error: string) {
82 state.error = error;
83 saveMigrationState(state);
84 }
85
86 function setProgress(updates: Partial<MigrationProgress>) {
87 state.progress = { ...state.progress, ...updates };
88 updateProgress(updates);
89 }
90
91 async function loadLocalServerInfo(): Promise<ServerDescription> {
92 if (!localClient) {
93 localClient = createLocalClient();
94 }
95 if (!localServerInfo) {
96 localServerInfo = await localClient.describeServer();
97 }
98 return localServerInfo;
99 }
100
101 async function resolveSourcePds(handle: string): Promise<void> {
102 try {
103 const { did, pdsUrl } = await resolvePdsUrl(handle);
104 state.sourcePdsUrl = pdsUrl;
105 state.sourceDid = did;
106 state.sourceHandle = handle;
107 sourceClient = new AtprotoClient(pdsUrl);
108 } catch (e) {
109 throw new Error(`Could not resolve handle: ${(e as Error).message}`);
110 }
111 }
112
113 async function loginToSource(
114 handle: string,
115 password: string,
116 twoFactorCode?: string,
117 ): Promise<void> {
118 migrationLog("loginToSource START", { handle, has2FA: !!twoFactorCode });
119
120 if (!state.sourcePdsUrl) {
121 await resolveSourcePds(handle);
122 }
123
124 if (!sourceClient) {
125 sourceClient = new AtprotoClient(state.sourcePdsUrl);
126 }
127
128 try {
129 migrationLog("loginToSource: Calling createSession on OLD PDS", {
130 pdsUrl: state.sourcePdsUrl,
131 });
132 const session = await sourceClient.login(handle, password, twoFactorCode);
133 migrationLog("loginToSource SUCCESS", {
134 did: session.did,
135 handle: session.handle,
136 pdsUrl: state.sourcePdsUrl,
137 });
138 state.sourceAccessToken = session.accessJwt;
139 state.sourceRefreshToken = session.refreshJwt;
140 state.sourceDid = session.did;
141 state.sourceHandle = session.handle;
142 state.requires2FA = false;
143 saveMigrationState(state);
144 } catch (e) {
145 const err = e as Error & { error?: string };
146 migrationLog("loginToSource FAILED", {
147 error: err.message,
148 errorCode: err.error,
149 });
150 if (err.error === "AuthFactorTokenRequired") {
151 state.requires2FA = true;
152 throw new Error(
153 "Two-factor authentication required. Please enter the code sent to your email.",
154 );
155 }
156 throw e;
157 }
158 }
159
160 async function checkHandleAvailability(handle: string): Promise<boolean> {
161 if (!localClient) {
162 localClient = createLocalClient();
163 }
164 try {
165 await localClient.resolveHandle(handle);
166 return false;
167 } catch {
168 return true;
169 }
170 }
171
172 async function authenticateToLocal(
173 email: string,
174 password: string,
175 ): Promise<void> {
176 if (!localClient) {
177 localClient = createLocalClient();
178 }
179 await localClient.loginDeactivated(email, password);
180 }
181
182 async function startMigration(): Promise<void> {
183 migrationLog("startMigration START", {
184 sourceDid: state.sourceDid,
185 sourceHandle: state.sourceHandle,
186 targetHandle: state.targetHandle,
187 sourcePdsUrl: state.sourcePdsUrl,
188 });
189
190 if (!sourceClient || !state.sourceAccessToken) {
191 migrationLog("startMigration ERROR: Not logged in to source PDS");
192 throw new Error("Not logged in to source PDS");
193 }
194
195 if (!localClient) {
196 localClient = createLocalClient();
197 }
198
199 setStep("migrating");
200 setProgress({ currentOperation: "Getting service auth token..." });
201
202 try {
203 migrationLog("startMigration: Loading local server info");
204 const serverInfo = await loadLocalServerInfo();
205 migrationLog("startMigration: Got server info", {
206 serverDid: serverInfo.did,
207 });
208
209 migrationLog("startMigration: Getting service auth token from OLD PDS");
210 const { token } = await sourceClient.getServiceAuth(
211 serverInfo.did,
212 "com.atproto.server.createAccount",
213 );
214 migrationLog("startMigration: Got service auth token");
215 state.serviceAuthToken = token;
216
217 setProgress({ currentOperation: "Creating account on new PDS..." });
218
219 const accountParams = {
220 did: state.sourceDid,
221 handle: state.targetHandle,
222 email: state.targetEmail,
223 password: state.targetPassword,
224 inviteCode: state.inviteCode || undefined,
225 };
226
227 migrationLog("startMigration: Creating account on NEW PDS", {
228 did: accountParams.did,
229 handle: accountParams.handle,
230 });
231 const session = await localClient.createAccount(accountParams, token);
232 migrationLog("startMigration: Account created on NEW PDS", {
233 did: session.did,
234 });
235 localClient.setAccessToken(session.accessJwt);
236
237 setProgress({ currentOperation: "Exporting repository..." });
238 migrationLog("startMigration: Exporting repo from OLD PDS");
239 const exportStart = Date.now();
240 const car = await sourceClient.getRepo(state.sourceDid);
241 migrationLog("startMigration: Repo exported", {
242 durationMs: Date.now() - exportStart,
243 sizeBytes: car.byteLength,
244 });
245 setProgress({
246 repoExported: true,
247 currentOperation: "Importing repository...",
248 });
249
250 migrationLog("startMigration: Importing repo to NEW PDS");
251 const importStart = Date.now();
252 await localClient.importRepo(car);
253 migrationLog("startMigration: Repo imported", {
254 durationMs: Date.now() - importStart,
255 });
256 setProgress({
257 repoImported: true,
258 currentOperation: "Counting blobs...",
259 });
260
261 const accountStatus = await localClient.checkAccountStatus();
262 migrationLog("startMigration: Account status", {
263 expectedBlobs: accountStatus.expectedBlobs,
264 importedBlobs: accountStatus.importedBlobs,
265 });
266 setProgress({
267 blobsTotal: accountStatus.expectedBlobs,
268 currentOperation: "Migrating blobs...",
269 });
270
271 await migrateBlobs();
272
273 setProgress({ currentOperation: "Migrating preferences..." });
274 await migratePreferences();
275
276 migrationLog(
277 "startMigration: Initial migration complete, waiting for email verification",
278 );
279 setStep("email-verify");
280 } catch (e) {
281 const err = e as Error & { error?: string; status?: number };
282 const message = err.message || err.error ||
283 `Unknown error (status ${err.status || "unknown"})`;
284 migrationLog("startMigration FAILED", {
285 error: message,
286 errorCode: err.error,
287 status: err.status,
288 stack: err.stack,
289 });
290 setError(message);
291 setStep("error");
292 }
293 }
294
295 async function migrateBlobs(): Promise<void> {
296 if (!sourceClient || !localClient) return;
297
298 let cursor: string | undefined;
299 let migrated = 0;
300
301 do {
302 const { blobs, cursor: nextCursor } = await localClient.listMissingBlobs(
303 cursor,
304 100,
305 );
306
307 for (const blob of blobs) {
308 try {
309 setProgress({
310 currentOperation: `Migrating blob ${
311 migrated + 1
312 }/${state.progress.blobsTotal}...`,
313 });
314
315 const blobData = await sourceClient.getBlob(
316 state.sourceDid,
317 blob.cid,
318 );
319 await localClient.uploadBlob(blobData, "application/octet-stream");
320 migrated++;
321 setProgress({ blobsMigrated: migrated });
322 } catch (e) {
323 state.progress.blobsFailed.push(blob.cid);
324 }
325 }
326
327 cursor = nextCursor;
328 } while (cursor);
329 }
330
331 async function migratePreferences(): Promise<void> {
332 if (!sourceClient || !localClient) return;
333
334 try {
335 const prefs = await sourceClient.getPreferences();
336 await localClient.putPreferences(prefs);
337 setProgress({ prefsMigrated: true });
338 } catch {
339 }
340 }
341
342 async function submitEmailVerifyToken(
343 token: string,
344 localPassword?: string,
345 ): Promise<void> {
346 if (!localClient) {
347 localClient = createLocalClient();
348 }
349
350 state.emailVerifyToken = token;
351 setError(null);
352
353 try {
354 await localClient.verifyToken(token, state.targetEmail);
355
356 if (!sourceClient) {
357 setStep("source-login");
358 setError(
359 "Email verified! Please log in to your old account again to complete the migration.",
360 );
361 return;
362 }
363
364 if (localPassword) {
365 setProgress({ currentOperation: "Authenticating to new PDS..." });
366 await localClient.loginDeactivated(state.targetEmail, localPassword);
367 }
368
369 if (!localClient.getAccessToken()) {
370 setError("Email verified! Please enter your password to continue.");
371 return;
372 }
373
374 if (state.sourceDid.startsWith("did:web:")) {
375 setStep("did-web-update");
376 } else {
377 setProgress({ currentOperation: "Requesting PLC operation token..." });
378 await sourceClient.requestPlcOperationSignature();
379 setStep("plc-token");
380 }
381 } catch (e) {
382 const err = e as Error & { error?: string; status?: number };
383 const message = err.message || err.error ||
384 `Unknown error (status ${err.status || "unknown"})`;
385 setError(message);
386 }
387 }
388
389 async function resendEmailVerification(): Promise<void> {
390 if (!localClient) {
391 localClient = createLocalClient();
392 }
393 await localClient.resendMigrationVerification();
394 }
395
396 let checkingEmailVerification = false;
397
398 async function checkEmailVerifiedAndProceed(): Promise<boolean> {
399 if (checkingEmailVerification) return false;
400 if (!sourceClient || !localClient) return false;
401
402 checkingEmailVerification = true;
403 try {
404 await localClient.loginDeactivated(
405 state.targetEmail,
406 state.targetPassword,
407 );
408 if (state.sourceDid.startsWith("did:web:")) {
409 setStep("did-web-update");
410 } else {
411 await sourceClient.requestPlcOperationSignature();
412 setStep("plc-token");
413 }
414 return true;
415 } catch (e) {
416 const err = e as Error & { error?: string };
417 if (err.error === "AccountNotVerified") {
418 return false;
419 }
420 return false;
421 } finally {
422 checkingEmailVerification = false;
423 }
424 }
425
426 async function submitPlcToken(token: string): Promise<void> {
427 migrationLog("submitPlcToken START", {
428 sourceDid: state.sourceDid,
429 sourceHandle: state.sourceHandle,
430 targetHandle: state.targetHandle,
431 sourcePdsUrl: state.sourcePdsUrl,
432 });
433
434 if (!sourceClient || !localClient) {
435 migrationLog("submitPlcToken ERROR: Not connected to PDSes", {
436 hasSourceClient: !!sourceClient,
437 hasLocalClient: !!localClient,
438 });
439 throw new Error("Not connected to PDSes");
440 }
441
442 state.plcToken = token;
443 setStep("finalizing");
444 setProgress({ currentOperation: "Signing PLC operation..." });
445
446 try {
447 migrationLog("Step 1: Getting recommended DID credentials from NEW PDS");
448 const credentials = await localClient.getRecommendedDidCredentials();
449 migrationLog("Step 1 COMPLETE: Got credentials", {
450 rotationKeys: credentials.rotationKeys,
451 alsoKnownAs: credentials.alsoKnownAs,
452 verificationMethods: credentials.verificationMethods,
453 services: credentials.services,
454 });
455
456 migrationLog("Step 2: Signing PLC operation on OLD PDS", {
457 sourcePdsUrl: state.sourcePdsUrl,
458 });
459 const signStart = Date.now();
460 const { operation } = await sourceClient.signPlcOperation({
461 token,
462 ...credentials,
463 });
464 migrationLog("Step 2 COMPLETE: PLC operation signed", {
465 durationMs: Date.now() - signStart,
466 operationType: operation.type,
467 operationPrev: operation.prev,
468 });
469
470 setProgress({
471 plcSigned: true,
472 currentOperation: "Submitting PLC operation...",
473 });
474 migrationLog("Step 3: Submitting PLC operation to NEW PDS");
475 const submitStart = Date.now();
476 await localClient.submitPlcOperation(operation);
477 migrationLog("Step 3 COMPLETE: PLC operation submitted", {
478 durationMs: Date.now() - submitStart,
479 });
480
481 setProgress({
482 currentOperation: "Activating account (waiting for DID propagation)...",
483 });
484 migrationLog("Step 4: Activating account on NEW PDS");
485 const activateStart = Date.now();
486 await localClient.activateAccount();
487 migrationLog("Step 4 COMPLETE: Account activated on NEW PDS", {
488 durationMs: Date.now() - activateStart,
489 });
490 setProgress({ activated: true });
491
492 setProgress({ currentOperation: "Deactivating old account..." });
493 migrationLog("Step 5: Deactivating account on OLD PDS", {
494 sourcePdsUrl: state.sourcePdsUrl,
495 });
496 const deactivateStart = Date.now();
497 try {
498 await sourceClient.deactivateAccount();
499 migrationLog("Step 5 COMPLETE: Account deactivated on OLD PDS", {
500 durationMs: Date.now() - deactivateStart,
501 success: true,
502 });
503 setProgress({ deactivated: true });
504 } catch (deactivateErr) {
505 const err = deactivateErr as Error & {
506 error?: string;
507 status?: number;
508 };
509 migrationLog("Step 5 FAILED: Could not deactivate on OLD PDS", {
510 durationMs: Date.now() - deactivateStart,
511 error: err.message,
512 errorCode: err.error,
513 status: err.status,
514 });
515 }
516
517 migrationLog("submitPlcToken SUCCESS: Migration complete", {
518 sourceDid: state.sourceDid,
519 newHandle: state.targetHandle,
520 });
521 setStep("success");
522 clearMigrationState();
523 } catch (e) {
524 const err = e as Error & { error?: string; status?: number };
525 const message = err.message || err.error ||
526 `Unknown error (status ${err.status || "unknown"})`;
527 migrationLog("submitPlcToken FAILED", {
528 error: message,
529 errorCode: err.error,
530 status: err.status,
531 stack: err.stack,
532 });
533 state.step = "plc-token";
534 state.error = message;
535 saveMigrationState(state);
536 }
537 }
538
539 async function requestPlcToken(): Promise<void> {
540 if (!sourceClient) {
541 throw new Error("Not connected to source PDS");
542 }
543 setProgress({ currentOperation: "Requesting PLC operation token..." });
544 await sourceClient.requestPlcOperationSignature();
545 }
546
547 async function resendPlcToken(): Promise<void> {
548 if (!sourceClient) {
549 throw new Error("Not connected to source PDS");
550 }
551 await sourceClient.requestPlcOperationSignature();
552 }
553
554 async function completeDidWebMigration(): Promise<void> {
555 migrationLog("completeDidWebMigration START", {
556 sourceDid: state.sourceDid,
557 sourceHandle: state.sourceHandle,
558 targetHandle: state.targetHandle,
559 });
560
561 if (!sourceClient || !localClient) {
562 migrationLog("completeDidWebMigration ERROR: Not connected to PDSes");
563 throw new Error("Not connected to PDSes");
564 }
565
566 setStep("finalizing");
567 setProgress({ currentOperation: "Activating account..." });
568
569 try {
570 migrationLog("Activating account on NEW PDS");
571 const activateStart = Date.now();
572 await localClient.activateAccount();
573 migrationLog("Account activated", { durationMs: Date.now() - activateStart });
574 setProgress({ activated: true });
575
576 setProgress({ currentOperation: "Deactivating old account..." });
577 migrationLog("Deactivating account on OLD PDS");
578 const deactivateStart = Date.now();
579 try {
580 await sourceClient.deactivateAccount();
581 migrationLog("Account deactivated on OLD PDS", {
582 durationMs: Date.now() - deactivateStart,
583 });
584 setProgress({ deactivated: true });
585 } catch (deactivateErr) {
586 const err = deactivateErr as Error & { error?: string };
587 migrationLog("Could not deactivate on OLD PDS", { error: err.message });
588 }
589
590 migrationLog("completeDidWebMigration SUCCESS");
591 setStep("success");
592 clearMigrationState();
593 } catch (e) {
594 const err = e as Error & { error?: string; status?: number };
595 const message = err.message || err.error ||
596 `Unknown error (status ${err.status || "unknown"})`;
597 migrationLog("completeDidWebMigration FAILED", { error: message });
598 setError(message);
599 setStep("did-web-update");
600 }
601 }
602
603 function reset(): void {
604 state = {
605 direction: "inbound",
606 step: "welcome",
607 sourcePdsUrl: "",
608 sourceDid: "",
609 sourceHandle: "",
610 targetHandle: "",
611 targetEmail: "",
612 targetPassword: "",
613 inviteCode: "",
614 sourceAccessToken: null,
615 sourceRefreshToken: null,
616 serviceAuthToken: null,
617 emailVerifyToken: "",
618 plcToken: "",
619 progress: createInitialProgress(),
620 error: null,
621 requires2FA: false,
622 twoFactorCode: "",
623 };
624 sourceClient = null;
625 clearMigrationState();
626 }
627
628 async function resumeFromState(stored: StoredMigrationState): Promise<void> {
629 if (stored.direction !== "inbound") return;
630
631 state.sourcePdsUrl = stored.sourcePdsUrl;
632 state.sourceDid = stored.sourceDid;
633 state.sourceHandle = stored.sourceHandle;
634 state.targetHandle = stored.targetHandle;
635 state.targetEmail = stored.targetEmail;
636 state.progress = {
637 ...createInitialProgress(),
638 ...stored.progress,
639 };
640
641 state.step = "source-login";
642 }
643
644 function getLocalSession():
645 | { accessJwt: string; did: string; handle: string }
646 | null {
647 if (!localClient) return null;
648 const token = localClient.getAccessToken();
649 if (!token) return null;
650 return {
651 accessJwt: token,
652 did: state.sourceDid,
653 handle: state.targetHandle,
654 };
655 }
656
657 return {
658 get state() {
659 return state;
660 },
661 setStep,
662 setError,
663 loadLocalServerInfo,
664 loginToSource,
665 authenticateToLocal,
666 checkHandleAvailability,
667 startMigration,
668 submitEmailVerifyToken,
669 resendEmailVerification,
670 checkEmailVerifiedAndProceed,
671 requestPlcToken,
672 submitPlcToken,
673 resendPlcToken,
674 completeDidWebMigration,
675 reset,
676 resumeFromState,
677 getLocalSession,
678
679 updateField<K extends keyof InboundMigrationState>(
680 field: K,
681 value: InboundMigrationState[K],
682 ) {
683 state[field] = value;
684 },
685 };
686}
687
688export function createOutboundMigrationFlow() {
689 let state = $state<OutboundMigrationState>({
690 direction: "outbound",
691 step: "welcome",
692 localDid: "",
693 localHandle: "",
694 targetPdsUrl: "",
695 targetPdsDid: "",
696 targetHandle: "",
697 targetEmail: "",
698 targetPassword: "",
699 inviteCode: "",
700 targetAccessToken: null,
701 targetRefreshToken: null,
702 serviceAuthToken: null,
703 plcToken: "",
704 progress: createInitialProgress(),
705 error: null,
706 targetServerInfo: null,
707 });
708
709 let localClient: AtprotoClient | null = null;
710 let targetClient: AtprotoClient | null = null;
711
712 function setStep(step: OutboundStep) {
713 state.step = step;
714 state.error = null;
715 saveMigrationState(state);
716 updateStep(step);
717 }
718
719 function setError(error: string) {
720 state.error = error;
721 saveMigrationState(state);
722 }
723
724 function setProgress(updates: Partial<MigrationProgress>) {
725 state.progress = { ...state.progress, ...updates };
726 updateProgress(updates);
727 }
728
729 async function validateTargetPds(url: string): Promise<ServerDescription> {
730 const normalizedUrl = url.replace(/\/$/, "");
731 targetClient = new AtprotoClient(normalizedUrl);
732
733 try {
734 const serverInfo = await targetClient.describeServer();
735 state.targetPdsUrl = normalizedUrl;
736 state.targetPdsDid = serverInfo.did;
737 state.targetServerInfo = serverInfo;
738 return serverInfo;
739 } catch (e) {
740 throw new Error(`Could not connect to PDS: ${(e as Error).message}`);
741 }
742 }
743
744 function initLocalClient(
745 accessToken: string,
746 did?: string,
747 handle?: string,
748 ): void {
749 localClient = createLocalClient();
750 localClient.setAccessToken(accessToken);
751 if (did) {
752 state.localDid = did;
753 }
754 if (handle) {
755 state.localHandle = handle;
756 }
757 }
758
759 async function startMigration(currentDid: string): Promise<void> {
760 if (!localClient || !targetClient) {
761 throw new Error("Not connected to PDSes");
762 }
763
764 setStep("migrating");
765 setProgress({ currentOperation: "Getting service auth token..." });
766
767 try {
768 const { token } = await localClient.getServiceAuth(
769 state.targetPdsDid,
770 "com.atproto.server.createAccount",
771 );
772 state.serviceAuthToken = token;
773
774 setProgress({ currentOperation: "Creating account on new PDS..." });
775
776 const accountParams = {
777 did: currentDid,
778 handle: state.targetHandle,
779 email: state.targetEmail,
780 password: state.targetPassword,
781 inviteCode: state.inviteCode || undefined,
782 };
783
784 const session = await targetClient.createAccount(accountParams, token);
785 state.targetAccessToken = session.accessJwt;
786 state.targetRefreshToken = session.refreshJwt;
787 targetClient.setAccessToken(session.accessJwt);
788
789 setProgress({ currentOperation: "Exporting repository..." });
790
791 const car = await localClient.getRepo(currentDid);
792 setProgress({
793 repoExported: true,
794 currentOperation: "Importing repository...",
795 });
796
797 await targetClient.importRepo(car);
798 setProgress({
799 repoImported: true,
800 currentOperation: "Counting blobs...",
801 });
802
803 const accountStatus = await targetClient.checkAccountStatus();
804 setProgress({
805 blobsTotal: accountStatus.expectedBlobs,
806 currentOperation: "Migrating blobs...",
807 });
808
809 await migrateBlobs(currentDid);
810
811 setProgress({ currentOperation: "Migrating preferences..." });
812 await migratePreferences();
813
814 setProgress({ currentOperation: "Requesting PLC operation token..." });
815 await localClient.requestPlcOperationSignature();
816
817 setStep("plc-token");
818 } catch (e) {
819 const err = e as Error & { error?: string; status?: number };
820 const message = err.message || err.error ||
821 `Unknown error (status ${err.status || "unknown"})`;
822 setError(message);
823 setStep("error");
824 }
825 }
826
827 async function migrateBlobs(did: string): Promise<void> {
828 if (!localClient || !targetClient) return;
829
830 let cursor: string | undefined;
831 let migrated = 0;
832
833 do {
834 const { blobs, cursor: nextCursor } = await targetClient.listMissingBlobs(
835 cursor,
836 100,
837 );
838
839 for (const blob of blobs) {
840 try {
841 setProgress({
842 currentOperation: `Migrating blob ${
843 migrated + 1
844 }/${state.progress.blobsTotal}...`,
845 });
846
847 const blobData = await localClient.getBlob(did, blob.cid);
848 await targetClient.uploadBlob(blobData, "application/octet-stream");
849 migrated++;
850 setProgress({ blobsMigrated: migrated });
851 } catch (e) {
852 state.progress.blobsFailed.push(blob.cid);
853 }
854 }
855
856 cursor = nextCursor;
857 } while (cursor);
858 }
859
860 async function migratePreferences(): Promise<void> {
861 if (!localClient || !targetClient) return;
862
863 try {
864 const prefs = await localClient.getPreferences();
865 await targetClient.putPreferences(prefs);
866 setProgress({ prefsMigrated: true });
867 } catch {
868 }
869 }
870
871 async function submitPlcToken(token: string): Promise<void> {
872 if (!localClient || !targetClient) {
873 throw new Error("Not connected to PDSes");
874 }
875
876 state.plcToken = token;
877 setStep("finalizing");
878 setProgress({ currentOperation: "Signing PLC operation..." });
879
880 try {
881 const credentials = await targetClient.getRecommendedDidCredentials();
882
883 const { operation } = await localClient.signPlcOperation({
884 token,
885 ...credentials,
886 });
887
888 setProgress({
889 plcSigned: true,
890 currentOperation: "Submitting PLC operation...",
891 });
892
893 await targetClient.submitPlcOperation(operation);
894
895 setProgress({ currentOperation: "Activating account on new PDS..." });
896 await targetClient.activateAccount();
897 setProgress({ activated: true });
898
899 setProgress({ currentOperation: "Deactivating old account..." });
900 try {
901 await localClient.deactivateAccount();
902 setProgress({ deactivated: true });
903 } catch {
904 }
905
906 if (state.localDid.startsWith("did:web:")) {
907 setProgress({
908 currentOperation: "Updating DID document forwarding...",
909 });
910 try {
911 await localClient.updateMigrationForwarding(state.targetPdsUrl);
912 } catch (e) {
913 console.warn("Failed to update migration forwarding:", e);
914 }
915 }
916
917 setStep("success");
918 clearMigrationState();
919 } catch (e) {
920 const err = e as Error & { error?: string; status?: number };
921 const message = err.message || err.error ||
922 `Unknown error (status ${err.status || "unknown"})`;
923 setError(message);
924 setStep("plc-token");
925 }
926 }
927
928 async function resendPlcToken(): Promise<void> {
929 if (!localClient) {
930 throw new Error("Not connected to local PDS");
931 }
932 await localClient.requestPlcOperationSignature();
933 }
934
935 function reset(): void {
936 state = {
937 direction: "outbound",
938 step: "welcome",
939 localDid: "",
940 localHandle: "",
941 targetPdsUrl: "",
942 targetPdsDid: "",
943 targetHandle: "",
944 targetEmail: "",
945 targetPassword: "",
946 inviteCode: "",
947 targetAccessToken: null,
948 targetRefreshToken: null,
949 serviceAuthToken: null,
950 plcToken: "",
951 progress: createInitialProgress(),
952 error: null,
953 targetServerInfo: null,
954 };
955 localClient = null;
956 targetClient = null;
957 clearMigrationState();
958 }
959
960 return {
961 get state() {
962 return state;
963 },
964 setStep,
965 setError,
966 validateTargetPds,
967 initLocalClient,
968 startMigration,
969 submitPlcToken,
970 resendPlcToken,
971 reset,
972
973 updateField<K extends keyof OutboundMigrationState>(
974 field: K,
975 value: OutboundMigrationState[K],
976 ) {
977 state[field] = value;
978 },
979 };
980}
981
982export type InboundMigrationFlow = ReturnType<
983 typeof createInboundMigrationFlow
984>;
985export type OutboundMigrationFlow = ReturnType<
986 typeof createOutboundMigrationFlow
987>;