Client side atproto account migrator in your web browser, along with services for backups and adversarial migrations. pdsmoover.com
pds atproto migrations moo cow
128
fork

Configure Feed

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

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