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

feat(MM-97): add HTTP well-known fallback to resolveHandle

Extends handle resolution to three-step priority chain:
local DB → DNS TXT → HTTP well-known (/.well-known/atproto-did).

Adds WellKnownResolver trait and HttpWellKnownResolver production impl
to dns.rs. Reverts atproto-identity dep (0.8 API uses concrete
TokioResolver, not injectable), restoring hickory-resolver for DNS TXT.

Wires HttpWellKnownResolver into AppState at startup; tests use
FixedWellKnownResolver mock. Covers: well-known resolves DID, returns
404 when absent, DNS takes priority over well-known.

authored by malpercio.dev and committed by

Tangled ef26a124 e9384b65

+178 -5
+1 -1
Cargo.toml
··· 69 69 subtle = "2" 70 70 uuid = { version = "1", features = ["v4"] } 71 71 72 - # DNS resolver (relay — handle resolution fallback via TXT records) 72 + # ATProto handle resolution — DNS TXT fallback (relay) 73 73 hickory-resolver = { version = "0.25", features = ["tokio", "system-config"] } 74 74 75 75 # Testing
+6 -1
crates/relay/src/app.rs
··· 12 12 use tower_http::{cors::CorsLayer, trace::TraceLayer}; 13 13 use tracing_opentelemetry::OpenTelemetrySpanExt; 14 14 15 - use crate::dns::{DnsProvider, TxtResolver}; 15 + use crate::dns::{DnsProvider, TxtResolver, WellKnownResolver}; 16 16 use crate::routes::claim_codes::claim_codes; 17 17 use crate::routes::create_account::create_account; 18 18 use crate::routes::create_did::create_did_handler; ··· 90 90 /// When `None`, `resolveHandle` skips DNS and returns `HandleNotFound` for 91 91 /// handles not present in the local database. 92 92 pub txt_resolver: Option<Arc<dyn TxtResolver>>, 93 + /// Optional HTTP well-known resolver for handle resolution fallback. 94 + /// Used as the third step after local DB and DNS TXT: calls 95 + /// `GET https://<handle>/.well-known/atproto-did`. 96 + pub well_known_resolver: Option<Arc<dyn WellKnownResolver>>, 93 97 } 94 98 95 99 /// Build the Axum router with middleware and routes. ··· 175 179 http_client, 176 180 dns_provider: None, 177 181 txt_resolver: None, 182 + well_known_resolver: None, 178 183 } 179 184 } 180 185
+63
crates/relay/src/dns.rs
··· 7 7 // TxtResolver — resolves DNS TXT records for handle lookup fallback 8 8 // (GET /xrpc/com.atproto.identity.resolveHandle). 9 9 // HickoryTxtResolver is the production implementation; tests inject mocks. 10 + // 11 + // WellKnownResolver — resolves handles via HTTP GET /.well-known/atproto-did. 12 + // Used as third fallback after local DB and DNS TXT. 13 + // HttpWellKnownResolver is the production implementation; tests inject mocks. 10 14 11 15 use std::future::Future; 12 16 use std::pin::Pin; ··· 72 76 } 73 77 } 74 78 Ok(results) 79 + }) 80 + } 81 + } 82 + 83 + /// Error returned by a [`WellKnownResolver`] operation. 84 + #[derive(Debug, thiserror::Error)] 85 + #[error("HTTP well-known error: {0}")] 86 + pub struct WellKnownError(pub String); 87 + 88 + /// Abstraction over HTTP well-known handle resolution. 89 + /// 90 + /// Used by `resolveHandle` as the third fallback after local DB and DNS TXT. 91 + /// Calls `GET https://<handle>/.well-known/atproto-did` and returns the DID 92 + /// from the response body, or `None` if the endpoint doesn't exist / returns non-2xx. 93 + /// 94 + /// Object-safe: uses `Pin<Box<dyn Future>>` so `dyn WellKnownResolver` works with `Arc`. 95 + pub trait WellKnownResolver: Send + Sync { 96 + /// Attempt to resolve a handle via its `/.well-known/atproto-did` endpoint. 97 + /// 98 + /// Returns `Ok(Some(did))` on success, `Ok(None)` if the endpoint is absent 99 + /// or returns non-2xx, and `Err` only on transport-level failures. 100 + fn resolve<'a>( 101 + &'a self, 102 + handle: &'a str, 103 + ) -> Pin<Box<dyn Future<Output = Result<Option<String>, WellKnownError>> + Send + 'a>>; 104 + } 105 + 106 + /// Production [`WellKnownResolver`] that calls `https://<handle>/.well-known/atproto-did`. 107 + pub struct HttpWellKnownResolver { 108 + client: reqwest::Client, 109 + } 110 + 111 + impl HttpWellKnownResolver { 112 + pub fn new(client: reqwest::Client) -> Self { 113 + Self { client } 114 + } 115 + } 116 + 117 + impl WellKnownResolver for HttpWellKnownResolver { 118 + fn resolve<'a>( 119 + &'a self, 120 + handle: &'a str, 121 + ) -> Pin<Box<dyn Future<Output = Result<Option<String>, WellKnownError>> + Send + 'a>> { 122 + let url = format!("https://{}/.well-known/atproto-did", handle); 123 + Box::pin(async move { 124 + let resp = self 125 + .client 126 + .get(&url) 127 + .send() 128 + .await 129 + .map_err(|e| WellKnownError(e.to_string()))?; 130 + if !resp.status().is_success() { 131 + return Ok(None); 132 + } 133 + let text = resp 134 + .text() 135 + .await 136 + .map_err(|e| WellKnownError(e.to_string()))?; 137 + Ok(Some(text.trim().to_string())) 75 138 }) 76 139 } 77 140 }
+4
crates/relay/src/main.rs
··· 118 118 } 119 119 }; 120 120 121 + let well_known_resolver: Option<Arc<dyn dns::WellKnownResolver>> = 122 + Some(Arc::new(dns::HttpWellKnownResolver::new(http_client.clone()))); 123 + 121 124 let state = app::AppState { 122 125 config: Arc::new(config), 123 126 db: pool, 124 127 http_client, 125 128 dns_provider: None, 126 129 txt_resolver, 130 + well_known_resolver, 127 131 }; 128 132 129 133 let listener = tokio::net::TcpListener::bind(&addr)
+1
crates/relay/src/routes/auth.rs
··· 220 220 http_client: base.http_client, 221 221 dns_provider: base.dns_provider, 222 222 txt_resolver: base.txt_resolver, 223 + well_known_resolver: base.well_known_resolver, 223 224 } 224 225 } 225 226
+2
crates/relay/src/routes/create_signing_key.rs
··· 127 127 http_client: base.http_client, 128 128 dns_provider: base.dns_provider, 129 129 txt_resolver: base.txt_resolver, 130 + well_known_resolver: base.well_known_resolver, 130 131 } 131 132 } 132 133 ··· 389 390 http_client: base.http_client, 390 391 dns_provider: base.dns_provider, 391 392 txt_resolver: base.txt_resolver, 393 + well_known_resolver: base.well_known_resolver, 392 394 }; 393 395 394 396 let response = app(state)
+1
crates/relay/src/routes/describe_server.rs
··· 129 129 http_client: base.http_client, 130 130 dns_provider: base.dns_provider, 131 131 txt_resolver: base.txt_resolver, 132 + well_known_resolver: base.well_known_resolver, 132 133 }; 133 134 134 135 let response = app(state)
+99 -3
crates/relay/src/routes/resolve_handle.rs
··· 1 1 // pattern: Imperative Shell 2 2 // 3 - // Gathers: handle from query param, DID from local handles table or DNS TXT record 4 - // Processes: none (resolution priority is local → DNS) 3 + // Gathers: handle from query param, DID from local handles table, DNS TXT record, or HTTP well-known 4 + // Processes: none (resolution priority is local → DNS TXT → HTTP well-known) 5 5 // Returns: JSON { did: "..." } matching com.atproto.identity.resolveHandle Lexicon 6 6 7 7 use axum::{ ··· 58 58 } 59 59 } 60 60 61 + // 3. HTTP well-known fallback: GET https://<handle>/.well-known/atproto-did 62 + if let Some(resolver) = &state.well_known_resolver { 63 + match resolver.resolve(&params.handle).await { 64 + Ok(Some(did)) => return Ok(Json(ResolveHandleResponse { did })), 65 + Ok(None) => {} 66 + Err(e) => { 67 + tracing::warn!( 68 + error = %e, 69 + handle = %params.handle, 70 + "HTTP well-known lookup failed" 71 + ); 72 + } 73 + } 74 + } 75 + 61 76 Err(ApiError::new(ErrorCode::HandleNotFound, "handle not found")) 62 77 } 63 78 ··· 72 87 use tower::ServiceExt; 73 88 74 89 use crate::app::{app, test_state, AppState}; 75 - use crate::dns::{DnsError, TxtResolver}; 90 + use crate::dns::{DnsError, TxtResolver, WellKnownError, WellKnownResolver}; 76 91 77 92 // ── Test doubles ────────────────────────────────────────────────────────── 78 93 ··· 98 113 } 99 114 } 100 115 116 + // ── Well-known test doubles ──────────────────────────────────────────────── 117 + 118 + struct FixedWellKnownResolver { 119 + did: Option<String>, 120 + } 121 + 122 + impl WellKnownResolver for FixedWellKnownResolver { 123 + fn resolve<'a>( 124 + &'a self, 125 + _handle: &'a str, 126 + ) -> Pin<Box<dyn Future<Output = Result<Option<String>, WellKnownError>> + Send + 'a>> 127 + { 128 + let did = self.did.clone(); 129 + Box::pin(async move { Ok(did) }) 130 + } 131 + } 132 + 133 + fn state_with_well_known(state: AppState, did: Option<String>) -> AppState { 134 + AppState { 135 + well_known_resolver: Some(Arc::new(FixedWellKnownResolver { did })), 136 + ..state 137 + } 138 + } 139 + 101 140 fn resolve_handle_request(handle: &str) -> Request<Body> { 102 141 Request::builder() 103 142 .uri(format!( ··· 223 262 .unwrap(); 224 263 225 264 assert_eq!(response.status(), StatusCode::NOT_FOUND); 265 + } 266 + 267 + // ── HTTP well-known fallback ─────────────────────────────────────────────── 268 + 269 + #[tokio::test] 270 + async fn well_known_fallback_resolves_did() { 271 + let did = "did:plc:wellknownuser12345678901234"; 272 + let state = state_with_well_known(test_state().await, Some(did.to_string())); 273 + 274 + let response = app(state) 275 + .oneshot(resolve_handle_request("jcsalterego.bsky.social")) 276 + .await 277 + .unwrap(); 278 + 279 + assert_eq!(response.status(), StatusCode::OK); 280 + let body: serde_json::Value = serde_json::from_slice( 281 + &axum::body::to_bytes(response.into_body(), usize::MAX) 282 + .await 283 + .unwrap(), 284 + ) 285 + .unwrap(); 286 + assert_eq!(body["did"], did); 287 + } 288 + 289 + #[tokio::test] 290 + async fn well_known_fallback_returns_404_when_resolver_returns_none() { 291 + let state = state_with_well_known(test_state().await, None); 292 + 293 + let response = app(state) 294 + .oneshot(resolve_handle_request("nobody.bsky.social")) 295 + .await 296 + .unwrap(); 297 + 298 + assert_eq!(response.status(), StatusCode::NOT_FOUND); 299 + } 300 + 301 + #[tokio::test] 302 + async fn dns_takes_priority_over_well_known() { 303 + let dns_did = "did:plc:fromdns123456789012345678"; 304 + let well_known_did = "did:plc:fromwellknown123456789012"; 305 + let state = test_state().await; 306 + let state = state_with_dns(state, vec![format!("did={dns_did}")]); 307 + let state = state_with_well_known(state, Some(well_known_did.to_string())); 308 + 309 + let response = app(state) 310 + .oneshot(resolve_handle_request("alice.external.example.com")) 311 + .await 312 + .unwrap(); 313 + 314 + assert_eq!(response.status(), StatusCode::OK); 315 + let body: serde_json::Value = serde_json::from_slice( 316 + &axum::body::to_bytes(response.into_body(), usize::MAX) 317 + .await 318 + .unwrap(), 319 + ) 320 + .unwrap(); 321 + assert_eq!(body["did"], dns_did); 226 322 } 227 323 228 324 // ── Response shape ────────────────────────────────────────────────────────
+1
crates/relay/src/routes/test_utils.rs
··· 17 17 http_client: base.http_client, 18 18 dns_provider: base.dns_provider, 19 19 txt_resolver: base.txt_resolver, 20 + well_known_resolver: base.well_known_resolver, 20 21 } 21 22 }