Client side atproto account migrator in your web browser, along with services for backups and adversarial migrations.
pdsmoover.com
pds
atproto
migrations
moo
cow
1import {Client, CredentialManager, ok} from '@atcute/client';
2import {fetchPDSMooverDIDWeb, handleAndPDSResolver} from './atprotoUtils.js';
3
4
5class BackupService {
6 constructor() {
7 /**
8 *
9 * @type {Client}
10 */
11 this.atCuteClient = null;
12 /**
13 *
14 * @type {CredentialManager}
15 */
16 this.atCuteCredentialManager = null;
17 }
18
19 // Perform backup sign-up using user credentials. If the server requires 2FA,
20 // it will throw with error.error === 'AuthFactorTokenRequired'.
21 async loginAndStatus(identifier, password, onStatus = null, twoFactorCode = null) {
22 let {pds} = await handleAndPDSResolver(identifier);
23
24
25 const manager = new CredentialManager({
26 service: pds
27 });
28
29 //TODO prob should be a function param, but eh
30 const didWeb = await fetchPDSMooverDIDWeb(window.location.origin);
31
32 const rpc = new Client({
33 handler: manager,
34 proxy: {
35 did: didWeb,
36 serviceId: '#repo_backup'
37 }
38 });
39
40 try {
41 if (onStatus) onStatus('Signing in…');
42
43 let loginInput = {
44 identifier,
45 password,
46
47 };
48 if (twoFactorCode) {
49 loginInput.code = twoFactorCode;
50 }
51 await manager.login(loginInput);
52
53
54 // Make the client/manager available regardless of repo status so we can sign up if needed.
55 this.atCuteClient = rpc;
56 this.atCuteCredentialManager = manager;
57
58 if (onStatus) onStatus('Checking backup status');
59 const result = await rpc.get('com.pdsmoover.backup.getRepoStatus', {
60 params: {
61 did: manager.session.did.toString()
62 }
63 });
64 if (result.ok) {
65 return result.data;
66 } else {
67 switch (result.data.error) {
68 case 'RepoNotFound':
69 console.log('Repo not found');
70 return null;
71 default:
72 throw result.data.error;
73 }
74
75 }
76 } catch (err) {
77 throw err;
78 }
79 }
80
81 // Sign up for backups. Optionally create a new recovery key on signup.
82 async signUp(onStatus = null) {
83 if (!this.atCuteClient || !this.atCuteCredentialManager) {
84 throw new Error('Not signed in');
85 }
86 if (onStatus) onStatus('Creating backup registration…');
87 const data = await ok(
88 this.atCuteClient.post('com.pdsmoover.backup.signUp', {
89 as: null,
90 })
91 );
92 if (onStatus) onStatus('Backup registration complete');
93 return data;
94 }
95
96 async requestAPlcToken() {
97 if (!this.atCuteClient || !this.atCuteCredentialManager) {
98 throw new Error('Not signed in');
99 }
100 const rpc = new Client({
101 handler: this.atCuteCredentialManager,
102 });
103
104 let response = await rpc.post('com.atproto.identity.requestPlcOperationSignature', {
105 as: null,
106 });
107 if (!response.ok) {
108 throw new Error(response.data?.message || 'Failed to request PLC token');
109 }
110 }
111
112 async addANewRotationKey(plcToken, rotationKey) {
113 if (!this.atCuteClient || !this.atCuteCredentialManager) {
114 throw new Error('Not signed in');
115 }
116
117
118 const rpc = new Client({
119 handler: this.atCuteCredentialManager,
120 });
121
122 let getDidCredentials = await rpc.get('com.atproto.identity.getRecommendedDidCredentials');
123
124 if (getDidCredentials.ok) {
125 const pdsProvidedRotationKeys = getDidCredentials.data.rotationKeys ?? [];
126 const updatedRotationKeys = [rotationKey, ...pdsProvidedRotationKeys];
127
128 const credentials = {
129 ...getDidCredentials.data,
130 rotationKeys: updatedRotationKeys,
131 };
132
133 const signDocRes = await rpc.post('com.atproto.identity.signPlcOperation', {
134 input: {
135 token: plcToken,
136 ...credentials,
137 }
138 });
139
140 if (signDocRes.ok) {
141 const submitDocRes = await rpc.post('com.atproto.identity.submitPlcOperation', {
142 input: signDocRes.data,
143 as: null,
144 });
145
146 if (!submitDocRes.ok) {
147 throw new Error(submitDocRes.data?.message || 'Failed to submit PLC operation');
148 }
149
150 } else {
151 throw new Error(signDocRes.data?.message || 'Failed to sign PLC operation');
152 }
153
154
155 } else {
156 throw new Error(getDidCredentials.data?.message || 'Failed to get status');
157 }
158 }
159
160 async getUsersRepoStatus(onStatus = null) {
161 if (!this.atCuteClient || !this.atCuteCredentialManager) {
162 throw new Error('Not signed in');
163 }
164 if (onStatus) onStatus('Refreshing backup status…');
165 const result = await this.atCuteClient.get('com.pdsmoover.backup.getRepoStatus', {
166 params: {did: this.atCuteCredentialManager.session.did.toString()}
167 });
168 if (result.ok) {
169 return result.data;
170 } else {
171 throw new Error(result.data?.message || 'Failed to get status');
172 }
173 }
174
175 async runBackupNow(onStatus = null) {
176 if (!this.atCuteClient || !this.atCuteCredentialManager) {
177 throw new Error('Not signed in');
178 }
179 if (onStatus) onStatus('Requesting backup…');
180 const res = await this.atCuteClient.post('com.pdsmoover.backup.requestBackup', {as: null, data: {}});
181 if (res.ok) {
182 if (onStatus) onStatus('Backup requested.');
183 // After requesting, refresh status to reflect any immediate changes
184 try {
185 await this.refreshStatus(onStatus);
186 } catch (_) { /* ignore */
187 }
188 return true;
189 } else {
190 const err = res.data;
191 if (err?.error === 'Timeout') {
192 throw {error: 'Timeout', message: err?.message || 'Please wait a few minutes before requesting again.'};
193 }
194 throw new Error(err?.message || 'Failed to request backup');
195 }
196 }
197
198 // Remove (delete) the user's backup repository
199 async removeRepo(onStatus = null) {
200 if (!this.atCuteClient || !this.atCuteCredentialManager) {
201 throw new Error('Not signed in');
202 }
203 if (onStatus) onStatus('Deleting backup repository…');
204 const res = await this.atCuteClient.post('com.pdsmoover.backup.removeRepo', {as: null, data: {}});
205 if (res.ok) {
206 if (onStatus) onStatus('Backup repository deleted.');
207 return true;
208 } else {
209 const err = res.data;
210 throw new Error(err?.message || 'Failed to delete backup repository');
211 }
212 }
213}
214
215
216export {BackupService};