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