this repo has no description
at main 22 kB view raw
1import type { 2 AuthMethod, 3 MigrationProgress, 4 OfflineInboundMigrationState, 5 OfflineInboundStep, 6 ServerDescription, 7} from "./types.ts"; 8import { 9 AtprotoClient, 10 base64UrlEncode, 11 createLocalClient, 12 prepareWebAuthnCreationOptions, 13} from "./atproto-client.ts"; 14import { api } from "../api.ts"; 15import { type KeypairInfo, plcOps, type PrivateKey } from "./plc-ops.ts"; 16import { migrateBlobs as migrateBlobsUtil } from "./blob-migration.ts"; 17import { Secp256k1PrivateKeyExportable } from "@atcute/crypto"; 18import { 19 unsafeAsAccessToken, 20 unsafeAsDid, 21 unsafeAsEmail, 22 unsafeAsHandle, 23} from "../types/branded.ts"; 24 25const OFFLINE_STORAGE_KEY = "tranquil_offline_migration_state"; 26const MAX_AGE_MS = 24 * 60 * 60 * 1000; 27 28interface StoredOfflineMigrationState { 29 version: number; 30 step: OfflineInboundStep; 31 startedAt: string; 32 userDid: string; 33 carFileName: string; 34 carSizeBytes: number; 35 rotationKeyDidKey: string; 36 targetHandle: string; 37 targetEmail: string; 38 authMethod: AuthMethod; 39 passkeySetupToken?: string; 40 oldPdsUrl?: string; 41 plcUpdatedTemporarily?: boolean; 42 progress: { 43 accountCreated: boolean; 44 repoImported: boolean; 45 plcSigned: boolean; 46 activated: boolean; 47 }; 48 lastError?: string; 49} 50 51function saveOfflineState(state: OfflineInboundMigrationState): void { 52 const stored: StoredOfflineMigrationState = { 53 version: 1, 54 step: state.step, 55 startedAt: new Date().toISOString(), 56 userDid: state.userDid, 57 carFileName: state.carFileName, 58 carSizeBytes: state.carSizeBytes, 59 rotationKeyDidKey: state.rotationKeyDidKey, 60 targetHandle: state.targetHandle, 61 targetEmail: state.targetEmail, 62 authMethod: state.authMethod, 63 passkeySetupToken: state.passkeySetupToken ?? undefined, 64 oldPdsUrl: state.oldPdsUrl ?? undefined, 65 plcUpdatedTemporarily: state.plcUpdatedTemporarily || undefined, 66 progress: { 67 accountCreated: state.progress.repoExported, 68 repoImported: state.progress.repoImported, 69 plcSigned: state.progress.plcSigned, 70 activated: state.progress.activated, 71 }, 72 lastError: state.error ?? undefined, 73 }; 74 try { 75 localStorage.setItem(OFFLINE_STORAGE_KEY, JSON.stringify(stored)); 76 } catch { /* ignore localStorage errors */ } 77} 78 79function loadOfflineState(): StoredOfflineMigrationState | null { 80 try { 81 const stored = localStorage.getItem(OFFLINE_STORAGE_KEY); 82 if (!stored) return null; 83 const state = JSON.parse(stored) as StoredOfflineMigrationState; 84 if (state.version !== 1) { 85 clearOfflineState(); 86 return null; 87 } 88 const startedAt = new Date(state.startedAt).getTime(); 89 if (Date.now() - startedAt > MAX_AGE_MS) { 90 clearOfflineState(); 91 return null; 92 } 93 return state; 94 } catch { 95 /* ignore parse errors */ 96 clearOfflineState(); 97 return null; 98 } 99} 100 101function clearOfflineState(): void { 102 try { 103 localStorage.removeItem(OFFLINE_STORAGE_KEY); 104 } catch { /* ignore localStorage errors */ } 105} 106 107export function hasPendingOfflineMigration(): boolean { 108 return loadOfflineState() !== null; 109} 110 111export function getOfflineResumeInfo(): { 112 step: OfflineInboundStep; 113 userDid: string; 114 targetHandle: string; 115} | null { 116 const state = loadOfflineState(); 117 if (!state) return null; 118 return { 119 step: state.step, 120 userDid: state.userDid, 121 targetHandle: state.targetHandle, 122 }; 123} 124 125export { clearOfflineState }; 126 127function createInitialProgress(): MigrationProgress { 128 return { 129 repoExported: false, 130 repoImported: false, 131 blobsTotal: 0, 132 blobsMigrated: 0, 133 blobsFailed: [], 134 prefsMigrated: false, 135 plcSigned: false, 136 activated: false, 137 deactivated: false, 138 currentOperation: "", 139 }; 140} 141 142export type OfflineInboundMigrationFlow = ReturnType< 143 typeof createOfflineInboundMigrationFlow 144>; 145 146export function createOfflineInboundMigrationFlow() { 147 let state = $state<OfflineInboundMigrationState>({ 148 direction: "offline-inbound", 149 step: "welcome", 150 userDid: "", 151 carFile: null, 152 carFileName: "", 153 carSizeBytes: 0, 154 carNeedsReupload: false, 155 rotationKey: "", 156 rotationKeyDidKey: "", 157 oldPdsUrl: null, 158 targetHandle: "", 159 targetEmail: "", 160 targetPassword: "", 161 inviteCode: "", 162 authMethod: "password", 163 localAccessToken: null, 164 localRefreshToken: null, 165 passkeySetupToken: null, 166 generatedAppPassword: null, 167 generatedAppPasswordName: null, 168 emailVerifyToken: "", 169 progress: createInitialProgress(), 170 error: null, 171 plcUpdatedTemporarily: false, 172 }); 173 174 let localServerInfo: ServerDescription | null = null; 175 let userRotationKeypair: KeypairInfo | null = null; 176 let tempVerificationKeypair: Secp256k1PrivateKeyExportable | null = null; 177 178 function setStep(step: OfflineInboundStep) { 179 state.step = step; 180 state.error = null; 181 if (step !== "success") { 182 saveOfflineState(state); 183 } 184 } 185 186 function setError(error: string | null) { 187 state.error = error; 188 saveOfflineState(state); 189 } 190 191 function setProgress(updates: Partial<MigrationProgress>) { 192 state.progress = { ...state.progress, ...updates }; 193 saveOfflineState(state); 194 } 195 196 async function loadLocalServerInfo(): Promise<ServerDescription> { 197 if (!localServerInfo) { 198 const client = createLocalClient(); 199 localServerInfo = await client.describeServer(); 200 } 201 return localServerInfo; 202 } 203 204 async function checkHandleAvailability(handle: string): Promise<boolean> { 205 const client = createLocalClient(); 206 try { 207 await client.resolveHandle(handle); 208 return false; 209 } catch { 210 return true; 211 } 212 } 213 214 async function validateRotationKey(): Promise<boolean> { 215 if (!state.userDid || !state.rotationKey) { 216 throw new Error("DID and rotation key are required"); 217 } 218 219 try { 220 userRotationKeypair = await plcOps.getKeyPair(state.rotationKey.trim()); 221 const { lastOperation } = await plcOps.getLastPlcOpFromPlc(state.userDid); 222 const currentRotationKeys = lastOperation.rotationKeys || []; 223 224 if (!currentRotationKeys.includes(userRotationKeypair.didPublicKey)) { 225 state.rotationKeyDidKey = ""; 226 return false; 227 } 228 229 state.rotationKeyDidKey = userRotationKeypair.didPublicKey; 230 231 const pdsService = lastOperation.services?.atproto_pds; 232 if (pdsService?.endpoint) { 233 state.oldPdsUrl = pdsService.endpoint; 234 console.log( 235 "[offline-migration] Captured old PDS URL:", 236 state.oldPdsUrl, 237 ); 238 } else { 239 console.warn( 240 "[offline-migration] No PDS service endpoint found in PLC document", 241 ); 242 console.log( 243 "[offline-migration] PLC services:", 244 JSON.stringify(lastOperation.services), 245 ); 246 } 247 248 saveOfflineState(state); 249 return true; 250 } catch (e) { 251 throw new Error(`Failed to parse rotation key: ${(e as Error).message}`); 252 } 253 } 254 255 async function prepareTempCredentials(): Promise<string> { 256 if (!userRotationKeypair) { 257 throw new Error("Rotation key not validated"); 258 } 259 260 setProgress({ currentOperation: "Preparing temporary credentials..." }); 261 262 tempVerificationKeypair = await Secp256k1PrivateKeyExportable 263 .createKeypair(); 264 const tempVerificationPublicKey = await tempVerificationKeypair 265 .exportPublicKey("did"); 266 267 const { lastOperation, base } = await plcOps.getLastPlcOpFromPlc( 268 state.userDid, 269 ); 270 const prevCid = base.cid; 271 272 setProgress({ currentOperation: "Updating DID document temporarily..." }); 273 274 const localPdsUrl = globalThis.location.origin; 275 await plcOps.signAndPublishNewOp( 276 state.userDid, 277 userRotationKeypair.keypair, 278 lastOperation.alsoKnownAs || [], 279 [userRotationKeypair.didPublicKey], 280 localPdsUrl, 281 tempVerificationPublicKey, 282 prevCid, 283 ); 284 285 state.plcUpdatedTemporarily = true; 286 saveOfflineState(state); 287 288 const serverInfo = await loadLocalServerInfo(); 289 const serviceAuthToken = await plcOps.createServiceAuthToken( 290 state.userDid, 291 serverInfo.did, 292 tempVerificationKeypair as unknown as PrivateKey, 293 "com.atproto.server.createAccount", 294 ); 295 296 return serviceAuthToken; 297 } 298 299 async function createPasswordAccount( 300 serviceAuthToken: string, 301 ): Promise<void> { 302 setProgress({ currentOperation: "Creating account on new PDS..." }); 303 304 const serverInfo = await loadLocalServerInfo(); 305 const fullHandle = state.targetHandle.includes(".") 306 ? state.targetHandle 307 : `${state.targetHandle}.${serverInfo.availableUserDomains[0]}`; 308 309 const createResult = await api.createAccountWithServiceAuth( 310 serviceAuthToken, 311 { 312 did: unsafeAsDid(state.userDid), 313 handle: unsafeAsHandle(fullHandle), 314 email: unsafeAsEmail(state.targetEmail), 315 password: state.targetPassword, 316 inviteCode: state.inviteCode || undefined, 317 }, 318 ); 319 320 state.targetHandle = fullHandle; 321 state.localAccessToken = createResult.accessJwt; 322 state.localRefreshToken = createResult.refreshJwt; 323 setProgress({ repoExported: true }); 324 } 325 326 async function createPasskeyAccount(serviceAuthToken: string): Promise<void> { 327 setProgress({ currentOperation: "Creating passkey account on new PDS..." }); 328 329 const serverInfo = await loadLocalServerInfo(); 330 const fullHandle = state.targetHandle.includes(".") 331 ? state.targetHandle 332 : `${state.targetHandle}.${serverInfo.availableUserDomains[0]}`; 333 334 const createResult = await api.createPasskeyAccount({ 335 did: unsafeAsDid(state.userDid), 336 handle: unsafeAsHandle(fullHandle), 337 email: unsafeAsEmail(state.targetEmail), 338 inviteCode: state.inviteCode || undefined, 339 }, serviceAuthToken); 340 341 state.targetHandle = fullHandle; 342 state.passkeySetupToken = createResult.setupToken; 343 setProgress({ repoExported: true }); 344 saveOfflineState(state); 345 } 346 347 async function signFinalPlcOperation(): Promise<void> { 348 if (!userRotationKeypair || !state.localAccessToken) { 349 throw new Error("Prerequisites not met for PLC signing"); 350 } 351 352 setProgress({ currentOperation: "Finalizing DID document..." }); 353 354 const { base } = await plcOps.getLastPlcOpFromPlc(state.userDid); 355 const prevCid = base.cid; 356 357 const credentials = await api.getRecommendedDidCredentials( 358 unsafeAsAccessToken(state.localAccessToken), 359 ); 360 361 await plcOps.signPlcOperationWithCredentials( 362 state.userDid, 363 userRotationKeypair.keypair, 364 { 365 rotationKeys: credentials.rotationKeys, 366 alsoKnownAs: credentials.alsoKnownAs, 367 verificationMethods: credentials.verificationMethods, 368 services: credentials.services, 369 }, 370 [userRotationKeypair.didPublicKey], 371 prevCid, 372 ); 373 374 setProgress({ plcSigned: true }); 375 } 376 377 async function importRepository(): Promise<void> { 378 if (!state.carFile || !state.localAccessToken) { 379 throw new Error("CAR file and access token are required"); 380 } 381 382 setProgress({ currentOperation: "Importing repository..." }); 383 await api.importRepo( 384 unsafeAsAccessToken(state.localAccessToken), 385 state.carFile, 386 ); 387 setProgress({ repoImported: true }); 388 } 389 390 async function migrateBlobs(): Promise<void> { 391 if (!state.localAccessToken) { 392 throw new Error("Access token required"); 393 } 394 395 const localClient = createLocalClient(); 396 localClient.setAccessToken(unsafeAsAccessToken(state.localAccessToken)); 397 398 if (state.oldPdsUrl) { 399 setProgress({ 400 currentOperation: `Will fetch blobs from ${state.oldPdsUrl}`, 401 }); 402 } else { 403 setProgress({ 404 currentOperation: "No source PDS URL available for blob migration", 405 }); 406 } 407 408 const sourceClient = state.oldPdsUrl 409 ? new AtprotoClient(state.oldPdsUrl) 410 : null; 411 412 const result = await migrateBlobsUtil( 413 localClient, 414 sourceClient, 415 state.userDid, 416 setProgress, 417 ); 418 419 state.progress.blobsFailed = result.failed; 420 state.progress.blobsTotal = result.total; 421 state.progress.blobsMigrated = result.migrated; 422 423 if (result.total === 0) { 424 setProgress({ currentOperation: "No blobs to migrate" }); 425 } else if (result.sourceUnreachable) { 426 setProgress({ 427 currentOperation: 428 `Source PDS unreachable. ${result.failed.length} blobs could not be migrated.`, 429 }); 430 } else if (result.failed.length > 0) { 431 setProgress({ 432 currentOperation: 433 `${result.migrated}/${result.total} blobs migrated. ${result.failed.length} failed.`, 434 }); 435 } else { 436 setProgress({ 437 currentOperation: `All ${result.migrated} blobs migrated successfully`, 438 }); 439 } 440 } 441 442 async function activateAccount(): Promise<void> { 443 if (!state.localAccessToken) { 444 throw new Error("Access token required"); 445 } 446 447 setProgress({ currentOperation: "Activating account..." }); 448 await api.activateAccount(unsafeAsAccessToken(state.localAccessToken)); 449 setProgress({ activated: true }); 450 } 451 452 async function submitEmailVerifyToken(token: string): Promise<void> { 453 state.emailVerifyToken = token; 454 setError(null); 455 456 try { 457 await api.verifyMigrationEmail(token, unsafeAsEmail(state.targetEmail)); 458 459 if (state.authMethod === "passkey") { 460 setStep("passkey-setup"); 461 } else { 462 const session = await api.createSession( 463 state.targetEmail, 464 state.targetPassword, 465 ); 466 state.localAccessToken = session.accessJwt; 467 state.localRefreshToken = session.refreshJwt; 468 saveOfflineState(state); 469 470 setStep("plc-signing"); 471 await signFinalPlcOperation(); 472 473 setStep("finalizing"); 474 await activateAccount(); 475 476 cleanup(); 477 setStep("success"); 478 } 479 } catch (e) { 480 const err = e as Error & { error?: string }; 481 setError(err.message || err.error || "Email verification failed"); 482 } 483 } 484 485 async function resendEmailVerification(): Promise<void> { 486 await api.resendMigrationVerification(unsafeAsEmail(state.targetEmail)); 487 } 488 489 let checkingEmailVerification = false; 490 491 async function checkEmailVerifiedAndProceed(): Promise<boolean> { 492 if (checkingEmailVerification) return false; 493 if (state.authMethod === "passkey") return false; 494 495 checkingEmailVerification = true; 496 try { 497 const { verified } = await api.checkEmailVerified(state.targetEmail); 498 if (!verified) return false; 499 500 const session = await api.createSession( 501 state.targetEmail, 502 state.targetPassword, 503 ); 504 state.localAccessToken = session.accessJwt; 505 state.localRefreshToken = session.refreshJwt; 506 saveOfflineState(state); 507 508 setStep("plc-signing"); 509 await signFinalPlcOperation(); 510 511 setStep("finalizing"); 512 await activateAccount(); 513 514 cleanup(); 515 setStep("success"); 516 return true; 517 } catch { 518 return false; 519 } finally { 520 checkingEmailVerification = false; 521 } 522 } 523 524 async function startPasskeyRegistration(): Promise<{ options: unknown }> { 525 if (!state.passkeySetupToken) { 526 throw new Error("No passkey setup token"); 527 } 528 529 return api.startPasskeyRegistrationForSetup( 530 unsafeAsDid(state.userDid), 531 state.passkeySetupToken, 532 ); 533 } 534 535 async function registerPasskey(passkeyName?: string): Promise<void> { 536 if (!state.passkeySetupToken) { 537 throw new Error("No passkey setup token"); 538 } 539 540 if (!globalThis.PublicKeyCredential) { 541 throw new Error("Passkeys are not supported in this browser"); 542 } 543 544 const { options } = await startPasskeyRegistration(); 545 546 const publicKeyOptions = prepareWebAuthnCreationOptions( 547 options as { publicKey: Record<string, unknown> }, 548 ); 549 const credential = await navigator.credentials.create({ 550 publicKey: publicKeyOptions, 551 }); 552 553 if (!credential) { 554 throw new Error("Passkey creation was cancelled"); 555 } 556 557 const publicKeyCredential = credential as PublicKeyCredential; 558 const response = publicKeyCredential 559 .response as AuthenticatorAttestationResponse; 560 561 const credentialData = { 562 id: publicKeyCredential.id, 563 rawId: base64UrlEncode(publicKeyCredential.rawId), 564 type: publicKeyCredential.type, 565 response: { 566 clientDataJSON: base64UrlEncode(response.clientDataJSON), 567 attestationObject: base64UrlEncode(response.attestationObject), 568 }, 569 }; 570 571 const result = await api.completePasskeySetup( 572 unsafeAsDid(state.userDid), 573 state.passkeySetupToken, 574 credentialData, 575 passkeyName, 576 ); 577 578 state.generatedAppPassword = result.appPassword; 579 state.generatedAppPasswordName = result.appPasswordName; 580 581 const session = await api.createSession( 582 state.targetEmail, 583 result.appPassword, 584 ); 585 state.localAccessToken = session.accessJwt; 586 state.localRefreshToken = session.refreshJwt; 587 saveOfflineState(state); 588 589 setStep("app-password"); 590 } 591 592 async function proceedFromAppPassword(): Promise<void> { 593 setStep("plc-signing"); 594 await signFinalPlcOperation(); 595 596 setStep("finalizing"); 597 await activateAccount(); 598 599 cleanup(); 600 setStep("success"); 601 } 602 603 function cleanup(): void { 604 clearOfflineState(); 605 userRotationKeypair = null; 606 tempVerificationKeypair = null; 607 state.rotationKey = ""; 608 } 609 610 async function runMigration(): Promise<void> { 611 try { 612 setStep("creating"); 613 614 const serviceAuthToken = await prepareTempCredentials(); 615 616 if (state.authMethod === "passkey") { 617 await createPasskeyAccount(serviceAuthToken); 618 } else { 619 await createPasswordAccount(serviceAuthToken); 620 } 621 622 setStep("importing"); 623 await importRepository(); 624 625 setStep("migrating-blobs"); 626 await migrateBlobs(); 627 628 if ( 629 state.progress.blobsTotal > 0 || state.progress.blobsFailed.length > 0 630 ) { 631 await new Promise((resolve) => setTimeout(resolve, 3000)); 632 } 633 634 setStep("email-verify"); 635 } catch (e) { 636 setError((e as Error).message); 637 setStep("error"); 638 } 639 } 640 641 function reset() { 642 clearOfflineState(); 643 userRotationKeypair = null; 644 tempVerificationKeypair = null; 645 state = { 646 direction: "offline-inbound", 647 step: "welcome", 648 userDid: "", 649 carFile: null, 650 carFileName: "", 651 carSizeBytes: 0, 652 carNeedsReupload: false, 653 rotationKey: "", 654 rotationKeyDidKey: "", 655 oldPdsUrl: null, 656 targetHandle: "", 657 targetEmail: "", 658 targetPassword: "", 659 inviteCode: "", 660 authMethod: "password", 661 localAccessToken: null, 662 localRefreshToken: null, 663 passkeySetupToken: null, 664 generatedAppPassword: null, 665 generatedAppPasswordName: null, 666 emailVerifyToken: "", 667 progress: createInitialProgress(), 668 error: null, 669 plcUpdatedTemporarily: false, 670 }; 671 localServerInfo = null; 672 } 673 674 function tryResume(): boolean { 675 const stored = loadOfflineState(); 676 if (!stored) return false; 677 678 state.userDid = stored.userDid; 679 state.carFileName = stored.carFileName; 680 state.carSizeBytes = stored.carSizeBytes; 681 state.rotationKeyDidKey = stored.rotationKeyDidKey; 682 state.targetHandle = stored.targetHandle; 683 state.targetEmail = stored.targetEmail; 684 state.authMethod = stored.authMethod ?? "password"; 685 state.passkeySetupToken = stored.passkeySetupToken ?? null; 686 state.oldPdsUrl = stored.oldPdsUrl ?? null; 687 state.plcUpdatedTemporarily = stored.plcUpdatedTemporarily ?? false; 688 state.step = stored.step; 689 state.progress.repoExported = stored.progress.accountCreated; 690 state.progress.repoImported = stored.progress.repoImported; 691 state.progress.plcSigned = stored.progress.plcSigned; 692 state.progress.activated = stored.progress.activated; 693 state.error = stored.lastError ?? null; 694 695 if (stored.carFileName && stored.carSizeBytes > 0) { 696 state.carNeedsReupload = true; 697 } 698 699 return true; 700 } 701 702 function getLocalSession(): 703 | { accessJwt: string; did: string; handle: string } 704 | null { 705 if (!state.localAccessToken) return null; 706 return { 707 accessJwt: state.localAccessToken, 708 did: state.userDid, 709 handle: state.targetHandle, 710 }; 711 } 712 713 return { 714 get state() { 715 return state; 716 }, 717 getLocalSession, 718 setStep, 719 setError, 720 setProgress, 721 loadLocalServerInfo, 722 checkHandleAvailability, 723 validateRotationKey, 724 runMigration, 725 submitEmailVerifyToken, 726 resendEmailVerification, 727 checkEmailVerifiedAndProceed, 728 startPasskeyRegistration, 729 registerPasskey, 730 proceedFromAppPassword, 731 reset, 732 tryResume, 733 clearOfflineState, 734 setUserDid(did: string) { 735 state.userDid = did; 736 saveOfflineState(state); 737 }, 738 setCarFile(file: Uint8Array, fileName: string) { 739 state.carFile = file; 740 state.carFileName = fileName; 741 state.carSizeBytes = file.length; 742 state.carNeedsReupload = false; 743 saveOfflineState(state); 744 }, 745 setRotationKey(key: string) { 746 state.rotationKey = key; 747 }, 748 setTargetHandle(handle: string) { 749 state.targetHandle = handle; 750 saveOfflineState(state); 751 }, 752 setTargetEmail(email: string) { 753 state.targetEmail = email; 754 saveOfflineState(state); 755 }, 756 setTargetPassword(password: string) { 757 state.targetPassword = password; 758 }, 759 setInviteCode(code: string) { 760 state.inviteCode = code; 761 }, 762 setAuthMethod(method: AuthMethod) { 763 state.authMethod = method; 764 saveOfflineState(state); 765 }, 766 updateField<K extends keyof OfflineInboundMigrationState>( 767 field: K, 768 value: OfflineInboundMigrationState[K], 769 ) { 770 state[field] = value; 771 saveOfflineState(state); 772 }, 773 }; 774}