this repo has no description
1import type { 2 InboundMigrationState, 3 InboundStep, 4 MigrationProgress, 5 OutboundMigrationState, 6 OutboundStep, 7 ServerDescription, 8 StoredMigrationState, 9} from "./types"; 10import { 11 AtprotoClient, 12 createLocalClient, 13 resolvePdsUrl, 14} from "./atproto-client"; 15import { 16 clearMigrationState, 17 loadMigrationState, 18 saveMigrationState, 19 updateProgress, 20 updateStep, 21} from "./storage"; 22 23function createInitialProgress(): MigrationProgress { 24 return { 25 repoExported: false, 26 repoImported: false, 27 blobsTotal: 0, 28 blobsMigrated: 0, 29 blobsFailed: [], 30 prefsMigrated: false, 31 plcSigned: false, 32 activated: false, 33 deactivated: false, 34 currentOperation: "", 35 }; 36} 37 38export function createInboundMigrationFlow() { 39 let state = $state<InboundMigrationState>({ 40 direction: "inbound", 41 step: "welcome", 42 sourcePdsUrl: "", 43 sourceDid: "", 44 sourceHandle: "", 45 targetHandle: "", 46 targetEmail: "", 47 targetPassword: "", 48 inviteCode: "", 49 sourceAccessToken: null, 50 sourceRefreshToken: null, 51 serviceAuthToken: null, 52 emailVerifyToken: "", 53 plcToken: "", 54 progress: createInitialProgress(), 55 error: null, 56 requires2FA: false, 57 twoFactorCode: "", 58 }); 59 60 let sourceClient: AtprotoClient | null = null; 61 let localClient: AtprotoClient | null = null; 62 let localServerInfo: ServerDescription | null = null; 63 64 function setStep(step: InboundStep) { 65 state.step = step; 66 state.error = null; 67 saveMigrationState(state); 68 updateStep(step); 69 } 70 71 function setError(error: string) { 72 state.error = error; 73 saveMigrationState(state); 74 } 75 76 function setProgress(updates: Partial<MigrationProgress>) { 77 state.progress = { ...state.progress, ...updates }; 78 updateProgress(updates); 79 } 80 81 async function loadLocalServerInfo(): Promise<ServerDescription> { 82 if (!localClient) { 83 localClient = createLocalClient(); 84 } 85 if (!localServerInfo) { 86 localServerInfo = await localClient.describeServer(); 87 } 88 return localServerInfo; 89 } 90 91 async function resolveSourcePds(handle: string): Promise<void> { 92 try { 93 const { did, pdsUrl } = await resolvePdsUrl(handle); 94 state.sourcePdsUrl = pdsUrl; 95 state.sourceDid = did; 96 state.sourceHandle = handle; 97 sourceClient = new AtprotoClient(pdsUrl); 98 } catch (e) { 99 throw new Error(`Could not resolve handle: ${(e as Error).message}`); 100 } 101 } 102 103 async function loginToSource( 104 handle: string, 105 password: string, 106 twoFactorCode?: string, 107 ): Promise<void> { 108 if (!state.sourcePdsUrl) { 109 await resolveSourcePds(handle); 110 } 111 112 if (!sourceClient) { 113 sourceClient = new AtprotoClient(state.sourcePdsUrl); 114 } 115 116 try { 117 const session = await sourceClient.login(handle, password, twoFactorCode); 118 state.sourceAccessToken = session.accessJwt; 119 state.sourceRefreshToken = session.refreshJwt; 120 state.sourceDid = session.did; 121 state.sourceHandle = session.handle; 122 state.requires2FA = false; 123 saveMigrationState(state); 124 } catch (e) { 125 const err = e as Error & { error?: string }; 126 if (err.error === "AuthFactorTokenRequired") { 127 state.requires2FA = true; 128 throw new Error("Two-factor authentication required. Please enter the code sent to your email."); 129 } 130 throw e; 131 } 132 } 133 134 async function checkHandleAvailability(handle: string): Promise<boolean> { 135 if (!localClient) { 136 localClient = createLocalClient(); 137 } 138 try { 139 await localClient.resolveHandle(handle); 140 return false; 141 } catch { 142 return true; 143 } 144 } 145 146 async function authenticateToLocal(email: string, password: string): Promise<void> { 147 if (!localClient) { 148 localClient = createLocalClient(); 149 } 150 await localClient.loginDeactivated(email, password); 151 } 152 153 async function startMigration(): Promise<void> { 154 if (!sourceClient || !state.sourceAccessToken) { 155 throw new Error("Not logged in to source PDS"); 156 } 157 158 if (!localClient) { 159 localClient = createLocalClient(); 160 } 161 162 setStep("migrating"); 163 setProgress({ currentOperation: "Getting service auth token..." }); 164 165 try { 166 const serverInfo = await loadLocalServerInfo(); 167 const { token } = await sourceClient.getServiceAuth( 168 serverInfo.did, 169 "com.atproto.server.createAccount", 170 ); 171 state.serviceAuthToken = token; 172 173 setProgress({ currentOperation: "Creating account on new PDS..." }); 174 175 const accountParams = { 176 did: state.sourceDid, 177 handle: state.targetHandle, 178 email: state.targetEmail, 179 password: state.targetPassword, 180 inviteCode: state.inviteCode || undefined, 181 }; 182 183 const session = await localClient.createAccount(accountParams, token); 184 localClient.setAccessToken(session.accessJwt); 185 186 setProgress({ currentOperation: "Exporting repository..." }); 187 188 const car = await sourceClient.getRepo(state.sourceDid); 189 setProgress({ repoExported: true, currentOperation: "Importing repository..." }); 190 191 await localClient.importRepo(car); 192 setProgress({ repoImported: true, currentOperation: "Counting blobs..." }); 193 194 const accountStatus = await localClient.checkAccountStatus(); 195 setProgress({ 196 blobsTotal: accountStatus.expectedBlobs, 197 currentOperation: "Migrating blobs...", 198 }); 199 200 await migrateBlobs(); 201 202 setProgress({ currentOperation: "Migrating preferences..." }); 203 await migratePreferences(); 204 205 setStep("email-verify"); 206 } catch (e) { 207 const err = e as Error & { error?: string; status?: number }; 208 const message = err.message || err.error || `Unknown error (status ${err.status || 'unknown'})`; 209 setError(message); 210 setStep("error"); 211 } 212 } 213 214 async function migrateBlobs(): Promise<void> { 215 if (!sourceClient || !localClient) return; 216 217 let cursor: string | undefined; 218 let migrated = 0; 219 220 do { 221 const { blobs, cursor: nextCursor } = await localClient.listMissingBlobs( 222 cursor, 223 100, 224 ); 225 226 for (const blob of blobs) { 227 try { 228 setProgress({ 229 currentOperation: `Migrating blob ${migrated + 1}/${state.progress.blobsTotal}...`, 230 }); 231 232 const blobData = await sourceClient.getBlob(state.sourceDid, blob.cid); 233 await localClient.uploadBlob(blobData, "application/octet-stream"); 234 migrated++; 235 setProgress({ blobsMigrated: migrated }); 236 } catch (e) { 237 state.progress.blobsFailed.push(blob.cid); 238 } 239 } 240 241 cursor = nextCursor; 242 } while (cursor); 243 } 244 245 async function migratePreferences(): Promise<void> { 246 if (!sourceClient || !localClient) return; 247 248 try { 249 const prefs = await sourceClient.getPreferences(); 250 await localClient.putPreferences(prefs); 251 setProgress({ prefsMigrated: true }); 252 } catch { 253 } 254 } 255 256 async function submitEmailVerifyToken(token: string, localPassword?: string): Promise<void> { 257 if (!localClient) { 258 localClient = createLocalClient(); 259 } 260 261 state.emailVerifyToken = token; 262 setError(null); 263 264 try { 265 await localClient.verifyToken(token, state.targetEmail); 266 267 if (!sourceClient) { 268 setStep("source-login"); 269 setError("Email verified! Please log in to your old account again to complete the migration."); 270 return; 271 } 272 273 if (localPassword) { 274 setProgress({ currentOperation: "Authenticating to new PDS..." }); 275 await localClient.loginDeactivated(state.targetEmail, localPassword); 276 } 277 278 if (!localClient.getAccessToken()) { 279 setError("Email verified! Please enter your password to continue."); 280 return; 281 } 282 283 setProgress({ currentOperation: "Requesting PLC operation token..." }); 284 await sourceClient.requestPlcOperationSignature(); 285 setStep("plc-token"); 286 } catch (e) { 287 const err = e as Error & { error?: string; status?: number }; 288 const message = err.message || err.error || `Unknown error (status ${err.status || 'unknown'})`; 289 setError(message); 290 } 291 } 292 293 async function resendEmailVerification(): Promise<void> { 294 if (!localClient) { 295 localClient = createLocalClient(); 296 } 297 await localClient.resendMigrationVerification(); 298 } 299 300 let checkingEmailVerification = false; 301 302 async function checkEmailVerifiedAndProceed(): Promise<boolean> { 303 if (checkingEmailVerification) return false; 304 if (!sourceClient || !localClient) return false; 305 306 checkingEmailVerification = true; 307 try { 308 await localClient.loginDeactivated(state.targetEmail, state.targetPassword); 309 await sourceClient.requestPlcOperationSignature(); 310 setStep("plc-token"); 311 return true; 312 } catch (e) { 313 const err = e as Error & { error?: string }; 314 if (err.error === "AccountNotVerified") { 315 return false; 316 } 317 return false; 318 } finally { 319 checkingEmailVerification = false; 320 } 321 } 322 323 async function submitPlcToken(token: string): Promise<void> { 324 if (!sourceClient || !localClient) { 325 throw new Error("Not connected to PDSes"); 326 } 327 328 state.plcToken = token; 329 setStep("finalizing"); 330 setProgress({ currentOperation: "Signing PLC operation..." }); 331 332 try { 333 const credentials = await localClient.getRecommendedDidCredentials(); 334 335 const { operation } = await sourceClient.signPlcOperation({ 336 token, 337 ...credentials, 338 }); 339 340 setProgress({ plcSigned: true, currentOperation: "Submitting PLC operation..." }); 341 await localClient.submitPlcOperation(operation); 342 343 setProgress({ currentOperation: "Activating account (waiting for DID propagation)..." }); 344 await localClient.activateAccount(); 345 setProgress({ activated: true }); 346 347 setProgress({ currentOperation: "Deactivating old account..." }); 348 try { 349 await sourceClient.deactivateAccount(); 350 setProgress({ deactivated: true }); 351 } catch { 352 } 353 354 setStep("success"); 355 clearMigrationState(); 356 } catch (e) { 357 const err = e as Error & { error?: string; status?: number }; 358 const message = err.message || err.error || `Unknown error (status ${err.status || 'unknown'})`; 359 state.step = "plc-token"; 360 state.error = message; 361 saveMigrationState(state); 362 } 363 } 364 365 async function requestPlcToken(): Promise<void> { 366 if (!sourceClient) { 367 throw new Error("Not connected to source PDS"); 368 } 369 setProgress({ currentOperation: "Requesting PLC operation token..." }); 370 await sourceClient.requestPlcOperationSignature(); 371 } 372 373 async function resendPlcToken(): Promise<void> { 374 if (!sourceClient) { 375 throw new Error("Not connected to source PDS"); 376 } 377 await sourceClient.requestPlcOperationSignature(); 378 } 379 380 function reset(): void { 381 state = { 382 direction: "inbound", 383 step: "welcome", 384 sourcePdsUrl: "", 385 sourceDid: "", 386 sourceHandle: "", 387 targetHandle: "", 388 targetEmail: "", 389 targetPassword: "", 390 inviteCode: "", 391 sourceAccessToken: null, 392 sourceRefreshToken: null, 393 serviceAuthToken: null, 394 emailVerifyToken: "", 395 plcToken: "", 396 progress: createInitialProgress(), 397 error: null, 398 requires2FA: false, 399 twoFactorCode: "", 400 }; 401 sourceClient = null; 402 clearMigrationState(); 403 } 404 405 async function resumeFromState(stored: StoredMigrationState): Promise<void> { 406 if (stored.direction !== "inbound") return; 407 408 state.sourcePdsUrl = stored.sourcePdsUrl; 409 state.sourceDid = stored.sourceDid; 410 state.sourceHandle = stored.sourceHandle; 411 state.targetHandle = stored.targetHandle; 412 state.targetEmail = stored.targetEmail; 413 state.progress = { 414 ...createInitialProgress(), 415 ...stored.progress, 416 }; 417 418 state.step = "source-login"; 419 } 420 421 function getLocalSession(): { accessJwt: string; did: string; handle: string } | null { 422 if (!localClient) return null; 423 const token = localClient.getAccessToken(); 424 if (!token) return null; 425 return { 426 accessJwt: token, 427 did: state.sourceDid, 428 handle: state.targetHandle, 429 }; 430 } 431 432 return { 433 get state() { return state; }, 434 setStep, 435 setError, 436 loadLocalServerInfo, 437 loginToSource, 438 authenticateToLocal, 439 checkHandleAvailability, 440 startMigration, 441 submitEmailVerifyToken, 442 resendEmailVerification, 443 checkEmailVerifiedAndProceed, 444 requestPlcToken, 445 submitPlcToken, 446 resendPlcToken, 447 reset, 448 resumeFromState, 449 getLocalSession, 450 451 updateField<K extends keyof InboundMigrationState>( 452 field: K, 453 value: InboundMigrationState[K], 454 ) { 455 state[field] = value; 456 }, 457 }; 458} 459 460export function createOutboundMigrationFlow() { 461 let state = $state<OutboundMigrationState>({ 462 direction: "outbound", 463 step: "welcome", 464 localDid: "", 465 localHandle: "", 466 targetPdsUrl: "", 467 targetPdsDid: "", 468 targetHandle: "", 469 targetEmail: "", 470 targetPassword: "", 471 inviteCode: "", 472 targetAccessToken: null, 473 targetRefreshToken: null, 474 serviceAuthToken: null, 475 plcToken: "", 476 progress: createInitialProgress(), 477 error: null, 478 targetServerInfo: null, 479 }); 480 481 let localClient: AtprotoClient | null = null; 482 let targetClient: AtprotoClient | null = null; 483 484 function setStep(step: OutboundStep) { 485 state.step = step; 486 state.error = null; 487 saveMigrationState(state); 488 updateStep(step); 489 } 490 491 function setError(error: string) { 492 state.error = error; 493 saveMigrationState(state); 494 } 495 496 function setProgress(updates: Partial<MigrationProgress>) { 497 state.progress = { ...state.progress, ...updates }; 498 updateProgress(updates); 499 } 500 501 async function validateTargetPds(url: string): Promise<ServerDescription> { 502 const normalizedUrl = url.replace(/\/$/, ""); 503 targetClient = new AtprotoClient(normalizedUrl); 504 505 try { 506 const serverInfo = await targetClient.describeServer(); 507 state.targetPdsUrl = normalizedUrl; 508 state.targetPdsDid = serverInfo.did; 509 state.targetServerInfo = serverInfo; 510 return serverInfo; 511 } catch (e) { 512 throw new Error(`Could not connect to PDS: ${(e as Error).message}`); 513 } 514 } 515 516 function initLocalClient(accessToken: string, did?: string, handle?: string): void { 517 localClient = createLocalClient(); 518 localClient.setAccessToken(accessToken); 519 if (did) { 520 state.localDid = did; 521 } 522 if (handle) { 523 state.localHandle = handle; 524 } 525 } 526 527 async function startMigration(currentDid: string): Promise<void> { 528 if (!localClient || !targetClient) { 529 throw new Error("Not connected to PDSes"); 530 } 531 532 setStep("migrating"); 533 setProgress({ currentOperation: "Getting service auth token..." }); 534 535 try { 536 const { token } = await localClient.getServiceAuth( 537 state.targetPdsDid, 538 "com.atproto.server.createAccount", 539 ); 540 state.serviceAuthToken = token; 541 542 setProgress({ currentOperation: "Creating account on new PDS..." }); 543 544 const accountParams = { 545 did: currentDid, 546 handle: state.targetHandle, 547 email: state.targetEmail, 548 password: state.targetPassword, 549 inviteCode: state.inviteCode || undefined, 550 }; 551 552 const session = await targetClient.createAccount(accountParams, token); 553 state.targetAccessToken = session.accessJwt; 554 state.targetRefreshToken = session.refreshJwt; 555 targetClient.setAccessToken(session.accessJwt); 556 557 setProgress({ currentOperation: "Exporting repository..." }); 558 559 const car = await localClient.getRepo(currentDid); 560 setProgress({ repoExported: true, currentOperation: "Importing repository..." }); 561 562 await targetClient.importRepo(car); 563 setProgress({ repoImported: true, currentOperation: "Counting blobs..." }); 564 565 const accountStatus = await targetClient.checkAccountStatus(); 566 setProgress({ 567 blobsTotal: accountStatus.expectedBlobs, 568 currentOperation: "Migrating blobs...", 569 }); 570 571 await migrateBlobs(currentDid); 572 573 setProgress({ currentOperation: "Migrating preferences..." }); 574 await migratePreferences(); 575 576 setProgress({ currentOperation: "Requesting PLC operation token..." }); 577 await localClient.requestPlcOperationSignature(); 578 579 setStep("plc-token"); 580 } catch (e) { 581 const err = e as Error & { error?: string; status?: number }; 582 const message = err.message || err.error || `Unknown error (status ${err.status || 'unknown'})`; 583 setError(message); 584 setStep("error"); 585 } 586 } 587 588 async function migrateBlobs(did: string): Promise<void> { 589 if (!localClient || !targetClient) return; 590 591 let cursor: string | undefined; 592 let migrated = 0; 593 594 do { 595 const { blobs, cursor: nextCursor } = await targetClient.listMissingBlobs( 596 cursor, 597 100, 598 ); 599 600 for (const blob of blobs) { 601 try { 602 setProgress({ 603 currentOperation: `Migrating blob ${migrated + 1}/${state.progress.blobsTotal}...`, 604 }); 605 606 const blobData = await localClient.getBlob(did, blob.cid); 607 await targetClient.uploadBlob(blobData, "application/octet-stream"); 608 migrated++; 609 setProgress({ blobsMigrated: migrated }); 610 } catch (e) { 611 state.progress.blobsFailed.push(blob.cid); 612 } 613 } 614 615 cursor = nextCursor; 616 } while (cursor); 617 } 618 619 async function migratePreferences(): Promise<void> { 620 if (!localClient || !targetClient) return; 621 622 try { 623 const prefs = await localClient.getPreferences(); 624 await targetClient.putPreferences(prefs); 625 setProgress({ prefsMigrated: true }); 626 } catch { 627 } 628 } 629 630 async function submitPlcToken(token: string): Promise<void> { 631 if (!localClient || !targetClient) { 632 throw new Error("Not connected to PDSes"); 633 } 634 635 state.plcToken = token; 636 setStep("finalizing"); 637 setProgress({ currentOperation: "Signing PLC operation..." }); 638 639 try { 640 const credentials = await targetClient.getRecommendedDidCredentials(); 641 642 const { operation } = await localClient.signPlcOperation({ 643 token, 644 ...credentials, 645 }); 646 647 setProgress({ plcSigned: true, currentOperation: "Submitting PLC operation..." }); 648 649 await targetClient.submitPlcOperation(operation); 650 651 setProgress({ currentOperation: "Activating account on new PDS..." }); 652 await targetClient.activateAccount(); 653 setProgress({ activated: true }); 654 655 setProgress({ currentOperation: "Deactivating old account..." }); 656 try { 657 await localClient.deactivateAccount(); 658 setProgress({ deactivated: true }); 659 } catch { 660 } 661 662 if (state.localDid.startsWith("did:web:")) { 663 setProgress({ currentOperation: "Updating DID document forwarding..." }); 664 try { 665 await localClient.updateMigrationForwarding(state.targetPdsUrl); 666 } catch (e) { 667 console.warn("Failed to update migration forwarding:", e); 668 } 669 } 670 671 setStep("success"); 672 clearMigrationState(); 673 } catch (e) { 674 const err = e as Error & { error?: string; status?: number }; 675 const message = err.message || err.error || `Unknown error (status ${err.status || 'unknown'})`; 676 setError(message); 677 setStep("plc-token"); 678 } 679 } 680 681 async function resendPlcToken(): Promise<void> { 682 if (!localClient) { 683 throw new Error("Not connected to local PDS"); 684 } 685 await localClient.requestPlcOperationSignature(); 686 } 687 688 function reset(): void { 689 state = { 690 direction: "outbound", 691 step: "welcome", 692 localDid: "", 693 localHandle: "", 694 targetPdsUrl: "", 695 targetPdsDid: "", 696 targetHandle: "", 697 targetEmail: "", 698 targetPassword: "", 699 inviteCode: "", 700 targetAccessToken: null, 701 targetRefreshToken: null, 702 serviceAuthToken: null, 703 plcToken: "", 704 progress: createInitialProgress(), 705 error: null, 706 targetServerInfo: null, 707 }; 708 localClient = null; 709 targetClient = null; 710 clearMigrationState(); 711 } 712 713 return { 714 get state() { return state; }, 715 setStep, 716 setError, 717 validateTargetPds, 718 initLocalClient, 719 startMigration, 720 submitPlcToken, 721 resendPlcToken, 722 reset, 723 724 updateField<K extends keyof OutboundMigrationState>( 725 field: K, 726 value: OutboundMigrationState[K], 727 ) { 728 state[field] = value; 729 }, 730 }; 731} 732 733export type InboundMigrationFlow = ReturnType<typeof createInboundMigrationFlow>; 734export type OutboundMigrationFlow = ReturnType<typeof createOutboundMigrationFlow>;