···3A series of web tools to help users: migrate to a new PDS, find missing blobs, have free backups, and restore from
4backups in the event of emergency. [pdsmoover.com](https://pdsmoover.com)
56-
78A little light on documentation as I come off of about a week long crunch but.
9···23- [cron-worker](./cron-worker) - Very simple binary to tick every hour telling the main worker to check for repos that
24 need an update
25- [Dockerfiles](./Dockerfiles) - Dockerfiles for all the services in the repo
26-- [lexicon_types](./lexicon_types) - TypeScript types for PDS MOOver lexicons (not currently in use)
00027- [lexicon_types_crate](./lexicon_types_crate) - Rust lexicon types
28- [lexicons](./lexicons) - JSON Lexicons
29- [ProductionComposes](./ProductionComposes) - What I use to run PDS MOOver in production. One instance of web behind a
30 load balancer, one worker node currently with 3 instances on that one server. All can scale horizontally
31- [shared](./shared) - Shared code between all the services
32- [web](./web) - The web frontend that servers XRPC endpoints and the frontend
33-- [web-ui](./web/ui-code) - JS code to handle everything to do with atproto such as Migrations, missing blobs, signing
34 plc ops, restores, etc
35- [worker](./worker) - What acutally handles all the backing up, but the actual logic is
36 in [./shared/src/jobs/](./shared/src/jobs/)
···3A series of web tools to help users: migrate to a new PDS, find missing blobs, have free backups, and restore from
4backups in the event of emergency. [pdsmoover.com](https://pdsmoover.com)
56+
78A little light on documentation as I come off of about a week long crunch but.
9···23- [cron-worker](./cron-worker) - Very simple binary to tick every hour telling the main worker to check for repos that
24 need an update
25- [Dockerfiles](./Dockerfiles) - Dockerfiles for all the services in the repo
26+- [packages/lexicons](./packages/lexicons) - TypeScript types for PDS MOOver lexicons
27+- [packages/moover](./packages/moover) - Frontend logic that handles all the atproto processes. Also published as a node
28+ module
29+ at [@pds-moover/moover](https://www.npmjs.com/package/@pds-moover/moover)
30- [lexicon_types_crate](./lexicon_types_crate) - Rust lexicon types
31- [lexicons](./lexicons) - JSON Lexicons
32- [ProductionComposes](./ProductionComposes) - What I use to run PDS MOOver in production. One instance of web behind a
33 load balancer, one worker node currently with 3 instances on that one server. All can scale horizontally
34- [shared](./shared) - Shared code between all the services
35- [web](./web) - The web frontend that servers XRPC endpoints and the frontend
36+- [web-ui](./web-ui) - Svelte frontend.
37 plc ops, restores, etc
38- [worker](./worker) - What acutally handles all the backing up, but the actual logic is
39 in [./shared/src/jobs/](./shared/src/jobs/)
···1+# @pds-moover/moover
2+3+
4+5+This is the core logic that runs [PDS MOOver](https://pdsmoover.com). With this you should be able to create your own
6+"PDS MOOver" without having
7+to figure out the atproto logic.
8+9+- [lib/atprotoUtils.js](./lib/atprotoUtils.js) - Helpers for atproto actions
10+- [Migrator](./lib/pdsmoover.js) - For handling regular migrations
11+- [BackupService](./lib/backup.js) - For signing up for backups, request a back up, and remove backups to a PDS MOOver
12+ instance
13+- [MissingBlobs](./lib/missingBlobs.js) - Finds missing blobs on your old PDS and uploads them to your new PDS
14+- [PlcOps](./lib/plc-ops.js) - Helpers for manual PCL operations
15+- [Restore](./lib/restore.js) - Handles a recovery and restores the at proto from the backup
···1+import {
2+ CompositeDidDocumentResolver, CompositeHandleResolver,
3+ DohJsonHandleResolver,
4+ PlcDidDocumentResolver, WebDidDocumentResolver,
5+ WellKnownHandleResolver
6+} from '@atcute/identity-resolver';
7+8+const handleResolver = new CompositeHandleResolver({
9+ strategy: 'race',
10+ methods: {
11+ dns: new DohJsonHandleResolver({
12+ dohUrl: 'https://mozilla.cloudflare-dns.com/dns-query',
13+ }),
14+ http: new WellKnownHandleResolver(),
15+ },
16+});
17+18+const docResolver = new CompositeDidDocumentResolver({
19+ methods: {
20+ plc: new PlcDidDocumentResolver(),
21+ web: new WebDidDocumentResolver(),
22+ },
23+});
24+25+/**
26+ * Cleans the handle of @ and some other unicode characters that used to show up when copied from the profile
27+ * @param handle {string}
28+ * @returns {string}
29+ */
30+const cleanHandle = (handle) =>
31+ handle.replace('@', '').trim().replace(
32+ /[\u202A\u202C\u200E\u200F\u2066-\u2069]/g,
33+ '',
34+ );
35+36+37+/**
38+ * Convince helper to resolve a handle to a did and then find the PDS url from the did document.
39+ *
40+ * @param handle
41+ * @returns {Promise<{usersDid: string, pds: string}>}
42+ */
43+async function handleAndPDSResolver(handle) {
44+ let usersDid = null;
45+ if (handle.startsWith('did:')) {
46+ usersDid = handle;
47+ } else {
48+ const cleanedHandle = cleanHandle(handle);
49+ usersDid = await handleResolver.resolve(cleanedHandle);
50+ }
51+ const didDoc = await docResolver.resolve(usersDid);
52+53+ let pds;
54+ try {
55+ pds = didDoc.service?.filter((s) =>
56+ s.type === 'AtprotoPersonalDataServer'
57+ )[0].serviceEndpoint;
58+ } catch (error) {
59+ throw new Error('Could not find a PDS in the DID document.');
60+ }
61+ return {usersDid, pds};
62+}
63+64+65+/**
66+ * Fetches the DID Web from the .well-known/did.json endpoint of the server.
67+ * Legacy and was helpful if the web ui and server are on the same domain, not as useful now
68+ * @param baseUrl
69+ * @returns {Promise<*>}
70+ */
71+async function fetchPDSMooverDIDWeb(baseUrl) {
72+ const response = await fetch(`${baseUrl}/.well-known/did.json`);
73+ if (!response.ok) {
74+ throw new Error(`Failed to fetch DID document: ${response.status}`);
75+ }
76+ const didDoc = await response.json();
77+ return didDoc.id;
78+}
79+80+81+export {handleResolver, docResolver, cleanHandle, handleAndPDSResolver, fetchPDSMooverDIDWeb};
···1+import {Client, CredentialManager, ok} from '@atcute/client';
2+import {handleAndPDSResolver} from './atprotoUtils.js';
3+//Shows as unused, but is used in the return types
4+import {ComPdsmooverBackupDescribeServer} from '@pds-moover/lexicons';
5+6+/**
7+ * JSDoc type-only import to avoid runtime import errors in the browser.
8+ * @typedef {import('@atcute/lexicons').InferXRPCBodyOutput} InferXRPCBodyOutput
9+ */
10+11+12+/**
13+ * Logic to sign up and manage backups for pdsmoover.com (or your own selfhosted instance)
14+ */
15+class BackupService {
16+ /**
17+ *
18+ * @param backupDidWeb {string} - The did:web for the xrpc service for backups, defaults to did:web:pdsmoover.com
19+ */
20+ constructor(backupDidWeb = 'did:web:pdsmoover.com') {
21+ /**
22+ *
23+ * @type {Client}
24+ */
25+ this.atCuteClient = null;
26+ /**
27+ *
28+ * @type {CredentialManager}
29+ */
30+ this.atCuteCredentialManager = null;
31+32+ /**
33+ * The did:web for the xrpc service for backups, defaults to pdsmoover.com
34+ * @type {string}
35+ */
36+ this.backupDidWeb = backupDidWeb;
37+ }
38+39+40+ /**
41+ * Logs in and returns the backup status.
42+ * To use the rest of the BackupService, it is assumed that this has ran first,
43+ * and the user has successfully signed up. A successful login is a returned null if the user has not signed up.
44+ * or the backup status if they are
45+ *
46+ * If the server requires 2FA,
47+ * it will throw with error.error === 'AuthFactorTokenRequired'.
48+ * @param identifier {string} handle or did
49+ * @param password {string}
50+ * @param {function|null} onStatus - a function that takes a string used to update the UI.
51+ * Like (status) => console.log(status)
52+ * @param twoFactorCode {string|null}
53+ *
54+ * @returns {Promise<InferXRPCBodyOutput<ComPdsmooverBackupDescribeServer.mainSchema['output']>|null>}
55+ */
56+ async loginAndStatus(identifier, password, onStatus = null, twoFactorCode = null) {
57+ let {pds} = await handleAndPDSResolver(identifier);
58+59+60+ const manager = new CredentialManager({
61+ service: pds
62+ });
63+64+65+ const rpc = new Client({
66+ handler: manager,
67+ proxy: {
68+ did: this.backupDidWeb,
69+ serviceId: '#repo_backup'
70+ }
71+ });
72+73+ try {
74+ if (onStatus) onStatus('Signing in…');
75+76+ let loginInput = {
77+ identifier,
78+ password,
79+80+ };
81+ if (twoFactorCode) {
82+ loginInput.code = twoFactorCode;
83+ }
84+ await manager.login(loginInput);
85+86+87+ // Make the client/manager available regardless of repo status so we can sign up if needed.
88+ this.atCuteClient = rpc;
89+ this.atCuteCredentialManager = manager;
90+91+ if (onStatus) onStatus('Checking backup status');
92+ const result = await rpc.get('com.pdsmoover.backup.getRepoStatus', {
93+ params: {
94+ did: manager.session.did.toString()
95+ }
96+ });
97+ if (result.ok) {
98+ return result.data;
99+ } else {
100+ switch (result.data.error) {
101+ case 'RepoNotFound':
102+ return null;
103+ default:
104+ throw result.data.error;
105+ }
106+107+ }
108+ } catch (err) {
109+ throw err;
110+ }
111+ }
112+113+ /**
114+ * Signs the user up for backups with the service
115+ * @param onStatus
116+ * @returns {Promise<void>}
117+ */
118+ async signUp(onStatus = null) {
119+ if (!this.atCuteClient || !this.atCuteCredentialManager) {
120+ throw new Error('Not signed in');
121+ }
122+ if (onStatus) onStatus('Creating backup registration…');
123+ await ok(
124+ this.atCuteClient.post('com.pdsmoover.backup.signUp', {
125+ as: null,
126+ })
127+ );
128+ if (onStatus) onStatus('Backup registration complete');
129+ //No return if successful
130+ }
131+132+ /**
133+ * Requests a PLC token to be sent to the user's email, needed to add a new rotation key
134+ * @returns {Promise<void>}
135+ */
136+ async requestAPlcToken() {
137+ if (!this.atCuteClient || !this.atCuteCredentialManager) {
138+ throw new Error('Not signed in');
139+ }
140+ const rpc = new Client({
141+ handler: this.atCuteCredentialManager,
142+ });
143+144+ let response = await rpc.post('com.atproto.identity.requestPlcOperationSignature', {
145+ as: null,
146+ });
147+ if (!response.ok) {
148+ throw new Error(response.data?.message || 'Failed to request PLC token');
149+ }
150+ }
151+152+ /**
153+ * Adds a new rotation to the users did document. Assumes you are already signed in.
154+ *
155+ * WARNING: This will overwrite any existing rotation keys with the new one at the top, and the PDS key as the second one
156+ * @param plcToken {string} - PLC token from the user's email that was sent from requestAPlcToken
157+ * @param rotationKey {string} - The new rotation key to add to the user's did document
158+ * @returns {Promise<void>}
159+ */
160+ async addANewRotationKey(plcToken, rotationKey) {
161+ if (!this.atCuteClient || !this.atCuteCredentialManager) {
162+ throw new Error('Not signed in');
163+ }
164+165+166+ const rpc = new Client({
167+ handler: this.atCuteCredentialManager,
168+ });
169+170+ let getDidCredentials = await rpc.get('com.atproto.identity.getRecommendedDidCredentials');
171+172+ if (getDidCredentials.ok) {
173+ const pdsProvidedRotationKeys = getDidCredentials.data.rotationKeys ?? [];
174+ const updatedRotationKeys = [rotationKey, ...pdsProvidedRotationKeys];
175+176+ const credentials = {
177+ ...getDidCredentials.data,
178+ rotationKeys: updatedRotationKeys,
179+ };
180+181+ const signDocRes = await rpc.post('com.atproto.identity.signPlcOperation', {
182+ input: {
183+ token: plcToken,
184+ ...credentials,
185+ }
186+ });
187+188+ if (signDocRes.ok) {
189+ const submitDocRes = await rpc.post('com.atproto.identity.submitPlcOperation', {
190+ input: signDocRes.data,
191+ as: null,
192+ });
193+194+ if (!submitDocRes.ok) {
195+ throw new Error(submitDocRes.data?.message || 'Failed to submit PLC operation');
196+ }
197+198+ } else {
199+ throw new Error(signDocRes.data?.message || 'Failed to sign PLC operation');
200+ }
201+202+203+ } else {
204+ throw new Error(getDidCredentials.data?.message || 'Failed to get status');
205+ }
206+ }
207+208+209+ /**
210+ *
211+ * Gets the current status of the user's backup repository.
212+ *
213+ * @param onStatus {function|null} - a function that takes a string used to update the UI.
214+ * @returns {Promise<InferXRPCBodyOutput<ComPdsmooverBackupDescribeServer.mainSchema['output']>>}
215+ */
216+ async getUsersRepoStatus(onStatus = null) {
217+ if (!this.atCuteClient || !this.atCuteCredentialManager) {
218+ throw new Error('Not signed in');
219+ }
220+ if (onStatus) onStatus('Refreshing backup status…');
221+ const result = await this.atCuteClient.get('com.pdsmoover.backup.getRepoStatus', {
222+ params: {did: this.atCuteCredentialManager.session.did.toString()}
223+ });
224+ if (result.ok) {
225+ return result.data;
226+ } else {
227+ throw new Error(result.data?.message || 'Failed to get status');
228+ }
229+ }
230+231+ /**
232+ * Requests a backup to be run immediately for the signed-in user. Usually does, depend on the server's backup queue
233+ * @param onStatus
234+ * @returns {Promise<boolean>}
235+ */
236+ async runBackupNow(onStatus = null) {
237+ if (!this.atCuteClient || !this.atCuteCredentialManager) {
238+ throw new Error('Not signed in');
239+ }
240+ if (onStatus) onStatus('Requesting backup…');
241+ const res = await this.atCuteClient.post('com.pdsmoover.backup.requestBackup', {as: null, data: {}});
242+ if (res.ok) {
243+ if (onStatus) onStatus('Backup requested.');
244+ return true;
245+ } else {
246+ const err = res.data;
247+ if (err?.error === 'Timeout') {
248+ throw {error: 'Timeout', message: err?.message || 'Please wait a few minutes before requesting again.'};
249+ }
250+ throw new Error(err?.message || 'Failed to request backup');
251+ }
252+ }
253+254+ /**
255+ * Remove (delete) the signed-in user's backup repository. this also deletes all the user's backup data.
256+ * @param onStatus
257+ * @returns {Promise<boolean>}
258+ */
259+ async removeRepo(onStatus = null) {
260+ if (!this.atCuteClient || !this.atCuteCredentialManager) {
261+ throw new Error('Not signed in');
262+ }
263+ if (onStatus) onStatus('Deleting backup repository…');
264+ const res = await this.atCuteClient.post('com.pdsmoover.backup.removeRepo', {as: null, data: {}});
265+ if (res.ok) {
266+ if (onStatus) onStatus('Backup repository deleted.');
267+ return true;
268+ } else {
269+ const err = res.data;
270+ throw new Error(err?.message || 'Failed to delete backup repository');
271+ }
272+ }
273+}
274+275+276+export {BackupService};
+17
packages/moover/lib/main.js
···00000000000000000
···1+import {Migrator} from './pdsmoover.js';
2+import {MissingBlobs} from './missingBlobs.js';
3+import {BackupService} from './backup.js';
4+import {PlcOps} from './plc-ops.js';
5+import {Restore} from './restore.js';
6+import {handleAndPDSResolver} from './atprotoUtils.js';
7+8+export {
9+ Migrator,
10+ MissingBlobs,
11+ BackupService,
12+ PlcOps,
13+ Restore,
14+ handleAndPDSResolver,
15+16+}
17+
···1+import {AtpAgent} from '@atproto/api';
2+import {handleAndPDSResolver} from './atprotoUtils.js';
3+4+5+/**
6+ * Class to help find missing blobs from the did's previous PDS and import them into the current PDS
7+ */
8+class MissingBlobs {
9+10+ constructor() {
11+ /**
12+ * The user's current PDS agent
13+ * @type {AtpAgent}
14+ */
15+ this.currentPdsAgent = null;
16+ /**
17+ * The user's old PDS agent
18+ * @type {AtpAgent}
19+ */
20+ this.oldPdsAgent = null;
21+ /**
22+ * the user's did
23+ * @type {string|null}
24+ */
25+ this.did = null;
26+ /**
27+ * The user's current PDS url
28+ * @type {null}
29+ */
30+ this.currentPdsUrl = null;
31+ /**
32+ * 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
33+ * @type {string[]}
34+ */
35+ this.missingBlobs = [];
36+37+ }
38+39+ /**
40+ * Logs the user into the current PDS and gets the account status
41+ * @param handle {string}
42+ * @param password {string}
43+ * @param twoFactorCode {string|null}
44+ * @returns {Promise<{accountStatus: OutputSchema, missingBlobsCount: number}>}
45+ */
46+ async currentAgentLogin(
47+ handle,
48+ password,
49+ twoFactorCode = null,
50+ ) {
51+ let {usersDid, pds} = await handleAndPDSResolver(handle);
52+ this.did = usersDid;
53+ this.currentPdsUrl = pds;
54+ const agent = new AtpAgent({
55+ service: pds,
56+ });
57+58+ if (twoFactorCode === null) {
59+ await agent.login({identifier: usersDid, password});
60+ } else {
61+ await agent.login({identifier: usersDid, password: password, authFactorToken: twoFactorCode});
62+ }
63+64+ this.currentPdsAgent = agent;
65+66+ const result = await agent.com.atproto.server.checkAccountStatus();
67+ const missingBlobs = await this.currentPdsAgent.com.atproto.repo.listMissingBlobs({
68+ limit: 10,
69+ });
70+ return {accountStatus: result.data, missingBlobsCount: missingBlobs.data.blobs.length};
71+ }
72+73+ /**
74+ * Logs into the old PDS and gets the account status.
75+ * Does not need a handle
76+ * since it is assumed the user has already logged in with the current PDS and we are using their did
77+ * @param password {string}
78+ * @param twoFactorCode {string|null}
79+ * @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
80+ * @returns {Promise<void>}
81+ */
82+ async oldAgentLogin(
83+ password,
84+ twoFactorCode = null,
85+ pdsUrl = null,
86+ ) {
87+ let oldPds = null;
88+89+ if (pdsUrl === null) {
90+ const response = await fetch(`https://plc.directory/${this.did}/log`);
91+ let auditLog = await response.json();
92+ auditLog = auditLog.reverse();
93+ let debugCount = 0;
94+ for (const entry of auditLog) {
95+ console.log(`Loop: ${debugCount++}`);
96+ console.log(entry);
97+ if (entry.services) {
98+ if (entry.services.atproto_pds) {
99+ if (entry.services.atproto_pds.type === 'AtprotoPersonalDataServer') {
100+ const pds = entry.services.atproto_pds.endpoint;
101+ console.log(`Found PDS: ${pds}`);
102+ if (pds.toLowerCase() !== this.currentPdsUrl.toLowerCase()) {
103+ oldPds = pds;
104+ break;
105+ }
106+ }
107+ }
108+ }
109+ }
110+ if (oldPds === null) {
111+ throw new Error('Could not find your old PDS');
112+ }
113+ } else {
114+ oldPds = pdsUrl;
115+ }
116+117+ const agent = new AtpAgent({
118+ service: oldPds,
119+ });
120+121+ if (twoFactorCode === null) {
122+ await agent.login({identifier: this.did, password});
123+ } else {
124+ await agent.login({identifier: this.did, password: password, authFactorToken: twoFactorCode});
125+ }
126+ this.oldPdsAgent = agent;
127+ }
128+129+ /**
130+ * Gets the missing blobs from the old PDS and uploads them to the current PDS
131+ * @param statusUpdateHandler {function} - A function to update the status of the migration. This is useful for showing the user the progress of the migration
132+ * @returns {Promise<{accountStatus: OutputSchema, missingBlobsCount: number}>}
133+ */
134+ async migrateMissingBlobs(statusUpdateHandler) {
135+ if (this.currentPdsAgent === null) {
136+ throw new Error('Current PDS agent is not set');
137+ }
138+ if (this.oldPdsAgent === null) {
139+ throw new Error('Old PDS agent is not set');
140+ }
141+ statusUpdateHandler('Starting to import blobs...');
142+143+ let totalMissingBlobs = 0;
144+ let missingBlobCursor = undefined;
145+ let missingUploadedBlobs = 0;
146+147+ do {
148+149+ const missingBlobs = await this.currentPdsAgent.com.atproto.repo.listMissingBlobs({
150+ cursor: missingBlobCursor,
151+ limit: 1000,
152+ });
153+ totalMissingBlobs += missingBlobs.data.blobs.length;
154+155+ for (const recordBlob of missingBlobs.data.blobs) {
156+ try {
157+158+ const blobRes = await this.oldPdsAgent.com.atproto.sync.getBlob({
159+ did: this.did,
160+ cid: recordBlob.cid,
161+ });
162+ let result = await this.currentPdsAgent.com.atproto.repo.uploadBlob(blobRes.data, {
163+ encoding: blobRes.headers['content-type'],
164+ });
165+166+ if (result.status === 429) {
167+ statusUpdateHandler(`You are being rate limited. Will need to try again later to get the rest of the blobs. Migrated blobs: ${missingUploadedBlobs}/${totalMissingBlobs}`);
168+ }
169+170+ if (missingUploadedBlobs % 2 === 0) {
171+ statusUpdateHandler(`Migrating blobs: ${missingUploadedBlobs}/${totalMissingBlobs} (The total may increase as we find more)`);
172+ }
173+ missingUploadedBlobs++;
174+ } catch (error) {
175+ console.error(error);
176+ this.missingBlobs.push(recordBlob.cid);
177+ }
178+ }
179+ missingBlobCursor = missingBlobs.data.cursor;
180+ } while (missingBlobCursor);
181+182+ const accountStatus = await this.currentPdsAgent.com.atproto.server.checkAccountStatus();
183+ const missingBlobs = await this.currentPdsAgent.com.atproto.repo.listMissingBlobs({
184+ limit: 10,
185+ });
186+ return {accountStatus: accountStatus.data, missingBlobsCount: missingBlobs.data.blobs.length};
187+188+189+ }
190+191+}
192+193+export {MissingBlobs};
···1+import {docResolver, cleanHandle, handleResolver} from './atprotoUtils.js';
2+import {AtpAgent} from '@atproto/api';
3+4+5+function safeStatusUpdate(statusUpdateHandler, status) {
6+ if (statusUpdateHandler) {
7+ statusUpdateHandler(status);
8+ }
9+}
10+11+/**
12+ * Handles normal PDS Migrations between two PDSs that are both up.
13+ * On pdsmoover.com this is the logic for the MOOver
14+ */
15+class Migrator {
16+ constructor() {
17+ /** @type {AtpAgent} */
18+ this.oldAgent = null;
19+ /** @type {AtpAgent} */
20+ this.newAgent = null;
21+ /** @type {[string]} */
22+ this.missingBlobs = [];
23+ //State for reruns
24+ /** @type {boolean} */
25+ this.createNewAccount = true;
26+ /** @type {boolean} */
27+ this.migrateRepo = true;
28+ /** @type {boolean} */
29+ this.migrateBlobs = true;
30+ /** @type {boolean} */
31+ this.migrateMissingBlobs = true;
32+ /** @type {boolean} */
33+ this.migratePrefs = true;
34+ /** @type {boolean} */
35+ this.migratePlcRecord = true;
36+ }
37+38+ /**
39+ * This migrator is pretty cut and dry and makes a few assumptions
40+ * 1. You are using the same password between each account
41+ * 2. If this command fails for something like oauth 2fa code it throws an error and expects the same values when ran again.
42+ * 3. You can control which "actions" happen by setting the class variables to false.
43+ * 4. Each instance of the class is assumed to be for a single migration
44+ * @param {string} oldHandle - The handle you use on your old pds, something like alice.bsky.social
45+ * @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
46+ * @param {string} newPdsUrl - The new URL for your pds. Like https://coolnewpds.com
47+ * @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)
48+ * @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.
49+ * @param {string|null} inviteCode - The invite code you got from the PDS you are migrating to. If null does not include one
50+ * @param {function|null} statusUpdateHandler - a function that takes a string used to update the UI. Like (status) => console.log(status)
51+ * @param {string|null} twoFactorCode - Optional, but needed if it fails with 2fa required
52+ */
53+ async migrate(oldHandle, password, newPdsUrl, newEmail, newHandle, inviteCode, statusUpdateHandler = null, twoFactorCode = null) {
54+ //Leaving this logic that either sets the agent to bsky.social, or the PDS since it's what I found worked best for migrations.
55+ // handleAndPDSResolver should be able to handle it, but there have been edge cases and this was what worked best
56+ oldHandle = cleanHandle(oldHandle);
57+ let oldAgent;
58+ let usersDid;
59+ //If it's a bsky handle just go with the entryway and let it sort everything
60+ if (oldHandle.endsWith('.bsky.social')) {
61+ oldAgent = new AtpAgent({service: 'https://bsky.social'});
62+ const publicAgent = new AtpAgent({service: 'https://public.api.bsky.app'});
63+ const resolveIdentityFromEntryway = await publicAgent.com.atproto.identity.resolveHandle({handle: oldHandle});
64+ usersDid = resolveIdentityFromEntryway.data.did;
65+66+ } else {
67+ //Resolves the did and finds the did document for the old PDS
68+ safeStatusUpdate(statusUpdateHandler, 'Resolving old PDS');
69+ usersDid = await handleResolver.resolve(oldHandle);
70+ const didDoc = await docResolver.resolve(usersDid);
71+ safeStatusUpdate(statusUpdateHandler, 'Resolving did document and finding your current PDS URL');
72+73+ let oldPds;
74+ try {
75+ oldPds = didDoc.service.filter(s => s.type === 'AtprotoPersonalDataServer')[0].serviceEndpoint;
76+ } catch (error) {
77+ console.error(error);
78+ throw new Error('Could not find a PDS in the DID document.');
79+ }
80+81+ oldAgent = new AtpAgent({
82+ service: oldPds,
83+ });
84+85+ }
86+87+ safeStatusUpdate(statusUpdateHandler, 'Logging you in to the old PDS');
88+ //Login to the old PDS
89+ if (twoFactorCode === null) {
90+ await oldAgent.login({identifier: oldHandle, password});
91+ } else {
92+ await oldAgent.login({identifier: oldHandle, password: password, authFactorToken: twoFactorCode});
93+ }
94+95+ safeStatusUpdate(statusUpdateHandler, 'Checking that the new PDS is an actual PDS (if the url is wrong this takes a while to error out)');
96+ const newAgent = new AtpAgent({service: newPdsUrl});
97+ const newHostDesc = await newAgent.com.atproto.server.describeServer();
98+ if (this.createNewAccount) {
99+ const newHostWebDid = newHostDesc.data.did;
100+101+ safeStatusUpdate(statusUpdateHandler, 'Creating a new account on the new PDS');
102+103+ const createAuthResp = await oldAgent.com.atproto.server.getServiceAuth({
104+ aud: newHostWebDid,
105+ lxm: 'com.atproto.server.createAccount',
106+ });
107+ const serviceJwt = createAuthResp.data.token;
108+109+ let createAccountRequest = {
110+ did: usersDid,
111+ handle: newHandle,
112+ email: newEmail,
113+ password: password,
114+ };
115+ if (inviteCode) {
116+ createAccountRequest.inviteCode = inviteCode;
117+ }
118+ const createNewAccount = await newAgent.com.atproto.server.createAccount(
119+ createAccountRequest,
120+ {
121+ headers: {authorization: `Bearer ${serviceJwt}`},
122+ encoding: 'application/json',
123+ });
124+125+ if (createNewAccount.data.did !== usersDid.toString()) {
126+ throw new Error('Did not create the new account with the same did as the old account');
127+ }
128+ }
129+ safeStatusUpdate(statusUpdateHandler, 'Logging in with the new account');
130+131+ await newAgent.login({
132+ identifier: usersDid,
133+ password: password,
134+ });
135+136+ if (this.migrateRepo) {
137+ safeStatusUpdate(statusUpdateHandler, 'Migrating your repo');
138+ const repoRes = await oldAgent.com.atproto.sync.getRepo({did: usersDid});
139+ await newAgent.com.atproto.repo.importRepo(repoRes.data, {
140+ encoding: 'application/vnd.ipld.car',
141+ });
142+ }
143+144+ let newAccountStatus = await newAgent.com.atproto.server.checkAccountStatus();
145+146+ if (this.migrateBlobs) {
147+ safeStatusUpdate(statusUpdateHandler, 'Migrating your blobs');
148+149+ let blobCursor = undefined;
150+ let uploadedBlobs = 0;
151+ do {
152+ safeStatusUpdate(statusUpdateHandler, `Migrating blobs: ${uploadedBlobs}/${newAccountStatus.data.expectedBlobs}`);
153+154+ const listedBlobs = await oldAgent.com.atproto.sync.listBlobs({
155+ did: usersDid,
156+ cursor: blobCursor,
157+ limit: 100,
158+ });
159+160+ for (const cid of listedBlobs.data.cids) {
161+ try {
162+ const blobRes = await oldAgent.com.atproto.sync.getBlob({
163+ did: usersDid,
164+ cid,
165+ });
166+ await newAgent.com.atproto.repo.uploadBlob(blobRes.data, {
167+ encoding: blobRes.headers['content-type'],
168+ });
169+ uploadedBlobs++;
170+ if (uploadedBlobs % 10 === 0) {
171+ safeStatusUpdate(statusUpdateHandler, `Migrating blobs: ${uploadedBlobs}/${newAccountStatus.data.expectedBlobs}`);
172+ }
173+ } catch (error) {
174+ console.error(error);
175+ }
176+ }
177+ blobCursor = listedBlobs.data.cursor;
178+ } while (blobCursor);
179+ }
180+181+ if (this.migrateMissingBlobs) {
182+ newAccountStatus = await newAgent.com.atproto.server.checkAccountStatus();
183+ if (newAccountStatus.data.expectedBlobs !== newAccountStatus.data.importedBlobs) {
184+ let totalMissingBlobs = newAccountStatus.data.expectedBlobs - newAccountStatus.data.importedBlobs;
185+ safeStatusUpdate(statusUpdateHandler, 'Looks like there are some missing blobs. Going to try and upload them now.');
186+ //Probably should be shared between main blob uploader, but eh
187+ let missingBlobCursor = undefined;
188+ let missingUploadedBlobs = 0;
189+ do {
190+ safeStatusUpdate(statusUpdateHandler, `Migrating blobs: ${missingUploadedBlobs}/${totalMissingBlobs}`);
191+192+ const missingBlobs = await newAgent.com.atproto.repo.listMissingBlobs({
193+ cursor: missingBlobCursor,
194+ limit: 100,
195+ });
196+197+ for (const recordBlob of missingBlobs.data.blobs) {
198+ try {
199+200+ const blobRes = await oldAgent.com.atproto.sync.getBlob({
201+ did: usersDid,
202+ cid: recordBlob.cid,
203+ });
204+ await newAgent.com.atproto.repo.uploadBlob(blobRes.data, {
205+ encoding: blobRes.headers['content-type'],
206+ });
207+ if (missingUploadedBlobs % 10 === 0) {
208+ safeStatusUpdate(statusUpdateHandler, `Migrating blobs: ${missingUploadedBlobs}/${totalMissingBlobs}`);
209+ }
210+ missingUploadedBlobs++;
211+ } catch (error) {
212+ //TODO silently logging prob should list them so user can manually download
213+ console.error(error);
214+ this.missingBlobs.push(recordBlob.cid);
215+ }
216+ }
217+ missingBlobCursor = missingBlobs.data.cursor;
218+ } while (missingBlobCursor);
219+220+ }
221+ }
222+ if (this.migratePrefs) {
223+ const prefs = await oldAgent.app.bsky.actor.getPreferences();
224+ await newAgent.app.bsky.actor.putPreferences(prefs.data);
225+ }
226+227+ this.oldAgent = oldAgent;
228+ this.newAgent = newAgent;
229+230+ if (this.migratePlcRecord) {
231+ await oldAgent.com.atproto.identity.requestPlcOperationSignature();
232+ safeStatusUpdate(statusUpdateHandler, 'Please check your email for a PLC token');
233+ }
234+ }
235+236+ /**
237+ * Sign and submits the PLC operation to officially migrate the account
238+ * @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
239+ * @param additionalRotationKeysToAdd {string[]} - additional rotation keys to add in addition to the ones provided by the new PDS.
240+ * @returns {Promise<void>}
241+ */
242+ async signPlcOperation(token, additionalRotationKeysToAdd = []) {
243+ const getDidCredentials =
244+ await this.newAgent.com.atproto.identity.getRecommendedDidCredentials();
245+ const pdsProvidedRotationKeys = getDidCredentials.data.rotationKeys ?? [];
246+ // Prepend any additional rotation keys (e.g., user-added keys, newly created key) so they appear above the new PDS rotation key
247+ const rotationKeys = [...(additionalRotationKeysToAdd || []), ...pdsProvidedRotationKeys];
248+ if (!rotationKeys) {
249+ throw new Error('No rotation key provided from the new PDS');
250+ }
251+ const credentials = {
252+ ...getDidCredentials.data,
253+ rotationKeys: rotationKeys,
254+ };
255+256+257+ const plcOp = await this.oldAgent.com.atproto.identity.signPlcOperation({
258+ token: token,
259+ ...credentials,
260+ });
261+262+ await this.newAgent.com.atproto.identity.submitPlcOperation({
263+ operation: plcOp.data.operation,
264+ });
265+266+ await this.newAgent.com.atproto.server.activateAccount();
267+ await this.oldAgent.com.atproto.server.deactivateAccount({});
268+ }
269+270+ /**
271+ * Using this method assumes the Migrator class was constructed new and this was called.
272+ * Find the user's previous PDS from the PLC op logs,
273+ * logs in and deactivates their old account if it was found still active.
274+ *
275+ * @param oldHandle {string}
276+ * @param oldPassword {string}
277+ * @param {function|null} statusUpdateHandler - a function that takes a string used to update the UI.
278+ * Like (status) => console.log(status)
279+ * @param {string|null} twoFactorCode - Optional, but needed if it fails with 2fa required
280+ * @returns {Promise<void>}
281+ */
282+ async deactivateOldAccount(oldHandle, oldPassword, statusUpdateHandler = null, twoFactorCode = null) {
283+ //Leaving this logic that either sets the agent to bsky.social, or the PDS since it's what I found worked best for migrations.
284+ // handleAndPDSResolver should be able to handle it, but there have been edge cases and this was what worked best oldHandle = cleanHandle(oldHandle);
285+ let usersDid;
286+ //If it's a bsky handle just go with the entryway and let it sort everything
287+ if (oldHandle.endsWith('.bsky.social')) {
288+ const publicAgent = new AtpAgent({service: 'https://public.api.bsky.app'});
289+ const resolveIdentityFromEntryway = await publicAgent.com.atproto.identity.resolveHandle({handle: oldHandle});
290+ usersDid = resolveIdentityFromEntryway.data.did;
291+ } else {
292+ //Resolves the did and finds the did document for the old PDS
293+ safeStatusUpdate(statusUpdateHandler, 'Resolving did from handle');
294+ usersDid = await handleResolver.resolve(oldHandle);
295+ }
296+297+ const didDoc = await docResolver.resolve(usersDid);
298+ let currentPds;
299+ try {
300+ currentPds = didDoc.service.filter(s => s.type === 'AtprotoPersonalDataServer')[0].serviceEndpoint;
301+ } catch (error) {
302+ console.error(error);
303+ throw new Error('Could not find a PDS in the DID document.');
304+ }
305+306+ const plcLogRequest = await fetch(`https://plc.directory/${usersDid}/log`);
307+ const plcLog = await plcLogRequest.json();
308+ let pdsBeforeCurrent = '';
309+ for (const log of plcLog) {
310+ try {
311+ const pds = log.services.atproto_pds.endpoint;
312+ if (pds.toLowerCase() === currentPds.toLowerCase()) {
313+ console.log('Found the PDS before the current one');
314+ break;
315+ }
316+ pdsBeforeCurrent = pds;
317+ } catch (e) {
318+ console.log(e);
319+ }
320+ }
321+ if (pdsBeforeCurrent === '') {
322+ throw new Error('Could not find the PDS before the current one');
323+ }
324+325+ let oldAgent = new AtpAgent({service: pdsBeforeCurrent});
326+ safeStatusUpdate(statusUpdateHandler, `Logging you in to the old PDS: ${pdsBeforeCurrent}`);
327+ //Login to the old PDS
328+ if (twoFactorCode === null) {
329+ await oldAgent.login({identifier: oldHandle, password: oldPassword});
330+ } else {
331+ await oldAgent.login({identifier: oldHandle, password: oldPassword, authFactorToken: twoFactorCode});
332+ }
333+ safeStatusUpdate(statusUpdateHandler, 'Checking this isn\'t your current PDS');
334+ if (pdsBeforeCurrent === currentPds) {
335+ throw new Error('This is your current PDS. Login to your old account username and password');
336+ }
337+338+ let currentAccountStatus = await oldAgent.com.atproto.server.checkAccountStatus();
339+ if (!currentAccountStatus.data.activated) {
340+ safeStatusUpdate(statusUpdateHandler, 'All good. Your old account is not activated.');
341+ }
342+ safeStatusUpdate(statusUpdateHandler, 'Deactivating your OLD account');
343+ await oldAgent.com.atproto.server.deactivateAccount({});
344+ safeStatusUpdate(statusUpdateHandler, 'Successfully deactivated your OLD account');
345+ }
346+347+ /**
348+ * Signs the logged-in user in this.newAgent for backups with PDS MOOver. This is usually called after migrate and signPlcOperation are successful
349+ *
350+ * @param {string} didWeb
351+ * @returns {Promise<void>}
352+ */
353+ async signUpForBackupsFromMigration(didWeb = 'did:web:pdsmoover.com') {
354+355+ //Manually grabbing the jwt and making a call with fetch cause for the life of me I could not figure out
356+ //how you used @atproto/api to make a call for proxying
357+ const url = `${this.newAgent.serviceUrl.origin}/xrpc/com.pdsmoover.backup.signUp`;
358+359+ const accessJwt = this.newAgent?.session?.accessJwt;
360+ if (!accessJwt) {
361+ throw new Error('Missing access token for authorization');
362+ }
363+364+ const res = await fetch(url, {
365+ method: 'POST',
366+ headers: {
367+ 'Authorization': `Bearer ${accessJwt}`,
368+ 'Content-Type': 'application/json',
369+ 'Accept': 'application/json',
370+ 'atproto-proxy': `${didWeb}#repo_backup`,
371+ },
372+ body: JSON.stringify({}),
373+ });
374+375+ if (!res.ok) {
376+ let bodyText = '';
377+ try {
378+ bodyText = await res.text();
379+ } catch {
380+ }
381+ throw new Error(`Backup signup failed: ${res.status} ${res.statusText}${bodyText ? ` - ${bodyText}` : ''}`);
382+ }
383+384+ //No return the success is all that is needed, if there's an error it will throw
385+ }
386+}
387+388+export {Migrator};
389+
···1+/**
2+ * @typedef {import('@atcute/did-plc').Operation} Operation
3+ */
4+import {P256PrivateKey, Secp256k1PrivateKey} from '@atcute/crypto';
5+import {handleAndPDSResolver} from './atprotoUtils.js';
6+import {PlcOps} from './plc-ops.js';
7+import {normalizeOp} from '@atcute/did-plc';
8+import {AtpAgent} from '@atproto/api';
9+import {Secp256k1PrivateKeyExportable} from '@atcute/crypto';
10+import * as CBOR from '@atcute/cbor';
11+import {toBase64Url} from '@atcute/multibase';
12+13+class Restore {
14+15+ /**
16+ *
17+ * @param pdsMooverInstance {string} - The url of the pds moover instance to restore from. Defaults to https://pdsmover.com
18+ */
19+ constructor(pdsMooverInstance = 'https://pdsmover.com') {
20+ /**
21+ * If you want to use a different plc directory create your own instance of the plc ops class and pass it in here
22+ * @type {PlcOps} */
23+ this.plcOps = new PlcOps();
24+25+ /**
26+ * This is the base url for the pds moover instance used to restore the files from a backup.
27+ * @type {string}
28+ */
29+ this.pdsMooverInstance = pdsMooverInstance
30+31+ /**
32+ * To keep it simple, only uses secp256k for the temp verification key that is used to create the new account on the new PDS
33+ * and is temporarily assigned to the user's account on PLC
34+ * @type {null|Secp256k1PrivateKeyExportable}
35+ */
36+ this.tempVerificationKeypair = null;
37+38+ /** @type {AtpAgent} */
39+ this.atpAgent = null;
40+41+ /**
42+ * The keypair that is used to sign the plc operation
43+ * @type {null|{type: string, didPublicKey: `did:key:${string}`, keypair: P256PrivateKey|Secp256k1PrivateKey}}
44+ */
45+ this.recoveryRotationKeyPair = null;
46+47+ /**
48+ * If this is true we are just restoring the repo and blobs. Ideally for rerunning a restore process after account recovery
49+ * @type {boolean}
50+ */
51+ this.RestoreFromBackup = true;
52+53+ /**
54+ * If set to true then it will do the account recovery. Writes a temp key to the did doc,
55+ * 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)
56+ * @type {boolean}
57+ */
58+ this.AccountRecovery = true;
59+ }
60+61+ /**
62+ * Recovers an account with the users rotation key and restores the repo from a PDS MOOver backup
63+ * 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
64+ * @param rotationKey {string} - The users private rotation key, can be a multi key or hex key
65+ * @param rotationKeyType {string} - The type of the key, secp256k1 or p256. Required if the key is in hex format, defaults to secp256k1
66+ * @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
67+ * @param newPDS {string} - The new PDS url, like https://coolnewpds.com
68+ * @param newHandle {string} - Can be the users DNS handle if it is already setup with their did, if not it's bob.mypds.com
69+ * @param newPassword {string} - The new password for the new account
70+ * @param newEmail {string} - The new email for the new account
71+ * @param inviteCode {string|null} - The invite code for the new PDS if it requires one
72+ * @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
73+ * @param onStatus {function|null} - A function that takes a string used to update the UI. Like (status) => console.log(status)
74+ * @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
75+ * recovered and future runs need to have the RestoreFromBackup flag set to true and AccountRecovery set to false.
76+ */
77+ async recover(
78+ rotationKey,
79+ rotationKeyType = 'secp256k1',
80+ currentHandleOrDid,
81+ newPDS,
82+ newHandle,
83+ newPassword,
84+ newEmail,
85+ inviteCode,
86+ cidToRestoreTo = null,
87+ onStatus = null) {
88+89+ if (onStatus) onStatus('Resolving your handle...');
90+91+ let {usersDid} = await handleAndPDSResolver(currentHandleOrDid);
92+93+ if (onStatus) onStatus('Checking that the new PDS is an actual PDS (if the url is wrong, this takes a while to error out)');
94+ this.atpAgent = new AtpAgent({service: newPDS});
95+ const newHostDesc = await this.atpAgent.com.atproto.server.describeServer();
96+97+98+ //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
99+ try {
100+ await this.atpAgent.com.atproto.repo.describeRepo({repo: usersDid.toString()});
101+ //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.
102+ //We do not want to mess with the plc ops if we dont have to
103+ this.AccountRecovery = false;
104+105+ } catch (error) {
106+ console.error(error);
107+ let parsedError = error.error;
108+ if (parsedError === 'RepoDeactivated') {
109+ //Ideally should mean they already have a repo on the new PDS and we just need to restore the files
110+ this.AccountRecovery = false;
111+ }
112+ //This is the error we want to see, anything else throw
113+ if (parsedError !== 'RepoNotFound') {
114+ throw error;
115+ }
116+ }
117+118+ //We need to double check that the new handle has not been taken, if it has we need to throw an error
119+ //We care a bit more because we do not want any unnecessary plc ops to be created
120+ try {
121+ let resolveHandle = await this.atpAgent.com.atproto.identity.resolveHandle({handle: newHandle});
122+ if (resolveHandle.data.did === usersDid.toString()) {
123+ //This was originally setting the AccountRecovery to false, which works if it is resolved via .well-known, but not dns
124+ //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
125+ //their did should be set anyhow
126+127+ } else {
128+ //There is a repo with that name and it's not the users did,
129+ throw new Error('The new handle is already taken, please select a different handle');
130+ }
131+ } catch (error) {
132+ // Going to silently log this and just assume the handle has not been taken.
133+ console.error(error);
134+ if (error.message.startsWith('The new handle')) {
135+ //it's not our custom error, so we can just throw it
136+ throw error;
137+ }
138+139+ }
140+141+ if (this.AccountRecovery) {
142+143+ if (onStatus) onStatus('Validating your private rotation key is in the correct format...');
144+145+ this.recoveryRotationKeyPair = await this.plcOps.getKeyPair(rotationKey, rotationKeyType);
146+147+148+ if (onStatus) onStatus('Resolving PlC operation logs...');
149+150+ /** @type {Operation} */
151+ let baseOpForSigning = null;
152+ let opPrevCid = null;
153+154+ //This is for reversals against a rogue plc op and you want to restore to a specific cid in the audit log
155+ if (cidToRestoreTo) {
156+ let auditLogs = await this.plcOps.getPlcAuditLogs(usersDid);
157+ for (const log of auditLogs) {
158+ if (log.cid === cidToRestoreTo) {
159+ baseOpForSigning = normalizeOp(log.operation);
160+ opPrevCid = log.cid;
161+ break;
162+ }
163+ }
164+ if (!baseOpForSigning) {
165+ throw new Error('Could not find the cid in the audit logs');
166+ }
167+ } else {
168+ let {lastOperation, base} = await this.plcOps.getLastPlcOpFromPlc(usersDid);
169+ opPrevCid = base.cid;
170+ baseOpForSigning = lastOperation;
171+ }
172+173+ if (onStatus) onStatus('Preparing to switch to a temp atproto key...');
174+ if (this.tempVerificationKeypair == null) {
175+ if (onStatus) onStatus('Creating a new temp atproto key...');
176+ this.tempVerificationKeypair = await Secp256k1PrivateKeyExportable.createKeypair();
177+ }
178+ //Just defaulting to the user's recovery key for now. Advance cases will be something else
179+ //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
180+ let tempRotationKeys = [this.recoveryRotationKeyPair.didPublicKey];
181+182+ if (onStatus) onStatus('Modifying the PLC OP for recovery...');
183+ //A temp plc op for control of the atproto key to create a serviceAuth and new account on the new PDS
184+ await this.plcOps.signAndPublishNewOp(
185+ usersDid,
186+ this.recoveryRotationKeyPair.keypair,
187+ baseOpForSigning.alsoKnownAs,
188+ tempRotationKeys,
189+ newPDS,
190+ await this.tempVerificationKeypair.exportPublicKey('did'),
191+ opPrevCid);
192+193+194+ if (onStatus) onStatus('Creating your new account on the new PDS...');
195+ let serviceAuthToken = await this.plcOps.createANewServiceAuthToken(usersDid, newHostDesc.data.did, this.tempVerificationKeypair, 'com.atproto.server.createAccount');
196+197+ let createAccountRequest = {
198+ did: usersDid,
199+ handle: newHandle,
200+ email: newEmail,
201+ password: newPassword,
202+ };
203+ if (inviteCode) {
204+ createAccountRequest.inviteCode = inviteCode;
205+ }
206+ const _ = await this.atpAgent.com.atproto.server.createAccount(
207+ createAccountRequest,
208+ {
209+ headers: {authorization: `Bearer ${serviceAuthToken}`},
210+ encoding: 'application/json',
211+ });
212+ }
213+214+ await this.atpAgent.login({
215+ identifier: usersDid,
216+ password: newPassword,
217+ });
218+219+ if (this.AccountRecovery) {
220+ //Moving the user offically to the new PDS
221+ if (onStatus) onStatus('Signing the papers...');
222+ let {base} = await this.plcOps.getLastPlcOpFromPlc(usersDid);
223+ await this.signRestorePlcOperation(usersDid, [this.recoveryRotationKeyPair.didPublicKey], base.cid);
224+ }
225+226+ if (this.RestoreFromBackup) {
227+ if (onStatus) onStatus('Success! Restoring your repo...');
228+ const pdsMoover = new AtpAgent({service: this.pdsMooverInstance});
229+ const repoRes = await pdsMoover.com.atproto.sync.getRepo({did: usersDid});
230+ await this.atpAgent.com.atproto.repo.importRepo(repoRes.data, {
231+ encoding: 'application/vnd.ipld.car',
232+ });
233+234+ if (onStatus) onStatus('Restoring your blobs...');
235+236+ //Using the missing endpoint to findout what's missing then the PDS MOOver endpoint to restore
237+ let totalMissingBlobs = 0;
238+ let missingBlobCursor = undefined;
239+ let missingUploadedBlobs = 0;
240+241+ do {
242+243+ const missingBlobs = await this.atpAgent.com.atproto.repo.listMissingBlobs({
244+ cursor: missingBlobCursor,
245+ limit: 1000,
246+ });
247+ totalMissingBlobs += missingBlobs.data.blobs.length;
248+249+ for (const recordBlob of missingBlobs.data.blobs) {
250+ try {
251+252+ const blobRes = await pdsMoover.com.atproto.sync.getBlob({
253+ did: usersDid,
254+ cid: recordBlob.cid,
255+ });
256+ let result = await this.atpAgent.com.atproto.repo.uploadBlob(blobRes.data, {
257+ encoding: blobRes.headers['content-type'],
258+ });
259+260+261+ if (missingUploadedBlobs % 2 === 0) {
262+ if (onStatus) onStatus(`Migrating blobs: ${missingUploadedBlobs}/${totalMissingBlobs} (The total may increase as we find more)`);
263+ }
264+ missingUploadedBlobs++;
265+ } catch (error) {
266+ console.error(error);
267+ }
268+ }
269+ missingBlobCursor = missingBlobs.data.cursor;
270+ } while (missingBlobCursor);
271+ }
272+ const accountStatus = await this.atpAgent.com.atproto.server.checkAccountStatus();
273+ if (!accountStatus.data.activated) {
274+ if (onStatus) onStatus('Activating your account...');
275+ await this.atpAgent.com.atproto.server.activateAccount();
276+ }
277+278+ }
279+280+281+ /**
282+ * This method signs the plc operation over to the new PDS and activates the account
283+ * Assumes you have already created a new account during the recovery process and logged in
284+ * Uses the recommended did doc from the PDS as a base and adds the users rotation key to the rotation keys array
285+ *
286+ * @param usersDid
287+ * @param additionalRotationKeysToAdd
288+ * @param prevCid
289+ * @returns {Promise<void>}
290+ */
291+ async signRestorePlcOperation(usersDid, additionalRotationKeysToAdd = [], prevCid) {
292+ const getDidCredentials =
293+ await this.atpAgent.com.atproto.identity.getRecommendedDidCredentials();
294+295+ const pdsProvidedRotationKeys = getDidCredentials.data.rotationKeys ?? [];
296+ //Puts the provided rotation keys above the pds pro
297+ const rotationKeys = [...new Set([...(additionalRotationKeysToAdd || []), ...pdsProvidedRotationKeys])];
298+ if (!rotationKeys) {
299+ throw new Error('No rotation keys were found to be added to the PLC');
300+ }
301+302+ if (rotationKeys.length > 5) {
303+ throw new Error('You can only add up to 5 rotation keys to the PLC');
304+ }
305+306+307+ const plcOpToSubmit = {
308+ type: 'plc_operation',
309+ ...getDidCredentials.data,
310+ prev: prevCid,
311+ rotationKeys: rotationKeys,
312+ };
313+314+315+ const opBytes = CBOR.encode(plcOpToSubmit);
316+ const sigBytes = await this.recoveryRotationKeyPair.keypair.sign(opBytes);
317+318+ const signature = toBase64Url(sigBytes);
319+320+ const signedOperation = {
321+ ...plcOpToSubmit,
322+ sig: signature,
323+ };
324+325+ await this.plcOps.pushPlcOperation(usersDid, signedOperation);
326+ await this.atpAgent.com.atproto.server.activateAccount();
327+328+ }
329+}
330+331+export {Restore};
···1+export const handleResolver: CompositeHandleResolver;
2+export const docResolver: CompositeDidDocumentResolver<"plc" | "web">;
3+/**
4+ * Cleans the handle of @ and some other unicode characters that used to show up when copied from the profile
5+ * @param handle {string}
6+ * @returns {string}
7+ */
8+export function cleanHandle(handle: string): string;
9+/**
10+ * Convince helper to resolve a handle to a did and then find the PDS url from the did document.
11+ *
12+ * @param handle
13+ * @returns {Promise<{usersDid: string, pds: string}>}
14+ */
15+export function handleAndPDSResolver(handle: any): Promise<{
16+ usersDid: string;
17+ pds: string;
18+}>;
19+/**
20+ * Fetches the DID Web from the .well-known/did.json endpoint of the server.
21+ * Legacy and was helpful if the web ui and server are on the same domain, not as useful now
22+ * @param baseUrl
23+ * @returns {Promise<*>}
24+ */
25+export function fetchPDSMooverDIDWeb(baseUrl: any): Promise<any>;
26+import { CompositeHandleResolver } from '@atcute/identity-resolver';
27+import { CompositeDidDocumentResolver } from '@atcute/identity-resolver';
28+//# sourceMappingURL=atprotoUtils.d.ts.map
···1+/**
2+ * JSDoc type-only import to avoid runtime import errors in the browser.
3+ */
4+export type InferXRPCBodyOutput = any;
5+/**
6+ * JSDoc type-only import to avoid runtime import errors in the browser.
7+ * @typedef {import('@atcute/lexicons').InferXRPCBodyOutput} InferXRPCBodyOutput
8+ */
9+/**
10+ * Logic to sign up and manage backups for pdsmoover.com (or your own selfhosted instance)
11+ */
12+export class BackupService {
13+ /**
14+ *
15+ * @param backupDidWeb {string} - The did:web for the xrpc service for backups, defaults to did:web:pdsmoover.com
16+ */
17+ constructor(backupDidWeb?: string);
18+ /**
19+ *
20+ * @type {Client}
21+ */
22+ atCuteClient: Client;
23+ /**
24+ *
25+ * @type {CredentialManager}
26+ */
27+ atCuteCredentialManager: CredentialManager;
28+ /**
29+ * The did:web for the xrpc service for backups, defaults to pdsmoover.com
30+ * @type {string}
31+ */
32+ backupDidWeb: string;
33+ /**
34+ * Logs in and returns the backup status.
35+ * To use the rest of the BackupService, it is assumed that this has ran first,
36+ * and the user has successfully signed up. A successful login is a returned null if the user has not signed up.
37+ * or the backup status if they are
38+ *
39+ * If the server requires 2FA,
40+ * it will throw with error.error === 'AuthFactorTokenRequired'.
41+ * @param identifier {string} handle or did
42+ * @param password {string}
43+ * @param {function|null} onStatus - a function that takes a string used to update the UI.
44+ * Like (status) => console.log(status)
45+ * @param twoFactorCode {string|null}
46+ *
47+ * @returns {Promise<InferXRPCBodyOutput<ComPdsmooverBackupDescribeServer.mainSchema['output']>|null>}
48+ */
49+ loginAndStatus(identifier: string, password: string, onStatus?: Function | null, twoFactorCode?: string | null): Promise<InferXRPCBodyOutput<ComPdsmooverBackupDescribeServer.mainSchema["output"]> | null>;
50+ /**
51+ * Signs the user up for backups with the service
52+ * @param onStatus
53+ * @returns {Promise<void>}
54+ */
55+ signUp(onStatus?: any): Promise<void>;
56+ /**
57+ * Requests a PLC token to be sent to the user's email, needed to add a new rotation key
58+ * @returns {Promise<void>}
59+ */
60+ requestAPlcToken(): Promise<void>;
61+ /**
62+ * Adds a new rotation to the users did document. Assumes you are already signed in.
63+ *
64+ * WARNING: This will overwrite any existing rotation keys with the new one at the top, and the PDS key as the second one
65+ * @param plcToken {string} - PLC token from the user's email that was sent from requestAPlcToken
66+ * @param rotationKey {string} - The new rotation key to add to the user's did document
67+ * @returns {Promise<void>}
68+ */
69+ addANewRotationKey(plcToken: string, rotationKey: string): Promise<void>;
70+ /**
71+ *
72+ * Gets the current status of the user's backup repository.
73+ *
74+ * @param onStatus {function|null} - a function that takes a string used to update the UI.
75+ * @returns {Promise<InferXRPCBodyOutput<ComPdsmooverBackupDescribeServer.mainSchema['output']>>}
76+ */
77+ getUsersRepoStatus(onStatus?: Function | null): Promise<InferXRPCBodyOutput<ComPdsmooverBackupDescribeServer.mainSchema["output"]>>;
78+ /**
79+ * Requests a backup to be run immediately for the signed-in user. Usually does, depend on the server's backup queue
80+ * @param onStatus
81+ * @returns {Promise<boolean>}
82+ */
83+ runBackupNow(onStatus?: any): Promise<boolean>;
84+ /**
85+ * Remove (delete) the signed-in user's backup repository. this also deletes all the user's backup data.
86+ * @param onStatus
87+ * @returns {Promise<boolean>}
88+ */
89+ removeRepo(onStatus?: any): Promise<boolean>;
90+}
91+import { Client } from '@atcute/client';
92+import { CredentialManager } from '@atcute/client';
93+import { ComPdsmooverBackupDescribeServer } from '@pds-moover/lexicons';
94+//# sourceMappingURL=backup.d.ts.map
···1+/**
2+ * Class to help find missing blobs from the did's previous PDS and import them into the current PDS
3+ */
4+export class MissingBlobs {
5+ /**
6+ * The user's current PDS agent
7+ * @type {AtpAgent}
8+ */
9+ currentPdsAgent: AtpAgent;
10+ /**
11+ * The user's old PDS agent
12+ * @type {AtpAgent}
13+ */
14+ oldPdsAgent: AtpAgent;
15+ /**
16+ * the user's did
17+ * @type {string|null}
18+ */
19+ did: string | null;
20+ /**
21+ * The user's current PDS url
22+ * @type {null}
23+ */
24+ currentPdsUrl: any;
25+ /**
26+ * 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
27+ * @type {string[]}
28+ */
29+ missingBlobs: string[];
30+ /**
31+ * Logs the user into the current PDS and gets the account status
32+ * @param handle {string}
33+ * @param password {string}
34+ * @param twoFactorCode {string|null}
35+ * @returns {Promise<{accountStatus: OutputSchema, missingBlobsCount: number}>}
36+ */
37+ currentAgentLogin(handle: string, password: string, twoFactorCode?: string | null): Promise<{
38+ accountStatus: OutputSchema;
39+ missingBlobsCount: number;
40+ }>;
41+ /**
42+ * Logs into the old PDS and gets the account status.
43+ * Does not need a handle
44+ * since it is assumed the user has already logged in with the current PDS and we are using their did
45+ * @param password {string}
46+ * @param twoFactorCode {string|null}
47+ * @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
48+ * @returns {Promise<void>}
49+ */
50+ oldAgentLogin(password: string, twoFactorCode?: string | null, pdsUrl?: string | null): Promise<void>;
51+ /**
52+ * Gets the missing blobs from the old PDS and uploads them to the current PDS
53+ * @param statusUpdateHandler {function} - A function to update the status of the migration. This is useful for showing the user the progress of the migration
54+ * @returns {Promise<{accountStatus: OutputSchema, missingBlobsCount: number}>}
55+ */
56+ migrateMissingBlobs(statusUpdateHandler: Function): Promise<{
57+ accountStatus: OutputSchema;
58+ missingBlobsCount: number;
59+ }>;
60+}
61+import { AtpAgent } from '@atproto/api';
62+//# sourceMappingURL=missingBlobs.d.ts.map
···1+/**
2+ * Handles normal PDS Migrations between two PDSs that are both up.
3+ * On pdsmoover.com this is the logic for the MOOver
4+ */
5+export class Migrator {
6+ /** @type {AtpAgent} */
7+ oldAgent: AtpAgent;
8+ /** @type {AtpAgent} */
9+ newAgent: AtpAgent;
10+ /** @type {[string]} */
11+ missingBlobs: [string];
12+ /** @type {boolean} */
13+ createNewAccount: boolean;
14+ /** @type {boolean} */
15+ migrateRepo: boolean;
16+ /** @type {boolean} */
17+ migrateBlobs: boolean;
18+ /** @type {boolean} */
19+ migrateMissingBlobs: boolean;
20+ /** @type {boolean} */
21+ migratePrefs: boolean;
22+ /** @type {boolean} */
23+ migratePlcRecord: boolean;
24+ /**
25+ * This migrator is pretty cut and dry and makes a few assumptions
26+ * 1. You are using the same password between each account
27+ * 2. If this command fails for something like oauth 2fa code it throws an error and expects the same values when ran again.
28+ * 3. You can control which "actions" happen by setting the class variables to false.
29+ * 4. Each instance of the class is assumed to be for a single migration
30+ * @param {string} oldHandle - The handle you use on your old pds, something like alice.bsky.social
31+ * @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
32+ * @param {string} newPdsUrl - The new URL for your pds. Like https://coolnewpds.com
33+ * @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)
34+ * @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.
35+ * @param {string|null} inviteCode - The invite code you got from the PDS you are migrating to. If null does not include one
36+ * @param {function|null} statusUpdateHandler - a function that takes a string used to update the UI. Like (status) => console.log(status)
37+ * @param {string|null} twoFactorCode - Optional, but needed if it fails with 2fa required
38+ */
39+ migrate(oldHandle: string, password: string, newPdsUrl: string, newEmail: string, newHandle: string, inviteCode: string | null, statusUpdateHandler?: Function | null, twoFactorCode?: string | null): Promise<void>;
40+ /**
41+ * Sign and submits the PLC operation to officially migrate the account
42+ * @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
43+ * @param additionalRotationKeysToAdd {string[]} - additional rotation keys to add in addition to the ones provided by the new PDS.
44+ * @returns {Promise<void>}
45+ */
46+ signPlcOperation(token: string, additionalRotationKeysToAdd?: string[]): Promise<void>;
47+ /**
48+ * Using this method assumes the Migrator class was constructed new and this was called.
49+ * Find the user's previous PDS from the PLC op logs,
50+ * logs in and deactivates their old account if it was found still active.
51+ *
52+ * @param oldHandle {string}
53+ * @param oldPassword {string}
54+ * @param {function|null} statusUpdateHandler - a function that takes a string used to update the UI.
55+ * Like (status) => console.log(status)
56+ * @param {string|null} twoFactorCode - Optional, but needed if it fails with 2fa required
57+ * @returns {Promise<void>}
58+ */
59+ deactivateOldAccount(oldHandle: string, oldPassword: string, statusUpdateHandler?: Function | null, twoFactorCode?: string | null): Promise<void>;
60+ /**
61+ * Signs the logged-in user in this.newAgent for backups with PDS MOOver. This is usually called after migrate and signPlcOperation are successful
62+ *
63+ * @param {string} didWeb
64+ * @returns {Promise<void>}
65+ */
66+ signUpForBackupsFromMigration(didWeb?: string): Promise<void>;
67+}
68+import { AtpAgent } from '@atproto/api';
69+//# sourceMappingURL=pdsmoover.d.ts.map
···1+/**
2+ * JSDoc type-only import to avoid runtime import errors in the browser.
3+ */
4+export type defs = typeof defs;
5+/**
6+ * JSDoc type-only import to avoid runtime import errors in the browser.
7+ */
8+export type normalizeOp = any;
9+/**
10+ * JSDoc type-only import to avoid runtime import errors in the browser.
11+ */
12+export type Operation = import("@atcute/did-plc").Operation;
13+/**
14+ * JSDoc type-only import to avoid runtime import errors in the browser.
15+ */
16+export type CompatibleOperation = import("@atcute/did-plc").CompatibleOperation;
17+/**
18+ * JSDoc type-only import to avoid runtime import errors in the browser.
19+ */
20+export type IndexedEntryLog = import("@atcute/did-plc").IndexedEntryLog;
21+/**
22+ * JSDoc type-only import to avoid runtime import errors in the browser.
23+ */
24+export type IndexedEntry = import("@atcute/did-plc").IndexedEntry;
25+/**
26+ * Class to help with various PLC operations
27+ */
28+export class PlcOps {
29+ /**
30+ *
31+ * @param plcDirectoryUrl {string} - The url of the plc directory, defaults to https://plc.directory
32+ */
33+ constructor(plcDirectoryUrl?: string);
34+ /**
35+ * The url of the plc directory
36+ * @type {string}
37+ */
38+ plcDirectoryUrl: string;
39+ /**
40+ * Gets the current rotation keys for a user via their last PlC operation
41+ * @param did
42+ * @returns {Promise<string[]>}
43+ */
44+ getCurrentRotationKeysForUser(did: any): Promise<string[]>;
45+ /**
46+ * Gets the last PlC operation for a user from the plc directory
47+ * @param did
48+ * @returns {Promise<{lastOperation: Operation, base: any}>}
49+ */
50+ getLastPlcOpFromPlc(did: any): Promise<{
51+ lastOperation: Operation;
52+ base: any;
53+ }>;
54+ /**
55+ *
56+ * @param logs {IndexedEntryLog}
57+ * @returns {{lastOperation: Operation, base: IndexedEntry}}
58+ */
59+ getLastPlcOp(logs: IndexedEntryLog): {
60+ lastOperation: Operation;
61+ base: IndexedEntry;
62+ };
63+ /**
64+ * Gets the plc audit logs for a user from the plc directory
65+ * @param did
66+ * @returns {Promise<IndexedEntryLog>}
67+ */
68+ getPlcAuditLogs(did: any): Promise<IndexedEntryLog>;
69+ /**
70+ * Creates a new secp256k1 key that can be used for either rotation or verification key
71+ * @returns {Promise<{privateKey: string, publicKey: `did:key:${string}`}>}
72+ */
73+ createANewSecp256k1(): Promise<{
74+ privateKey: string;
75+ publicKey: `did:key:${string}`;
76+ }>;
77+ /**
78+ * Signs a new operation with the provided signing key, and information and submits it to the plc directory
79+ * @param did {string} - The user's did
80+ * @param signingRotationKey { P256PrivateKey|Secp256k1PrivateKey} - The keypair to sign the op with
81+ * @param alsoKnownAs {string[]}
82+ * @param rotationKeys {string[]}
83+ * @param pds {string}
84+ * @param verificationKey {string} - The public verification key
85+ * @param prev {string} - The previous valid operation's cid.
86+ * @returns {Promise<void>}
87+ */
88+ signAndPublishNewOp(did: string, signingRotationKey: P256PrivateKey | Secp256k1PrivateKey, alsoKnownAs: string[], rotationKeys: string[], pds: string, verificationKey: string, prev: string): Promise<void>;
89+ /**
90+ * Takes a multi or hex based private key and returns a keypair
91+ * @param privateKeyString {string}
92+ * @param type {string} - secp256k1 or p256, needed if the private key is hex based, can be assumed if it's a multikey
93+ * @returns {Promise<{type: string, didPublicKey: `did:key:${string}`, keypair: P256PrivateKey|Secp256k1PrivateKey}>}
94+ */
95+ getKeyPair(privateKeyString: string, type?: string): Promise<{
96+ type: string;
97+ didPublicKey: `did:key:${string}`;
98+ keypair: P256PrivateKey | Secp256k1PrivateKey;
99+ }>;
100+ /**
101+ * Submits a new operation to the plc directory
102+ * @param did {string} - The user's did
103+ * @param operation
104+ * @returns {Promise<void>}
105+ */
106+ pushPlcOperation(did: string, operation: any): Promise<void>;
107+ /**
108+ * 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
109+ *
110+ * @param iss The user's did
111+ * @param aud The did:web, if it's a PDS it's usually from /xrpc/com.atproto.server.describeServer
112+ * @param keypair The keypair to sign with only supporting ES256K atm
113+ * @param lxm The lxm which is usually com.atproto.server.createAccount for creating a new account
114+ * @returns {Promise<string>}
115+ */
116+ createANewServiceAuthToken(iss: any, aud: any, keypair: any, lxm: any): Promise<string>;
117+}
118+import { defs } from '@atcute/did-plc';
119+import { P256PrivateKey } from '@atcute/crypto';
120+import { Secp256k1PrivateKey } from '@atcute/crypto';
121+//# sourceMappingURL=plc-ops.d.ts.map
···1+export type Operation = import("@atcute/did-plc").Operation;
2+export class Restore {
3+ /**
4+ *
5+ * @param pdsMooverInstance {string} - The url of the pds moover instance to restore from. Defaults to https://pdsmover.com
6+ */
7+ constructor(pdsMooverInstance?: string);
8+ /**
9+ * If you want to use a different plc directory create your own instance of the plc ops class and pass it in here
10+ * @type {PlcOps} */
11+ plcOps: PlcOps;
12+ /**
13+ * This is the base url for the pds moover instance used to restore the files from a backup.
14+ * @type {string}
15+ */
16+ pdsMooverInstance: string;
17+ /**
18+ * To keep it simple, only uses secp256k for the temp verification key that is used to create the new account on the new PDS
19+ * and is temporarily assigned to the user's account on PLC
20+ * @type {null|Secp256k1PrivateKeyExportable}
21+ */
22+ tempVerificationKeypair: null | Secp256k1PrivateKeyExportable;
23+ /** @type {AtpAgent} */
24+ atpAgent: AtpAgent;
25+ /**
26+ * The keypair that is used to sign the plc operation
27+ * @type {null|{type: string, didPublicKey: `did:key:${string}`, keypair: P256PrivateKey|Secp256k1PrivateKey}}
28+ */
29+ recoveryRotationKeyPair: null | {
30+ type: string;
31+ didPublicKey: `did:key:${string}`;
32+ keypair: P256PrivateKey | Secp256k1PrivateKey;
33+ };
34+ /**
35+ * If this is true we are just restoring the repo and blobs. Ideally for rerunning a restore process after account recovery
36+ * @type {boolean}
37+ */
38+ RestoreFromBackup: boolean;
39+ /**
40+ * If set to true then it will do the account recovery. Writes a temp key to the did doc,
41+ * 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)
42+ * @type {boolean}
43+ */
44+ AccountRecovery: boolean;
45+ /**
46+ * Recovers an account with the users rotation key and restores the repo from a PDS MOOver backup
47+ * 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
48+ * @param rotationKey {string} - The users private rotation key, can be a multi key or hex key
49+ * @param rotationKeyType {string} - The type of the key, secp256k1 or p256. Required if the key is in hex format, defaults to secp256k1
50+ * @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
51+ * @param newPDS {string} - The new PDS url, like https://coolnewpds.com
52+ * @param newHandle {string} - Can be the users DNS handle if it is already setup with their did, if not it's bob.mypds.com
53+ * @param newPassword {string} - The new password for the new account
54+ * @param newEmail {string} - The new email for the new account
55+ * @param inviteCode {string|null} - The invite code for the new PDS if it requires one
56+ * @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
57+ * @param onStatus {function|null} - A function that takes a string used to update the UI. Like (status) => console.log(status)
58+ * @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
59+ * recovered and future runs need to have the RestoreFromBackup flag set to true and AccountRecovery set to false.
60+ */
61+ 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>;
62+ /**
63+ * This method signs the plc operation over to the new PDS and activates the account
64+ * Assumes you have already created a new account during the recovery process and logged in
65+ * Uses the recommended did doc from the PDS as a base and adds the users rotation key to the rotation keys array
66+ *
67+ * @param usersDid
68+ * @param additionalRotationKeysToAdd
69+ * @param prevCid
70+ * @returns {Promise<void>}
71+ */
72+ signRestorePlcOperation(usersDid: any, additionalRotationKeysToAdd: any[], prevCid: any): Promise<void>;
73+}
74+import { PlcOps } from './plc-ops.js';
75+import { Secp256k1PrivateKeyExportable } from '@atcute/crypto';
76+import { AtpAgent } from '@atproto/api';
77+import { P256PrivateKey } from '@atcute/crypto';
78+import { Secp256k1PrivateKey } from '@atcute/crypto';
79+//# sourceMappingURL=restore.d.ts.map
···68 .await
69 .map_err(|e| Error::Failed(Arc::new(Box::new(e))))?;
7071- let missing_cids = filter_missing_blob_cids(&pool, &resp.cids)
72 .await
73 .map_err(|e| Error::Failed(Arc::new(Box::new(AnyhowErrorWrapper(e)))))?;
7475 // Process missing CIDs in batches of 5
76 let mut processed = 0;
7778- //TODO need to fix where if it fails still tries the other 4 chunks
79 for chunk in missing_cids.chunks(5) {
80 processed += chunk.len();
81 let last_chunk = processed >= missing_cids.len();
···68 .await
69 .map_err(|e| Error::Failed(Arc::new(Box::new(e))))?;
7071+ let missing_cids = filter_missing_blob_cids(&pool, &resp.cids, &job.did)
72 .await
73 .map_err(|e| Error::Failed(Arc::new(Box::new(AnyhowErrorWrapper(e)))))?;
7475 // Process missing CIDs in batches of 5
76 let mut processed = 0;
77078 for chunk in missing_cids.chunks(5) {
79 processed += chunk.len();
80 let last_chunk = processed >= missing_cids.len();
+66-25
shared/src/jobs/mod.rs
···4pub mod scheduled_back_up_start;
5pub mod start_all_backup;
6pub mod upload_blob;
078use crate::db::models;
9use crate::db::models::BlobModel;
···64}
6566/// Given a list of CIDs, returns those that are NOT already present in the blobs table
67-/// with blob type = 'blob'. The returned order matches the input order and duplicates in the
68-/// input are preserved if they are not present in the DB.
69pub async fn filter_missing_blob_cids(
70 pool: &Pool<Postgres>,
71- cids: &[String],
072) -> anyhow::Result<Vec<String>> {
73 if cids.is_empty() {
74 return Ok(Vec::new());
···7677 // Fetch the subset of provided CIDs that already exist as type 'blob'
78 let existing: Vec<String> = sqlx::query_scalar(
79- r#"SELECT cid_or_rev FROM blobs WHERE type = $1 AND cid_or_rev = ANY($2)"#,
80 )
81 .bind(crate::db::models::BlobType::Blob)
82- .bind(cids)
083 .fetch_all(pool)
84 .await?;
85···100 size: i64,
101 blob_type: models::BlobType,
102) -> anyhow::Result<models::BlobModel> {
103- // For repo blobs, perform an upsert on the unique `cid` to avoid duplicate inserts
104- // and to refresh metadata if the same cid is seen again.
105- Ok(sqlx::query_as::<_, BlobModel>(
106- r#"
107- INSERT INTO blobs (account_did, size, type, cid_or_rev)
108- VALUES ($1, $2, $3, $4)
109- ON CONFLICT (cid_or_rev) DO UPDATE
110- SET account_did = EXCLUDED.account_did,
111- size = EXCLUDED.size,
112- type = EXCLUDED.type,
113- cid_or_rev = EXCLUDED.cid_or_rev
114- RETURNING id, created_at, account_did, size, type AS blob_type, cid_or_rev
115- "#,
116- )
117- .bind(did)
118- .bind(size)
119- .bind(blob_type)
120- .bind(cid_or_rev)
121- .fetch_one(pool)
122- .await?)
000000000000000000000000000000000000000123}
124125/// Look up the user's account by DID and return their repo_rev, if present.
···4pub mod scheduled_back_up_start;
5pub mod start_all_backup;
6pub mod upload_blob;
7+pub mod verify_backups;
89use crate::db::models;
10use crate::db::models::BlobModel;
···65}
6667/// Given a list of CIDs, returns those that are NOT already present in the blobs table
68+/// with blob type = 'blob' and matches the user's did in the case of duplicate blobs for each user
069pub async fn filter_missing_blob_cids(
70 pool: &Pool<Postgres>,
71+ cids: &Vec<String>,
72+ users_did: &String,
73) -> anyhow::Result<Vec<String>> {
74 if cids.is_empty() {
75 return Ok(Vec::new());
···7778 // Fetch the subset of provided CIDs that already exist as type 'blob'
79 let existing: Vec<String> = sqlx::query_scalar(
80+ r#"SELECT cid_or_rev FROM blobs WHERE type = $1 AND cid_or_rev = ANY($2) AND account_did = $3"#,
81 )
82 .bind(crate::db::models::BlobType::Blob)
83+ .bind(&cids)
84+ .bind(users_did)
85 .fetch_all(pool)
86 .await?;
87···102 size: i64,
103 blob_type: models::BlobType,
104) -> anyhow::Result<models::BlobModel> {
105+ match blob_type {
106+ //On repo we need to upsert on did
107+ models::BlobType::Repo => {
108+ // First try to update an existing 'repo' blob row for this DID.
109+ if let Some(updated) = sqlx::query_as::<_, BlobModel>(
110+ r#"
111+ UPDATE blobs
112+ SET size = $2,
113+ type = $3,
114+ cid_or_rev = $4
115+ WHERE account_did = $1 AND type = $3
116+ RETURNING id, created_at, account_did, size, type, cid_or_rev
117+ "#,
118+ )
119+ .bind(&did)
120+ .bind(size)
121+ .bind(&blob_type)
122+ .bind(&cid_or_rev)
123+ .fetch_optional(pool)
124+ .await?
125+ {
126+ Ok(updated)
127+ } else {
128+ // If no row was updated, insert a new one for this DID and repo type.
129+ Ok(sqlx::query_as::<_, BlobModel>(
130+ r#"
131+ INSERT INTO blobs (account_did, size, type, cid_or_rev)
132+ VALUES ($1, $2, $3, $4)
133+ RETURNING id, created_at, account_did, size, type, cid_or_rev
134+ "#,
135+ )
136+ .bind(did)
137+ .bind(size)
138+ .bind(blob_type)
139+ .bind(cid_or_rev)
140+ .fetch_one(pool)
141+ .await?)
142+ }
143+ }
144+ //on blob we upsert on cid (shouldnt happen ideally)
145+ models::BlobType::Blob | _ => Ok(sqlx::query_as::<_, BlobModel>(
146+ r#"
147+ INSERT INTO blobs (account_did, size, type, cid_or_rev)
148+ VALUES ($1, $2, $3, $4)
149+ ON CONFLICT (cid_or_rev) DO UPDATE
150+ SET account_did = EXCLUDED.account_did,
151+ size = EXCLUDED.size,
152+ type = EXCLUDED.type,
153+ cid_or_rev = EXCLUDED.cid_or_rev
154+ RETURNING id, created_at, account_did, size, type, cid_or_rev
155+ "#,
156+ )
157+ .bind(did)
158+ .bind(size)
159+ .bind(blob_type)
160+ .bind(cid_or_rev)
161+ .fetch_one(pool)
162+ .await?),
163+ }
164}
165166/// 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
···11pub struct ScheduledBackUpStartJobContext;
1213/// This scheduled job finds:
14-/// - accounts that have not been backed up in the last 24 hours and have pds_sign_up = false,
15/// and enqueues AccountBackup jobs for them;
16-/// - pds_hosts that are active and have not started a backup in the last 24 hours (tracked via
17/// pds_hosts.last_backup_start), and enqueues PdsBackup jobs for each.
18pub async fn scheduled_back_up_start_job(
19 _job: ScheduledBackUpStartJobContext,
···21) -> Result<(), Error> {
22 log::info!("Starting a backup for the whole instance");
23 // Record the start of a whole-network backup run
24- sqlx::query(
25- r#"INSERT INTO network_backup_runs DEFAULT VALUES"#,
26- )
27- .execute(&*pool)
28- .await
29- .map_err(|e| Error::Failed(Arc::new(Box::new(e))))?;
30 // 1) Query accounts needing backup
31- // Condition: pds_sign_up = false AND (last_backup is NULL OR older than 24h)
32 // We include did and pds_host to build AccountBackupJobContext
33 let accounts: Vec<(String, String)> = sqlx::query_as(
34 r#"
35 SELECT did, pds_host
36 FROM accounts
37 WHERE pds_sign_up = FALSE
38- AND (last_backup IS NULL OR last_backup < NOW() - INTERVAL '24 HOURS')
39 "#,
40 )
41 .fetch_all(&*pool)
···58 SELECT pds_host
59 FROM pds_hosts
60 WHERE active = TRUE
61- AND (last_backup_start IS NULL OR last_backup_start < NOW() - INTERVAL '24 HOURS')
62 "#,
63 )
64 .fetch_all(&*pool)
···11pub struct ScheduledBackUpStartJobContext;
1213/// This scheduled job finds:
14+/// - accounts that have not been backed up in the last 6 hours and have pds_sign_up = false,
15/// and enqueues AccountBackup jobs for them;
16+/// - pds_hosts that are active and have not started a backup in the last 6 hours (tracked via
17/// pds_hosts.last_backup_start), and enqueues PdsBackup jobs for each.
18pub async fn scheduled_back_up_start_job(
19 _job: ScheduledBackUpStartJobContext,
···21) -> Result<(), Error> {
22 log::info!("Starting a backup for the whole instance");
23 // Record the start of a whole-network backup run
24+ sqlx::query(r#"INSERT INTO network_backup_runs DEFAULT VALUES"#)
25+ .execute(&*pool)
26+ .await
27+ .map_err(|e| Error::Failed(Arc::new(Box::new(e))))?;
0028 // 1) Query accounts needing backup
29+ // Condition: pds_sign_up = false AND (last_backup is NULL OR older than 6h)
30 // We include did and pds_host to build AccountBackupJobContext
31 let accounts: Vec<(String, String)> = sqlx::query_as(
32 r#"
33 SELECT did, pds_host
34 FROM accounts
35 WHERE pds_sign_up = FALSE
36+ AND (last_backup IS NULL OR last_backup < NOW() - INTERVAL '6 HOURS')
37 "#,
38 )
39 .fetch_all(&*pool)
···56 SELECT pds_host
57 FROM pds_hosts
58 WHERE active = TRUE
59+ AND (last_backup_start IS NULL OR last_backup_start < NOW() - INTERVAL '6 HOURS')
60 "#,
61 )
62 .fetch_all(&*pool)
···1+# sv
2+3+Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
4+5+## Creating a project
6+7+If you're seeing this, you've probably already done this step. Congrats!
8+9+```sh
10+# create a new project in the current directory
11+npx sv create
12+13+# create a new project in my-app
14+npx sv create my-app
15+```
16+17+## Developing
18+19+Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
20+21+```sh
22+npm run dev
23+24+# or start the server and open the app in a new browser tab
25+npm run dev -- --open
26+```
27+28+## Building
29+30+To create a production version of your app:
31+32+```sh
33+npm run build
34+```
35+36+You can preview the production build with `npm run preview`.
37+38+> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
···1+<script lang="ts">
2+ import {handleAndPDSResolver} from '@pds-moover/moover'
3+ import type {RotationKeyType} from '$lib/types';
4+5+6+ let {handle, rotationKey}: {
7+ handle: string,
8+ rotationKey: RotationKeyType
9+ } = $props();
10+11+12+ const copyToClipboard = async (text: string) => {
13+ try {
14+ await navigator.clipboard.writeText(text);
15+ alert('Copied to clipboard');
16+ } catch (e) {
17+ console.error(e);
18+ alert('Failed to copy to clipboard');
19+ }
20+ }
21+22+ const downloadNewRotationKey = async (rotationKey: RotationKeyType, handle: string) => {
23+ if (!rotationKey) return;
24+ //try and find the did to add to the file as well
25+ let didText = '';
26+ try {
27+ let {usersDid} = await handleAndPDSResolver(handle);
28+ didText = `DID: ${usersDid}\n`;
29+ } catch (e) {
30+ //sliently log. Rather the user have their rotation key than not. a did can always be found other ways if needed
31+ console.error(e);
32+ }
33+34+ 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`;
35+ const blob = new Blob([content], {type: 'text/plain'});
36+ const url = URL.createObjectURL(blob);
37+ const a = document.createElement('a');
38+ a.href = url;
39+40+41+ a.download = `${handle}-rotation-key.txt`;
42+ document.body.appendChild(a);
43+ a.click();
44+ document.body.removeChild(a);
45+ URL.revokeObjectURL(url);
46+ }
47+</script>
48+49+50+<div class="section" style="margin-top: 16px; border: 2px solid #f39c12; padding: 16px;">
51+ <h3 style="color: #d35400;">Important: Save Your New Rotation Key Now</h3>
52+ <p style="color: #c0392b; font-weight: bold;">
53+ Warning: This is the only time we will show you your private rotation key. Save it in a secure place.
54+ If you lose it, you may not be able to recover your account in the event of a PDS failure or hijack.
55+ </p>
56+ <div class="form-group">
57+ <span>New Rotation Key (Private - keep secret)</span>
58+ <div style="display:flex; gap:8px; align-items:center;">
59+ {#if rotationKey}
60+ <code
61+ style="overflow-wrap:anywhere;">{rotationKey.privateKey}</code>
62+ {/if}
63+64+ <button type="button"
65+ onclick={async () => await copyToClipboard(rotationKey.privateKey)}>Copy
66+ </button>
67+ </div>
68+ </div>
69+ <div class="form-group">
70+ <button type="button" onclick={async () => await downloadNewRotationKey(rotationKey, handle)}>Download
71+ Key File
72+ </button>
73+ </div>
74+</div>
+1
web-ui/src/lib/index.ts
···0
···1+// place files you want to import through the `$lib` alias in this folder.
···1+<script lang="ts">
2+ import OgImage from '$lib/components/OgImage.svelte';
3+ import MooHeader from '$lib/components/MooHeader.svelte';
4+ import SignThePapersImg from '$lib/assets/sign_the_papers.png'
5+</script>
6+7+<svelte:head>
8+ <title>PDS MOOver - Info</title>
9+ <meta property="og:description" content="ATProto account migration tool"/>
10+ <OgImage/>
11+</svelte:head>
12+13+<div class="container">
14+ <MooHeader title="PDS MOOver Info"/>
15+16+17+ <div class="section" id="top">
18+ <p> This page is to help you decide if you want to use PDS MOOver to move your ATProto(Bluesky) account to a new
19+ PDS along with some other information about all the new tools.
20+ One way or the other. TLDR (You should still read the whole thing), at least read and follow the <a
21+ href="#precautions">precautions section</a>.</p>
22+23+24+ <section class="section" style="text-align:left">
25+ <h2>Info</h2>
26+ <p>PDS MOOver is a set of tools to help you migrate to a new PDS. The creator
27+ or host of this tool will not be able to help you recover your account if something goes wrong. So be
28+ advised you and your PDS admin may be on your own besides helpful answers and understand the risk you
29+ take in doing an account movement.</p>
30+ </section>
31+32+33+ <nav aria-label="Table of contents" class="section" style="text-align:left">
34+ <h3>Table of contents</h3>
35+ <ol>
36+ <li><a href="#precautions">Precautions</a></li>
37+ <li><a href="#backups">Backups</a></li>
38+ <li><a href="#restore">Restore</a></li>
39+ <li><a href="#blacksky">I'm here for Blacksky, is there a video guide?</a></li>
40+ <li><a href="#cant-login">I can't log in?/Says my account is deactivated?</a></li>
41+ <li><a href="#invalid-handle">My account says Invalid Handle?</a></li>
42+ <li><a href="#help">!!!!!HELP!!!!!</a></li>
43+ <li><a href="#why">Why doesn't PDS MOOver have xyz?</a></li>
44+ <li><a href="#done">Alright account migrated, now what?</a></li>
45+ <li><a href="#slow">Why is it so SLOW?</a></li>
46+ <li><a href="#open-source">Can I check out the code anywhere?</a></li>
47+ </ol>
48+ </nav>
49+50+ <section id="precautions" class="section" style="text-align:left">
51+ <h2>Precautions</h2>
52+ <p> Migrations can be a potentially dangerous operation. It is recommended to follow these few steps to
53+ protect your account and identity.</p>
54+ <ul>
55+ <li>During migration make sure to do not leave the page</li>
56+ <li>It is recommended to use a desktop computer for this process due to the amount of time it can
57+ take.
58+ </li>
59+ <li>Your account is not actually fully moved over to the new PDS till you receive a code in your email
60+ and enter it on PDS MOOver, this is the final step.
61+ </li>
62+ <li>Your data will not be deleted from Bluesky(or your previous PDS) during migration. If you find you
63+ are missing any pictures or videos after the move you can use the <a href="/missing">Missing
64+ tool</a> to recover those from your previous PDS.
65+ </li>
66+ </ul>
67+68+ <p>At the end of your migration and before you move you will be asked if you'd like to sign up for PDS
69+ MOOver's backup service and to add a rotation key. Both of these are recommended and secure your account
70+ if your PDS ever goes down, allowing for account recovery. </p>
71+ <img src="{SignThePapersImg}" alt="Sign the papers"
72+ style="max-width: 100%; max-height: 100%; object-fit: contain;">
73+74+ </section>
75+76+ <section id="backups" class="section" style="text-align:left">
77+ <h2>Backups</h2>
78+ <p>PDS MOOver now supports backups. These are automated backups of your account saving your repo
79+ (posts,likes,etc), and your blobs(picture/videos) from your AT Proto account to a cloud base object
80+ store (S3). This is a free service for individual accounts and stores the backups on PDS MOOver's
81+ servers. These backups will happen every 6 hours from the time you sign up. We are expecting to lower
82+ this as we see how the service does. On login, you will be asked if you'd like to add a rotation key to
83+ your account. It is highly recommended to do this if you do not already have one. This is the only way
84+ you can recover your account in the event of a PDS failure or rogue account takeover</p>
85+86+ <p>Just like your <a target="_blank" rel="noopener noreferrer"
87+ href="https://blueskyweb.zendesk.com/hc/en-us/articles/15835264007693-Data-Privacy">AT
88+ Proto data</a>, your backedup data is also public.
89+ You can access your data much the same way you do on your PDS by calling these endpoints. Since these
90+ behave much the same as the PDS they are public.
91+ </p>
92+ <ul>
93+ <li><code>/xrpc/com.atproto.sync.getRepo?did="your did"</code> to get a copy of your repo's CAR export
94+ </li>
95+ <li><code>/xrpc/com.atproto.sync.getBlob?did="your did"&cid="cid of the blob"</code></li>
96+ </ul>
97+98+ </section>
99+100+ <section id="restore" class="section" style="text-align:left">
101+ <h2>Restore</h2>
102+ <p>Backups without a restore option aren't very good backups. So to pair well with our new backup service
103+ PDS MOOver now also offers recovery of your account and restoring from a backup. To use this you must
104+ have your Private Rotation Key. You can add one to your account either after migration or during backups
105+ sign up. This recovery process follows much the same process as out line in <a
106+ href="https://www.da.vidbuchanan.co.uk/blog/adversarial-pds-migration.html">Adversarial ATProto
107+ PDS Migration</a> blog post by <a href="https://bsky.app/profile/retr0.id">@retr0.id</a>. </p>
108+ </section>
109+110+ <section id="blacksky" class="section" style="text-align:left">
111+ <h2>I'm here for Blacksky, Is there a video guide?</h2>
112+ <p>
113+ <a href="https://blacksky.community/profile/did:plc:g7j6qok5us4hjqlwjxwrrkjm">@sharpiepls.com</a> has
114+ made an amazing video guide on
115+ how to use PDS MOOver to move your bluesky account to blacksky.app.
116+ <video width="100%" controls>
117+118+ <source src="https://blacksky.app/xrpc/com.atproto.sync.getBlob?did=did:plc:g7j6qok5us4hjqlwjxwrrkjm&cid=bafkreielhdoa2lcyiat5oumkbkwy26hlj36rwwwfi5fbnvd5haxq3t4joa"/>
119+ </video>
120+ </p>
121+ </section>
122+123+ <section id="cant-login" class="section" style="text-align:left">
124+ <h2>I can't log in?/Says my account is deactivated?</h2>
125+ <p>When you move to a non Bluesky PDS you have to do an extra step on login.</p>
126+ <ol>
127+ <li>On the Sign in screen for <a href="https://bsky.app">bsky.app</a> or on the app click the top input
128+ titled "Hosting provider" and has a globe icon and says Bluesky Social"
129+ </li>
130+ <li>Click the tab labeled custom</li>
131+ <li>In the input for server address you put the same URL you used for the new PDS URL with the https://
132+ like so <code>https://example.com</code></li>
133+ <li>Click done and enter your new handle(or email) and password</li>
134+ </ol>
135+ </section>
136+137+ <section id="invalid-handle" class="section" style="text-align:left">
138+ <h2>My account says Invalid Handle?</h2>
139+ <p>It's a bit of a bug sometimes. I'm not sure what causes it, but usually mentioning your handle in a post
140+ or reply fixes it.
141+ Like <code>@fullhandle.newpds.com</code>, may or may not highlight it blue and autofill it but make sure
142+ you have the
143+ full handle and the @ like that. Can also check your handle with the <a
144+ href="https://bsky-debug.app/handle">Bluesky Debug Page</a>. If you see green, and it says one
145+ of
146+ them pass you should be fine and just may take a while to update.</p>
147+ </section>
148+149+ <section id="help" class="section" style="text-align:left">
150+ <h2>!!!!!HELP!!!!!</h2>
151+ <p>If you're having issues with PDS MOOver first of all, I'm very sorry. I have tested this to the best of
152+ my
153+ ability, but PDS migrations do come with risks. I would recommend getting with the owner of the PDS and
154+ seeing where the account stands with tools like <a href="https://pdsls.dev">pdsls</a>.</p>
155+156+ <p> The tool is designed to be able to be re ran IF you set the Advance Options flags.For example, lets say
157+ if it created the account, repo is there but some blobs are missing. You can uncheck everything but
158+ "Migrate Missing Blobs", "Migrate Prefs", and "Migrate PLC record" and it will pick up after the account
159+ repo migration. It is odd in the fact that all the fields are required. That's just to cut down on logic
160+ to hopefully cut down on bugs. If you don't ever see the "Please enter your PLC Token" and enter the
161+ token sent to your email, you can just
162+ forget about it and call it a day if it's too much. Your old account is still active and working.</p>
163+ </section>
164+165+166+ <section id="why" class="section" style="text-align:left">
167+ <h2>Why doesn't PDS MOOver have xyz for migrations?</h2>
168+ <p>PDS MOOver was designed to pretty much be the goat account migration with a UI. Like in this <a
169+ href="https://whtwnd.com/bnewbold.net/3l5ii332pf32u"> post</a>. Keeping it simple and hard fails if
170+ anything
171+ goes wrong
172+ to
173+ hopefully cover most use cases.</p>
174+ </section>
175+176+ <section id="done" class="section" style="text-align:left">
177+ <h2>Alright account migrated, now what?</h2>
178+ <p>Welcome to your new PDS! You can login to your new PDS on Bluesky's login screen by selecting "Hosting
179+ provider" and entering your PDS url. I also recommend making sure you are signed up for our <a
180+ href="/backups">backups</a> and have a recovery key that you control in case your PDS disappears
181+ overnight you can regain your account. </p>
182+ </section>
183+184+ <section id="slow" class="section" style="text-align:left">
185+ <h2>Why is it so SLOW?</h2>
186+ <p>Everything happens client side, and the blob uploads take a while. Nothing runs in parallel. Blob uploads
187+ happen one at a time; once one is done, the next goes. This is done just to keep it as simple as
188+ possible and to hopefully limit the chance of failures on uploads. My personal account takes about
189+ 20-30ish mins to move with 1,700ish blobs at 800mb on a 1gig internet connection.</p>
190+ </section>
191+192+ <section id="open-source" class="section" style="text-align:left">
193+ <h2>Can I check out the code anywhere?</h2>
194+ <p>Yep! PDS MOOver is 100% open source and can find the code on <a
195+ href="https://tangled.sh/@baileytownsend.dev/pds-moover">tangled.sh</a>. Also, if you're a
196+ developer,
197+ 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
198+ its dependencies and have at it.</p>
199+ </section>
200+ </div>
201+202+</div>
···1+<script lang="ts">
2+ import OgImage from '$lib/components/OgImage.svelte';
3+</script>
4+5+<svelte:head>
6+ <title>PDS MOOver - Legal</title>
7+ <meta name="description" content="Terms of Service and Privacy Policy for PDS MOOver"/>
8+ <meta property="og:description" content="Terms of Service and Privacy Policy for PDS MOOver"/>
9+ <OgImage/>
10+</svelte:head>
11+12+<div class="container" style="text-align:left">
13+ <section class="section">
14+15+ <h1>Privacy Policy</h1>
16+ <p>Last updated: 2025-10-20</p>
17+18+ <h2>Overview</h2>
19+ <p>PDS MOOver performs migrations in your browser. Where possible, operations are client side to minimize
20+ collection of personal data by the server. Backups happen on our servers, and your data is stored in a cloud
21+ base object store we have access to</p>
22+23+ <h2>Information We May Receive</h2>
24+ <ul>
25+ <li>If you use optional features (e.g., backups), we take a backup of your AT Proto repo when it has a
26+ change, any blobs you have posted to your account are stored in a <a target="_blank"
27+ rel="noopener noreferrer"
28+ href="https://en.wikipedia.org/wiki/Object_storage">object
29+ store</a> that we control. Just like your <a target="_blank" rel="noopener noreferrer"
30+ href="https://blueskyweb.zendesk.com/hc/en-us/articles/15835264007693-Data-Privacy">AT
31+ Proto data</a> this is public and accessible to anyone. But this does not mean anyone is able to
32+ take this data and impersonate you without what is known as a Rotation Key.
33+ </li>
34+ </ul>
35+36+ <h2>What We Do Not Do</h2>
37+ <ul>
38+ <li>If you use PDS MOOver just for migrations, missing blobs, or turn off feature. We do not collect or
39+ store any information. This happens all client side.
40+ </li>
41+ <li>We do not collect any personal information beyond your current PDS host and your DID if you sign up for
42+ backups.
43+ </li>
44+ <li>Your passwords are only ever sent to your PDS. Your passwords are never collected, and all authenticated
45+ requests happen proxied via your PDS to our services for backups
46+ </li>
47+ </ul>
48+49+ <h2>Retention</h2>
50+ <p>Server-side logs may be retained for a limited time for security, debugging, and rate-limiting. This is short
51+ term logging and cleared on instance reboots and kept no longer than 60 days. Backup-related data, if
52+ enabled by you, is stored indefinitely for the life of this service unless you opt out via <a
53+ href="/backups">backups</a> and click delete to remove all of your backups and from any future ones.
54+ </p>
55+56+ <h2>Your Choices</h2>
57+ <p>You may choose not to use the service. If you proceed, ensure you have backups and understand the migration
58+ risks. If you ever want to be removed from our services you can also login via <a
59+ href="/backups">backups</a> and click delete to remove all of your backups and from any future ones.
60+ </p>
61+62+ <h2>Changes</h2>
63+ <p>We may update this Privacy Policy from time to time. Continued use after changes means you accept the updated
64+ policy.</p>
65+66+ <h2>Service Provider and Location</h2>
67+ <p>All backup data is currently being stored on <a href="https://upcloud.com/">UpCloud's</a> <a
68+ href="https://upcloud.com/products/object-storage/">object store</a> in a data center located in the US.
69+ UpCloud is also our service provider for the VPS running the services. We may change service providers in
70+ the future, if we do this terms of service will be updated to reflect that.</p>
71+72+ </section>
73+74+ <section class="section">
75+ <h1>Terms of Service</h1>
76+ <p>Last updated: 2025-10-20</p>
77+78+ <p>PDS MOOver is provided on an as-is and as-available basis, without warranties of any kind. By using this site
79+ and its tools, you agree that you understand the risks of migrating and or restoring ATProto/Bluesky
80+ accounts and that neither the creator nor the host of this tool is responsible for any loss, corruption, or
81+ unavailability of data, accounts, or services.</p>
82+83+ <h2>Acceptable Use</h2>
84+ <p>You agree not to misuse the service, disrupt other users, or attempt to gain unauthorized access to systems
85+ or data. You are responsible for complying with applicable laws and policies of your hosting
86+ provider(s).</p>
87+88+ <h2>Limitation of Liability</h2>
89+ <p>To the fullest extent permitted by law, the creator and host are not liable for any indirect, incidental,
90+ special, consequential, or punitive damages, or any loss of data, profits, or revenues, whether incurred
91+ directly or indirectly, resulting from your use of the service.</p>
92+93+ <h2>Changes to the Service</h2>
94+ <p>Features may change, be limited, or be discontinued at any time without notice.</p>
95+96+ <h2>Backup User Content</h2>
97+ <p>In extreme circumstances we reserve the right to ban, block, and or remove your content placed on our servers
98+ if it is found to be illegal or harmful. We will notify you via Bluesky that you are being removed along
99+ with giving you an export of your backed-up data.</p>
100+101+ <h2>Contact</h2>
102+ <p>If you have questions about these terms, please reach out via the project repository or to <a
103+ href="https://bsky.app/profile/baileytownsend.dev">@baileytownsend.dev</a> directly.</p>
104+ </section>
105+</div>
···17 showTwoFactorCodeInput: false,
18 error: null,
19 showStatusMessage: false,
20- showLoginScreen: true,
00021 showRepoNotFoundScreen: false,
22 addRecoveryKey: true,
23 // Rotation key flow state
···47 this.showStatusMessage = false;
48 }
49 },
00000000050 async handleLoginSubmit() {
00051 this.error = null;
52 this.showStatusMessage = false;
53···203 {% call cow::cow_header("Backups") %}
204205206- <form id="backup-signup-form" @submit.prevent="await handleLoginSubmit()" x-show="showLoginScreen">
0207 <!-- Informational section before sign-in -->
208 <div class="section" style="text-align: left;">
209- <p>
210- 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.
211- 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.
212- </p>
000000000213 </div>
214- <!-- Sign-in section -->
215- <div class="section">
216- <h2>Sign in to your account</h2>
217- <div class="form-group">
218- <label for="handle">Handle</label>
219- <input type="text" id="handle" name="handle" placeholder="alice.bsky.social" x-model="handle" required>
220- </div>
221222- <div class="form-group">
223- <label for="password">Real Password</label>
224- <input type="password" id="password" name="password" x-model="password" required>
225- <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>
0000000000000000022600000227 </div>
228229- <div x-show="showTwoFactorCodeInput" class="form-group">
230- <label for="two-factor-code">Two-factor code (email)</label>
231- <input type="text" id="two-factor-code" name="two-factor-code" x-model="twoFactorCode">
232- <div class="error-message">Enter the 2FA code from your email.</div>
233 </div>
234- </div>
235-236- <div x-show="error" x-text="error" class="error-message"></div>
237- <div x-show="showStatusMessage" id="status-message" class="status-message"></div>
238- <div>
239- <button type="submit">Login for backups</button>
240- </div>
241- </form>
242243 <!-- Repo not found prompt -->
244 <div class="section" x-show="showRepoNotFoundScreen">
···298 </template>
299300 <!-- Repo status view for signed-up users -->
301- <div class="section" x-show="!showLoginScreen && !showRepoNotFoundScreen && !showRotationKeyScreen">
302 <div class="section-header">
303 <h2 style="margin: 0;">Backup repository status</h2>
304 <button type="button" class="icon-button" title="Refresh status" aria-label="Refresh status" @click="refreshStatus">
···17 showTwoFactorCodeInput: false,
18 error: null,
19 showStatusMessage: false,
20+ //The landing page to pick to login with password or oauth
21+ showLandingButtons: true,
22+ //Password login
23+ showLoginScreen: false,
24 showRepoNotFoundScreen: false,
25 addRecoveryKey: true,
26 // Rotation key flow state
···50 this.showStatusMessage = false;
51 }
52 },
53+ handleShowLogin() {
54+ this.showLandingButtons = false;
55+ this.showLoginScreen = true;
56+ },
57+ async handleOAuthSignup() {
58+ // TODO: Replace this URL with your actual OAuth signup endpoint for backups
59+ // If you have an environment-specific route, consider injecting it or making it configurable
60+ window.location.href = '/oauth/backups';
61+ },
62 async handleLoginSubmit() {
63+ this.error = null;
64+ this.showStatusMessage = false;
65+ this.showLandingButtons = false;
66 this.error = null;
67 this.showStatusMessage = false;
68···218 {% call cow::cow_header("Backups") %}
219220221+ <!-- Landing choice: two buttons -->
222+ <div x-show="showLoginScreen">
223 <!-- Informational section before sign-in -->
224 <div class="section" style="text-align: left;">
225+ <p>
226+ PDS MOOver can provide worry-free backups of your AT Protocol account.
227+ This is a free service for individual accounts
228+ and stores the backups on PDS MOOver's servers.
229+ 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>,
230+ this is also public.
231+ <span x-show="showLoginScreen">On login,
232+ you will be asked if you'd like to add a rotation key to your account.
233+ A rotation key is a recovery key
234+ that allows you to restore your account if your PDS ever goes down.
235+ If you're already signed up for backups, then you can log in here to manage them.</span>
236+237+ </p>
238 </div>
0000000239240+ <div x-show="showLandingButtons" class="actions" style="display: flex; gap: 1rem; flex-wrap: wrap;">
241+ <button type="button" @click="handleShowLogin()">Sign in to add a recovery key</button>
242+ <button type="button" @click="await handleOAuthSignup()">Sign in for backups with OAuth</button>
243+ </div>
244+245+ <!-- Sign in with password section -->
246+ <form id="backup-signup-form" @submit.prevent="await handleLoginSubmit()">
247+248+ <div class="section">
249+ <h2>Sign in to your account</h2>
250+ <div class="form-group">
251+ <label for="handle">Handle</label>
252+ <input type="text" id="handle" name="handle" placeholder="alice.bsky.social" x-model="handle" required>
253+ </div>
254+255+ <div class="form-group">
256+ <label for="password">Real Password</label>
257+ <input type="password" id="password" name="password" x-model="password" required>
258+ <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>
259+260+ </div>
261262+ <div x-show="showTwoFactorCodeInput" class="form-group">
263+ <label for="two-factor-code">Two-factor code (email)</label>
264+ <input type="text" id="two-factor-code" name="two-factor-code" x-model="twoFactorCode">
265+ <div class="error-message">Enter the 2FA code from your email.</div>
266+ </div>
267 </div>
268269+ <div x-show="error" x-text="error" class="error-message"></div>
270+ <div x-show="showStatusMessage" id="status-message" class="status-message"></div>
271+ <div>
272+ <button type="submit">Login for backups</button>
273 </div>
274+ </form>
275+ </div>
000000276277 <!-- Repo not found prompt -->
278 <div class="section" x-show="showRepoNotFoundScreen">
···332 </template>
333334 <!-- Repo status view for signed-up users -->
335+ <div class="section" x-show="!showLandingButtons && !showLoginScreen && !showRepoNotFoundScreen && !showRotationKeyScreen">
336 <div class="section-header">
337 <h2 style="margin: 0;">Backup repository status</h2>
338 <button type="button" class="icon-button" title="Refresh status" aria-label="Refresh status" @click="refreshStatus">
+1-20
web/templates/index.askama.html
···28 }
29 },
30 //TODO bad but do not want to figure out onload with current setup
31- fmtBytes(bytes) {
32- if (bytes == null) return '—';
33- const units = ['B', 'KB', 'MB', 'GB', 'TB'];
34- let i = 0;
35- let v = Number(bytes);
36- while (v >= 1024 && i < units.length - 1) {
37- v /= 1024;
38- i++;
39- }
40- return v.toFixed(1) + ' ' + units[i];
41- },
42- fmtDate(value) {
43- if (!value) return '—';
44- try {
45- const d = new Date(value);
46- return d.toLocaleString();
47- } catch (_) {
48- return String(value);
49- }
50- }
5152 }));
53});
···28 }
29 },
30 //TODO bad but do not want to figure out onload with current setup
31+00000000000000000003233 }));
34});
···1-import {Migrator} from './pdsmoover.js';
2-import {MissingBlobs} from './missingBlobs.js';
3-import {BackupService} from './backup.js';
4-import {PlcOps} from './plc-ops.js';
5-import {MooverUtils} from './utils.js';
6-import {Restore} from './restore.js';
7-import {handleAndPDSResolver} from './atprotoUtils.js';
8-import Alpine from 'alpinejs';
9-10-11-window.Migrator = new Migrator();
12-window.MissingBlobs = new MissingBlobs();
13-window.BackupService = new BackupService();
14-window.PlcOps = new PlcOps();
15-window.MooverUtils = new MooverUtils();
16-window.Restore = new Restore();
17-window.handleAndPDSResolver = handleAndPDSResolver;
18-19-window.Alpine = Alpine;
20-21-Alpine.start();
22-23-export {Migrator, MissingBlobs};
24-
···000000000000000000000000
-153
web/ui-code/src/missingBlobs.js
···1-//I need to condense this code with the rest of PDS MOOver cause it has a lot of overlap
2-import {AtpAgent} from '@atproto/api';
3-import {handleAndPDSResolver} from './atprotoUtils.js';
4-5-6-class MissingBlobs {
7-8- constructor() {
9- this.currentPdsAgent = null;
10- this.oldPdsAgent = null;
11- this.did = null;
12- this.currentPdsUrl = null;
13- this.missingBlobs = [];
14-15- }
16-17- async currentAgentLogin(
18- handle,
19- password,
20- twoFactorCode = null,
21- ) {
22- let {usersDid, pds} = await handleAndPDSResolver(handle);
23- this.did = usersDid;
24- this.currentPdsUrl = pds;
25- const agent = new AtpAgent({
26- service: pds,
27- });
28-29- if (twoFactorCode === null) {
30- await agent.login({identifier: usersDid, password});
31- } else {
32- await agent.login({identifier: usersDid, password: password, authFactorToken: twoFactorCode});
33- }
34-35- this.currentPdsAgent = agent;
36-37- const result = await agent.com.atproto.server.checkAccountStatus();
38- const missingBlobs = await this.currentPdsAgent.com.atproto.repo.listMissingBlobs({
39- limit: 10,
40- });
41- return {accountStatus: result.data, missingBlobsCount: missingBlobs.data.blobs.length};
42- }
43-44- async oldAgentLogin(
45- password,
46- twoFactorCode = null,
47- pdsUrl = null,
48- ) {
49- let oldPds = null;
50-51- if (pdsUrl === null) {
52- const response = await fetch(`https://plc.directory/${this.did}/log`);
53- let auditLog = await response.json();
54- auditLog = auditLog.reverse();
55- let debugCount = 0;
56- for (const entry of auditLog) {
57- console.log(`Loop: ${debugCount++}`);
58- console.log(entry);
59- if (entry.services) {
60- if (entry.services.atproto_pds) {
61- if (entry.services.atproto_pds.type === 'AtprotoPersonalDataServer') {
62- const pds = entry.services.atproto_pds.endpoint;
63- console.log(`Found PDS: ${pds}`);
64- if (pds.toLowerCase() !== this.currentPdsUrl.toLowerCase()) {
65- oldPds = pds;
66- break;
67- }
68- }
69- }
70- }
71- }
72- if (oldPds === null) {
73- throw new Error('Could not find your old PDS');
74- }
75- } else {
76- oldPds = pdsUrl;
77- }
78-79- const agent = new AtpAgent({
80- service: oldPds,
81- });
82-83- if (twoFactorCode === null) {
84- await agent.login({identifier: this.did, password});
85- } else {
86- await agent.login({identifier: this.did, password: password, authFactorToken: twoFactorCode});
87- }
88- this.oldPdsAgent = agent;
89- }
90-91- async migrateMissingBlobs(statusUpdateHandler) {
92- if (this.currentPdsAgent === null) {
93- throw new Error('Current PDS agent is not set');
94- }
95- if (this.oldPdsAgent === null) {
96- throw new Error('Old PDS agent is not set');
97- }
98- statusUpdateHandler('Starting to import blobs...');
99-100- // const currentAccountStatus = await this.currentPdsAgent.com.atproto.server.checkAccountStatus();
101- // let totalMissingBlobs = currentAccountStatus.data.expectedBlobs - currentAccountStatus.data.importedBlobs;
102- let totalMissingBlobs = 0;
103- let missingBlobCursor = undefined;
104- let missingUploadedBlobs = 0;
105-106- do {
107-108- const missingBlobs = await this.currentPdsAgent.com.atproto.repo.listMissingBlobs({
109- cursor: missingBlobCursor,
110- //Test this cause it may be a big update
111- limit: 1000,
112- });
113- totalMissingBlobs += missingBlobs.data.blobs.length;
114-115- for (const recordBlob of missingBlobs.data.blobs) {
116- try {
117-118- const blobRes = await this.oldPdsAgent.com.atproto.sync.getBlob({
119- did: this.did,
120- cid: recordBlob.cid,
121- });
122- let result = await this.currentPdsAgent.com.atproto.repo.uploadBlob(blobRes.data, {
123- encoding: blobRes.headers['content-type'],
124- });
125-126- if (result.status === 429) {
127- statusUpdateHandler(`You are being rate limited. Will need to try again later to get the rest of the blobs. Migrated blobs: ${missingUploadedBlobs}/${totalMissingBlobs}`);
128- }
129-130- if (missingUploadedBlobs % 2 === 0) {
131- statusUpdateHandler(`Migrating blobs: ${missingUploadedBlobs}/${totalMissingBlobs} (The total may increase as we find more)`);
132- }
133- missingUploadedBlobs++;
134- } catch (error) {
135- console.error(error);
136- this.missingBlobs.push(recordBlob.cid);
137- }
138- }
139- missingBlobCursor = missingBlobs.data.cursor;
140- } while (missingBlobCursor);
141-142- const accountStatus = await this.currentPdsAgent.com.atproto.server.checkAccountStatus();
143- const missingBlobs = await this.currentPdsAgent.com.atproto.repo.listMissingBlobs({
144- limit: 10,
145- });
146- return {accountStatus: accountStatus.data, missingBlobsCount: missingBlobs.data.blobs.length};
147-148-149- }
150-151-}
152-153-export {MissingBlobs};
···1-import {docResolver, fetchPDSMooverDIDWeb, handleResolver} from './atprotoUtils.js';
2-import {AtpAgent} from '@atproto/api';
3-4-5-function safeStatusUpdate(statusUpdateHandler, status) {
6- if (statusUpdateHandler) {
7- statusUpdateHandler(status);
8- }
9-}
10-11-12-class Migrator {
13- constructor() {
14- /** @type {AtpAgent} */
15- this.oldAgent = null;
16- /** @type {AtpAgent} */
17- this.newAgent = null;
18- this.missingBlobs = [];
19- //State for reruns
20- this.createNewAccount = true;
21- this.migrateRepo = true;
22- this.migrateBlobs = true;
23- this.migrateMissingBlobs = true;
24- this.migratePrefs = true;
25- this.migratePlcRecord = true;
26- }
27-28- /**
29- * This migrator is pretty cut and dry and makes a few assumptions
30- * 1. You are using the same password between each account
31- * 2. If this command fails for something like oauth 2fa code it throws an error and expects the same values when ran again.
32- * @param {string} oldHandle - The handle you use on your old pds, something like alice.bsky.social
33- * @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
34- * @param {string} newPdsUrl - The new URL for your pds. Like https://coolnewpds.com
35- * @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)
36- * @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.
37- * @param {string|null} inviteCode - The invite code you got from the PDS you are migrating to. If null does not include one
38- * @param {function|null} statusUpdateHandler - a function that takes a string used to update the UI. Like (status) => console.log(status)
39- * @param {string|null} twoFactorCode - Optional, but needed if it fails with 2fa required
40- */
41- async migrate(oldHandle, password, newPdsUrl, newEmail, newHandle, inviteCode, statusUpdateHandler = null, twoFactorCode = null) {
42-43- //Copying the handle from bsky website adds some random unicodes on
44- oldHandle = oldHandle.replace('@', '').trim().replace(/[\u202A\u202C\u200E\u200F\u2066-\u2069]/g, '');
45- let oldAgent;
46- let usersDid;
47- //If it's a bsky handle just go with the entryway and let it sort everything
48- if (oldHandle.endsWith('.bsky.social')) {
49- oldAgent = new AtpAgent({service: 'https://bsky.social'});
50- const publicAgent = new AtpAgent({service: 'https://public.api.bsky.app'});
51- const resolveIdentityFromEntryway = await publicAgent.com.atproto.identity.resolveHandle({handle: oldHandle});
52- usersDid = resolveIdentityFromEntryway.data.did;
53-54- } else {
55- //Resolves the did and finds the did document for the old PDS
56- safeStatusUpdate(statusUpdateHandler, 'Resolving old PDS');
57- usersDid = await handleResolver.resolve(oldHandle);
58- const didDoc = await docResolver.resolve(usersDid);
59- safeStatusUpdate(statusUpdateHandler, 'Resolving did document and finding your current PDS URL');
60-61- let oldPds;
62- try {
63- oldPds = didDoc.service.filter(s => s.type === 'AtprotoPersonalDataServer')[0].serviceEndpoint;
64- } catch (error) {
65- console.error(error);
66- throw new Error('Could not find a PDS in the DID document.');
67- }
68-69- oldAgent = new AtpAgent({
70- service: oldPds,
71- });
72-73- }
74-75- safeStatusUpdate(statusUpdateHandler, 'Logging you in to the old PDS');
76- //Login to the old PDS
77- if (twoFactorCode === null) {
78- await oldAgent.login({identifier: oldHandle, password});
79- } else {
80- await oldAgent.login({identifier: oldHandle, password: password, authFactorToken: twoFactorCode});
81- }
82-83- safeStatusUpdate(statusUpdateHandler, 'Checking that the new PDS is an actual PDS (if the url is wrong this takes a while to error out)');
84- const newAgent = new AtpAgent({service: newPdsUrl});
85- const newHostDesc = await newAgent.com.atproto.server.describeServer();
86- if (this.createNewAccount) {
87- const newHostWebDid = newHostDesc.data.did;
88-89- safeStatusUpdate(statusUpdateHandler, 'Creating a new account on the new PDS');
90-91- const createAuthResp = await oldAgent.com.atproto.server.getServiceAuth({
92- aud: newHostWebDid,
93- lxm: 'com.atproto.server.createAccount',
94- });
95- const serviceJwt = createAuthResp.data.token;
96-97- let createAccountRequest = {
98- did: usersDid,
99- handle: newHandle,
100- email: newEmail,
101- password: password,
102- };
103- if (inviteCode) {
104- createAccountRequest.inviteCode = inviteCode;
105- }
106- const createNewAccount = await newAgent.com.atproto.server.createAccount(
107- createAccountRequest,
108- {
109- headers: {authorization: `Bearer ${serviceJwt}`},
110- encoding: 'application/json',
111- });
112-113- if (createNewAccount.data.did !== usersDid.toString()) {
114- throw new Error('Did not create the new account with the same did as the old account');
115- }
116- }
117- safeStatusUpdate(statusUpdateHandler, 'Logging in with the new account');
118-119- await newAgent.login({
120- identifier: usersDid,
121- password: password,
122- });
123-124- if (this.migrateRepo) {
125- safeStatusUpdate(statusUpdateHandler, 'Migrating your repo');
126- const repoRes = await oldAgent.com.atproto.sync.getRepo({did: usersDid});
127- await newAgent.com.atproto.repo.importRepo(repoRes.data, {
128- encoding: 'application/vnd.ipld.car',
129- });
130- }
131-132- let newAccountStatus = await newAgent.com.atproto.server.checkAccountStatus();
133-134- if (this.migrateBlobs) {
135- safeStatusUpdate(statusUpdateHandler, 'Migrating your blobs');
136-137- let blobCursor = undefined;
138- let uploadedBlobs = 0;
139- do {
140- safeStatusUpdate(statusUpdateHandler, `Migrating blobs: ${uploadedBlobs}/${newAccountStatus.data.expectedBlobs}`);
141-142- const listedBlobs = await oldAgent.com.atproto.sync.listBlobs({
143- did: usersDid,
144- cursor: blobCursor,
145- limit: 100,
146- });
147-148- for (const cid of listedBlobs.data.cids) {
149- try {
150- const blobRes = await oldAgent.com.atproto.sync.getBlob({
151- did: usersDid,
152- cid,
153- });
154- await newAgent.com.atproto.repo.uploadBlob(blobRes.data, {
155- encoding: blobRes.headers['content-type'],
156- });
157- uploadedBlobs++;
158- if (uploadedBlobs % 10 === 0) {
159- safeStatusUpdate(statusUpdateHandler, `Migrating blobs: ${uploadedBlobs}/${newAccountStatus.data.expectedBlobs}`);
160- }
161- } catch (error) {
162- console.error(error);
163- }
164- }
165- blobCursor = listedBlobs.data.cursor;
166- } while (blobCursor);
167- }
168-169- if (this.migrateMissingBlobs) {
170- newAccountStatus = await newAgent.com.atproto.server.checkAccountStatus();
171- if (newAccountStatus.data.expectedBlobs !== newAccountStatus.data.importedBlobs) {
172- let totalMissingBlobs = newAccountStatus.data.expectedBlobs - newAccountStatus.data.importedBlobs;
173- safeStatusUpdate(statusUpdateHandler, 'Looks like there are some missing blobs. Going to try and upload them now.');
174- //Probably should be shared between main blob uploader, but eh
175- let missingBlobCursor = undefined;
176- let missingUploadedBlobs = 0;
177- do {
178- safeStatusUpdate(statusUpdateHandler, `Migrating blobs: ${missingUploadedBlobs}/${totalMissingBlobs}`);
179-180- const missingBlobs = await newAgent.com.atproto.repo.listMissingBlobs({
181- cursor: missingBlobCursor,
182- limit: 100,
183- });
184-185- for (const recordBlob of missingBlobs.data.blobs) {
186- try {
187-188- const blobRes = await oldAgent.com.atproto.sync.getBlob({
189- did: usersDid,
190- cid: recordBlob.cid,
191- });
192- await newAgent.com.atproto.repo.uploadBlob(blobRes.data, {
193- encoding: blobRes.headers['content-type'],
194- });
195- if (missingUploadedBlobs % 10 === 0) {
196- safeStatusUpdate(statusUpdateHandler, `Migrating blobs: ${missingUploadedBlobs}/${totalMissingBlobs}`);
197- }
198- missingUploadedBlobs++;
199- } catch (error) {
200- //TODO silently logging prob should list them so user can manually download
201- console.error(error);
202- this.missingBlobs.push(recordBlob.cid);
203- }
204- }
205- missingBlobCursor = missingBlobs.data.cursor;
206- } while (missingBlobCursor);
207-208- }
209- }
210- if (this.migratePrefs) {
211- const prefs = await oldAgent.app.bsky.actor.getPreferences();
212- await newAgent.app.bsky.actor.putPreferences(prefs.data);
213- }
214-215- this.oldAgent = oldAgent;
216- this.newAgent = newAgent;
217-218- if (this.migratePlcRecord) {
219- await oldAgent.com.atproto.identity.requestPlcOperationSignature();
220- safeStatusUpdate(statusUpdateHandler, 'Please check your email for a PLC token');
221- }
222- }
223-224- /**
225- * Sign and submits the PLC operation to officially migrate the account
226- * @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
227- * @param additionalRotationKeysToAdd {[string]} - additional rotation keys to add in addition to the ones provided by the new PDS.
228- * @returns {Promise<void>}
229- */
230- async signPlcOperation(token, additionalRotationKeysToAdd = []) {
231- const getDidCredentials =
232- await this.newAgent.com.atproto.identity.getRecommendedDidCredentials();
233- const pdsProvidedRotationKeys = getDidCredentials.data.rotationKeys ?? [];
234- // Prepend any additional rotation keys (e.g., user-added keys, newly created key) so they appear above the new PDS rotation key
235- const rotationKeys = [...(additionalRotationKeysToAdd || []), ...pdsProvidedRotationKeys];
236- if (!rotationKeys) {
237- throw new Error('No rotation key provided from the new PDS');
238- }
239- const credentials = {
240- ...getDidCredentials.data,
241- rotationKeys: rotationKeys,
242- };
243-244-245- const plcOp = await this.oldAgent.com.atproto.identity.signPlcOperation({
246- token: token,
247- ...credentials,
248- });
249-250- await this.newAgent.com.atproto.identity.submitPlcOperation({
251- operation: plcOp.data.operation,
252- });
253-254- await this.newAgent.com.atproto.server.activateAccount();
255- await this.oldAgent.com.atproto.server.deactivateAccount({});
256- }
257-258- // 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
259- async deactivateOldAccount(oldHandle, oldPassword, statusUpdateHandler = null, twoFactorCode = null) {
260- //Copying the handle from bsky website adds some random unicodes on
261- oldHandle = oldHandle.replace('@', '').trim().replace(/[\u202A\u202C\u200E\u200F\u2066-\u2069]/g, '');
262- let usersDid;
263- //If it's a bsky handle just go with the entryway and let it sort everything
264- if (oldHandle.endsWith('.bsky.social')) {
265- const publicAgent = new AtpAgent({service: 'https://public.api.bsky.app'});
266- const resolveIdentityFromEntryway = await publicAgent.com.atproto.identity.resolveHandle({handle: oldHandle});
267- usersDid = resolveIdentityFromEntryway.data.did;
268- } else {
269- //Resolves the did and finds the did document for the old PDS
270- safeStatusUpdate(statusUpdateHandler, 'Resolving did from handle');
271- usersDid = await handleResolver.resolve(oldHandle);
272- }
273-274- const didDoc = await docResolver.resolve(usersDid);
275- let currentPds;
276- try {
277- currentPds = didDoc.service.filter(s => s.type === 'AtprotoPersonalDataServer')[0].serviceEndpoint;
278- } catch (error) {
279- console.error(error);
280- throw new Error('Could not find a PDS in the DID document.');
281- }
282-283- const plcLogRequest = await fetch(`https://plc.directory/${usersDid}/log`);
284- const plcLog = await plcLogRequest.json();
285- let pdsBeforeCurrent = '';
286- for (const log of plcLog) {
287- try {
288- const pds = log.services.atproto_pds.endpoint;
289- console.log(pds);
290- if (pds.toLowerCase() === currentPds.toLowerCase()) {
291- console.log('Found the PDS before the current one');
292- break;
293- }
294- pdsBeforeCurrent = pds;
295- } catch (e) {
296- console.log(e);
297- }
298- }
299- if (pdsBeforeCurrent === '') {
300- throw new Error('Could not find the PDS before the current one');
301- }
302-303- let oldAgent = new AtpAgent({service: pdsBeforeCurrent});
304- safeStatusUpdate(statusUpdateHandler, `Logging you in to the old PDS: ${pdsBeforeCurrent}`);
305- //Login to the old PDS
306- if (twoFactorCode === null) {
307- await oldAgent.login({identifier: oldHandle, password: oldPassword});
308- } else {
309- await oldAgent.login({identifier: oldHandle, password: oldPassword, authFactorToken: twoFactorCode});
310- }
311- safeStatusUpdate(statusUpdateHandler, 'Checking this isn\'t your current PDS');
312- if (pdsBeforeCurrent === currentPds) {
313- throw new Error('This is your current PDS. Login to your old account username and password');
314- }
315-316- let currentAccountStatus = await oldAgent.com.atproto.server.checkAccountStatus();
317- if (!currentAccountStatus.data.activated) {
318- safeStatusUpdate(statusUpdateHandler, 'All good. Your old account is not activated.');
319- }
320- safeStatusUpdate(statusUpdateHandler, 'Deactivating your OLD account');
321- await oldAgent.com.atproto.server.deactivateAccount({});
322- safeStatusUpdate(statusUpdateHandler, 'Successfully deactivated your OLD account');
323- }
324-325- async signUpForBackupsFromMigration() {
326- // Use a plain fetch POST with atproto-proxy header and bearer from the new agent
327- const didWeb = await fetchPDSMooverDIDWeb(window.location.origin);
328-329- const url = `${this.newAgent.serviceUrl.origin}/xrpc/com.pdsmoover.backup.signUp`;
330-331- const accessJwt = this.newAgent?.session?.accessJwt;
332- if (!accessJwt) {
333- throw new Error('Missing access token for authorization');
334- }
335-336- const res = await fetch(url, {
337- method: 'POST',
338- headers: {
339- 'Authorization': `Bearer ${accessJwt}`,
340- 'Content-Type': 'application/json',
341- 'Accept': 'application/json',
342- 'atproto-proxy': `${didWeb}#repo_backup`,
343- },
344- body: JSON.stringify({}),
345- });
346-347- if (!res.ok) {
348- let bodyText = '';
349- try {
350- bodyText = await res.text();
351- } catch {
352- }
353- throw new Error(`Backup signup failed: ${res.status} ${res.statusText}${bodyText ? ` - ${bodyText}` : ''}`);
354- }
355-356- //No return the success is all that is needed, if there's an error it will throw
357- }
358-}
359-360-export {Migrator};
361-
···1-import {handleAndPDSResolver} from './atprotoUtils.js';
2-import {PlcOps} from './plc-ops.js';
3-import {normalizeOp, Operation} from '@atcute/did-plc';
4-import {AtpAgent} from '@atproto/api';
5-import {Secp256k1PrivateKeyExportable} from '@atcute/crypto';
6-import * as CBOR from '@atcute/cbor';
7-import {toBase64Url} from '@atcute/multibase';
8-9-class Restore {
10-11- constructor() {
12- /** @type {PlcOps} */
13- this.plcOps = new PlcOps();
14- this.tempVerificationKeypair = null;
15- /** @type {AtpAgent} */
16- this.atpAgent = null;
17- this.recoveryRotationKeyPair = null;
18- //Feature flags
19- this.justRestoreFiles = false;
20- }
21-22- async recover(
23- rotationKey,
24- currentHandle,
25- newPDS,
26- newHandle,
27- newPassword,
28- newEmail,
29- inviteCode,
30- cidToRestoreTo = null,
31- onStatus = null) {
32-33-34- if (onStatus) onStatus('Resolving your handle...');
35-36- let {usersDid} = await handleAndPDSResolver(currentHandle);
37-38- if (onStatus) onStatus('Checking that the new PDS is an actual PDS (if the url is wrong, this takes a while to error out)');
39- this.atpAgent = new AtpAgent({service: newPDS});
40- const newHostDesc = await this.atpAgent.com.atproto.server.describeServer();
41-42-43- //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
44- try {
45- await this.atpAgent.com.atproto.repo.describeRepo({repo: usersDid.toString()});
46- //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
47- this.justRestoreFiles = true;
48-49- } catch (error) {
50- console.error(error);
51- let parsedError = error.error;
52- if (parsedError === 'RepoDeactivated') {
53- //Ideally should mean they already have a repo on the new PDS and we just need to restore the files
54- this.justRestoreFiles = true;
55- }
56- //This is the error we want to see, anything else throw
57- if (parsedError !== 'RepoNotFound') {
58- throw error;
59- }
60- }
61-62- //We need to double check that the new handle has not been taken, if it has we need to throw an error
63- //We care a bit more because we do not want any unnecessary plc ops to be created
64- try {
65- let resolveHandle = await this.atpAgent.com.atproto.identity.resolveHandle({handle: newHandle});
66- if (resolveHandle.data.did === usersDid.toString()) {
67- //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
68- this.justRestoreFiles = true;
69- } else {
70- //There is a repo with that name and it's not the users did,
71- throw new Error('The new handle is already taken, please select a different handle');
72- }
73- } catch (error) {
74- // Going to silently log this and just assume the handle has not been taken.
75- console.error(error);
76- if (error.message.startsWith('The new handle')) {
77- //it's not our custom error, so we can just throw it
78- throw error;
79- }
80-81- }
82-83- if (!this.justRestoreFiles) {
84-85- if (onStatus) onStatus('Validating your private rotation key is in the correct format...');
86- this.recoveryRotationKeyPair = await this.plcOps.getKeyPair(rotationKey);
87-88-89- if (onStatus) onStatus('Resolving PlC operation logs...');
90-91- /** @type {Operation} */
92- let baseOpForSigning = null;
93- let opPrevCid = null;
94-95- //This is for reversals against a rogue plc op and you want to restore to a specific cid in the audit log
96- if (cidToRestoreTo) {
97- let auditLogs = await this.plcOps.getPlcAuditLogs(usersDid);
98- for (const log of auditLogs) {
99- if (log.cid === cidToRestoreTo) {
100- baseOpForSigning = normalizeOp(log.operation);
101- opPrevCid = log.cid;
102- break;
103- }
104- }
105- if (!baseOpForSigning) {
106- throw new Error('Could not find the cid in the audit logs');
107- }
108- } else {
109- let {lastOperation, base} = await this.plcOps.getLastPlcOpFromPlc(usersDid);
110- opPrevCid = base.cid;
111- baseOpForSigning = lastOperation;
112- }
113-114- if (onStatus) onStatus('Preparing to switch to a temp atproto key...');
115- if (this.tempVerificationKeypair == null) {
116- if (onStatus) onStatus('Creating a new temp atproto key...');
117- this.tempVerificationKeypair = await Secp256k1PrivateKeyExportable.createKeypair();
118- }
119- //Just defaulting to the user's recovery key for now. Advance cases will be something else
120- //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
121- let tempRotationKeys = [this.recoveryRotationKeyPair.didPublicKey];
122-123- if (onStatus) onStatus('Modifying the PLC OP for recovery...');
124- //A temp plc op for control of the atproto key to create a serviceAuth and new account on the new PDS
125- await this.plcOps.signAndPublishNewOp(
126- usersDid,
127- this.recoveryRotationKeyPair.keypair,
128- baseOpForSigning.alsoKnownAs,
129- tempRotationKeys,
130- newPDS,
131- await this.tempVerificationKeypair.exportPublicKey('did'),
132- opPrevCid);
133-134-135- if (onStatus) onStatus('Creating your new account on the new PDS...');
136- let serviceAuthToken = await this.plcOps.createANewServiceAuthToken(usersDid, newHostDesc.data.did, this.tempVerificationKeypair, 'com.atproto.server.createAccount');
137-138- let createAccountRequest = {
139- did: usersDid,
140- handle: newHandle,
141- email: newEmail,
142- password: newPassword,
143- };
144- if (inviteCode) {
145- createAccountRequest.inviteCode = inviteCode;
146- }
147- const _ = await this.atpAgent.com.atproto.server.createAccount(
148- createAccountRequest,
149- {
150- headers: {authorization: `Bearer ${serviceAuthToken}`},
151- encoding: 'application/json',
152- });
153- }
154-155- await this.atpAgent.login({
156- identifier: usersDid,
157- password: newPassword,
158- });
159-160- if (!this.justRestoreFiles) {
161- //Moving the user offically to the new PDS
162- if (onStatus) onStatus('Signing the papers...');
163- let {base} = await this.plcOps.getLastPlcOpFromPlc(usersDid);
164- await this.signRestorePlcOperation(usersDid, [this.recoveryRotationKeyPair.didPublicKey], base.cid);
165- }
166-167- if (onStatus) onStatus('Success! Restoring your repo...');
168- const pdsMoover = new AtpAgent({service: window.location.origin});
169- const repoRes = await pdsMoover.com.atproto.sync.getRepo({did: usersDid});
170- await this.atpAgent.com.atproto.repo.importRepo(repoRes.data, {
171- encoding: 'application/vnd.ipld.car',
172- });
173-174- if (onStatus) onStatus('Restoring your blobs...');
175-176- //Using the missing endpoint to findout what's missing then the PDS MOOver endpoint to restore
177- let totalMissingBlobs = 0;
178- let missingBlobCursor = undefined;
179- let missingUploadedBlobs = 0;
180-181- do {
182-183- const missingBlobs = await this.atpAgent.com.atproto.repo.listMissingBlobs({
184- cursor: missingBlobCursor,
185- limit: 1000,
186- });
187- totalMissingBlobs += missingBlobs.data.blobs.length;
188-189- for (const recordBlob of missingBlobs.data.blobs) {
190- try {
191-192- const blobRes = await pdsMoover.com.atproto.sync.getBlob({
193- did: usersDid,
194- cid: recordBlob.cid,
195- });
196- let result = await this.atpAgent.com.atproto.repo.uploadBlob(blobRes.data, {
197- encoding: blobRes.headers['content-type'],
198- });
199-200-201- if (missingUploadedBlobs % 2 === 0) {
202- if (onStatus) onStatus(`Migrating blobs: ${missingUploadedBlobs}/${totalMissingBlobs} (The total may increase as we find more)`);
203- }
204- missingUploadedBlobs++;
205- } catch (error) {
206- console.error(error);
207- }
208- }
209- missingBlobCursor = missingBlobs.data.cursor;
210- } while (missingBlobCursor);
211-212- const accountStatus = await this.atpAgent.com.atproto.server.checkAccountStatus();
213- if (!accountStatus.data.activated) {
214- if (onStatus) onStatus('Activating your account...');
215- await this.atpAgent.com.atproto.server.activateAccount();
216- }
217- }
218-219- async signRestorePlcOperation(usersDid, additionalRotationKeysToAdd = [], prevCid) {
220- const getDidCredentials =
221- await this.atpAgent.com.atproto.identity.getRecommendedDidCredentials();
222- console.log(getDidCredentials);
223-224- const pdsProvidedRotationKeys = getDidCredentials.data.rotationKeys ?? [];
225- // Prepend any additional rotation keys (e.g., user-added keys, newly created key) so they appear above the new PDS rotation key
226- const rotationKeys = [...(additionalRotationKeysToAdd || []), ...pdsProvidedRotationKeys];
227- if (!rotationKeys) {
228- throw new Error('No rotation key provided from the new PDS');
229- }
230- const plcOpToSubmit = {
231- type: 'plc_operation',
232- ...getDidCredentials.data,
233- prev: prevCid,
234- rotationKeys: rotationKeys,
235- };
236-237-238- const opBytes = CBOR.encode(plcOpToSubmit);
239- const sigBytes = await this.recoveryRotationKeyPair.keypair.sign(opBytes);
240-241- const signature = toBase64Url(sigBytes);
242-243- const signedOperation = {
244- ...plcOpToSubmit,
245- sig: signature,
246- };
247-248- await this.plcOps.pushPlcOperation(usersDid, signedOperation);
249- await this.atpAgent.com.atproto.server.activateAccount();
250-251- }
252-}
253-254-export {Restore};