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 { 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 }