Our Personal Data Server from scratch! tranquil.farm
oauth atproto pds rust postgresql objectstorage fun

feat: fun handle resolution during migration #17

merged opened by lewis.moe targeting main from feat/fun-handle-res-during-migrate
Labels

None yet.

assignee

None yet.

Participants 1
AT URI
at://did:plc:3fwecdnvtcscjnrx2p4n7alz/sh.tangled.repo.pull/3me5ak7igbo22
+1009 -103
Diff #0
+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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 300 300 } 301 301 302 302 pub fn normalize_token_input(input: &str) -> String { 303 - input.trim().to_string() 303 + input.chars().filter(|c| !c.is_whitespace()).collect() 304 304 } 305 305 306 306 #[cfg(test)]
+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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 233 233 availableCommsChannels?: VerificationChannel[]; 234 234 selfHostedDidWebEnabled?: boolean; 235 235 telegramBotUsername?: string; 236 + discordBotUsername?: string; 237 + discordAppId?: string; 236 238 } 237 239 238 240 export interface UpdateNotificationPrefsResponse {
+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
··· 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
··· 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
··· 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
··· 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
··· 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 .migration-wizard { 2 - max-width: var(--width-sm); 2 + max-width: var(--width-md); 3 3 margin: 0 auto; 4 4 } 5 5
+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
··· 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
··· 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
··· 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", {

History

1 round 0 comments
sign up or login to add to the discussion
lewis.moe submitted #0
1 commit
expand
feat: fun handle resolution during migration
expand 0 comments
pull request successfully merged