diff --git a/crates/tranquil-api/src/delegation.rs b/crates/tranquil-api/src/delegation.rs index 9597067..3ed913c 100644 --- a/crates/tranquil-api/src/delegation.rs +++ b/crates/tranquil-api/src/delegation.rs @@ -37,8 +37,9 @@ pub async fn list_controllers( async move { if c.handle.is_none() { c.handle = did_resolver - .resolve_did_document(c.did.as_str()) + .fetch_did_document(c.did.as_str()) .await + .ok() .and_then(|doc| tranquil_types::did_doc::extract_handle(&doc)) .map(Into::into); } @@ -64,7 +65,7 @@ pub async fn add_controller( ) -> Result, ApiError> { let resolved = tranquil_pds::delegation::resolve_identity(&state, &input.controller_did) .await - .ok_or(ApiError::ControllerNotFound)?; + .map_err(|_| ApiError::ControllerNotFound)?; if !resolved.is_local && let Some(ref pds_url) = resolved.pds_url @@ -476,7 +477,7 @@ pub async fn resolve_controller( let resolved = tranquil_pds::delegation::resolve_identity(&state, &did) .await - .ok_or(ApiError::ControllerNotFound)?; + .map_err(|_| ApiError::ControllerNotFound)?; Ok(Json(resolved)) } diff --git a/crates/tranquil-api/src/identity/account.rs b/crates/tranquil-api/src/identity/account.rs index f07d509..280fe66 100644 --- a/crates/tranquil-api/src/identity/account.rs +++ b/crates/tranquil-api/src/identity/account.rs @@ -153,7 +153,12 @@ async fn try_reactivate_migration( Json(CreateAccountOutput { handle: handle.to_string().into(), did: did_typed.clone(), - did_doc: state.did_resolver.resolve_did_document(did).await, + did_doc: state + .did_resolver + .fetch_did_document(did) + .await + .ok() + .and_then(|f| Some((*f).clone())), access_jwt: access_meta.token, refresh_jwt: refresh_meta.token, verification_required, @@ -580,7 +585,7 @@ pub async fn create_account( Err(e) => return e.into_response(), }; - let did_doc = state.did_resolver.resolve_did_document(&did).await; + let did_doc = state.did_resolver.fetch_did_document(&did).await.ok(); if is_migration { info!( @@ -594,7 +599,7 @@ pub async fn create_account( Json(CreateAccountOutput { handle: handle.clone().into(), did: did_for_commit, - did_doc, + did_doc: did_doc.and_then(|f| Some((*f).clone())), access_jwt: session.access_jwt, refresh_jwt: session.refresh_jwt, verification_required: !is_migration, diff --git a/crates/tranquil-api/src/identity/plc/submit.rs b/crates/tranquil-api/src/identity/plc/submit.rs index e1cedcf..bd60f60 100644 --- a/crates/tranquil-api/src/identity/plc/submit.rs +++ b/crates/tranquil-api/src/identity/plc/submit.rs @@ -153,7 +153,7 @@ pub async fn submit_plc_operation( .cache .delete(&tranquil_pds::cache_keys::plc_data_key(did)) .await; - if state.did_resolver.refresh_did(did).await.is_none() { + if state.did_resolver.refresh_did(did).await.is_err() { warn!(did = %did, "Failed to refresh DID cache after PLC update"); } info!(did = %did, "PLC operation submitted successfully"); diff --git a/crates/tranquil-api/src/server/account_status.rs b/crates/tranquil-api/src/server/account_status.rs index dd68a94..b36848b 100644 --- a/crates/tranquil-api/src/server/account_status.rs +++ b/crates/tranquil-api/src/server/account_status.rs @@ -365,7 +365,7 @@ pub async fn activate_account( .cache .delete(&tranquil_pds::cache_keys::plc_data_key(&did)) .await; - if state.did_resolver.refresh_did(did.as_str()).await.is_none() { + if state.did_resolver.refresh_did(did.as_str()).await.is_err() { warn!( "[MIGRATION] activateAccount: Failed to refresh DID cache for {}", did diff --git a/crates/tranquil-api/src/server/session.rs b/crates/tranquil-api/src/server/session.rs index 9682375..3735793 100644 --- a/crates/tranquil-api/src/server/session.rs +++ b/crates/tranquil-api/src/server/session.rs @@ -69,7 +69,8 @@ pub async fn create_session( input.identifier, normalized_identifier ); let row = match state - .repos.user + .repos + .user .get_login_full_by_identifier(normalized_identifier.as_str()) .await { @@ -130,7 +131,8 @@ pub async fn create_session( } let is_verified = row.channel_verification.has_any_verified(); let is_delegated = state - .repos.delegation + .repos + .delegation .is_delegated_account(&row.did) .await .unwrap_or(false); @@ -270,7 +272,7 @@ pub async fn create_session( }; let (insert_result, did_doc) = tokio::join!( state.repos.session.create_session(&session_data), - did_resolver.resolve_did_document(&did_for_doc) + did_resolver.fetch_did_document(&did_for_doc), ); if let Err(e) = insert_result { error!("Failed to insert session: {:?}", e); @@ -311,7 +313,7 @@ pub async fn create_session( refresh_jwt: refresh_meta.token, handle, did: row.did, - did_doc, + did_doc: did_doc.ok().and_then(|f| Some((*f).clone())), email: row.email, email_confirmed: Some(row.channel_verification.email), email_auth_factor: email_auth_factor_out, @@ -360,7 +362,7 @@ pub async fn get_session( let did_resolver = state.did_resolver.clone(); let (db_result, did_doc) = tokio::join!( state.repos.user.get_session_info_by_did(&auth.did), - did_resolver.resolve_did_document(&did_for_doc) + did_resolver.fetch_did_document(&did_for_doc) ); match db_result { Ok(Some(row)) => { @@ -404,7 +406,7 @@ pub async fn get_session( status: account_state.status_for_session().map(String::from), migrated_to_pds, migrated_at, - did_doc, + did_doc: did_doc.ok().and_then(|f| Some((*f).clone())), })) } Ok(None) => Err(ApiError::AuthenticationFailed(None)), @@ -476,7 +478,8 @@ pub async fn refresh_session( } }; if let Ok(Some(_)) = state - .repos.session + .repos + .session .check_refresh_token_used(&refresh_jti) .await { @@ -486,7 +489,8 @@ pub async fn refresh_session( ))); } let session_row = match state - .repos.session + .repos + .session .get_session_for_refresh(&refresh_jti) .await { @@ -548,7 +552,8 @@ pub async fn refresh_session( new_refresh_expires_at: new_refresh_meta.expires_at, }; match state - .repos.session + .repos + .session .refresh_session_atomic(&refresh_data) .await { @@ -577,7 +582,7 @@ pub async fn refresh_session( let did_resolver = state.did_resolver.clone(); let (db_result, did_doc) = tokio::join!( state.repos.user.get_session_info_by_did(&session_row.did), - did_resolver.resolve_did_document(&did_for_doc) + did_resolver.fetch_did_document(&did_for_doc) ); match db_result { Ok(Some(u)) => { @@ -599,7 +604,7 @@ pub async fn refresh_session( preferred_locale: u.preferred_locale, is_admin: u.is_admin, active: account_state.is_active(), - did_doc, + did_doc: did_doc.ok().and_then(|f| Some((*f).clone())), status: account_state.status_for_session().map(String::from), })) } @@ -702,7 +707,8 @@ pub async fn confirm_signup( }; if let Err(e) = state - .repos.user + .repos + .user .set_channel_verified(&input.did, row.channel) .await { @@ -821,7 +827,8 @@ pub async fn resend_verification( ) -> Result, ApiError> { info!("resend_verification called for DID: {}", input.did); let row = match state - .repos.user + .repos + .user .get_resend_verification_by_did(&input.did) .await { @@ -895,13 +902,15 @@ pub async fn list_sessions( let current_jti = tranquil_pds::auth::extract_jti_from_headers(&headers); let jwt_rows = state - .repos.session + .repos + .session .list_sessions_by_did(&auth.did) .await .log_db_err("fetching JWT sessions")?; let oauth_rows = state - .repos.oauth + .repos + .oauth .list_sessions_by_did(&auth.did) .await .log_db_err("fetching OAuth sessions")?; @@ -962,13 +971,15 @@ pub async fn revoke_session( .map(SessionId::new) .map_err(|_| ApiError::InvalidRequest("Invalid session ID".into()))?; let access_jti = state - .repos.session + .repos + .session .get_session_access_jti_by_id(session_id, &auth.did) .await .log_db_err("in revoke_session")? .ok_or(ApiError::SessionNotFound)?; state - .repos.session + .repos + .session .delete_session_by_id(session_id) .await .log_db_err("deleting session")?; @@ -983,7 +994,8 @@ pub async fn revoke_session( .map(TokenFamilyId::new) .map_err(|_| ApiError::InvalidRequest("Invalid session ID".into()))?; let deleted = state - .repos.oauth + .repos + .oauth .delete_session_by_id(session_id, &auth.did) .await .log_db_err("deleting OAuth session")?; @@ -1007,24 +1019,28 @@ pub async fn revoke_all_sessions( if auth.is_oauth() { state - .repos.session + .repos + .session .delete_sessions_by_did(&auth.did) .await .log_db_err("revoking JWT sessions")?; let jti_typed = TokenId::from(jti.clone()); state - .repos.oauth + .repos + .oauth .delete_sessions_by_did_except(&auth.did, &jti_typed) .await .log_db_err("revoking OAuth sessions")?; } else { state - .repos.session + .repos + .session .delete_sessions_by_did_except_jti(&auth.did, &jti) .await .log_db_err("revoking JWT sessions")?; state - .repos.oauth + .repos + .oauth .delete_sessions_by_did(&auth.did) .await .log_db_err("revoking OAuth sessions")?; @@ -1046,7 +1062,8 @@ pub async fn get_legacy_login_preference( auth: Auth, ) -> Result, ApiError> { let pref = state - .repos.user + .repos + .user .get_legacy_login_pref(&auth.did) .await .log_db_err("getting legacy login pref")? @@ -1079,7 +1096,8 @@ pub async fn update_legacy_login_preference( let reauth_mfa = require_reauth_window(&state, &auth).await?; let updated = state - .repos.user + .repos + .user .update_legacy_login(reauth_mfa.did(), input.allow_legacy_login) .await .log_db_err("updating legacy login")?; @@ -1117,7 +1135,8 @@ pub async fn update_locale( } let updated = state - .repos.user + .repos + .user .update_locale(&auth.did, &input.preferred_locale) .await .log_db_err("updating locale")?; diff --git a/crates/tranquil-pds/src/api/proxy.rs b/crates/tranquil-pds/src/api/proxy.rs index bb870cb..cbef623 100644 --- a/crates/tranquil-pds/src/api/proxy.rs +++ b/crates/tranquil-pds/src/api/proxy.rs @@ -205,9 +205,14 @@ async fn proxy_handler( .into_response(); }; - let did = proxy_header.split('#').next().unwrap_or(&proxy_header); - let Some(resolved) = state.did_resolver.resolve_did(did).await else { - error!(did = %did, "Could not resolve service DID"); + let Some((did, service_id)) = proxy_header.split_once("#") else { + return ApiError::InvalidRequest( + "Invalid atproto-proxy header. Missing service identifier.".into(), + ) + .into_response(); + }; + let Ok(resolved) = state.did_resolver.resolve_service(did, service_id).await else { + error!(did = %did, service_id = %service_id, "Could not resolve service DID"); return ApiError::UpstreamFailure.into_response(); }; diff --git a/crates/tranquil-pds/src/appview/mod.rs b/crates/tranquil-pds/src/appview/mod.rs deleted file mode 100644 index 277df34..0000000 --- a/crates/tranquil-pds/src/appview/mod.rs +++ /dev/null @@ -1,418 +0,0 @@ -use reqwest::Client; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use std::sync::Arc; -use std::time::{Duration, Instant}; -use tokio::sync::RwLock; -use tracing::{debug, error, info, warn}; - -#[derive(Debug, thiserror::Error)] -pub enum DidResolutionError { - #[error("Invalid did:web format")] - InvalidDidWeb, - #[error("HTTP request failed: {0}")] - HttpFailed(String), - #[error("Invalid DID document: {0}")] - InvalidDocument(String), - #[error("DID not found")] - NotFound, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct DidDocument { - pub id: String, - #[serde(default)] - pub service: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct DidService { - pub id: String, - #[serde(rename = "type")] - pub service_type: String, - pub service_endpoint: String, -} - -#[derive(Clone)] -struct CachedDid { - url: String, - did: String, - resolved_at: Instant, -} - -#[derive(Clone)] -struct CachedDidDocument { - document: serde_json::Value, - resolved_at: Instant, -} - -#[derive(Debug, Clone)] -pub struct ResolvedService { - pub url: String, - pub did: String, -} - -#[derive(Clone)] -pub struct DidResolver { - did_cache: Arc>>, - did_doc_cache: Arc>>, - client: Client, - cache_ttl: Duration, - plc_directory_url: String, -} - -impl DidResolver { - pub fn new() -> Self { - let cfg = tranquil_config::get(); - let cache_ttl_secs = cfg.plc.did_cache_ttl_secs; - - let plc_directory_url = cfg.plc.directory_url.clone(); - - let client = Client::builder() - .timeout(Duration::from_secs(10)) - .connect_timeout(Duration::from_secs(5)) - .pool_max_idle_per_host(10) - .build() - .unwrap_or_else(|_| Client::new()); - - info!("DID resolver initialized"); - - Self { - did_cache: Arc::new(RwLock::new(HashMap::new())), - did_doc_cache: Arc::new(RwLock::new(HashMap::new())), - client, - cache_ttl: Duration::from_secs(cache_ttl_secs), - plc_directory_url, - } - } - - fn build_did_web_url(did: &str) -> Result { - let host = did - .strip_prefix("did:web:") - .ok_or(DidResolutionError::InvalidDidWeb)?; - - let (host, path) = if host.contains(':') { - let decoded = host.replace("%3A", ":"); - let parts: Vec<&str> = decoded.splitn(2, '/').collect(); - if parts.len() > 1 { - (parts[0].to_string(), format!("/{}", parts[1])) - } else { - (decoded, String::new()) - } - } else { - let parts: Vec<&str> = host.splitn(2, ':').collect(); - if parts.len() > 1 && parts[1].contains('/') { - let path_parts: Vec<&str> = parts[1].splitn(2, '/').collect(); - if path_parts.len() > 1 { - ( - format!("{}:{}", parts[0], path_parts[0]), - format!("/{}", path_parts[1]), - ) - } else { - (host.to_string(), String::new()) - } - } else { - (host.to_string(), String::new()) - } - }; - - let scheme = - if host.starts_with("localhost") || host.starts_with("127.0.0.1") || host.contains(':') - { - "http" - } else { - "https" - }; - - let url = if path.is_empty() { - format!("{}://{}/.well-known/did.json", scheme, host) - } else { - format!("{}://{}{}/did.json", scheme, host, path) - }; - - Ok(url) - } - - pub async fn resolve_did(&self, did: &str) -> Option { - { - let cache = self.did_cache.read().await; - if let Some(cached) = cache.get(did) - && cached.resolved_at.elapsed() < self.cache_ttl - { - return Some(ResolvedService { - url: cached.url.clone(), - did: cached.did.clone(), - }); - } - } - - let resolved = self.resolve_did_internal(did).await?; - - { - let mut cache = self.did_cache.write().await; - cache.insert( - did.to_string(), - CachedDid { - url: resolved.url.clone(), - did: resolved.did.clone(), - resolved_at: Instant::now(), - }, - ); - } - - Some(resolved) - } - - pub async fn refresh_did(&self, did: &str) -> Option { - { - let mut cache = self.did_cache.write().await; - cache.remove(did); - } - self.resolve_did(did).await - } - - async fn resolve_did_internal(&self, did: &str) -> Option { - let did_doc = if did.starts_with("did:web:") { - self.resolve_did_web(did).await - } else if did.starts_with("did:plc:") { - self.resolve_did_plc(did).await - } else { - warn!("Unsupported DID method: {}", did); - return None; - }; - - let doc = match did_doc { - Ok(doc) => doc, - Err(e) => { - error!("Failed to resolve DID {}: {}", did, e); - return None; - } - }; - - self.extract_service_endpoint(&doc) - } - - async fn resolve_did_web(&self, did: &str) -> Result { - let url = Self::build_did_web_url(did)?; - - debug!("Resolving did:web {} via {}", did, url); - - let resp = self - .client - .get(&url) - .send() - .await - .map_err(|e| DidResolutionError::HttpFailed(e.to_string()))?; - - if !resp.status().is_success() { - return Err(DidResolutionError::HttpFailed(format!( - "HTTP {}", - resp.status() - ))); - } - - resp.json::() - .await - .map_err(|e| DidResolutionError::InvalidDocument(e.to_string())) - } - - async fn resolve_did_plc(&self, did: &str) -> Result { - let url = format!("{}/{}", self.plc_directory_url, urlencoding::encode(did)); - - debug!("Resolving did:plc {} via {}", did, url); - - let resp = self - .client - .get(&url) - .send() - .await - .map_err(|e| DidResolutionError::HttpFailed(e.to_string()))?; - - if resp.status() == reqwest::StatusCode::NOT_FOUND { - return Err(DidResolutionError::NotFound); - } - - if !resp.status().is_success() { - return Err(DidResolutionError::HttpFailed(format!( - "HTTP {}", - resp.status() - ))); - } - - resp.json::() - .await - .map_err(|e| DidResolutionError::InvalidDocument(e.to_string())) - } - - fn extract_service_endpoint(&self, doc: &DidDocument) -> Option { - if let Some(service) = doc.service.iter().find(|s| { - s.service_type == crate::plc::ServiceType::AppView.as_str() - || s.id.contains("atproto_appview") - || s.id.ends_with("#bsky_appview") - }) { - return Some(ResolvedService { - url: service.service_endpoint.clone(), - did: doc.id.clone(), - }); - } - - if let Some(service) = doc - .service - .iter() - .find(|s| s.service_type.contains("AppView") || s.id.contains("appview")) - { - return Some(ResolvedService { - url: service.service_endpoint.clone(), - did: doc.id.clone(), - }); - } - - if let Some(service) = doc.service.first() - && service.service_endpoint.starts_with("http") - { - warn!( - "No explicit AppView service found for {}, using first service: {}", - doc.id, service.service_endpoint - ); - return Some(ResolvedService { - url: service.service_endpoint.clone(), - did: doc.id.clone(), - }); - } - - if doc.id.starts_with("did:web:") { - let host = doc.id.strip_prefix("did:web:")?; - let decoded_host = host.replace("%3A", ":"); - let base_host = decoded_host.split('/').next()?; - let scheme = if base_host.starts_with("localhost") - || base_host.starts_with("127.0.0.1") - || base_host.contains(':') - { - "http" - } else { - "https" - }; - warn!( - "No service found for {}, deriving URL from DID: {}://{}", - doc.id, scheme, base_host - ); - return Some(ResolvedService { - url: format!("{}://{}", scheme, base_host), - did: doc.id.clone(), - }); - } - - None - } - - pub async fn resolve_did_document(&self, did: &str) -> Option { - { - let cache = self.did_doc_cache.read().await; - if let Some(cached) = cache.get(did) - && cached.resolved_at.elapsed() < self.cache_ttl - { - return Some(cached.document.clone()); - } - } - - let result = if did.starts_with("did:web:") { - self.fetch_did_document_web(did).await - } else if did.starts_with("did:plc:") { - self.fetch_did_document_plc(did).await - } else { - warn!("Unsupported DID method for document resolution: {}", did); - return None; - }; - - match result { - Ok(doc) => { - let mut cache = self.did_doc_cache.write().await; - cache.insert( - did.to_string(), - CachedDidDocument { - document: doc.clone(), - resolved_at: Instant::now(), - }, - ); - Some(doc) - } - Err(e) => { - warn!("Failed to resolve DID document for {}: {}", did, e); - None - } - } - } - - async fn fetch_did_document_web( - &self, - did: &str, - ) -> Result { - let url = Self::build_did_web_url(did)?; - - let resp = self - .client - .get(&url) - .send() - .await - .map_err(|e| DidResolutionError::HttpFailed(e.to_string()))?; - - if !resp.status().is_success() { - return Err(DidResolutionError::HttpFailed(format!( - "HTTP {}", - resp.status() - ))); - } - - resp.json::() - .await - .map_err(|e| DidResolutionError::InvalidDocument(e.to_string())) - } - - async fn fetch_did_document_plc( - &self, - did: &str, - ) -> Result { - let url = format!("{}/{}", self.plc_directory_url, urlencoding::encode(did)); - - let resp = self - .client - .get(&url) - .send() - .await - .map_err(|e| DidResolutionError::HttpFailed(e.to_string()))?; - - if resp.status() == reqwest::StatusCode::NOT_FOUND { - return Err(DidResolutionError::NotFound); - } - - if !resp.status().is_success() { - return Err(DidResolutionError::HttpFailed(format!( - "HTTP {}", - resp.status() - ))); - } - - resp.json::() - .await - .map_err(|e| DidResolutionError::InvalidDocument(e.to_string())) - } - - pub async fn invalidate_cache(&self, did: &str) { - let mut cache = self.did_cache.write().await; - cache.remove(did); - drop(cache); - let mut doc_cache = self.did_doc_cache.write().await; - doc_cache.remove(did); - } -} - -impl Default for DidResolver { - fn default() -> Self { - Self::new() - } -} - -pub fn create_did_resolver() -> Arc { - Arc::new(DidResolver::new()) -} diff --git a/crates/tranquil-pds/src/delegation/mod.rs b/crates/tranquil-pds/src/delegation/mod.rs index 80e15ef..cbe9750 100644 --- a/crates/tranquil-pds/src/delegation/mod.rs +++ b/crates/tranquil-pds/src/delegation/mod.rs @@ -10,6 +10,7 @@ pub use scopes::{ }; pub use tranquil_db_traits::DelegationActionType; +use crate::did::DidResolutionError; use crate::state::AppState; use crate::types::Did; @@ -24,7 +25,10 @@ pub struct ResolvedIdentity { pub is_local: bool, } -pub async fn resolve_identity(state: &AppState, did: &Did) -> Option { +pub async fn resolve_identity( + state: &AppState, + did: &Did, +) -> Result { let is_local = state .repos.user .get_by_did(did) @@ -33,15 +37,24 @@ pub async fn resolve_identity(state: &AppState, did: &Did) -> Option, + #[serde(default)] + #[serde(rename = "alsoKnownAs")] + pub also_known_as: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DidService { + pub id: String, + #[serde(rename = "type")] + pub service_type: String, + pub service_endpoint: String, +} + +#[derive(Debug, Clone)] +pub struct ResolvedService { + pub url: String, + pub did: String, + pub service_id: String, +} + +pub struct DidResolver { + did_doc_cache: RwLock, (Instant, Arc)>>, + parsed_did_doc_cache: RwLock, (Instant, Arc)>>, + service_cache: RwLock, (Instant, Arc)>>, + client: Client, + cache_ttl: Duration, + plc_directory_url: String, +} + +impl DidResolver { + pub fn new() -> Self { + let cfg = tranquil_config::get(); + let cache_ttl_secs = cfg.plc.did_cache_ttl_secs; + + let plc_directory_url = cfg.plc.directory_url.clone(); + + let client = Client::builder() + .timeout(Duration::from_secs(10)) + .connect_timeout(Duration::from_secs(5)) + .pool_max_idle_per_host(10) + .build() + .unwrap_or_else(|_| Client::new()); + + info!("DID resolver initialized"); + + Self { + did_doc_cache: RwLock::new(HashMap::new()), + parsed_did_doc_cache: RwLock::new(HashMap::new()), + service_cache: RwLock::new(HashMap::new()), + client, + cache_ttl: Duration::from_secs(cache_ttl_secs), + plc_directory_url, + } + } + + pub async fn resolve_service( + &self, + did: &str, + service_id: &str, + ) -> Result, ServiceResolutionError> { + { + let cache = self.service_cache.read().await; + if let Some(cached) = cache.get(&*format!("{did}#{service_id}")) + && cached.0.elapsed() < self.cache_ttl + { + return Ok(cached.1.clone()); + } + } + + let did_doc = self.resolve_did(did).await?; + let Some(service) = did_doc + .services + .iter() + .find(|s| s.id.ends_with(&format!("#{service_id}"))) + else { + return Err(ServiceResolutionError::ServiceIdNotFound(service_id.into())); + }; + + let resolved = Arc::new(ResolvedService { + url: service.service_endpoint.clone(), + did: did.into(), + service_id: service_id.into(), + }); + + { + let mut cache = self.service_cache.write().await; + cache.insert( + format!("{did}#{service_id}").into(), + (Instant::now(), resolved.clone()), + ); + } + + Ok(resolved) + } + + pub async fn resolve_did(&self, did: &str) -> Result, DidResolutionError> { + { + let cache = self.parsed_did_doc_cache.read().await; + if let Some(cached) = cache.get(did) + && cached.0.elapsed() < self.cache_ttl + { + return Ok(cached.1.clone()); + } + } + + let resolved = Arc::new(self.resolve_did_uncached(did).await?); + + { + let mut cache = self.parsed_did_doc_cache.write().await; + cache.insert(did.into(), (Instant::now(), resolved.clone())); + } + + Ok(resolved) + } + + pub async fn refresh_did(&self, did: &str) -> Result, DidResolutionError> { + { + let mut cache = self.parsed_did_doc_cache.write().await; + cache.remove(did); + let mut cache = self.service_cache.write().await; + cache.retain(|k, _| !k.starts_with(did)); + } + self.resolve_did(did).await + } + + async fn resolve_did_uncached(&self, did: &str) -> Result { + if did.starts_with("did:web:") { + self.resolve_did_web(did).await + } else if did.starts_with("did:plc:") { + self.resolve_did_plc(did).await + } else { + warn!("Unsupported DID method: {}", did); + Err(DidResolutionError::UnsupportedDidMethod(did.into())) + } + } + + async fn resolve_did_web(&self, did: &str) -> Result { + let url = build_did_web_url(did)?; + + debug!("Resolving did:web {} via {}", did, url); + + let resp = self + .client + .get(&url) + .send() + .await + .map_err(|e| DidResolutionError::HttpFailed(e.to_string()))?; + + if !resp.status().is_success() { + return Err(DidResolutionError::HttpFailed(format!( + "HTTP {}", + resp.status() + ))); + } + + resp.json::() + .await + .map_err(|e| DidResolutionError::InvalidDocument(e.to_string())) + } + + async fn resolve_did_plc(&self, did: &str) -> Result { + let url = format!("{}/{}", self.plc_directory_url, urlencoding::encode(did)); + + debug!("Resolving did:plc {} via {}", did, url); + + let resp = self + .client + .get(&url) + .send() + .await + .map_err(|e| DidResolutionError::HttpFailed(e.to_string()))?; + + if resp.status() == reqwest::StatusCode::NOT_FOUND { + return Err(DidResolutionError::NotFound); + } + + if !resp.status().is_success() { + return Err(DidResolutionError::HttpFailed(format!( + "HTTP {}", + resp.status() + ))); + } + + resp.json::() + .await + .map_err(|e| DidResolutionError::InvalidDocument(e.to_string())) + } + + pub async fn fetch_did_document( + &self, + did: &str, + ) -> Result, DidResolutionError> { + { + let cache = self.did_doc_cache.read().await; + if let Some(cached) = cache.get(did) + && cached.0.elapsed() < self.cache_ttl + { + return Ok(cached.1.clone()); + } + } + + let resolved = Arc::new(self.fetch_did_document_uncached(did).await?); + + { + let mut cache = self.did_doc_cache.write().await; + cache.insert(did.into(), (Instant::now(), resolved.clone())); + } + + Ok(resolved) + } + + // TODO: make cached version + async fn fetch_did_document_uncached( + &self, + did: &str, + ) -> Result { + if did.starts_with("did:web:") { + self.fetch_did_document_web(did).await + } else if did.starts_with("did:plc:") { + self.fetch_did_document_plc(did).await + } else { + warn!("Unsupported DID method: {}", did); + Err(DidResolutionError::UnsupportedDidMethod(did.into())) + } + } + + async fn fetch_did_document_web( + &self, + did: &str, + ) -> Result { + let url = build_did_web_url(did)?; + + let resp = self + .client + .get(&url) + .send() + .await + .map_err(|e| DidResolutionError::HttpFailed(e.to_string()))?; + + if !resp.status().is_success() { + return Err(DidResolutionError::HttpFailed(format!( + "HTTP {}", + resp.status() + ))); + } + + resp.json::() + .await + .map_err(|e| DidResolutionError::InvalidDocument(e.to_string())) + } + + async fn fetch_did_document_plc( + &self, + did: &str, + ) -> Result { + let url = format!("{}/{}", self.plc_directory_url, urlencoding::encode(did)); + + let resp = self + .client + .get(&url) + .send() + .await + .map_err(|e| DidResolutionError::HttpFailed(e.to_string()))?; + + if resp.status() == reqwest::StatusCode::NOT_FOUND { + return Err(DidResolutionError::NotFound); + } + + if !resp.status().is_success() { + return Err(DidResolutionError::HttpFailed(format!( + "HTTP {}", + resp.status() + ))); + } + + resp.json::() + .await + .map_err(|e| DidResolutionError::InvalidDocument(e.to_string())) + } + + pub async fn invalidate_cache(&self, did: &str) { + let mut doc_cache = self.parsed_did_doc_cache.write().await; + doc_cache.remove(did); + } +} + +impl Default for DidResolver { + fn default() -> Self { + Self::new() + } +} + +pub fn create_did_resolver() -> Arc { + Arc::new(DidResolver::new()) +} + +fn build_did_web_url(did: &str) -> Result { + let host = did + .strip_prefix("did:web:") + .ok_or(DidResolutionError::InvalidDidWeb)?; + + let (host, path) = if host.contains(':') { + let decoded = host.replace("%3A", ":"); + let parts: Vec<&str> = decoded.splitn(2, '/').collect(); + if parts.len() > 1 { + (parts[0].to_string(), format!("/{}", parts[1])) + } else { + (decoded, String::new()) + } + } else { + let parts: Vec<&str> = host.splitn(2, ':').collect(); + if parts.len() > 1 && parts[1].contains('/') { + let path_parts: Vec<&str> = parts[1].splitn(2, '/').collect(); + if path_parts.len() > 1 { + ( + format!("{}:{}", parts[0], path_parts[0]), + format!("/{}", path_parts[1]), + ) + } else { + (host.to_string(), String::new()) + } + } else { + (host.to_string(), String::new()) + } + }; + + let scheme = + if host.starts_with("localhost") || host.starts_with("127.0.0.1") || host.contains(':') { + "http" + } else { + "https" + }; + + let url = if path.is_empty() { + format!("{}://{}/.well-known/did.json", scheme, host) + } else { + format!("{}://{}{}/did.json", scheme, host, path) + }; + + Ok(url) +} diff --git a/crates/tranquil-pds/src/lib.rs b/crates/tranquil-pds/src/lib.rs index 8aac451..e701281 100644 --- a/crates/tranquil-pds/src/lib.rs +++ b/crates/tranquil-pds/src/lib.rs @@ -1,5 +1,4 @@ pub mod api; -pub mod appview; pub mod auth; pub mod cache; pub mod cache_keys; @@ -9,6 +8,7 @@ pub mod comms; pub mod config; pub mod crawlers; pub mod delegation; +pub mod did; pub mod handle; pub mod image; pub mod metrics; diff --git a/crates/tranquil-pds/src/state.rs b/crates/tranquil-pds/src/state.rs index 3a8050c..813f24b 100644 --- a/crates/tranquil-pds/src/state.rs +++ b/crates/tranquil-pds/src/state.rs @@ -1,8 +1,8 @@ -use crate::appview::DidResolver; use crate::auth::webauthn::WebAuthnConfig; use crate::cache::{Cache, DistributedRateLimiter, create_cache}; use crate::circuit_breaker::CircuitBreakers; use crate::config::AuthConfig; +use crate::did::DidResolver; use crate::oauth::client::CrossPdsOAuthClient; use crate::plc::PlcClient; use crate::rate_limit::RateLimiters;