An easy-to-host PDS on the ATProtocol, iPhone and MacOS. Maintain control of your keys and data, always.
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: implement GET /.well-known/atproto-did for handle verification

Serves the ATProto HTTP well-known endpoint so clients can verify handles
registered on this PDS via HTTPS without requiring DNS TXT records.

authored by

Malpercio and committed by
Tangled
a16efffa 1447ff18

+167
+15
bruno/atproto_did.bru
··· 1 + meta { 2 + name: ATProto DID (well-known) 3 + type: http 4 + seq: 23 5 + } 6 + 7 + get { 8 + url: {{baseUrl}}/.well-known/atproto-did 9 + body: none 10 + auth: none 11 + } 12 + 13 + vars:pre-request { 14 + baseUrl: http://localhost:8080 15 + }
+2
crates/relay/src/app.rs
··· 16 16 17 17 use crate::auth::{DpopNonceStore, OAuthSigningKey}; 18 18 use crate::dns::{DnsProvider, TxtResolver}; 19 + use crate::routes::atproto_did::atproto_did_handler; 19 20 use crate::routes::claim_codes::claim_codes; 20 21 use crate::routes::create_account::create_account; 21 22 use crate::routes::create_did::create_did_handler; ··· 139 140 /// listener — callers can use `tower::ServiceExt::oneshot` to drive requests in tests. 140 141 pub fn app(state: AppState) -> Router { 141 142 Router::new() 143 + .route("/.well-known/atproto-did", get(atproto_did_handler)) 142 144 .route( 143 145 "/.well-known/oauth-authorization-server", 144 146 get(oauth_server_metadata),
+149
crates/relay/src/routes/atproto_did.rs
··· 1 + // pattern: Imperative Shell 2 + // 3 + // Gathers: Host header from request, DID from handles table 4 + // Processes: none (handle → DID lookup is a direct DB read) 5 + // Returns: 200 text/plain with DID, or 404 if the host is not a registered handle 6 + 7 + use axum::{ 8 + extract::{Host, State}, 9 + http::{header, StatusCode}, 10 + response::{IntoResponse, Response}, 11 + }; 12 + 13 + use crate::app::AppState; 14 + 15 + pub async fn atproto_did_handler( 16 + Host(host): Host, 17 + State(state): State<AppState>, 18 + ) -> Response { 19 + // Strip port if present (e.g. "example.com:8080" → "example.com"). 20 + let handle = host.split(':').next().unwrap_or(&host); 21 + 22 + let row: Option<(String,)> = 23 + match sqlx::query_as("SELECT did FROM handles WHERE handle = ?") 24 + .bind(handle) 25 + .fetch_optional(&state.db) 26 + .await 27 + { 28 + Ok(row) => row, 29 + Err(e) => { 30 + tracing::error!(error = %e, handle = %handle, "DB error in well-known atproto-did"); 31 + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); 32 + } 33 + }; 34 + 35 + match row { 36 + Some((did,)) => ( 37 + StatusCode::OK, 38 + [(header::CONTENT_TYPE, "text/plain")], 39 + did, 40 + ) 41 + .into_response(), 42 + None => StatusCode::NOT_FOUND.into_response(), 43 + } 44 + } 45 + 46 + #[cfg(test)] 47 + mod tests { 48 + use axum::{ 49 + body::Body, 50 + http::{Request, StatusCode}, 51 + }; 52 + use tower::ServiceExt; 53 + 54 + use crate::app::{app, test_state}; 55 + 56 + async fn seed_handle(db: &sqlx::SqlitePool, handle: &str, did: &str) { 57 + sqlx::query( 58 + "INSERT INTO accounts (did, email, password_hash, created_at, updated_at) \ 59 + VALUES (?, ?, NULL, datetime('now'), datetime('now'))", 60 + ) 61 + .bind(did) 62 + .bind(format!("{did}@test.example.com")) 63 + .execute(db) 64 + .await 65 + .expect("insert account"); 66 + 67 + sqlx::query( 68 + "INSERT INTO handles (handle, did, created_at) VALUES (?, ?, datetime('now'))", 69 + ) 70 + .bind(handle) 71 + .bind(did) 72 + .execute(db) 73 + .await 74 + .expect("insert handle"); 75 + } 76 + 77 + fn well_known_request(host: &str) -> Request<Body> { 78 + Request::builder() 79 + .uri("/.well-known/atproto-did") 80 + .header("host", host) 81 + .body(Body::empty()) 82 + .unwrap() 83 + } 84 + 85 + #[tokio::test] 86 + async fn registered_handle_returns_did_as_plain_text() { 87 + let state = test_state().await; 88 + let did = "did:plc:alice123456789012345678901"; 89 + seed_handle(&state.db, "alice.example.com", did).await; 90 + 91 + let response = app(state) 92 + .oneshot(well_known_request("alice.example.com")) 93 + .await 94 + .unwrap(); 95 + 96 + assert_eq!(response.status(), StatusCode::OK); 97 + let body = axum::body::to_bytes(response.into_body(), usize::MAX) 98 + .await 99 + .unwrap(); 100 + assert_eq!(std::str::from_utf8(&body).unwrap(), did); 101 + } 102 + 103 + #[tokio::test] 104 + async fn unregistered_host_returns_404() { 105 + let state = test_state().await; 106 + 107 + let response = app(state) 108 + .oneshot(well_known_request("nobody.example.com")) 109 + .await 110 + .unwrap(); 111 + 112 + assert_eq!(response.status(), StatusCode::NOT_FOUND); 113 + } 114 + 115 + #[tokio::test] 116 + async fn response_content_type_is_text_plain() { 117 + let state = test_state().await; 118 + let did = "did:plc:alice123456789012345678901"; 119 + seed_handle(&state.db, "alice.example.com", did).await; 120 + 121 + let response = app(state) 122 + .oneshot(well_known_request("alice.example.com")) 123 + .await 124 + .unwrap(); 125 + 126 + assert_eq!( 127 + response.headers().get("content-type").unwrap(), 128 + "text/plain", 129 + ); 130 + } 131 + 132 + #[tokio::test] 133 + async fn host_with_port_is_resolved_correctly() { 134 + let state = test_state().await; 135 + let did = "did:plc:alice123456789012345678901"; 136 + seed_handle(&state.db, "alice.example.com", did).await; 137 + 138 + let response = app(state) 139 + .oneshot(well_known_request("alice.example.com:8080")) 140 + .await 141 + .unwrap(); 142 + 143 + assert_eq!(response.status(), StatusCode::OK); 144 + let body = axum::body::to_bytes(response.into_body(), usize::MAX) 145 + .await 146 + .unwrap(); 147 + assert_eq!(std::str::from_utf8(&body).unwrap(), did); 148 + } 149 + }
+1
crates/relay/src/routes/mod.rs
··· 1 1 pub(crate) mod auth; 2 + pub mod atproto_did; 2 3 pub mod claim_codes; 3 4 pub mod delete_session; 4 5 pub mod create_account;