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 /v1/dids/:did for DID document retrieval

Serves locally-cached DID documents and proxies to plc.directory for
unknown DIDs. Returns 404 when the DID is not found anywhere.

authored by

Malpercio and committed by
Tangled
c705fbd9 10f953e7

+296
+15
bruno/get_did.bru
··· 1 + meta { 2 + name: Get DID Document 3 + type: http 4 + seq: 24 5 + } 6 + 7 + get { 8 + url: {{baseUrl}}/v1/dids/:did 9 + body: none 10 + auth: none 11 + } 12 + 13 + params:path { 14 + did: did:plc:exampleabcdefghijklmnopqrst 15 + }
+1
crates/relay/CLAUDE.md
··· 69 69 | `get_session.rs` | `GET /xrpc/com.atproto.server.getSession` | 70 70 | `refresh_session.rs` | `POST /xrpc/com.atproto.server.refreshSession` | 71 71 | `create_did.rs` | `POST /v1/dids` | 72 + | `get_did.rs` | `GET /v1/dids/:did` | 72 73 | `create_account.rs` | `POST /v1/accounts` | 73 74 | `create_handle.rs` | `POST /v1/handles` | 74 75 | `create_mobile_account.rs` | `POST /v1/accounts/mobile` |
+2
crates/relay/src/app.rs
··· 26 26 use crate::routes::delete_session::delete_session; 27 27 use crate::routes::create_signing_key::create_signing_key; 28 28 use crate::routes::describe_server::describe_server; 29 + use crate::routes::get_did::get_did_handler; 29 30 use crate::routes::get_relay_signing_key::get_relay_signing_key; 30 31 use crate::routes::get_session::get_session; 31 32 use crate::routes::health::health; ··· 181 182 .route("/v1/accounts/sessions", post(create_provisioning_session)) 182 183 .route("/v1/devices", post(register_device)) 183 184 .route("/v1/dids", post(create_did_handler)) 185 + .route("/v1/dids/:did", get(get_did_handler)) 184 186 .route("/v1/handles", post(create_handle_handler)) 185 187 .route( 186 188 "/v1/relay/keys",
+35
crates/relay/src/db/dids.rs
··· 1 + // pattern: Imperative Shell 2 + // 3 + // DID document lookup. Returns a parsed JSON document from the did_documents table. 4 + // No business logic — callers decide what to do with the result. 5 + 6 + use common::{ApiError, ErrorCode}; 7 + 8 + /// Look up a locally cached DID document by DID string. 9 + /// 10 + /// Returns `None` when no row exists for the given DID; `Err` only on DB errors. 11 + pub(crate) async fn get_did_document( 12 + db: &sqlx::SqlitePool, 13 + did: &str, 14 + ) -> Result<Option<serde_json::Value>, ApiError> { 15 + let row: Option<(String,)> = 16 + sqlx::query_as("SELECT document FROM did_documents WHERE did = ? LIMIT 1") 17 + .bind(did) 18 + .fetch_optional(db) 19 + .await 20 + .map_err(|e| { 21 + tracing::error!(did = %did, error = %e, "DB error fetching DID document"); 22 + ApiError::new(ErrorCode::InternalError, "failed to load DID document") 23 + })?; 24 + 25 + match row { 26 + None => Ok(None), 27 + Some((doc_str,)) => { 28 + let doc = serde_json::from_str(&doc_str).map_err(|e| { 29 + tracing::error!(did = %did, error = %e, "malformed DID document in DB"); 30 + ApiError::new(ErrorCode::InternalError, "malformed DID document") 31 + })?; 32 + Ok(Some(doc)) 33 + } 34 + } 35 + }
+1
crates/relay/src/db/mod.rs
··· 1 1 pub mod accounts; 2 + pub mod dids; 2 3 pub mod oauth; 3 4 4 5 use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions};
+225
crates/relay/src/routes/get_did.rs
··· 1 + // pattern: Imperative Shell 2 + // 3 + // Gathers: DID from path parameter, document from local cache or PLC directory proxy 4 + // Processes: none (lookup priority is local did_documents → PLC directory) 5 + // Returns: DID document JSON with 200, or 404 if not found anywhere 6 + 7 + use axum::{ 8 + extract::{Path, State}, 9 + Json, 10 + }; 11 + use common::{ApiError, ErrorCode}; 12 + use serde_json::Value; 13 + 14 + use crate::app::AppState; 15 + use crate::db::dids::get_did_document; 16 + 17 + pub async fn get_did_handler( 18 + Path(did): Path<String>, 19 + State(state): State<AppState>, 20 + ) -> Result<Json<Value>, ApiError> { 21 + // 1. Check local cache. 22 + if let Some(doc) = get_did_document(&state.db, &did).await? { 23 + return Ok(Json(doc)); 24 + } 25 + 26 + // 2. Proxy to PLC directory. 27 + let plc_url = format!("{}/{}", state.config.plc_directory_url, did); 28 + let response = state 29 + .http_client 30 + .get(&plc_url) 31 + .send() 32 + .await 33 + .map_err(|e| { 34 + tracing::error!(did = %did, error = %e, plc_url = %plc_url, "failed to contact plc.directory"); 35 + ApiError::new(ErrorCode::PlcDirectoryError, "failed to contact plc.directory") 36 + })?; 37 + 38 + if response.status() == reqwest::StatusCode::NOT_FOUND { 39 + return Err(ApiError::new(ErrorCode::NotFound, "DID not found")); 40 + } 41 + 42 + if !response.status().is_success() { 43 + let status = response.status(); 44 + tracing::error!(did = %did, status = %status, "plc.directory returned error"); 45 + return Err(ApiError::new( 46 + ErrorCode::PlcDirectoryError, 47 + "plc.directory returned error", 48 + )); 49 + } 50 + 51 + let doc: Value = response.json().await.map_err(|e| { 52 + tracing::error!(did = %did, error = %e, "failed to parse plc.directory response"); 53 + ApiError::new(ErrorCode::PlcDirectoryError, "invalid response from plc.directory") 54 + })?; 55 + 56 + Ok(Json(doc)) 57 + } 58 + 59 + #[cfg(test)] 60 + mod tests { 61 + use axum::{ 62 + body::Body, 63 + http::{Request, StatusCode}, 64 + }; 65 + use serde_json::json; 66 + use tower::ServiceExt; 67 + use wiremock::matchers::{method, path_regex}; 68 + use wiremock::{Mock, MockServer, ResponseTemplate}; 69 + 70 + use crate::app::{app, test_state_with_plc_url}; 71 + use crate::routes::test_utils::{body_json, seed_did_document}; 72 + 73 + fn get_did_request(did: &str) -> Request<Body> { 74 + Request::builder() 75 + .uri(format!("/v1/dids/{did}")) 76 + .body(Body::empty()) 77 + .unwrap() 78 + } 79 + 80 + // ── Local cache hit ─────────────────────────────────────────────────────── 81 + 82 + #[tokio::test] 83 + async fn known_did_returns_cached_document_200() { 84 + let state = test_state_with_plc_url("https://plc.directory".to_string()).await; 85 + let did = "did:plc:cacheduser12345678901234567"; 86 + let doc = json!({ 87 + "@context": ["https://www.w3.org/ns/did/v1"], 88 + "id": did, 89 + "alsoKnownAs": ["at://alice.test"], 90 + "verificationMethod": [], 91 + "service": [] 92 + }); 93 + seed_did_document(&state.db, did, doc.clone()).await; 94 + 95 + let response = app(state) 96 + .oneshot(get_did_request(did)) 97 + .await 98 + .unwrap(); 99 + 100 + assert_eq!(response.status(), StatusCode::OK); 101 + let body = body_json(response).await; 102 + assert_eq!(body["id"], did); 103 + assert_eq!(body["alsoKnownAs"][0], "at://alice.test"); 104 + } 105 + 106 + // ── PLC directory proxy ─────────────────────────────────────────────────── 107 + 108 + #[tokio::test] 109 + async fn unknown_did_proxies_to_plc_and_returns_document() { 110 + let mock_server = MockServer::start().await; 111 + let did = "did:plc:externaluser12345678901234"; 112 + let plc_doc = json!({ 113 + "@context": ["https://www.w3.org/ns/did/v1"], 114 + "id": did, 115 + "alsoKnownAs": [], 116 + "verificationMethod": [], 117 + "service": [] 118 + }); 119 + 120 + Mock::given(method("GET")) 121 + .and(path_regex(r"^/did:plc:.+")) 122 + .respond_with(ResponseTemplate::new(200).set_body_json(&plc_doc)) 123 + .expect(1) 124 + .named("plc.directory GET did") 125 + .mount(&mock_server) 126 + .await; 127 + 128 + let state = test_state_with_plc_url(mock_server.uri()).await; 129 + 130 + let response = app(state) 131 + .oneshot(get_did_request(did)) 132 + .await 133 + .unwrap(); 134 + 135 + assert_eq!(response.status(), StatusCode::OK); 136 + let body = body_json(response).await; 137 + assert_eq!(body["id"], did); 138 + } 139 + 140 + #[tokio::test] 141 + async fn did_not_found_in_plc_returns_404() { 142 + let mock_server = MockServer::start().await; 143 + let did = "did:plc:nobody1234567890123456789"; 144 + 145 + Mock::given(method("GET")) 146 + .and(path_regex(r"^/did:plc:.+")) 147 + .respond_with(ResponseTemplate::new(404)) 148 + .expect(1) 149 + .named("plc.directory 404") 150 + .mount(&mock_server) 151 + .await; 152 + 153 + let state = test_state_with_plc_url(mock_server.uri()).await; 154 + 155 + let response = app(state) 156 + .oneshot(get_did_request(did)) 157 + .await 158 + .unwrap(); 159 + 160 + assert_eq!(response.status(), StatusCode::NOT_FOUND); 161 + let body = body_json(response).await; 162 + assert_eq!(body["error"]["code"], "NOT_FOUND"); 163 + } 164 + 165 + #[tokio::test] 166 + async fn plc_directory_error_returns_502() { 167 + let mock_server = MockServer::start().await; 168 + let did = "did:plc:errordid12345678901234567"; 169 + 170 + Mock::given(method("GET")) 171 + .and(path_regex(r"^/did:plc:.+")) 172 + .respond_with(ResponseTemplate::new(500)) 173 + .expect(1) 174 + .named("plc.directory 500") 175 + .mount(&mock_server) 176 + .await; 177 + 178 + let state = test_state_with_plc_url(mock_server.uri()).await; 179 + 180 + let response = app(state) 181 + .oneshot(get_did_request(did)) 182 + .await 183 + .unwrap(); 184 + 185 + assert_eq!(response.status(), StatusCode::BAD_GATEWAY); 186 + let body = body_json(response).await; 187 + assert_eq!(body["error"]["code"], "PLC_DIRECTORY_ERROR"); 188 + } 189 + 190 + // ── Local cache priority ────────────────────────────────────────────────── 191 + 192 + #[tokio::test] 193 + async fn local_cache_takes_priority_over_plc() { 194 + let mock_server = MockServer::start().await; 195 + let did = "did:plc:localoverride12345678901234"; 196 + let local_doc = json!({ 197 + "@context": ["https://www.w3.org/ns/did/v1"], 198 + "id": did, 199 + "alsoKnownAs": ["at://local.test"], 200 + "verificationMethod": [], 201 + "service": [] 202 + }); 203 + 204 + // PLC server should NOT be called; if it is, wiremock's expect(0) will fail. 205 + Mock::given(method("GET")) 206 + .and(path_regex(r"^/did:plc:.+")) 207 + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"id": "wrong"}))) 208 + .expect(0) 209 + .named("plc.directory should not be called") 210 + .mount(&mock_server) 211 + .await; 212 + 213 + let state = test_state_with_plc_url(mock_server.uri()).await; 214 + seed_did_document(&state.db, did, local_doc).await; 215 + 216 + let response = app(state) 217 + .oneshot(get_did_request(did)) 218 + .await 219 + .unwrap(); 220 + 221 + assert_eq!(response.status(), StatusCode::OK); 222 + let body = body_json(response).await; 223 + assert_eq!(body["alsoKnownAs"][0], "at://local.test"); 224 + } 225 + }
+1
crates/relay/src/routes/mod.rs
··· 9 9 pub mod create_session; 10 10 pub mod create_signing_key; 11 11 pub mod describe_server; 12 + pub mod get_did; 12 13 pub mod get_relay_signing_key; 13 14 pub mod get_session; 14 15 pub mod health;
+16
crates/relay/src/routes/test_utils.rs
··· 88 88 .expect("insert handle"); 89 89 } 90 90 91 + /// Insert a DID document row directly into `did_documents`. 92 + /// 93 + /// `did_documents` has no FK to `accounts`, so this can be used without a corresponding 94 + /// account row. Used in tests that exercise DID document retrieval endpoints. 95 + pub async fn seed_did_document(db: &sqlx::SqlitePool, did: &str, document: serde_json::Value) { 96 + sqlx::query( 97 + "INSERT INTO did_documents (did, document, created_at, updated_at) \ 98 + VALUES (?, ?, datetime('now'), datetime('now'))", 99 + ) 100 + .bind(did) 101 + .bind(document.to_string()) 102 + .execute(db) 103 + .await 104 + .expect("insert did_document"); 105 + } 106 + 91 107 /// Deserialise a response body as `serde_json::Value`, consuming the response. 92 108 pub async fn body_json(response: axum::response::Response) -> serde_json::Value { 93 109 let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)