A better Rust ATProto crate
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
7use crate::resolver::{IdentityError, IdentityResolver};
8
9use jacquard_common::{
10 BosStr,
11 deps::smol_str,
12 http_client::HttpClient,
13 types::{cid::Cid, did::Did, ident::AtIdentifier, string::Nsid, string::RecordKey},
14};
15use smol_str::SmolStr;
16
17/// Resolve lexicon authority (NSID → authoritative DID)
18#[cfg_attr(not(target_arch = "wasm32"), trait_variant::make(Send))]
19pub trait LexiconAuthorityResolver {
20 /// Resolve an NSID to the authoritative DID via DNS
21 ///
22 /// Uses DNS TXT records at `_lexicon.{reversed-authority}`, following the
23 /// AT Protocol lexicon authority spec. Authority segments are reversed
24 /// (e.g., `app.bsky.feed` → query `_lexicon.feed.bsky.app`).
25 ///
26 /// Note: No hierarchical fallback - per the spec, only exact authority match is checked.
27 fn resolve_lexicon_authority<S: BosStr + Sync>(
28 &self,
29 nsid: &Nsid<S>,
30 ) -> impl Future<Output = std::result::Result<Did, LexiconResolutionError>>;
31}
32
33/// Resolve lexicon schemas (NSID → schema document)
34#[cfg_attr(not(target_arch = "wasm32"), trait_variant::make(Send))]
35pub trait LexiconSchemaResolver {
36 /// Resolve a complete lexicon schema for an NSID
37 fn resolve_lexicon_schema<S: BosStr + Sync>(
38 &self,
39 nsid: &Nsid<S>,
40 ) -> impl Future<Output = std::result::Result<ResolvedLexiconSchema<'static>, LexiconResolutionError>>;
41}
42
43/// A resolved lexicon schema with metadata
44#[derive(Debug, Clone)]
45pub struct ResolvedLexiconSchema<'s> {
46 /// The NSID of the schema
47 pub nsid: Nsid,
48 /// DID of the repository this schema was fetched from
49 pub repo: Did,
50 /// Content ID of the record (for cache invalidation)
51 pub cid: Cid,
52 /// Parsed lexicon document
53 pub doc: jacquard_lexicon::lexicon::LexiconDoc<'s>,
54}
55
56/// Error type for lexicon resolution operations
57#[derive(Debug, thiserror::Error, miette::Diagnostic)]
58#[error("{kind}")]
59pub struct LexiconResolutionError {
60 #[diagnostic_source]
61 kind: LexiconResolutionErrorKind,
62 #[source]
63 source: Option<Box<dyn std::error::Error + Send + Sync>>,
64 context: Option<SmolStr>,
65}
66
67impl LexiconResolutionError {
68 /// Create a new error with the given kind and optional source.
69 pub fn new(
70 kind: LexiconResolutionErrorKind,
71 source: Option<Box<dyn std::error::Error + Send + Sync>>,
72 ) -> Self {
73 Self {
74 kind,
75 source,
76 context: None,
77 }
78 }
79
80 /// Return the error kind.
81 pub fn kind(&self) -> &LexiconResolutionErrorKind {
82 &self.kind
83 }
84
85 /// Add context to this error
86 pub fn with_context(mut self, context: impl Into<SmolStr>) -> Self {
87 self.context = Some(context.into());
88 self
89 }
90
91 /// Get the context if present
92 pub fn context(&self) -> Option<&str> {
93 self.context.as_deref()
94 }
95
96 /// Create an error for a failed DNS TXT lookup while resolving a lexicon authority.
97 pub fn dns_lookup_failed(
98 authority: impl Into<SmolStr>,
99 source: impl std::error::Error + Send + Sync + 'static,
100 ) -> Self {
101 Self::new(
102 LexiconResolutionErrorKind::DnsLookupFailed {
103 authority: authority.into(),
104 },
105 Some(Box::new(source)),
106 )
107 }
108
109 /// Create an error for when DNS records exist but contain no `did=...` entry.
110 pub fn no_did_found(authority: impl Into<SmolStr>) -> Self {
111 Self::new(
112 LexiconResolutionErrorKind::NoDIDFound {
113 authority: authority.into(),
114 },
115 None,
116 )
117 }
118
119 /// Create an error for a syntactically invalid DID found in DNS for the given authority.
120 pub fn invalid_did(authority: impl Into<SmolStr>, value: impl Into<SmolStr>) -> Self {
121 Self::new(
122 LexiconResolutionErrorKind::InvalidDID {
123 authority: authority.into(),
124 value: value.into(),
125 },
126 None,
127 )
128 }
129
130 /// Create an error for when DNS is not available (feature disabled or WASM target).
131 pub fn dns_not_configured() -> Self {
132 Self::new(LexiconResolutionErrorKind::DnsNotConfigured, None)
133 }
134
135 /// Create an error for a failure to fetch the lexicon record for an NSID.
136 pub fn fetch_failed(
137 nsid: impl Into<SmolStr>,
138 source: impl std::error::Error + Send + Sync + 'static,
139 ) -> Self {
140 Self::new(
141 LexiconResolutionErrorKind::FetchFailed { nsid: nsid.into() },
142 Some(Box::new(source)),
143 )
144 }
145
146 /// Create an error for a failure to parse a fetched lexicon schema document.
147 pub fn parse_failed(
148 nsid: impl Into<SmolStr>,
149 source: impl std::error::Error + Send + Sync + 'static,
150 ) -> Self {
151 Self::new(
152 LexiconResolutionErrorKind::ParseFailed { nsid: nsid.into() },
153 Some(Box::new(source)),
154 )
155 }
156
157 /// Create a generic resolution failure error with a descriptive message.
158 pub fn resolution_failed(nsid: impl Into<SmolStr>, message: impl Into<SmolStr>) -> Self {
159 Self::new(
160 LexiconResolutionErrorKind::ResolutionFailed {
161 nsid: nsid.into(),
162 message: message.into(),
163 },
164 None,
165 )
166 }
167
168 /// Create an error for a non-success HTTP status received while fetching a lexicon.
169 pub fn http_error(nsid: impl Into<SmolStr>, status: u16) -> Self {
170 Self::new(
171 LexiconResolutionErrorKind::HttpError {
172 nsid: nsid.into(),
173 status,
174 },
175 None,
176 )
177 }
178
179 /// Create an error for a required field missing from the XRPC response.
180 pub fn missing_response_field(nsid: impl Into<SmolStr>, field: &'static str) -> Self {
181 Self::new(
182 LexiconResolutionErrorKind::MissingResponseField {
183 nsid: nsid.into(),
184 field,
185 },
186 None,
187 )
188 }
189
190 /// Create an error for an invalid lexicon collection NSID.
191 pub fn invalid_collection() -> Self {
192 Self::new(LexiconResolutionErrorKind::InvalidCollection, None)
193 }
194
195 /// Create an error for a lexicon record response that is missing its CID.
196 pub fn missing_cid(nsid: impl Into<SmolStr>) -> Self {
197 Self::new(
198 LexiconResolutionErrorKind::MissingCID { nsid: nsid.into() },
199 None,
200 )
201 }
202}
203
204impl From<IdentityError> for LexiconResolutionError {
205 fn from(err: IdentityError) -> Self {
206 Self::new(LexiconResolutionErrorKind::IdentityResolution(err), None)
207 }
208}
209
210/// Error categories for lexicon resolution
211#[derive(Debug, thiserror::Error, miette::Diagnostic)]
212#[non_exhaustive]
213pub enum LexiconResolutionErrorKind {
214 /// DNS TXT lookup for the lexicon authority failed.
215 #[error("DNS lookup failed for authority {authority}")]
216 #[diagnostic(code(jacquard::lexicon::dns_lookup_failed))]
217 DnsLookupFailed {
218 /// The NSID authority segment that was being looked up.
219 authority: SmolStr,
220 },
221
222 /// DNS records were reachable but contained no `did=...` entry.
223 #[error("no DID found in DNS for authority {authority}")]
224 #[diagnostic(
225 code(jacquard::lexicon::no_did_found),
226 help("ensure _lexicon.{{reversed-authority}} TXT record exists with did=...")
227 )]
228 NoDIDFound {
229 /// The NSID authority segment that was being looked up.
230 authority: SmolStr,
231 },
232
233 /// DNS returned a `did=...` entry but its value is not a valid DID.
234 #[error("invalid DID in DNS for authority {authority}: {value}")]
235 #[diagnostic(code(jacquard::lexicon::invalid_did))]
236 InvalidDID {
237 /// The NSID authority segment.
238 authority: SmolStr,
239 /// The raw invalid DID string found in DNS.
240 value: SmolStr,
241 },
242
243 /// DNS is not available on this build (the `dns` feature is disabled or target is WASM).
244 #[error("DNS not configured (dns feature disabled or WASM target)")]
245 #[diagnostic(
246 code(jacquard::lexicon::dns_not_configured),
247 help("enable the 'dns' feature or use a non-WASM target")
248 )]
249 DnsNotConfigured,
250
251 /// XRPC or HTTP request to fetch the lexicon record failed.
252 #[error("failed to fetch lexicon record for {nsid}")]
253 #[diagnostic(code(jacquard::lexicon::fetch_failed))]
254 FetchFailed {
255 /// The NSID of the lexicon that could not be fetched.
256 nsid: SmolStr,
257 },
258
259 /// The fetched lexicon record could not be deserialized as a `LexiconDoc`.
260 #[error("failed to parse lexicon schema for {nsid}")]
261 #[diagnostic(code(jacquard::lexicon::parse_failed))]
262 ParseFailed {
263 /// The NSID of the lexicon that could not be parsed.
264 nsid: SmolStr,
265 },
266
267 /// Generic resolution failure with a descriptive message.
268 #[error("failed to parse lexicon schema for {nsid}")]
269 #[diagnostic(code(jacquard::lexicon::resolution_failed))]
270 ResolutionFailed {
271 /// The NSID of the lexicon being resolved.
272 nsid: SmolStr,
273 /// Human-readable description of what went wrong.
274 message: SmolStr,
275 },
276
277 /// HTTP non-success status from lexicon fetch.
278 #[error("HTTP {status} fetching lexicon {nsid}")]
279 #[diagnostic(code(jacquard::lexicon::http_error))]
280 HttpError {
281 /// The NSID of the lexicon being fetched.
282 nsid: SmolStr,
283 /// The HTTP status code received.
284 status: u16,
285 },
286
287 /// Required field missing in XRPC response.
288 #[error("missing '{field}' field in response for {nsid}")]
289 #[diagnostic(
290 code(jacquard::lexicon::missing_response_field),
291 help("the XRPC response is missing a required field")
292 )]
293 MissingResponseField {
294 /// The NSID of the lexicon being fetched.
295 nsid: SmolStr,
296 /// Name of the missing field.
297 field: &'static str,
298 },
299
300 /// The lexicon collection NSID was not valid.
301 #[error("invalid collection NSID")]
302 #[diagnostic(code(jacquard::lexicon::invalid_collection))]
303 InvalidCollection,
304
305 /// The `getRecord` response did not include a CID for the lexicon record.
306 #[error("record missing CID for {nsid}")]
307 #[diagnostic(code(jacquard::lexicon::missing_cid))]
308 MissingCID {
309 /// The NSID of the lexicon whose record was missing a CID.
310 nsid: SmolStr,
311 },
312
313 /// Identity resolution failed while locating the PDS that hosts the lexicon.
314 #[error(transparent)]
315 #[diagnostic(code(jacquard::lexicon::identity_resolution_failed))]
316 IdentityResolution(#[from] crate::resolver::IdentityError),
317}
318
319// Implementation on JacquardResolver
320impl<C: HttpClient> crate::JacquardResolver<C> {
321 /// Resolve lexicon authority via DNS
322 ///
323 /// Queries `_lexicon.{reversed-authority}` for a TXT record containing `did=...`
324 #[cfg(all(feature = "dns", not(target_family = "wasm")))]
325 async fn resolve_lexicon_authority_dns<S: BosStr + Sync>(
326 &self,
327 nsid: &Nsid<S>,
328 ) -> std::result::Result<Did, LexiconResolutionError> {
329 let Some(dns) = &self.dns else {
330 return Err(LexiconResolutionError::dns_not_configured());
331 };
332
333 // Extract and reverse authority segments
334 let authority = nsid.domain_authority();
335 let reversed_authority = authority.split('.').rev().collect::<Vec<_>>().join(".");
336 let fqdn = format!("_lexicon.{}.", reversed_authority);
337
338 #[cfg(feature = "tracing")]
339 tracing::debug!("resolving lexicon authority via DNS: {}", fqdn);
340
341 let response = dns
342 .txt_lookup(fqdn)
343 .await
344 .map_err(|e| LexiconResolutionError::dns_lookup_failed(authority, e))?;
345
346 // Parse TXT records looking for "did=..."
347 for txt in response.iter() {
348 for data in txt.txt_data().iter() {
349 let text = std::str::from_utf8(data).unwrap_or("");
350 if let Some(did_str) = text.strip_prefix("did=") {
351 return Did::new_owned(did_str).map_err(|_| {
352 LexiconResolutionError::invalid_did(authority, did_str)
353 .with_context(format!("resolving NSID {}", nsid))
354 });
355 }
356 }
357 }
358
359 Err(LexiconResolutionError::no_did_found(authority))
360 }
361}
362
363#[cfg(all(feature = "dns", not(target_family = "wasm")))]
364impl<C: HttpClient + Sync> LexiconAuthorityResolver for crate::JacquardResolver<C> {
365 async fn resolve_lexicon_authority<S: BosStr + Sync>(
366 &self,
367 nsid: &Nsid<S>,
368 ) -> std::result::Result<Did, LexiconResolutionError> {
369 // Try cache first
370 #[cfg(feature = "cache")]
371 if let Some(caches) = &self.caches {
372 let authority = jacquard_common::deps::smol_str::SmolStr::from(nsid.domain_authority());
373 if let Some(did) = crate::cache_impl::get(&caches.authority_to_did, &authority) {
374 return Ok(did);
375 }
376 }
377
378 // Resolve via DNS
379 let result = self.resolve_lexicon_authority_dns(nsid).await;
380
381 // Cache on success, invalidate on error
382 #[cfg(feature = "cache")]
383 match &result {
384 Ok(did) => {
385 if let Some(caches) = &self.caches {
386 let authority =
387 jacquard_common::deps::smol_str::SmolStr::from(nsid.domain_authority());
388 crate::cache_impl::insert(&caches.authority_to_did, authority, did.clone());
389 }
390 }
391 Err(_) => {
392 self.invalidate_authority_chain(nsid.domain_authority())
393 .await;
394 }
395 }
396
397 result
398 }
399}
400
401#[cfg(not(all(feature = "dns", not(target_family = "wasm"))))]
402impl<C: HttpClient + Sync> LexiconAuthorityResolver for crate::JacquardResolver<C> {
403 async fn resolve_lexicon_authority<S: BosStr + Sync>(
404 &self,
405 nsid: &Nsid<S>,
406 ) -> std::result::Result<Did, LexiconResolutionError> {
407 // Use DNS-over-HTTPS fallback for WASM/non-DNS builds
408 self.resolve_lexicon_authority_doh(nsid).await
409 }
410}
411
412impl<C: HttpClient> crate::JacquardResolver<C> {
413 /// Resolve lexicon authority via DNS-over-HTTPS (for WASM compatibility)
414 #[allow(dead_code)]
415 async fn resolve_lexicon_authority_doh<S: BosStr + Sync>(
416 &self,
417 nsid: &Nsid<S>,
418 ) -> std::result::Result<Did, LexiconResolutionError> {
419 // Try cache first
420 #[cfg(feature = "cache")]
421 if let Some(caches) = &self.caches {
422 let authority = jacquard_common::deps::smol_str::SmolStr::from(nsid.domain_authority());
423 if let Some(did) = crate::cache_impl::get(&caches.authority_to_did, &authority) {
424 return Ok(did);
425 }
426 }
427
428 let authority = nsid.domain_authority();
429 let reversed_authority = authority.split('.').rev().collect::<Vec<_>>().join(".");
430 let fqdn = format!("_lexicon.{}.", reversed_authority);
431
432 #[cfg(feature = "tracing")]
433 tracing::trace!("resolving lexicon authority via DoH: {}", fqdn);
434
435 let response = self
436 .query_dns_doh(&fqdn, "TXT")
437 .await
438 .map_err(|e| LexiconResolutionError::dns_lookup_failed(authority, e))?;
439
440 // Parse DoH JSON response
441 let answers = response
442 .get("Answer")
443 .and_then(|a| a.as_array())
444 .ok_or_else(|| LexiconResolutionError::no_did_found(authority))?;
445
446 for answer in answers {
447 if let Some(data) = answer.get("data").and_then(|d| d.as_str()) {
448 // TXT records are quoted in DNS responses, strip quotes
449 let txt_data = data.trim_matches('"');
450
451 if let Some(did_str) = txt_data.strip_prefix("did=") {
452 let result = Did::new_owned(did_str).map_err(|_| {
453 LexiconResolutionError::invalid_did(authority, did_str)
454 .with_context(format!("resolving NSID {}", nsid))
455 });
456
457 // Cache on success
458 #[cfg(feature = "cache")]
459 if let Ok(ref did) = result {
460 if let Some(caches) = &self.caches {
461 let authority_key =
462 jacquard_common::deps::smol_str::SmolStr::from(authority);
463 crate::cache_impl::insert(
464 &caches.authority_to_did,
465 authority_key,
466 did.clone(),
467 );
468 }
469 }
470
471 return result;
472 }
473 }
474 }
475
476 Err(LexiconResolutionError::no_did_found(authority))
477 }
478}
479
480impl<C: HttpClient + Sync> LexiconSchemaResolver for crate::JacquardResolver<C> {
481 async fn resolve_lexicon_schema<S: BosStr + Sync>(
482 &self,
483 nsid: &Nsid<S>,
484 ) -> std::result::Result<ResolvedLexiconSchema<'static>, LexiconResolutionError> {
485 use jacquard_common::xrpc::atproto::GetRecord;
486 use jacquard_common::{IntoStatic, xrpc::XrpcExt};
487
488 let nsid_str = nsid.as_str();
489 let owned_nsid: Nsid = Nsid::new_owned(nsid_str).expect("already validated NSID");
490
491 // Try cache first
492 #[cfg(feature = "cache")]
493 if let Some(caches) = &self.caches {
494 if let Some(schema) = crate::cache_impl::get(&caches.nsid_to_schema, &owned_nsid) {
495 return Ok((*schema).clone());
496 }
497 }
498
499 // Perform resolution
500 let result = async {
501 // 1. Resolve authority DID via DNS
502 let authority_did = self.resolve_lexicon_authority(nsid).await?;
503
504 #[cfg(feature = "tracing")]
505 tracing::trace!(
506 "resolved lexicon authority {} -> {}",
507 nsid.domain_authority(),
508 authority_did
509 );
510
511 // 2. Resolve DID document to get PDS endpoint
512 let did_doc_resp = self.resolve_did_doc(&authority_did).await?;
513 let did_doc = did_doc_resp.parse()?;
514 let pds = did_doc
515 .pds_endpoint()
516 .ok_or_else(|| IdentityError::missing_pds_endpoint(authority_did.as_str()))?;
517
518 #[cfg(feature = "tracing")]
519 tracing::trace!("fetching lexicon {} from PDS {}", nsid, pds);
520
521 // 3. Fetch lexicon record via XRPC getRecord
522 let collection = Nsid::new_owned("com.atproto.lexicon.schema")
523 .map_err(|_| LexiconResolutionError::invalid_collection())?;
524
525 let request = GetRecord {
526 repo: AtIdentifier::Did(authority_did.clone()),
527 collection,
528 rkey: RecordKey::any_owned(nsid_str).unwrap(),
529 cid: None,
530 };
531
532 let response = self
533 .xrpc(pds)
534 .send(&request)
535 .await
536 .map_err(|e| LexiconResolutionError::fetch_failed(nsid_str, e))?;
537
538 let output = response
539 .into_output()
540 .map_err(|e| LexiconResolutionError::fetch_failed(nsid_str, e))?;
541
542 // 4. Parse lexicon document from value
543 let json_str = serde_json::to_string(&output.value)
544 .map_err(|e| LexiconResolutionError::parse_failed(nsid_str, e))?;
545
546 let doc: jacquard_lexicon::lexicon::LexiconDoc = serde_json::from_str(&json_str)
547 .map_err(|e| LexiconResolutionError::parse_failed(nsid_str, e))?;
548
549 #[cfg(feature = "tracing")]
550 tracing::trace!("successfully parsed lexicon schema {}", nsid);
551
552 let cid = output
553 .cid
554 .ok_or_else(|| LexiconResolutionError::missing_cid(nsid_str))?;
555
556 Ok(ResolvedLexiconSchema {
557 nsid: owned_nsid.clone(),
558 repo: authority_did,
559 cid,
560 doc: doc.into_static(),
561 })
562 }
563 .await;
564
565 // Handle result
566 match result {
567 Ok(schema) => {
568 // Cache successful resolution
569 #[cfg(feature = "cache")]
570 if let Some(caches) = &self.caches {
571 crate::cache_impl::insert(
572 &caches.nsid_to_schema,
573 owned_nsid,
574 std::sync::Arc::new(schema.clone()),
575 );
576 }
577 Ok(schema)
578 }
579 Err(e) => {
580 // Invalidate on error
581 #[cfg(feature = "cache")]
582 self.invalidate_lexicon_chain(nsid).await;
583 Err(e)
584 }
585 }
586 }
587}