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