+26
.sqlx/query-1261d14a3763b98464b212d001c9a11da30a55869128320de6d62693415953f5.json
+26
.sqlx/query-1261d14a3763b98464b212d001c9a11da30a55869128320de6d62693415953f5.json
···
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "SELECT cid, data FROM blocks",
4
+
"describe": {
5
+
"columns": [
6
+
{
7
+
"ordinal": 0,
8
+
"name": "cid",
9
+
"type_info": "Bytea"
10
+
},
11
+
{
12
+
"ordinal": 1,
13
+
"name": "data",
14
+
"type_info": "Bytea"
15
+
}
16
+
],
17
+
"parameters": {
18
+
"Left": []
19
+
},
20
+
"nullable": [
21
+
false,
22
+
false
23
+
]
24
+
},
25
+
"hash": "1261d14a3763b98464b212d001c9a11da30a55869128320de6d62693415953f5"
26
+
}
+28
.sqlx/query-4993f00aedabc48d6d1c722c0928d8bd792cc2d5ade5a8ec0296258cc2a2d1db.json
+28
.sqlx/query-4993f00aedabc48d6d1c722c0928d8bd792cc2d5ade5a8ec0296258cc2a2d1db.json
···
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "SELECT uk.key_bytes, uk.encryption_version FROM user_keys uk JOIN users u ON uk.user_id = u.id WHERE u.did = $1",
4
+
"describe": {
5
+
"columns": [
6
+
{
7
+
"ordinal": 0,
8
+
"name": "key_bytes",
9
+
"type_info": "Bytea"
10
+
},
11
+
{
12
+
"ordinal": 1,
13
+
"name": "encryption_version",
14
+
"type_info": "Int4"
15
+
}
16
+
],
17
+
"parameters": {
18
+
"Left": [
19
+
"Text"
20
+
]
21
+
},
22
+
"nullable": [
23
+
false,
24
+
true
25
+
]
26
+
},
27
+
"hash": "4993f00aedabc48d6d1c722c0928d8bd792cc2d5ade5a8ec0296258cc2a2d1db"
28
+
}
+14
.sqlx/query-603711611d2100957c67bb18485f03eecf54a5f2865fe2e40b251ab6d6c64cd1.json
+14
.sqlx/query-603711611d2100957c67bb18485f03eecf54a5f2865fe2e40b251ab6d6c64cd1.json
···
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "UPDATE users SET migrated_to_pds = NULL, migrated_at = NULL WHERE did = $1",
4
+
"describe": {
5
+
"columns": [],
6
+
"parameters": {
7
+
"Left": [
8
+
"Text"
9
+
]
10
+
},
11
+
"nullable": []
12
+
},
13
+
"hash": "603711611d2100957c67bb18485f03eecf54a5f2865fe2e40b251ab6d6c64cd1"
14
+
}
+34
.sqlx/query-791d0d6ea6fbedc3e51fb8b1fda7bfe756f2631daf688e6646705725342ad67e.json
+34
.sqlx/query-791d0d6ea6fbedc3e51fb8b1fda7bfe756f2631daf688e6646705725342ad67e.json
···
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "SELECT did, migrated_to_pds, migrated_at FROM users WHERE did = $1",
4
+
"describe": {
5
+
"columns": [
6
+
{
7
+
"ordinal": 0,
8
+
"name": "did",
9
+
"type_info": "Text"
10
+
},
11
+
{
12
+
"ordinal": 1,
13
+
"name": "migrated_to_pds",
14
+
"type_info": "Text"
15
+
},
16
+
{
17
+
"ordinal": 2,
18
+
"name": "migrated_at",
19
+
"type_info": "Timestamptz"
20
+
}
21
+
],
22
+
"parameters": {
23
+
"Left": [
24
+
"Text"
25
+
]
26
+
},
27
+
"nullable": [
28
+
false,
29
+
true,
30
+
true
31
+
]
32
+
},
33
+
"hash": "791d0d6ea6fbedc3e51fb8b1fda7bfe756f2631daf688e6646705725342ad67e"
34
+
}
+16
.sqlx/query-f64d6af0147ae292b98685fd50669174009b0b6bc86c5c649c5ce9927deb3d93.json
+16
.sqlx/query-f64d6af0147ae292b98685fd50669174009b0b6bc86c5c649c5ce9927deb3d93.json
···
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "UPDATE users SET migrated_to_pds = $1, migrated_at = $2 WHERE did = $3",
4
+
"describe": {
5
+
"columns": [],
6
+
"parameters": {
7
+
"Left": [
8
+
"Text",
9
+
"Timestamptz",
10
+
"Text"
11
+
]
12
+
},
13
+
"nullable": []
14
+
},
15
+
"hash": "f64d6af0147ae292b98685fd50669174009b0b6bc86c5c649c5ce9927deb3d93"
16
+
}
+3
frontend/src/App.svelte
+3
frontend/src/App.svelte
···
32
import Controllers from './routes/Controllers.svelte'
33
import DelegationAudit from './routes/DelegationAudit.svelte'
34
import ActAs from './routes/ActAs.svelte'
35
import Home from './routes/Home.svelte'
36
37
initI18n()
···
113
return DelegationAudit
114
case '/act-as':
115
return ActAs
116
default:
117
return Home
118
}
···
32
import Controllers from './routes/Controllers.svelte'
33
import DelegationAudit from './routes/DelegationAudit.svelte'
34
import ActAs from './routes/ActAs.svelte'
35
+
import Migration from './routes/Migration.svelte'
36
import Home from './routes/Home.svelte'
37
38
initI18n()
···
114
return DelegationAudit
115
case '/act-as':
116
return ActAs
117
+
case '/migrate':
118
+
return Migration
119
default:
120
return Home
121
}
+1024
frontend/src/components/migration/InboundWizard.svelte
+1024
frontend/src/components/migration/InboundWizard.svelte
···
···
1
+
<script lang="ts">
2
+
import type { InboundMigrationFlow } from '../../lib/migration'
3
+
import type { ServerDescription } from '../../lib/migration/types'
4
+
import { _ } from '../../lib/i18n'
5
+
6
+
interface Props {
7
+
flow: InboundMigrationFlow
8
+
onBack: () => void
9
+
onComplete: () => void
10
+
}
11
+
12
+
let { flow, onBack, onComplete }: Props = $props()
13
+
14
+
let serverInfo = $state<ServerDescription | null>(null)
15
+
let loading = $state(false)
16
+
let handleInput = $state('')
17
+
let passwordInput = $state('')
18
+
let localPasswordInput = $state('')
19
+
let understood = $state(false)
20
+
let selectedDomain = $state('')
21
+
let handleAvailable = $state<boolean | null>(null)
22
+
let checkingHandle = $state(false)
23
+
24
+
const isResumedMigration = $derived(flow.state.progress.repoImported)
25
+
26
+
$effect(() => {
27
+
if (flow.state.step === 'welcome' || flow.state.step === 'choose-handle') {
28
+
loadServerInfo()
29
+
}
30
+
})
31
+
32
+
33
+
let redirectTriggered = $state(false)
34
+
35
+
$effect(() => {
36
+
if (flow.state.step === 'success' && !redirectTriggered) {
37
+
redirectTriggered = true
38
+
setTimeout(() => {
39
+
onComplete()
40
+
}, 2000)
41
+
}
42
+
})
43
+
44
+
$effect(() => {
45
+
if (flow.state.step === 'email-verify') {
46
+
const interval = setInterval(async () => {
47
+
if (flow.state.emailVerifyToken.trim()) return
48
+
await flow.checkEmailVerifiedAndProceed()
49
+
}, 3000)
50
+
return () => clearInterval(interval)
51
+
}
52
+
})
53
+
54
+
async function loadServerInfo() {
55
+
if (!serverInfo) {
56
+
serverInfo = await flow.loadLocalServerInfo()
57
+
if (serverInfo.availableUserDomains.length > 0) {
58
+
selectedDomain = serverInfo.availableUserDomains[0]
59
+
}
60
+
}
61
+
}
62
+
63
+
async function handleLogin(e: Event) {
64
+
e.preventDefault()
65
+
loading = true
66
+
flow.updateField('error', null)
67
+
68
+
try {
69
+
await flow.loginToSource(handleInput, passwordInput, flow.state.twoFactorCode || undefined)
70
+
const username = flow.state.sourceHandle.split('.')[0]
71
+
handleInput = username
72
+
flow.updateField('targetPassword', passwordInput)
73
+
74
+
if (flow.state.progress.repoImported) {
75
+
if (!localPasswordInput) {
76
+
flow.setError('Please enter your password for your new account on this PDS')
77
+
return
78
+
}
79
+
await flow.loadLocalServerInfo()
80
+
81
+
try {
82
+
await flow.authenticateToLocal(flow.state.targetEmail, localPasswordInput)
83
+
await flow.requestPlcToken()
84
+
flow.setStep('plc-token')
85
+
} catch (err) {
86
+
const error = err as Error & { error?: string }
87
+
if (error.error === 'AccountNotVerified') {
88
+
flow.setStep('email-verify')
89
+
} else {
90
+
throw err
91
+
}
92
+
}
93
+
} else {
94
+
flow.setStep('choose-handle')
95
+
}
96
+
} catch (err) {
97
+
flow.setError((err as Error).message)
98
+
} finally {
99
+
loading = false
100
+
}
101
+
}
102
+
103
+
async function checkHandle() {
104
+
if (!handleInput.trim()) return
105
+
106
+
const fullHandle = handleInput.includes('.')
107
+
? handleInput
108
+
: `${handleInput}.${selectedDomain}`
109
+
110
+
checkingHandle = true
111
+
handleAvailable = null
112
+
113
+
try {
114
+
handleAvailable = await flow.checkHandleAvailability(fullHandle)
115
+
} catch {
116
+
handleAvailable = true
117
+
} finally {
118
+
checkingHandle = false
119
+
}
120
+
}
121
+
122
+
function proceedToReview() {
123
+
const fullHandle = handleInput.includes('.')
124
+
? handleInput
125
+
: `${handleInput}.${selectedDomain}`
126
+
127
+
flow.updateField('targetHandle', fullHandle)
128
+
flow.setStep('review')
129
+
}
130
+
131
+
async function startMigration() {
132
+
loading = true
133
+
try {
134
+
await flow.startMigration()
135
+
} catch (err) {
136
+
flow.setError((err as Error).message)
137
+
} finally {
138
+
loading = false
139
+
}
140
+
}
141
+
142
+
async function submitEmailVerify(e: Event) {
143
+
e.preventDefault()
144
+
loading = true
145
+
try {
146
+
await flow.submitEmailVerifyToken(flow.state.emailVerifyToken, localPasswordInput || undefined)
147
+
} catch (err) {
148
+
flow.setError((err as Error).message)
149
+
} finally {
150
+
loading = false
151
+
}
152
+
}
153
+
154
+
async function resendEmailVerify() {
155
+
loading = true
156
+
try {
157
+
await flow.resendEmailVerification()
158
+
flow.setError(null)
159
+
} catch (err) {
160
+
flow.setError((err as Error).message)
161
+
} finally {
162
+
loading = false
163
+
}
164
+
}
165
+
166
+
async function submitPlcToken(e: Event) {
167
+
e.preventDefault()
168
+
loading = true
169
+
try {
170
+
await flow.submitPlcToken(flow.state.plcToken)
171
+
} catch (err) {
172
+
flow.setError((err as Error).message)
173
+
} finally {
174
+
loading = false
175
+
}
176
+
}
177
+
178
+
async function resendToken() {
179
+
loading = true
180
+
try {
181
+
await flow.resendPlcToken()
182
+
flow.setError(null)
183
+
} catch (err) {
184
+
flow.setError((err as Error).message)
185
+
} finally {
186
+
loading = false
187
+
}
188
+
}
189
+
190
+
const steps = ['Login', 'Handle', 'Review', 'Transfer', 'Verify Email', 'Verify PLC', 'Complete']
191
+
function getCurrentStepIndex(): number {
192
+
switch (flow.state.step) {
193
+
case 'welcome':
194
+
case 'source-login': return 0
195
+
case 'choose-handle': return 1
196
+
case 'review': return 2
197
+
case 'migrating': return 3
198
+
case 'email-verify': return 4
199
+
case 'plc-token':
200
+
case 'finalizing': return 5
201
+
case 'success': return 6
202
+
default: return 0
203
+
}
204
+
}
205
+
</script>
206
+
207
+
<div class="inbound-wizard">
208
+
<div class="step-indicator">
209
+
{#each steps as stepName, i}
210
+
<div class="step" class:active={i === getCurrentStepIndex()} class:completed={i < getCurrentStepIndex()}>
211
+
<div class="step-dot">{i < getCurrentStepIndex() ? '✓' : i + 1}</div>
212
+
<span class="step-label">{stepName}</span>
213
+
</div>
214
+
{#if i < steps.length - 1}
215
+
<div class="step-line" class:completed={i < getCurrentStepIndex()}></div>
216
+
{/if}
217
+
{/each}
218
+
</div>
219
+
220
+
{#if flow.state.error}
221
+
<div class="message error">{flow.state.error}</div>
222
+
{/if}
223
+
224
+
{#if flow.state.step === 'welcome'}
225
+
<div class="step-content">
226
+
<h2>Migrate Your Account Here</h2>
227
+
<p>This wizard will help you move your AT Protocol account from another PDS to this one.</p>
228
+
229
+
<div class="info-box">
230
+
<h3>What will happen:</h3>
231
+
<ol>
232
+
<li>Log in to your current PDS</li>
233
+
<li>Choose your new handle on this server</li>
234
+
<li>Your repository and blobs will be transferred</li>
235
+
<li>Verify the migration via email</li>
236
+
<li>Your identity will be updated to point here</li>
237
+
</ol>
238
+
</div>
239
+
240
+
<div class="warning-box">
241
+
<strong>Before you proceed:</strong>
242
+
<ul>
243
+
<li>You need access to the email registered with your current account</li>
244
+
<li>Large accounts may take several minutes to transfer</li>
245
+
<li>Your old account will be deactivated after migration</li>
246
+
</ul>
247
+
</div>
248
+
249
+
<label class="checkbox-label">
250
+
<input type="checkbox" bind:checked={understood} />
251
+
<span>I understand the risks and want to proceed with migration</span>
252
+
</label>
253
+
254
+
<div class="button-row">
255
+
<button class="ghost" onclick={onBack}>Cancel</button>
256
+
<button disabled={!understood} onclick={() => flow.setStep('source-login')}>
257
+
Continue
258
+
</button>
259
+
</div>
260
+
</div>
261
+
262
+
{:else if flow.state.step === 'source-login'}
263
+
<div class="step-content">
264
+
<h2>{isResumedMigration ? 'Resume Migration' : 'Log In to Your Current PDS'}</h2>
265
+
<p>{isResumedMigration ? 'Enter your credentials to continue the migration.' : 'Enter your credentials for the account you want to migrate.'}</p>
266
+
267
+
{#if isResumedMigration}
268
+
<div class="info-box">
269
+
<p>Your migration was interrupted. Log in to both accounts to resume.</p>
270
+
<p class="hint" style="margin-top: 8px;">Migrating: <strong>{flow.state.sourceHandle}</strong> → <strong>{flow.state.targetHandle}</strong></p>
271
+
</div>
272
+
{/if}
273
+
274
+
<form onsubmit={handleLogin}>
275
+
<div class="field">
276
+
<label for="handle">{isResumedMigration ? 'Old Account Handle' : 'Handle'}</label>
277
+
<input
278
+
id="handle"
279
+
type="text"
280
+
placeholder="alice.bsky.social"
281
+
bind:value={handleInput}
282
+
disabled={loading}
283
+
required
284
+
/>
285
+
<p class="hint">Your current handle on your existing PDS</p>
286
+
</div>
287
+
288
+
<div class="field">
289
+
<label for="password">{isResumedMigration ? 'Old Account Password' : 'Password'}</label>
290
+
<input
291
+
id="password"
292
+
type="password"
293
+
bind:value={passwordInput}
294
+
disabled={loading}
295
+
required
296
+
/>
297
+
<p class="hint">Your account password (not an app password)</p>
298
+
</div>
299
+
300
+
{#if flow.state.requires2FA}
301
+
<div class="field">
302
+
<label for="2fa">Two-Factor Code</label>
303
+
<input
304
+
id="2fa"
305
+
type="text"
306
+
placeholder="Enter code from email"
307
+
bind:value={flow.state.twoFactorCode}
308
+
disabled={loading}
309
+
required
310
+
/>
311
+
<p class="hint">Check your email for the verification code</p>
312
+
</div>
313
+
{/if}
314
+
315
+
{#if isResumedMigration}
316
+
<hr style="margin: 24px 0; border: none; border-top: 1px solid var(--border);" />
317
+
318
+
<div class="field">
319
+
<label for="local-password">New Account Password</label>
320
+
<input
321
+
id="local-password"
322
+
type="password"
323
+
placeholder="Password for your new account"
324
+
bind:value={localPasswordInput}
325
+
disabled={loading}
326
+
required
327
+
/>
328
+
<p class="hint">The password you set for your account on this PDS</p>
329
+
</div>
330
+
{/if}
331
+
332
+
<div class="button-row">
333
+
<button type="button" class="ghost" onclick={onBack} disabled={loading}>Back</button>
334
+
<button type="submit" disabled={loading}>
335
+
{loading ? 'Logging in...' : (isResumedMigration ? 'Continue Migration' : 'Log In')}
336
+
</button>
337
+
</div>
338
+
</form>
339
+
</div>
340
+
341
+
{:else if flow.state.step === 'choose-handle'}
342
+
<div class="step-content">
343
+
<h2>Choose Your New Handle</h2>
344
+
<p>Select a handle for your account on this PDS.</p>
345
+
346
+
<div class="current-info">
347
+
<span class="label">Migrating from:</span>
348
+
<span class="value">{flow.state.sourceHandle}</span>
349
+
</div>
350
+
351
+
<div class="field">
352
+
<label for="new-handle">New Handle</label>
353
+
<div class="handle-input-group">
354
+
<input
355
+
id="new-handle"
356
+
type="text"
357
+
placeholder="username"
358
+
bind:value={handleInput}
359
+
onblur={checkHandle}
360
+
/>
361
+
{#if serverInfo && serverInfo.availableUserDomains.length > 0 && !handleInput.includes('.')}
362
+
<select bind:value={selectedDomain}>
363
+
{#each serverInfo.availableUserDomains as domain}
364
+
<option value={domain}>.{domain}</option>
365
+
{/each}
366
+
</select>
367
+
{/if}
368
+
</div>
369
+
370
+
{#if checkingHandle}
371
+
<p class="hint">Checking availability...</p>
372
+
{:else if handleAvailable === true}
373
+
<p class="hint success">Handle is available!</p>
374
+
{:else if handleAvailable === false}
375
+
<p class="hint error">Handle is already taken</p>
376
+
{:else}
377
+
<p class="hint">You can also use your own domain by entering the full handle (e.g., alice.mydomain.com)</p>
378
+
{/if}
379
+
</div>
380
+
381
+
<div class="field">
382
+
<label for="email">Email Address</label>
383
+
<input
384
+
id="email"
385
+
type="email"
386
+
placeholder="you@example.com"
387
+
bind:value={flow.state.targetEmail}
388
+
oninput={(e) => flow.updateField('targetEmail', (e.target as HTMLInputElement).value)}
389
+
required
390
+
/>
391
+
</div>
392
+
393
+
<div class="field">
394
+
<label for="new-password">Password</label>
395
+
<input
396
+
id="new-password"
397
+
type="password"
398
+
placeholder="Password for your new account"
399
+
bind:value={flow.state.targetPassword}
400
+
oninput={(e) => flow.updateField('targetPassword', (e.target as HTMLInputElement).value)}
401
+
required
402
+
minlength="8"
403
+
/>
404
+
<p class="hint">At least 8 characters</p>
405
+
</div>
406
+
407
+
{#if serverInfo?.inviteCodeRequired}
408
+
<div class="field">
409
+
<label for="invite">Invite Code</label>
410
+
<input
411
+
id="invite"
412
+
type="text"
413
+
placeholder="Enter invite code"
414
+
bind:value={flow.state.inviteCode}
415
+
oninput={(e) => flow.updateField('inviteCode', (e.target as HTMLInputElement).value)}
416
+
required
417
+
/>
418
+
</div>
419
+
{/if}
420
+
421
+
<div class="button-row">
422
+
<button class="ghost" onclick={() => flow.setStep('source-login')}>Back</button>
423
+
<button
424
+
disabled={!handleInput.trim() || !flow.state.targetEmail || !flow.state.targetPassword || handleAvailable === false}
425
+
onclick={proceedToReview}
426
+
>
427
+
Continue
428
+
</button>
429
+
</div>
430
+
</div>
431
+
432
+
{:else if flow.state.step === 'review'}
433
+
<div class="step-content">
434
+
<h2>Review Migration</h2>
435
+
<p>Please confirm the details of your migration.</p>
436
+
437
+
<div class="review-card">
438
+
<div class="review-row">
439
+
<span class="label">Current Handle:</span>
440
+
<span class="value">{flow.state.sourceHandle}</span>
441
+
</div>
442
+
<div class="review-row">
443
+
<span class="label">New Handle:</span>
444
+
<span class="value">{flow.state.targetHandle}</span>
445
+
</div>
446
+
<div class="review-row">
447
+
<span class="label">DID:</span>
448
+
<span class="value mono">{flow.state.sourceDid}</span>
449
+
</div>
450
+
<div class="review-row">
451
+
<span class="label">From PDS:</span>
452
+
<span class="value">{flow.state.sourcePdsUrl}</span>
453
+
</div>
454
+
<div class="review-row">
455
+
<span class="label">To PDS:</span>
456
+
<span class="value">{window.location.origin}</span>
457
+
</div>
458
+
<div class="review-row">
459
+
<span class="label">Email:</span>
460
+
<span class="value">{flow.state.targetEmail}</span>
461
+
</div>
462
+
</div>
463
+
464
+
<div class="warning-box">
465
+
<strong>Final confirmation:</strong> After you click "Start Migration", your repository and data will begin
466
+
transferring. This process cannot be easily undone.
467
+
</div>
468
+
469
+
<div class="button-row">
470
+
<button class="ghost" onclick={() => flow.setStep('choose-handle')} disabled={loading}>Back</button>
471
+
<button onclick={startMigration} disabled={loading}>
472
+
{loading ? 'Starting...' : 'Start Migration'}
473
+
</button>
474
+
</div>
475
+
</div>
476
+
477
+
{:else if flow.state.step === 'migrating'}
478
+
<div class="step-content">
479
+
<h2>Migration in Progress</h2>
480
+
<p>Please wait while your account is being transferred...</p>
481
+
482
+
<div class="progress-section">
483
+
<div class="progress-item" class:completed={flow.state.progress.repoExported}>
484
+
<span class="icon">{flow.state.progress.repoExported ? '✓' : '○'}</span>
485
+
<span>Export repository</span>
486
+
</div>
487
+
<div class="progress-item" class:completed={flow.state.progress.repoImported}>
488
+
<span class="icon">{flow.state.progress.repoImported ? '✓' : '○'}</span>
489
+
<span>Import repository</span>
490
+
</div>
491
+
<div class="progress-item" class:active={flow.state.progress.repoImported && !flow.state.progress.prefsMigrated}>
492
+
<span class="icon">{flow.state.progress.blobsMigrated === flow.state.progress.blobsTotal && flow.state.progress.blobsTotal > 0 ? '✓' : '○'}</span>
493
+
<span>Migrate blobs ({flow.state.progress.blobsMigrated}/{flow.state.progress.blobsTotal})</span>
494
+
</div>
495
+
<div class="progress-item" class:completed={flow.state.progress.prefsMigrated}>
496
+
<span class="icon">{flow.state.progress.prefsMigrated ? '✓' : '○'}</span>
497
+
<span>Migrate preferences</span>
498
+
</div>
499
+
</div>
500
+
501
+
{#if flow.state.progress.blobsTotal > 0}
502
+
<div class="progress-bar">
503
+
<div
504
+
class="progress-fill"
505
+
style="width: {(flow.state.progress.blobsMigrated / flow.state.progress.blobsTotal) * 100}%"
506
+
></div>
507
+
</div>
508
+
{/if}
509
+
510
+
<p class="status-text">{flow.state.progress.currentOperation}</p>
511
+
</div>
512
+
513
+
{:else if flow.state.step === 'email-verify'}
514
+
<div class="step-content">
515
+
<h2>{$_('migration.inbound.emailVerify.title')}</h2>
516
+
<p>{@html $_('migration.inbound.emailVerify.desc', { values: { email: `<strong>${flow.state.targetEmail}</strong>` } })}</p>
517
+
518
+
<div class="info-box">
519
+
<p>
520
+
{$_('migration.inbound.emailVerify.hint')}
521
+
</p>
522
+
</div>
523
+
524
+
{#if flow.state.error}
525
+
<div class="error-box">
526
+
{flow.state.error}
527
+
</div>
528
+
{/if}
529
+
530
+
<form onsubmit={submitEmailVerify}>
531
+
<div class="field">
532
+
<label for="email-verify-token">{$_('migration.inbound.emailVerify.tokenLabel')}</label>
533
+
<input
534
+
id="email-verify-token"
535
+
type="text"
536
+
placeholder={$_('migration.inbound.emailVerify.tokenPlaceholder')}
537
+
bind:value={flow.state.emailVerifyToken}
538
+
oninput={(e) => flow.updateField('emailVerifyToken', (e.target as HTMLInputElement).value)}
539
+
disabled={loading}
540
+
required
541
+
/>
542
+
</div>
543
+
544
+
<div class="button-row">
545
+
<button type="button" class="ghost" onclick={resendEmailVerify} disabled={loading}>
546
+
{$_('migration.inbound.emailVerify.resend')}
547
+
</button>
548
+
<button type="submit" disabled={loading || !flow.state.emailVerifyToken}>
549
+
{loading ? $_('migration.inbound.emailVerify.verifying') : $_('migration.inbound.emailVerify.verify')}
550
+
</button>
551
+
</div>
552
+
</form>
553
+
</div>
554
+
555
+
{:else if flow.state.step === 'plc-token'}
556
+
<div class="step-content">
557
+
<h2>Verify Migration</h2>
558
+
<p>A verification code has been sent to the email registered with your old account.</p>
559
+
560
+
<div class="info-box">
561
+
<p>
562
+
This code confirms you have access to the account and authorizes updating your identity
563
+
to point to this PDS.
564
+
</p>
565
+
</div>
566
+
567
+
<form onsubmit={submitPlcToken}>
568
+
<div class="field">
569
+
<label for="plc-token">Verification Code</label>
570
+
<input
571
+
id="plc-token"
572
+
type="text"
573
+
placeholder="Enter code from email"
574
+
bind:value={flow.state.plcToken}
575
+
oninput={(e) => flow.updateField('plcToken', (e.target as HTMLInputElement).value)}
576
+
disabled={loading}
577
+
required
578
+
/>
579
+
</div>
580
+
581
+
<div class="button-row">
582
+
<button type="button" class="ghost" onclick={resendToken} disabled={loading}>
583
+
Resend Code
584
+
</button>
585
+
<button type="submit" disabled={loading || !flow.state.plcToken}>
586
+
{loading ? 'Verifying...' : 'Complete Migration'}
587
+
</button>
588
+
</div>
589
+
</form>
590
+
</div>
591
+
592
+
{:else if flow.state.step === 'finalizing'}
593
+
<div class="step-content">
594
+
<h2>Finalizing Migration</h2>
595
+
<p>Please wait while we complete the migration...</p>
596
+
597
+
<div class="progress-section">
598
+
<div class="progress-item" class:completed={flow.state.progress.plcSigned}>
599
+
<span class="icon">{flow.state.progress.plcSigned ? '✓' : '○'}</span>
600
+
<span>Sign identity update</span>
601
+
</div>
602
+
<div class="progress-item" class:completed={flow.state.progress.activated}>
603
+
<span class="icon">{flow.state.progress.activated ? '✓' : '○'}</span>
604
+
<span>Activate new account</span>
605
+
</div>
606
+
<div class="progress-item" class:completed={flow.state.progress.deactivated}>
607
+
<span class="icon">{flow.state.progress.deactivated ? '✓' : '○'}</span>
608
+
<span>Deactivate old account</span>
609
+
</div>
610
+
</div>
611
+
612
+
<p class="status-text">{flow.state.progress.currentOperation}</p>
613
+
</div>
614
+
615
+
{:else if flow.state.step === 'success'}
616
+
<div class="step-content success-content">
617
+
<div class="success-icon">✓</div>
618
+
<h2>Migration Complete!</h2>
619
+
<p>Your account has been successfully migrated to this PDS.</p>
620
+
621
+
<div class="success-details">
622
+
<div class="detail-row">
623
+
<span class="label">Your new handle:</span>
624
+
<span class="value">{flow.state.targetHandle}</span>
625
+
</div>
626
+
<div class="detail-row">
627
+
<span class="label">DID:</span>
628
+
<span class="value mono">{flow.state.sourceDid}</span>
629
+
</div>
630
+
</div>
631
+
632
+
{#if flow.state.progress.blobsFailed.length > 0}
633
+
<div class="warning-box">
634
+
<strong>Note:</strong> {flow.state.progress.blobsFailed.length} blobs could not be migrated.
635
+
These may be images or other media that are no longer available.
636
+
</div>
637
+
{/if}
638
+
639
+
<p class="redirect-text">Redirecting to dashboard...</p>
640
+
</div>
641
+
642
+
{:else if flow.state.step === 'error'}
643
+
<div class="step-content">
644
+
<h2>Migration Error</h2>
645
+
<p>An error occurred during migration.</p>
646
+
647
+
<div class="error-box">
648
+
{flow.state.error}
649
+
</div>
650
+
651
+
<div class="button-row">
652
+
<button class="ghost" onclick={onBack}>Start Over</button>
653
+
</div>
654
+
</div>
655
+
{/if}
656
+
</div>
657
+
658
+
<style>
659
+
.inbound-wizard {
660
+
max-width: 600px;
661
+
margin: 0 auto;
662
+
}
663
+
664
+
.step-indicator {
665
+
display: flex;
666
+
align-items: center;
667
+
justify-content: center;
668
+
margin-bottom: var(--space-8);
669
+
padding: 0 var(--space-4);
670
+
}
671
+
672
+
.step {
673
+
display: flex;
674
+
flex-direction: column;
675
+
align-items: center;
676
+
gap: var(--space-2);
677
+
}
678
+
679
+
.step-dot {
680
+
width: 32px;
681
+
height: 32px;
682
+
border-radius: 50%;
683
+
background: var(--bg-secondary);
684
+
border: 2px solid var(--border);
685
+
display: flex;
686
+
align-items: center;
687
+
justify-content: center;
688
+
font-size: var(--text-sm);
689
+
font-weight: var(--font-medium);
690
+
color: var(--text-secondary);
691
+
}
692
+
693
+
.step.active .step-dot {
694
+
background: var(--accent);
695
+
border-color: var(--accent);
696
+
color: var(--text-inverse);
697
+
}
698
+
699
+
.step.completed .step-dot {
700
+
background: var(--success-bg);
701
+
border-color: var(--success-text);
702
+
color: var(--success-text);
703
+
}
704
+
705
+
.step-label {
706
+
font-size: var(--text-xs);
707
+
color: var(--text-secondary);
708
+
}
709
+
710
+
.step.active .step-label {
711
+
color: var(--accent);
712
+
font-weight: var(--font-medium);
713
+
}
714
+
715
+
.step-line {
716
+
flex: 1;
717
+
height: 2px;
718
+
background: var(--border);
719
+
margin: 0 var(--space-2);
720
+
margin-bottom: var(--space-6);
721
+
min-width: 20px;
722
+
}
723
+
724
+
.step-line.completed {
725
+
background: var(--success-text);
726
+
}
727
+
728
+
.step-content {
729
+
background: var(--bg-secondary);
730
+
border-radius: var(--radius-xl);
731
+
padding: var(--space-6);
732
+
}
733
+
734
+
.step-content h2 {
735
+
margin: 0 0 var(--space-3) 0;
736
+
}
737
+
738
+
.step-content > p {
739
+
color: var(--text-secondary);
740
+
margin: 0 0 var(--space-5) 0;
741
+
}
742
+
743
+
.info-box {
744
+
background: var(--accent-muted);
745
+
border: 1px solid var(--accent);
746
+
border-radius: var(--radius-lg);
747
+
padding: var(--space-5);
748
+
margin-bottom: var(--space-5);
749
+
}
750
+
751
+
.info-box h3 {
752
+
margin: 0 0 var(--space-3) 0;
753
+
font-size: var(--text-base);
754
+
}
755
+
756
+
.info-box ol, .info-box ul {
757
+
margin: 0;
758
+
padding-left: var(--space-5);
759
+
}
760
+
761
+
.info-box li {
762
+
margin-bottom: var(--space-2);
763
+
color: var(--text-secondary);
764
+
}
765
+
766
+
.info-box p {
767
+
margin: 0;
768
+
color: var(--text-secondary);
769
+
}
770
+
771
+
.warning-box {
772
+
background: var(--warning-bg);
773
+
border: 1px solid var(--warning-border);
774
+
border-radius: var(--radius-lg);
775
+
padding: var(--space-5);
776
+
margin-bottom: var(--space-5);
777
+
font-size: var(--text-sm);
778
+
}
779
+
780
+
.warning-box strong {
781
+
color: var(--warning-text);
782
+
}
783
+
784
+
.warning-box ul {
785
+
margin: var(--space-3) 0 0 0;
786
+
padding-left: var(--space-5);
787
+
}
788
+
789
+
.error-box {
790
+
background: var(--error-bg);
791
+
border: 1px solid var(--error-border);
792
+
border-radius: var(--radius-lg);
793
+
padding: var(--space-5);
794
+
margin-bottom: var(--space-5);
795
+
color: var(--error-text);
796
+
}
797
+
798
+
.checkbox-label {
799
+
display: inline-flex;
800
+
align-items: flex-start;
801
+
gap: var(--space-3);
802
+
cursor: pointer;
803
+
margin-bottom: var(--space-5);
804
+
text-align: left;
805
+
}
806
+
807
+
.checkbox-label input[type="checkbox"] {
808
+
width: 18px;
809
+
height: 18px;
810
+
margin: 0;
811
+
flex-shrink: 0;
812
+
}
813
+
814
+
.button-row {
815
+
display: flex;
816
+
gap: var(--space-3);
817
+
justify-content: flex-end;
818
+
margin-top: var(--space-5);
819
+
}
820
+
821
+
.field {
822
+
margin-bottom: var(--space-5);
823
+
}
824
+
825
+
.field label {
826
+
display: block;
827
+
margin-bottom: var(--space-2);
828
+
font-weight: var(--font-medium);
829
+
}
830
+
831
+
.field input, .field select {
832
+
width: 100%;
833
+
padding: var(--space-3);
834
+
border: 1px solid var(--border);
835
+
border-radius: var(--radius-md);
836
+
background: var(--bg-primary);
837
+
color: var(--text-primary);
838
+
}
839
+
840
+
.field input:focus, .field select:focus {
841
+
outline: none;
842
+
border-color: var(--accent);
843
+
}
844
+
845
+
.hint {
846
+
font-size: var(--text-sm);
847
+
color: var(--text-secondary);
848
+
margin: var(--space-2) 0 0 0;
849
+
}
850
+
851
+
.hint.success {
852
+
color: var(--success-text);
853
+
}
854
+
855
+
.hint.error {
856
+
color: var(--error-text);
857
+
}
858
+
859
+
.handle-input-group {
860
+
display: flex;
861
+
gap: var(--space-2);
862
+
}
863
+
864
+
.handle-input-group input {
865
+
flex: 1;
866
+
}
867
+
868
+
.handle-input-group select {
869
+
width: auto;
870
+
}
871
+
872
+
.current-info {
873
+
background: var(--bg-primary);
874
+
border-radius: var(--radius-lg);
875
+
padding: var(--space-4);
876
+
margin-bottom: var(--space-5);
877
+
display: flex;
878
+
justify-content: space-between;
879
+
}
880
+
881
+
.current-info .label {
882
+
color: var(--text-secondary);
883
+
}
884
+
885
+
.current-info .value {
886
+
font-weight: var(--font-medium);
887
+
}
888
+
889
+
.review-card {
890
+
background: var(--bg-primary);
891
+
border-radius: var(--radius-lg);
892
+
padding: var(--space-4);
893
+
margin-bottom: var(--space-5);
894
+
}
895
+
896
+
.review-row {
897
+
display: flex;
898
+
justify-content: space-between;
899
+
padding: var(--space-3) 0;
900
+
border-bottom: 1px solid var(--border);
901
+
}
902
+
903
+
.review-row:last-child {
904
+
border-bottom: none;
905
+
}
906
+
907
+
.review-row .label {
908
+
color: var(--text-secondary);
909
+
}
910
+
911
+
.review-row .value {
912
+
font-weight: var(--font-medium);
913
+
text-align: right;
914
+
word-break: break-all;
915
+
}
916
+
917
+
.review-row .value.mono {
918
+
font-family: var(--font-mono);
919
+
font-size: var(--text-sm);
920
+
}
921
+
922
+
.progress-section {
923
+
margin-bottom: var(--space-5);
924
+
}
925
+
926
+
.progress-item {
927
+
display: flex;
928
+
align-items: center;
929
+
gap: var(--space-3);
930
+
padding: var(--space-3) 0;
931
+
color: var(--text-secondary);
932
+
}
933
+
934
+
.progress-item.completed {
935
+
color: var(--success-text);
936
+
}
937
+
938
+
.progress-item.active {
939
+
color: var(--accent);
940
+
}
941
+
942
+
.progress-item .icon {
943
+
width: 24px;
944
+
text-align: center;
945
+
}
946
+
947
+
.progress-bar {
948
+
height: 8px;
949
+
background: var(--bg-primary);
950
+
border-radius: 4px;
951
+
overflow: hidden;
952
+
margin-bottom: var(--space-4);
953
+
}
954
+
955
+
.progress-fill {
956
+
height: 100%;
957
+
background: var(--accent);
958
+
transition: width 0.3s ease;
959
+
}
960
+
961
+
.status-text {
962
+
text-align: center;
963
+
color: var(--text-secondary);
964
+
font-size: var(--text-sm);
965
+
}
966
+
967
+
.success-content {
968
+
text-align: center;
969
+
}
970
+
971
+
.success-icon {
972
+
width: 64px;
973
+
height: 64px;
974
+
background: var(--success-bg);
975
+
color: var(--success-text);
976
+
border-radius: 50%;
977
+
display: flex;
978
+
align-items: center;
979
+
justify-content: center;
980
+
font-size: var(--text-2xl);
981
+
margin: 0 auto var(--space-5) auto;
982
+
}
983
+
984
+
.success-details {
985
+
background: var(--bg-primary);
986
+
border-radius: var(--radius-lg);
987
+
padding: var(--space-4);
988
+
margin: var(--space-5) 0;
989
+
text-align: left;
990
+
}
991
+
992
+
.success-details .detail-row {
993
+
display: flex;
994
+
justify-content: space-between;
995
+
padding: var(--space-2) 0;
996
+
}
997
+
998
+
.success-details .label {
999
+
color: var(--text-secondary);
1000
+
}
1001
+
1002
+
.success-details .value {
1003
+
font-weight: var(--font-medium);
1004
+
}
1005
+
1006
+
.success-details .value.mono {
1007
+
font-family: var(--font-mono);
1008
+
font-size: var(--text-sm);
1009
+
}
1010
+
1011
+
.redirect-text {
1012
+
color: var(--text-secondary);
1013
+
font-style: italic;
1014
+
}
1015
+
1016
+
.message.error {
1017
+
background: var(--error-bg);
1018
+
border: 1px solid var(--error-border);
1019
+
color: var(--error-text);
1020
+
padding: var(--space-4);
1021
+
border-radius: var(--radius-lg);
1022
+
margin-bottom: var(--space-5);
1023
+
}
1024
+
</style>
+992
frontend/src/components/migration/OutboundWizard.svelte
+992
frontend/src/components/migration/OutboundWizard.svelte
···
···
1
+
<script lang="ts">
2
+
import type { OutboundMigrationFlow } from '../../lib/migration'
3
+
import type { ServerDescription } from '../../lib/migration/types'
4
+
import { getAuthState, logout } from '../../lib/auth.svelte'
5
+
6
+
interface Props {
7
+
flow: OutboundMigrationFlow
8
+
onBack: () => void
9
+
onComplete: () => void
10
+
}
11
+
12
+
let { flow, onBack, onComplete }: Props = $props()
13
+
14
+
const auth = getAuthState()
15
+
16
+
let loading = $state(false)
17
+
let understood = $state(false)
18
+
let pdsUrlInput = $state('')
19
+
let handleInput = $state('')
20
+
let selectedDomain = $state('')
21
+
let confirmFinal = $state(false)
22
+
23
+
$effect(() => {
24
+
if (flow.state.step === 'success') {
25
+
setTimeout(async () => {
26
+
await logout()
27
+
onComplete()
28
+
}, 3000)
29
+
}
30
+
})
31
+
32
+
$effect(() => {
33
+
if (flow.state.targetServerInfo?.availableUserDomains?.length) {
34
+
selectedDomain = flow.state.targetServerInfo.availableUserDomains[0]
35
+
}
36
+
})
37
+
38
+
async function validatePds(e: Event) {
39
+
e.preventDefault()
40
+
loading = true
41
+
flow.updateField('error', null)
42
+
43
+
try {
44
+
let url = pdsUrlInput.trim()
45
+
if (!url.startsWith('http://') && !url.startsWith('https://')) {
46
+
url = `https://${url}`
47
+
}
48
+
await flow.validateTargetPds(url)
49
+
flow.setStep('new-account')
50
+
} catch (err) {
51
+
flow.setError((err as Error).message)
52
+
} finally {
53
+
loading = false
54
+
}
55
+
}
56
+
57
+
function proceedToReview() {
58
+
const fullHandle = handleInput.includes('.')
59
+
? handleInput
60
+
: `${handleInput}.${selectedDomain}`
61
+
62
+
flow.updateField('targetHandle', fullHandle)
63
+
flow.setStep('review')
64
+
}
65
+
66
+
async function startMigration() {
67
+
if (!auth.session) return
68
+
loading = true
69
+
try {
70
+
await flow.startMigration(auth.session.did)
71
+
} catch (err) {
72
+
flow.setError((err as Error).message)
73
+
} finally {
74
+
loading = false
75
+
}
76
+
}
77
+
78
+
async function submitPlcToken(e: Event) {
79
+
e.preventDefault()
80
+
loading = true
81
+
try {
82
+
await flow.submitPlcToken(flow.state.plcToken)
83
+
} catch (err) {
84
+
flow.setError((err as Error).message)
85
+
} finally {
86
+
loading = false
87
+
}
88
+
}
89
+
90
+
async function resendToken() {
91
+
loading = true
92
+
try {
93
+
await flow.resendPlcToken()
94
+
flow.setError(null)
95
+
} catch (err) {
96
+
flow.setError((err as Error).message)
97
+
} finally {
98
+
loading = false
99
+
}
100
+
}
101
+
102
+
function isDidWeb(): boolean {
103
+
return auth.session?.did?.startsWith('did:web:') ?? false
104
+
}
105
+
106
+
const steps = ['Target', 'Setup', 'Review', 'Transfer', 'Verify', 'Complete']
107
+
function getCurrentStepIndex(): number {
108
+
switch (flow.state.step) {
109
+
case 'welcome': return -1
110
+
case 'target-pds': return 0
111
+
case 'new-account': return 1
112
+
case 'review': return 2
113
+
case 'migrating': return 3
114
+
case 'plc-token':
115
+
case 'finalizing': return 4
116
+
case 'success': return 5
117
+
default: return 0
118
+
}
119
+
}
120
+
</script>
121
+
122
+
<div class="outbound-wizard">
123
+
{#if flow.state.step !== 'welcome'}
124
+
<div class="step-indicator">
125
+
{#each steps as stepName, i}
126
+
<div class="step" class:active={i === getCurrentStepIndex()} class:completed={i < getCurrentStepIndex()}>
127
+
<div class="step-dot">{i < getCurrentStepIndex() ? '✓' : i + 1}</div>
128
+
<span class="step-label">{stepName}</span>
129
+
</div>
130
+
{#if i < steps.length - 1}
131
+
<div class="step-line" class:completed={i < getCurrentStepIndex()}></div>
132
+
{/if}
133
+
{/each}
134
+
</div>
135
+
{/if}
136
+
137
+
{#if flow.state.error}
138
+
<div class="message error">{flow.state.error}</div>
139
+
{/if}
140
+
141
+
{#if flow.state.step === 'welcome'}
142
+
<div class="step-content">
143
+
<h2>Migrate Your Account Away</h2>
144
+
<p>This wizard will help you move your AT Protocol account from this PDS to another one.</p>
145
+
146
+
<div class="current-account">
147
+
<span class="label">Current account:</span>
148
+
<span class="value">@{auth.session?.handle}</span>
149
+
</div>
150
+
151
+
{#if isDidWeb()}
152
+
<div class="warning-box">
153
+
<strong>did:web Migration Notice</strong>
154
+
<p>
155
+
Your account uses a did:web identifier ({auth.session?.did}). After migrating, this PDS will
156
+
continue serving your DID document with an updated service endpoint pointing to your new PDS.
157
+
</p>
158
+
<p>
159
+
You can return here anytime to update the forwarding if you migrate again in the future.
160
+
</p>
161
+
</div>
162
+
{/if}
163
+
164
+
<div class="info-box">
165
+
<h3>What will happen:</h3>
166
+
<ol>
167
+
<li>Choose your new PDS</li>
168
+
<li>Set up your account on the new server</li>
169
+
<li>Your repository and blobs will be transferred</li>
170
+
<li>Verify the migration via email</li>
171
+
<li>Your identity will be updated to point to the new PDS</li>
172
+
<li>Your account here will be deactivated</li>
173
+
</ol>
174
+
</div>
175
+
176
+
<div class="warning-box">
177
+
<strong>Before you proceed:</strong>
178
+
<ul>
179
+
<li>You need access to the email registered with this account</li>
180
+
<li>You will lose access to this account on this PDS</li>
181
+
<li>Make sure you trust the destination PDS</li>
182
+
<li>Large accounts may take several minutes to transfer</li>
183
+
</ul>
184
+
</div>
185
+
186
+
<label class="checkbox-label">
187
+
<input type="checkbox" bind:checked={understood} />
188
+
<span>I understand that my account will be moved and deactivated here</span>
189
+
</label>
190
+
191
+
<div class="button-row">
192
+
<button class="ghost" onclick={onBack}>Cancel</button>
193
+
<button disabled={!understood} onclick={() => flow.setStep('target-pds')}>
194
+
Continue
195
+
</button>
196
+
</div>
197
+
</div>
198
+
199
+
{:else if flow.state.step === 'target-pds'}
200
+
<div class="step-content">
201
+
<h2>Choose Your New PDS</h2>
202
+
<p>Enter the URL of the PDS you want to migrate to.</p>
203
+
204
+
<form onsubmit={validatePds}>
205
+
<div class="field">
206
+
<label for="pds-url">PDS URL</label>
207
+
<input
208
+
id="pds-url"
209
+
type="text"
210
+
placeholder="pds.example.com"
211
+
bind:value={pdsUrlInput}
212
+
disabled={loading}
213
+
required
214
+
/>
215
+
<p class="hint">The server address of your new PDS (e.g., bsky.social, pds.example.com)</p>
216
+
</div>
217
+
218
+
<div class="button-row">
219
+
<button type="button" class="ghost" onclick={() => flow.setStep('welcome')} disabled={loading}>Back</button>
220
+
<button type="submit" disabled={loading || !pdsUrlInput.trim()}>
221
+
{loading ? 'Checking...' : 'Connect'}
222
+
</button>
223
+
</div>
224
+
</form>
225
+
226
+
{#if flow.state.targetServerInfo}
227
+
<div class="server-info">
228
+
<h3>Connected to PDS</h3>
229
+
<div class="info-row">
230
+
<span class="label">Server:</span>
231
+
<span class="value">{flow.state.targetPdsUrl}</span>
232
+
</div>
233
+
{#if flow.state.targetServerInfo.availableUserDomains.length > 0}
234
+
<div class="info-row">
235
+
<span class="label">Available domains:</span>
236
+
<span class="value">{flow.state.targetServerInfo.availableUserDomains.join(', ')}</span>
237
+
</div>
238
+
{/if}
239
+
<div class="info-row">
240
+
<span class="label">Invite required:</span>
241
+
<span class="value">{flow.state.targetServerInfo.inviteCodeRequired ? 'Yes' : 'No'}</span>
242
+
</div>
243
+
{#if flow.state.targetServerInfo.links?.termsOfService}
244
+
<a href={flow.state.targetServerInfo.links.termsOfService} target="_blank" rel="noopener">
245
+
Terms of Service
246
+
</a>
247
+
{/if}
248
+
{#if flow.state.targetServerInfo.links?.privacyPolicy}
249
+
<a href={flow.state.targetServerInfo.links.privacyPolicy} target="_blank" rel="noopener">
250
+
Privacy Policy
251
+
</a>
252
+
{/if}
253
+
</div>
254
+
{/if}
255
+
</div>
256
+
257
+
{:else if flow.state.step === 'new-account'}
258
+
<div class="step-content">
259
+
<h2>Set Up Your New Account</h2>
260
+
<p>Configure your account details on the new PDS.</p>
261
+
262
+
<div class="current-info">
263
+
<span class="label">Migrating to:</span>
264
+
<span class="value">{flow.state.targetPdsUrl}</span>
265
+
</div>
266
+
267
+
<div class="field">
268
+
<label for="new-handle">New Handle</label>
269
+
<div class="handle-input-group">
270
+
<input
271
+
id="new-handle"
272
+
type="text"
273
+
placeholder="username"
274
+
bind:value={handleInput}
275
+
/>
276
+
{#if flow.state.targetServerInfo && flow.state.targetServerInfo.availableUserDomains.length > 0 && !handleInput.includes('.')}
277
+
<select bind:value={selectedDomain}>
278
+
{#each flow.state.targetServerInfo.availableUserDomains as domain}
279
+
<option value={domain}>.{domain}</option>
280
+
{/each}
281
+
</select>
282
+
{/if}
283
+
</div>
284
+
<p class="hint">You can also use your own domain by entering the full handle (e.g., alice.mydomain.com)</p>
285
+
</div>
286
+
287
+
<div class="field">
288
+
<label for="email">Email Address</label>
289
+
<input
290
+
id="email"
291
+
type="email"
292
+
placeholder="you@example.com"
293
+
bind:value={flow.state.targetEmail}
294
+
oninput={(e) => flow.updateField('targetEmail', (e.target as HTMLInputElement).value)}
295
+
required
296
+
/>
297
+
</div>
298
+
299
+
<div class="field">
300
+
<label for="new-password">Password</label>
301
+
<input
302
+
id="new-password"
303
+
type="password"
304
+
placeholder="Password for your new account"
305
+
bind:value={flow.state.targetPassword}
306
+
oninput={(e) => flow.updateField('targetPassword', (e.target as HTMLInputElement).value)}
307
+
required
308
+
minlength="8"
309
+
/>
310
+
<p class="hint">At least 8 characters. This will be your password on the new PDS.</p>
311
+
</div>
312
+
313
+
{#if flow.state.targetServerInfo?.inviteCodeRequired}
314
+
<div class="field">
315
+
<label for="invite">Invite Code</label>
316
+
<input
317
+
id="invite"
318
+
type="text"
319
+
placeholder="Enter invite code"
320
+
bind:value={flow.state.inviteCode}
321
+
oninput={(e) => flow.updateField('inviteCode', (e.target as HTMLInputElement).value)}
322
+
required
323
+
/>
324
+
<p class="hint">Required by this PDS to create an account</p>
325
+
</div>
326
+
{/if}
327
+
328
+
<div class="button-row">
329
+
<button class="ghost" onclick={() => flow.setStep('target-pds')}>Back</button>
330
+
<button
331
+
disabled={!handleInput.trim() || !flow.state.targetEmail || !flow.state.targetPassword}
332
+
onclick={proceedToReview}
333
+
>
334
+
Continue
335
+
</button>
336
+
</div>
337
+
</div>
338
+
339
+
{:else if flow.state.step === 'review'}
340
+
<div class="step-content">
341
+
<h2>Review Migration</h2>
342
+
<p>Please confirm the details of your migration.</p>
343
+
344
+
<div class="review-card">
345
+
<div class="review-row">
346
+
<span class="label">Current Handle:</span>
347
+
<span class="value">@{auth.session?.handle}</span>
348
+
</div>
349
+
<div class="review-row">
350
+
<span class="label">New Handle:</span>
351
+
<span class="value">@{flow.state.targetHandle}</span>
352
+
</div>
353
+
<div class="review-row">
354
+
<span class="label">DID:</span>
355
+
<span class="value mono">{auth.session?.did}</span>
356
+
</div>
357
+
<div class="review-row">
358
+
<span class="label">From PDS:</span>
359
+
<span class="value">{window.location.origin}</span>
360
+
</div>
361
+
<div class="review-row">
362
+
<span class="label">To PDS:</span>
363
+
<span class="value">{flow.state.targetPdsUrl}</span>
364
+
</div>
365
+
<div class="review-row">
366
+
<span class="label">New Email:</span>
367
+
<span class="value">{flow.state.targetEmail}</span>
368
+
</div>
369
+
</div>
370
+
371
+
<div class="warning-box final-warning">
372
+
<strong>This action cannot be easily undone!</strong>
373
+
<p>
374
+
After migration completes, your account on this PDS will be deactivated.
375
+
To return, you would need to migrate back from the new PDS.
376
+
</p>
377
+
</div>
378
+
379
+
<label class="checkbox-label">
380
+
<input type="checkbox" bind:checked={confirmFinal} />
381
+
<span>I confirm I want to migrate my account to {flow.state.targetPdsUrl}</span>
382
+
</label>
383
+
384
+
<div class="button-row">
385
+
<button class="ghost" onclick={() => flow.setStep('new-account')} disabled={loading}>Back</button>
386
+
<button class="danger" onclick={startMigration} disabled={loading || !confirmFinal}>
387
+
{loading ? 'Starting...' : 'Start Migration'}
388
+
</button>
389
+
</div>
390
+
</div>
391
+
392
+
{:else if flow.state.step === 'migrating'}
393
+
<div class="step-content">
394
+
<h2>Migration in Progress</h2>
395
+
<p>Please wait while your account is being transferred...</p>
396
+
397
+
<div class="progress-section">
398
+
<div class="progress-item" class:completed={flow.state.progress.repoExported}>
399
+
<span class="icon">{flow.state.progress.repoExported ? '✓' : '○'}</span>
400
+
<span>Export repository</span>
401
+
</div>
402
+
<div class="progress-item" class:completed={flow.state.progress.repoImported}>
403
+
<span class="icon">{flow.state.progress.repoImported ? '✓' : '○'}</span>
404
+
<span>Import repository to new PDS</span>
405
+
</div>
406
+
<div class="progress-item" class:active={flow.state.progress.repoImported && !flow.state.progress.prefsMigrated}>
407
+
<span class="icon">{flow.state.progress.blobsMigrated === flow.state.progress.blobsTotal && flow.state.progress.blobsTotal > 0 ? '✓' : '○'}</span>
408
+
<span>Migrate blobs ({flow.state.progress.blobsMigrated}/{flow.state.progress.blobsTotal})</span>
409
+
</div>
410
+
<div class="progress-item" class:completed={flow.state.progress.prefsMigrated}>
411
+
<span class="icon">{flow.state.progress.prefsMigrated ? '✓' : '○'}</span>
412
+
<span>Migrate preferences</span>
413
+
</div>
414
+
</div>
415
+
416
+
{#if flow.state.progress.blobsTotal > 0}
417
+
<div class="progress-bar">
418
+
<div
419
+
class="progress-fill"
420
+
style="width: {(flow.state.progress.blobsMigrated / flow.state.progress.blobsTotal) * 100}%"
421
+
></div>
422
+
</div>
423
+
{/if}
424
+
425
+
<p class="status-text">{flow.state.progress.currentOperation}</p>
426
+
</div>
427
+
428
+
{:else if flow.state.step === 'plc-token'}
429
+
<div class="step-content">
430
+
<h2>Verify Migration</h2>
431
+
<p>A verification code has been sent to your email ({auth.session?.email}).</p>
432
+
433
+
<div class="info-box">
434
+
<p>
435
+
This code confirms you have access to the account and authorizes updating your identity
436
+
to point to the new PDS.
437
+
</p>
438
+
</div>
439
+
440
+
<form onsubmit={submitPlcToken}>
441
+
<div class="field">
442
+
<label for="plc-token">Verification Code</label>
443
+
<input
444
+
id="plc-token"
445
+
type="text"
446
+
placeholder="Enter code from email"
447
+
bind:value={flow.state.plcToken}
448
+
oninput={(e) => flow.updateField('plcToken', (e.target as HTMLInputElement).value)}
449
+
disabled={loading}
450
+
required
451
+
/>
452
+
</div>
453
+
454
+
<div class="button-row">
455
+
<button type="button" class="ghost" onclick={resendToken} disabled={loading}>
456
+
Resend Code
457
+
</button>
458
+
<button type="submit" disabled={loading || !flow.state.plcToken}>
459
+
{loading ? 'Verifying...' : 'Complete Migration'}
460
+
</button>
461
+
</div>
462
+
</form>
463
+
</div>
464
+
465
+
{:else if flow.state.step === 'finalizing'}
466
+
<div class="step-content">
467
+
<h2>Finalizing Migration</h2>
468
+
<p>Please wait while we complete the migration...</p>
469
+
470
+
<div class="progress-section">
471
+
<div class="progress-item" class:completed={flow.state.progress.plcSigned}>
472
+
<span class="icon">{flow.state.progress.plcSigned ? '✓' : '○'}</span>
473
+
<span>Sign identity update</span>
474
+
</div>
475
+
<div class="progress-item" class:completed={flow.state.progress.activated}>
476
+
<span class="icon">{flow.state.progress.activated ? '✓' : '○'}</span>
477
+
<span>Activate account on new PDS</span>
478
+
</div>
479
+
<div class="progress-item" class:completed={flow.state.progress.deactivated}>
480
+
<span class="icon">{flow.state.progress.deactivated ? '✓' : '○'}</span>
481
+
<span>Deactivate account here</span>
482
+
</div>
483
+
</div>
484
+
485
+
<p class="status-text">{flow.state.progress.currentOperation}</p>
486
+
</div>
487
+
488
+
{:else if flow.state.step === 'success'}
489
+
<div class="step-content success-content">
490
+
<div class="success-icon">✓</div>
491
+
<h2>Migration Complete!</h2>
492
+
<p>Your account has been successfully migrated to your new PDS.</p>
493
+
494
+
<div class="success-details">
495
+
<div class="detail-row">
496
+
<span class="label">Your new handle:</span>
497
+
<span class="value">@{flow.state.targetHandle}</span>
498
+
</div>
499
+
<div class="detail-row">
500
+
<span class="label">New PDS:</span>
501
+
<span class="value">{flow.state.targetPdsUrl}</span>
502
+
</div>
503
+
<div class="detail-row">
504
+
<span class="label">DID:</span>
505
+
<span class="value mono">{auth.session?.did}</span>
506
+
</div>
507
+
</div>
508
+
509
+
{#if flow.state.progress.blobsFailed.length > 0}
510
+
<div class="warning-box">
511
+
<strong>Note:</strong> {flow.state.progress.blobsFailed.length} blobs could not be migrated.
512
+
These may be images or other media that are no longer available.
513
+
</div>
514
+
{/if}
515
+
516
+
<div class="next-steps">
517
+
<h3>Next Steps</h3>
518
+
<ol>
519
+
<li>Visit your new PDS at <a href={flow.state.targetPdsUrl} target="_blank" rel="noopener">{flow.state.targetPdsUrl}</a></li>
520
+
<li>Log in with your new credentials</li>
521
+
<li>Your followers and following will continue to work</li>
522
+
</ol>
523
+
</div>
524
+
525
+
<p class="redirect-text">Logging out in a moment...</p>
526
+
</div>
527
+
528
+
{:else if flow.state.step === 'error'}
529
+
<div class="step-content">
530
+
<h2>Migration Error</h2>
531
+
<p>An error occurred during migration.</p>
532
+
533
+
<div class="error-box">
534
+
{flow.state.error}
535
+
</div>
536
+
537
+
<div class="button-row">
538
+
<button class="ghost" onclick={onBack}>Start Over</button>
539
+
</div>
540
+
</div>
541
+
{/if}
542
+
</div>
543
+
544
+
<style>
545
+
.outbound-wizard {
546
+
max-width: 600px;
547
+
margin: 0 auto;
548
+
}
549
+
550
+
.step-indicator {
551
+
display: flex;
552
+
align-items: center;
553
+
justify-content: center;
554
+
margin-bottom: var(--space-8);
555
+
padding: 0 var(--space-4);
556
+
}
557
+
558
+
.step {
559
+
display: flex;
560
+
flex-direction: column;
561
+
align-items: center;
562
+
gap: var(--space-2);
563
+
}
564
+
565
+
.step-dot {
566
+
width: 32px;
567
+
height: 32px;
568
+
border-radius: 50%;
569
+
background: var(--bg-secondary);
570
+
border: 2px solid var(--border);
571
+
display: flex;
572
+
align-items: center;
573
+
justify-content: center;
574
+
font-size: var(--text-sm);
575
+
font-weight: var(--font-medium);
576
+
color: var(--text-secondary);
577
+
}
578
+
579
+
.step.active .step-dot {
580
+
background: var(--accent);
581
+
border-color: var(--accent);
582
+
color: var(--text-inverse);
583
+
}
584
+
585
+
.step.completed .step-dot {
586
+
background: var(--success-bg);
587
+
border-color: var(--success-text);
588
+
color: var(--success-text);
589
+
}
590
+
591
+
.step-label {
592
+
font-size: var(--text-xs);
593
+
color: var(--text-secondary);
594
+
}
595
+
596
+
.step.active .step-label {
597
+
color: var(--accent);
598
+
font-weight: var(--font-medium);
599
+
}
600
+
601
+
.step-line {
602
+
flex: 1;
603
+
height: 2px;
604
+
background: var(--border);
605
+
margin: 0 var(--space-2);
606
+
margin-bottom: var(--space-6);
607
+
min-width: 20px;
608
+
}
609
+
610
+
.step-line.completed {
611
+
background: var(--success-text);
612
+
}
613
+
614
+
.step-content {
615
+
background: var(--bg-secondary);
616
+
border-radius: var(--radius-xl);
617
+
padding: var(--space-6);
618
+
}
619
+
620
+
.step-content h2 {
621
+
margin: 0 0 var(--space-3) 0;
622
+
}
623
+
624
+
.step-content > p {
625
+
color: var(--text-secondary);
626
+
margin: 0 0 var(--space-5) 0;
627
+
}
628
+
629
+
.current-account {
630
+
background: var(--bg-primary);
631
+
border-radius: var(--radius-lg);
632
+
padding: var(--space-4);
633
+
margin-bottom: var(--space-5);
634
+
display: flex;
635
+
justify-content: space-between;
636
+
align-items: center;
637
+
}
638
+
639
+
.current-account .label {
640
+
color: var(--text-secondary);
641
+
}
642
+
643
+
.current-account .value {
644
+
font-weight: var(--font-medium);
645
+
font-size: var(--text-lg);
646
+
}
647
+
648
+
.info-box {
649
+
background: var(--accent-muted);
650
+
border: 1px solid var(--accent);
651
+
border-radius: var(--radius-lg);
652
+
padding: var(--space-5);
653
+
margin-bottom: var(--space-5);
654
+
}
655
+
656
+
.info-box h3 {
657
+
margin: 0 0 var(--space-3) 0;
658
+
font-size: var(--text-base);
659
+
}
660
+
661
+
.info-box ol, .info-box ul {
662
+
margin: 0;
663
+
padding-left: var(--space-5);
664
+
}
665
+
666
+
.info-box li {
667
+
margin-bottom: var(--space-2);
668
+
color: var(--text-secondary);
669
+
}
670
+
671
+
.info-box p {
672
+
margin: 0;
673
+
color: var(--text-secondary);
674
+
}
675
+
676
+
.warning-box {
677
+
background: var(--warning-bg);
678
+
border: 1px solid var(--warning-border);
679
+
border-radius: var(--radius-lg);
680
+
padding: var(--space-5);
681
+
margin-bottom: var(--space-5);
682
+
font-size: var(--text-sm);
683
+
}
684
+
685
+
.warning-box strong {
686
+
color: var(--warning-text);
687
+
}
688
+
689
+
.warning-box p {
690
+
margin: var(--space-3) 0 0 0;
691
+
color: var(--text-secondary);
692
+
}
693
+
694
+
.warning-box ul {
695
+
margin: var(--space-3) 0 0 0;
696
+
padding-left: var(--space-5);
697
+
}
698
+
699
+
.final-warning {
700
+
background: var(--error-bg);
701
+
border-color: var(--error-border);
702
+
}
703
+
704
+
.final-warning strong {
705
+
color: var(--error-text);
706
+
}
707
+
708
+
.error-box {
709
+
background: var(--error-bg);
710
+
border: 1px solid var(--error-border);
711
+
border-radius: var(--radius-lg);
712
+
padding: var(--space-5);
713
+
margin-bottom: var(--space-5);
714
+
color: var(--error-text);
715
+
}
716
+
717
+
.checkbox-label {
718
+
display: inline-flex;
719
+
align-items: flex-start;
720
+
gap: var(--space-3);
721
+
cursor: pointer;
722
+
margin-bottom: var(--space-5);
723
+
text-align: left;
724
+
}
725
+
726
+
.checkbox-label input[type="checkbox"] {
727
+
width: 18px;
728
+
height: 18px;
729
+
margin: 0;
730
+
flex-shrink: 0;
731
+
}
732
+
733
+
.button-row {
734
+
display: flex;
735
+
gap: var(--space-3);
736
+
justify-content: flex-end;
737
+
margin-top: var(--space-5);
738
+
}
739
+
740
+
.field {
741
+
margin-bottom: var(--space-5);
742
+
}
743
+
744
+
.field label {
745
+
display: block;
746
+
margin-bottom: var(--space-2);
747
+
font-weight: var(--font-medium);
748
+
}
749
+
750
+
.field input, .field select {
751
+
width: 100%;
752
+
padding: var(--space-3);
753
+
border: 1px solid var(--border);
754
+
border-radius: var(--radius-md);
755
+
background: var(--bg-primary);
756
+
color: var(--text-primary);
757
+
}
758
+
759
+
.field input:focus, .field select:focus {
760
+
outline: none;
761
+
border-color: var(--accent);
762
+
}
763
+
764
+
.hint {
765
+
font-size: var(--text-sm);
766
+
color: var(--text-secondary);
767
+
margin: var(--space-2) 0 0 0;
768
+
}
769
+
770
+
.handle-input-group {
771
+
display: flex;
772
+
gap: var(--space-2);
773
+
}
774
+
775
+
.handle-input-group input {
776
+
flex: 1;
777
+
}
778
+
779
+
.handle-input-group select {
780
+
width: auto;
781
+
}
782
+
783
+
.current-info {
784
+
background: var(--bg-primary);
785
+
border-radius: var(--radius-lg);
786
+
padding: var(--space-4);
787
+
margin-bottom: var(--space-5);
788
+
display: flex;
789
+
justify-content: space-between;
790
+
}
791
+
792
+
.current-info .label {
793
+
color: var(--text-secondary);
794
+
}
795
+
796
+
.current-info .value {
797
+
font-weight: var(--font-medium);
798
+
}
799
+
800
+
.server-info {
801
+
background: var(--bg-primary);
802
+
border-radius: var(--radius-lg);
803
+
padding: var(--space-4);
804
+
margin-top: var(--space-5);
805
+
}
806
+
807
+
.server-info h3 {
808
+
margin: 0 0 var(--space-3) 0;
809
+
font-size: var(--text-base);
810
+
color: var(--success-text);
811
+
}
812
+
813
+
.server-info .info-row {
814
+
display: flex;
815
+
justify-content: space-between;
816
+
padding: var(--space-2) 0;
817
+
font-size: var(--text-sm);
818
+
}
819
+
820
+
.server-info .label {
821
+
color: var(--text-secondary);
822
+
}
823
+
824
+
.server-info a {
825
+
display: inline-block;
826
+
margin-top: var(--space-2);
827
+
margin-right: var(--space-3);
828
+
color: var(--accent);
829
+
font-size: var(--text-sm);
830
+
}
831
+
832
+
.review-card {
833
+
background: var(--bg-primary);
834
+
border-radius: var(--radius-lg);
835
+
padding: var(--space-4);
836
+
margin-bottom: var(--space-5);
837
+
}
838
+
839
+
.review-row {
840
+
display: flex;
841
+
justify-content: space-between;
842
+
padding: var(--space-3) 0;
843
+
border-bottom: 1px solid var(--border);
844
+
}
845
+
846
+
.review-row:last-child {
847
+
border-bottom: none;
848
+
}
849
+
850
+
.review-row .label {
851
+
color: var(--text-secondary);
852
+
}
853
+
854
+
.review-row .value {
855
+
font-weight: var(--font-medium);
856
+
text-align: right;
857
+
word-break: break-all;
858
+
}
859
+
860
+
.review-row .value.mono {
861
+
font-family: var(--font-mono);
862
+
font-size: var(--text-sm);
863
+
}
864
+
865
+
.progress-section {
866
+
margin-bottom: var(--space-5);
867
+
}
868
+
869
+
.progress-item {
870
+
display: flex;
871
+
align-items: center;
872
+
gap: var(--space-3);
873
+
padding: var(--space-3) 0;
874
+
color: var(--text-secondary);
875
+
}
876
+
877
+
.progress-item.completed {
878
+
color: var(--success-text);
879
+
}
880
+
881
+
.progress-item.active {
882
+
color: var(--accent);
883
+
}
884
+
885
+
.progress-item .icon {
886
+
width: 24px;
887
+
text-align: center;
888
+
}
889
+
890
+
.progress-bar {
891
+
height: 8px;
892
+
background: var(--bg-primary);
893
+
border-radius: 4px;
894
+
overflow: hidden;
895
+
margin-bottom: var(--space-4);
896
+
}
897
+
898
+
.progress-fill {
899
+
height: 100%;
900
+
background: var(--accent);
901
+
transition: width 0.3s ease;
902
+
}
903
+
904
+
.status-text {
905
+
text-align: center;
906
+
color: var(--text-secondary);
907
+
font-size: var(--text-sm);
908
+
}
909
+
910
+
.success-content {
911
+
text-align: center;
912
+
}
913
+
914
+
.success-icon {
915
+
width: 64px;
916
+
height: 64px;
917
+
background: var(--success-bg);
918
+
color: var(--success-text);
919
+
border-radius: 50%;
920
+
display: flex;
921
+
align-items: center;
922
+
justify-content: center;
923
+
font-size: var(--text-2xl);
924
+
margin: 0 auto var(--space-5) auto;
925
+
}
926
+
927
+
.success-details {
928
+
background: var(--bg-primary);
929
+
border-radius: var(--radius-lg);
930
+
padding: var(--space-4);
931
+
margin: var(--space-5) 0;
932
+
text-align: left;
933
+
}
934
+
935
+
.success-details .detail-row {
936
+
display: flex;
937
+
justify-content: space-between;
938
+
padding: var(--space-2) 0;
939
+
}
940
+
941
+
.success-details .label {
942
+
color: var(--text-secondary);
943
+
}
944
+
945
+
.success-details .value {
946
+
font-weight: var(--font-medium);
947
+
}
948
+
949
+
.success-details .value.mono {
950
+
font-family: var(--font-mono);
951
+
font-size: var(--text-sm);
952
+
}
953
+
954
+
.next-steps {
955
+
background: var(--accent-muted);
956
+
border-radius: var(--radius-lg);
957
+
padding: var(--space-5);
958
+
margin: var(--space-5) 0;
959
+
text-align: left;
960
+
}
961
+
962
+
.next-steps h3 {
963
+
margin: 0 0 var(--space-3) 0;
964
+
}
965
+
966
+
.next-steps ol {
967
+
margin: 0;
968
+
padding-left: var(--space-5);
969
+
}
970
+
971
+
.next-steps li {
972
+
margin-bottom: var(--space-2);
973
+
}
974
+
975
+
.next-steps a {
976
+
color: var(--accent);
977
+
}
978
+
979
+
.redirect-text {
980
+
color: var(--text-secondary);
981
+
font-style: italic;
982
+
}
983
+
984
+
.message.error {
985
+
background: var(--error-bg);
986
+
border: 1px solid var(--error-border);
987
+
color: var(--error-text);
988
+
padding: var(--space-4);
989
+
border-radius: var(--radius-lg);
990
+
margin-bottom: var(--space-5);
991
+
}
992
+
</style>
+448
frontend/src/lib/migration/atproto-client.ts
+448
frontend/src/lib/migration/atproto-client.ts
···
···
1
+
import type {
2
+
AccountStatus,
3
+
BlobRef,
4
+
CreateAccountParams,
5
+
DidCredentials,
6
+
DidDocument,
7
+
MigrationError,
8
+
PlcOperation,
9
+
Preferences,
10
+
ServerDescription,
11
+
Session,
12
+
} from "./types";
13
+
14
+
export class AtprotoClient {
15
+
private baseUrl: string;
16
+
private accessToken: string | null = null;
17
+
18
+
constructor(pdsUrl: string) {
19
+
this.baseUrl = pdsUrl.replace(/\/$/, "");
20
+
}
21
+
22
+
setAccessToken(token: string | null) {
23
+
this.accessToken = token;
24
+
}
25
+
26
+
getAccessToken(): string | null {
27
+
return this.accessToken;
28
+
}
29
+
30
+
private async xrpc<T>(
31
+
method: string,
32
+
options?: {
33
+
httpMethod?: "GET" | "POST";
34
+
params?: Record<string, string>;
35
+
body?: unknown;
36
+
authToken?: string;
37
+
rawBody?: Uint8Array | Blob;
38
+
contentType?: string;
39
+
},
40
+
): Promise<T> {
41
+
const {
42
+
httpMethod = "GET",
43
+
params,
44
+
body,
45
+
authToken,
46
+
rawBody,
47
+
contentType,
48
+
} = options ?? {};
49
+
50
+
let url = `${this.baseUrl}/xrpc/${method}`;
51
+
if (params) {
52
+
const searchParams = new URLSearchParams(params);
53
+
url += `?${searchParams}`;
54
+
}
55
+
56
+
const headers: Record<string, string> = {};
57
+
const token = authToken ?? this.accessToken;
58
+
if (token) {
59
+
headers["Authorization"] = `Bearer ${token}`;
60
+
}
61
+
62
+
let requestBody: BodyInit | undefined;
63
+
if (rawBody) {
64
+
headers["Content-Type"] = contentType ?? "application/octet-stream";
65
+
requestBody = rawBody;
66
+
} else if (body) {
67
+
headers["Content-Type"] = "application/json";
68
+
requestBody = JSON.stringify(body);
69
+
} else if (httpMethod === "POST") {
70
+
headers["Content-Type"] = "application/json";
71
+
}
72
+
73
+
const res = await fetch(url, {
74
+
method: httpMethod,
75
+
headers,
76
+
body: requestBody,
77
+
});
78
+
79
+
if (!res.ok) {
80
+
const err = await res.json().catch(() => ({
81
+
error: "Unknown",
82
+
message: res.statusText,
83
+
}));
84
+
const error = new Error(err.message) as Error & {
85
+
status: number;
86
+
error: string;
87
+
};
88
+
error.status = res.status;
89
+
error.error = err.error;
90
+
throw error;
91
+
}
92
+
93
+
const responseContentType = res.headers.get("content-type") ?? "";
94
+
if (responseContentType.includes("application/json")) {
95
+
return res.json();
96
+
}
97
+
return res.arrayBuffer().then((buf) => new Uint8Array(buf)) as T;
98
+
}
99
+
100
+
async login(
101
+
identifier: string,
102
+
password: string,
103
+
authFactorToken?: string,
104
+
): Promise<Session> {
105
+
const body: Record<string, string> = { identifier, password };
106
+
if (authFactorToken) {
107
+
body.authFactorToken = authFactorToken;
108
+
}
109
+
110
+
const session = await this.xrpc<Session>("com.atproto.server.createSession", {
111
+
httpMethod: "POST",
112
+
body,
113
+
});
114
+
115
+
this.accessToken = session.accessJwt;
116
+
return session;
117
+
}
118
+
119
+
async refreshSession(refreshJwt: string): Promise<Session> {
120
+
const session = await this.xrpc<Session>(
121
+
"com.atproto.server.refreshSession",
122
+
{
123
+
httpMethod: "POST",
124
+
authToken: refreshJwt,
125
+
},
126
+
);
127
+
this.accessToken = session.accessJwt;
128
+
return session;
129
+
}
130
+
131
+
async describeServer(): Promise<ServerDescription> {
132
+
return this.xrpc<ServerDescription>("com.atproto.server.describeServer");
133
+
}
134
+
135
+
async getServiceAuth(
136
+
aud: string,
137
+
lxm?: string,
138
+
): Promise<{ token: string }> {
139
+
const params: Record<string, string> = { aud };
140
+
if (lxm) {
141
+
params.lxm = lxm;
142
+
}
143
+
return this.xrpc("com.atproto.server.getServiceAuth", { params });
144
+
}
145
+
146
+
async getRepo(did: string): Promise<Uint8Array> {
147
+
return this.xrpc("com.atproto.sync.getRepo", {
148
+
params: { did },
149
+
});
150
+
}
151
+
152
+
async listBlobs(
153
+
did: string,
154
+
cursor?: string,
155
+
limit = 100,
156
+
): Promise<{ cids: string[]; cursor?: string }> {
157
+
const params: Record<string, string> = { did, limit: String(limit) };
158
+
if (cursor) {
159
+
params.cursor = cursor;
160
+
}
161
+
return this.xrpc("com.atproto.sync.listBlobs", { params });
162
+
}
163
+
164
+
async getBlob(did: string, cid: string): Promise<Uint8Array> {
165
+
return this.xrpc("com.atproto.sync.getBlob", {
166
+
params: { did, cid },
167
+
});
168
+
}
169
+
170
+
async uploadBlob(
171
+
data: Uint8Array,
172
+
mimeType: string,
173
+
): Promise<{ blob: BlobRef }> {
174
+
return this.xrpc("com.atproto.repo.uploadBlob", {
175
+
httpMethod: "POST",
176
+
rawBody: data,
177
+
contentType: mimeType,
178
+
});
179
+
}
180
+
181
+
async getPreferences(): Promise<Preferences> {
182
+
return this.xrpc("app.bsky.actor.getPreferences");
183
+
}
184
+
185
+
async putPreferences(preferences: Preferences): Promise<void> {
186
+
await this.xrpc("app.bsky.actor.putPreferences", {
187
+
httpMethod: "POST",
188
+
body: preferences,
189
+
});
190
+
}
191
+
192
+
async createAccount(
193
+
params: CreateAccountParams,
194
+
serviceToken?: string,
195
+
): Promise<Session> {
196
+
const headers: Record<string, string> = {
197
+
"Content-Type": "application/json",
198
+
};
199
+
if (serviceToken) {
200
+
headers["Authorization"] = `Bearer ${serviceToken}`;
201
+
}
202
+
203
+
const res = await fetch(
204
+
`${this.baseUrl}/xrpc/com.atproto.server.createAccount`,
205
+
{
206
+
method: "POST",
207
+
headers,
208
+
body: JSON.stringify(params),
209
+
},
210
+
);
211
+
212
+
if (!res.ok) {
213
+
const err = await res.json().catch(() => ({
214
+
error: "Unknown",
215
+
message: res.statusText,
216
+
}));
217
+
const error = new Error(err.message) as Error & {
218
+
status: number;
219
+
error: string;
220
+
};
221
+
error.status = res.status;
222
+
error.error = err.error;
223
+
throw error;
224
+
}
225
+
226
+
const session = (await res.json()) as Session;
227
+
this.accessToken = session.accessJwt;
228
+
return session;
229
+
}
230
+
231
+
async importRepo(car: Uint8Array): Promise<void> {
232
+
await this.xrpc("com.atproto.repo.importRepo", {
233
+
httpMethod: "POST",
234
+
rawBody: car,
235
+
contentType: "application/vnd.ipld.car",
236
+
});
237
+
}
238
+
239
+
async listMissingBlobs(
240
+
cursor?: string,
241
+
limit = 100,
242
+
): Promise<{ blobs: Array<{ cid: string; recordUri: string }>; cursor?: string }> {
243
+
const params: Record<string, string> = { limit: String(limit) };
244
+
if (cursor) {
245
+
params.cursor = cursor;
246
+
}
247
+
return this.xrpc("com.atproto.repo.listMissingBlobs", { params });
248
+
}
249
+
250
+
async requestPlcOperationSignature(): Promise<void> {
251
+
await this.xrpc("com.atproto.identity.requestPlcOperationSignature", {
252
+
httpMethod: "POST",
253
+
});
254
+
}
255
+
256
+
async signPlcOperation(params: {
257
+
token?: string;
258
+
rotationKeys?: string[];
259
+
alsoKnownAs?: string[];
260
+
verificationMethods?: { atproto?: string };
261
+
services?: { atproto_pds?: { type: string; endpoint: string } };
262
+
}): Promise<{ operation: PlcOperation }> {
263
+
return this.xrpc("com.atproto.identity.signPlcOperation", {
264
+
httpMethod: "POST",
265
+
body: params,
266
+
});
267
+
}
268
+
269
+
async submitPlcOperation(operation: PlcOperation): Promise<void> {
270
+
await this.xrpc("com.atproto.identity.submitPlcOperation", {
271
+
httpMethod: "POST",
272
+
body: { operation },
273
+
});
274
+
}
275
+
276
+
async getRecommendedDidCredentials(): Promise<DidCredentials> {
277
+
return this.xrpc("com.atproto.identity.getRecommendedDidCredentials");
278
+
}
279
+
280
+
async activateAccount(): Promise<void> {
281
+
await this.xrpc("com.atproto.server.activateAccount", {
282
+
httpMethod: "POST",
283
+
});
284
+
}
285
+
286
+
async deactivateAccount(): Promise<void> {
287
+
await this.xrpc("com.atproto.server.deactivateAccount", {
288
+
httpMethod: "POST",
289
+
});
290
+
}
291
+
292
+
async checkAccountStatus(): Promise<AccountStatus> {
293
+
return this.xrpc("com.atproto.server.checkAccountStatus");
294
+
}
295
+
296
+
async getMigrationStatus(): Promise<{
297
+
did: string;
298
+
didType: string;
299
+
migrated: boolean;
300
+
migratedToPds?: string;
301
+
migratedAt?: string;
302
+
}> {
303
+
return this.xrpc("com.tranquil.account.getMigrationStatus");
304
+
}
305
+
306
+
async updateMigrationForwarding(pdsUrl: string): Promise<{
307
+
success: boolean;
308
+
migratedToPds: string;
309
+
migratedAt: string;
310
+
}> {
311
+
return this.xrpc("com.tranquil.account.updateMigrationForwarding", {
312
+
httpMethod: "POST",
313
+
body: { pdsUrl },
314
+
});
315
+
}
316
+
317
+
async clearMigrationForwarding(): Promise<{ success: boolean }> {
318
+
return this.xrpc("com.tranquil.account.clearMigrationForwarding", {
319
+
httpMethod: "POST",
320
+
});
321
+
}
322
+
323
+
async resolveHandle(handle: string): Promise<{ did: string }> {
324
+
return this.xrpc("com.atproto.identity.resolveHandle", {
325
+
params: { handle },
326
+
});
327
+
}
328
+
329
+
async loginDeactivated(
330
+
identifier: string,
331
+
password: string,
332
+
): Promise<Session> {
333
+
const session = await this.xrpc<Session>("com.atproto.server.createSession", {
334
+
httpMethod: "POST",
335
+
body: { identifier, password, allowDeactivated: true },
336
+
});
337
+
this.accessToken = session.accessJwt;
338
+
return session;
339
+
}
340
+
341
+
async verifyToken(
342
+
token: string,
343
+
identifier: string,
344
+
): Promise<{ success: boolean; did: string; purpose: string; channel: string }> {
345
+
return this.xrpc("com.tranquil.account.verifyToken", {
346
+
httpMethod: "POST",
347
+
body: { token, identifier },
348
+
});
349
+
}
350
+
351
+
async resendMigrationVerification(): Promise<void> {
352
+
await this.xrpc("com.atproto.server.resendMigrationVerification", {
353
+
httpMethod: "POST",
354
+
});
355
+
}
356
+
}
357
+
358
+
export async function resolveDidDocument(did: string): Promise<DidDocument> {
359
+
if (did.startsWith("did:plc:")) {
360
+
const res = await fetch(`https://plc.directory/${did}`);
361
+
if (!res.ok) {
362
+
throw new Error(`Failed to resolve DID: ${res.statusText}`);
363
+
}
364
+
return res.json();
365
+
}
366
+
367
+
if (did.startsWith("did:web:")) {
368
+
const domain = did.slice(8).replace(/%3A/g, ":");
369
+
const url = domain.includes("/")
370
+
? `https://${domain}/did.json`
371
+
: `https://${domain}/.well-known/did.json`;
372
+
373
+
const res = await fetch(url);
374
+
if (!res.ok) {
375
+
throw new Error(`Failed to resolve DID: ${res.statusText}`);
376
+
}
377
+
return res.json();
378
+
}
379
+
380
+
throw new Error(`Unsupported DID method: ${did}`);
381
+
}
382
+
383
+
export async function resolvePdsUrl(
384
+
handleOrDid: string,
385
+
): Promise<{ did: string; pdsUrl: string }> {
386
+
let did: string;
387
+
388
+
if (handleOrDid.startsWith("did:")) {
389
+
did = handleOrDid;
390
+
} else {
391
+
const handle = handleOrDid.replace(/^@/, "");
392
+
393
+
if (handle.endsWith(".bsky.social")) {
394
+
const res = await fetch(
395
+
`https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`,
396
+
);
397
+
if (!res.ok) {
398
+
throw new Error(`Failed to resolve handle: ${res.statusText}`);
399
+
}
400
+
const data = await res.json();
401
+
did = data.did;
402
+
} else {
403
+
const dnsRes = await fetch(
404
+
`https://dns.google/resolve?name=_atproto.${handle}&type=TXT`,
405
+
);
406
+
if (dnsRes.ok) {
407
+
const dnsData = await dnsRes.json();
408
+
const txtRecords = dnsData.Answer ?? [];
409
+
for (const record of txtRecords) {
410
+
const txt = record.data?.replace(/"/g, "") ?? "";
411
+
if (txt.startsWith("did=")) {
412
+
did = txt.slice(4);
413
+
break;
414
+
}
415
+
}
416
+
}
417
+
418
+
if (!did) {
419
+
const wellKnownRes = await fetch(
420
+
`https://${handle}/.well-known/atproto-did`,
421
+
);
422
+
if (wellKnownRes.ok) {
423
+
did = (await wellKnownRes.text()).trim();
424
+
}
425
+
}
426
+
427
+
if (!did) {
428
+
throw new Error(`Could not resolve handle: ${handle}`);
429
+
}
430
+
}
431
+
}
432
+
433
+
const didDoc = await resolveDidDocument(did);
434
+
435
+
const pdsService = didDoc.service?.find(
436
+
(s: { type: string }) => s.type === "AtprotoPersonalDataServer",
437
+
);
438
+
439
+
if (!pdsService) {
440
+
throw new Error("No PDS service found in DID document");
441
+
}
442
+
443
+
return { did, pdsUrl: pdsService.serviceEndpoint };
444
+
}
445
+
446
+
export function createLocalClient(): AtprotoClient {
447
+
return new AtprotoClient(window.location.origin);
448
+
}
+734
frontend/src/lib/migration/flow.svelte.ts
+734
frontend/src/lib/migration/flow.svelte.ts
···
···
1
+
import type {
2
+
InboundMigrationState,
3
+
InboundStep,
4
+
MigrationProgress,
5
+
OutboundMigrationState,
6
+
OutboundStep,
7
+
ServerDescription,
8
+
StoredMigrationState,
9
+
} from "./types";
10
+
import {
11
+
AtprotoClient,
12
+
createLocalClient,
13
+
resolvePdsUrl,
14
+
} from "./atproto-client";
15
+
import {
16
+
clearMigrationState,
17
+
loadMigrationState,
18
+
saveMigrationState,
19
+
updateProgress,
20
+
updateStep,
21
+
} from "./storage";
22
+
23
+
function createInitialProgress(): MigrationProgress {
24
+
return {
25
+
repoExported: false,
26
+
repoImported: false,
27
+
blobsTotal: 0,
28
+
blobsMigrated: 0,
29
+
blobsFailed: [],
30
+
prefsMigrated: false,
31
+
plcSigned: false,
32
+
activated: false,
33
+
deactivated: false,
34
+
currentOperation: "",
35
+
};
36
+
}
37
+
38
+
export function createInboundMigrationFlow() {
39
+
let state = $state<InboundMigrationState>({
40
+
direction: "inbound",
41
+
step: "welcome",
42
+
sourcePdsUrl: "",
43
+
sourceDid: "",
44
+
sourceHandle: "",
45
+
targetHandle: "",
46
+
targetEmail: "",
47
+
targetPassword: "",
48
+
inviteCode: "",
49
+
sourceAccessToken: null,
50
+
sourceRefreshToken: null,
51
+
serviceAuthToken: null,
52
+
emailVerifyToken: "",
53
+
plcToken: "",
54
+
progress: createInitialProgress(),
55
+
error: null,
56
+
requires2FA: false,
57
+
twoFactorCode: "",
58
+
});
59
+
60
+
let sourceClient: AtprotoClient | null = null;
61
+
let localClient: AtprotoClient | null = null;
62
+
let localServerInfo: ServerDescription | null = null;
63
+
64
+
function setStep(step: InboundStep) {
65
+
state.step = step;
66
+
state.error = null;
67
+
saveMigrationState(state);
68
+
updateStep(step);
69
+
}
70
+
71
+
function setError(error: string) {
72
+
state.error = error;
73
+
saveMigrationState(state);
74
+
}
75
+
76
+
function setProgress(updates: Partial<MigrationProgress>) {
77
+
state.progress = { ...state.progress, ...updates };
78
+
updateProgress(updates);
79
+
}
80
+
81
+
async function loadLocalServerInfo(): Promise<ServerDescription> {
82
+
if (!localClient) {
83
+
localClient = createLocalClient();
84
+
}
85
+
if (!localServerInfo) {
86
+
localServerInfo = await localClient.describeServer();
87
+
}
88
+
return localServerInfo;
89
+
}
90
+
91
+
async function resolveSourcePds(handle: string): Promise<void> {
92
+
try {
93
+
const { did, pdsUrl } = await resolvePdsUrl(handle);
94
+
state.sourcePdsUrl = pdsUrl;
95
+
state.sourceDid = did;
96
+
state.sourceHandle = handle;
97
+
sourceClient = new AtprotoClient(pdsUrl);
98
+
} catch (e) {
99
+
throw new Error(`Could not resolve handle: ${(e as Error).message}`);
100
+
}
101
+
}
102
+
103
+
async function loginToSource(
104
+
handle: string,
105
+
password: string,
106
+
twoFactorCode?: string,
107
+
): Promise<void> {
108
+
if (!state.sourcePdsUrl) {
109
+
await resolveSourcePds(handle);
110
+
}
111
+
112
+
if (!sourceClient) {
113
+
sourceClient = new AtprotoClient(state.sourcePdsUrl);
114
+
}
115
+
116
+
try {
117
+
const session = await sourceClient.login(handle, password, twoFactorCode);
118
+
state.sourceAccessToken = session.accessJwt;
119
+
state.sourceRefreshToken = session.refreshJwt;
120
+
state.sourceDid = session.did;
121
+
state.sourceHandle = session.handle;
122
+
state.requires2FA = false;
123
+
saveMigrationState(state);
124
+
} catch (e) {
125
+
const err = e as Error & { error?: string };
126
+
if (err.error === "AuthFactorTokenRequired") {
127
+
state.requires2FA = true;
128
+
throw new Error("Two-factor authentication required. Please enter the code sent to your email.");
129
+
}
130
+
throw e;
131
+
}
132
+
}
133
+
134
+
async function checkHandleAvailability(handle: string): Promise<boolean> {
135
+
if (!localClient) {
136
+
localClient = createLocalClient();
137
+
}
138
+
try {
139
+
await localClient.resolveHandle(handle);
140
+
return false;
141
+
} catch {
142
+
return true;
143
+
}
144
+
}
145
+
146
+
async function authenticateToLocal(email: string, password: string): Promise<void> {
147
+
if (!localClient) {
148
+
localClient = createLocalClient();
149
+
}
150
+
await localClient.loginDeactivated(email, password);
151
+
}
152
+
153
+
async function startMigration(): Promise<void> {
154
+
if (!sourceClient || !state.sourceAccessToken) {
155
+
throw new Error("Not logged in to source PDS");
156
+
}
157
+
158
+
if (!localClient) {
159
+
localClient = createLocalClient();
160
+
}
161
+
162
+
setStep("migrating");
163
+
setProgress({ currentOperation: "Getting service auth token..." });
164
+
165
+
try {
166
+
const serverInfo = await loadLocalServerInfo();
167
+
const { token } = await sourceClient.getServiceAuth(
168
+
serverInfo.did,
169
+
"com.atproto.server.createAccount",
170
+
);
171
+
state.serviceAuthToken = token;
172
+
173
+
setProgress({ currentOperation: "Creating account on new PDS..." });
174
+
175
+
const accountParams = {
176
+
did: state.sourceDid,
177
+
handle: state.targetHandle,
178
+
email: state.targetEmail,
179
+
password: state.targetPassword,
180
+
inviteCode: state.inviteCode || undefined,
181
+
};
182
+
183
+
const session = await localClient.createAccount(accountParams, token);
184
+
localClient.setAccessToken(session.accessJwt);
185
+
186
+
setProgress({ currentOperation: "Exporting repository..." });
187
+
188
+
const car = await sourceClient.getRepo(state.sourceDid);
189
+
setProgress({ repoExported: true, currentOperation: "Importing repository..." });
190
+
191
+
await localClient.importRepo(car);
192
+
setProgress({ repoImported: true, currentOperation: "Counting blobs..." });
193
+
194
+
const accountStatus = await localClient.checkAccountStatus();
195
+
setProgress({
196
+
blobsTotal: accountStatus.expectedBlobs,
197
+
currentOperation: "Migrating blobs...",
198
+
});
199
+
200
+
await migrateBlobs();
201
+
202
+
setProgress({ currentOperation: "Migrating preferences..." });
203
+
await migratePreferences();
204
+
205
+
setStep("email-verify");
206
+
} catch (e) {
207
+
const err = e as Error & { error?: string; status?: number };
208
+
const message = err.message || err.error || `Unknown error (status ${err.status || 'unknown'})`;
209
+
setError(message);
210
+
setStep("error");
211
+
}
212
+
}
213
+
214
+
async function migrateBlobs(): Promise<void> {
215
+
if (!sourceClient || !localClient) return;
216
+
217
+
let cursor: string | undefined;
218
+
let migrated = 0;
219
+
220
+
do {
221
+
const { blobs, cursor: nextCursor } = await localClient.listMissingBlobs(
222
+
cursor,
223
+
100,
224
+
);
225
+
226
+
for (const blob of blobs) {
227
+
try {
228
+
setProgress({
229
+
currentOperation: `Migrating blob ${migrated + 1}/${state.progress.blobsTotal}...`,
230
+
});
231
+
232
+
const blobData = await sourceClient.getBlob(state.sourceDid, blob.cid);
233
+
await localClient.uploadBlob(blobData, "application/octet-stream");
234
+
migrated++;
235
+
setProgress({ blobsMigrated: migrated });
236
+
} catch (e) {
237
+
state.progress.blobsFailed.push(blob.cid);
238
+
}
239
+
}
240
+
241
+
cursor = nextCursor;
242
+
} while (cursor);
243
+
}
244
+
245
+
async function migratePreferences(): Promise<void> {
246
+
if (!sourceClient || !localClient) return;
247
+
248
+
try {
249
+
const prefs = await sourceClient.getPreferences();
250
+
await localClient.putPreferences(prefs);
251
+
setProgress({ prefsMigrated: true });
252
+
} catch {
253
+
}
254
+
}
255
+
256
+
async function submitEmailVerifyToken(token: string, localPassword?: string): Promise<void> {
257
+
if (!localClient) {
258
+
localClient = createLocalClient();
259
+
}
260
+
261
+
state.emailVerifyToken = token;
262
+
setError(null);
263
+
264
+
try {
265
+
await localClient.verifyToken(token, state.targetEmail);
266
+
267
+
if (!sourceClient) {
268
+
setStep("source-login");
269
+
setError("Email verified! Please log in to your old account again to complete the migration.");
270
+
return;
271
+
}
272
+
273
+
if (localPassword) {
274
+
setProgress({ currentOperation: "Authenticating to new PDS..." });
275
+
await localClient.loginDeactivated(state.targetEmail, localPassword);
276
+
}
277
+
278
+
if (!localClient.getAccessToken()) {
279
+
setError("Email verified! Please enter your password to continue.");
280
+
return;
281
+
}
282
+
283
+
setProgress({ currentOperation: "Requesting PLC operation token..." });
284
+
await sourceClient.requestPlcOperationSignature();
285
+
setStep("plc-token");
286
+
} catch (e) {
287
+
const err = e as Error & { error?: string; status?: number };
288
+
const message = err.message || err.error || `Unknown error (status ${err.status || 'unknown'})`;
289
+
setError(message);
290
+
}
291
+
}
292
+
293
+
async function resendEmailVerification(): Promise<void> {
294
+
if (!localClient) {
295
+
localClient = createLocalClient();
296
+
}
297
+
await localClient.resendMigrationVerification();
298
+
}
299
+
300
+
let checkingEmailVerification = false;
301
+
302
+
async function checkEmailVerifiedAndProceed(): Promise<boolean> {
303
+
if (checkingEmailVerification) return false;
304
+
if (!sourceClient || !localClient) return false;
305
+
306
+
checkingEmailVerification = true;
307
+
try {
308
+
await localClient.loginDeactivated(state.targetEmail, state.targetPassword);
309
+
await sourceClient.requestPlcOperationSignature();
310
+
setStep("plc-token");
311
+
return true;
312
+
} catch (e) {
313
+
const err = e as Error & { error?: string };
314
+
if (err.error === "AccountNotVerified") {
315
+
return false;
316
+
}
317
+
return false;
318
+
} finally {
319
+
checkingEmailVerification = false;
320
+
}
321
+
}
322
+
323
+
async function submitPlcToken(token: string): Promise<void> {
324
+
if (!sourceClient || !localClient) {
325
+
throw new Error("Not connected to PDSes");
326
+
}
327
+
328
+
state.plcToken = token;
329
+
setStep("finalizing");
330
+
setProgress({ currentOperation: "Signing PLC operation..." });
331
+
332
+
try {
333
+
const credentials = await localClient.getRecommendedDidCredentials();
334
+
335
+
const { operation } = await sourceClient.signPlcOperation({
336
+
token,
337
+
...credentials,
338
+
});
339
+
340
+
setProgress({ plcSigned: true, currentOperation: "Submitting PLC operation..." });
341
+
await localClient.submitPlcOperation(operation);
342
+
343
+
setProgress({ currentOperation: "Activating account (waiting for DID propagation)..." });
344
+
await localClient.activateAccount();
345
+
setProgress({ activated: true });
346
+
347
+
setProgress({ currentOperation: "Deactivating old account..." });
348
+
try {
349
+
await sourceClient.deactivateAccount();
350
+
setProgress({ deactivated: true });
351
+
} catch {
352
+
}
353
+
354
+
setStep("success");
355
+
clearMigrationState();
356
+
} catch (e) {
357
+
const err = e as Error & { error?: string; status?: number };
358
+
const message = err.message || err.error || `Unknown error (status ${err.status || 'unknown'})`;
359
+
state.step = "plc-token";
360
+
state.error = message;
361
+
saveMigrationState(state);
362
+
}
363
+
}
364
+
365
+
async function requestPlcToken(): Promise<void> {
366
+
if (!sourceClient) {
367
+
throw new Error("Not connected to source PDS");
368
+
}
369
+
setProgress({ currentOperation: "Requesting PLC operation token..." });
370
+
await sourceClient.requestPlcOperationSignature();
371
+
}
372
+
373
+
async function resendPlcToken(): Promise<void> {
374
+
if (!sourceClient) {
375
+
throw new Error("Not connected to source PDS");
376
+
}
377
+
await sourceClient.requestPlcOperationSignature();
378
+
}
379
+
380
+
function reset(): void {
381
+
state = {
382
+
direction: "inbound",
383
+
step: "welcome",
384
+
sourcePdsUrl: "",
385
+
sourceDid: "",
386
+
sourceHandle: "",
387
+
targetHandle: "",
388
+
targetEmail: "",
389
+
targetPassword: "",
390
+
inviteCode: "",
391
+
sourceAccessToken: null,
392
+
sourceRefreshToken: null,
393
+
serviceAuthToken: null,
394
+
emailVerifyToken: "",
395
+
plcToken: "",
396
+
progress: createInitialProgress(),
397
+
error: null,
398
+
requires2FA: false,
399
+
twoFactorCode: "",
400
+
};
401
+
sourceClient = null;
402
+
clearMigrationState();
403
+
}
404
+
405
+
async function resumeFromState(stored: StoredMigrationState): Promise<void> {
406
+
if (stored.direction !== "inbound") return;
407
+
408
+
state.sourcePdsUrl = stored.sourcePdsUrl;
409
+
state.sourceDid = stored.sourceDid;
410
+
state.sourceHandle = stored.sourceHandle;
411
+
state.targetHandle = stored.targetHandle;
412
+
state.targetEmail = stored.targetEmail;
413
+
state.progress = {
414
+
...createInitialProgress(),
415
+
...stored.progress,
416
+
};
417
+
418
+
state.step = "source-login";
419
+
}
420
+
421
+
function getLocalSession(): { accessJwt: string; did: string; handle: string } | null {
422
+
if (!localClient) return null;
423
+
const token = localClient.getAccessToken();
424
+
if (!token) return null;
425
+
return {
426
+
accessJwt: token,
427
+
did: state.sourceDid,
428
+
handle: state.targetHandle,
429
+
};
430
+
}
431
+
432
+
return {
433
+
get state() { return state; },
434
+
setStep,
435
+
setError,
436
+
loadLocalServerInfo,
437
+
loginToSource,
438
+
authenticateToLocal,
439
+
checkHandleAvailability,
440
+
startMigration,
441
+
submitEmailVerifyToken,
442
+
resendEmailVerification,
443
+
checkEmailVerifiedAndProceed,
444
+
requestPlcToken,
445
+
submitPlcToken,
446
+
resendPlcToken,
447
+
reset,
448
+
resumeFromState,
449
+
getLocalSession,
450
+
451
+
updateField<K extends keyof InboundMigrationState>(
452
+
field: K,
453
+
value: InboundMigrationState[K],
454
+
) {
455
+
state[field] = value;
456
+
},
457
+
};
458
+
}
459
+
460
+
export function createOutboundMigrationFlow() {
461
+
let state = $state<OutboundMigrationState>({
462
+
direction: "outbound",
463
+
step: "welcome",
464
+
localDid: "",
465
+
localHandle: "",
466
+
targetPdsUrl: "",
467
+
targetPdsDid: "",
468
+
targetHandle: "",
469
+
targetEmail: "",
470
+
targetPassword: "",
471
+
inviteCode: "",
472
+
targetAccessToken: null,
473
+
targetRefreshToken: null,
474
+
serviceAuthToken: null,
475
+
plcToken: "",
476
+
progress: createInitialProgress(),
477
+
error: null,
478
+
targetServerInfo: null,
479
+
});
480
+
481
+
let localClient: AtprotoClient | null = null;
482
+
let targetClient: AtprotoClient | null = null;
483
+
484
+
function setStep(step: OutboundStep) {
485
+
state.step = step;
486
+
state.error = null;
487
+
saveMigrationState(state);
488
+
updateStep(step);
489
+
}
490
+
491
+
function setError(error: string) {
492
+
state.error = error;
493
+
saveMigrationState(state);
494
+
}
495
+
496
+
function setProgress(updates: Partial<MigrationProgress>) {
497
+
state.progress = { ...state.progress, ...updates };
498
+
updateProgress(updates);
499
+
}
500
+
501
+
async function validateTargetPds(url: string): Promise<ServerDescription> {
502
+
const normalizedUrl = url.replace(/\/$/, "");
503
+
targetClient = new AtprotoClient(normalizedUrl);
504
+
505
+
try {
506
+
const serverInfo = await targetClient.describeServer();
507
+
state.targetPdsUrl = normalizedUrl;
508
+
state.targetPdsDid = serverInfo.did;
509
+
state.targetServerInfo = serverInfo;
510
+
return serverInfo;
511
+
} catch (e) {
512
+
throw new Error(`Could not connect to PDS: ${(e as Error).message}`);
513
+
}
514
+
}
515
+
516
+
function initLocalClient(accessToken: string, did?: string, handle?: string): void {
517
+
localClient = createLocalClient();
518
+
localClient.setAccessToken(accessToken);
519
+
if (did) {
520
+
state.localDid = did;
521
+
}
522
+
if (handle) {
523
+
state.localHandle = handle;
524
+
}
525
+
}
526
+
527
+
async function startMigration(currentDid: string): Promise<void> {
528
+
if (!localClient || !targetClient) {
529
+
throw new Error("Not connected to PDSes");
530
+
}
531
+
532
+
setStep("migrating");
533
+
setProgress({ currentOperation: "Getting service auth token..." });
534
+
535
+
try {
536
+
const { token } = await localClient.getServiceAuth(
537
+
state.targetPdsDid,
538
+
"com.atproto.server.createAccount",
539
+
);
540
+
state.serviceAuthToken = token;
541
+
542
+
setProgress({ currentOperation: "Creating account on new PDS..." });
543
+
544
+
const accountParams = {
545
+
did: currentDid,
546
+
handle: state.targetHandle,
547
+
email: state.targetEmail,
548
+
password: state.targetPassword,
549
+
inviteCode: state.inviteCode || undefined,
550
+
};
551
+
552
+
const session = await targetClient.createAccount(accountParams, token);
553
+
state.targetAccessToken = session.accessJwt;
554
+
state.targetRefreshToken = session.refreshJwt;
555
+
targetClient.setAccessToken(session.accessJwt);
556
+
557
+
setProgress({ currentOperation: "Exporting repository..." });
558
+
559
+
const car = await localClient.getRepo(currentDid);
560
+
setProgress({ repoExported: true, currentOperation: "Importing repository..." });
561
+
562
+
await targetClient.importRepo(car);
563
+
setProgress({ repoImported: true, currentOperation: "Counting blobs..." });
564
+
565
+
const accountStatus = await targetClient.checkAccountStatus();
566
+
setProgress({
567
+
blobsTotal: accountStatus.expectedBlobs,
568
+
currentOperation: "Migrating blobs...",
569
+
});
570
+
571
+
await migrateBlobs(currentDid);
572
+
573
+
setProgress({ currentOperation: "Migrating preferences..." });
574
+
await migratePreferences();
575
+
576
+
setProgress({ currentOperation: "Requesting PLC operation token..." });
577
+
await localClient.requestPlcOperationSignature();
578
+
579
+
setStep("plc-token");
580
+
} catch (e) {
581
+
const err = e as Error & { error?: string; status?: number };
582
+
const message = err.message || err.error || `Unknown error (status ${err.status || 'unknown'})`;
583
+
setError(message);
584
+
setStep("error");
585
+
}
586
+
}
587
+
588
+
async function migrateBlobs(did: string): Promise<void> {
589
+
if (!localClient || !targetClient) return;
590
+
591
+
let cursor: string | undefined;
592
+
let migrated = 0;
593
+
594
+
do {
595
+
const { blobs, cursor: nextCursor } = await targetClient.listMissingBlobs(
596
+
cursor,
597
+
100,
598
+
);
599
+
600
+
for (const blob of blobs) {
601
+
try {
602
+
setProgress({
603
+
currentOperation: `Migrating blob ${migrated + 1}/${state.progress.blobsTotal}...`,
604
+
});
605
+
606
+
const blobData = await localClient.getBlob(did, blob.cid);
607
+
await targetClient.uploadBlob(blobData, "application/octet-stream");
608
+
migrated++;
609
+
setProgress({ blobsMigrated: migrated });
610
+
} catch (e) {
611
+
state.progress.blobsFailed.push(blob.cid);
612
+
}
613
+
}
614
+
615
+
cursor = nextCursor;
616
+
} while (cursor);
617
+
}
618
+
619
+
async function migratePreferences(): Promise<void> {
620
+
if (!localClient || !targetClient) return;
621
+
622
+
try {
623
+
const prefs = await localClient.getPreferences();
624
+
await targetClient.putPreferences(prefs);
625
+
setProgress({ prefsMigrated: true });
626
+
} catch {
627
+
}
628
+
}
629
+
630
+
async function submitPlcToken(token: string): Promise<void> {
631
+
if (!localClient || !targetClient) {
632
+
throw new Error("Not connected to PDSes");
633
+
}
634
+
635
+
state.plcToken = token;
636
+
setStep("finalizing");
637
+
setProgress({ currentOperation: "Signing PLC operation..." });
638
+
639
+
try {
640
+
const credentials = await targetClient.getRecommendedDidCredentials();
641
+
642
+
const { operation } = await localClient.signPlcOperation({
643
+
token,
644
+
...credentials,
645
+
});
646
+
647
+
setProgress({ plcSigned: true, currentOperation: "Submitting PLC operation..." });
648
+
649
+
await targetClient.submitPlcOperation(operation);
650
+
651
+
setProgress({ currentOperation: "Activating account on new PDS..." });
652
+
await targetClient.activateAccount();
653
+
setProgress({ activated: true });
654
+
655
+
setProgress({ currentOperation: "Deactivating old account..." });
656
+
try {
657
+
await localClient.deactivateAccount();
658
+
setProgress({ deactivated: true });
659
+
} catch {
660
+
}
661
+
662
+
if (state.localDid.startsWith("did:web:")) {
663
+
setProgress({ currentOperation: "Updating DID document forwarding..." });
664
+
try {
665
+
await localClient.updateMigrationForwarding(state.targetPdsUrl);
666
+
} catch (e) {
667
+
console.warn("Failed to update migration forwarding:", e);
668
+
}
669
+
}
670
+
671
+
setStep("success");
672
+
clearMigrationState();
673
+
} catch (e) {
674
+
const err = e as Error & { error?: string; status?: number };
675
+
const message = err.message || err.error || `Unknown error (status ${err.status || 'unknown'})`;
676
+
setError(message);
677
+
setStep("plc-token");
678
+
}
679
+
}
680
+
681
+
async function resendPlcToken(): Promise<void> {
682
+
if (!localClient) {
683
+
throw new Error("Not connected to local PDS");
684
+
}
685
+
await localClient.requestPlcOperationSignature();
686
+
}
687
+
688
+
function reset(): void {
689
+
state = {
690
+
direction: "outbound",
691
+
step: "welcome",
692
+
localDid: "",
693
+
localHandle: "",
694
+
targetPdsUrl: "",
695
+
targetPdsDid: "",
696
+
targetHandle: "",
697
+
targetEmail: "",
698
+
targetPassword: "",
699
+
inviteCode: "",
700
+
targetAccessToken: null,
701
+
targetRefreshToken: null,
702
+
serviceAuthToken: null,
703
+
plcToken: "",
704
+
progress: createInitialProgress(),
705
+
error: null,
706
+
targetServerInfo: null,
707
+
};
708
+
localClient = null;
709
+
targetClient = null;
710
+
clearMigrationState();
711
+
}
712
+
713
+
return {
714
+
get state() { return state; },
715
+
setStep,
716
+
setError,
717
+
validateTargetPds,
718
+
initLocalClient,
719
+
startMigration,
720
+
submitPlcToken,
721
+
resendPlcToken,
722
+
reset,
723
+
724
+
updateField<K extends keyof OutboundMigrationState>(
725
+
field: K,
726
+
value: OutboundMigrationState[K],
727
+
) {
728
+
state[field] = value;
729
+
},
730
+
};
731
+
}
732
+
733
+
export type InboundMigrationFlow = ReturnType<typeof createInboundMigrationFlow>;
734
+
export type OutboundMigrationFlow = ReturnType<typeof createOutboundMigrationFlow>;
+9
frontend/src/lib/migration/index.ts
+9
frontend/src/lib/migration/index.ts
+138
frontend/src/lib/migration/storage.ts
+138
frontend/src/lib/migration/storage.ts
···
···
1
+
import type { MigrationDirection, MigrationState, StoredMigrationState } from "./types";
2
+
3
+
const STORAGE_KEY = "tranquil_migration_state";
4
+
const MAX_AGE_MS = 24 * 60 * 60 * 1000;
5
+
6
+
export function saveMigrationState(state: MigrationState): void {
7
+
const storedState: StoredMigrationState = {
8
+
version: 1,
9
+
direction: state.direction,
10
+
step: state.direction === "inbound" ? state.step : state.step,
11
+
startedAt: new Date().toISOString(),
12
+
sourcePdsUrl: state.direction === "inbound" ? state.sourcePdsUrl : window.location.origin,
13
+
targetPdsUrl: state.direction === "inbound" ? window.location.origin : state.targetPdsUrl,
14
+
sourceDid: state.direction === "inbound" ? state.sourceDid : "",
15
+
sourceHandle: state.direction === "inbound" ? state.sourceHandle : "",
16
+
targetHandle: state.targetHandle,
17
+
targetEmail: state.targetEmail,
18
+
progress: {
19
+
repoExported: state.progress.repoExported,
20
+
repoImported: state.progress.repoImported,
21
+
blobsTotal: state.progress.blobsTotal,
22
+
blobsMigrated: state.progress.blobsMigrated,
23
+
prefsMigrated: state.progress.prefsMigrated,
24
+
plcSigned: state.progress.plcSigned,
25
+
},
26
+
lastError: state.error ?? undefined,
27
+
lastErrorStep: state.error ? state.step : undefined,
28
+
};
29
+
30
+
try {
31
+
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(storedState));
32
+
} catch {
33
+
}
34
+
}
35
+
36
+
export function loadMigrationState(): StoredMigrationState | null {
37
+
try {
38
+
const stored = sessionStorage.getItem(STORAGE_KEY);
39
+
if (!stored) return null;
40
+
41
+
const state = JSON.parse(stored) as StoredMigrationState;
42
+
43
+
if (state.version !== 1) return null;
44
+
45
+
const startedAt = new Date(state.startedAt).getTime();
46
+
if (Date.now() - startedAt > MAX_AGE_MS) {
47
+
clearMigrationState();
48
+
return null;
49
+
}
50
+
51
+
return state;
52
+
} catch {
53
+
return null;
54
+
}
55
+
}
56
+
57
+
export function clearMigrationState(): void {
58
+
try {
59
+
sessionStorage.removeItem(STORAGE_KEY);
60
+
} catch {
61
+
}
62
+
}
63
+
64
+
export function hasPendingMigration(): boolean {
65
+
return loadMigrationState() !== null;
66
+
}
67
+
68
+
export function getResumeInfo(): {
69
+
direction: MigrationDirection;
70
+
sourceHandle: string;
71
+
targetHandle: string;
72
+
sourcePdsUrl: string;
73
+
targetPdsUrl: string;
74
+
progressSummary: string;
75
+
step: string;
76
+
} | null {
77
+
const state = loadMigrationState();
78
+
if (!state) return null;
79
+
80
+
const progressParts: string[] = [];
81
+
if (state.progress.repoExported) progressParts.push("repo exported");
82
+
if (state.progress.repoImported) progressParts.push("repo imported");
83
+
if (state.progress.blobsMigrated > 0) {
84
+
progressParts.push(
85
+
`${state.progress.blobsMigrated}/${state.progress.blobsTotal} blobs`,
86
+
);
87
+
}
88
+
if (state.progress.prefsMigrated) progressParts.push("preferences migrated");
89
+
if (state.progress.plcSigned) progressParts.push("PLC signed");
90
+
91
+
return {
92
+
direction: state.direction,
93
+
sourceHandle: state.sourceHandle,
94
+
targetHandle: state.targetHandle,
95
+
sourcePdsUrl: state.sourcePdsUrl,
96
+
targetPdsUrl: state.targetPdsUrl,
97
+
progressSummary: progressParts.length > 0
98
+
? progressParts.join(", ")
99
+
: "just started",
100
+
step: state.step,
101
+
};
102
+
}
103
+
104
+
export function updateProgress(
105
+
updates: Partial<StoredMigrationState["progress"]>,
106
+
): void {
107
+
const state = loadMigrationState();
108
+
if (!state) return;
109
+
110
+
state.progress = { ...state.progress, ...updates };
111
+
try {
112
+
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(state));
113
+
} catch {
114
+
}
115
+
}
116
+
117
+
export function updateStep(step: string): void {
118
+
const state = loadMigrationState();
119
+
if (!state) return;
120
+
121
+
state.step = step;
122
+
try {
123
+
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(state));
124
+
} catch {
125
+
}
126
+
}
127
+
128
+
export function setError(error: string, step: string): void {
129
+
const state = loadMigrationState();
130
+
if (!state) return;
131
+
132
+
state.lastError = error;
133
+
state.lastErrorStep = step;
134
+
try {
135
+
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(state));
136
+
} catch {
137
+
}
138
+
}
+214
frontend/src/lib/migration/types.ts
+214
frontend/src/lib/migration/types.ts
···
···
1
+
export type InboundStep =
2
+
| "welcome"
3
+
| "source-login"
4
+
| "choose-handle"
5
+
| "review"
6
+
| "migrating"
7
+
| "email-verify"
8
+
| "plc-token"
9
+
| "finalizing"
10
+
| "success"
11
+
| "error";
12
+
13
+
export type OutboundStep =
14
+
| "welcome"
15
+
| "target-pds"
16
+
| "new-account"
17
+
| "review"
18
+
| "migrating"
19
+
| "plc-token"
20
+
| "finalizing"
21
+
| "success"
22
+
| "error";
23
+
24
+
export type MigrationDirection = "inbound" | "outbound";
25
+
26
+
export interface MigrationProgress {
27
+
repoExported: boolean;
28
+
repoImported: boolean;
29
+
blobsTotal: number;
30
+
blobsMigrated: number;
31
+
blobsFailed: string[];
32
+
prefsMigrated: boolean;
33
+
plcSigned: boolean;
34
+
activated: boolean;
35
+
deactivated: boolean;
36
+
currentOperation: string;
37
+
}
38
+
39
+
export interface InboundMigrationState {
40
+
direction: "inbound";
41
+
step: InboundStep;
42
+
sourcePdsUrl: string;
43
+
sourceDid: string;
44
+
sourceHandle: string;
45
+
targetHandle: string;
46
+
targetEmail: string;
47
+
targetPassword: string;
48
+
inviteCode: string;
49
+
sourceAccessToken: string | null;
50
+
sourceRefreshToken: string | null;
51
+
serviceAuthToken: string | null;
52
+
emailVerifyToken: string;
53
+
plcToken: string;
54
+
progress: MigrationProgress;
55
+
error: string | null;
56
+
requires2FA: boolean;
57
+
twoFactorCode: string;
58
+
}
59
+
60
+
export interface OutboundMigrationState {
61
+
direction: "outbound";
62
+
step: OutboundStep;
63
+
localDid: string;
64
+
localHandle: string;
65
+
targetPdsUrl: string;
66
+
targetPdsDid: string;
67
+
targetHandle: string;
68
+
targetEmail: string;
69
+
targetPassword: string;
70
+
inviteCode: string;
71
+
targetAccessToken: string | null;
72
+
targetRefreshToken: string | null;
73
+
serviceAuthToken: string | null;
74
+
plcToken: string;
75
+
progress: MigrationProgress;
76
+
error: string | null;
77
+
targetServerInfo: ServerDescription | null;
78
+
}
79
+
80
+
export type MigrationState = InboundMigrationState | OutboundMigrationState;
81
+
82
+
export interface StoredMigrationState {
83
+
version: 1;
84
+
direction: MigrationDirection;
85
+
step: string;
86
+
startedAt: string;
87
+
sourcePdsUrl: string;
88
+
targetPdsUrl: string;
89
+
sourceDid: string;
90
+
sourceHandle: string;
91
+
targetHandle: string;
92
+
targetEmail: string;
93
+
progress: {
94
+
repoExported: boolean;
95
+
repoImported: boolean;
96
+
blobsTotal: number;
97
+
blobsMigrated: number;
98
+
prefsMigrated: boolean;
99
+
plcSigned: boolean;
100
+
};
101
+
lastErrorStep?: string;
102
+
lastError?: string;
103
+
}
104
+
105
+
export interface ServerDescription {
106
+
did: string;
107
+
availableUserDomains: string[];
108
+
inviteCodeRequired: boolean;
109
+
phoneVerificationRequired?: boolean;
110
+
links?: {
111
+
privacyPolicy?: string;
112
+
termsOfService?: string;
113
+
};
114
+
}
115
+
116
+
export interface Session {
117
+
did: string;
118
+
handle: string;
119
+
email?: string;
120
+
accessJwt: string;
121
+
refreshJwt: string;
122
+
active?: boolean;
123
+
}
124
+
125
+
export interface DidDocument {
126
+
id: string;
127
+
alsoKnownAs?: string[];
128
+
verificationMethod?: Array<{
129
+
id: string;
130
+
type: string;
131
+
controller: string;
132
+
publicKeyMultibase?: string;
133
+
}>;
134
+
service?: Array<{
135
+
id: string;
136
+
type: string;
137
+
serviceEndpoint: string;
138
+
}>;
139
+
}
140
+
141
+
export interface DidCredentials {
142
+
rotationKeys?: string[];
143
+
alsoKnownAs?: string[];
144
+
verificationMethods?: {
145
+
atproto?: string;
146
+
};
147
+
services?: {
148
+
atproto_pds?: {
149
+
type: string;
150
+
endpoint: string;
151
+
};
152
+
};
153
+
}
154
+
155
+
export interface PlcOperation {
156
+
type: "plc_operation";
157
+
prev: string | null;
158
+
sig: string;
159
+
rotationKeys: string[];
160
+
verificationMethods: {
161
+
atproto: string;
162
+
};
163
+
alsoKnownAs: string[];
164
+
services: {
165
+
atproto_pds: {
166
+
type: string;
167
+
endpoint: string;
168
+
};
169
+
};
170
+
}
171
+
172
+
export interface AccountStatus {
173
+
activated: boolean;
174
+
validDid: boolean;
175
+
repoCommit: string;
176
+
repoRev: string;
177
+
repoBlocks: number;
178
+
indexedRecords: number;
179
+
privateStateValues: number;
180
+
expectedBlobs: number;
181
+
importedBlobs: number;
182
+
}
183
+
184
+
export interface BlobRef {
185
+
$type: "blob";
186
+
ref: { $link: string };
187
+
mimeType: string;
188
+
size: number;
189
+
}
190
+
191
+
export interface CreateAccountParams {
192
+
did?: string;
193
+
handle: string;
194
+
email: string;
195
+
password: string;
196
+
inviteCode?: string;
197
+
recoveryKey?: string;
198
+
}
199
+
200
+
export interface Preferences {
201
+
preferences: unknown[];
202
+
}
203
+
204
+
export class MigrationError extends Error {
205
+
constructor(
206
+
message: string,
207
+
public code: string,
208
+
public recoverable: boolean = false,
209
+
public details?: unknown,
210
+
) {
211
+
super(message);
212
+
this.name = "MigrationError";
213
+
}
214
+
}
+202
-1
frontend/src/locales/en.json
+202
-1
frontend/src/locales/en.json
···
71
"infoNextDesc": "After creating your account, you'll verify your contact method and then you're ready to use any ATProto app with your new identity.",
72
"migrateTitle": "Already have a Bluesky account?",
73
"migrateDescription": "You can migrate your existing account to this PDS instead of creating a new one. Your followers, posts, and identity will come with you.",
74
-
"migrateLink": "Migrate with PDS Moover",
75
"handle": "Handle",
76
"handlePlaceholder": "yourname",
77
"handleHint": "Your full handle will be: @{handle}",
···
991
"codeLabel": "Verification Code",
992
"codeHelp": "Copy the entire code from your message, including dashes.",
993
"verifyButton": "Verify"
994
}
995
}
···
71
"infoNextDesc": "After creating your account, you'll verify your contact method and then you're ready to use any ATProto app with your new identity.",
72
"migrateTitle": "Already have a Bluesky account?",
73
"migrateDescription": "You can migrate your existing account to this PDS instead of creating a new one. Your followers, posts, and identity will come with you.",
74
+
"migrateLink": "Migrate your account",
75
"handle": "Handle",
76
"handlePlaceholder": "yourname",
77
"handleHint": "Your full handle will be: @{handle}",
···
991
"codeLabel": "Verification Code",
992
"codeHelp": "Copy the entire code from your message, including dashes.",
993
"verifyButton": "Verify"
994
+
},
995
+
"migration": {
996
+
"title": "Account Migration",
997
+
"subtitle": "Move your AT Protocol identity between servers",
998
+
"navTitle": "Migration",
999
+
"navDesc": "Move your account to or from another PDS",
1000
+
"migrateHere": "Migrate Here",
1001
+
"migrateHereDesc": "Move your existing AT Protocol account to this PDS from another server.",
1002
+
"migrateAway": "Migrate Away",
1003
+
"migrateAwayDesc": "Move your account from this PDS to another server.",
1004
+
"loginRequired": "Login required",
1005
+
"bringDid": "Bring your DID and identity",
1006
+
"transferData": "Transfer all your data",
1007
+
"keepFollowers": "Keep your followers",
1008
+
"exportRepo": "Export your repository",
1009
+
"transferToPds": "Transfer to new PDS",
1010
+
"updateIdentity": "Update your identity",
1011
+
"whatIsMigration": "What is account migration?",
1012
+
"whatIsMigrationDesc": "Account migration allows you to move your AT Protocol identity between Personal Data Servers (PDSes). Your DID (decentralized identifier) stays the same, so your followers and social connections are preserved.",
1013
+
"beforeMigrate": "Before you migrate",
1014
+
"beforeMigrate1": "You will need your current account credentials",
1015
+
"beforeMigrate2": "Migration requires email verification for security",
1016
+
"beforeMigrate3": "Large accounts with many images may take several minutes",
1017
+
"beforeMigrate4": "Your old PDS will be notified to deactivate your account",
1018
+
"importantWarning": "Account migration is a significant action. Make sure you trust the destination PDS and understand that your data will be moved. If something goes wrong, recovery may require manual intervention.",
1019
+
"learnMore": "Learn more about migration risks",
1020
+
"resume": {
1021
+
"title": "Resume Migration?",
1022
+
"incomplete": "You have an incomplete migration in progress:",
1023
+
"direction": "Direction",
1024
+
"migratingHere": "Migrating here",
1025
+
"migratingAway": "Migrating away",
1026
+
"from": "From",
1027
+
"to": "To",
1028
+
"progress": "Progress",
1029
+
"reenterCredentials": "You will need to re-enter your credentials to continue.",
1030
+
"startOver": "Start Over",
1031
+
"resumeButton": "Resume"
1032
+
},
1033
+
"inbound": {
1034
+
"welcome": {
1035
+
"title": "Migrate to This PDS",
1036
+
"desc": "Move your existing AT Protocol account to this server.",
1037
+
"understand": "I understand the risks and want to proceed"
1038
+
},
1039
+
"sourceLogin": {
1040
+
"title": "Sign In to Your Current PDS",
1041
+
"desc": "Enter your credentials for the account you want to migrate.",
1042
+
"handle": "Handle",
1043
+
"handlePlaceholder": "you.bsky.social",
1044
+
"password": "Password",
1045
+
"twoFactorCode": "Two-Factor Code",
1046
+
"twoFactorRequired": "Two-factor authentication required",
1047
+
"signIn": "Sign In & Continue"
1048
+
},
1049
+
"chooseHandle": {
1050
+
"title": "Choose Your New Handle",
1051
+
"desc": "Select a handle for your account on this PDS.",
1052
+
"handleHint": "Your full handle will be: @{handle}"
1053
+
},
1054
+
"review": {
1055
+
"title": "Review Migration",
1056
+
"desc": "Please review and confirm your migration details.",
1057
+
"currentHandle": "Current Handle",
1058
+
"newHandle": "New Handle",
1059
+
"sourcePds": "Source PDS",
1060
+
"targetPds": "This PDS",
1061
+
"email": "Email",
1062
+
"inviteCode": "Invite Code",
1063
+
"confirm": "I confirm I want to migrate my account",
1064
+
"startMigration": "Start Migration"
1065
+
},
1066
+
"migrating": {
1067
+
"title": "Migrating Your Account",
1068
+
"desc": "Please wait while we transfer your data...",
1069
+
"gettingServiceAuth": "Getting service authorization...",
1070
+
"creatingAccount": "Creating account on new PDS...",
1071
+
"exportingRepo": "Exporting repository...",
1072
+
"importingRepo": "Importing repository...",
1073
+
"countingBlobs": "Counting blobs...",
1074
+
"migratingBlobs": "Migrating blobs ({current}/{total})...",
1075
+
"migratingPrefs": "Migrating preferences...",
1076
+
"requestingPlc": "Requesting PLC operation..."
1077
+
},
1078
+
"emailVerify": {
1079
+
"title": "Verify Your Email",
1080
+
"desc": "A verification code has been sent to {email}.",
1081
+
"hint": "Enter the code below, or click the link in the email to continue automatically.",
1082
+
"tokenLabel": "Verification Code",
1083
+
"tokenPlaceholder": "Enter code from email",
1084
+
"resend": "Resend Code",
1085
+
"verify": "Verify Email",
1086
+
"verifying": "Verifying..."
1087
+
},
1088
+
"plcToken": {
1089
+
"title": "Verify Your Identity",
1090
+
"desc": "A verification code has been sent to your email on your current PDS.",
1091
+
"tokenLabel": "Verification Token",
1092
+
"tokenPlaceholder": "Enter the token from your email",
1093
+
"resend": "Resend Token",
1094
+
"resending": "Resending..."
1095
+
},
1096
+
"finalizing": {
1097
+
"title": "Finalizing Migration",
1098
+
"desc": "Please wait while we complete the migration...",
1099
+
"signingPlc": "Sign identity update",
1100
+
"activating": "Activate account on new PDS",
1101
+
"deactivating": "Deactivate account on old PDS"
1102
+
},
1103
+
"success": {
1104
+
"title": "Migration Complete!",
1105
+
"desc": "Your account has been successfully migrated to this PDS.",
1106
+
"newHandle": "New Handle",
1107
+
"did": "DID",
1108
+
"goToDashboard": "Go to Dashboard"
1109
+
}
1110
+
},
1111
+
"outbound": {
1112
+
"welcome": {
1113
+
"title": "Migrate Away from This PDS",
1114
+
"desc": "Move your account to another Personal Data Server.",
1115
+
"warning": "After migration, your account here will be deactivated.",
1116
+
"didWebNotice": "did:web Migration Notice",
1117
+
"didWebNoticeDesc": "Your account uses a did:web identifier ({did}). After migrating, this PDS will continue to serve your DID document pointing to the new PDS. Your identity will remain functional as long as this server is online.",
1118
+
"understand": "I understand the risks and want to proceed"
1119
+
},
1120
+
"targetPds": {
1121
+
"title": "Choose Target PDS",
1122
+
"desc": "Enter the URL of the PDS you want to migrate to.",
1123
+
"url": "PDS URL",
1124
+
"urlPlaceholder": "https://pds.example.com",
1125
+
"validate": "Validate & Continue",
1126
+
"validating": "Validating...",
1127
+
"connected": "Connected to {name}",
1128
+
"inviteRequired": "Invite code required",
1129
+
"privacyPolicy": "Privacy Policy",
1130
+
"termsOfService": "Terms of Service"
1131
+
},
1132
+
"newAccount": {
1133
+
"title": "New Account Details",
1134
+
"desc": "Set up your account on the new PDS.",
1135
+
"handle": "Handle",
1136
+
"availableDomains": "Available domains",
1137
+
"email": "Email",
1138
+
"password": "Password",
1139
+
"confirmPassword": "Confirm Password",
1140
+
"inviteCode": "Invite Code"
1141
+
},
1142
+
"review": {
1143
+
"title": "Review Migration",
1144
+
"desc": "Please review and confirm your migration details.",
1145
+
"currentHandle": "Current Handle",
1146
+
"newHandle": "New Handle",
1147
+
"sourcePds": "This PDS",
1148
+
"targetPds": "Target PDS",
1149
+
"confirm": "I confirm I want to migrate my account",
1150
+
"startMigration": "Start Migration"
1151
+
},
1152
+
"migrating": {
1153
+
"title": "Migrating Your Account",
1154
+
"desc": "Please wait while we transfer your data..."
1155
+
},
1156
+
"plcToken": {
1157
+
"title": "Verify Your Identity",
1158
+
"desc": "A verification code has been sent to your email."
1159
+
},
1160
+
"finalizing": {
1161
+
"title": "Finalizing Migration",
1162
+
"desc": "Please wait while we complete the migration...",
1163
+
"updatingForwarding": "Updating DID document forwarding..."
1164
+
},
1165
+
"success": {
1166
+
"title": "Migration Complete!",
1167
+
"desc": "Your account has been successfully migrated to your new PDS.",
1168
+
"newHandle": "New Handle",
1169
+
"newPds": "New PDS",
1170
+
"nextSteps": "Next Steps",
1171
+
"nextSteps1": "Sign in to your new PDS",
1172
+
"nextSteps2": "Update any apps with your new credentials",
1173
+
"nextSteps3": "Your followers will automatically see your new location",
1174
+
"loggingOut": "Logging you out in {seconds} seconds..."
1175
+
}
1176
+
},
1177
+
"progress": {
1178
+
"repoExported": "Repository exported",
1179
+
"repoImported": "Repository imported",
1180
+
"blobsMigrated": "{count} blobs migrated",
1181
+
"prefsMigrated": "Preferences migrated",
1182
+
"plcSigned": "Identity updated",
1183
+
"activated": "Account activated",
1184
+
"deactivated": "Old account deactivated"
1185
+
},
1186
+
"errors": {
1187
+
"connectionFailed": "Could not connect to PDS",
1188
+
"invalidCredentials": "Invalid credentials",
1189
+
"twoFactorRequired": "Two-factor authentication required",
1190
+
"accountExists": "Account already exists on target PDS",
1191
+
"plcFailed": "PLC operation failed",
1192
+
"blobFailed": "Failed to migrate blob: {cid}",
1193
+
"networkError": "Network error. Please try again."
1194
+
}
1195
}
1196
}
+201
frontend/src/locales/fi.json
+201
frontend/src/locales/fi.json
···
1007
"permissionsLimitedDesc": "Todelliset oikeutesi rajoitetaan {level}-käyttöoikeustasoosi riippumatta siitä, mitä sovellus pyytää.",
1008
"viewerLimitedDesc": "Katselijana sinulla on vain lukuoikeus. Tämä sovellus ei voi luoda, muokata tai poistaa sisältöä tällä tilillä.",
1009
"editorLimitedDesc": "Muokkaajana voit luoda ja muokata sisältöä, mutta et voi hallita tilin asetuksia tai tietoturvaa."
1010
}
1011
}
···
1007
"permissionsLimitedDesc": "Todelliset oikeutesi rajoitetaan {level}-käyttöoikeustasoosi riippumatta siitä, mitä sovellus pyytää.",
1008
"viewerLimitedDesc": "Katselijana sinulla on vain lukuoikeus. Tämä sovellus ei voi luoda, muokata tai poistaa sisältöä tällä tilillä.",
1009
"editorLimitedDesc": "Muokkaajana voit luoda ja muokata sisältöä, mutta et voi hallita tilin asetuksia tai tietoturvaa."
1010
+
},
1011
+
"migration": {
1012
+
"title": "Tilin siirto",
1013
+
"subtitle": "Siirrä AT Protocol -identiteettisi palvelimien välillä",
1014
+
"navTitle": "Siirto",
1015
+
"navDesc": "Siirrä tilisi toiseen tai toisesta PDS:stä",
1016
+
"migrateHere": "Siirrä tänne",
1017
+
"migrateHereDesc": "Siirrä olemassa oleva AT Protocol -tilisi tähän PDS:ään toiselta palvelimelta.",
1018
+
"migrateAway": "Siirrä pois",
1019
+
"migrateAwayDesc": "Siirrä tilisi tästä PDS:stä toiselle palvelimelle.",
1020
+
"loginRequired": "Kirjautuminen vaaditaan",
1021
+
"bringDid": "Tuo DID ja identiteettisi",
1022
+
"transferData": "Siirrä kaikki tietosi",
1023
+
"keepFollowers": "Säilytä seuraajasi",
1024
+
"exportRepo": "Vie tietovarastosi",
1025
+
"transferToPds": "Siirrä uuteen PDS:ään",
1026
+
"updateIdentity": "Päivitä identiteettisi",
1027
+
"whatIsMigration": "Mikä on tilin siirto?",
1028
+
"whatIsMigrationDesc": "Tilin siirto mahdollistaa AT Protocol -identiteettisi siirtämisen henkilökohtaisten datapalvelimien (PDS) välillä. DID (hajautettu tunniste) pysyy samana, joten seuraajasi ja sosiaaliset yhteytesi säilyvät.",
1029
+
"beforeMigrate": "Ennen siirtoa",
1030
+
"beforeMigrate1": "Tarvitset nykyisen tilisi tunnukset",
1031
+
"beforeMigrate2": "Siirto vaatii sähköpostivahvistuksen turvallisuussyistä",
1032
+
"beforeMigrate3": "Suuret tilit, joissa on paljon kuvia, voivat kestää useita minuutteja",
1033
+
"beforeMigrate4": "Vanhalle PDS:llesi ilmoitetaan tilisi deaktivoinnista",
1034
+
"importantWarning": "Tilin siirto on merkittävä toimenpide. Varmista, että luotat kohde-PDS:ään ja ymmärrät, että tietosi siirretään. Jos jokin menee pieleen, palautus voi vaatia manuaalista toimenpidettä.",
1035
+
"learnMore": "Lue lisää siirron riskeistä",
1036
+
"resume": {
1037
+
"title": "Jatka siirtoa?",
1038
+
"incomplete": "Sinulla on keskeneräinen siirto:",
1039
+
"direction": "Suunta",
1040
+
"migratingHere": "Siirretään tänne",
1041
+
"migratingAway": "Siirretään pois",
1042
+
"from": "Mistä",
1043
+
"to": "Minne",
1044
+
"progress": "Edistyminen",
1045
+
"reenterCredentials": "Sinun täytyy syöttää tunnuksesi uudelleen jatkaaksesi.",
1046
+
"startOver": "Aloita alusta",
1047
+
"resumeButton": "Jatka"
1048
+
},
1049
+
"inbound": {
1050
+
"welcome": {
1051
+
"title": "Siirrä tähän PDS:ään",
1052
+
"desc": "Siirrä olemassa oleva AT Protocol -tilisi tälle palvelimelle.",
1053
+
"understand": "Ymmärrän riskit ja haluan jatkaa"
1054
+
},
1055
+
"sourceLogin": {
1056
+
"title": "Kirjaudu nykyiseen PDS:ääsi",
1057
+
"desc": "Syötä siirrettävän tilin tunnukset.",
1058
+
"handle": "Käyttäjätunnus",
1059
+
"handlePlaceholder": "sinä.bsky.social",
1060
+
"password": "Salasana",
1061
+
"twoFactorCode": "Kaksivaiheinen koodi",
1062
+
"twoFactorRequired": "Kaksivaiheinen tunnistautuminen vaaditaan",
1063
+
"signIn": "Kirjaudu ja jatka"
1064
+
},
1065
+
"chooseHandle": {
1066
+
"title": "Valitse uusi käyttäjätunnuksesi",
1067
+
"desc": "Valitse käyttäjätunnus tilillesi tässä PDS:ssä.",
1068
+
"handleHint": "Täydellinen käyttäjätunnuksesi on: @{handle}"
1069
+
},
1070
+
"review": {
1071
+
"title": "Tarkista siirto",
1072
+
"desc": "Tarkista ja vahvista siirtotietosi.",
1073
+
"currentHandle": "Nykyinen käyttäjätunnus",
1074
+
"newHandle": "Uusi käyttäjätunnus",
1075
+
"sourcePds": "Lähde-PDS",
1076
+
"targetPds": "Tämä PDS",
1077
+
"email": "Sähköposti",
1078
+
"inviteCode": "Kutsukoodi",
1079
+
"confirm": "Vahvistan haluavani siirtää tilini",
1080
+
"startMigration": "Aloita siirto"
1081
+
},
1082
+
"migrating": {
1083
+
"title": "Siirretään tiliäsi",
1084
+
"desc": "Odota, kun siirrämme tietojasi...",
1085
+
"gettingServiceAuth": "Haetaan palveluvaltuutusta...",
1086
+
"creatingAccount": "Luodaan tiliä uuteen PDS:ään...",
1087
+
"exportingRepo": "Viedään tietovarastoa...",
1088
+
"importingRepo": "Tuodaan tietovarastoa...",
1089
+
"countingBlobs": "Lasketaan blob-tiedostoja...",
1090
+
"migratingBlobs": "Siirretään blob-tiedostoja ({current}/{total})...",
1091
+
"migratingPrefs": "Siirretään asetuksia...",
1092
+
"requestingPlc": "Pyydetään PLC-toimintoa..."
1093
+
},
1094
+
"emailVerify": {
1095
+
"title": "Vahvista sähköpostisi",
1096
+
"desc": "Vahvistuskoodi on lähetetty osoitteeseen {email}.",
1097
+
"hint": "Syötä koodi alle tai klikkaa sähköpostissa olevaa linkkiä jatkaaksesi automaattisesti.",
1098
+
"tokenLabel": "Vahvistuskoodi",
1099
+
"tokenPlaceholder": "Syötä sähköpostista saatu koodi",
1100
+
"resend": "Lähetä koodi uudelleen",
1101
+
"verify": "Vahvista sähköposti",
1102
+
"verifying": "Vahvistetaan..."
1103
+
},
1104
+
"plcToken": {
1105
+
"title": "Vahvista henkilöllisyytesi",
1106
+
"desc": "Vahvistuskoodi on lähetetty sähköpostiisi nykyisessä PDS:ssäsi.",
1107
+
"tokenLabel": "Vahvistuskoodi",
1108
+
"tokenPlaceholder": "Syötä sähköpostista saatu koodi",
1109
+
"resend": "Lähetä uudelleen",
1110
+
"resending": "Lähetetään..."
1111
+
},
1112
+
"finalizing": {
1113
+
"title": "Viimeistellään siirtoa",
1114
+
"desc": "Odota, kun viimeistelemme siirtoa...",
1115
+
"signingPlc": "Allekirjoita identiteettipäivitys",
1116
+
"activating": "Aktivoi tili uudessa PDS:ssä",
1117
+
"deactivating": "Deaktivoi tili vanhassa PDS:ssä"
1118
+
},
1119
+
"success": {
1120
+
"title": "Siirto valmis!",
1121
+
"desc": "Tilisi on siirretty onnistuneesti tähän PDS:ään.",
1122
+
"newHandle": "Uusi käyttäjätunnus",
1123
+
"did": "DID",
1124
+
"goToDashboard": "Siirry hallintapaneeliin"
1125
+
}
1126
+
},
1127
+
"outbound": {
1128
+
"welcome": {
1129
+
"title": "Siirrä pois tästä PDS:stä",
1130
+
"desc": "Siirrä tilisi toiseen henkilökohtaiseen datapalvelimeen.",
1131
+
"warning": "Siirron jälkeen tilisi täällä deaktivoidaan.",
1132
+
"didWebNotice": "did:web-siirtoilmoitus",
1133
+
"didWebNoticeDesc": "Tilisi käyttää did:web-tunnistetta ({did}). Siirron jälkeen tämä PDS jatkaa DID-dokumenttisi tarjoamista osoittaen uuteen PDS:ään. Identiteettisi toimii niin kauan kuin tämä palvelin on päällä.",
1134
+
"understand": "Ymmärrän riskit ja haluan jatkaa"
1135
+
},
1136
+
"targetPds": {
1137
+
"title": "Valitse kohde-PDS",
1138
+
"desc": "Syötä sen PDS:n URL, johon haluat siirtyä.",
1139
+
"url": "PDS URL",
1140
+
"urlPlaceholder": "https://pds.example.com",
1141
+
"validate": "Vahvista ja jatka",
1142
+
"validating": "Vahvistetaan...",
1143
+
"connected": "Yhdistetty: {name}",
1144
+
"inviteRequired": "Kutsukoodi vaaditaan",
1145
+
"privacyPolicy": "Tietosuojakäytäntö",
1146
+
"termsOfService": "Käyttöehdot"
1147
+
},
1148
+
"newAccount": {
1149
+
"title": "Uuden tilin tiedot",
1150
+
"desc": "Määritä tilisi uudessa PDS:ssä.",
1151
+
"handle": "Käyttäjätunnus",
1152
+
"availableDomains": "Käytettävissä olevat verkkotunnukset",
1153
+
"email": "Sähköposti",
1154
+
"password": "Salasana",
1155
+
"confirmPassword": "Vahvista salasana",
1156
+
"inviteCode": "Kutsukoodi"
1157
+
},
1158
+
"review": {
1159
+
"title": "Tarkista siirto",
1160
+
"desc": "Tarkista ja vahvista siirtotietosi.",
1161
+
"currentHandle": "Nykyinen käyttäjätunnus",
1162
+
"newHandle": "Uusi käyttäjätunnus",
1163
+
"sourcePds": "Tämä PDS",
1164
+
"targetPds": "Kohde-PDS",
1165
+
"confirm": "Vahvistan haluavani siirtää tilini",
1166
+
"startMigration": "Aloita siirto"
1167
+
},
1168
+
"migrating": {
1169
+
"title": "Siirretään tiliäsi",
1170
+
"desc": "Odota, kun siirrämme tietojasi..."
1171
+
},
1172
+
"plcToken": {
1173
+
"title": "Vahvista henkilöllisyytesi",
1174
+
"desc": "Vahvistuskoodi on lähetetty sähköpostiisi."
1175
+
},
1176
+
"finalizing": {
1177
+
"title": "Viimeistellään siirtoa",
1178
+
"desc": "Odota, kun viimeistelemme siirtoa...",
1179
+
"updatingForwarding": "Päivitetään DID-dokumentin uudelleenohjausta..."
1180
+
},
1181
+
"success": {
1182
+
"title": "Siirto valmis!",
1183
+
"desc": "Tilisi on siirretty onnistuneesti uuteen PDS:ääsi.",
1184
+
"newHandle": "Uusi käyttäjätunnus",
1185
+
"newPds": "Uusi PDS",
1186
+
"nextSteps": "Seuraavat vaiheet",
1187
+
"nextSteps1": "Kirjaudu uuteen PDS:ääsi",
1188
+
"nextSteps2": "Päivitä sovellukset uusilla tunnuksillasi",
1189
+
"nextSteps3": "Seuraajasi näkevät automaattisesti uuden sijaintisi",
1190
+
"loggingOut": "Kirjaudutaan ulos {seconds} sekunnin kuluttua..."
1191
+
}
1192
+
},
1193
+
"progress": {
1194
+
"repoExported": "Tietovarasto viety",
1195
+
"repoImported": "Tietovarasto tuotu",
1196
+
"blobsMigrated": "{count} blob-tiedostoa siirretty",
1197
+
"prefsMigrated": "Asetukset siirretty",
1198
+
"plcSigned": "Identiteetti päivitetty",
1199
+
"activated": "Tili aktivoitu",
1200
+
"deactivated": "Vanha tili deaktivoitu"
1201
+
},
1202
+
"errors": {
1203
+
"connectionFailed": "Yhteys PDS:ään epäonnistui",
1204
+
"invalidCredentials": "Virheelliset tunnukset",
1205
+
"twoFactorRequired": "Kaksivaiheinen tunnistautuminen vaaditaan",
1206
+
"accountExists": "Tili on jo olemassa kohde-PDS:ssä",
1207
+
"plcFailed": "PLC-toiminto epäonnistui",
1208
+
"blobFailed": "Blob-tiedoston siirto epäonnistui: {cid}",
1209
+
"networkError": "Verkkovirhe. Yritä uudelleen."
1210
+
}
1211
}
1212
}
+201
frontend/src/locales/ja.json
+201
frontend/src/locales/ja.json
···
1029
"permissionsLimitedDesc": "アプリが何を要求しても、実際の権限は{level}アクセスレベルに制限されます。",
1030
"viewerLimitedDesc": "閲覧者として、読み取り専用アクセスのみ可能です。このアプリはこのアカウントでコンテンツの作成、更新、削除ができません。",
1031
"editorLimitedDesc": "編集者として、コンテンツの作成と編集が可能ですが、アカウント設定やセキュリティの管理はできません。"
1032
+
},
1033
+
"migration": {
1034
+
"title": "アカウント移行",
1035
+
"subtitle": "AT Protocolアイデンティティをサーバー間で移動",
1036
+
"navTitle": "移行",
1037
+
"navDesc": "別のPDSへ、または別のPDSからアカウントを移動",
1038
+
"migrateHere": "ここに移行",
1039
+
"migrateHereDesc": "既存のAT ProtocolアカウントをこのPDSに移動します。",
1040
+
"migrateAway": "別の場所に移行",
1041
+
"migrateAwayDesc": "このPDSから別のサーバーにアカウントを移動します。",
1042
+
"loginRequired": "ログインが必要です",
1043
+
"bringDid": "DIDとアイデンティティを持ち込む",
1044
+
"transferData": "すべてのデータを転送",
1045
+
"keepFollowers": "フォロワーを維持",
1046
+
"exportRepo": "リポジトリをエクスポート",
1047
+
"transferToPds": "新しいPDSに転送",
1048
+
"updateIdentity": "アイデンティティを更新",
1049
+
"whatIsMigration": "アカウント移行とは?",
1050
+
"whatIsMigrationDesc": "アカウント移行により、AT Protocolアイデンティティをパーソナルデータサーバー(PDS)間で移動できます。DID(分散型識別子)は変わらないため、フォロワーやソーシャルコネクションは維持されます。",
1051
+
"beforeMigrate": "移行前の確認事項",
1052
+
"beforeMigrate1": "現在のアカウント認証情報が必要です",
1053
+
"beforeMigrate2": "セキュリティのためメール認証が必要です",
1054
+
"beforeMigrate3": "画像が多い大きなアカウントは数分かかる場合があります",
1055
+
"beforeMigrate4": "古いPDSにアカウントの無効化が通知されます",
1056
+
"importantWarning": "アカウント移行は重要な操作です。移行先のPDSを信頼し、データが移動されることを理解してください。問題が発生した場合、手動での復旧が必要になる可能性があります。",
1057
+
"learnMore": "移行のリスクについて詳しく",
1058
+
"resume": {
1059
+
"title": "移行を再開しますか?",
1060
+
"incomplete": "未完了の移行があります:",
1061
+
"direction": "方向",
1062
+
"migratingHere": "ここに移行中",
1063
+
"migratingAway": "別の場所に移行中",
1064
+
"from": "移行元",
1065
+
"to": "移行先",
1066
+
"progress": "進行状況",
1067
+
"reenterCredentials": "続行するには認証情報を再入力する必要があります。",
1068
+
"startOver": "最初からやり直す",
1069
+
"resumeButton": "再開"
1070
+
},
1071
+
"inbound": {
1072
+
"welcome": {
1073
+
"title": "このPDSに移行",
1074
+
"desc": "既存のAT Protocolアカウントをこのサーバーに移動します。",
1075
+
"understand": "リスクを理解し、続行します"
1076
+
},
1077
+
"sourceLogin": {
1078
+
"title": "現在のPDSにサインイン",
1079
+
"desc": "移行するアカウントの認証情報を入力してください。",
1080
+
"handle": "ハンドル",
1081
+
"handlePlaceholder": "you.bsky.social",
1082
+
"password": "パスワード",
1083
+
"twoFactorCode": "2要素認証コード",
1084
+
"twoFactorRequired": "2要素認証が必要です",
1085
+
"signIn": "サインインして続行"
1086
+
},
1087
+
"chooseHandle": {
1088
+
"title": "新しいハンドルを選択",
1089
+
"desc": "このPDSでのアカウントのハンドルを選択してください。",
1090
+
"handleHint": "完全なハンドル: @{handle}"
1091
+
},
1092
+
"review": {
1093
+
"title": "移行の確認",
1094
+
"desc": "移行の詳細を確認してください。",
1095
+
"currentHandle": "現在のハンドル",
1096
+
"newHandle": "新しいハンドル",
1097
+
"sourcePds": "移行元PDS",
1098
+
"targetPds": "このPDS",
1099
+
"email": "メール",
1100
+
"inviteCode": "招待コード",
1101
+
"confirm": "アカウントを移行することを確認します",
1102
+
"startMigration": "移行を開始"
1103
+
},
1104
+
"migrating": {
1105
+
"title": "アカウントを移行中",
1106
+
"desc": "データを転送しています...",
1107
+
"gettingServiceAuth": "サービス認証を取得中...",
1108
+
"creatingAccount": "新しいPDSにアカウントを作成中...",
1109
+
"exportingRepo": "リポジトリをエクスポート中...",
1110
+
"importingRepo": "リポジトリをインポート中...",
1111
+
"countingBlobs": "blobをカウント中...",
1112
+
"migratingBlobs": "blobを移行中 ({current}/{total})...",
1113
+
"migratingPrefs": "設定を移行中...",
1114
+
"requestingPlc": "PLC操作をリクエスト中..."
1115
+
},
1116
+
"emailVerify": {
1117
+
"title": "メールアドレスを確認",
1118
+
"desc": "確認コードが {email} に送信されました。",
1119
+
"hint": "下記にコードを入力するか、メール内のリンクをクリックして自動的に続行できます。",
1120
+
"tokenLabel": "確認コード",
1121
+
"tokenPlaceholder": "メールに記載されたコードを入力",
1122
+
"resend": "コードを再送信",
1123
+
"verify": "メールを確認",
1124
+
"verifying": "確認中..."
1125
+
},
1126
+
"plcToken": {
1127
+
"title": "本人確認",
1128
+
"desc": "現在のPDSに登録されているメールアドレスに確認コードが送信されました。",
1129
+
"tokenLabel": "確認トークン",
1130
+
"tokenPlaceholder": "メールに記載されたトークンを入力",
1131
+
"resend": "再送信",
1132
+
"resending": "送信中..."
1133
+
},
1134
+
"finalizing": {
1135
+
"title": "移行を完了中",
1136
+
"desc": "移行を完了しています...",
1137
+
"signingPlc": "アイデンティティ更新に署名",
1138
+
"activating": "新しいPDSでアカウントを有効化",
1139
+
"deactivating": "古いPDSでアカウントを無効化"
1140
+
},
1141
+
"success": {
1142
+
"title": "移行完了!",
1143
+
"desc": "アカウントはこのPDSに正常に移行されました。",
1144
+
"newHandle": "新しいハンドル",
1145
+
"did": "DID",
1146
+
"goToDashboard": "ダッシュボードへ"
1147
+
}
1148
+
},
1149
+
"outbound": {
1150
+
"welcome": {
1151
+
"title": "このPDSから移行",
1152
+
"desc": "アカウントを別のパーソナルデータサーバーに移動します。",
1153
+
"warning": "移行後、ここでのアカウントは無効化されます。",
1154
+
"didWebNotice": "did:web移行のお知らせ",
1155
+
"didWebNoticeDesc": "あなたのアカウントはdid:web識別子({did})を使用しています。移行後、このPDSは新しいPDSを指すDIDドキュメントを引き続き提供します。このサーバーがオンラインである限り、アイデンティティは機能し続けます。",
1156
+
"understand": "リスクを理解し、続行します"
1157
+
},
1158
+
"targetPds": {
1159
+
"title": "移行先PDSを選択",
1160
+
"desc": "移行先のPDSのURLを入力してください。",
1161
+
"url": "PDS URL",
1162
+
"urlPlaceholder": "https://pds.example.com",
1163
+
"validate": "検証して続行",
1164
+
"validating": "検証中...",
1165
+
"connected": "{name}に接続しました",
1166
+
"inviteRequired": "招待コードが必要です",
1167
+
"privacyPolicy": "プライバシーポリシー",
1168
+
"termsOfService": "利用規約"
1169
+
},
1170
+
"newAccount": {
1171
+
"title": "新しいアカウントの詳細",
1172
+
"desc": "新しいPDSでアカウントを設定します。",
1173
+
"handle": "ハンドル",
1174
+
"availableDomains": "利用可能なドメイン",
1175
+
"email": "メール",
1176
+
"password": "パスワード",
1177
+
"confirmPassword": "パスワードを確認",
1178
+
"inviteCode": "招待コード"
1179
+
},
1180
+
"review": {
1181
+
"title": "移行の確認",
1182
+
"desc": "移行の詳細を確認してください。",
1183
+
"currentHandle": "現在のハンドル",
1184
+
"newHandle": "新しいハンドル",
1185
+
"sourcePds": "このPDS",
1186
+
"targetPds": "移行先PDS",
1187
+
"confirm": "アカウントを移行することを確認します",
1188
+
"startMigration": "移行を開始"
1189
+
},
1190
+
"migrating": {
1191
+
"title": "アカウントを移行中",
1192
+
"desc": "データを転送しています..."
1193
+
},
1194
+
"plcToken": {
1195
+
"title": "本人確認",
1196
+
"desc": "確認コードがメールに送信されました。"
1197
+
},
1198
+
"finalizing": {
1199
+
"title": "移行を完了中",
1200
+
"desc": "移行を完了しています...",
1201
+
"updatingForwarding": "DIDドキュメントの転送先を更新中..."
1202
+
},
1203
+
"success": {
1204
+
"title": "移行完了!",
1205
+
"desc": "アカウントは新しいPDSに正常に移行されました。",
1206
+
"newHandle": "新しいハンドル",
1207
+
"newPds": "新しいPDS",
1208
+
"nextSteps": "次のステップ",
1209
+
"nextSteps1": "新しいPDSにサインイン",
1210
+
"nextSteps2": "アプリの認証情報を更新",
1211
+
"nextSteps3": "フォロワーは自動的に新しい場所を確認できます",
1212
+
"loggingOut": "{seconds}秒後にログアウトします..."
1213
+
}
1214
+
},
1215
+
"progress": {
1216
+
"repoExported": "リポジトリをエクスポートしました",
1217
+
"repoImported": "リポジトリをインポートしました",
1218
+
"blobsMigrated": "{count}個のblobを移行しました",
1219
+
"prefsMigrated": "設定を移行しました",
1220
+
"plcSigned": "アイデンティティを更新しました",
1221
+
"activated": "アカウントを有効化しました",
1222
+
"deactivated": "古いアカウントを無効化しました"
1223
+
},
1224
+
"errors": {
1225
+
"connectionFailed": "PDSに接続できませんでした",
1226
+
"invalidCredentials": "認証情報が無効です",
1227
+
"twoFactorRequired": "2要素認証が必要です",
1228
+
"accountExists": "移行先PDSにアカウントが既に存在します",
1229
+
"plcFailed": "PLC操作に失敗しました",
1230
+
"blobFailed": "blobの移行に失敗しました: {cid}",
1231
+
"networkError": "ネットワークエラー。再試行してください。"
1232
+
}
1233
}
1234
}
+201
frontend/src/locales/ko.json
+201
frontend/src/locales/ko.json
···
1029
"permissionsLimitedDesc": "앱이 무엇을 요청하든 실제 권한은 {level} 액세스 수준으로 제한됩니다.",
1030
"viewerLimitedDesc": "뷰어로서 읽기 전용 액세스 권한만 있습니다. 이 앱은 이 계정에서 콘텐츠를 생성, 수정 또는 삭제할 수 없습니다.",
1031
"editorLimitedDesc": "편집자로서 콘텐츠를 생성하고 편집할 수 있지만 계정 설정이나 보안을 관리할 수 없습니다."
1032
+
},
1033
+
"migration": {
1034
+
"title": "계정 마이그레이션",
1035
+
"subtitle": "AT Protocol 아이덴티티를 서버 간에 이동",
1036
+
"navTitle": "마이그레이션",
1037
+
"navDesc": "다른 PDS로 또는 다른 PDS에서 계정 이동",
1038
+
"migrateHere": "여기로 마이그레이션",
1039
+
"migrateHereDesc": "기존 AT Protocol 계정을 다른 서버에서 이 PDS로 이동합니다.",
1040
+
"migrateAway": "다른 곳으로 마이그레이션",
1041
+
"migrateAwayDesc": "이 PDS에서 다른 서버로 계정을 이동합니다.",
1042
+
"loginRequired": "로그인 필요",
1043
+
"bringDid": "DID와 아이덴티티 가져오기",
1044
+
"transferData": "모든 데이터 전송",
1045
+
"keepFollowers": "팔로워 유지",
1046
+
"exportRepo": "저장소 내보내기",
1047
+
"transferToPds": "새 PDS로 전송",
1048
+
"updateIdentity": "아이덴티티 업데이트",
1049
+
"whatIsMigration": "계정 마이그레이션이란?",
1050
+
"whatIsMigrationDesc": "계정 마이그레이션을 통해 AT Protocol 아이덴티티를 개인 데이터 서버(PDS) 간에 이동할 수 있습니다. DID(분산 식별자)는 동일하게 유지되므로 팔로워와 소셜 연결이 보존됩니다.",
1051
+
"beforeMigrate": "마이그레이션 전 확인사항",
1052
+
"beforeMigrate1": "현재 계정 인증 정보가 필요합니다",
1053
+
"beforeMigrate2": "보안을 위해 이메일 인증이 필요합니다",
1054
+
"beforeMigrate3": "이미지가 많은 대용량 계정은 몇 분이 걸릴 수 있습니다",
1055
+
"beforeMigrate4": "이전 PDS에 계정 비활성화가 통보됩니다",
1056
+
"importantWarning": "계정 마이그레이션은 중요한 작업입니다. 대상 PDS를 신뢰하고 데이터가 이동된다는 것을 이해하세요. 문제가 발생하면 수동 복구가 필요할 수 있습니다.",
1057
+
"learnMore": "마이그레이션 위험에 대해 자세히 알아보기",
1058
+
"resume": {
1059
+
"title": "마이그레이션을 재개하시겠습니까?",
1060
+
"incomplete": "완료되지 않은 마이그레이션이 있습니다:",
1061
+
"direction": "방향",
1062
+
"migratingHere": "여기로 마이그레이션 중",
1063
+
"migratingAway": "다른 곳으로 마이그레이션 중",
1064
+
"from": "출발지",
1065
+
"to": "목적지",
1066
+
"progress": "진행 상황",
1067
+
"reenterCredentials": "계속하려면 인증 정보를 다시 입력해야 합니다.",
1068
+
"startOver": "처음부터 다시 시작",
1069
+
"resumeButton": "재개"
1070
+
},
1071
+
"inbound": {
1072
+
"welcome": {
1073
+
"title": "이 PDS로 마이그레이션",
1074
+
"desc": "기존 AT Protocol 계정을 이 서버로 이동합니다.",
1075
+
"understand": "위험을 이해하고 계속 진행합니다"
1076
+
},
1077
+
"sourceLogin": {
1078
+
"title": "현재 PDS에 로그인",
1079
+
"desc": "마이그레이션할 계정의 인증 정보를 입력하세요.",
1080
+
"handle": "핸들",
1081
+
"handlePlaceholder": "you.bsky.social",
1082
+
"password": "비밀번호",
1083
+
"twoFactorCode": "2단계 인증 코드",
1084
+
"twoFactorRequired": "2단계 인증이 필요합니다",
1085
+
"signIn": "로그인 및 계속"
1086
+
},
1087
+
"chooseHandle": {
1088
+
"title": "새 핸들 선택",
1089
+
"desc": "이 PDS에서 사용할 계정 핸들을 선택하세요.",
1090
+
"handleHint": "전체 핸들: @{handle}"
1091
+
},
1092
+
"review": {
1093
+
"title": "마이그레이션 검토",
1094
+
"desc": "마이그레이션 세부 정보를 검토하고 확인하세요.",
1095
+
"currentHandle": "현재 핸들",
1096
+
"newHandle": "새 핸들",
1097
+
"sourcePds": "소스 PDS",
1098
+
"targetPds": "이 PDS",
1099
+
"email": "이메일",
1100
+
"inviteCode": "초대 코드",
1101
+
"confirm": "계정 마이그레이션을 확인합니다",
1102
+
"startMigration": "마이그레이션 시작"
1103
+
},
1104
+
"migrating": {
1105
+
"title": "계정 마이그레이션 중",
1106
+
"desc": "데이터를 전송하는 중입니다...",
1107
+
"gettingServiceAuth": "서비스 인증 획득 중...",
1108
+
"creatingAccount": "새 PDS에 계정 생성 중...",
1109
+
"exportingRepo": "저장소 내보내기 중...",
1110
+
"importingRepo": "저장소 가져오기 중...",
1111
+
"countingBlobs": "blob 개수 세는 중...",
1112
+
"migratingBlobs": "blob 마이그레이션 중 ({current}/{total})...",
1113
+
"migratingPrefs": "환경설정 마이그레이션 중...",
1114
+
"requestingPlc": "PLC 작업 요청 중..."
1115
+
},
1116
+
"emailVerify": {
1117
+
"title": "이메일 인증",
1118
+
"desc": "인증 코드가 {email}(으)로 전송되었습니다.",
1119
+
"hint": "아래에 코드를 입력하거나, 이메일의 링크를 클릭하여 자동으로 계속할 수 있습니다.",
1120
+
"tokenLabel": "인증 코드",
1121
+
"tokenPlaceholder": "이메일에서 받은 코드 입력",
1122
+
"resend": "코드 재전송",
1123
+
"verify": "이메일 인증",
1124
+
"verifying": "인증 중..."
1125
+
},
1126
+
"plcToken": {
1127
+
"title": "신원 확인",
1128
+
"desc": "현재 PDS에 등록된 이메일로 인증 코드가 전송되었습니다.",
1129
+
"tokenLabel": "인증 토큰",
1130
+
"tokenPlaceholder": "이메일에서 받은 토큰 입력",
1131
+
"resend": "재전송",
1132
+
"resending": "전송 중..."
1133
+
},
1134
+
"finalizing": {
1135
+
"title": "마이그레이션 완료 중",
1136
+
"desc": "마이그레이션을 완료하는 중입니다...",
1137
+
"signingPlc": "아이덴티티 업데이트 서명",
1138
+
"activating": "새 PDS에서 계정 활성화",
1139
+
"deactivating": "이전 PDS에서 계정 비활성화"
1140
+
},
1141
+
"success": {
1142
+
"title": "마이그레이션 완료!",
1143
+
"desc": "계정이 이 PDS로 성공적으로 마이그레이션되었습니다.",
1144
+
"newHandle": "새 핸들",
1145
+
"did": "DID",
1146
+
"goToDashboard": "대시보드로 이동"
1147
+
}
1148
+
},
1149
+
"outbound": {
1150
+
"welcome": {
1151
+
"title": "이 PDS에서 마이그레이션",
1152
+
"desc": "계정을 다른 개인 데이터 서버로 이동합니다.",
1153
+
"warning": "마이그레이션 후 이 PDS에서 계정이 비활성화됩니다.",
1154
+
"didWebNotice": "did:web 마이그레이션 알림",
1155
+
"didWebNoticeDesc": "귀하의 계정은 did:web 식별자({did})를 사용합니다. 마이그레이션 후 이 PDS는 새 PDS를 가리키는 DID 문서를 계속 제공합니다. 이 서버가 온라인인 한 아이덴티티는 계속 작동합니다.",
1156
+
"understand": "위험을 이해하고 계속 진행합니다"
1157
+
},
1158
+
"targetPds": {
1159
+
"title": "대상 PDS 선택",
1160
+
"desc": "마이그레이션할 PDS의 URL을 입력하세요.",
1161
+
"url": "PDS URL",
1162
+
"urlPlaceholder": "https://pds.example.com",
1163
+
"validate": "확인 및 계속",
1164
+
"validating": "확인 중...",
1165
+
"connected": "{name}에 연결됨",
1166
+
"inviteRequired": "초대 코드 필요",
1167
+
"privacyPolicy": "개인정보 처리방침",
1168
+
"termsOfService": "서비스 약관"
1169
+
},
1170
+
"newAccount": {
1171
+
"title": "새 계정 세부 정보",
1172
+
"desc": "새 PDS에서 계정을 설정합니다.",
1173
+
"handle": "핸들",
1174
+
"availableDomains": "사용 가능한 도메인",
1175
+
"email": "이메일",
1176
+
"password": "비밀번호",
1177
+
"confirmPassword": "비밀번호 확인",
1178
+
"inviteCode": "초대 코드"
1179
+
},
1180
+
"review": {
1181
+
"title": "마이그레이션 검토",
1182
+
"desc": "마이그레이션 세부 정보를 검토하고 확인하세요.",
1183
+
"currentHandle": "현재 핸들",
1184
+
"newHandle": "새 핸들",
1185
+
"sourcePds": "이 PDS",
1186
+
"targetPds": "대상 PDS",
1187
+
"confirm": "계정 마이그레이션을 확인합니다",
1188
+
"startMigration": "마이그레이션 시작"
1189
+
},
1190
+
"migrating": {
1191
+
"title": "계정 마이그레이션 중",
1192
+
"desc": "데이터를 전송하는 중입니다..."
1193
+
},
1194
+
"plcToken": {
1195
+
"title": "신원 확인",
1196
+
"desc": "이메일로 인증 코드가 전송되었습니다."
1197
+
},
1198
+
"finalizing": {
1199
+
"title": "마이그레이션 완료 중",
1200
+
"desc": "마이그레이션을 완료하는 중입니다...",
1201
+
"updatingForwarding": "DID 문서 포워딩 업데이트 중..."
1202
+
},
1203
+
"success": {
1204
+
"title": "마이그레이션 완료!",
1205
+
"desc": "계정이 새 PDS로 성공적으로 마이그레이션되었습니다.",
1206
+
"newHandle": "새 핸들",
1207
+
"newPds": "새 PDS",
1208
+
"nextSteps": "다음 단계",
1209
+
"nextSteps1": "새 PDS에 로그인",
1210
+
"nextSteps2": "새 인증 정보로 앱 업데이트",
1211
+
"nextSteps3": "팔로워가 자동으로 새 위치를 확인할 수 있습니다",
1212
+
"loggingOut": "{seconds}초 후 로그아웃됩니다..."
1213
+
}
1214
+
},
1215
+
"progress": {
1216
+
"repoExported": "저장소 내보내기 완료",
1217
+
"repoImported": "저장소 가져오기 완료",
1218
+
"blobsMigrated": "{count}개 blob 마이그레이션됨",
1219
+
"prefsMigrated": "환경설정 마이그레이션됨",
1220
+
"plcSigned": "아이덴티티 업데이트됨",
1221
+
"activated": "계정 활성화됨",
1222
+
"deactivated": "이전 계정 비활성화됨"
1223
+
},
1224
+
"errors": {
1225
+
"connectionFailed": "PDS에 연결할 수 없습니다",
1226
+
"invalidCredentials": "잘못된 인증 정보",
1227
+
"twoFactorRequired": "2단계 인증이 필요합니다",
1228
+
"accountExists": "대상 PDS에 계정이 이미 존재합니다",
1229
+
"plcFailed": "PLC 작업 실패",
1230
+
"blobFailed": "blob 마이그레이션 실패: {cid}",
1231
+
"networkError": "네트워크 오류. 다시 시도하세요."
1232
+
}
1233
}
1234
}
+201
frontend/src/locales/sv.json
+201
frontend/src/locales/sv.json
···
1029
"permissionsLimitedDesc": "Dina faktiska behörigheter begränsas till din {level}-åtkomstnivå, oavsett vad appen begär.",
1030
"viewerLimitedDesc": "Som visare har du endast läsåtkomst. Denna app kommer inte att kunna skapa, uppdatera eller ta bort innehåll på detta konto.",
1031
"editorLimitedDesc": "Som redigerare kan du skapa och redigera innehåll men kan inte hantera kontoinställningar eller säkerhet."
1032
}
1033
}
···
1029
"permissionsLimitedDesc": "Dina faktiska behörigheter begränsas till din {level}-åtkomstnivå, oavsett vad appen begär.",
1030
"viewerLimitedDesc": "Som visare har du endast läsåtkomst. Denna app kommer inte att kunna skapa, uppdatera eller ta bort innehåll på detta konto.",
1031
"editorLimitedDesc": "Som redigerare kan du skapa och redigera innehåll men kan inte hantera kontoinställningar eller säkerhet."
1032
+
},
1033
+
"migration": {
1034
+
"title": "Kontoflyttning",
1035
+
"subtitle": "Flytta din AT Protocol-identitet mellan servrar",
1036
+
"navTitle": "Flytta",
1037
+
"navDesc": "Flytta ditt konto till eller från en annan PDS",
1038
+
"migrateHere": "Flytta hit",
1039
+
"migrateHereDesc": "Flytta ditt befintliga AT Protocol-konto till denna PDS från en annan server.",
1040
+
"migrateAway": "Flytta bort",
1041
+
"migrateAwayDesc": "Flytta ditt konto från denna PDS till en annan server.",
1042
+
"loginRequired": "Inloggning krävs",
1043
+
"bringDid": "Ta med din DID och identitet",
1044
+
"transferData": "Överför all din data",
1045
+
"keepFollowers": "Behåll dina följare",
1046
+
"exportRepo": "Exportera ditt arkiv",
1047
+
"transferToPds": "Överför till ny PDS",
1048
+
"updateIdentity": "Uppdatera din identitet",
1049
+
"whatIsMigration": "Vad är kontoflyttning?",
1050
+
"whatIsMigrationDesc": "Kontoflyttning låter dig flytta din AT Protocol-identitet mellan personliga dataservrar (PDS). Din DID (decentraliserad identifierare) förblir densamma, så dina följare och sociala kopplingar bevaras.",
1051
+
"beforeMigrate": "Innan du flyttar",
1052
+
"beforeMigrate1": "Du behöver dina nuvarande kontouppgifter",
1053
+
"beforeMigrate2": "Flytt kräver e-postverifiering för säkerhet",
1054
+
"beforeMigrate3": "Stora konton med många bilder kan ta flera minuter",
1055
+
"beforeMigrate4": "Din gamla PDS kommer att meddelas om kontoinaktivering",
1056
+
"importantWarning": "Kontoflyttning är en betydande åtgärd. Se till att du litar på mål-PDS och förstår att din data kommer att flyttas. Om något går fel kan manuell återställning krävas.",
1057
+
"learnMore": "Läs mer om flyttningsrisker",
1058
+
"resume": {
1059
+
"title": "Återuppta flytt?",
1060
+
"incomplete": "Du har en ofullständig flytt pågående:",
1061
+
"direction": "Riktning",
1062
+
"migratingHere": "Flyttar hit",
1063
+
"migratingAway": "Flyttar bort",
1064
+
"from": "Från",
1065
+
"to": "Till",
1066
+
"progress": "Framsteg",
1067
+
"reenterCredentials": "Du måste ange dina uppgifter igen för att fortsätta.",
1068
+
"startOver": "Börja om",
1069
+
"resumeButton": "Återuppta"
1070
+
},
1071
+
"inbound": {
1072
+
"welcome": {
1073
+
"title": "Flytta till denna PDS",
1074
+
"desc": "Flytta ditt befintliga AT Protocol-konto till denna server.",
1075
+
"understand": "Jag förstår riskerna och vill fortsätta"
1076
+
},
1077
+
"sourceLogin": {
1078
+
"title": "Logga in på din nuvarande PDS",
1079
+
"desc": "Ange uppgifterna för kontot du vill flytta.",
1080
+
"handle": "Användarnamn",
1081
+
"handlePlaceholder": "du.bsky.social",
1082
+
"password": "Lösenord",
1083
+
"twoFactorCode": "Tvåfaktorkod",
1084
+
"twoFactorRequired": "Tvåfaktorautentisering krävs",
1085
+
"signIn": "Logga in och fortsätt"
1086
+
},
1087
+
"chooseHandle": {
1088
+
"title": "Välj ditt nya användarnamn",
1089
+
"desc": "Välj ett användarnamn för ditt konto på denna PDS.",
1090
+
"handleHint": "Ditt fullständiga användarnamn blir: @{handle}"
1091
+
},
1092
+
"review": {
1093
+
"title": "Granska flytt",
1094
+
"desc": "Granska och bekräfta dina flyttdetaljer.",
1095
+
"currentHandle": "Nuvarande användarnamn",
1096
+
"newHandle": "Nytt användarnamn",
1097
+
"sourcePds": "Käll-PDS",
1098
+
"targetPds": "Denna PDS",
1099
+
"email": "E-post",
1100
+
"inviteCode": "Inbjudningskod",
1101
+
"confirm": "Jag bekräftar att jag vill flytta mitt konto",
1102
+
"startMigration": "Starta flytt"
1103
+
},
1104
+
"migrating": {
1105
+
"title": "Flyttar ditt konto",
1106
+
"desc": "Vänta medan vi överför din data...",
1107
+
"gettingServiceAuth": "Hämtar tjänstauktorisering...",
1108
+
"creatingAccount": "Skapar konto på ny PDS...",
1109
+
"exportingRepo": "Exporterar arkiv...",
1110
+
"importingRepo": "Importerar arkiv...",
1111
+
"countingBlobs": "Räknar blobbar...",
1112
+
"migratingBlobs": "Flyttar blobbar ({current}/{total})...",
1113
+
"migratingPrefs": "Flyttar inställningar...",
1114
+
"requestingPlc": "Begär PLC-operation..."
1115
+
},
1116
+
"emailVerify": {
1117
+
"title": "Verifiera din e-post",
1118
+
"desc": "En verifieringskod har skickats till {email}.",
1119
+
"hint": "Ange koden nedan eller klicka på länken i e-postmeddelandet för att fortsätta automatiskt.",
1120
+
"tokenLabel": "Verifieringskod",
1121
+
"tokenPlaceholder": "Ange kod från e-post",
1122
+
"resend": "Skicka kod igen",
1123
+
"verify": "Verifiera e-post",
1124
+
"verifying": "Verifierar..."
1125
+
},
1126
+
"plcToken": {
1127
+
"title": "Verifiera din identitet",
1128
+
"desc": "En verifieringskod har skickats till din e-post på din nuvarande PDS.",
1129
+
"tokenLabel": "Verifieringstoken",
1130
+
"tokenPlaceholder": "Ange token från din e-post",
1131
+
"resend": "Skicka igen",
1132
+
"resending": "Skickar..."
1133
+
},
1134
+
"finalizing": {
1135
+
"title": "Slutför flytt",
1136
+
"desc": "Vänta medan vi slutför flytten...",
1137
+
"signingPlc": "Signera identitetsuppdatering",
1138
+
"activating": "Aktivera konto på ny PDS",
1139
+
"deactivating": "Inaktivera konto på gammal PDS"
1140
+
},
1141
+
"success": {
1142
+
"title": "Flytt klar!",
1143
+
"desc": "Ditt konto har framgångsrikt flyttats till denna PDS.",
1144
+
"newHandle": "Nytt användarnamn",
1145
+
"did": "DID",
1146
+
"goToDashboard": "Gå till instrumentpanel"
1147
+
}
1148
+
},
1149
+
"outbound": {
1150
+
"welcome": {
1151
+
"title": "Flytta från denna PDS",
1152
+
"desc": "Flytta ditt konto till en annan personlig dataserver.",
1153
+
"warning": "Efter flytten kommer ditt konto här att inaktiveras.",
1154
+
"didWebNotice": "did:web-flyttmeddelande",
1155
+
"didWebNoticeDesc": "Ditt konto använder en did:web-identifierare ({did}). Efter flytten kommer denna PDS att fortsätta servera ditt DID-dokument som pekar till den nya PDS. Din identitet kommer att fungera så länge denna server är online.",
1156
+
"understand": "Jag förstår riskerna och vill fortsätta"
1157
+
},
1158
+
"targetPds": {
1159
+
"title": "Välj mål-PDS",
1160
+
"desc": "Ange URL:en för PDS du vill flytta till.",
1161
+
"url": "PDS URL",
1162
+
"urlPlaceholder": "https://pds.example.com",
1163
+
"validate": "Validera och fortsätt",
1164
+
"validating": "Validerar...",
1165
+
"connected": "Ansluten till {name}",
1166
+
"inviteRequired": "Inbjudningskod krävs",
1167
+
"privacyPolicy": "Integritetspolicy",
1168
+
"termsOfService": "Användarvillkor"
1169
+
},
1170
+
"newAccount": {
1171
+
"title": "Nya kontouppgifter",
1172
+
"desc": "Konfigurera ditt konto på den nya PDS.",
1173
+
"handle": "Användarnamn",
1174
+
"availableDomains": "Tillgängliga domäner",
1175
+
"email": "E-post",
1176
+
"password": "Lösenord",
1177
+
"confirmPassword": "Bekräfta lösenord",
1178
+
"inviteCode": "Inbjudningskod"
1179
+
},
1180
+
"review": {
1181
+
"title": "Granska flytt",
1182
+
"desc": "Granska och bekräfta dina flyttdetaljer.",
1183
+
"currentHandle": "Nuvarande användarnamn",
1184
+
"newHandle": "Nytt användarnamn",
1185
+
"sourcePds": "Denna PDS",
1186
+
"targetPds": "Mål-PDS",
1187
+
"confirm": "Jag bekräftar att jag vill flytta mitt konto",
1188
+
"startMigration": "Starta flytt"
1189
+
},
1190
+
"migrating": {
1191
+
"title": "Flyttar ditt konto",
1192
+
"desc": "Vänta medan vi överför din data..."
1193
+
},
1194
+
"plcToken": {
1195
+
"title": "Verifiera din identitet",
1196
+
"desc": "En verifieringskod har skickats till din e-post."
1197
+
},
1198
+
"finalizing": {
1199
+
"title": "Slutför flytt",
1200
+
"desc": "Vänta medan vi slutför flytten...",
1201
+
"updatingForwarding": "Uppdaterar DID-dokumentvidarebefordran..."
1202
+
},
1203
+
"success": {
1204
+
"title": "Flytt klar!",
1205
+
"desc": "Ditt konto har framgångsrikt flyttats till din nya PDS.",
1206
+
"newHandle": "Nytt användarnamn",
1207
+
"newPds": "Ny PDS",
1208
+
"nextSteps": "Nästa steg",
1209
+
"nextSteps1": "Logga in på din nya PDS",
1210
+
"nextSteps2": "Uppdatera dina appar med nya uppgifter",
1211
+
"nextSteps3": "Dina följare kommer automatiskt se din nya plats",
1212
+
"loggingOut": "Loggar ut om {seconds} sekunder..."
1213
+
}
1214
+
},
1215
+
"progress": {
1216
+
"repoExported": "Arkiv exporterat",
1217
+
"repoImported": "Arkiv importerat",
1218
+
"blobsMigrated": "{count} blobbar flyttade",
1219
+
"prefsMigrated": "Inställningar flyttade",
1220
+
"plcSigned": "Identitet uppdaterad",
1221
+
"activated": "Konto aktiverat",
1222
+
"deactivated": "Gammalt konto inaktiverat"
1223
+
},
1224
+
"errors": {
1225
+
"connectionFailed": "Kunde inte ansluta till PDS",
1226
+
"invalidCredentials": "Ogiltiga uppgifter",
1227
+
"twoFactorRequired": "Tvåfaktorautentisering krävs",
1228
+
"accountExists": "Konto finns redan på mål-PDS",
1229
+
"plcFailed": "PLC-operation misslyckades",
1230
+
"blobFailed": "Kunde inte flytta blob: {cid}",
1231
+
"networkError": "Nätverksfel. Försök igen."
1232
+
}
1233
}
1234
}
+201
frontend/src/locales/zh.json
+201
frontend/src/locales/zh.json
···
1013
"permissionsLimitedDesc": "无论应用请求什么权限,您的实际权限将限制在{level}访问级别。",
1014
"viewerLimitedDesc": "作为查看者,您只有只读权限。此应用无法在此账户上创建、更新或删除内容。",
1015
"editorLimitedDesc": "作为编辑者,您可以创建和编辑内容,但无法管理账户设置或安全选项。"
1016
+
},
1017
+
"migration": {
1018
+
"title": "账户迁移",
1019
+
"subtitle": "在服务器之间移动您的AT Protocol身份",
1020
+
"navTitle": "迁移",
1021
+
"navDesc": "将您的账户移至其他PDS或从其他PDS移入",
1022
+
"migrateHere": "迁移到此处",
1023
+
"migrateHereDesc": "将您现有的AT Protocol账户从其他服务器移至此PDS。",
1024
+
"migrateAway": "迁移离开",
1025
+
"migrateAwayDesc": "将您的账户从此PDS移至其他服务器。",
1026
+
"loginRequired": "需要登录",
1027
+
"bringDid": "携带您的DID和身份",
1028
+
"transferData": "转移所有数据",
1029
+
"keepFollowers": "保留您的关注者",
1030
+
"exportRepo": "导出您的存储库",
1031
+
"transferToPds": "转移到新PDS",
1032
+
"updateIdentity": "更新您的身份",
1033
+
"whatIsMigration": "什么是账户迁移?",
1034
+
"whatIsMigrationDesc": "账户迁移允许您在个人数据服务器(PDS)之间移动AT Protocol身份。您的DID(去中心化标识符)保持不变,因此您的关注者和社交连接得以保留。",
1035
+
"beforeMigrate": "迁移前须知",
1036
+
"beforeMigrate1": "您需要当前账户的凭据",
1037
+
"beforeMigrate2": "为确保安全,迁移需要邮箱验证",
1038
+
"beforeMigrate3": "包含大量图片的大型账户可能需要几分钟",
1039
+
"beforeMigrate4": "您的旧PDS将收到账户停用通知",
1040
+
"importantWarning": "账户迁移是一项重要操作。请确保您信任目标PDS,并了解您的数据将被移动。如果出现问题,可能需要手动恢复。",
1041
+
"learnMore": "了解更多迁移风险",
1042
+
"resume": {
1043
+
"title": "恢复迁移?",
1044
+
"incomplete": "您有一个未完成的迁移:",
1045
+
"direction": "方向",
1046
+
"migratingHere": "正在迁移到此处",
1047
+
"migratingAway": "正在迁移离开",
1048
+
"from": "从",
1049
+
"to": "到",
1050
+
"progress": "进度",
1051
+
"reenterCredentials": "您需要重新输入凭据以继续。",
1052
+
"startOver": "重新开始",
1053
+
"resumeButton": "恢复"
1054
+
},
1055
+
"inbound": {
1056
+
"welcome": {
1057
+
"title": "迁移到此PDS",
1058
+
"desc": "将您现有的AT Protocol账户移至此服务器。",
1059
+
"understand": "我了解风险并希望继续"
1060
+
},
1061
+
"sourceLogin": {
1062
+
"title": "登录到您当前的PDS",
1063
+
"desc": "输入您要迁移的账户凭据。",
1064
+
"handle": "用户名",
1065
+
"handlePlaceholder": "you.bsky.social",
1066
+
"password": "密码",
1067
+
"twoFactorCode": "双因素验证码",
1068
+
"twoFactorRequired": "需要双因素认证",
1069
+
"signIn": "登录并继续"
1070
+
},
1071
+
"chooseHandle": {
1072
+
"title": "选择新用户名",
1073
+
"desc": "为您在此PDS上的账户选择用户名。",
1074
+
"handleHint": "您的完整用户名将是:@{handle}"
1075
+
},
1076
+
"review": {
1077
+
"title": "检查迁移",
1078
+
"desc": "请检查并确认您的迁移详情。",
1079
+
"currentHandle": "当前用户名",
1080
+
"newHandle": "新用户名",
1081
+
"sourcePds": "源PDS",
1082
+
"targetPds": "此PDS",
1083
+
"email": "邮箱",
1084
+
"inviteCode": "邀请码",
1085
+
"confirm": "我确认要迁移我的账户",
1086
+
"startMigration": "开始迁移"
1087
+
},
1088
+
"migrating": {
1089
+
"title": "正在迁移您的账户",
1090
+
"desc": "请稍候,正在转移您的数据...",
1091
+
"gettingServiceAuth": "正在获取服务授权...",
1092
+
"creatingAccount": "正在新PDS上创建账户...",
1093
+
"exportingRepo": "正在导出存储库...",
1094
+
"importingRepo": "正在导入存储库...",
1095
+
"countingBlobs": "正在统计blob...",
1096
+
"migratingBlobs": "正在迁移blob ({current}/{total})...",
1097
+
"migratingPrefs": "正在迁移偏好设置...",
1098
+
"requestingPlc": "正在请求PLC操作..."
1099
+
},
1100
+
"emailVerify": {
1101
+
"title": "验证您的邮箱",
1102
+
"desc": "验证码已发送至 {email}。",
1103
+
"hint": "在下方输入验证码,或点击邮件中的链接自动继续。",
1104
+
"tokenLabel": "验证码",
1105
+
"tokenPlaceholder": "输入邮件中的验证码",
1106
+
"resend": "重新发送",
1107
+
"verify": "验证邮箱",
1108
+
"verifying": "验证中..."
1109
+
},
1110
+
"plcToken": {
1111
+
"title": "验证您的身份",
1112
+
"desc": "验证码已发送到您在当前PDS注册的邮箱。",
1113
+
"tokenLabel": "验证令牌",
1114
+
"tokenPlaceholder": "输入邮件中的令牌",
1115
+
"resend": "重新发送",
1116
+
"resending": "发送中..."
1117
+
},
1118
+
"finalizing": {
1119
+
"title": "正在完成迁移",
1120
+
"desc": "请稍候,正在完成迁移...",
1121
+
"signingPlc": "签署身份更新",
1122
+
"activating": "在新PDS上激活账户",
1123
+
"deactivating": "在旧PDS上停用账户"
1124
+
},
1125
+
"success": {
1126
+
"title": "迁移完成!",
1127
+
"desc": "您的账户已成功迁移到此PDS。",
1128
+
"newHandle": "新用户名",
1129
+
"did": "DID",
1130
+
"goToDashboard": "前往仪表板"
1131
+
}
1132
+
},
1133
+
"outbound": {
1134
+
"welcome": {
1135
+
"title": "从此PDS迁移离开",
1136
+
"desc": "将您的账户移至另一个个人数据服务器。",
1137
+
"warning": "迁移后,您在此处的账户将被停用。",
1138
+
"didWebNotice": "did:web迁移通知",
1139
+
"didWebNoticeDesc": "您的账户使用did:web标识符({did})。迁移后,此PDS将继续提供指向新PDS的DID文档。只要此服务器在线,您的身份将继续有效。",
1140
+
"understand": "我了解风险并希望继续"
1141
+
},
1142
+
"targetPds": {
1143
+
"title": "选择目标PDS",
1144
+
"desc": "输入您要迁移到的PDS的URL。",
1145
+
"url": "PDS URL",
1146
+
"urlPlaceholder": "https://pds.example.com",
1147
+
"validate": "验证并继续",
1148
+
"validating": "验证中...",
1149
+
"connected": "已连接到 {name}",
1150
+
"inviteRequired": "需要邀请码",
1151
+
"privacyPolicy": "隐私政策",
1152
+
"termsOfService": "服务条款"
1153
+
},
1154
+
"newAccount": {
1155
+
"title": "新账户详情",
1156
+
"desc": "在新PDS上设置您的账户。",
1157
+
"handle": "用户名",
1158
+
"availableDomains": "可用域名",
1159
+
"email": "邮箱",
1160
+
"password": "密码",
1161
+
"confirmPassword": "确认密码",
1162
+
"inviteCode": "邀请码"
1163
+
},
1164
+
"review": {
1165
+
"title": "检查迁移",
1166
+
"desc": "请检查并确认您的迁移详情。",
1167
+
"currentHandle": "当前用户名",
1168
+
"newHandle": "新用户名",
1169
+
"sourcePds": "此PDS",
1170
+
"targetPds": "目标PDS",
1171
+
"confirm": "我确认要迁移我的账户",
1172
+
"startMigration": "开始迁移"
1173
+
},
1174
+
"migrating": {
1175
+
"title": "正在迁移您的账户",
1176
+
"desc": "请稍候,正在转移您的数据..."
1177
+
},
1178
+
"plcToken": {
1179
+
"title": "验证您的身份",
1180
+
"desc": "验证码已发送到您的邮箱。"
1181
+
},
1182
+
"finalizing": {
1183
+
"title": "正在完成迁移",
1184
+
"desc": "请稍候,正在完成迁移...",
1185
+
"updatingForwarding": "正在更新DID文档转发..."
1186
+
},
1187
+
"success": {
1188
+
"title": "迁移完成!",
1189
+
"desc": "您的账户已成功迁移到新PDS。",
1190
+
"newHandle": "新用户名",
1191
+
"newPds": "新PDS",
1192
+
"nextSteps": "后续步骤",
1193
+
"nextSteps1": "登录到您的新PDS",
1194
+
"nextSteps2": "使用新凭据更新您的应用",
1195
+
"nextSteps3": "您的关注者将自动看到您的新位置",
1196
+
"loggingOut": "{seconds}秒后退出登录..."
1197
+
}
1198
+
},
1199
+
"progress": {
1200
+
"repoExported": "存储库已导出",
1201
+
"repoImported": "存储库已导入",
1202
+
"blobsMigrated": "已迁移{count}个blob",
1203
+
"prefsMigrated": "偏好设置已迁移",
1204
+
"plcSigned": "身份已更新",
1205
+
"activated": "账户已激活",
1206
+
"deactivated": "旧账户已停用"
1207
+
},
1208
+
"errors": {
1209
+
"connectionFailed": "无法连接到PDS",
1210
+
"invalidCredentials": "凭据无效",
1211
+
"twoFactorRequired": "需要双因素认证",
1212
+
"accountExists": "目标PDS上已存在账户",
1213
+
"plcFailed": "PLC操作失败",
1214
+
"blobFailed": "blob迁移失败:{cid}",
1215
+
"networkError": "网络错误,请重试。"
1216
+
}
1217
}
1218
}
+4
frontend/src/routes/Dashboard.svelte
+4
frontend/src/routes/Dashboard.svelte
···
190
<h3>{$_('dashboard.navDelegation')}</h3>
191
<p>{$_('dashboard.navDelegationDesc')}</p>
192
</a>
193
+
<a href="#/migrate" class="nav-card">
194
+
<h3>{$_('migration.navTitle')}</h3>
195
+
<p>{$_('migration.navDesc')}</p>
196
+
</a>
197
{#if auth.session.isAdmin}
198
<a href="#/admin" class="nav-card admin-card">
199
<h3>{$_('dashboard.navAdmin')}</h3>
+413
frontend/src/routes/Migration.svelte
+413
frontend/src/routes/Migration.svelte
···
···
1
+
<script lang="ts">
2
+
import { getAuthState, logout, setSession } from '../lib/auth.svelte'
3
+
import { navigate } from '../lib/router.svelte'
4
+
import {
5
+
createInboundMigrationFlow,
6
+
createOutboundMigrationFlow,
7
+
hasPendingMigration,
8
+
getResumeInfo,
9
+
clearMigrationState,
10
+
loadMigrationState,
11
+
} from '../lib/migration'
12
+
import InboundWizard from '../components/migration/InboundWizard.svelte'
13
+
import OutboundWizard from '../components/migration/OutboundWizard.svelte'
14
+
15
+
const auth = getAuthState()
16
+
17
+
type Direction = 'select' | 'inbound' | 'outbound'
18
+
let direction = $state<Direction>('select')
19
+
let showResumeModal = $state(false)
20
+
let resumeInfo = $state<ReturnType<typeof getResumeInfo>>(null)
21
+
22
+
let inboundFlow = $state<ReturnType<typeof createInboundMigrationFlow> | null>(null)
23
+
let outboundFlow = $state<ReturnType<typeof createOutboundMigrationFlow> | null>(null)
24
+
25
+
if (hasPendingMigration()) {
26
+
resumeInfo = getResumeInfo()
27
+
if (resumeInfo) {
28
+
showResumeModal = true
29
+
}
30
+
}
31
+
32
+
function selectInbound() {
33
+
direction = 'inbound'
34
+
inboundFlow = createInboundMigrationFlow()
35
+
}
36
+
37
+
function selectOutbound() {
38
+
if (!auth.session) {
39
+
navigate('/login')
40
+
return
41
+
}
42
+
direction = 'outbound'
43
+
outboundFlow = createOutboundMigrationFlow()
44
+
outboundFlow.initLocalClient(auth.session.accessJwt, auth.session.did, auth.session.handle)
45
+
}
46
+
47
+
function handleResume() {
48
+
const stored = loadMigrationState()
49
+
if (!stored) return
50
+
51
+
showResumeModal = false
52
+
53
+
if (stored.direction === 'inbound') {
54
+
direction = 'inbound'
55
+
inboundFlow = createInboundMigrationFlow()
56
+
inboundFlow.resumeFromState(stored)
57
+
} else {
58
+
if (!auth.session) {
59
+
navigate('/login')
60
+
return
61
+
}
62
+
direction = 'outbound'
63
+
outboundFlow = createOutboundMigrationFlow()
64
+
outboundFlow.initLocalClient(auth.session.accessJwt, auth.session.did, auth.session.handle)
65
+
}
66
+
}
67
+
68
+
function handleStartOver() {
69
+
showResumeModal = false
70
+
clearMigrationState()
71
+
resumeInfo = null
72
+
}
73
+
74
+
function handleBack() {
75
+
if (inboundFlow) {
76
+
inboundFlow.reset()
77
+
inboundFlow = null
78
+
}
79
+
if (outboundFlow) {
80
+
outboundFlow.reset()
81
+
outboundFlow = null
82
+
}
83
+
direction = 'select'
84
+
}
85
+
86
+
function handleInboundComplete() {
87
+
const session = inboundFlow?.getLocalSession()
88
+
if (session) {
89
+
setSession({
90
+
did: session.did,
91
+
handle: session.handle,
92
+
accessJwt: session.accessJwt,
93
+
refreshJwt: '',
94
+
})
95
+
}
96
+
navigate('/dashboard')
97
+
}
98
+
99
+
async function handleOutboundComplete() {
100
+
await logout()
101
+
navigate('/login')
102
+
}
103
+
</script>
104
+
105
+
<div class="migration-page">
106
+
{#if showResumeModal && resumeInfo}
107
+
<div class="modal-overlay">
108
+
<div class="modal">
109
+
<h2>Resume Migration?</h2>
110
+
<p>You have an incomplete migration in progress:</p>
111
+
<div class="resume-details">
112
+
<div class="detail-row">
113
+
<span class="label">Direction:</span>
114
+
<span class="value">{resumeInfo.direction === 'inbound' ? 'Migrating here' : 'Migrating away'}</span>
115
+
</div>
116
+
{#if resumeInfo.sourceHandle}
117
+
<div class="detail-row">
118
+
<span class="label">From:</span>
119
+
<span class="value">{resumeInfo.sourceHandle}</span>
120
+
</div>
121
+
{/if}
122
+
{#if resumeInfo.targetHandle}
123
+
<div class="detail-row">
124
+
<span class="label">To:</span>
125
+
<span class="value">{resumeInfo.targetHandle}</span>
126
+
</div>
127
+
{/if}
128
+
<div class="detail-row">
129
+
<span class="label">Progress:</span>
130
+
<span class="value">{resumeInfo.progressSummary}</span>
131
+
</div>
132
+
</div>
133
+
<p class="note">You will need to re-enter your credentials to continue.</p>
134
+
<div class="modal-actions">
135
+
<button class="ghost" onclick={handleStartOver}>Start Over</button>
136
+
<button onclick={handleResume}>Resume</button>
137
+
</div>
138
+
</div>
139
+
</div>
140
+
{/if}
141
+
142
+
{#if direction === 'select'}
143
+
<header class="page-header">
144
+
<h1>Account Migration</h1>
145
+
<p class="subtitle">Move your AT Protocol identity between servers</p>
146
+
</header>
147
+
148
+
<div class="direction-cards">
149
+
<button class="direction-card ghost" onclick={selectInbound}>
150
+
<div class="card-icon">↓</div>
151
+
<h2>Migrate Here</h2>
152
+
<p>Move your existing AT Protocol account to this PDS from another server.</p>
153
+
<ul class="features">
154
+
<li>Bring your DID and identity</li>
155
+
<li>Transfer all your data</li>
156
+
<li>Keep your followers</li>
157
+
</ul>
158
+
</button>
159
+
160
+
<button class="direction-card ghost" onclick={selectOutbound} disabled={!auth.session}>
161
+
<div class="card-icon">↑</div>
162
+
<h2>Migrate Away</h2>
163
+
<p>Move your account from this PDS to another server.</p>
164
+
<ul class="features">
165
+
<li>Export your repository</li>
166
+
<li>Transfer to new PDS</li>
167
+
<li>Update your identity</li>
168
+
</ul>
169
+
{#if !auth.session}
170
+
<p class="login-required">Login required</p>
171
+
{/if}
172
+
</button>
173
+
</div>
174
+
175
+
<div class="info-section">
176
+
<h3>What is account migration?</h3>
177
+
<p>
178
+
Account migration allows you to move your AT Protocol identity between Personal Data Servers (PDSes).
179
+
Your DID (decentralized identifier) stays the same, so your followers and social connections are preserved.
180
+
</p>
181
+
182
+
<h3>Before you migrate</h3>
183
+
<ul>
184
+
<li>You will need your current account credentials</li>
185
+
<li>Migration requires email verification for security</li>
186
+
<li>Large accounts with many images may take several minutes</li>
187
+
<li>Your old PDS will be notified to deactivate your account</li>
188
+
</ul>
189
+
190
+
<div class="warning-box">
191
+
<strong>Important:</strong> Account migration is a significant action. Make sure you trust the destination PDS
192
+
and understand that your data will be moved. If something goes wrong, recovery may require manual intervention.
193
+
<a href="https://github.com/bluesky-social/pds/blob/main/ACCOUNT_MIGRATION.md" target="_blank" rel="noopener">
194
+
Learn more about migration risks
195
+
</a>
196
+
</div>
197
+
</div>
198
+
199
+
{:else if direction === 'inbound' && inboundFlow}
200
+
<InboundWizard
201
+
flow={inboundFlow}
202
+
onBack={handleBack}
203
+
onComplete={handleInboundComplete}
204
+
/>
205
+
206
+
{:else if direction === 'outbound' && outboundFlow}
207
+
<OutboundWizard
208
+
flow={outboundFlow}
209
+
onBack={handleBack}
210
+
onComplete={handleOutboundComplete}
211
+
/>
212
+
{/if}
213
+
</div>
214
+
215
+
<style>
216
+
.migration-page {
217
+
max-width: var(--width-lg);
218
+
margin: var(--space-9) auto;
219
+
padding: var(--space-7);
220
+
}
221
+
222
+
.page-header {
223
+
text-align: center;
224
+
margin-bottom: var(--space-8);
225
+
}
226
+
227
+
.page-header h1 {
228
+
margin: 0 0 var(--space-3) 0;
229
+
}
230
+
231
+
.subtitle {
232
+
color: var(--text-secondary);
233
+
margin: 0;
234
+
font-size: var(--text-lg);
235
+
}
236
+
237
+
.direction-cards {
238
+
display: grid;
239
+
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
240
+
gap: var(--space-6);
241
+
margin-bottom: var(--space-8);
242
+
}
243
+
244
+
.direction-card {
245
+
background: var(--bg-secondary);
246
+
border: 1px solid var(--border);
247
+
border-radius: var(--radius-xl);
248
+
padding: var(--space-6);
249
+
text-align: left;
250
+
cursor: pointer;
251
+
transition: all 0.2s ease;
252
+
}
253
+
254
+
.direction-card:hover:not(:disabled) {
255
+
border-color: var(--accent);
256
+
transform: translateY(-2px);
257
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
258
+
}
259
+
260
+
.direction-card:disabled {
261
+
opacity: 0.6;
262
+
cursor: not-allowed;
263
+
}
264
+
265
+
.card-icon {
266
+
font-size: var(--text-3xl);
267
+
margin-bottom: var(--space-4);
268
+
color: var(--accent);
269
+
}
270
+
271
+
.direction-card h2 {
272
+
margin: 0 0 var(--space-3) 0;
273
+
font-size: var(--text-xl);
274
+
color: var(--text-primary);
275
+
}
276
+
277
+
.direction-card p {
278
+
color: var(--text-secondary);
279
+
margin: 0 0 var(--space-4) 0;
280
+
font-size: var(--text-sm);
281
+
}
282
+
283
+
.features {
284
+
margin: 0;
285
+
padding-left: var(--space-5);
286
+
color: var(--text-secondary);
287
+
font-size: var(--text-sm);
288
+
}
289
+
290
+
.features li {
291
+
margin-bottom: var(--space-2);
292
+
}
293
+
294
+
.login-required {
295
+
color: var(--warning-text);
296
+
font-weight: var(--font-medium);
297
+
margin-top: var(--space-4);
298
+
}
299
+
300
+
.info-section {
301
+
background: var(--bg-secondary);
302
+
border-radius: var(--radius-xl);
303
+
padding: var(--space-6);
304
+
}
305
+
306
+
.info-section h3 {
307
+
margin: 0 0 var(--space-3) 0;
308
+
font-size: var(--text-lg);
309
+
}
310
+
311
+
.info-section h3:not(:first-child) {
312
+
margin-top: var(--space-6);
313
+
}
314
+
315
+
.info-section p {
316
+
color: var(--text-secondary);
317
+
line-height: var(--leading-relaxed);
318
+
margin: 0;
319
+
}
320
+
321
+
.info-section ul {
322
+
color: var(--text-secondary);
323
+
padding-left: var(--space-5);
324
+
margin: var(--space-3) 0 0 0;
325
+
}
326
+
327
+
.info-section li {
328
+
margin-bottom: var(--space-2);
329
+
}
330
+
331
+
.warning-box {
332
+
margin-top: var(--space-6);
333
+
padding: var(--space-5);
334
+
background: var(--warning-bg);
335
+
border: 1px solid var(--warning-border);
336
+
border-radius: var(--radius-lg);
337
+
font-size: var(--text-sm);
338
+
}
339
+
340
+
.warning-box strong {
341
+
color: var(--warning-text);
342
+
}
343
+
344
+
.warning-box a {
345
+
display: block;
346
+
margin-top: var(--space-3);
347
+
color: var(--accent);
348
+
}
349
+
350
+
.modal-overlay {
351
+
position: fixed;
352
+
inset: 0;
353
+
background: rgba(0, 0, 0, 0.5);
354
+
display: flex;
355
+
align-items: center;
356
+
justify-content: center;
357
+
z-index: 1000;
358
+
}
359
+
360
+
.modal {
361
+
background: var(--bg-primary);
362
+
border-radius: var(--radius-xl);
363
+
padding: var(--space-6);
364
+
max-width: 400px;
365
+
width: 90%;
366
+
}
367
+
368
+
.modal h2 {
369
+
margin: 0 0 var(--space-4) 0;
370
+
}
371
+
372
+
.modal p {
373
+
color: var(--text-secondary);
374
+
margin: 0 0 var(--space-4) 0;
375
+
}
376
+
377
+
.resume-details {
378
+
background: var(--bg-secondary);
379
+
border-radius: var(--radius-lg);
380
+
padding: var(--space-4);
381
+
margin-bottom: var(--space-4);
382
+
}
383
+
384
+
.detail-row {
385
+
display: flex;
386
+
justify-content: space-between;
387
+
padding: var(--space-2) 0;
388
+
font-size: var(--text-sm);
389
+
}
390
+
391
+
.detail-row:not(:last-child) {
392
+
border-bottom: 1px solid var(--border);
393
+
}
394
+
395
+
.detail-row .label {
396
+
color: var(--text-secondary);
397
+
}
398
+
399
+
.detail-row .value {
400
+
font-weight: var(--font-medium);
401
+
}
402
+
403
+
.note {
404
+
font-size: var(--text-sm);
405
+
font-style: italic;
406
+
}
407
+
408
+
.modal-actions {
409
+
display: flex;
410
+
gap: var(--space-3);
411
+
justify-content: flex-end;
412
+
}
413
+
</style>
+1
-1
frontend/src/routes/Register.svelte
+1
-1
frontend/src/routes/Register.svelte
+1
src/api/identity/did.rs
+1
src/api/identity/did.rs
+1
-1
src/api/repo/blob.rs
+1
-1
src/api/repo/blob.rs
+4
-2
src/api/repo/import.rs
+4
-2
src/api/repo/import.rs
+5
-1
src/api/repo/record/batch.rs
+5
-1
src/api/repo/record/batch.rs
···
1
use super::validation::validate_record;
2
use super::write::has_verified_comms_channel;
3
-
use crate::api::repo::record::utils::{CommitParams, RecordOp, commit_and_log};
4
use crate::delegation::{self, DelegationActionType};
5
use crate::repo::tracking::TrackingBlockStore;
6
use crate::state::AppState;
···
295
let mut results: Vec<WriteResult> = Vec::new();
296
let mut ops: Vec<RecordOp> = Vec::new();
297
let mut modified_keys: Vec<String> = Vec::new();
298
for write in &input.writes {
299
match write {
300
WriteOp::Create {
···
307
{
308
return *err_response;
309
}
310
let rkey = rkey
311
.clone()
312
.unwrap_or_else(|| Tid::now(LimitedU32::MIN).to_string());
···
359
{
360
return *err_response;
361
}
362
let mut record_bytes = Vec::new();
363
if serde_ipld_dagcbor::to_writer(&mut record_bytes, value).is_err() {
364
return (StatusCode::BAD_REQUEST, Json(json!({"error": "InvalidRecord", "message": "Failed to serialize record"}))).into_response();
···
468
new_mst_root,
469
ops,
470
blocks_cids: &written_cids_str,
471
},
472
)
473
.await
···
1
use super::validation::validate_record;
2
use super::write::has_verified_comms_channel;
3
+
use crate::api::repo::record::utils::{CommitParams, RecordOp, commit_and_log, extract_blob_cids};
4
use crate::delegation::{self, DelegationActionType};
5
use crate::repo::tracking::TrackingBlockStore;
6
use crate::state::AppState;
···
295
let mut results: Vec<WriteResult> = Vec::new();
296
let mut ops: Vec<RecordOp> = Vec::new();
297
let mut modified_keys: Vec<String> = Vec::new();
298
+
let mut all_blob_cids: Vec<String> = Vec::new();
299
for write in &input.writes {
300
match write {
301
WriteOp::Create {
···
308
{
309
return *err_response;
310
}
311
+
all_blob_cids.extend(extract_blob_cids(value));
312
let rkey = rkey
313
.clone()
314
.unwrap_or_else(|| Tid::now(LimitedU32::MIN).to_string());
···
361
{
362
return *err_response;
363
}
364
+
all_blob_cids.extend(extract_blob_cids(value));
365
let mut record_bytes = Vec::new();
366
if serde_ipld_dagcbor::to_writer(&mut record_bytes, value).is_err() {
367
return (StatusCode::BAD_REQUEST, Json(json!({"error": "InvalidRecord", "message": "Failed to serialize record"}))).into_response();
···
471
new_mst_root,
472
ops,
473
blocks_cids: &written_cids_str,
474
+
blobs: &all_blob_cids,
475
},
476
)
477
.await
+1
src/api/repo/record/delete.rs
+1
src/api/repo/record/delete.rs
+38
-3
src/api/repo/record/read.rs
+38
-3
src/api/repo/record/read.rs
···
6
http::{HeaderMap, StatusCode},
7
response::{IntoResponse, Response},
8
};
9
use cid::Cid;
10
use jacquard_repo::storage::BlockStore;
11
use serde::{Deserialize, Serialize};
12
-
use serde_json::json;
13
use std::collections::HashMap;
14
use std::str::FromStr;
15
use tracing::{error, info};
16
17
#[derive(Deserialize)]
18
pub struct GetRecordInput {
19
pub repo: String,
···
163
.into_response();
164
}
165
};
166
-
let value: serde_json::Value = match serde_ipld_dagcbor::from_slice(&block) {
167
Ok(v) => v,
168
Err(e) => {
169
error!("Failed to deserialize record: {:?}", e);
···
174
.into_response();
175
}
176
};
177
Json(json!({
178
"uri": format!("at://{}/{}/{}", input.repo, input.collection, input.rkey),
179
"cid": record_cid_str,
···
323
for (cid, block_opt) in cids.iter().zip(blocks.into_iter()) {
324
if let Some(block) = block_opt
325
&& let Some((rkey, cid_str)) = cid_to_rkey.get(cid)
326
-
&& let Ok(value) = serde_ipld_dagcbor::from_slice::<serde_json::Value>(&block)
327
{
328
records.push(json!({
329
"uri": format!("at://{}/{}/{}", input.repo, input.collection, rkey),
330
"cid": cid_str,
···
6
http::{HeaderMap, StatusCode},
7
response::{IntoResponse, Response},
8
};
9
+
use base64::Engine;
10
use cid::Cid;
11
+
use ipld_core::ipld::Ipld;
12
use jacquard_repo::storage::BlockStore;
13
use serde::{Deserialize, Serialize};
14
+
use serde_json::{json, Map, Value};
15
use std::collections::HashMap;
16
use std::str::FromStr;
17
use tracing::{error, info};
18
19
+
fn ipld_to_json(ipld: Ipld) -> Value {
20
+
match ipld {
21
+
Ipld::Null => Value::Null,
22
+
Ipld::Bool(b) => Value::Bool(b),
23
+
Ipld::Integer(i) => {
24
+
if let Ok(n) = i64::try_from(i) {
25
+
Value::Number(n.into())
26
+
} else {
27
+
Value::String(i.to_string())
28
+
}
29
+
}
30
+
Ipld::Float(f) => serde_json::Number::from_f64(f)
31
+
.map(Value::Number)
32
+
.unwrap_or(Value::Null),
33
+
Ipld::String(s) => Value::String(s),
34
+
Ipld::Bytes(b) => {
35
+
let encoded = base64::engine::general_purpose::STANDARD.encode(&b);
36
+
json!({ "$bytes": encoded })
37
+
}
38
+
Ipld::List(arr) => Value::Array(arr.into_iter().map(ipld_to_json).collect()),
39
+
Ipld::Map(map) => {
40
+
let obj: Map<String, Value> = map
41
+
.into_iter()
42
+
.map(|(k, v)| (k, ipld_to_json(v)))
43
+
.collect();
44
+
Value::Object(obj)
45
+
}
46
+
Ipld::Link(cid) => json!({ "$link": cid.to_string() }),
47
+
}
48
+
}
49
+
50
#[derive(Deserialize)]
51
pub struct GetRecordInput {
52
pub repo: String,
···
196
.into_response();
197
}
198
};
199
+
let ipld: Ipld = match serde_ipld_dagcbor::from_slice(&block) {
200
Ok(v) => v,
201
Err(e) => {
202
error!("Failed to deserialize record: {:?}", e);
···
207
.into_response();
208
}
209
};
210
+
let value = ipld_to_json(ipld);
211
Json(json!({
212
"uri": format!("at://{}/{}/{}", input.repo, input.collection, input.rkey),
213
"cid": record_cid_str,
···
357
for (cid, block_opt) in cids.iter().zip(blocks.into_iter()) {
358
if let Some(block) = block_opt
359
&& let Some((rkey, cid_str)) = cid_to_rkey.get(cid)
360
+
&& let Ok(ipld) = serde_ipld_dagcbor::from_slice::<Ipld>(&block)
361
{
362
+
let value = ipld_to_json(ipld);
363
records.push(json!({
364
"uri": format!("at://{}/{}/{}", input.repo, input.collection, rkey),
365
"cid": cid_str,
+35
-2
src/api/repo/record/utils.rs
+35
-2
src/api/repo/record/utils.rs
···
5
use jacquard_repo::commit::Commit;
6
use jacquard_repo::storage::BlockStore;
7
use k256::ecdsa::SigningKey;
8
-
use serde_json::json;
9
use std::str::FromStr;
10
use uuid::Uuid;
11
12
pub fn create_signed_commit(
13
did: &str,
14
data: Cid,
···
63
pub new_mst_root: Cid,
64
pub ops: Vec<RecordOp>,
65
pub blocks_cids: &'a [String],
66
}
67
68
pub async fn commit_and_log(
···
77
new_mst_root,
78
ops,
79
blocks_cids,
80
} = params;
81
let key_row = sqlx::query!(
82
"SELECT key_bytes, encryption_version FROM user_keys WHERE user_id = $1",
···
274
new_root_cid.to_string(),
275
prev_cid_str,
276
json!(ops_json),
277
-
&[] as &[String],
278
blocks_cids,
279
prev_data_cid_str,
280
)
···
368
}
369
}
370
let written_cids_str: Vec<String> = written_cids.iter().map(|c| c.to_string()).collect();
371
let result = commit_and_log(
372
state,
373
CommitParams {
···
378
new_mst_root,
379
ops: vec![op],
380
blocks_cids: &written_cids_str,
381
},
382
)
383
.await?;
···
5
use jacquard_repo::commit::Commit;
6
use jacquard_repo::storage::BlockStore;
7
use k256::ecdsa::SigningKey;
8
+
use serde_json::{json, Value};
9
use std::str::FromStr;
10
use uuid::Uuid;
11
12
+
pub fn extract_blob_cids(record: &Value) -> Vec<String> {
13
+
let mut blobs = Vec::new();
14
+
extract_blob_cids_recursive(record, &mut blobs);
15
+
blobs
16
+
}
17
+
18
+
fn extract_blob_cids_recursive(value: &Value, blobs: &mut Vec<String>) {
19
+
match value {
20
+
Value::Object(map) => {
21
+
if map.get("$type").and_then(|v| v.as_str()) == Some("blob") {
22
+
if let Some(ref_obj) = map.get("ref") {
23
+
if let Some(link) = ref_obj.get("$link").and_then(|v| v.as_str()) {
24
+
blobs.push(link.to_string());
25
+
}
26
+
}
27
+
}
28
+
for v in map.values() {
29
+
extract_blob_cids_recursive(v, blobs);
30
+
}
31
+
}
32
+
Value::Array(arr) => {
33
+
for v in arr {
34
+
extract_blob_cids_recursive(v, blobs);
35
+
}
36
+
}
37
+
_ => {}
38
+
}
39
+
}
40
+
41
pub fn create_signed_commit(
42
did: &str,
43
data: Cid,
···
92
pub new_mst_root: Cid,
93
pub ops: Vec<RecordOp>,
94
pub blocks_cids: &'a [String],
95
+
pub blobs: &'a [String],
96
}
97
98
pub async fn commit_and_log(
···
107
new_mst_root,
108
ops,
109
blocks_cids,
110
+
blobs,
111
} = params;
112
let key_row = sqlx::query!(
113
"SELECT key_bytes, encryption_version FROM user_keys WHERE user_id = $1",
···
305
new_root_cid.to_string(),
306
prev_cid_str,
307
json!(ops_json),
308
+
blobs,
309
blocks_cids,
310
prev_data_cid_str,
311
)
···
399
}
400
}
401
let written_cids_str: Vec<String> = written_cids.iter().map(|c| c.to_string()).collect();
402
+
let blob_cids = extract_blob_cids(record);
403
let result = commit_and_log(
404
state,
405
CommitParams {
···
410
new_mst_root,
411
ops: vec![op],
412
blocks_cids: &written_cids_str,
413
+
blobs: &blob_cids,
414
},
415
)
416
.await?;
+5
-1
src/api/repo/record/write.rs
+5
-1
src/api/repo/record/write.rs
···
1
use super::validation::validate_record;
2
-
use crate::api::repo::record::utils::{CommitParams, RecordOp, commit_and_log};
3
use crate::delegation::{self, DelegationActionType};
4
use crate::repo::tracking::TrackingBlockStore;
5
use crate::state::AppState;
···
334
.iter()
335
.map(|c| c.to_string())
336
.collect::<Vec<_>>();
337
if let Err(e) = commit_and_log(
338
&state,
339
CommitParams {
···
344
new_mst_root,
345
ops: vec![op],
346
blocks_cids: &written_cids_str,
347
},
348
)
349
.await
···
582
.map(|c| c.to_string())
583
.collect::<Vec<_>>();
584
let is_update = existing_cid.is_some();
585
if let Err(e) = commit_and_log(
586
&state,
587
CommitParams {
···
592
new_mst_root,
593
ops: vec![op],
594
blocks_cids: &written_cids_str,
595
},
596
)
597
.await
···
1
use super::validation::validate_record;
2
+
use crate::api::repo::record::utils::{CommitParams, RecordOp, commit_and_log, extract_blob_cids};
3
use crate::delegation::{self, DelegationActionType};
4
use crate::repo::tracking::TrackingBlockStore;
5
use crate::state::AppState;
···
334
.iter()
335
.map(|c| c.to_string())
336
.collect::<Vec<_>>();
337
+
let blob_cids = extract_blob_cids(&input.record);
338
if let Err(e) = commit_and_log(
339
&state,
340
CommitParams {
···
345
new_mst_root,
346
ops: vec![op],
347
blocks_cids: &written_cids_str,
348
+
blobs: &blob_cids,
349
},
350
)
351
.await
···
584
.map(|c| c.to_string())
585
.collect::<Vec<_>>();
586
let is_update = existing_cid.is_some();
587
+
let blob_cids = extract_blob_cids(&input.record);
588
if let Err(e) = commit_and_log(
589
&state,
590
CommitParams {
···
595
new_mst_root,
596
ops: vec![op],
597
blocks_cids: &written_cids_str,
598
+
blobs: &blob_cids,
599
},
600
)
601
.await
+204
-7
src/api/server/account_status.rs
+204
-7
src/api/server/account_status.rs
···
1
use crate::api::ApiError;
2
use crate::state::AppState;
3
use axum::{
4
Json,
···
8
};
9
use bcrypt::verify;
10
use chrono::{Duration, Utc};
11
use serde::{Deserialize, Serialize};
12
use serde_json::json;
13
use tracing::{error, info, warn};
···
118
.into_response()
119
}
120
121
pub async fn activate_account(
122
State(state): State<AppState>,
123
headers: axum::http::HeaderMap,
···
158
}
159
160
let did = auth_user.did;
161
let handle = sqlx::query_scalar!("SELECT handle FROM users WHERE did = $1", did)
162
.fetch_optional(&state.db)
163
.await
···
182
{
183
warn!("Failed to sequence identity event for activation: {}", e);
184
}
185
-
if let Err(e) =
186
-
crate::api::repo::record::sequence_empty_commit_event(&state, &did).await
187
-
{
188
-
warn!(
189
-
"Failed to sequence empty commit event for activation: {}",
190
-
e
191
-
);
192
}
193
(StatusCode::OK, Json(json!({}))).into_response()
194
}
···
1
use crate::api::ApiError;
2
+
use crate::plc::PlcClient;
3
use crate::state::AppState;
4
use axum::{
5
Json,
···
9
};
10
use bcrypt::verify;
11
use chrono::{Duration, Utc};
12
+
use k256::ecdsa::SigningKey;
13
use serde::{Deserialize, Serialize};
14
use serde_json::json;
15
use tracing::{error, info, warn};
···
120
.into_response()
121
}
122
123
+
async fn assert_valid_did_document_for_service(
124
+
db: &sqlx::PgPool,
125
+
did: &str,
126
+
) -> Result<(), (StatusCode, Json<serde_json::Value>)> {
127
+
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
128
+
let expected_endpoint = format!("https://{}", hostname);
129
+
130
+
if did.starts_with("did:plc:") {
131
+
let plc_client = PlcClient::new(None);
132
+
133
+
let mut last_error = None;
134
+
let mut doc_data = None;
135
+
for attempt in 0..5 {
136
+
if attempt > 0 {
137
+
let delay_ms = 500 * (1 << (attempt - 1));
138
+
info!(
139
+
"Waiting {}ms before retry {} for DID document validation ({})",
140
+
delay_ms, attempt, did
141
+
);
142
+
tokio::time::sleep(tokio::time::Duration::from_millis(delay_ms)).await;
143
+
}
144
+
145
+
match plc_client.get_document_data(did).await {
146
+
Ok(data) => {
147
+
let pds_endpoint = data
148
+
.get("services")
149
+
.and_then(|s| s.get("atproto_pds").or_else(|| s.get("atprotoPds")))
150
+
.and_then(|p| p.get("endpoint"))
151
+
.and_then(|e| e.as_str());
152
+
153
+
if pds_endpoint == Some(&expected_endpoint) {
154
+
doc_data = Some(data);
155
+
break;
156
+
} else {
157
+
info!(
158
+
"Attempt {}: DID {} has endpoint {:?}, expected {} - retrying",
159
+
attempt + 1,
160
+
did,
161
+
pds_endpoint,
162
+
expected_endpoint
163
+
);
164
+
last_error = Some(format!(
165
+
"DID document endpoint {:?} does not match expected {}",
166
+
pds_endpoint, expected_endpoint
167
+
));
168
+
}
169
+
}
170
+
Err(e) => {
171
+
warn!(
172
+
"Attempt {}: Failed to fetch PLC document for {}: {:?}",
173
+
attempt + 1,
174
+
did,
175
+
e
176
+
);
177
+
last_error = Some(format!("Could not resolve DID document: {}", e));
178
+
}
179
+
}
180
+
}
181
+
182
+
let doc_data = match doc_data {
183
+
Some(d) => d,
184
+
None => {
185
+
return Err((
186
+
StatusCode::BAD_REQUEST,
187
+
Json(json!({
188
+
"error": "InvalidRequest",
189
+
"message": last_error.unwrap_or_else(|| "DID document validation failed".to_string())
190
+
})),
191
+
));
192
+
}
193
+
};
194
+
195
+
let doc_signing_key = doc_data
196
+
.get("verificationMethods")
197
+
.and_then(|v| v.get("atproto"))
198
+
.and_then(|k| k.as_str());
199
+
200
+
let user_row = sqlx::query!(
201
+
"SELECT uk.key_bytes, uk.encryption_version FROM user_keys uk JOIN users u ON uk.user_id = u.id WHERE u.did = $1",
202
+
did
203
+
)
204
+
.fetch_optional(db)
205
+
.await
206
+
.map_err(|e| {
207
+
error!("Failed to fetch user key: {:?}", e);
208
+
(
209
+
StatusCode::INTERNAL_SERVER_ERROR,
210
+
Json(json!({"error": "InternalError"})),
211
+
)
212
+
})?;
213
+
214
+
if let Some(row) = user_row {
215
+
let key_bytes =
216
+
crate::config::decrypt_key(&row.key_bytes, row.encryption_version).map_err(|e| {
217
+
error!("Failed to decrypt user key: {}", e);
218
+
(
219
+
StatusCode::INTERNAL_SERVER_ERROR,
220
+
Json(json!({"error": "InternalError"})),
221
+
)
222
+
})?;
223
+
let signing_key = SigningKey::from_slice(&key_bytes).map_err(|e| {
224
+
error!("Failed to create signing key: {:?}", e);
225
+
(
226
+
StatusCode::INTERNAL_SERVER_ERROR,
227
+
Json(json!({"error": "InternalError"})),
228
+
)
229
+
})?;
230
+
let expected_did_key = crate::plc::signing_key_to_did_key(&signing_key);
231
+
232
+
if doc_signing_key != Some(&expected_did_key) {
233
+
warn!(
234
+
"DID {} has signing key {:?}, expected {}",
235
+
did, doc_signing_key, expected_did_key
236
+
);
237
+
return Err((
238
+
StatusCode::BAD_REQUEST,
239
+
Json(json!({
240
+
"error": "InvalidRequest",
241
+
"message": "DID document verification method does not match expected signing key"
242
+
})),
243
+
));
244
+
}
245
+
}
246
+
} else if did.starts_with("did:web:") {
247
+
let client = reqwest::Client::new();
248
+
let did_path = &did[8..];
249
+
let url = format!("https://{}/.well-known/did.json", did_path.replace(':', "/"));
250
+
let resp = client.get(&url).send().await.map_err(|e| {
251
+
warn!("Failed to fetch did:web document for {}: {:?}", did, e);
252
+
(
253
+
StatusCode::BAD_REQUEST,
254
+
Json(json!({
255
+
"error": "InvalidRequest",
256
+
"message": format!("Could not resolve DID document: {}", e)
257
+
})),
258
+
)
259
+
})?;
260
+
let doc: serde_json::Value = resp.json().await.map_err(|e| {
261
+
warn!("Failed to parse did:web document for {}: {:?}", did, e);
262
+
(
263
+
StatusCode::BAD_REQUEST,
264
+
Json(json!({
265
+
"error": "InvalidRequest",
266
+
"message": format!("Could not parse DID document: {}", e)
267
+
})),
268
+
)
269
+
})?;
270
+
271
+
let pds_endpoint = doc
272
+
.get("service")
273
+
.and_then(|s| s.as_array())
274
+
.and_then(|arr| {
275
+
arr.iter().find(|svc| {
276
+
svc.get("id").and_then(|id| id.as_str()) == Some("#atproto_pds")
277
+
|| svc.get("type").and_then(|t| t.as_str())
278
+
== Some("AtprotoPersonalDataServer")
279
+
})
280
+
})
281
+
.and_then(|svc| svc.get("serviceEndpoint"))
282
+
.and_then(|e| e.as_str());
283
+
284
+
if pds_endpoint != Some(&expected_endpoint) {
285
+
warn!(
286
+
"DID {} has endpoint {:?}, expected {}",
287
+
did, pds_endpoint, expected_endpoint
288
+
);
289
+
return Err((
290
+
StatusCode::BAD_REQUEST,
291
+
Json(json!({
292
+
"error": "InvalidRequest",
293
+
"message": "DID document atproto_pds service endpoint does not match PDS public url"
294
+
})),
295
+
));
296
+
}
297
+
}
298
+
299
+
Ok(())
300
+
}
301
+
302
pub async fn activate_account(
303
State(state): State<AppState>,
304
headers: axum::http::HeaderMap,
···
339
}
340
341
let did = auth_user.did;
342
+
343
+
if let Err((status, json)) = assert_valid_did_document_for_service(&state.db, &did).await {
344
+
info!(
345
+
"activateAccount rejected for {}: DID document validation failed",
346
+
did
347
+
);
348
+
return (status, json).into_response();
349
+
}
350
+
351
let handle = sqlx::query_scalar!("SELECT handle FROM users WHERE did = $1", did)
352
.fetch_optional(&state.db)
353
.await
···
372
{
373
warn!("Failed to sequence identity event for activation: {}", e);
374
}
375
+
let repo_root = sqlx::query_scalar!(
376
+
"SELECT r.repo_root_cid FROM repos r JOIN users u ON r.user_id = u.id WHERE u.did = $1",
377
+
did
378
+
)
379
+
.fetch_optional(&state.db)
380
+
.await
381
+
.ok()
382
+
.flatten();
383
+
if let Some(root_cid) = repo_root {
384
+
if let Err(e) =
385
+
crate::api::repo::record::sequence_sync_event(&state, &did, &root_cid).await
386
+
{
387
+
warn!("Failed to sequence sync event for activation: {}", e);
388
+
}
389
}
390
(StatusCode::OK, Json(json!({}))).into_response()
391
}
+239
src/api/server/migration.rs
+239
src/api/server/migration.rs
···
···
1
+
use crate::api::ApiError;
2
+
use crate::state::AppState;
3
+
use axum::{
4
+
Json,
5
+
extract::State,
6
+
http::StatusCode,
7
+
response::{IntoResponse, Response},
8
+
};
9
+
use chrono::{DateTime, Utc};
10
+
use serde::{Deserialize, Serialize};
11
+
use serde_json::json;
12
+
13
+
#[derive(Serialize)]
14
+
#[serde(rename_all = "camelCase")]
15
+
pub struct GetMigrationStatusOutput {
16
+
pub did: String,
17
+
pub did_type: String,
18
+
pub migrated: bool,
19
+
#[serde(skip_serializing_if = "Option::is_none")]
20
+
pub migrated_to_pds: Option<String>,
21
+
#[serde(skip_serializing_if = "Option::is_none")]
22
+
pub migrated_at: Option<DateTime<Utc>>,
23
+
}
24
+
25
+
pub async fn get_migration_status(
26
+
State(state): State<AppState>,
27
+
headers: axum::http::HeaderMap,
28
+
) -> Response {
29
+
let extracted = match crate::auth::extract_auth_token_from_header(
30
+
headers.get("Authorization").and_then(|h| h.to_str().ok()),
31
+
) {
32
+
Some(t) => t,
33
+
None => return ApiError::AuthenticationRequired.into_response(),
34
+
};
35
+
let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok());
36
+
let http_uri = format!(
37
+
"https://{}/xrpc/com.tranquil.account.getMigrationStatus",
38
+
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string())
39
+
);
40
+
let auth_user = match crate::auth::validate_token_with_dpop(
41
+
&state.db,
42
+
&extracted.token,
43
+
extracted.is_dpop,
44
+
dpop_proof,
45
+
"GET",
46
+
&http_uri,
47
+
true,
48
+
)
49
+
.await
50
+
{
51
+
Ok(user) => user,
52
+
Err(e) => return ApiError::from(e).into_response(),
53
+
};
54
+
let user = match sqlx::query!(
55
+
"SELECT did, migrated_to_pds, migrated_at FROM users WHERE did = $1",
56
+
auth_user.did
57
+
)
58
+
.fetch_optional(&state.db)
59
+
.await
60
+
{
61
+
Ok(Some(row)) => row,
62
+
Ok(None) => return ApiError::AccountNotFound.into_response(),
63
+
Err(e) => {
64
+
tracing::error!("DB error getting migration status: {:?}", e);
65
+
return ApiError::InternalError.into_response();
66
+
}
67
+
};
68
+
let did_type = if user.did.starts_with("did:plc:") {
69
+
"plc"
70
+
} else if user.did.starts_with("did:web:") {
71
+
"web"
72
+
} else {
73
+
"unknown"
74
+
};
75
+
let migrated = user.migrated_to_pds.is_some();
76
+
(
77
+
StatusCode::OK,
78
+
Json(GetMigrationStatusOutput {
79
+
did: user.did,
80
+
did_type: did_type.to_string(),
81
+
migrated,
82
+
migrated_to_pds: user.migrated_to_pds,
83
+
migrated_at: user.migrated_at,
84
+
}),
85
+
)
86
+
.into_response()
87
+
}
88
+
89
+
#[derive(Deserialize)]
90
+
#[serde(rename_all = "camelCase")]
91
+
pub struct UpdateMigrationForwardingInput {
92
+
pub pds_url: String,
93
+
}
94
+
95
+
#[derive(Serialize)]
96
+
#[serde(rename_all = "camelCase")]
97
+
pub struct UpdateMigrationForwardingOutput {
98
+
pub success: bool,
99
+
pub migrated_to_pds: String,
100
+
pub migrated_at: DateTime<Utc>,
101
+
}
102
+
103
+
pub async fn update_migration_forwarding(
104
+
State(state): State<AppState>,
105
+
headers: axum::http::HeaderMap,
106
+
Json(input): Json<UpdateMigrationForwardingInput>,
107
+
) -> Response {
108
+
let extracted = match crate::auth::extract_auth_token_from_header(
109
+
headers.get("Authorization").and_then(|h| h.to_str().ok()),
110
+
) {
111
+
Some(t) => t,
112
+
None => return ApiError::AuthenticationRequired.into_response(),
113
+
};
114
+
let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok());
115
+
let http_uri = format!(
116
+
"https://{}/xrpc/com.tranquil.account.updateMigrationForwarding",
117
+
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string())
118
+
);
119
+
let auth_user = match crate::auth::validate_token_with_dpop(
120
+
&state.db,
121
+
&extracted.token,
122
+
extracted.is_dpop,
123
+
dpop_proof,
124
+
"POST",
125
+
&http_uri,
126
+
true,
127
+
)
128
+
.await
129
+
{
130
+
Ok(user) => user,
131
+
Err(e) => return ApiError::from(e).into_response(),
132
+
};
133
+
if !auth_user.did.starts_with("did:web:") {
134
+
return (
135
+
StatusCode::BAD_REQUEST,
136
+
Json(json!({
137
+
"error": "InvalidRequest",
138
+
"message": "Migration forwarding is only available for did:web accounts. did:plc accounts use PLC directory for identity updates."
139
+
})),
140
+
)
141
+
.into_response();
142
+
}
143
+
let pds_url = input.pds_url.trim();
144
+
if pds_url.is_empty() {
145
+
return ApiError::InvalidRequest("pds_url is required".into()).into_response();
146
+
}
147
+
if !pds_url.starts_with("https://") {
148
+
return ApiError::InvalidRequest("pds_url must start with https://".into()).into_response();
149
+
}
150
+
let pds_url_clean = pds_url.trim_end_matches('/');
151
+
let now = Utc::now();
152
+
let result = sqlx::query!(
153
+
"UPDATE users SET migrated_to_pds = $1, migrated_at = $2 WHERE did = $3",
154
+
pds_url_clean,
155
+
now,
156
+
auth_user.did
157
+
)
158
+
.execute(&state.db)
159
+
.await;
160
+
match result {
161
+
Ok(_) => {
162
+
tracing::info!(
163
+
"Updated migration forwarding for {} to {}",
164
+
auth_user.did,
165
+
pds_url_clean
166
+
);
167
+
(
168
+
StatusCode::OK,
169
+
Json(UpdateMigrationForwardingOutput {
170
+
success: true,
171
+
migrated_to_pds: pds_url_clean.to_string(),
172
+
migrated_at: now,
173
+
}),
174
+
)
175
+
.into_response()
176
+
}
177
+
Err(e) => {
178
+
tracing::error!("DB error updating migration forwarding: {:?}", e);
179
+
ApiError::InternalError.into_response()
180
+
}
181
+
}
182
+
}
183
+
184
+
pub async fn clear_migration_forwarding(
185
+
State(state): State<AppState>,
186
+
headers: axum::http::HeaderMap,
187
+
) -> Response {
188
+
let extracted = match crate::auth::extract_auth_token_from_header(
189
+
headers.get("Authorization").and_then(|h| h.to_str().ok()),
190
+
) {
191
+
Some(t) => t,
192
+
None => return ApiError::AuthenticationRequired.into_response(),
193
+
};
194
+
let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok());
195
+
let http_uri = format!(
196
+
"https://{}/xrpc/com.tranquil.account.clearMigrationForwarding",
197
+
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string())
198
+
);
199
+
let auth_user = match crate::auth::validate_token_with_dpop(
200
+
&state.db,
201
+
&extracted.token,
202
+
extracted.is_dpop,
203
+
dpop_proof,
204
+
"POST",
205
+
&http_uri,
206
+
true,
207
+
)
208
+
.await
209
+
{
210
+
Ok(user) => user,
211
+
Err(e) => return ApiError::from(e).into_response(),
212
+
};
213
+
if !auth_user.did.starts_with("did:web:") {
214
+
return (
215
+
StatusCode::BAD_REQUEST,
216
+
Json(json!({
217
+
"error": "InvalidRequest",
218
+
"message": "Migration forwarding is only available for did:web accounts"
219
+
})),
220
+
)
221
+
.into_response();
222
+
}
223
+
let result = sqlx::query!(
224
+
"UPDATE users SET migrated_to_pds = NULL, migrated_at = NULL WHERE did = $1",
225
+
auth_user.did
226
+
)
227
+
.execute(&state.db)
228
+
.await;
229
+
match result {
230
+
Ok(_) => {
231
+
tracing::info!("Cleared migration forwarding for {}", auth_user.did);
232
+
(StatusCode::OK, Json(json!({ "success": true }))).into_response()
233
+
}
234
+
Err(e) => {
235
+
tracing::error!("DB error clearing migration forwarding: {:?}", e);
236
+
ApiError::InternalError.into_response()
237
+
}
238
+
}
239
+
}
+4
src/api/server/mod.rs
+4
src/api/server/mod.rs
···
4
pub mod invite;
5
pub mod logo;
6
pub mod meta;
7
pub mod passkey_account;
8
pub mod passkeys;
9
pub mod password;
···
55
pub use trusted_devices::{
56
extend_device_trust, is_device_trusted, list_trusted_devices, revoke_trusted_device,
57
trust_device, update_trusted_device,
58
};
59
pub use verify_email::{resend_migration_verification, verify_migration_email};
60
pub use verify_token::{VerifyTokenInput, VerifyTokenOutput, verify_token, verify_token_internal};
···
4
pub mod invite;
5
pub mod logo;
6
pub mod meta;
7
+
pub mod migration;
8
pub mod passkey_account;
9
pub mod passkeys;
10
pub mod password;
···
56
pub use trusted_devices::{
57
extend_device_trust, is_device_trusted, list_trusted_devices, revoke_trusted_device,
58
trust_device, update_trusted_device,
59
+
};
60
+
pub use migration::{
61
+
clear_migration_forwarding, get_migration_status, update_migration_forwarding,
62
};
63
pub use verify_email::{resend_migration_verification, verify_migration_email};
64
pub use verify_token::{VerifyTokenInput, VerifyTokenOutput, verify_token, verify_token_internal};
+12
src/lib.rs
+12
src/lib.rs
···
281
post(api::server::recover_passkey_account),
282
)
283
.route(
284
+
"/xrpc/com.tranquil.account.getMigrationStatus",
285
+
get(api::server::get_migration_status),
286
+
)
287
+
.route(
288
+
"/xrpc/com.tranquil.account.updateMigrationForwarding",
289
+
post(api::server::update_migration_forwarding),
290
+
)
291
+
.route(
292
+
"/xrpc/com.tranquil.account.clearMigrationForwarding",
293
+
post(api::server::clear_migration_forwarding),
294
+
)
295
+
.route(
296
"/xrpc/com.atproto.server.requestEmailUpdate",
297
post(api::server::request_email_update),
298
)
+77
-54
src/sync/import.rs
+77
-54
src/sync/import.rs
···
163
root_cid: &Cid,
164
) -> Result<Vec<ImportedRecord>, ImportError> {
165
let mut records = Vec::new();
166
-
let mut stack = vec![*root_cid];
167
-
let mut visited = std::collections::HashSet::new();
168
-
while let Some(cid) = stack.pop() {
169
-
if visited.contains(&cid) {
170
-
continue;
171
}
172
-
visited.insert(cid);
173
-
let block = blocks
174
-
.get(&cid)
175
-
.ok_or_else(|| ImportError::BlockNotFound(cid.to_string()))?;
176
-
let value: Ipld = serde_ipld_dagcbor::from_slice(block)
177
-
.map_err(|e| ImportError::InvalidCbor(e.to_string()))?;
178
-
if let Ipld::Map(ref obj) = value {
179
-
if let Some(Ipld::List(entries)) = obj.get("e") {
180
-
for entry in entries {
181
-
if let Ipld::Map(entry_obj) = entry {
182
-
let key = entry_obj.get("k").and_then(|k| {
183
-
if let Ipld::Bytes(b) = k {
184
-
String::from_utf8(b.clone()).ok()
185
-
} else if let Ipld::String(s) = k {
186
-
Some(s.clone())
187
-
} else {
188
-
None
189
}
190
-
});
191
-
let record_cid = entry_obj.get("v").and_then(|v| {
192
-
if let Ipld::Link(cid) = v {
193
-
Some(*cid)
194
-
} else {
195
-
None
196
-
}
197
-
});
198
-
if let (Some(key), Some(record_cid)) = (key, record_cid)
199
-
&& let Some(record_block) = blocks.get(&record_cid)
200
-
&& let Ok(record_value) =
201
-
serde_ipld_dagcbor::from_slice::<Ipld>(record_block)
202
-
{
203
-
let blob_refs = find_blob_refs_ipld(&record_value, 0);
204
-
let parts: Vec<&str> = key.split('/').collect();
205
-
if parts.len() >= 2 {
206
-
let collection = parts[..parts.len() - 1].join("/");
207
-
let rkey = parts[parts.len() - 1].to_string();
208
-
records.push(ImportedRecord {
209
-
collection,
210
-
rkey,
211
-
cid: record_cid,
212
-
blob_refs,
213
-
});
214
-
}
215
-
}
216
-
if let Some(Ipld::Link(tree_cid)) = entry_obj.get("t") {
217
-
stack.push(*tree_cid);
218
}
219
}
220
}
221
}
222
-
if let Some(Ipld::Link(left_cid)) = obj.get("l") {
223
-
stack.push(*left_cid);
224
-
}
225
}
226
}
227
-
Ok(records)
228
}
229
230
pub struct CommitInfo {
···
163
root_cid: &Cid,
164
) -> Result<Vec<ImportedRecord>, ImportError> {
165
let mut records = Vec::new();
166
+
walk_mst_node(blocks, root_cid, &[], &mut records)?;
167
+
Ok(records)
168
+
}
169
+
170
+
fn walk_mst_node(
171
+
blocks: &HashMap<Cid, Bytes>,
172
+
cid: &Cid,
173
+
prev_key: &[u8],
174
+
records: &mut Vec<ImportedRecord>,
175
+
) -> Result<(), ImportError> {
176
+
let block = blocks
177
+
.get(cid)
178
+
.ok_or_else(|| ImportError::BlockNotFound(cid.to_string()))?;
179
+
let value: Ipld = serde_ipld_dagcbor::from_slice(block)
180
+
.map_err(|e| ImportError::InvalidCbor(e.to_string()))?;
181
+
182
+
if let Ipld::Map(ref obj) = value {
183
+
if let Some(Ipld::Link(left_cid)) = obj.get("l") {
184
+
walk_mst_node(blocks, left_cid, prev_key, records)?;
185
}
186
+
187
+
let mut current_key = prev_key.to_vec();
188
+
189
+
if let Some(Ipld::List(entries)) = obj.get("e") {
190
+
for entry in entries {
191
+
if let Ipld::Map(entry_obj) = entry {
192
+
let prefix_len = entry_obj.get("p").and_then(|p| {
193
+
if let Ipld::Integer(n) = p {
194
+
Some(*n as usize)
195
+
} else {
196
+
None
197
+
}
198
+
}).unwrap_or(0);
199
+
200
+
let key_suffix = entry_obj.get("k").and_then(|k| {
201
+
if let Ipld::Bytes(b) = k {
202
+
Some(b.clone())
203
+
} else {
204
+
None
205
+
}
206
+
});
207
+
208
+
if let Some(suffix) = key_suffix {
209
+
current_key.truncate(prefix_len);
210
+
current_key.extend_from_slice(&suffix);
211
+
}
212
+
213
+
if let Some(Ipld::Link(tree_cid)) = entry_obj.get("t") {
214
+
walk_mst_node(blocks, tree_cid, ¤t_key, records)?;
215
+
}
216
+
217
+
let record_cid = entry_obj.get("v").and_then(|v| {
218
+
if let Ipld::Link(cid) = v {
219
+
Some(*cid)
220
+
} else {
221
+
None
222
+
}
223
+
});
224
+
225
+
if let Some(record_cid) = record_cid {
226
+
if let Ok(full_key) = String::from_utf8(current_key.clone()) {
227
+
if let Some(record_block) = blocks.get(&record_cid)
228
+
&& let Ok(record_value) =
229
+
serde_ipld_dagcbor::from_slice::<Ipld>(record_block)
230
+
{
231
+
let blob_refs = find_blob_refs_ipld(&record_value, 0);
232
+
let parts: Vec<&str> = full_key.split('/').collect();
233
+
if parts.len() >= 2 {
234
+
let collection = parts[..parts.len() - 1].join("/");
235
+
let rkey = parts[parts.len() - 1].to_string();
236
+
records.push(ImportedRecord {
237
+
collection,
238
+
rkey,
239
+
cid: record_cid,
240
+
blob_refs,
241
+
});
242
+
}
243
}
244
}
245
}
246
}
247
}
248
}
249
}
250
+
Ok(())
251
}
252
253
pub struct CommitInfo {
+9
src/sync/util.rs
+9
src/sync/util.rs
···
86
let mut bytes = Vec::new();
87
serde_ipld_dagcbor::to_writer(&mut bytes, &header)?;
88
serde_ipld_dagcbor::to_writer(&mut bytes, &frame)?;
89
+
let hex_str: String = bytes.iter().map(|b| format!("{:02x}", b)).collect();
90
+
tracing::info!(
91
+
did = %frame.did,
92
+
active = frame.active,
93
+
status = ?frame.status,
94
+
cbor_len = bytes.len(),
95
+
cbor_hex = %hex_str,
96
+
"Sending account event to firehose"
97
+
);
98
Ok(bytes)
99
}
100