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

fix: did:web also uses handle domains not hostname

authored by lewis.moe and committed by tangled.org 2c8568b2 f78b004d

+226 -92
+15 -15
Cargo.lock
··· 6094 6094 6095 6095 [[package]] 6096 6096 name = "tranquil-auth" 6097 - version = "0.3.0" 6097 + version = "0.3.1" 6098 6098 dependencies = [ 6099 6099 "anyhow", 6100 6100 "base32", ··· 6117 6117 6118 6118 [[package]] 6119 6119 name = "tranquil-cache" 6120 - version = "0.3.0" 6120 + version = "0.3.1" 6121 6121 dependencies = [ 6122 6122 "async-trait", 6123 6123 "base64 0.22.1", ··· 6131 6131 6132 6132 [[package]] 6133 6133 name = "tranquil-comms" 6134 - version = "0.3.0" 6134 + version = "0.3.1" 6135 6135 dependencies = [ 6136 6136 "async-trait", 6137 6137 "base64 0.22.1", ··· 6146 6146 6147 6147 [[package]] 6148 6148 name = "tranquil-config" 6149 - version = "0.3.0" 6149 + version = "0.3.1" 6150 6150 dependencies = [ 6151 6151 "confique", 6152 6152 "serde", ··· 6154 6154 6155 6155 [[package]] 6156 6156 name = "tranquil-crypto" 6157 - version = "0.3.0" 6157 + version = "0.3.1" 6158 6158 dependencies = [ 6159 6159 "aes-gcm", 6160 6160 "base64 0.22.1", ··· 6170 6170 6171 6171 [[package]] 6172 6172 name = "tranquil-db" 6173 - version = "0.3.0" 6173 + version = "0.3.1" 6174 6174 dependencies = [ 6175 6175 "async-trait", 6176 6176 "chrono", ··· 6187 6187 6188 6188 [[package]] 6189 6189 name = "tranquil-db-traits" 6190 - version = "0.3.0" 6190 + version = "0.3.1" 6191 6191 dependencies = [ 6192 6192 "async-trait", 6193 6193 "base64 0.22.1", ··· 6203 6203 6204 6204 [[package]] 6205 6205 name = "tranquil-infra" 6206 - version = "0.3.0" 6206 + version = "0.3.1" 6207 6207 dependencies = [ 6208 6208 "async-trait", 6209 6209 "bytes", ··· 6214 6214 6215 6215 [[package]] 6216 6216 name = "tranquil-oauth" 6217 - version = "0.3.0" 6217 + version = "0.3.1" 6218 6218 dependencies = [ 6219 6219 "anyhow", 6220 6220 "axum", ··· 6237 6237 6238 6238 [[package]] 6239 6239 name = "tranquil-pds" 6240 - version = "0.3.0" 6240 + version = "0.3.1" 6241 6241 dependencies = [ 6242 6242 "aes-gcm", 6243 6243 "anyhow", ··· 6324 6324 6325 6325 [[package]] 6326 6326 name = "tranquil-repo" 6327 - version = "0.3.0" 6327 + version = "0.3.1" 6328 6328 dependencies = [ 6329 6329 "bytes", 6330 6330 "cid", ··· 6336 6336 6337 6337 [[package]] 6338 6338 name = "tranquil-ripple" 6339 - version = "0.3.0" 6339 + version = "0.3.1" 6340 6340 dependencies = [ 6341 6341 "async-trait", 6342 6342 "backon", ··· 6361 6361 6362 6362 [[package]] 6363 6363 name = "tranquil-scopes" 6364 - version = "0.3.0" 6364 + version = "0.3.1" 6365 6365 dependencies = [ 6366 6366 "axum", 6367 6367 "futures", ··· 6377 6377 6378 6378 [[package]] 6379 6379 name = "tranquil-storage" 6380 - version = "0.3.0" 6380 + version = "0.3.1" 6381 6381 dependencies = [ 6382 6382 "async-trait", 6383 6383 "aws-config", ··· 6394 6394 6395 6395 [[package]] 6396 6396 name = "tranquil-types" 6397 - version = "0.3.0" 6397 + version = "0.3.1" 6398 6398 dependencies = [ 6399 6399 "chrono", 6400 6400 "cid",
+1 -1
Cargo.toml
··· 19 19 ] 20 20 21 21 [workspace.package] 22 - version = "0.3.0" 22 + version = "0.3.1" 23 23 edition = "2024" 24 24 license = "AGPL-3.0-or-later" 25 25
+8 -22
crates/tranquil-pds/src/api/identity/account.rs
··· 140 140 } 141 141 } 142 142 143 - let available_domains = tranquil_config::get().server.available_user_domain_list(); 143 + let cfg = tranquil_config::get(); 144 + let available_domains = cfg.server.available_user_domain_list(); 144 145 let matched_domain = available_domains 145 146 .iter() 146 147 .filter(|d| input.handle.ends_with(&format!(".{}", d))) ··· 163 164 } 164 165 } 165 166 } else { 166 - if input.handle.contains(' ') || input.handle.contains('\t') { 167 - return ApiError::InvalidRequest("Handle cannot contain spaces".into()).into_response(); 168 - } 169 - if let Some(c) = input 170 - .handle 171 - .chars() 172 - .find(|c| !c.is_ascii_alphanumeric() && *c != '.' && *c != '-') 173 - { 174 - return ApiError::InvalidRequest(format!("Handle contains invalid character: {}", c)) 175 - .into_response(); 176 - } 177 - let handle_lower = input.handle.to_lowercase(); 178 - if crate::moderation::has_explicit_slur(&handle_lower) { 179 - return ApiError::InvalidRequest("Inappropriate language in handle".into()) 180 - .into_response(); 167 + match crate::api::validation::validate_full_domain_handle(&input.handle) { 168 + Ok(h) => h, 169 + Err(e) => return ApiError::from(e).into_response(), 181 170 } 182 - handle_lower 183 171 }; 184 172 let email: Option<String> = input 185 173 .email ··· 234 222 }, 235 223 }) 236 224 }; 237 - let hostname = &tranquil_config::get().server.hostname; 225 + let hostname = &cfg.server.hostname; 238 226 let pds_endpoint = format!("https://{}", hostname); 239 227 let handle = match matched_domain { 240 228 Some(domain) => format!("{}.{}", validated_short_handle, domain), ··· 274 262 if !crate::api::server::meta::is_self_hosted_did_web_enabled() { 275 263 return ApiError::SelfHostedDidWebDisabled.into_response(); 276 264 } 277 - let pds_hostname = tranquil_config::get().server.hostname_without_port(); 278 - let subdomain_host = format!("{}.{}", input.handle, pds_hostname); 279 - let encoded_subdomain = subdomain_host.replace(':', "%3A"); 280 - let self_hosted_did = format!("did:web:{}", encoded_subdomain); 265 + let encoded_handle = handle.replace(':', "%3A"); 266 + let self_hosted_did = format!("did:web:{}", encoded_handle); 281 267 info!(did = %self_hosted_did, "Creating self-hosted did:web account (subdomain)"); 282 268 self_hosted_did 283 269 }
+16 -14
crates/tranquil-pds/src/api/identity/did.rs
··· 122 122 } 123 123 124 124 pub async fn well_known_did(State(state): State<AppState>, headers: HeaderMap) -> Response { 125 - let hostname = &tranquil_config::get().server.hostname; 126 - let hostname_without_port = tranquil_config::get().server.hostname_without_port(); 125 + let cfg = tranquil_config::get(); 126 + let hostname = &cfg.server.hostname; 127 + let hostname_without_port = cfg.server.hostname_without_port(); 127 128 let host_header = get_header_str(&headers, http::header::HOST).unwrap_or(hostname); 128 129 let host_without_port = host_header.split(':').next().unwrap_or(host_header); 129 - if host_without_port != hostname_without_port 130 - && host_without_port.ends_with(&format!(".{}", hostname_without_port)) 131 - { 132 - let handle = host_without_port 133 - .strip_suffix(&format!(".{}", hostname_without_port)) 134 - .unwrap_or(host_without_port); 135 - return serve_subdomain_did_doc(&state, handle, hostname).await; 130 + if host_without_port != hostname_without_port { 131 + let is_subdomain = cfg 132 + .server 133 + .available_user_domain_list() 134 + .into_iter() 135 + .chain(std::iter::once(hostname_without_port.to_string())) 136 + .any(|d| host_without_port.ends_with(&format!(".{}", d))); 137 + if is_subdomain { 138 + return serve_handle_did_doc(&state, host_without_port, hostname).await; 139 + } 136 140 } 137 141 let did = if hostname.contains(':') { 138 142 format!("did:web:{}", hostname.replace(':', "%3A")) ··· 151 155 .into_response() 152 156 } 153 157 154 - async fn serve_subdomain_did_doc(state: &AppState, subdomain: &str, hostname: &str) -> Response { 155 - let hostname_for_handles = hostname.split(':').next().unwrap_or(hostname); 156 - let subdomain_host = format!("{}.{}", subdomain, hostname_for_handles); 157 - let encoded_subdomain = subdomain_host.replace(':', "%3A"); 158 - let expected_did = format!("did:web:{}", encoded_subdomain); 158 + async fn serve_handle_did_doc(state: &AppState, handle: &str, hostname: &str) -> Response { 159 + let encoded_handle = handle.replace(':', "%3A"); 160 + let expected_did = format!("did:web:{}", encoded_handle); 159 161 let expected_did_typed: crate::types::Did = match expected_did.parse() { 160 162 Ok(d) => d, 161 163 Err(_) => return ApiError::InvalidRequest("Invalid DID format".into()).into_response(),
+18 -6
crates/tranquil-pds/src/api/proxy_client.rs
··· 215 215 use super::*; 216 216 #[test] 217 217 fn test_ssrf_safe_https() { 218 - assert!(is_ssrf_safe("https://api.bsky.app/xrpc/test").is_ok()); 218 + assert!(is_ssrf_safe("https://1.1.1.1/xrpc/test").is_ok()); 219 219 } 220 220 #[test] 221 221 fn test_ssrf_blocks_http_by_default() { 222 - let result = is_ssrf_safe("http://external.example.com/xrpc/test"); 223 - assert!(matches!( 224 - result, 225 - Err(SsrfError::InsecureProtocol(_)) | Err(SsrfError::DnsResolutionFailed(_)) 226 - )); 222 + let result = is_ssrf_safe("http://93.184.216.34/xrpc/test"); 223 + assert!(matches!(result, Err(SsrfError::InsecureProtocol(_)))); 227 224 } 228 225 #[test] 229 226 fn test_ssrf_allows_localhost_http() { 230 227 assert!(is_ssrf_safe("http://127.0.0.1:8080/test").is_ok()); 231 228 assert!(is_ssrf_safe("http://localhost:8080/test").is_ok()); 229 + } 230 + #[test] 231 + fn test_ssrf_blocks_non_unicast_ip() { 232 + assert!(matches!( 233 + is_ssrf_safe("https://0.0.0.0/test"), 234 + Err(SsrfError::NonUnicastIp(_)) 235 + )); 236 + assert!(matches!( 237 + is_ssrf_safe("https://224.0.0.1/test"), 238 + Err(SsrfError::NonUnicastIp(_)) 239 + )); 240 + assert!(matches!( 241 + is_ssrf_safe("https://255.255.255.255/test"), 242 + Err(SsrfError::NonUnicastIp(_)) 243 + )); 232 244 } 233 245 #[test] 234 246 fn test_validate_at_uri() {
+12 -7
crates/tranquil-pds/src/api/server/passkey_account.rs
··· 112 112 .map(|d| d.starts_with("did:web:")) 113 113 .unwrap_or(false); 114 114 115 - let hostname = &tranquil_config::get().server.hostname; 116 - let available_domains = tranquil_config::get().server.available_user_domain_list(); 115 + let cfg = tranquil_config::get(); 116 + let hostname = &cfg.server.hostname; 117 + let available_domains = cfg.server.available_user_domain_list(); 117 118 let matched_domain = available_domains 118 119 .iter() 119 120 .filter(|d| input.handle.ends_with(&format!(".{}", d))) ··· 134 135 } 135 136 } 136 137 } else { 137 - input.handle.to_lowercase() 138 + match crate::api::validation::validate_full_domain_handle(&input.handle) { 139 + Ok(h) => h, 140 + Err(_) => return ApiError::InvalidHandle(None).into_response(), 141 + } 138 142 }; 139 143 140 144 let email = input ··· 246 250 247 251 let did = match did_type { 248 252 "web" => { 249 - let pds_hostname = tranquil_config::get().server.hostname_without_port(); 250 - let subdomain_host = format!("{}.{}", input.handle, pds_hostname); 251 - let encoded_subdomain = subdomain_host.replace(':', "%3A"); 252 - let self_hosted_did = format!("did:web:{}", encoded_subdomain); 253 + if !crate::api::server::meta::is_self_hosted_did_web_enabled() { 254 + return ApiError::SelfHostedDidWebDisabled.into_response(); 255 + } 256 + let encoded_handle = handle.replace(':', "%3A"); 257 + let self_hosted_did = format!("did:web:{}", encoded_handle); 253 258 info!(did = %self_hosted_did, "Creating self-hosted did:web passkey account"); 254 259 self_hosted_did 255 260 }
+43
crates/tranquil-pds/src/api/validation.rs
··· 258 258 Reject, 259 259 } 260 260 261 + pub fn validate_full_domain_handle(handle: &str) -> Result<String, HandleValidationError> { 262 + let handle = handle.trim(); 263 + 264 + if handle.is_empty() { 265 + return Err(HandleValidationError::Empty); 266 + } 267 + 268 + if handle.contains(' ') || handle.contains('\t') || handle.contains('\n') { 269 + return Err(HandleValidationError::ContainsSpaces); 270 + } 271 + 272 + if handle.len() > MAX_HANDLE_LENGTH { 273 + return Err(HandleValidationError::TooLong); 274 + } 275 + 276 + if handle 277 + .chars() 278 + .any(|c| !c.is_ascii_alphanumeric() && c != '.' && c != '-') 279 + { 280 + return Err(HandleValidationError::InvalidCharacters); 281 + } 282 + 283 + if !handle.contains('.') { 284 + return Err(HandleValidationError::InvalidCharacters); 285 + } 286 + 287 + let labels: Vec<&str> = handle.split('.').collect(); 288 + let has_invalid_label = labels 289 + .iter() 290 + .any(|label| label.is_empty() || label.len() > MAX_DOMAIN_LABEL_LENGTH || label.starts_with('-') || label.ends_with('-')); 291 + if has_invalid_label { 292 + return Err(HandleValidationError::InvalidCharacters); 293 + } 294 + 295 + let handle_lower = handle.to_lowercase(); 296 + 297 + if crate::moderation::has_explicit_slur(&handle_lower) { 298 + return Err(HandleValidationError::BannedWord); 299 + } 300 + 301 + Ok(handle_lower) 302 + } 303 + 261 304 pub fn validate_short_handle(handle: &str) -> Result<String, HandleValidationError> { 262 305 validate_service_handle(handle, ReservedHandlePolicy::Reject) 263 306 }
+43 -10
crates/tranquil-pds/src/sso/endpoints.rs
··· 743 743 #[derive(Debug, Deserialize)] 744 744 pub struct CheckHandleQuery { 745 745 pub handle: String, 746 + pub domain: Option<String>, 746 747 } 747 748 748 749 #[derive(Debug, Serialize)] ··· 773 774 }; 774 775 775 776 let available_domains = tranquil_config::get().server.available_user_domain_list(); 776 - let full_handle = format!("{}.{}", validated, &available_domains[0]); 777 + if let Some(ref d) = query.domain { 778 + if !available_domains.iter().any(|ad| ad == d) { 779 + return Err(ApiError::InvalidRequest( 780 + "Unknown user domain".into(), 781 + )); 782 + } 783 + } 784 + let domain = query 785 + .domain 786 + .as_deref() 787 + .unwrap_or(&available_domains[0]); 788 + let full_handle = format!("{}.{}", validated, domain); 777 789 let handle_typed: crate::types::Handle = match full_handle.parse() { 778 790 Ok(h) => h, 779 791 Err(_) => return Err(ApiError::InvalidHandle(None)), ··· 855 867 .await? 856 868 .ok_or(ApiError::SsoSessionExpired)?; 857 869 858 - let hostname = &tranquil_config::get().server.hostname; 859 - let available_domains = tranquil_config::get().server.available_user_domain_list(); 870 + let cfg = tranquil_config::get(); 871 + let hostname = &cfg.server.hostname; 872 + let available_domains = cfg.server.available_user_domain_list(); 873 + 874 + let matched_domain = available_domains 875 + .iter() 876 + .filter(|d| input.handle.ends_with(&format!(".{}", d))) 877 + .max_by_key(|d| d.len()); 860 878 861 - let handle = match crate::api::validation::validate_short_handle(&input.handle) { 862 - Ok(h) => format!("{}.{}", h, &available_domains[0]), 863 - Err(_) => return Err(ApiError::InvalidHandle(None)), 879 + let handle = if !input.handle.contains('.') || matched_domain.is_some() { 880 + let handle_to_validate = match matched_domain { 881 + Some(domain) => input 882 + .handle 883 + .strip_suffix(&format!(".{}", domain)) 884 + .unwrap_or(&input.handle), 885 + None => &input.handle, 886 + }; 887 + match crate::api::validation::validate_short_handle(handle_to_validate) { 888 + Ok(h) => format!("{}.{}", h, matched_domain.unwrap_or(&available_domains[0])), 889 + Err(_) => return Err(ApiError::InvalidHandle(None)), 890 + } 891 + } else { 892 + match crate::api::validation::validate_full_domain_handle(&input.handle) { 893 + Ok(h) => h, 894 + Err(_) => return Err(ApiError::InvalidHandle(None)), 895 + } 864 896 }; 865 897 866 898 let verification_channel = input ··· 981 1013 982 1014 let did = match did_type { 983 1015 "web" => { 984 - let pds_hostname = tranquil_config::get().server.hostname_without_port(); 985 - let subdomain_host = format!("{}.{}", input.handle, pds_hostname); 986 - let encoded_subdomain = subdomain_host.replace(':', "%3A"); 987 - let self_hosted_did = format!("did:web:{}", encoded_subdomain); 1016 + if !crate::api::server::meta::is_self_hosted_did_web_enabled() { 1017 + return Err(ApiError::SelfHostedDidWebDisabled); 1018 + } 1019 + let encoded_handle = handle.replace(':', "%3A"); 1020 + let self_hosted_did = format!("did:web:{}", encoded_handle); 988 1021 tracing::info!(did = %self_hosted_did, "Creating self-hosted did:web SSO account"); 989 1022 self_hosted_did 990 1023 }
-4
crates/tranquil-pds/tests/firehose/mod.rs
··· 133 133 } 134 134 135 135 impl FirehoseConsumer { 136 - pub async fn connect(port: u16) -> Self { 137 - Self::connect_inner(port, None).await 138 - } 139 - 140 136 pub async fn connect_with_cursor(port: u16, cursor: i64) -> Self { 141 137 Self::connect_inner(port, Some(cursor)).await 142 138 }
+34
crates/tranquil-pds/tests/handle_domains.rs
··· 276 276 "updateHandle with bare handle should use configured domain, not PDS hostname" 277 277 ); 278 278 } 279 + 280 + #[tokio::test] 281 + async fn did_web_uses_handle_domain_not_hostname() { 282 + unsafe { 283 + std::env::set_var("ENABLE_PDS_HOSTED_DID_WEB", "true"); 284 + } 285 + let client = client(); 286 + let base = base_url_with_domain().await; 287 + let short_handle = format!("hd{}", &uuid::Uuid::new_v4().simple().to_string()[..12]); 288 + let payload = json!({ 289 + "handle": short_handle, 290 + "email": format!("{}@example.com", short_handle), 291 + "password": "Testpass123!", 292 + "didType": "web" 293 + }); 294 + let res = client 295 + .post(format!( 296 + "{}/xrpc/com.atproto.server.createAccount", 297 + base 298 + )) 299 + .json(&payload) 300 + .send() 301 + .await 302 + .expect("createAccount request failed"); 303 + assert_eq!(res.status(), StatusCode::OK); 304 + let body: Value = res.json().await.expect("Invalid JSON"); 305 + let did = body["did"].as_str().expect("No DID in response"); 306 + let expected_did = format!("did:web:{}.{}", short_handle, HANDLE_DOMAIN); 307 + assert_eq!( 308 + did, expected_did, 309 + "did:web should use handle domain '{}', not PDS hostname", 310 + HANDLE_DOMAIN 311 + ); 312 + }
+14 -6
frontend/src/lib/registration/flow.svelte.ts
··· 34 34 error: string | null; 35 35 submitting: boolean; 36 36 pdsHostname: string; 37 + selectedDomain: string; 37 38 handleAvailable: boolean | null; 38 39 checkingHandle: boolean; 39 40 discordInUse: boolean; ··· 68 69 error: null, 69 70 submitting: false, 70 71 pdsHostname, 72 + selectedDomain: "", 71 73 handleAvailable: null, 72 74 checkingHandle: false, 73 75 discordInUse: false, ··· 84 86 } 85 87 86 88 function getFullHandle(): string { 87 - return `${state.info.handle.trim()}.${state.pdsHostname}`; 89 + const handle = state.info.handle.trim(); 90 + if (handle.includes('.')) return handle; 91 + const domain = state.selectedDomain || state.pdsHostname; 92 + return `${handle}.${domain}`; 88 93 } 89 94 90 95 function extractDomain(did: string): string { ··· 132 137 } 133 138 state.checkingHandle = true; 134 139 try { 140 + const params = new URLSearchParams({ handle }); 141 + if (state.selectedDomain) params.set("domain", state.selectedDomain); 135 142 const response = await fetch( 136 - `${getPdsEndpoint()}/oauth/sso/check-handle-available?handle=${ 137 - encodeURIComponent(handle) 138 - }`, 143 + `${getPdsEndpoint()}/oauth/sso/check-handle-available?${params}`, 139 144 ); 140 145 const data = await response.json(); 141 146 state.handleAvailable = data.available === true; ··· 239 244 } 240 245 241 246 const result = await api.createAccount({ 242 - handle: state.info.handle.trim(), 247 + handle: getFullHandle(), 243 248 email: state.info.email.trim(), 244 249 password: state.info.password!, 245 250 inviteCode: state.info.inviteCode?.trim() || undefined, ··· 291 296 } 292 297 293 298 const result = await api.createPasskeyAccount({ 294 - handle: unsafeAsHandle(state.info.handle.trim()), 299 + handle: unsafeAsHandle(getFullHandle()), 295 300 email: state.info.email?.trim() 296 301 ? unsafeAsEmail(state.info.email.trim()) 297 302 : undefined, ··· 532 537 getPdsDid, 533 538 getFullHandle, 534 539 extractDomain, 540 + setSelectedDomain(domain: string) { 541 + state.selectedDomain = domain; 542 + }, 535 543 536 544 proceedFromInfo, 537 545 selectKeyMode,
+2 -1
frontend/src/routes/OAuthRegister.svelte
··· 102 102 flow = createRegistrationFlow('passkey', hostname) 103 103 } 104 104 selectedDomain = serverInfo?.availableUserDomains?.[0] || window.location.hostname 105 + if (flow) flow.setSelectedDomain(selectedDomain) 105 106 } catch (e) { 106 107 console.error('Failed to load server info:', e) 107 108 } finally { ··· 353 354 placeholder={$_('register.handlePlaceholder')} 354 355 disabled={flow.state.submitting} 355 356 onInput={(v) => { flow!.info.handle = v }} 356 - onDomainChange={(d) => { selectedDomain = d }} 357 + onDomainChange={(d) => { selectedDomain = d; flow!.setSelectedDomain(d) }} 357 358 /> 358 359 {#if fullHandle()} 359 360 <p class="hint">{$_('register.handleHint', { values: { handle: fullHandle() } })}</p>
+8 -2
frontend/src/routes/OAuthSsoRegister.svelte
··· 150 150 let checkHandleTimeout: ReturnType<typeof setTimeout> | null = null 151 151 152 152 $effect(() => { 153 + void selectedDomain 153 154 if (checkHandleTimeout) { 154 155 clearTimeout(checkHandleTimeout) 155 156 } ··· 167 168 handleError = null 168 169 169 170 try { 170 - const response = await fetch(`/oauth/sso/check-handle-available?handle=${encodeURIComponent(handle)}`) 171 + const params = new URLSearchParams({ handle }) 172 + if (selectedDomain) params.set('domain', selectedDomain) 173 + const response = await fetch(`/oauth/sso/check-handle-available?${params}`) 171 174 const data = await response.json() 172 175 handleAvailable = data.available 173 176 if (!data.available && data.reason) { ··· 222 225 return 223 226 } 224 227 228 + const fullHandle = !handle.includes('.') && selectedDomain 229 + ? `${handle.trim()}.${selectedDomain}` 230 + : handle.trim() 225 231 submitting = true 226 232 227 233 try { ··· 233 239 }, 234 240 body: JSON.stringify({ 235 241 token, 236 - handle, 242 + handle: fullHandle, 237 243 email: email || null, 238 244 invite_code: inviteCode || null, 239 245 verification_channel: verificationChannel,
+2 -1
frontend/src/routes/Register.svelte
··· 115 115 flow = createRegistrationFlow('passkey', hostname) 116 116 } 117 117 selectedDomain = serverInfo?.availableUserDomains?.[0] || window.location.hostname 118 + if (flow) flow.setSelectedDomain(selectedDomain) 118 119 } catch (e) { 119 120 console.error('Failed to load server info:', e) 120 121 } finally { ··· 368 369 placeholder={$_('register.handlePlaceholder')} 369 370 disabled={flow.state.submitting} 370 371 onInput={(v) => { flow!.info.handle = v }} 371 - onDomainChange={(d) => { selectedDomain = d }} 372 + onDomainChange={(d) => { selectedDomain = d; flow!.setSelectedDomain(d) }} 372 373 /> 373 374 {#if flow.info.handle.includes('.')} 374 375 <p class="hint warning">{$_('register.handleDotWarning')}</p>
+2 -1
frontend/src/routes/RegisterPassword.svelte
··· 109 109 flow = createRegistrationFlow('password', hostname) 110 110 } 111 111 selectedDomain = serverInfo?.availableUserDomains?.[0] || window.location.hostname 112 + if (flow) flow.setSelectedDomain(selectedDomain) 112 113 } catch (e) { 113 114 console.error('Failed to load server info:', e) 114 115 } finally { ··· 313 314 placeholder={$_('register.handlePlaceholder')} 314 315 disabled={flow.state.submitting} 315 316 onInput={(v) => { flow!.info.handle = v }} 316 - onDomainChange={(d) => { selectedDomain = d }} 317 + onDomainChange={(d) => { selectedDomain = d; flow!.setSelectedDomain(d) }} 317 318 /> 318 319 {#if flow.info.handle.includes('.')} 319 320 <p class="hint warning">{$_('register.handleDotWarning')}</p>
+8 -2
frontend/src/routes/SsoRegisterComplete.svelte
··· 165 165 let checkHandleTimeout: ReturnType<typeof setTimeout> | null = null 166 166 167 167 $effect(() => { 168 + void selectedDomain 168 169 if (checkHandleTimeout) { 169 170 clearTimeout(checkHandleTimeout) 170 171 } ··· 182 183 handleError = null 183 184 184 185 try { 185 - const response = await fetch(`/oauth/sso/check-handle-available?handle=${encodeURIComponent(handle)}`) 186 + const params = new URLSearchParams({ handle }) 187 + if (selectedDomain) params.set('domain', selectedDomain) 188 + const response = await fetch(`/oauth/sso/check-handle-available?${params}`) 186 189 const data = await response.json() 187 190 handleAvailable = data.available 188 191 if (!data.available && data.reason) { ··· 269 272 return 270 273 } 271 274 275 + const fullHandle = !handle.includes('.') && selectedDomain 276 + ? `${handle.trim()}.${selectedDomain}` 277 + : handle.trim() 272 278 submitting = true 273 279 274 280 try { ··· 280 286 }, 281 287 body: JSON.stringify({ 282 288 token, 283 - handle, 289 + handle: fullHandle, 284 290 email: email || null, 285 291 invite_code: inviteCode || null, 286 292 verification_channel: verificationChannel,