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