diff --git a/.zed/settings.json b/.zed/settings.json index 816e93cba..3ac05441b 100644 --- a/.zed/settings.json +++ b/.zed/settings.json @@ -10,7 +10,7 @@ "analyzerTargetDir": true }, "cargo": { - //"allFeatures": true + "allFeatures": true } } } diff --git a/Cargo.lock b/Cargo.lock index 0fbdb064c..1176438cb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2382,6 +2382,7 @@ dependencies = [ "http", "jacquard-api", "jacquard-common", + "jacquard-lexicon", "miette", "n0-future", "percent-encoding", diff --git a/crates/jacquard-identity/Cargo.toml b/crates/jacquard-identity/Cargo.toml index 0a160982f..0d2816e8d 100644 --- a/crates/jacquard-identity/Cargo.toml +++ b/crates/jacquard-identity/Cargo.toml @@ -23,6 +23,7 @@ bon.workspace = true bytes.workspace = true jacquard-common = { version = "0.8", path = "../jacquard-common", features = ["reqwest-client"] } jacquard-api = { version = "0.8", path = "../jacquard-api", default-features = false, features = ["minimal"] } +jacquard-lexicon = { version = "0.8", path = "../jacquard-lexicon", default-features = false } percent-encoding.workspace = true reqwest.workspace = true url.workspace = true @@ -39,3 +40,8 @@ n0-future = { workspace = true, optional = true } [target.'cfg(not(target_family = "wasm"))'.dependencies] hickory-resolver = { optional = true, version = "0.24", default-features = false, features = ["system-config", "tokio-runtime"]} tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } + +[[example]] +name = "resolve_lexicon" +path = "../../examples/resolve_lexicon.rs" +required-features = ["dns"] diff --git a/crates/jacquard-identity/src/lexicon_resolver.rs b/crates/jacquard-identity/src/lexicon_resolver.rs new file mode 100644 index 000000000..8df78b833 --- /dev/null +++ b/crates/jacquard-identity/src/lexicon_resolver.rs @@ -0,0 +1,327 @@ +//! Lexicon schema resolution via DNS and XRPC +//! +//! This module provides traits and implementations for resolving lexicon schemas at runtime: +//! 1. Resolve NSID authority to DID via DNS TXT records (`_lexicon.{reversed-authority}`) +//! 2. Fetch lexicon schema from `com.atproto.lexicon.schema` collection via XRPC + +use crate::resolver::{IdentityError, IdentityResolver}; +use jacquard_common::{ + IntoStatic, smol_str, + types::{cid::Cid, did::Did, string::Nsid}, +}; +use smol_str::SmolStr; + +/// Resolve lexicon authority (NSID → authoritative DID) +#[cfg_attr(not(target_arch = "wasm32"), trait_variant::make(Send))] +pub trait LexiconAuthorityResolver { + /// Resolve an NSID to the authoritative DID via DNS + /// + /// Uses DNS TXT records at `_lexicon.{reversed-authority}`, following the + /// AT Protocol lexicon authority spec. Authority segments are reversed + /// (e.g., `app.bsky.feed` → query `_lexicon.feed.bsky.app`). + /// + /// Note: No hierarchical fallback - per the spec, only exact authority match is checked. + async fn resolve_lexicon_authority( + &self, + nsid: &Nsid, + ) -> std::result::Result, LexiconResolutionError>; +} + +/// Resolve lexicon schemas (NSID → schema document) +#[cfg_attr(not(target_arch = "wasm32"), trait_variant::make(Send))] +pub trait LexiconSchemaResolver { + /// Resolve a complete lexicon schema for an NSID + async fn resolve_lexicon_schema( + &self, + nsid: &Nsid, + ) -> std::result::Result, LexiconResolutionError>; +} + +/// A resolved lexicon schema with metadata +#[derive(Debug, Clone)] +pub struct ResolvedLexiconSchema<'s> { + /// The NSID of the schema + pub nsid: Nsid<'s>, + /// DID of the repository this schema was fetched from + pub repo: Did<'s>, + /// Content ID of the record (for cache invalidation) + pub cid: Cid<'s>, + /// Parsed lexicon document + pub doc: jacquard_lexicon::lexicon::LexiconDoc<'s>, +} + +/// Error type for lexicon resolution operations +#[derive(Debug, thiserror::Error, miette::Diagnostic)] +#[error("{kind}")] +pub struct LexiconResolutionError { + #[diagnostic_source] + kind: LexiconResolutionErrorKind, + #[source] + source: Option>, +} + +impl LexiconResolutionError { + pub fn new( + kind: LexiconResolutionErrorKind, + source: Option>, + ) -> Self { + Self { kind, source } + } + + pub fn kind(&self) -> &LexiconResolutionErrorKind { + &self.kind + } + + pub fn dns_lookup_failed( + authority: impl Into, + source: impl std::error::Error + Send + Sync + 'static, + ) -> Self { + Self::new( + LexiconResolutionErrorKind::DnsLookupFailed { + authority: authority.into(), + }, + Some(Box::new(source)), + ) + } + + pub fn no_did_found(authority: impl Into) -> Self { + Self::new( + LexiconResolutionErrorKind::NoDIDFound { + authority: authority.into(), + }, + None, + ) + } + + pub fn invalid_did(authority: impl Into, value: impl Into) -> Self { + Self::new( + LexiconResolutionErrorKind::InvalidDID { + authority: authority.into(), + value: value.into(), + }, + None, + ) + } + + pub fn dns_not_configured() -> Self { + Self::new(LexiconResolutionErrorKind::DnsNotConfigured, None) + } + + pub fn fetch_failed( + nsid: impl Into, + source: impl std::error::Error + Send + Sync + 'static, + ) -> Self { + Self::new( + LexiconResolutionErrorKind::FetchFailed { nsid: nsid.into() }, + Some(Box::new(source)), + ) + } + + pub fn parse_failed( + nsid: impl Into, + source: impl std::error::Error + Send + Sync + 'static, + ) -> Self { + Self::new( + LexiconResolutionErrorKind::ParseFailed { nsid: nsid.into() }, + Some(Box::new(source)), + ) + } + + pub fn invalid_collection() -> Self { + Self::new(LexiconResolutionErrorKind::InvalidCollection, None) + } + + pub fn missing_cid(nsid: impl Into) -> Self { + Self::new( + LexiconResolutionErrorKind::MissingCID { nsid: nsid.into() }, + None, + ) + } +} + +impl From for LexiconResolutionError { + fn from(err: IdentityError) -> Self { + Self::new(LexiconResolutionErrorKind::IdentityResolution(err), None) + } +} + +/// Error categories for lexicon resolution +#[derive(Debug, thiserror::Error, miette::Diagnostic)] +pub enum LexiconResolutionErrorKind { + #[error("DNS lookup failed for authority {authority}")] + #[diagnostic(code(jacquard::lexicon::dns_lookup_failed))] + DnsLookupFailed { authority: SmolStr }, + + #[error("no DID found in DNS for authority {authority}")] + #[diagnostic( + code(jacquard::lexicon::no_did_found), + help("ensure _lexicon.{{reversed-authority}} TXT record exists with did=...") + )] + NoDIDFound { authority: SmolStr }, + + #[error("invalid DID in DNS for authority {authority}: {value}")] + #[diagnostic(code(jacquard::lexicon::invalid_did))] + InvalidDID { authority: SmolStr, value: SmolStr }, + + #[error("DNS not configured (dns feature disabled or WASM target)")] + #[diagnostic( + code(jacquard::lexicon::dns_not_configured), + help("enable the 'dns' feature or use a non-WASM target") + )] + DnsNotConfigured, + + #[error("failed to fetch lexicon record for {nsid}")] + #[diagnostic(code(jacquard::lexicon::fetch_failed))] + FetchFailed { nsid: SmolStr }, + + #[error("failed to parse lexicon schema for {nsid}")] + #[diagnostic(code(jacquard::lexicon::parse_failed))] + ParseFailed { nsid: SmolStr }, + + #[error("invalid collection NSID")] + #[diagnostic(code(jacquard::lexicon::invalid_collection))] + InvalidCollection, + + #[error("record missing CID for {nsid}")] + #[diagnostic(code(jacquard::lexicon::missing_cid))] + MissingCID { nsid: SmolStr }, + + #[error(transparent)] + #[diagnostic(code(jacquard::lexicon::identity_resolution_failed))] + IdentityResolution(#[from] crate::resolver::IdentityError), +} + +// Implementation on JacquardResolver +impl crate::JacquardResolver { + /// Resolve lexicon authority via DNS + /// + /// Queries `_lexicon.{reversed-authority}` for a TXT record containing `did=...` + #[cfg(all(feature = "dns", not(target_family = "wasm")))] + async fn resolve_lexicon_authority_dns( + &self, + nsid: &Nsid<'_>, + ) -> std::result::Result, LexiconResolutionError> { + let Some(dns) = &self.dns else { + return Err(LexiconResolutionError::dns_not_configured()); + }; + + // Extract and reverse authority segments + let authority = nsid.domain_authority(); + let reversed_authority = authority.split('.').rev().collect::>().join("."); + let fqdn = format!("_lexicon.{}.", reversed_authority); + + #[cfg(feature = "tracing")] + tracing::debug!("resolving lexicon authority via DNS: {}", fqdn); + + let response = dns + .txt_lookup(fqdn) + .await + .map_err(|e| LexiconResolutionError::dns_lookup_failed(authority, e))?; + + // Parse TXT records looking for "did=..." + for txt in response.iter() { + for data in txt.txt_data().iter() { + let text = std::str::from_utf8(data).unwrap_or(""); + if let Some(did_str) = text.strip_prefix("did=") { + return Did::new_owned(did_str) + .map(|d| d.into_static()) + .map_err(|_| LexiconResolutionError::invalid_did(authority, did_str)); + } + } + } + + Err(LexiconResolutionError::no_did_found(authority)) + } +} + +#[cfg(all(feature = "dns", not(target_family = "wasm")))] +impl LexiconAuthorityResolver for crate::JacquardResolver { + async fn resolve_lexicon_authority( + &self, + nsid: &Nsid<'_>, + ) -> std::result::Result, LexiconResolutionError> { + self.resolve_lexicon_authority_dns(nsid).await + } +} + +#[cfg(not(all(feature = "dns", not(target_family = "wasm"))))] +impl LexiconAuthorityResolver for crate::JacquardResolver { + async fn resolve_lexicon_authority( + &self, + _nsid: &Nsid<'_>, + ) -> std::result::Result, LexiconResolutionError> { + Err(LexiconResolutionError::dns_not_configured()) + } +} + +impl LexiconSchemaResolver for crate::JacquardResolver { + async fn resolve_lexicon_schema( + &self, + nsid: &Nsid<'_>, + ) -> std::result::Result, LexiconResolutionError> { + use jacquard_api::com_atproto::repo::get_record::GetRecord; + use jacquard_common::{IntoStatic, xrpc::XrpcExt}; + + // 1. Resolve authority DID via DNS + let authority_did = self.resolve_lexicon_authority(nsid).await?; + + #[cfg(feature = "tracing")] + tracing::debug!( + "resolved lexicon authority {} -> {}", + nsid.domain_authority(), + authority_did + ); + + // 2. Resolve DID document to get PDS endpoint + let did_doc_resp = self.resolve_did_doc(&authority_did).await?; + let did_doc = did_doc_resp.parse()?; + let pds = did_doc + .pds_endpoint() + .ok_or_else(|| IdentityError::missing_pds_endpoint())?; + + #[cfg(feature = "tracing")] + tracing::debug!("fetching lexicon {} from PDS {}", nsid, pds); + + // 3. Fetch lexicon record via XRPC getRecord + let collection = Nsid::new("com.atproto.lexicon.schema") + .map_err(|_| LexiconResolutionError::invalid_collection())?; + + let request = GetRecord::new() + .repo(authority_did.clone()) + .collection(collection.into_static()) + .rkey(nsid.clone()) + .build(); + + let response = self + .xrpc(pds) + .send(&request) + .await + .map_err(|e| LexiconResolutionError::fetch_failed(nsid.as_str(), e))?; + + let output = response + .into_output() + .map_err(|e| LexiconResolutionError::fetch_failed(nsid.as_str(), e))?; + + // 4. Parse lexicon document from value + let json_str = serde_json::to_string(&output.value) + .map_err(|e| LexiconResolutionError::parse_failed(nsid.as_str(), e))?; + + let doc: jacquard_lexicon::lexicon::LexiconDoc = serde_json::from_str(&json_str) + .map_err(|e| LexiconResolutionError::parse_failed(nsid.as_str(), e))?; + + #[cfg(feature = "tracing")] + tracing::debug!("successfully parsed lexicon schema {}", nsid); + + let cid = output + .cid + .ok_or_else(|| LexiconResolutionError::missing_cid(nsid.as_str()))? + .into_static(); + + Ok(ResolvedLexiconSchema { + nsid: nsid.clone().into_static(), + repo: authority_did.into_static(), + cid, + doc: doc.into_static(), + }) + } +} diff --git a/crates/jacquard-identity/src/lib.rs b/crates/jacquard-identity/src/lib.rs index 161c1ca09..9e19c308e 100644 --- a/crates/jacquard-identity/src/lib.rs +++ b/crates/jacquard-identity/src/lib.rs @@ -68,6 +68,7 @@ // use crate::CowStr; // not currently needed directly here #![cfg_attr(target_arch = "wasm32", allow(unused))] +pub mod lexicon_resolver; pub mod resolver; use crate::resolver::{ diff --git a/crates/jacquard-lexgen/Cargo.toml b/crates/jacquard-lexgen/Cargo.toml index bdd4430d7..51528a657 100644 --- a/crates/jacquard-lexgen/Cargo.toml +++ b/crates/jacquard-lexgen/Cargo.toml @@ -23,6 +23,12 @@ path = "src/bin/lex_fetch.rs" name = "extract-schemas" path = "src/bin/extract_schemas.rs" +[[example]] +name = "extract_inventory" +path = "../../examples/extract_inventory.rs" +required-features = ["codegen"] + + [dependencies] clap.workspace = true glob = "0.3" @@ -30,7 +36,7 @@ inventory = "0.3" jacquard-api = { version = "0.8", path = "../jacquard-api", default-features = false, features = [ "minimal" ] } jacquard-common = { version = "0.8", features = [ "reqwest-client" ], path = "../jacquard-common" } jacquard-derive = { version = "0.8", path = "../jacquard-derive" } -jacquard-identity = { version = "0.8", path = "../jacquard-identity" } +jacquard-identity = { version = "0.8", path = "../jacquard-identity", features = ["dns"] } jacquard-lexicon = { version = "0.8", path = "../jacquard-lexicon" } kdl = "6" miette = { workspace = true, features = ["fancy"] } diff --git a/crates/jacquard-lexgen/lexicons.kdl.example b/crates/jacquard-lexgen/lexicons.kdl.example index 059ce5726..129b0538f 100644 --- a/crates/jacquard-lexgen/lexicons.kdl.example +++ b/crates/jacquard-lexgen/lexicons.kdl.example @@ -20,6 +20,11 @@ source "atproto" type="git" priority=101 { pattern "lexicons/**/*.json" } +// Fetch a single lexicon by NSID - will use DNS to resolve authority +source "bsky-post" type="atproto" priority=30 { + endpoint "app.bsky.feed.post" +} + // Fetch lexicons from a Git repository source "my-lexicons" type="git" priority=100 { repo "https://github.com/example/my-lexicons" diff --git a/crates/jacquard-lexgen/src/fetch/sources/atproto.rs b/crates/jacquard-lexgen/src/fetch/sources/atproto.rs index 1ac00d527..55df87a98 100644 --- a/crates/jacquard-lexgen/src/fetch/sources/atproto.rs +++ b/crates/jacquard-lexgen/src/fetch/sources/atproto.rs @@ -5,6 +5,7 @@ use jacquard_common::types::string::Nsid; use jacquard_common::xrpc::XrpcExt; use jacquard_common::{CowStr, IntoStatic}; use jacquard_identity::JacquardResolver; +use jacquard_identity::lexicon_resolver::LexiconSchemaResolver; use jacquard_identity::resolver::{IdentityResolver, ResolverOptions}; use jacquard_lexicon::lexicon::LexiconDoc; use miette::{Result, miette}; @@ -17,6 +18,23 @@ pub struct AtProtoSource { } impl AtProtoSource { + /// Fetch a single lexicon schema by NSID using DNS + XRPC resolution + async fn fetch_single_lexicon( + &self, + resolver: &JacquardResolver, + nsid: &Nsid<'_>, + ) -> Result>> { + let schema = resolver + .resolve_lexicon_schema(nsid) + .await + .map_err(|e| miette!("Failed to resolve lexicon '{}': {}", nsid, e))?; + + let mut lexicons = HashMap::new(); + lexicons.insert(schema.nsid.to_string(), schema.doc); + + Ok(lexicons) + } + fn parse_lexicon_record(record_data: &Record<'_>) -> Option> { // // Extract the 'value' field from the record // let value = match record_data { @@ -46,9 +64,14 @@ impl AtProtoSource { impl LexiconSource for AtProtoSource { async fn fetch(&self) -> Result>> { let http = reqwest::Client::new(); - let resolver = JacquardResolver::new(http, ResolverOptions::default()); + let resolver = JacquardResolver::new_dns(http.clone(), ResolverOptions::default()); + + // Try parsing as NSID first (for single lexicon fetch) + if let Ok(nsid) = Nsid::new(&self.endpoint) { + return self.fetch_single_lexicon(&resolver, &nsid).await; + } - // Parse endpoint as at-identifier (handle or DID) + // Otherwise parse as at-identifier (handle or DID) for bulk fetch let identifier = AtIdentifier::new(&self.endpoint) .map_err(|e| miette!("Invalid endpoint '{}': {}", self.endpoint, e))?; diff --git a/crates/jacquard-lexgen/examples/extract_inventory.rs b/examples/extract_inventory.rs similarity index 100% rename from crates/jacquard-lexgen/examples/extract_inventory.rs rename to examples/extract_inventory.rs diff --git a/examples/resolve_lexicon.rs b/examples/resolve_lexicon.rs new file mode 100644 index 000000000..e14e11ebf --- /dev/null +++ b/examples/resolve_lexicon.rs @@ -0,0 +1,57 @@ +//! Example demonstrating lexicon schema resolution via DNS + XRPC +//! +//! Run with: cargo run --example resolve_lexicon --features dns + +use jacquard_common::types::string::Nsid; +use jacquard_identity::{ + JacquardResolver, + lexicon_resolver::{LexiconAuthorityResolver, LexiconSchemaResolver}, + resolver::ResolverOptions, +}; + +#[tokio::main] +async fn main() -> miette::Result<()> { + // Set up resolver with DNS enabled + let http = reqwest::Client::new(); + let opts = ResolverOptions::default(); + let resolver = JacquardResolver::new_dns(http, opts); + + // Test NSID - using app.bsky.feed.post as a known lexicon + let nsid = + Nsid::new("app.bsky.feed.post").map_err(|e| miette::miette!("invalid NSID: {}", e))?; + + println!("Resolving lexicon for: {}", nsid); + println!(); + println!("Resolving authority via DNS..."); + match resolver.resolve_lexicon_authority(&nsid).await { + Ok(did) => { + println!("Authority DID: {}", did); + } + Err(e) => { + eprintln!("Failed to resolve authority: {:?}", e); + return Err(e.into()); + } + } + + println!(); + println!("Fetching full lexicon schema..."); + match resolver.resolve_lexicon_schema(&nsid).await { + Ok(schema) => { + println!("Successfully fetched schema"); + println!(" NSID: {}", schema.nsid); + println!(" Repo: {}", schema.repo); + println!(" CID: {}", schema.cid); + println!(" Doc ID: {}", schema.doc.id); + println!( + "\nSchema:\n{}", + serde_json::to_string_pretty(&schema.doc).unwrap() + ) + } + Err(e) => { + eprintln!("Failed to resolve schema: {:?}", e); + return Err(e.into()); + } + } + + Ok(()) +}