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

fix: user handle domains upgrade #40

merged opened by lewis.moe targeting main from fix/user-handle-domains

Actually use the user handle domains

Labels

None yet.

assignee

None yet.

Participants 1
AT URI
at://did:plc:3fwecdnvtcscjnrx2p4n7alz/sh.tangled.repo.pull/3mgjzdv26ay22
+534 -323
Diff #0
+8
.config/nextest.toml
··· 21 filter = "test(/import_with_verification/) | test(/plc_migration/)" 22 test-group = "serial-env-tests" 23 24 [[profile.default.overrides]] 25 filter = "binary(ripple_cluster)" 26 test-group = "serial-env-tests" ··· 41 filter = "test(/import_with_verification/) | test(/plc_migration/)" 42 test-group = "serial-env-tests" 43 44 [[profile.ci.overrides]] 45 filter = "binary(ripple_cluster)" 46 test-group = "serial-env-tests"
··· 21 filter = "test(/import_with_verification/) | test(/plc_migration/)" 22 test-group = "serial-env-tests" 23 24 + [[profile.default.overrides]] 25 + filter = "binary(handle_domains)" 26 + test-group = "serial-env-tests" 27 + 28 [[profile.default.overrides]] 29 filter = "binary(ripple_cluster)" 30 test-group = "serial-env-tests" ··· 45 filter = "test(/import_with_verification/) | test(/plc_migration/)" 46 test-group = "serial-env-tests" 47 48 + [[profile.ci.overrides]] 49 + filter = "binary(handle_domains)" 50 + test-group = "serial-env-tests" 51 + 52 [[profile.ci.overrides]] 53 filter = "binary(ripple_cluster)" 54 test-group = "serial-env-tests"
+15 -15
Cargo.lock
··· 6094 6095 [[package]] 6096 name = "tranquil-auth" 6097 - version = "0.2.1" 6098 dependencies = [ 6099 "anyhow", 6100 "base32", ··· 6117 6118 [[package]] 6119 name = "tranquil-cache" 6120 - version = "0.2.1" 6121 dependencies = [ 6122 "async-trait", 6123 "base64 0.22.1", ··· 6131 6132 [[package]] 6133 name = "tranquil-comms" 6134 - version = "0.2.1" 6135 dependencies = [ 6136 "async-trait", 6137 "base64 0.22.1", ··· 6146 6147 [[package]] 6148 name = "tranquil-config" 6149 - version = "0.2.1" 6150 dependencies = [ 6151 "confique", 6152 "serde", ··· 6154 6155 [[package]] 6156 name = "tranquil-crypto" 6157 - version = "0.2.1" 6158 dependencies = [ 6159 "aes-gcm", 6160 "base64 0.22.1", ··· 6170 6171 [[package]] 6172 name = "tranquil-db" 6173 - version = "0.2.1" 6174 dependencies = [ 6175 "async-trait", 6176 "chrono", ··· 6187 6188 [[package]] 6189 name = "tranquil-db-traits" 6190 - version = "0.2.1" 6191 dependencies = [ 6192 "async-trait", 6193 "base64 0.22.1", ··· 6203 6204 [[package]] 6205 name = "tranquil-infra" 6206 - version = "0.2.1" 6207 dependencies = [ 6208 "async-trait", 6209 "bytes", ··· 6214 6215 [[package]] 6216 name = "tranquil-oauth" 6217 - version = "0.2.1" 6218 dependencies = [ 6219 "anyhow", 6220 "axum", ··· 6237 6238 [[package]] 6239 name = "tranquil-pds" 6240 - version = "0.2.1" 6241 dependencies = [ 6242 "aes-gcm", 6243 "anyhow", ··· 6324 6325 [[package]] 6326 name = "tranquil-repo" 6327 - version = "0.2.1" 6328 dependencies = [ 6329 "bytes", 6330 "cid", ··· 6336 6337 [[package]] 6338 name = "tranquil-ripple" 6339 - version = "0.2.1" 6340 dependencies = [ 6341 "async-trait", 6342 "backon", ··· 6361 6362 [[package]] 6363 name = "tranquil-scopes" 6364 - version = "0.2.1" 6365 dependencies = [ 6366 "axum", 6367 "futures", ··· 6377 6378 [[package]] 6379 name = "tranquil-storage" 6380 - version = "0.2.1" 6381 dependencies = [ 6382 "async-trait", 6383 "aws-config", ··· 6394 6395 [[package]] 6396 name = "tranquil-types" 6397 - version = "0.2.1" 6398 dependencies = [ 6399 "chrono", 6400 "cid",
··· 6094 6095 [[package]] 6096 name = "tranquil-auth" 6097 + version = "0.3.0" 6098 dependencies = [ 6099 "anyhow", 6100 "base32", ··· 6117 6118 [[package]] 6119 name = "tranquil-cache" 6120 + version = "0.3.0" 6121 dependencies = [ 6122 "async-trait", 6123 "base64 0.22.1", ··· 6131 6132 [[package]] 6133 name = "tranquil-comms" 6134 + version = "0.3.0" 6135 dependencies = [ 6136 "async-trait", 6137 "base64 0.22.1", ··· 6146 6147 [[package]] 6148 name = "tranquil-config" 6149 + version = "0.3.0" 6150 dependencies = [ 6151 "confique", 6152 "serde", ··· 6154 6155 [[package]] 6156 name = "tranquil-crypto" 6157 + version = "0.3.0" 6158 dependencies = [ 6159 "aes-gcm", 6160 "base64 0.22.1", ··· 6170 6171 [[package]] 6172 name = "tranquil-db" 6173 + version = "0.3.0" 6174 dependencies = [ 6175 "async-trait", 6176 "chrono", ··· 6187 6188 [[package]] 6189 name = "tranquil-db-traits" 6190 + version = "0.3.0" 6191 dependencies = [ 6192 "async-trait", 6193 "base64 0.22.1", ··· 6203 6204 [[package]] 6205 name = "tranquil-infra" 6206 + version = "0.3.0" 6207 dependencies = [ 6208 "async-trait", 6209 "bytes", ··· 6214 6215 [[package]] 6216 name = "tranquil-oauth" 6217 + version = "0.3.0" 6218 dependencies = [ 6219 "anyhow", 6220 "axum", ··· 6237 6238 [[package]] 6239 name = "tranquil-pds" 6240 + version = "0.3.0" 6241 dependencies = [ 6242 "aes-gcm", 6243 "anyhow", ··· 6324 6325 [[package]] 6326 name = "tranquil-repo" 6327 + version = "0.3.0" 6328 dependencies = [ 6329 "bytes", 6330 "cid", ··· 6336 6337 [[package]] 6338 name = "tranquil-ripple" 6339 + version = "0.3.0" 6340 dependencies = [ 6341 "async-trait", 6342 "backon", ··· 6361 6362 [[package]] 6363 name = "tranquil-scopes" 6364 + version = "0.3.0" 6365 dependencies = [ 6366 "axum", 6367 "futures", ··· 6377 6378 [[package]] 6379 name = "tranquil-storage" 6380 + version = "0.3.0" 6381 dependencies = [ 6382 "async-trait", 6383 "aws-config", ··· 6394 6395 [[package]] 6396 name = "tranquil-types" 6397 + version = "0.3.0" 6398 dependencies = [ 6399 "chrono", 6400 "cid",
+1 -1
Cargo.toml
··· 19 ] 20 21 [workspace.package] 22 - version = "0.2.1" 23 edition = "2024" 24 license = "AGPL-3.0-or-later" 25
··· 19 ] 20 21 [workspace.package] 22 + version = "0.3.0" 23 edition = "2024" 24 license = "AGPL-3.0-or-later" 25
-128
TODO.md
··· 1 - # Lewis' Big Boy TODO list 2 - 3 - ## Active development 4 - 5 - ### Storage backend abstraction 6 - Make storage layers swappable via traits. 7 - 8 - sqlite database backend 9 - - [ ] abstract db layer behind trait (queries, transactions, migrations) 10 - - [ ] sqlite implementation matching postgres behavior 11 - - [ ] handle sqlite's single-writer limitation (connection pooling strategy) 12 - - [ ] migrations system that works for both 13 - - [ ] testing: run full test suite against both backends 14 - - [ ] config option to choose backend (postgres vs sqlite) 15 - - [ ] document tradeoffs (sqlite for single-user/small, postgres for multi-user/scale) 16 - 17 - - [ ] skip sqlite and just straight-up do our own db?! 18 - 19 - ### Plugin system 20 - WASM component model plugins. Compile to wasm32-wasip2, sandboxed via wasmtime, capability-gated. Based on zed's extensions. 21 - 22 - WIT interface 23 - - [ ] record hooks before/after create, update, delete 24 - - [ ] blob hooks before/after upload, validate 25 - - [ ] xrpc hooks before/after (middleware), custom endpoint handler 26 - - [ ] firehose hook on_commit 27 - - [ ] host imports http client, kv store, logging, read records 28 - 29 - wasmtime host 30 - - [ ] engine with epoch interruption (kill runaway plugins) 31 - - [ ] plugin manifest (plugin.toml): id, version, capabilities, hooks 32 - - [ ] capability enforcement at runtime 33 - - [ ] plugin loader, lifecycle (enable/disable/reload) 34 - - [ ] resource limits (memory, time) 35 - - [ ] per-plugin fs sandbox 36 - 37 - capabilities 38 - - [ ] http:fetch with domain allowlist 39 - - [ ] kv:read, kv:write 40 - - [ ] record:read, blob:read 41 - - [ ] xrpc:register 42 - - [ ] firehose:subscribe 43 - 44 - pds-plugin-api (rust), MVP for plugin system 45 - - [ ] plugin trait with default impls 46 - - [ ] register_plugin! macro 47 - - [ ] typed host import wrappers 48 - - [ ] publish to crates.io 49 - - [ ] docs + example 50 - 51 - pds-plugin-api in golang, nice to have after the fact 52 - - [ ] wit-bindgen-go bindings 53 - - [ ] go wrappers 54 - - [ ] tinygo build instructions 55 - - [ ] example 56 - 57 - @pds/plugin-api in typescript, nice to have after the fact 58 - - [ ] jco/componentize-js bindings 59 - - [ ] typeScript types 60 - - [ ] build tooling 61 - - [ ] example 62 - 63 - example plugins 64 - - [ ] content filter 65 - - [ ] webhook notifier 66 - - [ ] objsto backup mirror 67 - - [ ] custom lexicon handler 68 - - [ ] better audit logger 69 - 70 - ### Misc 71 - 72 - cross-pds delegation 73 - when a client (eg. tangled.org) tries to log into a delegated account: 74 - - [ ] client starts oauth flow to delegated account's pds 75 - - [ ] delegated pds sees account is externally controlled, launches oauth to controller's pds (delegated pds acts as oauth client) 76 - - [ ] controller authenticates at their own pds 77 - - [ ] delegated pds verifies controller perms and scope from its local delegation grants 78 - - [ ] delegated pds issues session to client within the intersection of controller's granted scope and client's requested scope 79 - 80 - per-request "act as" 81 - - [ ] authed as user X, perform action as delegated user Y in single request 82 - - [ ] approach decision 83 - - [ ] option 1: `X-Act-As` header with target did, server verifies delegation grant 84 - - [ ] option 2: token exchange (RFC 8693) for short-lived delegated token 85 - - [ ] option 3 (lewis fav): extend existing `act` claim to support on-demand minting 86 - - [ ] something else? 87 - 88 - ### Private/encrypted data 89 - Records only authorized parties can see and decrypt. 90 - 91 - research 92 - - [ ] survey atproto discourse on private data 93 - - [ ] document bluesky team's likely approach. wait.. are they even gonna do this? whatever 94 - - [ ] look at matrix/signal for federated e2ee patterns 95 - 96 - key management 97 - - [ ] db schema for encryption keys (user_keys, key_grants, key_rotations) 98 - - [ ] per-user encryption keypair generation (separate from signing keys) 99 - - [ ] key derivation scheme (per-collection? per-record? both?) 100 - - [ ] key storage (encrypted at rest, hsm option?) 101 - - [ ] rotation and revocation flow 102 - 103 - storage layer 104 - - [ ] encrypted record format (encrypted cbor blob + metadata) 105 - - [ ] collection-level vs per-record encryption flag 106 - - [ ] how encrypted records appear in mst (hash of ciphertext? separate tree?) 107 - - [ ] blob encryption (same keys? separate?) 108 - 109 - api surface 110 - - [ ] xrpc getPublicKey, grantAccess, revokeAccess, listGrants 111 - - [ ] xrpc getEncryptedRecord (ciphertext for client-side decrypt) 112 - - [ ] or transparent server-side decrypt if requester has grant? 113 - - [ ] lexicon for key grant records 114 - 115 - sync/federation 116 - - [ ] how encrypted records appear on firehose (ciphertext? omitted? placeholder?) 117 - - [ ] pds-to-pds key exchange protocol 118 - - [ ] appview behavior (can't index without grants) 119 - - [ ] relay behavior with encrypted commits 120 - 121 - client integration 122 - - [ ] client-side encryption (pds never sees plaintext) vs server-side with trust 123 - - [ ] key backup/recovery (lose key = lose data) 124 - 125 - plugin hooks (once core exists) 126 - - [ ] on_access_grant_request for custom authorization 127 - - [ ] on_key_rotation to notify interested parties 128 -
···
+6 -2
crates/tranquil-config/src/lib.rs
··· 473 /// Returns the available user domains, falling back to `[hostname_without_port]`. 474 pub fn available_user_domain_list(&self) -> Vec<String> { 475 self.available_user_domains 476 - .clone() 477 .unwrap_or_else(|| vec![self.hostname_without_port().to_string()]) 478 } 479 480 /// Returns the user handle domains, falling back to `[hostname_without_port]`. 481 pub fn user_handle_domain_list(&self) -> Vec<String> { 482 self.user_handle_domains 483 - .clone() 484 .unwrap_or_else(|| vec![self.hostname_without_port().to_string()]) 485 } 486 }
··· 473 /// Returns the available user domains, falling back to `[hostname_without_port]`. 474 pub fn available_user_domain_list(&self) -> Vec<String> { 475 self.available_user_domains 476 + .as_deref() 477 + .filter(|v| !v.is_empty()) 478 + .map(|v| v.to_vec()) 479 .unwrap_or_else(|| vec![self.hostname_without_port().to_string()]) 480 } 481 482 /// Returns the user handle domains, falling back to `[hostname_without_port]`. 483 pub fn user_handle_domain_list(&self) -> Vec<String> { 484 self.user_handle_domains 485 + .as_deref() 486 + .filter(|v| !v.is_empty()) 487 + .map(|v| v.to_vec()) 488 .unwrap_or_else(|| vec![self.hostname_without_port().to_string()]) 489 } 490 }
+2 -2
crates/tranquil-pds/src/api/admin/account/update.rs
··· 69 { 70 return Err(ApiError::InvalidHandle(None)); 71 } 72 - let hostname_for_handles = tranquil_config::get().server.hostname_without_port(); 73 let handle = if !input_handle.contains('.') { 74 - format!("{}.{}", input_handle, hostname_for_handles) 75 } else { 76 input_handle.to_string() 77 };
··· 69 { 70 return Err(ApiError::InvalidHandle(None)); 71 } 72 + let available_domains = tranquil_config::get().server.available_user_domain_list(); 73 let handle = if !input_handle.contains('.') { 74 + format!("{}.{}", input_handle, &available_domains[0]) 75 } else { 76 input_handle.to_string() 77 };
+13 -11
crates/tranquil-pds/src/api/delegation.rs
··· 435 }; 436 437 let hostname = &tranquil_config::get().server.hostname; 438 - let hostname_for_handles = tranquil_config::get().server.hostname_without_port(); 439 - let pds_suffix = format!(".{}", hostname_for_handles); 440 - 441 - let handle = if !input.handle.contains('.') || input.handle.ends_with(&pds_suffix) { 442 - let handle_to_validate = if input.handle.ends_with(&pds_suffix) { 443 - input 444 .handle 445 - .strip_suffix(&pds_suffix) 446 - .unwrap_or(&input.handle) 447 - } else { 448 - &input.handle 449 }; 450 match crate::api::validation::validate_short_handle(handle_to_validate) { 451 - Ok(h) => format!("{}.{}", h, hostname_for_handles), 452 Err(e) => { 453 return Ok(ApiError::InvalidRequest(e.to_string()).into_response()); 454 }
··· 435 }; 436 437 let hostname = &tranquil_config::get().server.hostname; 438 + let available_domains = tranquil_config::get().server.available_user_domain_list(); 439 + let matched_domain = available_domains 440 + .iter() 441 + .filter(|d| input.handle.ends_with(&format!(".{}", d))) 442 + .max_by_key(|d| d.len()); 443 + 444 + let handle = if !input.handle.contains('.') || matched_domain.is_some() { 445 + let handle_to_validate = match matched_domain { 446 + Some(domain) => input 447 .handle 448 + .strip_suffix(&format!(".{}", domain)) 449 + .unwrap_or(&input.handle), 450 + None => &input.handle, 451 }; 452 match crate::api::validation::validate_short_handle(handle_to_validate) { 453 + Ok(h) => format!("{}.{}", h, matched_domain.unwrap_or(&available_domains[0])), 454 Err(e) => { 455 return Ok(ApiError::InvalidRequest(e.to_string()).into_response()); 456 }
+17 -18
crates/tranquil-pds/src/api/identity/account.rs
··· 140 } 141 } 142 143 - let hostname_for_validation = tranquil_config::get().server.hostname_without_port(); 144 - let pds_suffix = format!(".{}", hostname_for_validation); 145 146 let validated_short_handle = if !input.handle.contains('.') 147 - || input.handle.ends_with(&pds_suffix) 148 { 149 - let handle_to_validate = if input.handle.ends_with(&pds_suffix) { 150 - input 151 .handle 152 - .strip_suffix(&pds_suffix) 153 - .unwrap_or(&input.handle) 154 - } else { 155 - &input.handle 156 }; 157 match crate::api::validation::validate_short_handle(handle_to_validate) { 158 Ok(h) => h, ··· 233 }) 234 }; 235 let hostname = &tranquil_config::get().server.hostname; 236 - let hostname_for_handles = tranquil_config::get().server.hostname_without_port(); 237 let pds_endpoint = format!("https://{}", hostname); 238 - let suffix = format!(".{}", hostname_for_handles); 239 - let handle = if input.handle.ends_with(&suffix) { 240 - format!("{}.{}", validated_short_handle, hostname_for_handles) 241 - } else if input.handle.contains('.') { 242 - validated_short_handle.clone() 243 - } else { 244 - format!("{}.{}", validated_short_handle, hostname_for_handles) 245 }; 246 let (secret_key_bytes, reserved_key_id): (Vec<u8>, Option<uuid::Uuid>) = 247 if let Some(signing_key_did) = &input.signing_key { ··· 276 if !crate::api::server::meta::is_self_hosted_did_web_enabled() { 277 return ApiError::SelfHostedDidWebDisabled.into_response(); 278 } 279 - let subdomain_host = format!("{}.{}", input.handle, hostname_for_handles); 280 let encoded_subdomain = subdomain_host.replace(':', "%3A"); 281 let self_hosted_did = format!("did:web:{}", encoded_subdomain); 282 info!(did = %self_hosted_did, "Creating self-hosted did:web account (subdomain)");
··· 140 } 141 } 142 143 + let available_domains = tranquil_config::get().server.available_user_domain_list(); 144 + let matched_domain = available_domains 145 + .iter() 146 + .filter(|d| input.handle.ends_with(&format!(".{}", d))) 147 + .max_by_key(|d| d.len()); 148 149 let validated_short_handle = if !input.handle.contains('.') 150 + || matched_domain.is_some() 151 { 152 + let handle_to_validate = match matched_domain { 153 + Some(domain) => input 154 .handle 155 + .strip_suffix(&format!(".{}", domain)) 156 + .unwrap_or(&input.handle), 157 + None => &input.handle, 158 }; 159 match crate::api::validation::validate_short_handle(handle_to_validate) { 160 Ok(h) => h, ··· 235 }) 236 }; 237 let hostname = &tranquil_config::get().server.hostname; 238 let pds_endpoint = format!("https://{}", hostname); 239 + let handle = match matched_domain { 240 + Some(domain) => format!("{}.{}", validated_short_handle, domain), 241 + None if input.handle.contains('.') => validated_short_handle.clone(), 242 + None => format!("{}.{}", validated_short_handle, &available_domains[0]), 243 }; 244 let (secret_key_bytes, reserved_key_id): (Vec<u8>, Option<uuid::Uuid>) = 245 if let Some(signing_key_did) = &input.signing_key { ··· 274 if !crate::api::server::meta::is_self_hosted_did_web_enabled() { 275 return ApiError::SelfHostedDidWebDisabled.into_response(); 276 } 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); 281 info!(did = %self_hosted_did, "Creating self-hosted did:web account (subdomain)");
+18 -14
crates/tranquil-pds/src/api/identity/did.rs
··· 675 "Inappropriate language in handle".into(), 676 ))); 677 } 678 - let hostname_for_handles = tranquil_config::get().server.hostname_without_port(); 679 - let suffix = format!(".{}", hostname_for_handles); 680 - let is_service_domain = 681 - crate::handle::is_service_domain_handle(&new_handle, hostname_for_handles); 682 - let handle = if is_service_domain && new_handle != hostname_for_handles { 683 - let short_part = if new_handle.ends_with(&suffix) { 684 - new_handle.strip_suffix(&suffix).unwrap_or(&new_handle) 685 - } else { 686 - &new_handle 687 - }; 688 - let full_handle = if new_handle.ends_with(&suffix) { 689 - new_handle.clone() 690 - } else { 691 - format!("{}.{}", new_handle, hostname_for_handles) 692 }; 693 if full_handle == current_handle { 694 let handle_typed: Handle = match full_handle.parse() {
··· 675 "Inappropriate language in handle".into(), 676 ))); 677 } 678 + let handle_domains = tranquil_config::get().server.user_handle_domain_list(); 679 + let matched_handle_domain = handle_domains 680 + .iter() 681 + .filter(|d| new_handle.ends_with(&format!(".{}", d))) 682 + .max_by_key(|d| d.len()) 683 + .cloned(); 684 + let is_domain_itself = handle_domains.iter().any(|d| d == &new_handle); 685 + let handle = if (!new_handle.contains('.') || matched_handle_domain.is_some()) && !is_domain_itself { 686 + let (short_part, full_handle) = match &matched_handle_domain { 687 + Some(domain) => { 688 + let suffix = format!(".{}", domain); 689 + let short = new_handle.strip_suffix(&suffix).unwrap_or(&new_handle); 690 + (short.to_string(), new_handle.clone()) 691 + } 692 + None => { 693 + let primary = &handle_domains[0]; 694 + (new_handle.clone(), format!("{}.{}", new_handle, primary)) 695 + } 696 }; 697 if full_handle == current_handle { 698 let handle_typed: Handle = match full_handle.parse() {
+14 -11
crates/tranquil-pds/src/api/server/passkey_account.rs
··· 113 .unwrap_or(false); 114 115 let hostname = &tranquil_config::get().server.hostname; 116 - let hostname_for_handles = tranquil_config::get().server.hostname_without_port(); 117 - let pds_suffix = format!(".{}", hostname_for_handles); 118 119 - let handle = if !input.handle.contains('.') || input.handle.ends_with(&pds_suffix) { 120 - let handle_to_validate = if input.handle.ends_with(&pds_suffix) { 121 - input 122 .handle 123 - .strip_suffix(&pds_suffix) 124 - .unwrap_or(&input.handle) 125 - } else { 126 - &input.handle 127 }; 128 match crate::api::validation::validate_short_handle(handle_to_validate) { 129 - Ok(h) => format!("{}.{}", h, hostname_for_handles), 130 Err(_) => { 131 return ApiError::InvalidHandle(None).into_response(); 132 } ··· 244 245 let did = match did_type { 246 "web" => { 247 - let subdomain_host = format!("{}.{}", input.handle, hostname_for_handles); 248 let encoded_subdomain = subdomain_host.replace(':', "%3A"); 249 let self_hosted_did = format!("did:web:{}", encoded_subdomain); 250 info!(did = %self_hosted_did, "Creating self-hosted did:web passkey account");
··· 113 .unwrap_or(false); 114 115 let hostname = &tranquil_config::get().server.hostname; 116 + let available_domains = tranquil_config::get().server.available_user_domain_list(); 117 + let matched_domain = available_domains 118 + .iter() 119 + .filter(|d| input.handle.ends_with(&format!(".{}", d))) 120 + .max_by_key(|d| d.len()); 121 122 + let handle = if !input.handle.contains('.') || matched_domain.is_some() { 123 + let handle_to_validate = match matched_domain { 124 + Some(domain) => input 125 .handle 126 + .strip_suffix(&format!(".{}", domain)) 127 + .unwrap_or(&input.handle), 128 + None => &input.handle, 129 }; 130 match crate::api::validation::validate_short_handle(handle_to_validate) { 131 + Ok(h) => format!("{}.{}", h, matched_domain.unwrap_or(&available_domains[0])), 132 Err(_) => { 133 return ApiError::InvalidHandle(None).into_response(); 134 } ··· 246 247 let did = match did_type { 248 "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 info!(did = %self_hosted_did, "Creating self-hosted did:web passkey account");
+6 -5
crates/tranquil-pds/src/sso/endpoints.rs
··· 772 } 773 }; 774 775 - let hostname_for_handles = tranquil_config::get().server.hostname_without_port(); 776 - let full_handle = format!("{}.{}", validated, hostname_for_handles); 777 let handle_typed: crate::types::Handle = match full_handle.parse() { 778 Ok(h) => h, 779 Err(_) => return Err(ApiError::InvalidHandle(None)), ··· 856 .ok_or(ApiError::SsoSessionExpired)?; 857 858 let hostname = &tranquil_config::get().server.hostname; 859 - let hostname_for_handles = tranquil_config::get().server.hostname_without_port(); 860 861 let handle = match crate::api::validation::validate_short_handle(&input.handle) { 862 - Ok(h) => format!("{}.{}", h, hostname_for_handles), 863 Err(_) => return Err(ApiError::InvalidHandle(None)), 864 }; 865 ··· 981 982 let did = match did_type { 983 "web" => { 984 - let subdomain_host = format!("{}.{}", input.handle, hostname_for_handles); 985 let encoded_subdomain = subdomain_host.replace(':', "%3A"); 986 let self_hosted_did = format!("did:web:{}", encoded_subdomain); 987 tracing::info!(did = %self_hosted_did, "Creating self-hosted did:web SSO account");
··· 772 } 773 }; 774 775 + let available_domains = tranquil_config::get().server.available_user_domain_list(); 776 + let full_handle = format!("{}.{}", validated, &available_domains[0]); 777 let handle_typed: crate::types::Handle = match full_handle.parse() { 778 Ok(h) => h, 779 Err(_) => return Err(ApiError::InvalidHandle(None)), ··· 856 .ok_or(ApiError::SsoSessionExpired)?; 857 858 let hostname = &tranquil_config::get().server.hostname; 859 + let available_domains = tranquil_config::get().server.available_user_domain_list(); 860 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)), 864 }; 865 ··· 981 982 let did = match did_type { 983 "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); 988 tracing::info!(did = %self_hosted_did, "Creating self-hosted did:web SSO account");
+278
crates/tranquil-pds/tests/handle_domains.rs
···
··· 1 + mod common; 2 + use common::*; 3 + use reqwest::StatusCode; 4 + use reqwest::header; 5 + use serde_json::{Value, json}; 6 + 7 + const HANDLE_DOMAIN: &str = "handles.test"; 8 + 9 + fn set_handle_domain() { 10 + unsafe { 11 + std::env::set_var("AVAILABLE_USER_DOMAINS", HANDLE_DOMAIN); 12 + std::env::set_var("PDS_USER_HANDLE_DOMAINS", HANDLE_DOMAIN); 13 + } 14 + } 15 + 16 + async fn base_url_with_domain() -> &'static str { 17 + set_handle_domain(); 18 + base_url().await 19 + } 20 + 21 + #[tokio::test] 22 + async fn describe_server_returns_configured_domain() { 23 + let client = client(); 24 + let base = base_url_with_domain().await; 25 + let res = client 26 + .get(format!( 27 + "{}/xrpc/com.atproto.server.describeServer", 28 + base 29 + )) 30 + .send() 31 + .await 32 + .expect("describeServer request failed"); 33 + assert_eq!(res.status(), StatusCode::OK); 34 + let body: Value = res.json().await.expect("Invalid JSON"); 35 + let domains = body["availableUserDomains"] 36 + .as_array() 37 + .expect("No availableUserDomains"); 38 + assert!( 39 + domains.iter().any(|d| d.as_str() == Some(HANDLE_DOMAIN)), 40 + "availableUserDomains should contain {}, got {:?}", 41 + HANDLE_DOMAIN, 42 + domains 43 + ); 44 + } 45 + 46 + #[tokio::test] 47 + async fn short_handle_uses_configured_domain() { 48 + let client = client(); 49 + let base = base_url_with_domain().await; 50 + let short_handle = format!("hd{}", &uuid::Uuid::new_v4().simple().to_string()[..12]); 51 + let payload = json!({ 52 + "handle": short_handle, 53 + "email": format!("{}@example.com", short_handle), 54 + "password": "Testpass123!" 55 + }); 56 + let res = client 57 + .post(format!( 58 + "{}/xrpc/com.atproto.server.createAccount", 59 + base 60 + )) 61 + .json(&payload) 62 + .send() 63 + .await 64 + .expect("createAccount request failed"); 65 + assert_eq!(res.status(), StatusCode::OK); 66 + let body: Value = res.json().await.expect("Invalid JSON"); 67 + let handle = body["handle"].as_str().expect("No handle in response"); 68 + let expected_suffix = format!(".{}", HANDLE_DOMAIN); 69 + assert!( 70 + handle.ends_with(&expected_suffix), 71 + "Handle '{}' should end with '{}' (not PDS hostname)", 72 + handle, 73 + expected_suffix 74 + ); 75 + assert_eq!( 76 + handle, 77 + format!("{}.{}", short_handle, HANDLE_DOMAIN), 78 + "Handle should be short_handle.configured_domain" 79 + ); 80 + } 81 + 82 + #[tokio::test] 83 + async fn full_handle_with_configured_domain_accepted() { 84 + let client = client(); 85 + let base = base_url_with_domain().await; 86 + let short_handle = format!("hd{}", &uuid::Uuid::new_v4().simple().to_string()[..12]); 87 + let full_handle = format!("{}.{}", short_handle, HANDLE_DOMAIN); 88 + let payload = json!({ 89 + "handle": full_handle, 90 + "email": format!("{}@example.com", short_handle), 91 + "password": "Testpass123!" 92 + }); 93 + let res = client 94 + .post(format!( 95 + "{}/xrpc/com.atproto.server.createAccount", 96 + base 97 + )) 98 + .json(&payload) 99 + .send() 100 + .await 101 + .expect("createAccount request failed"); 102 + assert_eq!(res.status(), StatusCode::OK); 103 + let body: Value = res.json().await.expect("Invalid JSON"); 104 + let handle = body["handle"].as_str().expect("No handle in response"); 105 + assert_eq!( 106 + handle, full_handle, 107 + "Handle should match the full handle submitted" 108 + ); 109 + } 110 + 111 + #[tokio::test] 112 + async fn handle_with_pds_hostname_treated_as_custom() { 113 + let client = client(); 114 + let base = base_url_with_domain().await; 115 + let pds_hostname = pds_hostname(); 116 + let pds_host_no_port = pds_hostname.split(':').next().unwrap_or(&pds_hostname); 117 + let short_handle = format!("hd{}", &uuid::Uuid::new_v4().simple().to_string()[..12]); 118 + let handle_with_hostname = format!("{}.{}", short_handle, pds_host_no_port); 119 + let payload = json!({ 120 + "handle": handle_with_hostname, 121 + "email": format!("{}@example.com", short_handle), 122 + "password": "Testpass123!" 123 + }); 124 + let res = client 125 + .post(format!( 126 + "{}/xrpc/com.atproto.server.createAccount", 127 + base 128 + )) 129 + .json(&payload) 130 + .send() 131 + .await 132 + .expect("createAccount request failed"); 133 + assert_eq!(res.status(), StatusCode::OK); 134 + let body: Value = res.json().await.expect("Invalid JSON"); 135 + let handle = body["handle"].as_str().expect("No handle in response"); 136 + assert_eq!( 137 + handle, handle_with_hostname, 138 + "Handle with non-available domain suffix should be treated as custom handle (passed through)" 139 + ); 140 + } 141 + 142 + #[tokio::test] 143 + async fn resolve_handle_works_with_configured_domain() { 144 + let client = client(); 145 + let base = base_url_with_domain().await; 146 + let short_handle = format!("hd{}", &uuid::Uuid::new_v4().simple().to_string()[..12]); 147 + let payload = json!({ 148 + "handle": short_handle, 149 + "email": format!("{}@example.com", short_handle), 150 + "password": "Testpass123!" 151 + }); 152 + let res = client 153 + .post(format!( 154 + "{}/xrpc/com.atproto.server.createAccount", 155 + base 156 + )) 157 + .json(&payload) 158 + .send() 159 + .await 160 + .expect("createAccount request failed"); 161 + assert_eq!(res.status(), StatusCode::OK); 162 + let body: Value = res.json().await.expect("Invalid JSON"); 163 + let did = body["did"].as_str().expect("No DID").to_string(); 164 + let full_handle = body["handle"].as_str().expect("No handle").to_string(); 165 + 166 + let res = client 167 + .get(format!( 168 + "{}/xrpc/com.atproto.identity.resolveHandle", 169 + base 170 + )) 171 + .query(&[("handle", full_handle.as_str())]) 172 + .send() 173 + .await 174 + .expect("resolveHandle request failed"); 175 + assert_eq!(res.status(), StatusCode::OK); 176 + let body: Value = res.json().await.expect("Invalid JSON"); 177 + assert_eq!(body["did"], did); 178 + } 179 + 180 + #[tokio::test] 181 + async fn admin_update_handle_uses_configured_domain() { 182 + let client = client(); 183 + let base = base_url_with_domain().await; 184 + let (admin_jwt, _) = create_admin_account_and_login(&client).await; 185 + let (_, target_did) = create_account_and_login(&client).await; 186 + 187 + let new_short = format!("hd{}", &uuid::Uuid::new_v4().simple().to_string()[..12]); 188 + let res = client 189 + .post(format!( 190 + "{}/xrpc/com.atproto.admin.updateAccountHandle", 191 + base 192 + )) 193 + .bearer_auth(&admin_jwt) 194 + .json(&json!({ 195 + "did": target_did, 196 + "handle": new_short, 197 + })) 198 + .send() 199 + .await 200 + .expect("admin updateAccountHandle request failed"); 201 + assert_eq!(res.status(), StatusCode::OK); 202 + 203 + let res = client 204 + .get(format!( 205 + "{}/xrpc/com.atproto.identity.resolveHandle", 206 + base 207 + )) 208 + .query(&[("handle", format!("{}.{}", new_short, HANDLE_DOMAIN))]) 209 + .send() 210 + .await 211 + .expect("resolveHandle request failed"); 212 + assert_eq!(res.status(), StatusCode::OK); 213 + let body: Value = res.json().await.expect("Invalid JSON"); 214 + assert_eq!( 215 + body["did"], target_did, 216 + "Admin bare handle update should use configured domain, not PDS hostname" 217 + ); 218 + } 219 + 220 + #[tokio::test] 221 + async fn update_handle_bare_uses_configured_domain() { 222 + let client = client(); 223 + let base = base_url_with_domain().await; 224 + let short_handle = format!("hd{}", &uuid::Uuid::new_v4().simple().to_string()[..12]); 225 + let payload = json!({ 226 + "handle": short_handle, 227 + "email": format!("{}@example.com", short_handle), 228 + "password": "Testpass123!" 229 + }); 230 + let res = client 231 + .post(format!( 232 + "{}/xrpc/com.atproto.server.createAccount", 233 + base 234 + )) 235 + .json(&payload) 236 + .send() 237 + .await 238 + .expect("createAccount request failed"); 239 + assert_eq!(res.status(), StatusCode::OK); 240 + let body: Value = res.json().await.expect("Invalid JSON"); 241 + let did = body["did"].as_str().expect("No DID").to_string(); 242 + let access_jwt = verify_new_account(&client, &did).await; 243 + 244 + let new_short = format!("hd{}", &uuid::Uuid::new_v4().simple().to_string()[..12]); 245 + let res = client 246 + .post(format!( 247 + "{}/xrpc/com.atproto.identity.updateHandle", 248 + base 249 + )) 250 + .bearer_auth(&access_jwt) 251 + .header(header::CONTENT_TYPE, "application/json") 252 + .json(&json!({ "handle": new_short })) 253 + .send() 254 + .await 255 + .expect("updateHandle request failed"); 256 + assert_eq!( 257 + res.status(), 258 + StatusCode::OK, 259 + "updateHandle failed: {:?}", 260 + res.text().await 261 + ); 262 + 263 + let res = client 264 + .get(format!( 265 + "{}/xrpc/com.atproto.identity.resolveHandle", 266 + base 267 + )) 268 + .query(&[("handle", format!("{}.{}", new_short, HANDLE_DOMAIN))]) 269 + .send() 270 + .await 271 + .expect("resolveHandle request failed"); 272 + assert_eq!(res.status(), StatusCode::OK); 273 + let body: Value = res.json().await.expect("Invalid JSON"); 274 + assert_eq!( 275 + body["did"], did, 276 + "updateHandle with bare handle should use configured domain, not PDS hostname" 277 + ); 278 + }
+3 -10
frontend/public/homepage.html
··· 428 429 <footer class="site-footer"> 430 <span>Made by people who don't take themselves too seriously</span> 431 - <span>Open Source: issues & PRs welcome</span> 432 </footer> 433 </div> 434 ··· 485 }) 486 .then(function (info) { 487 var hostnameEl = document.getElementById("hostname"); 488 - if ( 489 - info.availableUserDomains && 490 - info.availableUserDomains.length 491 - ) { 492 - hostnameEl.textContent = info.availableUserDomains[0]; 493 - } else { 494 - hostnameEl.textContent = "Tranquil PDS"; 495 - } 496 hostnameEl.classList.remove("placeholder"); 497 if (info.version) { 498 document.getElementById("version").textContent = ··· 501 }) 502 .catch(function () { 503 var hostnameEl = document.getElementById("hostname"); 504 - hostnameEl.textContent = "Tranquil PDS"; 505 hostnameEl.classList.remove("placeholder"); 506 }); 507
··· 428 429 <footer class="site-footer"> 430 <span>Made by people who don't take themselves too seriously</span> 431 + <span>Open source & open hearts</span> 432 </footer> 433 </div> 434 ··· 485 }) 486 .then(function (info) { 487 var hostnameEl = document.getElementById("hostname"); 488 + hostnameEl.textContent = window.location.hostname; 489 hostnameEl.classList.remove("placeholder"); 490 if (info.version) { 491 document.getElementById("version").textContent = ··· 494 }) 495 .catch(function () { 496 var hostnameEl = document.getElementById("hostname"); 497 + hostnameEl.textContent = window.location.hostname; 498 hostnameEl.classList.remove("placeholder"); 499 }); 500
+71
frontend/src/components/HandleInput.svelte
···
··· 1 + <script lang="ts"> 2 + interface Props { 3 + value: string 4 + domains: string[] 5 + selectedDomain: string 6 + disabled?: boolean 7 + placeholder?: string 8 + id?: string 9 + autocomplete?: string 10 + onInput: (value: string) => void 11 + onDomainChange: (domain: string) => void 12 + } 13 + 14 + let { 15 + value, 16 + domains, 17 + selectedDomain, 18 + disabled = false, 19 + placeholder = 'username', 20 + id = 'handle', 21 + autocomplete = 'off', 22 + onInput, 23 + onDomainChange, 24 + }: Props = $props() 25 + 26 + const showDomainSelect = $derived(domains.length > 1 && !value.includes('.')) 27 + </script> 28 + 29 + <div class="handle-input-group"> 30 + <input 31 + {id} 32 + type="text" 33 + value={value} 34 + {placeholder} 35 + {disabled} 36 + autocomplete={autocomplete} 37 + required 38 + oninput={(e) => onInput((e.target as HTMLInputElement).value)} 39 + /> 40 + {#if showDomainSelect} 41 + <select value={selectedDomain} onchange={(e) => onDomainChange((e.target as HTMLSelectElement).value)}> 42 + {#each domains as domain} 43 + <option value={domain}>.{domain}</option> 44 + {/each} 45 + </select> 46 + {:else if domains.length === 1 && !value.includes('.')} 47 + <span class="domain-suffix">.{domains[0]}</span> 48 + {/if} 49 + </div> 50 + 51 + <style> 52 + .handle-input-group { 53 + display: flex; 54 + gap: var(--space-2); 55 + align-items: center; 56 + } 57 + 58 + .handle-input-group input { 59 + flex: 1; 60 + } 61 + 62 + .handle-input-group select { 63 + width: auto; 64 + } 65 + 66 + .domain-suffix { 67 + color: var(--text-secondary); 68 + font-size: var(--text-sm); 69 + white-space: nowrap; 70 + } 71 + </style>
+18 -38
frontend/src/components/dashboard/SettingsContent.svelte
··· 9 import { getSessionEmail } from '../../lib/types/api' 10 import { formatDate } from '../../lib/date' 11 import { navigate, routes } from '../../lib/router.svelte' 12 13 interface Props { 14 session: Session ··· 17 let { session }: Props = $props() 18 19 const supportedLocales = getSupportedLocales() 20 - let pdsHostname = $state<string | null>(null) 21 22 onMount(() => { 23 const init = async () => { 24 try { 25 const info = await api.describeServer() 26 if (info.availableUserDomains?.length) { 27 - pdsHostname = info.availableUserDomains[0] 28 } 29 } catch {} 30 loadBackups() ··· 150 if (!newHandle) return 151 handleLoading = true 152 try { 153 - const fullHandle = showBYOHandle ? newHandle : `${newHandle}.${pdsHostname}` 154 await api.updateHandle(session.accessJwt, unsafeAsHandle(fullHandle)) 155 await refreshSession() 156 toast.success($_('settings.messages.handleUpdated')) ··· 481 <form onsubmit={handleUpdateHandle}> 482 <div class="field"> 483 <label for="new-handle">{$_('settings.newHandle')}</label> 484 - <div class="handle-input-wrapper"> 485 - <input id="new-handle" type="text" bind:value={newHandle} placeholder={$_('settings.newHandlePlaceholder')} disabled={handleLoading} required /> 486 - <span class="handle-suffix">.{pdsHostname ?? '...'}</span> 487 - </div> 488 </div> 489 - <button type="submit" disabled={handleLoading || !newHandle || !pdsHostname}> 490 {handleLoading ? $_('settings.updating') : $_('settings.changeHandleButton')} 491 </button> 492 </form> ··· 689 color: var(--text-inverse); 690 } 691 692 - .handle-input-wrapper { 693 - display: flex; 694 - align-items: center; 695 - background: var(--bg-input); 696 - border: 1px solid var(--border-color); 697 - border-radius: var(--radius-md); 698 - overflow: hidden; 699 - } 700 - 701 - .handle-input-wrapper input { 702 - flex: 1; 703 - border: none; 704 - border-radius: 0; 705 - background: transparent; 706 - } 707 - 708 - .handle-input-wrapper input:focus { 709 - outline: none; 710 - box-shadow: none; 711 - } 712 - 713 - .handle-suffix { 714 - padding: 0 var(--space-3); 715 - color: var(--text-secondary); 716 - font-size: var(--text-sm); 717 - white-space: nowrap; 718 - border-left: 1px solid var(--border-color); 719 - background: var(--bg-card); 720 - } 721 - 722 .loading, 723 .empty { 724 color: var(--text-secondary);
··· 9 import { getSessionEmail } from '../../lib/types/api' 10 import { formatDate } from '../../lib/date' 11 import { navigate, routes } from '../../lib/router.svelte' 12 + import HandleInput from '../HandleInput.svelte' 13 14 interface Props { 15 session: Session ··· 18 let { session }: Props = $props() 19 20 const supportedLocales = getSupportedLocales() 21 + let availableDomains = $state<string[]>([]) 22 + let selectedDomain = $state('') 23 + let pdsHostname = $derived(selectedDomain || null) 24 25 onMount(() => { 26 const init = async () => { 27 try { 28 const info = await api.describeServer() 29 if (info.availableUserDomains?.length) { 30 + availableDomains = info.availableUserDomains 31 + selectedDomain = info.availableUserDomains[0] 32 } 33 } catch {} 34 loadBackups() ··· 154 if (!newHandle) return 155 handleLoading = true 156 try { 157 + const fullHandle = showBYOHandle ? newHandle : `${newHandle}.${selectedDomain}` 158 await api.updateHandle(session.accessJwt, unsafeAsHandle(fullHandle)) 159 await refreshSession() 160 toast.success($_('settings.messages.handleUpdated')) ··· 485 <form onsubmit={handleUpdateHandle}> 486 <div class="field"> 487 <label for="new-handle">{$_('settings.newHandle')}</label> 488 + <HandleInput 489 + id="new-handle" 490 + value={newHandle} 491 + domains={availableDomains} 492 + {selectedDomain} 493 + placeholder={$_('settings.newHandlePlaceholder')} 494 + disabled={handleLoading} 495 + onInput={(v) => { newHandle = v }} 496 + onDomainChange={(d) => { selectedDomain = d }} 497 + /> 498 </div> 499 + <button type="submit" disabled={handleLoading || !newHandle || !selectedDomain}> 500 {handleLoading ? $_('settings.updating') : $_('settings.changeHandleButton')} 501 </button> 502 </form> ··· 699 color: var(--text-inverse); 700 } 701 702 .loading, 703 .empty { 704 color: var(--text-secondary);
+10 -17
frontend/src/components/migration/ChooseHandleStep.svelte
··· 1 <script lang="ts"> 2 import type { AuthMethod, HandlePreservation, ServerDescription } from '../../lib/migration/types' 3 import { _ } from '../../lib/i18n' 4 5 interface Props { 6 handleInput: string ··· 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> 191 192 {#if handleTooShort} 193 <p class="hint error">{$_('migration.inbound.chooseHandle.handleTooShort')}</p>
··· 1 <script lang="ts"> 2 import type { AuthMethod, HandlePreservation, ServerDescription } from '../../lib/migration/types' 3 import { _ } from '../../lib/i18n' 4 + import HandleInput from '../HandleInput.svelte' 5 6 interface Props { 7 handleInput: string ··· 172 {:else} 173 <div class="field"> 174 <label for="new-handle">{$_('migration.inbound.chooseHandle.newHandle')}</label> 175 + <HandleInput 176 + id="new-handle" 177 + value={handleInput} 178 + domains={serverInfo?.availableUserDomains ?? []} 179 + {selectedDomain} 180 + placeholder="username" 181 + onInput={onHandleChange} 182 + onDomainChange={onDomainChange} 183 + /> 184 185 {#if handleTooShort} 186 <p class="hint error">{$_('migration.inbound.chooseHandle.handleTooShort')}</p>
+11 -7
frontend/src/routes/OAuthRegister.svelte
··· 16 type WebAuthnCreationOptionsResponse, 17 } from '../lib/webauthn' 18 import AccountTypeSwitcher from '../components/AccountTypeSwitcher.svelte' 19 20 let serverInfo = $state<{ 21 availableUserDomains: string[] ··· 30 let flow = $state<ReturnType<typeof createRegistrationFlow> | null>(null) 31 let passkeyName = $state('') 32 let clientName = $state<string | null>(null) 33 34 function getRequestUri(): string | null { 35 const params = new URLSearchParams(window.location.search) ··· 99 const hostname = serverInfo?.availableUserDomains?.[0] || window.location.hostname 100 flow = createRegistrationFlow('passkey', hostname) 101 } 102 } catch (e) { 103 console.error('Failed to load server info:', e) 104 } finally { ··· 262 263 let fullHandle = $derived(() => { 264 if (!flow?.info.handle.trim()) return '' 265 - return `${flow.info.handle.trim()}.${flow.state.pdsHostname}` 266 }) 267 268 async function handleCancel() { ··· 342 <form onsubmit={handleInfoSubmit}> 343 <div class="field"> 344 <label for="handle">{$_('register.handle')}</label> 345 - <input 346 - id="handle" 347 - type="text" 348 - bind:value={flow.info.handle} 349 placeholder={$_('register.handlePlaceholder')} 350 disabled={flow.state.submitting} 351 - required 352 - autocomplete="off" 353 /> 354 {#if fullHandle()} 355 <p class="hint">{$_('register.handleHint', { values: { handle: fullHandle() } })}</p>
··· 16 type WebAuthnCreationOptionsResponse, 17 } from '../lib/webauthn' 18 import AccountTypeSwitcher from '../components/AccountTypeSwitcher.svelte' 19 + import HandleInput from '../components/HandleInput.svelte' 20 21 let serverInfo = $state<{ 22 availableUserDomains: string[] ··· 31 let flow = $state<ReturnType<typeof createRegistrationFlow> | null>(null) 32 let passkeyName = $state('') 33 let clientName = $state<string | null>(null) 34 + let selectedDomain = $state('') 35 36 function getRequestUri(): string | null { 37 const params = new URLSearchParams(window.location.search) ··· 101 const hostname = serverInfo?.availableUserDomains?.[0] || window.location.hostname 102 flow = createRegistrationFlow('passkey', hostname) 103 } 104 + selectedDomain = serverInfo?.availableUserDomains?.[0] || window.location.hostname 105 } catch (e) { 106 console.error('Failed to load server info:', e) 107 } finally { ··· 265 266 let fullHandle = $derived(() => { 267 if (!flow?.info.handle.trim()) return '' 268 + if (flow.info.handle.includes('.')) return flow.info.handle.trim() 269 + return selectedDomain ? `${flow.info.handle.trim()}.${selectedDomain}` : flow.info.handle.trim() 270 }) 271 272 async function handleCancel() { ··· 346 <form onsubmit={handleInfoSubmit}> 347 <div class="field"> 348 <label for="handle">{$_('register.handle')}</label> 349 + <HandleInput 350 + value={flow.info.handle} 351 + domains={serverInfo?.availableUserDomains ?? []} 352 + {selectedDomain} 353 placeholder={$_('register.handlePlaceholder')} 354 disabled={flow.state.submitting} 355 + onInput={(v) => { flow!.info.handle = v }} 356 + onDomainChange={(d) => { selectedDomain = d }} 357 /> 358 {#if fullHandle()} 359 <p class="hint">{$_('register.handleHint', { values: { handle: fullHandle() } })}</p>
+11 -8
frontend/src/routes/OAuthSsoRegister.svelte
··· 3 import { _ } from '../lib/i18n' 4 import { toast } from '../lib/toast.svelte' 5 import SsoIcon from '../components/SsoIcon.svelte' 6 7 interface PendingRegistration { 8 request_uri: string ··· 37 let handleAvailable = $state<boolean | null>(null) 38 let checkingHandle = $state(false) 39 let handleError = $state<string | null>(null) 40 41 let didType = $state<'plc' | 'web' | 'web-external'>('plc') 42 let externalDid = $state('') ··· 80 81 let fullHandle = $derived(() => { 82 if (!handle.trim()) return '' 83 - const domain = serverInfo?.availableUserDomains?.[0] 84 - return domain ? `${handle.trim()}.${domain}` : handle.trim() 85 }) 86 87 onMount(() => { ··· 106 telegram: available.includes('telegram'), 107 signal: available.includes('signal'), 108 } 109 } 110 } catch { 111 serverInfo = null ··· 317 <form onsubmit={handleSubmit}> 318 <div class="field"> 319 <label for="handle">{$_('sso_register.handle_label')}</label> 320 - <input 321 - id="handle" 322 - type="text" 323 - bind:value={handle} 324 placeholder={$_('register.handlePlaceholder')} 325 disabled={submitting} 326 - required 327 - autocomplete="off" 328 /> 329 {#if checkingHandle} 330 <p class="hint">{$_('common.checking')}</p>
··· 3 import { _ } from '../lib/i18n' 4 import { toast } from '../lib/toast.svelte' 5 import SsoIcon from '../components/SsoIcon.svelte' 6 + import HandleInput from '../components/HandleInput.svelte' 7 8 interface PendingRegistration { 9 request_uri: string ··· 38 let handleAvailable = $state<boolean | null>(null) 39 let checkingHandle = $state(false) 40 let handleError = $state<string | null>(null) 41 + let selectedDomain = $state('') 42 43 let didType = $state<'plc' | 'web' | 'web-external'>('plc') 44 let externalDid = $state('') ··· 82 83 let fullHandle = $derived(() => { 84 if (!handle.trim()) return '' 85 + if (handle.includes('.')) return handle.trim() 86 + return selectedDomain ? `${handle.trim()}.${selectedDomain}` : handle.trim() 87 }) 88 89 onMount(() => { ··· 108 telegram: available.includes('telegram'), 109 signal: available.includes('signal'), 110 } 111 + selectedDomain = data.availableUserDomains?.[0] || window.location.hostname 112 } 113 } catch { 114 serverInfo = null ··· 320 <form onsubmit={handleSubmit}> 321 <div class="field"> 322 <label for="handle">{$_('sso_register.handle_label')}</label> 323 + <HandleInput 324 + value={handle} 325 + domains={serverInfo?.availableUserDomains ?? []} 326 + {selectedDomain} 327 placeholder={$_('register.handlePlaceholder')} 328 disabled={submitting} 329 + onInput={(v) => { handle = v }} 330 + onDomainChange={(d) => { selectedDomain = d }} 331 /> 332 {#if checkingHandle} 333 <p class="hint">{$_('common.checking')}</p>
+11 -7
frontend/src/routes/Register.svelte
··· 16 type WebAuthnCreationOptionsResponse, 17 } from '../lib/webauthn' 18 import AccountTypeSwitcher from '../components/AccountTypeSwitcher.svelte' 19 import { ensureRequestUri, getRequestUriFromUrl } from '../lib/oauth' 20 21 let serverInfo = $state<{ ··· 31 let flow = $state<ReturnType<typeof createRegistrationFlow> | null>(null) 32 let passkeyName = $state('') 33 let clientName = $state<string | null>(null) 34 let checkHandleTimeout: ReturnType<typeof setTimeout> | null = null 35 36 $effect(() => { ··· 112 const hostname = serverInfo?.availableUserDomains?.[0] || window.location.hostname 113 flow = createRegistrationFlow('passkey', hostname) 114 } 115 } catch (e) { 116 console.error('Failed to load server info:', e) 117 } finally { ··· 276 277 let fullHandle = $derived(() => { 278 if (!flow?.info.handle.trim()) return '' 279 - return `${flow.info.handle.trim()}.${flow.state.pdsHostname}` 280 }) 281 282 async function handleCancel() { ··· 357 <form class="register-form" onsubmit={handleInfoSubmit}> 358 <div class="field"> 359 <label for="handle">{$_('register.handle')}</label> 360 - <input 361 - id="handle" 362 - type="text" 363 - bind:value={flow.info.handle} 364 placeholder={$_('register.handlePlaceholder')} 365 disabled={flow.state.submitting} 366 - required 367 - autocomplete="off" 368 /> 369 {#if flow.info.handle.includes('.')} 370 <p class="hint warning">{$_('register.handleDotWarning')}</p>
··· 16 type WebAuthnCreationOptionsResponse, 17 } from '../lib/webauthn' 18 import AccountTypeSwitcher from '../components/AccountTypeSwitcher.svelte' 19 + import HandleInput from '../components/HandleInput.svelte' 20 import { ensureRequestUri, getRequestUriFromUrl } from '../lib/oauth' 21 22 let serverInfo = $state<{ ··· 32 let flow = $state<ReturnType<typeof createRegistrationFlow> | null>(null) 33 let passkeyName = $state('') 34 let clientName = $state<string | null>(null) 35 + let selectedDomain = $state('') 36 let checkHandleTimeout: ReturnType<typeof setTimeout> | null = null 37 38 $effect(() => { ··· 114 const hostname = serverInfo?.availableUserDomains?.[0] || window.location.hostname 115 flow = createRegistrationFlow('passkey', hostname) 116 } 117 + selectedDomain = serverInfo?.availableUserDomains?.[0] || window.location.hostname 118 } catch (e) { 119 console.error('Failed to load server info:', e) 120 } finally { ··· 279 280 let fullHandle = $derived(() => { 281 if (!flow?.info.handle.trim()) return '' 282 + if (flow.info.handle.includes('.')) return flow.info.handle.trim() 283 + return selectedDomain ? `${flow.info.handle.trim()}.${selectedDomain}` : flow.info.handle.trim() 284 }) 285 286 async function handleCancel() { ··· 361 <form class="register-form" onsubmit={handleInfoSubmit}> 362 <div class="field"> 363 <label for="handle">{$_('register.handle')}</label> 364 + <HandleInput 365 + value={flow.info.handle} 366 + domains={serverInfo?.availableUserDomains ?? []} 367 + {selectedDomain} 368 placeholder={$_('register.handlePlaceholder')} 369 disabled={flow.state.submitting} 370 + onInput={(v) => { flow!.info.handle = v }} 371 + onDomainChange={(d) => { selectedDomain = d }} 372 /> 373 {#if flow.info.handle.includes('.')} 374 <p class="hint warning">{$_('register.handleDotWarning')}</p>
+10 -8
frontend/src/routes/RegisterPassword.svelte
··· 10 DidDocStep, 11 } from '../lib/registration' 12 import AccountTypeSwitcher from '../components/AccountTypeSwitcher.svelte' 13 import { ensureRequestUri, getRequestUriFromUrl } from '../lib/oauth' 14 15 let serverInfo = $state<{ ··· 25 let flow = $state<ReturnType<typeof createRegistrationFlow> | null>(null) 26 let confirmPassword = $state('') 27 let clientName = $state<string | null>(null) 28 let checkHandleTimeout: ReturnType<typeof setTimeout> | null = null 29 30 $effect(() => { ··· 106 const hostname = serverInfo?.availableUserDomains?.[0] || window.location.hostname 107 flow = createRegistrationFlow('password', hostname) 108 } 109 } catch (e) { 110 console.error('Failed to load server info:', e) 111 } finally { ··· 229 let fullHandle = $derived(() => { 230 if (!flow?.info.handle.trim()) return '' 231 if (flow.info.handle.includes('.')) return flow.info.handle.trim() 232 - const domain = serverInfo?.availableUserDomains?.[0] 233 - if (domain) return `${flow.info.handle.trim()}.${domain}` 234 - return flow.info.handle.trim() 235 }) 236 237 function extractDomain(did: string): string { ··· 305 <form class="register-form" onsubmit={handleInfoSubmit}> 306 <div class="field"> 307 <label for="handle">{$_('register.handle')}</label> 308 - <input 309 - id="handle" 310 - type="text" 311 - bind:value={flow.info.handle} 312 placeholder={$_('register.handlePlaceholder')} 313 disabled={flow.state.submitting} 314 - required 315 /> 316 {#if flow.info.handle.includes('.')} 317 <p class="hint warning">{$_('register.handleDotWarning')}</p>
··· 10 DidDocStep, 11 } from '../lib/registration' 12 import AccountTypeSwitcher from '../components/AccountTypeSwitcher.svelte' 13 + import HandleInput from '../components/HandleInput.svelte' 14 import { ensureRequestUri, getRequestUriFromUrl } from '../lib/oauth' 15 16 let serverInfo = $state<{ ··· 26 let flow = $state<ReturnType<typeof createRegistrationFlow> | null>(null) 27 let confirmPassword = $state('') 28 let clientName = $state<string | null>(null) 29 + let selectedDomain = $state('') 30 let checkHandleTimeout: ReturnType<typeof setTimeout> | null = null 31 32 $effect(() => { ··· 108 const hostname = serverInfo?.availableUserDomains?.[0] || window.location.hostname 109 flow = createRegistrationFlow('password', hostname) 110 } 111 + selectedDomain = serverInfo?.availableUserDomains?.[0] || window.location.hostname 112 } catch (e) { 113 console.error('Failed to load server info:', e) 114 } finally { ··· 232 let fullHandle = $derived(() => { 233 if (!flow?.info.handle.trim()) return '' 234 if (flow.info.handle.includes('.')) return flow.info.handle.trim() 235 + return selectedDomain ? `${flow.info.handle.trim()}.${selectedDomain}` : flow.info.handle.trim() 236 }) 237 238 function extractDomain(did: string): string { ··· 306 <form class="register-form" onsubmit={handleInfoSubmit}> 307 <div class="field"> 308 <label for="handle">{$_('register.handle')}</label> 309 + <HandleInput 310 + value={flow.info.handle} 311 + domains={serverInfo?.availableUserDomains ?? []} 312 + {selectedDomain} 313 placeholder={$_('register.handlePlaceholder')} 314 disabled={flow.state.submitting} 315 + onInput={(v) => { flow!.info.handle = v }} 316 + onDomainChange={(d) => { selectedDomain = d }} 317 /> 318 {#if flow.info.handle.includes('.')} 319 <p class="hint warning">{$_('register.handleDotWarning')}</p>
+11 -8
frontend/src/routes/SsoRegisterComplete.svelte
··· 3 import { _ } from '../lib/i18n' 4 import { toast } from '../lib/toast.svelte' 5 import SsoIcon from '../components/SsoIcon.svelte' 6 7 interface PendingRegistration { 8 request_uri: string ··· 47 let handleAvailable = $state<boolean | null>(null) 48 let checkingHandle = $state(false) 49 let handleError = $state<string | null>(null) 50 51 let didType = $state<'plc' | 'web' | 'web-external'>('plc') 52 let externalDid = $state('') ··· 95 96 let fullHandle = $derived(() => { 97 if (!handle.trim()) return '' 98 - const domain = serverInfo?.availableUserDomains?.[0] 99 - return domain ? `${handle.trim()}.${domain}` : handle.trim() 100 }) 101 102 onMount(() => { ··· 121 telegram: available.includes('telegram'), 122 signal: available.includes('signal'), 123 } 124 } 125 } catch { 126 serverInfo = null ··· 390 <form onsubmit={handleSubmit}> 391 <div class="field"> 392 <label for="handle">{$_('sso_register.handle_label')}</label> 393 - <input 394 - id="handle" 395 - type="text" 396 - bind:value={handle} 397 placeholder={$_('register.handlePlaceholder')} 398 disabled={submitting} 399 - required 400 - autocomplete="off" 401 /> 402 {#if checkingHandle} 403 <p class="hint">{$_('common.checking')}</p>
··· 3 import { _ } from '../lib/i18n' 4 import { toast } from '../lib/toast.svelte' 5 import SsoIcon from '../components/SsoIcon.svelte' 6 + import HandleInput from '../components/HandleInput.svelte' 7 8 interface PendingRegistration { 9 request_uri: string ··· 48 let handleAvailable = $state<boolean | null>(null) 49 let checkingHandle = $state(false) 50 let handleError = $state<string | null>(null) 51 + let selectedDomain = $state('') 52 53 let didType = $state<'plc' | 'web' | 'web-external'>('plc') 54 let externalDid = $state('') ··· 97 98 let fullHandle = $derived(() => { 99 if (!handle.trim()) return '' 100 + if (handle.includes('.')) return handle.trim() 101 + return selectedDomain ? `${handle.trim()}.${selectedDomain}` : handle.trim() 102 }) 103 104 onMount(() => { ··· 123 telegram: available.includes('telegram'), 124 signal: available.includes('signal'), 125 } 126 + selectedDomain = data.availableUserDomains?.[0] || window.location.hostname 127 } 128 } catch { 129 serverInfo = null ··· 393 <form onsubmit={handleSubmit}> 394 <div class="field"> 395 <label for="handle">{$_('sso_register.handle_label')}</label> 396 + <HandleInput 397 + value={handle} 398 + domains={serverInfo?.availableUserDomains ?? []} 399 + {selectedDomain} 400 placeholder={$_('register.handlePlaceholder')} 401 disabled={submitting} 402 + onInput={(v) => { handle = v }} 403 + onDomainChange={(d) => { selectedDomain = d }} 404 /> 405 {#if checkingHandle} 406 <p class="hint">{$_('common.checking')}</p>
-13
frontend/src/styles/migration.css
··· 170 margin-top: var(--space-5); 171 } 172 173 - .handle-input-group { 174 - display: flex; 175 - gap: var(--space-2); 176 - } 177 - 178 - .handle-input-group input { 179 - flex: 1; 180 - } 181 - 182 - .handle-input-group select { 183 - width: auto; 184 - } 185 - 186 .current-info { 187 background: var(--bg-primary); 188 border-radius: var(--radius-lg);
··· 170 margin-top: var(--space-5); 171 } 172 173 .current-info { 174 background: var(--bg-primary); 175 border-radius: var(--radius-lg);

History

2 rounds 0 comments
sign up or login to add to the discussion
1 commit
expand
fix: user handle domains upgrade
expand 0 comments
pull request successfully merged
lewis.moe submitted #0
1 commit
expand
fix: user handle domains upgrade
expand 0 comments