handy online tools for AT Protocol boat.kelinci.net
atproto bluesky atcute typescript solidjs

feat: more work into migration

mary.my.id a463ad3c c7e2b3c9

verified
Changed files
+178 -75
src
views
account
account-migrate
+17 -16
src/views/account/account-migrate/sections/blobs.tsx
··· 172 172 try { 173 173 const data = await entry.bytes(); 174 174 await destClient.post('com.atproto.repo.uploadBlob', { 175 - encoding: 'application/octet-stream', 176 175 input: data, 176 + headers: { 177 + 'content-type': 'application/octet-stream', 178 + }, 177 179 }); 178 180 uploaded++; 179 181 } catch (err) { ··· 228 230 const contentType = response.headers.get('content-type') || 'application/octet-stream'; 229 231 230 232 await destClient.post('com.atproto.repo.uploadBlob', { 231 - encoding: contentType, 232 233 input: response.data, 234 + headers: { 235 + 'content-type': contentType, 236 + }, 233 237 }); 234 238 235 239 uploaded++; ··· 297 301 const sourceData = importFromSourceMutation.data; 298 302 299 303 if (fileData && !fileData.cancelled) { 300 - return `Uploaded ${fileData.uploaded} blobs` + (fileData.failed > 0 ? ` (${fileData.failed} failed)` : ''); 304 + return ( 305 + `Uploaded ${fileData.uploaded} blobs` + (fileData.failed > 0 ? ` (${fileData.failed} failed)` : '') 306 + ); 301 307 } 302 308 if (sourceData) { 303 309 if (sourceData.uploaded === 0 && sourceData.failed === 0) return 'No missing blobs'; 304 - return `Uploaded ${sourceData.uploaded} blobs` + (sourceData.failed > 0 ? ` (${sourceData.failed} failed)` : ''); 310 + return ( 311 + `Uploaded ${sourceData.uploaded} blobs` + 312 + (sourceData.failed > 0 ? ` (${sourceData.failed} failed)` : '') 313 + ); 305 314 } 306 315 return importProgress(); 307 316 }; ··· 311 320 return ( 312 321 <Accordion title="Blobs"> 313 322 <Subsection title="Export from source"> 314 - <p class="text-sm text-gray-600"> 315 - Download all blobs as a tarball for backup or manual import. 316 - </p> 323 + <p class="text-sm text-gray-600">Download all blobs as a tarball for backup or manual import.</p> 317 324 318 325 <Show when={source()} fallback={<p class="text-sm text-gray-500">Resolve source account first.</p>}> 319 326 {(src) => ( ··· 339 346 </Subsection> 340 347 341 348 <Subsection title="Import to destination"> 342 - <p class="text-sm text-gray-600"> 343 - Upload blobs from a tarball or transfer directly from source. 344 - </p> 349 + <p class="text-sm text-gray-600">Upload blobs from a tarball or transfer directly from source.</p> 345 350 346 351 <Show 347 352 when={destination()?.manager} ··· 376 381 {(text) => <span class="text-sm text-gray-600">{text()}</span>} 377 382 </Show> 378 383 379 - <Show when={getImportError()}> 380 - {(err) => <p class="text-sm text-red-600">{`${err()}`}</p>} 381 - </Show> 384 + <Show when={getImportError()}>{(err) => <p class="text-sm text-red-600">{`${err()}`}</p>}</Show> 382 385 </> 383 386 )} 384 387 </Show> ··· 403 406 <Show when={checkStatusMutation.data}> 404 407 {(status) => ( 405 408 <span class="text-sm"> 406 - <StatusBadge 407 - variant={status().imported === status().expected ? 'success' : 'pending'} 408 - > 409 + <StatusBadge variant={status().imported === status().expected ? 'success' : 'pending'}> 409 410 {status().imported}/{status().expected} blobs 410 411 </StatusBadge> 411 412 </span>
+152 -50
src/views/account/account-migrate/sections/identity.tsx
··· 2 2 3 3 import { Client, ClientResponseError, type CredentialManager, ok } from '@atcute/client'; 4 4 import { type DidKeyString, Secp256k1PrivateKeyExportable } from '@atcute/crypto'; 5 + import type { Did } from '@atcute/lexicons/syntax'; 5 6 7 + import { getPlcAuditLogs } from '~/api/queries/plc'; 6 8 import { formatTotpCode, TOTP_RE } from '~/api/utils/auth'; 7 9 8 10 import { createMutation } from '~/lib/utils/mutation'; ··· 11 13 import Button from '~/components/inputs/button'; 12 14 import TextInput from '~/components/inputs/text-input'; 13 15 import ToggleInput from '~/components/inputs/toggle-input'; 16 + 17 + import { getPlcPayload } from '~/views/identity/plc-applicator/plc-utils'; 14 18 15 19 import { useMigration } from '../context'; 16 20 ··· 48 52 const loadCredentialsMutation = createMutation({ 49 53 async mutationFn({ manager }: { manager: CredentialManager }) { 50 54 const client = new Client({ handler: manager }); 51 - return (await ok(client.get('com.atproto.identity.getRecommendedDidCredentials', {}))) as RecommendedCredentials; 55 + return (await ok( 56 + client.get('com.atproto.identity.getRecommendedDidCredentials', {}), 57 + )) as RecommendedCredentials; 58 + }, 59 + onError(err) { 60 + console.error(err); 61 + }, 62 + }); 63 + 64 + // Analyze current rotation keys to find user-controlled keys that should be preserved 65 + const analyzeRotationKeysMutation = createMutation({ 66 + async mutationFn({ did, sourceManager }: { did: Did<'plc'>; sourceManager: CredentialManager }, signal) { 67 + // Get current rotation keys from PLC audit log 68 + const auditLogs = await getPlcAuditLogs({ did, signal }); 69 + const latestEntry = auditLogs[auditLogs.length - 1]; 70 + const currentPayload = getPlcPayload(latestEntry); 71 + const currentRotationKeys = currentPayload.rotationKeys ?? []; 72 + 73 + // Get source PDS's recommended credentials to identify PDS-controlled keys 74 + const sourceClient = new Client({ handler: sourceManager }); 75 + const sourcePdsCredentials = (await ok( 76 + sourceClient.get('com.atproto.identity.getRecommendedDidCredentials', {}), 77 + )) as RecommendedCredentials; 78 + const sourcePdsKeys = new Set(sourcePdsCredentials.rotationKeys ?? []); 79 + 80 + // Keys in current doc that aren't from source PDS are user-controlled 81 + const userControlledKeys = currentRotationKeys.filter((key) => !sourcePdsKeys.has(key)); 82 + 83 + return { 84 + currentRotationKeys, 85 + sourcePdsKeys: sourcePdsCredentials.rotationKeys ?? [], 86 + userControlledKeys, 87 + }; 88 + }, 89 + onSuccess(data) { 90 + // Pre-populate custom keys with user-controlled keys 91 + if (data.userControlledKeys.length > 0) { 92 + setCustomKeys(data.userControlledKeys); 93 + } 52 94 }, 53 95 onError(err) { 54 96 console.error(err); ··· 199 241 </p> 200 242 </div> 201 243 202 - <Subsection title="1. Request operation signature"> 203 - <p class="text-sm text-gray-600">Request a confirmation token via email from your source PDS.</p> 204 - 205 - <Show 206 - when={source()?.manager} 207 - fallback={<p class="text-sm text-gray-500">Sign in to source account first.</p>} 208 - > 209 - {(manager) => ( 210 - <> 211 - <div class="flex items-center gap-3"> 212 - <Button 213 - onClick={() => requestTokenMutation.mutate({ manager: manager() })} 214 - disabled={requestTokenMutation.isPending} 215 - > 216 - {requestTokenMutation.isPending ? 'Requesting...' : 'Request token'} 217 - </Button> 218 - 219 - <Show when={requestTokenMutation.isSuccess}> 220 - <StatusBadge variant="success">Email sent</StatusBadge> 221 - </Show> 222 - </div> 223 - 224 - <Show when={requestTokenMutation.isError}> 225 - <p class="text-sm text-red-600">{`${requestTokenMutation.error}`}</p> 226 - </Show> 227 - 228 - <Show when={requestTokenMutation.isSuccess}> 229 - <p class="text-sm text-gray-600">Check your email inbox for the confirmation code.</p> 230 - </Show> 231 - </> 232 - )} 233 - </Show> 234 - </Subsection> 235 - 236 - <Subsection title="2. Preview new credentials"> 244 + <Subsection title="1. Preview new credentials"> 237 245 <p class="text-sm text-gray-600">View what your DID document will look like after the migration.</p> 238 246 239 247 <Show ··· 265 273 <> 266 274 <div class="mt-2 text-sm"> 267 275 <p class="text-gray-500"> 268 - PDS rotation keys ({creds().rotationKeys?.length ?? 0}/5): 276 + Destination PDS rotation keys ({creds().rotationKeys?.length ?? 0}/5): 269 277 </p> 270 278 <div class="mt-1 flex flex-col gap-1"> 271 279 <For each={creds().rotationKeys ?? []}> 272 - {(key) => ( 273 - <code class="block truncate text-xs text-gray-700">{key}</code> 274 - )} 280 + {(key) => <code class="block truncate text-xs text-gray-700">{key}</code>} 275 281 </For> 276 282 </div> 277 283 </div> 278 284 285 + <Show when={source()?.manager && source()}> 286 + {(src) => ( 287 + <div class="mt-3 rounded border border-blue-200 bg-blue-50 p-3"> 288 + <div class="flex items-center justify-between"> 289 + <p class="text-sm font-medium text-blue-800">Analyze existing rotation keys</p> 290 + <Button 291 + variant="outline" 292 + onClick={() => 293 + analyzeRotationKeysMutation.mutate({ 294 + did: src().did as Did<'plc'>, 295 + sourceManager: src().manager!, 296 + }) 297 + } 298 + disabled={analyzeRotationKeysMutation.isPending} 299 + > 300 + {analyzeRotationKeysMutation.isPending ? 'Analyzing...' : 'Analyze'} 301 + </Button> 302 + </div> 303 + <p class="mt-1 text-xs text-blue-600"> 304 + Check if you have any user-controlled rotation keys that should be preserved 305 + during migration. 306 + </p> 307 + 308 + <Show when={analyzeRotationKeysMutation.error}> 309 + <p class="mt-2 text-sm text-red-600">{`${analyzeRotationKeysMutation.error}`}</p> 310 + </Show> 311 + 312 + <Show when={analyzeRotationKeysMutation.data}> 313 + {(analysis) => ( 314 + <div class="mt-2 text-sm"> 315 + <Show 316 + when={analysis().userControlledKeys.length > 0} 317 + fallback={ 318 + <p class="text-blue-700"> 319 + No user-controlled rotation keys found. Your current keys are all 320 + managed by your source PDS. 321 + </p> 322 + } 323 + > 324 + <p class="font-medium text-blue-800"> 325 + Found {analysis().userControlledKeys.length} user-controlled key(s) to 326 + preserve: 327 + </p> 328 + <div class="mt-1 flex flex-col gap-1"> 329 + <For each={analysis().userControlledKeys}> 330 + {(key) => ( 331 + <code class="block truncate text-xs text-blue-700">{key}</code> 332 + )} 333 + </For> 334 + </div> 335 + <p class="mt-2 text-xs text-blue-600"> 336 + These keys have been added to the custom keys section below. 337 + </p> 338 + </Show> 339 + </div> 340 + )} 341 + </Show> 342 + </div> 343 + )} 344 + </Show> 345 + 279 346 <details class="mt-2"> 280 - <summary class="cursor-pointer text-sm text-gray-600"> 281 - View full credentials 282 - </summary> 347 + <summary class="cursor-pointer text-sm text-gray-600">View full credentials</summary> 283 348 <pre class="mt-2 max-h-48 overflow-auto rounded border border-gray-200 bg-gray-50 p-2 font-mono text-xs"> 284 349 {JSON.stringify(creds(), null, 2)} 285 350 </pre> ··· 292 357 </Show> 293 358 </Subsection> 294 359 295 - <Subsection title="3. Rotation keys (optional)"> 360 + <Subsection title="2. Rotation keys (optional)"> 296 361 <p class="text-sm text-gray-600"> 297 - Add a rotation key to recover your account if your new PDS goes rogue. This will be prepended to 298 - the PDS rotation keys shown above. 362 + Add a rotation key to recover your account if your new PDS goes rogue. This will be prepended to the 363 + PDS rotation keys shown above. 299 364 </p> 300 365 301 366 <ToggleInput ··· 323 388 <div class="rounded border border-green-300 bg-green-50 p-3"> 324 389 <p class="mb-2 text-sm font-semibold text-green-800">Save your rotation key private key!</p> 325 390 <p class="mb-3 text-xs text-green-700"> 326 - Store this securely. You'll need it to recover your account if your PDS becomes 327 - unavailable or malicious. 391 + Store this securely. You'll need it to recover your account if your PDS becomes unavailable or 392 + malicious. 328 393 </p> 329 394 330 395 <div class="flex flex-col gap-2 text-sm"> ··· 345 410 )} 346 411 </Show> 347 412 348 - <div class="mt-4 border-t border-gray-200 pt-4"> 413 + <div class="rounded border border-gray-200 bg-gray-50 p-3"> 349 414 <p class="mb-2 text-sm font-medium text-gray-700">Custom rotation keys</p> 350 415 <p class="mb-3 text-xs text-gray-500"> 351 416 Add existing rotation keys (did:key format) you already control. ··· 394 459 </div> 395 460 </Subsection> 396 461 462 + <Subsection title="3. Request operation signature"> 463 + <p class="text-sm text-gray-600">Request a confirmation token via email from your source PDS.</p> 464 + 465 + <Show 466 + when={source()?.manager} 467 + fallback={<p class="text-sm text-gray-500">Sign in to source account first.</p>} 468 + > 469 + {(manager) => ( 470 + <> 471 + <div class="flex items-center gap-3"> 472 + <Button 473 + onClick={() => requestTokenMutation.mutate({ manager: manager() })} 474 + disabled={requestTokenMutation.isPending} 475 + > 476 + {requestTokenMutation.isPending ? 'Requesting...' : 'Request token'} 477 + </Button> 478 + 479 + <Show when={requestTokenMutation.isSuccess}> 480 + <StatusBadge variant="success">Email sent</StatusBadge> 481 + </Show> 482 + </div> 483 + 484 + <Show when={requestTokenMutation.isError}> 485 + <p class="text-sm text-red-600">{`${requestTokenMutation.error}`}</p> 486 + </Show> 487 + 488 + <Show when={requestTokenMutation.isSuccess}> 489 + <p class="text-sm text-gray-600">Check your email inbox for the confirmation code.</p> 490 + </Show> 491 + </> 492 + )} 493 + </Show> 494 + </Subsection> 495 + 397 496 <Subsection title="4. Sign and submit"> 398 497 <p class="text-sm text-gray-600">Enter the confirmation code and submit the PLC operation.</p> 399 498 ··· 422 521 /> 423 522 424 523 <div class="flex items-center gap-3"> 425 - <Button onClick={handleSignAndSubmit} disabled={signAndSubmitMutation.isPending || !canSignAndSubmit()}> 524 + <Button 525 + onClick={handleSignAndSubmit} 526 + disabled={signAndSubmitMutation.isPending || !canSignAndSubmit()} 527 + > 426 528 {signAndSubmitMutation.isPending ? 'Submitting...' : 'Sign and submit'} 427 529 </Button> 428 530
+9 -9
src/views/account/account-migrate/sections/repository.tsx
··· 109 109 return null; 110 110 } 111 111 112 - setImportStatus('Reading file...'); 113 112 const file = await fd.getFile(); 114 - const data = new Uint8Array(await file.arrayBuffer()); 115 113 116 - setImportStatus(`Uploading repository (${formatBytes(data.length)})...`); 114 + setImportStatus(`Uploading repository (${formatBytes(file.size)})...`); 117 115 118 116 const destClient = new Client({ handler: manager }); 119 117 const importResp = await destClient.post('com.atproto.repo.importRepo', { 120 118 as: null, 121 - encoding: 'application/vnd.ipld.car', 122 - input: data, 119 + input: file, 120 + headers: { 121 + 'content-type': 'application/vnd.ipld.car', 122 + }, 123 123 }); 124 124 125 125 if (!importResp.ok) { ··· 170 170 const destClient = new Client({ handler: destManager }); 171 171 const importResp = await destClient.post('com.atproto.repo.importRepo', { 172 172 as: null, 173 - encoding: 'application/vnd.ipld.car', 174 173 input: response.data, 174 + headers: { 175 + 'content-type': 'application/vnd.ipld.car', 176 + }, 175 177 }); 176 178 177 179 if (!importResp.ok) { ··· 229 231 </Subsection> 230 232 231 233 <Subsection title="Import to destination"> 232 - <p class="text-sm text-gray-600"> 233 - Upload a repository CAR file or transfer directly from source. 234 - </p> 234 + <p class="text-sm text-gray-600">Upload a repository CAR file or transfer directly from source.</p> 235 235 236 236 <Show 237 237 when={destination()?.manager}