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

PDS auto fill link

Changed files
+214 -33
.tangled
images
web-ui
.tangled/images/network.webp

This is a binary file and will not be displayed.

+2 -2
README.md
··· 9 9 10 10 - Looking for the old pds moover for simple code to fork 11 11 check [here](https://tangled.org/@baileytownsend.dev/pds-moover/tree/803d8a70b7100c9e14df3402277441050e0f6194), if 12 - you'd like to see the newer front end check [here](./web/ui-code/src) 12 + you'd like to see the newer front end check [here](./web-ui) 13 13 - Want to run your own instance of PDS MOOver? [check this docker compose](./compose.selfhost.yml). It should have all 14 14 the 15 15 services in one easy `docker compose up`, just don't forget to create a `.env` from [.env.template](.env.template) ··· 43 43 ## Do you have a pretty picture to show how the network looks? 44 44 45 45 yes. Thanks to [Orual](https://bsky.app/profile/nonbinary.computer) 46 - ![](./web/public/PDSMOOver.excalidraw.png) 46 + ![](.tangled/images/network.webp)
+1 -1
justfile
··· 25 25 docker buildx build \ 26 26 --platform linux/arm64,linux/amd64 \ 27 27 --tag fatfingers23/moover_ui:latest \ 28 - --tag fatfingers23/moover_ui:0.0.2 \ 28 + --tag fatfingers23/moover_ui:0.0.3 \ 29 29 --file Dockerfiles/web-ui.Dockerfile \ 30 30 --push .
test.compose.yml

This is a binary file and will not be displayed.

+1
web-ui/package.json
··· 13 13 "lint": "eslint ." 14 14 }, 15 15 "dependencies": { 16 + "@atcute/atproto": "^3.1.9", 16 17 "@atcute/client": "^4.0.5", 17 18 "@atcute/lexicons": "^1.2.2", 18 19 "@pds-moover/lexicons": "^1.0.1",
+10
web-ui/pnpm-lock.yaml
··· 8 8 9 9 .: 10 10 dependencies: 11 + '@atcute/atproto': 12 + specifier: ^3.1.9 13 + version: 3.1.9 11 14 '@atcute/client': 12 15 specifier: ^4.0.5 13 16 version: 4.0.5 ··· 72 75 version: 4.52.5 73 76 74 77 packages: 78 + 79 + '@atcute/atproto@3.1.9': 80 + resolution: {integrity: sha512-DyWwHCTdR4hY2BPNbLXgVmm7lI+fceOwWbE4LXbGvbvVtSn+ejSVFaAv01Ra3kWDha0whsOmbJL8JP0QPpf1+w==} 75 81 76 82 '@atcute/cbor@2.2.7': 77 83 resolution: {integrity: sha512-/mwAF0gnokOphceZqFq3uzMGdd8sbw5y6bxF8CRutRkCCUcpjjpJc5fkLwhxyGgOveF3mZuHE6p7t/+IAqb7Aw==} ··· 1341 1347 resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} 1342 1348 1343 1349 snapshots: 1350 + 1351 + '@atcute/atproto@3.1.9': 1352 + dependencies: 1353 + '@atcute/lexicons': 1.2.2 1344 1354 1345 1355 '@atcute/cbor@2.2.7': 1346 1356 dependencies:
+8 -8
web-ui/src/app.html
··· 1 1 <!doctype html> 2 2 <html lang="en"> 3 - <head> 4 - <meta charset="utf-8" /> 5 - <meta name="viewport" content="width=device-width, initial-scale=1" /> 6 - %sveltekit.head% 7 - </head> 8 - <body data-sveltekit-preload-data="hover"> 9 - <div style="display: contents">%sveltekit.body%</div> 10 - </body> 3 + <head> 4 + <meta charset="utf-8"/> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"/> 6 + %sveltekit.head% 7 + </head> 8 + <body data-sveltekit-preload-data="hover"> 9 + <div style="display: contents">%sveltekit.body%</div> 10 + </body> 11 11 </html>
+33
web-ui/src/lib/assets/style.css
··· 89 89 box-sizing: border-box; 90 90 } 91 91 92 + /* Input group for handle with domain dropdown */ 93 + .input-group { 94 + display: flex; 95 + width: 100%; 96 + } 97 + 98 + .input-group input { 99 + flex: 1; 100 + border-top-right-radius: 0; 101 + border-bottom-right-radius: 0; 102 + border-right: none; 103 + } 104 + 105 + .input-group .domain-select { 106 + padding: 8px; 107 + border: 1px solid rgba(128, 128, 128, 0.5); 108 + border-top-left-radius: 0; 109 + border-bottom-left-radius: 0; 110 + border-top-right-radius: 4px; 111 + border-bottom-right-radius: 4px; 112 + background-color: #1a1a1a; 113 + color: rgba(255, 255, 255, 0.87); 114 + cursor: pointer; 115 + min-width: 120px; 116 + } 117 + 118 + @media (prefers-color-scheme: light) { 119 + .input-group .domain-select { 120 + background-color: #f9f9f9; 121 + color: #213547; 122 + } 123 + } 124 + 92 125 .cow-image { 93 126 height: 150px; 94 127 margin: 20px 0 8px 0;
+3 -2
web-ui/src/lib/components/NavBar.svelte
··· 15 15 ]; 16 16 </script> 17 17 18 - <header class="navbar" role="banner"> 18 + <header class="navbar"> 19 19 20 20 <div class="navbar-inner"> 21 21 <a class="brand" href={resolve('/')}>PDS MOOver</a> ··· 45 45 <nav id="primary-navigation" class="nav-links {open ? 'open' : ''}" aria-label="Primary" 46 46 > 47 47 {#each links as link (link.path)} 48 - <a href={resolve(link.path)} class={page.url.pathname === link.path ? 'active' : '' } onclick={() => open = false}>{link.text}</a> 48 + <a href={resolve(link.path)} class={page.url.pathname.startsWith(link.path) ? 'active' : '' } 49 + onclick={() => open = false}>{link.text}</a> 49 50 {/each} 50 51 </nav> 51 52 </div>
+122 -20
web-ui/src/routes/moover/+page.svelte web-ui/src/routes/moover/[[pds]]/+page.svelte
··· 5 5 import {Migrator} from '@pds-moover/moover'; 6 6 import SignThePapers from './SignThePapers.svelte'; 7 7 8 + let {data} = $props(); 9 + 10 + let selectedPds = $derived(data.pdsOptions); 11 + let cleanSelectedPds = $derived(selectedPds?.did.replace('did:web:', '')); 12 + //Kept as a "global" state to handle logic of passing the full handle that is used to SignThePapers 13 + let newHandle = $state(''); 14 + 15 + let selectedDomain = $state(data.intinalDomain); 16 + 17 + let handlePlaceHolder = $derived( 18 + selectedPds ? `username${selectedDomain === 'custom' ? '' : `${selectedPds?.availableUserDomains[0]}`} or mydomain.com` : 'username.newpds.com or mycooldomain.com') 19 + 20 + 21 + $effect(() => { 22 + if (!selectedPds) return; 23 + 24 + if (selectedDomain == 'custom') return; 25 + 26 + 27 + if (formData.newHandle.includes('.')) { 28 + // When a period is typed, force custom domain selection 29 + selectedDomain = 'custom'; 30 + } else { 31 + // If user clears the dot and we have provider domains, fall back to first option 32 + if ((selectedPds?.availableUserDomains?.length ?? 0) > 0 && selectedDomain === 'custom') { 33 + selectedDomain = selectedPds!.availableUserDomains[0]! 34 + } 35 + } 36 + }); 37 + 8 38 let formData = $state({ 9 39 handle: '', 10 40 password: '', ··· 14 44 inviteCode: null, 15 45 twoFactorCode: null, 16 46 confirmation: false, 47 + // Acceptance of provider policies (when required by selected PDS) 48 + acceptPolicies: false, 17 49 // Advanced options 18 50 createNewAccount: true, 19 51 migrateRepo: true, ··· 35 67 let errorMessage: null | string = $state(null); 36 68 let statusMessage: null | string = $state(null); 37 69 70 + // Links that may require acceptance prior to migration from the selected PDS 71 + const privacyUrl = $derived(selectedPds?.links?.privacyPolicy); 72 + const tosUrl = $derived(selectedPds?.links?.termsOfService); 73 + const requiresAccept = $derived(!!(privacyUrl || tosUrl)); 74 + 38 75 const updateStatusHandler = (status: string) => { 39 76 statusMessage = status; 40 77 } ··· 49 86 errorMessage = 'Please confirm that you understand the risks of doing an account migration'; 50 87 disableSubmit = false; 51 88 return; 89 + } 90 + 91 + // If the selected PDS provides policy or privacy links, require explicit acceptance 92 + if (requiresAccept && !formData.acceptPolicies) { 93 + errorMessage = 'Please review and accept the providers policies'; 94 + disableSubmit = false; 95 + return; 96 + } 97 + newHandle = formData.newHandle; 98 + if (selectedPds) { 99 + //Not happy about this unwrap, but it should always have a value on a legit PDS that I know of 100 + 101 + formData.newPds = `https://${cleanSelectedPds!}`; 102 + // Combine username and selected domain for the new handle 103 + if (selectedDomain !== 'custom') { 104 + newHandle = formData.newHandle + selectedDomain; 105 + } 52 106 } 53 107 54 108 try { ··· 69 123 migrator.migratePrefs = formData.migratePrefs; 70 124 migrator.migratePlcRecord = formData.migratePlcRecord; 71 125 72 - console.log(migrator); 126 + console.log(formData.newPds, newHandle); 73 127 74 128 updateStatusHandler('Starting migration...'); 75 129 showStatusMessage = true; ··· 78 132 formData.password, 79 133 formData.newPds, 80 134 formData.newEmail, 81 - formData.newHandle, 135 + newHandle, 82 136 formData.inviteCode, 83 137 updateStatusHandler, 84 138 formData.twoFactorCode, ··· 144 198 145 199 <!-- Second section: New account details --> 146 200 <div class="section"> 147 - <h2>Setup for the new PDS</h2> 148 - <div class="form-group"> 149 - <label for="new-pds">New PDS (URL):</label> 150 - <input type="url" id="new-pds" name="newPds" placeholder="https://coolnewpds.com" 151 - required bind:value={formData.newPds}> 152 - </div> 201 + <h2>{selectedPds ? `Setup for ${cleanSelectedPds}` : 'Setup for the new PDS'}</h2> 202 + {#if !selectedPds} 203 + <div class="form-group"> 204 + <label for="new-pds">New PDS (URL):</label> 205 + <input type="url" id="new-pds" name="newPds" placeholder="https://coolnewpds.com" 206 + required bind:value={formData.newPds}> 207 + </div> 208 + {/if} 153 209 154 210 <div class="form-group"> 155 211 <label for="new-email">New Email:</label> 156 - <input type="email" id="new-email" name="newEmail" placeholder="CanBeSameEmailAsTheOldPds@email.com" 212 + <input type="email" id="new-email" name="newEmail" 213 + placeholder="CanBeSameEmailAsTheOldPds@email.com" 157 214 required bind:value={formData.newEmail}> 158 215 </div> 216 + 159 217 160 218 <div class="form-group"> 161 219 <label for="new-handle">New Handle:</label> 162 - <input type="text" id="new-handle" name="newHandle" 163 - placeholder="username.newpds.com or mycooldomain.com" required 164 - bind:value={formData.newHandle}> 220 + <div class={selectedPds ? 'input-group' : ''}> 221 + <input type="text" id="new-handle" name="newHandle" 222 + placeholder="{handlePlaceHolder}" 223 + required 224 + bind:value={formData.newHandle}> 225 + {#if selectedPds} 226 + 227 + <select bind:value={selectedDomain} class="domain-select"> 228 + {#each selectedPds?.availableUserDomains as domain (domain)} 229 + <option value={domain}>{domain}</option> 230 + {/each} 231 + <option value="custom">I have my own domain setup</option> 232 + 233 + </select> 234 + {/if} 235 + </div> 165 236 </div> 166 237 167 - <div class="form-group"> 168 - <label for="invite-code">Invite Code:</label> 169 - <input type="text" id="invite-code" name="inviteCode" 170 - placeholder="Invite code from your new PDS (Leave blank if you don't have one)" 171 - bind:value={formData.inviteCode}> 172 - </div> 238 + {#if !selectedPds || selectedPds.inviteCodeRequired !== false} 239 + <div class="form-group"> 240 + <label for="invite-code">Invite Code:</label> 241 + <input type="text" id="invite-code" name="inviteCode" 242 + placeholder="Invite code from your new PDS (Leave blank if you don't have one)" 243 + bind:value={formData.inviteCode}> 244 + </div> 245 + {/if} 173 246 </div> 247 + 174 248 175 249 <div class="form-group"> 176 250 <button type="button" onclick={() => showAdvance = !showAdvance} id="advance" name="advance">Advance ··· 227 301 </div> 228 302 {/if} 229 303 304 + {#if requiresAccept} 305 + <div class="section" style="text-align: left"> 306 + <h3>Provider policies</h3> 307 + <p> 308 + To migrate to {cleanSelectedPds}, you must review and accept: 309 + </p> 310 + <ul> 311 + {#if privacyUrl} 312 + <li><a href={privacyUrl} target="_blank" rel="noopener noreferrer">Privacy 313 + Policy</a></li> 314 + {/if} 315 + {#if tosUrl} 316 + <li><a href={tosUrl} target="_blank" rel="noopener noreferrer">Terms of Service</a></li> 317 + {/if} 318 + </ul> 319 + <div class="form-group"> 320 + <label for="accept-policies" class="moove-checkbox-label"> 321 + <input bind:checked={formData.acceptPolicies} type="checkbox" id="accept-policies" 322 + name="acceptPolicies" required> 323 + <span> 324 + I have read and accept 325 + 326 + </span> 327 + </label> 328 + </div> 329 + </div> 330 + {/if} 230 331 <p style="text-align: left">There are some risks that come with doing an account migration. 231 332 (Can view them 232 333 <a href="https://github.com/bluesky-social/pds/blob/main/ACCOUNT_MIGRATION.md#%EF%B8%8F-warning-%EF%B8%8F-%EF%B8%8F">here</a>) ··· 257 358 {/if} 258 359 259 360 <div> 260 - <button disabled={disableSubmit} type="submit">MOOve</button> 361 + <button disabled={disableSubmit} 362 + type="submit">{selectedPds ? `MOOve to ${cleanSelectedPds}` : 'MOOve'}</button> 261 363 </div> 262 364 </form> 263 365 264 366 {:else} 265 - <SignThePapers migrator={migrator} newHandle={formData.newHandle}/> 367 + <SignThePapers migrator={migrator} newHandle={newHandle}/> 266 368 {/if} 267 369 </div>
web-ui/src/routes/moover/SignThePapers.svelte web-ui/src/routes/moover/[[pds]]/SignThePapers.svelte
+34
web-ui/src/routes/moover/[[pds]]/+page.server.ts
··· 1 + import type {PageServerLoad} from './$types'; 2 + import {Client, simpleFetchHandler} from '@atcute/client'; 3 + import type {} from '@atcute/atproto'; 4 + import {env} from '$env/dynamic/private'; 5 + 6 + export const load: PageServerLoad = async ({params}) => { 7 + 8 + if (!params.pds) { 9 + return {pdsOptions: null, intinalDomain: null}; 10 + } 11 + 12 + const allowedPds = env.PDS_AUTOFILL.split(','); 13 + if (!allowedPds.includes(params.pds.toLowerCase())) { 14 + console.error('PDS not allowed', params.pds); 15 + return {pdsOptions: null, intinalDomain: null}; 16 + } 17 + 18 + try { 19 + const handler = simpleFetchHandler({service: `https://${params.pds}`}); 20 + const rpc = new Client({handler}); 21 + const {ok, data} = await rpc.get('com.atproto.server.describeServer', {}) 22 + if (!ok) { 23 + console.error('Failed to describe the PDS server', data); 24 + return {pds: null}; 25 + } 26 + return { 27 + pdsOptions: data, 28 + intinalDomain: data?.availableUserDomains[0] ?? '' 29 + }; 30 + } catch (e) { 31 + console.error('Failed to describe the PDS server', e); 32 + return {pdsOptions: null, intinalDomain: null}; 33 + } 34 + };