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 createInitialProgress(): MigrationProgress {
24 return {
25 repoExported: false,
26 repoImported: false,
27 blobsTotal: 0,
28 blobsMigrated: 0,
29 blobsFailed: [],
30 prefsMigrated: false,
31 plcSigned: false,
32 activated: false,
33 deactivated: false,
34 currentOperation: "",
35 };
36}
37
38export function createInboundMigrationFlow() {
39 let state = $state<InboundMigrationState>({
40 direction: "inbound",
41 step: "welcome",
42 sourcePdsUrl: "",
43 sourceDid: "",
44 sourceHandle: "",
45 targetHandle: "",
46 targetEmail: "",
47 targetPassword: "",
48 inviteCode: "",
49 sourceAccessToken: null,
50 sourceRefreshToken: null,
51 serviceAuthToken: null,
52 emailVerifyToken: "",
53 plcToken: "",
54 progress: createInitialProgress(),
55 error: null,
56 requires2FA: false,
57 twoFactorCode: "",
58 });
59
60 let sourceClient: AtprotoClient | null = null;
61 let localClient: AtprotoClient | null = null;
62 let localServerInfo: ServerDescription | null = null;
63
64 function setStep(step: InboundStep) {
65 state.step = step;
66 state.error = null;
67 saveMigrationState(state);
68 updateStep(step);
69 }
70
71 function setError(error: string) {
72 state.error = error;
73 saveMigrationState(state);
74 }
75
76 function setProgress(updates: Partial<MigrationProgress>) {
77 state.progress = { ...state.progress, ...updates };
78 updateProgress(updates);
79 }
80
81 async function loadLocalServerInfo(): Promise<ServerDescription> {
82 if (!localClient) {
83 localClient = createLocalClient();
84 }
85 if (!localServerInfo) {
86 localServerInfo = await localClient.describeServer();
87 }
88 return localServerInfo;
89 }
90
91 async function resolveSourcePds(handle: string): Promise<void> {
92 try {
93 const { did, pdsUrl } = await resolvePdsUrl(handle);
94 state.sourcePdsUrl = pdsUrl;
95 state.sourceDid = did;
96 state.sourceHandle = handle;
97 sourceClient = new AtprotoClient(pdsUrl);
98 } catch (e) {
99 throw new Error(`Could not resolve handle: ${(e as Error).message}`);
100 }
101 }
102
103 async function loginToSource(
104 handle: string,
105 password: string,
106 twoFactorCode?: string,
107 ): Promise<void> {
108 if (!state.sourcePdsUrl) {
109 await resolveSourcePds(handle);
110 }
111
112 if (!sourceClient) {
113 sourceClient = new AtprotoClient(state.sourcePdsUrl);
114 }
115
116 try {
117 const session = await sourceClient.login(handle, password, twoFactorCode);
118 state.sourceAccessToken = session.accessJwt;
119 state.sourceRefreshToken = session.refreshJwt;
120 state.sourceDid = session.did;
121 state.sourceHandle = session.handle;
122 state.requires2FA = false;
123 saveMigrationState(state);
124 } catch (e) {
125 const err = e as Error & { error?: string };
126 if (err.error === "AuthFactorTokenRequired") {
127 state.requires2FA = true;
128 throw new Error("Two-factor authentication required. Please enter the code sent to your email.");
129 }
130 throw e;
131 }
132 }
133
134 async function checkHandleAvailability(handle: string): Promise<boolean> {
135 if (!localClient) {
136 localClient = createLocalClient();
137 }
138 try {
139 await localClient.resolveHandle(handle);
140 return false;
141 } catch {
142 return true;
143 }
144 }
145
146 async function authenticateToLocal(email: string, password: string): Promise<void> {
147 if (!localClient) {
148 localClient = createLocalClient();
149 }
150 await localClient.loginDeactivated(email, password);
151 }
152
153 async function startMigration(): Promise<void> {
154 if (!sourceClient || !state.sourceAccessToken) {
155 throw new Error("Not logged in to source PDS");
156 }
157
158 if (!localClient) {
159 localClient = createLocalClient();
160 }
161
162 setStep("migrating");
163 setProgress({ currentOperation: "Getting service auth token..." });
164
165 try {
166 const serverInfo = await loadLocalServerInfo();
167 const { token } = await sourceClient.getServiceAuth(
168 serverInfo.did,
169 "com.atproto.server.createAccount",
170 );
171 state.serviceAuthToken = token;
172
173 setProgress({ currentOperation: "Creating account on new PDS..." });
174
175 const accountParams = {
176 did: state.sourceDid,
177 handle: state.targetHandle,
178 email: state.targetEmail,
179 password: state.targetPassword,
180 inviteCode: state.inviteCode || undefined,
181 };
182
183 const session = await localClient.createAccount(accountParams, token);
184 localClient.setAccessToken(session.accessJwt);
185
186 setProgress({ currentOperation: "Exporting repository..." });
187
188 const car = await sourceClient.getRepo(state.sourceDid);
189 setProgress({ repoExported: true, currentOperation: "Importing repository..." });
190
191 await localClient.importRepo(car);
192 setProgress({ repoImported: true, currentOperation: "Counting blobs..." });
193
194 const accountStatus = await localClient.checkAccountStatus();
195 setProgress({
196 blobsTotal: accountStatus.expectedBlobs,
197 currentOperation: "Migrating blobs...",
198 });
199
200 await migrateBlobs();
201
202 setProgress({ currentOperation: "Migrating preferences..." });
203 await migratePreferences();
204
205 setStep("email-verify");
206 } catch (e) {
207 const err = e as Error & { error?: string; status?: number };
208 const message = err.message || err.error || `Unknown error (status ${err.status || 'unknown'})`;
209 setError(message);
210 setStep("error");
211 }
212 }
213
214 async function migrateBlobs(): Promise<void> {
215 if (!sourceClient || !localClient) return;
216
217 let cursor: string | undefined;
218 let migrated = 0;
219
220 do {
221 const { blobs, cursor: nextCursor } = await localClient.listMissingBlobs(
222 cursor,
223 100,
224 );
225
226 for (const blob of blobs) {
227 try {
228 setProgress({
229 currentOperation: `Migrating blob ${migrated + 1}/${state.progress.blobsTotal}...`,
230 });
231
232 const blobData = await sourceClient.getBlob(state.sourceDid, blob.cid);
233 await localClient.uploadBlob(blobData, "application/octet-stream");
234 migrated++;
235 setProgress({ blobsMigrated: migrated });
236 } catch (e) {
237 state.progress.blobsFailed.push(blob.cid);
238 }
239 }
240
241 cursor = nextCursor;
242 } while (cursor);
243 }
244
245 async function migratePreferences(): Promise<void> {
246 if (!sourceClient || !localClient) return;
247
248 try {
249 const prefs = await sourceClient.getPreferences();
250 await localClient.putPreferences(prefs);
251 setProgress({ prefsMigrated: true });
252 } catch {
253 }
254 }
255
256 async function submitEmailVerifyToken(token: string, localPassword?: string): Promise<void> {
257 if (!localClient) {
258 localClient = createLocalClient();
259 }
260
261 state.emailVerifyToken = token;
262 setError(null);
263
264 try {
265 await localClient.verifyToken(token, state.targetEmail);
266
267 if (!sourceClient) {
268 setStep("source-login");
269 setError("Email verified! Please log in to your old account again to complete the migration.");
270 return;
271 }
272
273 if (localPassword) {
274 setProgress({ currentOperation: "Authenticating to new PDS..." });
275 await localClient.loginDeactivated(state.targetEmail, localPassword);
276 }
277
278 if (!localClient.getAccessToken()) {
279 setError("Email verified! Please enter your password to continue.");
280 return;
281 }
282
283 setProgress({ currentOperation: "Requesting PLC operation token..." });
284 await sourceClient.requestPlcOperationSignature();
285 setStep("plc-token");
286 } catch (e) {
287 const err = e as Error & { error?: string; status?: number };
288 const message = err.message || err.error || `Unknown error (status ${err.status || 'unknown'})`;
289 setError(message);
290 }
291 }
292
293 async function resendEmailVerification(): Promise<void> {
294 if (!localClient) {
295 localClient = createLocalClient();
296 }
297 await localClient.resendMigrationVerification();
298 }
299
300 let checkingEmailVerification = false;
301
302 async function checkEmailVerifiedAndProceed(): Promise<boolean> {
303 if (checkingEmailVerification) return false;
304 if (!sourceClient || !localClient) return false;
305
306 checkingEmailVerification = true;
307 try {
308 await localClient.loginDeactivated(state.targetEmail, state.targetPassword);
309 await sourceClient.requestPlcOperationSignature();
310 setStep("plc-token");
311 return true;
312 } catch (e) {
313 const err = e as Error & { error?: string };
314 if (err.error === "AccountNotVerified") {
315 return false;
316 }
317 return false;
318 } finally {
319 checkingEmailVerification = false;
320 }
321 }
322
323 async function submitPlcToken(token: string): Promise<void> {
324 if (!sourceClient || !localClient) {
325 throw new Error("Not connected to PDSes");
326 }
327
328 state.plcToken = token;
329 setStep("finalizing");
330 setProgress({ currentOperation: "Signing PLC operation..." });
331
332 try {
333 const credentials = await localClient.getRecommendedDidCredentials();
334
335 const { operation } = await sourceClient.signPlcOperation({
336 token,
337 ...credentials,
338 });
339
340 setProgress({ plcSigned: true, currentOperation: "Submitting PLC operation..." });
341 await localClient.submitPlcOperation(operation);
342
343 setProgress({ currentOperation: "Activating account (waiting for DID propagation)..." });
344 await localClient.activateAccount();
345 setProgress({ activated: true });
346
347 setProgress({ currentOperation: "Deactivating old account..." });
348 try {
349 await sourceClient.deactivateAccount();
350 setProgress({ deactivated: true });
351 } catch {
352 }
353
354 setStep("success");
355 clearMigrationState();
356 } catch (e) {
357 const err = e as Error & { error?: string; status?: number };
358 const message = err.message || err.error || `Unknown error (status ${err.status || 'unknown'})`;
359 state.step = "plc-token";
360 state.error = message;
361 saveMigrationState(state);
362 }
363 }
364
365 async function requestPlcToken(): Promise<void> {
366 if (!sourceClient) {
367 throw new Error("Not connected to source PDS");
368 }
369 setProgress({ currentOperation: "Requesting PLC operation token..." });
370 await sourceClient.requestPlcOperationSignature();
371 }
372
373 async function resendPlcToken(): Promise<void> {
374 if (!sourceClient) {
375 throw new Error("Not connected to source PDS");
376 }
377 await sourceClient.requestPlcOperationSignature();
378 }
379
380 function reset(): void {
381 state = {
382 direction: "inbound",
383 step: "welcome",
384 sourcePdsUrl: "",
385 sourceDid: "",
386 sourceHandle: "",
387 targetHandle: "",
388 targetEmail: "",
389 targetPassword: "",
390 inviteCode: "",
391 sourceAccessToken: null,
392 sourceRefreshToken: null,
393 serviceAuthToken: null,
394 emailVerifyToken: "",
395 plcToken: "",
396 progress: createInitialProgress(),
397 error: null,
398 requires2FA: false,
399 twoFactorCode: "",
400 };
401 sourceClient = null;
402 clearMigrationState();
403 }
404
405 async function resumeFromState(stored: StoredMigrationState): Promise<void> {
406 if (stored.direction !== "inbound") return;
407
408 state.sourcePdsUrl = stored.sourcePdsUrl;
409 state.sourceDid = stored.sourceDid;
410 state.sourceHandle = stored.sourceHandle;
411 state.targetHandle = stored.targetHandle;
412 state.targetEmail = stored.targetEmail;
413 state.progress = {
414 ...createInitialProgress(),
415 ...stored.progress,
416 };
417
418 state.step = "source-login";
419 }
420
421 function getLocalSession(): { accessJwt: string; did: string; handle: string } | null {
422 if (!localClient) return null;
423 const token = localClient.getAccessToken();
424 if (!token) return null;
425 return {
426 accessJwt: token,
427 did: state.sourceDid,
428 handle: state.targetHandle,
429 };
430 }
431
432 return {
433 get state() { return state; },
434 setStep,
435 setError,
436 loadLocalServerInfo,
437 loginToSource,
438 authenticateToLocal,
439 checkHandleAvailability,
440 startMigration,
441 submitEmailVerifyToken,
442 resendEmailVerification,
443 checkEmailVerifiedAndProceed,
444 requestPlcToken,
445 submitPlcToken,
446 resendPlcToken,
447 reset,
448 resumeFromState,
449 getLocalSession,
450
451 updateField<K extends keyof InboundMigrationState>(
452 field: K,
453 value: InboundMigrationState[K],
454 ) {
455 state[field] = value;
456 },
457 };
458}
459
460export function createOutboundMigrationFlow() {
461 let state = $state<OutboundMigrationState>({
462 direction: "outbound",
463 step: "welcome",
464 localDid: "",
465 localHandle: "",
466 targetPdsUrl: "",
467 targetPdsDid: "",
468 targetHandle: "",
469 targetEmail: "",
470 targetPassword: "",
471 inviteCode: "",
472 targetAccessToken: null,
473 targetRefreshToken: null,
474 serviceAuthToken: null,
475 plcToken: "",
476 progress: createInitialProgress(),
477 error: null,
478 targetServerInfo: null,
479 });
480
481 let localClient: AtprotoClient | null = null;
482 let targetClient: AtprotoClient | null = null;
483
484 function setStep(step: OutboundStep) {
485 state.step = step;
486 state.error = null;
487 saveMigrationState(state);
488 updateStep(step);
489 }
490
491 function setError(error: string) {
492 state.error = error;
493 saveMigrationState(state);
494 }
495
496 function setProgress(updates: Partial<MigrationProgress>) {
497 state.progress = { ...state.progress, ...updates };
498 updateProgress(updates);
499 }
500
501 async function validateTargetPds(url: string): Promise<ServerDescription> {
502 const normalizedUrl = url.replace(/\/$/, "");
503 targetClient = new AtprotoClient(normalizedUrl);
504
505 try {
506 const serverInfo = await targetClient.describeServer();
507 state.targetPdsUrl = normalizedUrl;
508 state.targetPdsDid = serverInfo.did;
509 state.targetServerInfo = serverInfo;
510 return serverInfo;
511 } catch (e) {
512 throw new Error(`Could not connect to PDS: ${(e as Error).message}`);
513 }
514 }
515
516 function initLocalClient(accessToken: string, did?: string, handle?: string): void {
517 localClient = createLocalClient();
518 localClient.setAccessToken(accessToken);
519 if (did) {
520 state.localDid = did;
521 }
522 if (handle) {
523 state.localHandle = handle;
524 }
525 }
526
527 async function startMigration(currentDid: string): Promise<void> {
528 if (!localClient || !targetClient) {
529 throw new Error("Not connected to PDSes");
530 }
531
532 setStep("migrating");
533 setProgress({ currentOperation: "Getting service auth token..." });
534
535 try {
536 const { token } = await localClient.getServiceAuth(
537 state.targetPdsDid,
538 "com.atproto.server.createAccount",
539 );
540 state.serviceAuthToken = token;
541
542 setProgress({ currentOperation: "Creating account on new PDS..." });
543
544 const accountParams = {
545 did: currentDid,
546 handle: state.targetHandle,
547 email: state.targetEmail,
548 password: state.targetPassword,
549 inviteCode: state.inviteCode || undefined,
550 };
551
552 const session = await targetClient.createAccount(accountParams, token);
553 state.targetAccessToken = session.accessJwt;
554 state.targetRefreshToken = session.refreshJwt;
555 targetClient.setAccessToken(session.accessJwt);
556
557 setProgress({ currentOperation: "Exporting repository..." });
558
559 const car = await localClient.getRepo(currentDid);
560 setProgress({ repoExported: true, currentOperation: "Importing repository..." });
561
562 await targetClient.importRepo(car);
563 setProgress({ repoImported: true, currentOperation: "Counting blobs..." });
564
565 const accountStatus = await targetClient.checkAccountStatus();
566 setProgress({
567 blobsTotal: accountStatus.expectedBlobs,
568 currentOperation: "Migrating blobs...",
569 });
570
571 await migrateBlobs(currentDid);
572
573 setProgress({ currentOperation: "Migrating preferences..." });
574 await migratePreferences();
575
576 setProgress({ currentOperation: "Requesting PLC operation token..." });
577 await localClient.requestPlcOperationSignature();
578
579 setStep("plc-token");
580 } catch (e) {
581 const err = e as Error & { error?: string; status?: number };
582 const message = err.message || err.error || `Unknown error (status ${err.status || 'unknown'})`;
583 setError(message);
584 setStep("error");
585 }
586 }
587
588 async function migrateBlobs(did: string): Promise<void> {
589 if (!localClient || !targetClient) return;
590
591 let cursor: string | undefined;
592 let migrated = 0;
593
594 do {
595 const { blobs, cursor: nextCursor } = await targetClient.listMissingBlobs(
596 cursor,
597 100,
598 );
599
600 for (const blob of blobs) {
601 try {
602 setProgress({
603 currentOperation: `Migrating blob ${migrated + 1}/${state.progress.blobsTotal}...`,
604 });
605
606 const blobData = await localClient.getBlob(did, blob.cid);
607 await targetClient.uploadBlob(blobData, "application/octet-stream");
608 migrated++;
609 setProgress({ blobsMigrated: migrated });
610 } catch (e) {
611 state.progress.blobsFailed.push(blob.cid);
612 }
613 }
614
615 cursor = nextCursor;
616 } while (cursor);
617 }
618
619 async function migratePreferences(): Promise<void> {
620 if (!localClient || !targetClient) return;
621
622 try {
623 const prefs = await localClient.getPreferences();
624 await targetClient.putPreferences(prefs);
625 setProgress({ prefsMigrated: true });
626 } catch {
627 }
628 }
629
630 async function submitPlcToken(token: string): Promise<void> {
631 if (!localClient || !targetClient) {
632 throw new Error("Not connected to PDSes");
633 }
634
635 state.plcToken = token;
636 setStep("finalizing");
637 setProgress({ currentOperation: "Signing PLC operation..." });
638
639 try {
640 const credentials = await targetClient.getRecommendedDidCredentials();
641
642 const { operation } = await localClient.signPlcOperation({
643 token,
644 ...credentials,
645 });
646
647 setProgress({ plcSigned: true, currentOperation: "Submitting PLC operation..." });
648
649 await targetClient.submitPlcOperation(operation);
650
651 setProgress({ currentOperation: "Activating account on new PDS..." });
652 await targetClient.activateAccount();
653 setProgress({ activated: true });
654
655 setProgress({ currentOperation: "Deactivating old account..." });
656 try {
657 await localClient.deactivateAccount();
658 setProgress({ deactivated: true });
659 } catch {
660 }
661
662 if (state.localDid.startsWith("did:web:")) {
663 setProgress({ currentOperation: "Updating DID document forwarding..." });
664 try {
665 await localClient.updateMigrationForwarding(state.targetPdsUrl);
666 } catch (e) {
667 console.warn("Failed to update migration forwarding:", e);
668 }
669 }
670
671 setStep("success");
672 clearMigrationState();
673 } catch (e) {
674 const err = e as Error & { error?: string; status?: number };
675 const message = err.message || err.error || `Unknown error (status ${err.status || 'unknown'})`;
676 setError(message);
677 setStep("plc-token");
678 }
679 }
680
681 async function resendPlcToken(): Promise<void> {
682 if (!localClient) {
683 throw new Error("Not connected to local PDS");
684 }
685 await localClient.requestPlcOperationSignature();
686 }
687
688 function reset(): void {
689 state = {
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 localClient = null;
709 targetClient = null;
710 clearMigrationState();
711 }
712
713 return {
714 get state() { return state; },
715 setStep,
716 setError,
717 validateTargetPds,
718 initLocalClient,
719 startMigration,
720 submitPlcToken,
721 resendPlcToken,
722 reset,
723
724 updateField<K extends keyof OutboundMigrationState>(
725 field: K,
726 value: OutboundMigrationState[K],
727 ) {
728 state[field] = value;
729 },
730 };
731}
732
733export type InboundMigrationFlow = ReturnType<typeof createInboundMigrationFlow>;
734export type OutboundMigrationFlow = ReturnType<typeof createOutboundMigrationFlow>;