···33A series of web tools to help users: migrate to a new PDS, find missing blobs, have free backups, and restore from
44backups in the event of emergency. [pdsmoover.com](https://pdsmoover.com)
5566-
66+
7788A little light on documentation as I come off of about a week long crunch but.
99···2323- [cron-worker](./cron-worker) - Very simple binary to tick every hour telling the main worker to check for repos that
2424 need an update
2525- [Dockerfiles](./Dockerfiles) - Dockerfiles for all the services in the repo
2626-- [lexicon_types](./lexicon_types) - TypeScript types for PDS MOOver lexicons (not currently in use)
2626+- [packages/lexicons](./packages/lexicons) - TypeScript types for PDS MOOver lexicons
2727+- [packages/moover](./packages/moover) - Frontend logic that handles all the atproto processes. Also published as a node
2828+ module
2929+ at [@pds-moover/moover](https://www.npmjs.com/package/@pds-moover/moover)
2730- [lexicon_types_crate](./lexicon_types_crate) - Rust lexicon types
2831- [lexicons](./lexicons) - JSON Lexicons
2932- [ProductionComposes](./ProductionComposes) - What I use to run PDS MOOver in production. One instance of web behind a
3033 load balancer, one worker node currently with 3 instances on that one server. All can scale horizontally
3134- [shared](./shared) - Shared code between all the services
3235- [web](./web) - The web frontend that servers XRPC endpoints and the frontend
3333-- [web-ui](./web/ui-code) - JS code to handle everything to do with atproto such as Migrations, missing blobs, signing
3636+- [web-ui](./web-ui) - Svelte frontend.
3437 plc ops, restores, etc
3538- [worker](./worker) - What acutally handles all the backing up, but the actual logic is
3639 in [./shared/src/jobs/](./shared/src/jobs/)
···11+# @pds-moover/moover
22+33+
44+55+This is the core logic that runs [PDS MOOver](https://pdsmoover.com). With this you should be able to create your own
66+"PDS MOOver" without having
77+to figure out the atproto logic.
88+99+- [lib/atprotoUtils.js](./lib/atprotoUtils.js) - Helpers for atproto actions
1010+- [Migrator](./lib/pdsmoover.js) - For handling regular migrations
1111+- [BackupService](./lib/backup.js) - For signing up for backups, request a back up, and remove backups to a PDS MOOver
1212+ instance
1313+- [MissingBlobs](./lib/missingBlobs.js) - Finds missing blobs on your old PDS and uploads them to your new PDS
1414+- [PlcOps](./lib/plc-ops.js) - Helpers for manual PCL operations
1515+- [Restore](./lib/restore.js) - Handles a recovery and restores the at proto from the backup
···11+import {
22+ CompositeDidDocumentResolver, CompositeHandleResolver,
33+ DohJsonHandleResolver,
44+ PlcDidDocumentResolver, WebDidDocumentResolver,
55+ WellKnownHandleResolver
66+} from '@atcute/identity-resolver';
77+88+const handleResolver = new CompositeHandleResolver({
99+ strategy: 'race',
1010+ methods: {
1111+ dns: new DohJsonHandleResolver({
1212+ dohUrl: 'https://mozilla.cloudflare-dns.com/dns-query',
1313+ }),
1414+ http: new WellKnownHandleResolver(),
1515+ },
1616+});
1717+1818+const docResolver = new CompositeDidDocumentResolver({
1919+ methods: {
2020+ plc: new PlcDidDocumentResolver(),
2121+ web: new WebDidDocumentResolver(),
2222+ },
2323+});
2424+2525+/**
2626+ * Cleans the handle of @ and some other unicode characters that used to show up when copied from the profile
2727+ * @param handle {string}
2828+ * @returns {string}
2929+ */
3030+const cleanHandle = (handle) =>
3131+ handle.replace('@', '').trim().replace(
3232+ /[\u202A\u202C\u200E\u200F\u2066-\u2069]/g,
3333+ '',
3434+ );
3535+3636+3737+/**
3838+ * Convince helper to resolve a handle to a did and then find the PDS url from the did document.
3939+ *
4040+ * @param handle
4141+ * @returns {Promise<{usersDid: string, pds: string}>}
4242+ */
4343+async function handleAndPDSResolver(handle) {
4444+ let usersDid = null;
4545+ if (handle.startsWith('did:')) {
4646+ usersDid = handle;
4747+ } else {
4848+ const cleanedHandle = cleanHandle(handle);
4949+ usersDid = await handleResolver.resolve(cleanedHandle);
5050+ }
5151+ const didDoc = await docResolver.resolve(usersDid);
5252+5353+ let pds;
5454+ try {
5555+ pds = didDoc.service?.filter((s) =>
5656+ s.type === 'AtprotoPersonalDataServer'
5757+ )[0].serviceEndpoint;
5858+ } catch (error) {
5959+ throw new Error('Could not find a PDS in the DID document.');
6060+ }
6161+ return {usersDid, pds};
6262+}
6363+6464+6565+/**
6666+ * Fetches the DID Web from the .well-known/did.json endpoint of the server.
6767+ * Legacy and was helpful if the web ui and server are on the same domain, not as useful now
6868+ * @param baseUrl
6969+ * @returns {Promise<*>}
7070+ */
7171+async function fetchPDSMooverDIDWeb(baseUrl) {
7272+ const response = await fetch(`${baseUrl}/.well-known/did.json`);
7373+ if (!response.ok) {
7474+ throw new Error(`Failed to fetch DID document: ${response.status}`);
7575+ }
7676+ const didDoc = await response.json();
7777+ return didDoc.id;
7878+}
7979+8080+8181+export {handleResolver, docResolver, cleanHandle, handleAndPDSResolver, fetchPDSMooverDIDWeb};
+276
packages/moover/lib/backup.js
···11+import {Client, CredentialManager, ok} from '@atcute/client';
22+import {handleAndPDSResolver} from './atprotoUtils.js';
33+//Shows as unused, but is used in the return types
44+import {ComPdsmooverBackupDescribeServer} from '@pds-moover/lexicons';
55+66+/**
77+ * JSDoc type-only import to avoid runtime import errors in the browser.
88+ * @typedef {import('@atcute/lexicons').InferXRPCBodyOutput} InferXRPCBodyOutput
99+ */
1010+1111+1212+/**
1313+ * Logic to sign up and manage backups for pdsmoover.com (or your own selfhosted instance)
1414+ */
1515+class BackupService {
1616+ /**
1717+ *
1818+ * @param backupDidWeb {string} - The did:web for the xrpc service for backups, defaults to did:web:pdsmoover.com
1919+ */
2020+ constructor(backupDidWeb = 'did:web:pdsmoover.com') {
2121+ /**
2222+ *
2323+ * @type {Client}
2424+ */
2525+ this.atCuteClient = null;
2626+ /**
2727+ *
2828+ * @type {CredentialManager}
2929+ */
3030+ this.atCuteCredentialManager = null;
3131+3232+ /**
3333+ * The did:web for the xrpc service for backups, defaults to pdsmoover.com
3434+ * @type {string}
3535+ */
3636+ this.backupDidWeb = backupDidWeb;
3737+ }
3838+3939+4040+ /**
4141+ * Logs in and returns the backup status.
4242+ * To use the rest of the BackupService, it is assumed that this has ran first,
4343+ * and the user has successfully signed up. A successful login is a returned null if the user has not signed up.
4444+ * or the backup status if they are
4545+ *
4646+ * If the server requires 2FA,
4747+ * it will throw with error.error === 'AuthFactorTokenRequired'.
4848+ * @param identifier {string} handle or did
4949+ * @param password {string}
5050+ * @param {function|null} onStatus - a function that takes a string used to update the UI.
5151+ * Like (status) => console.log(status)
5252+ * @param twoFactorCode {string|null}
5353+ *
5454+ * @returns {Promise<InferXRPCBodyOutput<ComPdsmooverBackupDescribeServer.mainSchema['output']>|null>}
5555+ */
5656+ async loginAndStatus(identifier, password, onStatus = null, twoFactorCode = null) {
5757+ let {pds} = await handleAndPDSResolver(identifier);
5858+5959+6060+ const manager = new CredentialManager({
6161+ service: pds
6262+ });
6363+6464+6565+ const rpc = new Client({
6666+ handler: manager,
6767+ proxy: {
6868+ did: this.backupDidWeb,
6969+ serviceId: '#repo_backup'
7070+ }
7171+ });
7272+7373+ try {
7474+ if (onStatus) onStatus('Signing in…');
7575+7676+ let loginInput = {
7777+ identifier,
7878+ password,
7979+8080+ };
8181+ if (twoFactorCode) {
8282+ loginInput.code = twoFactorCode;
8383+ }
8484+ await manager.login(loginInput);
8585+8686+8787+ // Make the client/manager available regardless of repo status so we can sign up if needed.
8888+ this.atCuteClient = rpc;
8989+ this.atCuteCredentialManager = manager;
9090+9191+ if (onStatus) onStatus('Checking backup status');
9292+ const result = await rpc.get('com.pdsmoover.backup.getRepoStatus', {
9393+ params: {
9494+ did: manager.session.did.toString()
9595+ }
9696+ });
9797+ if (result.ok) {
9898+ return result.data;
9999+ } else {
100100+ switch (result.data.error) {
101101+ case 'RepoNotFound':
102102+ return null;
103103+ default:
104104+ throw result.data.error;
105105+ }
106106+107107+ }
108108+ } catch (err) {
109109+ throw err;
110110+ }
111111+ }
112112+113113+ /**
114114+ * Signs the user up for backups with the service
115115+ * @param onStatus
116116+ * @returns {Promise<void>}
117117+ */
118118+ async signUp(onStatus = null) {
119119+ if (!this.atCuteClient || !this.atCuteCredentialManager) {
120120+ throw new Error('Not signed in');
121121+ }
122122+ if (onStatus) onStatus('Creating backup registration…');
123123+ await ok(
124124+ this.atCuteClient.post('com.pdsmoover.backup.signUp', {
125125+ as: null,
126126+ })
127127+ );
128128+ if (onStatus) onStatus('Backup registration complete');
129129+ //No return if successful
130130+ }
131131+132132+ /**
133133+ * Requests a PLC token to be sent to the user's email, needed to add a new rotation key
134134+ * @returns {Promise<void>}
135135+ */
136136+ async requestAPlcToken() {
137137+ if (!this.atCuteClient || !this.atCuteCredentialManager) {
138138+ throw new Error('Not signed in');
139139+ }
140140+ const rpc = new Client({
141141+ handler: this.atCuteCredentialManager,
142142+ });
143143+144144+ let response = await rpc.post('com.atproto.identity.requestPlcOperationSignature', {
145145+ as: null,
146146+ });
147147+ if (!response.ok) {
148148+ throw new Error(response.data?.message || 'Failed to request PLC token');
149149+ }
150150+ }
151151+152152+ /**
153153+ * Adds a new rotation to the users did document. Assumes you are already signed in.
154154+ *
155155+ * WARNING: This will overwrite any existing rotation keys with the new one at the top, and the PDS key as the second one
156156+ * @param plcToken {string} - PLC token from the user's email that was sent from requestAPlcToken
157157+ * @param rotationKey {string} - The new rotation key to add to the user's did document
158158+ * @returns {Promise<void>}
159159+ */
160160+ async addANewRotationKey(plcToken, rotationKey) {
161161+ if (!this.atCuteClient || !this.atCuteCredentialManager) {
162162+ throw new Error('Not signed in');
163163+ }
164164+165165+166166+ const rpc = new Client({
167167+ handler: this.atCuteCredentialManager,
168168+ });
169169+170170+ let getDidCredentials = await rpc.get('com.atproto.identity.getRecommendedDidCredentials');
171171+172172+ if (getDidCredentials.ok) {
173173+ const pdsProvidedRotationKeys = getDidCredentials.data.rotationKeys ?? [];
174174+ const updatedRotationKeys = [rotationKey, ...pdsProvidedRotationKeys];
175175+176176+ const credentials = {
177177+ ...getDidCredentials.data,
178178+ rotationKeys: updatedRotationKeys,
179179+ };
180180+181181+ const signDocRes = await rpc.post('com.atproto.identity.signPlcOperation', {
182182+ input: {
183183+ token: plcToken,
184184+ ...credentials,
185185+ }
186186+ });
187187+188188+ if (signDocRes.ok) {
189189+ const submitDocRes = await rpc.post('com.atproto.identity.submitPlcOperation', {
190190+ input: signDocRes.data,
191191+ as: null,
192192+ });
193193+194194+ if (!submitDocRes.ok) {
195195+ throw new Error(submitDocRes.data?.message || 'Failed to submit PLC operation');
196196+ }
197197+198198+ } else {
199199+ throw new Error(signDocRes.data?.message || 'Failed to sign PLC operation');
200200+ }
201201+202202+203203+ } else {
204204+ throw new Error(getDidCredentials.data?.message || 'Failed to get status');
205205+ }
206206+ }
207207+208208+209209+ /**
210210+ *
211211+ * Gets the current status of the user's backup repository.
212212+ *
213213+ * @param onStatus {function|null} - a function that takes a string used to update the UI.
214214+ * @returns {Promise<InferXRPCBodyOutput<ComPdsmooverBackupDescribeServer.mainSchema['output']>>}
215215+ */
216216+ async getUsersRepoStatus(onStatus = null) {
217217+ if (!this.atCuteClient || !this.atCuteCredentialManager) {
218218+ throw new Error('Not signed in');
219219+ }
220220+ if (onStatus) onStatus('Refreshing backup status…');
221221+ const result = await this.atCuteClient.get('com.pdsmoover.backup.getRepoStatus', {
222222+ params: {did: this.atCuteCredentialManager.session.did.toString()}
223223+ });
224224+ if (result.ok) {
225225+ return result.data;
226226+ } else {
227227+ throw new Error(result.data?.message || 'Failed to get status');
228228+ }
229229+ }
230230+231231+ /**
232232+ * Requests a backup to be run immediately for the signed-in user. Usually does, depend on the server's backup queue
233233+ * @param onStatus
234234+ * @returns {Promise<boolean>}
235235+ */
236236+ async runBackupNow(onStatus = null) {
237237+ if (!this.atCuteClient || !this.atCuteCredentialManager) {
238238+ throw new Error('Not signed in');
239239+ }
240240+ if (onStatus) onStatus('Requesting backup…');
241241+ const res = await this.atCuteClient.post('com.pdsmoover.backup.requestBackup', {as: null, data: {}});
242242+ if (res.ok) {
243243+ if (onStatus) onStatus('Backup requested.');
244244+ return true;
245245+ } else {
246246+ const err = res.data;
247247+ if (err?.error === 'Timeout') {
248248+ throw {error: 'Timeout', message: err?.message || 'Please wait a few minutes before requesting again.'};
249249+ }
250250+ throw new Error(err?.message || 'Failed to request backup');
251251+ }
252252+ }
253253+254254+ /**
255255+ * Remove (delete) the signed-in user's backup repository. this also deletes all the user's backup data.
256256+ * @param onStatus
257257+ * @returns {Promise<boolean>}
258258+ */
259259+ async removeRepo(onStatus = null) {
260260+ if (!this.atCuteClient || !this.atCuteCredentialManager) {
261261+ throw new Error('Not signed in');
262262+ }
263263+ if (onStatus) onStatus('Deleting backup repository…');
264264+ const res = await this.atCuteClient.post('com.pdsmoover.backup.removeRepo', {as: null, data: {}});
265265+ if (res.ok) {
266266+ if (onStatus) onStatus('Backup repository deleted.');
267267+ return true;
268268+ } else {
269269+ const err = res.data;
270270+ throw new Error(err?.message || 'Failed to delete backup repository');
271271+ }
272272+ }
273273+}
274274+275275+276276+export {BackupService};
+17
packages/moover/lib/main.js
···11+import {Migrator} from './pdsmoover.js';
22+import {MissingBlobs} from './missingBlobs.js';
33+import {BackupService} from './backup.js';
44+import {PlcOps} from './plc-ops.js';
55+import {Restore} from './restore.js';
66+import {handleAndPDSResolver} from './atprotoUtils.js';
77+88+export {
99+ Migrator,
1010+ MissingBlobs,
1111+ BackupService,
1212+ PlcOps,
1313+ Restore,
1414+ handleAndPDSResolver,
1515+1616+}
1717+
+193
packages/moover/lib/missingBlobs.js
···11+import {AtpAgent} from '@atproto/api';
22+import {handleAndPDSResolver} from './atprotoUtils.js';
33+44+55+/**
66+ * Class to help find missing blobs from the did's previous PDS and import them into the current PDS
77+ */
88+class MissingBlobs {
99+1010+ constructor() {
1111+ /**
1212+ * The user's current PDS agent
1313+ * @type {AtpAgent}
1414+ */
1515+ this.currentPdsAgent = null;
1616+ /**
1717+ * The user's old PDS agent
1818+ * @type {AtpAgent}
1919+ */
2020+ this.oldPdsAgent = null;
2121+ /**
2222+ * the user's did
2323+ * @type {string|null}
2424+ */
2525+ this.did = null;
2626+ /**
2727+ * The user's current PDS url
2828+ * @type {null}
2929+ */
3030+ this.currentPdsUrl = null;
3131+ /**
3232+ * A list of the missing cids blobs from the old PDS. In this case if a retry upload fails it gets put in this array for the ui
3333+ * @type {string[]}
3434+ */
3535+ this.missingBlobs = [];
3636+3737+ }
3838+3939+ /**
4040+ * Logs the user into the current PDS and gets the account status
4141+ * @param handle {string}
4242+ * @param password {string}
4343+ * @param twoFactorCode {string|null}
4444+ * @returns {Promise<{accountStatus: OutputSchema, missingBlobsCount: number}>}
4545+ */
4646+ async currentAgentLogin(
4747+ handle,
4848+ password,
4949+ twoFactorCode = null,
5050+ ) {
5151+ let {usersDid, pds} = await handleAndPDSResolver(handle);
5252+ this.did = usersDid;
5353+ this.currentPdsUrl = pds;
5454+ const agent = new AtpAgent({
5555+ service: pds,
5656+ });
5757+5858+ if (twoFactorCode === null) {
5959+ await agent.login({identifier: usersDid, password});
6060+ } else {
6161+ await agent.login({identifier: usersDid, password: password, authFactorToken: twoFactorCode});
6262+ }
6363+6464+ this.currentPdsAgent = agent;
6565+6666+ const result = await agent.com.atproto.server.checkAccountStatus();
6767+ const missingBlobs = await this.currentPdsAgent.com.atproto.repo.listMissingBlobs({
6868+ limit: 10,
6969+ });
7070+ return {accountStatus: result.data, missingBlobsCount: missingBlobs.data.blobs.length};
7171+ }
7272+7373+ /**
7474+ * Logs into the old PDS and gets the account status.
7575+ * Does not need a handle
7676+ * since it is assumed the user has already logged in with the current PDS and we are using their did
7777+ * @param password {string}
7878+ * @param twoFactorCode {string|null}
7979+ * @param pdsUrl {string|null} - If you know the url of the old PDS you can pass it in here. If not it will be guessed at from plc ops
8080+ * @returns {Promise<void>}
8181+ */
8282+ async oldAgentLogin(
8383+ password,
8484+ twoFactorCode = null,
8585+ pdsUrl = null,
8686+ ) {
8787+ let oldPds = null;
8888+8989+ if (pdsUrl === null) {
9090+ const response = await fetch(`https://plc.directory/${this.did}/log`);
9191+ let auditLog = await response.json();
9292+ auditLog = auditLog.reverse();
9393+ let debugCount = 0;
9494+ for (const entry of auditLog) {
9595+ console.log(`Loop: ${debugCount++}`);
9696+ console.log(entry);
9797+ if (entry.services) {
9898+ if (entry.services.atproto_pds) {
9999+ if (entry.services.atproto_pds.type === 'AtprotoPersonalDataServer') {
100100+ const pds = entry.services.atproto_pds.endpoint;
101101+ console.log(`Found PDS: ${pds}`);
102102+ if (pds.toLowerCase() !== this.currentPdsUrl.toLowerCase()) {
103103+ oldPds = pds;
104104+ break;
105105+ }
106106+ }
107107+ }
108108+ }
109109+ }
110110+ if (oldPds === null) {
111111+ throw new Error('Could not find your old PDS');
112112+ }
113113+ } else {
114114+ oldPds = pdsUrl;
115115+ }
116116+117117+ const agent = new AtpAgent({
118118+ service: oldPds,
119119+ });
120120+121121+ if (twoFactorCode === null) {
122122+ await agent.login({identifier: this.did, password});
123123+ } else {
124124+ await agent.login({identifier: this.did, password: password, authFactorToken: twoFactorCode});
125125+ }
126126+ this.oldPdsAgent = agent;
127127+ }
128128+129129+ /**
130130+ * Gets the missing blobs from the old PDS and uploads them to the current PDS
131131+ * @param statusUpdateHandler {function} - A function to update the status of the migration. This is useful for showing the user the progress of the migration
132132+ * @returns {Promise<{accountStatus: OutputSchema, missingBlobsCount: number}>}
133133+ */
134134+ async migrateMissingBlobs(statusUpdateHandler) {
135135+ if (this.currentPdsAgent === null) {
136136+ throw new Error('Current PDS agent is not set');
137137+ }
138138+ if (this.oldPdsAgent === null) {
139139+ throw new Error('Old PDS agent is not set');
140140+ }
141141+ statusUpdateHandler('Starting to import blobs...');
142142+143143+ let totalMissingBlobs = 0;
144144+ let missingBlobCursor = undefined;
145145+ let missingUploadedBlobs = 0;
146146+147147+ do {
148148+149149+ const missingBlobs = await this.currentPdsAgent.com.atproto.repo.listMissingBlobs({
150150+ cursor: missingBlobCursor,
151151+ limit: 1000,
152152+ });
153153+ totalMissingBlobs += missingBlobs.data.blobs.length;
154154+155155+ for (const recordBlob of missingBlobs.data.blobs) {
156156+ try {
157157+158158+ const blobRes = await this.oldPdsAgent.com.atproto.sync.getBlob({
159159+ did: this.did,
160160+ cid: recordBlob.cid,
161161+ });
162162+ let result = await this.currentPdsAgent.com.atproto.repo.uploadBlob(blobRes.data, {
163163+ encoding: blobRes.headers['content-type'],
164164+ });
165165+166166+ if (result.status === 429) {
167167+ statusUpdateHandler(`You are being rate limited. Will need to try again later to get the rest of the blobs. Migrated blobs: ${missingUploadedBlobs}/${totalMissingBlobs}`);
168168+ }
169169+170170+ if (missingUploadedBlobs % 2 === 0) {
171171+ statusUpdateHandler(`Migrating blobs: ${missingUploadedBlobs}/${totalMissingBlobs} (The total may increase as we find more)`);
172172+ }
173173+ missingUploadedBlobs++;
174174+ } catch (error) {
175175+ console.error(error);
176176+ this.missingBlobs.push(recordBlob.cid);
177177+ }
178178+ }
179179+ missingBlobCursor = missingBlobs.data.cursor;
180180+ } while (missingBlobCursor);
181181+182182+ const accountStatus = await this.currentPdsAgent.com.atproto.server.checkAccountStatus();
183183+ const missingBlobs = await this.currentPdsAgent.com.atproto.repo.listMissingBlobs({
184184+ limit: 10,
185185+ });
186186+ return {accountStatus: accountStatus.data, missingBlobsCount: missingBlobs.data.blobs.length};
187187+188188+189189+ }
190190+191191+}
192192+193193+export {MissingBlobs};
+389
packages/moover/lib/pdsmoover.js
···11+import {docResolver, cleanHandle, handleResolver} from './atprotoUtils.js';
22+import {AtpAgent} from '@atproto/api';
33+44+55+function safeStatusUpdate(statusUpdateHandler, status) {
66+ if (statusUpdateHandler) {
77+ statusUpdateHandler(status);
88+ }
99+}
1010+1111+/**
1212+ * Handles normal PDS Migrations between two PDSs that are both up.
1313+ * On pdsmoover.com this is the logic for the MOOver
1414+ */
1515+class Migrator {
1616+ constructor() {
1717+ /** @type {AtpAgent} */
1818+ this.oldAgent = null;
1919+ /** @type {AtpAgent} */
2020+ this.newAgent = null;
2121+ /** @type {[string]} */
2222+ this.missingBlobs = [];
2323+ //State for reruns
2424+ /** @type {boolean} */
2525+ this.createNewAccount = true;
2626+ /** @type {boolean} */
2727+ this.migrateRepo = true;
2828+ /** @type {boolean} */
2929+ this.migrateBlobs = true;
3030+ /** @type {boolean} */
3131+ this.migrateMissingBlobs = true;
3232+ /** @type {boolean} */
3333+ this.migratePrefs = true;
3434+ /** @type {boolean} */
3535+ this.migratePlcRecord = true;
3636+ }
3737+3838+ /**
3939+ * This migrator is pretty cut and dry and makes a few assumptions
4040+ * 1. You are using the same password between each account
4141+ * 2. If this command fails for something like oauth 2fa code it throws an error and expects the same values when ran again.
4242+ * 3. You can control which "actions" happen by setting the class variables to false.
4343+ * 4. Each instance of the class is assumed to be for a single migration
4444+ * @param {string} oldHandle - The handle you use on your old pds, something like alice.bsky.social
4545+ * @param {string} password - Your password for your current login. Has to be your real password, no app password. When setting up a new account we reuse it as well for that account
4646+ * @param {string} newPdsUrl - The new URL for your pds. Like https://coolnewpds.com
4747+ * @param {string} newEmail - The email you want to use on the new pds (can be the same as the previous one as long as it's not already being used on the new pds)
4848+ * @param {string} newHandle - The new handle you want, like alice.bsky.social, or if you already have a domain name set as a handle can use it myname.com.
4949+ * @param {string|null} inviteCode - The invite code you got from the PDS you are migrating to. If null does not include one
5050+ * @param {function|null} statusUpdateHandler - a function that takes a string used to update the UI. Like (status) => console.log(status)
5151+ * @param {string|null} twoFactorCode - Optional, but needed if it fails with 2fa required
5252+ */
5353+ async migrate(oldHandle, password, newPdsUrl, newEmail, newHandle, inviteCode, statusUpdateHandler = null, twoFactorCode = null) {
5454+ //Leaving this logic that either sets the agent to bsky.social, or the PDS since it's what I found worked best for migrations.
5555+ // handleAndPDSResolver should be able to handle it, but there have been edge cases and this was what worked best
5656+ oldHandle = cleanHandle(oldHandle);
5757+ let oldAgent;
5858+ let usersDid;
5959+ //If it's a bsky handle just go with the entryway and let it sort everything
6060+ if (oldHandle.endsWith('.bsky.social')) {
6161+ oldAgent = new AtpAgent({service: 'https://bsky.social'});
6262+ const publicAgent = new AtpAgent({service: 'https://public.api.bsky.app'});
6363+ const resolveIdentityFromEntryway = await publicAgent.com.atproto.identity.resolveHandle({handle: oldHandle});
6464+ usersDid = resolveIdentityFromEntryway.data.did;
6565+6666+ } else {
6767+ //Resolves the did and finds the did document for the old PDS
6868+ safeStatusUpdate(statusUpdateHandler, 'Resolving old PDS');
6969+ usersDid = await handleResolver.resolve(oldHandle);
7070+ const didDoc = await docResolver.resolve(usersDid);
7171+ safeStatusUpdate(statusUpdateHandler, 'Resolving did document and finding your current PDS URL');
7272+7373+ let oldPds;
7474+ try {
7575+ oldPds = didDoc.service.filter(s => s.type === 'AtprotoPersonalDataServer')[0].serviceEndpoint;
7676+ } catch (error) {
7777+ console.error(error);
7878+ throw new Error('Could not find a PDS in the DID document.');
7979+ }
8080+8181+ oldAgent = new AtpAgent({
8282+ service: oldPds,
8383+ });
8484+8585+ }
8686+8787+ safeStatusUpdate(statusUpdateHandler, 'Logging you in to the old PDS');
8888+ //Login to the old PDS
8989+ if (twoFactorCode === null) {
9090+ await oldAgent.login({identifier: oldHandle, password});
9191+ } else {
9292+ await oldAgent.login({identifier: oldHandle, password: password, authFactorToken: twoFactorCode});
9393+ }
9494+9595+ safeStatusUpdate(statusUpdateHandler, 'Checking that the new PDS is an actual PDS (if the url is wrong this takes a while to error out)');
9696+ const newAgent = new AtpAgent({service: newPdsUrl});
9797+ const newHostDesc = await newAgent.com.atproto.server.describeServer();
9898+ if (this.createNewAccount) {
9999+ const newHostWebDid = newHostDesc.data.did;
100100+101101+ safeStatusUpdate(statusUpdateHandler, 'Creating a new account on the new PDS');
102102+103103+ const createAuthResp = await oldAgent.com.atproto.server.getServiceAuth({
104104+ aud: newHostWebDid,
105105+ lxm: 'com.atproto.server.createAccount',
106106+ });
107107+ const serviceJwt = createAuthResp.data.token;
108108+109109+ let createAccountRequest = {
110110+ did: usersDid,
111111+ handle: newHandle,
112112+ email: newEmail,
113113+ password: password,
114114+ };
115115+ if (inviteCode) {
116116+ createAccountRequest.inviteCode = inviteCode;
117117+ }
118118+ const createNewAccount = await newAgent.com.atproto.server.createAccount(
119119+ createAccountRequest,
120120+ {
121121+ headers: {authorization: `Bearer ${serviceJwt}`},
122122+ encoding: 'application/json',
123123+ });
124124+125125+ if (createNewAccount.data.did !== usersDid.toString()) {
126126+ throw new Error('Did not create the new account with the same did as the old account');
127127+ }
128128+ }
129129+ safeStatusUpdate(statusUpdateHandler, 'Logging in with the new account');
130130+131131+ await newAgent.login({
132132+ identifier: usersDid,
133133+ password: password,
134134+ });
135135+136136+ if (this.migrateRepo) {
137137+ safeStatusUpdate(statusUpdateHandler, 'Migrating your repo');
138138+ const repoRes = await oldAgent.com.atproto.sync.getRepo({did: usersDid});
139139+ await newAgent.com.atproto.repo.importRepo(repoRes.data, {
140140+ encoding: 'application/vnd.ipld.car',
141141+ });
142142+ }
143143+144144+ let newAccountStatus = await newAgent.com.atproto.server.checkAccountStatus();
145145+146146+ if (this.migrateBlobs) {
147147+ safeStatusUpdate(statusUpdateHandler, 'Migrating your blobs');
148148+149149+ let blobCursor = undefined;
150150+ let uploadedBlobs = 0;
151151+ do {
152152+ safeStatusUpdate(statusUpdateHandler, `Migrating blobs: ${uploadedBlobs}/${newAccountStatus.data.expectedBlobs}`);
153153+154154+ const listedBlobs = await oldAgent.com.atproto.sync.listBlobs({
155155+ did: usersDid,
156156+ cursor: blobCursor,
157157+ limit: 100,
158158+ });
159159+160160+ for (const cid of listedBlobs.data.cids) {
161161+ try {
162162+ const blobRes = await oldAgent.com.atproto.sync.getBlob({
163163+ did: usersDid,
164164+ cid,
165165+ });
166166+ await newAgent.com.atproto.repo.uploadBlob(blobRes.data, {
167167+ encoding: blobRes.headers['content-type'],
168168+ });
169169+ uploadedBlobs++;
170170+ if (uploadedBlobs % 10 === 0) {
171171+ safeStatusUpdate(statusUpdateHandler, `Migrating blobs: ${uploadedBlobs}/${newAccountStatus.data.expectedBlobs}`);
172172+ }
173173+ } catch (error) {
174174+ console.error(error);
175175+ }
176176+ }
177177+ blobCursor = listedBlobs.data.cursor;
178178+ } while (blobCursor);
179179+ }
180180+181181+ if (this.migrateMissingBlobs) {
182182+ newAccountStatus = await newAgent.com.atproto.server.checkAccountStatus();
183183+ if (newAccountStatus.data.expectedBlobs !== newAccountStatus.data.importedBlobs) {
184184+ let totalMissingBlobs = newAccountStatus.data.expectedBlobs - newAccountStatus.data.importedBlobs;
185185+ safeStatusUpdate(statusUpdateHandler, 'Looks like there are some missing blobs. Going to try and upload them now.');
186186+ //Probably should be shared between main blob uploader, but eh
187187+ let missingBlobCursor = undefined;
188188+ let missingUploadedBlobs = 0;
189189+ do {
190190+ safeStatusUpdate(statusUpdateHandler, `Migrating blobs: ${missingUploadedBlobs}/${totalMissingBlobs}`);
191191+192192+ const missingBlobs = await newAgent.com.atproto.repo.listMissingBlobs({
193193+ cursor: missingBlobCursor,
194194+ limit: 100,
195195+ });
196196+197197+ for (const recordBlob of missingBlobs.data.blobs) {
198198+ try {
199199+200200+ const blobRes = await oldAgent.com.atproto.sync.getBlob({
201201+ did: usersDid,
202202+ cid: recordBlob.cid,
203203+ });
204204+ await newAgent.com.atproto.repo.uploadBlob(blobRes.data, {
205205+ encoding: blobRes.headers['content-type'],
206206+ });
207207+ if (missingUploadedBlobs % 10 === 0) {
208208+ safeStatusUpdate(statusUpdateHandler, `Migrating blobs: ${missingUploadedBlobs}/${totalMissingBlobs}`);
209209+ }
210210+ missingUploadedBlobs++;
211211+ } catch (error) {
212212+ //TODO silently logging prob should list them so user can manually download
213213+ console.error(error);
214214+ this.missingBlobs.push(recordBlob.cid);
215215+ }
216216+ }
217217+ missingBlobCursor = missingBlobs.data.cursor;
218218+ } while (missingBlobCursor);
219219+220220+ }
221221+ }
222222+ if (this.migratePrefs) {
223223+ const prefs = await oldAgent.app.bsky.actor.getPreferences();
224224+ await newAgent.app.bsky.actor.putPreferences(prefs.data);
225225+ }
226226+227227+ this.oldAgent = oldAgent;
228228+ this.newAgent = newAgent;
229229+230230+ if (this.migratePlcRecord) {
231231+ await oldAgent.com.atproto.identity.requestPlcOperationSignature();
232232+ safeStatusUpdate(statusUpdateHandler, 'Please check your email for a PLC token');
233233+ }
234234+ }
235235+236236+ /**
237237+ * Sign and submits the PLC operation to officially migrate the account
238238+ * @param {string} token - the PLC token sent in the email. If you're just wanting to run this rerun migrate with all the flags set as false except for migratePlcRecord
239239+ * @param additionalRotationKeysToAdd {string[]} - additional rotation keys to add in addition to the ones provided by the new PDS.
240240+ * @returns {Promise<void>}
241241+ */
242242+ async signPlcOperation(token, additionalRotationKeysToAdd = []) {
243243+ const getDidCredentials =
244244+ await this.newAgent.com.atproto.identity.getRecommendedDidCredentials();
245245+ const pdsProvidedRotationKeys = getDidCredentials.data.rotationKeys ?? [];
246246+ // Prepend any additional rotation keys (e.g., user-added keys, newly created key) so they appear above the new PDS rotation key
247247+ const rotationKeys = [...(additionalRotationKeysToAdd || []), ...pdsProvidedRotationKeys];
248248+ if (!rotationKeys) {
249249+ throw new Error('No rotation key provided from the new PDS');
250250+ }
251251+ const credentials = {
252252+ ...getDidCredentials.data,
253253+ rotationKeys: rotationKeys,
254254+ };
255255+256256+257257+ const plcOp = await this.oldAgent.com.atproto.identity.signPlcOperation({
258258+ token: token,
259259+ ...credentials,
260260+ });
261261+262262+ await this.newAgent.com.atproto.identity.submitPlcOperation({
263263+ operation: plcOp.data.operation,
264264+ });
265265+266266+ await this.newAgent.com.atproto.server.activateAccount();
267267+ await this.oldAgent.com.atproto.server.deactivateAccount({});
268268+ }
269269+270270+ /**
271271+ * Using this method assumes the Migrator class was constructed new and this was called.
272272+ * Find the user's previous PDS from the PLC op logs,
273273+ * logs in and deactivates their old account if it was found still active.
274274+ *
275275+ * @param oldHandle {string}
276276+ * @param oldPassword {string}
277277+ * @param {function|null} statusUpdateHandler - a function that takes a string used to update the UI.
278278+ * Like (status) => console.log(status)
279279+ * @param {string|null} twoFactorCode - Optional, but needed if it fails with 2fa required
280280+ * @returns {Promise<void>}
281281+ */
282282+ async deactivateOldAccount(oldHandle, oldPassword, statusUpdateHandler = null, twoFactorCode = null) {
283283+ //Leaving this logic that either sets the agent to bsky.social, or the PDS since it's what I found worked best for migrations.
284284+ // handleAndPDSResolver should be able to handle it, but there have been edge cases and this was what worked best oldHandle = cleanHandle(oldHandle);
285285+ let usersDid;
286286+ //If it's a bsky handle just go with the entryway and let it sort everything
287287+ if (oldHandle.endsWith('.bsky.social')) {
288288+ const publicAgent = new AtpAgent({service: 'https://public.api.bsky.app'});
289289+ const resolveIdentityFromEntryway = await publicAgent.com.atproto.identity.resolveHandle({handle: oldHandle});
290290+ usersDid = resolveIdentityFromEntryway.data.did;
291291+ } else {
292292+ //Resolves the did and finds the did document for the old PDS
293293+ safeStatusUpdate(statusUpdateHandler, 'Resolving did from handle');
294294+ usersDid = await handleResolver.resolve(oldHandle);
295295+ }
296296+297297+ const didDoc = await docResolver.resolve(usersDid);
298298+ let currentPds;
299299+ try {
300300+ currentPds = didDoc.service.filter(s => s.type === 'AtprotoPersonalDataServer')[0].serviceEndpoint;
301301+ } catch (error) {
302302+ console.error(error);
303303+ throw new Error('Could not find a PDS in the DID document.');
304304+ }
305305+306306+ const plcLogRequest = await fetch(`https://plc.directory/${usersDid}/log`);
307307+ const plcLog = await plcLogRequest.json();
308308+ let pdsBeforeCurrent = '';
309309+ for (const log of plcLog) {
310310+ try {
311311+ const pds = log.services.atproto_pds.endpoint;
312312+ if (pds.toLowerCase() === currentPds.toLowerCase()) {
313313+ console.log('Found the PDS before the current one');
314314+ break;
315315+ }
316316+ pdsBeforeCurrent = pds;
317317+ } catch (e) {
318318+ console.log(e);
319319+ }
320320+ }
321321+ if (pdsBeforeCurrent === '') {
322322+ throw new Error('Could not find the PDS before the current one');
323323+ }
324324+325325+ let oldAgent = new AtpAgent({service: pdsBeforeCurrent});
326326+ safeStatusUpdate(statusUpdateHandler, `Logging you in to the old PDS: ${pdsBeforeCurrent}`);
327327+ //Login to the old PDS
328328+ if (twoFactorCode === null) {
329329+ await oldAgent.login({identifier: oldHandle, password: oldPassword});
330330+ } else {
331331+ await oldAgent.login({identifier: oldHandle, password: oldPassword, authFactorToken: twoFactorCode});
332332+ }
333333+ safeStatusUpdate(statusUpdateHandler, 'Checking this isn\'t your current PDS');
334334+ if (pdsBeforeCurrent === currentPds) {
335335+ throw new Error('This is your current PDS. Login to your old account username and password');
336336+ }
337337+338338+ let currentAccountStatus = await oldAgent.com.atproto.server.checkAccountStatus();
339339+ if (!currentAccountStatus.data.activated) {
340340+ safeStatusUpdate(statusUpdateHandler, 'All good. Your old account is not activated.');
341341+ }
342342+ safeStatusUpdate(statusUpdateHandler, 'Deactivating your OLD account');
343343+ await oldAgent.com.atproto.server.deactivateAccount({});
344344+ safeStatusUpdate(statusUpdateHandler, 'Successfully deactivated your OLD account');
345345+ }
346346+347347+ /**
348348+ * Signs the logged-in user in this.newAgent for backups with PDS MOOver. This is usually called after migrate and signPlcOperation are successful
349349+ *
350350+ * @param {string} didWeb
351351+ * @returns {Promise<void>}
352352+ */
353353+ async signUpForBackupsFromMigration(didWeb = 'did:web:pdsmoover.com') {
354354+355355+ //Manually grabbing the jwt and making a call with fetch cause for the life of me I could not figure out
356356+ //how you used @atproto/api to make a call for proxying
357357+ const url = `${this.newAgent.serviceUrl.origin}/xrpc/com.pdsmoover.backup.signUp`;
358358+359359+ const accessJwt = this.newAgent?.session?.accessJwt;
360360+ if (!accessJwt) {
361361+ throw new Error('Missing access token for authorization');
362362+ }
363363+364364+ const res = await fetch(url, {
365365+ method: 'POST',
366366+ headers: {
367367+ 'Authorization': `Bearer ${accessJwt}`,
368368+ 'Content-Type': 'application/json',
369369+ 'Accept': 'application/json',
370370+ 'atproto-proxy': `${didWeb}#repo_backup`,
371371+ },
372372+ body: JSON.stringify({}),
373373+ });
374374+375375+ if (!res.ok) {
376376+ let bodyText = '';
377377+ try {
378378+ bodyText = await res.text();
379379+ } catch {
380380+ }
381381+ throw new Error(`Backup signup failed: ${res.status} ${res.statusText}${bodyText ? ` - ${bodyText}` : ''}`);
382382+ }
383383+384384+ //No return the success is all that is needed, if there's an error it will throw
385385+ }
386386+}
387387+388388+export {Migrator};
389389+
+292
packages/moover/lib/plc-ops.js
···11+/**
22+ * JSDoc type-only import to avoid runtime import errors in the browser.
33+ * @typedef {import('@atcute/did-plc').defs} defs
44+ * @typedef {import('@atcute/did-plc').normalizeOp} normalizeOp
55+ * @typedef {import('@atcute/did-plc').Operation} Operation
66+ * @typedef {import('@atcute/did-plc').CompatibleOperation} CompatibleOperation
77+ * @typedef {import('@atcute/did-plc').IndexedEntryLog} IndexedEntryLog
88+ * @typedef {import('@atcute/did-plc').IndexedEntry} IndexedEntry
99+ */
1010+1111+import {defs, normalizeOp} from '@atcute/did-plc';
1212+import {P256PrivateKey, parsePrivateMultikey, Secp256k1PrivateKey, Secp256k1PrivateKeyExportable} from '@atcute/crypto';
1313+import * as CBOR from '@atcute/cbor';
1414+import {fromBase16, toBase64Url} from '@atcute/multibase';
1515+1616+1717+// Helper to base64url-encode JSON
1818+const jsonToB64Url = (obj) => {
1919+ const enc = new TextEncoder();
2020+ const json = JSON.stringify(obj);
2121+ return toBase64Url(enc.encode(json));
2222+};
2323+2424+/**
2525+ * Class to help with various PLC operations
2626+ */
2727+class PlcOps {
2828+ /**
2929+ *
3030+ * @param plcDirectoryUrl {string} - The url of the plc directory, defaults to https://plc.directory
3131+ */
3232+ constructor(plcDirectoryUrl = 'https://plc.directory') {
3333+ /**
3434+ * The url of the plc directory
3535+ * @type {string}
3636+ */
3737+ this.plcDirectoryUrl = plcDirectoryUrl;
3838+ }
3939+4040+ /**
4141+ * Gets the current rotation keys for a user via their last PlC operation
4242+ * @param did
4343+ * @returns {Promise<string[]>}
4444+ */
4545+ async getCurrentRotationKeysForUser(did) {
4646+ const logs = await this.getPlcAuditLogs(did);
4747+ const {lastOperation} = this.getLastPlcOp(logs);
4848+ return lastOperation.rotationKeys || [];
4949+ }
5050+5151+ /**
5252+ * Gets the last PlC operation for a user from the plc directory
5353+ * @param did
5454+ * @returns {Promise<{lastOperation: Operation, base: any}>}
5555+ */
5656+ async getLastPlcOpFromPlc(did) {
5757+ const logs = await this.getPlcAuditLogs(did);
5858+ return this.getLastPlcOp(logs);
5959+ }
6060+6161+ /**
6262+ *
6363+ * @param logs {IndexedEntryLog}
6464+ * @returns {{lastOperation: Operation, base: IndexedEntry}}
6565+ */
6666+ getLastPlcOp(logs) {
6767+ const lastOp = logs.at(-1);
6868+ return {lastOperation: normalizeOp(lastOp.operation), base: lastOp};
6969+ }
7070+7171+7272+ /**
7373+ * Gets the plc audit logs for a user from the plc directory
7474+ * @param did
7575+ * @returns {Promise<IndexedEntryLog>}
7676+ */
7777+ async getPlcAuditLogs(did) {
7878+ const response = await fetch(`${this.plcDirectoryUrl}/${did}/log/audit`);
7979+ if (!response.ok) {
8080+ throw new Error(`got response ${response.status}`);
8181+ }
8282+8383+ const json = await response.json();
8484+ return defs.indexedEntryLog.parse(json);
8585+ }
8686+8787+ /**
8888+ * Creates a new secp256k1 key that can be used for either rotation or verification key
8989+ * @returns {Promise<{privateKey: string, publicKey: `did:key:${string}`}>}
9090+ */
9191+ async createANewSecp256k1() {
9292+ let keypair = await Secp256k1PrivateKeyExportable.createKeypair();
9393+ let publicKey = await keypair.exportPublicKey('did');
9494+ let privateKey = await keypair.exportPrivateKey('multikey');
9595+ return {
9696+ privateKey,
9797+ publicKey
9898+ };
9999+ }
100100+101101+102102+ /**
103103+ * Signs a new operation with the provided signing key, and information and submits it to the plc directory
104104+ * @param did {string} - The user's did
105105+ * @param signingRotationKey { P256PrivateKey|Secp256k1PrivateKey} - The keypair to sign the op with
106106+ * @param alsoKnownAs {string[]}
107107+ * @param rotationKeys {string[]}
108108+ * @param pds {string}
109109+ * @param verificationKey {string} - The public verification key
110110+ * @param prev {string} - The previous valid operation's cid.
111111+ * @returns {Promise<void>}
112112+ */
113113+ async signAndPublishNewOp(did, signingRotationKey, alsoKnownAs, rotationKeys, pds, verificationKey, prev) {
114114+115115+ const rotationKeysToUse = [...new Set(rotationKeys)];
116116+ if (!rotationKeysToUse) {
117117+ throw new Error('No rotation keys were found to be added to the PLC');
118118+ }
119119+120120+ if (rotationKeysToUse.length > 5) {
121121+ throw new Error('You can only add up to 5 rotation keys to the PLC');
122122+ }
123123+124124+ const operation = {
125125+ type: 'plc_operation',
126126+ prev,
127127+ alsoKnownAs,
128128+ rotationKeys: rotationKeysToUse,
129129+ services: {
130130+ atproto_pds: {
131131+ type: 'AtprotoPersonalDataServer',
132132+ endpoint: pds
133133+ }
134134+ },
135135+ verificationMethods: {
136136+ atproto: verificationKey
137137+ }
138138+ };
139139+ const opBytes = CBOR.encode(operation);
140140+ const sigBytes = await signingRotationKey.sign(opBytes);
141141+142142+ const signature = toBase64Url(sigBytes);
143143+144144+ const signedOperation = {
145145+ ...operation,
146146+ sig: signature,
147147+ };
148148+149149+ await this.pushPlcOperation(did, signedOperation);
150150+ }
151151+152152+ /**
153153+ * Takes a multi or hex based private key and returns a keypair
154154+ * @param privateKeyString {string}
155155+ * @param type {string} - secp256k1 or p256, needed if the private key is hex based, can be assumed if it's a multikey
156156+ * @returns {Promise<{type: string, didPublicKey: `did:key:${string}`, keypair: P256PrivateKey|Secp256k1PrivateKey}>}
157157+ */
158158+ async getKeyPair(privateKeyString, type = 'secp256k1') {
159159+ const HEX_REGEX = /^[0-9a-f]+$/i;
160160+ const MULTIKEY_REGEX = /^z[a-km-zA-HJ-NP-Z1-9]+$/;
161161+ let keypair = undefined;
162162+163163+ if (HEX_REGEX.test(privateKeyString)) {
164164+ const privateKeyBytes = fromBase16(privateKeyString);
165165+166166+ switch (type) {
167167+ case 'p256': {
168168+ keypair = await P256PrivateKey.importRaw(privateKeyBytes);
169169+ break;
170170+ }
171171+ case 'secp256k1': {
172172+ keypair = await Secp256k1PrivateKey.importRaw(privateKeyBytes);
173173+ break;
174174+ }
175175+ default: {
176176+ throw new Error(`unsupported "${type}" type`);
177177+ }
178178+ }
179179+ } else if (MULTIKEY_REGEX.test(privateKeyString)) {
180180+181181+ const match = parsePrivateMultikey(privateKeyString);
182182+ const privateKeyBytes = match.privateKeyBytes;
183183+184184+ switch (match.type) {
185185+ case 'p256': {
186186+ keypair = await P256PrivateKey.importRaw(privateKeyBytes);
187187+ console.log(keypair);
188188+ break;
189189+ }
190190+ case 'secp256k1': {
191191+ keypair = await Secp256k1PrivateKey.importRaw(privateKeyBytes);
192192+ break;
193193+ }
194194+ default: {
195195+ throw new Error(`unsupported "${type}" type`);
196196+ }
197197+ }
198198+ } else {
199199+ throw new Error('unknown input format');
200200+ }
201201+ return {
202202+ type: 'private_key',
203203+ didPublicKey: await keypair.exportPublicKey('did'),
204204+ keypair: keypair,
205205+ };
206206+ }
207207+208208+ /**
209209+ * Submits a new operation to the plc directory
210210+ * @param did {string} - The user's did
211211+ * @param operation
212212+ * @returns {Promise<void>}
213213+ */
214214+ async pushPlcOperation(did, operation) {
215215+ const response = await fetch(`${this.plcDirectoryUrl}/${did}`, {
216216+ method: 'post',
217217+ headers: {
218218+ 'content-type': 'application/json',
219219+ },
220220+ body: JSON.stringify(operation),
221221+ });
222222+223223+ const headers = response.headers;
224224+ if (!response.ok) {
225225+ const type = headers.get('content-type');
226226+227227+ if (type?.includes('application/json')) {
228228+ const json = await response.json();
229229+ if (typeof json === 'object' && json !== null && typeof json.message === 'string') {
230230+ throw new Error(json.message);
231231+ }
232232+ }
233233+234234+ throw new Error(`got http ${response.status} from plc`);
235235+ }
236236+ };
237237+238238+239239+ /**
240240+ * Creates a new service auth token for a user. This is what is used to create a new account on a PDS for your did
241241+ *
242242+ * @param iss The user's did
243243+ * @param aud The did:web, if it's a PDS it's usually from /xrpc/com.atproto.server.describeServer
244244+ * @param keypair The keypair to sign with only supporting ES256K atm
245245+ * @param lxm The lxm which is usually com.atproto.server.createAccount for creating a new account
246246+ * @returns {Promise<string>}
247247+ */
248248+ async createANewServiceAuthToken(iss, aud, keypair, lxm) {
249249+250250+251251+ // Compute iat/exp defaults (60s window like reference: MINUTE/1e3)
252252+ const iat = Math.floor(Date.now() / 1e3);
253253+ const exp = iat + 60;
254254+255255+ // Generate a 16-byte hex jti
256256+ const jti = (() => {
257257+ const bytes = new Uint8Array(16);
258258+ // crypto in browser or node; fall back safely
259259+ (globalThis.crypto || window.crypto).getRandomValues(bytes);
260260+ return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
261261+ })();
262262+263263+264264+ // Build header and payload (omit undefined fields)
265265+ // Just defaulting to ES256K since p256 was not importing on firefox
266266+ const header = {typ: 'JWT', alg: 'ES256K'};
267267+ const payload = {};
268268+ payload.iat = iat;
269269+ payload.iss = iss;
270270+ payload.aud = aud;
271271+ payload.exp = exp;
272272+ payload.lxm = lxm;
273273+ payload.jti = jti;
274274+275275+ const headerB64 = jsonToB64Url(header);
276276+ const payloadB64 = jsonToB64Url(payload);
277277+ const toSignStr = `${headerB64}.${payloadB64}`;
278278+279279+ // Sign
280280+ const toSignBytes = new TextEncoder().encode(toSignStr);
281281+ const sigBytes = await keypair.sign(toSignBytes);
282282+283283+ // Return compact JWS
284284+ const sigB64 = toBase64Url(sigBytes);
285285+ return `${toSignStr}.${sigB64}`;
286286+ }
287287+288288+289289+}
290290+291291+292292+export {PlcOps};
+331
packages/moover/lib/restore.js
···11+/**
22+ * @typedef {import('@atcute/did-plc').Operation} Operation
33+ */
44+import {P256PrivateKey, Secp256k1PrivateKey} from '@atcute/crypto';
55+import {handleAndPDSResolver} from './atprotoUtils.js';
66+import {PlcOps} from './plc-ops.js';
77+import {normalizeOp} from '@atcute/did-plc';
88+import {AtpAgent} from '@atproto/api';
99+import {Secp256k1PrivateKeyExportable} from '@atcute/crypto';
1010+import * as CBOR from '@atcute/cbor';
1111+import {toBase64Url} from '@atcute/multibase';
1212+1313+class Restore {
1414+1515+ /**
1616+ *
1717+ * @param pdsMooverInstance {string} - The url of the pds moover instance to restore from. Defaults to https://pdsmover.com
1818+ */
1919+ constructor(pdsMooverInstance = 'https://pdsmover.com') {
2020+ /**
2121+ * If you want to use a different plc directory create your own instance of the plc ops class and pass it in here
2222+ * @type {PlcOps} */
2323+ this.plcOps = new PlcOps();
2424+2525+ /**
2626+ * This is the base url for the pds moover instance used to restore the files from a backup.
2727+ * @type {string}
2828+ */
2929+ this.pdsMooverInstance = pdsMooverInstance
3030+3131+ /**
3232+ * To keep it simple, only uses secp256k for the temp verification key that is used to create the new account on the new PDS
3333+ * and is temporarily assigned to the user's account on PLC
3434+ * @type {null|Secp256k1PrivateKeyExportable}
3535+ */
3636+ this.tempVerificationKeypair = null;
3737+3838+ /** @type {AtpAgent} */
3939+ this.atpAgent = null;
4040+4141+ /**
4242+ * The keypair that is used to sign the plc operation
4343+ * @type {null|{type: string, didPublicKey: `did:key:${string}`, keypair: P256PrivateKey|Secp256k1PrivateKey}}
4444+ */
4545+ this.recoveryRotationKeyPair = null;
4646+4747+ /**
4848+ * If this is true we are just restoring the repo and blobs. Ideally for rerunning a restore process after account recovery
4949+ * @type {boolean}
5050+ */
5151+ this.RestoreFromBackup = true;
5252+5353+ /**
5454+ * If set to true then it will do the account recovery. Writes a temp key to the did doc,
5555+ * create a new account on the new pds, and then submit a new plc op for the pds to have control (finishes the migration, can always restore the backup later)
5656+ * @type {boolean}
5757+ */
5858+ this.AccountRecovery = true;
5959+ }
6060+6161+ /**
6262+ * Recovers an account with the users rotation key and restores the repo from a PDS MOOver backup
6363+ * This method can fail, and the account was still recovered, it's best to check the PLC logs to see where an account stands before reruns
6464+ * @param rotationKey {string} - The users private rotation key, can be a multi key or hex key
6565+ * @param rotationKeyType {string} - The type of the key, secp256k1 or p256. Required if the key is in hex format, defaults to secp256k1
6666+ * @param currentHandleOrDid {string} - The users current handle or did, if they don't have a DNS record it will have to be their did for success
6767+ * @param newPDS {string} - The new PDS url, like https://coolnewpds.com
6868+ * @param newHandle {string} - Can be the users DNS handle if it is already setup with their did, if not it's bob.mypds.com
6969+ * @param newPassword {string} - The new password for the new account
7070+ * @param newEmail {string} - The new email for the new account
7171+ * @param inviteCode {string|null} - The invite code for the new PDS if it requires one
7272+ * @param cidToRestoreTo {string|null} - The cid of the plc op to restore to, used mostly to revert a fraudulent plc op. Want to give it the last valid operations cid
7373+ * @param onStatus {function|null} - A function that takes a string used to update the UI. Like (status) => console.log(status)
7474+ * @returns {Promise<void>} If there is a failure during restoring the back up (after the status Success! Restoring your repo...) then your account is most likely
7575+ * recovered and future runs need to have the RestoreFromBackup flag set to true and AccountRecovery set to false.
7676+ */
7777+ async recover(
7878+ rotationKey,
7979+ rotationKeyType = 'secp256k1',
8080+ currentHandleOrDid,
8181+ newPDS,
8282+ newHandle,
8383+ newPassword,
8484+ newEmail,
8585+ inviteCode,
8686+ cidToRestoreTo = null,
8787+ onStatus = null) {
8888+8989+ if (onStatus) onStatus('Resolving your handle...');
9090+9191+ let {usersDid} = await handleAndPDSResolver(currentHandleOrDid);
9292+9393+ if (onStatus) onStatus('Checking that the new PDS is an actual PDS (if the url is wrong, this takes a while to error out)');
9494+ this.atpAgent = new AtpAgent({service: newPDS});
9595+ const newHostDesc = await this.atpAgent.com.atproto.server.describeServer();
9696+9797+9898+ //Check to see if the user already has a repo on the new PDS, if they do no reason to try and restore via the plc operations
9999+ try {
100100+ await this.atpAgent.com.atproto.repo.describeRepo({repo: usersDid.toString()});
101101+ //If we got this far and there is a repo on the new PDS with the users did, we can just move on and restore the files.
102102+ //We do not want to mess with the plc ops if we dont have to
103103+ this.AccountRecovery = false;
104104+105105+ } catch (error) {
106106+ console.error(error);
107107+ let parsedError = error.error;
108108+ if (parsedError === 'RepoDeactivated') {
109109+ //Ideally should mean they already have a repo on the new PDS and we just need to restore the files
110110+ this.AccountRecovery = false;
111111+ }
112112+ //This is the error we want to see, anything else throw
113113+ if (parsedError !== 'RepoNotFound') {
114114+ throw error;
115115+ }
116116+ }
117117+118118+ //We need to double check that the new handle has not been taken, if it has we need to throw an error
119119+ //We care a bit more because we do not want any unnecessary plc ops to be created
120120+ try {
121121+ let resolveHandle = await this.atpAgent.com.atproto.identity.resolveHandle({handle: newHandle});
122122+ if (resolveHandle.data.did === usersDid.toString()) {
123123+ //This was originally setting the AccountRecovery to false, which works if it is resolved via .well-known, but not dns
124124+ //The idea was to check and see if the handle has been taken. just leaving for now since it does that check and if the user owns the handle
125125+ //their did should be set anyhow
126126+127127+ } else {
128128+ //There is a repo with that name and it's not the users did,
129129+ throw new Error('The new handle is already taken, please select a different handle');
130130+ }
131131+ } catch (error) {
132132+ // Going to silently log this and just assume the handle has not been taken.
133133+ console.error(error);
134134+ if (error.message.startsWith('The new handle')) {
135135+ //it's not our custom error, so we can just throw it
136136+ throw error;
137137+ }
138138+139139+ }
140140+141141+ if (this.AccountRecovery) {
142142+143143+ if (onStatus) onStatus('Validating your private rotation key is in the correct format...');
144144+145145+ this.recoveryRotationKeyPair = await this.plcOps.getKeyPair(rotationKey, rotationKeyType);
146146+147147+148148+ if (onStatus) onStatus('Resolving PlC operation logs...');
149149+150150+ /** @type {Operation} */
151151+ let baseOpForSigning = null;
152152+ let opPrevCid = null;
153153+154154+ //This is for reversals against a rogue plc op and you want to restore to a specific cid in the audit log
155155+ if (cidToRestoreTo) {
156156+ let auditLogs = await this.plcOps.getPlcAuditLogs(usersDid);
157157+ for (const log of auditLogs) {
158158+ if (log.cid === cidToRestoreTo) {
159159+ baseOpForSigning = normalizeOp(log.operation);
160160+ opPrevCid = log.cid;
161161+ break;
162162+ }
163163+ }
164164+ if (!baseOpForSigning) {
165165+ throw new Error('Could not find the cid in the audit logs');
166166+ }
167167+ } else {
168168+ let {lastOperation, base} = await this.plcOps.getLastPlcOpFromPlc(usersDid);
169169+ opPrevCid = base.cid;
170170+ baseOpForSigning = lastOperation;
171171+ }
172172+173173+ if (onStatus) onStatus('Preparing to switch to a temp atproto key...');
174174+ if (this.tempVerificationKeypair == null) {
175175+ if (onStatus) onStatus('Creating a new temp atproto key...');
176176+ this.tempVerificationKeypair = await Secp256k1PrivateKeyExportable.createKeypair();
177177+ }
178178+ //Just defaulting to the user's recovery key for now. Advance cases will be something else
179179+ //Maybe just a new ui to edit the PLC doc in a limited capacity, but sinc ethis is a temp plc op i don't think it's needed
180180+ let tempRotationKeys = [this.recoveryRotationKeyPair.didPublicKey];
181181+182182+ if (onStatus) onStatus('Modifying the PLC OP for recovery...');
183183+ //A temp plc op for control of the atproto key to create a serviceAuth and new account on the new PDS
184184+ await this.plcOps.signAndPublishNewOp(
185185+ usersDid,
186186+ this.recoveryRotationKeyPair.keypair,
187187+ baseOpForSigning.alsoKnownAs,
188188+ tempRotationKeys,
189189+ newPDS,
190190+ await this.tempVerificationKeypair.exportPublicKey('did'),
191191+ opPrevCid);
192192+193193+194194+ if (onStatus) onStatus('Creating your new account on the new PDS...');
195195+ let serviceAuthToken = await this.plcOps.createANewServiceAuthToken(usersDid, newHostDesc.data.did, this.tempVerificationKeypair, 'com.atproto.server.createAccount');
196196+197197+ let createAccountRequest = {
198198+ did: usersDid,
199199+ handle: newHandle,
200200+ email: newEmail,
201201+ password: newPassword,
202202+ };
203203+ if (inviteCode) {
204204+ createAccountRequest.inviteCode = inviteCode;
205205+ }
206206+ const _ = await this.atpAgent.com.atproto.server.createAccount(
207207+ createAccountRequest,
208208+ {
209209+ headers: {authorization: `Bearer ${serviceAuthToken}`},
210210+ encoding: 'application/json',
211211+ });
212212+ }
213213+214214+ await this.atpAgent.login({
215215+ identifier: usersDid,
216216+ password: newPassword,
217217+ });
218218+219219+ if (this.AccountRecovery) {
220220+ //Moving the user offically to the new PDS
221221+ if (onStatus) onStatus('Signing the papers...');
222222+ let {base} = await this.plcOps.getLastPlcOpFromPlc(usersDid);
223223+ await this.signRestorePlcOperation(usersDid, [this.recoveryRotationKeyPair.didPublicKey], base.cid);
224224+ }
225225+226226+ if (this.RestoreFromBackup) {
227227+ if (onStatus) onStatus('Success! Restoring your repo...');
228228+ const pdsMoover = new AtpAgent({service: this.pdsMooverInstance});
229229+ const repoRes = await pdsMoover.com.atproto.sync.getRepo({did: usersDid});
230230+ await this.atpAgent.com.atproto.repo.importRepo(repoRes.data, {
231231+ encoding: 'application/vnd.ipld.car',
232232+ });
233233+234234+ if (onStatus) onStatus('Restoring your blobs...');
235235+236236+ //Using the missing endpoint to findout what's missing then the PDS MOOver endpoint to restore
237237+ let totalMissingBlobs = 0;
238238+ let missingBlobCursor = undefined;
239239+ let missingUploadedBlobs = 0;
240240+241241+ do {
242242+243243+ const missingBlobs = await this.atpAgent.com.atproto.repo.listMissingBlobs({
244244+ cursor: missingBlobCursor,
245245+ limit: 1000,
246246+ });
247247+ totalMissingBlobs += missingBlobs.data.blobs.length;
248248+249249+ for (const recordBlob of missingBlobs.data.blobs) {
250250+ try {
251251+252252+ const blobRes = await pdsMoover.com.atproto.sync.getBlob({
253253+ did: usersDid,
254254+ cid: recordBlob.cid,
255255+ });
256256+ let result = await this.atpAgent.com.atproto.repo.uploadBlob(blobRes.data, {
257257+ encoding: blobRes.headers['content-type'],
258258+ });
259259+260260+261261+ if (missingUploadedBlobs % 2 === 0) {
262262+ if (onStatus) onStatus(`Migrating blobs: ${missingUploadedBlobs}/${totalMissingBlobs} (The total may increase as we find more)`);
263263+ }
264264+ missingUploadedBlobs++;
265265+ } catch (error) {
266266+ console.error(error);
267267+ }
268268+ }
269269+ missingBlobCursor = missingBlobs.data.cursor;
270270+ } while (missingBlobCursor);
271271+ }
272272+ const accountStatus = await this.atpAgent.com.atproto.server.checkAccountStatus();
273273+ if (!accountStatus.data.activated) {
274274+ if (onStatus) onStatus('Activating your account...');
275275+ await this.atpAgent.com.atproto.server.activateAccount();
276276+ }
277277+278278+ }
279279+280280+281281+ /**
282282+ * This method signs the plc operation over to the new PDS and activates the account
283283+ * Assumes you have already created a new account during the recovery process and logged in
284284+ * Uses the recommended did doc from the PDS as a base and adds the users rotation key to the rotation keys array
285285+ *
286286+ * @param usersDid
287287+ * @param additionalRotationKeysToAdd
288288+ * @param prevCid
289289+ * @returns {Promise<void>}
290290+ */
291291+ async signRestorePlcOperation(usersDid, additionalRotationKeysToAdd = [], prevCid) {
292292+ const getDidCredentials =
293293+ await this.atpAgent.com.atproto.identity.getRecommendedDidCredentials();
294294+295295+ const pdsProvidedRotationKeys = getDidCredentials.data.rotationKeys ?? [];
296296+ //Puts the provided rotation keys above the pds pro
297297+ const rotationKeys = [...new Set([...(additionalRotationKeysToAdd || []), ...pdsProvidedRotationKeys])];
298298+ if (!rotationKeys) {
299299+ throw new Error('No rotation keys were found to be added to the PLC');
300300+ }
301301+302302+ if (rotationKeys.length > 5) {
303303+ throw new Error('You can only add up to 5 rotation keys to the PLC');
304304+ }
305305+306306+307307+ const plcOpToSubmit = {
308308+ type: 'plc_operation',
309309+ ...getDidCredentials.data,
310310+ prev: prevCid,
311311+ rotationKeys: rotationKeys,
312312+ };
313313+314314+315315+ const opBytes = CBOR.encode(plcOpToSubmit);
316316+ const sigBytes = await this.recoveryRotationKeyPair.keypair.sign(opBytes);
317317+318318+ const signature = toBase64Url(sigBytes);
319319+320320+ const signedOperation = {
321321+ ...plcOpToSubmit,
322322+ sig: signature,
323323+ };
324324+325325+ await this.plcOps.pushPlcOperation(usersDid, signedOperation);
326326+ await this.atpAgent.com.atproto.server.activateAccount();
327327+328328+ }
329329+}
330330+331331+export {Restore};
···11+export const handleResolver: CompositeHandleResolver;
22+export const docResolver: CompositeDidDocumentResolver<"plc" | "web">;
33+/**
44+ * Cleans the handle of @ and some other unicode characters that used to show up when copied from the profile
55+ * @param handle {string}
66+ * @returns {string}
77+ */
88+export function cleanHandle(handle: string): string;
99+/**
1010+ * Convince helper to resolve a handle to a did and then find the PDS url from the did document.
1111+ *
1212+ * @param handle
1313+ * @returns {Promise<{usersDid: string, pds: string}>}
1414+ */
1515+export function handleAndPDSResolver(handle: any): Promise<{
1616+ usersDid: string;
1717+ pds: string;
1818+}>;
1919+/**
2020+ * Fetches the DID Web from the .well-known/did.json endpoint of the server.
2121+ * Legacy and was helpful if the web ui and server are on the same domain, not as useful now
2222+ * @param baseUrl
2323+ * @returns {Promise<*>}
2424+ */
2525+export function fetchPDSMooverDIDWeb(baseUrl: any): Promise<any>;
2626+import { CompositeHandleResolver } from '@atcute/identity-resolver';
2727+import { CompositeDidDocumentResolver } from '@atcute/identity-resolver';
2828+//# sourceMappingURL=atprotoUtils.d.ts.map
···11+/**
22+ * JSDoc type-only import to avoid runtime import errors in the browser.
33+ */
44+export type InferXRPCBodyOutput = any;
55+/**
66+ * JSDoc type-only import to avoid runtime import errors in the browser.
77+ * @typedef {import('@atcute/lexicons').InferXRPCBodyOutput} InferXRPCBodyOutput
88+ */
99+/**
1010+ * Logic to sign up and manage backups for pdsmoover.com (or your own selfhosted instance)
1111+ */
1212+export class BackupService {
1313+ /**
1414+ *
1515+ * @param backupDidWeb {string} - The did:web for the xrpc service for backups, defaults to did:web:pdsmoover.com
1616+ */
1717+ constructor(backupDidWeb?: string);
1818+ /**
1919+ *
2020+ * @type {Client}
2121+ */
2222+ atCuteClient: Client;
2323+ /**
2424+ *
2525+ * @type {CredentialManager}
2626+ */
2727+ atCuteCredentialManager: CredentialManager;
2828+ /**
2929+ * The did:web for the xrpc service for backups, defaults to pdsmoover.com
3030+ * @type {string}
3131+ */
3232+ backupDidWeb: string;
3333+ /**
3434+ * Logs in and returns the backup status.
3535+ * To use the rest of the BackupService, it is assumed that this has ran first,
3636+ * and the user has successfully signed up. A successful login is a returned null if the user has not signed up.
3737+ * or the backup status if they are
3838+ *
3939+ * If the server requires 2FA,
4040+ * it will throw with error.error === 'AuthFactorTokenRequired'.
4141+ * @param identifier {string} handle or did
4242+ * @param password {string}
4343+ * @param {function|null} onStatus - a function that takes a string used to update the UI.
4444+ * Like (status) => console.log(status)
4545+ * @param twoFactorCode {string|null}
4646+ *
4747+ * @returns {Promise<InferXRPCBodyOutput<ComPdsmooverBackupDescribeServer.mainSchema['output']>|null>}
4848+ */
4949+ loginAndStatus(identifier: string, password: string, onStatus?: Function | null, twoFactorCode?: string | null): Promise<InferXRPCBodyOutput<ComPdsmooverBackupDescribeServer.mainSchema["output"]> | null>;
5050+ /**
5151+ * Signs the user up for backups with the service
5252+ * @param onStatus
5353+ * @returns {Promise<void>}
5454+ */
5555+ signUp(onStatus?: any): Promise<void>;
5656+ /**
5757+ * Requests a PLC token to be sent to the user's email, needed to add a new rotation key
5858+ * @returns {Promise<void>}
5959+ */
6060+ requestAPlcToken(): Promise<void>;
6161+ /**
6262+ * Adds a new rotation to the users did document. Assumes you are already signed in.
6363+ *
6464+ * WARNING: This will overwrite any existing rotation keys with the new one at the top, and the PDS key as the second one
6565+ * @param plcToken {string} - PLC token from the user's email that was sent from requestAPlcToken
6666+ * @param rotationKey {string} - The new rotation key to add to the user's did document
6767+ * @returns {Promise<void>}
6868+ */
6969+ addANewRotationKey(plcToken: string, rotationKey: string): Promise<void>;
7070+ /**
7171+ *
7272+ * Gets the current status of the user's backup repository.
7373+ *
7474+ * @param onStatus {function|null} - a function that takes a string used to update the UI.
7575+ * @returns {Promise<InferXRPCBodyOutput<ComPdsmooverBackupDescribeServer.mainSchema['output']>>}
7676+ */
7777+ getUsersRepoStatus(onStatus?: Function | null): Promise<InferXRPCBodyOutput<ComPdsmooverBackupDescribeServer.mainSchema["output"]>>;
7878+ /**
7979+ * Requests a backup to be run immediately for the signed-in user. Usually does, depend on the server's backup queue
8080+ * @param onStatus
8181+ * @returns {Promise<boolean>}
8282+ */
8383+ runBackupNow(onStatus?: any): Promise<boolean>;
8484+ /**
8585+ * Remove (delete) the signed-in user's backup repository. this also deletes all the user's backup data.
8686+ * @param onStatus
8787+ * @returns {Promise<boolean>}
8888+ */
8989+ removeRepo(onStatus?: any): Promise<boolean>;
9090+}
9191+import { Client } from '@atcute/client';
9292+import { CredentialManager } from '@atcute/client';
9393+import { ComPdsmooverBackupDescribeServer } from '@pds-moover/lexicons';
9494+//# sourceMappingURL=backup.d.ts.map
···11+/**
22+ * Class to help find missing blobs from the did's previous PDS and import them into the current PDS
33+ */
44+export class MissingBlobs {
55+ /**
66+ * The user's current PDS agent
77+ * @type {AtpAgent}
88+ */
99+ currentPdsAgent: AtpAgent;
1010+ /**
1111+ * The user's old PDS agent
1212+ * @type {AtpAgent}
1313+ */
1414+ oldPdsAgent: AtpAgent;
1515+ /**
1616+ * the user's did
1717+ * @type {string|null}
1818+ */
1919+ did: string | null;
2020+ /**
2121+ * The user's current PDS url
2222+ * @type {null}
2323+ */
2424+ currentPdsUrl: any;
2525+ /**
2626+ * A list of the missing cids blobs from the old PDS. In this case if a retry upload fails it gets put in this array for the ui
2727+ * @type {string[]}
2828+ */
2929+ missingBlobs: string[];
3030+ /**
3131+ * Logs the user into the current PDS and gets the account status
3232+ * @param handle {string}
3333+ * @param password {string}
3434+ * @param twoFactorCode {string|null}
3535+ * @returns {Promise<{accountStatus: OutputSchema, missingBlobsCount: number}>}
3636+ */
3737+ currentAgentLogin(handle: string, password: string, twoFactorCode?: string | null): Promise<{
3838+ accountStatus: OutputSchema;
3939+ missingBlobsCount: number;
4040+ }>;
4141+ /**
4242+ * Logs into the old PDS and gets the account status.
4343+ * Does not need a handle
4444+ * since it is assumed the user has already logged in with the current PDS and we are using their did
4545+ * @param password {string}
4646+ * @param twoFactorCode {string|null}
4747+ * @param pdsUrl {string|null} - If you know the url of the old PDS you can pass it in here. If not it will be guessed at from plc ops
4848+ * @returns {Promise<void>}
4949+ */
5050+ oldAgentLogin(password: string, twoFactorCode?: string | null, pdsUrl?: string | null): Promise<void>;
5151+ /**
5252+ * Gets the missing blobs from the old PDS and uploads them to the current PDS
5353+ * @param statusUpdateHandler {function} - A function to update the status of the migration. This is useful for showing the user the progress of the migration
5454+ * @returns {Promise<{accountStatus: OutputSchema, missingBlobsCount: number}>}
5555+ */
5656+ migrateMissingBlobs(statusUpdateHandler: Function): Promise<{
5757+ accountStatus: OutputSchema;
5858+ missingBlobsCount: number;
5959+ }>;
6060+}
6161+import { AtpAgent } from '@atproto/api';
6262+//# sourceMappingURL=missingBlobs.d.ts.map
···11+/**
22+ * Handles normal PDS Migrations between two PDSs that are both up.
33+ * On pdsmoover.com this is the logic for the MOOver
44+ */
55+export class Migrator {
66+ /** @type {AtpAgent} */
77+ oldAgent: AtpAgent;
88+ /** @type {AtpAgent} */
99+ newAgent: AtpAgent;
1010+ /** @type {[string]} */
1111+ missingBlobs: [string];
1212+ /** @type {boolean} */
1313+ createNewAccount: boolean;
1414+ /** @type {boolean} */
1515+ migrateRepo: boolean;
1616+ /** @type {boolean} */
1717+ migrateBlobs: boolean;
1818+ /** @type {boolean} */
1919+ migrateMissingBlobs: boolean;
2020+ /** @type {boolean} */
2121+ migratePrefs: boolean;
2222+ /** @type {boolean} */
2323+ migratePlcRecord: boolean;
2424+ /**
2525+ * This migrator is pretty cut and dry and makes a few assumptions
2626+ * 1. You are using the same password between each account
2727+ * 2. If this command fails for something like oauth 2fa code it throws an error and expects the same values when ran again.
2828+ * 3. You can control which "actions" happen by setting the class variables to false.
2929+ * 4. Each instance of the class is assumed to be for a single migration
3030+ * @param {string} oldHandle - The handle you use on your old pds, something like alice.bsky.social
3131+ * @param {string} password - Your password for your current login. Has to be your real password, no app password. When setting up a new account we reuse it as well for that account
3232+ * @param {string} newPdsUrl - The new URL for your pds. Like https://coolnewpds.com
3333+ * @param {string} newEmail - The email you want to use on the new pds (can be the same as the previous one as long as it's not already being used on the new pds)
3434+ * @param {string} newHandle - The new handle you want, like alice.bsky.social, or if you already have a domain name set as a handle can use it myname.com.
3535+ * @param {string|null} inviteCode - The invite code you got from the PDS you are migrating to. If null does not include one
3636+ * @param {function|null} statusUpdateHandler - a function that takes a string used to update the UI. Like (status) => console.log(status)
3737+ * @param {string|null} twoFactorCode - Optional, but needed if it fails with 2fa required
3838+ */
3939+ migrate(oldHandle: string, password: string, newPdsUrl: string, newEmail: string, newHandle: string, inviteCode: string | null, statusUpdateHandler?: Function | null, twoFactorCode?: string | null): Promise<void>;
4040+ /**
4141+ * Sign and submits the PLC operation to officially migrate the account
4242+ * @param {string} token - the PLC token sent in the email. If you're just wanting to run this rerun migrate with all the flags set as false except for migratePlcRecord
4343+ * @param additionalRotationKeysToAdd {string[]} - additional rotation keys to add in addition to the ones provided by the new PDS.
4444+ * @returns {Promise<void>}
4545+ */
4646+ signPlcOperation(token: string, additionalRotationKeysToAdd?: string[]): Promise<void>;
4747+ /**
4848+ * Using this method assumes the Migrator class was constructed new and this was called.
4949+ * Find the user's previous PDS from the PLC op logs,
5050+ * logs in and deactivates their old account if it was found still active.
5151+ *
5252+ * @param oldHandle {string}
5353+ * @param oldPassword {string}
5454+ * @param {function|null} statusUpdateHandler - a function that takes a string used to update the UI.
5555+ * Like (status) => console.log(status)
5656+ * @param {string|null} twoFactorCode - Optional, but needed if it fails with 2fa required
5757+ * @returns {Promise<void>}
5858+ */
5959+ deactivateOldAccount(oldHandle: string, oldPassword: string, statusUpdateHandler?: Function | null, twoFactorCode?: string | null): Promise<void>;
6060+ /**
6161+ * Signs the logged-in user in this.newAgent for backups with PDS MOOver. This is usually called after migrate and signPlcOperation are successful
6262+ *
6363+ * @param {string} didWeb
6464+ * @returns {Promise<void>}
6565+ */
6666+ signUpForBackupsFromMigration(didWeb?: string): Promise<void>;
6767+}
6868+import { AtpAgent } from '@atproto/api';
6969+//# sourceMappingURL=pdsmoover.d.ts.map
···11+/**
22+ * JSDoc type-only import to avoid runtime import errors in the browser.
33+ */
44+export type defs = typeof defs;
55+/**
66+ * JSDoc type-only import to avoid runtime import errors in the browser.
77+ */
88+export type normalizeOp = any;
99+/**
1010+ * JSDoc type-only import to avoid runtime import errors in the browser.
1111+ */
1212+export type Operation = import("@atcute/did-plc").Operation;
1313+/**
1414+ * JSDoc type-only import to avoid runtime import errors in the browser.
1515+ */
1616+export type CompatibleOperation = import("@atcute/did-plc").CompatibleOperation;
1717+/**
1818+ * JSDoc type-only import to avoid runtime import errors in the browser.
1919+ */
2020+export type IndexedEntryLog = import("@atcute/did-plc").IndexedEntryLog;
2121+/**
2222+ * JSDoc type-only import to avoid runtime import errors in the browser.
2323+ */
2424+export type IndexedEntry = import("@atcute/did-plc").IndexedEntry;
2525+/**
2626+ * Class to help with various PLC operations
2727+ */
2828+export class PlcOps {
2929+ /**
3030+ *
3131+ * @param plcDirectoryUrl {string} - The url of the plc directory, defaults to https://plc.directory
3232+ */
3333+ constructor(plcDirectoryUrl?: string);
3434+ /**
3535+ * The url of the plc directory
3636+ * @type {string}
3737+ */
3838+ plcDirectoryUrl: string;
3939+ /**
4040+ * Gets the current rotation keys for a user via their last PlC operation
4141+ * @param did
4242+ * @returns {Promise<string[]>}
4343+ */
4444+ getCurrentRotationKeysForUser(did: any): Promise<string[]>;
4545+ /**
4646+ * Gets the last PlC operation for a user from the plc directory
4747+ * @param did
4848+ * @returns {Promise<{lastOperation: Operation, base: any}>}
4949+ */
5050+ getLastPlcOpFromPlc(did: any): Promise<{
5151+ lastOperation: Operation;
5252+ base: any;
5353+ }>;
5454+ /**
5555+ *
5656+ * @param logs {IndexedEntryLog}
5757+ * @returns {{lastOperation: Operation, base: IndexedEntry}}
5858+ */
5959+ getLastPlcOp(logs: IndexedEntryLog): {
6060+ lastOperation: Operation;
6161+ base: IndexedEntry;
6262+ };
6363+ /**
6464+ * Gets the plc audit logs for a user from the plc directory
6565+ * @param did
6666+ * @returns {Promise<IndexedEntryLog>}
6767+ */
6868+ getPlcAuditLogs(did: any): Promise<IndexedEntryLog>;
6969+ /**
7070+ * Creates a new secp256k1 key that can be used for either rotation or verification key
7171+ * @returns {Promise<{privateKey: string, publicKey: `did:key:${string}`}>}
7272+ */
7373+ createANewSecp256k1(): Promise<{
7474+ privateKey: string;
7575+ publicKey: `did:key:${string}`;
7676+ }>;
7777+ /**
7878+ * Signs a new operation with the provided signing key, and information and submits it to the plc directory
7979+ * @param did {string} - The user's did
8080+ * @param signingRotationKey { P256PrivateKey|Secp256k1PrivateKey} - The keypair to sign the op with
8181+ * @param alsoKnownAs {string[]}
8282+ * @param rotationKeys {string[]}
8383+ * @param pds {string}
8484+ * @param verificationKey {string} - The public verification key
8585+ * @param prev {string} - The previous valid operation's cid.
8686+ * @returns {Promise<void>}
8787+ */
8888+ signAndPublishNewOp(did: string, signingRotationKey: P256PrivateKey | Secp256k1PrivateKey, alsoKnownAs: string[], rotationKeys: string[], pds: string, verificationKey: string, prev: string): Promise<void>;
8989+ /**
9090+ * Takes a multi or hex based private key and returns a keypair
9191+ * @param privateKeyString {string}
9292+ * @param type {string} - secp256k1 or p256, needed if the private key is hex based, can be assumed if it's a multikey
9393+ * @returns {Promise<{type: string, didPublicKey: `did:key:${string}`, keypair: P256PrivateKey|Secp256k1PrivateKey}>}
9494+ */
9595+ getKeyPair(privateKeyString: string, type?: string): Promise<{
9696+ type: string;
9797+ didPublicKey: `did:key:${string}`;
9898+ keypair: P256PrivateKey | Secp256k1PrivateKey;
9999+ }>;
100100+ /**
101101+ * Submits a new operation to the plc directory
102102+ * @param did {string} - The user's did
103103+ * @param operation
104104+ * @returns {Promise<void>}
105105+ */
106106+ pushPlcOperation(did: string, operation: any): Promise<void>;
107107+ /**
108108+ * Creates a new service auth token for a user. This is what is used to create a new account on a PDS for your did
109109+ *
110110+ * @param iss The user's did
111111+ * @param aud The did:web, if it's a PDS it's usually from /xrpc/com.atproto.server.describeServer
112112+ * @param keypair The keypair to sign with only supporting ES256K atm
113113+ * @param lxm The lxm which is usually com.atproto.server.createAccount for creating a new account
114114+ * @returns {Promise<string>}
115115+ */
116116+ createANewServiceAuthToken(iss: any, aud: any, keypair: any, lxm: any): Promise<string>;
117117+}
118118+import { defs } from '@atcute/did-plc';
119119+import { P256PrivateKey } from '@atcute/crypto';
120120+import { Secp256k1PrivateKey } from '@atcute/crypto';
121121+//# sourceMappingURL=plc-ops.d.ts.map
···11+export type Operation = import("@atcute/did-plc").Operation;
22+export class Restore {
33+ /**
44+ *
55+ * @param pdsMooverInstance {string} - The url of the pds moover instance to restore from. Defaults to https://pdsmover.com
66+ */
77+ constructor(pdsMooverInstance?: string);
88+ /**
99+ * If you want to use a different plc directory create your own instance of the plc ops class and pass it in here
1010+ * @type {PlcOps} */
1111+ plcOps: PlcOps;
1212+ /**
1313+ * This is the base url for the pds moover instance used to restore the files from a backup.
1414+ * @type {string}
1515+ */
1616+ pdsMooverInstance: string;
1717+ /**
1818+ * To keep it simple, only uses secp256k for the temp verification key that is used to create the new account on the new PDS
1919+ * and is temporarily assigned to the user's account on PLC
2020+ * @type {null|Secp256k1PrivateKeyExportable}
2121+ */
2222+ tempVerificationKeypair: null | Secp256k1PrivateKeyExportable;
2323+ /** @type {AtpAgent} */
2424+ atpAgent: AtpAgent;
2525+ /**
2626+ * The keypair that is used to sign the plc operation
2727+ * @type {null|{type: string, didPublicKey: `did:key:${string}`, keypair: P256PrivateKey|Secp256k1PrivateKey}}
2828+ */
2929+ recoveryRotationKeyPair: null | {
3030+ type: string;
3131+ didPublicKey: `did:key:${string}`;
3232+ keypair: P256PrivateKey | Secp256k1PrivateKey;
3333+ };
3434+ /**
3535+ * If this is true we are just restoring the repo and blobs. Ideally for rerunning a restore process after account recovery
3636+ * @type {boolean}
3737+ */
3838+ RestoreFromBackup: boolean;
3939+ /**
4040+ * If set to true then it will do the account recovery. Writes a temp key to the did doc,
4141+ * create a new account on the new pds, and then submit a new plc op for the pds to have control (finishes the migration, can always restore the backup later)
4242+ * @type {boolean}
4343+ */
4444+ AccountRecovery: boolean;
4545+ /**
4646+ * Recovers an account with the users rotation key and restores the repo from a PDS MOOver backup
4747+ * This method can fail, and the account was still recovered, it's best to check the PLC logs to see where an account stands before reruns
4848+ * @param rotationKey {string} - The users private rotation key, can be a multi key or hex key
4949+ * @param rotationKeyType {string} - The type of the key, secp256k1 or p256. Required if the key is in hex format, defaults to secp256k1
5050+ * @param currentHandleOrDid {string} - The users current handle or did, if they don't have a DNS record it will have to be their did for success
5151+ * @param newPDS {string} - The new PDS url, like https://coolnewpds.com
5252+ * @param newHandle {string} - Can be the users DNS handle if it is already setup with their did, if not it's bob.mypds.com
5353+ * @param newPassword {string} - The new password for the new account
5454+ * @param newEmail {string} - The new email for the new account
5555+ * @param inviteCode {string|null} - The invite code for the new PDS if it requires one
5656+ * @param cidToRestoreTo {string|null} - The cid of the plc op to restore to, used mostly to revert a fraudulent plc op. Want to give it the last valid operations cid
5757+ * @param onStatus {function|null} - A function that takes a string used to update the UI. Like (status) => console.log(status)
5858+ * @returns {Promise<void>} If there is a failure during restoring the back up (after the status Success! Restoring your repo...) then your account is most likely
5959+ * recovered and future runs need to have the RestoreFromBackup flag set to true and AccountRecovery set to false.
6060+ */
6161+ recover(rotationKey: string, rotationKeyType: string, currentHandleOrDid: string, newPDS: string, newHandle: string, newPassword: string, newEmail: string, inviteCode: string | null, cidToRestoreTo?: string | null, onStatus?: Function | null): Promise<void>;
6262+ /**
6363+ * This method signs the plc operation over to the new PDS and activates the account
6464+ * Assumes you have already created a new account during the recovery process and logged in
6565+ * Uses the recommended did doc from the PDS as a base and adds the users rotation key to the rotation keys array
6666+ *
6767+ * @param usersDid
6868+ * @param additionalRotationKeysToAdd
6969+ * @param prevCid
7070+ * @returns {Promise<void>}
7171+ */
7272+ signRestorePlcOperation(usersDid: any, additionalRotationKeysToAdd: any[], prevCid: any): Promise<void>;
7373+}
7474+import { PlcOps } from './plc-ops.js';
7575+import { Secp256k1PrivateKeyExportable } from '@atcute/crypto';
7676+import { AtpAgent } from '@atproto/api';
7777+import { P256PrivateKey } from '@atcute/crypto';
7878+import { Secp256k1PrivateKey } from '@atcute/crypto';
7979+//# sourceMappingURL=restore.d.ts.map
···6868 .await
6969 .map_err(|e| Error::Failed(Arc::new(Box::new(e))))?;
70707171- let missing_cids = filter_missing_blob_cids(&pool, &resp.cids)
7171+ let missing_cids = filter_missing_blob_cids(&pool, &resp.cids, &job.did)
7272 .await
7373 .map_err(|e| Error::Failed(Arc::new(Box::new(AnyhowErrorWrapper(e)))))?;
74747575 // Process missing CIDs in batches of 5
7676 let mut processed = 0;
77777878- //TODO need to fix where if it fails still tries the other 4 chunks
7978 for chunk in missing_cids.chunks(5) {
8079 processed += chunk.len();
8180 let last_chunk = processed >= missing_cids.len();
+66-25
shared/src/jobs/mod.rs
···44pub mod scheduled_back_up_start;
55pub mod start_all_backup;
66pub mod upload_blob;
77+pub mod verify_backups;
7889use crate::db::models;
910use crate::db::models::BlobModel;
···6465}
65666667/// Given a list of CIDs, returns those that are NOT already present in the blobs table
6767-/// with blob type = 'blob'. The returned order matches the input order and duplicates in the
6868-/// input are preserved if they are not present in the DB.
6868+/// with blob type = 'blob' and matches the user's did in the case of duplicate blobs for each user
6969pub async fn filter_missing_blob_cids(
7070 pool: &Pool<Postgres>,
7171- cids: &[String],
7171+ cids: &Vec<String>,
7272+ users_did: &String,
7273) -> anyhow::Result<Vec<String>> {
7374 if cids.is_empty() {
7475 return Ok(Vec::new());
···76777778 // Fetch the subset of provided CIDs that already exist as type 'blob'
7879 let existing: Vec<String> = sqlx::query_scalar(
7979- r#"SELECT cid_or_rev FROM blobs WHERE type = $1 AND cid_or_rev = ANY($2)"#,
8080+ r#"SELECT cid_or_rev FROM blobs WHERE type = $1 AND cid_or_rev = ANY($2) AND account_did = $3"#,
8081 )
8182 .bind(crate::db::models::BlobType::Blob)
8282- .bind(cids)
8383+ .bind(&cids)
8484+ .bind(users_did)
8385 .fetch_all(pool)
8486 .await?;
8587···100102 size: i64,
101103 blob_type: models::BlobType,
102104) -> anyhow::Result<models::BlobModel> {
103103- // For repo blobs, perform an upsert on the unique `cid` to avoid duplicate inserts
104104- // and to refresh metadata if the same cid is seen again.
105105- Ok(sqlx::query_as::<_, BlobModel>(
106106- r#"
107107- INSERT INTO blobs (account_did, size, type, cid_or_rev)
108108- VALUES ($1, $2, $3, $4)
109109- ON CONFLICT (cid_or_rev) DO UPDATE
110110- SET account_did = EXCLUDED.account_did,
111111- size = EXCLUDED.size,
112112- type = EXCLUDED.type,
113113- cid_or_rev = EXCLUDED.cid_or_rev
114114- RETURNING id, created_at, account_did, size, type AS blob_type, cid_or_rev
115115- "#,
116116- )
117117- .bind(did)
118118- .bind(size)
119119- .bind(blob_type)
120120- .bind(cid_or_rev)
121121- .fetch_one(pool)
122122- .await?)
105105+ match blob_type {
106106+ //On repo we need to upsert on did
107107+ models::BlobType::Repo => {
108108+ // First try to update an existing 'repo' blob row for this DID.
109109+ if let Some(updated) = sqlx::query_as::<_, BlobModel>(
110110+ r#"
111111+ UPDATE blobs
112112+ SET size = $2,
113113+ type = $3,
114114+ cid_or_rev = $4
115115+ WHERE account_did = $1 AND type = $3
116116+ RETURNING id, created_at, account_did, size, type, cid_or_rev
117117+ "#,
118118+ )
119119+ .bind(&did)
120120+ .bind(size)
121121+ .bind(&blob_type)
122122+ .bind(&cid_or_rev)
123123+ .fetch_optional(pool)
124124+ .await?
125125+ {
126126+ Ok(updated)
127127+ } else {
128128+ // If no row was updated, insert a new one for this DID and repo type.
129129+ Ok(sqlx::query_as::<_, BlobModel>(
130130+ r#"
131131+ INSERT INTO blobs (account_did, size, type, cid_or_rev)
132132+ VALUES ($1, $2, $3, $4)
133133+ RETURNING id, created_at, account_did, size, type, cid_or_rev
134134+ "#,
135135+ )
136136+ .bind(did)
137137+ .bind(size)
138138+ .bind(blob_type)
139139+ .bind(cid_or_rev)
140140+ .fetch_one(pool)
141141+ .await?)
142142+ }
143143+ }
144144+ //on blob we upsert on cid (shouldnt happen ideally)
145145+ models::BlobType::Blob | _ => Ok(sqlx::query_as::<_, BlobModel>(
146146+ r#"
147147+ INSERT INTO blobs (account_did, size, type, cid_or_rev)
148148+ VALUES ($1, $2, $3, $4)
149149+ ON CONFLICT (cid_or_rev) DO UPDATE
150150+ SET account_did = EXCLUDED.account_did,
151151+ size = EXCLUDED.size,
152152+ type = EXCLUDED.type,
153153+ cid_or_rev = EXCLUDED.cid_or_rev
154154+ RETURNING id, created_at, account_did, size, type, cid_or_rev
155155+ "#,
156156+ )
157157+ .bind(did)
158158+ .bind(size)
159159+ .bind(blob_type)
160160+ .bind(cid_or_rev)
161161+ .fetch_one(pool)
162162+ .await?),
163163+ }
123164}
124165125166/// Look up the user's account by DID and return their repo_rev, if present.
+9-11
shared/src/jobs/scheduled_back_up_start.rs
···1111pub struct ScheduledBackUpStartJobContext;
12121313/// This scheduled job finds:
1414-/// - accounts that have not been backed up in the last 24 hours and have pds_sign_up = false,
1414+/// - accounts that have not been backed up in the last 6 hours and have pds_sign_up = false,
1515/// and enqueues AccountBackup jobs for them;
1616-/// - pds_hosts that are active and have not started a backup in the last 24 hours (tracked via
1616+/// - pds_hosts that are active and have not started a backup in the last 6 hours (tracked via
1717/// pds_hosts.last_backup_start), and enqueues PdsBackup jobs for each.
1818pub async fn scheduled_back_up_start_job(
1919 _job: ScheduledBackUpStartJobContext,
···2121) -> Result<(), Error> {
2222 log::info!("Starting a backup for the whole instance");
2323 // Record the start of a whole-network backup run
2424- sqlx::query(
2525- r#"INSERT INTO network_backup_runs DEFAULT VALUES"#,
2626- )
2727- .execute(&*pool)
2828- .await
2929- .map_err(|e| Error::Failed(Arc::new(Box::new(e))))?;
2424+ sqlx::query(r#"INSERT INTO network_backup_runs DEFAULT VALUES"#)
2525+ .execute(&*pool)
2626+ .await
2727+ .map_err(|e| Error::Failed(Arc::new(Box::new(e))))?;
3028 // 1) Query accounts needing backup
3131- // Condition: pds_sign_up = false AND (last_backup is NULL OR older than 24h)
2929+ // Condition: pds_sign_up = false AND (last_backup is NULL OR older than 6h)
3230 // We include did and pds_host to build AccountBackupJobContext
3331 let accounts: Vec<(String, String)> = sqlx::query_as(
3432 r#"
3533 SELECT did, pds_host
3634 FROM accounts
3735 WHERE pds_sign_up = FALSE
3838- AND (last_backup IS NULL OR last_backup < NOW() - INTERVAL '24 HOURS')
3636+ AND (last_backup IS NULL OR last_backup < NOW() - INTERVAL '6 HOURS')
3937 "#,
4038 )
4139 .fetch_all(&*pool)
···5856 SELECT pds_host
5957 FROM pds_hosts
6058 WHERE active = TRUE
6161- AND (last_backup_start IS NULL OR last_backup_start < NOW() - INTERVAL '24 HOURS')
5959+ AND (last_backup_start IS NULL OR last_backup_start < NOW() - INTERVAL '6 HOURS')
6260 "#,
6361 )
6462 .fetch_all(&*pool)
+76
shared/src/jobs/verify_backups.rs
···11+use crate::db::models::{BlobModel, BlobType};
22+use crate::storage::{blob_backup_path, repo_backup_path};
33+use anyhow::Result;
44+use s3::Bucket;
55+use sqlx::{Pool, Postgres};
66+77+/// Verifies that all blobs in the database exist in S3.
88+/// Returns a Vec of missing blob information (did, cid_or_rev, blob_type).
99+pub async fn verify_backups(pool: &Pool<Postgres>, s3_bucket: &Bucket) -> Result<Vec<MissingBlob>> {
1010+ // Get all blobs from the database
1111+ let blobs = sqlx::query_as::<_, BlobModel>("SELECT * FROM blobs ORDER BY created_at")
1212+ .fetch_all(pool)
1313+ .await?;
1414+1515+ let total_blobs = blobs.len();
1616+ log::info!("Checking {} blobs in S3...", total_blobs);
1717+1818+ let mut missing_blobs = Vec::new();
1919+ let mut checked = 0;
2020+2121+ for blob in blobs {
2222+ checked += 1;
2323+ if checked % 100 == 0 {
2424+ log::info!("Checked {}/{} blobs...", checked, total_blobs);
2525+ }
2626+2727+ let s3_path = match blob.r#type {
2828+ BlobType::Repo => repo_backup_path(blob.account_did.clone()),
2929+ BlobType::Blob => blob_backup_path(blob.account_did.clone(), blob.cid_or_rev.clone()),
3030+ BlobType::Prefs => {
3131+ // Handle prefs if needed - for now skip
3232+ log::debug!("Skipping prefs blob: {:?}", blob);
3333+ continue;
3434+ }
3535+ };
3636+3737+ // Check if the object exists in S3
3838+ match s3_bucket.head_object(&s3_path).await {
3939+ Ok(_) => {
4040+ // Object exists, all good
4141+ log::debug!("✓ Found: {}", s3_path);
4242+ }
4343+ Err(e) => {
4444+ // Check if it's a 404 error (not found)
4545+ if e.to_string().contains("404") {
4646+ log::warn!("✗ Missing: {}", s3_path);
4747+ missing_blobs.push(MissingBlob {
4848+ did: blob.account_did.clone(),
4949+ cid_or_rev: blob.cid_or_rev.clone(),
5050+ blob_type: blob.r#type.clone(),
5151+ s3_path,
5252+ });
5353+ } else {
5454+ // Some other error - log it but don't count as missing
5555+ log::error!("Error checking {}: {}", s3_path, e);
5656+ }
5757+ }
5858+ }
5959+ }
6060+6161+ log::info!(
6262+ "Verification complete. Checked {} blobs, found {} missing.",
6363+ checked,
6464+ missing_blobs.len()
6565+ );
6666+6767+ Ok(missing_blobs)
6868+}
6969+7070+#[derive(Debug, Clone)]
7171+pub struct MissingBlob {
7272+ pub did: String,
7373+ pub cid_or_rev: String,
7474+ pub blob_type: BlobType,
7575+ pub s3_path: String,
7676+}
···11+# sv
22+33+Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
44+55+## Creating a project
66+77+If you're seeing this, you've probably already done this step. Congrats!
88+99+```sh
1010+# create a new project in the current directory
1111+npx sv create
1212+1313+# create a new project in my-app
1414+npx sv create my-app
1515+```
1616+1717+## Developing
1818+1919+Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
2020+2121+```sh
2222+npm run dev
2323+2424+# or start the server and open the app in a new browser tab
2525+npm run dev -- --open
2626+```
2727+2828+## Building
2929+3030+To create a production version of your app:
3131+3232+```sh
3333+npm run build
3434+```
3535+3636+You can preview the production build with `npm run preview`.
3737+3838+> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
···11+<script lang="ts">
22+ import {handleAndPDSResolver} from '@pds-moover/moover'
33+ import type {RotationKeyType} from '$lib/types';
44+55+66+ let {handle, rotationKey}: {
77+ handle: string,
88+ rotationKey: RotationKeyType
99+ } = $props();
1010+1111+1212+ const copyToClipboard = async (text: string) => {
1313+ try {
1414+ await navigator.clipboard.writeText(text);
1515+ alert('Copied to clipboard');
1616+ } catch (e) {
1717+ console.error(e);
1818+ alert('Failed to copy to clipboard');
1919+ }
2020+ }
2121+2222+ const downloadNewRotationKey = async (rotationKey: RotationKeyType, handle: string) => {
2323+ if (!rotationKey) return;
2424+ //try and find the did to add to the file as well
2525+ let didText = '';
2626+ try {
2727+ let {usersDid} = await handleAndPDSResolver(handle);
2828+ didText = `DID: ${usersDid}\n`;
2929+ } catch (e) {
3030+ //sliently log. Rather the user have their rotation key than not. a did can always be found other ways if needed
3131+ console.error(e);
3232+ }
3333+3434+ const content = `You can use these to recover your account if it's ever necessary via https://pdsmoover.com/restore. The restore process will ask for the Private key\n\nKEEP IN A SECURE LOCATION\n\n${didText}PublicKey: ${rotationKey.publicKey}\nPrivateKey: ${rotationKey.privateKey}\n`;
3535+ const blob = new Blob([content], {type: 'text/plain'});
3636+ const url = URL.createObjectURL(blob);
3737+ const a = document.createElement('a');
3838+ a.href = url;
3939+4040+4141+ a.download = `${handle}-rotation-key.txt`;
4242+ document.body.appendChild(a);
4343+ a.click();
4444+ document.body.removeChild(a);
4545+ URL.revokeObjectURL(url);
4646+ }
4747+</script>
4848+4949+5050+<div class="section" style="margin-top: 16px; border: 2px solid #f39c12; padding: 16px;">
5151+ <h3 style="color: #d35400;">Important: Save Your New Rotation Key Now</h3>
5252+ <p style="color: #c0392b; font-weight: bold;">
5353+ Warning: This is the only time we will show you your private rotation key. Save it in a secure place.
5454+ If you lose it, you may not be able to recover your account in the event of a PDS failure or hijack.
5555+ </p>
5656+ <div class="form-group">
5757+ <span>New Rotation Key (Private - keep secret)</span>
5858+ <div style="display:flex; gap:8px; align-items:center;">
5959+ {#if rotationKey}
6060+ <code
6161+ style="overflow-wrap:anywhere;">{rotationKey.privateKey}</code>
6262+ {/if}
6363+6464+ <button type="button"
6565+ onclick={async () => await copyToClipboard(rotationKey.privateKey)}>Copy
6666+ </button>
6767+ </div>
6868+ </div>
6969+ <div class="form-group">
7070+ <button type="button" onclick={async () => await downloadNewRotationKey(rotationKey, handle)}>Download
7171+ Key File
7272+ </button>
7373+ </div>
7474+</div>
+1
web-ui/src/lib/index.ts
···11+// place files you want to import through the `$lib` alias in this folder.
···11+<script lang="ts">
22+ import type {BackupService, PlcOps} from '@pds-moover/moover';
33+ import type {RotationKeyType} from '$lib/types';
44+ import type {InferXRPCBodyOutput} from '@atcute/lexicons';
55+ import {ComPdsmooverBackupGetRepoStatus} from '@pds-moover/lexicons';
66+ import RotationKeyDisplay from '$lib/components/RotationKeyDisplay.svelte';
77+ import LoadingSpinner from '$lib/components/LoadingSpinner.svelte';
88+99+ let {
1010+ backupService,
1111+ plcOps,
1212+ handle,
1313+ onComplete
1414+ }: {
1515+ backupService: BackupService;
1616+ plcOps: PlcOps;
1717+ handle: string;
1818+ onComplete: (
1919+ repoStatus: InferXRPCBodyOutput<ComPdsmooverBackupGetRepoStatus.mainSchema['output']>
2020+ ) => void;
2121+ } = $props();
2222+2323+ // State variables
2424+ let errorMessage = $state<string | null>(null);
2525+ let showStatusMessage = $state(false);
2626+ let statusMessageText = $state('');
2727+ let addRecoveryKey = $state(true);
2828+ let plcTokenInput = $state<string | null>(null);
2929+ let newlyCreatedRotationKey = $state<RotationKeyType | null>(null);
3030+ let showRotationKeyScreen = $state(false);
3131+ let isRequestingToken = $state(false);
3232+ let isSigningUp = $state(false);
3333+ let isProceeding = $state(false);
3434+3535+ function updateStatusHandler(status: string) {
3636+ console.log('Status update:', status);
3737+ statusMessageText = status;
3838+ }
3939+4040+ async function requestPlcToken() {
4141+ errorMessage = null;
4242+ isRequestingToken = true;
4343+ try {
4444+ showStatusMessage = true;
4545+ updateStatusHandler('Requesting PLC token…');
4646+ await backupService.requestAPlcToken();
4747+ updateStatusHandler('PLC token emailed. Check your inbox and paste the token below.');
4848+ } catch (e) {
4949+ //@ts-expect-error: Error is handled
5050+ errorMessage = e?.message || 'Failed to request PLC token';
5151+ } finally {
5252+ showStatusMessage = false;
5353+ isRequestingToken = false;
5454+ }
5555+ }
5656+5757+ async function handleSignUpSubmit() {
5858+ errorMessage = null;
5959+ isSigningUp = true;
6060+ showStatusMessage = true;
6161+ try {
6262+ if (addRecoveryKey) {
6363+ if (!plcTokenInput) {
6464+ errorMessage =
6565+ 'Please paste your PLC token. Use the "Email me a PLC token" button to request one.';
6666+ showStatusMessage = false;
6767+ isSigningUp = false;
6868+ return;
6969+ }
7070+ updateStatusHandler('Creating new rotation key…');
7171+ const created = await plcOps.createANewSecp256k1();
7272+ newlyCreatedRotationKey = created;
7373+ updateStatusHandler('Adding new rotation key to your DID…');
7474+ await backupService.addANewRotationKey(plcTokenInput, created.publicKey);
7575+ updateStatusHandler('Rotation key added. Please save the private key now.');
7676+ showRotationKeyScreen = true;
7777+ showStatusMessage = false;
7878+ isSigningUp = false;
7979+ //Return and then on next button click we sign the user up that way if it fails it won't ruin them from
8080+ //Getting their rotation key
8181+ return;
8282+ }
8383+ await backupService.signUp(updateStatusHandler);
8484+ updateStatusHandler('Signed up for backups successfully.');
8585+8686+ const repoStatus = await backupService.getUsersRepoStatus(updateStatusHandler);
8787+ showStatusMessage = false;
8888+ isSigningUp = false;
8989+ onComplete(repoStatus);
9090+ } catch (e) {
9191+ console.error(e);
9292+ //@ts-expect-error: Error is handled
9393+ errorMessage = e.message || 'Failed to sign up for backups.';
9494+ showStatusMessage = false;
9595+ isSigningUp = false;
9696+ }
9797+ }
9898+9999+ async function proceedToRepoStatus() {
100100+ isProceeding = true;
101101+ try {
102102+ await backupService.signUp(updateStatusHandler);
103103+ updateStatusHandler('Signed up for backups successfully.');
104104+105105+ showStatusMessage = true;
106106+ updateStatusHandler('Fetching repository status…');
107107+ const repoStatus = await backupService.getUsersRepoStatus(updateStatusHandler);
108108+ showRotationKeyScreen = false;
109109+ showStatusMessage = false;
110110+ isProceeding = false;
111111+ onComplete(repoStatus);
112112+ } catch (e) {
113113+ console.error(e);
114114+ //@ts-expect-error: Error is handled
115115+ errorMessage = e?.message || 'Failed to load repository status';
116116+ showStatusMessage = false;
117117+ isProceeding = false;
118118+ }
119119+ }
120120+</script>
121121+122122+{#if showRotationKeyScreen && newlyCreatedRotationKey}
123123+ <div class="section">
124124+ <RotationKeyDisplay {handle} rotationKey={newlyCreatedRotationKey}/>
125125+ <div class="form-group">
126126+ <button type="button" onclick={proceedToRepoStatus} disabled={isProceeding}>
127127+ {#if isProceeding}
128128+ <LoadingSpinner/>
129129+ {/if}
130130+ Next
131131+ </button>
132132+ </div>
133133+ </div>
134134+{:else}
135135+ <div class="section">
136136+ <h2>No backup repository found</h2>
137137+ <p style="text-align: left;">
138138+ Sign up now to backup your AT Protocol account. PDS MOOver automatically backups your posts,
139139+ likes, media, and all account data every 6 hours. This is stored on our servers in something
140140+ called an <a
141141+ target="_blank"
142142+ rel="noopener noreferrer"
143143+ href="https://en.wikipedia.org/wiki/Object_storage">object store.</a
144144+ >
145145+ Just like your
146146+ <a
147147+ target="_blank"
148148+ rel="noopener noreferrer"
149149+ href="https://blueskyweb.zendesk.com/hc/en-us/articles/15835264007693-Data-Privacy"
150150+ >AT Proto data</a
151151+ >, this data is public.
152152+ </p>
153153+ <div class="info">
154154+ <h3>Critical: Save your rotation key</h3>
155155+ <p style="text-align: left;">
156156+ A rotation key is your account recovery key. If you've migrated to a selfhosted or
157157+ thirdparty PDS, this key is the <span class="bold">ONLY</span> way to recover your account if
158158+ your PDS goes down. Without it, a failed or rogue PDS means permanent account loss. Generate
159159+ one and store it safely.
160160+ </p>
161161+ </div>
162162+ <div class="form-group">
163163+ <label style="display: inline-flex; align-items: center; gap: 0.5rem; white-space: nowrap;">
164164+ <input type="checkbox" bind:checked={addRecoveryKey} style="margin: 0;"/>
165165+ <span>Create a rotation key (recommended)</span>
166166+ </label>
167167+ </div>
168168+ {#if addRecoveryKey}
169169+ <div class="form-group">
170170+ <p>
171171+ To add a new rotation key, you must authorize a PLC operation. Click the button to email
172172+ yourself a PLC token, then paste it below.
173173+ <span class="bold"
174174+ >NOTE: Adding a new key will remove all current rotation keys except for the one created
175175+ and the one from your PDS</span
176176+ >
177177+ </p>
178178+179179+ <div class="actions" style="margin-bottom: 0.5rem;">
180180+ <button type="button" onclick={requestPlcToken} disabled={isRequestingToken}>
181181+ {#if isRequestingToken}
182182+ <LoadingSpinner/>
183183+ {/if}
184184+ Email me a PLC token
185185+ </button>
186186+ </div>
187187+ <div class="form-group">
188188+ <label for="plc-token">PLC token</label>
189189+ <input
190190+ type="text"
191191+ id="plc-token"
192192+ name="plc-token"
193193+ bind:value={plcTokenInput}
194194+ placeholder="Paste PLC token from email"
195195+ autocomplete="one-time-code"
196196+ />
197197+ </div>
198198+ </div>
199199+ {:else}
200200+ <div class="form-group">
201201+ <div class="status-message" style="text-align: left;">
202202+ <p>
203203+ <span class="bold">Note:</span> a recovery key is
204204+ <span class="bold">required</span> to restore your account. If you're not sure what this means,
205205+ then it is important to check the above box and save the rotation key file given.
206206+ </p>
207207+ </div>
208208+ </div>
209209+ {/if}
210210+ {#if errorMessage}
211211+ <div class="error-message">{errorMessage}</div>
212212+ {/if}
213213+ {#if showStatusMessage}
214214+ <div class="status-message">{statusMessageText}</div>
215215+ {/if}
216216+ <div>
217217+ <button type="button" onclick={handleSignUpSubmit} disabled={isSigningUp}>
218218+ {#if isSigningUp}
219219+ <LoadingSpinner/>
220220+ {/if}
221221+ Sign up for backups
222222+ </button>
223223+ </div>
224224+ </div>
225225+{/if}
+202
web-ui/src/routes/info/+page.svelte
···11+<script lang="ts">
22+ import OgImage from '$lib/components/OgImage.svelte';
33+ import MooHeader from '$lib/components/MooHeader.svelte';
44+ import SignThePapersImg from '$lib/assets/sign_the_papers.png'
55+</script>
66+77+<svelte:head>
88+ <title>PDS MOOver - Info</title>
99+ <meta property="og:description" content="ATProto account migration tool"/>
1010+ <OgImage/>
1111+</svelte:head>
1212+1313+<div class="container">
1414+ <MooHeader title="PDS MOOver Info"/>
1515+1616+1717+ <div class="section" id="top">
1818+ <p> This page is to help you decide if you want to use PDS MOOver to move your ATProto(Bluesky) account to a new
1919+ PDS along with some other information about all the new tools.
2020+ One way or the other. TLDR (You should still read the whole thing), at least read and follow the <a
2121+ href="#precautions">precautions section</a>.</p>
2222+2323+2424+ <section class="section" style="text-align:left">
2525+ <h2>Info</h2>
2626+ <p>PDS MOOver is a set of tools to help you migrate to a new PDS. The creator
2727+ or host of this tool will not be able to help you recover your account if something goes wrong. So be
2828+ advised you and your PDS admin may be on your own besides helpful answers and understand the risk you
2929+ take in doing an account movement.</p>
3030+ </section>
3131+3232+3333+ <nav aria-label="Table of contents" class="section" style="text-align:left">
3434+ <h3>Table of contents</h3>
3535+ <ol>
3636+ <li><a href="#precautions">Precautions</a></li>
3737+ <li><a href="#backups">Backups</a></li>
3838+ <li><a href="#restore">Restore</a></li>
3939+ <li><a href="#blacksky">I'm here for Blacksky, is there a video guide?</a></li>
4040+ <li><a href="#cant-login">I can't log in?/Says my account is deactivated?</a></li>
4141+ <li><a href="#invalid-handle">My account says Invalid Handle?</a></li>
4242+ <li><a href="#help">!!!!!HELP!!!!!</a></li>
4343+ <li><a href="#why">Why doesn't PDS MOOver have xyz?</a></li>
4444+ <li><a href="#done">Alright account migrated, now what?</a></li>
4545+ <li><a href="#slow">Why is it so SLOW?</a></li>
4646+ <li><a href="#open-source">Can I check out the code anywhere?</a></li>
4747+ </ol>
4848+ </nav>
4949+5050+ <section id="precautions" class="section" style="text-align:left">
5151+ <h2>Precautions</h2>
5252+ <p> Migrations can be a potentially dangerous operation. It is recommended to follow these few steps to
5353+ protect your account and identity.</p>
5454+ <ul>
5555+ <li>During migration make sure to do not leave the page</li>
5656+ <li>It is recommended to use a desktop computer for this process due to the amount of time it can
5757+ take.
5858+ </li>
5959+ <li>Your account is not actually fully moved over to the new PDS till you receive a code in your email
6060+ and enter it on PDS MOOver, this is the final step.
6161+ </li>
6262+ <li>Your data will not be deleted from Bluesky(or your previous PDS) during migration. If you find you
6363+ are missing any pictures or videos after the move you can use the <a href="/missing">Missing
6464+ tool</a> to recover those from your previous PDS.
6565+ </li>
6666+ </ul>
6767+6868+ <p>At the end of your migration and before you move you will be asked if you'd like to sign up for PDS
6969+ MOOver's backup service and to add a rotation key. Both of these are recommended and secure your account
7070+ if your PDS ever goes down, allowing for account recovery. </p>
7171+ <img src="{SignThePapersImg}" alt="Sign the papers"
7272+ style="max-width: 100%; max-height: 100%; object-fit: contain;">
7373+7474+ </section>
7575+7676+ <section id="backups" class="section" style="text-align:left">
7777+ <h2>Backups</h2>
7878+ <p>PDS MOOver now supports backups. These are automated backups of your account saving your repo
7979+ (posts,likes,etc), and your blobs(picture/videos) from your AT Proto account to a cloud base object
8080+ store (S3). This is a free service for individual accounts and stores the backups on PDS MOOver's
8181+ servers. These backups will happen every 6 hours from the time you sign up. We are expecting to lower
8282+ this as we see how the service does. On login, you will be asked if you'd like to add a rotation key to
8383+ your account. It is highly recommended to do this if you do not already have one. This is the only way
8484+ you can recover your account in the event of a PDS failure or rogue account takeover</p>
8585+8686+ <p>Just like your <a target="_blank" rel="noopener noreferrer"
8787+ href="https://blueskyweb.zendesk.com/hc/en-us/articles/15835264007693-Data-Privacy">AT
8888+ Proto data</a>, your backedup data is also public.
8989+ You can access your data much the same way you do on your PDS by calling these endpoints. Since these
9090+ behave much the same as the PDS they are public.
9191+ </p>
9292+ <ul>
9393+ <li><code>/xrpc/com.atproto.sync.getRepo?did="your did"</code> to get a copy of your repo's CAR export
9494+ </li>
9595+ <li><code>/xrpc/com.atproto.sync.getBlob?did="your did"&cid="cid of the blob"</code></li>
9696+ </ul>
9797+9898+ </section>
9999+100100+ <section id="restore" class="section" style="text-align:left">
101101+ <h2>Restore</h2>
102102+ <p>Backups without a restore option aren't very good backups. So to pair well with our new backup service
103103+ PDS MOOver now also offers recovery of your account and restoring from a backup. To use this you must
104104+ have your Private Rotation Key. You can add one to your account either after migration or during backups
105105+ sign up. This recovery process follows much the same process as out line in <a
106106+ href="https://www.da.vidbuchanan.co.uk/blog/adversarial-pds-migration.html">Adversarial ATProto
107107+ PDS Migration</a> blog post by <a href="https://bsky.app/profile/retr0.id">@retr0.id</a>. </p>
108108+ </section>
109109+110110+ <section id="blacksky" class="section" style="text-align:left">
111111+ <h2>I'm here for Blacksky, Is there a video guide?</h2>
112112+ <p>
113113+ <a href="https://blacksky.community/profile/did:plc:g7j6qok5us4hjqlwjxwrrkjm">@sharpiepls.com</a> has
114114+ made an amazing video guide on
115115+ how to use PDS MOOver to move your bluesky account to blacksky.app.
116116+ <video width="100%" controls>
117117+118118+ <source src="https://blacksky.app/xrpc/com.atproto.sync.getBlob?did=did:plc:g7j6qok5us4hjqlwjxwrrkjm&cid=bafkreielhdoa2lcyiat5oumkbkwy26hlj36rwwwfi5fbnvd5haxq3t4joa"/>
119119+ </video>
120120+ </p>
121121+ </section>
122122+123123+ <section id="cant-login" class="section" style="text-align:left">
124124+ <h2>I can't log in?/Says my account is deactivated?</h2>
125125+ <p>When you move to a non Bluesky PDS you have to do an extra step on login.</p>
126126+ <ol>
127127+ <li>On the Sign in screen for <a href="https://bsky.app">bsky.app</a> or on the app click the top input
128128+ titled "Hosting provider" and has a globe icon and says Bluesky Social"
129129+ </li>
130130+ <li>Click the tab labeled custom</li>
131131+ <li>In the input for server address you put the same URL you used for the new PDS URL with the https://
132132+ like so <code>https://example.com</code></li>
133133+ <li>Click done and enter your new handle(or email) and password</li>
134134+ </ol>
135135+ </section>
136136+137137+ <section id="invalid-handle" class="section" style="text-align:left">
138138+ <h2>My account says Invalid Handle?</h2>
139139+ <p>It's a bit of a bug sometimes. I'm not sure what causes it, but usually mentioning your handle in a post
140140+ or reply fixes it.
141141+ Like <code>@fullhandle.newpds.com</code>, may or may not highlight it blue and autofill it but make sure
142142+ you have the
143143+ full handle and the @ like that. Can also check your handle with the <a
144144+ href="https://bsky-debug.app/handle">Bluesky Debug Page</a>. If you see green, and it says one
145145+ of
146146+ them pass you should be fine and just may take a while to update.</p>
147147+ </section>
148148+149149+ <section id="help" class="section" style="text-align:left">
150150+ <h2>!!!!!HELP!!!!!</h2>
151151+ <p>If you're having issues with PDS MOOver first of all, I'm very sorry. I have tested this to the best of
152152+ my
153153+ ability, but PDS migrations do come with risks. I would recommend getting with the owner of the PDS and
154154+ seeing where the account stands with tools like <a href="https://pdsls.dev">pdsls</a>.</p>
155155+156156+ <p> The tool is designed to be able to be re ran IF you set the Advance Options flags.For example, lets say
157157+ if it created the account, repo is there but some blobs are missing. You can uncheck everything but
158158+ "Migrate Missing Blobs", "Migrate Prefs", and "Migrate PLC record" and it will pick up after the account
159159+ repo migration. It is odd in the fact that all the fields are required. That's just to cut down on logic
160160+ to hopefully cut down on bugs. If you don't ever see the "Please enter your PLC Token" and enter the
161161+ token sent to your email, you can just
162162+ forget about it and call it a day if it's too much. Your old account is still active and working.</p>
163163+ </section>
164164+165165+166166+ <section id="why" class="section" style="text-align:left">
167167+ <h2>Why doesn't PDS MOOver have xyz for migrations?</h2>
168168+ <p>PDS MOOver was designed to pretty much be the goat account migration with a UI. Like in this <a
169169+ href="https://whtwnd.com/bnewbold.net/3l5ii332pf32u"> post</a>. Keeping it simple and hard fails if
170170+ anything
171171+ goes wrong
172172+ to
173173+ hopefully cover most use cases.</p>
174174+ </section>
175175+176176+ <section id="done" class="section" style="text-align:left">
177177+ <h2>Alright account migrated, now what?</h2>
178178+ <p>Welcome to your new PDS! You can login to your new PDS on Bluesky's login screen by selecting "Hosting
179179+ provider" and entering your PDS url. I also recommend making sure you are signed up for our <a
180180+ href="/backups">backups</a> and have a recovery key that you control in case your PDS disappears
181181+ overnight you can regain your account. </p>
182182+ </section>
183183+184184+ <section id="slow" class="section" style="text-align:left">
185185+ <h2>Why is it so SLOW?</h2>
186186+ <p>Everything happens client side, and the blob uploads take a while. Nothing runs in parallel. Blob uploads
187187+ happen one at a time; once one is done, the next goes. This is done just to keep it as simple as
188188+ possible and to hopefully limit the chance of failures on uploads. My personal account takes about
189189+ 20-30ish mins to move with 1,700ish blobs at 800mb on a 1gig internet connection.</p>
190190+ </section>
191191+192192+ <section id="open-source" class="section" style="text-align:left">
193193+ <h2>Can I check out the code anywhere?</h2>
194194+ <p>Yep! PDS MOOver is 100% open source and can find the code on <a
195195+ href="https://tangled.sh/@baileytownsend.dev/pds-moover">tangled.sh</a>. Also, if you're a
196196+ developer,
197197+ and you want to fork the code for a new UI. PDS MOOver's logic is all in one js file. Just take it and
198198+ its dependencies and have at it.</p>
199199+ </section>
200200+ </div>
201201+202202+</div>
+230
web-ui/src/routes/missing-blobs/+page.svelte
···11+<script lang="ts">
22+ import MooHeader from '$lib/components/MooHeader.svelte';
33+ import missingPicture from '$lib/assets/missing.webp'
44+ import {MissingBlobs} from '@pds-moover/moover';
55+66+ let missingBlobs = $state(new MissingBlobs());
77+88+ // Form state
99+ let showCurrentLogin = $state(true);
1010+ let showOldLogin = $state(false);
1111+ let showAdvance = $state(false);
1212+ let disableLoginButton = $state(false);
1313+ let showBlobMoveProgress = $state(false);
1414+ let oldPdsUrl = $state<string | null>(null);
1515+ let showTryAgain = $state(false);
1616+ let errorMessage: string | null = $state(null);
1717+ let showStatusMessage = $state(false);
1818+ let statusMessage = $state('');
1919+2020+ let loginForm = $state({
2121+ handle: '',
2222+ password: '',
2323+ twoFactorCode: '',
2424+ showTwoFactorCodeInput: false,
2525+ });
2626+2727+2828+ function resetStatusAndErrors() {
2929+ showStatusMessage = false;
3030+ statusMessage = '';
3131+ errorMessage = '';
3232+ disableLoginButton = true;
3333+ }
3434+3535+ async function handleCurrentLogin(event: SubmitEvent) {
3636+ event.preventDefault();
3737+ resetStatusAndErrors();
3838+ try {
3939+ const {
4040+ accountStatus,
4141+ missingBlobsCount
4242+ } = await missingBlobs.currentAgentLogin(loginForm.handle, loginForm.password, loginForm.twoFactorCode);
4343+ console.log(missingBlobsCount);
4444+ const noMissingBlobs = missingBlobsCount === 0;
4545+ if (noMissingBlobs) {
4646+ statusMessage = `You are good to go! You are not missing any blobs. Your account has ${accountStatus.importedBlobs} imported blobs and expects to have at least ${accountStatus.expectedBlobs} blobs. No action is required.`
4747+ } else {
4848+ showCurrentLogin = false;
4949+ statusMessage = 'You are currently missing some blobs. Login with your old password to import the missing blobs. We will automatically find your old handle.';
5050+ showOldLogin = true;
5151+ //Reset the form
5252+ loginForm.showTwoFactorCodeInput = false;
5353+ loginForm.twoFactorCode = '';
5454+ loginForm.password = '';
5555+ loginForm.handle = '';
5656+ }
5757+ showStatusMessage = true;
5858+5959+ } catch (err) {
6060+ //@ts-expect-error: Should always have an error message
6161+ if (err.error === 'AuthFactorTokenRequired') {
6262+ loginForm.showTwoFactorCodeInput = true;
6363+ }
6464+ //@ts-expect-error: Should always have an error message
6565+ errorMessage = err.message;
6666+ }
6767+ disableLoginButton = false;
6868+ }
6969+7070+ async function handleOldLogin(event: SubmitEvent) {
7171+ event.preventDefault();
7272+ resetStatusAndErrors();
7373+ try {
7474+ await missingBlobs.oldAgentLogin(loginForm.password, loginForm.twoFactorCode, oldPdsUrl);
7575+ showOldLogin = false;
7676+ showBlobMoveProgress = true;
7777+ showStatusMessage = true;
7878+ statusMessage = '';
7979+ await migrateMissingBlobs();
8080+ } catch (err) {
8181+ //@ts-expect-error: Should always have an error message
8282+ if (err.error === 'AuthFactorTokenRequired') {
8383+ loginForm.showTwoFactorCodeInput = true;
8484+ }
8585+ //@ts-expect-error: Should always have an error message
8686+ errorMessage = err.message;
8787+ }
8888+ disableLoginButton = false;
8989+ }
9090+9191+ function updateStatusHandler(status: string) {
9292+ console.log('Status update:', status);
9393+ statusMessage = status;
9494+ }
9595+9696+ async function migrateMissingBlobs() {
9797+ try {
9898+ resetStatusAndErrors();
9999+ showStatusMessage = true;
100100+ showTryAgain = false;
101101+ const {
102102+ accountStatus,
103103+ missingBlobsCount
104104+ } = await missingBlobs.migrateMissingBlobs(updateStatusHandler);
105105+ const noMissingBlobs = missingBlobsCount === 0;
106106+ if (noMissingBlobs) {
107107+ statusMessage = `You are good to go! You have all ${accountStatus.importedBlobs} of the expected ${accountStatus.expectedBlobs} blobs. You're done!!`
108108+ } else {
109109+ statusMessage = `Expected blobs: ${accountStatus.expectedBlobs} Imported blobs: ${accountStatus.importedBlobs}`;
110110+ showTryAgain = true;
111111+ }
112112+ } catch (err) {
113113+ //@ts-expect-error: Should always have an error message
114114+ errorMessage = err.message;
115115+ showTryAgain = true;
116116+ }
117117+ disableLoginButton = false;
118118+ }
119119+120120+ function toggleAdvanceMenu() {
121121+ showAdvance = !showAdvance;
122122+ }
123123+</script>
124124+125125+<svelte:head>
126126+ <title>PDS MOOver - Missing Blobs</title>
127127+ <meta property="og:description" content="Import missing blobs from your old PDS to your new PDS"/>
128128+ <meta property="og:image" content="{missingPicture}">
129129+</svelte:head>
130130+131131+{#snippet custom_img()}
132132+ <img src='{missingPicture}' alt='Cartoon milk cow on a missing poster'
133133+ style='max-width: 100%; max-height: 100%; object-fit: contain;'>
134134+{/snippet}
135135+136136+<div class="container">
137137+ <MooHeader title="Missing Blobs Importer" customImg={custom_img}/>
138138+139139+ <a href="https://blacksky.community/profile/did:plc:g7j6qok5us4hjqlwjxwrrkjm/post/3lyylumcpok2c">How to video
140140+ guide</a>
141141+142142+ <!-- Login Form -->
143143+ {#if showCurrentLogin || showOldLogin}
144144+ <form id="moover-form" onsubmit={showCurrentLogin ? handleCurrentLogin : handleOldLogin}>
145145+ <div class="section">
146146+ <h2>{showCurrentLogin ? 'Login for your current PDS' : 'Password for your OLD PDS'}</h2>
147147+148148+ {#if showOldLogin}
149149+ <p>We only need your password for your old account. We can find your old handle from your current
150150+ login.</p>
151151+ {/if}
152152+153153+ {#if showCurrentLogin}
154154+ <div class="form-group">
155155+ <label for="handle">Current Handle:</label>
156156+ <input type="text" id="handle" name="handle" placeholder="alice.bsky.social"
157157+ bind:value={loginForm.handle}
158158+ required>
159159+ </div>
160160+ {/if}
161161+162162+ <div class="form-group">
163163+ <label for="password">{showCurrentLogin ? 'Current' : 'OLD'} Password:</label>
164164+ <input type="password" id="password" name="password"
165165+ bind:value={loginForm.password}
166166+ required>
167167+ </div>
168168+169169+ {#if (showCurrentLogin && loginForm.showTwoFactorCodeInput) || (showOldLogin && loginForm.showTwoFactorCodeInput)}
170170+ <div class="form-group">
171171+ <label for="two-factor-code">2FA from the email sent</label>
172172+ <input type="text" id="two-factor-code" name="two-factor-code"
173173+ bind:value={loginForm.twoFactorCode}>
174174+ <div class="error-message">Enter your 2fa code here</div>
175175+ </div>
176176+ {/if}
177177+178178+ {#if showOldLogin}
179179+ {#if showAdvance}
180180+ <div class="form-group show-advance">
181181+ <label for="old_pds">This is optional. If you do not know your old PDS url please leave it
182182+ blank. We will find it for you.</label>
183183+ <input type="url" id="old_pds" name="old-pds"
184184+ placeholder="(Optional) Your old PDS URL" bind:value={oldPdsUrl}>
185185+ </div>
186186+ {/if}
187187+ <div class="form-group">
188188+ <button type="button" onclick={toggleAdvanceMenu} id="advance" name="advance">Advance Options
189189+ </button>
190190+ </div>
191191+ {/if}
192192+193193+ {#if errorMessage}
194194+ <div class="error-message">{errorMessage}</div>
195195+ {/if}
196196+ {#if showStatusMessage}
197197+ <div class="status-message">{statusMessage}</div>
198198+ {/if}
199199+200200+ <div>
201201+ <button disabled={disableLoginButton} type="submit">
202202+ {showCurrentLogin ? 'Login' : 'Login and start the import of missing blobs'}
203203+ </button>
204204+ </div>
205205+ </div>
206206+ </form>
207207+ {/if}
208208+209209+ <!-- Progress while uploading blobs-->
210210+ {#if showBlobMoveProgress}
211211+ <div>
212212+ {#if showStatusMessage}
213213+ <div id="warning">*This will take a while. Please do not close this tab. And watch
214214+ the status message below for updates
215215+ </div>
216216+ <div id="missing-status-message" class="status-message">{statusMessage}</div>
217217+ {/if}
218218+ {#if errorMessage}
219219+ <div class="error-message">{errorMessage}</div>
220220+ {/if}
221221+ {#if showTryAgain}
222222+ <p style="color: yellow">We were unable to import all of your previous blobs, please try again. If it is
223223+ still not completing give it a few hours and come back and try again. It may be rate limited. Re
224224+ running this tool does not harm your account.</p>
225225+ <br>
226226+ <button onclick={migrateMissingBlobs}>Try again</button>
227227+ {/if}
228228+ </div>
229229+ {/if}
230230+</div>
+267
web-ui/src/routes/moover/+page.svelte
···11+<script lang="ts">
22+ import MooHeader from '$lib/components/MooHeader.svelte';
33+ import OgImage from '$lib/components/OgImage.svelte';
44+ import {resolve} from '$app/paths';
55+ import {Migrator} from '@pds-moover/moover';
66+ import SignThePapers from './SignThePapers.svelte';
77+88+ let formData = $state({
99+ handle: '',
1010+ password: '',
1111+ newPds: '',
1212+ newEmail: '',
1313+ newHandle: '',
1414+ inviteCode: null,
1515+ twoFactorCode: null,
1616+ confirmation: false,
1717+ // Advanced options
1818+ createNewAccount: true,
1919+ migrateRepo: true,
2020+ migrateBlobs: true,
2121+ migrateMissingBlobs: true,
2222+ migratePrefs: true,
2323+ migratePlcRecord: true,
2424+ });
2525+2626+ let migrator = $state(new Migrator());
2727+2828+ //UI state
2929+ let showTwoFactorCodeInput = $state(false);
3030+ let showAdvance = $state(false);
3131+ let showStatusMessage = $state(false);
3232+ let askForPlcToken = $state(false);
3333+ let disableSubmit = $state(false);
3434+3535+ let errorMessage: null | string = $state(null);
3636+ let statusMessage: null | string = $state(null);
3737+3838+ const updateStatusHandler = (status: string) => {
3939+ statusMessage = status;
4040+ }
4141+4242+ async function submitMoove(event: SubmitEvent & { currentTarget: EventTarget & HTMLFormElement }) {
4343+ event.preventDefault();
4444+ disableSubmit = true;
4545+ errorMessage = null;
4646+ showStatusMessage = false;
4747+4848+ if (!formData.confirmation) {
4949+ errorMessage = 'Please confirm that you understand the risks of doing an account migration';
5050+ disableSubmit = false;
5151+ return;
5252+ }
5353+5454+ try {
5555+5656+ if (showTwoFactorCodeInput) {
5757+ if (showTwoFactorCodeInput === null) {
5858+ errorMessage = 'Please enter the 2FA that was sent to your email.'
5959+ disableSubmit = false;
6060+ return;
6161+ }
6262+ }
6363+6464+ // Advance options from $state
6565+ migrator.createNewAccount = formData.createNewAccount;
6666+ migrator.migrateRepo = formData.migrateRepo;
6767+ migrator.migrateBlobs = formData.migrateBlobs;
6868+ migrator.migrateMissingBlobs = formData.migrateMissingBlobs;
6969+ migrator.migratePrefs = formData.migratePrefs;
7070+ migrator.migratePlcRecord = formData.migratePlcRecord;
7171+7272+ console.log(migrator);
7373+7474+ updateStatusHandler('Starting migration...');
7575+ showStatusMessage = true;
7676+ await migrator.migrate(
7777+ formData.handle,
7878+ formData.password,
7979+ formData.newPds,
8080+ formData.newEmail,
8181+ formData.newHandle,
8282+ formData.inviteCode,
8383+ updateStatusHandler,
8484+ formData.twoFactorCode,
8585+ );
8686+ if (migrator.migratePlcRecord) {
8787+ //I don't think disable submit is needed, but you never know.
8888+ disableSubmit = false;
8989+ askForPlcToken = true;
9090+ } else {
9191+ updateStatusHandler('Migration of your repo is complete! But the PLC operation was not done so your old account is still the valid one.');
9292+ }
9393+ } catch (error) {
9494+ disableSubmit = false;
9595+ console.error(error);
9696+ //@ts-expect-error: JS being js. doesn't like not having the type'
9797+ if (error.error === 'AuthFactorTokenRequired') {
9898+ showTwoFactorCodeInput = true;
9999+ }
100100+ //@ts-expect-error: JS being js. doesn't like not having the type'
101101+ errorMessage = error.message;
102102+ }
103103+ }
104104+</script>
105105+106106+<svelte:head>
107107+ <title>PDS MOOver</title>
108108+ <meta property="og:description" content="ATProto account migration tool"/>
109109+ <OgImage/>
110110+</svelte:head>
111111+112112+<div class="container">
113113+ <MooHeader title="PDS MOOver"/>
114114+ {#if !askForPlcToken}
115115+ <a href={resolve('/info')}>Idk if I trust a cow to move my atproto account to a new PDS</a>
116116+ <br/>
117117+ <a href="https://blacksky.community/profile/did:plc:g7j6qok5us4hjqlwjxwrrkjm/post/3lw3hcuojck2u">Video guide for
118118+ joining blacksky.app</a>
119119+120120+ <form id="moover-form" onsubmit={submitMoove}>
121121+ <!-- First section: Login credentials -->
122122+ <div class="section">
123123+ <h2>Login for your current PDS</h2>
124124+ <div class="form-group">
125125+ <label for="handle">Old Handle:</label>
126126+ <input type="text" id="handle" name="handle" placeholder="alice.bsky.social" required
127127+ bind:value={formData.handle}>
128128+ </div>
129129+130130+ <div class="form-group">
131131+ <label for="password">Old Password (Will also be your new password):</label>
132132+ <input type="password" id="password" name="password" required bind:value={formData.password}>
133133+ </div>
134134+ {#if showTwoFactorCodeInput}
135135+ <div class="form-group">
136136+ <label for="two-factor-code">2FA from the email sent</label>
137137+ <input type="text" id="two-factor-code" name="twoFactorCode"
138138+ bind:value={formData.twoFactorCode}>
139139+ <div class="error-message">Enter your 2fa code here</div>
140140+141141+ </div>
142142+ {/if}
143143+ </div>
144144+145145+ <!-- Second section: New account details -->
146146+ <div class="section">
147147+ <h2>Setup for the new PDS</h2>
148148+ <div class="form-group">
149149+ <label for="new-pds">New PDS (URL):</label>
150150+ <input type="url" id="new-pds" name="newPds" placeholder="https://coolnewpds.com"
151151+ required bind:value={formData.newPds}>
152152+ </div>
153153+154154+ <div class="form-group">
155155+ <label for="new-email">New Email:</label>
156156+ <input type="email" id="new-email" name="newEmail" placeholder="CanBeSameEmailAsTheOldPds@email.com"
157157+ required bind:value={formData.newEmail}>
158158+ </div>
159159+160160+ <div class="form-group">
161161+ <label for="new-handle">New Handle:</label>
162162+ <input type="text" id="new-handle" name="newHandle"
163163+ placeholder="username.newpds.com or mycooldomain.com" required
164164+ bind:value={formData.newHandle}>
165165+ </div>
166166+167167+ <div class="form-group">
168168+ <label for="invite-code">Invite Code:</label>
169169+ <input type="text" id="invite-code" name="inviteCode"
170170+ placeholder="Invite code from your new PDS (Leave blank if you don't have one)"
171171+ bind:value={formData.inviteCode}>
172172+ </div>
173173+ </div>
174174+175175+ <div class="form-group">
176176+ <button type="button" onclick={() => showAdvance = !showAdvance} id="advance" name="advance">Advance
177177+ Options
178178+ </button>
179179+ </div>
180180+ {#if showAdvance}
181181+ <div class="section" style="padding-bottom: 10px; text-align: left">
182182+ <h3>Pick and choose which actions to run</h3>
183183+ <p>Useful if a migration failed and you want to have a bit more manual control</p>
184184+ <div class="form-control">
185185+ <label class="moove-checkbox-label">
186186+ <input type="checkbox" id="createNewAccount" name="createNewAccount"
187187+ bind:checked={formData.createNewAccount}>
188188+ Create a New Account on the New PDS
189189+ </label>
190190+ </div>
191191+ <div class="form-control">
192192+ <label class="moove-checkbox-label">
193193+ <input bind:checked={formData.migrateRepo} type="checkbox" id="migrateRepo"
194194+ name="migrateRepo">
195195+ Migrate Repo
196196+ </label>
197197+ </div>
198198+ <div class="form-control">
199199+ <label class="moove-checkbox-label">
200200+ <input bind:checked={formData.migrateBlobs} type="checkbox" id="migrateBlobs"
201201+ name="migrateBlobs">
202202+ Migrate Blobs
203203+ </label>
204204+ </div>
205205+ <div class="form-control">
206206+ <label class="moove-checkbox-label">
207207+ <input bind:checked={formData.migrateMissingBlobs} type="checkbox" id="migrateMissingBlobs"
208208+ name="migrateMissingBlobs">
209209+ Migrate Missing Blobs
210210+ </label>
211211+ </div>
212212+ <div class="form-control">
213213+ <label class="moove-checkbox-label">
214214+ <input bind:checked={formData.migratePrefs} type="checkbox" id="migratePrefs"
215215+ name="migratePrefs">
216216+ Migrate Prefs
217217+ </label>
218218+ </div>
219219+ <div class="form-control">
220220+ <label class="moove-checkbox-label">
221221+ <input bind:checked={formData.migratePlcRecord} type="checkbox" id="migratePlcRecord"
222222+ name="migratePlcRecord">
223223+ Migrate PLC Record
224224+ </label>
225225+ </div>
226226+227227+ </div>
228228+ {/if}
229229+230230+ <p style="text-align: left">There are some risks that come with doing an account migration.
231231+ (Can view them
232232+ <a href="https://github.com/bluesky-social/pds/blob/main/ACCOUNT_MIGRATION.md#%EF%B8%8F-warning-%EF%B8%8F-%EF%B8%8F">here</a>)
233233+ and that the creator or host of this migration tool is not liable and will not be able to help you in
234234+ the
235235+ event something goes wrong. I also have read over the <a href={resolve('/info')}>extended information
236236+ from
237237+ PDS MOOver
238238+ about account
239239+ migrations.</a>
240240+ </p>
241241+ <div class="form-group">
242242+ <label for="confirmation" class="moove-checkbox-label">
243243+ <input bind:checked={formData.confirmation} type="checkbox" id="confirmation" name="confirmation"
244244+ required>
245245+ <span>I understand</span>
246246+ </label>
247247+ </div>
248248+ {#if errorMessage !== null}
249249+ <div class="error-message">{errorMessage}</div>
250250+ {/if}
251251+252252+ {#if showStatusMessage}
253253+ <div id="warning">*Please make sure to stay on this page during the MOOve for the
254254+ best result
255255+ </div>
256256+ <div id="status-message" class="status-message">{statusMessage}</div>
257257+ {/if}
258258+259259+ <div>
260260+ <button disabled={disableSubmit} type="submit">MOOve</button>
261261+ </div>
262262+ </form>
263263+264264+ {:else}
265265+ <SignThePapers migrator={migrator} newHandle={formData.newHandle}/>
266266+ {/if}
267267+</div>
+178
web-ui/src/routes/moover/SignThePapers.svelte
···11+<script lang="ts">
22+ import {Migrator, PlcOps} from '@pds-moover/moover'
33+ import {resolve} from '$app/paths'
44+ import RotationKeyDisplay from '$lib/components/RotationKeyDisplay.svelte';
55+ import type {RotationKeyType} from '$lib/types';
66+ import {PUBLIC_XRPC_BASE} from '$env/static/public';
77+88+ let {migrator, newHandle}: { migrator: Migrator, newHandle: string } = $props();
99+1010+ //UI State
1111+ let errorMessage: null | string = $state(null);
1212+ let done = $state(false);
1313+ let plcStatus: null | string = $state(null);
1414+ let showAdvancedPlcOptions = $state(false);
1515+ let backupSignupMessage: null | string = $state(null);
1616+ let backupSignupError: null | string = $state(null);
1717+1818+ //Input State
1919+ let createANewRotationKey = $state(false);
2020+ let signupForBackups = $state(false);
2121+ let plcToken = $state('');
2222+ let rotationKeys = $state(['', '', '', '']);
2323+ let newlyCreatedRotationKey: RotationKeyType | null = $state(null);
2424+2525+2626+ async function signPlcOperation(event: SubmitEvent & { currentTarget: EventTarget & HTMLFormElement }) {
2727+ event.preventDefault();
2828+ try {
2929+ plcStatus = 'Signing PLC operation...';
3030+ backupSignupMessage = null;
3131+ backupSignupError = null;
3232+ // Build an additional rotation keys list (user-provided and/or newly created)
3333+ const additionalRotationKeysToAdd: string[] = [];
3434+ // Generate a new rotation key if requested
3535+ if (createANewRotationKey) {
3636+3737+ let plcOps = new PlcOps();
3838+ const created = await plcOps.createANewSecp256k1();
3939+ newlyCreatedRotationKey = created; // { publicKey, privateKey }
4040+ // Reserve the first slot for the newly created key (will appear above the PDS rotation key)
4141+ additionalRotationKeysToAdd.push(created.publicKey);
4242+ }
4343+ // Append any manually entered rotation keys (non-empty)
4444+ //TODO idk about this i need to look at it again
4545+ for (let i = 0; i < rotationKeys.length; i++) {
4646+ const k = (rotationKeys[i] || '').trim();
4747+ if (k) {
4848+ additionalRotationKeysToAdd.push(k);
4949+ }
5050+ }
5151+5252+ //TODO nervous about this state
5353+ await migrator.signPlcOperation(plcToken, additionalRotationKeysToAdd);
5454+ plcStatus = 'PLC operation signed successfully! Your account has been MOOved to the new PDS.';
5555+ done = true;
5656+5757+ if (signupForBackups) {
5858+ try {
5959+ backupSignupMessage = 'Signing you up for automated backups...';
6060+ //TODO nervous about this state
6161+ await migrator.signUpForBackupsFromMigration(`did:web:${PUBLIC_XRPC_BASE}`);
6262+ backupSignupMessage = 'Signed up for automated backups successfully.';
6363+ } catch (e) {
6464+ console.error(e);
6565+ //@ts-expect-error: There is a e.message, or at least a check for it
6666+ backupSignupError = e?.message || 'Failed to sign you up for automated backups.';
6767+ }
6868+ }
6969+ } catch (error) {
7070+ //@ts-expect-error: There is a message
7171+ errorMessage = error.message;
7272+ console.error(error);
7373+ }
7474+ }
7575+7676+</script>
7777+7878+7979+<div class="section">
8080+ <form onsubmit="{signPlcOperation}">
8181+ {#if !done}
8282+ <div>
8383+ <h2>Please enter your PLC Token you received in an email</h2>
8484+ <div class="form-group">
8585+ <label for="plc-token">PLC Token:</label>
8686+ <input type="text" id="plc-token" name="plc-token" bind:value={plcToken} required>
8787+ </div>
8888+ <p style="text-align: left">You can now select to add a new Rotation Key during migration and sign up
8989+ for PDS MOOver's free backup service. With a Rotation Key and backups if your new PDS ever goes down
9090+ you can recover your account and it's data.</p>
9191+ <div class="form-group">
9292+ <label for="rotation-key" class="moove-checkbox-label">
9393+ <input bind:checked={createANewRotationKey} type="checkbox" id="rotation-key"
9494+ name="rotation-key">
9595+ <span>Create and add a new Rotation Key</span>
9696+ </label>
9797+ </div>
9898+ <div class="form-group">
9999+ <label for="backups-signup" class="moove-checkbox-label">
100100+ <input bind:checked={signupForBackups} type="checkbox" id="backups-signup"
101101+ name="backups-signup">
102102+ <span>Signup for automated account backups</span>
103103+ </label>
104104+ </div>
105105+ <div class="form-group">
106106+ <button type="button" id="plc-advance"
107107+ onclick={() => showAdvancedPlcOptions = !showAdvancedPlcOptions}>Add
108108+ Additional Rotation Keys
109109+ </button>
110110+ </div>
111111+ {#if showAdvancedPlcOptions}
112112+ <div class="section" style="padding-bottom: 10px;">
113113+ <div style="text-align: left;">
114114+ You can pick up to 4 rotation keys to your PLC document. These will appear above your new
115115+ PDS
116116+ rotation key so you can recover your account in the event of an adversarial take over from a
117117+ rogue PDS
118118+ </div>
119119+ <div class="form-group" style="margin-top: 10px;">
120120+ <label for="rotation-key-1">Rotation key 1</label>
121121+ <input type="text" id="rotation-key-1" name="rotation-key-1"
122122+ bind:value={rotationKeys[0]}
123123+ disabled={createANewRotationKey}
124124+ placeholder={createANewRotationKey ? 'reserved for the newly created rotation key' : ''}>
125125+ </div>
126126+ <div class="form-group">
127127+ <label for="rotation-key-2">Rotation key 2</label>
128128+ <input type="text" id="rotation-key-2" name="rotation-key-2" bind:value={rotationKeys[1]}>
129129+ </div>
130130+ <div class="form-group">
131131+ <label for="rotation-key-3">Rotation key 3</label>
132132+ <input type="text" id="rotation-key-3" name="rotation-key-3" bind:value={rotationKeys[2]}>
133133+ </div>
134134+ <div class="form-group">
135135+ <label for="rotation-key-4">Rotation key 4</label>
136136+ <input type="text" id="rotation-key-4" name="rotation-key-4" bind:value={rotationKeys[3]}>
137137+ </div>
138138+ </div>
139139+ {/if}
140140+ </div>
141141+ {/if}
142142+ {#if errorMessage}
143143+ <div class="error-message">{errorMessage}</div>
144144+ {/if}
145145+146146+ {#if done && createANewRotationKey && newlyCreatedRotationKey}
147147+ <div>
148148+ <RotationKeyDisplay handle={newHandle} rotationKey={newlyCreatedRotationKey}/>
149149+ </div>
150150+ {/if}
151151+152152+ {#if !done && plcStatus}
153153+ <div class="status-message">{plcStatus}</div>
154154+ {/if}
155155+156156+ {#if signupForBackups && backupSignupMessage}
157157+ <div>
158158+ <div class="status-message">{backupSignupMessage}</div>
159159+ {#if backupSignupError}
160160+ <div class="error-message">{backupSignupError}</div>
161161+ {/if}
162162+163163+ </div>
164164+ {/if}
165165+166166+ {#if done}
167167+ <div class="status-message">Congratulations! You have MOOved to a new PDS! Remember to use
168168+ your new PDS URL under "Hosting provider" when logging in on Bluesky. Can find more detail information
169169+ <a href={resolve('/info#cant-login')}>here.</a></div>
170170+ {:else }
171171+ <div>
172172+ <button type="submit">Sign the papers</button>
173173+ </div>
174174+ {/if}
175175+176176+177177+ </form>
178178+</div>
+296
web-ui/src/routes/restore/+page.svelte
···11+<script lang="ts">
22+ import {Restore} from '@pds-moover/moover';
33+ import MooHeader from '$lib/components/MooHeader.svelte';
44+ import LoadingSpinner from '$lib/components/LoadingSpinner.svelte';
55+ import {PUBLIC_XRPC_BASE} from '$env/static/public';
66+ import OgImage from '$lib/components/OgImage.svelte';
77+88+ //Regexs to catch rotation key type
99+ const HEX_REGEX = /^[0-9a-f]+$/i;
1010+1111+ // Service instances
1212+ let restoreService = $state(new Restore(`https://${PUBLIC_XRPC_BASE}`));
1313+1414+ // Form inputs
1515+ let currentHandle = $state('');
1616+ let rotationKey = $state('');
1717+ let newPds = $state('');
1818+ let newHandle = $state('');
1919+ let newPassword = $state('');
2020+ let newEmail = $state('');
2121+ let inviteCode = $state('');
2222+ let cid = $state('');
2323+2424+ // Rotation key type detection and selection
2525+ let isHexKey = $state(false);
2626+ let rotationKeyType = $state<'secp256k1' | 'p256'>('secp256k1');
2727+2828+ // Advanced options
2929+ let restoreFilesFromBackup = $state(true);
3030+ let recoverAccount = $state(true);
3131+3232+ // UI state
3333+ let errorMessage = $state<string | null>(null);
3434+ let showStatusMessage = $state(false);
3535+ let showAdvanced = $state(false);
3636+ let done = $state(false);
3737+ let statusMessageText = $state('');
3838+ let isSubmitting = $state(false);
3939+4040+ function setStatus(message: string) {
4141+ console.log('Status update:', message);
4242+ statusMessageText = message;
4343+ showStatusMessage = true;
4444+ }
4545+4646+ function handleRotationKeyChange() {
4747+ const trimmedKey = rotationKey.trim();
4848+ if (trimmedKey && HEX_REGEX.test(trimmedKey)) {
4949+ isHexKey = true;
5050+ } else {
5151+ isHexKey = false;
5252+ }
5353+ }
5454+5555+ async function handleSubmit() {
5656+ errorMessage = null;
5757+ showStatusMessage = false;
5858+ isSubmitting = true;
5959+6060+ try {
6161+ restoreService.RestoreFromBackup = restoreFilesFromBackup;
6262+ restoreService.AccountRecovery = recoverAccount;
6363+6464+ await restoreService.recover(
6565+ rotationKey,
6666+ rotationKeyType,
6767+ currentHandle,
6868+ newPds,
6969+ newHandle,
7070+ newPassword,
7171+ newEmail,
7272+ inviteCode || null,
7373+ cid || null,
7474+ setStatus
7575+ );
7676+ done = true;
7777+ } catch (e) {
7878+ console.error(e);
7979+ // @ts-expect-error: Error is handled
8080+ errorMessage = e.message || 'An error occurred';
8181+ } finally {
8282+ isSubmitting = false;
8383+ }
8484+ }
8585+</script>
8686+8787+<svelte:head>
8888+ <title>PDS MOOver - Restore</title>
8989+ <meta property="og:description" content="Recovery and restore your ATProto account"/>
9090+ <OgImage/>
9191+</svelte:head>
9292+9393+<div class="container">
9494+ <MooHeader title="Restore"/>
9595+9696+ <div class="section">
9797+ <p style="text-align: left">
9898+ Use this page to restore your AT Protocol account on a new PDS using your private rotation
9999+ key. It's intended to be used in the worst case scenario, and not for normal account
100100+ migrations. We do not need your password since your rotation key is what is used to recover
101101+ your account. Once your account has been moved to a new PDS we will also attempt to restore
102102+ your repo and blobs(pictures/videos) from our backups.
103103+ </p>
104104+ </div>
105105+106106+ {#if !done}
107107+ <form
108108+ id="restore-form"
109109+ onsubmit={(e) => {
110110+ e.preventDefault();
111111+ handleSubmit();
112112+ }}
113113+ >
114114+ <div class="form-group">
115115+ <label for="current-handle"
116116+ >Your current handle or did (found in the recovery rotation-key.txt)</label
117117+ >
118118+ <input
119119+ id="current-handle"
120120+ bind:value={currentHandle}
121121+ placeholder="did:plc:rnpkyqnmsw4ipey6eotbdnnf"
122122+ required
123123+ />
124124+ </div>
125125+126126+ {#if recoverAccount}
127127+ <div class="form-group">
128128+ <label for="rotationKey">Private Rotation Key</label>
129129+ <input
130130+ id="rotationKey"
131131+ bind:value={rotationKey}
132132+ oninput={handleRotationKeyChange}
133133+ placeholder="a secp256k1 or p256 in either hex or multikey format"
134134+ required={recoverAccount}
135135+ />
136136+ </div>
137137+138138+ {#if isHexKey}
139139+ <div class="form-group">
140140+ <fieldset style="border: none; padding: 0; margin: 0;">
141141+ <legend style="margin-bottom: 0.5rem;">Rotation Key Type</legend>
142142+ <div style="display: flex; gap: 1rem;">
143143+ <label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
144144+ <input
145145+ type="radio"
146146+ name="rotationKeyType"
147147+ value="secp256k1"
148148+ bind:group={rotationKeyType}
149149+ />
150150+ secp256k1
151151+ </label>
152152+ <label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
153153+ <input
154154+ type="radio"
155155+ name="rotationKeyType"
156156+ value="p256"
157157+ bind:group={rotationKeyType}
158158+ />
159159+ p256
160160+ </label>
161161+ </div>
162162+ <small class="help-text">Select the type of your hex-encoded rotation key</small>
163163+ </fieldset>
164164+ </div>
165165+ {/if}
166166+ {/if}
167167+168168+ <div class="form-group">
169169+ <label for="newPds">New PDS</label>
170170+ <input
171171+ id="newPds"
172172+ type="text"
173173+ bind:value={newPds}
174174+ placeholder="https://newpds.com"
175175+ required
176176+ />
177177+ </div>
178178+179179+ <div class="form-group">
180180+ <label for="newHandle">New Handle</label>
181181+ <input
182182+ id="newHandle"
183183+ type="text"
184184+ bind:value={newHandle}
185185+ placeholder="you.newpds.com or thisisme.com"
186186+ required
187187+ />
188188+ </div>
189189+190190+ <div class="form-group">
191191+ <label for="newPassword">New Password</label>
192192+ <input id="newPassword" type="password" bind:value={newPassword} required/>
193193+ </div>
194194+195195+ <div class="form-group">
196196+ <label for="newEmail">New Email</label>
197197+ <input
198198+ id="newEmail"
199199+ type="email"
200200+ bind:value={newEmail}
201201+ placeholder="you@example.com"
202202+ required
203203+ />
204204+ </div>
205205+206206+ <div class="form-group">
207207+ <label for="inviteCode">Invite code (optional)</label>
208208+ <input id="inviteCode" type="text" bind:value={inviteCode} placeholder="Optional"/>
209209+ </div>
210210+211211+ <div class="form-group">
212212+ <button type="button" onclick={() => (showAdvanced = !showAdvanced)}>
213213+ {showAdvanced ? 'Hide advanced options' : 'Show advanced options'}
214214+ </button>
215215+ </div>
216216+217217+ {#if showAdvanced}
218218+ <div class="advanced-options">
219219+ <div class="form-group">
220220+ <label class="moove-checkbox-label">
221221+ <input
222222+ id="recoverAccount"
223223+ type="checkbox"
224224+ bind:checked={recoverAccount}
225225+ />
226226+ Recover the did doc & account.
227227+ </label>
228228+ <br/>
229229+ <small class="help-text">This option temporary assigns a signing key to your did doc, uses it to
230230+ create a new account on the new PDS with your did, and migrates the account to the new
231231+ PDS.</small>
232232+ </div>
233233+234234+ <div class="form-group">
235235+ <label class="moove-checkbox-label">
236236+ <input
237237+ id="restoreFilesFromBackup"
238238+ type="checkbox"
239239+ bind:checked={restoreFilesFromBackup}
240240+ />
241241+ Restores files from backup.
242242+ </label>
243243+ </div>
244244+245245+ {#if recoverAccount}
246246+ <div class="form-group">
247247+ <label for="cid">Cid (optional)</label>
248248+ <input
249249+ id="cid"
250250+ type="text"
251251+ bind:value={cid}
252252+ placeholder="Specific PLC audit log CID to restore to (advanced)"
253253+ />
254254+ <small class="help-text"
255255+ >Leave empty for normal restore. IF someone besides you changed your did doc and
256256+ stole your atproto identity or a mistake was made you have up to 72hrs to reverse that
257257+ change with a valid
258258+ rotation key from the last valid operation. This input allows you to restore to the
259259+ last good PLC op via it's
260260+ CID.</small
261261+ >
262262+ </div>
263263+ {/if}
264264+ </div>
265265+ {/if}
266266+267267+ {#if errorMessage}
268268+ <div class="error-message">{errorMessage}</div>
269269+ {/if}
270270+ {#if showStatusMessage}
271271+ <div class="status-message">{statusMessageText}</div>
272272+ {/if}
273273+274274+ <div class="form-actions">
275275+ <button type="submit" disabled={isSubmitting}>
276276+ {#if isSubmitting}
277277+ <LoadingSpinner/>
278278+ {/if}
279279+ Recover and restore your account
280280+ </button>
281281+ </div>
282282+ </form>
283283+ {/if}
284284+285285+ <!-- Done -->
286286+ {#if done}
287287+ <div class="section">
288288+ <div class="status-message">
289289+ Congratulations! You have successfully recovered your account! Remember to use your new
290290+ PDS URL under "Hosting provider" when logging in on Bluesky. Can find more detail
291291+ information
292292+ <a href="/info#cant-login">here.</a>
293293+ </div>
294294+ </div>
295295+ {/if}
296296+</div>
+59
web-ui/src/routes/rotation-key/+page.svelte
···11+<script lang="ts">
22+ import MooHeader from '$lib/components/MooHeader.svelte';
33+ import {PlcOps} from '@pds-moover/moover';
44+ import type {RotationKeyType} from '$lib/types';
55+ import RotationKeyDisplay from '$lib/components/RotationKeyDisplay.svelte';
66+77+ let handle: string = $state('');
88+ let errorMessage: null | string = $state(null);
99+ let status: null | string = $state(null);
1010+ let newlyCreatedRotationPrivateKey: null | RotationKeyType = $state(null);
1111+1212+1313+ const generateNewKey = async () => {
1414+ errorMessage = null;
1515+ status = 'Generating a new rotation key…';
1616+ try {
1717+ const plcOps = new PlcOps();
1818+ newlyCreatedRotationPrivateKey = await plcOps.createANewSecp256k1();
1919+ status = 'New rotation key generated.';
2020+ } catch (e) {
2121+ console.error(e);
2222+ // @ts-expect-error: already checked for null
2323+ errorMessage = e?.message || 'Failed to generate a new rotation key';
2424+ status = null;
2525+ }
2626+ };
2727+2828+</script>
2929+3030+3131+<div class="container">
3232+3333+ <MooHeader title="Rotation Key"/>
3434+ <div class="section">
3535+ <p style="text-align: left;">This page is intended for development and is not listed. This will NOT save a key
3636+ to your account.</p>
3737+ <div class="form-group">
3838+ <label for="handle">Handle</label>
3939+ <input bind:value={handle} type="text" id="handle" name="handle" placeholder="Enter your handle"/>
4040+ </div>
4141+ <div class="form-group">
4242+ <button type="button" onclick={generateNewKey}>Generate New Rotation Key</button>
4343+ </div>
4444+ </div>
4545+4646+ {#if status}
4747+ <div class="status-message">{status}</div>
4848+ {/if}
4949+5050+ {#if errorMessage}
5151+ <div class="error-message">{errorMessage}</div>
5252+ {/if}
5353+5454+5555+ {#if newlyCreatedRotationPrivateKey}
5656+ <RotationKeyDisplay handle={handle} rotationKey={newlyCreatedRotationPrivateKey}/>
5757+ {/if}
5858+5959+</div>
+105
web-ui/src/routes/terms/+page.svelte
···11+<script lang="ts">
22+ import OgImage from '$lib/components/OgImage.svelte';
33+</script>
44+55+<svelte:head>
66+ <title>PDS MOOver - Legal</title>
77+ <meta name="description" content="Terms of Service and Privacy Policy for PDS MOOver"/>
88+ <meta property="og:description" content="Terms of Service and Privacy Policy for PDS MOOver"/>
99+ <OgImage/>
1010+</svelte:head>
1111+1212+<div class="container" style="text-align:left">
1313+ <section class="section">
1414+1515+ <h1>Privacy Policy</h1>
1616+ <p>Last updated: 2025-10-20</p>
1717+1818+ <h2>Overview</h2>
1919+ <p>PDS MOOver performs migrations in your browser. Where possible, operations are client side to minimize
2020+ collection of personal data by the server. Backups happen on our servers, and your data is stored in a cloud
2121+ base object store we have access to</p>
2222+2323+ <h2>Information We May Receive</h2>
2424+ <ul>
2525+ <li>If you use optional features (e.g., backups), we take a backup of your AT Proto repo when it has a
2626+ change, any blobs you have posted to your account are stored in a <a target="_blank"
2727+ rel="noopener noreferrer"
2828+ href="https://en.wikipedia.org/wiki/Object_storage">object
2929+ store</a> that we control. Just like your <a target="_blank" rel="noopener noreferrer"
3030+ href="https://blueskyweb.zendesk.com/hc/en-us/articles/15835264007693-Data-Privacy">AT
3131+ Proto data</a> this is public and accessible to anyone. But this does not mean anyone is able to
3232+ take this data and impersonate you without what is known as a Rotation Key.
3333+ </li>
3434+ </ul>
3535+3636+ <h2>What We Do Not Do</h2>
3737+ <ul>
3838+ <li>If you use PDS MOOver just for migrations, missing blobs, or turn off feature. We do not collect or
3939+ store any information. This happens all client side.
4040+ </li>
4141+ <li>We do not collect any personal information beyond your current PDS host and your DID if you sign up for
4242+ backups.
4343+ </li>
4444+ <li>Your passwords are only ever sent to your PDS. Your passwords are never collected, and all authenticated
4545+ requests happen proxied via your PDS to our services for backups
4646+ </li>
4747+ </ul>
4848+4949+ <h2>Retention</h2>
5050+ <p>Server-side logs may be retained for a limited time for security, debugging, and rate-limiting. This is short
5151+ term logging and cleared on instance reboots and kept no longer than 60 days. Backup-related data, if
5252+ enabled by you, is stored indefinitely for the life of this service unless you opt out via <a
5353+ href="/backups">backups</a> and click delete to remove all of your backups and from any future ones.
5454+ </p>
5555+5656+ <h2>Your Choices</h2>
5757+ <p>You may choose not to use the service. If you proceed, ensure you have backups and understand the migration
5858+ risks. If you ever want to be removed from our services you can also login via <a
5959+ href="/backups">backups</a> and click delete to remove all of your backups and from any future ones.
6060+ </p>
6161+6262+ <h2>Changes</h2>
6363+ <p>We may update this Privacy Policy from time to time. Continued use after changes means you accept the updated
6464+ policy.</p>
6565+6666+ <h2>Service Provider and Location</h2>
6767+ <p>All backup data is currently being stored on <a href="https://upcloud.com/">UpCloud's</a> <a
6868+ href="https://upcloud.com/products/object-storage/">object store</a> in a data center located in the US.
6969+ UpCloud is also our service provider for the VPS running the services. We may change service providers in
7070+ the future, if we do this terms of service will be updated to reflect that.</p>
7171+7272+ </section>
7373+7474+ <section class="section">
7575+ <h1>Terms of Service</h1>
7676+ <p>Last updated: 2025-10-20</p>
7777+7878+ <p>PDS MOOver is provided on an as-is and as-available basis, without warranties of any kind. By using this site
7979+ and its tools, you agree that you understand the risks of migrating and or restoring ATProto/Bluesky
8080+ accounts and that neither the creator nor the host of this tool is responsible for any loss, corruption, or
8181+ unavailability of data, accounts, or services.</p>
8282+8383+ <h2>Acceptable Use</h2>
8484+ <p>You agree not to misuse the service, disrupt other users, or attempt to gain unauthorized access to systems
8585+ or data. You are responsible for complying with applicable laws and policies of your hosting
8686+ provider(s).</p>
8787+8888+ <h2>Limitation of Liability</h2>
8989+ <p>To the fullest extent permitted by law, the creator and host are not liable for any indirect, incidental,
9090+ special, consequential, or punitive damages, or any loss of data, profits, or revenues, whether incurred
9191+ directly or indirectly, resulting from your use of the service.</p>
9292+9393+ <h2>Changes to the Service</h2>
9494+ <p>Features may change, be limited, or be discontinued at any time without notice.</p>
9595+9696+ <h2>Backup User Content</h2>
9797+ <p>In extreme circumstances we reserve the right to ban, block, and or remove your content placed on our servers
9898+ if it is found to be illegal or harmful. We will notify you via Bluesky that you are being removed along
9999+ with giving you an export of your backed-up data.</p>
100100+101101+ <h2>Contact</h2>
102102+ <p>If you have questions about these terms, please reach out via the project repository or to <a
103103+ href="https://bsky.app/profile/baileytownsend.dev">@baileytownsend.dev</a> directly.</p>
104104+ </section>
105105+</div>
+101
web-ui/src/routes/turn-off/+page.svelte
···11+<script lang="ts">
22+ import MooHeader from '$lib/components/MooHeader.svelte';
33+ import {Migrator} from '@pds-moover/moover';
44+ import OgImage from '$lib/components/OgImage.svelte';
55+66+ let migrator = $state(new Migrator());
77+88+ // Form state
99+ let oldHandle = $state('');
1010+ let oldPassword = $state('');
1111+ let twoFactorCode = $state('');
1212+ let showTwoFactorCodeInput = $state(false);
1313+ let errorMessage: string | null = $state(null);
1414+ let showStatusMessage = $state(false);
1515+ let statusMessage = $state('');
1616+1717+ function updateStatusHandler(status: string) {
1818+ console.log('Status update:', status);
1919+ statusMessage = status;
2020+ }
2121+2222+ async function handleSubmit(event: SubmitEvent) {
2323+ event.preventDefault();
2424+ errorMessage = null;
2525+ showStatusMessage = false;
2626+2727+2828+ try {
2929+ if (showTwoFactorCodeInput) {
3030+ if (twoFactorCode === null || twoFactorCode === '') {
3131+ errorMessage = 'Please enter the 2FA that was sent to your email.';
3232+ return;
3333+ }
3434+ }
3535+3636+ showStatusMessage = true;
3737+ await migrator.deactivateOldAccount(
3838+ oldHandle,
3939+ oldPassword,
4040+ updateStatusHandler,
4141+ twoFactorCode
4242+ );
4343+ } catch (err) {
4444+ //@ts-expect-error: error should be fine
4545+ if (err.error === 'AuthFactorTokenRequired') {
4646+ showTwoFactorCodeInput = true;
4747+ }
4848+ //@ts-expect-error: error should be fine
4949+ errorMessage = err.message;
5050+ }
5151+ }
5252+</script>
5353+5454+<svelte:head>
5555+ <title>PDS MOOver - Turn OFF</title>
5656+ <meta property="og:description" content="Deactivate your old account"/>
5757+ <OgImage/>
5858+</svelte:head>
5959+6060+<div class="container">
6161+ <MooHeader title="Turn OFF"/>
6262+6363+ <p>Use this page to make sure your old account is deactivated</p>
6464+6565+ <form id="moover-form" onsubmit={handleSubmit}>
6666+ <!-- Login credentials -->
6767+ <div class="section">
6868+ <h2>Login for your old PDS</h2>
6969+ <div class="form-group">
7070+ <label for="handle">Old Handle:</label>
7171+ <input type="text" id="handle" name="handle" placeholder="alice.bsky.social"
7272+ bind:value={oldHandle}
7373+ required>
7474+ </div>
7575+7676+ <div class="form-group">
7777+ <label for="password">Old Password:</label>
7878+ <input type="password" id="password" name="password" bind:value={oldPassword} required>
7979+ </div>
8080+8181+ {#if showTwoFactorCodeInput}
8282+ <div class="form-group">
8383+ <label for="two-factor-code">2FA from the email sent</label>
8484+ <input type="text" id="two-factor-code" name="two-factor-code" bind:value={twoFactorCode}>
8585+ <div class="error-message">Enter your 2fa code here</div>
8686+ </div>
8787+ {/if}
8888+ </div>
8989+9090+ {#if errorMessage}
9191+ <div class="error-message">{errorMessage}</div>
9292+ {/if}
9393+ {#if showStatusMessage}
9494+ <div id="status-message" class="status-message">{statusMessage}</div>
9595+ {/if}
9696+9797+ <div>
9898+ <button type="submit">Turn it off</button>
9999+ </div>
100100+ </form>
101101+</div>
+6
web-ui/src/routes/turnoff.html/+server.ts
···11+import { redirect } from '@sveltejs/kit';
22+import type { RequestHandler } from './$types';
33+44+export const GET: RequestHandler = () => {
55+ throw redirect(301, '/turn-off');
66+};
+3
web-ui/static/robots.txt
···11+# allow crawling everything by default
22+User-agent: *
33+Disallow:
+16
web-ui/svelte.config.js
···11+import adapter from '@sveltejs/adapter-node';
22+import {vitePreprocess} from '@sveltejs/vite-plugin-svelte';
33+44+/** @type {import('@sveltejs/kit').Config} */
55+const config = {
66+ // Consult https://svelte.dev/docs/kit/integrations
77+ // for more information about preprocessors
88+ preprocess: vitePreprocess(),
99+1010+ kit: {
1111+ // https://svelte.dev/docs/kit/adapter-node
1212+ adapter: adapter()
1313+ }
1414+};
1515+1616+export default config;
+22
web-ui/tsconfig.json
···11+{
22+ "extends": "./.svelte-kit/tsconfig.json",
33+ "compilerOptions": {
44+ "allowJs": true,
55+ "checkJs": true,
66+ "esModuleInterop": true,
77+ "forceConsistentCasingInFileNames": true,
88+ "resolveJsonModule": true,
99+ "skipLibCheck": true,
1010+ "sourceMap": true,
1111+ "strict": true,
1212+ "moduleResolution": "bundler",
1313+ "types": [
1414+ "@pds-moover/lexicon_types"
1515+ ]
1616+ }
1717+ // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
1818+ // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
1919+ //
2020+ // To make changes to top-level options such as include and exclude, we recommend extending
2121+ // the generated config; see https://svelte.dev/docs/kit/configuration#typescript
2222+}
+6
web-ui/vite.config.ts
···11+import { sveltekit } from '@sveltejs/kit/vite';
22+import { defineConfig } from 'vite';
33+44+export default defineConfig({
55+ plugins: [sveltekit()]
66+});
+1-3
web/Cargo.toml
···1111tracing = "0.1"
1212tracing-subscriber = { version = "0.3", features = ["env-filter"] }
1313dotenvy.workspace = true
1414-askama = "0.14"
1514serde = { version = "1", features = ["derive"] }
1615serde_json = "1"
1716anyhow.workspace = true
1817log.workspace = true
1918shared = { path = "../shared" }
2020-vite-rust = "0.2.4"
2121-tower-http = { version = "0.6.1", features = ["fs", "trace"] }
1919+tower-http = { version = "0.6.1", features = ["fs", "trace", "cors"] }
2220tower = { version = "0.5.1", features = ["util"] }
2321jacquard-axum.workspace = true
2422jacquard-common.workspace = true
···1717 showTwoFactorCodeInput: false,
1818 error: null,
1919 showStatusMessage: false,
2020- showLoginScreen: true,
2020+ //The landing page to pick to login with password or oauth
2121+ showLandingButtons: true,
2222+ //Password login
2323+ showLoginScreen: false,
2124 showRepoNotFoundScreen: false,
2225 addRecoveryKey: true,
2326 // Rotation key flow state
···4750 this.showStatusMessage = false;
4851 }
4952 },
5353+ handleShowLogin() {
5454+ this.showLandingButtons = false;
5555+ this.showLoginScreen = true;
5656+ },
5757+ async handleOAuthSignup() {
5858+ // TODO: Replace this URL with your actual OAuth signup endpoint for backups
5959+ // If you have an environment-specific route, consider injecting it or making it configurable
6060+ window.location.href = '/oauth/backups';
6161+ },
5062 async handleLoginSubmit() {
6363+ this.error = null;
6464+ this.showStatusMessage = false;
6565+ this.showLandingButtons = false;
5166 this.error = null;
5267 this.showStatusMessage = false;
5368···203218 {% call cow::cow_header("Backups") %}
204219205220206206- <form id="backup-signup-form" @submit.prevent="await handleLoginSubmit()" x-show="showLoginScreen">
221221+ <!-- Landing choice: two buttons -->
222222+ <div x-show="showLoginScreen">
207223 <!-- Informational section before sign-in -->
208224 <div class="section" style="text-align: left;">
209209- <p>
210210- PDS MOOver can provide worry free backups of your AT Protocol account. This is a free service for individual accounts and stores the backups on PDS MOOver's servers. Just like your <a target="_blank" rel="noopener noreferrer" href="https://blueskyweb.zendesk.com/hc/en-us/articles/15835264007693-Data-Privacy">AT Proto data</a>, this is also public. On login, you will be asked if you'd like to add a rotation key to your account.
211211- A rotation key is a recovery key that allows you to restore your account if your PDS ever goes down. If you're already signed up for backups, then you can log in here to manage them.
212212- </p>
225225+ <p>
226226+ PDS MOOver can provide worry-free backups of your AT Protocol account.
227227+ This is a free service for individual accounts
228228+ and stores the backups on PDS MOOver's servers.
229229+ Just like your <a target="_blank" rel="noopener noreferrer" href="https://blueskyweb.zendesk.com/hc/en-us/articles/15835264007693-Data-Privacy">AT Proto data</a>,
230230+ this is also public.
231231+ <span x-show="showLoginScreen">On login,
232232+ you will be asked if you'd like to add a rotation key to your account.
233233+ A rotation key is a recovery key
234234+ that allows you to restore your account if your PDS ever goes down.
235235+ If you're already signed up for backups, then you can log in here to manage them.</span>
236236+237237+ </p>
213238 </div>
214214- <!-- Sign-in section -->
215215- <div class="section">
216216- <h2>Sign in to your account</h2>
217217- <div class="form-group">
218218- <label for="handle">Handle</label>
219219- <input type="text" id="handle" name="handle" placeholder="alice.bsky.social" x-model="handle" required>
220220- </div>
221239222222- <div class="form-group">
223223- <label for="password">Real Password</label>
224224- <input type="password" id="password" name="password" x-model="password" required>
225225- <p> If you are signing up and adding a rotation key you have to use your account's real password. If you are just managing your backups or have your own rotation key you can use an app password</p>
240240+ <div x-show="showLandingButtons" class="actions" style="display: flex; gap: 1rem; flex-wrap: wrap;">
241241+ <button type="button" @click="handleShowLogin()">Sign in to add a recovery key</button>
242242+ <button type="button" @click="await handleOAuthSignup()">Sign in for backups with OAuth</button>
243243+ </div>
244244+245245+ <!-- Sign in with password section -->
246246+ <form id="backup-signup-form" @submit.prevent="await handleLoginSubmit()">
247247+248248+ <div class="section">
249249+ <h2>Sign in to your account</h2>
250250+ <div class="form-group">
251251+ <label for="handle">Handle</label>
252252+ <input type="text" id="handle" name="handle" placeholder="alice.bsky.social" x-model="handle" required>
253253+ </div>
254254+255255+ <div class="form-group">
256256+ <label for="password">Real Password</label>
257257+ <input type="password" id="password" name="password" x-model="password" required>
258258+ <p> If you are signing up and adding a rotation key you have to use your account's real password. If you are just managing your backups or have your own rotation key you can use an app password</p>
259259+260260+ </div>
226261262262+ <div x-show="showTwoFactorCodeInput" class="form-group">
263263+ <label for="two-factor-code">Two-factor code (email)</label>
264264+ <input type="text" id="two-factor-code" name="two-factor-code" x-model="twoFactorCode">
265265+ <div class="error-message">Enter the 2FA code from your email.</div>
266266+ </div>
227267 </div>
228268229229- <div x-show="showTwoFactorCodeInput" class="form-group">
230230- <label for="two-factor-code">Two-factor code (email)</label>
231231- <input type="text" id="two-factor-code" name="two-factor-code" x-model="twoFactorCode">
232232- <div class="error-message">Enter the 2FA code from your email.</div>
269269+ <div x-show="error" x-text="error" class="error-message"></div>
270270+ <div x-show="showStatusMessage" id="status-message" class="status-message"></div>
271271+ <div>
272272+ <button type="submit">Login for backups</button>
233273 </div>
234234- </div>
235235-236236- <div x-show="error" x-text="error" class="error-message"></div>
237237- <div x-show="showStatusMessage" id="status-message" class="status-message"></div>
238238- <div>
239239- <button type="submit">Login for backups</button>
240240- </div>
241241- </form>
274274+ </form>
275275+ </div>
242276243277 <!-- Repo not found prompt -->
244278 <div class="section" x-show="showRepoNotFoundScreen">
···298332 </template>
299333300334 <!-- Repo status view for signed-up users -->
301301- <div class="section" x-show="!showLoginScreen && !showRepoNotFoundScreen && !showRotationKeyScreen">
335335+ <div class="section" x-show="!showLandingButtons && !showLoginScreen && !showRepoNotFoundScreen && !showRotationKeyScreen">
302336 <div class="section-header">
303337 <h2 style="margin: 0;">Backup repository status</h2>
304338 <button type="button" class="icon-button" title="Refresh status" aria-label="Refresh status" @click="refreshStatus">
+1-20
web/templates/index.askama.html
···2828 }
2929 },
3030 //TODO bad but do not want to figure out onload with current setup
3131- fmtBytes(bytes) {
3232- if (bytes == null) return '—';
3333- const units = ['B', 'KB', 'MB', 'GB', 'TB'];
3434- let i = 0;
3535- let v = Number(bytes);
3636- while (v >= 1024 && i < units.length - 1) {
3737- v /= 1024;
3838- i++;
3939- }
4040- return v.toFixed(1) + ' ' + units[i];
4141- },
4242- fmtDate(value) {
4343- if (!value) return '—';
4444- try {
4545- const d = new Date(value);
4646- return d.toLocaleString();
4747- } catch (_) {
4848- return String(value);
4949- }
5050- }
3131+51325233 }));
5334});
···11-import {
22- CompositeDidDocumentResolver, CompositeHandleResolver,
33- DohJsonHandleResolver,
44- PlcDidDocumentResolver, WebDidDocumentResolver,
55- WellKnownHandleResolver
66-} from '@atcute/identity-resolver';
77-import {Client, simpleFetchHandler} from '@atcute/client';
88-99-const handleResolver = new CompositeHandleResolver({
1010- strategy: 'race',
1111- methods: {
1212- dns: new DohJsonHandleResolver({
1313- dohUrl: 'https://mozilla.cloudflare-dns.com/dns-query',
1414- }),
1515- http: new WellKnownHandleResolver(),
1616- },
1717-});
1818-1919-const docResolver = new CompositeDidDocumentResolver({
2020- methods: {
2121- plc: new PlcDidDocumentResolver(),
2222- web: new WebDidDocumentResolver(),
2323- },
2424-});
2525-2626-const cleanHandle = (handle) =>
2727- handle.replace('@', '').trim().replace(
2828- /[\u202A\u202C\u200E\u200F\u2066-\u2069]/g,
2929- '',
3030- );
3131-3232-3333-async function handleAndPDSResolver(handle) {
3434- let usersDid = null;
3535- if (handle.startsWith('did:')) {
3636- usersDid = handle;
3737- } else {
3838- const cleanedHandle = cleanHandle(handle);
3939- usersDid = await handleResolver.resolve(cleanedHandle);
4040- }
4141- const didDoc = await docResolver.resolve(usersDid);
4242-4343- let pds;
4444- try {
4545- pds = didDoc.service?.filter((s) =>
4646- s.type === 'AtprotoPersonalDataServer'
4747- )[0].serviceEndpoint;
4848- } catch (error) {
4949- console.error(error);
5050- throw new Error('Could not find a PDS in the DID document.');
5151- }
5252- return {usersDid, pds};
5353-}
5454-5555-5656-async function fetchPDSMooverDIDWeb(baseUrl) {
5757- const response = await fetch(`${baseUrl}/.well-known/did.json`);
5858- if (!response.ok) {
5959- throw new Error(`Failed to fetch DID document: ${response.status}`);
6060- }
6161- const didDoc = await response.json();
6262- return didDoc.id;
6363-}
6464-6565-async function describeServer() {
6666- const baseUrl = window.location.origin;
6767- const handler = simpleFetchHandler({service: baseUrl});
6868- const rpc = new Client({
6969- handler
7070- });
7171-7272- let describeServerResponse = await rpc.get('com.pdsmoover.backup.describeServer');
7373- if (describeServerResponse.ok) {
7474- return describeServerResponse.data;
7575- } else {
7676- throw new Error('Could not describe server');
7777- }
7878-7979-8080-}
8181-8282-8383-export {handleResolver, docResolver, cleanHandle, handleAndPDSResolver, fetchPDSMooverDIDWeb, describeServer};
-216
web/ui-code/src/backup.js
···11-import {Client, CredentialManager, ok} from '@atcute/client';
22-import {fetchPDSMooverDIDWeb, handleAndPDSResolver} from './atprotoUtils.js';
33-44-55-class BackupService {
66- constructor() {
77- /**
88- *
99- * @type {Client}
1010- */
1111- this.atCuteClient = null;
1212- /**
1313- *
1414- * @type {CredentialManager}
1515- */
1616- this.atCuteCredentialManager = null;
1717- }
1818-1919- // Perform backup sign-up using user credentials. If the server requires 2FA,
2020- // it will throw with error.error === 'AuthFactorTokenRequired'.
2121- async loginAndStatus(identifier, password, onStatus = null, twoFactorCode = null) {
2222- let {pds} = await handleAndPDSResolver(identifier);
2323-2424-2525- const manager = new CredentialManager({
2626- service: pds
2727- });
2828-2929- //TODO prob should be a function param, but eh
3030- const didWeb = await fetchPDSMooverDIDWeb(window.location.origin);
3131-3232- const rpc = new Client({
3333- handler: manager,
3434- proxy: {
3535- did: didWeb,
3636- serviceId: '#repo_backup'
3737- }
3838- });
3939-4040- try {
4141- if (onStatus) onStatus('Signing in…');
4242-4343- let loginInput = {
4444- identifier,
4545- password,
4646-4747- };
4848- if (twoFactorCode) {
4949- loginInput.code = twoFactorCode;
5050- }
5151- await manager.login(loginInput);
5252-5353-5454- // Make the client/manager available regardless of repo status so we can sign up if needed.
5555- this.atCuteClient = rpc;
5656- this.atCuteCredentialManager = manager;
5757-5858- if (onStatus) onStatus('Checking backup status');
5959- const result = await rpc.get('com.pdsmoover.backup.getRepoStatus', {
6060- params: {
6161- did: manager.session.did.toString()
6262- }
6363- });
6464- if (result.ok) {
6565- return result.data;
6666- } else {
6767- switch (result.data.error) {
6868- case 'RepoNotFound':
6969- console.log('Repo not found');
7070- return null;
7171- default:
7272- throw result.data.error;
7373- }
7474-7575- }
7676- } catch (err) {
7777- throw err;
7878- }
7979- }
8080-8181- // Sign up for backups. Optionally create a new recovery key on signup.
8282- async signUp(onStatus = null) {
8383- if (!this.atCuteClient || !this.atCuteCredentialManager) {
8484- throw new Error('Not signed in');
8585- }
8686- if (onStatus) onStatus('Creating backup registration…');
8787- const data = await ok(
8888- this.atCuteClient.post('com.pdsmoover.backup.signUp', {
8989- as: null,
9090- })
9191- );
9292- if (onStatus) onStatus('Backup registration complete');
9393- return data;
9494- }
9595-9696- async requestAPlcToken() {
9797- if (!this.atCuteClient || !this.atCuteCredentialManager) {
9898- throw new Error('Not signed in');
9999- }
100100- const rpc = new Client({
101101- handler: this.atCuteCredentialManager,
102102- });
103103-104104- let response = await rpc.post('com.atproto.identity.requestPlcOperationSignature', {
105105- as: null,
106106- });
107107- if (!response.ok) {
108108- throw new Error(response.data?.message || 'Failed to request PLC token');
109109- }
110110- }
111111-112112- async addANewRotationKey(plcToken, rotationKey) {
113113- if (!this.atCuteClient || !this.atCuteCredentialManager) {
114114- throw new Error('Not signed in');
115115- }
116116-117117-118118- const rpc = new Client({
119119- handler: this.atCuteCredentialManager,
120120- });
121121-122122- let getDidCredentials = await rpc.get('com.atproto.identity.getRecommendedDidCredentials');
123123-124124- if (getDidCredentials.ok) {
125125- const pdsProvidedRotationKeys = getDidCredentials.data.rotationKeys ?? [];
126126- const updatedRotationKeys = [rotationKey, ...pdsProvidedRotationKeys];
127127-128128- const credentials = {
129129- ...getDidCredentials.data,
130130- rotationKeys: updatedRotationKeys,
131131- };
132132-133133- const signDocRes = await rpc.post('com.atproto.identity.signPlcOperation', {
134134- input: {
135135- token: plcToken,
136136- ...credentials,
137137- }
138138- });
139139-140140- if (signDocRes.ok) {
141141- const submitDocRes = await rpc.post('com.atproto.identity.submitPlcOperation', {
142142- input: signDocRes.data,
143143- as: null,
144144- });
145145-146146- if (!submitDocRes.ok) {
147147- throw new Error(submitDocRes.data?.message || 'Failed to submit PLC operation');
148148- }
149149-150150- } else {
151151- throw new Error(signDocRes.data?.message || 'Failed to sign PLC operation');
152152- }
153153-154154-155155- } else {
156156- throw new Error(getDidCredentials.data?.message || 'Failed to get status');
157157- }
158158- }
159159-160160- async getUsersRepoStatus(onStatus = null) {
161161- if (!this.atCuteClient || !this.atCuteCredentialManager) {
162162- throw new Error('Not signed in');
163163- }
164164- if (onStatus) onStatus('Refreshing backup status…');
165165- const result = await this.atCuteClient.get('com.pdsmoover.backup.getRepoStatus', {
166166- params: {did: this.atCuteCredentialManager.session.did.toString()}
167167- });
168168- if (result.ok) {
169169- return result.data;
170170- } else {
171171- throw new Error(result.data?.message || 'Failed to get status');
172172- }
173173- }
174174-175175- async runBackupNow(onStatus = null) {
176176- if (!this.atCuteClient || !this.atCuteCredentialManager) {
177177- throw new Error('Not signed in');
178178- }
179179- if (onStatus) onStatus('Requesting backup…');
180180- const res = await this.atCuteClient.post('com.pdsmoover.backup.requestBackup', {as: null, data: {}});
181181- if (res.ok) {
182182- if (onStatus) onStatus('Backup requested.');
183183- // After requesting, refresh status to reflect any immediate changes
184184- try {
185185- await this.refreshStatus(onStatus);
186186- } catch (_) { /* ignore */
187187- }
188188- return true;
189189- } else {
190190- const err = res.data;
191191- if (err?.error === 'Timeout') {
192192- throw {error: 'Timeout', message: err?.message || 'Please wait a few minutes before requesting again.'};
193193- }
194194- throw new Error(err?.message || 'Failed to request backup');
195195- }
196196- }
197197-198198- // Remove (delete) the user's backup repository
199199- async removeRepo(onStatus = null) {
200200- if (!this.atCuteClient || !this.atCuteCredentialManager) {
201201- throw new Error('Not signed in');
202202- }
203203- if (onStatus) onStatus('Deleting backup repository…');
204204- const res = await this.atCuteClient.post('com.pdsmoover.backup.removeRepo', {as: null, data: {}});
205205- if (res.ok) {
206206- if (onStatus) onStatus('Backup repository deleted.');
207207- return true;
208208- } else {
209209- const err = res.data;
210210- throw new Error(err?.message || 'Failed to delete backup repository');
211211- }
212212- }
213213-}
214214-215215-216216-export {BackupService};
-24
web/ui-code/src/main.js
···11-import {Migrator} from './pdsmoover.js';
22-import {MissingBlobs} from './missingBlobs.js';
33-import {BackupService} from './backup.js';
44-import {PlcOps} from './plc-ops.js';
55-import {MooverUtils} from './utils.js';
66-import {Restore} from './restore.js';
77-import {handleAndPDSResolver} from './atprotoUtils.js';
88-import Alpine from 'alpinejs';
99-1010-1111-window.Migrator = new Migrator();
1212-window.MissingBlobs = new MissingBlobs();
1313-window.BackupService = new BackupService();
1414-window.PlcOps = new PlcOps();
1515-window.MooverUtils = new MooverUtils();
1616-window.Restore = new Restore();
1717-window.handleAndPDSResolver = handleAndPDSResolver;
1818-1919-window.Alpine = Alpine;
2020-2121-Alpine.start();
2222-2323-export {Migrator, MissingBlobs};
2424-
-153
web/ui-code/src/missingBlobs.js
···11-//I need to condense this code with the rest of PDS MOOver cause it has a lot of overlap
22-import {AtpAgent} from '@atproto/api';
33-import {handleAndPDSResolver} from './atprotoUtils.js';
44-55-66-class MissingBlobs {
77-88- constructor() {
99- this.currentPdsAgent = null;
1010- this.oldPdsAgent = null;
1111- this.did = null;
1212- this.currentPdsUrl = null;
1313- this.missingBlobs = [];
1414-1515- }
1616-1717- async currentAgentLogin(
1818- handle,
1919- password,
2020- twoFactorCode = null,
2121- ) {
2222- let {usersDid, pds} = await handleAndPDSResolver(handle);
2323- this.did = usersDid;
2424- this.currentPdsUrl = pds;
2525- const agent = new AtpAgent({
2626- service: pds,
2727- });
2828-2929- if (twoFactorCode === null) {
3030- await agent.login({identifier: usersDid, password});
3131- } else {
3232- await agent.login({identifier: usersDid, password: password, authFactorToken: twoFactorCode});
3333- }
3434-3535- this.currentPdsAgent = agent;
3636-3737- const result = await agent.com.atproto.server.checkAccountStatus();
3838- const missingBlobs = await this.currentPdsAgent.com.atproto.repo.listMissingBlobs({
3939- limit: 10,
4040- });
4141- return {accountStatus: result.data, missingBlobsCount: missingBlobs.data.blobs.length};
4242- }
4343-4444- async oldAgentLogin(
4545- password,
4646- twoFactorCode = null,
4747- pdsUrl = null,
4848- ) {
4949- let oldPds = null;
5050-5151- if (pdsUrl === null) {
5252- const response = await fetch(`https://plc.directory/${this.did}/log`);
5353- let auditLog = await response.json();
5454- auditLog = auditLog.reverse();
5555- let debugCount = 0;
5656- for (const entry of auditLog) {
5757- console.log(`Loop: ${debugCount++}`);
5858- console.log(entry);
5959- if (entry.services) {
6060- if (entry.services.atproto_pds) {
6161- if (entry.services.atproto_pds.type === 'AtprotoPersonalDataServer') {
6262- const pds = entry.services.atproto_pds.endpoint;
6363- console.log(`Found PDS: ${pds}`);
6464- if (pds.toLowerCase() !== this.currentPdsUrl.toLowerCase()) {
6565- oldPds = pds;
6666- break;
6767- }
6868- }
6969- }
7070- }
7171- }
7272- if (oldPds === null) {
7373- throw new Error('Could not find your old PDS');
7474- }
7575- } else {
7676- oldPds = pdsUrl;
7777- }
7878-7979- const agent = new AtpAgent({
8080- service: oldPds,
8181- });
8282-8383- if (twoFactorCode === null) {
8484- await agent.login({identifier: this.did, password});
8585- } else {
8686- await agent.login({identifier: this.did, password: password, authFactorToken: twoFactorCode});
8787- }
8888- this.oldPdsAgent = agent;
8989- }
9090-9191- async migrateMissingBlobs(statusUpdateHandler) {
9292- if (this.currentPdsAgent === null) {
9393- throw new Error('Current PDS agent is not set');
9494- }
9595- if (this.oldPdsAgent === null) {
9696- throw new Error('Old PDS agent is not set');
9797- }
9898- statusUpdateHandler('Starting to import blobs...');
9999-100100- // const currentAccountStatus = await this.currentPdsAgent.com.atproto.server.checkAccountStatus();
101101- // let totalMissingBlobs = currentAccountStatus.data.expectedBlobs - currentAccountStatus.data.importedBlobs;
102102- let totalMissingBlobs = 0;
103103- let missingBlobCursor = undefined;
104104- let missingUploadedBlobs = 0;
105105-106106- do {
107107-108108- const missingBlobs = await this.currentPdsAgent.com.atproto.repo.listMissingBlobs({
109109- cursor: missingBlobCursor,
110110- //Test this cause it may be a big update
111111- limit: 1000,
112112- });
113113- totalMissingBlobs += missingBlobs.data.blobs.length;
114114-115115- for (const recordBlob of missingBlobs.data.blobs) {
116116- try {
117117-118118- const blobRes = await this.oldPdsAgent.com.atproto.sync.getBlob({
119119- did: this.did,
120120- cid: recordBlob.cid,
121121- });
122122- let result = await this.currentPdsAgent.com.atproto.repo.uploadBlob(blobRes.data, {
123123- encoding: blobRes.headers['content-type'],
124124- });
125125-126126- if (result.status === 429) {
127127- statusUpdateHandler(`You are being rate limited. Will need to try again later to get the rest of the blobs. Migrated blobs: ${missingUploadedBlobs}/${totalMissingBlobs}`);
128128- }
129129-130130- if (missingUploadedBlobs % 2 === 0) {
131131- statusUpdateHandler(`Migrating blobs: ${missingUploadedBlobs}/${totalMissingBlobs} (The total may increase as we find more)`);
132132- }
133133- missingUploadedBlobs++;
134134- } catch (error) {
135135- console.error(error);
136136- this.missingBlobs.push(recordBlob.cid);
137137- }
138138- }
139139- missingBlobCursor = missingBlobs.data.cursor;
140140- } while (missingBlobCursor);
141141-142142- const accountStatus = await this.currentPdsAgent.com.atproto.server.checkAccountStatus();
143143- const missingBlobs = await this.currentPdsAgent.com.atproto.repo.listMissingBlobs({
144144- limit: 10,
145145- });
146146- return {accountStatus: accountStatus.data, missingBlobsCount: missingBlobs.data.blobs.length};
147147-148148-149149- }
150150-151151-}
152152-153153-export {MissingBlobs};
-361
web/ui-code/src/pdsmoover.js
···11-import {docResolver, fetchPDSMooverDIDWeb, handleResolver} from './atprotoUtils.js';
22-import {AtpAgent} from '@atproto/api';
33-44-55-function safeStatusUpdate(statusUpdateHandler, status) {
66- if (statusUpdateHandler) {
77- statusUpdateHandler(status);
88- }
99-}
1010-1111-1212-class Migrator {
1313- constructor() {
1414- /** @type {AtpAgent} */
1515- this.oldAgent = null;
1616- /** @type {AtpAgent} */
1717- this.newAgent = null;
1818- this.missingBlobs = [];
1919- //State for reruns
2020- this.createNewAccount = true;
2121- this.migrateRepo = true;
2222- this.migrateBlobs = true;
2323- this.migrateMissingBlobs = true;
2424- this.migratePrefs = true;
2525- this.migratePlcRecord = true;
2626- }
2727-2828- /**
2929- * This migrator is pretty cut and dry and makes a few assumptions
3030- * 1. You are using the same password between each account
3131- * 2. If this command fails for something like oauth 2fa code it throws an error and expects the same values when ran again.
3232- * @param {string} oldHandle - The handle you use on your old pds, something like alice.bsky.social
3333- * @param {string} password - Your password for your current login. Has to be your real password, no app password. When setting up a new account we reuse it as well for that account
3434- * @param {string} newPdsUrl - The new URL for your pds. Like https://coolnewpds.com
3535- * @param {string} newEmail - The email you want to use on the new pds (can be the same as the previous one as long as it's not already being used on the new pds)
3636- * @param {string} newHandle - The new handle you want, like alice.bsky.social, or if you already have a domain name set as a handle can use it myname.com.
3737- * @param {string|null} inviteCode - The invite code you got from the PDS you are migrating to. If null does not include one
3838- * @param {function|null} statusUpdateHandler - a function that takes a string used to update the UI. Like (status) => console.log(status)
3939- * @param {string|null} twoFactorCode - Optional, but needed if it fails with 2fa required
4040- */
4141- async migrate(oldHandle, password, newPdsUrl, newEmail, newHandle, inviteCode, statusUpdateHandler = null, twoFactorCode = null) {
4242-4343- //Copying the handle from bsky website adds some random unicodes on
4444- oldHandle = oldHandle.replace('@', '').trim().replace(/[\u202A\u202C\u200E\u200F\u2066-\u2069]/g, '');
4545- let oldAgent;
4646- let usersDid;
4747- //If it's a bsky handle just go with the entryway and let it sort everything
4848- if (oldHandle.endsWith('.bsky.social')) {
4949- oldAgent = new AtpAgent({service: 'https://bsky.social'});
5050- const publicAgent = new AtpAgent({service: 'https://public.api.bsky.app'});
5151- const resolveIdentityFromEntryway = await publicAgent.com.atproto.identity.resolveHandle({handle: oldHandle});
5252- usersDid = resolveIdentityFromEntryway.data.did;
5353-5454- } else {
5555- //Resolves the did and finds the did document for the old PDS
5656- safeStatusUpdate(statusUpdateHandler, 'Resolving old PDS');
5757- usersDid = await handleResolver.resolve(oldHandle);
5858- const didDoc = await docResolver.resolve(usersDid);
5959- safeStatusUpdate(statusUpdateHandler, 'Resolving did document and finding your current PDS URL');
6060-6161- let oldPds;
6262- try {
6363- oldPds = didDoc.service.filter(s => s.type === 'AtprotoPersonalDataServer')[0].serviceEndpoint;
6464- } catch (error) {
6565- console.error(error);
6666- throw new Error('Could not find a PDS in the DID document.');
6767- }
6868-6969- oldAgent = new AtpAgent({
7070- service: oldPds,
7171- });
7272-7373- }
7474-7575- safeStatusUpdate(statusUpdateHandler, 'Logging you in to the old PDS');
7676- //Login to the old PDS
7777- if (twoFactorCode === null) {
7878- await oldAgent.login({identifier: oldHandle, password});
7979- } else {
8080- await oldAgent.login({identifier: oldHandle, password: password, authFactorToken: twoFactorCode});
8181- }
8282-8383- safeStatusUpdate(statusUpdateHandler, 'Checking that the new PDS is an actual PDS (if the url is wrong this takes a while to error out)');
8484- const newAgent = new AtpAgent({service: newPdsUrl});
8585- const newHostDesc = await newAgent.com.atproto.server.describeServer();
8686- if (this.createNewAccount) {
8787- const newHostWebDid = newHostDesc.data.did;
8888-8989- safeStatusUpdate(statusUpdateHandler, 'Creating a new account on the new PDS');
9090-9191- const createAuthResp = await oldAgent.com.atproto.server.getServiceAuth({
9292- aud: newHostWebDid,
9393- lxm: 'com.atproto.server.createAccount',
9494- });
9595- const serviceJwt = createAuthResp.data.token;
9696-9797- let createAccountRequest = {
9898- did: usersDid,
9999- handle: newHandle,
100100- email: newEmail,
101101- password: password,
102102- };
103103- if (inviteCode) {
104104- createAccountRequest.inviteCode = inviteCode;
105105- }
106106- const createNewAccount = await newAgent.com.atproto.server.createAccount(
107107- createAccountRequest,
108108- {
109109- headers: {authorization: `Bearer ${serviceJwt}`},
110110- encoding: 'application/json',
111111- });
112112-113113- if (createNewAccount.data.did !== usersDid.toString()) {
114114- throw new Error('Did not create the new account with the same did as the old account');
115115- }
116116- }
117117- safeStatusUpdate(statusUpdateHandler, 'Logging in with the new account');
118118-119119- await newAgent.login({
120120- identifier: usersDid,
121121- password: password,
122122- });
123123-124124- if (this.migrateRepo) {
125125- safeStatusUpdate(statusUpdateHandler, 'Migrating your repo');
126126- const repoRes = await oldAgent.com.atproto.sync.getRepo({did: usersDid});
127127- await newAgent.com.atproto.repo.importRepo(repoRes.data, {
128128- encoding: 'application/vnd.ipld.car',
129129- });
130130- }
131131-132132- let newAccountStatus = await newAgent.com.atproto.server.checkAccountStatus();
133133-134134- if (this.migrateBlobs) {
135135- safeStatusUpdate(statusUpdateHandler, 'Migrating your blobs');
136136-137137- let blobCursor = undefined;
138138- let uploadedBlobs = 0;
139139- do {
140140- safeStatusUpdate(statusUpdateHandler, `Migrating blobs: ${uploadedBlobs}/${newAccountStatus.data.expectedBlobs}`);
141141-142142- const listedBlobs = await oldAgent.com.atproto.sync.listBlobs({
143143- did: usersDid,
144144- cursor: blobCursor,
145145- limit: 100,
146146- });
147147-148148- for (const cid of listedBlobs.data.cids) {
149149- try {
150150- const blobRes = await oldAgent.com.atproto.sync.getBlob({
151151- did: usersDid,
152152- cid,
153153- });
154154- await newAgent.com.atproto.repo.uploadBlob(blobRes.data, {
155155- encoding: blobRes.headers['content-type'],
156156- });
157157- uploadedBlobs++;
158158- if (uploadedBlobs % 10 === 0) {
159159- safeStatusUpdate(statusUpdateHandler, `Migrating blobs: ${uploadedBlobs}/${newAccountStatus.data.expectedBlobs}`);
160160- }
161161- } catch (error) {
162162- console.error(error);
163163- }
164164- }
165165- blobCursor = listedBlobs.data.cursor;
166166- } while (blobCursor);
167167- }
168168-169169- if (this.migrateMissingBlobs) {
170170- newAccountStatus = await newAgent.com.atproto.server.checkAccountStatus();
171171- if (newAccountStatus.data.expectedBlobs !== newAccountStatus.data.importedBlobs) {
172172- let totalMissingBlobs = newAccountStatus.data.expectedBlobs - newAccountStatus.data.importedBlobs;
173173- safeStatusUpdate(statusUpdateHandler, 'Looks like there are some missing blobs. Going to try and upload them now.');
174174- //Probably should be shared between main blob uploader, but eh
175175- let missingBlobCursor = undefined;
176176- let missingUploadedBlobs = 0;
177177- do {
178178- safeStatusUpdate(statusUpdateHandler, `Migrating blobs: ${missingUploadedBlobs}/${totalMissingBlobs}`);
179179-180180- const missingBlobs = await newAgent.com.atproto.repo.listMissingBlobs({
181181- cursor: missingBlobCursor,
182182- limit: 100,
183183- });
184184-185185- for (const recordBlob of missingBlobs.data.blobs) {
186186- try {
187187-188188- const blobRes = await oldAgent.com.atproto.sync.getBlob({
189189- did: usersDid,
190190- cid: recordBlob.cid,
191191- });
192192- await newAgent.com.atproto.repo.uploadBlob(blobRes.data, {
193193- encoding: blobRes.headers['content-type'],
194194- });
195195- if (missingUploadedBlobs % 10 === 0) {
196196- safeStatusUpdate(statusUpdateHandler, `Migrating blobs: ${missingUploadedBlobs}/${totalMissingBlobs}`);
197197- }
198198- missingUploadedBlobs++;
199199- } catch (error) {
200200- //TODO silently logging prob should list them so user can manually download
201201- console.error(error);
202202- this.missingBlobs.push(recordBlob.cid);
203203- }
204204- }
205205- missingBlobCursor = missingBlobs.data.cursor;
206206- } while (missingBlobCursor);
207207-208208- }
209209- }
210210- if (this.migratePrefs) {
211211- const prefs = await oldAgent.app.bsky.actor.getPreferences();
212212- await newAgent.app.bsky.actor.putPreferences(prefs.data);
213213- }
214214-215215- this.oldAgent = oldAgent;
216216- this.newAgent = newAgent;
217217-218218- if (this.migratePlcRecord) {
219219- await oldAgent.com.atproto.identity.requestPlcOperationSignature();
220220- safeStatusUpdate(statusUpdateHandler, 'Please check your email for a PLC token');
221221- }
222222- }
223223-224224- /**
225225- * Sign and submits the PLC operation to officially migrate the account
226226- * @param {string} token - the PLC token sent in the email. If you're just wanting to run this rerun migrate with all the flags set as false except for migratePlcRecord
227227- * @param additionalRotationKeysToAdd {[string]} - additional rotation keys to add in addition to the ones provided by the new PDS.
228228- * @returns {Promise<void>}
229229- */
230230- async signPlcOperation(token, additionalRotationKeysToAdd = []) {
231231- const getDidCredentials =
232232- await this.newAgent.com.atproto.identity.getRecommendedDidCredentials();
233233- const pdsProvidedRotationKeys = getDidCredentials.data.rotationKeys ?? [];
234234- // Prepend any additional rotation keys (e.g., user-added keys, newly created key) so they appear above the new PDS rotation key
235235- const rotationKeys = [...(additionalRotationKeysToAdd || []), ...pdsProvidedRotationKeys];
236236- if (!rotationKeys) {
237237- throw new Error('No rotation key provided from the new PDS');
238238- }
239239- const credentials = {
240240- ...getDidCredentials.data,
241241- rotationKeys: rotationKeys,
242242- };
243243-244244-245245- const plcOp = await this.oldAgent.com.atproto.identity.signPlcOperation({
246246- token: token,
247247- ...credentials,
248248- });
249249-250250- await this.newAgent.com.atproto.identity.submitPlcOperation({
251251- operation: plcOp.data.operation,
252252- });
253253-254254- await this.newAgent.com.atproto.server.activateAccount();
255255- await this.oldAgent.com.atproto.server.deactivateAccount({});
256256- }
257257-258258- // Quick and dirty copy and paste of the above to get a fix out to help people without breaking or introducing any bugs to the migration service...hopefully
259259- async deactivateOldAccount(oldHandle, oldPassword, statusUpdateHandler = null, twoFactorCode = null) {
260260- //Copying the handle from bsky website adds some random unicodes on
261261- oldHandle = oldHandle.replace('@', '').trim().replace(/[\u202A\u202C\u200E\u200F\u2066-\u2069]/g, '');
262262- let usersDid;
263263- //If it's a bsky handle just go with the entryway and let it sort everything
264264- if (oldHandle.endsWith('.bsky.social')) {
265265- const publicAgent = new AtpAgent({service: 'https://public.api.bsky.app'});
266266- const resolveIdentityFromEntryway = await publicAgent.com.atproto.identity.resolveHandle({handle: oldHandle});
267267- usersDid = resolveIdentityFromEntryway.data.did;
268268- } else {
269269- //Resolves the did and finds the did document for the old PDS
270270- safeStatusUpdate(statusUpdateHandler, 'Resolving did from handle');
271271- usersDid = await handleResolver.resolve(oldHandle);
272272- }
273273-274274- const didDoc = await docResolver.resolve(usersDid);
275275- let currentPds;
276276- try {
277277- currentPds = didDoc.service.filter(s => s.type === 'AtprotoPersonalDataServer')[0].serviceEndpoint;
278278- } catch (error) {
279279- console.error(error);
280280- throw new Error('Could not find a PDS in the DID document.');
281281- }
282282-283283- const plcLogRequest = await fetch(`https://plc.directory/${usersDid}/log`);
284284- const plcLog = await plcLogRequest.json();
285285- let pdsBeforeCurrent = '';
286286- for (const log of plcLog) {
287287- try {
288288- const pds = log.services.atproto_pds.endpoint;
289289- console.log(pds);
290290- if (pds.toLowerCase() === currentPds.toLowerCase()) {
291291- console.log('Found the PDS before the current one');
292292- break;
293293- }
294294- pdsBeforeCurrent = pds;
295295- } catch (e) {
296296- console.log(e);
297297- }
298298- }
299299- if (pdsBeforeCurrent === '') {
300300- throw new Error('Could not find the PDS before the current one');
301301- }
302302-303303- let oldAgent = new AtpAgent({service: pdsBeforeCurrent});
304304- safeStatusUpdate(statusUpdateHandler, `Logging you in to the old PDS: ${pdsBeforeCurrent}`);
305305- //Login to the old PDS
306306- if (twoFactorCode === null) {
307307- await oldAgent.login({identifier: oldHandle, password: oldPassword});
308308- } else {
309309- await oldAgent.login({identifier: oldHandle, password: oldPassword, authFactorToken: twoFactorCode});
310310- }
311311- safeStatusUpdate(statusUpdateHandler, 'Checking this isn\'t your current PDS');
312312- if (pdsBeforeCurrent === currentPds) {
313313- throw new Error('This is your current PDS. Login to your old account username and password');
314314- }
315315-316316- let currentAccountStatus = await oldAgent.com.atproto.server.checkAccountStatus();
317317- if (!currentAccountStatus.data.activated) {
318318- safeStatusUpdate(statusUpdateHandler, 'All good. Your old account is not activated.');
319319- }
320320- safeStatusUpdate(statusUpdateHandler, 'Deactivating your OLD account');
321321- await oldAgent.com.atproto.server.deactivateAccount({});
322322- safeStatusUpdate(statusUpdateHandler, 'Successfully deactivated your OLD account');
323323- }
324324-325325- async signUpForBackupsFromMigration() {
326326- // Use a plain fetch POST with atproto-proxy header and bearer from the new agent
327327- const didWeb = await fetchPDSMooverDIDWeb(window.location.origin);
328328-329329- const url = `${this.newAgent.serviceUrl.origin}/xrpc/com.pdsmoover.backup.signUp`;
330330-331331- const accessJwt = this.newAgent?.session?.accessJwt;
332332- if (!accessJwt) {
333333- throw new Error('Missing access token for authorization');
334334- }
335335-336336- const res = await fetch(url, {
337337- method: 'POST',
338338- headers: {
339339- 'Authorization': `Bearer ${accessJwt}`,
340340- 'Content-Type': 'application/json',
341341- 'Accept': 'application/json',
342342- 'atproto-proxy': `${didWeb}#repo_backup`,
343343- },
344344- body: JSON.stringify({}),
345345- });
346346-347347- if (!res.ok) {
348348- let bodyText = '';
349349- try {
350350- bodyText = await res.text();
351351- } catch {
352352- }
353353- throw new Error(`Backup signup failed: ${res.status} ${res.statusText}${bodyText ? ` - ${bodyText}` : ''}`);
354354- }
355355-356356- //No return the success is all that is needed, if there's an error it will throw
357357- }
358358-}
359359-360360-export {Migrator};
361361-
-280
web/ui-code/src/plc-ops.js
···11-import {defs, normalizeOp} from '@atcute/did-plc';
22-import {P256PrivateKey, parsePrivateMultikey, Secp256k1PrivateKey, Secp256k1PrivateKeyExportable} from '@atcute/crypto';
33-import * as CBOR from '@atcute/cbor';
44-import {fromBase16, toBase64Url} from '@atcute/multibase';
55-66-//NOTES
77-// Don't forget disputes can check https://github.dev/mary-ext/boat/blob/trunk/src/views/identity/plc-applicator/steps/step1_handle-input.tsx
88-//This is if a previous operation should be disputed, it will be a check box on recovery
99-1010-const PLC_DIRECTORY_URL = 'https://plc.directory';
1111-1212-1313-// Helper to base64url-encode JSON
1414-const jsonToB64Url = (obj) => {
1515- const enc = new TextEncoder();
1616- const json = JSON.stringify(obj);
1717- return toBase64Url(enc.encode(json));
1818-};
1919-2020-class PlcOps {
2121- constructor() {
2222-2323- }
2424-2525- //TODO ui
2626- // For unvaldiating a record in 72hr window add an advance option input for that cid
2727- //that will be the easiest way at launch and just help ppl
2828-2929- //NEEDS
3030- // Function to get current rotation keys
3131- // function to create a new key
3232- // function to add a new key and save and submit
3333- // Can use that same function for signing a recover
3434-3535- async testSignAServiceAuthToken(did) {
3636- //
3737- const testPrivateKey = 'z3vLhP9c6gwUUwA4jkyYE9SXifE7wD1f3rVMMLmqBq8TaT2B';
3838- let signingKeypair = await this.getKeyPair(testPrivateKey);
3939- let test = await this.createANewServiceAuthToken(did, 'did:web:dev.pdsmoover.com', signingKeypair.keypair, 'com.pdsmoover.backup.requestBackup');
4040- console.log(test);
4141- }
4242-4343-4444- async exampleOfSigningAPLCOPManuallyThatWorks() {
4545- //dev keys
4646- // New Rotation Key: did:key:zQ3shXuksWLbyTTbWrSJ41qZvR2eyNFGTdbjjG3b2MWRo5cSx
4747- // New Private Key: z3vLhP9c6gwUUwA4jkyYE9SXifE7wD1f3rVMMLmqBq8TaT2B
4848- const testPrivateKey = 'z3vLhP9c6gwUUwA4jkyYE9SXifE7wD1f3rVMMLmqBq8TaT2B';
4949- let signingKeypair = await this.getKeyPair(testPrivateKey);
5050- let {lastOperation, base} = await this.getLastPlcOpFromPlc(did);
5151- console.log(lastOperation);
5252- let {privateKey, publicKey} = await this.createANewSecp256k1();
5353- console.log('New Rotation Key:', publicKey);
5454- console.log('New Private Key:', privateKey);
5555- let newRotationKeys = lastOperation.rotationKeys || [];
5656- //Adds the new one to the top
5757- newRotationKeys.unshift(publicKey);
5858- await this.signAndPublishNewOp(did, signingKeypair.keypair, lastOperation.alsoKnownAs, newRotationKeys, lastOperation.services.atproto_pds.endpoint, lastOperation.verificationMethods.atproto, base.cid);
5959-6060- }
6161-6262- async getCurrentRotationKeysForUser(did) {
6363- const logs = await this.getPlcAuditLogs(did);
6464- const {rotationKeys} = this.getLastPlcOp(logs);
6565- return rotationKeys;
6666- }
6767-6868- async getLastPlcOpFromPlc(did) {
6969- const logs = await this.getPlcAuditLogs(did);
7070- return this.getLastPlcOp(logs);
7171- }
7272-7373- getLastPlcOp(logs) {
7474- const lastOp = logs.at(-1);
7575- return {lastOperation: normalizeOp(lastOp.operation), base: lastOp};
7676- }
7777-7878-7979- async getPlcAuditLogs(did) {
8080- const response = await fetch(`${PLC_DIRECTORY_URL}/${did}/log/audit`);
8181- if (!response.ok) {
8282- throw new Error(`got resposne ${response.status}`);
8383- }
8484-8585- const json = await response.json();
8686- return defs.indexedEntryLog.parse(json);
8787- }
8888-8989- /**
9090- * Creates a new secp256k1 key that can be used for either rotation or verification key
9191- * @returns {Promise<{privateKey: string, publicKey: `did:key:${string}`}>}
9292- */
9393-9494- async createANewSecp256k1() {
9595- let keypair = await Secp256k1PrivateKeyExportable.createKeypair();
9696- let publicKey = await keypair.exportPublicKey('did');
9797- let privateKey = await keypair.exportPrivateKey('multikey');
9898- return {
9999- privateKey,
100100- publicKey
101101- };
102102- }
103103-104104-105105- /**
106106- * Signs a new operation with the provided signing key and submits it
107107- * @param did
108108- * @param signingRotationKey
109109- * @param alsoKnownAs
110110- * @param rotationKeys
111111- * @param pds
112112- * @param verificationKey
113113- * @returns {Promise<void>}
114114- */
115115- async signAndPublishNewOp(did, signingRotationKey, alsoKnownAs, rotationKeys, pds, verificationKey, prev) {
116116- const operation = {
117117- type: 'plc_operation',
118118- // prev: prev!.cid,
119119- prev,
120120- alsoKnownAs,
121121- rotationKeys,
122122- services: {
123123- atproto_pds: {
124124- type: 'AtprotoPersonalDataServer',
125125- endpoint: pds
126126- }
127127- },
128128- verificationMethods: {
129129- atproto: verificationKey
130130- }
131131- };
132132- const opBytes = CBOR.encode(operation);
133133- const sigBytes = await signingRotationKey.sign(opBytes);
134134-135135- const signature = toBase64Url(sigBytes);
136136-137137- const signedOperation = {
138138- ...operation,
139139- sig: signature,
140140- };
141141-142142- await this.pushPlcOperation(did, signedOperation);
143143- }
144144-145145- /**
146146- * Takes a multi or hexbased private key and returns a keypair
147147- * @param privateKeyString
148148- * @param type
149149- * @returns {Promise<{type: string, didPublicKey: `did:key:${string}`, keypair: P256PrivateKey|Secp256k1PrivateKey}>}
150150- */
151151- async getKeyPair(privateKeyString, type = 'p256') {
152152- const HEX_REGEX = /^[0-9a-f]+$/i;
153153- const MULTIKEY_REGEX = /^z[a-km-zA-HJ-NP-Z1-9]+$/;
154154- let keypair = undefined;
155155-156156- if (HEX_REGEX.test(privateKeyString)) {
157157- const privateKeyBytes = fromBase16(privateKeyString);
158158-159159- switch (type) {
160160- case 'p256': {
161161- keypair = await P256PrivateKey.importRaw(privateKeyBytes);
162162- break;
163163- }
164164- case 'secp256k1': {
165165- keypair = await Secp256k1PrivateKey.importRaw(privateKeyBytes);
166166- break;
167167- }
168168- default: {
169169- throw new Error(`unsupported "${type}" type`);
170170- }
171171- }
172172- } else if (MULTIKEY_REGEX.test(privateKeyString)) {
173173-174174- const match = parsePrivateMultikey(privateKeyString);
175175- console.log(match);
176176- const privateKeyBytes = match.privateKeyBytes;
177177-178178- switch (match.type) {
179179- case 'p256': {
180180- keypair = await P256PrivateKey.importRaw(privateKeyBytes);
181181- console.log(keypair);
182182- break;
183183- }
184184- case 'secp256k1': {
185185- keypair = await Secp256k1PrivateKey.importRaw(privateKeyBytes);
186186- break;
187187- }
188188- default: {
189189- throw new Error(`unsupported "${type}" type`);
190190- }
191191- }
192192- } else {
193193- throw new Error('unknown input format');
194194- }
195195-196196- return {
197197- type: 'private_key',
198198- didPublicKey: await keypair.exportPublicKey('did'),
199199- keypair: keypair,
200200- };
201201- }
202202-203203- async pushPlcOperation(did, operation) {
204204- const response = await fetch(`${PLC_DIRECTORY_URL}/${did}`, {
205205- method: 'post',
206206- headers: {
207207- 'content-type': 'application/json',
208208- },
209209- body: JSON.stringify(operation),
210210- });
211211-212212- const headers = response.headers;
213213- if (!response.ok) {
214214- const type = headers.get('content-type');
215215-216216- if (type?.includes('application/json')) {
217217- const json = await response.json();
218218- if (typeof json === 'object' && json !== null && typeof json.message === 'string') {
219219- throw new Error(json.message);
220220- }
221221- }
222222-223223- throw new Error(`got http ${response.status} from plc`);
224224- }
225225- };
226226-227227-228228- /**
229229- *
230230- * @param iss The user's did
231231- * @param aud The did:web, if it's a PDS it's usually from /xrpc/com.atproto.server.describeServer
232232- * @param keypair The keypair to sign with only supporting ES256K atm
233233- * @param lxm The lxm which is usually com.atproto.server.createAccount for creating a new account
234234- * @returns {Promise<string>}
235235- */
236236- async createANewServiceAuthToken(iss, aud, keypair, lxm) {
237237-238238-239239- // Compute iat/exp defaults (60s window like reference: MINUTE/1e3)
240240- const iat = Math.floor(Date.now() / 1e3);
241241- const exp = iat + 60;
242242-243243- // Generate a 16-byte hex jti
244244- const jti = (() => {
245245- const bytes = new Uint8Array(16);
246246- // crypto in browser or node; fall back safely
247247- (globalThis.crypto || window.crypto).getRandomValues(bytes);
248248- return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
249249- })();
250250-251251-252252- // Build header and payload (omit undefined fields)
253253- // Just defaulting to ES256K since p256 was not importing on firefox
254254- const header = {typ: 'JWT', alg: 'ES256K'};
255255- const payload = {};
256256- payload.iat = iat;
257257- payload.iss = iss;
258258- payload.aud = aud;
259259- payload.exp = exp;
260260- payload.lxm = lxm;
261261- payload.jti = jti;
262262-263263- const headerB64 = jsonToB64Url(header);
264264- const payloadB64 = jsonToB64Url(payload);
265265- const toSignStr = `${headerB64}.${payloadB64}`;
266266-267267- // Sign
268268- const toSignBytes = new TextEncoder().encode(toSignStr);
269269- const sigBytes = await keypair.sign(toSignBytes);
270270-271271- // Return compact JWS
272272- const sigB64 = toBase64Url(sigBytes);
273273- return `${toSignStr}.${sigB64}`;
274274- }
275275-276276-277277-}
278278-279279-280280-export {PlcOps};
-254
web/ui-code/src/restore.js
···11-import {handleAndPDSResolver} from './atprotoUtils.js';
22-import {PlcOps} from './plc-ops.js';
33-import {normalizeOp, Operation} from '@atcute/did-plc';
44-import {AtpAgent} from '@atproto/api';
55-import {Secp256k1PrivateKeyExportable} from '@atcute/crypto';
66-import * as CBOR from '@atcute/cbor';
77-import {toBase64Url} from '@atcute/multibase';
88-99-class Restore {
1010-1111- constructor() {
1212- /** @type {PlcOps} */
1313- this.plcOps = new PlcOps();
1414- this.tempVerificationKeypair = null;
1515- /** @type {AtpAgent} */
1616- this.atpAgent = null;
1717- this.recoveryRotationKeyPair = null;
1818- //Feature flags
1919- this.justRestoreFiles = false;
2020- }
2121-2222- async recover(
2323- rotationKey,
2424- currentHandle,
2525- newPDS,
2626- newHandle,
2727- newPassword,
2828- newEmail,
2929- inviteCode,
3030- cidToRestoreTo = null,
3131- onStatus = null) {
3232-3333-3434- if (onStatus) onStatus('Resolving your handle...');
3535-3636- let {usersDid} = await handleAndPDSResolver(currentHandle);
3737-3838- if (onStatus) onStatus('Checking that the new PDS is an actual PDS (if the url is wrong, this takes a while to error out)');
3939- this.atpAgent = new AtpAgent({service: newPDS});
4040- const newHostDesc = await this.atpAgent.com.atproto.server.describeServer();
4141-4242-4343- //Check to see if the user already has a repo on the new PDS, if they do no reason to try and restore via the plc operations
4444- try {
4545- await this.atpAgent.com.atproto.repo.describeRepo({repo: usersDid.toString()});
4646- //If we got this far and there is a repo on the new PDS with the users did, we can just move on and restore the files
4747- this.justRestoreFiles = true;
4848-4949- } catch (error) {
5050- console.error(error);
5151- let parsedError = error.error;
5252- if (parsedError === 'RepoDeactivated') {
5353- //Ideally should mean they already have a repo on the new PDS and we just need to restore the files
5454- this.justRestoreFiles = true;
5555- }
5656- //This is the error we want to see, anything else throw
5757- if (parsedError !== 'RepoNotFound') {
5858- throw error;
5959- }
6060- }
6161-6262- //We need to double check that the new handle has not been taken, if it has we need to throw an error
6363- //We care a bit more because we do not want any unnecessary plc ops to be created
6464- try {
6565- let resolveHandle = await this.atpAgent.com.atproto.identity.resolveHandle({handle: newHandle});
6666- if (resolveHandle.data.did === usersDid.toString()) {
6767- //Ideally shouldn't get here without the checks above. But we do not need to create a new account or do plc ops. It should already be there
6868- this.justRestoreFiles = true;
6969- } else {
7070- //There is a repo with that name and it's not the users did,
7171- throw new Error('The new handle is already taken, please select a different handle');
7272- }
7373- } catch (error) {
7474- // Going to silently log this and just assume the handle has not been taken.
7575- console.error(error);
7676- if (error.message.startsWith('The new handle')) {
7777- //it's not our custom error, so we can just throw it
7878- throw error;
7979- }
8080-8181- }
8282-8383- if (!this.justRestoreFiles) {
8484-8585- if (onStatus) onStatus('Validating your private rotation key is in the correct format...');
8686- this.recoveryRotationKeyPair = await this.plcOps.getKeyPair(rotationKey);
8787-8888-8989- if (onStatus) onStatus('Resolving PlC operation logs...');
9090-9191- /** @type {Operation} */
9292- let baseOpForSigning = null;
9393- let opPrevCid = null;
9494-9595- //This is for reversals against a rogue plc op and you want to restore to a specific cid in the audit log
9696- if (cidToRestoreTo) {
9797- let auditLogs = await this.plcOps.getPlcAuditLogs(usersDid);
9898- for (const log of auditLogs) {
9999- if (log.cid === cidToRestoreTo) {
100100- baseOpForSigning = normalizeOp(log.operation);
101101- opPrevCid = log.cid;
102102- break;
103103- }
104104- }
105105- if (!baseOpForSigning) {
106106- throw new Error('Could not find the cid in the audit logs');
107107- }
108108- } else {
109109- let {lastOperation, base} = await this.plcOps.getLastPlcOpFromPlc(usersDid);
110110- opPrevCid = base.cid;
111111- baseOpForSigning = lastOperation;
112112- }
113113-114114- if (onStatus) onStatus('Preparing to switch to a temp atproto key...');
115115- if (this.tempVerificationKeypair == null) {
116116- if (onStatus) onStatus('Creating a new temp atproto key...');
117117- this.tempVerificationKeypair = await Secp256k1PrivateKeyExportable.createKeypair();
118118- }
119119- //Just defaulting to the user's recovery key for now. Advance cases will be something else
120120- //Maybe just a new ui to edit the PLC doc in a limited capacity, but sinc ethis is a temp plc op i don't think it's needed
121121- let tempRotationKeys = [this.recoveryRotationKeyPair.didPublicKey];
122122-123123- if (onStatus) onStatus('Modifying the PLC OP for recovery...');
124124- //A temp plc op for control of the atproto key to create a serviceAuth and new account on the new PDS
125125- await this.plcOps.signAndPublishNewOp(
126126- usersDid,
127127- this.recoveryRotationKeyPair.keypair,
128128- baseOpForSigning.alsoKnownAs,
129129- tempRotationKeys,
130130- newPDS,
131131- await this.tempVerificationKeypair.exportPublicKey('did'),
132132- opPrevCid);
133133-134134-135135- if (onStatus) onStatus('Creating your new account on the new PDS...');
136136- let serviceAuthToken = await this.plcOps.createANewServiceAuthToken(usersDid, newHostDesc.data.did, this.tempVerificationKeypair, 'com.atproto.server.createAccount');
137137-138138- let createAccountRequest = {
139139- did: usersDid,
140140- handle: newHandle,
141141- email: newEmail,
142142- password: newPassword,
143143- };
144144- if (inviteCode) {
145145- createAccountRequest.inviteCode = inviteCode;
146146- }
147147- const _ = await this.atpAgent.com.atproto.server.createAccount(
148148- createAccountRequest,
149149- {
150150- headers: {authorization: `Bearer ${serviceAuthToken}`},
151151- encoding: 'application/json',
152152- });
153153- }
154154-155155- await this.atpAgent.login({
156156- identifier: usersDid,
157157- password: newPassword,
158158- });
159159-160160- if (!this.justRestoreFiles) {
161161- //Moving the user offically to the new PDS
162162- if (onStatus) onStatus('Signing the papers...');
163163- let {base} = await this.plcOps.getLastPlcOpFromPlc(usersDid);
164164- await this.signRestorePlcOperation(usersDid, [this.recoveryRotationKeyPair.didPublicKey], base.cid);
165165- }
166166-167167- if (onStatus) onStatus('Success! Restoring your repo...');
168168- const pdsMoover = new AtpAgent({service: window.location.origin});
169169- const repoRes = await pdsMoover.com.atproto.sync.getRepo({did: usersDid});
170170- await this.atpAgent.com.atproto.repo.importRepo(repoRes.data, {
171171- encoding: 'application/vnd.ipld.car',
172172- });
173173-174174- if (onStatus) onStatus('Restoring your blobs...');
175175-176176- //Using the missing endpoint to findout what's missing then the PDS MOOver endpoint to restore
177177- let totalMissingBlobs = 0;
178178- let missingBlobCursor = undefined;
179179- let missingUploadedBlobs = 0;
180180-181181- do {
182182-183183- const missingBlobs = await this.atpAgent.com.atproto.repo.listMissingBlobs({
184184- cursor: missingBlobCursor,
185185- limit: 1000,
186186- });
187187- totalMissingBlobs += missingBlobs.data.blobs.length;
188188-189189- for (const recordBlob of missingBlobs.data.blobs) {
190190- try {
191191-192192- const blobRes = await pdsMoover.com.atproto.sync.getBlob({
193193- did: usersDid,
194194- cid: recordBlob.cid,
195195- });
196196- let result = await this.atpAgent.com.atproto.repo.uploadBlob(blobRes.data, {
197197- encoding: blobRes.headers['content-type'],
198198- });
199199-200200-201201- if (missingUploadedBlobs % 2 === 0) {
202202- if (onStatus) onStatus(`Migrating blobs: ${missingUploadedBlobs}/${totalMissingBlobs} (The total may increase as we find more)`);
203203- }
204204- missingUploadedBlobs++;
205205- } catch (error) {
206206- console.error(error);
207207- }
208208- }
209209- missingBlobCursor = missingBlobs.data.cursor;
210210- } while (missingBlobCursor);
211211-212212- const accountStatus = await this.atpAgent.com.atproto.server.checkAccountStatus();
213213- if (!accountStatus.data.activated) {
214214- if (onStatus) onStatus('Activating your account...');
215215- await this.atpAgent.com.atproto.server.activateAccount();
216216- }
217217- }
218218-219219- async signRestorePlcOperation(usersDid, additionalRotationKeysToAdd = [], prevCid) {
220220- const getDidCredentials =
221221- await this.atpAgent.com.atproto.identity.getRecommendedDidCredentials();
222222- console.log(getDidCredentials);
223223-224224- const pdsProvidedRotationKeys = getDidCredentials.data.rotationKeys ?? [];
225225- // Prepend any additional rotation keys (e.g., user-added keys, newly created key) so they appear above the new PDS rotation key
226226- const rotationKeys = [...(additionalRotationKeysToAdd || []), ...pdsProvidedRotationKeys];
227227- if (!rotationKeys) {
228228- throw new Error('No rotation key provided from the new PDS');
229229- }
230230- const plcOpToSubmit = {
231231- type: 'plc_operation',
232232- ...getDidCredentials.data,
233233- prev: prevCid,
234234- rotationKeys: rotationKeys,
235235- };
236236-237237-238238- const opBytes = CBOR.encode(plcOpToSubmit);
239239- const sigBytes = await this.recoveryRotationKeyPair.keypair.sign(opBytes);
240240-241241- const signature = toBase64Url(sigBytes);
242242-243243- const signedOperation = {
244244- ...plcOpToSubmit,
245245- sig: signature,
246246- };
247247-248248- await this.plcOps.pushPlcOperation(usersDid, signedOperation);
249249- await this.atpAgent.com.atproto.server.activateAccount();
250250-251251- }
252252-}
253253-254254-export {Restore};
-28
web/ui-code/src/utils.js
···11-class MooverUtils {
22- formatDate(value) {
33- if (!value) return '—';
44- try {
55- const d = new Date(value);
66- return d.toLocaleString();
77- } catch (err) {
88- console.error(err);
99- return String(value);
1010- }
1111- }
1212-1313- formatBytes(bytes) {
1414- if (bytes == null) return '—';
1515- const units = ['B', 'KB', 'MB', 'GB', 'TB'];
1616- let i = 0;
1717- let v = Number(bytes);
1818- while (v >= 1024 && i < units.length - 1) {
1919- v /= 1024;
2020- i++;
2121- }
2222- return v.toFixed(1) + ' ' + units[i];
2323- }
2424-2525-}
2626-2727-2828-export {MooverUtils};