Graphical PDS migrator for AT Protocol
1/** 2 * The migration state info. 3 * @type {MigrationStateInfo} 4 */ 5export interface MigrationStateInfo { 6 state: "up" | "issue" | "maintenance"; 7 message: string; 8 allowMigration: boolean; 9} 10 11/** 12 * The migration progress props. 13 * @type {MigrationProgressProps} 14 */ 15export interface MigrationProgressProps { 16 service: string; 17 handle: string; 18 email: string; 19 password: string; 20 invite?: string; 21} 22 23/** 24 * The migration step. 25 * @type {MigrationStep} 26 */ 27export interface MigrationStep { 28 name: string; 29 status: "pending" | "in-progress" | "verifying" | "completed" | "error"; 30 error?: string; 31 isVerificationError?: boolean; 32} 33 34export enum MigrationErrorType { 35 NOT_ALLOWED = "NOT_ALLOWED", 36 NETWORK = "NETWORK", 37 OTHER = "OTHER", 38} 39 40export class MigrationError extends Error { 41 stepIndex: number; 42 type: MigrationErrorType; 43 44 constructor(message: string, stepIndex: number, type: MigrationErrorType) { 45 super(message); 46 this.name = "MigrationError"; 47 this.stepIndex = stepIndex; 48 this.type = type; 49 } 50} 51 52export class MigrationClient { 53 private updateStepStatus: ( 54 stepIndex: number, 55 status: MigrationStep["status"], 56 error?: string, 57 isVerificationError?: boolean, 58 ) => void; 59 private setRetryAttempts?: ( 60 value: (prev: Record<number, number>) => Record<number, number>, 61 ) => void; 62 private setShowContinueAnyway?: ( 63 value: (prev: Record<number, boolean>) => Record<number, boolean>, 64 ) => void; 65 private baseUrl = ""; 66 private nextStepHook?: (stepNum: number) => unknown; 67 68 constructor({ 69 updateStepStatus, 70 setRetryAttempts, 71 setShowContinueAnyway, 72 baseUrl = "", 73 nextStepHook, 74 }: { 75 updateStepStatus: ( 76 stepIndex: number, 77 status: MigrationStep["status"], 78 error?: string, 79 isVerificationError?: boolean, 80 ) => void; 81 setRetryAttempts?: ( 82 value: (prev: Record<number, number>) => Record<number, number>, 83 ) => void; 84 setShowContinueAnyway?: ( 85 value: (prev: Record<number, boolean>) => Record<number, boolean>, 86 ) => void; 87 baseUrl?: string; 88 nextStepHook?: (stepNum: number) => unknown; 89 }) { 90 this.updateStepStatus = updateStepStatus; 91 this.setRetryAttempts = setRetryAttempts; 92 this.setShowContinueAnyway = setShowContinueAnyway; 93 this.baseUrl = baseUrl; 94 this.nextStepHook = nextStepHook; 95 } 96 97 async checkState() { 98 try { 99 const migrationResponse = await fetch( 100 `${this.baseUrl}/api/migration-state`, 101 ); 102 if (migrationResponse.ok) { 103 const migrationData = await migrationResponse.json(); 104 105 if (!migrationData.allowMigration) { 106 throw new MigrationError( 107 migrationData.message, 108 0, 109 MigrationErrorType.NOT_ALLOWED, 110 ); 111 } 112 113 return migrationData; 114 } 115 } catch (error) { 116 console.error("Error checking migration state:", error); 117 throw new MigrationError( 118 "Unable to verify migration availability", 119 0, 120 MigrationErrorType.OTHER, 121 ); 122 } 123 } 124 125 async startMigration(props: MigrationProgressProps) { 126 console.log("Starting migration with props:", { 127 service: props.service, 128 handle: props.handle, 129 email: props.email, 130 hasPassword: !!props.password, 131 invite: props.invite, 132 }); 133 134 try { 135 // Step 1: Create Account 136 this.updateStepStatus(0, "in-progress"); 137 console.log("Starting account creation..."); 138 139 try { 140 const createRes = await fetch(`${this.baseUrl}/api/migrate/create`, { 141 method: "POST", 142 headers: { "Content-Type": "application/json" }, 143 body: JSON.stringify({ 144 service: props.service, 145 handle: props.handle, 146 password: props.password, 147 email: props.email, 148 ...(props.invite ? { invite: props.invite } : {}), 149 }), 150 }); 151 152 console.log("Create account response status:", createRes.status); 153 const responseText = await createRes.text(); 154 console.log("Create account response:", responseText); 155 156 if (!createRes.ok) { 157 try { 158 const json = JSON.parse(responseText); 159 throw new Error(json.message || "Failed to create account"); 160 } catch { 161 throw new Error(responseText || "Failed to create account"); 162 } 163 } 164 165 try { 166 const jsonData = JSON.parse(responseText); 167 if (!jsonData.success) { 168 throw new Error(jsonData.message || "Account creation failed"); 169 } 170 } catch (e) { 171 console.log("Response is not JSON or lacks success field:", e); 172 } 173 174 this.updateStepStatus(0, "verifying"); 175 const verified = await this.verifyStep(0); 176 if (!verified) { 177 console.log( 178 "Account creation: Verification failed, waiting for user action", 179 ); 180 return; 181 } 182 183 this.updateStepStatus(0, "completed"); 184 if (this.nextStepHook) { 185 await this.nextStepHook(0); 186 } 187 } catch (error) { 188 this.updateStepStatus( 189 0, 190 "error", 191 error instanceof Error ? error.message : String(error), 192 ); 193 throw error; 194 } 195 196 // Step 2: Data Migration 197 await this.startDataMigration(); 198 if (this.nextStepHook) { 199 await this.nextStepHook(1); 200 } 201 202 // Step 3: Identity Migration (request email) 203 await this.startIdentityMigration(); 204 if (this.nextStepHook) { 205 await this.nextStepHook(2); 206 } 207 208 // Stop here - finalization will be called from handleIdentityMigration 209 // after user enters the token 210 return; 211 } catch (error) { 212 console.error("Migration error in try/catch:", error); 213 throw error; 214 } 215 } 216 217 async startDataMigration() { 218 // Step 2: Migrate Data 219 this.updateStepStatus(1, "in-progress"); 220 console.log("Starting data migration..."); 221 222 // Step 2.1: Migrate Repo 223 console.log("Data migration: Starting repo migration"); 224 const repoRes = await fetch(`${this.baseUrl}/api/migrate/data/repo`, { 225 method: "POST", 226 headers: { "Content-Type": "application/json" }, 227 }); 228 229 console.log("Repo migration: Response status:", repoRes.status); 230 const repoText = await repoRes.text(); 231 console.log("Repo migration: Raw response:", repoText); 232 233 if (!repoRes.ok) { 234 try { 235 const json = JSON.parse(repoText); 236 console.error("Repo migration: Error response:", json); 237 throw new Error(json.message || "Failed to migrate repo"); 238 } catch { 239 console.error("Repo migration: Non-JSON error response:", repoText); 240 throw new Error(repoText || "Failed to migrate repo"); 241 } 242 } 243 244 // Step 2.2: Migrate Blobs 245 console.log("Data migration: Starting blob migration"); 246 const blobsRes = await fetch(`${this.baseUrl}/api/migrate/data/blobs`, { 247 method: "POST", 248 headers: { "Content-Type": "application/json" }, 249 }); 250 251 console.log("Blob migration: Response status:", blobsRes.status); 252 const blobsText = await blobsRes.text(); 253 console.log("Blob migration: Raw response:", blobsText); 254 255 if (!blobsRes.ok) { 256 try { 257 const json = JSON.parse(blobsText); 258 console.error("Blob migration: Error response:", json); 259 throw new Error(json.message || "Failed to migrate blobs"); 260 } catch { 261 console.error( 262 "Blob migration: Non-JSON error response:", 263 blobsText, 264 ); 265 throw new Error(blobsText || "Failed to migrate blobs"); 266 } 267 } 268 269 // Step 2.3: Migrate Preferences 270 console.log("Data migration: Starting preferences migration"); 271 const prefsRes = await fetch(`${this.baseUrl}/api/migrate/data/prefs`, { 272 method: "POST", 273 headers: { "Content-Type": "application/json" }, 274 }); 275 276 console.log("Preferences migration: Response status:", prefsRes.status); 277 const prefsText = await prefsRes.text(); 278 console.log("Preferences migration: Raw response:", prefsText); 279 280 if (!prefsRes.ok) { 281 try { 282 const json = JSON.parse(prefsText); 283 console.error("Preferences migration: Error response:", json); 284 throw new Error(json.message || "Failed to migrate preferences"); 285 } catch { 286 console.error( 287 "Preferences migration: Non-JSON error response:", 288 prefsText, 289 ); 290 throw new Error(prefsText || "Failed to migrate preferences"); 291 } 292 } 293 294 console.log("Data migration: Starting verification"); 295 this.updateStepStatus(1, "verifying"); 296 const verified = await this.verifyStep(1); 297 console.log("Data migration: Verification result:", verified); 298 if (!verified) { 299 console.log( 300 "Data migration: Verification failed, waiting for user action", 301 ); 302 return; 303 } 304 305 this.updateStepStatus(1, "completed"); 306 // Continue to next step 307 //await this.continueToNextStep(2); 308 } 309 310 async startIdentityMigration() { 311 // Step 3: Start Identity Migration (just trigger the email) 312 this.updateStepStatus(2, "in-progress"); 313 console.log("Starting identity migration..."); 314 315 try { 316 const identityRes = await fetch( 317 `${this.baseUrl}/api/migrate/identity/request`, 318 { 319 method: "POST", 320 headers: { "Content-Type": "application/json" }, 321 }, 322 ); 323 324 const identityData = await identityRes.text(); 325 326 if (!identityRes.ok) { 327 try { 328 const json = JSON.parse(identityData); 329 throw new Error( 330 json.message || "Failed to start identity migration", 331 ); 332 } catch { 333 throw new Error( 334 identityData || "Failed to start identity migration", 335 ); 336 } 337 } 338 339 // Update the step to show token input 340 this.updateStepStatus(2, "in-progress"); 341 } catch (error) { 342 this.updateStepStatus( 343 2, 344 "error", 345 error instanceof Error ? error.message : String(error), 346 ); 347 throw error; 348 } 349 } 350 351 async handleIdentityMigration(token: string) { 352 if (!token) { 353 throw new Error("Token is required"); 354 } 355 356 const identityRes = await fetch( 357 `${this.baseUrl}/api/migrate/identity/sign?token=${ 358 encodeURIComponent(token) 359 }`, 360 { 361 method: "POST", 362 headers: { "Content-Type": "application/json" }, 363 }, 364 ); 365 366 const identityData = await identityRes.text(); 367 if (!identityRes.ok) { 368 try { 369 const json = JSON.parse(identityData); 370 throw new Error( 371 json.message || "Failed to complete identity migration", 372 ); 373 } catch { 374 throw new Error( 375 identityData || "Failed to complete identity migration", 376 ); 377 } 378 } 379 380 let data; 381 try { 382 data = JSON.parse(identityData); 383 if (!data.success) { 384 throw new Error(data.message || "Identity migration failed"); 385 } 386 } catch { 387 throw new Error("Invalid response from server"); 388 } 389 390 // Verify the identity migration succeeded 391 this.updateStepStatus(2, "verifying"); 392 const verified = await this.verifyStep(2, true); 393 if (!verified) { 394 console.log( 395 "Identity migration: Verification failed after token submission", 396 ); 397 throw new Error("Identity migration verification failed"); 398 } 399 400 this.updateStepStatus(2, "completed"); 401 } 402 403 async finalizeMigration() { 404 // Step 4: Finalize Migration 405 this.updateStepStatus(3, "in-progress"); 406 const finalizeRes = await fetch(`${this.baseUrl}/api/migrate/finalize`, { 407 method: "POST", 408 headers: { "Content-Type": "application/json" }, 409 }); 410 411 const finalizeData = await finalizeRes.text(); 412 if (!finalizeRes.ok) { 413 try { 414 const json = JSON.parse(finalizeData); 415 throw new Error(json.message || "Failed to finalize migration"); 416 } catch { 417 throw new Error(finalizeData || "Failed to finalize migration"); 418 } 419 } 420 421 try { 422 const jsonData = JSON.parse(finalizeData); 423 if (!jsonData.success) { 424 throw new Error(jsonData.message || "Finalization failed"); 425 } 426 } catch { 427 throw new Error("Invalid response from server during finalization"); 428 } 429 430 this.updateStepStatus(3, "verifying"); 431 const verified = await this.verifyStep(3); 432 if (!verified) { 433 console.log( 434 "Finalization: Verification failed, waiting for user action", 435 ); 436 return; 437 } 438 439 this.updateStepStatus(3, "completed"); 440 } 441 442 async retryVerification(stepNum: number, steps: MigrationStep[]) { 443 console.log(`Retrying verification for step ${stepNum + 1}`); 444 const isManualSubmission = stepNum === 2 && 445 steps[2].name === 446 "Enter the token sent to your email to complete identity migration"; 447 const verified = await this.verifyStep(stepNum, isManualSubmission); 448 449 if (verified && stepNum < 3) { 450 await this.continueToNextStep(stepNum + 1); 451 } 452 } 453 454 async verifyStep( 455 stepNum: number, 456 isManualSubmission: boolean = false, 457 ) { 458 console.log(`Verification: Starting step ${stepNum + 1}`); 459 460 // Skip automatic verification for step 2 (identity migration) unless it's after manual token submission 461 if (stepNum === 2 && !isManualSubmission) { 462 console.log( 463 `Verification: Skipping automatic verification for identity migration step`, 464 ); 465 return false; 466 } 467 468 this.updateStepStatus(stepNum, "verifying"); 469 try { 470 console.log(`Verification: Fetching status for step ${stepNum + 1}`); 471 const res = await fetch( 472 `${this.baseUrl}/api/migrate/status?step=${stepNum + 1}`, 473 ); 474 console.log(`Verification: Status response status:`, res.status); 475 const data = await res.json(); 476 console.log(`Verification: Status data for step ${stepNum + 1}:`, data); 477 478 if (data.ready) { 479 console.log(`Verification: Step ${stepNum + 1} is ready`); 480 this.updateStepStatus(stepNum, "completed"); 481 482 // Reset retry state on success if callbacks are available 483 if (this.setRetryAttempts) { 484 this.setRetryAttempts((prev: any) => ({ ...prev, [stepNum]: 0 })); 485 } 486 if (this.setShowContinueAnyway) { 487 this.setShowContinueAnyway((prev: any) => ({ 488 ...prev, 489 [stepNum]: false, 490 })); 491 } 492 493 return true; 494 } else { 495 console.log( 496 `Verification: Step ${stepNum + 1} is not ready:`, 497 data.reason, 498 ); 499 const statusDetails = { 500 activated: data.activated, 501 validDid: data.validDid, 502 repoCommit: data.repoCommit, 503 repoRev: data.repoRev, 504 repoBlocks: data.repoBlocks, 505 expectedRecords: data.expectedRecords, 506 indexedRecords: data.indexedRecords, 507 privateStateValues: data.privateStateValues, 508 expectedBlobs: data.expectedBlobs, 509 importedBlobs: data.importedBlobs, 510 }; 511 console.log( 512 `Verification: Step ${stepNum + 1} status details:`, 513 statusDetails, 514 ); 515 const errorMessage = `${ 516 data.reason || "Verification failed" 517 }\nStatus details: ${JSON.stringify(statusDetails, null, 2)}`; 518 519 // Track retry attempts only if callbacks are available 520 if (this.setRetryAttempts && this.setShowContinueAnyway) { 521 this.setRetryAttempts((prev: any) => ({ 522 ...prev, 523 [stepNum]: (prev[stepNum] || 0) + 1, 524 })); 525 526 // Show continue anyway option if this is the second failure 527 this.setRetryAttempts((prev: any) => { 528 const currentAttempts = (prev[stepNum] || 0) + 1; 529 if (currentAttempts >= 2) { 530 this.setShowContinueAnyway!((prevShow: any) => ({ 531 ...prevShow, 532 [stepNum]: true, 533 })); 534 } 535 return { ...prev, [stepNum]: currentAttempts }; 536 }); 537 538 this.updateStepStatus(stepNum, "error", errorMessage, true); 539 } else { 540 // No retry callbacks - just fail 541 this.updateStepStatus(stepNum, "error", errorMessage, false); 542 } 543 544 return false; 545 } 546 } catch (e) { 547 console.error(`Verification: Error in step ${stepNum + 1}:`, e); 548 549 // Track retry attempts only if callbacks are available 550 if (this.setRetryAttempts && this.setShowContinueAnyway) { 551 this.setRetryAttempts((prev: any) => { 552 const currentAttempts = (prev[stepNum] || 0) + 1; 553 if (currentAttempts >= 2) { 554 this.setShowContinueAnyway!((prevShow: any) => ({ 555 ...prevShow, 556 [stepNum]: true, 557 })); 558 } 559 return { ...prev, [stepNum]: currentAttempts }; 560 }); 561 562 this.updateStepStatus( 563 stepNum, 564 "error", 565 e instanceof Error ? e.message : String(e), 566 true, 567 ); 568 } else { 569 // No retry callbacks - just fail 570 this.updateStepStatus( 571 stepNum, 572 "error", 573 e instanceof Error ? e.message : String(e), 574 false, 575 ); 576 } 577 578 return false; 579 } 580 } 581 582 async continueToNextStep(stepNum: number) { 583 console.log(`Continuing to step ${stepNum + 1}`); 584 try { 585 switch (stepNum) { 586 case 1: 587 await this.startDataMigration(); 588 break; 589 case 2: 590 await this.startIdentityMigration(); 591 break; 592 case 3: 593 await this.finalizeMigration(); 594 break; 595 } 596 597 this.nextStepHook?.(stepNum); 598 } catch (error) { 599 console.error(`Error in step ${stepNum + 1}:`, error); 600 this.updateStepStatus( 601 stepNum, 602 "error", 603 error instanceof Error ? error.message : String(error), 604 ); 605 } 606 } 607}