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.

Different PDS impl migration helper

+454 -434
+1 -1
justfile
··· 27 27 docker buildx build \ 28 28 --platform linux/arm64,linux/amd64 \ 29 29 --tag fatfingers23/moover_ui:latest \ 30 - --tag fatfingers23/moover_ui:0.0.11 \ 30 + --tag fatfingers23/moover_ui:0.0.12 \ 31 31 --file Dockerfiles/web-ui.Dockerfile \ 32 32 --builder desktop-linux \ 33 33 --push .
+434 -424
packages/moover/lib/pdsmoover.js
··· 1 - import { docResolver, cleanHandle, handleResolver, handleAndPDSResolver } from './atprotoUtils.js' 2 - import { AtpAgent } from '@atproto/api' 1 + import {docResolver, cleanHandle, handleResolver, handleAndPDSResolver} from './atprotoUtils.js' 2 + import {AtpAgent} from '@atproto/api' 3 3 4 4 function safeStatusUpdate(statusUpdateHandler, status) { 5 - if (statusUpdateHandler) { 6 - statusUpdateHandler(status) 7 - } 5 + if (statusUpdateHandler) { 6 + statusUpdateHandler(status) 7 + } 8 8 } 9 9 10 10 /** ··· 12 12 * On pdsmoover.com this is the logic for the MOOver 13 13 */ 14 14 class 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 - } 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 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 - ) 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 + } 66 70 } 67 - } catch (error) { 68 - console.error(error) 69 - } 70 71 } 71 - } 72 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 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 118 126 119 - oldAgent = new AtpAgent({ 120 - service: oldPds, 121 - }) 122 - } 127 + oldAgent = new AtpAgent({ 128 + service: oldPds, 129 + }) 130 + } 123 131 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 - } 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 + } 135 143 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() 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() 142 151 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 - } 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 + } 159 168 160 - if (needToCreateANewAccount) { 161 - const newHostWebDid = newHostDesc.data.did 169 + if (needToCreateANewAccount) { 170 + const newHostWebDid = newHostDesc.data.did 162 171 163 - safeStatusUpdate(statusUpdateHandler, 'Creating a new account on the new PDS') 172 + safeStatusUpdate(statusUpdateHandler, 'Creating a new account on the new PDS') 164 173 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 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 170 179 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 - ) 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 + ) 191 200 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 - } 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 + } 205 216 } 206 - } 207 - } 208 217 209 - safeStatusUpdate(statusUpdateHandler, 'Logging in with the new account') 218 + safeStatusUpdate(statusUpdateHandler, 'Logging in with the new account') 210 219 211 - await newAgent.login({ 212 - identifier: usersDid, 213 - password: password, 214 - }) 220 + await newAgent.login({ 221 + identifier: usersDid, 222 + password: password, 223 + }) 215 224 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 + 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 + } 225 234 226 - let newAccountStatus = await newAgent.com.atproto.server.checkAccountStatus() 227 - 228 - if (this.migrateBlobs) { 229 - safeStatusUpdate(statusUpdateHandler, 'Migrating your blobs') 235 + let newAccountStatus = await newAgent.com.atproto.server.checkAccountStatus() 230 236 231 - let blobCursor = undefined 232 - let uploadedBlobs = 0 233 - do { 234 - safeStatusUpdate( 235 - statusUpdateHandler, 236 - `Migrating blobs: ${uploadedBlobs}/${newAccountStatus.data.expectedBlobs}`, 237 - ) 237 + if (this.migrateBlobs) { 238 + safeStatusUpdate(statusUpdateHandler, 'Migrating your blobs') 238 239 239 - const listedBlobs = await oldAgent.com.atproto.sync.listBlobs({ 240 - did: usersDid, 241 - cursor: blobCursor, 242 - limit: 100, 243 - }) 240 + let blobCursor = undefined 241 + let uploadedBlobs = 0 242 + do { 243 + safeStatusUpdate( 244 + statusUpdateHandler, 245 + `Migrating blobs: ${uploadedBlobs}/${newAccountStatus.data.expectedBlobs}`, 246 + ) 244 247 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 - } 248 + const listedBlobs = await oldAgent.com.atproto.sync.listBlobs({ 249 + did: usersDid, 250 + cursor: blobCursor, 251 + limit: 100, 252 + }) 258 253 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 - ) 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 + } 276 267 277 - const missingBlobs = await newAgent.com.atproto.repo.listMissingBlobs({ 278 - cursor: missingBlobCursor, 279 - limit: 100, 280 - }) 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 + ) 281 285 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 - ) 286 + const missingBlobs = await newAgent.com.atproto.repo.listMissingBlobs({ 287 + cursor: missingBlobCursor, 288 + limit: 100, 289 + }) 291 290 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 - } 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 + ) 302 300 303 - this.oldAgent = oldAgent 304 - this.newAgent = newAgent 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 + } 305 311 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 - } 312 + this.oldAgent = oldAgent 313 + this.newAgent = newAgent 314 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, 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 + } 333 322 } 334 323 335 - const plcOp = await this.oldAgent.com.atproto.identity.signPlcOperation({ 336 - token: token, 337 - ...credentials, 338 - }) 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 + } 339 343 340 - await this.newAgent.com.atproto.identity.submitPlcOperation({ 341 - operation: plcOp.data.operation, 342 - }) 344 + const plcOp = await this.oldAgent.com.atproto.identity.signPlcOperation({ 345 + token: token, 346 + ...credentials, 347 + }) 343 348 344 - await this.newAgent.com.atproto.server.activateAccount() 345 - await this.oldAgent.com.atproto.server.deactivateAccount({}) 346 - } 349 + await this.newAgent.com.atproto.identity.submitPlcOperation({ 350 + operation: plcOp.data.operation, 351 + }) 347 352 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) 353 + await this.newAgent.com.atproto.server.activateAccount() 354 + await this.oldAgent.com.atproto.server.deactivateAccount({}) 382 355 } 383 356 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 - } 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 + } 393 392 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 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.') 403 401 } 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 402 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 - } 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 + } 429 421 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.') 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') 433 446 } 434 - safeStatusUpdate(statusUpdateHandler, 'Deactivating your OLD account') 435 - await oldAgent.com.atproto.server.deactivateAccount({}) 436 - safeStatusUpdate(statusUpdateHandler, 'Successfully deactivated your OLD account') 437 - } 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` 438 458 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` 459 + const accessJwt = this.newAgent?.session?.accessJwt 460 + if (!accessJwt) { 461 + throw new Error('Missing access token for authorization') 462 + } 449 463 450 - const accessJwt = this.newAgent?.session?.accessJwt 451 - if (!accessJwt) { 452 - throw new Error('Missing access token for authorization') 453 - } 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 + }) 454 474 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 - }) 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 + } 465 485 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 - ) 486 + //No return the success is all that is needed, if there's an error it will throw 474 487 } 475 - 476 - //No return the success is all that is needed, if there's an error it will throw 477 - } 478 488 } 479 489 480 - export { Migrator } 490 + export {Migrator}
+1 -1
packages/moover/package.json
··· 1 1 { 2 2 "name": "@pds-moover/moover", 3 - "version": "1.0.9", 3 + "version": "1.0.10", 4 4 "description": "Utilities for ATProto PDS migrations and recovery", 5 5 "repository": { 6 6 "type": "git",
+2 -1
packages/moover/types/pdsmoover.d.ts
··· 50 50 * @param {function|null} statusUpdateHandler - a function that takes a string used to update the UI. Like (status) => console.log(status) 51 51 * @param {string|null} twoFactorCode - Optional, but needed if it fails with 2fa required 52 52 * @param verificationCode - Optional verification captcha code for account creation if the PDS requires it 53 + * @param {string|null} sourcePdsUrl - Optional URL to use as the source PDS instead of resolving from DID doc 53 54 */ 54 - migrate(oldHandle: string, password: string, newPdsUrl: string, newEmail: string, newHandle: string, inviteCode: string | null, statusUpdateHandler?: Function | null, twoFactorCode?: string | null, verificationCode?: any): Promise<void>; 55 + migrate(oldHandle: string, password: string, newPdsUrl: string, newEmail: string, newHandle: string, inviteCode: string | null, statusUpdateHandler?: Function | null, twoFactorCode?: string | null, verificationCode?: any, sourcePdsUrl?: string | null): Promise<void>; 55 56 /** 56 57 * Sign and submits the PLC operation to officially migrate the account 57 58 * @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
+1 -1
packages/moover/types/pdsmoover.d.ts.map
··· 1 - {"version":3,"file":"pdsmoover.d.ts","sourceRoot":"","sources":["../lib/pdsmoover.js"],"names":[],"mappings":"AASA;;;GAGG;AACH;IAEI,uBAAuB;IACvB,UADW,QAAQ,CACC;IACpB,uBAAuB;IACvB,UADW,QAAQ,CACC;IACpB,uBAAuB;IACvB,cADW,CAAC,MAAM,CAAC,CACG;IAEtB,sBAAsB;IACtB,kBADW,OAAO,CACU;IAC5B,sBAAsB;IACtB,aADW,OAAO,CACK;IACvB,sBAAsB;IACtB,cADW,OAAO,CACM;IACxB,sBAAsB;IACtB,qBADW,OAAO,CACa;IAC/B,sBAAsB;IACtB,cADW,OAAO,CACM;IACxB,sBAAsB;IACtB,kBADW,OAAO,CACU;IAC5B;;sBAEkB;IAClB,oBADQ,MAAM,CACa;IAG7B;;;;;;;;OAQG;IACH,sBAPW,QAAQ,YACR,QAAQ,YACR,MAAM,QACN,CAAC,MAAM,CAAC,cACR,MAAM,uBACN,WAAS,IAAI,iBAuBvB;IAED;;;;;;;;;;;;;;;OAeG;IACH,mBAVW,MAAM,YACN,MAAM,aACN,MAAM,YACN,MAAM,aACN,MAAM,cACN,MAAM,GAAC,IAAI,wBACX,WAAS,IAAI,kBACb,MAAM,GAAC,IAAI,yCAmOrB;IAED;;;;;OAKG;IACH,wBAJW,MAAM,gCACsB,MAAM,EAAE,GAClC,OAAO,CAAC,IAAI,CAAC,CA2BzB;IAED;;;;;;;;;;;OAWG;IACH,gCAPqB,MAAM,eACJ,MAAM,wBAClB,WAAS,IAAI,kBAEb,MAAM,GAAC,IAAI,GACT,OAAO,CAAC,IAAI,CAAC,CA+EzB;IAED;;;;;OAKG;IACH,uCAHW,MAAM,GACJ,OAAO,CAAC,IAAI,CAAC,CAkCzB;CACF;yBA5dwB,cAAc"} 1 + {"version":3,"file":"pdsmoover.d.ts","sourceRoot":"","sources":["../lib/pdsmoover.js"],"names":[],"mappings":"AASA;;;GAGG;AACH;IAEQ,uBAAuB;IACvB,UADW,QAAQ,CACC;IACpB,uBAAuB;IACvB,UADW,QAAQ,CACC;IACpB,uBAAuB;IACvB,cADW,CAAC,MAAM,CAAC,CACG;IAEtB,sBAAsB;IACtB,kBADW,OAAO,CACU;IAC5B,sBAAsB;IACtB,aADW,OAAO,CACK;IACvB,sBAAsB;IACtB,cADW,OAAO,CACM;IACxB,sBAAsB;IACtB,qBADW,OAAO,CACa;IAC/B,sBAAsB;IACtB,cADW,OAAO,CACM;IACxB,sBAAsB;IACtB,kBADW,OAAO,CACU;IAC5B;;sBAEkB;IAClB,oBADQ,MAAM,CACa;IAG/B;;;;;;;;OAQG;IACH,sBAPW,QAAQ,YACR,QAAQ,YACR,MAAM,QACN,CAAC,MAAM,CAAC,cACR,MAAM,uBACN,WAAS,IAAI,iBAuBvB;IAED;;;;;;;;;;;;;;;;OAgBG;IACH,mBAXW,MAAM,YACN,MAAM,aACN,MAAM,YACN,MAAM,aACN,MAAM,cACN,MAAM,GAAC,IAAI,wBACX,WAAS,IAAI,kBACb,MAAM,GAAC,IAAI,yCAEX,MAAM,GAAC,IAAI,iBA0OrB;IAED;;;;;OAKG;IACH,wBAJW,MAAM,gCACsB,MAAM,EAAE,GAClC,OAAO,CAAC,IAAI,CAAC,CA2BzB;IAED;;;;;;;;;;;OAWG;IACH,gCAPqB,MAAM,eACJ,MAAM,wBAClB,WAAS,IAAI,kBAEb,MAAM,GAAC,IAAI,GACT,OAAO,CAAC,IAAI,CAAC,CA+EzB;IAED;;;;;OAKG;IACH,uCAHW,MAAM,GACJ,OAAO,CAAC,IAAI,CAAC,CAmCzB;CACJ;yBAtesB,cAAc"}
+1 -1
web-ui/package.json
··· 17 17 "@atcute/client": "^4.0.5", 18 18 "@atcute/lexicons": "^1.2.2", 19 19 "@pds-moover/lexicons": "^1.0.1", 20 - "@pds-moover/moover": "^1.0.9" 20 + "@pds-moover/moover": "^1.0.10" 21 21 }, 22 22 "devDependencies": { 23 23 "@eslint/compat": "^1.4.0",
+5 -5
web-ui/pnpm-lock.yaml
··· 21 21 specifier: ^1.0.1 22 22 version: 1.0.1 23 23 '@pds-moover/moover': 24 - specifier: ^1.0.9 25 - version: 1.0.9(@atcute/identity@1.1.1)(vite@7.1.12(@types/node@22.19.0)) 24 + specifier: ^1.0.10 25 + version: 1.0.10(@atcute/identity@1.1.1)(vite@7.1.12(@types/node@22.19.0)) 26 26 devDependencies: 27 27 '@eslint/compat': 28 28 specifier: ^1.4.0 ··· 509 509 '@pds-moover/lexicons@1.0.1': 510 510 resolution: {integrity: sha512-fv5b/DtHM7FEo/JklyF9gdK0ainlb6mWjWrBe6cmSAeg9G/4O2jBlQUOqfOAICY9gOcrCpkOrk9PHgGw//JQ2A==} 511 511 512 - '@pds-moover/moover@1.0.9': 513 - resolution: {integrity: sha512-t3MF1tbXSBZApPlUdqdnxII7+xu/rkD0LSENKzMiPC6ihmPAa/I9oA/iyl4y9wLaLdZuIg8GSM48chALkECV0w==} 512 + '@pds-moover/moover@1.0.10': 513 + resolution: {integrity: sha512-Zkb3OfoX004G2bim3XQojiRVPFwhTqzA/njSVRBx6nAezXjgINnoPMhpNqqrpqOjq79LPil1t+LjUcH3uhTF5Q==} 514 514 515 515 '@polka/url@1.0.0-next.29': 516 516 resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} ··· 1833 1833 '@atproto/lexicon': 0.5.1 1834 1834 '@atproto/xrpc': 0.7.5 1835 1835 1836 - '@pds-moover/moover@1.0.9(@atcute/identity@1.1.1)(vite@7.1.12(@types/node@22.19.0))': 1836 + '@pds-moover/moover@1.0.10(@atcute/identity@1.1.1)(vite@7.1.12(@types/node@22.19.0))': 1837 1837 dependencies: 1838 1838 '@atcute/cbor': 2.3.2 1839 1839 '@atcute/client': 4.0.5
+9
web-ui/src/routes/moover/[[pds]]/+page.svelte
··· 87 87 migrateMissingBlobs: true, 88 88 migratePrefs: true, 89 89 migratePlcRecord: true, 90 + sourcePdsUrl: '', 90 91 }); 91 92 92 93 let migrator = $state(new Migrator()); ··· 198 199 updateStatusHandler, 199 200 formData.twoFactorCode, 200 201 formData.verificationCode, 202 + formData.sourcePdsUrl || null, 201 203 ); 202 204 if (migrator.migratePlcRecord) { 203 205 //I don't think disable submit is needed, but you never know. ··· 386 388 name="migratePlcRecord"> 387 389 Migrate PLC Record 388 390 </label> 391 + </div> 392 + <div class="form-group" style="margin-top: 10px;"> 393 + <label for="sourcePdsUrl">Developer option: Override the Source PDS URL</label> 394 + <input type="url" id="sourcePdsUrl" name="sourcePdsUrl" 395 + placeholder="http://localhost:3000" 396 + bind:value={formData.sourcePdsUrl}> 397 + 389 398 </div> 390 399 391 400 </div>