mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-07-27 10:12:36 +00:00
feat: fun handle resolution during migration
This commit is contained in:
@@ -74,11 +74,6 @@ example plugins
|
||||
|
||||
### Misc
|
||||
|
||||
migration handle preservation
|
||||
- [ ] allow users to keep their existing handle during migration (eg. lewis.moe instead of forcing lewis.newpds.com)
|
||||
- [ ] UI option to preserve external handle vs create new pds-subdomain handle
|
||||
- [ ] handle the DNS verification flow for external handles during migration
|
||||
|
||||
cross-pds delegation
|
||||
when a client (eg. tangled.org) tries to log into a delegated account:
|
||||
- [ ] client starts oauth flow to delegated account's pds
|
||||
@@ -162,4 +157,4 @@ App password scopes: Granular permissions for app passwords using the same scope
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
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).
|
||||
|
||||
@@ -1159,10 +1159,10 @@ impl RepoRepository for PostgresRepoRepository {
|
||||
None => return Err(ImportRepoError::RepoNotFound),
|
||||
};
|
||||
|
||||
if let Some(expected) = expected_root_cid {
|
||||
if repo.repo_root_cid.as_str() != expected.as_str() {
|
||||
return Err(ImportRepoError::ConcurrentModification);
|
||||
}
|
||||
if let Some(expected) = expected_root_cid
|
||||
&& repo.repo_root_cid.as_str() != expected.as_str()
|
||||
{
|
||||
return Err(ImportRepoError::ConcurrentModification);
|
||||
}
|
||||
|
||||
let block_chunks: Vec<Vec<&ImportBlock>> = blocks
|
||||
|
||||
@@ -108,7 +108,10 @@ pub async fn handle_discord_webhook(
|
||||
1 => Json(json!({"type": 1})).into_response(),
|
||||
2 => handle_command(state, interaction).await,
|
||||
other => {
|
||||
debug!(interaction_type = other, "Received unknown Discord interaction type");
|
||||
debug!(
|
||||
interaction_type = other,
|
||||
"Received unknown Discord interaction type"
|
||||
);
|
||||
StatusCode::OK.into_response()
|
||||
}
|
||||
}
|
||||
@@ -148,14 +151,14 @@ async fn handle_command(state: AppState, interaction: Interaction) -> Response {
|
||||
|
||||
let handle = parse_start_handle(interaction.data.as_ref().and_then(|d| d.options.as_deref()));
|
||||
|
||||
if let Some(ref h) = handle {
|
||||
if Handle::new(h).is_err() {
|
||||
return Json(json!({
|
||||
"type": 4,
|
||||
"data": {"content": "Invalid handle format. Handle should look like: alice.example.com", "flags": 64}
|
||||
}))
|
||||
.into_response();
|
||||
}
|
||||
if let Some(ref h) = handle
|
||||
&& Handle::new(h).is_err()
|
||||
{
|
||||
return Json(json!({
|
||||
"type": 4,
|
||||
"data": {"content": "Invalid handle format. Handle should look like: alice.example.com", "flags": 64}
|
||||
}))
|
||||
.into_response();
|
||||
}
|
||||
|
||||
debug!(
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
use crate::api::error::ApiError;
|
||||
use crate::rate_limit::{HandleVerificationLimit, RateLimited};
|
||||
use crate::types::{Did, Handle};
|
||||
use axum::{
|
||||
Json,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct VerifyHandleOwnershipInput {
|
||||
pub handle: String,
|
||||
pub did: Did,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct VerifyHandleOwnershipOutput {
|
||||
pub verified: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub method: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn verify_handle_ownership(
|
||||
_rate_limit: RateLimited<HandleVerificationLimit>,
|
||||
Json(input): Json<VerifyHandleOwnershipInput>,
|
||||
) -> Response {
|
||||
let handle: Handle = match input.handle.parse() {
|
||||
Ok(h) => h,
|
||||
Err(_) => {
|
||||
return ApiError::InvalidHandle(Some("Invalid handle format".into())).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let handle_str = handle.as_str();
|
||||
let did_str = input.did.as_str();
|
||||
|
||||
let dns_mismatch = match crate::handle::resolve_handle_dns(handle_str).await {
|
||||
Ok(did) if did == did_str => {
|
||||
return Json(VerifyHandleOwnershipOutput {
|
||||
verified: true,
|
||||
method: Some("dns".to_string()),
|
||||
error: None,
|
||||
})
|
||||
.into_response();
|
||||
}
|
||||
Ok(did) => Some(format!(
|
||||
"DNS record points to {}, expected {}",
|
||||
did, did_str
|
||||
)),
|
||||
Err(_) => None,
|
||||
};
|
||||
|
||||
match crate::handle::resolve_handle_http(handle_str).await {
|
||||
Ok(did) if did == did_str => Json(VerifyHandleOwnershipOutput {
|
||||
verified: true,
|
||||
method: Some("http".to_string()),
|
||||
error: None,
|
||||
})
|
||||
.into_response(),
|
||||
Ok(did) => Json(VerifyHandleOwnershipOutput {
|
||||
verified: false,
|
||||
method: None,
|
||||
error: Some(format!("Handle resolves to {}, expected {}", did, did_str)),
|
||||
})
|
||||
.into_response(),
|
||||
Err(e) => Json(VerifyHandleOwnershipOutput {
|
||||
verified: false,
|
||||
method: None,
|
||||
error: Some(dns_mismatch.unwrap_or_else(|| format!("Handle resolution failed: {}", e))),
|
||||
})
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod account;
|
||||
pub mod did;
|
||||
pub mod handle;
|
||||
pub mod plc;
|
||||
|
||||
pub use account::create_account;
|
||||
@@ -7,4 +8,5 @@ pub use did::{
|
||||
get_recommended_did_credentials, resolve_handle, update_handle, user_did_doc,
|
||||
well_known_atproto_did, well_known_did,
|
||||
};
|
||||
pub use handle::verify_handle_ownership;
|
||||
pub use plc::{request_plc_operation_signature, sign_plc_operation, submit_plc_operation};
|
||||
|
||||
@@ -550,7 +550,9 @@ mod tests {
|
||||
assert!(is_valid_discord_username("user.name"));
|
||||
assert!(is_valid_discord_username("user123"));
|
||||
assert!(is_valid_discord_username("a_b.c_d"));
|
||||
assert!(is_valid_discord_username("12345678901234567890123456789012"));
|
||||
assert!(is_valid_discord_username(
|
||||
"12345678901234567890123456789012"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -564,6 +566,8 @@ mod tests {
|
||||
assert!(!is_valid_discord_username("username."));
|
||||
assert!(!is_valid_discord_username("user..name"));
|
||||
assert!(!is_valid_discord_username("user name"));
|
||||
assert!(!is_valid_discord_username("123456789012345678901234567890123"));
|
||||
assert!(!is_valid_discord_username(
|
||||
"123456789012345678901234567890123"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -300,7 +300,7 @@ pub fn format_token_for_display(token: &str) -> String {
|
||||
}
|
||||
|
||||
pub fn normalize_token_input(input: &str) -> String {
|
||||
input.trim().to_string()
|
||||
input.chars().filter(|c| !c.is_whitespace()).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -337,6 +337,10 @@ pub fn app(state: AppState) -> Router {
|
||||
"/com.atproto.identity.submitPlcOperation",
|
||||
post(api::identity::submit_plc_operation),
|
||||
)
|
||||
.route(
|
||||
"/_identity.verifyHandleOwnership",
|
||||
post(api::identity::verify_handle_ownership),
|
||||
)
|
||||
.route("/com.atproto.repo.importRepo", post(api::repo::import_repo))
|
||||
.route(
|
||||
"/com.atproto.admin.deleteAccount",
|
||||
|
||||
@@ -110,6 +110,11 @@ impl RateLimitPolicy for OAuthRegisterCompleteLimit {
|
||||
const KIND: RateLimitKind = RateLimitKind::OAuthRegisterComplete;
|
||||
}
|
||||
|
||||
pub struct HandleVerificationLimit;
|
||||
impl RateLimitPolicy for HandleVerificationLimit {
|
||||
const KIND: RateLimitKind = RateLimitKind::HandleVerification;
|
||||
}
|
||||
|
||||
pub trait RateLimitRejection: IntoResponse + Send + 'static {
|
||||
fn new() -> Self;
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ pub struct RateLimiters {
|
||||
pub sso_callback: Arc<KeyedRateLimiter>,
|
||||
pub sso_unlink: Arc<KeyedRateLimiter>,
|
||||
pub oauth_register_complete: Arc<KeyedRateLimiter>,
|
||||
pub handle_verification: Arc<KeyedRateLimiter>,
|
||||
}
|
||||
|
||||
impl Default for RateLimiters {
|
||||
@@ -109,6 +110,9 @@ impl RateLimiters {
|
||||
.unwrap()
|
||||
.allow_burst(NonZeroU32::new(5).unwrap()),
|
||||
)),
|
||||
handle_verification: Arc::new(RateLimiter::keyed(Quota::per_minute(
|
||||
NonZeroU32::new(10).unwrap(),
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -71,6 +71,7 @@ pub enum RateLimitKind {
|
||||
SsoCallback,
|
||||
SsoUnlink,
|
||||
OAuthRegisterComplete,
|
||||
HandleVerification,
|
||||
}
|
||||
|
||||
impl RateLimitKind {
|
||||
@@ -95,6 +96,7 @@ impl RateLimitKind {
|
||||
Self::SsoCallback => "sso_callback",
|
||||
Self::SsoUnlink => "sso_unlink",
|
||||
Self::OAuthRegisterComplete => "oauth_register_complete",
|
||||
Self::HandleVerification => "handle_verification",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,6 +121,7 @@ impl RateLimitKind {
|
||||
Self::SsoCallback => (30, 60_000),
|
||||
Self::SsoUnlink => (10, 60_000),
|
||||
Self::OAuthRegisterComplete => (5, 300_000),
|
||||
Self::HandleVerification => (10, 60_000),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -271,6 +274,7 @@ impl AppState {
|
||||
RateLimitKind::SsoCallback => &self.rate_limiters.sso_callback,
|
||||
RateLimitKind::SsoUnlink => &self.rate_limiters.sso_unlink,
|
||||
RateLimitKind::OAuthRegisterComplete => &self.rate_limiters.oauth_register_complete,
|
||||
RateLimitKind::HandleVerification => &self.rate_limiters.handle_verification,
|
||||
};
|
||||
|
||||
let ok = limiter.check_key(&client_ip.to_string()).is_ok();
|
||||
|
||||
@@ -531,3 +531,99 @@ async fn test_update_handle_too_long() {
|
||||
assert_eq!(body["error"], "InvalidHandle");
|
||||
assert!(body["message"].as_str().unwrap().contains("long"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_verify_handle_ownership_invalid_did() {
|
||||
let client = client();
|
||||
let res = client
|
||||
.post(format!(
|
||||
"{}/xrpc/_identity.verifyHandleOwnership",
|
||||
base_url().await
|
||||
))
|
||||
.json(&json!({
|
||||
"handle": "some.handle.test",
|
||||
"did": "not-a-did"
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to send request");
|
||||
assert_eq!(res.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_verify_handle_ownership_invalid_handle() {
|
||||
let client = client();
|
||||
let res = client
|
||||
.post(format!(
|
||||
"{}/xrpc/_identity.verifyHandleOwnership",
|
||||
base_url().await
|
||||
))
|
||||
.json(&json!({
|
||||
"handle": "@#$!",
|
||||
"did": "did:plc:abc123"
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to send request");
|
||||
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
|
||||
let body: Value = res.json().await.expect("Response was not valid JSON");
|
||||
assert_eq!(body["error"], "InvalidHandle");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_verify_handle_ownership_missing_fields() {
|
||||
let client = client();
|
||||
let res = client
|
||||
.post(format!(
|
||||
"{}/xrpc/_identity.verifyHandleOwnership",
|
||||
base_url().await
|
||||
))
|
||||
.json(&json!({}))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to send request");
|
||||
assert_eq!(res.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_verify_handle_ownership_unresolvable() {
|
||||
let client = client();
|
||||
let res = client
|
||||
.post(format!(
|
||||
"{}/xrpc/_identity.verifyHandleOwnership",
|
||||
base_url().await
|
||||
))
|
||||
.json(&json!({
|
||||
"handle": "nonexistent.example.com",
|
||||
"did": "did:plc:abc123def456"
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to send request");
|
||||
assert_eq!(res.status(), StatusCode::OK);
|
||||
let body: Value = res.json().await.expect("Response was not valid JSON");
|
||||
assert_eq!(body["verified"], false);
|
||||
assert!(body["error"].as_str().is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_verify_handle_ownership_wrong_did() {
|
||||
let client = client();
|
||||
let res = client
|
||||
.post(format!(
|
||||
"{}/xrpc/_identity.verifyHandleOwnership",
|
||||
base_url().await
|
||||
))
|
||||
.json(&json!({
|
||||
"handle": "nonexistent.example.com",
|
||||
"did": "did:plc:aaaaaaaaaaaaaaaaaaaaaa"
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to send request");
|
||||
assert_eq!(res.status(), StatusCode::OK);
|
||||
let body: Value = res.json().await.expect("Response was not valid JSON");
|
||||
assert_eq!(body["verified"], false);
|
||||
assert!(body["error"].as_str().is_some());
|
||||
assert!(body["method"].is_null());
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
mod common;
|
||||
use tranquil_pds::comms::{SendError, is_valid_phone_number, is_valid_signal_username, sanitize_header_value};
|
||||
use tranquil_pds::comms::{
|
||||
SendError, is_valid_phone_number, is_valid_signal_username, sanitize_header_value,
|
||||
};
|
||||
use tranquil_pds::image::{ImageError, ImageProcessor};
|
||||
|
||||
#[test]
|
||||
@@ -101,15 +103,20 @@ fn test_signal_username_validation() {
|
||||
|
||||
assert!(!is_valid_signal_username("a".repeat(33).as_str()));
|
||||
|
||||
["alice.01; rm -rf /", "bob.01 && cat /etc/passwd", "user.01`id`", "test.01$(whoami)"]
|
||||
.iter()
|
||||
.for_each(|malicious| {
|
||||
assert!(
|
||||
!is_valid_signal_username(malicious),
|
||||
"Command injection '{}' should be rejected",
|
||||
malicious
|
||||
);
|
||||
});
|
||||
[
|
||||
"alice.01; rm -rf /",
|
||||
"bob.01 && cat /etc/passwd",
|
||||
"user.01`id`",
|
||||
"test.01$(whoami)",
|
||||
]
|
||||
.iter()
|
||||
.for_each(|malicious| {
|
||||
assert!(
|
||||
!is_valid_signal_username(malicious),
|
||||
"Command injection '{}' should be rejected",
|
||||
malicious
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -333,18 +333,14 @@
|
||||
placeholder={$_('register.signalUsernamePlaceholder')}
|
||||
disabled={saving}
|
||||
/>
|
||||
{#if signalUsername && signalUsername === savedSignalUsername && !signalVerified}
|
||||
<button type="button" class="verify-btn" onclick={() => verifyingChannel = 'signal'}>{$_('comms.verifyButton')}</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if signalInUse}
|
||||
<p class="hint warning">{$_('comms.signalInUseWarning')}</p>
|
||||
{/if}
|
||||
{#if verifyingChannel === 'signal'}
|
||||
{#if signalUsername && signalUsername === savedSignalUsername && !signalVerified}
|
||||
<div class="verify-form">
|
||||
<input type="text" bind:value={verificationCode} placeholder={$_('comms.verifyCodePlaceholder')} maxlength="128" />
|
||||
<input type="text" bind:value={verificationCode} placeholder={$_('comms.verifyCodePlaceholder')} maxlength="512" />
|
||||
<button type="button" onclick={() => handleVerify('signal')}>{$_('comms.submit')}</button>
|
||||
<button type="button" class="cancel" onclick={() => { verifyingChannel = null; verificationCode = '' }}>{$_('common.cancel')}</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -553,20 +549,11 @@
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.verify-btn {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.verify-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.verify-form input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.verify-form button {
|
||||
@@ -574,12 +561,6 @@
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.verify-form button.cancel {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-bottom: var(--space-5);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import type { AuthMethod, ServerDescription } from '../../lib/migration/types'
|
||||
import type { AuthMethod, HandlePreservation, ServerDescription } from '../../lib/migration/types'
|
||||
import { _ } from '../../lib/i18n'
|
||||
|
||||
interface Props {
|
||||
@@ -15,6 +15,12 @@
|
||||
migratingFromLabel: string
|
||||
migratingFromValue: string
|
||||
loading?: boolean
|
||||
sourceHandle: string
|
||||
sourceDid: string
|
||||
handlePreservation: HandlePreservation
|
||||
existingHandleVerified: boolean
|
||||
verifyingExistingHandle?: boolean
|
||||
existingHandleError?: string | null
|
||||
onHandleChange: (handle: string) => void
|
||||
onDomainChange: (domain: string) => void
|
||||
onCheckHandle: () => void
|
||||
@@ -22,6 +28,8 @@
|
||||
onPasswordChange: (password: string) => void
|
||||
onAuthMethodChange: (method: AuthMethod) => void
|
||||
onInviteCodeChange: (code: string) => void
|
||||
onHandlePreservationChange?: (preservation: HandlePreservation) => void
|
||||
onVerifyExistingHandle?: () => void
|
||||
onBack: () => void
|
||||
onContinue: () => void
|
||||
}
|
||||
@@ -39,6 +47,12 @@
|
||||
migratingFromLabel,
|
||||
migratingFromValue,
|
||||
loading = false,
|
||||
sourceHandle,
|
||||
sourceDid,
|
||||
handlePreservation,
|
||||
existingHandleVerified,
|
||||
verifyingExistingHandle = false,
|
||||
existingHandleError = null,
|
||||
onHandleChange,
|
||||
onDomainChange,
|
||||
onCheckHandle,
|
||||
@@ -46,17 +60,27 @@
|
||||
onPasswordChange,
|
||||
onAuthMethodChange,
|
||||
onInviteCodeChange,
|
||||
onHandlePreservationChange,
|
||||
onVerifyExistingHandle,
|
||||
onBack,
|
||||
onContinue,
|
||||
}: Props = $props()
|
||||
|
||||
const handleTooShort = $derived(handleInput.trim().length > 0 && handleInput.trim().length < 3)
|
||||
|
||||
const isExternalHandle = $derived(
|
||||
serverInfo != null &&
|
||||
sourceHandle.includes('.') &&
|
||||
!serverInfo.availableUserDomains.some(d => sourceHandle.endsWith(`.${d}`))
|
||||
)
|
||||
|
||||
const canContinue = $derived(
|
||||
handleInput.trim().length >= 3 &&
|
||||
email &&
|
||||
(authMethod === 'passkey' || password) &&
|
||||
handleAvailable !== false
|
||||
(
|
||||
(handlePreservation === 'existing' && existingHandleVerified) ||
|
||||
(handlePreservation === 'new' && handleInput.trim().length >= 3 && handleAvailable !== false)
|
||||
)
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -69,38 +93,115 @@
|
||||
<span class="value">{migratingFromValue}</span>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="new-handle">{$_('migration.inbound.chooseHandle.newHandle')}</label>
|
||||
<div class="handle-input-group">
|
||||
<input
|
||||
id="new-handle"
|
||||
type="text"
|
||||
placeholder="username"
|
||||
value={handleInput}
|
||||
oninput={(e) => onHandleChange((e.target as HTMLInputElement).value)}
|
||||
onblur={onCheckHandle}
|
||||
/>
|
||||
{#if serverInfo && serverInfo.availableUserDomains.length > 0 && !handleInput.includes('.')}
|
||||
<select value={selectedDomain} onchange={(e) => onDomainChange((e.target as HTMLSelectElement).value)}>
|
||||
{#each serverInfo.availableUserDomains as domain}
|
||||
<option value={domain}>.{domain}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{#if isExternalHandle}
|
||||
<div class="field">
|
||||
<span class="field-label">{$_('migration.inbound.chooseHandle.handleChoice')}</span>
|
||||
<div class="handle-choice-options">
|
||||
<label class="handle-choice-option" class:selected={handlePreservation === 'existing'}>
|
||||
<input
|
||||
type="radio"
|
||||
name="handle-preservation"
|
||||
value="existing"
|
||||
checked={handlePreservation === 'existing'}
|
||||
onchange={() => onHandlePreservationChange?.('existing')}
|
||||
/>
|
||||
<div class="handle-choice-content">
|
||||
<strong>{$_('migration.inbound.chooseHandle.keepExisting')}</strong>
|
||||
<span class="handle-preview">@{sourceHandle}</span>
|
||||
</div>
|
||||
</label>
|
||||
<label class="handle-choice-option" class:selected={handlePreservation === 'new'}>
|
||||
<input
|
||||
type="radio"
|
||||
name="handle-preservation"
|
||||
value="new"
|
||||
checked={handlePreservation === 'new'}
|
||||
onchange={() => onHandlePreservationChange?.('new')}
|
||||
/>
|
||||
<div class="handle-choice-content">
|
||||
<strong>{$_('migration.inbound.chooseHandle.createNew')}</strong>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if handlePreservation === 'existing' && isExternalHandle}
|
||||
<div class="field">
|
||||
<span class="field-label">{$_('migration.inbound.chooseHandle.existingHandle')}</span>
|
||||
<div class="existing-handle-display">
|
||||
<span class="handle-value">@{sourceHandle}</span>
|
||||
{#if existingHandleVerified}
|
||||
<span class="verified-badge">{$_('migration.inbound.chooseHandle.verified')}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if !existingHandleVerified}
|
||||
<div class="verification-instructions">
|
||||
<p class="instruction-header">{$_('migration.inbound.chooseHandle.verifyInstructions')}</p>
|
||||
<div class="verification-record">
|
||||
<code>_atproto.{sourceHandle} TXT "did={sourceDid}"</code>
|
||||
</div>
|
||||
<p class="instruction-or">{$_('migration.inbound.chooseHandle.or')}</p>
|
||||
<div class="verification-record">
|
||||
<code>https://{sourceHandle}/.well-known/atproto-did</code>
|
||||
<span class="record-content">{$_('migration.inbound.chooseHandle.returning')} <code>{sourceDid}</code></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="verify-btn"
|
||||
onclick={() => onVerifyExistingHandle?.()}
|
||||
disabled={verifyingExistingHandle}
|
||||
>
|
||||
{#if verifyingExistingHandle}
|
||||
{$_('migration.inbound.chooseHandle.verifying')}
|
||||
{:else if existingHandleError}
|
||||
{$_('migration.inbound.chooseHandle.checkAgain')}
|
||||
{:else}
|
||||
{$_('migration.inbound.chooseHandle.verifyOwnership')}
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
{#if existingHandleError}
|
||||
<p class="hint error">{existingHandleError}</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="field">
|
||||
<label for="new-handle">{$_('migration.inbound.chooseHandle.newHandle')}</label>
|
||||
<div class="handle-input-group">
|
||||
<input
|
||||
id="new-handle"
|
||||
type="text"
|
||||
placeholder="username"
|
||||
value={handleInput}
|
||||
oninput={(e) => onHandleChange((e.target as HTMLInputElement).value)}
|
||||
onblur={onCheckHandle}
|
||||
/>
|
||||
{#if serverInfo && serverInfo.availableUserDomains.length > 0 && !handleInput.includes('.')}
|
||||
<select value={selectedDomain} onchange={(e) => onDomainChange((e.target as HTMLSelectElement).value)}>
|
||||
{#each serverInfo.availableUserDomains as domain}
|
||||
<option value={domain}>.{domain}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if handleTooShort}
|
||||
<p class="hint error">{$_('migration.inbound.chooseHandle.handleTooShort')}</p>
|
||||
{:else if checkingHandle}
|
||||
<p class="hint">{$_('migration.inbound.chooseHandle.checkingAvailability')}</p>
|
||||
{:else if handleAvailable === true}
|
||||
<p class="hint" style="color: var(--success-text)">{$_('migration.inbound.chooseHandle.handleAvailable')}</p>
|
||||
{:else if handleAvailable === false}
|
||||
<p class="hint error">{$_('migration.inbound.chooseHandle.handleTaken')}</p>
|
||||
{:else}
|
||||
<p class="hint">{$_('migration.inbound.chooseHandle.handleHint')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{#if handleTooShort}
|
||||
<p class="hint error">{$_('migration.inbound.chooseHandle.handleTooShort')}</p>
|
||||
{:else if checkingHandle}
|
||||
<p class="hint">{$_('migration.inbound.chooseHandle.checkingAvailability')}</p>
|
||||
{:else if handleAvailable === true}
|
||||
<p class="hint" style="color: var(--success-text)">{$_('migration.inbound.chooseHandle.handleAvailable')}</p>
|
||||
{:else if handleAvailable === false}
|
||||
<p class="hint error">{$_('migration.inbound.chooseHandle.handleTaken')}</p>
|
||||
{:else}
|
||||
<p class="hint">{$_('migration.inbound.chooseHandle.handleHint')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="field">
|
||||
<label for="email">{$_('migration.inbound.chooseHandle.email')}</label>
|
||||
@@ -187,3 +288,123 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.handle-choice-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.handle-choice-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-4);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
cursor: pointer;
|
||||
transition: border-color var(--transition-normal), background var(--transition-normal);
|
||||
}
|
||||
|
||||
.handle-choice-option:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.handle-choice-option.selected {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-muted);
|
||||
}
|
||||
|
||||
.handle-choice-option input[type="radio"] {
|
||||
flex-shrink: 0;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.handle-choice-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.handle-preview {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.existing-handle-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-4);
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-lg);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.handle-value {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
|
||||
.verified-badge {
|
||||
font-size: var(--text-xs);
|
||||
padding: var(--space-1) var(--space-3);
|
||||
background: var(--success-bg);
|
||||
color: var(--success-text);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.verification-instructions {
|
||||
background: var(--bg-secondary);
|
||||
padding: var(--space-5);
|
||||
border-radius: var(--radius-lg);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.instruction-header {
|
||||
margin: 0 0 var(--space-4) 0;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.instruction-or {
|
||||
margin: var(--space-3) 0;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.verification-record {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.verification-record code {
|
||||
font-size: var(--text-sm);
|
||||
padding: var(--space-3);
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius-md);
|
||||
overflow-x: auto;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.record-content {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
padding-left: var(--space-3);
|
||||
}
|
||||
|
||||
.record-content code {
|
||||
padding: var(--space-1) var(--space-2);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.verify-btn {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { InboundMigrationFlow } from '../../lib/migration'
|
||||
import type { AuthMethod, ServerDescription } from '../../lib/migration/types'
|
||||
import type { AuthMethod, HandlePreservation, ServerDescription } from '../../lib/migration/types'
|
||||
import { getErrorMessage } from '../../lib/migration/types'
|
||||
import { base64UrlEncode, prepareWebAuthnCreationOptions } from '../../lib/migration/atproto-client'
|
||||
import { _ } from '../../lib/i18n'
|
||||
@@ -43,6 +43,8 @@
|
||||
let checkingHandle = $state(false)
|
||||
let selectedAuthMethod = $state<AuthMethod>('password')
|
||||
let passkeyName = $state('')
|
||||
let verifyingExistingHandle = $state(false)
|
||||
let existingHandleError = $state<string | null>(null)
|
||||
|
||||
const isResuming = $derived(flow.state.needsReauth === true)
|
||||
const isDidWeb = $derived(flow.state.sourceDid.startsWith("did:web:"))
|
||||
@@ -54,6 +56,9 @@
|
||||
if (flow.state.step === 'choose-handle') {
|
||||
handleInput = ''
|
||||
handleAvailable = null
|
||||
existingHandleError = null
|
||||
flow.updateField('handlePreservation', 'new')
|
||||
flow.updateField('existingHandleVerified', false)
|
||||
}
|
||||
if (flow.state.step === 'source-handle' && resumeInfo) {
|
||||
handleInput = resumeInfo.sourceHandle
|
||||
@@ -112,6 +117,30 @@
|
||||
}
|
||||
}
|
||||
|
||||
function handlePreservationChange(preservation: HandlePreservation) {
|
||||
flow.updateField('handlePreservation', preservation)
|
||||
existingHandleError = null
|
||||
if (preservation === 'existing') {
|
||||
flow.updateField('existingHandleVerified', false)
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyExistingHandle() {
|
||||
verifyingExistingHandle = true
|
||||
existingHandleError = null
|
||||
|
||||
try {
|
||||
const result = await flow.verifyExistingHandle()
|
||||
if (!result.verified && result.error) {
|
||||
existingHandleError = result.error
|
||||
}
|
||||
} catch (err) {
|
||||
existingHandleError = getErrorMessage(err)
|
||||
} finally {
|
||||
verifyingExistingHandle = false
|
||||
}
|
||||
}
|
||||
|
||||
function proceedToReview() {
|
||||
const fullHandle = handleInput.includes('.')
|
||||
? handleInput
|
||||
@@ -265,11 +294,16 @@
|
||||
}
|
||||
|
||||
function proceedToReviewWithAuth() {
|
||||
const fullHandle = handleInput.includes('.')
|
||||
? handleInput
|
||||
: `${handleInput}.${selectedDomain}`
|
||||
let targetHandle: string
|
||||
if (flow.state.handlePreservation === 'existing' && flow.state.existingHandleVerified) {
|
||||
targetHandle = flow.state.sourceHandle
|
||||
} else {
|
||||
targetHandle = handleInput.includes('.')
|
||||
? handleInput
|
||||
: `${handleInput}.${selectedDomain}`
|
||||
}
|
||||
|
||||
flow.updateField('targetHandle', fullHandle)
|
||||
flow.updateField('targetHandle', targetHandle)
|
||||
flow.updateField('authMethod', selectedAuthMethod)
|
||||
flow.setStep('review')
|
||||
}
|
||||
@@ -420,6 +454,12 @@
|
||||
migratingFromLabel={$_('migration.inbound.chooseHandle.migratingFrom')}
|
||||
migratingFromValue={flow.state.sourceHandle}
|
||||
{loading}
|
||||
sourceHandle={flow.state.sourceHandle}
|
||||
sourceDid={flow.state.sourceDid}
|
||||
handlePreservation={flow.state.handlePreservation}
|
||||
existingHandleVerified={flow.state.existingHandleVerified}
|
||||
{verifyingExistingHandle}
|
||||
{existingHandleError}
|
||||
onHandleChange={(h) => handleInput = h}
|
||||
onDomainChange={(d) => selectedDomain = d}
|
||||
onCheckHandle={checkHandle}
|
||||
@@ -427,6 +467,8 @@
|
||||
onPasswordChange={(p) => flow.updateField('targetPassword', p)}
|
||||
onAuthMethodChange={(m) => selectedAuthMethod = m}
|
||||
onInviteCodeChange={(c) => flow.updateField('inviteCode', c)}
|
||||
onHandlePreservationChange={handlePreservationChange}
|
||||
onVerifyExistingHandle={verifyExistingHandle}
|
||||
onBack={() => flow.setStep('source-handle')}
|
||||
onContinue={proceedToReviewWithAuth}
|
||||
/>
|
||||
|
||||
@@ -412,6 +412,12 @@
|
||||
migratingFromLabel={$_('migration.offline.chooseHandle.migratingDid')}
|
||||
migratingFromValue={flow.state.userDid}
|
||||
{loading}
|
||||
sourceHandle=""
|
||||
sourceDid={flow.state.userDid}
|
||||
handlePreservation="new"
|
||||
existingHandleVerified={false}
|
||||
verifyingExistingHandle={false}
|
||||
existingHandleError={null}
|
||||
onHandleChange={(h) => handleInput = h}
|
||||
onDomainChange={(d) => selectedDomain = d}
|
||||
onCheckHandle={checkHandle}
|
||||
|
||||
+21
-7
@@ -327,8 +327,12 @@ function _castDelegationController(raw: unknown): DelegationController {
|
||||
return {
|
||||
did: unsafeAsDid(c.did as string),
|
||||
handle: unsafeAsHandle(c.handle as string),
|
||||
grantedScopes: unsafeAsScopeSet((c.granted_scopes ?? c.grantedScopes) as string),
|
||||
grantedAt: unsafeAsISODate((c.granted_at ?? c.grantedAt ?? c.added_at) as string),
|
||||
grantedScopes: unsafeAsScopeSet(
|
||||
(c.granted_scopes ?? c.grantedScopes) as string,
|
||||
),
|
||||
grantedAt: unsafeAsISODate(
|
||||
(c.granted_at ?? c.grantedAt ?? c.added_at) as string,
|
||||
),
|
||||
isActive: (c.is_active ?? c.isActive ?? true) as boolean,
|
||||
};
|
||||
}
|
||||
@@ -340,15 +344,21 @@ function _castDelegationControlledAccount(
|
||||
return {
|
||||
did: unsafeAsDid(a.did as string),
|
||||
handle: unsafeAsHandle(a.handle as string),
|
||||
grantedScopes: unsafeAsScopeSet((a.granted_scopes ?? a.grantedScopes) as string),
|
||||
grantedAt: unsafeAsISODate((a.granted_at ?? a.grantedAt ?? a.added_at) as string),
|
||||
grantedScopes: unsafeAsScopeSet(
|
||||
(a.granted_scopes ?? a.grantedScopes) as string,
|
||||
),
|
||||
grantedAt: unsafeAsISODate(
|
||||
(a.granted_at ?? a.grantedAt ?? a.added_at) as string,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function _castDelegationAuditEntry(raw: unknown): DelegationAuditEntry {
|
||||
const e = raw as Record<string, unknown>;
|
||||
const actorDid = (e.actor_did ?? e.actorDid) as string;
|
||||
const targetDid = (e.target_did ?? e.targetDid ?? e.delegatedDid) as string | undefined;
|
||||
const targetDid = (e.target_did ?? e.targetDid ?? e.delegatedDid) as
|
||||
| string
|
||||
| undefined;
|
||||
const createdAt = (e.created_at ?? e.createdAt) as string;
|
||||
const action = (e.action ?? e.actionType) as string;
|
||||
const details = e.details ?? e.actionDetails;
|
||||
@@ -1434,7 +1444,9 @@ export const api = {
|
||||
);
|
||||
if (!result.ok) return result;
|
||||
return ok({
|
||||
controllers: (result.value.controllers ?? []).map(_castDelegationController),
|
||||
controllers: (result.value.controllers ?? []).map(
|
||||
_castDelegationController,
|
||||
),
|
||||
});
|
||||
},
|
||||
|
||||
@@ -1447,7 +1459,9 @@ export const api = {
|
||||
);
|
||||
if (!result.ok) return result;
|
||||
return ok({
|
||||
accounts: (result.value.accounts ?? []).map(_castDelegationControlledAccount),
|
||||
accounts: (result.value.accounts ?? []).map(
|
||||
_castDelegationControlledAccount,
|
||||
),
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
@@ -615,6 +615,16 @@ export class AtprotoClient {
|
||||
});
|
||||
}
|
||||
|
||||
async verifyHandleOwnership(
|
||||
handle: string,
|
||||
did: string,
|
||||
): Promise<{ verified: boolean; method?: string; error?: string }> {
|
||||
return this.xrpc("_identity.verifyHandleOwnership", {
|
||||
httpMethod: "POST",
|
||||
body: { handle, did },
|
||||
});
|
||||
}
|
||||
|
||||
async resendMigrationVerification(): Promise<void> {
|
||||
await this.xrpc("com.atproto.server.resendMigrationVerification", {
|
||||
httpMethod: "POST",
|
||||
|
||||
@@ -80,6 +80,8 @@ export function createInboundMigrationFlow() {
|
||||
localAccessToken: null,
|
||||
generatedAppPassword: null,
|
||||
generatedAppPasswordName: null,
|
||||
handlePreservation: "new",
|
||||
existingHandleVerified: false,
|
||||
});
|
||||
|
||||
let sourceClient: AtprotoClient | null = null;
|
||||
@@ -118,11 +120,12 @@ export function createInboundMigrationFlow() {
|
||||
}
|
||||
|
||||
async function resolveSourcePds(handle: string): Promise<void> {
|
||||
const normalized = handle.startsWith("@") ? handle.slice(1) : handle;
|
||||
try {
|
||||
const { did, pdsUrl } = await resolvePdsUrl(handle);
|
||||
const { did, pdsUrl } = await resolvePdsUrl(normalized);
|
||||
state.sourcePdsUrl = pdsUrl;
|
||||
state.sourceDid = did;
|
||||
state.sourceHandle = handle;
|
||||
state.sourceHandle = normalized;
|
||||
sourceClient = new AtprotoClient(pdsUrl);
|
||||
} catch (e) {
|
||||
throw new Error(`Could not resolve handle: ${(e as Error).message}`);
|
||||
@@ -132,8 +135,9 @@ export function createInboundMigrationFlow() {
|
||||
async function initiateOAuthLogin(handle: string): Promise<void> {
|
||||
migrationLog("initiateOAuthLogin START", { handle });
|
||||
|
||||
if (!state.sourcePdsUrl) {
|
||||
await resolveSourcePds(handle);
|
||||
const normalizedHandle = handle.startsWith("@") ? handle.slice(1) : handle;
|
||||
if (!state.sourcePdsUrl || state.sourceHandle !== normalizedHandle) {
|
||||
await resolveSourcePds(normalizedHandle);
|
||||
}
|
||||
|
||||
const metadata = await getOAuthServerMetadata(state.sourcePdsUrl);
|
||||
@@ -322,6 +326,25 @@ export function createInboundMigrationFlow() {
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyExistingHandle(): Promise<{
|
||||
verified: boolean;
|
||||
method?: string;
|
||||
error?: string;
|
||||
}> {
|
||||
if (!localClient) {
|
||||
localClient = createLocalClient();
|
||||
}
|
||||
const result = await localClient.verifyHandleOwnership(
|
||||
state.sourceHandle,
|
||||
state.sourceDid,
|
||||
);
|
||||
if (result.verified) {
|
||||
state.existingHandleVerified = true;
|
||||
state.targetHandle = state.sourceHandle;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function authenticateToLocal(
|
||||
email: string,
|
||||
password: string,
|
||||
@@ -921,6 +944,8 @@ export function createInboundMigrationFlow() {
|
||||
localAccessToken: null,
|
||||
generatedAppPassword: null,
|
||||
generatedAppPasswordName: null,
|
||||
handlePreservation: "new",
|
||||
existingHandleVerified: false,
|
||||
};
|
||||
sourceClient = null;
|
||||
passkeySetup = null;
|
||||
@@ -1005,6 +1030,7 @@ export function createInboundMigrationFlow() {
|
||||
handleOAuthCallback,
|
||||
authenticateToLocal,
|
||||
checkHandleAvailability,
|
||||
verifyExistingHandle,
|
||||
startMigration,
|
||||
submitEmailVerifyToken,
|
||||
resendEmailVerification,
|
||||
|
||||
@@ -169,6 +169,8 @@ export function createOfflineInboundMigrationFlow() {
|
||||
progress: createInitialProgress(),
|
||||
error: null,
|
||||
plcUpdatedTemporarily: false,
|
||||
handlePreservation: "new",
|
||||
existingHandleVerified: false,
|
||||
});
|
||||
|
||||
let localServerInfo: ServerDescription | null = null;
|
||||
@@ -671,6 +673,8 @@ export function createOfflineInboundMigrationFlow() {
|
||||
progress: createInitialProgress(),
|
||||
error: null,
|
||||
plcUpdatedTemporarily: false,
|
||||
handlePreservation: "new",
|
||||
existingHandleVerified: false,
|
||||
};
|
||||
localServerInfo = null;
|
||||
}
|
||||
|
||||
@@ -48,6 +48,8 @@ export interface MigrationProgress {
|
||||
currentOperation: string;
|
||||
}
|
||||
|
||||
export type HandlePreservation = "new" | "existing";
|
||||
|
||||
export interface InboundMigrationState {
|
||||
direction: "inbound";
|
||||
step: InboundStep;
|
||||
@@ -74,6 +76,8 @@ export interface InboundMigrationState {
|
||||
generatedAppPasswordName: string | null;
|
||||
needsReauth?: boolean;
|
||||
resumeToStep?: InboundStep;
|
||||
handlePreservation: HandlePreservation;
|
||||
existingHandleVerified: boolean;
|
||||
}
|
||||
|
||||
export interface OfflineInboundMigrationState {
|
||||
@@ -101,6 +105,8 @@ export interface OfflineInboundMigrationState {
|
||||
progress: MigrationProgress;
|
||||
error: string | null;
|
||||
plcUpdatedTemporarily: boolean;
|
||||
handlePreservation: HandlePreservation;
|
||||
existingHandleVerified: boolean;
|
||||
}
|
||||
|
||||
export type MigrationState = InboundMigrationState;
|
||||
|
||||
@@ -233,6 +233,8 @@ export interface ServerDescription {
|
||||
availableCommsChannels?: VerificationChannel[];
|
||||
selfHostedDidWebEnabled?: boolean;
|
||||
telegramBotUsername?: string;
|
||||
discordBotUsername?: string;
|
||||
discordAppId?: string;
|
||||
}
|
||||
|
||||
export interface UpdateNotificationPrefsResponse {
|
||||
|
||||
@@ -986,6 +986,17 @@
|
||||
"title": "Choose Your New Handle",
|
||||
"desc": "Select a handle for your account on this PDS.",
|
||||
"migratingFrom": "Migrating from",
|
||||
"handleChoice": "Handle",
|
||||
"keepExisting": "Keep existing handle",
|
||||
"createNew": "Create new handle",
|
||||
"existingHandle": "Your handle",
|
||||
"verifyInstructions": "Verify ownership via DNS or HTTP",
|
||||
"or": "or",
|
||||
"returning": "returning",
|
||||
"verifyOwnership": "Verify ownership",
|
||||
"verifying": "Verifying",
|
||||
"verified": "Verified",
|
||||
"checkAgain": "Check again",
|
||||
"newHandle": "New Handle",
|
||||
"checkingAvailability": "Checking availability...",
|
||||
"handleAvailable": "Handle is available!",
|
||||
|
||||
@@ -986,6 +986,17 @@
|
||||
"title": "Valitse uusi käyttäjätunnuksesi",
|
||||
"desc": "Valitse käyttäjätunnus tilillesi tässä PDS:ssä.",
|
||||
"migratingFrom": "Siirretään tililtä",
|
||||
"handleChoice": "Käyttäjätunnus",
|
||||
"keepExisting": "Säilytä nykyinen tunnus",
|
||||
"createNew": "Luo uusi tunnus",
|
||||
"existingHandle": "Nykyinen tunnus",
|
||||
"verifyInstructions": "Vahvista omistajuus DNS- tai HTTP-tietueella",
|
||||
"or": "tai",
|
||||
"returning": "palauttaa",
|
||||
"verifyOwnership": "Vahvista omistajuus",
|
||||
"verifying": "Vahvistetaan",
|
||||
"verified": "Vahvistettu",
|
||||
"checkAgain": "Tarkista uudelleen",
|
||||
"newHandle": "Uusi käyttäjätunnus",
|
||||
"checkingAvailability": "Tarkistetaan saatavuutta...",
|
||||
"handleAvailable": "Käyttäjätunnus on saatavilla!",
|
||||
|
||||
@@ -986,6 +986,17 @@
|
||||
"title": "新しいハンドルを選択",
|
||||
"desc": "このPDSでのアカウントのハンドルを選択してください。",
|
||||
"migratingFrom": "移行元",
|
||||
"handleChoice": "ハンドル",
|
||||
"keepExisting": "既存のハンドルを維持",
|
||||
"createNew": "新しいハンドルを作成",
|
||||
"existingHandle": "現在のハンドル",
|
||||
"verifyInstructions": "DNSまたはHTTPで所有権を確認",
|
||||
"or": "または",
|
||||
"returning": "返却値",
|
||||
"verifyOwnership": "所有権を確認",
|
||||
"verifying": "確認中",
|
||||
"verified": "確認済み",
|
||||
"checkAgain": "再確認",
|
||||
"newHandle": "新しいハンドル",
|
||||
"checkingAvailability": "利用可能か確認中...",
|
||||
"handleAvailable": "ハンドルは利用可能です!",
|
||||
|
||||
@@ -986,6 +986,17 @@
|
||||
"title": "새 핸들 선택",
|
||||
"desc": "이 PDS에서 사용할 계정 핸들을 선택하세요.",
|
||||
"migratingFrom": "마이그레이션 원본",
|
||||
"handleChoice": "핸들",
|
||||
"keepExisting": "기존 핸들 유지",
|
||||
"createNew": "새 핸들 생성",
|
||||
"existingHandle": "현재 핸들",
|
||||
"verifyInstructions": "DNS 또는 HTTP로 소유권 확인",
|
||||
"or": "또는",
|
||||
"returning": "반환값",
|
||||
"verifyOwnership": "소유권 확인",
|
||||
"verifying": "확인 중",
|
||||
"verified": "확인됨",
|
||||
"checkAgain": "다시 확인",
|
||||
"newHandle": "새 핸들",
|
||||
"checkingAvailability": "사용 가능 여부 확인 중...",
|
||||
"handleAvailable": "핸들을 사용할 수 있습니다!",
|
||||
|
||||
@@ -986,6 +986,17 @@
|
||||
"title": "Välj ditt nya användarnamn",
|
||||
"desc": "Välj ett användarnamn för ditt konto på denna PDS.",
|
||||
"migratingFrom": "Flyttar från",
|
||||
"handleChoice": "Användarnamn",
|
||||
"keepExisting": "Behåll befintligt användarnamn",
|
||||
"createNew": "Skapa nytt användarnamn",
|
||||
"existingHandle": "Ditt användarnamn",
|
||||
"verifyInstructions": "Verifiera ägarskap via DNS eller HTTP",
|
||||
"or": "eller",
|
||||
"returning": "returnerar",
|
||||
"verifyOwnership": "Verifiera ägarskap",
|
||||
"verifying": "Verifierar",
|
||||
"verified": "Verifierad",
|
||||
"checkAgain": "Kontrollera igen",
|
||||
"newHandle": "Nytt användarnamn",
|
||||
"checkingAvailability": "Kontrollerar tillgänglighet...",
|
||||
"handleAvailable": "Användarnamnet är tillgängligt!",
|
||||
|
||||
@@ -986,6 +986,17 @@
|
||||
"title": "选择新用户名",
|
||||
"desc": "为您在此PDS上的账户选择用户名。",
|
||||
"migratingFrom": "迁移自",
|
||||
"handleChoice": "用户名",
|
||||
"keepExisting": "保留现有用户名",
|
||||
"createNew": "创建新用户名",
|
||||
"existingHandle": "当前用户名",
|
||||
"verifyInstructions": "通过DNS或HTTP验证所有权",
|
||||
"or": "或",
|
||||
"returning": "返回值",
|
||||
"verifyOwnership": "验证所有权",
|
||||
"verifying": "验证中",
|
||||
"verified": "已验证",
|
||||
"checkAgain": "重新验证",
|
||||
"newHandle": "新用户名",
|
||||
"checkingAvailability": "检查可用性...",
|
||||
"handleAvailable": "用户名可用!",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
.migration-wizard {
|
||||
max-width: var(--width-sm);
|
||||
max-width: var(--width-md);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
AtprotoClient,
|
||||
base64UrlDecode,
|
||||
base64UrlEncode,
|
||||
buildOAuthAuthorizationUrl,
|
||||
@@ -517,4 +518,90 @@ describe("migration/atproto-client", () => {
|
||||
expect(prepared.user?.displayName).toBe("Test User");
|
||||
});
|
||||
});
|
||||
|
||||
describe("AtprotoClient.verifyHandleOwnership", () => {
|
||||
function createMockJsonResponse(data: unknown, status = 200) {
|
||||
return new Response(JSON.stringify(data), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
it("sends POST with handle and did", async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue(
|
||||
createMockJsonResponse({ verified: true, method: "dns" }),
|
||||
);
|
||||
|
||||
const client = new AtprotoClient("https://pds.example.com");
|
||||
await client.verifyHandleOwnership("alice.custom.com", "did:plc:abc123");
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"https://pds.example.com/xrpc/_identity.verifyHandleOwnership",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns verified result with method", async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue(
|
||||
createMockJsonResponse({ verified: true, method: "dns" }),
|
||||
);
|
||||
|
||||
const client = new AtprotoClient("https://pds.example.com");
|
||||
const result = await client.verifyHandleOwnership(
|
||||
"alice.custom.com",
|
||||
"did:plc:abc123",
|
||||
);
|
||||
|
||||
expect(result.verified).toBe(true);
|
||||
expect(result.method).toBe("dns");
|
||||
});
|
||||
|
||||
it("returns unverified result with error", async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue(
|
||||
createMockJsonResponse({
|
||||
verified: false,
|
||||
error: "Handle resolution failed",
|
||||
}),
|
||||
);
|
||||
|
||||
const client = new AtprotoClient("https://pds.example.com");
|
||||
const result = await client.verifyHandleOwnership(
|
||||
"nonexistent.example.com",
|
||||
"did:plc:abc123",
|
||||
);
|
||||
|
||||
expect(result.verified).toBe(false);
|
||||
expect(result.error).toBe("Handle resolution failed");
|
||||
});
|
||||
|
||||
it("throws on server error responses", async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue(
|
||||
createMockJsonResponse(
|
||||
{ error: "InvalidHandle", message: "Invalid handle format" },
|
||||
400,
|
||||
),
|
||||
);
|
||||
|
||||
const client = new AtprotoClient("https://pds.example.com");
|
||||
await expect(
|
||||
client.verifyHandleOwnership("@#$!", "did:plc:abc123"),
|
||||
).rejects.toThrow("Invalid handle format");
|
||||
});
|
||||
|
||||
it("strips trailing slash from base URL", async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue(
|
||||
createMockJsonResponse({ verified: true, method: "http" }),
|
||||
);
|
||||
|
||||
const client = new AtprotoClient("https://pds.example.com/");
|
||||
await client.verifyHandleOwnership("alice.example.com", "did:plc:abc");
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"https://pds.example.com/xrpc/_identity.verifyHandleOwnership",
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createInboundMigrationFlow } from "../../lib/migration/flow.svelte.ts";
|
||||
|
||||
const STORAGE_KEY = "tranquil_migration_state";
|
||||
|
||||
function createMockJsonResponse(data: unknown, status = 200) {
|
||||
return new Response(JSON.stringify(data), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
describe("migration/flow handle preservation", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("initial state", () => {
|
||||
it("defaults handlePreservation to 'new'", () => {
|
||||
const flow = createInboundMigrationFlow();
|
||||
expect(flow.state.handlePreservation).toBe("new");
|
||||
});
|
||||
|
||||
it("defaults existingHandleVerified to false", () => {
|
||||
const flow = createInboundMigrationFlow();
|
||||
expect(flow.state.existingHandleVerified).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateField for handle preservation", () => {
|
||||
it("sets handlePreservation to 'existing'", () => {
|
||||
const flow = createInboundMigrationFlow();
|
||||
flow.updateField("handlePreservation", "existing");
|
||||
expect(flow.state.handlePreservation).toBe("existing");
|
||||
});
|
||||
|
||||
it("sets handlePreservation back to 'new'", () => {
|
||||
const flow = createInboundMigrationFlow();
|
||||
flow.updateField("handlePreservation", "existing");
|
||||
flow.updateField("handlePreservation", "new");
|
||||
expect(flow.state.handlePreservation).toBe("new");
|
||||
});
|
||||
|
||||
it("sets existingHandleVerified", () => {
|
||||
const flow = createInboundMigrationFlow();
|
||||
flow.updateField("existingHandleVerified", true);
|
||||
expect(flow.state.existingHandleVerified).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("verifyExistingHandle", () => {
|
||||
it("sets existingHandleVerified and targetHandle on success", async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue(
|
||||
createMockJsonResponse({ verified: true, method: "dns" }),
|
||||
);
|
||||
|
||||
const flow = createInboundMigrationFlow();
|
||||
flow.updateField("sourceHandle", "alice.custom.com");
|
||||
flow.updateField("sourceDid", "did:plc:abc123");
|
||||
|
||||
const result = await flow.verifyExistingHandle();
|
||||
|
||||
expect(result.verified).toBe(true);
|
||||
expect(result.method).toBe("dns");
|
||||
expect(flow.state.existingHandleVerified).toBe(true);
|
||||
expect(flow.state.targetHandle).toBe("alice.custom.com");
|
||||
});
|
||||
|
||||
it("does not set existingHandleVerified on failure", async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue(
|
||||
createMockJsonResponse({
|
||||
verified: false,
|
||||
error: "Handle resolution failed",
|
||||
}),
|
||||
);
|
||||
|
||||
const flow = createInboundMigrationFlow();
|
||||
flow.updateField("sourceHandle", "alice.custom.com");
|
||||
flow.updateField("sourceDid", "did:plc:abc123");
|
||||
|
||||
const result = await flow.verifyExistingHandle();
|
||||
|
||||
expect(result.verified).toBe(false);
|
||||
expect(result.error).toBe("Handle resolution failed");
|
||||
expect(flow.state.existingHandleVerified).toBe(false);
|
||||
});
|
||||
|
||||
it("sends correct handle and did to endpoint", async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue(
|
||||
createMockJsonResponse({ verified: true, method: "http" }),
|
||||
);
|
||||
|
||||
const flow = createInboundMigrationFlow();
|
||||
flow.updateField("sourceHandle", "bob.example.org");
|
||||
flow.updateField("sourceDid", "did:plc:xyz789");
|
||||
|
||||
await flow.verifyExistingHandle();
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining("_identity.verifyHandleOwnership"),
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
handle: "bob.example.org",
|
||||
did: "did:plc:xyz789",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("handles http verification method", async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue(
|
||||
createMockJsonResponse({ verified: true, method: "http" }),
|
||||
);
|
||||
|
||||
const flow = createInboundMigrationFlow();
|
||||
flow.updateField("sourceHandle", "alice.custom.com");
|
||||
flow.updateField("sourceDid", "did:plc:abc123");
|
||||
|
||||
const result = await flow.verifyExistingHandle();
|
||||
|
||||
expect(result.method).toBe("http");
|
||||
});
|
||||
|
||||
it("propagates xrpc errors as thrown exceptions", async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue(
|
||||
createMockJsonResponse(
|
||||
{ error: "InvalidHandle", message: "Invalid handle format" },
|
||||
400,
|
||||
),
|
||||
);
|
||||
|
||||
const flow = createInboundMigrationFlow();
|
||||
flow.updateField("sourceHandle", "@#$!");
|
||||
flow.updateField("sourceDid", "did:plc:abc123");
|
||||
|
||||
await expect(flow.verifyExistingHandle()).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("reset clears handle preservation state", () => {
|
||||
it("resets handlePreservation to 'new'", () => {
|
||||
const flow = createInboundMigrationFlow();
|
||||
flow.updateField("handlePreservation", "existing");
|
||||
flow.updateField("existingHandleVerified", true);
|
||||
|
||||
flow.reset();
|
||||
|
||||
expect(flow.state.handlePreservation).toBe("new");
|
||||
expect(flow.state.existingHandleVerified).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handle @ normalization", () => {
|
||||
it("resolveSourcePds strips leading @", async () => {
|
||||
globalThis.fetch = vi.fn()
|
||||
.mockResolvedValueOnce(
|
||||
createMockJsonResponse({
|
||||
Answer: [{ data: '"did=did:plc:test123"' }],
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
createMockJsonResponse({
|
||||
id: "did:plc:test123",
|
||||
service: [
|
||||
{
|
||||
type: "AtprotoPersonalDataServer",
|
||||
serviceEndpoint: "https://pds.example.com",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const flow = createInboundMigrationFlow();
|
||||
await flow.resolveSourcePds("@alice.example.com");
|
||||
|
||||
expect(flow.state.sourceHandle).toBe("alice.example.com");
|
||||
});
|
||||
|
||||
it("resolveSourcePds preserves handle without @", async () => {
|
||||
globalThis.fetch = vi.fn()
|
||||
.mockResolvedValueOnce(
|
||||
createMockJsonResponse({
|
||||
Answer: [{ data: '"did=did:plc:test456"' }],
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
createMockJsonResponse({
|
||||
id: "did:plc:test456",
|
||||
service: [
|
||||
{
|
||||
type: "AtprotoPersonalDataServer",
|
||||
serviceEndpoint: "https://pds.example.com",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const flow = createInboundMigrationFlow();
|
||||
await flow.resolveSourcePds("bob.example.com");
|
||||
|
||||
expect(flow.state.sourceHandle).toBe("bob.example.com");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -86,6 +86,8 @@ function createInboundState(
|
||||
localAccessToken: null,
|
||||
generatedAppPassword: null,
|
||||
generatedAppPasswordName: null,
|
||||
handlePreservation: "new",
|
||||
existingHandleVerified: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -203,6 +205,24 @@ describe("migration/storage", () => {
|
||||
expect(stored.passkeySetupToken).toBe("setup-token-123");
|
||||
});
|
||||
|
||||
it("does not persist handlePreservation to storage (transient state)", () => {
|
||||
const state = createInboundState({ handlePreservation: "existing" });
|
||||
|
||||
saveMigrationState(state);
|
||||
|
||||
const stored = JSON.parse(localStorage.getItem(STORAGE_KEY)!);
|
||||
expect(stored.handlePreservation).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not persist existingHandleVerified to storage (transient state)", () => {
|
||||
const state = createInboundState({ existingHandleVerified: true });
|
||||
|
||||
saveMigrationState(state);
|
||||
|
||||
const stored = JSON.parse(localStorage.getItem(STORAGE_KEY)!);
|
||||
expect(stored.existingHandleVerified).toBeUndefined();
|
||||
});
|
||||
|
||||
it("saves error information", () => {
|
||||
const state = createInboundState({
|
||||
step: "error",
|
||||
|
||||
@@ -10,6 +10,21 @@ init({
|
||||
initialLocale: "en",
|
||||
});
|
||||
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: vi.fn((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
|
||||
let locationHash = "";
|
||||
|
||||
Object.defineProperty(window, "location", {
|
||||
|
||||
Reference in New Issue
Block a user