PDS software with bells & whistles you didn’t even know you needed. will move this to its own account when ready.

try to fix

Changed files
+311 -9
frontend
src
lib
migration
+1
Dockerfile
··· 17 17 cp target/release/tranquil-pds /tmp/tranquil-pds 18 18 19 19 FROM alpine:3.23 20 + RUN apk add --no-cache msmtp ca-certificates && ln -sf /usr/bin/msmtp /usr/sbin/sendmail 20 21 COPY --from=builder /tmp/tranquil-pds /usr/local/bin/tranquil-pds 21 22 COPY --from=builder /app/migrations /app/migrations 22 23 COPY --from=frontend-builder /frontend/dist /app/frontend/dist
+310 -9
frontend/src/lib/migration/flow.svelte.ts
··· 84 84 let sourceClient: AtprotoClient | null = null; 85 85 let localClient: AtprotoClient | null = null; 86 86 let localServerInfo: ServerDescription | null = null; 87 - let sourceOAuthMetadata: Awaited<ReturnType<typeof getOAuthServerMetadata>> = 88 - null; 87 + let sourceOAuthMetadata: Awaited<ReturnType<typeof getOAuthServerMetadata>> = null; 89 88 90 89 function setStep(step: InboundStep) { 91 90 state.step = step; ··· 461 460 async function migrateBlobs(): Promise<void> { 462 461 if (!sourceClient || !localClient) return; 463 462 464 - const result = await migrateBlobsUtil( 465 - localClient, 466 - sourceClient, 467 - state.sourceDid, 468 - setProgress, 469 - ); 463 + let cursor: string | undefined; 464 + let migrated = 0; 470 465 471 - state.progress.blobsFailed = result.failed; 466 + do { 467 + const { blobs, cursor: nextCursor } = await localClient.listMissingBlobs( 468 + cursor, 469 + 100, 470 + ); 471 + 472 + for (const blob of blobs) { 473 + try { 474 + setProgress({ 475 + currentOperation: `Migrating blob ${migrated + 1 476 + }/${state.progress.blobsTotal}...`, 477 + }); 478 + 479 + const blobData = await sourceClient.getBlob( 480 + state.sourceDid, 481 + blob.cid, 482 + ); 483 + await localClient.uploadBlob(blobData, "application/octet-stream"); 484 + migrated++; 485 + setProgress({ blobsMigrated: migrated }); 486 + } catch { 487 + state.progress.blobsFailed.push(blob.cid); 488 + } 489 + } 490 + 491 + cursor = nextCursor; 492 + } while (cursor); 472 493 } 473 494 474 495 async function migratePreferences(): Promise<void> { ··· 955 976 updateField<K extends keyof InboundMigrationState>( 956 977 field: K, 957 978 value: InboundMigrationState[K], 979 + ) { 980 + state[field] = value; 981 + }, 982 + }; 983 + } 984 + 985 + export function createOutboundMigrationFlow() { 986 + let state = $state<OutboundMigrationState>({ 987 + direction: "outbound", 988 + step: "welcome", 989 + localDid: "", 990 + localHandle: "", 991 + targetPdsUrl: "", 992 + targetPdsDid: "", 993 + targetHandle: "", 994 + targetEmail: "", 995 + targetPassword: "", 996 + inviteCode: "", 997 + targetAccessToken: null, 998 + targetRefreshToken: null, 999 + serviceAuthToken: null, 1000 + plcToken: "", 1001 + progress: createInitialProgress(), 1002 + error: null, 1003 + targetServerInfo: null, 1004 + }); 1005 + 1006 + let localClient: AtprotoClient | null = null; 1007 + let targetClient: AtprotoClient | null = null; 1008 + 1009 + function setStep(step: OutboundStep) { 1010 + state.step = step; 1011 + state.error = null; 1012 + saveMigrationState(state); 1013 + updateStep(step); 1014 + } 1015 + 1016 + function setError(error: string) { 1017 + state.error = error; 1018 + saveMigrationState(state); 1019 + } 1020 + 1021 + function setProgress(updates: Partial<MigrationProgress>) { 1022 + state.progress = { ...state.progress, ...updates }; 1023 + updateProgress(updates); 1024 + } 1025 + 1026 + async function validateTargetPds(url: string): Promise<ServerDescription> { 1027 + const normalizedUrl = url.replace(/\/$/, ""); 1028 + targetClient = new AtprotoClient(normalizedUrl); 1029 + 1030 + try { 1031 + const serverInfo = await targetClient.describeServer(); 1032 + state.targetPdsUrl = normalizedUrl; 1033 + state.targetPdsDid = serverInfo.did; 1034 + state.targetServerInfo = serverInfo; 1035 + return serverInfo; 1036 + } catch (e) { 1037 + throw new Error(`Could not connect to PDS: ${(e as Error).message}`); 1038 + } 1039 + } 1040 + 1041 + function initLocalClient( 1042 + accessToken: string, 1043 + did?: string, 1044 + handle?: string, 1045 + ): void { 1046 + localClient = createLocalClient(); 1047 + localClient.setAccessToken(accessToken); 1048 + if (did) { 1049 + state.localDid = did; 1050 + } 1051 + if (handle) { 1052 + state.localHandle = handle; 1053 + } 1054 + } 1055 + 1056 + async function startMigration(currentDid: string): Promise<void> { 1057 + if (!localClient || !targetClient) { 1058 + throw new Error("Not connected to PDSes"); 1059 + } 1060 + 1061 + setStep("migrating"); 1062 + setProgress({ currentOperation: "Getting service auth token..." }); 1063 + 1064 + try { 1065 + const { token } = await localClient.getServiceAuth( 1066 + state.targetPdsDid, 1067 + "com.atproto.server.createAccount", 1068 + ); 1069 + state.serviceAuthToken = token; 1070 + 1071 + setProgress({ currentOperation: "Creating account on new PDS..." }); 1072 + 1073 + const accountParams = { 1074 + did: currentDid, 1075 + handle: state.targetHandle, 1076 + email: state.targetEmail, 1077 + password: state.targetPassword, 1078 + inviteCode: state.inviteCode || undefined, 1079 + }; 1080 + 1081 + const session = await targetClient.createAccount(accountParams, token); 1082 + state.targetAccessToken = session.accessJwt; 1083 + state.targetRefreshToken = session.refreshJwt; 1084 + targetClient.setAccessToken(session.accessJwt); 1085 + 1086 + setProgress({ currentOperation: "Exporting repository..." }); 1087 + 1088 + const car = await localClient.getRepo(currentDid); 1089 + setProgress({ 1090 + repoExported: true, 1091 + currentOperation: "Importing repository...", 1092 + }); 1093 + 1094 + await targetClient.importRepo(car); 1095 + setProgress({ 1096 + repoImported: true, 1097 + currentOperation: "Counting blobs...", 1098 + }); 1099 + 1100 + const accountStatus = await targetClient.checkAccountStatus(); 1101 + setProgress({ 1102 + blobsTotal: accountStatus.expectedBlobs, 1103 + currentOperation: "Migrating blobs...", 1104 + }); 1105 + 1106 + await migrateBlobs(currentDid); 1107 + 1108 + setProgress({ currentOperation: "Migrating preferences..." }); 1109 + await migratePreferences(); 1110 + 1111 + setProgress({ currentOperation: "Requesting PLC operation token..." }); 1112 + await localClient.requestPlcOperationSignature(); 1113 + 1114 + setStep("plc-token"); 1115 + } catch (e) { 1116 + const err = e as Error & { error?: string; status?: number }; 1117 + const message = err.message || err.error || 1118 + `Unknown error (status ${err.status || "unknown"})`; 1119 + setError(message); 1120 + setStep("error"); 1121 + } 1122 + } 1123 + 1124 + async function migrateBlobs(did: string): Promise<void> { 1125 + if (!localClient || !targetClient) return; 1126 + 1127 + let cursor: string | undefined; 1128 + let migrated = 0; 1129 + 1130 + do { 1131 + const { blobs, cursor: nextCursor } = await targetClient.listMissingBlobs( 1132 + cursor, 1133 + 100, 1134 + ); 1135 + 1136 + for (const blob of blobs) { 1137 + try { 1138 + setProgress({ 1139 + currentOperation: `Migrating blob ${migrated + 1 1140 + }/${state.progress.blobsTotal}...`, 1141 + }); 1142 + 1143 + const blobData = await localClient.getBlob(did, blob.cid); 1144 + await targetClient.uploadBlob(blobData, "application/octet-stream"); 1145 + migrated++; 1146 + setProgress({ blobsMigrated: migrated }); 1147 + } catch { 1148 + state.progress.blobsFailed.push(blob.cid); 1149 + } 1150 + } 1151 + 1152 + cursor = nextCursor; 1153 + } while (cursor); 1154 + } 1155 + 1156 + async function migratePreferences(): Promise<void> { 1157 + if (!localClient || !targetClient) return; 1158 + 1159 + try { 1160 + const prefs = await localClient.getPreferences(); 1161 + await targetClient.putPreferences(prefs); 1162 + setProgress({ prefsMigrated: true }); 1163 + } catch { /* optional, best-effort */ } 1164 + } 1165 + 1166 + async function submitPlcToken(token: string): Promise<void> { 1167 + if (!localClient || !targetClient) { 1168 + throw new Error("Not connected to PDSes"); 1169 + } 1170 + 1171 + state.plcToken = token; 1172 + setStep("finalizing"); 1173 + setProgress({ currentOperation: "Signing PLC operation..." }); 1174 + 1175 + try { 1176 + const credentials = await targetClient.getRecommendedDidCredentials(); 1177 + 1178 + const { operation } = await localClient.signPlcOperation({ 1179 + token, 1180 + ...credentials, 1181 + }); 1182 + 1183 + setProgress({ 1184 + plcSigned: true, 1185 + currentOperation: "Submitting PLC operation...", 1186 + }); 1187 + 1188 + await targetClient.submitPlcOperation(operation); 1189 + 1190 + setProgress({ currentOperation: "Activating account on new PDS..." }); 1191 + await targetClient.activateAccount(); 1192 + setProgress({ activated: true }); 1193 + 1194 + setProgress({ currentOperation: "Deactivating old account..." }); 1195 + try { 1196 + await localClient.deactivateAccount(state.targetPdsUrl); 1197 + setProgress({ deactivated: true }); 1198 + } catch { /* optional, best-effort */ } 1199 + 1200 + setStep("success"); 1201 + clearMigrationState(); 1202 + } catch (e) { 1203 + const err = e as Error & { error?: string; status?: number }; 1204 + const message = err.message || err.error || 1205 + `Unknown error (status ${err.status || "unknown"})`; 1206 + setError(message); 1207 + setStep("plc-token"); 1208 + } 1209 + } 1210 + 1211 + async function resendPlcToken(): Promise<void> { 1212 + if (!localClient) { 1213 + throw new Error("Not connected to local PDS"); 1214 + } 1215 + await localClient.requestPlcOperationSignature(); 1216 + } 1217 + 1218 + function reset(): void { 1219 + state = { 1220 + direction: "outbound", 1221 + step: "welcome", 1222 + localDid: "", 1223 + localHandle: "", 1224 + targetPdsUrl: "", 1225 + targetPdsDid: "", 1226 + targetHandle: "", 1227 + targetEmail: "", 1228 + targetPassword: "", 1229 + inviteCode: "", 1230 + targetAccessToken: null, 1231 + targetRefreshToken: null, 1232 + serviceAuthToken: null, 1233 + plcToken: "", 1234 + progress: createInitialProgress(), 1235 + error: null, 1236 + targetServerInfo: null, 1237 + }; 1238 + localClient = null; 1239 + targetClient = null; 1240 + clearMigrationState(); 1241 + } 1242 + 1243 + return { 1244 + get state() { 1245 + return state; 1246 + }, 1247 + setStep, 1248 + setError, 1249 + validateTargetPds, 1250 + initLocalClient, 1251 + startMigration, 1252 + submitPlcToken, 1253 + resendPlcToken, 1254 + reset, 1255 + 1256 + updateField<K extends keyof OutboundMigrationState>( 1257 + field: K, 1258 + value: OutboundMigrationState[K], 958 1259 ) { 959 1260 state[field] = value; 960 1261 },