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>;