diff --git a/crates/jacquard-identity/src/lexicon_resolver.rs b/crates/jacquard-identity/src/lexicon_resolver.rs index dcd7c6ba..958aa226 100644 --- a/crates/jacquard-identity/src/lexicon_resolver.rs +++ b/crates/jacquard-identity/src/lexicon_resolver.rs @@ -130,6 +130,16 @@ impl LexiconResolutionError { ) } + pub fn resolution_failed(nsid: impl Into, message: impl Into) -> Self { + Self::new( + LexiconResolutionErrorKind::ResolutionFailed { + nsid: nsid.into(), + message: message.into(), + }, + None, + ) + } + pub fn invalid_collection() -> Self { Self::new(LexiconResolutionErrorKind::InvalidCollection, None) } @@ -181,6 +191,10 @@ pub enum LexiconResolutionErrorKind { #[diagnostic(code(jacquard::lexicon::parse_failed))] ParseFailed { nsid: SmolStr }, + #[error("failed to parse lexicon schema for {nsid}")] + #[diagnostic(code(jacquard::lexicon::resolution_failed))] + ResolutionFailed { nsid: SmolStr, message: SmolStr }, + #[error("invalid collection NSID")] #[diagnostic(code(jacquard::lexicon::invalid_collection))] InvalidCollection, @@ -248,45 +262,91 @@ impl crate::JacquardResolver { &self, nsid: &Nsid<'_>, ) -> std::result::Result, LexiconResolutionError> { - if let Ok(mut url) = Url::parse("https://public.api.bsky.app") { - url.set_path("/xrpc/com.atproto.lexicon.resolveLexicon"); - if let Ok(qs) = - serde_html_form::to_string(&ResolveLexicon::new().nsid(nsid.clone()).build()) - { - url.set_query(Some(&qs)); - } else { - return Err(LexiconResolutionError::invalid_collection()); - } - if let Ok((buf, status)) = self.get_json_bytes(url).await { - if status.is_success() { - if let Ok(val) = serde_json::from_slice::(&buf) { - if let Some(obj) = val.as_object() { - if let Some(schema) = obj.get("schema") { - if let Ok(schema) = from_json_value::(schema.clone()) { - let uri = - obj.get("uri").expect("uri should be present").to_string(); - let cid = - obj.get("cid").expect("cid should be present").to_string(); - let uri = AtUri::new_owned(uri).map_err(|e| { - LexiconResolutionError::parse_failed("uri", e) - })?; - let cid = Cid::str(&cid).into_static(); - let repo = Did::raw(uri.authority().as_str()).into_static(); - return Ok(ResolvedLexiconSchema { - repo, - cid, - nsid: nsid.clone().into_static(), - doc: schema.into_static(), - }); - } - } - } - } - } - } + #[cfg(feature = "tracing")] + tracing::debug!("resolving lexicon via XRPC: {}", nsid); + + let mut url = + Url::parse("https://public.api.bsky.app").expect("hardcoded URL should be valid"); + + url.set_path("/xrpc/com.atproto.lexicon.resolveLexicon"); + + let qs = serde_html_form::to_string(&ResolveLexicon::new().nsid(nsid.clone()).build()) + .map_err(|e| LexiconResolutionError::fetch_failed(nsid.as_str(), e))?; + url.set_query(Some(&qs)); + + #[cfg(feature = "tracing")] + tracing::debug!("fetching from URL: {}", url); + + let (buf, status) = self + .get_json_bytes(url) + .await + .map_err(|e| LexiconResolutionError::fetch_failed(nsid.as_str(), e))?; + + #[cfg(feature = "tracing")] + tracing::debug!("got response with status: {}", status); + + if !status.is_success() { + return Err(LexiconResolutionError::resolution_failed( + nsid.as_str(), + format!("HTTP {}", status.as_u16()), + )); } - Err(LexiconResolutionError::invalid_collection()) + let val = serde_json::from_slice::(&buf) + .map_err(|e| LexiconResolutionError::parse_failed(nsid.as_str(), e))?; + + #[cfg(feature = "tracing")] + tracing::debug!("parsed JSON response"); + + let obj = val.as_object().ok_or_else(|| { + LexiconResolutionError::resolution_failed(nsid.as_str(), "response not an object") + })?; + + let schema_val = obj.get("schema").ok_or_else(|| { + #[cfg(feature = "tracing")] + tracing::error!( + "response missing 'schema' field, got keys: {:?}", + obj.keys().collect::>() + ); + + LexiconResolutionError::resolution_failed(nsid.as_str(), "missing 'schema' field") + })?; + + #[cfg(feature = "tracing")] + tracing::debug!("found schema field in response"); + + let schema = from_json_value::(schema_val.clone()) + .map_err(|e| LexiconResolutionError::parse_failed(nsid.as_str(), e))?; + + let uri_str = obj.get("uri").and_then(|v| v.as_str()).ok_or_else(|| { + LexiconResolutionError::resolution_failed( + nsid.as_str(), + "missing or invalid 'uri' field", + ) + })?; + + let cid_str = obj.get("cid").and_then(|v| v.as_str()).ok_or_else(|| { + LexiconResolutionError::resolution_failed( + nsid.as_str(), + "missing or invalid 'cid' field", + ) + })?; + + let uri = AtUri::new_owned(uri_str) + .map_err(|e| LexiconResolutionError::parse_failed(nsid.as_str(), e))?; + + let cid = Cid::str(cid_str).into_static(); + let repo = Did::raw(uri.authority().as_str()).into_static(); + + #[cfg(feature = "tracing")] + tracing::debug!("successfully resolved lexicon schema for {}", nsid); + + Ok(ResolvedLexiconSchema { + repo, + cid, + nsid: nsid.clone().into_static(), + doc: schema.into_static(), + }) } } @@ -334,9 +394,76 @@ impl LexiconAuthorityResolver for crate::JacquardResolver { impl LexiconAuthorityResolver for crate::JacquardResolver { async fn resolve_lexicon_authority( &self, - _nsid: &Nsid<'_>, + nsid: &Nsid<'_>, ) -> std::result::Result, LexiconResolutionError> { - Err(LexiconResolutionError::dns_not_configured()) + // Use DNS-over-HTTPS fallback for WASM/non-DNS builds + self.resolve_lexicon_authority_doh(nsid).await + } +} + +impl crate::JacquardResolver { + /// Resolve lexicon authority via DNS-over-HTTPS (for WASM compatibility) + #[allow(dead_code)] + async fn resolve_lexicon_authority_doh( + &self, + nsid: &Nsid<'_>, + ) -> std::result::Result, LexiconResolutionError> { + // Try cache first + #[cfg(feature = "cache")] + if let Some(caches) = &self.caches { + let authority = jacquard_common::smol_str::SmolStr::from(nsid.domain_authority()); + if let Some(did) = crate::cache_impl::get(&caches.authority_to_did, &authority) { + return Ok(did); + } + } + + let authority = nsid.domain_authority(); + let reversed_authority = authority.split('.').rev().collect::>().join("."); + let fqdn = format!("_lexicon.{}.", reversed_authority); + + #[cfg(feature = "tracing")] + tracing::trace!("resolving lexicon authority via DoH: {}", fqdn); + + let response = self + .query_dns_doh(&fqdn, "TXT") + .await + .map_err(|e| LexiconResolutionError::dns_lookup_failed(authority, e))?; + + // Parse DoH JSON response + let answers = response + .get("Answer") + .and_then(|a| a.as_array()) + .ok_or_else(|| LexiconResolutionError::no_did_found(authority))?; + + for answer in answers { + if let Some(data) = answer.get("data").and_then(|d| d.as_str()) { + // TXT records are quoted in DNS responses, strip quotes + let txt_data = data.trim_matches('"'); + + if let Some(did_str) = txt_data.strip_prefix("did=") { + let result = Did::new_owned(did_str) + .map(|d| d.into_static()) + .map_err(|_| LexiconResolutionError::invalid_did(authority, did_str)); + + // Cache on success + if let Ok(ref did) = result { + #[cfg(feature = "cache")] + if let Some(caches) = &self.caches { + let authority_key = jacquard_common::smol_str::SmolStr::from(authority); + crate::cache_impl::insert( + &caches.authority_to_did, + authority_key, + did.clone(), + ); + } + } + + return result; + } + } + } + + Err(LexiconResolutionError::no_did_found(authority)) } } @@ -358,13 +485,13 @@ impl LexiconSchemaResolver for crate::JacquardResolver { } // Perform resolution - #[cfg(feature = "dns")] + //#[cfg(feature = "dns")] let result = async { // 1. Resolve authority DID via DNS let authority_did = self.resolve_lexicon_authority(nsid).await?; #[cfg(feature = "tracing")] - tracing::debug!( + tracing::trace!( "resolved lexicon authority {} -> {}", nsid.domain_authority(), authority_did @@ -378,7 +505,7 @@ impl LexiconSchemaResolver for crate::JacquardResolver { .ok_or_else(|| IdentityError::missing_pds_endpoint())?; #[cfg(feature = "tracing")] - tracing::debug!("fetching lexicon {} from PDS {}", nsid, pds); + tracing::trace!("fetching lexicon {} from PDS {}", nsid, pds); // 3. Fetch lexicon record via XRPC getRecord let collection = Nsid::new("com.atproto.lexicon.schema") @@ -408,7 +535,7 @@ impl LexiconSchemaResolver for crate::JacquardResolver { .map_err(|e| LexiconResolutionError::parse_failed(nsid.as_str(), e))?; #[cfg(feature = "tracing")] - tracing::debug!("successfully parsed lexicon schema {}", nsid); + tracing::trace!("successfully parsed lexicon schema {}", nsid); let cid = output .cid @@ -424,9 +551,6 @@ impl LexiconSchemaResolver for crate::JacquardResolver { } .await; - #[cfg(not(feature = "dns"))] - let result = self.resolve_lexicon_xrpc(nsid).await; - // Handle result match result { Ok(schema) => { diff --git a/crates/jacquard-identity/src/lib.rs b/crates/jacquard-identity/src/lib.rs index 106c584e..4cbc26d2 100644 --- a/crates/jacquard-identity/src/lib.rs +++ b/crates/jacquard-identity/src/lib.rs @@ -477,6 +477,66 @@ impl JacquardResolver { Ok(out) } + /// Query DNS via DNS-over-HTTPS using Cloudflare + pub async fn query_dns_doh( + &self, + name: &str, + record_type: &str, + ) -> resolver::Result { + #[cfg(feature = "tracing")] + tracing::trace!("querying DNS via DoH: {} ({})", name, record_type); + + let mut url = Url::parse("https://cloudflare-dns.com/dns-query") + .expect("hardcoded URL should be valid"); + + url.query_pairs_mut() + .append_pair("name", name) + .append_pair("type", record_type); + + let response = self + .http + .get(url) + .header("Accept", "application/dns-json") + .send() + .await?; + + let status = response.status(); + if !status.is_success() { + return Err(IdentityError::http_status(status)); + } + + let json: serde_json::Value = response.json().await?; + Ok(json) + } + + #[cfg(not(feature = "dns"))] + async fn dns_txt(&self, name: &str) -> resolver::Result> { + let fqdn = format!("_atproto.{name}."); + let response = self + .query_dns_doh(&fqdn, "TXT") + .await + .map_err(|e| IdentityError::dns(e))?; + + // Parse DoH JSON response + let answers = response + .get("Answer") + .and_then(|a| a.as_array()) + .ok_or_else(|| { + IdentityError::invalid_well_known().with_context(format!( + "couldn't parse cloudflare DoH answers looking for {name}" + )) + })?; + + let mut results: Vec = Vec::new(); + for answer in answers { + if let Some(data) = answer.get("data").and_then(|d| d.as_str()) { + // TXT records are quoted in DNS responses, strip quotes + results.push(data.trim_matches('"').to_string()) + } + } + Ok(results) + } + fn parse_atproto_did_body(body: &str) -> resolver::Result> { let line = body .lines() @@ -592,15 +652,12 @@ impl IdentityResolver for JacquardResolver { 'outer: for step in &self.opts.handle_order { match step { HandleStep::DnsTxt => { - #[cfg(feature = "dns")] - { - if let Ok(txts) = self.dns_txt(host).await { - for txt in txts { - if let Some(did_str) = txt.strip_prefix("did=") { - if let Ok(did) = Did::new(did_str) { - resolved_did = Some(did.into_static()); - break 'outer; - } + if let Ok(txts) = self.dns_txt(host).await { + for txt in txts { + if let Some(did_str) = txt.strip_prefix("did=") { + if let Ok(did) = Did::new(did_str) { + resolved_did = Some(did.into_static()); + break 'outer; } } } diff --git a/crates/jacquard-identity/src/resolver.rs b/crates/jacquard-identity/src/resolver.rs index 81a4a93b..bc865890 100644 --- a/crates/jacquard-identity/src/resolver.rs +++ b/crates/jacquard-identity/src/resolver.rs @@ -233,6 +233,8 @@ impl Default for ResolverOptions { handle_order.push(HandleStep::PdsResolveHandle); #[cfg(target_family = "wasm")] handle_order.push(HandleStep::HttpsWellKnown); + #[cfg(target_family = "wasm")] + handle_order.push(HandleStep::DnsTxt); let mut did_order = vec![]; #[cfg(not(target_family = "wasm"))] @@ -558,7 +560,7 @@ pub enum IdentityErrorKind { Url, /// DNS resolution error - #[cfg(all(feature = "dns", not(target_family = "wasm")))] + //#[cfg(all(feature = "dns", not(target_family = "wasm")))] #[error("DNS resolution error")] #[diagnostic( code(jacquard::identity::dns), @@ -667,7 +669,7 @@ impl IdentityError { } /// Create a DNS error - #[cfg(all(feature = "dns", not(target_family = "wasm")))] + //#[cfg(all(feature = "dns", not(target_family = "wasm")))] pub fn dns(source: impl std::error::Error + Send + Sync + 'static) -> Self { Self::new(IdentityErrorKind::Dns, Some(Box::new(source))) } diff --git a/crates/jacquard-lexicon/src/validation.rs b/crates/jacquard-lexicon/src/validation.rs index 11ba048f..cae5cadb 100644 --- a/crates/jacquard-lexicon/src/validation.rs +++ b/crates/jacquard-lexicon/src/validation.rs @@ -365,13 +365,14 @@ pub struct SchemaValidator { cache: DashMap>, } +static VALIDATOR: LazyLock = LazyLock::new(|| SchemaValidator { + registry: SchemaRegistry::from_inventory(), + cache: DashMap::new(), +}); + impl SchemaValidator { /// Get the global validator instance pub fn global() -> &'static Self { - static VALIDATOR: LazyLock = LazyLock::new(|| SchemaValidator { - registry: SchemaRegistry::from_inventory(), - cache: DashMap::new(), - }); &VALIDATOR }