import type { AuthMethod, MigrationProgress, OfflineInboundMigrationState, OfflineInboundStep, ServerDescription, } from "./types.ts"; import { AtprotoClient, base64UrlEncode, createLocalClient, prepareWebAuthnCreationOptions, } from "./atproto-client.ts"; import { api } from "../api.ts"; import { type KeypairInfo, plcOps, type PrivateKey } from "./plc-ops.ts"; import { migrateBlobs as migrateBlobsUtil } from "./blob-migration.ts"; import { Secp256k1PrivateKeyExportable } from "@atcute/crypto"; import { unsafeAsAccessToken, unsafeAsDid, unsafeAsEmail, unsafeAsHandle, } from "../types/branded.ts"; const OFFLINE_STORAGE_KEY = "tranquil_offline_migration_state"; const MAX_AGE_MS = 24 * 60 * 60 * 1000; interface StoredOfflineMigrationState { version: number; step: OfflineInboundStep; startedAt: string; userDid: string; carFileName: string; carSizeBytes: number; rotationKeyDidKey: string; targetHandle: string; targetEmail: string; authMethod: AuthMethod; passkeySetupToken?: string; oldPdsUrl?: string; plcUpdatedTemporarily?: boolean; progress: { accountCreated: boolean; repoImported: boolean; plcSigned: boolean; activated: boolean; }; lastError?: string; } function saveOfflineState(state: OfflineInboundMigrationState): void { const stored: StoredOfflineMigrationState = { version: 1, step: state.step, startedAt: new Date().toISOString(), userDid: state.userDid, carFileName: state.carFileName, carSizeBytes: state.carSizeBytes, rotationKeyDidKey: state.rotationKeyDidKey, targetHandle: state.targetHandle, targetEmail: state.targetEmail, authMethod: state.authMethod, passkeySetupToken: state.passkeySetupToken ?? undefined, oldPdsUrl: state.oldPdsUrl ?? undefined, plcUpdatedTemporarily: state.plcUpdatedTemporarily || undefined, progress: { accountCreated: state.progress.repoExported, repoImported: state.progress.repoImported, plcSigned: state.progress.plcSigned, activated: state.progress.activated, }, lastError: state.error ?? undefined, }; try { localStorage.setItem(OFFLINE_STORAGE_KEY, JSON.stringify(stored)); } catch { /* ignore localStorage errors */ } } function loadOfflineState(): StoredOfflineMigrationState | null { try { const stored = localStorage.getItem(OFFLINE_STORAGE_KEY); if (!stored) return null; const state = JSON.parse(stored) as StoredOfflineMigrationState; if (state.version !== 1) { clearOfflineState(); return null; } const startedAt = new Date(state.startedAt).getTime(); if (Date.now() - startedAt > MAX_AGE_MS) { clearOfflineState(); return null; } return state; } catch { /* ignore parse errors */ clearOfflineState(); return null; } } function clearOfflineState(): void { try { localStorage.removeItem(OFFLINE_STORAGE_KEY); } catch { /* ignore localStorage errors */ } } export function hasPendingOfflineMigration(): boolean { return loadOfflineState() !== null; } export function getOfflineResumeInfo(): { step: OfflineInboundStep; userDid: string; targetHandle: string; } | null { const state = loadOfflineState(); if (!state) return null; return { step: state.step, userDid: state.userDid, targetHandle: state.targetHandle, }; } export { clearOfflineState }; function createInitialProgress(): MigrationProgress { return { repoExported: false, repoImported: false, blobsTotal: 0, blobsMigrated: 0, blobsFailed: [], prefsMigrated: false, plcSigned: false, activated: false, deactivated: false, currentOperation: "", }; } export type OfflineInboundMigrationFlow = ReturnType< typeof createOfflineInboundMigrationFlow >; export function createOfflineInboundMigrationFlow() { let state = $state({ direction: "offline-inbound", step: "welcome", userDid: "", carFile: null, carFileName: "", carSizeBytes: 0, carNeedsReupload: false, rotationKey: "", rotationKeyDidKey: "", oldPdsUrl: null, targetHandle: "", targetEmail: "", targetPassword: "", inviteCode: "", authMethod: "password", localAccessToken: null, localRefreshToken: null, passkeySetupToken: null, generatedAppPassword: null, generatedAppPasswordName: null, emailVerifyToken: "", progress: createInitialProgress(), error: null, plcUpdatedTemporarily: false, }); let localServerInfo: ServerDescription | null = null; let userRotationKeypair: KeypairInfo | null = null; let tempVerificationKeypair: Secp256k1PrivateKeyExportable | null = null; function setStep(step: OfflineInboundStep) { state.step = step; state.error = null; if (step !== "success") { saveOfflineState(state); } } function setError(error: string | null) { state.error = error; saveOfflineState(state); } function setProgress(updates: Partial) { state.progress = { ...state.progress, ...updates }; saveOfflineState(state); } async function loadLocalServerInfo(): Promise { if (!localServerInfo) { const client = createLocalClient(); localServerInfo = await client.describeServer(); } return localServerInfo; } async function checkHandleAvailability(handle: string): Promise { const client = createLocalClient(); try { await client.resolveHandle(handle); return false; } catch { return true; } } async function validateRotationKey(): Promise { if (!state.userDid || !state.rotationKey) { throw new Error("DID and rotation key are required"); } try { userRotationKeypair = await plcOps.getKeyPair(state.rotationKey.trim()); const { lastOperation } = await plcOps.getLastPlcOpFromPlc(state.userDid); const currentRotationKeys = lastOperation.rotationKeys || []; if (!currentRotationKeys.includes(userRotationKeypair.didPublicKey)) { state.rotationKeyDidKey = ""; return false; } state.rotationKeyDidKey = userRotationKeypair.didPublicKey; const pdsService = lastOperation.services?.atproto_pds; if (pdsService?.endpoint) { state.oldPdsUrl = pdsService.endpoint; console.log( "[offline-migration] Captured old PDS URL:", state.oldPdsUrl, ); } else { console.warn( "[offline-migration] No PDS service endpoint found in PLC document", ); console.log( "[offline-migration] PLC services:", JSON.stringify(lastOperation.services), ); } saveOfflineState(state); return true; } catch (e) { throw new Error(`Failed to parse rotation key: ${(e as Error).message}`); } } async function prepareTempCredentials(): Promise { if (!userRotationKeypair) { throw new Error("Rotation key not validated"); } setProgress({ currentOperation: "Preparing temporary credentials..." }); tempVerificationKeypair = await Secp256k1PrivateKeyExportable .createKeypair(); const tempVerificationPublicKey = await tempVerificationKeypair .exportPublicKey("did"); const { lastOperation, base } = await plcOps.getLastPlcOpFromPlc( state.userDid, ); const prevCid = base.cid; setProgress({ currentOperation: "Updating DID document temporarily..." }); const localPdsUrl = globalThis.location.origin; await plcOps.signAndPublishNewOp( state.userDid, userRotationKeypair.keypair, lastOperation.alsoKnownAs || [], [userRotationKeypair.didPublicKey], localPdsUrl, tempVerificationPublicKey, prevCid, ); state.plcUpdatedTemporarily = true; saveOfflineState(state); const serverInfo = await loadLocalServerInfo(); const serviceAuthToken = await plcOps.createServiceAuthToken( state.userDid, serverInfo.did, tempVerificationKeypair as unknown as PrivateKey, "com.atproto.server.createAccount", ); return serviceAuthToken; } async function createPasswordAccount( serviceAuthToken: string, ): Promise { setProgress({ currentOperation: "Creating account on new PDS..." }); const serverInfo = await loadLocalServerInfo(); const fullHandle = state.targetHandle.includes(".") ? state.targetHandle : `${state.targetHandle}.${serverInfo.availableUserDomains[0]}`; const createResult = await api.createAccountWithServiceAuth( serviceAuthToken, { did: unsafeAsDid(state.userDid), handle: unsafeAsHandle(fullHandle), email: unsafeAsEmail(state.targetEmail), password: state.targetPassword, inviteCode: state.inviteCode || undefined, }, ); state.targetHandle = fullHandle; state.localAccessToken = createResult.accessJwt; state.localRefreshToken = createResult.refreshJwt; setProgress({ repoExported: true }); } async function createPasskeyAccount(serviceAuthToken: string): Promise { setProgress({ currentOperation: "Creating passkey account on new PDS..." }); const serverInfo = await loadLocalServerInfo(); const fullHandle = state.targetHandle.includes(".") ? state.targetHandle : `${state.targetHandle}.${serverInfo.availableUserDomains[0]}`; const createResult = await api.createPasskeyAccount({ did: unsafeAsDid(state.userDid), handle: unsafeAsHandle(fullHandle), email: unsafeAsEmail(state.targetEmail), inviteCode: state.inviteCode || undefined, }, serviceAuthToken); state.targetHandle = fullHandle; state.passkeySetupToken = createResult.setupToken; setProgress({ repoExported: true }); saveOfflineState(state); } async function signFinalPlcOperation(): Promise { if (!userRotationKeypair || !state.localAccessToken) { throw new Error("Prerequisites not met for PLC signing"); } setProgress({ currentOperation: "Finalizing DID document..." }); const { base } = await plcOps.getLastPlcOpFromPlc(state.userDid); const prevCid = base.cid; const credentials = await api.getRecommendedDidCredentials( unsafeAsAccessToken(state.localAccessToken), ); await plcOps.signPlcOperationWithCredentials( state.userDid, userRotationKeypair.keypair, { rotationKeys: credentials.rotationKeys, alsoKnownAs: credentials.alsoKnownAs, verificationMethods: credentials.verificationMethods, services: credentials.services, }, [userRotationKeypair.didPublicKey], prevCid, ); setProgress({ plcSigned: true }); } async function importRepository(): Promise { if (!state.carFile || !state.localAccessToken) { throw new Error("CAR file and access token are required"); } setProgress({ currentOperation: "Importing repository..." }); await api.importRepo( unsafeAsAccessToken(state.localAccessToken), state.carFile, ); setProgress({ repoImported: true }); } async function migrateBlobs(): Promise { if (!state.localAccessToken) { throw new Error("Access token required"); } const localClient = createLocalClient(); localClient.setAccessToken(unsafeAsAccessToken(state.localAccessToken)); if (state.oldPdsUrl) { setProgress({ currentOperation: `Will fetch blobs from ${state.oldPdsUrl}`, }); } else { setProgress({ currentOperation: "No source PDS URL available for blob migration", }); } const sourceClient = state.oldPdsUrl ? new AtprotoClient(state.oldPdsUrl) : null; const result = await migrateBlobsUtil( localClient, sourceClient, state.userDid, setProgress, ); state.progress.blobsFailed = result.failed; state.progress.blobsTotal = result.total; state.progress.blobsMigrated = result.migrated; if (result.total === 0) { setProgress({ currentOperation: "No blobs to migrate" }); } else if (result.sourceUnreachable) { setProgress({ currentOperation: `Source PDS unreachable. ${result.failed.length} blobs could not be migrated.`, }); } else if (result.failed.length > 0) { setProgress({ currentOperation: `${result.migrated}/${result.total} blobs migrated. ${result.failed.length} failed.`, }); } else { setProgress({ currentOperation: `All ${result.migrated} blobs migrated successfully`, }); } } async function activateAccount(): Promise { if (!state.localAccessToken) { throw new Error("Access token required"); } setProgress({ currentOperation: "Activating account..." }); await api.activateAccount(unsafeAsAccessToken(state.localAccessToken)); setProgress({ activated: true }); } async function submitEmailVerifyToken(token: string): Promise { state.emailVerifyToken = token; setError(null); try { await api.verifyMigrationEmail(token, unsafeAsEmail(state.targetEmail)); if (state.authMethod === "passkey") { setStep("passkey-setup"); } else { const session = await api.createSession( state.targetEmail, state.targetPassword, ); state.localAccessToken = session.accessJwt; state.localRefreshToken = session.refreshJwt; saveOfflineState(state); setStep("plc-signing"); await signFinalPlcOperation(); setStep("finalizing"); await activateAccount(); cleanup(); setStep("success"); } } catch (e) { const err = e as Error & { error?: string }; setError(err.message || err.error || "Email verification failed"); } } async function resendEmailVerification(): Promise { await api.resendMigrationVerification(unsafeAsEmail(state.targetEmail)); } let checkingEmailVerification = false; async function checkEmailVerifiedAndProceed(): Promise { if (checkingEmailVerification) return false; if (state.authMethod === "passkey") return false; checkingEmailVerification = true; try { const { verified } = await api.checkEmailVerified(state.targetEmail); if (!verified) return false; const session = await api.createSession( state.targetEmail, state.targetPassword, ); state.localAccessToken = session.accessJwt; state.localRefreshToken = session.refreshJwt; saveOfflineState(state); setStep("plc-signing"); await signFinalPlcOperation(); setStep("finalizing"); await activateAccount(); cleanup(); setStep("success"); return true; } catch { return false; } finally { checkingEmailVerification = false; } } async function startPasskeyRegistration(): Promise<{ options: unknown }> { if (!state.passkeySetupToken) { throw new Error("No passkey setup token"); } return api.startPasskeyRegistrationForSetup( unsafeAsDid(state.userDid), state.passkeySetupToken, ); } async function registerPasskey(passkeyName?: string): Promise { if (!state.passkeySetupToken) { throw new Error("No passkey setup token"); } if (!globalThis.PublicKeyCredential) { throw new Error("Passkeys are not supported in this browser"); } const { options } = await startPasskeyRegistration(); const publicKeyOptions = prepareWebAuthnCreationOptions( options as { publicKey: Record }, ); const credential = await navigator.credentials.create({ publicKey: publicKeyOptions, }); if (!credential) { throw new Error("Passkey creation was cancelled"); } const publicKeyCredential = credential as PublicKeyCredential; const response = publicKeyCredential .response as AuthenticatorAttestationResponse; const credentialData = { id: publicKeyCredential.id, rawId: base64UrlEncode(publicKeyCredential.rawId), type: publicKeyCredential.type, response: { clientDataJSON: base64UrlEncode(response.clientDataJSON), attestationObject: base64UrlEncode(response.attestationObject), }, }; const result = await api.completePasskeySetup( unsafeAsDid(state.userDid), state.passkeySetupToken, credentialData, passkeyName, ); state.generatedAppPassword = result.appPassword; state.generatedAppPasswordName = result.appPasswordName; const session = await api.createSession( state.targetEmail, result.appPassword, ); state.localAccessToken = session.accessJwt; state.localRefreshToken = session.refreshJwt; saveOfflineState(state); setStep("app-password"); } async function proceedFromAppPassword(): Promise { setStep("plc-signing"); await signFinalPlcOperation(); setStep("finalizing"); await activateAccount(); cleanup(); setStep("success"); } function cleanup(): void { clearOfflineState(); userRotationKeypair = null; tempVerificationKeypair = null; state.rotationKey = ""; } async function runMigration(): Promise { try { setStep("creating"); const serviceAuthToken = await prepareTempCredentials(); if (state.authMethod === "passkey") { await createPasskeyAccount(serviceAuthToken); } else { await createPasswordAccount(serviceAuthToken); } setStep("importing"); await importRepository(); setStep("migrating-blobs"); await migrateBlobs(); if ( state.progress.blobsTotal > 0 || state.progress.blobsFailed.length > 0 ) { await new Promise((resolve) => setTimeout(resolve, 3000)); } setStep("email-verify"); } catch (e) { setError((e as Error).message); setStep("error"); } } function reset() { clearOfflineState(); userRotationKeypair = null; tempVerificationKeypair = null; state = { direction: "offline-inbound", step: "welcome", userDid: "", carFile: null, carFileName: "", carSizeBytes: 0, carNeedsReupload: false, rotationKey: "", rotationKeyDidKey: "", oldPdsUrl: null, targetHandle: "", targetEmail: "", targetPassword: "", inviteCode: "", authMethod: "password", localAccessToken: null, localRefreshToken: null, passkeySetupToken: null, generatedAppPassword: null, generatedAppPasswordName: null, emailVerifyToken: "", progress: createInitialProgress(), error: null, plcUpdatedTemporarily: false, }; localServerInfo = null; } function tryResume(): boolean { const stored = loadOfflineState(); if (!stored) return false; state.userDid = stored.userDid; state.carFileName = stored.carFileName; state.carSizeBytes = stored.carSizeBytes; state.rotationKeyDidKey = stored.rotationKeyDidKey; state.targetHandle = stored.targetHandle; state.targetEmail = stored.targetEmail; state.authMethod = stored.authMethod ?? "password"; state.passkeySetupToken = stored.passkeySetupToken ?? null; state.oldPdsUrl = stored.oldPdsUrl ?? null; state.plcUpdatedTemporarily = stored.plcUpdatedTemporarily ?? false; state.step = stored.step; state.progress.repoExported = stored.progress.accountCreated; state.progress.repoImported = stored.progress.repoImported; state.progress.plcSigned = stored.progress.plcSigned; state.progress.activated = stored.progress.activated; state.error = stored.lastError ?? null; if (stored.carFileName && stored.carSizeBytes > 0) { state.carNeedsReupload = true; } return true; } function getLocalSession(): | { accessJwt: string; did: string; handle: string } | null { if (!state.localAccessToken) return null; return { accessJwt: state.localAccessToken, did: state.userDid, handle: state.targetHandle, }; } return { get state() { return state; }, getLocalSession, setStep, setError, setProgress, loadLocalServerInfo, checkHandleAvailability, validateRotationKey, runMigration, submitEmailVerifyToken, resendEmailVerification, checkEmailVerifiedAndProceed, startPasskeyRegistration, registerPasskey, proceedFromAppPassword, reset, tryResume, clearOfflineState, setUserDid(did: string) { state.userDid = did; saveOfflineState(state); }, setCarFile(file: Uint8Array, fileName: string) { state.carFile = file; state.carFileName = fileName; state.carSizeBytes = file.length; state.carNeedsReupload = false; saveOfflineState(state); }, setRotationKey(key: string) { state.rotationKey = key; }, setTargetHandle(handle: string) { state.targetHandle = handle; saveOfflineState(state); }, setTargetEmail(email: string) { state.targetEmail = email; saveOfflineState(state); }, setTargetPassword(password: string) { state.targetPassword = password; }, setInviteCode(code: string) { state.inviteCode = code; }, setAuthMethod(method: AuthMethod) { state.authMethod = method; saveOfflineState(state); }, updateField( field: K, value: OfflineInboundMigrationState[K], ) { state[field] = value; saveOfflineState(state); }, }; }