Something went wrong. Try again.
A downstream fork of Tranquil PDS for use in margin.cafe
Something went wrong. Try again.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157import type { InboundMigrationState, InboundStep, MigrationProgress, PasskeyAccountSetup, ServerDescription, StoredMigrationState,} from "./types.ts";import { AtprotoClient, clearDPoPKey, createLocalClient, exchangeOAuthCode, generateDPoPKeyPair, getMigrationOAuthClientId, getMigrationOAuthRedirectUri, getOAuthServerMetadata, initiateOAuthWithPAR, loadDPoPKey, resolvePdsUrl, saveDPoPKey,} from "./atproto-client.ts";import { generateCodeChallenge, generateCodeVerifier, generateState,} from "../oauth.ts";import { clearMigrationState, saveMigrationState, updateProgress, updateStep,} from "./storage.ts";import { migrateBlobs as migrateBlobsUtil } from "./blob-migration.ts";import { createInitialProgress, checkHandleAvailabilityViaClient, finalizeAccountMigration, loadServerInfo, resolveVerificationIdentifier,} from "../flows/migration-shared.ts";import { createEmailVerificationPoller } from "../flows/email-verification.ts";
function migrationLog(stage: string, data?: Record<string, unknown>) { const timestamp = new Date().toISOString(); const msg = `[MIGRATION ${timestamp}] ${stage}`; if (data) { console.log(msg, JSON.stringify(data, null, 2)); } else { console.log(msg); }}
export function getPasskeyResumeStep( targetStep: InboundStep, sourceDid: string,): InboundStep { if (targetStep === "app-password" || targetStep === "finalizing") { return sourceDid.startsWith("did:web:") ? "did-web-update" : "plc-token"; } return targetStep;}
export function createInboundMigrationFlow() { let state = $state<InboundMigrationState>({ direction: "inbound", step: "welcome", sourcePdsUrl: "", sourceDid: "", sourceHandle: "", targetHandle: "", targetEmail: "", targetPassword: "", inviteCode: "", sourceAccessToken: null, sourceRefreshToken: null, serviceAuthToken: null, emailVerifyToken: "", plcToken: "", progress: createInitialProgress(), error: null, targetVerificationMethod: null, authMethod: "password", passkeySetupToken: null, oauthCodeVerifier: null, localAccessToken: null, localRefreshToken: null, generatedAppPassword: null, generatedAppPasswordName: null, handlePreservation: "new", existingHandleVerified: false, verificationChannel: "email", discordUsername: "", telegramUsername: "", signalUsername: "", });
let sourceClient: AtprotoClient | null = null; let localClient: AtprotoClient | null = null; let localServerInfo: ServerDescription | null = null; let sourcePdsDomains: string[] = [];
function setStep(step: InboundStep) { state.step = step; if (step !== "error") { state.error = null; } if (step !== "success") { saveMigrationState(state); updateStep(step); } }
function setError(error: string | null) { state.error = error; saveMigrationState(state); }
function setProgress(updates: Partial<MigrationProgress>) { state.progress = { ...state.progress, ...updates }; updateProgress(updates); }
async function loadLocalServerInfo(): Promise<ServerDescription> { if (!localClient) { localClient = createLocalClient(); } const info = await loadServerInfo(localClient, localServerInfo); localServerInfo = info; return info; }
async function resolveSourcePds(handle: string): Promise<void> { const normalized = handle.startsWith("@") ? handle.slice(1) : handle; try { const { did, pdsUrl } = await resolvePdsUrl(normalized); state.sourcePdsUrl = pdsUrl; state.sourceDid = did; state.sourceHandle = normalized; sourceClient = new AtprotoClient(pdsUrl); } catch (e) { throw new Error(`Could not resolve handle: ${(e as Error).message}`); } }
async function initiateOAuthLogin(handle: string): Promise<void> { migrationLog("initiateOAuthLogin START", { handle });
const normalizedHandle = handle.startsWith("@") ? handle.slice(1) : handle; if (!state.sourcePdsUrl || state.sourceHandle !== normalizedHandle) { await resolveSourcePds(normalizedHandle); }
const metadata = await getOAuthServerMetadata(state.sourcePdsUrl); if (!metadata) { throw new Error( "Source PDS does not support OAuth. This PDS only supports OAuth-based migrations.", ); }
const codeVerifier = generateCodeVerifier(); const codeChallenge = await generateCodeChallenge(codeVerifier); const oauthState = generateState();
const dpopKeyPair = await generateDPoPKeyPair(); await saveDPoPKey(dpopKeyPair);
localStorage.setItem("migration_oauth_state", oauthState); localStorage.setItem("migration_oauth_code_verifier", codeVerifier); localStorage.setItem("migration_source_pds_url", state.sourcePdsUrl); localStorage.setItem("migration_source_did", state.sourceDid); localStorage.setItem("migration_source_handle", state.sourceHandle); localStorage.setItem("migration_oauth_issuer", metadata.issuer); if (state.resumeToStep) { localStorage.setItem("migration_resume_to_step", state.resumeToStep); }
const authUrl = await initiateOAuthWithPAR(metadata, { clientId: getMigrationOAuthClientId(), redirectUri: getMigrationOAuthRedirectUri(), codeChallenge, state: oauthState, scope: "atproto identity:* account:repo?action=manage rpc:com.atproto.server.createAccount?aud=*", dpopJkt: dpopKeyPair.thumbprint, loginHint: state.sourceHandle, });
migrationLog("initiateOAuthLogin: Redirecting to authorization", { sourcePdsUrl: state.sourcePdsUrl, authEndpoint: metadata.authorization_endpoint, dpopJkt: dpopKeyPair.thumbprint, });
state.oauthCodeVerifier = codeVerifier; saveMigrationState(state);
globalThis.location.href = authUrl; }
function cleanupOAuthSessionData(): void { localStorage.removeItem("migration_oauth_state"); localStorage.removeItem("migration_oauth_code_verifier"); localStorage.removeItem("migration_source_pds_url"); localStorage.removeItem("migration_source_did"); localStorage.removeItem("migration_source_handle"); localStorage.removeItem("migration_oauth_issuer"); localStorage.removeItem("migration_resume_to_step"); }
async function handleOAuthCallback( code: string, returnedState: string, ): Promise<void> { migrationLog("handleOAuthCallback START");
const savedState = localStorage.getItem("migration_oauth_state"); const codeVerifier = localStorage.getItem("migration_oauth_code_verifier"); const sourcePdsUrl = localStorage.getItem("migration_source_pds_url"); const sourceDid = localStorage.getItem("migration_source_did"); const sourceHandle = localStorage.getItem("migration_source_handle"); const oauthIssuer = localStorage.getItem("migration_oauth_issuer"); const savedResumeToStep = localStorage.getItem("migration_resume_to_step");
if (savedResumeToStep) { state.needsReauth = true; state.resumeToStep = savedResumeToStep as InboundMigrationState["step"]; }
if (returnedState !== savedState) { cleanupOAuthSessionData(); throw new Error("OAuth state mismatch - possible CSRF attack"); }
if (!codeVerifier || !sourcePdsUrl || !sourceDid || !sourceHandle) { cleanupOAuthSessionData(); throw new Error("Missing OAuth session data"); }
const dpopKeyPair = await loadDPoPKey(); if (!dpopKeyPair) { cleanupOAuthSessionData(); throw new Error("Missing DPoP key - please restart the migration"); }
state.sourcePdsUrl = sourcePdsUrl; state.sourceDid = sourceDid; state.sourceHandle = sourceHandle; sourceClient = new AtprotoClient(sourcePdsUrl);
let metadata = await getOAuthServerMetadata(sourcePdsUrl); if (!metadata && oauthIssuer) { metadata = await getOAuthServerMetadata(oauthIssuer); } if (!metadata) { cleanupOAuthSessionData(); throw new Error("Could not fetch OAuth server metadata"); }
migrationLog("handleOAuthCallback: Exchanging code for tokens");
let tokenResponse; try { tokenResponse = await exchangeOAuthCode(metadata, { code, codeVerifier, clientId: getMigrationOAuthClientId(), redirectUri: getMigrationOAuthRedirectUri(), dpopKeyPair, }); } catch (err) { cleanupOAuthSessionData(); throw err; }
migrationLog("handleOAuthCallback: Got access token");
state.sourceAccessToken = tokenResponse.access_token; state.sourceRefreshToken = tokenResponse.refresh_token ?? null; sourceClient.setAccessToken(tokenResponse.access_token); sourceClient.setRefreshToken(tokenResponse.refresh_token ?? null); sourceClient.setDPoPKeyPair(dpopKeyPair); sourceClient.setOAuthRefreshContext( metadata.token_endpoint, getMigrationOAuthClientId(), );
cleanupOAuthSessionData();
if (state.needsReauth && state.resumeToStep) { const targetStep = state.resumeToStep; state.needsReauth = false; state.resumeToStep = undefined;
const postEmailSteps = [ "plc-token", "did-web-update", "finalizing", "app-password", ];
if (postEmailSteps.includes(targetStep)) { localClient = createLocalClient(); if (state.localAccessToken) { localClient.setAccessToken(state.localAccessToken); } if (state.localRefreshToken) { localClient.setRefreshToken(state.localRefreshToken); } if (state.authMethod === "passkey") { const resumeStep = getPasskeyResumeStep(targetStep, state.sourceDid); if (resumeStep !== targetStep) { await proceedFromAppPassword(); } else { setStep(resumeStep); } migrationLog("handleOAuthCallback: Resuming completed passkey flow", { targetStep, resumeStep, }); } else { const alreadyVerified = await localClient .checkChannelVerified(state.sourceDid, state.verificationChannel) .catch(() => false); if (alreadyVerified) { migrationLog( "handleOAuthCallback: Already verified, skipping email-verify", ); await proceedAfterVerification(); } else { setStep("email-verify"); migrationLog( "handleOAuthCallback: Resuming at email-verify for re-auth", ); } } } else if (targetStep === "email-verify") { localClient = createLocalClient(); if (state.localAccessToken) { localClient.setAccessToken(state.localAccessToken); } if (state.localRefreshToken) { localClient.setRefreshToken(state.localRefreshToken); } setStep("email-verify"); migrationLog("handleOAuthCallback: Resuming at email-verify"); } else { setStep(targetStep); } } else { setStep("choose-handle"); } saveMigrationState(state); }
async function loadSourcePdsDomains(): Promise<string[]> { if (sourcePdsDomains.length > 0) return sourcePdsDomains; if (!sourceClient) return []; try { const info = await sourceClient.describeServer(); sourcePdsDomains = info.availableUserDomains; } catch { sourcePdsDomains = []; } return sourcePdsDomains; }
async function checkHandleAvailability(handle: string): Promise<boolean> { if (!localClient) { localClient = createLocalClient(); } return checkHandleAvailabilityViaClient(localClient, handle); }
async function verifyExistingHandle(): Promise<{ verified: boolean; method?: string; error?: string; }> { if (!localClient) { localClient = createLocalClient(); } const result = await localClient.verifyHandleOwnership( state.sourceHandle, state.sourceDid, ); if (result.verified) { state.existingHandleVerified = true; state.targetHandle = state.sourceHandle; } return result; }
async function authenticateToLocal( email: string, password: string, ): Promise<void> { if (!localClient) { localClient = createLocalClient(); } await localClient.loginDeactivated(email, password); }
let passkeySetup: PasskeyAccountSetup | null = null;
async function startMigration(): Promise<void> { migrationLog("startMigration START", { sourceDid: state.sourceDid, sourceHandle: state.sourceHandle, targetHandle: state.targetHandle, sourcePdsUrl: state.sourcePdsUrl, authMethod: state.authMethod, });
if (!sourceClient || !state.sourceAccessToken) { migrationLog("startMigration ERROR: Not authenticated to source PDS"); throw new Error("Not authenticated to source PDS"); }
if (!localClient) { localClient = createLocalClient(); }
setStep("migrating");
try { setProgress({ currentOperation: "Getting service auth token..." }); migrationLog("startMigration: Loading local server info"); const serverInfo = await loadLocalServerInfo(); migrationLog("startMigration: Got server info", { serverDid: serverInfo.did, });
migrationLog( "startMigration: Getting service auth token from source PDS", ); const { token } = await sourceClient.getServiceAuth( serverInfo.did, "com.atproto.server.createAccount", ); migrationLog("startMigration: Got service auth token"); state.serviceAuthToken = token;
setProgress({ currentOperation: "Creating account on new PDS..." });
if (state.authMethod === "passkey") { const passkeyParams = { did: state.sourceDid, handle: state.targetHandle, email: state.targetEmail || undefined, inviteCode: state.inviteCode || undefined, verificationChannel: state.verificationChannel, discordUsername: state.discordUsername || undefined, telegramUsername: state.telegramUsername || undefined, signalUsername: state.signalUsername || undefined, };
migrationLog("startMigration: Creating passkey account on NEW PDS", { did: passkeyParams.did, handle: passkeyParams.handle, inviteCode: passkeyParams.inviteCode, stateInviteCode: state.inviteCode, }); passkeySetup = await localClient.createPasskeyAccount( passkeyParams, token, ); migrationLog("startMigration: Passkey account created on NEW PDS", { did: passkeySetup.did, hasAccessJwt: !!passkeySetup.accessJwt, }); state.passkeySetupToken = passkeySetup.setupToken; if (passkeySetup.accessJwt) { localClient.setAccessToken(passkeySetup.accessJwt); state.localAccessToken = passkeySetup.accessJwt; } } else { const accountParams = { did: state.sourceDid, handle: state.targetHandle, email: state.targetEmail || undefined, password: state.targetPassword, inviteCode: state.inviteCode || undefined, verificationChannel: state.verificationChannel, discordUsername: state.discordUsername || undefined, telegramUsername: state.telegramUsername || undefined, signalUsername: state.signalUsername || undefined, };
migrationLog("startMigration: Creating account on NEW PDS", { did: accountParams.did, handle: accountParams.handle, }); const session = await localClient.createAccount(accountParams, token); migrationLog("startMigration: Account created on NEW PDS", { did: session.did, }); localClient.setAccessToken(session.accessJwt); state.localAccessToken = session.accessJwt; state.localRefreshToken = session.refreshJwt; }
setProgress({ currentOperation: "Exporting repository..." }); migrationLog("startMigration: Exporting repo from source PDS"); const exportStart = Date.now(); const car = await sourceClient.getRepo(state.sourceDid); migrationLog("startMigration: Repo exported", { durationMs: Date.now() - exportStart, sizeBytes: car.byteLength, }); setProgress({ repoExported: true, currentOperation: "Importing repository...", });
migrationLog("startMigration: Importing repo to NEW PDS"); const importStart = Date.now(); await localClient.importRepo(car); migrationLog("startMigration: Repo imported", { durationMs: Date.now() - importStart, }); setProgress({ repoImported: true, currentOperation: "Counting blobs...", });
const accountStatus = await localClient.checkAccountStatus(); migrationLog("startMigration: Account status", { expectedBlobs: accountStatus.expectedBlobs, importedBlobs: accountStatus.importedBlobs, }); setProgress({ blobsTotal: accountStatus.expectedBlobs, currentOperation: "Migrating blobs...", });
await migrateBlobs();
setProgress({ currentOperation: "Migrating preferences..." }); await migratePreferences();
migrationLog( "startMigration: Initial migration complete, waiting for email verification", ); setStep("email-verify"); } catch (e) { const err = e as Error & { error?: string; status?: number }; const message = err.message || err.error || `Unknown error (status ${err.status || "unknown"})`; migrationLog("startMigration FAILED", { error: message, errorCode: err.error, status: err.status, stack: err.stack, }); setError(message); setStep("error"); } }
async function migrateBlobs(): Promise<void> { if (!sourceClient) { console.error( "[migration] migrateBlobs: sourceClient is null, skipping blob migration", ); migrationLog("migrateBlobs SKIPPED: sourceClient is null"); setProgress({ currentOperation: "Warning: Could not migrate blobs - source PDS connection lost", }); return; } if (!localClient) { console.error( "[migration] migrateBlobs: localClient is null, skipping blob migration", ); migrationLog("migrateBlobs SKIPPED: localClient is null"); setProgress({ currentOperation: "Warning: Could not migrate blobs - local PDS connection lost", }); return; }
migrationLog("migrateBlobs: Starting blob migration", { sourceClientBaseUrl: sourceClient.getBaseUrl(), localClientBaseUrl: localClient.getBaseUrl(), localClientHasToken: !!localClient.getAccessToken(), });
const result = await migrateBlobsUtil( localClient, sourceClient, state.sourceDid, setProgress, );
state.progress.blobsFailed = result.failed; }
async function migratePreferences(): Promise<void> { if (!sourceClient || !localClient) { console.warn("[migration] migratePreferences: client missing, skipping"); return; }
try { const prefs = await sourceClient.getPreferences(); await localClient.putPreferences(prefs); setProgress({ prefsMigrated: true }); } catch { /* optional, best-effort */ } }
async function submitEmailVerifyToken( token: string, localPassword?: string, ): Promise<void> { if (!localClient) { localClient = createLocalClient(); }
state.emailVerifyToken = token; setError(null);
try { await localClient.verifyToken(token, state.targetEmail);
if (!sourceClient) { setStep("source-handle"); setError( "Email verified! Please log in to your old account again to complete the migration.", ); return; }
if (state.authMethod === "passkey") { migrationLog( "submitEmailVerifyToken: Email verified, proceeding to passkey setup", ); setStep("passkey-setup"); return; }
if (localPassword) { setProgress({ currentOperation: "Authenticating to new PDS..." }); await localClient.loginDeactivated(state.targetEmail, localPassword); }
if (!localClient.getAccessToken()) { setError("Email verified! Please enter your password to continue."); return; }
if (state.sourceDid.startsWith("did:web:")) { const credentials = await localClient.getRecommendedDidCredentials(); state.targetVerificationMethod = credentials.verificationMethods?.atproto || null; setStep("did-web-update"); } else { setProgress({ currentOperation: "Requesting PLC operation token..." }); await sourceClient.requestPlcOperationSignature(); setStep("plc-token"); } } catch (e) { const err = e as Error & { error?: string; status?: number }; const message = err.message || err.error || `Unknown error (status ${err.status || "unknown"})`; setError(message); } }
async function resendEmailVerification(): Promise<void> { if (!localClient) { localClient = createLocalClient(); } await localClient.resendMigrationVerification( state.verificationChannel, resolveVerificationIdentifier( state.verificationChannel, state.targetEmail, state.discordUsername, state.telegramUsername, state.signalUsername, ), ); }
async function proceedAfterVerification(): Promise<void> { if (state.authMethod === "passkey") { setStep("passkey-setup"); return; }
if (!localClient!.getAccessToken()) { await localClient!.loginDeactivated( state.targetEmail, state.targetPassword, ); }
if (!sourceClient) { setStep("source-handle"); setError( "Email verified! Please log in to your old account again to complete the migration.", ); return; }
if (state.sourceDid.startsWith("did:web:")) { const credentials = await localClient!.getRecommendedDidCredentials(); state.targetVerificationMethod = credentials.verificationMethods?.atproto || null; setStep("did-web-update"); } else { await sourceClient.requestPlcOperationSignature(); setStep("plc-token"); } }
const verificationPoller = createEmailVerificationPoller({ async checkVerified() { if (!localClient) return false; return localClient.checkChannelVerified( state.sourceDid, state.verificationChannel, ); }, async onVerified() { await proceedAfterVerification(); }, });
function checkEmailVerifiedAndProceed(): Promise<boolean> { return verificationPoller.checkAndAdvance(); }
async function submitPlcToken(token: string): Promise<void> { migrationLog("submitPlcToken START", { sourceDid: state.sourceDid, sourceHandle: state.sourceHandle, targetHandle: state.targetHandle, sourcePdsUrl: state.sourcePdsUrl, });
if (!sourceClient || !localClient) { migrationLog("submitPlcToken ERROR: Not connected to PDSes", { hasSourceClient: !!sourceClient, hasLocalClient: !!localClient, }); throw new Error("Not connected to PDSes"); }
state.plcToken = token; setStep("finalizing"); setProgress({ currentOperation: "Signing PLC operation..." });
try { if (state.progress.deactivated) { setProgress({ currentOperation: "Activating account..." }); migrationLog("Retrying activation on NEW PDS"); await finalizeAccountMigration(sourceClient, localClient, { sourceAlreadyDeactivated: true, }); setProgress({ activated: true }); setStep("success"); clearMigrationState(); return; }
migrationLog("Step 1: Getting recommended DID credentials from NEW PDS"); const credentials = await localClient.getRecommendedDidCredentials(); migrationLog("Step 1 COMPLETE: Got credentials", { rotationKeys: credentials.rotationKeys, alsoKnownAs: credentials.alsoKnownAs, verificationMethods: credentials.verificationMethods, services: credentials.services, });
migrationLog("Step 2: Signing PLC operation on source PDS", { sourcePdsUrl: state.sourcePdsUrl, }); const signStart = Date.now(); const { operation } = await sourceClient.signPlcOperation({ token, ...credentials, }); migrationLog("Step 2 COMPLETE: PLC operation signed", { durationMs: Date.now() - signStart, operationType: operation.type, operationPrev: operation.prev, });
setProgress({ plcSigned: true, currentOperation: "Submitting PLC operation...", }); migrationLog("Step 3: Submitting PLC operation to NEW PDS"); const submitStart = Date.now(); await localClient.submitPlcOperation(operation); migrationLog("Step 3 COMPLETE: PLC operation submitted", { durationMs: Date.now() - submitStart, });
setProgress({ currentOperation: "Deactivating old account..." }); migrationLog("Step 4: Deactivating account on source PDS", { sourcePdsUrl: state.sourcePdsUrl, }); await finalizeAccountMigration(sourceClient, localClient, { sourceAlreadyDeactivated: state.progress.deactivated, onSourceDeactivated: () => { migrationLog("Step 4 COMPLETE: Account deactivated on source PDS"); setProgress({ deactivated: true, currentOperation: "Activating account (waiting for DID propagation)...", }); migrationLog("Step 5: Activating account on NEW PDS"); }, onSourceDeactivationFailed: (error) => { console.warn( "[MIGRATION] Could not deactivate account on source PDS; continuing activation", error, ); setProgress({ currentOperation: "Activating account (waiting for DID propagation)...", }); migrationLog("Step 5: Activating account on NEW PDS"); }, }); migrationLog("Step 5 COMPLETE: Account activated on NEW PDS"); setProgress({ activated: true });
migrationLog("submitPlcToken SUCCESS: Migration complete", { sourceDid: state.sourceDid, newHandle: state.targetHandle, }); setStep("success"); clearMigrationState(); } catch (e) { const err = e as Error & { error?: string; status?: number }; const message = err.message || err.error || `Unknown error (status ${err.status || "unknown"})`; migrationLog("submitPlcToken FAILED", { error: message, errorCode: err.error, status: err.status, stack: err.stack, }); state.step = "plc-token"; state.error = message; saveMigrationState(state); } }
async function requestPlcToken(): Promise<void> { if (!sourceClient) { throw new Error("Not connected to source PDS"); } setProgress({ currentOperation: "Requesting PLC operation token..." }); await sourceClient.requestPlcOperationSignature(); }
async function resendPlcToken(): Promise<void> { if (!sourceClient) { throw new Error("Not connected to source PDS"); } await sourceClient.requestPlcOperationSignature(); }
async function completeDidWebMigration(): Promise<void> { migrationLog("completeDidWebMigration START", { sourceDid: state.sourceDid, sourceHandle: state.sourceHandle, targetHandle: state.targetHandle, });
if (!sourceClient || !localClient) { migrationLog("completeDidWebMigration ERROR: Not connected to PDSes"); throw new Error("Not connected to PDSes"); }
setStep("finalizing"); setProgress({ currentOperation: "Deactivating old account..." });
try { if (state.progress.deactivated) { setProgress({ currentOperation: "Activating account..." }); migrationLog("Retrying activation on NEW PDS"); await finalizeAccountMigration(sourceClient, localClient, { sourceAlreadyDeactivated: true, }); setProgress({ activated: true }); setStep("success"); clearMigrationState(); return; }
migrationLog("Deactivating account on source PDS"); await finalizeAccountMigration(sourceClient, localClient, { sourceAlreadyDeactivated: state.progress.deactivated, onSourceDeactivated: () => { migrationLog("Account deactivated on source PDS"); setProgress({ deactivated: true, currentOperation: "Activating account...", }); migrationLog("Activating account on NEW PDS"); }, onSourceDeactivationFailed: (error) => { console.warn( "[MIGRATION] Could not deactivate account on source PDS; continuing activation", error, ); setProgress({ currentOperation: "Activating account..." }); migrationLog("Activating account on NEW PDS"); }, }); migrationLog("Account activated"); setProgress({ activated: true });
migrationLog("completeDidWebMigration SUCCESS"); setStep("success"); clearMigrationState(); } catch (e) { const err = e as Error & { error?: string; status?: number }; const message = err.message || err.error || `Unknown error (status ${err.status || "unknown"})`; migrationLog("completeDidWebMigration FAILED", { error: message }); setError(message); setStep("did-web-update"); } }
async function startPasskeyRegistration(): Promise<{ options: unknown }> { if (!localClient || !state.passkeySetupToken) { throw new Error("Not ready for passkey registration"); }
migrationLog("startPasskeyRegistration START", { did: state.sourceDid }); const result = await localClient.startPasskeyRegistrationForSetup( state.sourceDid, state.passkeySetupToken, ); migrationLog("startPasskeyRegistration: Got WebAuthn options"); return result; }
async function completePasskeyRegistration( credential: unknown, friendlyName?: string, ): Promise<void> { if (!localClient || !state.passkeySetupToken || !sourceClient) { throw new Error("Not ready for passkey registration"); }
migrationLog("completePasskeyRegistration START", { did: state.sourceDid });
const result = await localClient.completePasskeySetup( state.sourceDid, state.passkeySetupToken, credential, friendlyName, ); migrationLog("completePasskeyRegistration: Passkey registered", { appPassword: "***", });
state.passkeySetupToken = null; state.generatedAppPassword = result.appPassword; state.generatedAppPasswordName = result.appPasswordName; setStep("app-password");
setProgress({ currentOperation: "Authenticating with app password..." }); try { const session = await localClient.loginDeactivated( state.targetEmail, result.appPassword, ); state.localAccessToken = session.accessJwt; state.localRefreshToken = session.refreshJwt; saveMigrationState(state); migrationLog("completePasskeyRegistration: Authenticated to new PDS"); } catch (error) { if (!localClient.getAccessToken()) throw error; migrationLog( "completePasskeyRegistration: Retaining migration access token", ); } }
async function proceedFromAppPassword(): Promise<void> { if (!sourceClient || !localClient) { throw new Error("Clients not initialized"); }
migrationLog("proceedFromAppPassword: Starting");
if (state.sourceDid.startsWith("did:web:")) { const credentials = await localClient.getRecommendedDidCredentials(); state.targetVerificationMethod = credentials.verificationMethods?.atproto || null; setStep("did-web-update"); } else { setProgress({ currentOperation: "Requesting PLC operation token..." }); await sourceClient.requestPlcOperationSignature(); setStep("plc-token"); } }
function reset(): void { state = { direction: "inbound", step: "welcome", sourcePdsUrl: "", sourceDid: "", sourceHandle: "", targetHandle: "", targetEmail: "", targetPassword: "", inviteCode: "", sourceAccessToken: null, sourceRefreshToken: null, serviceAuthToken: null, emailVerifyToken: "", plcToken: "", progress: createInitialProgress(), error: null, targetVerificationMethod: null, authMethod: "password", passkeySetupToken: null, oauthCodeVerifier: null, localAccessToken: null, localRefreshToken: null, generatedAppPassword: null, generatedAppPasswordName: null, handlePreservation: "new", existingHandleVerified: false, verificationChannel: "email", discordUsername: "", telegramUsername: "", signalUsername: "", }; sourceClient = null; passkeySetup = null; clearMigrationState(); clearDPoPKey(); }
async function resumeFromState(stored: StoredMigrationState): Promise<void> { if (stored.direction !== "inbound") return;
state.sourcePdsUrl = stored.sourcePdsUrl; state.sourceDid = stored.sourceDid; state.sourceHandle = stored.sourceHandle; state.targetHandle = stored.targetHandle; state.targetEmail = stored.targetEmail; state.authMethod = stored.authMethod ?? "password"; state.passkeySetupToken = stored.passkeySetupToken ?? null; state.localAccessToken = stored.localAccessToken ?? null; state.localRefreshToken = stored.localRefreshToken ?? null; state.progress = { ...createInitialProgress(), ...stored.progress, };
const stepsRequiringSourceAuth = [ "choose-handle", "review", "migrating", "email-verify", "plc-token", "did-web-update", "finalizing", "app-password", ];
if (stepsRequiringSourceAuth.includes(stored.step)) { state.step = "source-handle"; state.needsReauth = true; state.resumeToStep = stored.step as InboundMigrationState["step"]; migrationLog("resumeFromState: Requiring re-auth for step", { originalStep: stored.step, }); } else if (stored.step === "passkey-setup" && stored.passkeySetupToken) { localClient = createLocalClient(); state.step = "passkey-setup"; migrationLog("resumeFromState: Restored passkey-setup with token"); } else if (stored.step === "success") { state.step = "success"; } else if (stored.step === "error") { state.step = "source-handle"; state.needsReauth = true; migrationLog("resumeFromState: Error state, requiring re-auth"); } else { state.step = stored.step as InboundMigrationState["step"]; } }
function getLocalSession(): | { accessJwt: string; did: string; handle: string } | null { if (!localClient) return null; const token = localClient.getAccessToken(); if (!token) return null; return { accessJwt: token, did: state.sourceDid, handle: state.targetHandle, }; }
return { get state() { return state; }, get passkeySetup() { return passkeySetup; }, setStep, setError, loadLocalServerInfo, loadSourcePdsDomains, resolveSourcePds, initiateOAuthLogin, handleOAuthCallback, authenticateToLocal, checkHandleAvailability, verifyExistingHandle, startMigration, submitEmailVerifyToken, resendEmailVerification, checkEmailVerifiedAndProceed, requestPlcToken, submitPlcToken, resendPlcToken, completeDidWebMigration, startPasskeyRegistration, completePasskeyRegistration, proceedFromAppPassword, reset, resumeFromState, getLocalSession,
updateField<K extends keyof InboundMigrationState>( field: K, value: InboundMigrationState[K], ) { state[field] = value; }, };}
export type InboundMigrationFlow = ReturnType< typeof createInboundMigrationFlow>;