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 // Step 4: Finalize Migration 209 await this.finalizeMigration(); 210 if (this.nextStepHook) { 211 await this.nextStepHook(3); 212 } 213 214 return; 215 } catch (error) { 216 console.error("Migration error in try/catch:", error); 217 throw error; 218 } 219 } 220 221 async startDataMigration() { 222 // Step 2: Migrate Data 223 this.updateStepStatus(1, "in-progress"); 224 console.log("Starting data migration..."); 225 226 // Step 2.1: Migrate Repo 227 console.log("Data migration: Starting repo migration"); 228 const repoRes = await fetch(`${this.baseUrl}/api/migrate/data/repo`, { 229 method: "POST", 230 headers: { "Content-Type": "application/json" }, 231 }); 232 233 console.log("Repo migration: Response status:", repoRes.status); 234 const repoText = await repoRes.text(); 235 console.log("Repo migration: Raw response:", repoText); 236 237 if (!repoRes.ok) { 238 try { 239 const json = JSON.parse(repoText); 240 console.error("Repo migration: Error response:", json); 241 throw new Error(json.message || "Failed to migrate repo"); 242 } catch { 243 console.error("Repo migration: Non-JSON error response:", repoText); 244 throw new Error(repoText || "Failed to migrate repo"); 245 } 246 } 247 248 // Step 2.2: Migrate Blobs 249 console.log("Data migration: Starting blob migration"); 250 const blobsRes = await fetch(`${this.baseUrl}/api/migrate/data/blobs`, { 251 method: "POST", 252 headers: { "Content-Type": "application/json" }, 253 }); 254 255 console.log("Blob migration: Response status:", blobsRes.status); 256 const blobsText = await blobsRes.text(); 257 console.log("Blob migration: Raw response:", blobsText); 258 259 if (!blobsRes.ok) { 260 try { 261 const json = JSON.parse(blobsText); 262 console.error("Blob migration: Error response:", json); 263 throw new Error(json.message || "Failed to migrate blobs"); 264 } catch { 265 console.error( 266 "Blob migration: Non-JSON error response:", 267 blobsText, 268 ); 269 throw new Error(blobsText || "Failed to migrate blobs"); 270 } 271 } 272 273 // Step 2.3: Migrate Preferences 274 console.log("Data migration: Starting preferences migration"); 275 const prefsRes = await fetch(`${this.baseUrl}/api/migrate/data/prefs`, { 276 method: "POST", 277 headers: { "Content-Type": "application/json" }, 278 }); 279 280 console.log("Preferences migration: Response status:", prefsRes.status); 281 const prefsText = await prefsRes.text(); 282 console.log("Preferences migration: Raw response:", prefsText); 283 284 if (!prefsRes.ok) { 285 try { 286 const json = JSON.parse(prefsText); 287 console.error("Preferences migration: Error response:", json); 288 throw new Error(json.message || "Failed to migrate preferences"); 289 } catch { 290 console.error( 291 "Preferences migration: Non-JSON error response:", 292 prefsText, 293 ); 294 throw new Error(prefsText || "Failed to migrate preferences"); 295 } 296 } 297 298 console.log("Data migration: Starting verification"); 299 this.updateStepStatus(1, "verifying"); 300 const verified = await this.verifyStep(1); 301 console.log("Data migration: Verification result:", verified); 302 if (!verified) { 303 console.log( 304 "Data migration: Verification failed, waiting for user action", 305 ); 306 return; 307 } 308 309 this.updateStepStatus(1, "completed"); 310 // Continue to next step 311 //await this.continueToNextStep(2); 312 } 313 314 async startIdentityMigration() { 315 // Step 3: Start Identity Migration (just trigger the email) 316 this.updateStepStatus(2, "in-progress"); 317 console.log("Starting identity migration..."); 318 319 try { 320 const identityRes = await fetch( 321 `${this.baseUrl}/api/migrate/identity/request`, 322 { 323 method: "POST", 324 headers: { "Content-Type": "application/json" }, 325 }, 326 ); 327 328 const identityData = await identityRes.text(); 329 330 if (!identityRes.ok) { 331 try { 332 const json = JSON.parse(identityData); 333 throw new Error( 334 json.message || "Failed to start identity migration", 335 ); 336 } catch { 337 throw new Error( 338 identityData || "Failed to start identity migration", 339 ); 340 } 341 } 342 343 // Update the step to show token input 344 this.updateStepStatus(2, "in-progress"); 345 } catch (error) { 346 this.updateStepStatus( 347 2, 348 "error", 349 error instanceof Error ? error.message : String(error), 350 ); 351 throw error; 352 } 353 } 354 355 async handleIdentityMigration(token: string) { 356 if (!token) { 357 throw new Error("Token is required"); 358 } 359 360 const identityRes = await fetch( 361 `${this.baseUrl}/api/migrate/identity/sign?token=${ 362 encodeURIComponent(token) 363 }`, 364 { 365 method: "POST", 366 headers: { "Content-Type": "application/json" }, 367 }, 368 ); 369 370 const identityData = await identityRes.text(); 371 if (!identityRes.ok) { 372 try { 373 const json = JSON.parse(identityData); 374 throw new Error( 375 json.message || "Failed to complete identity migration", 376 ); 377 } catch { 378 throw new Error( 379 identityData || "Failed to complete identity migration", 380 ); 381 } 382 } 383 384 let data; 385 try { 386 data = JSON.parse(identityData); 387 if (!data.success) { 388 throw new Error(data.message || "Identity migration failed"); 389 } 390 } catch { 391 throw new Error("Invalid response from server"); 392 } 393 394 // Verify the identity migration succeeded 395 this.updateStepStatus(2, "verifying"); 396 const verified = await this.verifyStep(2, true); 397 if (!verified) { 398 console.log( 399 "Identity migration: Verification failed after token submission", 400 ); 401 throw new Error("Identity migration verification failed"); 402 } 403 404 this.updateStepStatus(2, "completed"); 405 } 406 407 async finalizeMigration() { 408 // Step 4: Finalize Migration 409 this.updateStepStatus(3, "in-progress"); 410 const finalizeRes = await fetch(`${this.baseUrl}/api/migrate/finalize`, { 411 method: "POST", 412 headers: { "Content-Type": "application/json" }, 413 }); 414 415 const finalizeData = await finalizeRes.text(); 416 if (!finalizeRes.ok) { 417 try { 418 const json = JSON.parse(finalizeData); 419 throw new Error(json.message || "Failed to finalize migration"); 420 } catch { 421 throw new Error(finalizeData || "Failed to finalize migration"); 422 } 423 } 424 425 try { 426 const jsonData = JSON.parse(finalizeData); 427 if (!jsonData.success) { 428 throw new Error(jsonData.message || "Finalization failed"); 429 } 430 } catch { 431 throw new Error("Invalid response from server during finalization"); 432 } 433 434 this.updateStepStatus(3, "verifying"); 435 const verified = await this.verifyStep(3); 436 if (!verified) { 437 console.log( 438 "Finalization: Verification failed, waiting for user action", 439 ); 440 return; 441 } 442 443 this.updateStepStatus(3, "completed"); 444 } 445 446 async retryVerification(stepNum: number, steps: MigrationStep[]) { 447 console.log(`Retrying verification for step ${stepNum + 1}`); 448 const isManualSubmission = stepNum === 2 && 449 steps[2].name === 450 "Enter the token sent to your email to complete identity migration"; 451 const verified = await this.verifyStep(stepNum, isManualSubmission); 452 453 if (verified && stepNum < 3) { 454 await this.continueToNextStep(stepNum + 1); 455 } 456 } 457 458 async verifyStep( 459 stepNum: number, 460 isManualSubmission: boolean = false, 461 ) { 462 console.log(`Verification: Starting step ${stepNum + 1}`); 463 464 // Skip automatic verification for step 2 (identity migration) unless it's after manual token submission 465 if (stepNum === 2 && !isManualSubmission) { 466 console.log( 467 `Verification: Skipping automatic verification for identity migration step`, 468 ); 469 return false; 470 } 471 472 this.updateStepStatus(stepNum, "verifying"); 473 try { 474 console.log(`Verification: Fetching status for step ${stepNum + 1}`); 475 const res = await fetch( 476 `${this.baseUrl}/api/migrate/status?step=${stepNum + 1}`, 477 ); 478 console.log(`Verification: Status response status:`, res.status); 479 const data = await res.json(); 480 console.log(`Verification: Status data for step ${stepNum + 1}:`, data); 481 482 if (data.ready) { 483 console.log(`Verification: Step ${stepNum + 1} is ready`); 484 this.updateStepStatus(stepNum, "completed"); 485 486 // Reset retry state on success if callbacks are available 487 if (this.setRetryAttempts) { 488 this.setRetryAttempts((prev: any) => ({ ...prev, [stepNum]: 0 })); 489 } 490 if (this.setShowContinueAnyway) { 491 this.setShowContinueAnyway((prev: any) => ({ 492 ...prev, 493 [stepNum]: false, 494 })); 495 } 496 497 return true; 498 } else { 499 console.log( 500 `Verification: Step ${stepNum + 1} is not ready:`, 501 data.reason, 502 ); 503 const statusDetails = { 504 activated: data.activated, 505 validDid: data.validDid, 506 repoCommit: data.repoCommit, 507 repoRev: data.repoRev, 508 repoBlocks: data.repoBlocks, 509 expectedRecords: data.expectedRecords, 510 indexedRecords: data.indexedRecords, 511 privateStateValues: data.privateStateValues, 512 expectedBlobs: data.expectedBlobs, 513 importedBlobs: data.importedBlobs, 514 }; 515 console.log( 516 `Verification: Step ${stepNum + 1} status details:`, 517 statusDetails, 518 ); 519 const errorMessage = `${ 520 data.reason || "Verification failed" 521 }\nStatus details: ${JSON.stringify(statusDetails, null, 2)}`; 522 523 // Track retry attempts only if callbacks are available 524 if (this.setRetryAttempts && this.setShowContinueAnyway) { 525 this.setRetryAttempts((prev: any) => ({ 526 ...prev, 527 [stepNum]: (prev[stepNum] || 0) + 1, 528 })); 529 530 // Show continue anyway option if this is the second failure 531 this.setRetryAttempts((prev: any) => { 532 const currentAttempts = (prev[stepNum] || 0) + 1; 533 if (currentAttempts >= 2) { 534 this.setShowContinueAnyway!((prevShow: any) => ({ 535 ...prevShow, 536 [stepNum]: true, 537 })); 538 } 539 return { ...prev, [stepNum]: currentAttempts }; 540 }); 541 542 this.updateStepStatus(stepNum, "error", errorMessage, true); 543 } else { 544 // No retry callbacks - just fail 545 this.updateStepStatus(stepNum, "error", errorMessage, false); 546 } 547 548 return false; 549 } 550 } catch (e) { 551 console.error(`Verification: Error in step ${stepNum + 1}:`, e); 552 553 // Track retry attempts only if callbacks are available 554 if (this.setRetryAttempts && this.setShowContinueAnyway) { 555 this.setRetryAttempts((prev: any) => { 556 const currentAttempts = (prev[stepNum] || 0) + 1; 557 if (currentAttempts >= 2) { 558 this.setShowContinueAnyway!((prevShow: any) => ({ 559 ...prevShow, 560 [stepNum]: true, 561 })); 562 } 563 return { ...prev, [stepNum]: currentAttempts }; 564 }); 565 566 this.updateStepStatus( 567 stepNum, 568 "error", 569 e instanceof Error ? e.message : String(e), 570 true, 571 ); 572 } else { 573 // No retry callbacks - just fail 574 this.updateStepStatus( 575 stepNum, 576 "error", 577 e instanceof Error ? e.message : String(e), 578 false, 579 ); 580 } 581 582 return false; 583 } 584 } 585 586 async continueToNextStep(stepNum: number) { 587 console.log(`Continuing to step ${stepNum + 1}`); 588 try { 589 switch (stepNum) { 590 case 1: 591 await this.startDataMigration(); 592 break; 593 case 2: 594 await this.startIdentityMigration(); 595 break; 596 case 3: 597 await this.finalizeMigration(); 598 break; 599 } 600 601 this.nextStepHook?.(stepNum); 602 } catch (error) { 603 console.error(`Error in step ${stepNum + 1}:`, error); 604 this.updateStepStatus( 605 stepNum, 606 "error", 607 error instanceof Error ? error.message : String(error), 608 ); 609 } 610 } 611}