Client side atproto account migrator in your web browser, along with services for backups and adversarial migrations. pdsmoover.com
pds atproto migrations moo cow
at main 272 lines 8.4 kB view raw
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 }