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