fix: user handle domains upgrade

This commit is contained in:
lewis
2026-03-08 12:25:04 +00:00
committed by Tangled
parent e02e8c9e8c
commit f78b004df3
22 changed files with 535 additions and 321 deletions
+8
View File
@@ -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"
Generated
+15 -15
View File
@@ -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",
+1 -1
View File
@@ -19,7 +19,7 @@ members = [
]
[workspace.package]
version = "0.2.1"
version = "0.3.0"
edition = "2024"
license = "AGPL-3.0-or-later"
-128
View File
@@ -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
+8 -1
View File
@@ -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<String> {
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<String> {
self.user_handle_domain_list()
}
}
#[derive(Debug, Config)]
@@ -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()
};
+12 -10
View File
@@ -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());
}
+17 -18
View File
@@ -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<u8>, Option<uuid::Uuid>) =
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)");
+18 -14
View File
@@ -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() {
@@ -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");
+6 -5
View File
@@ -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");
+278
View File
@@ -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"
);
}
+3 -10
View File
@@ -428,7 +428,7 @@
<footer class="site-footer">
<span>Made by people who don't take themselves too seriously</span>
<span>Open Source: issues & PRs welcome</span>
<span>Open source & open hearts</span>
</footer>
</div>
@@ -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");
});
@@ -0,0 +1,71 @@
<script lang="ts">
interface Props {
value: string
domains: string[]
selectedDomain: string
disabled?: boolean
placeholder?: string
id?: string
autocomplete?: string
onInput: (value: string) => void
onDomainChange: (domain: string) => void
}
let {
value,
domains,
selectedDomain,
disabled = false,
placeholder = 'username',
id = 'handle',
autocomplete = 'off',
onInput,
onDomainChange,
}: Props = $props()
const showDomainSelect = $derived(domains.length > 1 && !value.includes('.'))
</script>
<div class="handle-input-group">
<input
{id}
type="text"
value={value}
{placeholder}
{disabled}
autocomplete={autocomplete}
required
oninput={(e) => onInput((e.target as HTMLInputElement).value)}
/>
{#if showDomainSelect}
<select value={selectedDomain} onchange={(e) => onDomainChange((e.target as HTMLSelectElement).value)}>
{#each domains as domain}
<option value={domain}>.{domain}</option>
{/each}
</select>
{:else if domains.length === 1 && !value.includes('.')}
<span class="domain-suffix">.{domains[0]}</span>
{/if}
</div>
<style>
.handle-input-group {
display: flex;
gap: var(--space-2);
align-items: center;
}
.handle-input-group input {
flex: 1;
}
.handle-input-group select {
width: auto;
}
.domain-suffix {
color: var(--text-secondary);
font-size: var(--text-sm);
white-space: nowrap;
}
</style>
@@ -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<string | null>(null)
let availableDomains = $state<string[]>([])
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 @@
<form onsubmit={handleUpdateHandle}>
<div class="field">
<label for="new-handle">{$_('settings.newHandle')}</label>
<div class="handle-input-wrapper">
<input id="new-handle" type="text" bind:value={newHandle} placeholder={$_('settings.newHandlePlaceholder')} disabled={handleLoading} required />
<span class="handle-suffix">.{pdsHostname ?? '...'}</span>
</div>
<HandleInput
id="new-handle"
value={newHandle}
domains={availableDomains}
{selectedDomain}
placeholder={$_('settings.newHandlePlaceholder')}
disabled={handleLoading}
onInput={(v) => { newHandle = v }}
onDomainChange={(d) => { selectedDomain = d }}
/>
</div>
<button type="submit" disabled={handleLoading || !newHandle || !pdsHostname}>
<button type="submit" disabled={handleLoading || !newHandle || !selectedDomain}>
{handleLoading ? $_('settings.updating') : $_('settings.changeHandleButton')}
</button>
</form>
@@ -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);
@@ -1,6 +1,7 @@
<script lang="ts">
import type { AuthMethod, HandlePreservation, ServerDescription } from '../../lib/migration/types'
import { _ } from '../../lib/i18n'
import HandleInput from '../HandleInput.svelte'
interface Props {
handleInput: string
@@ -171,23 +172,15 @@
{:else}
<div class="field">
<label for="new-handle">{$_('migration.inbound.chooseHandle.newHandle')}</label>
<div class="handle-input-group">
<input
id="new-handle"
type="text"
placeholder="username"
value={handleInput}
oninput={(e) => onHandleChange((e.target as HTMLInputElement).value)}
onblur={onCheckHandle}
/>
{#if serverInfo && serverInfo.availableUserDomains.length > 0 && !handleInput.includes('.')}
<select value={selectedDomain} onchange={(e) => onDomainChange((e.target as HTMLSelectElement).value)}>
{#each serverInfo.availableUserDomains as domain}
<option value={domain}>.{domain}</option>
{/each}
</select>
{/if}
</div>
<HandleInput
id="new-handle"
value={handleInput}
domains={serverInfo?.availableUserDomains ?? []}
{selectedDomain}
placeholder="username"
onInput={onHandleChange}
onDomainChange={onDomainChange}
/>
{#if handleTooShort}
<p class="hint error">{$_('migration.inbound.chooseHandle.handleTooShort')}</p>
+11 -7
View File
@@ -16,6 +16,7 @@
type WebAuthnCreationOptionsResponse,
} from '../lib/webauthn'
import AccountTypeSwitcher from '../components/AccountTypeSwitcher.svelte'
import HandleInput from '../components/HandleInput.svelte'
let serverInfo = $state<{
availableUserDomains: string[]
@@ -30,6 +31,7 @@
let flow = $state<ReturnType<typeof createRegistrationFlow> | null>(null)
let passkeyName = $state('')
let clientName = $state<string | null>(null)
let selectedDomain = $state('')
function getRequestUri(): string | null {
const params = new URLSearchParams(window.location.search)
@@ -99,6 +101,7 @@
const hostname = serverInfo?.availableUserDomains?.[0] || window.location.hostname
flow = createRegistrationFlow('passkey', hostname)
}
selectedDomain = serverInfo?.availableUserDomains?.[0] || window.location.hostname
} catch (e) {
console.error('Failed to load server info:', e)
} finally {
@@ -262,7 +265,8 @@
let fullHandle = $derived(() => {
if (!flow?.info.handle.trim()) return ''
return `${flow.info.handle.trim()}.${flow.state.pdsHostname}`
if (flow.info.handle.includes('.')) return flow.info.handle.trim()
return selectedDomain ? `${flow.info.handle.trim()}.${selectedDomain}` : flow.info.handle.trim()
})
async function handleCancel() {
@@ -342,14 +346,14 @@
<form onsubmit={handleInfoSubmit}>
<div class="field">
<label for="handle">{$_('register.handle')}</label>
<input
id="handle"
type="text"
bind:value={flow.info.handle}
<HandleInput
value={flow.info.handle}
domains={serverInfo?.availableUserDomains ?? []}
{selectedDomain}
placeholder={$_('register.handlePlaceholder')}
disabled={flow.state.submitting}
required
autocomplete="off"
onInput={(v) => { flow!.info.handle = v }}
onDomainChange={(d) => { selectedDomain = d }}
/>
{#if fullHandle()}
<p class="hint">{$_('register.handleHint', { values: { handle: fullHandle() } })}</p>
+11 -8
View File
@@ -3,6 +3,7 @@
import { _ } from '../lib/i18n'
import { toast } from '../lib/toast.svelte'
import SsoIcon from '../components/SsoIcon.svelte'
import HandleInput from '../components/HandleInput.svelte'
interface PendingRegistration {
request_uri: string
@@ -37,6 +38,7 @@
let handleAvailable = $state<boolean | null>(null)
let checkingHandle = $state(false)
let handleError = $state<string | null>(null)
let selectedDomain = $state('')
let didType = $state<'plc' | 'web' | 'web-external'>('plc')
let externalDid = $state('')
@@ -80,8 +82,8 @@
let fullHandle = $derived(() => {
if (!handle.trim()) return ''
const domain = serverInfo?.availableUserDomains?.[0]
return domain ? `${handle.trim()}.${domain}` : handle.trim()
if (handle.includes('.')) return handle.trim()
return selectedDomain ? `${handle.trim()}.${selectedDomain}` : handle.trim()
})
onMount(() => {
@@ -106,6 +108,7 @@
telegram: available.includes('telegram'),
signal: available.includes('signal'),
}
selectedDomain = data.availableUserDomains?.[0] || window.location.hostname
}
} catch {
serverInfo = null
@@ -317,14 +320,14 @@
<form onsubmit={handleSubmit}>
<div class="field">
<label for="handle">{$_('sso_register.handle_label')}</label>
<input
id="handle"
type="text"
bind:value={handle}
<HandleInput
value={handle}
domains={serverInfo?.availableUserDomains ?? []}
{selectedDomain}
placeholder={$_('register.handlePlaceholder')}
disabled={submitting}
required
autocomplete="off"
onInput={(v) => { handle = v }}
onDomainChange={(d) => { selectedDomain = d }}
/>
{#if checkingHandle}
<p class="hint">{$_('common.checking')}</p>
+11 -7
View File
@@ -16,6 +16,7 @@
type WebAuthnCreationOptionsResponse,
} from '../lib/webauthn'
import AccountTypeSwitcher from '../components/AccountTypeSwitcher.svelte'
import HandleInput from '../components/HandleInput.svelte'
import { ensureRequestUri, getRequestUriFromUrl } from '../lib/oauth'
let serverInfo = $state<{
@@ -31,6 +32,7 @@
let flow = $state<ReturnType<typeof createRegistrationFlow> | null>(null)
let passkeyName = $state('')
let clientName = $state<string | null>(null)
let selectedDomain = $state('')
let checkHandleTimeout: ReturnType<typeof setTimeout> | null = null
$effect(() => {
@@ -112,6 +114,7 @@
const hostname = serverInfo?.availableUserDomains?.[0] || window.location.hostname
flow = createRegistrationFlow('passkey', hostname)
}
selectedDomain = serverInfo?.availableUserDomains?.[0] || window.location.hostname
} catch (e) {
console.error('Failed to load server info:', e)
} finally {
@@ -276,7 +279,8 @@
let fullHandle = $derived(() => {
if (!flow?.info.handle.trim()) return ''
return `${flow.info.handle.trim()}.${flow.state.pdsHostname}`
if (flow.info.handle.includes('.')) return flow.info.handle.trim()
return selectedDomain ? `${flow.info.handle.trim()}.${selectedDomain}` : flow.info.handle.trim()
})
async function handleCancel() {
@@ -357,14 +361,14 @@
<form class="register-form" onsubmit={handleInfoSubmit}>
<div class="field">
<label for="handle">{$_('register.handle')}</label>
<input
id="handle"
type="text"
bind:value={flow.info.handle}
<HandleInput
value={flow.info.handle}
domains={serverInfo?.availableUserDomains ?? []}
{selectedDomain}
placeholder={$_('register.handlePlaceholder')}
disabled={flow.state.submitting}
required
autocomplete="off"
onInput={(v) => { flow!.info.handle = v }}
onDomainChange={(d) => { selectedDomain = d }}
/>
{#if flow.info.handle.includes('.')}
<p class="hint warning">{$_('register.handleDotWarning')}</p>
+10 -8
View File
@@ -10,6 +10,7 @@
DidDocStep,
} from '../lib/registration'
import AccountTypeSwitcher from '../components/AccountTypeSwitcher.svelte'
import HandleInput from '../components/HandleInput.svelte'
import { ensureRequestUri, getRequestUriFromUrl } from '../lib/oauth'
let serverInfo = $state<{
@@ -25,6 +26,7 @@
let flow = $state<ReturnType<typeof createRegistrationFlow> | null>(null)
let confirmPassword = $state('')
let clientName = $state<string | null>(null)
let selectedDomain = $state('')
let checkHandleTimeout: ReturnType<typeof setTimeout> | null = null
$effect(() => {
@@ -106,6 +108,7 @@
const hostname = serverInfo?.availableUserDomains?.[0] || window.location.hostname
flow = createRegistrationFlow('password', hostname)
}
selectedDomain = serverInfo?.availableUserDomains?.[0] || window.location.hostname
} catch (e) {
console.error('Failed to load server info:', e)
} finally {
@@ -229,9 +232,7 @@
let fullHandle = $derived(() => {
if (!flow?.info.handle.trim()) return ''
if (flow.info.handle.includes('.')) return flow.info.handle.trim()
const domain = serverInfo?.availableUserDomains?.[0]
if (domain) return `${flow.info.handle.trim()}.${domain}`
return flow.info.handle.trim()
return selectedDomain ? `${flow.info.handle.trim()}.${selectedDomain}` : flow.info.handle.trim()
})
function extractDomain(did: string): string {
@@ -305,13 +306,14 @@
<form class="register-form" onsubmit={handleInfoSubmit}>
<div class="field">
<label for="handle">{$_('register.handle')}</label>
<input
id="handle"
type="text"
bind:value={flow.info.handle}
<HandleInput
value={flow.info.handle}
domains={serverInfo?.availableUserDomains ?? []}
{selectedDomain}
placeholder={$_('register.handlePlaceholder')}
disabled={flow.state.submitting}
required
onInput={(v) => { flow!.info.handle = v }}
onDomainChange={(d) => { selectedDomain = d }}
/>
{#if flow.info.handle.includes('.')}
<p class="hint warning">{$_('register.handleDotWarning')}</p>
+11 -8
View File
@@ -3,6 +3,7 @@
import { _ } from '../lib/i18n'
import { toast } from '../lib/toast.svelte'
import SsoIcon from '../components/SsoIcon.svelte'
import HandleInput from '../components/HandleInput.svelte'
interface PendingRegistration {
request_uri: string
@@ -47,6 +48,7 @@
let handleAvailable = $state<boolean | null>(null)
let checkingHandle = $state(false)
let handleError = $state<string | null>(null)
let selectedDomain = $state('')
let didType = $state<'plc' | 'web' | 'web-external'>('plc')
let externalDid = $state('')
@@ -95,8 +97,8 @@
let fullHandle = $derived(() => {
if (!handle.trim()) return ''
const domain = serverInfo?.availableUserDomains?.[0]
return domain ? `${handle.trim()}.${domain}` : handle.trim()
if (handle.includes('.')) return handle.trim()
return selectedDomain ? `${handle.trim()}.${selectedDomain}` : handle.trim()
})
onMount(() => {
@@ -121,6 +123,7 @@
telegram: available.includes('telegram'),
signal: available.includes('signal'),
}
selectedDomain = data.availableUserDomains?.[0] || window.location.hostname
}
} catch {
serverInfo = null
@@ -390,14 +393,14 @@
<form onsubmit={handleSubmit}>
<div class="field">
<label for="handle">{$_('sso_register.handle_label')}</label>
<input
id="handle"
type="text"
bind:value={handle}
<HandleInput
value={handle}
domains={serverInfo?.availableUserDomains ?? []}
{selectedDomain}
placeholder={$_('register.handlePlaceholder')}
disabled={submitting}
required
autocomplete="off"
onInput={(v) => { handle = v }}
onDomainChange={(d) => { selectedDomain = d }}
/>
{#if checkingHandle}
<p class="hint">{$_('common.checking')}</p>
-13
View File
@@ -170,19 +170,6 @@
margin-top: var(--space-5);
}
.handle-input-group {
display: flex;
gap: var(--space-2);
}
.handle-input-group input {
flex: 1;
}
.handle-input-group select {
width: auto;
}
.current-info {
background: var(--bg-primary);
border-radius: var(--radius-lg);