···1+//! Lexicon schema resolution via DNS and XRPC
2+//!
3+//! This module provides traits and implementations for resolving lexicon schemas at runtime:
4+//! 1. Resolve NSID authority to DID via DNS TXT records (`_lexicon.{reversed-authority}`)
5+//! 2. Fetch lexicon schema from `com.atproto.lexicon.schema` collection via XRPC
6+7+use crate::resolver::{IdentityError, IdentityResolver};
8+use jacquard_common::{
9+ IntoStatic, smol_str,
10+ types::{cid::Cid, did::Did, string::Nsid},
11+};
12+use smol_str::SmolStr;
13+14+/// Resolve lexicon authority (NSID → authoritative DID)
15+#[cfg_attr(not(target_arch = "wasm32"), trait_variant::make(Send))]
16+pub trait LexiconAuthorityResolver {
17+ /// Resolve an NSID to the authoritative DID via DNS
18+ ///
19+ /// Uses DNS TXT records at `_lexicon.{reversed-authority}`, following the
20+ /// AT Protocol lexicon authority spec. Authority segments are reversed
21+ /// (e.g., `app.bsky.feed` → query `_lexicon.feed.bsky.app`).
22+ ///
23+ /// Note: No hierarchical fallback - per the spec, only exact authority match is checked.
24+ async fn resolve_lexicon_authority(
25+ &self,
26+ nsid: &Nsid,
27+ ) -> std::result::Result<Did<'static>, LexiconResolutionError>;
28+}
29+30+/// Resolve lexicon schemas (NSID → schema document)
31+#[cfg_attr(not(target_arch = "wasm32"), trait_variant::make(Send))]
32+pub trait LexiconSchemaResolver {
33+ /// Resolve a complete lexicon schema for an NSID
34+ async fn resolve_lexicon_schema(
35+ &self,
36+ nsid: &Nsid,
37+ ) -> std::result::Result<ResolvedLexiconSchema<'static>, LexiconResolutionError>;
38+}
39+40+/// A resolved lexicon schema with metadata
41+#[derive(Debug, Clone)]
42+pub struct ResolvedLexiconSchema<'s> {
43+ /// The NSID of the schema
44+ pub nsid: Nsid<'s>,
45+ /// DID of the repository this schema was fetched from
46+ pub repo: Did<'s>,
47+ /// Content ID of the record (for cache invalidation)
48+ pub cid: Cid<'s>,
49+ /// Parsed lexicon document
50+ pub doc: jacquard_lexicon::lexicon::LexiconDoc<'s>,
51+}
52+53+/// Error type for lexicon resolution operations
54+#[derive(Debug, thiserror::Error, miette::Diagnostic)]
55+#[error("{kind}")]
56+pub struct LexiconResolutionError {
57+ #[diagnostic_source]
58+ kind: LexiconResolutionErrorKind,
59+ #[source]
60+ source: Option<Box<dyn std::error::Error + Send + Sync>>,
61+}
62+63+impl LexiconResolutionError {
64+ pub fn new(
65+ kind: LexiconResolutionErrorKind,
66+ source: Option<Box<dyn std::error::Error + Send + Sync>>,
67+ ) -> Self {
68+ Self { kind, source }
69+ }
70+71+ pub fn kind(&self) -> &LexiconResolutionErrorKind {
72+ &self.kind
73+ }
74+75+ pub fn dns_lookup_failed(
76+ authority: impl Into<SmolStr>,
77+ source: impl std::error::Error + Send + Sync + 'static,
78+ ) -> Self {
79+ Self::new(
80+ LexiconResolutionErrorKind::DnsLookupFailed {
81+ authority: authority.into(),
82+ },
83+ Some(Box::new(source)),
84+ )
85+ }
86+87+ pub fn no_did_found(authority: impl Into<SmolStr>) -> Self {
88+ Self::new(
89+ LexiconResolutionErrorKind::NoDIDFound {
90+ authority: authority.into(),
91+ },
92+ None,
93+ )
94+ }
95+96+ pub fn invalid_did(authority: impl Into<SmolStr>, value: impl Into<SmolStr>) -> Self {
97+ Self::new(
98+ LexiconResolutionErrorKind::InvalidDID {
99+ authority: authority.into(),
100+ value: value.into(),
101+ },
102+ None,
103+ )
104+ }
105+106+ pub fn dns_not_configured() -> Self {
107+ Self::new(LexiconResolutionErrorKind::DnsNotConfigured, None)
108+ }
109+110+ pub fn fetch_failed(
111+ nsid: impl Into<SmolStr>,
112+ source: impl std::error::Error + Send + Sync + 'static,
113+ ) -> Self {
114+ Self::new(
115+ LexiconResolutionErrorKind::FetchFailed { nsid: nsid.into() },
116+ Some(Box::new(source)),
117+ )
118+ }
119+120+ pub fn parse_failed(
121+ nsid: impl Into<SmolStr>,
122+ source: impl std::error::Error + Send + Sync + 'static,
123+ ) -> Self {
124+ Self::new(
125+ LexiconResolutionErrorKind::ParseFailed { nsid: nsid.into() },
126+ Some(Box::new(source)),
127+ )
128+ }
129+130+ pub fn invalid_collection() -> Self {
131+ Self::new(LexiconResolutionErrorKind::InvalidCollection, None)
132+ }
133+134+ pub fn missing_cid(nsid: impl Into<SmolStr>) -> Self {
135+ Self::new(
136+ LexiconResolutionErrorKind::MissingCID { nsid: nsid.into() },
137+ None,
138+ )
139+ }
140+}
141+142+impl From<IdentityError> for LexiconResolutionError {
143+ fn from(err: IdentityError) -> Self {
144+ Self::new(LexiconResolutionErrorKind::IdentityResolution(err), None)
145+ }
146+}
147+148+/// Error categories for lexicon resolution
149+#[derive(Debug, thiserror::Error, miette::Diagnostic)]
150+pub enum LexiconResolutionErrorKind {
151+ #[error("DNS lookup failed for authority {authority}")]
152+ #[diagnostic(code(jacquard::lexicon::dns_lookup_failed))]
153+ DnsLookupFailed { authority: SmolStr },
154+155+ #[error("no DID found in DNS for authority {authority}")]
156+ #[diagnostic(
157+ code(jacquard::lexicon::no_did_found),
158+ help("ensure _lexicon.{{reversed-authority}} TXT record exists with did=...")
159+ )]
160+ NoDIDFound { authority: SmolStr },
161+162+ #[error("invalid DID in DNS for authority {authority}: {value}")]
163+ #[diagnostic(code(jacquard::lexicon::invalid_did))]
164+ InvalidDID { authority: SmolStr, value: SmolStr },
165+166+ #[error("DNS not configured (dns feature disabled or WASM target)")]
167+ #[diagnostic(
168+ code(jacquard::lexicon::dns_not_configured),
169+ help("enable the 'dns' feature or use a non-WASM target")
170+ )]
171+ DnsNotConfigured,
172+173+ #[error("failed to fetch lexicon record for {nsid}")]
174+ #[diagnostic(code(jacquard::lexicon::fetch_failed))]
175+ FetchFailed { nsid: SmolStr },
176+177+ #[error("failed to parse lexicon schema for {nsid}")]
178+ #[diagnostic(code(jacquard::lexicon::parse_failed))]
179+ ParseFailed { nsid: SmolStr },
180+181+ #[error("invalid collection NSID")]
182+ #[diagnostic(code(jacquard::lexicon::invalid_collection))]
183+ InvalidCollection,
184+185+ #[error("record missing CID for {nsid}")]
186+ #[diagnostic(code(jacquard::lexicon::missing_cid))]
187+ MissingCID { nsid: SmolStr },
188+189+ #[error(transparent)]
190+ #[diagnostic(code(jacquard::lexicon::identity_resolution_failed))]
191+ IdentityResolution(#[from] crate::resolver::IdentityError),
192+}
193+194+// Implementation on JacquardResolver
195+impl crate::JacquardResolver {
196+ /// Resolve lexicon authority via DNS
197+ ///
198+ /// Queries `_lexicon.{reversed-authority}` for a TXT record containing `did=...`
199+ #[cfg(all(feature = "dns", not(target_family = "wasm")))]
200+ async fn resolve_lexicon_authority_dns(
201+ &self,
202+ nsid: &Nsid<'_>,
203+ ) -> std::result::Result<Did<'static>, LexiconResolutionError> {
204+ let Some(dns) = &self.dns else {
205+ return Err(LexiconResolutionError::dns_not_configured());
206+ };
207+208+ // Extract and reverse authority segments
209+ let authority = nsid.domain_authority();
210+ let reversed_authority = authority.split('.').rev().collect::<Vec<_>>().join(".");
211+ let fqdn = format!("_lexicon.{}.", reversed_authority);
212+213+ #[cfg(feature = "tracing")]
214+ tracing::debug!("resolving lexicon authority via DNS: {}", fqdn);
215+216+ let response = dns
217+ .txt_lookup(fqdn)
218+ .await
219+ .map_err(|e| LexiconResolutionError::dns_lookup_failed(authority, e))?;
220+221+ // Parse TXT records looking for "did=..."
222+ for txt in response.iter() {
223+ for data in txt.txt_data().iter() {
224+ let text = std::str::from_utf8(data).unwrap_or("");
225+ if let Some(did_str) = text.strip_prefix("did=") {
226+ return Did::new_owned(did_str)
227+ .map(|d| d.into_static())
228+ .map_err(|_| LexiconResolutionError::invalid_did(authority, did_str));
229+ }
230+ }
231+ }
232+233+ Err(LexiconResolutionError::no_did_found(authority))
234+ }
235+}
236+237+#[cfg(all(feature = "dns", not(target_family = "wasm")))]
238+impl LexiconAuthorityResolver for crate::JacquardResolver {
239+ async fn resolve_lexicon_authority(
240+ &self,
241+ nsid: &Nsid<'_>,
242+ ) -> std::result::Result<Did<'static>, LexiconResolutionError> {
243+ self.resolve_lexicon_authority_dns(nsid).await
244+ }
245+}
246+247+#[cfg(not(all(feature = "dns", not(target_family = "wasm"))))]
248+impl LexiconAuthorityResolver for crate::JacquardResolver {
249+ async fn resolve_lexicon_authority(
250+ &self,
251+ _nsid: &Nsid<'_>,
252+ ) -> std::result::Result<Did<'static>, LexiconResolutionError> {
253+ Err(LexiconResolutionError::dns_not_configured())
254+ }
255+}
256+257+impl LexiconSchemaResolver for crate::JacquardResolver {
258+ async fn resolve_lexicon_schema(
259+ &self,
260+ nsid: &Nsid<'_>,
261+ ) -> std::result::Result<ResolvedLexiconSchema<'static>, LexiconResolutionError> {
262+ use jacquard_api::com_atproto::repo::get_record::GetRecord;
263+ use jacquard_common::{IntoStatic, xrpc::XrpcExt};
264+265+ // 1. Resolve authority DID via DNS
266+ let authority_did = self.resolve_lexicon_authority(nsid).await?;
267+268+ #[cfg(feature = "tracing")]
269+ tracing::debug!(
270+ "resolved lexicon authority {} -> {}",
271+ nsid.domain_authority(),
272+ authority_did
273+ );
274+275+ // 2. Resolve DID document to get PDS endpoint
276+ let did_doc_resp = self.resolve_did_doc(&authority_did).await?;
277+ let did_doc = did_doc_resp.parse()?;
278+ let pds = did_doc
279+ .pds_endpoint()
280+ .ok_or_else(|| IdentityError::missing_pds_endpoint())?;
281+282+ #[cfg(feature = "tracing")]
283+ tracing::debug!("fetching lexicon {} from PDS {}", nsid, pds);
284+285+ // 3. Fetch lexicon record via XRPC getRecord
286+ let collection = Nsid::new("com.atproto.lexicon.schema")
287+ .map_err(|_| LexiconResolutionError::invalid_collection())?;
288+289+ let request = GetRecord::new()
290+ .repo(authority_did.clone())
291+ .collection(collection.into_static())
292+ .rkey(nsid.clone())
293+ .build();
294+295+ let response = self
296+ .xrpc(pds)
297+ .send(&request)
298+ .await
299+ .map_err(|e| LexiconResolutionError::fetch_failed(nsid.as_str(), e))?;
300+301+ let output = response
302+ .into_output()
303+ .map_err(|e| LexiconResolutionError::fetch_failed(nsid.as_str(), e))?;
304+305+ // 4. Parse lexicon document from value
306+ let json_str = serde_json::to_string(&output.value)
307+ .map_err(|e| LexiconResolutionError::parse_failed(nsid.as_str(), e))?;
308+309+ let doc: jacquard_lexicon::lexicon::LexiconDoc = serde_json::from_str(&json_str)
310+ .map_err(|e| LexiconResolutionError::parse_failed(nsid.as_str(), e))?;
311+312+ #[cfg(feature = "tracing")]
313+ tracing::debug!("successfully parsed lexicon schema {}", nsid);
314+315+ let cid = output
316+ .cid
317+ .ok_or_else(|| LexiconResolutionError::missing_cid(nsid.as_str()))?
318+ .into_static();
319+320+ Ok(ResolvedLexiconSchema {
321+ nsid: nsid.clone().into_static(),
322+ repo: authority_did.into_static(),
323+ cid,
324+ doc: doc.into_static(),
325+ })
326+ }
327+}
+1
crates/jacquard-identity/src/lib.rs
···68// use crate::CowStr; // not currently needed directly here
6970#![cfg_attr(target_arch = "wasm32", allow(unused))]
071pub mod resolver;
7273use crate::resolver::{
···68// use crate::CowStr; // not currently needed directly here
6970#![cfg_attr(target_arch = "wasm32", allow(unused))]
71+pub mod lexicon_resolver;
72pub mod resolver;
7374use crate::resolver::{
···20 pattern "lexicons/**/*.json"
21}
220000023// Fetch lexicons from a Git repository
24source "my-lexicons" type="git" priority=100 {
25 repo "https://github.com/example/my-lexicons"
···20 pattern "lexicons/**/*.json"
21}
2223+// Fetch a single lexicon by NSID - will use DNS to resolve authority
24+source "bsky-post" type="atproto" priority=30 {
25+ endpoint "app.bsky.feed.post"
26+}
27+28// Fetch lexicons from a Git repository
29source "my-lexicons" type="git" priority=100 {
30 repo "https://github.com/example/my-lexicons"