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