tangled
alpha
login
or
join now
mary.my.id
/
boat
22
fork
atom
handy online tools for AT Protocol
boat.kelinci.net
atproto
bluesky
atcute
typescript
solidjs
22
fork
atom
overview
issues
pulls
pipelines
feat: more work into migration
mary.my.id
3 months ago
a463ad3c
c7e2b3c9
verified
This commit was signed with the committer's
known signature
.
mary.my.id
SSH Key Fingerprint:
SHA256:ZlTP/auFSGpGnaoDg4mCTG1g9OZvXp62jWR4c6H4O3c=
+178
-75
3 changed files
expand all
collapse all
unified
split
src
views
account
account-migrate
sections
blobs.tsx
identity.tsx
repository.tsx
+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,
0
0
0
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,
0
0
0
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)` : '');
0
0
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)` : '');
0
0
0
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', {
0
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', {
0
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>
0
0
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>
0
0
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>
0
0
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'}>
0
0
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';
0
5
0
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';
0
0
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;
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
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
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
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
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
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()}>
0
0
0
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">
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
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>}
0
0
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>
0
0
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,
0
0
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,
0
0
0
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
0
112
const file = await fd.getFile();
0
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,
0
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>
0
0
235
236
<Show
237
when={destination()?.manager}