diff --git a/.config/nextest.toml b/.config/nextest.toml index 02f9184..0ca5cae 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -21,6 +21,10 @@ heavy-load-tests = { max-threads = 4 } filter = "test(/import_with_verification/) | test(/plc_migration/)" test-group = "serial-env-tests" +[[profile.default.overrides]] +filter = "binary(handle_domains)" +test-group = "serial-env-tests" + [[profile.default.overrides]] filter = "binary(ripple_cluster)" test-group = "serial-env-tests" @@ -41,6 +45,10 @@ test-group = "heavy-load-tests" filter = "test(/import_with_verification/) | test(/plc_migration/)" test-group = "serial-env-tests" +[[profile.ci.overrides]] +filter = "binary(handle_domains)" +test-group = "serial-env-tests" + [[profile.ci.overrides]] filter = "binary(ripple_cluster)" test-group = "serial-env-tests" diff --git a/Cargo.lock b/Cargo.lock index b67615e..5780812 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6094,7 +6094,7 @@ dependencies = [ [[package]] name = "tranquil-auth" -version = "0.2.1" +version = "0.3.0" dependencies = [ "anyhow", "base32", @@ -6117,7 +6117,7 @@ dependencies = [ [[package]] name = "tranquil-cache" -version = "0.2.1" +version = "0.3.0" dependencies = [ "async-trait", "base64 0.22.1", @@ -6131,7 +6131,7 @@ dependencies = [ [[package]] name = "tranquil-comms" -version = "0.2.1" +version = "0.3.0" dependencies = [ "async-trait", "base64 0.22.1", @@ -6146,7 +6146,7 @@ dependencies = [ [[package]] name = "tranquil-config" -version = "0.2.1" +version = "0.3.0" dependencies = [ "confique", "serde", @@ -6154,7 +6154,7 @@ dependencies = [ [[package]] name = "tranquil-crypto" -version = "0.2.1" +version = "0.3.0" dependencies = [ "aes-gcm", "base64 0.22.1", @@ -6170,7 +6170,7 @@ dependencies = [ [[package]] name = "tranquil-db" -version = "0.2.1" +version = "0.3.0" dependencies = [ "async-trait", "chrono", @@ -6187,7 +6187,7 @@ dependencies = [ [[package]] name = "tranquil-db-traits" -version = "0.2.1" +version = "0.3.0" dependencies = [ "async-trait", "base64 0.22.1", @@ -6203,7 +6203,7 @@ dependencies = [ [[package]] name = "tranquil-infra" -version = "0.2.1" +version = "0.3.0" dependencies = [ "async-trait", "bytes", @@ -6214,7 +6214,7 @@ dependencies = [ [[package]] name = "tranquil-oauth" -version = "0.2.1" +version = "0.3.0" dependencies = [ "anyhow", "axum", @@ -6237,7 +6237,7 @@ dependencies = [ [[package]] name = "tranquil-pds" -version = "0.2.1" +version = "0.3.0" dependencies = [ "aes-gcm", "anyhow", @@ -6324,7 +6324,7 @@ dependencies = [ [[package]] name = "tranquil-repo" -version = "0.2.1" +version = "0.3.0" dependencies = [ "bytes", "cid", @@ -6336,7 +6336,7 @@ dependencies = [ [[package]] name = "tranquil-ripple" -version = "0.2.1" +version = "0.3.0" dependencies = [ "async-trait", "backon", @@ -6361,7 +6361,7 @@ dependencies = [ [[package]] name = "tranquil-scopes" -version = "0.2.1" +version = "0.3.0" dependencies = [ "axum", "futures", @@ -6377,7 +6377,7 @@ dependencies = [ [[package]] name = "tranquil-storage" -version = "0.2.1" +version = "0.3.0" dependencies = [ "async-trait", "aws-config", @@ -6394,7 +6394,7 @@ dependencies = [ [[package]] name = "tranquil-types" -version = "0.2.1" +version = "0.3.0" dependencies = [ "chrono", "cid", diff --git a/Cargo.toml b/Cargo.toml index 1be3a30..665fd6f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,7 +19,7 @@ members = [ ] [workspace.package] -version = "0.2.1" +version = "0.3.0" edition = "2024" license = "AGPL-3.0-or-later" diff --git a/TODO.md b/TODO.md deleted file mode 100644 index c9c0972..0000000 --- a/TODO.md +++ /dev/null @@ -1,128 +0,0 @@ -# Lewis' Big Boy TODO list - -## Active development - -### Storage backend abstraction -Make storage layers swappable via traits. - -sqlite database backend -- [ ] abstract db layer behind trait (queries, transactions, migrations) -- [ ] sqlite implementation matching postgres behavior -- [ ] handle sqlite's single-writer limitation (connection pooling strategy) -- [ ] migrations system that works for both -- [ ] testing: run full test suite against both backends -- [ ] config option to choose backend (postgres vs sqlite) -- [ ] document tradeoffs (sqlite for single-user/small, postgres for multi-user/scale) - -- [ ] skip sqlite and just straight-up do our own db?! - -### Plugin system -WASM component model plugins. Compile to wasm32-wasip2, sandboxed via wasmtime, capability-gated. Based on zed's extensions. - -WIT interface -- [ ] record hooks before/after create, update, delete -- [ ] blob hooks before/after upload, validate -- [ ] xrpc hooks before/after (middleware), custom endpoint handler -- [ ] firehose hook on_commit -- [ ] host imports http client, kv store, logging, read records - -wasmtime host -- [ ] engine with epoch interruption (kill runaway plugins) -- [ ] plugin manifest (plugin.toml): id, version, capabilities, hooks -- [ ] capability enforcement at runtime -- [ ] plugin loader, lifecycle (enable/disable/reload) -- [ ] resource limits (memory, time) -- [ ] per-plugin fs sandbox - -capabilities -- [ ] http:fetch with domain allowlist -- [ ] kv:read, kv:write -- [ ] record:read, blob:read -- [ ] xrpc:register -- [ ] firehose:subscribe - -pds-plugin-api (rust), MVP for plugin system -- [ ] plugin trait with default impls -- [ ] register_plugin! macro -- [ ] typed host import wrappers -- [ ] publish to crates.io -- [ ] docs + example - -pds-plugin-api in golang, nice to have after the fact -- [ ] wit-bindgen-go bindings -- [ ] go wrappers -- [ ] tinygo build instructions -- [ ] example - -@pds/plugin-api in typescript, nice to have after the fact -- [ ] jco/componentize-js bindings -- [ ] typeScript types -- [ ] build tooling -- [ ] example - -example plugins -- [ ] content filter -- [ ] webhook notifier -- [ ] objsto backup mirror -- [ ] custom lexicon handler -- [ ] better audit logger - -### Misc - -cross-pds delegation -when a client (eg. tangled.org) tries to log into a delegated account: -- [ ] client starts oauth flow to delegated account's pds -- [ ] delegated pds sees account is externally controlled, launches oauth to controller's pds (delegated pds acts as oauth client) -- [ ] controller authenticates at their own pds -- [ ] delegated pds verifies controller perms and scope from its local delegation grants -- [ ] delegated pds issues session to client within the intersection of controller's granted scope and client's requested scope - -per-request "act as" -- [ ] authed as user X, perform action as delegated user Y in single request -- [ ] approach decision - - [ ] option 1: `X-Act-As` header with target did, server verifies delegation grant - - [ ] option 2: token exchange (RFC 8693) for short-lived delegated token - - [ ] option 3 (lewis fav): extend existing `act` claim to support on-demand minting - - [ ] something else? - -### Private/encrypted data -Records only authorized parties can see and decrypt. - -research -- [ ] survey atproto discourse on private data -- [ ] document bluesky team's likely approach. wait.. are they even gonna do this? whatever -- [ ] look at matrix/signal for federated e2ee patterns - -key management -- [ ] db schema for encryption keys (user_keys, key_grants, key_rotations) -- [ ] per-user encryption keypair generation (separate from signing keys) -- [ ] key derivation scheme (per-collection? per-record? both?) -- [ ] key storage (encrypted at rest, hsm option?) -- [ ] rotation and revocation flow - -storage layer -- [ ] encrypted record format (encrypted cbor blob + metadata) -- [ ] collection-level vs per-record encryption flag -- [ ] how encrypted records appear in mst (hash of ciphertext? separate tree?) -- [ ] blob encryption (same keys? separate?) - -api surface -- [ ] xrpc getPublicKey, grantAccess, revokeAccess, listGrants -- [ ] xrpc getEncryptedRecord (ciphertext for client-side decrypt) -- [ ] or transparent server-side decrypt if requester has grant? -- [ ] lexicon for key grant records - -sync/federation -- [ ] how encrypted records appear on firehose (ciphertext? omitted? placeholder?) -- [ ] pds-to-pds key exchange protocol -- [ ] appview behavior (can't index without grants) -- [ ] relay behavior with encrypted commits - -client integration -- [ ] client-side encryption (pds never sees plaintext) vs server-side with trust -- [ ] key backup/recovery (lose key = lose data) - -plugin hooks (once core exists) -- [ ] on_access_grant_request for custom authorization -- [ ] on_key_rotation to notify interested parties - diff --git a/crates/tranquil-config/src/lib.rs b/crates/tranquil-config/src/lib.rs index 95db9b0..a5422b5 100644 --- a/crates/tranquil-config/src/lib.rs +++ b/crates/tranquil-config/src/lib.rs @@ -468,9 +468,16 @@ impl ServerConfig { /// Returns the user handle domains, falling back to `[hostname_without_port]`. pub fn user_handle_domain_list(&self) -> Vec { self.user_handle_domains - .clone() + .as_deref() + .filter(|v| !v.is_empty()) + .map(|v| v.to_vec()) .unwrap_or_else(|| vec![self.hostname_without_port().to_string()]) } + + /// Alias for `user_handle_domain_list` (for callers that were using the now-removed `available_user_domains` field). + pub fn available_user_domain_list(&self) -> Vec { + self.user_handle_domain_list() + } } #[derive(Debug, Config)] diff --git a/crates/tranquil-pds/src/api/admin/account/update.rs b/crates/tranquil-pds/src/api/admin/account/update.rs index 70dd060..9661440 100644 --- a/crates/tranquil-pds/src/api/admin/account/update.rs +++ b/crates/tranquil-pds/src/api/admin/account/update.rs @@ -69,9 +69,9 @@ pub async fn update_account_handle( { return Err(ApiError::InvalidHandle(None)); } - let hostname_for_handles = tranquil_config::get().server.hostname_without_port(); + let available_domains = tranquil_config::get().server.available_user_domain_list(); let handle = if !input_handle.contains('.') { - format!("{}.{}", input_handle, hostname_for_handles) + format!("{}.{}", input_handle, &available_domains[0]) } else { input_handle.to_string() }; diff --git a/crates/tranquil-pds/src/api/delegation.rs b/crates/tranquil-pds/src/api/delegation.rs index c563623..ca669b2 100644 --- a/crates/tranquil-pds/src/api/delegation.rs +++ b/crates/tranquil-pds/src/api/delegation.rs @@ -435,20 +435,22 @@ pub async fn create_delegated_account( }; let hostname = &tranquil_config::get().server.hostname; - let hostname_for_handles = tranquil_config::get().server.hostname_without_port(); - let pds_suffix = format!(".{}", hostname_for_handles); + let available_domains = tranquil_config::get().server.available_user_domain_list(); + let matched_domain = available_domains + .iter() + .filter(|d| input.handle.ends_with(&format!(".{}", d))) + .max_by_key(|d| d.len()); - let handle = if !input.handle.contains('.') || input.handle.ends_with(&pds_suffix) { - let handle_to_validate = if input.handle.ends_with(&pds_suffix) { - input + let handle = if !input.handle.contains('.') || matched_domain.is_some() { + let handle_to_validate = match matched_domain { + Some(domain) => input .handle - .strip_suffix(&pds_suffix) - .unwrap_or(&input.handle) - } else { - &input.handle + .strip_suffix(&format!(".{}", domain)) + .unwrap_or(&input.handle), + None => &input.handle, }; match crate::api::validation::validate_short_handle(handle_to_validate) { - Ok(h) => format!("{}.{}", h, hostname_for_handles), + Ok(h) => format!("{}.{}", h, matched_domain.unwrap_or(&available_domains[0])), Err(e) => { return Ok(ApiError::InvalidRequest(e.to_string()).into_response()); } diff --git a/crates/tranquil-pds/src/api/identity/account.rs b/crates/tranquil-pds/src/api/identity/account.rs index 826637e..b31cae5 100644 --- a/crates/tranquil-pds/src/api/identity/account.rs +++ b/crates/tranquil-pds/src/api/identity/account.rs @@ -140,19 +140,21 @@ pub async fn create_account( } } - let hostname_for_validation = tranquil_config::get().server.hostname_without_port(); - let pds_suffix = format!(".{}", hostname_for_validation); + let available_domains = tranquil_config::get().server.available_user_domain_list(); + let matched_domain = available_domains + .iter() + .filter(|d| input.handle.ends_with(&format!(".{}", d))) + .max_by_key(|d| d.len()); let validated_short_handle = if !input.handle.contains('.') - || input.handle.ends_with(&pds_suffix) + || matched_domain.is_some() { - let handle_to_validate = if input.handle.ends_with(&pds_suffix) { - input + let handle_to_validate = match matched_domain { + Some(domain) => input .handle - .strip_suffix(&pds_suffix) - .unwrap_or(&input.handle) - } else { - &input.handle + .strip_suffix(&format!(".{}", domain)) + .unwrap_or(&input.handle), + None => &input.handle, }; match crate::api::validation::validate_short_handle(handle_to_validate) { Ok(h) => h, @@ -233,15 +235,11 @@ pub async fn create_account( }) }; let hostname = &tranquil_config::get().server.hostname; - let hostname_for_handles = tranquil_config::get().server.hostname_without_port(); let pds_endpoint = format!("https://{}", hostname); - let suffix = format!(".{}", hostname_for_handles); - let handle = if input.handle.ends_with(&suffix) { - format!("{}.{}", validated_short_handle, hostname_for_handles) - } else if input.handle.contains('.') { - validated_short_handle.clone() - } else { - format!("{}.{}", validated_short_handle, hostname_for_handles) + let handle = match matched_domain { + Some(domain) => format!("{}.{}", validated_short_handle, domain), + None if input.handle.contains('.') => validated_short_handle.clone(), + None => format!("{}.{}", validated_short_handle, &available_domains[0]), }; let (secret_key_bytes, reserved_key_id): (Vec, Option) = if let Some(signing_key_did) = &input.signing_key { @@ -276,7 +274,8 @@ pub async fn create_account( if !crate::api::server::meta::is_self_hosted_did_web_enabled() { return ApiError::SelfHostedDidWebDisabled.into_response(); } - let subdomain_host = format!("{}.{}", input.handle, hostname_for_handles); + let pds_hostname = tranquil_config::get().server.hostname_without_port(); + let subdomain_host = format!("{}.{}", input.handle, pds_hostname); let encoded_subdomain = subdomain_host.replace(':', "%3A"); let self_hosted_did = format!("did:web:{}", encoded_subdomain); info!(did = %self_hosted_did, "Creating self-hosted did:web account (subdomain)"); diff --git a/crates/tranquil-pds/src/api/identity/did.rs b/crates/tranquil-pds/src/api/identity/did.rs index b7697b0..ed1d565 100644 --- a/crates/tranquil-pds/src/api/identity/did.rs +++ b/crates/tranquil-pds/src/api/identity/did.rs @@ -675,20 +675,24 @@ pub async fn update_handle( "Inappropriate language in handle".into(), ))); } - let hostname_for_handles = tranquil_config::get().server.hostname_without_port(); - let suffix = format!(".{}", hostname_for_handles); - let is_service_domain = - crate::handle::is_service_domain_handle(&new_handle, hostname_for_handles); - let handle = if is_service_domain && new_handle != hostname_for_handles { - let short_part = if new_handle.ends_with(&suffix) { - new_handle.strip_suffix(&suffix).unwrap_or(&new_handle) - } else { - &new_handle - }; - let full_handle = if new_handle.ends_with(&suffix) { - new_handle.clone() - } else { - format!("{}.{}", new_handle, hostname_for_handles) + let handle_domains = tranquil_config::get().server.user_handle_domain_list(); + let matched_handle_domain = handle_domains + .iter() + .filter(|d| new_handle.ends_with(&format!(".{}", d))) + .max_by_key(|d| d.len()) + .cloned(); + let is_domain_itself = handle_domains.iter().any(|d| d == &new_handle); + let handle = if (!new_handle.contains('.') || matched_handle_domain.is_some()) && !is_domain_itself { + let (short_part, full_handle) = match &matched_handle_domain { + Some(domain) => { + let suffix = format!(".{}", domain); + let short = new_handle.strip_suffix(&suffix).unwrap_or(&new_handle); + (short.to_string(), new_handle.clone()) + } + None => { + let primary = &handle_domains[0]; + (new_handle.clone(), format!("{}.{}", new_handle, primary)) + } }; if full_handle == current_handle { let handle_typed: Handle = match full_handle.parse() { diff --git a/crates/tranquil-pds/src/api/server/passkey_account.rs b/crates/tranquil-pds/src/api/server/passkey_account.rs index 73627fa..f82471e 100644 --- a/crates/tranquil-pds/src/api/server/passkey_account.rs +++ b/crates/tranquil-pds/src/api/server/passkey_account.rs @@ -113,20 +113,22 @@ pub async fn create_passkey_account( .unwrap_or(false); let hostname = &tranquil_config::get().server.hostname; - let hostname_for_handles = tranquil_config::get().server.hostname_without_port(); - let pds_suffix = format!(".{}", hostname_for_handles); + let available_domains = tranquil_config::get().server.available_user_domain_list(); + let matched_domain = available_domains + .iter() + .filter(|d| input.handle.ends_with(&format!(".{}", d))) + .max_by_key(|d| d.len()); - let handle = if !input.handle.contains('.') || input.handle.ends_with(&pds_suffix) { - let handle_to_validate = if input.handle.ends_with(&pds_suffix) { - input + let handle = if !input.handle.contains('.') || matched_domain.is_some() { + let handle_to_validate = match matched_domain { + Some(domain) => input .handle - .strip_suffix(&pds_suffix) - .unwrap_or(&input.handle) - } else { - &input.handle + .strip_suffix(&format!(".{}", domain)) + .unwrap_or(&input.handle), + None => &input.handle, }; match crate::api::validation::validate_short_handle(handle_to_validate) { - Ok(h) => format!("{}.{}", h, hostname_for_handles), + Ok(h) => format!("{}.{}", h, matched_domain.unwrap_or(&available_domains[0])), Err(_) => { return ApiError::InvalidHandle(None).into_response(); } @@ -244,7 +246,8 @@ pub async fn create_passkey_account( let did = match did_type { "web" => { - let subdomain_host = format!("{}.{}", input.handle, hostname_for_handles); + let pds_hostname = tranquil_config::get().server.hostname_without_port(); + let subdomain_host = format!("{}.{}", input.handle, pds_hostname); let encoded_subdomain = subdomain_host.replace(':', "%3A"); let self_hosted_did = format!("did:web:{}", encoded_subdomain); info!(did = %self_hosted_did, "Creating self-hosted did:web passkey account"); diff --git a/crates/tranquil-pds/src/sso/endpoints.rs b/crates/tranquil-pds/src/sso/endpoints.rs index 4295d6b..2eb18b9 100644 --- a/crates/tranquil-pds/src/sso/endpoints.rs +++ b/crates/tranquil-pds/src/sso/endpoints.rs @@ -772,8 +772,8 @@ pub async fn check_handle_available( } }; - let hostname_for_handles = tranquil_config::get().server.hostname_without_port(); - let full_handle = format!("{}.{}", validated, hostname_for_handles); + let available_domains = tranquil_config::get().server.available_user_domain_list(); + let full_handle = format!("{}.{}", validated, &available_domains[0]); let handle_typed: crate::types::Handle = match full_handle.parse() { Ok(h) => h, Err(_) => return Err(ApiError::InvalidHandle(None)), @@ -856,10 +856,10 @@ pub async fn complete_registration( .ok_or(ApiError::SsoSessionExpired)?; let hostname = &tranquil_config::get().server.hostname; - let hostname_for_handles = tranquil_config::get().server.hostname_without_port(); + let available_domains = tranquil_config::get().server.available_user_domain_list(); let handle = match crate::api::validation::validate_short_handle(&input.handle) { - Ok(h) => format!("{}.{}", h, hostname_for_handles), + Ok(h) => format!("{}.{}", h, &available_domains[0]), Err(_) => return Err(ApiError::InvalidHandle(None)), }; @@ -981,7 +981,8 @@ pub async fn complete_registration( let did = match did_type { "web" => { - let subdomain_host = format!("{}.{}", input.handle, hostname_for_handles); + let pds_hostname = tranquil_config::get().server.hostname_without_port(); + let subdomain_host = format!("{}.{}", input.handle, pds_hostname); let encoded_subdomain = subdomain_host.replace(':', "%3A"); let self_hosted_did = format!("did:web:{}", encoded_subdomain); tracing::info!(did = %self_hosted_did, "Creating self-hosted did:web SSO account"); diff --git a/crates/tranquil-pds/tests/handle_domains.rs b/crates/tranquil-pds/tests/handle_domains.rs new file mode 100644 index 0000000..40f4b36 --- /dev/null +++ b/crates/tranquil-pds/tests/handle_domains.rs @@ -0,0 +1,278 @@ +mod common; +use common::*; +use reqwest::StatusCode; +use reqwest::header; +use serde_json::{Value, json}; + +const HANDLE_DOMAIN: &str = "handles.test"; + +fn set_handle_domain() { + unsafe { + std::env::set_var("AVAILABLE_USER_DOMAINS", HANDLE_DOMAIN); + std::env::set_var("PDS_USER_HANDLE_DOMAINS", HANDLE_DOMAIN); + } +} + +async fn base_url_with_domain() -> &'static str { + set_handle_domain(); + base_url().await +} + +#[tokio::test] +async fn describe_server_returns_configured_domain() { + let client = client(); + let base = base_url_with_domain().await; + let res = client + .get(format!( + "{}/xrpc/com.atproto.server.describeServer", + base + )) + .send() + .await + .expect("describeServer request failed"); + assert_eq!(res.status(), StatusCode::OK); + let body: Value = res.json().await.expect("Invalid JSON"); + let domains = body["availableUserDomains"] + .as_array() + .expect("No availableUserDomains"); + assert!( + domains.iter().any(|d| d.as_str() == Some(HANDLE_DOMAIN)), + "availableUserDomains should contain {}, got {:?}", + HANDLE_DOMAIN, + domains + ); +} + +#[tokio::test] +async fn short_handle_uses_configured_domain() { + let client = client(); + let base = base_url_with_domain().await; + let short_handle = format!("hd{}", &uuid::Uuid::new_v4().simple().to_string()[..12]); + let payload = json!({ + "handle": short_handle, + "email": format!("{}@example.com", short_handle), + "password": "Testpass123!" + }); + let res = client + .post(format!( + "{}/xrpc/com.atproto.server.createAccount", + base + )) + .json(&payload) + .send() + .await + .expect("createAccount request failed"); + assert_eq!(res.status(), StatusCode::OK); + let body: Value = res.json().await.expect("Invalid JSON"); + let handle = body["handle"].as_str().expect("No handle in response"); + let expected_suffix = format!(".{}", HANDLE_DOMAIN); + assert!( + handle.ends_with(&expected_suffix), + "Handle '{}' should end with '{}' (not PDS hostname)", + handle, + expected_suffix + ); + assert_eq!( + handle, + format!("{}.{}", short_handle, HANDLE_DOMAIN), + "Handle should be short_handle.configured_domain" + ); +} + +#[tokio::test] +async fn full_handle_with_configured_domain_accepted() { + let client = client(); + let base = base_url_with_domain().await; + let short_handle = format!("hd{}", &uuid::Uuid::new_v4().simple().to_string()[..12]); + let full_handle = format!("{}.{}", short_handle, HANDLE_DOMAIN); + let payload = json!({ + "handle": full_handle, + "email": format!("{}@example.com", short_handle), + "password": "Testpass123!" + }); + let res = client + .post(format!( + "{}/xrpc/com.atproto.server.createAccount", + base + )) + .json(&payload) + .send() + .await + .expect("createAccount request failed"); + assert_eq!(res.status(), StatusCode::OK); + let body: Value = res.json().await.expect("Invalid JSON"); + let handle = body["handle"].as_str().expect("No handle in response"); + assert_eq!( + handle, full_handle, + "Handle should match the full handle submitted" + ); +} + +#[tokio::test] +async fn handle_with_pds_hostname_treated_as_custom() { + let client = client(); + let base = base_url_with_domain().await; + let pds_hostname = pds_hostname(); + let pds_host_no_port = pds_hostname.split(':').next().unwrap_or(&pds_hostname); + let short_handle = format!("hd{}", &uuid::Uuid::new_v4().simple().to_string()[..12]); + let handle_with_hostname = format!("{}.{}", short_handle, pds_host_no_port); + let payload = json!({ + "handle": handle_with_hostname, + "email": format!("{}@example.com", short_handle), + "password": "Testpass123!" + }); + let res = client + .post(format!( + "{}/xrpc/com.atproto.server.createAccount", + base + )) + .json(&payload) + .send() + .await + .expect("createAccount request failed"); + assert_eq!(res.status(), StatusCode::OK); + let body: Value = res.json().await.expect("Invalid JSON"); + let handle = body["handle"].as_str().expect("No handle in response"); + assert_eq!( + handle, handle_with_hostname, + "Handle with non-available domain suffix should be treated as custom handle (passed through)" + ); +} + +#[tokio::test] +async fn resolve_handle_works_with_configured_domain() { + let client = client(); + let base = base_url_with_domain().await; + let short_handle = format!("hd{}", &uuid::Uuid::new_v4().simple().to_string()[..12]); + let payload = json!({ + "handle": short_handle, + "email": format!("{}@example.com", short_handle), + "password": "Testpass123!" + }); + let res = client + .post(format!( + "{}/xrpc/com.atproto.server.createAccount", + base + )) + .json(&payload) + .send() + .await + .expect("createAccount request failed"); + assert_eq!(res.status(), StatusCode::OK); + let body: Value = res.json().await.expect("Invalid JSON"); + let did = body["did"].as_str().expect("No DID").to_string(); + let full_handle = body["handle"].as_str().expect("No handle").to_string(); + + let res = client + .get(format!( + "{}/xrpc/com.atproto.identity.resolveHandle", + base + )) + .query(&[("handle", full_handle.as_str())]) + .send() + .await + .expect("resolveHandle request failed"); + assert_eq!(res.status(), StatusCode::OK); + let body: Value = res.json().await.expect("Invalid JSON"); + assert_eq!(body["did"], did); +} + +#[tokio::test] +async fn admin_update_handle_uses_configured_domain() { + let client = client(); + let base = base_url_with_domain().await; + let (admin_jwt, _) = create_admin_account_and_login(&client).await; + let (_, target_did) = create_account_and_login(&client).await; + + let new_short = format!("hd{}", &uuid::Uuid::new_v4().simple().to_string()[..12]); + let res = client + .post(format!( + "{}/xrpc/com.atproto.admin.updateAccountHandle", + base + )) + .bearer_auth(&admin_jwt) + .json(&json!({ + "did": target_did, + "handle": new_short, + })) + .send() + .await + .expect("admin updateAccountHandle request failed"); + assert_eq!(res.status(), StatusCode::OK); + + let res = client + .get(format!( + "{}/xrpc/com.atproto.identity.resolveHandle", + base + )) + .query(&[("handle", format!("{}.{}", new_short, HANDLE_DOMAIN))]) + .send() + .await + .expect("resolveHandle request failed"); + assert_eq!(res.status(), StatusCode::OK); + let body: Value = res.json().await.expect("Invalid JSON"); + assert_eq!( + body["did"], target_did, + "Admin bare handle update should use configured domain, not PDS hostname" + ); +} + +#[tokio::test] +async fn update_handle_bare_uses_configured_domain() { + let client = client(); + let base = base_url_with_domain().await; + let short_handle = format!("hd{}", &uuid::Uuid::new_v4().simple().to_string()[..12]); + let payload = json!({ + "handle": short_handle, + "email": format!("{}@example.com", short_handle), + "password": "Testpass123!" + }); + let res = client + .post(format!( + "{}/xrpc/com.atproto.server.createAccount", + base + )) + .json(&payload) + .send() + .await + .expect("createAccount request failed"); + assert_eq!(res.status(), StatusCode::OK); + let body: Value = res.json().await.expect("Invalid JSON"); + let did = body["did"].as_str().expect("No DID").to_string(); + let access_jwt = verify_new_account(&client, &did).await; + + let new_short = format!("hd{}", &uuid::Uuid::new_v4().simple().to_string()[..12]); + let res = client + .post(format!( + "{}/xrpc/com.atproto.identity.updateHandle", + base + )) + .bearer_auth(&access_jwt) + .header(header::CONTENT_TYPE, "application/json") + .json(&json!({ "handle": new_short })) + .send() + .await + .expect("updateHandle request failed"); + assert_eq!( + res.status(), + StatusCode::OK, + "updateHandle failed: {:?}", + res.text().await + ); + + let res = client + .get(format!( + "{}/xrpc/com.atproto.identity.resolveHandle", + base + )) + .query(&[("handle", format!("{}.{}", new_short, HANDLE_DOMAIN))]) + .send() + .await + .expect("resolveHandle request failed"); + assert_eq!(res.status(), StatusCode::OK); + let body: Value = res.json().await.expect("Invalid JSON"); + assert_eq!( + body["did"], did, + "updateHandle with bare handle should use configured domain, not PDS hostname" + ); +} diff --git a/frontend/public/homepage.html b/frontend/public/homepage.html index 40354d6..a4b1091 100644 --- a/frontend/public/homepage.html +++ b/frontend/public/homepage.html @@ -428,7 +428,7 @@
Made by people who don't take themselves too seriously - Open Source: issues & PRs welcome + Open source & open hearts
@@ -485,14 +485,7 @@ }) .then(function (info) { var hostnameEl = document.getElementById("hostname"); - if ( - info.availableUserDomains && - info.availableUserDomains.length - ) { - hostnameEl.textContent = info.availableUserDomains[0]; - } else { - hostnameEl.textContent = "Tranquil PDS"; - } + hostnameEl.textContent = window.location.hostname; hostnameEl.classList.remove("placeholder"); if (info.version) { document.getElementById("version").textContent = @@ -501,7 +494,7 @@ }) .catch(function () { var hostnameEl = document.getElementById("hostname"); - hostnameEl.textContent = "Tranquil PDS"; + hostnameEl.textContent = window.location.hostname; hostnameEl.classList.remove("placeholder"); }); diff --git a/frontend/src/components/HandleInput.svelte b/frontend/src/components/HandleInput.svelte new file mode 100644 index 0000000..cb05fe5 --- /dev/null +++ b/frontend/src/components/HandleInput.svelte @@ -0,0 +1,71 @@ + + +
+ onInput((e.target as HTMLInputElement).value)} + /> + {#if showDomainSelect} + + {:else if domains.length === 1 && !value.includes('.')} + .{domains[0]} + {/if} +
+ + diff --git a/frontend/src/components/dashboard/SettingsContent.svelte b/frontend/src/components/dashboard/SettingsContent.svelte index 8e6ec37..ead11c5 100644 --- a/frontend/src/components/dashboard/SettingsContent.svelte +++ b/frontend/src/components/dashboard/SettingsContent.svelte @@ -9,6 +9,7 @@ import { getSessionEmail } from '../../lib/types/api' import { formatDate } from '../../lib/date' import { navigate, routes } from '../../lib/router.svelte' + import HandleInput from '../HandleInput.svelte' interface Props { session: Session @@ -17,14 +18,17 @@ let { session }: Props = $props() const supportedLocales = getSupportedLocales() - let pdsHostname = $state(null) + let availableDomains = $state([]) + let selectedDomain = $state('') + let pdsHostname = $derived(selectedDomain || null) onMount(() => { const init = async () => { try { const info = await api.describeServer() if (info.availableUserDomains?.length) { - pdsHostname = info.availableUserDomains[0] + availableDomains = info.availableUserDomains + selectedDomain = info.availableUserDomains[0] } } catch {} loadBackups() @@ -150,7 +154,7 @@ if (!newHandle) return handleLoading = true try { - const fullHandle = showBYOHandle ? newHandle : `${newHandle}.${pdsHostname}` + const fullHandle = showBYOHandle ? newHandle : `${newHandle}.${selectedDomain}` await api.updateHandle(session.accessJwt, unsafeAsHandle(fullHandle)) await refreshSession() toast.success($_('settings.messages.handleUpdated')) @@ -481,12 +485,18 @@
-
- - .{pdsHostname ?? '...'} -
+ { newHandle = v }} + onDomainChange={(d) => { selectedDomain = d }} + />
-
@@ -689,36 +699,6 @@ color: var(--text-inverse); } - .handle-input-wrapper { - display: flex; - align-items: center; - background: var(--bg-input); - border: 1px solid var(--border-color); - border-radius: var(--radius-md); - overflow: hidden; - } - - .handle-input-wrapper input { - flex: 1; - border: none; - border-radius: 0; - background: transparent; - } - - .handle-input-wrapper input:focus { - outline: none; - box-shadow: none; - } - - .handle-suffix { - padding: 0 var(--space-3); - color: var(--text-secondary); - font-size: var(--text-sm); - white-space: nowrap; - border-left: 1px solid var(--border-color); - background: var(--bg-card); - } - .loading, .empty { color: var(--text-secondary); diff --git a/frontend/src/components/migration/ChooseHandleStep.svelte b/frontend/src/components/migration/ChooseHandleStep.svelte index 8f35bba..2a081eb 100644 --- a/frontend/src/components/migration/ChooseHandleStep.svelte +++ b/frontend/src/components/migration/ChooseHandleStep.svelte @@ -1,6 +1,7 @@