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
+178 -75
+17 -16
src/views/account/account-migrate/sections/blobs.tsx
··· 172 try { 173 const data = await entry.bytes(); 174 await destClient.post('com.atproto.repo.uploadBlob', { 175 - encoding: 'application/octet-stream', 176 input: data, 177 }); 178 uploaded++; 179 } catch (err) { ··· 228 const contentType = response.headers.get('content-type') || 'application/octet-stream'; 229 230 await destClient.post('com.atproto.repo.uploadBlob', { 231 - encoding: contentType, 232 input: response.data, 233 }); 234 235 uploaded++; ··· 297 const sourceData = importFromSourceMutation.data; 298 299 if (fileData && !fileData.cancelled) { 300 - return `Uploaded ${fileData.uploaded} blobs` + (fileData.failed > 0 ? ` (${fileData.failed} failed)` : ''); 301 } 302 if (sourceData) { 303 if (sourceData.uploaded === 0 && sourceData.failed === 0) return 'No missing blobs'; 304 - return `Uploaded ${sourceData.uploaded} blobs` + (sourceData.failed > 0 ? ` (${sourceData.failed} failed)` : ''); 305 } 306 return importProgress(); 307 }; ··· 311 return ( 312 <Accordion title="Blobs"> 313 <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> 317 318 <Show when={source()} fallback={<p class="text-sm text-gray-500">Resolve source account first.</p>}> 319 {(src) => ( ··· 339 </Subsection> 340 341 <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> 345 346 <Show 347 when={destination()?.manager} ··· 376 {(text) => <span class="text-sm text-gray-600">{text()}</span>} 377 </Show> 378 379 - <Show when={getImportError()}> 380 - {(err) => <p class="text-sm text-red-600">{`${err()}`}</p>} 381 - </Show> 382 </> 383 )} 384 </Show> ··· 403 <Show when={checkStatusMutation.data}> 404 {(status) => ( 405 <span class="text-sm"> 406 - <StatusBadge 407 - variant={status().imported === status().expected ? 'success' : 'pending'} 408 - > 409 {status().imported}/{status().expected} blobs 410 </StatusBadge> 411 </span>
··· 172 try { 173 const data = await entry.bytes(); 174 await destClient.post('com.atproto.repo.uploadBlob', { 175 input: data, 176 + headers: { 177 + 'content-type': 'application/octet-stream', 178 + }, 179 }); 180 uploaded++; 181 } catch (err) { ··· 230 const contentType = response.headers.get('content-type') || 'application/octet-stream'; 231 232 await destClient.post('com.atproto.repo.uploadBlob', { 233 input: response.data, 234 + headers: { 235 + 'content-type': contentType, 236 + }, 237 }); 238 239 uploaded++; ··· 301 const sourceData = importFromSourceMutation.data; 302 303 if (fileData && !fileData.cancelled) { 304 + return ( 305 + `Uploaded ${fileData.uploaded} blobs` + (fileData.failed > 0 ? ` (${fileData.failed} failed)` : '') 306 + ); 307 } 308 if (sourceData) { 309 if (sourceData.uploaded === 0 && sourceData.failed === 0) return 'No missing blobs'; 310 + return ( 311 + `Uploaded ${sourceData.uploaded} blobs` + 312 + (sourceData.failed > 0 ? ` (${sourceData.failed} failed)` : '') 313 + ); 314 } 315 return importProgress(); 316 }; ··· 320 return ( 321 <Accordion title="Blobs"> 322 <Subsection title="Export from source"> 323 + <p class="text-sm text-gray-600">Download all blobs as a tarball for backup or manual import.</p> 324 325 <Show when={source()} fallback={<p class="text-sm text-gray-500">Resolve source account first.</p>}> 326 {(src) => ( ··· 346 </Subsection> 347 348 <Subsection title="Import to destination"> 349 + <p class="text-sm text-gray-600">Upload blobs from a tarball or transfer directly from source.</p> 350 351 <Show 352 when={destination()?.manager} ··· 381 {(text) => <span class="text-sm text-gray-600">{text()}</span>} 382 </Show> 383 384 + <Show when={getImportError()}>{(err) => <p class="text-sm text-red-600">{`${err()}`}</p>}</Show> 385 </> 386 )} 387 </Show> ··· 406 <Show when={checkStatusMutation.data}> 407 {(status) => ( 408 <span class="text-sm"> 409 + <StatusBadge variant={status().imported === status().expected ? 'success' : 'pending'}> 410 {status().imported}/{status().expected} blobs 411 </StatusBadge> 412 </span>
+152 -50
src/views/account/account-migrate/sections/identity.tsx
··· 2 3 import { Client, ClientResponseError, type CredentialManager, ok } from '@atcute/client'; 4 import { type DidKeyString, Secp256k1PrivateKeyExportable } from '@atcute/crypto'; 5 6 import { formatTotpCode, TOTP_RE } from '~/api/utils/auth'; 7 8 import { createMutation } from '~/lib/utils/mutation'; ··· 11 import Button from '~/components/inputs/button'; 12 import TextInput from '~/components/inputs/text-input'; 13 import ToggleInput from '~/components/inputs/toggle-input'; 14 15 import { useMigration } from '../context'; 16 ··· 48 const loadCredentialsMutation = createMutation({ 49 async mutationFn({ manager }: { manager: CredentialManager }) { 50 const client = new Client({ handler: manager }); 51 - return (await ok(client.get('com.atproto.identity.getRecommendedDidCredentials', {}))) as RecommendedCredentials; 52 }, 53 onError(err) { 54 console.error(err); ··· 199 </p> 200 </div> 201 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"> 237 <p class="text-sm text-gray-600">View what your DID document will look like after the migration.</p> 238 239 <Show ··· 265 <> 266 <div class="mt-2 text-sm"> 267 <p class="text-gray-500"> 268 - PDS rotation keys ({creds().rotationKeys?.length ?? 0}/5): 269 </p> 270 <div class="mt-1 flex flex-col gap-1"> 271 <For each={creds().rotationKeys ?? []}> 272 - {(key) => ( 273 - <code class="block truncate text-xs text-gray-700">{key}</code> 274 - )} 275 </For> 276 </div> 277 </div> 278 279 <details class="mt-2"> 280 - <summary class="cursor-pointer text-sm text-gray-600"> 281 - View full credentials 282 - </summary> 283 <pre class="mt-2 max-h-48 overflow-auto rounded border border-gray-200 bg-gray-50 p-2 font-mono text-xs"> 284 {JSON.stringify(creds(), null, 2)} 285 </pre> ··· 292 </Show> 293 </Subsection> 294 295 - <Subsection title="3. Rotation keys (optional)"> 296 <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. 299 </p> 300 301 <ToggleInput ··· 323 <div class="rounded border border-green-300 bg-green-50 p-3"> 324 <p class="mb-2 text-sm font-semibold text-green-800">Save your rotation key private key!</p> 325 <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. 328 </p> 329 330 <div class="flex flex-col gap-2 text-sm"> ··· 345 )} 346 </Show> 347 348 - <div class="mt-4 border-t border-gray-200 pt-4"> 349 <p class="mb-2 text-sm font-medium text-gray-700">Custom rotation keys</p> 350 <p class="mb-3 text-xs text-gray-500"> 351 Add existing rotation keys (did:key format) you already control. ··· 394 </div> 395 </Subsection> 396 397 <Subsection title="4. Sign and submit"> 398 <p class="text-sm text-gray-600">Enter the confirmation code and submit the PLC operation.</p> 399 ··· 422 /> 423 424 <div class="flex items-center gap-3"> 425 - <Button onClick={handleSignAndSubmit} disabled={signAndSubmitMutation.isPending || !canSignAndSubmit()}> 426 {signAndSubmitMutation.isPending ? 'Submitting...' : 'Sign and submit'} 427 </Button> 428
··· 2 3 import { Client, ClientResponseError, type CredentialManager, ok } from '@atcute/client'; 4 import { type DidKeyString, Secp256k1PrivateKeyExportable } from '@atcute/crypto'; 5 + import type { Did } from '@atcute/lexicons/syntax'; 6 7 + import { getPlcAuditLogs } from '~/api/queries/plc'; 8 import { formatTotpCode, TOTP_RE } from '~/api/utils/auth'; 9 10 import { createMutation } from '~/lib/utils/mutation'; ··· 13 import Button from '~/components/inputs/button'; 14 import TextInput from '~/components/inputs/text-input'; 15 import ToggleInput from '~/components/inputs/toggle-input'; 16 + 17 + import { getPlcPayload } from '~/views/identity/plc-applicator/plc-utils'; 18 19 import { useMigration } from '../context'; 20 ··· 52 const loadCredentialsMutation = createMutation({ 53 async mutationFn({ manager }: { manager: CredentialManager }) { 54 const client = new Client({ handler: manager }); 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 + } 94 }, 95 onError(err) { 96 console.error(err); ··· 241 </p> 242 </div> 243 244 + <Subsection title="1. Preview new credentials"> 245 <p class="text-sm text-gray-600">View what your DID document will look like after the migration.</p> 246 247 <Show ··· 273 <> 274 <div class="mt-2 text-sm"> 275 <p class="text-gray-500"> 276 + Destination PDS rotation keys ({creds().rotationKeys?.length ?? 0}/5): 277 </p> 278 <div class="mt-1 flex flex-col gap-1"> 279 <For each={creds().rotationKeys ?? []}> 280 + {(key) => <code class="block truncate text-xs text-gray-700">{key}</code>} 281 </For> 282 </div> 283 </div> 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 + 346 <details class="mt-2"> 347 + <summary class="cursor-pointer text-sm text-gray-600">View full credentials</summary> 348 <pre class="mt-2 max-h-48 overflow-auto rounded border border-gray-200 bg-gray-50 p-2 font-mono text-xs"> 349 {JSON.stringify(creds(), null, 2)} 350 </pre> ··· 357 </Show> 358 </Subsection> 359 360 + <Subsection title="2. Rotation keys (optional)"> 361 <p class="text-sm text-gray-600"> 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. 364 </p> 365 366 <ToggleInput ··· 388 <div class="rounded border border-green-300 bg-green-50 p-3"> 389 <p class="mb-2 text-sm font-semibold text-green-800">Save your rotation key private key!</p> 390 <p class="mb-3 text-xs text-green-700"> 391 + Store this securely. You'll need it to recover your account if your PDS becomes unavailable or 392 + malicious. 393 </p> 394 395 <div class="flex flex-col gap-2 text-sm"> ··· 410 )} 411 </Show> 412 413 + <div class="rounded border border-gray-200 bg-gray-50 p-3"> 414 <p class="mb-2 text-sm font-medium text-gray-700">Custom rotation keys</p> 415 <p class="mb-3 text-xs text-gray-500"> 416 Add existing rotation keys (did:key format) you already control. ··· 459 </div> 460 </Subsection> 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 + 496 <Subsection title="4. Sign and submit"> 497 <p class="text-sm text-gray-600">Enter the confirmation code and submit the PLC operation.</p> 498 ··· 521 /> 522 523 <div class="flex items-center gap-3"> 524 + <Button 525 + onClick={handleSignAndSubmit} 526 + disabled={signAndSubmitMutation.isPending || !canSignAndSubmit()} 527 + > 528 {signAndSubmitMutation.isPending ? 'Submitting...' : 'Sign and submit'} 529 </Button> 530
+9 -9
src/views/account/account-migrate/sections/repository.tsx
··· 109 return null; 110 } 111 112 - setImportStatus('Reading file...'); 113 const file = await fd.getFile(); 114 - const data = new Uint8Array(await file.arrayBuffer()); 115 116 - setImportStatus(`Uploading repository (${formatBytes(data.length)})...`); 117 118 const destClient = new Client({ handler: manager }); 119 const importResp = await destClient.post('com.atproto.repo.importRepo', { 120 as: null, 121 - encoding: 'application/vnd.ipld.car', 122 - input: data, 123 }); 124 125 if (!importResp.ok) { ··· 170 const destClient = new Client({ handler: destManager }); 171 const importResp = await destClient.post('com.atproto.repo.importRepo', { 172 as: null, 173 - encoding: 'application/vnd.ipld.car', 174 input: response.data, 175 }); 176 177 if (!importResp.ok) { ··· 229 </Subsection> 230 231 <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> 235 236 <Show 237 when={destination()?.manager}
··· 109 return null; 110 } 111 112 const file = await fd.getFile(); 113 114 + setImportStatus(`Uploading repository (${formatBytes(file.size)})...`); 115 116 const destClient = new Client({ handler: manager }); 117 const importResp = await destClient.post('com.atproto.repo.importRepo', { 118 as: null, 119 + input: file, 120 + headers: { 121 + 'content-type': 'application/vnd.ipld.car', 122 + }, 123 }); 124 125 if (!importResp.ok) { ··· 170 const destClient = new Client({ handler: destManager }); 171 const importResp = await destClient.post('com.atproto.repo.importRepo', { 172 as: null, 173 input: response.data, 174 + headers: { 175 + 'content-type': 'application/vnd.ipld.car', 176 + }, 177 }); 178 179 if (!importResp.ok) { ··· 231 </Subsection> 232 233 <Subsection title="Import to destination"> 234 + <p class="text-sm text-gray-600">Upload a repository CAR file or transfer directly from source.</p> 235 236 <Show 237 when={destination()?.manager}