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 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"; 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 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) { 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 || !localClient) return; 473 474 const result = await migrateBlobsUtil( 475 localClient, 476 sourceClient, 477 state.sourceDid, 478 setProgress, 479 ); 480 481 state.progress.blobsFailed = result.failed; 482 } 483 484 async function migratePreferences(): Promise<void> { 485 if (!sourceClient || !localClient) return; 486 487 try { 488 const prefs = await sourceClient.getPreferences(); 489 await localClient.putPreferences(prefs); 490 setProgress({ prefsMigrated: true }); 491 } catch { /* optional, best-effort */ } 492 } 493 494 async function submitEmailVerifyToken( 495 token: string, 496 localPassword?: string, 497 ): Promise<void> { 498 if (!localClient) { 499 localClient = createLocalClient(); 500 } 501 502 state.emailVerifyToken = token; 503 setError(null); 504 505 try { 506 await localClient.verifyToken(token, state.targetEmail); 507 508 if (!sourceClient) { 509 setStep("source-handle"); 510 setError( 511 "Email verified! Please log in to your old account again to complete the migration.", 512 ); 513 return; 514 } 515 516 if (state.authMethod === "passkey") { 517 migrationLog( 518 "submitEmailVerifyToken: Email verified, proceeding to passkey setup", 519 ); 520 setStep("passkey-setup"); 521 return; 522 } 523 524 if (localPassword) { 525 setProgress({ currentOperation: "Authenticating to new PDS..." }); 526 await localClient.loginDeactivated(state.targetEmail, localPassword); 527 } 528 529 if (!localClient.getAccessToken()) { 530 setError("Email verified! Please enter your password to continue."); 531 return; 532 } 533 534 if (state.sourceDid.startsWith("did:web:")) { 535 const credentials = await localClient.getRecommendedDidCredentials(); 536 state.targetVerificationMethod = 537 credentials.verificationMethods?.atproto || null; 538 setStep("did-web-update"); 539 } else { 540 setProgress({ currentOperation: "Requesting PLC operation token..." }); 541 await sourceClient.requestPlcOperationSignature(); 542 setStep("plc-token"); 543 } 544 } catch (e) { 545 const err = e as Error & { error?: string; status?: number }; 546 const message = err.message || err.error || 547 `Unknown error (status ${err.status || "unknown"})`; 548 setError(message); 549 } 550 } 551 552 async function resendEmailVerification(): Promise<void> { 553 if (!localClient) { 554 localClient = createLocalClient(); 555 } 556 await localClient.resendMigrationVerification(); 557 } 558 559 let checkingEmailVerification = false; 560 561 async function checkEmailVerifiedAndProceed(): Promise<boolean> { 562 if (checkingEmailVerification) return false; 563 if (!localClient) return false; 564 565 checkingEmailVerification = true; 566 try { 567 const verified = await localClient.checkEmailVerified(state.targetEmail); 568 if (!verified) return false; 569 570 if (state.authMethod === "passkey") { 571 migrationLog( 572 "checkEmailVerifiedAndProceed: Email verified, proceeding to passkey setup", 573 ); 574 setStep("passkey-setup"); 575 return true; 576 } 577 578 await localClient.loginDeactivated( 579 state.targetEmail, 580 state.targetPassword, 581 ); 582 583 if (!sourceClient) { 584 setStep("source-handle"); 585 setError( 586 "Email verified! Please log in to your old account again to complete the migration.", 587 ); 588 return true; 589 } 590 591 if (state.sourceDid.startsWith("did:web:")) { 592 const credentials = await localClient.getRecommendedDidCredentials(); 593 state.targetVerificationMethod = 594 credentials.verificationMethods?.atproto || null; 595 setStep("did-web-update"); 596 } else { 597 await sourceClient.requestPlcOperationSignature(); 598 setStep("plc-token"); 599 } 600 return true; 601 } catch (e) { 602 const err = e as Error & { error?: string }; 603 if (err.error === "AccountNotVerified") { 604 return false; 605 } 606 return false; 607 } finally { 608 checkingEmailVerification = false; 609 } 610 } 611 612 async function submitPlcToken(token: string): Promise<void> { 613 migrationLog("submitPlcToken START", { 614 sourceDid: state.sourceDid, 615 sourceHandle: state.sourceHandle, 616 targetHandle: state.targetHandle, 617 sourcePdsUrl: state.sourcePdsUrl, 618 }); 619 620 if (!sourceClient || !localClient) { 621 migrationLog("submitPlcToken ERROR: Not connected to PDSes", { 622 hasSourceClient: !!sourceClient, 623 hasLocalClient: !!localClient, 624 }); 625 throw new Error("Not connected to PDSes"); 626 } 627 628 state.plcToken = token; 629 setStep("finalizing"); 630 setProgress({ currentOperation: "Signing PLC operation..." }); 631 632 try { 633 migrationLog("Step 1: Getting recommended DID credentials from NEW PDS"); 634 const credentials = await localClient.getRecommendedDidCredentials(); 635 migrationLog("Step 1 COMPLETE: Got credentials", { 636 rotationKeys: credentials.rotationKeys, 637 alsoKnownAs: credentials.alsoKnownAs, 638 verificationMethods: credentials.verificationMethods, 639 services: credentials.services, 640 }); 641 642 migrationLog("Step 2: Signing PLC operation on source PDS", { 643 sourcePdsUrl: state.sourcePdsUrl, 644 }); 645 const signStart = Date.now(); 646 const { operation } = await sourceClient.signPlcOperation({ 647 token, 648 ...credentials, 649 }); 650 migrationLog("Step 2 COMPLETE: PLC operation signed", { 651 durationMs: Date.now() - signStart, 652 operationType: operation.type, 653 operationPrev: operation.prev, 654 }); 655 656 setProgress({ 657 plcSigned: true, 658 currentOperation: "Submitting PLC operation...", 659 }); 660 migrationLog("Step 3: Submitting PLC operation to NEW PDS"); 661 const submitStart = Date.now(); 662 await localClient.submitPlcOperation(operation); 663 migrationLog("Step 3 COMPLETE: PLC operation submitted", { 664 durationMs: Date.now() - submitStart, 665 }); 666 667 setProgress({ 668 currentOperation: "Activating account (waiting for DID propagation)...", 669 }); 670 migrationLog("Step 4: Activating account on NEW PDS"); 671 const activateStart = Date.now(); 672 await localClient.activateAccount(); 673 migrationLog("Step 4 COMPLETE: Account activated on NEW PDS", { 674 durationMs: Date.now() - activateStart, 675 }); 676 setProgress({ activated: true }); 677 678 setProgress({ currentOperation: "Deactivating old account..." }); 679 migrationLog("Step 5: Deactivating account on source PDS", { 680 sourcePdsUrl: state.sourcePdsUrl, 681 }); 682 const deactivateStart = Date.now(); 683 try { 684 await sourceClient.deactivateAccount(); 685 migrationLog("Step 5 COMPLETE: Account deactivated on source PDS", { 686 durationMs: Date.now() - deactivateStart, 687 success: true, 688 }); 689 setProgress({ deactivated: true }); 690 } catch (deactivateErr) { 691 const err = deactivateErr as Error & { 692 error?: string; 693 status?: number; 694 }; 695 migrationLog("Step 5 FAILED: Could not deactivate on source PDS", { 696 durationMs: Date.now() - deactivateStart, 697 error: err.message, 698 errorCode: err.error, 699 status: err.status, 700 }); 701 } 702 703 migrationLog("submitPlcToken SUCCESS: Migration complete", { 704 sourceDid: state.sourceDid, 705 newHandle: state.targetHandle, 706 }); 707 setStep("success"); 708 clearMigrationState(); 709 } catch (e) { 710 const err = e as Error & { error?: string; status?: number }; 711 const message = err.message || err.error || 712 `Unknown error (status ${err.status || "unknown"})`; 713 migrationLog("submitPlcToken FAILED", { 714 error: message, 715 errorCode: err.error, 716 status: err.status, 717 stack: err.stack, 718 }); 719 state.step = "plc-token"; 720 state.error = message; 721 saveMigrationState(state); 722 } 723 } 724 725 async function requestPlcToken(): Promise<void> { 726 if (!sourceClient) { 727 throw new Error("Not connected to source PDS"); 728 } 729 setProgress({ currentOperation: "Requesting PLC operation token..." }); 730 await sourceClient.requestPlcOperationSignature(); 731 } 732 733 async function resendPlcToken(): Promise<void> { 734 if (!sourceClient) { 735 throw new Error("Not connected to source PDS"); 736 } 737 await sourceClient.requestPlcOperationSignature(); 738 } 739 740 async function completeDidWebMigration(): Promise<void> { 741 migrationLog("completeDidWebMigration START", { 742 sourceDid: state.sourceDid, 743 sourceHandle: state.sourceHandle, 744 targetHandle: state.targetHandle, 745 }); 746 747 if (!sourceClient || !localClient) { 748 migrationLog("completeDidWebMigration ERROR: Not connected to PDSes"); 749 throw new Error("Not connected to PDSes"); 750 } 751 752 setStep("finalizing"); 753 setProgress({ currentOperation: "Activating account..." }); 754 755 try { 756 migrationLog("Activating account on NEW PDS"); 757 const activateStart = Date.now(); 758 await localClient.activateAccount(); 759 migrationLog("Account activated", { 760 durationMs: Date.now() - activateStart, 761 }); 762 setProgress({ activated: true }); 763 764 setProgress({ currentOperation: "Deactivating old account..." }); 765 migrationLog("Deactivating account on source PDS"); 766 const deactivateStart = Date.now(); 767 try { 768 await sourceClient.deactivateAccount(); 769 migrationLog("Account deactivated on source PDS", { 770 durationMs: Date.now() - deactivateStart, 771 }); 772 setProgress({ deactivated: true }); 773 } catch (deactivateErr) { 774 const err = deactivateErr as Error & { error?: string }; 775 migrationLog("Could not deactivate on source PDS", { 776 error: err.message, 777 }); 778 } 779 780 migrationLog("completeDidWebMigration SUCCESS"); 781 setStep("success"); 782 clearMigrationState(); 783 } catch (e) { 784 const err = e as Error & { error?: string; status?: number }; 785 const message = err.message || err.error || 786 `Unknown error (status ${err.status || "unknown"})`; 787 migrationLog("completeDidWebMigration FAILED", { error: message }); 788 setError(message); 789 setStep("did-web-update"); 790 } 791 } 792 793 async function startPasskeyRegistration(): Promise<{ options: unknown }> { 794 if (!localClient || !state.passkeySetupToken) { 795 throw new Error("Not ready for passkey registration"); 796 } 797 798 migrationLog("startPasskeyRegistration START", { did: state.sourceDid }); 799 const result = await localClient.startPasskeyRegistrationForSetup( 800 state.sourceDid, 801 state.passkeySetupToken, 802 ); 803 migrationLog("startPasskeyRegistration: Got WebAuthn options"); 804 return result; 805 } 806 807 async function completePasskeyRegistration( 808 credential: unknown, 809 friendlyName?: string, 810 ): Promise<void> { 811 if (!localClient || !state.passkeySetupToken || !sourceClient) { 812 throw new Error("Not ready for passkey registration"); 813 } 814 815 migrationLog("completePasskeyRegistration START", { did: state.sourceDid }); 816 817 const result = await localClient.completePasskeySetup( 818 state.sourceDid, 819 state.passkeySetupToken, 820 credential, 821 friendlyName, 822 ); 823 migrationLog("completePasskeyRegistration: Passkey registered", { 824 appPassword: "***", 825 }); 826 827 setProgress({ currentOperation: "Authenticating with app password..." }); 828 await localClient.loginDeactivated(state.targetEmail, result.appPassword); 829 migrationLog("completePasskeyRegistration: Authenticated to new PDS"); 830 831 state.generatedAppPassword = result.appPassword; 832 state.generatedAppPasswordName = result.appPasswordName; 833 setStep("app-password"); 834 } 835 836 async function proceedFromAppPassword(): Promise<void> { 837 if (!sourceClient || !localClient) { 838 throw new Error("Clients not initialized"); 839 } 840 841 migrationLog("proceedFromAppPassword: Starting"); 842 843 if (state.sourceDid.startsWith("did:web:")) { 844 const credentials = await localClient.getRecommendedDidCredentials(); 845 state.targetVerificationMethod = 846 credentials.verificationMethods?.atproto || null; 847 setStep("did-web-update"); 848 } else { 849 setProgress({ currentOperation: "Requesting PLC operation token..." }); 850 await sourceClient.requestPlcOperationSignature(); 851 setStep("plc-token"); 852 } 853 } 854 855 function reset(): void { 856 state = { 857 direction: "inbound", 858 step: "welcome", 859 sourcePdsUrl: "", 860 sourceDid: "", 861 sourceHandle: "", 862 targetHandle: "", 863 targetEmail: "", 864 targetPassword: "", 865 inviteCode: "", 866 sourceAccessToken: null, 867 sourceRefreshToken: null, 868 serviceAuthToken: null, 869 emailVerifyToken: "", 870 plcToken: "", 871 progress: createInitialProgress(), 872 error: null, 873 targetVerificationMethod: null, 874 authMethod: "password", 875 passkeySetupToken: null, 876 oauthCodeVerifier: null, 877 generatedAppPassword: null, 878 generatedAppPasswordName: null, 879 }; 880 sourceClient = null; 881 passkeySetup = null; 882 clearMigrationState(); 883 clearDPoPKey(); 884 } 885 886 async function resumeFromState(stored: StoredMigrationState): Promise<void> { 887 if (stored.direction !== "inbound") return; 888 889 state.sourcePdsUrl = stored.sourcePdsUrl; 890 state.sourceDid = stored.sourceDid; 891 state.sourceHandle = stored.sourceHandle; 892 state.targetHandle = stored.targetHandle; 893 state.targetEmail = stored.targetEmail; 894 state.authMethod = stored.authMethod ?? "password"; 895 state.progress = { 896 ...createInitialProgress(), 897 ...stored.progress, 898 }; 899 900 const stepsRequiringSourceAuth = [ 901 "choose-handle", 902 "review", 903 "migrating", 904 "email-verify", 905 "plc-token", 906 "did-web-update", 907 "finalizing", 908 "app-password", 909 ]; 910 911 if (stepsRequiringSourceAuth.includes(stored.step)) { 912 state.step = "source-handle"; 913 state.needsReauth = true; 914 state.resumeToStep = stored.step as InboundMigrationState["step"]; 915 migrationLog("resumeFromState: Requiring re-auth for step", { 916 originalStep: stored.step, 917 }); 918 } else if (stored.step === "passkey-setup" && stored.passkeySetupToken) { 919 state.passkeySetupToken = stored.passkeySetupToken; 920 localClient = createLocalClient(); 921 state.step = "passkey-setup"; 922 migrationLog("resumeFromState: Restored passkey-setup with token"); 923 } else if (stored.step === "success") { 924 state.step = "success"; 925 } else if (stored.step === "error") { 926 state.step = "source-handle"; 927 state.needsReauth = true; 928 migrationLog("resumeFromState: Error state, requiring re-auth"); 929 } else { 930 state.step = stored.step as InboundMigrationState["step"]; 931 } 932 } 933 934 function getLocalSession(): 935 | { accessJwt: string; did: string; handle: string } 936 | null { 937 if (!localClient) return null; 938 const token = localClient.getAccessToken(); 939 if (!token) return null; 940 return { 941 accessJwt: token, 942 did: state.sourceDid, 943 handle: state.targetHandle, 944 }; 945 } 946 947 return { 948 get state() { 949 return state; 950 }, 951 get passkeySetup() { 952 return passkeySetup; 953 }, 954 setStep, 955 setError, 956 loadLocalServerInfo, 957 resolveSourcePds, 958 initiateOAuthLogin, 959 handleOAuthCallback, 960 authenticateToLocal, 961 checkHandleAvailability, 962 startMigration, 963 submitEmailVerifyToken, 964 resendEmailVerification, 965 checkEmailVerifiedAndProceed, 966 requestPlcToken, 967 submitPlcToken, 968 resendPlcToken, 969 completeDidWebMigration, 970 startPasskeyRegistration, 971 completePasskeyRegistration, 972 proceedFromAppPassword, 973 reset, 974 resumeFromState, 975 getLocalSession, 976 977 updateField<K extends keyof InboundMigrationState>( 978 field: K, 979 value: InboundMigrationState[K], 980 ) { 981 state[field] = value; 982 }, 983 }; 984} 985 986export type InboundMigrationFlow = ReturnType< 987 typeof createInboundMigrationFlow 988>;