An easy-to-host PDS on the ATProtocol, MacOS. Grandma-approved.

feat(relay): implement GET /xrpc/com.atproto.server.describeServer (MM-74)

Adds the ATProto service discovery endpoint required by Bluesky clients
during login. Config is extended with available_user_domains (required),
invite_code_required (default true), optional server_did, links, and
contact sections. The handler derives did:web:<host> from public_url as
a placeholder until Wave 3 generates a real DID.

authored by malpercio.dev and committed by

Tangled cc7f0292 8545763d

+502 -3
+208
crates/common/src/config.rs
··· 10 10 pub data_dir: PathBuf, 11 11 pub database_url: String, 12 12 pub public_url: String, 13 + pub server_did: Option<String>, 14 + pub available_user_domains: Vec<String>, 15 + pub invite_code_required: bool, 16 + pub links: ServerLinksConfig, 17 + pub contact: ContactConfig, 13 18 pub blobs: BlobsConfig, 14 19 pub oauth: OAuthConfig, 15 20 pub iroh: IrohConfig, 16 21 } 17 22 23 + /// Optional privacy/ToS links surfaced by `com.atproto.server.describeServer`. 24 + #[derive(Debug, Clone, Deserialize, Default)] 25 + pub struct ServerLinksConfig { 26 + pub privacy_policy: Option<String>, 27 + pub terms_of_service: Option<String>, 28 + } 29 + 30 + /// Optional admin contact surfaced by `com.atproto.server.describeServer`. 31 + #[derive(Debug, Clone, Deserialize, Default)] 32 + pub struct ContactConfig { 33 + pub email: Option<String>, 34 + } 35 + 18 36 /// Stub for future blob storage configuration. 19 37 #[derive(Debug, Clone, Deserialize, Default)] 20 38 pub struct BlobsConfig {} ··· 35 53 pub(crate) data_dir: Option<String>, 36 54 pub(crate) database_url: Option<String>, 37 55 pub(crate) public_url: Option<String>, 56 + pub(crate) server_did: Option<String>, 57 + pub(crate) available_user_domains: Option<Vec<String>>, 58 + pub(crate) invite_code_required: Option<bool>, 59 + #[serde(default)] 60 + pub(crate) links: ServerLinksConfig, 61 + #[serde(default)] 62 + pub(crate) contact: ContactConfig, 38 63 #[serde(default)] 39 64 pub(crate) blobs: BlobsConfig, 40 65 #[serde(default)] ··· 84 109 if let Some(v) = env.get("EZPDS_PUBLIC_URL") { 85 110 raw.public_url = Some(v.clone()); 86 111 } 112 + if let Some(v) = env.get("EZPDS_SERVER_DID") { 113 + raw.server_did = Some(v.clone()); 114 + } 115 + if let Some(v) = env.get("EZPDS_INVITE_CODE_REQUIRED") { 116 + raw.invite_code_required = Some(v.parse::<bool>().map_err(|e| { 117 + ConfigError::Invalid(format!( 118 + "EZPDS_INVITE_CODE_REQUIRED is not a valid boolean: '{v}': {e}" 119 + )) 120 + })?); 121 + } 122 + if let Some(v) = env.get("EZPDS_AVAILABLE_USER_DOMAINS") { 123 + raw.available_user_domains = Some( 124 + v.split(',') 125 + .map(str::trim) 126 + .filter(|s| !s.is_empty()) 127 + .map(str::to_string) 128 + .collect(), 129 + ); 130 + } 87 131 Ok(raw) 88 132 } 89 133 ··· 115 159 let public_url = raw.public_url.ok_or(ConfigError::MissingField { 116 160 field: "public_url", 117 161 })?; 162 + let available_user_domains = raw.available_user_domains.unwrap_or_default(); 163 + if available_user_domains.is_empty() { 164 + return Err(ConfigError::MissingField { 165 + field: "available_user_domains", 166 + }); 167 + } 168 + let invite_code_required = raw.invite_code_required.unwrap_or(true); 118 169 119 170 Ok(Config { 120 171 bind_address, ··· 122 173 data_dir, 123 174 database_url, 124 175 public_url, 176 + server_did: raw.server_did, 177 + available_user_domains, 178 + invite_code_required, 179 + links: raw.links, 180 + contact: raw.contact, 125 181 blobs: raw.blobs, 126 182 oauth: raw.oauth, 127 183 iroh: raw.iroh, ··· 136 192 RawConfig { 137 193 data_dir: Some("/var/pds".to_string()), 138 194 public_url: Some("https://pds.example.com".to_string()), 195 + available_user_domains: Some(vec!["example.com".to_string()]), 139 196 ..Default::default() 140 197 } 141 198 } ··· 145 202 let toml = r#" 146 203 data_dir = "/var/pds" 147 204 public_url = "https://pds.example.com" 205 + available_user_domains = ["example.com"] 148 206 "#; 149 207 let raw: RawConfig = toml::from_str(toml).unwrap(); 150 208 let config = validate_and_build(raw).unwrap(); ··· 164 222 data_dir = "/data" 165 223 database_url = "sqlite:///data/custom.db" 166 224 public_url = "https://pds.example.com" 225 + available_user_domains = ["example.com"] 167 226 "#; 168 227 let raw: RawConfig = toml::from_str(toml).unwrap(); 169 228 let config = validate_and_build(raw).unwrap(); ··· 179 238 let toml = r#" 180 239 data_dir = "/var/pds" 181 240 public_url = "https://pds.example.com" 241 + available_user_domains = ["example.com"] 182 242 183 243 [blobs] 184 244 ··· 214 274 data_dir = "/var/pds" 215 275 port = 3000 216 276 public_url = "https://pds.example.com" 277 + available_user_domains = ["example.com"] 217 278 "#; 218 279 let raw: RawConfig = toml::from_str(toml).unwrap(); 219 280 let env = HashMap::from([("EZPDS_PORT".to_string(), "9999".to_string())]); ··· 236 297 ( 237 298 "EZPDS_PUBLIC_URL".to_string(), 238 299 "https://pds.test".to_string(), 300 + ), 301 + ( 302 + "EZPDS_AVAILABLE_USER_DOMAINS".to_string(), 303 + "pds.test".to_string(), 239 304 ), 240 305 ]); 241 306 let raw = apply_env_overrides(RawConfig::default(), &env).unwrap(); ··· 286 351 field: "public_url" 287 352 } 288 353 )); 354 + } 355 + 356 + // --- describeServer config fields --- 357 + 358 + #[test] 359 + fn parses_describe_server_fields_from_toml() { 360 + let toml = r#" 361 + data_dir = "/var/pds" 362 + public_url = "https://pds.example.com" 363 + server_did = "did:plc:abc123" 364 + available_user_domains = ["pds.example.com", "alt.example.com"] 365 + invite_code_required = false 366 + 367 + [links] 368 + privacy_policy = "https://example.com/privacy" 369 + terms_of_service = "https://example.com/tos" 370 + 371 + [contact] 372 + email = "admin@example.com" 373 + "#; 374 + let raw: RawConfig = toml::from_str(toml).unwrap(); 375 + let config = validate_and_build(raw).unwrap(); 376 + 377 + assert_eq!(config.server_did.as_deref(), Some("did:plc:abc123")); 378 + assert_eq!( 379 + config.available_user_domains, 380 + vec!["pds.example.com", "alt.example.com"] 381 + ); 382 + assert!(!config.invite_code_required); 383 + assert_eq!( 384 + config.links.privacy_policy.as_deref(), 385 + Some("https://example.com/privacy") 386 + ); 387 + assert_eq!( 388 + config.links.terms_of_service.as_deref(), 389 + Some("https://example.com/tos") 390 + ); 391 + assert_eq!(config.contact.email.as_deref(), Some("admin@example.com")); 392 + } 393 + 394 + #[test] 395 + fn available_user_domains_missing_returns_error() { 396 + let raw = RawConfig { 397 + data_dir: Some("/var/pds".to_string()), 398 + public_url: Some("https://pds.example.com".to_string()), 399 + ..Default::default() 400 + }; 401 + let err = validate_and_build(raw).unwrap_err(); 402 + 403 + assert!(matches!( 404 + err, 405 + ConfigError::MissingField { 406 + field: "available_user_domains" 407 + } 408 + )); 409 + } 410 + 411 + #[test] 412 + fn available_user_domains_empty_returns_error() { 413 + let raw = RawConfig { 414 + data_dir: Some("/var/pds".to_string()), 415 + public_url: Some("https://pds.example.com".to_string()), 416 + available_user_domains: Some(vec![]), 417 + ..Default::default() 418 + }; 419 + let err = validate_and_build(raw).unwrap_err(); 420 + 421 + assert!(matches!( 422 + err, 423 + ConfigError::MissingField { 424 + field: "available_user_domains" 425 + } 426 + )); 427 + } 428 + 429 + #[test] 430 + fn invite_code_required_defaults_to_true() { 431 + let config = validate_and_build(minimal_raw()).unwrap(); 432 + assert!(config.invite_code_required); 433 + } 434 + 435 + #[test] 436 + fn server_did_is_optional() { 437 + let config = validate_and_build(minimal_raw()).unwrap(); 438 + assert!(config.server_did.is_none()); 439 + } 440 + 441 + #[test] 442 + fn links_section_optional() { 443 + let config = validate_and_build(minimal_raw()).unwrap(); 444 + assert!(config.links.privacy_policy.is_none()); 445 + assert!(config.links.terms_of_service.is_none()); 446 + } 447 + 448 + #[test] 449 + fn contact_section_optional() { 450 + let config = validate_and_build(minimal_raw()).unwrap(); 451 + assert!(config.contact.email.is_none()); 452 + } 453 + 454 + #[test] 455 + fn env_override_server_did() { 456 + let env = HashMap::from([("EZPDS_SERVER_DID".to_string(), "did:plc:xyz".to_string())]); 457 + let raw = apply_env_overrides(minimal_raw(), &env).unwrap(); 458 + let config = validate_and_build(raw).unwrap(); 459 + 460 + assert_eq!(config.server_did.as_deref(), Some("did:plc:xyz")); 461 + } 462 + 463 + #[test] 464 + fn env_override_invite_code_required_false() { 465 + let env = HashMap::from([( 466 + "EZPDS_INVITE_CODE_REQUIRED".to_string(), 467 + "false".to_string(), 468 + )]); 469 + let raw = apply_env_overrides(minimal_raw(), &env).unwrap(); 470 + let config = validate_and_build(raw).unwrap(); 471 + 472 + assert!(!config.invite_code_required); 473 + } 474 + 475 + #[test] 476 + fn env_override_invite_code_required_invalid_returns_error() { 477 + let env = HashMap::from([( 478 + "EZPDS_INVITE_CODE_REQUIRED".to_string(), 479 + "maybe".to_string(), 480 + )]); 481 + let err = apply_env_overrides(minimal_raw(), &env).unwrap_err(); 482 + 483 + assert!(matches!(err, ConfigError::Invalid(_))); 484 + assert!(err.to_string().contains("EZPDS_INVITE_CODE_REQUIRED")); 485 + } 486 + 487 + #[test] 488 + fn env_override_available_user_domains_comma_separated() { 489 + let env = HashMap::from([( 490 + "EZPDS_AVAILABLE_USER_DOMAINS".to_string(), 491 + "foo.com, bar.com".to_string(), 492 + )]); 493 + let raw = apply_env_overrides(minimal_raw(), &env).unwrap(); 494 + let config = validate_and_build(raw).unwrap(); 495 + 496 + assert_eq!(config.available_user_domains, vec!["foo.com", "bar.com"]); 289 497 } 290 498 }
+4 -2
crates/common/src/config_loader.rs
··· 60 60 writeln!( 61 61 tmp, 62 62 r#"data_dir = "/var/pds" 63 - public_url = "https://pds.example.com""# 63 + public_url = "https://pds.example.com" 64 + available_user_domains = ["example.com"]"# 64 65 ) 65 66 .unwrap(); 66 67 ··· 90 91 writeln!( 91 92 tmp, 92 93 r#"data_dir = "/var/pds" 93 - public_url = "https://pds.example.com""# 94 + public_url = "https://pds.example.com" 95 + available_user_domains = ["example.com"]"# 94 96 ) 95 97 .unwrap(); 96 98 let env = HashMap::from([("EZPDS_PORT".to_string(), "9999".to_string())]);
+3 -1
crates/common/src/lib.rs
··· 2 2 mod config_loader; 3 3 mod error; 4 4 5 - pub use config::{BlobsConfig, Config, ConfigError, IrohConfig, OAuthConfig}; 5 + pub use config::{ 6 + BlobsConfig, Config, ConfigError, ContactConfig, IrohConfig, OAuthConfig, ServerLinksConfig, 7 + }; 6 8 pub use config_loader::load_config; 7 9 pub use error::{ApiError, ErrorCode};
+10
crates/relay/src/app.rs
··· 4 4 use common::{ApiError, Config, ErrorCode}; 5 5 use tower_http::{cors::CorsLayer, trace::TraceLayer}; 6 6 7 + use crate::routes::describe_server::describe_server; 7 8 use crate::routes::health::health; 8 9 9 10 /// Shared application state cloned into every request handler via Axum's `State` extractor. ··· 21 22 pub fn app(state: AppState) -> Router { 22 23 Router::new() 23 24 .route("/xrpc/_health", get(health)) 25 + .route( 26 + "/xrpc/com.atproto.server.describeServer", 27 + get(describe_server), 28 + ) 24 29 .route("/xrpc/:method", get(xrpc_handler).post(xrpc_handler)) 25 30 .layer(CorsLayer::permissive()) 26 31 .layer(TraceLayer::new_for_http()) ··· 58 63 data_dir: PathBuf::from("/tmp"), 59 64 database_url: "sqlite::memory:".to_string(), 60 65 public_url: "https://test.example.com".to_string(), 66 + server_did: None, 67 + available_user_domains: vec!["test.example.com".to_string()], 68 + invite_code_required: true, 69 + links: common::ServerLinksConfig::default(), 70 + contact: common::ContactConfig::default(), 61 71 blobs: BlobsConfig::default(), 62 72 oauth: OAuthConfig::default(), 63 73 iroh: IrohConfig::default(),
+260
crates/relay/src/routes/describe_server.rs
··· 1 + // pattern: Imperative Shell 2 + // 3 + // Gathers: server metadata from config 4 + // Processes: none (response shape maps 1:1 from config fields) 5 + // Returns: JSON matching com.atproto.server.describeServer Lexicon 6 + 7 + use axum::{ 8 + extract::State, 9 + response::{IntoResponse, Json}, 10 + }; 11 + use serde::Serialize; 12 + 13 + use crate::app::AppState; 14 + 15 + #[derive(Serialize)] 16 + #[serde(rename_all = "camelCase")] 17 + struct DescribeServerResponse { 18 + did: String, 19 + available_user_domains: Vec<String>, 20 + invite_code_required: bool, 21 + phone_verification_required: bool, 22 + #[serde(skip_serializing_if = "Option::is_none")] 23 + links: Option<ServerLinks>, 24 + #[serde(skip_serializing_if = "Option::is_none")] 25 + contact: Option<Contact>, 26 + } 27 + 28 + #[derive(Serialize)] 29 + #[serde(rename_all = "camelCase")] 30 + struct ServerLinks { 31 + #[serde(skip_serializing_if = "Option::is_none")] 32 + privacy_policy: Option<String>, 33 + #[serde(skip_serializing_if = "Option::is_none")] 34 + terms_of_service: Option<String>, 35 + } 36 + 37 + #[derive(Serialize)] 38 + struct Contact { 39 + #[serde(skip_serializing_if = "Option::is_none")] 40 + email: Option<String>, 41 + } 42 + 43 + /// Resolve the DID to return in the `did` field. 44 + /// 45 + /// The ATProto Lexicon marks `did` as required, but in Wave 1 the server DID may not be 46 + /// configured — DID generation is deferred to Wave 3. This function decides what to surface 47 + /// in the meantime. 48 + /// 49 + /// TODO: implement this function (5-10 lines). 50 + /// 51 + /// Parameters: 52 + /// - `server_did`: the configured `server_did` value, if any 53 + /// - `public_url`: the server's configured public URL (e.g. "https://pds.example.com") 54 + /// 55 + /// Consider the trade-offs: 56 + /// - Returning `""` is the simplest option; the Bluesky app tolerates it during initial setup 57 + /// - Deriving `did:web:<hostname>` from `public_url` is a valid DID and more semantically correct 58 + /// - Whatever you choose becomes the pattern for how Wave 3 replaces the placeholder 59 + fn resolve_did(server_did: &Option<String>, public_url: &str) -> String { 60 + if let Some(did) = server_did { 61 + return did.clone(); 62 + } 63 + let host = public_url 64 + .strip_prefix("https://") 65 + .or_else(|| public_url.strip_prefix("http://")) 66 + .unwrap_or(public_url) 67 + .split('/') 68 + .next() 69 + .unwrap_or(""); 70 + format!("did:web:{host}") 71 + } 72 + 73 + pub async fn describe_server(State(state): State<AppState>) -> impl IntoResponse { 74 + let config = &state.config; 75 + 76 + let links = if config.links.privacy_policy.is_some() || config.links.terms_of_service.is_some() 77 + { 78 + Some(ServerLinks { 79 + privacy_policy: config.links.privacy_policy.clone(), 80 + terms_of_service: config.links.terms_of_service.clone(), 81 + }) 82 + } else { 83 + None 84 + }; 85 + 86 + let contact = config.contact.email.as_ref().map(|email| Contact { 87 + email: Some(email.clone()), 88 + }); 89 + 90 + Json(DescribeServerResponse { 91 + did: resolve_did(&config.server_did, &config.public_url), 92 + available_user_domains: config.available_user_domains.clone(), 93 + invite_code_required: config.invite_code_required, 94 + phone_verification_required: false, 95 + links, 96 + contact, 97 + }) 98 + } 99 + 100 + #[cfg(test)] 101 + mod tests { 102 + use axum::{ 103 + body::Body, 104 + http::{Request, StatusCode}, 105 + }; 106 + use tower::ServiceExt; 107 + 108 + use crate::app::{app, test_state}; 109 + 110 + #[test] 111 + fn resolve_did_returns_configured_did() { 112 + let did = super::resolve_did(&Some("did:plc:abc123".to_string()), "https://pds.example.com"); 113 + assert_eq!(did, "did:plc:abc123"); 114 + } 115 + 116 + #[test] 117 + fn resolve_did_derives_did_web_from_public_url() { 118 + let did = super::resolve_did(&None, "https://pds.example.com"); 119 + assert_eq!(did, "did:web:pds.example.com"); 120 + } 121 + 122 + #[test] 123 + fn resolve_did_did_web_strips_path() { 124 + let did = super::resolve_did(&None, "https://pds.example.com/some/path"); 125 + assert_eq!(did, "did:web:pds.example.com"); 126 + } 127 + 128 + #[tokio::test] 129 + async fn describe_server_returns_200() { 130 + let response = app(test_state().await) 131 + .oneshot( 132 + Request::builder() 133 + .uri("/xrpc/com.atproto.server.describeServer") 134 + .body(Body::empty()) 135 + .unwrap(), 136 + ) 137 + .await 138 + .unwrap(); 139 + 140 + assert_eq!(response.status(), StatusCode::OK); 141 + } 142 + 143 + #[tokio::test] 144 + async fn describe_server_has_json_content_type() { 145 + let response = app(test_state().await) 146 + .oneshot( 147 + Request::builder() 148 + .uri("/xrpc/com.atproto.server.describeServer") 149 + .body(Body::empty()) 150 + .unwrap(), 151 + ) 152 + .await 153 + .unwrap(); 154 + 155 + assert_eq!( 156 + response.headers().get("content-type").unwrap(), 157 + "application/json" 158 + ); 159 + } 160 + 161 + #[tokio::test] 162 + async fn describe_server_available_user_domains_from_config() { 163 + let response = app(test_state().await) 164 + .oneshot( 165 + Request::builder() 166 + .uri("/xrpc/com.atproto.server.describeServer") 167 + .body(Body::empty()) 168 + .unwrap(), 169 + ) 170 + .await 171 + .unwrap(); 172 + 173 + let body = axum::body::to_bytes(response.into_body(), 4096) 174 + .await 175 + .unwrap(); 176 + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); 177 + 178 + assert_eq!(json["availableUserDomains"][0], "test.example.com"); 179 + } 180 + 181 + #[tokio::test] 182 + async fn describe_server_invite_code_required_from_config() { 183 + let response = app(test_state().await) 184 + .oneshot( 185 + Request::builder() 186 + .uri("/xrpc/com.atproto.server.describeServer") 187 + .body(Body::empty()) 188 + .unwrap(), 189 + ) 190 + .await 191 + .unwrap(); 192 + 193 + let body = axum::body::to_bytes(response.into_body(), 4096) 194 + .await 195 + .unwrap(); 196 + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); 197 + 198 + assert!(json["inviteCodeRequired"].as_bool().unwrap()); 199 + } 200 + 201 + #[tokio::test] 202 + async fn describe_server_phone_verification_required_is_false() { 203 + let response = app(test_state().await) 204 + .oneshot( 205 + Request::builder() 206 + .uri("/xrpc/com.atproto.server.describeServer") 207 + .body(Body::empty()) 208 + .unwrap(), 209 + ) 210 + .await 211 + .unwrap(); 212 + 213 + let body = axum::body::to_bytes(response.into_body(), 4096) 214 + .await 215 + .unwrap(); 216 + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); 217 + 218 + assert!(!json["phoneVerificationRequired"].as_bool().unwrap()); 219 + } 220 + 221 + #[tokio::test] 222 + async fn describe_server_omits_links_when_not_configured() { 223 + let response = app(test_state().await) 224 + .oneshot( 225 + Request::builder() 226 + .uri("/xrpc/com.atproto.server.describeServer") 227 + .body(Body::empty()) 228 + .unwrap(), 229 + ) 230 + .await 231 + .unwrap(); 232 + 233 + let body = axum::body::to_bytes(response.into_body(), 4096) 234 + .await 235 + .unwrap(); 236 + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); 237 + 238 + assert!(json.get("links").is_none()); 239 + } 240 + 241 + #[tokio::test] 242 + async fn describe_server_omits_contact_when_not_configured() { 243 + let response = app(test_state().await) 244 + .oneshot( 245 + Request::builder() 246 + .uri("/xrpc/com.atproto.server.describeServer") 247 + .body(Body::empty()) 248 + .unwrap(), 249 + ) 250 + .await 251 + .unwrap(); 252 + 253 + let body = axum::body::to_bytes(response.into_body(), 4096) 254 + .await 255 + .unwrap(); 256 + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); 257 + 258 + assert!(json.get("contact").is_none()); 259 + } 260 + }
+1
crates/relay/src/routes/mod.rs
··· 1 + pub mod describe_server; 1 2 pub mod health;
+16
relay.dev.toml
··· 11 11 bind_address = "127.0.0.1" 12 12 port = 8080 13 13 database_url = ":memory:" 14 + 15 + # Required: at least one handle domain your PDS serves (e.g. ["your.domain"]) 16 + available_user_domains = [] 17 + invite_code_required = true 18 + 19 + # Optional: set to your server's DID once generated in Wave 3 20 + # server_did = "did:plc:..." 21 + 22 + # Optional: legal links 23 + # [links] 24 + # privacy_policy = "https://example.com/privacy" 25 + # terms_of_service = "https://example.com/tos" 26 + 27 + # Optional: admin contact 28 + # [contact] 29 + # email = "admin@example.com"