Actually use the user handle domains
+8
.config/nextest.toml
+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
+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
+1
-1
Cargo.toml
-128
TODO.md
-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
+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
+2
-2
crates/tranquil-pds/src/api/admin/account/update.rs
···
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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
-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
+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
-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);
History
2 rounds
0 comments
expand 0 comments
pull request successfully merged