Client side atproto account migrator in your web browser, along with services for backups and adversarial migrations.
0

Configure Feed

Select the types of activity you want to include in your feed.

pds-moover / packages / moover / lib / pdsmoover.js
17 kB 444 lines
1import { docResolver, cleanHandle, handleResolver } from './atprotoUtils.js' 2import { AtpAgent } from '@atproto/api' 3 4function safeStatusUpdate(statusUpdateHandler, status) { 5 if (statusUpdateHandler) { 6 statusUpdateHandler(status) 7 } 8} 9 10/** 11 * Handles normal PDS Migrations between two PDSs that are both up. 12 * On pdsmoover.com this is the logic for the MOOver 13 */ 14class Migrator { 15 constructor() { 16 /** @type {AtpAgent} */ 17 this.oldAgent = null 18 /** @type {AtpAgent} */ 19 this.newAgent = null 20 /** @type {[string]} */ 21 this.missingBlobs = [] 22 //State for reruns 23 /** @type {boolean} */ 24 this.createNewAccount = true 25 /** @type {boolean} */ 26 this.migrateRepo = true 27 /** @type {boolean} */ 28 this.migrateBlobs = true 29 /** @type {boolean} */ 30 this.migrateMissingBlobs = true 31 /** @type {boolean} */ 32 this.migratePrefs = true 33 /** @type {boolean} */ 34 this.migratePlcRecord = true 35 } 36 37 /** 38 * This migrator is pretty cut and dry and makes a few assumptions 39 * 1. You are using the same password between each account 40 * 2. If this command fails for something like oauth 2fa code it throws an error and expects the same values when ran again. 41 * 3. You can control which "actions" happen by setting the class variables to false. 42 * 4. Each instance of the class is assumed to be for a single migration 43 * @param {string} oldHandle - The handle you use on your old pds, something like alice.bsky.social 44 * @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 45 * @param {string} newPdsUrl - The new URL for your pds. Like https://coolnewpds.com 46 * @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) 47 * @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. 48 * @param {string|null} inviteCode - The invite code you got from the PDS you are migrating to. If null does not include one 49 * @param {function|null} statusUpdateHandler - a function that takes a string used to update the UI. Like (status) => console.log(status) 50 * @param {string|null} twoFactorCode - Optional, but needed if it fails with 2fa required 51 * @param verificationCode - Optional verification captcha code for account creation if the PDS requires it 52 */ 53 async migrate( 54 oldHandle, 55 password, 56 newPdsUrl, 57 newEmail, 58 newHandle, 59 inviteCode, 60 statusUpdateHandler = null, 61 twoFactorCode = null, 62 verificationCode = null, 63 ) { 64 //Leaving this logic that either sets the agent to bsky.social, or the PDS since it's what I found worked best for migrations. 65 // handleAndPDSResolver should be able to handle it, but there have been edge cases and this was what worked best 66 oldHandle = cleanHandle(oldHandle) 67 let oldAgent 68 let usersDid 69 //If it's a bsky handle just go with the entryway and let it sort everything 70 if (oldHandle.endsWith('.bsky.social')) { 71 oldAgent = new AtpAgent({ service: 'https://bsky.social' }) 72 const publicAgent = new AtpAgent({ 73 service: 'https://public.api.bsky.app', 74 }) 75 const resolveIdentityFromEntryway = await publicAgent.com.atproto.identity.resolveHandle({ 76 handle: oldHandle, 77 }) 78 usersDid = resolveIdentityFromEntryway.data.did 79 } else { 80 //Resolves the did and finds the did document for the old PDS 81 safeStatusUpdate(statusUpdateHandler, 'Resolving old PDS') 82 usersDid = await handleResolver.resolve(oldHandle) 83 const didDoc = await docResolver.resolve(usersDid) 84 safeStatusUpdate( 85 statusUpdateHandler, 86 'Resolving did document and finding your current PDS URL', 87 ) 88 89 let oldPds 90 try { 91 oldPds = didDoc.service.filter(s => s.type === 'AtprotoPersonalDataServer')[0] 92 .serviceEndpoint 93 } catch (error) { 94 console.error(error) 95 throw new Error('Could not find a PDS in the DID document.') 96 } 97 98 oldAgent = new AtpAgent({ 99 service: oldPds, 100 }) 101 } 102 103 safeStatusUpdate(statusUpdateHandler, 'Logging you in to the old PDS') 104 //Login to the old PDS 105 if (twoFactorCode === null) { 106 await oldAgent.login({ identifier: oldHandle, password }) 107 } else { 108 await oldAgent.login({ 109 identifier: oldHandle, 110 password: password, 111 authFactorToken: twoFactorCode, 112 }) 113 } 114 115 safeStatusUpdate( 116 statusUpdateHandler, 117 'Checking that the new PDS is an actual PDS (if the url is wrong this takes a while to error out)', 118 ) 119 const newAgent = new AtpAgent({ service: newPdsUrl }) 120 const newHostDesc = await newAgent.com.atproto.server.describeServer() 121 if (this.createNewAccount) { 122 const newHostWebDid = newHostDesc.data.did 123 124 safeStatusUpdate(statusUpdateHandler, 'Creating a new account on the new PDS') 125 126 const createAuthResp = await oldAgent.com.atproto.server.getServiceAuth({ 127 aud: newHostWebDid, 128 lxm: 'com.atproto.server.createAccount', 129 }) 130 const serviceJwt = createAuthResp.data.token 131 132 let createAccountRequest = { 133 did: usersDid, 134 handle: newHandle, 135 email: newEmail, 136 password: password, 137 } 138 if (inviteCode) { 139 createAccountRequest.inviteCode = inviteCode 140 } 141 if (verificationCode) { 142 createAccountRequest.verificationCode = verificationCode 143 } 144 const createNewAccount = await newAgent.com.atproto.server.createAccount( 145 createAccountRequest, 146 { 147 headers: { authorization: `Bearer ${serviceJwt}` }, 148 encoding: 'application/json', 149 }, 150 ) 151 152 if (createNewAccount.data.did !== usersDid.toString()) { 153 throw new Error('Did not create the new account with the same did as the old account') 154 } 155 } 156 safeStatusUpdate(statusUpdateHandler, 'Logging in with the new account') 157 158 await newAgent.login({ 159 identifier: usersDid, 160 password: password, 161 }) 162 163 if (this.migrateRepo) { 164 safeStatusUpdate(statusUpdateHandler, 'Migrating your repo') 165 const repoRes = await oldAgent.com.atproto.sync.getRepo({ 166 did: usersDid, 167 }) 168 await newAgent.com.atproto.repo.importRepo(repoRes.data, { 169 encoding: 'application/vnd.ipld.car', 170 }) 171 } 172 173 let newAccountStatus = await newAgent.com.atproto.server.checkAccountStatus() 174 175 if (this.migrateBlobs) { 176 safeStatusUpdate(statusUpdateHandler, 'Migrating your blobs') 177 178 let blobCursor = undefined 179 let uploadedBlobs = 0 180 do { 181 safeStatusUpdate( 182 statusUpdateHandler, 183 `Migrating blobs: ${uploadedBlobs}/${newAccountStatus.data.expectedBlobs}`, 184 ) 185 186 const listedBlobs = await oldAgent.com.atproto.sync.listBlobs({ 187 did: usersDid, 188 cursor: blobCursor, 189 limit: 100, 190 }) 191 192 for (const cid of listedBlobs.data.cids) { 193 try { 194 const blobRes = await oldAgent.com.atproto.sync.getBlob({ 195 did: usersDid, 196 cid, 197 }) 198 await newAgent.com.atproto.repo.uploadBlob(blobRes.data, { 199 encoding: blobRes.headers['content-type'], 200 }) 201 uploadedBlobs++ 202 if (uploadedBlobs % 10 === 0) { 203 safeStatusUpdate( 204 statusUpdateHandler, 205 `Migrating blobs: ${uploadedBlobs}/${newAccountStatus.data.expectedBlobs}`, 206 ) 207 } 208 } catch (error) { 209 console.error(error) 210 } 211 } 212 blobCursor = listedBlobs.data.cursor 213 } while (blobCursor) 214 } 215 216 if (this.migrateMissingBlobs) { 217 newAccountStatus = await newAgent.com.atproto.server.checkAccountStatus() 218 if (newAccountStatus.data.expectedBlobs !== newAccountStatus.data.importedBlobs) { 219 let totalMissingBlobs = 220 newAccountStatus.data.expectedBlobs - newAccountStatus.data.importedBlobs 221 safeStatusUpdate( 222 statusUpdateHandler, 223 'Looks like there are some missing blobs. Going to try and upload them now.', 224 ) 225 //Probably should be shared between main blob uploader, but eh 226 let missingBlobCursor = undefined 227 let missingUploadedBlobs = 0 228 do { 229 safeStatusUpdate( 230 statusUpdateHandler, 231 `Migrating blobs: ${missingUploadedBlobs}/${totalMissingBlobs}`, 232 ) 233 234 const missingBlobs = await newAgent.com.atproto.repo.listMissingBlobs({ 235 cursor: missingBlobCursor, 236 limit: 100, 237 }) 238 239 for (const recordBlob of missingBlobs.data.blobs) { 240 try { 241 const blobRes = await oldAgent.com.atproto.sync.getBlob({ 242 did: usersDid, 243 cid: recordBlob.cid, 244 }) 245 await newAgent.com.atproto.repo.uploadBlob(blobRes.data, { 246 encoding: blobRes.headers['content-type'], 247 }) 248 if (missingUploadedBlobs % 10 === 0) { 249 safeStatusUpdate( 250 statusUpdateHandler, 251 `Migrating blobs: ${missingUploadedBlobs}/${totalMissingBlobs}`, 252 ) 253 } 254 missingUploadedBlobs++ 255 } catch (error) { 256 //TODO silently logging prob should list them so user can manually download 257 console.error(error) 258 this.missingBlobs.push(recordBlob.cid) 259 } 260 } 261 missingBlobCursor = missingBlobs.data.cursor 262 } while (missingBlobCursor) 263 } 264 } 265 if (this.migratePrefs) { 266 const prefs = await oldAgent.app.bsky.actor.getPreferences() 267 await newAgent.app.bsky.actor.putPreferences(prefs.data) 268 } 269 270 this.oldAgent = oldAgent 271 this.newAgent = newAgent 272 273 if (this.migratePlcRecord) { 274 await oldAgent.com.atproto.identity.requestPlcOperationSignature() 275 safeStatusUpdate(statusUpdateHandler, 'Please check your email for a PLC token') 276 } 277 } 278 279 /** 280 * Sign and submits the PLC operation to officially migrate the account 281 * @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 282 * @param additionalRotationKeysToAdd {string[]} - additional rotation keys to add in addition to the ones provided by the new PDS. 283 * @returns {Promise<void>} 284 */ 285 async signPlcOperation(token, additionalRotationKeysToAdd = []) { 286 const getDidCredentials = 287 await this.newAgent.com.atproto.identity.getRecommendedDidCredentials() 288 const pdsProvidedRotationKeys = getDidCredentials.data.rotationKeys ?? [] 289 // Prepend any additional rotation keys (e.g., user-added keys, newly created key) so they appear above the new PDS rotation key 290 const rotationKeys = [...(additionalRotationKeysToAdd || []), ...pdsProvidedRotationKeys] 291 if (!rotationKeys) { 292 throw new Error('No rotation key provided from the new PDS') 293 } 294 const credentials = { 295 ...getDidCredentials.data, 296 rotationKeys: rotationKeys, 297 } 298 299 const plcOp = await this.oldAgent.com.atproto.identity.signPlcOperation({ 300 token: token, 301 ...credentials, 302 }) 303 304 await this.newAgent.com.atproto.identity.submitPlcOperation({ 305 operation: plcOp.data.operation, 306 }) 307 308 await this.newAgent.com.atproto.server.activateAccount() 309 await this.oldAgent.com.atproto.server.deactivateAccount({}) 310 } 311 312 /** 313 * Using this method assumes the Migrator class was constructed new and this was called. 314 * Find the user's previous PDS from the PLC op logs, 315 * logs in and deactivates their old account if it was found still active. 316 * 317 * @param oldHandle {string} 318 * @param oldPassword {string} 319 * @param {function|null} statusUpdateHandler - a function that takes a string used to update the UI. 320 * Like (status) => console.log(status) 321 * @param {string|null} twoFactorCode - Optional, but needed if it fails with 2fa required 322 * @returns {Promise<void>} 323 */ 324 async deactivateOldAccount( 325 oldHandle, 326 oldPassword, 327 statusUpdateHandler = null, 328 twoFactorCode = null, 329 ) { 330 //Leaving this logic that either sets the agent to bsky.social, or the PDS since it's what I found worked best for migrations. 331 // handleAndPDSResolver should be able to handle it, but there have been edge cases and this was what worked best oldHandle = cleanHandle(oldHandle); 332 let usersDid 333 //If it's a bsky handle just go with the entryway and let it sort everything 334 if (oldHandle.endsWith('.bsky.social')) { 335 const publicAgent = new AtpAgent({ 336 service: 'https://public.api.bsky.app', 337 }) 338 const resolveIdentityFromEntryway = await publicAgent.com.atproto.identity.resolveHandle({ 339 handle: oldHandle, 340 }) 341 usersDid = resolveIdentityFromEntryway.data.did 342 } else { 343 //Resolves the did and finds the did document for the old PDS 344 safeStatusUpdate(statusUpdateHandler, 'Resolving did from handle') 345 usersDid = await handleResolver.resolve(oldHandle) 346 } 347 348 const didDoc = await docResolver.resolve(usersDid) 349 let currentPds 350 try { 351 currentPds = didDoc.service.filter(s => s.type === 'AtprotoPersonalDataServer')[0] 352 .serviceEndpoint 353 } catch (error) { 354 console.error(error) 355 throw new Error('Could not find a PDS in the DID document.') 356 } 357 358 const plcLogRequest = await fetch(`https://plc.directory/${usersDid}/log`) 359 const plcLog = await plcLogRequest.json() 360 let pdsBeforeCurrent = '' 361 for (const log of plcLog) { 362 try { 363 const pds = log.services.atproto_pds.endpoint 364 if (pds.toLowerCase() === currentPds.toLowerCase()) { 365 console.log('Found the PDS before the current one') 366 break 367 } 368 pdsBeforeCurrent = pds 369 } catch (e) { 370 console.log(e) 371 } 372 } 373 if (pdsBeforeCurrent === '') { 374 throw new Error('Could not find the PDS before the current one') 375 } 376 377 let oldAgent = new AtpAgent({ service: pdsBeforeCurrent }) 378 safeStatusUpdate(statusUpdateHandler, `Logging you in to the old PDS: ${pdsBeforeCurrent}`) 379 //Login to the old PDS 380 if (twoFactorCode === null) { 381 await oldAgent.login({ identifier: oldHandle, password: oldPassword }) 382 } else { 383 await oldAgent.login({ 384 identifier: oldHandle, 385 password: oldPassword, 386 authFactorToken: twoFactorCode, 387 }) 388 } 389 safeStatusUpdate(statusUpdateHandler, "Checking this isn't your current PDS") 390 if (pdsBeforeCurrent === currentPds) { 391 throw new Error('This is your current PDS. Login to your old account username and password') 392 } 393 394 let currentAccountStatus = await oldAgent.com.atproto.server.checkAccountStatus() 395 if (!currentAccountStatus.data.activated) { 396 safeStatusUpdate(statusUpdateHandler, 'All good. Your old account is not activated.') 397 } 398 safeStatusUpdate(statusUpdateHandler, 'Deactivating your OLD account') 399 await oldAgent.com.atproto.server.deactivateAccount({}) 400 safeStatusUpdate(statusUpdateHandler, 'Successfully deactivated your OLD account') 401 } 402 403 /** 404 * Signs the logged-in user in this.newAgent for backups with PDS MOOver. This is usually called after migrate and signPlcOperation are successful 405 * 406 * @param {string} didWeb 407 * @returns {Promise<void>} 408 */ 409 async signUpForBackupsFromMigration(didWeb = 'did:web:pdsmoover.com') { 410 //Manually grabbing the jwt and making a call with fetch cause for the life of me I could not figure out 411 //how you used @atproto/api to make a call for proxying 412 const url = `${this.newAgent.serviceUrl.origin}/xrpc/com.pdsmoover.backup.signUp` 413 414 const accessJwt = this.newAgent?.session?.accessJwt 415 if (!accessJwt) { 416 throw new Error('Missing access token for authorization') 417 } 418 419 const res = await fetch(url, { 420 method: 'POST', 421 headers: { 422 'Authorization': `Bearer ${accessJwt}`, 423 'Content-Type': 'application/json', 424 'Accept': 'application/json', 425 'atproto-proxy': `${didWeb}#repo_backup`, 426 }, 427 body: JSON.stringify({}), 428 }) 429 430 if (!res.ok) { 431 let bodyText = '' 432 try { 433 bodyText = await res.text() 434 } catch {} 435 throw new Error( 436 `Backup signup failed: ${res.status} ${res.statusText}${bodyText ? ` - ${bodyText}` : ''}`, 437 ) 438 } 439 440 //No return the success is all that is needed, if there's an error it will throw 441 } 442} 443 444export { Migrator }