Something went wrong. Try again.
A downstream fork of Tranquil PDS for use in margin.cafe
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756import type { AuthMethod, MigrationProgress, OfflineInboundMigrationState, OfflineInboundStep, ServerDescription,} from "./types.ts";import { AtprotoClient, createLocalClient,} from "./atproto-client.ts";import { createPasskeyCredential } from "../flows/perform-passkey-registration.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 { composeHandle } from "../handle.ts";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 };
import { createInitialProgress, checkHandleAvailabilityViaClient, loadServerInfo, resolveVerificationIdentifier,} from "../flows/migration-shared.ts";import { createEmailVerificationPoller } from "../flows/email-verification.ts";
export type OfflineInboundMigrationFlow = ReturnType< typeof createOfflineInboundMigrationFlow>;
export function createOfflineInboundMigrationFlow() { let state = $state<OfflineInboundMigrationState>({ 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, handlePreservation: "new", existingHandleVerified: false, verificationChannel: "email", discordUsername: "", telegramUsername: "", signalUsername: "", });
let localServerInfo: ServerDescription | null = null; let userRotationKeypair: KeypairInfo | null = null; let tempVerificationKeypair: Secp256k1PrivateKeyExportable | null = null;
function setStep(step: OfflineInboundStep) { state.step = step; if (step !== "error") { state.error = null; } if (step !== "success") { saveOfflineState(state); } }
function setError(error: string | null) { state.error = error; saveOfflineState(state); }
function setProgress(updates: Partial<MigrationProgress>) { state.progress = { ...state.progress, ...updates }; saveOfflineState(state); }
async function loadLocalServerInfo(): Promise<ServerDescription> { const info = await loadServerInfo(createLocalClient(), localServerInfo); localServerInfo = info; return info; }
async function checkHandleAvailability(handle: string): Promise<boolean> { return checkHandleAvailabilityViaClient(createLocalClient(), handle); }
async function validateRotationKey(): Promise<boolean> { if (!state.userDid || !state.rotationKey) { throw new Error("DID and rotation key are required"); }
try { const { lastOperation } = await plcOps.getLastPlcOpFromPlc(state.userDid); const currentRotationKeys = lastOperation.rotationKeys || [];
userRotationKeypair = await plcOps.getMatchingKeyPair( state.rotationKey.trim(), currentRotationKeys, );
if (!userRotationKeypair) { state.rotationKeyDidKey = ""; return false; }
state.rotationKeyDidKey = userRotationKeypair.didPublicKey;
const pdsService = lastOperation.services?.atproto_pds; if (pdsService?.endpoint) { state.oldPdsUrl = pdsService.endpoint; }
saveOfflineState(state); return true; } catch (e) { throw new Error(`Failed to parse rotation key: ${(e as Error).message}`); } }
async function prepareTempCredentials(): Promise<string> { 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<void> { setProgress({ currentOperation: "Creating account on new PDS..." });
const serverInfo = await loadLocalServerInfo(); const fullHandle = composeHandle(state.targetHandle, serverInfo.availableUserDomains[0]);
const createResult = await api.createAccountWithServiceAuth( serviceAuthToken, { did: unsafeAsDid(state.userDid), handle: unsafeAsHandle(fullHandle), email: state.targetEmail ? unsafeAsEmail(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, }, );
state.targetHandle = fullHandle; state.localAccessToken = createResult.accessJwt; state.localRefreshToken = createResult.refreshJwt; setProgress({ repoExported: true }); }
async function createPasskeyAccount(serviceAuthToken: string): Promise<void> { setProgress({ currentOperation: "Creating passkey account on new PDS..." });
const serverInfo = await loadLocalServerInfo(); const fullHandle = composeHandle(state.targetHandle, serverInfo.availableUserDomains[0]);
const createResult = await api.createPasskeyAccount({ did: unsafeAsDid(state.userDid), handle: unsafeAsHandle(fullHandle), email: state.targetEmail ? unsafeAsEmail(state.targetEmail) : undefined, inviteCode: state.inviteCode || undefined, verificationChannel: state.verificationChannel, discordUsername: state.discordUsername || undefined, telegramUsername: state.telegramUsername || undefined, signalUsername: state.signalUsername || undefined, }, serviceAuthToken);
state.targetHandle = fullHandle; state.passkeySetupToken = createResult.setupToken; setProgress({ repoExported: true }); saveOfflineState(state); }
async function signFinalPlcOperation(): Promise<void> { 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<void> { 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<void> { 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<void> { 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<void> { 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<void> { await api.resendMigrationVerification( state.verificationChannel, resolveVerificationIdentifier( state.verificationChannel, state.targetEmail, state.discordUsername, state.telegramUsername, state.signalUsername, ), ); }
const verificationPoller = createEmailVerificationPoller({ async checkVerified() { if (state.authMethod === "passkey") return false; if (state.verificationChannel === "email") { const { verified } = await api.checkEmailVerified(state.targetEmail); return verified; } const { verified } = await api.checkChannelVerified( state.userDid, state.verificationChannel, ); return verified; }, async onVerified() { if (!state.localAccessToken) { 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"); }, });
function checkEmailVerifiedAndProceed(): Promise<boolean> { return verificationPoller.checkAndAdvance(); }
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<void> { if (!state.passkeySetupToken) { throw new Error("No passkey setup token"); }
const credential = await createPasskeyCredential( () => startPasskeyRegistration(), );
const result = await api.completePasskeySetup( unsafeAsDid(state.userDid), state.passkeySetupToken, credential, 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<void> { 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<void> { 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, handlePreservation: "new", existingHandleVerified: false, verificationChannel: "email", discordUsername: "", telegramUsername: "", signalUsername: "", }; 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<K extends keyof OfflineInboundMigrationState>( field: K, value: OfflineInboundMigrationState[K], ) { state[field] = value; saveOfflineState(state); }, };}