···11+//! Lexicon schema resolution via DNS and XRPC
22+//!
33+//! This module provides traits and implementations for resolving lexicon schemas at runtime:
44+//! 1. Resolve NSID authority to DID via DNS TXT records (`_lexicon.{reversed-authority}`)
55+//! 2. Fetch lexicon schema from `com.atproto.lexicon.schema` collection via XRPC
66+77+use crate::resolver::{IdentityError, IdentityResolver};
88+use jacquard_common::{
99+ IntoStatic, smol_str,
1010+ types::{cid::Cid, did::Did, string::Nsid},
1111+};
1212+use smol_str::SmolStr;
1313+1414+/// Resolve lexicon authority (NSID → authoritative DID)
1515+#[cfg_attr(not(target_arch = "wasm32"), trait_variant::make(Send))]
1616+pub trait LexiconAuthorityResolver {
1717+ /// Resolve an NSID to the authoritative DID via DNS
1818+ ///
1919+ /// Uses DNS TXT records at `_lexicon.{reversed-authority}`, following the
2020+ /// AT Protocol lexicon authority spec. Authority segments are reversed
2121+ /// (e.g., `app.bsky.feed` → query `_lexicon.feed.bsky.app`).
2222+ ///
2323+ /// Note: No hierarchical fallback - per the spec, only exact authority match is checked.
2424+ async fn resolve_lexicon_authority(
2525+ &self,
2626+ nsid: &Nsid,
2727+ ) -> std::result::Result<Did<'static>, LexiconResolutionError>;
2828+}
2929+3030+/// Resolve lexicon schemas (NSID → schema document)
3131+#[cfg_attr(not(target_arch = "wasm32"), trait_variant::make(Send))]
3232+pub trait LexiconSchemaResolver {
3333+ /// Resolve a complete lexicon schema for an NSID
3434+ async fn resolve_lexicon_schema(
3535+ &self,
3636+ nsid: &Nsid,
3737+ ) -> std::result::Result<ResolvedLexiconSchema<'static>, LexiconResolutionError>;
3838+}
3939+4040+/// A resolved lexicon schema with metadata
4141+#[derive(Debug, Clone)]
4242+pub struct ResolvedLexiconSchema<'s> {
4343+ /// The NSID of the schema
4444+ pub nsid: Nsid<'s>,
4545+ /// DID of the repository this schema was fetched from
4646+ pub repo: Did<'s>,
4747+ /// Content ID of the record (for cache invalidation)
4848+ pub cid: Cid<'s>,
4949+ /// Parsed lexicon document
5050+ pub doc: jacquard_lexicon::lexicon::LexiconDoc<'s>,
5151+}
5252+5353+/// Error type for lexicon resolution operations
5454+#[derive(Debug, thiserror::Error, miette::Diagnostic)]
5555+#[error("{kind}")]
5656+pub struct LexiconResolutionError {
5757+ #[diagnostic_source]
5858+ kind: LexiconResolutionErrorKind,
5959+ #[source]
6060+ source: Option<Box<dyn std::error::Error + Send + Sync>>,
6161+}
6262+6363+impl LexiconResolutionError {
6464+ pub fn new(
6565+ kind: LexiconResolutionErrorKind,
6666+ source: Option<Box<dyn std::error::Error + Send + Sync>>,
6767+ ) -> Self {
6868+ Self { kind, source }
6969+ }
7070+7171+ pub fn kind(&self) -> &LexiconResolutionErrorKind {
7272+ &self.kind
7373+ }
7474+7575+ pub fn dns_lookup_failed(
7676+ authority: impl Into<SmolStr>,
7777+ source: impl std::error::Error + Send + Sync + 'static,
7878+ ) -> Self {
7979+ Self::new(
8080+ LexiconResolutionErrorKind::DnsLookupFailed {
8181+ authority: authority.into(),
8282+ },
8383+ Some(Box::new(source)),
8484+ )
8585+ }
8686+8787+ pub fn no_did_found(authority: impl Into<SmolStr>) -> Self {
8888+ Self::new(
8989+ LexiconResolutionErrorKind::NoDIDFound {
9090+ authority: authority.into(),
9191+ },
9292+ None,
9393+ )
9494+ }
9595+9696+ pub fn invalid_did(authority: impl Into<SmolStr>, value: impl Into<SmolStr>) -> Self {
9797+ Self::new(
9898+ LexiconResolutionErrorKind::InvalidDID {
9999+ authority: authority.into(),
100100+ value: value.into(),
101101+ },
102102+ None,
103103+ )
104104+ }
105105+106106+ pub fn dns_not_configured() -> Self {
107107+ Self::new(LexiconResolutionErrorKind::DnsNotConfigured, None)
108108+ }
109109+110110+ pub fn fetch_failed(
111111+ nsid: impl Into<SmolStr>,
112112+ source: impl std::error::Error + Send + Sync + 'static,
113113+ ) -> Self {
114114+ Self::new(
115115+ LexiconResolutionErrorKind::FetchFailed { nsid: nsid.into() },
116116+ Some(Box::new(source)),
117117+ )
118118+ }
119119+120120+ pub fn parse_failed(
121121+ nsid: impl Into<SmolStr>,
122122+ source: impl std::error::Error + Send + Sync + 'static,
123123+ ) -> Self {
124124+ Self::new(
125125+ LexiconResolutionErrorKind::ParseFailed { nsid: nsid.into() },
126126+ Some(Box::new(source)),
127127+ )
128128+ }
129129+130130+ pub fn invalid_collection() -> Self {
131131+ Self::new(LexiconResolutionErrorKind::InvalidCollection, None)
132132+ }
133133+134134+ pub fn missing_cid(nsid: impl Into<SmolStr>) -> Self {
135135+ Self::new(
136136+ LexiconResolutionErrorKind::MissingCID { nsid: nsid.into() },
137137+ None,
138138+ )
139139+ }
140140+}
141141+142142+impl From<IdentityError> for LexiconResolutionError {
143143+ fn from(err: IdentityError) -> Self {
144144+ Self::new(LexiconResolutionErrorKind::IdentityResolution(err), None)
145145+ }
146146+}
147147+148148+/// Error categories for lexicon resolution
149149+#[derive(Debug, thiserror::Error, miette::Diagnostic)]
150150+pub enum LexiconResolutionErrorKind {
151151+ #[error("DNS lookup failed for authority {authority}")]
152152+ #[diagnostic(code(jacquard::lexicon::dns_lookup_failed))]
153153+ DnsLookupFailed { authority: SmolStr },
154154+155155+ #[error("no DID found in DNS for authority {authority}")]
156156+ #[diagnostic(
157157+ code(jacquard::lexicon::no_did_found),
158158+ help("ensure _lexicon.{{reversed-authority}} TXT record exists with did=...")
159159+ )]
160160+ NoDIDFound { authority: SmolStr },
161161+162162+ #[error("invalid DID in DNS for authority {authority}: {value}")]
163163+ #[diagnostic(code(jacquard::lexicon::invalid_did))]
164164+ InvalidDID { authority: SmolStr, value: SmolStr },
165165+166166+ #[error("DNS not configured (dns feature disabled or WASM target)")]
167167+ #[diagnostic(
168168+ code(jacquard::lexicon::dns_not_configured),
169169+ help("enable the 'dns' feature or use a non-WASM target")
170170+ )]
171171+ DnsNotConfigured,
172172+173173+ #[error("failed to fetch lexicon record for {nsid}")]
174174+ #[diagnostic(code(jacquard::lexicon::fetch_failed))]
175175+ FetchFailed { nsid: SmolStr },
176176+177177+ #[error("failed to parse lexicon schema for {nsid}")]
178178+ #[diagnostic(code(jacquard::lexicon::parse_failed))]
179179+ ParseFailed { nsid: SmolStr },
180180+181181+ #[error("invalid collection NSID")]
182182+ #[diagnostic(code(jacquard::lexicon::invalid_collection))]
183183+ InvalidCollection,
184184+185185+ #[error("record missing CID for {nsid}")]
186186+ #[diagnostic(code(jacquard::lexicon::missing_cid))]
187187+ MissingCID { nsid: SmolStr },
188188+189189+ #[error(transparent)]
190190+ #[diagnostic(code(jacquard::lexicon::identity_resolution_failed))]
191191+ IdentityResolution(#[from] crate::resolver::IdentityError),
192192+}
193193+194194+// Implementation on JacquardResolver
195195+impl crate::JacquardResolver {
196196+ /// Resolve lexicon authority via DNS
197197+ ///
198198+ /// Queries `_lexicon.{reversed-authority}` for a TXT record containing `did=...`
199199+ #[cfg(all(feature = "dns", not(target_family = "wasm")))]
200200+ async fn resolve_lexicon_authority_dns(
201201+ &self,
202202+ nsid: &Nsid<'_>,
203203+ ) -> std::result::Result<Did<'static>, LexiconResolutionError> {
204204+ let Some(dns) = &self.dns else {
205205+ return Err(LexiconResolutionError::dns_not_configured());
206206+ };
207207+208208+ // Extract and reverse authority segments
209209+ let authority = nsid.domain_authority();
210210+ let reversed_authority = authority.split('.').rev().collect::<Vec<_>>().join(".");
211211+ let fqdn = format!("_lexicon.{}.", reversed_authority);
212212+213213+ #[cfg(feature = "tracing")]
214214+ tracing::debug!("resolving lexicon authority via DNS: {}", fqdn);
215215+216216+ let response = dns
217217+ .txt_lookup(fqdn)
218218+ .await
219219+ .map_err(|e| LexiconResolutionError::dns_lookup_failed(authority, e))?;
220220+221221+ // Parse TXT records looking for "did=..."
222222+ for txt in response.iter() {
223223+ for data in txt.txt_data().iter() {
224224+ let text = std::str::from_utf8(data).unwrap_or("");
225225+ if let Some(did_str) = text.strip_prefix("did=") {
226226+ return Did::new_owned(did_str)
227227+ .map(|d| d.into_static())
228228+ .map_err(|_| LexiconResolutionError::invalid_did(authority, did_str));
229229+ }
230230+ }
231231+ }
232232+233233+ Err(LexiconResolutionError::no_did_found(authority))
234234+ }
235235+}
236236+237237+#[cfg(all(feature = "dns", not(target_family = "wasm")))]
238238+impl LexiconAuthorityResolver for crate::JacquardResolver {
239239+ async fn resolve_lexicon_authority(
240240+ &self,
241241+ nsid: &Nsid<'_>,
242242+ ) -> std::result::Result<Did<'static>, LexiconResolutionError> {
243243+ self.resolve_lexicon_authority_dns(nsid).await
244244+ }
245245+}
246246+247247+#[cfg(not(all(feature = "dns", not(target_family = "wasm"))))]
248248+impl LexiconAuthorityResolver for crate::JacquardResolver {
249249+ async fn resolve_lexicon_authority(
250250+ &self,
251251+ _nsid: &Nsid<'_>,
252252+ ) -> std::result::Result<Did<'static>, LexiconResolutionError> {
253253+ Err(LexiconResolutionError::dns_not_configured())
254254+ }
255255+}
256256+257257+impl LexiconSchemaResolver for crate::JacquardResolver {
258258+ async fn resolve_lexicon_schema(
259259+ &self,
260260+ nsid: &Nsid<'_>,
261261+ ) -> std::result::Result<ResolvedLexiconSchema<'static>, LexiconResolutionError> {
262262+ use jacquard_api::com_atproto::repo::get_record::GetRecord;
263263+ use jacquard_common::{IntoStatic, xrpc::XrpcExt};
264264+265265+ // 1. Resolve authority DID via DNS
266266+ let authority_did = self.resolve_lexicon_authority(nsid).await?;
267267+268268+ #[cfg(feature = "tracing")]
269269+ tracing::debug!(
270270+ "resolved lexicon authority {} -> {}",
271271+ nsid.domain_authority(),
272272+ authority_did
273273+ );
274274+275275+ // 2. Resolve DID document to get PDS endpoint
276276+ let did_doc_resp = self.resolve_did_doc(&authority_did).await?;
277277+ let did_doc = did_doc_resp.parse()?;
278278+ let pds = did_doc
279279+ .pds_endpoint()
280280+ .ok_or_else(|| IdentityError::missing_pds_endpoint())?;
281281+282282+ #[cfg(feature = "tracing")]
283283+ tracing::debug!("fetching lexicon {} from PDS {}", nsid, pds);
284284+285285+ // 3. Fetch lexicon record via XRPC getRecord
286286+ let collection = Nsid::new("com.atproto.lexicon.schema")
287287+ .map_err(|_| LexiconResolutionError::invalid_collection())?;
288288+289289+ let request = GetRecord::new()
290290+ .repo(authority_did.clone())
291291+ .collection(collection.into_static())
292292+ .rkey(nsid.clone())
293293+ .build();
294294+295295+ let response = self
296296+ .xrpc(pds)
297297+ .send(&request)
298298+ .await
299299+ .map_err(|e| LexiconResolutionError::fetch_failed(nsid.as_str(), e))?;
300300+301301+ let output = response
302302+ .into_output()
303303+ .map_err(|e| LexiconResolutionError::fetch_failed(nsid.as_str(), e))?;
304304+305305+ // 4. Parse lexicon document from value
306306+ let json_str = serde_json::to_string(&output.value)
307307+ .map_err(|e| LexiconResolutionError::parse_failed(nsid.as_str(), e))?;
308308+309309+ let doc: jacquard_lexicon::lexicon::LexiconDoc = serde_json::from_str(&json_str)
310310+ .map_err(|e| LexiconResolutionError::parse_failed(nsid.as_str(), e))?;
311311+312312+ #[cfg(feature = "tracing")]
313313+ tracing::debug!("successfully parsed lexicon schema {}", nsid);
314314+315315+ let cid = output
316316+ .cid
317317+ .ok_or_else(|| LexiconResolutionError::missing_cid(nsid.as_str()))?
318318+ .into_static();
319319+320320+ Ok(ResolvedLexiconSchema {
321321+ nsid: nsid.clone().into_static(),
322322+ repo: authority_did.into_static(),
323323+ cid,
324324+ doc: doc.into_static(),
325325+ })
326326+ }
327327+}
+1
crates/jacquard-identity/src/lib.rs
···6868// use crate::CowStr; // not currently needed directly here
69697070#![cfg_attr(target_arch = "wasm32", allow(unused))]
7171+pub mod lexicon_resolver;
7172pub mod resolver;
72737374use crate::resolver::{
···2020 pattern "lexicons/**/*.json"
2121}
22222323+// Fetch a single lexicon by NSID - will use DNS to resolve authority
2424+source "bsky-post" type="atproto" priority=30 {
2525+ endpoint "app.bsky.feed.post"
2626+}
2727+2328// Fetch lexicons from a Git repository
2429source "my-lexicons" type="git" priority=100 {
2530 repo "https://github.com/example/my-lexicons"