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 * @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}