+1
-6
TODO.md
+1
-6
TODO.md
···
74
74
75
75
### Misc
76
76
77
-
migration handle preservation
78
-
- [ ] allow users to keep their existing handle during migration (eg. lewis.moe instead of forcing lewis.newpds.com)
79
-
- [ ] UI option to preserve external handle vs create new pds-subdomain handle
80
-
- [ ] handle the DNS verification flow for external handles during migration
81
-
82
77
cross-pds delegation
83
78
when a client (eg. tangled.org) tries to log into a delegated account:
84
79
- [ ] client starts oauth flow to delegated account's pds
···
162
157
163
158
Account Delegation: Delegated accounts controlled by other accounts instead of passwords. OAuth delegation flow (authenticate as controller), scope-based permissions (owner/admin/editor/viewer presets), scope intersection (tokens limited to granted permissions), `act` claim for delegation tracking, creating delegated account flow, controller management UI, "act as" account switcher, comprehensive audit logging with actor/controller tracking, delegation-aware OAuth consent with permission limitation notices.
164
159
165
-
Migration: OAuth-based inbound migration wizard with PLC token flow, offline restore from CAR file + rotation key for disaster recovery, scheduled automatic backups, standalone repo/blob export, did:web DID document editor for self-service identity management.
160
+
Migration: OAuth-based inbound migration wizard with PLC token flow, offline restore from CAR file + rotation key for disaster recovery, scheduled automatic backups, standalone repo/blob export, did:web DID document editor for self-service identity management, handle preservation (keep existing external handle via DNS/HTTP verification or create new PDS-subdomain handle).
+4
-4
crates/tranquil-db/src/postgres/repo.rs
+4
-4
crates/tranquil-db/src/postgres/repo.rs
···
1159
1159
None => return Err(ImportRepoError::RepoNotFound),
1160
1160
};
1161
1161
1162
-
if let Some(expected) = expected_root_cid {
1163
-
if repo.repo_root_cid.as_str() != expected.as_str() {
1164
-
return Err(ImportRepoError::ConcurrentModification);
1165
-
}
1162
+
if let Some(expected) = expected_root_cid
1163
+
&& repo.repo_root_cid.as_str() != expected.as_str()
1164
+
{
1165
+
return Err(ImportRepoError::ConcurrentModification);
1166
1166
}
1167
1167
1168
1168
let block_chunks: Vec<Vec<&ImportBlock>> = blocks
+12
-9
crates/tranquil-pds/src/api/discord_webhook.rs
+12
-9
crates/tranquil-pds/src/api/discord_webhook.rs
···
108
108
1 => Json(json!({"type": 1})).into_response(),
109
109
2 => handle_command(state, interaction).await,
110
110
other => {
111
-
debug!(interaction_type = other, "Received unknown Discord interaction type");
111
+
debug!(
112
+
interaction_type = other,
113
+
"Received unknown Discord interaction type"
114
+
);
112
115
StatusCode::OK.into_response()
113
116
}
114
117
}
···
148
151
149
152
let handle = parse_start_handle(interaction.data.as_ref().and_then(|d| d.options.as_deref()));
150
153
151
-
if let Some(ref h) = handle {
152
-
if Handle::new(h).is_err() {
153
-
return Json(json!({
154
-
"type": 4,
155
-
"data": {"content": "Invalid handle format. Handle should look like: alice.example.com", "flags": 64}
156
-
}))
157
-
.into_response();
158
-
}
154
+
if let Some(ref h) = handle
155
+
&& Handle::new(h).is_err()
156
+
{
157
+
return Json(json!({
158
+
"type": 4,
159
+
"data": {"content": "Invalid handle format. Handle should look like: alice.example.com", "flags": 64}
160
+
}))
161
+
.into_response();
159
162
}
160
163
161
164
debug!(
+76
crates/tranquil-pds/src/api/identity/handle.rs
+76
crates/tranquil-pds/src/api/identity/handle.rs
···
1
+
use crate::api::error::ApiError;
2
+
use crate::rate_limit::{HandleVerificationLimit, RateLimited};
3
+
use crate::types::{Did, Handle};
4
+
use axum::{
5
+
Json,
6
+
response::{IntoResponse, Response},
7
+
};
8
+
use serde::{Deserialize, Serialize};
9
+
10
+
#[derive(Deserialize)]
11
+
pub struct VerifyHandleOwnershipInput {
12
+
pub handle: String,
13
+
pub did: Did,
14
+
}
15
+
16
+
#[derive(Serialize)]
17
+
#[serde(rename_all = "camelCase")]
18
+
pub struct VerifyHandleOwnershipOutput {
19
+
pub verified: bool,
20
+
#[serde(skip_serializing_if = "Option::is_none")]
21
+
pub method: Option<String>,
22
+
#[serde(skip_serializing_if = "Option::is_none")]
23
+
pub error: Option<String>,
24
+
}
25
+
26
+
pub async fn verify_handle_ownership(
27
+
_rate_limit: RateLimited<HandleVerificationLimit>,
28
+
Json(input): Json<VerifyHandleOwnershipInput>,
29
+
) -> Response {
30
+
let handle: Handle = match input.handle.parse() {
31
+
Ok(h) => h,
32
+
Err(_) => {
33
+
return ApiError::InvalidHandle(Some("Invalid handle format".into())).into_response();
34
+
}
35
+
};
36
+
37
+
let handle_str = handle.as_str();
38
+
let did_str = input.did.as_str();
39
+
40
+
let dns_mismatch = match crate::handle::resolve_handle_dns(handle_str).await {
41
+
Ok(did) if did == did_str => {
42
+
return Json(VerifyHandleOwnershipOutput {
43
+
verified: true,
44
+
method: Some("dns".to_string()),
45
+
error: None,
46
+
})
47
+
.into_response();
48
+
}
49
+
Ok(did) => Some(format!(
50
+
"DNS record points to {}, expected {}",
51
+
did, did_str
52
+
)),
53
+
Err(_) => None,
54
+
};
55
+
56
+
match crate::handle::resolve_handle_http(handle_str).await {
57
+
Ok(did) if did == did_str => Json(VerifyHandleOwnershipOutput {
58
+
verified: true,
59
+
method: Some("http".to_string()),
60
+
error: None,
61
+
})
62
+
.into_response(),
63
+
Ok(did) => Json(VerifyHandleOwnershipOutput {
64
+
verified: false,
65
+
method: None,
66
+
error: Some(format!("Handle resolves to {}, expected {}", did, did_str)),
67
+
})
68
+
.into_response(),
69
+
Err(e) => Json(VerifyHandleOwnershipOutput {
70
+
verified: false,
71
+
method: None,
72
+
error: Some(dns_mismatch.unwrap_or_else(|| format!("Handle resolution failed: {}", e))),
73
+
})
74
+
.into_response(),
75
+
}
76
+
}
+2
crates/tranquil-pds/src/api/identity/mod.rs
+2
crates/tranquil-pds/src/api/identity/mod.rs
···
1
1
pub mod account;
2
2
pub mod did;
3
+
pub mod handle;
3
4
pub mod plc;
4
5
5
6
pub use account::create_account;
···
7
8
get_recommended_did_credentials, resolve_handle, update_handle, user_did_doc,
8
9
well_known_atproto_did, well_known_did,
9
10
};
11
+
pub use handle::verify_handle_ownership;
10
12
pub use plc::{request_plc_operation_signature, sign_plc_operation, submit_plc_operation};
+6
-2
crates/tranquil-pds/src/api/validation.rs
+6
-2
crates/tranquil-pds/src/api/validation.rs
···
550
550
assert!(is_valid_discord_username("user.name"));
551
551
assert!(is_valid_discord_username("user123"));
552
552
assert!(is_valid_discord_username("a_b.c_d"));
553
-
assert!(is_valid_discord_username("12345678901234567890123456789012"));
553
+
assert!(is_valid_discord_username(
554
+
"12345678901234567890123456789012"
555
+
));
554
556
}
555
557
556
558
#[test]
···
564
566
assert!(!is_valid_discord_username("username."));
565
567
assert!(!is_valid_discord_username("user..name"));
566
568
assert!(!is_valid_discord_username("user name"));
567
-
assert!(!is_valid_discord_username("123456789012345678901234567890123"));
569
+
assert!(!is_valid_discord_username(
570
+
"123456789012345678901234567890123"
571
+
));
568
572
}
569
573
}
+1
-1
crates/tranquil-pds/src/auth/verification_token.rs
+1
-1
crates/tranquil-pds/src/auth/verification_token.rs
+4
crates/tranquil-pds/src/lib.rs
+4
crates/tranquil-pds/src/lib.rs
···
337
337
"/com.atproto.identity.submitPlcOperation",
338
338
post(api::identity::submit_plc_operation),
339
339
)
340
+
.route(
341
+
"/_identity.verifyHandleOwnership",
342
+
post(api::identity::verify_handle_ownership),
343
+
)
340
344
.route("/com.atproto.repo.importRepo", post(api::repo::import_repo))
341
345
.route(
342
346
"/com.atproto.admin.deleteAccount",
+5
crates/tranquil-pds/src/rate_limit/extractor.rs
+5
crates/tranquil-pds/src/rate_limit/extractor.rs
···
110
110
const KIND: RateLimitKind = RateLimitKind::OAuthRegisterComplete;
111
111
}
112
112
113
+
pub struct HandleVerificationLimit;
114
+
impl RateLimitPolicy for HandleVerificationLimit {
115
+
const KIND: RateLimitKind = RateLimitKind::HandleVerification;
116
+
}
117
+
113
118
pub trait RateLimitRejection: IntoResponse + Send + 'static {
114
119
fn new() -> Self;
115
120
}
+4
crates/tranquil-pds/src/rate_limit/mod.rs
+4
crates/tranquil-pds/src/rate_limit/mod.rs
···
33
33
pub sso_callback: Arc<KeyedRateLimiter>,
34
34
pub sso_unlink: Arc<KeyedRateLimiter>,
35
35
pub oauth_register_complete: Arc<KeyedRateLimiter>,
36
+
pub handle_verification: Arc<KeyedRateLimiter>,
36
37
}
37
38
38
39
impl Default for RateLimiters {
···
109
110
.unwrap()
110
111
.allow_burst(NonZeroU32::new(5).unwrap()),
111
112
)),
113
+
handle_verification: Arc::new(RateLimiter::keyed(Quota::per_minute(
114
+
NonZeroU32::new(10).unwrap(),
115
+
))),
112
116
}
113
117
}
114
118
+4
crates/tranquil-pds/src/state.rs
+4
crates/tranquil-pds/src/state.rs
···
71
71
SsoCallback,
72
72
SsoUnlink,
73
73
OAuthRegisterComplete,
74
+
HandleVerification,
74
75
}
75
76
76
77
impl RateLimitKind {
···
95
96
Self::SsoCallback => "sso_callback",
96
97
Self::SsoUnlink => "sso_unlink",
97
98
Self::OAuthRegisterComplete => "oauth_register_complete",
99
+
Self::HandleVerification => "handle_verification",
98
100
}
99
101
}
100
102
···
119
121
Self::SsoCallback => (30, 60_000),
120
122
Self::SsoUnlink => (10, 60_000),
121
123
Self::OAuthRegisterComplete => (5, 300_000),
124
+
Self::HandleVerification => (10, 60_000),
122
125
}
123
126
}
124
127
}
···
271
274
RateLimitKind::SsoCallback => &self.rate_limiters.sso_callback,
272
275
RateLimitKind::SsoUnlink => &self.rate_limiters.sso_unlink,
273
276
RateLimitKind::OAuthRegisterComplete => &self.rate_limiters.oauth_register_complete,
277
+
RateLimitKind::HandleVerification => &self.rate_limiters.handle_verification,
274
278
};
275
279
276
280
let ok = limiter.check_key(&client_ip.to_string()).is_ok();
+96
crates/tranquil-pds/tests/identity.rs
+96
crates/tranquil-pds/tests/identity.rs
···
531
531
assert_eq!(body["error"], "InvalidHandle");
532
532
assert!(body["message"].as_str().unwrap().contains("long"));
533
533
}
534
+
535
+
#[tokio::test]
536
+
async fn test_verify_handle_ownership_invalid_did() {
537
+
let client = client();
538
+
let res = client
539
+
.post(format!(
540
+
"{}/xrpc/_identity.verifyHandleOwnership",
541
+
base_url().await
542
+
))
543
+
.json(&json!({
544
+
"handle": "some.handle.test",
545
+
"did": "not-a-did"
546
+
}))
547
+
.send()
548
+
.await
549
+
.expect("Failed to send request");
550
+
assert_eq!(res.status(), StatusCode::UNPROCESSABLE_ENTITY);
551
+
}
552
+
553
+
#[tokio::test]
554
+
async fn test_verify_handle_ownership_invalid_handle() {
555
+
let client = client();
556
+
let res = client
557
+
.post(format!(
558
+
"{}/xrpc/_identity.verifyHandleOwnership",
559
+
base_url().await
560
+
))
561
+
.json(&json!({
562
+
"handle": "@#$!",
563
+
"did": "did:plc:abc123"
564
+
}))
565
+
.send()
566
+
.await
567
+
.expect("Failed to send request");
568
+
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
569
+
let body: Value = res.json().await.expect("Response was not valid JSON");
570
+
assert_eq!(body["error"], "InvalidHandle");
571
+
}
572
+
573
+
#[tokio::test]
574
+
async fn test_verify_handle_ownership_missing_fields() {
575
+
let client = client();
576
+
let res = client
577
+
.post(format!(
578
+
"{}/xrpc/_identity.verifyHandleOwnership",
579
+
base_url().await
580
+
))
581
+
.json(&json!({}))
582
+
.send()
583
+
.await
584
+
.expect("Failed to send request");
585
+
assert_eq!(res.status(), StatusCode::UNPROCESSABLE_ENTITY);
586
+
}
587
+
588
+
#[tokio::test]
589
+
async fn test_verify_handle_ownership_unresolvable() {
590
+
let client = client();
591
+
let res = client
592
+
.post(format!(
593
+
"{}/xrpc/_identity.verifyHandleOwnership",
594
+
base_url().await
595
+
))
596
+
.json(&json!({
597
+
"handle": "nonexistent.example.com",
598
+
"did": "did:plc:abc123def456"
599
+
}))
600
+
.send()
601
+
.await
602
+
.expect("Failed to send request");
603
+
assert_eq!(res.status(), StatusCode::OK);
604
+
let body: Value = res.json().await.expect("Response was not valid JSON");
605
+
assert_eq!(body["verified"], false);
606
+
assert!(body["error"].as_str().is_some());
607
+
}
608
+
609
+
#[tokio::test]
610
+
async fn test_verify_handle_ownership_wrong_did() {
611
+
let client = client();
612
+
let res = client
613
+
.post(format!(
614
+
"{}/xrpc/_identity.verifyHandleOwnership",
615
+
base_url().await
616
+
))
617
+
.json(&json!({
618
+
"handle": "nonexistent.example.com",
619
+
"did": "did:plc:aaaaaaaaaaaaaaaaaaaaaa"
620
+
}))
621
+
.send()
622
+
.await
623
+
.expect("Failed to send request");
624
+
assert_eq!(res.status(), StatusCode::OK);
625
+
let body: Value = res.json().await.expect("Response was not valid JSON");
626
+
assert_eq!(body["verified"], false);
627
+
assert!(body["error"].as_str().is_some());
628
+
assert!(body["method"].is_null());
629
+
}
+17
-10
crates/tranquil-pds/tests/security_fixes.rs
+17
-10
crates/tranquil-pds/tests/security_fixes.rs
···
1
1
mod common;
2
-
use tranquil_pds::comms::{SendError, is_valid_phone_number, is_valid_signal_username, sanitize_header_value};
2
+
use tranquil_pds::comms::{
3
+
SendError, is_valid_phone_number, is_valid_signal_username, sanitize_header_value,
4
+
};
3
5
use tranquil_pds::image::{ImageError, ImageProcessor};
4
6
5
7
#[test]
···
101
103
102
104
assert!(!is_valid_signal_username("a".repeat(33).as_str()));
103
105
104
-
["alice.01; rm -rf /", "bob.01 && cat /etc/passwd", "user.01`id`", "test.01$(whoami)"]
105
-
.iter()
106
-
.for_each(|malicious| {
107
-
assert!(
108
-
!is_valid_signal_username(malicious),
109
-
"Command injection '{}' should be rejected",
110
-
malicious
111
-
);
112
-
});
106
+
[
107
+
"alice.01; rm -rf /",
108
+
"bob.01 && cat /etc/passwd",
109
+
"user.01`id`",
110
+
"test.01$(whoami)",
111
+
]
112
+
.iter()
113
+
.for_each(|malicious| {
114
+
assert!(
115
+
!is_valid_signal_username(malicious),
116
+
"Command injection '{}' should be rejected",
117
+
malicious
118
+
);
119
+
});
113
120
}
114
121
115
122
#[test]
+3
-22
frontend/src/components/dashboard/CommsContent.svelte
+3
-22
frontend/src/components/dashboard/CommsContent.svelte
···
333
333
placeholder={$_('register.signalUsernamePlaceholder')}
334
334
disabled={saving}
335
335
/>
336
-
{#if signalUsername && signalUsername === savedSignalUsername && !signalVerified}
337
-
<button type="button" class="verify-btn" onclick={() => verifyingChannel = 'signal'}>{$_('comms.verifyButton')}</button>
338
-
{/if}
339
336
</div>
340
337
{#if signalInUse}
341
338
<p class="hint warning">{$_('comms.signalInUseWarning')}</p>
342
339
{/if}
343
-
{#if verifyingChannel === 'signal'}
340
+
{#if signalUsername && signalUsername === savedSignalUsername && !signalVerified}
344
341
<div class="verify-form">
345
-
<input type="text" bind:value={verificationCode} placeholder={$_('comms.verifyCodePlaceholder')} maxlength="128" />
342
+
<input type="text" bind:value={verificationCode} placeholder={$_('comms.verifyCodePlaceholder')} maxlength="512" />
346
343
<button type="button" onclick={() => handleVerify('signal')}>{$_('comms.submit')}</button>
347
-
<button type="button" class="cancel" onclick={() => { verifyingChannel = null; verificationCode = '' }}>{$_('common.cancel')}</button>
348
344
</div>
349
345
{/if}
350
346
</div>
···
553
549
color: var(--text-secondary);
554
550
}
555
551
556
-
.verify-btn {
557
-
padding: var(--space-2) var(--space-3);
558
-
font-size: var(--text-sm);
559
-
}
560
552
561
553
.verify-form {
562
554
display: flex;
555
+
flex-direction: column;
563
556
gap: var(--space-2);
564
-
align-items: center;
565
-
}
566
-
567
-
.verify-form input {
568
-
flex: 1;
569
-
min-width: 0;
570
557
}
571
558
572
559
.verify-form button {
···
574
561
font-size: var(--text-sm);
575
562
}
576
563
577
-
.verify-form button.cancel {
578
-
background: transparent;
579
-
border: 1px solid var(--border-color);
580
-
color: var(--text-secondary);
581
-
}
582
-
583
564
.actions {
584
565
margin-bottom: var(--space-5);
585
566
}
+253
-32
frontend/src/components/migration/ChooseHandleStep.svelte
+253
-32
frontend/src/components/migration/ChooseHandleStep.svelte
···
1
1
<script lang="ts">
2
-
import type { AuthMethod, ServerDescription } from '../../lib/migration/types'
2
+
import type { AuthMethod, HandlePreservation, ServerDescription } from '../../lib/migration/types'
3
3
import { _ } from '../../lib/i18n'
4
4
5
5
interface Props {
···
15
15
migratingFromLabel: string
16
16
migratingFromValue: string
17
17
loading?: boolean
18
+
sourceHandle: string
19
+
sourceDid: string
20
+
handlePreservation: HandlePreservation
21
+
existingHandleVerified: boolean
22
+
verifyingExistingHandle?: boolean
23
+
existingHandleError?: string | null
18
24
onHandleChange: (handle: string) => void
19
25
onDomainChange: (domain: string) => void
20
26
onCheckHandle: () => void
···
22
28
onPasswordChange: (password: string) => void
23
29
onAuthMethodChange: (method: AuthMethod) => void
24
30
onInviteCodeChange: (code: string) => void
31
+
onHandlePreservationChange?: (preservation: HandlePreservation) => void
32
+
onVerifyExistingHandle?: () => void
25
33
onBack: () => void
26
34
onContinue: () => void
27
35
}
···
39
47
migratingFromLabel,
40
48
migratingFromValue,
41
49
loading = false,
50
+
sourceHandle,
51
+
sourceDid,
52
+
handlePreservation,
53
+
existingHandleVerified,
54
+
verifyingExistingHandle = false,
55
+
existingHandleError = null,
42
56
onHandleChange,
43
57
onDomainChange,
44
58
onCheckHandle,
···
46
60
onPasswordChange,
47
61
onAuthMethodChange,
48
62
onInviteCodeChange,
63
+
onHandlePreservationChange,
64
+
onVerifyExistingHandle,
49
65
onBack,
50
66
onContinue,
51
67
}: Props = $props()
52
68
53
69
const handleTooShort = $derived(handleInput.trim().length > 0 && handleInput.trim().length < 3)
54
70
71
+
const isExternalHandle = $derived(
72
+
serverInfo != null &&
73
+
sourceHandle.includes('.') &&
74
+
!serverInfo.availableUserDomains.some(d => sourceHandle.endsWith(`.${d}`))
75
+
)
76
+
55
77
const canContinue = $derived(
56
-
handleInput.trim().length >= 3 &&
57
78
email &&
58
79
(authMethod === 'passkey' || password) &&
59
-
handleAvailable !== false
80
+
(
81
+
(handlePreservation === 'existing' && existingHandleVerified) ||
82
+
(handlePreservation === 'new' && handleInput.trim().length >= 3 && handleAvailable !== false)
83
+
)
60
84
)
61
85
</script>
62
86
···
69
93
<span class="value">{migratingFromValue}</span>
70
94
</div>
71
95
72
-
<div class="field">
73
-
<label for="new-handle">{$_('migration.inbound.chooseHandle.newHandle')}</label>
74
-
<div class="handle-input-group">
75
-
<input
76
-
id="new-handle"
77
-
type="text"
78
-
placeholder="username"
79
-
value={handleInput}
80
-
oninput={(e) => onHandleChange((e.target as HTMLInputElement).value)}
81
-
onblur={onCheckHandle}
82
-
/>
83
-
{#if serverInfo && serverInfo.availableUserDomains.length > 0 && !handleInput.includes('.')}
84
-
<select value={selectedDomain} onchange={(e) => onDomainChange((e.target as HTMLSelectElement).value)}>
85
-
{#each serverInfo.availableUserDomains as domain}
86
-
<option value={domain}>.{domain}</option>
87
-
{/each}
88
-
</select>
96
+
{#if isExternalHandle}
97
+
<div class="field">
98
+
<span class="field-label">{$_('migration.inbound.chooseHandle.handleChoice')}</span>
99
+
<div class="handle-choice-options">
100
+
<label class="handle-choice-option" class:selected={handlePreservation === 'existing'}>
101
+
<input
102
+
type="radio"
103
+
name="handle-preservation"
104
+
value="existing"
105
+
checked={handlePreservation === 'existing'}
106
+
onchange={() => onHandlePreservationChange?.('existing')}
107
+
/>
108
+
<div class="handle-choice-content">
109
+
<strong>{$_('migration.inbound.chooseHandle.keepExisting')}</strong>
110
+
<span class="handle-preview">@{sourceHandle}</span>
111
+
</div>
112
+
</label>
113
+
<label class="handle-choice-option" class:selected={handlePreservation === 'new'}>
114
+
<input
115
+
type="radio"
116
+
name="handle-preservation"
117
+
value="new"
118
+
checked={handlePreservation === 'new'}
119
+
onchange={() => onHandlePreservationChange?.('new')}
120
+
/>
121
+
<div class="handle-choice-content">
122
+
<strong>{$_('migration.inbound.chooseHandle.createNew')}</strong>
123
+
</div>
124
+
</label>
125
+
</div>
126
+
</div>
127
+
{/if}
128
+
129
+
{#if handlePreservation === 'existing' && isExternalHandle}
130
+
<div class="field">
131
+
<span class="field-label">{$_('migration.inbound.chooseHandle.existingHandle')}</span>
132
+
<div class="existing-handle-display">
133
+
<span class="handle-value">@{sourceHandle}</span>
134
+
{#if existingHandleVerified}
135
+
<span class="verified-badge">{$_('migration.inbound.chooseHandle.verified')}</span>
136
+
{/if}
137
+
</div>
138
+
139
+
{#if !existingHandleVerified}
140
+
<div class="verification-instructions">
141
+
<p class="instruction-header">{$_('migration.inbound.chooseHandle.verifyInstructions')}</p>
142
+
<div class="verification-record">
143
+
<code>_atproto.{sourceHandle} TXT "did={sourceDid}"</code>
144
+
</div>
145
+
<p class="instruction-or">{$_('migration.inbound.chooseHandle.or')}</p>
146
+
<div class="verification-record">
147
+
<code>https://{sourceHandle}/.well-known/atproto-did</code>
148
+
<span class="record-content">{$_('migration.inbound.chooseHandle.returning')} <code>{sourceDid}</code></span>
149
+
</div>
150
+
</div>
151
+
152
+
<button
153
+
class="verify-btn"
154
+
onclick={() => onVerifyExistingHandle?.()}
155
+
disabled={verifyingExistingHandle}
156
+
>
157
+
{#if verifyingExistingHandle}
158
+
{$_('migration.inbound.chooseHandle.verifying')}
159
+
{:else if existingHandleError}
160
+
{$_('migration.inbound.chooseHandle.checkAgain')}
161
+
{:else}
162
+
{$_('migration.inbound.chooseHandle.verifyOwnership')}
163
+
{/if}
164
+
</button>
165
+
166
+
{#if existingHandleError}
167
+
<p class="hint error">{existingHandleError}</p>
168
+
{/if}
89
169
{/if}
90
170
</div>
171
+
{:else}
172
+
<div class="field">
173
+
<label for="new-handle">{$_('migration.inbound.chooseHandle.newHandle')}</label>
174
+
<div class="handle-input-group">
175
+
<input
176
+
id="new-handle"
177
+
type="text"
178
+
placeholder="username"
179
+
value={handleInput}
180
+
oninput={(e) => onHandleChange((e.target as HTMLInputElement).value)}
181
+
onblur={onCheckHandle}
182
+
/>
183
+
{#if serverInfo && serverInfo.availableUserDomains.length > 0 && !handleInput.includes('.')}
184
+
<select value={selectedDomain} onchange={(e) => onDomainChange((e.target as HTMLSelectElement).value)}>
185
+
{#each serverInfo.availableUserDomains as domain}
186
+
<option value={domain}>.{domain}</option>
187
+
{/each}
188
+
</select>
189
+
{/if}
190
+
</div>
91
191
92
-
{#if handleTooShort}
93
-
<p class="hint error">{$_('migration.inbound.chooseHandle.handleTooShort')}</p>
94
-
{:else if checkingHandle}
95
-
<p class="hint">{$_('migration.inbound.chooseHandle.checkingAvailability')}</p>
96
-
{:else if handleAvailable === true}
97
-
<p class="hint" style="color: var(--success-text)">{$_('migration.inbound.chooseHandle.handleAvailable')}</p>
98
-
{:else if handleAvailable === false}
99
-
<p class="hint error">{$_('migration.inbound.chooseHandle.handleTaken')}</p>
100
-
{:else}
101
-
<p class="hint">{$_('migration.inbound.chooseHandle.handleHint')}</p>
102
-
{/if}
103
-
</div>
192
+
{#if handleTooShort}
193
+
<p class="hint error">{$_('migration.inbound.chooseHandle.handleTooShort')}</p>
194
+
{:else if checkingHandle}
195
+
<p class="hint">{$_('migration.inbound.chooseHandle.checkingAvailability')}</p>
196
+
{:else if handleAvailable === true}
197
+
<p class="hint" style="color: var(--success-text)">{$_('migration.inbound.chooseHandle.handleAvailable')}</p>
198
+
{:else if handleAvailable === false}
199
+
<p class="hint error">{$_('migration.inbound.chooseHandle.handleTaken')}</p>
200
+
{:else}
201
+
<p class="hint">{$_('migration.inbound.chooseHandle.handleHint')}</p>
202
+
{/if}
203
+
</div>
204
+
{/if}
104
205
105
206
<div class="field">
106
207
<label for="email">{$_('migration.inbound.chooseHandle.email')}</label>
···
187
288
</button>
188
289
</div>
189
290
</div>
291
+
292
+
<style>
293
+
.handle-choice-options {
294
+
display: flex;
295
+
flex-direction: column;
296
+
gap: var(--space-3);
297
+
}
298
+
299
+
.handle-choice-option {
300
+
display: flex;
301
+
align-items: center;
302
+
gap: var(--space-3);
303
+
padding: var(--space-4);
304
+
border: 1px solid var(--border-color);
305
+
border-radius: var(--radius-lg);
306
+
cursor: pointer;
307
+
transition: border-color var(--transition-normal), background var(--transition-normal);
308
+
}
309
+
310
+
.handle-choice-option:hover {
311
+
border-color: var(--accent);
312
+
}
313
+
314
+
.handle-choice-option.selected {
315
+
border-color: var(--accent);
316
+
background: var(--accent-muted);
317
+
}
318
+
319
+
.handle-choice-option input[type="radio"] {
320
+
flex-shrink: 0;
321
+
width: 18px;
322
+
height: 18px;
323
+
margin: 0;
324
+
}
325
+
326
+
.handle-choice-content {
327
+
display: flex;
328
+
flex-direction: column;
329
+
gap: var(--space-1);
330
+
}
331
+
332
+
.handle-preview {
333
+
font-family: var(--font-mono);
334
+
font-size: var(--text-sm);
335
+
color: var(--text-secondary);
336
+
}
337
+
338
+
.existing-handle-display {
339
+
display: flex;
340
+
align-items: center;
341
+
gap: var(--space-4);
342
+
padding: var(--space-4);
343
+
background: var(--bg-secondary);
344
+
border-radius: var(--radius-lg);
345
+
margin-bottom: var(--space-4);
346
+
}
347
+
348
+
.handle-value {
349
+
font-family: var(--font-mono);
350
+
font-size: var(--text-base);
351
+
}
352
+
353
+
.verified-badge {
354
+
font-size: var(--text-xs);
355
+
padding: var(--space-1) var(--space-3);
356
+
background: var(--success-bg);
357
+
color: var(--success-text);
358
+
border-radius: var(--radius-md);
359
+
}
360
+
361
+
.verification-instructions {
362
+
background: var(--bg-secondary);
363
+
padding: var(--space-5);
364
+
border-radius: var(--radius-lg);
365
+
margin-bottom: var(--space-4);
366
+
}
367
+
368
+
.instruction-header {
369
+
margin: 0 0 var(--space-4) 0;
370
+
font-size: var(--text-sm);
371
+
color: var(--text-secondary);
372
+
}
373
+
374
+
.instruction-or {
375
+
margin: var(--space-3) 0;
376
+
font-size: var(--text-xs);
377
+
color: var(--text-muted);
378
+
text-align: center;
379
+
}
380
+
381
+
.verification-record {
382
+
display: flex;
383
+
flex-direction: column;
384
+
gap: var(--space-2);
385
+
}
386
+
387
+
.verification-record code {
388
+
font-size: var(--text-sm);
389
+
padding: var(--space-3);
390
+
background: var(--bg-tertiary);
391
+
border-radius: var(--radius-md);
392
+
overflow-x: auto;
393
+
word-break: break-all;
394
+
}
395
+
396
+
.record-content {
397
+
font-size: var(--text-xs);
398
+
color: var(--text-secondary);
399
+
padding-left: var(--space-3);
400
+
}
401
+
402
+
.record-content code {
403
+
padding: var(--space-1) var(--space-2);
404
+
font-size: var(--text-xs);
405
+
}
406
+
407
+
.verify-btn {
408
+
width: 100%;
409
+
}
410
+
</style>
+47
-5
frontend/src/components/migration/InboundWizard.svelte
+47
-5
frontend/src/components/migration/InboundWizard.svelte
···
1
1
<script lang="ts">
2
2
import type { InboundMigrationFlow } from '../../lib/migration'
3
-
import type { AuthMethod, ServerDescription } from '../../lib/migration/types'
3
+
import type { AuthMethod, HandlePreservation, ServerDescription } from '../../lib/migration/types'
4
4
import { getErrorMessage } from '../../lib/migration/types'
5
5
import { base64UrlEncode, prepareWebAuthnCreationOptions } from '../../lib/migration/atproto-client'
6
6
import { _ } from '../../lib/i18n'
···
43
43
let checkingHandle = $state(false)
44
44
let selectedAuthMethod = $state<AuthMethod>('password')
45
45
let passkeyName = $state('')
46
+
let verifyingExistingHandle = $state(false)
47
+
let existingHandleError = $state<string | null>(null)
46
48
47
49
const isResuming = $derived(flow.state.needsReauth === true)
48
50
const isDidWeb = $derived(flow.state.sourceDid.startsWith("did:web:"))
···
54
56
if (flow.state.step === 'choose-handle') {
55
57
handleInput = ''
56
58
handleAvailable = null
59
+
existingHandleError = null
60
+
flow.updateField('handlePreservation', 'new')
61
+
flow.updateField('existingHandleVerified', false)
57
62
}
58
63
if (flow.state.step === 'source-handle' && resumeInfo) {
59
64
handleInput = resumeInfo.sourceHandle
···
112
117
}
113
118
}
114
119
120
+
function handlePreservationChange(preservation: HandlePreservation) {
121
+
flow.updateField('handlePreservation', preservation)
122
+
existingHandleError = null
123
+
if (preservation === 'existing') {
124
+
flow.updateField('existingHandleVerified', false)
125
+
}
126
+
}
127
+
128
+
async function verifyExistingHandle() {
129
+
verifyingExistingHandle = true
130
+
existingHandleError = null
131
+
132
+
try {
133
+
const result = await flow.verifyExistingHandle()
134
+
if (!result.verified && result.error) {
135
+
existingHandleError = result.error
136
+
}
137
+
} catch (err) {
138
+
existingHandleError = getErrorMessage(err)
139
+
} finally {
140
+
verifyingExistingHandle = false
141
+
}
142
+
}
143
+
115
144
function proceedToReview() {
116
145
const fullHandle = handleInput.includes('.')
117
146
? handleInput
···
265
294
}
266
295
267
296
function proceedToReviewWithAuth() {
268
-
const fullHandle = handleInput.includes('.')
269
-
? handleInput
270
-
: `${handleInput}.${selectedDomain}`
297
+
let targetHandle: string
298
+
if (flow.state.handlePreservation === 'existing' && flow.state.existingHandleVerified) {
299
+
targetHandle = flow.state.sourceHandle
300
+
} else {
301
+
targetHandle = handleInput.includes('.')
302
+
? handleInput
303
+
: `${handleInput}.${selectedDomain}`
304
+
}
271
305
272
-
flow.updateField('targetHandle', fullHandle)
306
+
flow.updateField('targetHandle', targetHandle)
273
307
flow.updateField('authMethod', selectedAuthMethod)
274
308
flow.setStep('review')
275
309
}
···
420
454
migratingFromLabel={$_('migration.inbound.chooseHandle.migratingFrom')}
421
455
migratingFromValue={flow.state.sourceHandle}
422
456
{loading}
457
+
sourceHandle={flow.state.sourceHandle}
458
+
sourceDid={flow.state.sourceDid}
459
+
handlePreservation={flow.state.handlePreservation}
460
+
existingHandleVerified={flow.state.existingHandleVerified}
461
+
{verifyingExistingHandle}
462
+
{existingHandleError}
423
463
onHandleChange={(h) => handleInput = h}
424
464
onDomainChange={(d) => selectedDomain = d}
425
465
onCheckHandle={checkHandle}
···
427
467
onPasswordChange={(p) => flow.updateField('targetPassword', p)}
428
468
onAuthMethodChange={(m) => selectedAuthMethod = m}
429
469
onInviteCodeChange={(c) => flow.updateField('inviteCode', c)}
470
+
onHandlePreservationChange={handlePreservationChange}
471
+
onVerifyExistingHandle={verifyExistingHandle}
430
472
onBack={() => flow.setStep('source-handle')}
431
473
onContinue={proceedToReviewWithAuth}
432
474
/>
+6
frontend/src/components/migration/OfflineInboundWizard.svelte
+6
frontend/src/components/migration/OfflineInboundWizard.svelte
···
412
412
migratingFromLabel={$_('migration.offline.chooseHandle.migratingDid')}
413
413
migratingFromValue={flow.state.userDid}
414
414
{loading}
415
+
sourceHandle=""
416
+
sourceDid={flow.state.userDid}
417
+
handlePreservation="new"
418
+
existingHandleVerified={false}
419
+
verifyingExistingHandle={false}
420
+
existingHandleError={null}
415
421
onHandleChange={(h) => handleInput = h}
416
422
onDomainChange={(d) => selectedDomain = d}
417
423
onCheckHandle={checkHandle}
+21
-7
frontend/src/lib/api.ts
+21
-7
frontend/src/lib/api.ts
···
327
327
return {
328
328
did: unsafeAsDid(c.did as string),
329
329
handle: unsafeAsHandle(c.handle as string),
330
-
grantedScopes: unsafeAsScopeSet((c.granted_scopes ?? c.grantedScopes) as string),
331
-
grantedAt: unsafeAsISODate((c.granted_at ?? c.grantedAt ?? c.added_at) as string),
330
+
grantedScopes: unsafeAsScopeSet(
331
+
(c.granted_scopes ?? c.grantedScopes) as string,
332
+
),
333
+
grantedAt: unsafeAsISODate(
334
+
(c.granted_at ?? c.grantedAt ?? c.added_at) as string,
335
+
),
332
336
isActive: (c.is_active ?? c.isActive ?? true) as boolean,
333
337
};
334
338
}
···
340
344
return {
341
345
did: unsafeAsDid(a.did as string),
342
346
handle: unsafeAsHandle(a.handle as string),
343
-
grantedScopes: unsafeAsScopeSet((a.granted_scopes ?? a.grantedScopes) as string),
344
-
grantedAt: unsafeAsISODate((a.granted_at ?? a.grantedAt ?? a.added_at) as string),
347
+
grantedScopes: unsafeAsScopeSet(
348
+
(a.granted_scopes ?? a.grantedScopes) as string,
349
+
),
350
+
grantedAt: unsafeAsISODate(
351
+
(a.granted_at ?? a.grantedAt ?? a.added_at) as string,
352
+
),
345
353
};
346
354
}
347
355
348
356
function _castDelegationAuditEntry(raw: unknown): DelegationAuditEntry {
349
357
const e = raw as Record<string, unknown>;
350
358
const actorDid = (e.actor_did ?? e.actorDid) as string;
351
-
const targetDid = (e.target_did ?? e.targetDid ?? e.delegatedDid) as string | undefined;
359
+
const targetDid = (e.target_did ?? e.targetDid ?? e.delegatedDid) as
360
+
| string
361
+
| undefined;
352
362
const createdAt = (e.created_at ?? e.createdAt) as string;
353
363
const action = (e.action ?? e.actionType) as string;
354
364
const details = e.details ?? e.actionDetails;
···
1434
1444
);
1435
1445
if (!result.ok) return result;
1436
1446
return ok({
1437
-
controllers: (result.value.controllers ?? []).map(_castDelegationController),
1447
+
controllers: (result.value.controllers ?? []).map(
1448
+
_castDelegationController,
1449
+
),
1438
1450
});
1439
1451
},
1440
1452
···
1447
1459
);
1448
1460
if (!result.ok) return result;
1449
1461
return ok({
1450
-
accounts: (result.value.accounts ?? []).map(_castDelegationControlledAccount),
1462
+
accounts: (result.value.accounts ?? []).map(
1463
+
_castDelegationControlledAccount,
1464
+
),
1451
1465
});
1452
1466
},
1453
1467
+10
frontend/src/lib/migration/atproto-client.ts
+10
frontend/src/lib/migration/atproto-client.ts
···
615
615
});
616
616
}
617
617
618
+
async verifyHandleOwnership(
619
+
handle: string,
620
+
did: string,
621
+
): Promise<{ verified: boolean; method?: string; error?: string }> {
622
+
return this.xrpc("_identity.verifyHandleOwnership", {
623
+
httpMethod: "POST",
624
+
body: { handle, did },
625
+
});
626
+
}
627
+
618
628
async resendMigrationVerification(): Promise<void> {
619
629
await this.xrpc("com.atproto.server.resendMigrationVerification", {
620
630
httpMethod: "POST",
+30
-4
frontend/src/lib/migration/flow.svelte.ts
+30
-4
frontend/src/lib/migration/flow.svelte.ts
···
80
80
localAccessToken: null,
81
81
generatedAppPassword: null,
82
82
generatedAppPasswordName: null,
83
+
handlePreservation: "new",
84
+
existingHandleVerified: false,
83
85
});
84
86
85
87
let sourceClient: AtprotoClient | null = null;
···
118
120
}
119
121
120
122
async function resolveSourcePds(handle: string): Promise<void> {
123
+
const normalized = handle.startsWith("@") ? handle.slice(1) : handle;
121
124
try {
122
-
const { did, pdsUrl } = await resolvePdsUrl(handle);
125
+
const { did, pdsUrl } = await resolvePdsUrl(normalized);
123
126
state.sourcePdsUrl = pdsUrl;
124
127
state.sourceDid = did;
125
-
state.sourceHandle = handle;
128
+
state.sourceHandle = normalized;
126
129
sourceClient = new AtprotoClient(pdsUrl);
127
130
} catch (e) {
128
131
throw new Error(`Could not resolve handle: ${(e as Error).message}`);
···
132
135
async function initiateOAuthLogin(handle: string): Promise<void> {
133
136
migrationLog("initiateOAuthLogin START", { handle });
134
137
135
-
if (!state.sourcePdsUrl) {
136
-
await resolveSourcePds(handle);
138
+
const normalizedHandle = handle.startsWith("@") ? handle.slice(1) : handle;
139
+
if (!state.sourcePdsUrl || state.sourceHandle !== normalizedHandle) {
140
+
await resolveSourcePds(normalizedHandle);
137
141
}
138
142
139
143
const metadata = await getOAuthServerMetadata(state.sourcePdsUrl);
···
322
326
}
323
327
}
324
328
329
+
async function verifyExistingHandle(): Promise<{
330
+
verified: boolean;
331
+
method?: string;
332
+
error?: string;
333
+
}> {
334
+
if (!localClient) {
335
+
localClient = createLocalClient();
336
+
}
337
+
const result = await localClient.verifyHandleOwnership(
338
+
state.sourceHandle,
339
+
state.sourceDid,
340
+
);
341
+
if (result.verified) {
342
+
state.existingHandleVerified = true;
343
+
state.targetHandle = state.sourceHandle;
344
+
}
345
+
return result;
346
+
}
347
+
325
348
async function authenticateToLocal(
326
349
email: string,
327
350
password: string,
···
921
944
localAccessToken: null,
922
945
generatedAppPassword: null,
923
946
generatedAppPasswordName: null,
947
+
handlePreservation: "new",
948
+
existingHandleVerified: false,
924
949
};
925
950
sourceClient = null;
926
951
passkeySetup = null;
···
1005
1030
handleOAuthCallback,
1006
1031
authenticateToLocal,
1007
1032
checkHandleAvailability,
1033
+
verifyExistingHandle,
1008
1034
startMigration,
1009
1035
submitEmailVerifyToken,
1010
1036
resendEmailVerification,
+4
frontend/src/lib/migration/offline-flow.svelte.ts
+4
frontend/src/lib/migration/offline-flow.svelte.ts
···
169
169
progress: createInitialProgress(),
170
170
error: null,
171
171
plcUpdatedTemporarily: false,
172
+
handlePreservation: "new",
173
+
existingHandleVerified: false,
172
174
});
173
175
174
176
let localServerInfo: ServerDescription | null = null;
···
671
673
progress: createInitialProgress(),
672
674
error: null,
673
675
plcUpdatedTemporarily: false,
676
+
handlePreservation: "new",
677
+
existingHandleVerified: false,
674
678
};
675
679
localServerInfo = null;
676
680
}
+6
frontend/src/lib/migration/types.ts
+6
frontend/src/lib/migration/types.ts
···
48
48
currentOperation: string;
49
49
}
50
50
51
+
export type HandlePreservation = "new" | "existing";
52
+
51
53
export interface InboundMigrationState {
52
54
direction: "inbound";
53
55
step: InboundStep;
···
74
76
generatedAppPasswordName: string | null;
75
77
needsReauth?: boolean;
76
78
resumeToStep?: InboundStep;
79
+
handlePreservation: HandlePreservation;
80
+
existingHandleVerified: boolean;
77
81
}
78
82
79
83
export interface OfflineInboundMigrationState {
···
101
105
progress: MigrationProgress;
102
106
error: string | null;
103
107
plcUpdatedTemporarily: boolean;
108
+
handlePreservation: HandlePreservation;
109
+
existingHandleVerified: boolean;
104
110
}
105
111
106
112
export type MigrationState = InboundMigrationState;
+2
frontend/src/lib/types/api.ts
+2
frontend/src/lib/types/api.ts
+11
frontend/src/locales/en.json
+11
frontend/src/locales/en.json
···
986
986
"title": "Choose Your New Handle",
987
987
"desc": "Select a handle for your account on this PDS.",
988
988
"migratingFrom": "Migrating from",
989
+
"handleChoice": "Handle",
990
+
"keepExisting": "Keep existing handle",
991
+
"createNew": "Create new handle",
992
+
"existingHandle": "Your handle",
993
+
"verifyInstructions": "Verify ownership via DNS or HTTP",
994
+
"or": "or",
995
+
"returning": "returning",
996
+
"verifyOwnership": "Verify ownership",
997
+
"verifying": "Verifying",
998
+
"verified": "Verified",
999
+
"checkAgain": "Check again",
989
1000
"newHandle": "New Handle",
990
1001
"checkingAvailability": "Checking availability...",
991
1002
"handleAvailable": "Handle is available!",
+11
frontend/src/locales/fi.json
+11
frontend/src/locales/fi.json
···
986
986
"title": "Valitse uusi käyttäjätunnuksesi",
987
987
"desc": "Valitse käyttäjätunnus tilillesi tässä PDS:ssä.",
988
988
"migratingFrom": "Siirretään tililtä",
989
+
"handleChoice": "Käyttäjätunnus",
990
+
"keepExisting": "Säilytä nykyinen tunnus",
991
+
"createNew": "Luo uusi tunnus",
992
+
"existingHandle": "Nykyinen tunnus",
993
+
"verifyInstructions": "Vahvista omistajuus DNS- tai HTTP-tietueella",
994
+
"or": "tai",
995
+
"returning": "palauttaa",
996
+
"verifyOwnership": "Vahvista omistajuus",
997
+
"verifying": "Vahvistetaan",
998
+
"verified": "Vahvistettu",
999
+
"checkAgain": "Tarkista uudelleen",
989
1000
"newHandle": "Uusi käyttäjätunnus",
990
1001
"checkingAvailability": "Tarkistetaan saatavuutta...",
991
1002
"handleAvailable": "Käyttäjätunnus on saatavilla!",
+11
frontend/src/locales/ja.json
+11
frontend/src/locales/ja.json
···
986
986
"title": "新しいハンドルを選択",
987
987
"desc": "このPDSでのアカウントのハンドルを選択してください。",
988
988
"migratingFrom": "移行元",
989
+
"handleChoice": "ハンドル",
990
+
"keepExisting": "既存のハンドルを維持",
991
+
"createNew": "新しいハンドルを作成",
992
+
"existingHandle": "現在のハンドル",
993
+
"verifyInstructions": "DNSまたはHTTPで所有権を確認",
994
+
"or": "または",
995
+
"returning": "返却値",
996
+
"verifyOwnership": "所有権を確認",
997
+
"verifying": "確認中",
998
+
"verified": "確認済み",
999
+
"checkAgain": "再確認",
989
1000
"newHandle": "新しいハンドル",
990
1001
"checkingAvailability": "利用可能か確認中...",
991
1002
"handleAvailable": "ハンドルは利用可能です!",
+11
frontend/src/locales/ko.json
+11
frontend/src/locales/ko.json
···
986
986
"title": "새 핸들 선택",
987
987
"desc": "이 PDS에서 사용할 계정 핸들을 선택하세요.",
988
988
"migratingFrom": "마이그레이션 원본",
989
+
"handleChoice": "핸들",
990
+
"keepExisting": "기존 핸들 유지",
991
+
"createNew": "새 핸들 생성",
992
+
"existingHandle": "현재 핸들",
993
+
"verifyInstructions": "DNS 또는 HTTP로 소유권 확인",
994
+
"or": "또는",
995
+
"returning": "반환값",
996
+
"verifyOwnership": "소유권 확인",
997
+
"verifying": "확인 중",
998
+
"verified": "확인됨",
999
+
"checkAgain": "다시 확인",
989
1000
"newHandle": "새 핸들",
990
1001
"checkingAvailability": "사용 가능 여부 확인 중...",
991
1002
"handleAvailable": "핸들을 사용할 수 있습니다!",
+11
frontend/src/locales/sv.json
+11
frontend/src/locales/sv.json
···
986
986
"title": "Välj ditt nya användarnamn",
987
987
"desc": "Välj ett användarnamn för ditt konto på denna PDS.",
988
988
"migratingFrom": "Flyttar från",
989
+
"handleChoice": "Användarnamn",
990
+
"keepExisting": "Behåll befintligt användarnamn",
991
+
"createNew": "Skapa nytt användarnamn",
992
+
"existingHandle": "Ditt användarnamn",
993
+
"verifyInstructions": "Verifiera ägarskap via DNS eller HTTP",
994
+
"or": "eller",
995
+
"returning": "returnerar",
996
+
"verifyOwnership": "Verifiera ägarskap",
997
+
"verifying": "Verifierar",
998
+
"verified": "Verifierad",
999
+
"checkAgain": "Kontrollera igen",
989
1000
"newHandle": "Nytt användarnamn",
990
1001
"checkingAvailability": "Kontrollerar tillgänglighet...",
991
1002
"handleAvailable": "Användarnamnet är tillgängligt!",
+11
frontend/src/locales/zh.json
+11
frontend/src/locales/zh.json
···
986
986
"title": "选择新用户名",
987
987
"desc": "为您在此PDS上的账户选择用户名。",
988
988
"migratingFrom": "迁移自",
989
+
"handleChoice": "用户名",
990
+
"keepExisting": "保留现有用户名",
991
+
"createNew": "创建新用户名",
992
+
"existingHandle": "当前用户名",
993
+
"verifyInstructions": "通过DNS或HTTP验证所有权",
994
+
"or": "或",
995
+
"returning": "返回值",
996
+
"verifyOwnership": "验证所有权",
997
+
"verifying": "验证中",
998
+
"verified": "已验证",
999
+
"checkAgain": "重新验证",
989
1000
"newHandle": "新用户名",
990
1001
"checkingAvailability": "检查可用性...",
991
1002
"handleAvailable": "用户名可用!",
+1
-1
frontend/src/styles/migration.css
+1
-1
frontend/src/styles/migration.css
+87
frontend/src/tests/migration/atproto-client.test.ts
+87
frontend/src/tests/migration/atproto-client.test.ts
···
1
1
import { beforeEach, describe, expect, it } from "vitest";
2
2
import {
3
+
AtprotoClient,
3
4
base64UrlDecode,
4
5
base64UrlEncode,
5
6
buildOAuthAuthorizationUrl,
···
517
518
expect(prepared.user?.displayName).toBe("Test User");
518
519
});
519
520
});
521
+
522
+
describe("AtprotoClient.verifyHandleOwnership", () => {
523
+
function createMockJsonResponse(data: unknown, status = 200) {
524
+
return new Response(JSON.stringify(data), {
525
+
status,
526
+
headers: { "Content-Type": "application/json" },
527
+
});
528
+
}
529
+
530
+
it("sends POST with handle and did", async () => {
531
+
globalThis.fetch = vi.fn().mockResolvedValue(
532
+
createMockJsonResponse({ verified: true, method: "dns" }),
533
+
);
534
+
535
+
const client = new AtprotoClient("https://pds.example.com");
536
+
await client.verifyHandleOwnership("alice.custom.com", "did:plc:abc123");
537
+
538
+
expect(fetch).toHaveBeenCalledWith(
539
+
"https://pds.example.com/xrpc/_identity.verifyHandleOwnership",
540
+
expect.objectContaining({
541
+
method: "POST",
542
+
}),
543
+
);
544
+
});
545
+
546
+
it("returns verified result with method", async () => {
547
+
globalThis.fetch = vi.fn().mockResolvedValue(
548
+
createMockJsonResponse({ verified: true, method: "dns" }),
549
+
);
550
+
551
+
const client = new AtprotoClient("https://pds.example.com");
552
+
const result = await client.verifyHandleOwnership(
553
+
"alice.custom.com",
554
+
"did:plc:abc123",
555
+
);
556
+
557
+
expect(result.verified).toBe(true);
558
+
expect(result.method).toBe("dns");
559
+
});
560
+
561
+
it("returns unverified result with error", async () => {
562
+
globalThis.fetch = vi.fn().mockResolvedValue(
563
+
createMockJsonResponse({
564
+
verified: false,
565
+
error: "Handle resolution failed",
566
+
}),
567
+
);
568
+
569
+
const client = new AtprotoClient("https://pds.example.com");
570
+
const result = await client.verifyHandleOwnership(
571
+
"nonexistent.example.com",
572
+
"did:plc:abc123",
573
+
);
574
+
575
+
expect(result.verified).toBe(false);
576
+
expect(result.error).toBe("Handle resolution failed");
577
+
});
578
+
579
+
it("throws on server error responses", async () => {
580
+
globalThis.fetch = vi.fn().mockResolvedValue(
581
+
createMockJsonResponse(
582
+
{ error: "InvalidHandle", message: "Invalid handle format" },
583
+
400,
584
+
),
585
+
);
586
+
587
+
const client = new AtprotoClient("https://pds.example.com");
588
+
await expect(
589
+
client.verifyHandleOwnership("@#$!", "did:plc:abc123"),
590
+
).rejects.toThrow("Invalid handle format");
591
+
});
592
+
593
+
it("strips trailing slash from base URL", async () => {
594
+
globalThis.fetch = vi.fn().mockResolvedValue(
595
+
createMockJsonResponse({ verified: true, method: "http" }),
596
+
);
597
+
598
+
const client = new AtprotoClient("https://pds.example.com/");
599
+
await client.verifyHandleOwnership("alice.example.com", "did:plc:abc");
600
+
601
+
expect(fetch).toHaveBeenCalledWith(
602
+
"https://pds.example.com/xrpc/_identity.verifyHandleOwnership",
603
+
expect.anything(),
604
+
);
605
+
});
606
+
});
520
607
});
+206
frontend/src/tests/migration/flow-handle-preservation.test.ts
+206
frontend/src/tests/migration/flow-handle-preservation.test.ts
···
1
+
import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
import { createInboundMigrationFlow } from "../../lib/migration/flow.svelte.ts";
3
+
4
+
const STORAGE_KEY = "tranquil_migration_state";
5
+
6
+
function createMockJsonResponse(data: unknown, status = 200) {
7
+
return new Response(JSON.stringify(data), {
8
+
status,
9
+
headers: { "Content-Type": "application/json" },
10
+
});
11
+
}
12
+
13
+
describe("migration/flow handle preservation", () => {
14
+
beforeEach(() => {
15
+
localStorage.removeItem(STORAGE_KEY);
16
+
vi.restoreAllMocks();
17
+
});
18
+
19
+
describe("initial state", () => {
20
+
it("defaults handlePreservation to 'new'", () => {
21
+
const flow = createInboundMigrationFlow();
22
+
expect(flow.state.handlePreservation).toBe("new");
23
+
});
24
+
25
+
it("defaults existingHandleVerified to false", () => {
26
+
const flow = createInboundMigrationFlow();
27
+
expect(flow.state.existingHandleVerified).toBe(false);
28
+
});
29
+
});
30
+
31
+
describe("updateField for handle preservation", () => {
32
+
it("sets handlePreservation to 'existing'", () => {
33
+
const flow = createInboundMigrationFlow();
34
+
flow.updateField("handlePreservation", "existing");
35
+
expect(flow.state.handlePreservation).toBe("existing");
36
+
});
37
+
38
+
it("sets handlePreservation back to 'new'", () => {
39
+
const flow = createInboundMigrationFlow();
40
+
flow.updateField("handlePreservation", "existing");
41
+
flow.updateField("handlePreservation", "new");
42
+
expect(flow.state.handlePreservation).toBe("new");
43
+
});
44
+
45
+
it("sets existingHandleVerified", () => {
46
+
const flow = createInboundMigrationFlow();
47
+
flow.updateField("existingHandleVerified", true);
48
+
expect(flow.state.existingHandleVerified).toBe(true);
49
+
});
50
+
});
51
+
52
+
describe("verifyExistingHandle", () => {
53
+
it("sets existingHandleVerified and targetHandle on success", async () => {
54
+
globalThis.fetch = vi.fn().mockResolvedValue(
55
+
createMockJsonResponse({ verified: true, method: "dns" }),
56
+
);
57
+
58
+
const flow = createInboundMigrationFlow();
59
+
flow.updateField("sourceHandle", "alice.custom.com");
60
+
flow.updateField("sourceDid", "did:plc:abc123");
61
+
62
+
const result = await flow.verifyExistingHandle();
63
+
64
+
expect(result.verified).toBe(true);
65
+
expect(result.method).toBe("dns");
66
+
expect(flow.state.existingHandleVerified).toBe(true);
67
+
expect(flow.state.targetHandle).toBe("alice.custom.com");
68
+
});
69
+
70
+
it("does not set existingHandleVerified on failure", async () => {
71
+
globalThis.fetch = vi.fn().mockResolvedValue(
72
+
createMockJsonResponse({
73
+
verified: false,
74
+
error: "Handle resolution failed",
75
+
}),
76
+
);
77
+
78
+
const flow = createInboundMigrationFlow();
79
+
flow.updateField("sourceHandle", "alice.custom.com");
80
+
flow.updateField("sourceDid", "did:plc:abc123");
81
+
82
+
const result = await flow.verifyExistingHandle();
83
+
84
+
expect(result.verified).toBe(false);
85
+
expect(result.error).toBe("Handle resolution failed");
86
+
expect(flow.state.existingHandleVerified).toBe(false);
87
+
});
88
+
89
+
it("sends correct handle and did to endpoint", async () => {
90
+
globalThis.fetch = vi.fn().mockResolvedValue(
91
+
createMockJsonResponse({ verified: true, method: "http" }),
92
+
);
93
+
94
+
const flow = createInboundMigrationFlow();
95
+
flow.updateField("sourceHandle", "bob.example.org");
96
+
flow.updateField("sourceDid", "did:plc:xyz789");
97
+
98
+
await flow.verifyExistingHandle();
99
+
100
+
expect(fetch).toHaveBeenCalledWith(
101
+
expect.stringContaining("_identity.verifyHandleOwnership"),
102
+
expect.objectContaining({
103
+
method: "POST",
104
+
body: JSON.stringify({
105
+
handle: "bob.example.org",
106
+
did: "did:plc:xyz789",
107
+
}),
108
+
}),
109
+
);
110
+
});
111
+
112
+
it("handles http verification method", async () => {
113
+
globalThis.fetch = vi.fn().mockResolvedValue(
114
+
createMockJsonResponse({ verified: true, method: "http" }),
115
+
);
116
+
117
+
const flow = createInboundMigrationFlow();
118
+
flow.updateField("sourceHandle", "alice.custom.com");
119
+
flow.updateField("sourceDid", "did:plc:abc123");
120
+
121
+
const result = await flow.verifyExistingHandle();
122
+
123
+
expect(result.method).toBe("http");
124
+
});
125
+
126
+
it("propagates xrpc errors as thrown exceptions", async () => {
127
+
globalThis.fetch = vi.fn().mockResolvedValue(
128
+
createMockJsonResponse(
129
+
{ error: "InvalidHandle", message: "Invalid handle format" },
130
+
400,
131
+
),
132
+
);
133
+
134
+
const flow = createInboundMigrationFlow();
135
+
flow.updateField("sourceHandle", "@#$!");
136
+
flow.updateField("sourceDid", "did:plc:abc123");
137
+
138
+
await expect(flow.verifyExistingHandle()).rejects.toThrow();
139
+
});
140
+
});
141
+
142
+
describe("reset clears handle preservation state", () => {
143
+
it("resets handlePreservation to 'new'", () => {
144
+
const flow = createInboundMigrationFlow();
145
+
flow.updateField("handlePreservation", "existing");
146
+
flow.updateField("existingHandleVerified", true);
147
+
148
+
flow.reset();
149
+
150
+
expect(flow.state.handlePreservation).toBe("new");
151
+
expect(flow.state.existingHandleVerified).toBe(false);
152
+
});
153
+
});
154
+
155
+
describe("handle @ normalization", () => {
156
+
it("resolveSourcePds strips leading @", async () => {
157
+
globalThis.fetch = vi.fn()
158
+
.mockResolvedValueOnce(
159
+
createMockJsonResponse({
160
+
Answer: [{ data: '"did=did:plc:test123"' }],
161
+
}),
162
+
)
163
+
.mockResolvedValueOnce(
164
+
createMockJsonResponse({
165
+
id: "did:plc:test123",
166
+
service: [
167
+
{
168
+
type: "AtprotoPersonalDataServer",
169
+
serviceEndpoint: "https://pds.example.com",
170
+
},
171
+
],
172
+
}),
173
+
);
174
+
175
+
const flow = createInboundMigrationFlow();
176
+
await flow.resolveSourcePds("@alice.example.com");
177
+
178
+
expect(flow.state.sourceHandle).toBe("alice.example.com");
179
+
});
180
+
181
+
it("resolveSourcePds preserves handle without @", async () => {
182
+
globalThis.fetch = vi.fn()
183
+
.mockResolvedValueOnce(
184
+
createMockJsonResponse({
185
+
Answer: [{ data: '"did=did:plc:test456"' }],
186
+
}),
187
+
)
188
+
.mockResolvedValueOnce(
189
+
createMockJsonResponse({
190
+
id: "did:plc:test456",
191
+
service: [
192
+
{
193
+
type: "AtprotoPersonalDataServer",
194
+
serviceEndpoint: "https://pds.example.com",
195
+
},
196
+
],
197
+
}),
198
+
);
199
+
200
+
const flow = createInboundMigrationFlow();
201
+
await flow.resolveSourcePds("bob.example.com");
202
+
203
+
expect(flow.state.sourceHandle).toBe("bob.example.com");
204
+
});
205
+
});
206
+
});
+20
frontend/src/tests/migration/storage.test.ts
+20
frontend/src/tests/migration/storage.test.ts
···
86
86
localAccessToken: null,
87
87
generatedAppPassword: null,
88
88
generatedAppPasswordName: null,
89
+
handlePreservation: "new",
90
+
existingHandleVerified: false,
89
91
...overrides,
90
92
};
91
93
}
···
203
205
expect(stored.passkeySetupToken).toBe("setup-token-123");
204
206
});
205
207
208
+
it("does not persist handlePreservation to storage (transient state)", () => {
209
+
const state = createInboundState({ handlePreservation: "existing" });
210
+
211
+
saveMigrationState(state);
212
+
213
+
const stored = JSON.parse(localStorage.getItem(STORAGE_KEY)!);
214
+
expect(stored.handlePreservation).toBeUndefined();
215
+
});
216
+
217
+
it("does not persist existingHandleVerified to storage (transient state)", () => {
218
+
const state = createInboundState({ existingHandleVerified: true });
219
+
220
+
saveMigrationState(state);
221
+
222
+
const stored = JSON.parse(localStorage.getItem(STORAGE_KEY)!);
223
+
expect(stored.existingHandleVerified).toBeUndefined();
224
+
});
225
+
206
226
it("saves error information", () => {
207
227
const state = createInboundState({
208
228
step: "error",
+15
frontend/src/tests/setup.ts
+15
frontend/src/tests/setup.ts
···
10
10
initialLocale: "en",
11
11
});
12
12
13
+
Object.defineProperty(window, "matchMedia", {
14
+
writable: true,
15
+
configurable: true,
16
+
value: vi.fn((query: string) => ({
17
+
matches: false,
18
+
media: query,
19
+
onchange: null,
20
+
addListener: vi.fn(),
21
+
removeListener: vi.fn(),
22
+
addEventListener: vi.fn(),
23
+
removeEventListener: vi.fn(),
24
+
dispatchEvent: vi.fn(),
25
+
})),
26
+
});
27
+
13
28
let locationHash = "";
14
29
15
30
Object.defineProperty(window, "location", {