···11+// pattern: Imperative Shell
22+//
33+// DID document lookup. Returns a parsed JSON document from the did_documents table.
44+// No business logic — callers decide what to do with the result.
55+66+use common::{ApiError, ErrorCode};
77+88+/// Look up a locally cached DID document by DID string.
99+///
1010+/// Returns `None` when no row exists for the given DID; `Err` only on DB errors.
1111+pub(crate) async fn get_did_document(
1212+ db: &sqlx::SqlitePool,
1313+ did: &str,
1414+) -> Result<Option<serde_json::Value>, ApiError> {
1515+ let row: Option<(String,)> =
1616+ sqlx::query_as("SELECT document FROM did_documents WHERE did = ? LIMIT 1")
1717+ .bind(did)
1818+ .fetch_optional(db)
1919+ .await
2020+ .map_err(|e| {
2121+ tracing::error!(did = %did, error = %e, "DB error fetching DID document");
2222+ ApiError::new(ErrorCode::InternalError, "failed to load DID document")
2323+ })?;
2424+2525+ match row {
2626+ None => Ok(None),
2727+ Some((doc_str,)) => {
2828+ let doc = serde_json::from_str(&doc_str).map_err(|e| {
2929+ tracing::error!(did = %did, error = %e, "malformed DID document in DB");
3030+ ApiError::new(ErrorCode::InternalError, "malformed DID document")
3131+ })?;
3232+ Ok(Some(doc))
3333+ }
3434+ }
3535+}
+1
crates/relay/src/db/mod.rs
···11pub mod accounts;
22+pub mod dids;
23pub mod oauth;
3445use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions};
+225
crates/relay/src/routes/get_did.rs
···11+// pattern: Imperative Shell
22+//
33+// Gathers: DID from path parameter, document from local cache or PLC directory proxy
44+// Processes: none (lookup priority is local did_documents → PLC directory)
55+// Returns: DID document JSON with 200, or 404 if not found anywhere
66+77+use axum::{
88+ extract::{Path, State},
99+ Json,
1010+};
1111+use common::{ApiError, ErrorCode};
1212+use serde_json::Value;
1313+1414+use crate::app::AppState;
1515+use crate::db::dids::get_did_document;
1616+1717+pub async fn get_did_handler(
1818+ Path(did): Path<String>,
1919+ State(state): State<AppState>,
2020+) -> Result<Json<Value>, ApiError> {
2121+ // 1. Check local cache.
2222+ if let Some(doc) = get_did_document(&state.db, &did).await? {
2323+ return Ok(Json(doc));
2424+ }
2525+2626+ // 2. Proxy to PLC directory.
2727+ let plc_url = format!("{}/{}", state.config.plc_directory_url, did);
2828+ let response = state
2929+ .http_client
3030+ .get(&plc_url)
3131+ .send()
3232+ .await
3333+ .map_err(|e| {
3434+ tracing::error!(did = %did, error = %e, plc_url = %plc_url, "failed to contact plc.directory");
3535+ ApiError::new(ErrorCode::PlcDirectoryError, "failed to contact plc.directory")
3636+ })?;
3737+3838+ if response.status() == reqwest::StatusCode::NOT_FOUND {
3939+ return Err(ApiError::new(ErrorCode::NotFound, "DID not found"));
4040+ }
4141+4242+ if !response.status().is_success() {
4343+ let status = response.status();
4444+ tracing::error!(did = %did, status = %status, "plc.directory returned error");
4545+ return Err(ApiError::new(
4646+ ErrorCode::PlcDirectoryError,
4747+ "plc.directory returned error",
4848+ ));
4949+ }
5050+5151+ let doc: Value = response.json().await.map_err(|e| {
5252+ tracing::error!(did = %did, error = %e, "failed to parse plc.directory response");
5353+ ApiError::new(ErrorCode::PlcDirectoryError, "invalid response from plc.directory")
5454+ })?;
5555+5656+ Ok(Json(doc))
5757+}
5858+5959+#[cfg(test)]
6060+mod tests {
6161+ use axum::{
6262+ body::Body,
6363+ http::{Request, StatusCode},
6464+ };
6565+ use serde_json::json;
6666+ use tower::ServiceExt;
6767+ use wiremock::matchers::{method, path_regex};
6868+ use wiremock::{Mock, MockServer, ResponseTemplate};
6969+7070+ use crate::app::{app, test_state_with_plc_url};
7171+ use crate::routes::test_utils::{body_json, seed_did_document};
7272+7373+ fn get_did_request(did: &str) -> Request<Body> {
7474+ Request::builder()
7575+ .uri(format!("/v1/dids/{did}"))
7676+ .body(Body::empty())
7777+ .unwrap()
7878+ }
7979+8080+ // ── Local cache hit ───────────────────────────────────────────────────────
8181+8282+ #[tokio::test]
8383+ async fn known_did_returns_cached_document_200() {
8484+ let state = test_state_with_plc_url("https://plc.directory".to_string()).await;
8585+ let did = "did:plc:cacheduser12345678901234567";
8686+ let doc = json!({
8787+ "@context": ["https://www.w3.org/ns/did/v1"],
8888+ "id": did,
8989+ "alsoKnownAs": ["at://alice.test"],
9090+ "verificationMethod": [],
9191+ "service": []
9292+ });
9393+ seed_did_document(&state.db, did, doc.clone()).await;
9494+9595+ let response = app(state)
9696+ .oneshot(get_did_request(did))
9797+ .await
9898+ .unwrap();
9999+100100+ assert_eq!(response.status(), StatusCode::OK);
101101+ let body = body_json(response).await;
102102+ assert_eq!(body["id"], did);
103103+ assert_eq!(body["alsoKnownAs"][0], "at://alice.test");
104104+ }
105105+106106+ // ── PLC directory proxy ───────────────────────────────────────────────────
107107+108108+ #[tokio::test]
109109+ async fn unknown_did_proxies_to_plc_and_returns_document() {
110110+ let mock_server = MockServer::start().await;
111111+ let did = "did:plc:externaluser12345678901234";
112112+ let plc_doc = json!({
113113+ "@context": ["https://www.w3.org/ns/did/v1"],
114114+ "id": did,
115115+ "alsoKnownAs": [],
116116+ "verificationMethod": [],
117117+ "service": []
118118+ });
119119+120120+ Mock::given(method("GET"))
121121+ .and(path_regex(r"^/did:plc:.+"))
122122+ .respond_with(ResponseTemplate::new(200).set_body_json(&plc_doc))
123123+ .expect(1)
124124+ .named("plc.directory GET did")
125125+ .mount(&mock_server)
126126+ .await;
127127+128128+ let state = test_state_with_plc_url(mock_server.uri()).await;
129129+130130+ let response = app(state)
131131+ .oneshot(get_did_request(did))
132132+ .await
133133+ .unwrap();
134134+135135+ assert_eq!(response.status(), StatusCode::OK);
136136+ let body = body_json(response).await;
137137+ assert_eq!(body["id"], did);
138138+ }
139139+140140+ #[tokio::test]
141141+ async fn did_not_found_in_plc_returns_404() {
142142+ let mock_server = MockServer::start().await;
143143+ let did = "did:plc:nobody1234567890123456789";
144144+145145+ Mock::given(method("GET"))
146146+ .and(path_regex(r"^/did:plc:.+"))
147147+ .respond_with(ResponseTemplate::new(404))
148148+ .expect(1)
149149+ .named("plc.directory 404")
150150+ .mount(&mock_server)
151151+ .await;
152152+153153+ let state = test_state_with_plc_url(mock_server.uri()).await;
154154+155155+ let response = app(state)
156156+ .oneshot(get_did_request(did))
157157+ .await
158158+ .unwrap();
159159+160160+ assert_eq!(response.status(), StatusCode::NOT_FOUND);
161161+ let body = body_json(response).await;
162162+ assert_eq!(body["error"]["code"], "NOT_FOUND");
163163+ }
164164+165165+ #[tokio::test]
166166+ async fn plc_directory_error_returns_502() {
167167+ let mock_server = MockServer::start().await;
168168+ let did = "did:plc:errordid12345678901234567";
169169+170170+ Mock::given(method("GET"))
171171+ .and(path_regex(r"^/did:plc:.+"))
172172+ .respond_with(ResponseTemplate::new(500))
173173+ .expect(1)
174174+ .named("plc.directory 500")
175175+ .mount(&mock_server)
176176+ .await;
177177+178178+ let state = test_state_with_plc_url(mock_server.uri()).await;
179179+180180+ let response = app(state)
181181+ .oneshot(get_did_request(did))
182182+ .await
183183+ .unwrap();
184184+185185+ assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
186186+ let body = body_json(response).await;
187187+ assert_eq!(body["error"]["code"], "PLC_DIRECTORY_ERROR");
188188+ }
189189+190190+ // ── Local cache priority ──────────────────────────────────────────────────
191191+192192+ #[tokio::test]
193193+ async fn local_cache_takes_priority_over_plc() {
194194+ let mock_server = MockServer::start().await;
195195+ let did = "did:plc:localoverride12345678901234";
196196+ let local_doc = json!({
197197+ "@context": ["https://www.w3.org/ns/did/v1"],
198198+ "id": did,
199199+ "alsoKnownAs": ["at://local.test"],
200200+ "verificationMethod": [],
201201+ "service": []
202202+ });
203203+204204+ // PLC server should NOT be called; if it is, wiremock's expect(0) will fail.
205205+ Mock::given(method("GET"))
206206+ .and(path_regex(r"^/did:plc:.+"))
207207+ .respond_with(ResponseTemplate::new(200).set_body_json(json!({"id": "wrong"})))
208208+ .expect(0)
209209+ .named("plc.directory should not be called")
210210+ .mount(&mock_server)
211211+ .await;
212212+213213+ let state = test_state_with_plc_url(mock_server.uri()).await;
214214+ seed_did_document(&state.db, did, local_doc).await;
215215+216216+ let response = app(state)
217217+ .oneshot(get_did_request(did))
218218+ .await
219219+ .unwrap();
220220+221221+ assert_eq!(response.status(), StatusCode::OK);
222222+ let body = body_json(response).await;
223223+ assert_eq!(body["alsoKnownAs"][0], "at://local.test");
224224+ }
225225+}
+1
crates/relay/src/routes/mod.rs
···99pub mod create_session;
1010pub mod create_signing_key;
1111pub mod describe_server;
1212+pub mod get_did;
1213pub mod get_relay_signing_key;
1314pub mod get_session;
1415pub mod health;
+16
crates/relay/src/routes/test_utils.rs
···8888 .expect("insert handle");
8989}
90909191+/// Insert a DID document row directly into `did_documents`.
9292+///
9393+/// `did_documents` has no FK to `accounts`, so this can be used without a corresponding
9494+/// account row. Used in tests that exercise DID document retrieval endpoints.
9595+pub async fn seed_did_document(db: &sqlx::SqlitePool, did: &str, document: serde_json::Value) {
9696+ sqlx::query(
9797+ "INSERT INTO did_documents (did, document, created_at, updated_at) \
9898+ VALUES (?, ?, datetime('now'), datetime('now'))",
9999+ )
100100+ .bind(did)
101101+ .bind(document.to_string())
102102+ .execute(db)
103103+ .await
104104+ .expect("insert did_document");
105105+}
106106+91107/// Deserialise a response body as `serde_json::Value`, consuming the response.
92108pub async fn body_json(response: axum::response::Response) -> serde_json::Value {
93109 let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)