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 { handleAndPDSResolver } from './atprotoUtils.js'
3//Shows as unused, but is used in the return types
4import { 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 * Logic to sign up and manage backups for pdsmoover.com (or your own selfhosted instance)
13 */
14class BackupService {
15 /**
16 *
17 * @param backupDidWeb {string} - The did:web for the xrpc service for backups, defaults to did:web:pdsmoover.com
18 */
19 constructor(backupDidWeb = 'did:web:pdsmoover.com') {
20 /**
21 *
22 * @type {Client}
23 */
24 this.atCuteClient = null
25 /**
26 *
27 * @type {CredentialManager}
28 */
29 this.atCuteCredentialManager = null
30
31 /**
32 * The did:web for the xrpc service for backups, defaults to pdsmoover.com
33 * @type {string}
34 */
35 this.backupDidWeb = backupDidWeb
36 }
37
38 /**
39 * Logs in and returns the backup status.
40 * To use the rest of the BackupService, it is assumed that this has ran first,
41 * and the user has successfully signed up. A successful login is a returned null if the user has not signed up.
42 * or the backup status if they are
43 *
44 * If the server requires 2FA,
45 * it will throw with error.error === 'AuthFactorTokenRequired'.
46 * @param identifier {string} handle or did
47 * @param password {string}
48 * @param {function|null} onStatus - a function that takes a string used to update the UI.
49 * Like (status) => console.log(status)
50 * @param twoFactorCode {string|null}
51 *
52 * @returns {Promise<InferXRPCBodyOutput<ComPdsmooverBackupDescribeServer.mainSchema['output']>|null>}
53 */
54 async loginAndStatus(identifier, password, onStatus = null, twoFactorCode = null) {
55 let { pds } = await handleAndPDSResolver(identifier)
56
57 const manager = new CredentialManager({
58 service: pds,
59 })
60
61 const rpc = new Client({
62 handler: manager,
63 proxy: {
64 did: this.backupDidWeb,
65 serviceId: '#repo_backup',
66 },
67 })
68
69 try {
70 if (onStatus) onStatus('Signing in…')
71
72 let loginInput = {
73 identifier,
74 password,
75 }
76 if (twoFactorCode) {
77 loginInput.code = twoFactorCode
78 }
79 await manager.login(loginInput)
80
81 // Make the client/manager available regardless of repo status so we can sign up if needed.
82 this.atCuteClient = rpc
83 this.atCuteCredentialManager = manager
84
85 if (onStatus) onStatus('Checking backup status')
86 const result = await rpc.get('com.pdsmoover.backup.getRepoStatus', {
87 params: {
88 did: manager.session.did.toString(),
89 },
90 })
91 if (result.ok) {
92 return result.data
93 } else {
94 switch (result.data.error) {
95 case 'RepoNotFound':
96 return null
97 default:
98 throw result.data.error
99 }
100 }
101 } catch (err) {
102 throw err
103 }
104 }
105
106 /**
107 * Signs the user up for backups with the service
108 * @param onStatus
109 * @returns {Promise<void>}
110 */
111 async signUp(onStatus = null) {
112 if (!this.atCuteClient || !this.atCuteCredentialManager) {
113 throw new Error('Not signed in')
114 }
115 if (onStatus) onStatus('Creating backup registration…')
116 await ok(
117 this.atCuteClient.post('com.pdsmoover.backup.signUp', {
118 as: null,
119 }),
120 )
121 if (onStatus) onStatus('Backup registration complete')
122 //No return if successful
123 }
124
125 /**
126 * Requests a PLC token to be sent to the user's email, needed to add a new rotation key
127 * @returns {Promise<void>}
128 */
129 async requestAPlcToken() {
130 if (!this.atCuteClient || !this.atCuteCredentialManager) {
131 throw new Error('Not signed in')
132 }
133 const rpc = new Client({
134 handler: this.atCuteCredentialManager,
135 })
136
137 let response = await rpc.post('com.atproto.identity.requestPlcOperationSignature', {
138 as: null,
139 })
140 if (!response.ok) {
141 throw new Error(response.data?.message || 'Failed to request PLC token')
142 }
143 }
144
145 /**
146 * Adds a new rotation to the users did document. Assumes you are already signed in.
147 *
148 * WARNING: This will overwrite any existing rotation keys with the new one at the top, and the PDS key as the second one
149 * @param plcToken {string} - PLC token from the user's email that was sent from requestAPlcToken
150 * @param rotationKey {string} - The new rotation key to add to the user's did document
151 * @returns {Promise<void>}
152 */
153 async addANewRotationKey(plcToken, rotationKey) {
154 if (!this.atCuteClient || !this.atCuteCredentialManager) {
155 throw new Error('Not signed in')
156 }
157
158 const rpc = new Client({
159 handler: this.atCuteCredentialManager,
160 })
161
162 let getDidCredentials = await rpc.get('com.atproto.identity.getRecommendedDidCredentials')
163
164 if (getDidCredentials.ok) {
165 const pdsProvidedRotationKeys = getDidCredentials.data.rotationKeys ?? []
166 const updatedRotationKeys = [rotationKey, ...pdsProvidedRotationKeys]
167
168 const credentials = {
169 ...getDidCredentials.data,
170 rotationKeys: updatedRotationKeys,
171 }
172
173 const signDocRes = await rpc.post('com.atproto.identity.signPlcOperation', {
174 input: {
175 token: plcToken,
176 ...credentials,
177 },
178 })
179
180 if (signDocRes.ok) {
181 const submitDocRes = await rpc.post('com.atproto.identity.submitPlcOperation', {
182 input: signDocRes.data,
183 as: null,
184 })
185
186 if (!submitDocRes.ok) {
187 throw new Error(submitDocRes.data?.message || 'Failed to submit PLC operation')
188 }
189 } else {
190 throw new Error(signDocRes.data?.message || 'Failed to sign PLC operation')
191 }
192 } else {
193 throw new Error(getDidCredentials.data?.message || 'Failed to get status')
194 }
195 }
196
197 /**
198 *
199 * Gets the current status of the user's backup repository.
200 *
201 * @param onStatus {function|null} - a function that takes a string used to update the UI.
202 * @returns {Promise<InferXRPCBodyOutput<ComPdsmooverBackupDescribeServer.mainSchema['output']>>}
203 */
204 async getUsersRepoStatus(onStatus = null) {
205 if (!this.atCuteClient || !this.atCuteCredentialManager) {
206 throw new Error('Not signed in')
207 }
208 if (onStatus) onStatus('Refreshing backup status…')
209 const result = await this.atCuteClient.get('com.pdsmoover.backup.getRepoStatus', {
210 params: { did: this.atCuteCredentialManager.session.did.toString() },
211 })
212 if (result.ok) {
213 return result.data
214 } else {
215 throw new Error(result.data?.message || 'Failed to get status')
216 }
217 }
218
219 /**
220 * Requests a backup to be run immediately for the signed-in user. Usually does, depend on the server's backup queue
221 * @param onStatus
222 * @returns {Promise<boolean>}
223 */
224 async runBackupNow(onStatus = null) {
225 if (!this.atCuteClient || !this.atCuteCredentialManager) {
226 throw new Error('Not signed in')
227 }
228 if (onStatus) onStatus('Requesting backup…')
229 const res = await this.atCuteClient.post('com.pdsmoover.backup.requestBackup', {
230 as: null,
231 data: {},
232 })
233 if (res.ok) {
234 if (onStatus) onStatus('Backup requested.')
235 return true
236 } else {
237 const err = res.data
238 if (err?.error === 'Timeout') {
239 throw {
240 error: 'Timeout',
241 message: err?.message || 'Please wait a few minutes before requesting again.',
242 }
243 }
244 throw new Error(err?.message || 'Failed to request backup')
245 }
246 }
247
248 /**
249 * Remove (delete) the signed-in user's backup repository. this also deletes all the user's backup data.
250 * @param onStatus
251 * @returns {Promise<boolean>}
252 */
253 async removeRepo(onStatus = null) {
254 if (!this.atCuteClient || !this.atCuteCredentialManager) {
255 throw new Error('Not signed in')
256 }
257 if (onStatus) onStatus('Deleting backup repository…')
258 const res = await this.atCuteClient.post('com.pdsmoover.backup.removeRepo', {
259 as: null,
260 data: {},
261 })
262 if (res.ok) {
263 if (onStatus) onStatus('Backup repository deleted.')
264 return true
265 } else {
266 const err = res.data
267 throw new Error(err?.message || 'Failed to delete backup repository')
268 }
269 }
270}
271
272export { BackupService }