A better Rust ATProto crate
1//! Identity resolution for the AT Protocol
2//!
3//! Jacquard's handle-to-DID and DID-to-document resolution with configurable
4//! fallback chains.
5//!
6//! ## Quick start
7//!
8//! ```no_run
9//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
10//! use jacquard_identity::{PublicResolver, resolver::IdentityResolver};
11//! use jacquard_common::types::string::Handle;
12//!
13//! let resolver = PublicResolver::default();
14//!
15//! // Resolve handle to DID
16//! let did = resolver.resolve_handle(&Handle::new("alice.bsky.social")?).await?;
17//!
18//! // Fetch DID document
19//! let doc_response = resolver.resolve_did_doc(&did).await?;
20//! let doc = doc_response.parse()?; // Borrow from response buffer
21//! # Ok(())
22//! # }
23//! ```
24//!
25//! ## Resolution fallback order
26//!
27//! **Handle → DID** (configurable via [`resolver::HandleStep`]):
28//! 1. DNS TXT record at `_atproto.{handle}` (if `dns` feature enabled)
29//! 2. HTTPS well-known at `https://{handle}/.well-known/atproto-did`
30//! 3. PDS XRPC `com.atproto.identity.resolveHandle` (if PDS configured)
31//! 4. Public API fallback (`https://public.api.bsky.app`)
32//! 5. Slingshot `resolveHandle` (if configured)
33//!
34//! **DID → Document** (configurable via [`resolver::DidStep`]):
35//! 1. `did:web` HTTPS well-known
36//! 2. PLC directory HTTP (for `did:plc`)
37//! 3. PDS XRPC `com.atproto.identity.resolveDid` (if PDS configured)
38//! 4. Slingshot mini-doc (partial document)
39//!
40//! ## Customization
41//!
42//! ```
43//! use jacquard_identity::JacquardResolver;
44//! use jacquard_identity::resolver::{ResolverOptions, PlcSource};
45//!
46//! let opts = ResolverOptions {
47//! plc_source: PlcSource::slingshot_default(),
48//! public_fallback_for_handle: true,
49//! validate_doc_id: true,
50//! ..Default::default()
51//! };
52//!
53//! let resolver = JacquardResolver::new(reqwest::Client::new(), opts);
54//! #[cfg(feature = "dns")]
55//! let resolver = resolver.with_system_dns(); // Enable DNS TXT resolution
56//! ```
57//!
58//! ## Response types
59//!
60//! Resolution methods return wrapper types that own the response buffer, allowing
61//! zero-copy parsing:
62//!
63//! - [`resolver::DidDocResponse`] - Full DID document response
64//! - [`MiniDocResponse`] - Slingshot mini-doc response (partial)
65//!
66//! Both support `.parse()` for borrowing and validation.
67
68#![warn(missing_docs)]
69#![cfg_attr(target_arch = "wasm32", allow(unused))]
70pub mod lexicon_resolver;
71pub mod resolver;
72
73use crate::resolver::{
74 DidDocResponse, DidStep, HandleStep, IdentityError, IdentityErrorKind, IdentityResolver,
75 MiniDoc, PlcSource, ResolverOptions,
76};
77use bytes::Bytes;
78use jacquard_common::BosStr;
79#[cfg(feature = "streaming")]
80use jacquard_common::ByteStream;
81use jacquard_common::bos::Bos;
82use jacquard_common::deps::fluent_uri::Uri;
83use jacquard_common::deps::fluent_uri::pct_enc::{
84 EString,
85 encoder::{Data as EncData, Query},
86};
87use jacquard_common::deps::smol_str::{SmolStr, ToSmolStr};
88use jacquard_common::http_client::HttpClient;
89use jacquard_common::types::did::Did;
90use jacquard_common::types::did_doc::DidDocument;
91use jacquard_common::types::ident::AtIdentifier;
92use jacquard_common::types::string::Handle;
93use jacquard_common::xrpc::XrpcExt;
94use jacquard_common::xrpc::atproto::{ResolveDid, ResolveHandle};
95use reqwest::StatusCode;
96
97#[cfg(all(feature = "dns", not(target_family = "wasm")))]
98use {
99 hickory_resolver::{TokioAsyncResolver, config::ResolverConfig},
100 std::sync::Arc,
101};
102
103#[cfg(feature = "cache")]
104use {
105 crate::lexicon_resolver::ResolvedLexiconSchema, jacquard_common::types::string::Nsid,
106 mini_moka::time::Duration,
107};
108
109#[cfg(all(
110 feature = "cache",
111 not(all(feature = "dns", not(target_family = "wasm")))
112))]
113use std::sync::Arc;
114
115// Platform-specific cache implementations
116//#[cfg(all(feature = "cache", not(target_arch = "wasm32")))]
117#[cfg(feature = "cache")]
118mod cache_impl {
119 /// Native: Use sync cache (thread-safe, no mutex needed)
120 pub type Cache<K, V> = mini_moka::sync::Cache<K, V>;
121
122 pub fn new_cache<K, V>(max_capacity: u64, ttl: std::time::Duration) -> Cache<K, V>
123 where
124 K: std::hash::Hash + Eq + Send + Sync + 'static,
125 V: Clone + Send + Sync + 'static,
126 {
127 mini_moka::sync::Cache::builder()
128 .max_capacity(max_capacity)
129 .time_to_idle(ttl)
130 .build()
131 }
132
133 pub fn get<K, V>(cache: &Cache<K, V>, key: &K) -> Option<V>
134 where
135 K: std::hash::Hash + Eq + Send + Sync + 'static,
136 V: Clone + Send + Sync + 'static,
137 {
138 cache.get(key)
139 }
140
141 pub fn insert<K, V>(cache: &Cache<K, V>, key: K, value: V)
142 where
143 K: std::hash::Hash + Eq + Send + Sync + 'static,
144 V: Clone + Send + Sync + 'static,
145 {
146 cache.insert(key, value);
147 }
148
149 pub fn invalidate<K, V>(cache: &Cache<K, V>, key: &K)
150 where
151 K: std::hash::Hash + Eq + Send + Sync + 'static,
152 V: Clone + Send + Sync + 'static,
153 {
154 cache.invalidate(key);
155 }
156}
157
158// #[cfg(all(feature = "cache", target_arch = "wasm32"))]
159// mod cache_impl {
160// use std::sync::{Arc, Mutex};
161
162// /// WASM: Use unsync cache in Arc<Mutex<_>> (no threads, but need interior mutability)
163// pub type Cache<K, V> = Arc<Mutex<mini_moka::unsync::Cache<K, V>>>;
164
165// pub fn new_cache<K, V>(max_capacity: u64, ttl: std::time::Duration) -> Cache<K, V>
166// where
167// K: std::hash::Hash + Eq + 'static,
168// V: Clone + 'static,
169// {
170// Arc::new(Mutex::new(
171// mini_moka::unsync::Cache::builder()
172// .max_capacity(max_capacity)
173// .time_to_idle(ttl)
174// .build(),
175// ))
176// }
177
178// pub fn get<K, V>(cache: &Cache<K, V>, key: &K) -> Option<V>
179// where
180// K: std::hash::Hash + Eq + 'static,
181// V: Clone + 'static,
182// {
183// cache.lock().unwrap().get(key).cloned()
184// }
185
186// pub fn insert<K, V>(cache: &Cache<K, V>, key: K, value: V)
187// where
188// K: std::hash::Hash + Eq + 'static,
189// V: Clone + 'static,
190// {
191// cache.lock().unwrap().insert(key, value);
192// }
193
194// pub fn invalidate<K, V>(cache: &Cache<K, V>, key: &K)
195// where
196// K: std::hash::Hash + Eq + 'static,
197// V: Clone + 'static,
198// {
199// cache.lock().unwrap().invalidate(key);
200// }
201// }
202
203/// Configuration for resolver caching
204#[cfg(feature = "cache")]
205#[derive(Clone, Debug)]
206pub struct CacheConfig {
207 /// Maximum capacity for handle→DID cache
208 pub handle_to_did_capacity: u64,
209 /// TTL for handle→DID cache
210 pub handle_to_did_ttl: Duration,
211 /// Maximum capacity for DID→document cache
212 pub did_to_doc_capacity: u64,
213 /// TTL for DID→document cache
214 pub did_to_doc_ttl: Duration,
215 /// Maximum capacity for authority→DID cache
216 pub authority_to_did_capacity: u64,
217 /// TTL for authority→DID cache
218 pub authority_to_did_ttl: Duration,
219 /// Maximum capacity for NSID→schema cache
220 pub nsid_to_schema_capacity: u64,
221 /// TTL for NSID→schema cache
222 pub nsid_to_schema_ttl: Duration,
223}
224
225#[cfg(feature = "cache")]
226impl Default for CacheConfig {
227 fn default() -> Self {
228 Self {
229 handle_to_did_capacity: 2000,
230 handle_to_did_ttl: Duration::from_secs(24 * 3600),
231 did_to_doc_capacity: 1000,
232 did_to_doc_ttl: Duration::from_secs(72 * 3600),
233 authority_to_did_capacity: 1000,
234 authority_to_did_ttl: Duration::from_secs(168 * 3600),
235 nsid_to_schema_capacity: 1000,
236 nsid_to_schema_ttl: Duration::from_secs(168 * 3600),
237 }
238 }
239}
240
241#[cfg(feature = "cache")]
242impl CacheConfig {
243 /// Set handle→DID cache parameters
244 pub fn with_handle_cache(mut self, capacity: u64, ttl: Duration) -> Self {
245 self.handle_to_did_capacity = capacity;
246 self.handle_to_did_ttl = ttl;
247 self
248 }
249
250 /// Set DID→document cache parameters
251 pub fn with_did_doc_cache(mut self, capacity: u64, ttl: Duration) -> Self {
252 self.did_to_doc_capacity = capacity;
253 self.did_to_doc_ttl = ttl;
254 self
255 }
256
257 /// Set authority→DID cache parameters
258 pub fn with_authority_cache(mut self, capacity: u64, ttl: Duration) -> Self {
259 self.authority_to_did_capacity = capacity;
260 self.authority_to_did_ttl = ttl;
261 self
262 }
263
264 /// Set NSID→schema cache parameters
265 pub fn with_schema_cache(mut self, capacity: u64, ttl: Duration) -> Self {
266 self.nsid_to_schema_capacity = capacity;
267 self.nsid_to_schema_ttl = ttl;
268 self
269 }
270}
271
272/// Cache layer for resolver operations
273///
274/// Fairly simple, in-memory only. If you want something more complex with persistence,
275/// implemement the appropriate resolver traits on your own struct, or wrap
276/// JacquardResolver in a custom cache layer. The intent here is to allow your
277/// backend service to not hammer people's DNS or PDS/entryway if you make requests
278/// that need to do resolution first (e.g. the get_record helper functions), not
279/// to provide a complete caching solution for all use cases of the resolver.
280///
281/// **Note from the author:** If there is desire or need, I can break out cache operation
282/// functions into a trait to make this more pluggable, but this solves the typical
283/// use case.
284#[cfg(feature = "cache")]
285#[derive(Clone)]
286pub struct ResolverCaches {
287 /// Cache mapping handles to their resolved DIDs.
288 pub handle_to_did: cache_impl::Cache<Handle, Did>,
289 /// Cache mapping DIDs to their full DID documents.
290 pub did_to_doc: cache_impl::Cache<Did, Arc<DidDocResponse>>,
291 /// Cache mapping authority strings (e.g., PDS hosts) to DIDs.
292 pub authority_to_did: cache_impl::Cache<SmolStr, Did>,
293 /// Cache mapping NSIDs to their resolved lexicon schemas.
294 pub nsid_to_schema: cache_impl::Cache<Nsid, Arc<ResolvedLexiconSchema<'static>>>,
295}
296
297#[cfg(feature = "cache")]
298impl ResolverCaches {
299 /// Creates a new set of resolver caches from the given configuration.
300 pub fn new(config: &CacheConfig) -> Self {
301 Self {
302 handle_to_did: cache_impl::new_cache(
303 config.handle_to_did_capacity,
304 config.handle_to_did_ttl,
305 ),
306 did_to_doc: cache_impl::new_cache(config.did_to_doc_capacity, config.did_to_doc_ttl),
307 authority_to_did: cache_impl::new_cache(
308 config.authority_to_did_capacity,
309 config.authority_to_did_ttl,
310 ),
311 nsid_to_schema: cache_impl::new_cache(
312 config.nsid_to_schema_capacity,
313 config.nsid_to_schema_ttl,
314 ),
315 }
316 }
317}
318
319#[cfg(feature = "cache")]
320impl Default for ResolverCaches {
321 fn default() -> Self {
322 Self::new(&CacheConfig::default())
323 }
324}
325
326/// Default resolver implementation with configurable fallback order.
327#[derive(Clone)]
328pub struct JacquardResolver<C> {
329 http: C,
330 opts: ResolverOptions,
331 #[cfg(feature = "dns")]
332 dns: Option<Arc<TokioAsyncResolver>>,
333 #[cfg(feature = "cache")]
334 caches: Option<ResolverCaches>,
335}
336
337impl<C: HttpClient> JacquardResolver<C> {
338 /// Create a new instance of the default resolver with all options (except DNS) up front
339 pub fn new(http: C, opts: ResolverOptions) -> Self {
340 // #[cfg(feature = "tracing")]
341 // tracing::info!(
342 // public_fallback = opts.public_fallback_for_handle,
343 // validate_doc_id = opts.validate_doc_id,
344 // plc_source = ?opts.plc_source,
345 // "jacquard resolver created"
346 // );
347
348 Self {
349 http,
350 opts,
351 #[cfg(feature = "dns")]
352 dns: None,
353 #[cfg(feature = "cache")]
354 caches: None,
355 }
356 }
357
358 #[cfg(feature = "dns")]
359 /// Create a new instance of the default resolver with all options, plus default DNS, up front
360 pub fn new_dns(http: C, opts: ResolverOptions) -> Self {
361 Self {
362 http,
363 opts,
364 dns: Some(Arc::new(TokioAsyncResolver::tokio(
365 ResolverConfig::default(),
366 Default::default(),
367 ))),
368 #[cfg(feature = "cache")]
369 caches: None,
370 }
371 }
372
373 #[cfg(feature = "dns")]
374 /// Add default DNS resolution to the resolver
375 pub fn with_system_dns(mut self) -> Self {
376 self.dns = Some(Arc::new(TokioAsyncResolver::tokio(
377 ResolverConfig::default(),
378 Default::default(),
379 )));
380 self
381 }
382
383 /// Set PLC source (PLC directory or Slingshot)
384 pub fn with_plc_source(mut self, source: PlcSource) -> Self {
385 self.opts.plc_source = source;
386 self
387 }
388
389 /// Enable/disable public unauthenticated fallback for resolveHandle
390 pub fn with_public_fallback_for_handle(mut self, enable: bool) -> Self {
391 self.opts.public_fallback_for_handle = enable;
392 self
393 }
394
395 /// Enable/disable doc id validation
396 pub fn with_validate_doc_id(mut self, enable: bool) -> Self {
397 self.opts.validate_doc_id = enable;
398 self
399 }
400
401 /// Set the HTTP request timeout. Pass `None` to disable timeout.
402 pub fn with_request_timeout(mut self, timeout: Option<n0_future::time::Duration>) -> Self {
403 self.opts.request_timeout = timeout;
404 self
405 }
406
407 #[cfg(feature = "cache")]
408 /// Enable caching with default configuration
409 pub fn with_cache(mut self) -> Self {
410 self.caches = Some(ResolverCaches::default());
411 self
412 }
413
414 #[cfg(feature = "cache")]
415 /// Enable caching with custom configuration
416 pub fn with_cache_config(mut self, config: CacheConfig) -> Self {
417 self.caches = Some(ResolverCaches::new(&config));
418 self
419 }
420
421 /// Construct the well-known HTTPS URL for a `did:web` DID.
422 ///
423 /// - `did:web:example.com` → `https://example.com/.well-known/did.json`
424 /// - `did:web:example.com:user:alice` → `https://example.com/user/alice/did.json`
425 fn did_web_url<S: BosStr + Sync>(&self, did: &Did<S>) -> resolver::Result<Uri<String>> {
426 // did:web:example.com[:path:segments]
427 let s = did.as_str();
428 let rest = s
429 .strip_prefix("did:web:")
430 .ok_or_else(|| IdentityError::unsupported_did_method(s))?;
431 let mut parts = rest.split(':');
432 let host = parts
433 .next()
434 .ok_or_else(|| IdentityError::unsupported_did_method(s))?;
435
436 let path_segments: Vec<&str> = parts.collect();
437
438 // Build the path using fluent-uri builder
439 let mut path = String::from("/");
440 if path_segments.is_empty() {
441 path.push_str(".well-known/did.json");
442 } else {
443 for seg in path_segments {
444 path.push_str(seg);
445 path.push('/');
446 }
447 path.push_str("did.json");
448 }
449
450 let url_str = format!("https://{}{}", host, path);
451 Uri::parse(url_str)
452 .map_err(|(e, _)| IdentityError::url(e))
453 .map(|u| u.to_owned())
454 }
455
456 #[cfg(test)]
457 fn test_did_web_url_raw(&self, s: &str) -> String {
458 let did: Did<SmolStr> = Did::new_owned(s).unwrap();
459 self.did_web_url(&did).unwrap().to_string()
460 }
461
462 async fn get_json_bytes(&self, uri: Uri<&str>) -> resolver::Result<(Bytes, StatusCode)> {
463 let resp = self
464 .http
465 .send_http(http::Request::get(uri.as_str()).body(Vec::new()).unwrap())
466 .await
467 .map_err(|e| IdentityError::transport(SmolStr::from(uri.as_str()), e))?;
468 let status = resp.status();
469 let buf = resp.into_body();
470 Ok((Bytes::from_owner(buf), status))
471 }
472
473 async fn get_text(&self, uri: Uri<&str>) -> resolver::Result<String> {
474 let u = SmolStr::from(uri.as_str());
475 let resp = self
476 .http
477 .send_http(http::Request::get(uri.as_str()).body(Vec::new()).unwrap())
478 .await
479 .map_err(|e| IdentityError::transport(u.clone(), e))?;
480 if resp.status() == StatusCode::OK {
481 Ok(String::from_utf8_lossy(resp.body()).to_string())
482 } else {
483 Err(IdentityError::new(
484 IdentityErrorKind::Transport(String::from_utf8_lossy(resp.body()).to_smolstr()),
485 None,
486 ))
487 }
488 }
489
490 #[cfg(feature = "dns")]
491 async fn dns_txt(&self, name: &str) -> resolver::Result<Vec<String>> {
492 let Some(dns) = &self.dns else {
493 return Ok(vec![]);
494 };
495 let fqdn = format!("_atproto.{name}.");
496 let response = dns.txt_lookup(fqdn).await?;
497 let mut out = Vec::new();
498 for txt in response.iter() {
499 for data in txt.txt_data().iter() {
500 out.push(String::from_utf8_lossy(data).to_string());
501 }
502 }
503 Ok(out)
504 }
505
506 /// Query DNS via DNS-over-HTTPS using Cloudflare
507 pub async fn query_dns_doh(
508 &self,
509 name: &str,
510 record_type: &str,
511 ) -> resolver::Result<serde_json::Value> {
512 #[cfg(feature = "tracing")]
513 tracing::trace!("querying DNS via DoH: {} ({})", name, record_type);
514
515 let mut enc_name = EString::<Query>::new();
516 enc_name.encode_str::<EncData>(name);
517 let mut enc_type = EString::<Query>::new();
518 enc_type.encode_str::<EncData>(record_type);
519 let url_str =
520 format!("https://cloudflare-dns.com/dns-query?name={enc_name}&type={enc_type}");
521
522 let response = self
523 .http
524 .send_http(
525 http::Request::get(url_str.as_str())
526 .header("Accept", "application/dns-json")
527 .body(Vec::new())
528 .unwrap(),
529 )
530 .await
531 .map_err(|e| IdentityError::transport(SmolStr::from(url_str.as_str()), e))?;
532
533 let status = response.status();
534 if !status.is_success() {
535 return Err(IdentityError::http_status(status).with_context(format!(
536 "DNS-over-HTTPS query for {} ({})",
537 name, record_type
538 )));
539 }
540
541 let json: serde_json::Value = serde_json::from_slice(&response.body())
542 .map_err(|e| IdentityError::transport(SmolStr::from(url_str.as_str()), e))?;
543 Ok(json)
544 }
545
546 #[cfg(not(feature = "dns"))]
547 async fn dns_txt(&self, name: &str) -> resolver::Result<Vec<String>> {
548 let fqdn = format!("_atproto.{name}.");
549 let response = self
550 .query_dns_doh(&fqdn, "TXT")
551 .await
552 .map_err(|e| IdentityError::dns(e))?;
553
554 // Parse DoH JSON response
555 let answers = response
556 .get("Answer")
557 .and_then(|a| a.as_array())
558 .ok_or_else(|| {
559 IdentityError::doh_parse_failed()
560 .with_context(format!("DoH response missing 'Answer' array for {name}"))
561 })?;
562
563 let mut results: Vec<String> = Vec::new();
564 for answer in answers {
565 if let Some(data) = answer.get("data").and_then(|d| d.as_str()) {
566 // TXT records are quoted in DNS responses, strip quotes
567 results.push(data.trim_matches('"').to_string())
568 }
569 }
570 Ok(results)
571 }
572
573 fn parse_atproto_did_body(body: &str, identifier: &str) -> resolver::Result<Did> {
574 let line = body
575 .lines()
576 .find(|l| !l.trim().is_empty())
577 .ok_or_else(|| IdentityError::invalid_well_known(identifier))?;
578 Did::new_owned(line.trim())
579 .map_err(|e| IdentityError::invalid_well_known_with_source(identifier, e))
580 }
581}
582
583impl<C: HttpClient> JacquardResolver<C> {
584 /// Resolve handle to DID via a PDS XRPC call (stateless, unauth by default)
585 pub async fn resolve_handle_via_pds<S: BosStr + Sync>(
586 &self,
587 handle: &Handle<S>,
588 ) -> resolver::Result<Did> {
589 let pds = match &self.opts.pds_fallback {
590 Some(u) => u.clone(),
591 None => return Err(IdentityError::no_pds_fallback()),
592 };
593 let owned_handle: Handle =
594 Handle::new_owned(handle.as_str()).expect("already validated handle");
595 let req = ResolveHandle {
596 handle: owned_handle,
597 };
598 let resp = self.http.xrpc(pds.borrow()).send(&req).await.map_err(|e| {
599 IdentityError::from(e).with_context(format!("resolving handle {}", handle))
600 })?;
601 // Note: XrpcError<E> has GAT lifetimes that prevent boxing; use debug format
602 let out = resp.parse::<SmolStr>().map_err(|e| {
603 IdentityError::xrpc(jacquard_common::deps::smol_str::format_smolstr!("{:?}", e))
604 .with_context(format!("parsing response for handle {}", handle))
605 })?;
606 Did::new_owned(out.did.as_str()).map_err(|e| {
607 IdentityError::invalid_doc(jacquard_common::deps::smol_str::format_smolstr!(
608 "PDS returned invalid DID '{}': {}",
609 out.did,
610 e
611 ))
612 })
613 }
614
615 /// Fetch DID document via PDS resolveDid (returns owned DidDocument)
616 pub async fn fetch_did_doc_via_pds_owned<S: BosStr + Sync>(
617 &self,
618 did: &Did<S>,
619 ) -> resolver::Result<DidDocument> {
620 let pds = match &self.opts.pds_fallback {
621 Some(u) => u.clone(),
622 None => return Err(IdentityError::no_pds_fallback()),
623 };
624 let owned_did: Did = Did::new_owned(did.as_str()).expect("already validated DID");
625 let req = ResolveDid { did: owned_did };
626 let resp = self.http.xrpc(pds.borrow()).send(&req).await.map_err(|e| {
627 IdentityError::from(e).with_context(format!("fetching DID doc for {}", did))
628 })?;
629 // Note: XrpcError<E> has GAT lifetimes that prevent boxing; use debug format
630 let out = resp.parse::<SmolStr>().map_err(|e| {
631 IdentityError::xrpc(jacquard_common::deps::smol_str::format_smolstr!("{:?}", e))
632 .with_context(format!("parsing DID doc response for {}", did))
633 })?;
634 let doc_json = serde_json::to_value(&out.did_doc)?;
635 let s = serde_json::to_string(&doc_json)?;
636 let doc: DidDocument = serde_json::from_str(&s)?;
637 Ok(doc)
638 }
639
640 /// Fetch a minimal DID document via a Slingshot mini-doc endpoint, if your PlcSource uses Slingshot.
641 /// Returns the raw response wrapper for borrowed parsing and validation.
642 pub async fn fetch_mini_doc_via_slingshot<S: BosStr + Sync>(
643 &self,
644 did: &Did<S>,
645 ) -> resolver::Result<DidDocResponse> {
646 let base = match &self.opts.plc_source {
647 PlcSource::Slingshot { base } => base.clone(),
648 _ => {
649 return Err(IdentityError::unsupported_did_method(
650 "mini-doc requires Slingshot source",
651 )
652 .with_context(format!("resolving {}", did)));
653 }
654 };
655 // Build URL using string manipulation, then parse
656 let owned_did: Did = Did::new_owned(did.as_str()).expect("already validated DID");
657 let qs = serde_html_form::to_string(&ResolveDid {
658 did: owned_did.clone(),
659 })
660 .unwrap_or_default();
661 let url_str = if qs.is_empty() {
662 format!(
663 "{}xrpc/blue.microcosm.identity.resolveMiniDoc",
664 base.as_str().trim_end_matches('/').to_string() + "/"
665 )
666 } else {
667 format!(
668 "{}xrpc/blue.microcosm.identity.resolveMiniDoc?{}",
669 base.as_str().trim_end_matches('/').to_string() + "/",
670 qs
671 )
672 };
673 let url = Uri::parse(url_str)
674 .map_err(|(e, _)| IdentityError::url(e))?
675 .to_owned();
676 let (buf, status) = self.get_json_bytes(url.borrow()).await?;
677 Ok(DidDocResponse {
678 buffer: buf,
679 status,
680 requested: Some(owned_did),
681 })
682 }
683}
684
685impl<C: HttpClient + Sync> IdentityResolver for JacquardResolver<C> {
686 fn options(&self) -> &ResolverOptions {
687 &self.opts
688 }
689 #[cfg_attr(feature = "tracing", tracing::instrument(level = "debug", skip(self), fields(handle = %handle)))]
690 async fn resolve_handle<S: BosStr + Sync>(&self, handle: &Handle<S>) -> resolver::Result<Did> {
691 // Try cache first
692 #[cfg(feature = "cache")]
693 if let Some(caches) = &self.caches {
694 let key = Handle::new_owned(handle.as_str()).expect("already validated handle");
695 if let Some(did) = cache_impl::get(&caches.handle_to_did, &key) {
696 return Ok(did);
697 }
698 }
699
700 let host = handle.as_str();
701 let mut resolved_did: Option<Did> = None;
702
703 'outer: for step in &self.opts.handle_order {
704 match step {
705 HandleStep::DnsTxt => {
706 if let Ok(txts) = self.dns_txt(host).await {
707 for txt in txts {
708 if let Some(did_str) = txt.strip_prefix("did=") {
709 if let Ok(did) = Did::new_owned(did_str) {
710 resolved_did = Some(did);
711 break 'outer;
712 }
713 }
714 }
715 }
716 }
717 HandleStep::HttpsWellKnown => {
718 let url_str = format!("https://{host}/.well-known/atproto-did");
719 let url = Uri::parse(url_str)
720 .map_err(|(e, _)| IdentityError::url(e))?
721 .to_owned();
722 if let Ok(text) = self.get_text(url.borrow()).await {
723 if let Ok(did) = Self::parse_atproto_did_body(&text, handle.as_str()) {
724 resolved_did = Some(did);
725 break 'outer;
726 }
727 }
728 }
729 HandleStep::PdsResolveHandle => {
730 // Prefer PDS XRPC via stateless client
731 if let Ok(did) = self.resolve_handle_via_pds(handle).await {
732 resolved_did = Some(did);
733 break 'outer;
734 }
735 // Public unauth fallback
736 if self.opts.public_fallback_for_handle {
737 let owned_handle: Handle =
738 Handle::new_owned(handle.as_str()).expect("already validated handle");
739 if let Ok(qs) = serde_html_form::to_string(&ResolveHandle {
740 handle: owned_handle,
741 }) {
742 let url_str = format!(
743 "https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?{}",
744 qs
745 );
746 if let Ok(url) = Uri::parse(url_str)
747 .map(|u| u.to_owned())
748 .map_err(|(e, _)| IdentityError::url(e))
749 {
750 if let Ok((buf, status)) = self.get_json_bytes(url.borrow()).await {
751 if status.is_success() {
752 if let Ok(val) =
753 serde_json::from_slice::<serde_json::Value>(&buf)
754 {
755 if let Some(did_str) =
756 val.get("did").and_then(|v| v.as_str())
757 {
758 if let Ok(did) = Did::new_owned(did_str) {
759 resolved_did = Some(did);
760 break 'outer;
761 }
762 }
763 }
764 }
765 }
766 }
767 } else {
768 continue;
769 }
770 }
771 // Non-auth path: if PlcSource is Slingshot, use its resolveHandle endpoint.
772 if let PlcSource::Slingshot { base } = &self.opts.plc_source {
773 let owned_handle: Handle =
774 Handle::new_owned(handle.as_str()).expect("already validated handle");
775 let qs = serde_html_form::to_string(&ResolveHandle {
776 handle: owned_handle,
777 })
778 .unwrap_or_default();
779 let url_str = if qs.is_empty() {
780 format!(
781 "{}xrpc/com.atproto.identity.resolveHandle",
782 base.as_str().trim_end_matches('/').to_string() + "/"
783 )
784 } else {
785 format!(
786 "{}xrpc/com.atproto.identity.resolveHandle?{}",
787 base.as_str().trim_end_matches('/').to_string() + "/",
788 qs
789 )
790 };
791 // TODO: Surface URI parse errors through tracing when the feature is available.
792 if let Ok(url) = Uri::parse(url_str)
793 .map(|u| u.to_owned())
794 .map_err(|(e, _)| e)
795 {
796 if let Ok((buf, status)) = self.get_json_bytes(url.borrow()).await {
797 if status.is_success() {
798 if let Ok(val) =
799 serde_json::from_slice::<serde_json::Value>(&buf)
800 {
801 if let Some(did_str) =
802 val.get("did").and_then(|v| v.as_str())
803 {
804 if let Ok(did) = Did::new_owned(did_str) {
805 resolved_did = Some(did);
806 break 'outer;
807 }
808 }
809 }
810 }
811 }
812 }
813 }
814 }
815 }
816 }
817
818 // Handle result
819 if let Some(did) = resolved_did {
820 // Cache successful resolution
821 #[cfg(feature = "cache")]
822 if let Some(caches) = &self.caches {
823 cache_impl::insert(
824 &caches.handle_to_did,
825 Handle::new_owned(handle.as_str()).expect("already validated handle"),
826 did.clone(),
827 );
828 }
829 Ok(did)
830 } else {
831 // Invalidate on error
832 #[cfg(feature = "cache")]
833 self.invalidate_handle_chain(handle).await;
834 Err(IdentityError::handle_resolution_exhausted()
835 .with_context(format!("failed to resolve handle: {}", handle)))
836 }
837 }
838
839 #[cfg_attr(feature = "tracing", tracing::instrument(level = "debug", skip(self), fields(did = %did)))]
840 async fn resolve_did_doc<S: BosStr + Sync>(
841 &self,
842 did: &Did<S>,
843 ) -> resolver::Result<DidDocResponse> {
844 let owned_did: Did = Did::new_owned(did.as_str()).expect("already validated DID");
845
846 // Try cache first
847 #[cfg(feature = "cache")]
848 if let Some(caches) = &self.caches {
849 if let Some(doc_resp) = cache_impl::get(&caches.did_to_doc, &owned_did) {
850 return Ok((*doc_resp).clone());
851 }
852 }
853
854 let s = did.as_str();
855 let mut resolved_doc: Option<DidDocResponse> = None;
856
857 'outer: for step in &self.opts.did_order {
858 match step {
859 DidStep::DidWebHttps if s.starts_with("did:web:") => {
860 let url = self.did_web_url(did)?;
861 if let Ok((buf, status)) = self.get_json_bytes(url.borrow()).await {
862 resolved_doc = Some(DidDocResponse {
863 buffer: buf,
864 status,
865 requested: Some(owned_did.clone()),
866 });
867 break 'outer;
868 }
869 }
870 DidStep::PlcHttp if s.starts_with("did:plc:") => {
871 let url_str = match &self.opts.plc_source {
872 PlcSource::PlcDirectory { base } => {
873 // this is odd, the join screws up with the plc directory but NOT slingshot
874 format!("{}{}", base, did.as_str())
875 }
876 PlcSource::Slingshot { base } => {
877 format!("{}{}", base, did.as_str())
878 }
879 };
880 if let Ok(url) = Uri::parse(url_str)
881 .map(|u| u.to_owned())
882 .map_err(|(_, _)| IdentityError::unsupported_did_method(did.as_str()))
883 {
884 if let Ok((buf, status)) = self.get_json_bytes(url.borrow()).await {
885 resolved_doc = Some(DidDocResponse {
886 buffer: buf,
887 status,
888 requested: Some(owned_did.clone()),
889 });
890 break 'outer;
891 }
892 }
893 }
894 DidStep::PdsResolveDid => {
895 // Try PDS XRPC for full DID doc
896 if let Ok(doc) = self.fetch_did_doc_via_pds_owned(did).await {
897 let buf = serde_json::to_vec(&doc).unwrap_or_default();
898 resolved_doc = Some(DidDocResponse {
899 buffer: Bytes::from(buf),
900 status: StatusCode::OK,
901 requested: Some(owned_did.clone()),
902 });
903 break 'outer;
904 }
905 // Fallback: if Slingshot configured, return mini-doc response (partial doc)
906 if let PlcSource::Slingshot { base } = &self.opts.plc_source {
907 let url = self.slingshot_mini_doc_url(base, did.as_str())?;
908 let (buf, status) = self.get_json_bytes(url.borrow()).await?;
909 resolved_doc = Some(DidDocResponse {
910 buffer: buf,
911 status,
912 requested: Some(owned_did.clone()),
913 });
914 break 'outer;
915 }
916 }
917 _ => {}
918 }
919 }
920
921 // Handle result
922 if let Some(doc_resp) = resolved_doc {
923 // Cache successful resolution
924 #[cfg(feature = "cache")]
925 if let Some(caches) = &self.caches {
926 cache_impl::insert(&caches.did_to_doc, owned_did, Arc::new(doc_resp.clone()));
927 }
928 Ok(doc_resp)
929 } else {
930 // Invalidate on error
931 #[cfg(feature = "cache")]
932 self.invalidate_did_chain(did).await;
933 Err(IdentityError::handle_resolution_exhausted())
934 }
935 }
936}
937
938impl<C: HttpClient + Sync> HttpClient for JacquardResolver<C> {
939 type Error = IdentityError;
940
941 async fn send_http(
942 &self,
943 request: http::Request<Vec<u8>>,
944 ) -> core::result::Result<http::Response<Vec<u8>>, Self::Error> {
945 let u = request.uri().clone();
946 match self.opts.request_timeout {
947 Some(duration) => n0_future::time::timeout(duration, self.http.send_http(request))
948 .await
949 .map_err(|_| IdentityError::timeout())?
950 .map_err(|e| IdentityError::transport(u.to_smolstr(), e)),
951 None => self
952 .http
953 .send_http(request)
954 .await
955 .map_err(|e| IdentityError::transport(u.to_smolstr(), e)),
956 }
957 }
958}
959
960#[cfg(feature = "streaming")]
961impl<C> jacquard_common::http_client::HttpClientExt for JacquardResolver<C>
962where
963 C: HttpClient + jacquard_common::http_client::HttpClientExt + Sync,
964{
965 /// Send HTTP request and return streaming response
966 async fn send_http_streaming(
967 &self,
968 request: http::Request<Vec<u8>>,
969 ) -> Result<http::Response<ByteStream>, Self::Error> {
970 let u = request.uri().clone();
971 match self.opts.request_timeout {
972 Some(duration) => {
973 n0_future::time::timeout(duration, self.http.send_http_streaming(request))
974 .await
975 .map_err(|_| IdentityError::timeout())?
976 .map_err(|e| IdentityError::transport(u.to_smolstr(), e))
977 }
978 None => self
979 .http
980 .send_http_streaming(request)
981 .await
982 .map_err(|e| IdentityError::transport(u.to_smolstr(), e)),
983 }
984 }
985
986 /// Send HTTP request with streaming body and receive streaming response
987 #[cfg(not(target_arch = "wasm32"))]
988 async fn send_http_bidirectional<S>(
989 &self,
990 parts: http::request::Parts,
991 body: S,
992 ) -> Result<http::Response<ByteStream>, Self::Error>
993 where
994 S: n0_future::Stream<Item = Result<bytes::Bytes, jacquard_common::StreamError>>
995 + Send
996 + 'static,
997 {
998 let u = parts.uri.clone();
999 match self.opts.request_timeout {
1000 Some(duration) => {
1001 n0_future::time::timeout(duration, self.http.send_http_bidirectional(parts, body))
1002 .await
1003 .map_err(|_| IdentityError::timeout())?
1004 .map_err(|e| IdentityError::transport(u.to_smolstr(), e))
1005 }
1006 None => self
1007 .http
1008 .send_http_bidirectional(parts, body)
1009 .await
1010 .map_err(|e| IdentityError::transport(u.to_smolstr(), e)),
1011 }
1012 }
1013
1014 /// Send HTTP request with streaming body and receive streaming response (WASM)
1015 #[cfg(target_arch = "wasm32")]
1016 async fn send_http_bidirectional<S>(
1017 &self,
1018 parts: http::request::Parts,
1019 body: S,
1020 ) -> Result<http::Response<ByteStream>, Self::Error>
1021 where
1022 S: n0_future::Stream<Item = Result<bytes::Bytes, jacquard_common::StreamError>> + 'static,
1023 {
1024 let u = parts.uri.clone();
1025 match self.opts.request_timeout {
1026 Some(duration) => {
1027 n0_future::time::timeout(duration, self.http.send_http_bidirectional(parts, body))
1028 .await
1029 .map_err(|_| IdentityError::timeout())?
1030 .map_err(|e| IdentityError::transport(u.to_smolstr(), e))
1031 }
1032 None => self
1033 .http
1034 .send_http_bidirectional(parts, body)
1035 .await
1036 .map_err(|e| IdentityError::transport(u.to_smolstr(), e)),
1037 }
1038 }
1039}
1040
1041/// Warnings produced during identity checks that are not fatal
1042#[derive(Debug, Clone, PartialEq, Eq)]
1043pub enum IdentityWarning {
1044 /// The DID doc did not contain the expected handle alias under alsoKnownAs
1045 HandleAliasMismatch {
1046 #[allow(missing_docs)]
1047 expected: Handle,
1048 },
1049}
1050
1051impl<C: HttpClient + Sync> JacquardResolver<C> {
1052 /// Resolve a handle to its DID, fetch the DID document, and return doc plus any warnings.
1053 /// This applies the default equality check on the document id (error with doc if mismatch).
1054 pub async fn resolve_handle_and_doc<S: BosStr + Sync>(
1055 &self,
1056 handle: &Handle<S>,
1057 ) -> resolver::Result<(Did, DidDocResponse, Vec<IdentityWarning>)> {
1058 let did = self.resolve_handle(handle).await?;
1059 let resp = self.resolve_did_doc(&did).await?;
1060 let doc_borrowed = resp.parse()?;
1061 if self.opts.validate_doc_id && doc_borrowed.id.as_str() != did.as_str() {
1062 let owned_doc = resp.clone().into_owned().unwrap_or_else(|_| DidDocument {
1063 context: jacquard_common::types::did_doc::default_context(),
1064 id: Did::new_owned(doc_borrowed.id.as_str()).expect("already validated DID"),
1065 also_known_as: None,
1066 verification_method: None,
1067 service: None,
1068 extra_data: std::collections::BTreeMap::new(),
1069 });
1070 return Err(IdentityError::doc_id_mismatch(did.clone(), owned_doc));
1071 }
1072 let mut warnings = Vec::new();
1073 // Check handle alias presence (soft warning)
1074 let has_alias = doc_borrowed
1075 .also_known_as
1076 .as_ref()
1077 .map(|v| {
1078 v.iter().any(|s| {
1079 let s = s.as_ref().strip_prefix("at://").unwrap_or(s.as_ref());
1080 s == handle.as_str()
1081 })
1082 })
1083 .unwrap_or(false);
1084 if !has_alias {
1085 warnings.push(IdentityWarning::HandleAliasMismatch {
1086 expected: Handle::new_owned(handle.as_str()).expect("already validated handle"),
1087 });
1088 }
1089 Ok((did, resp, warnings))
1090 }
1091
1092 /// Build Slingshot mini-doc URL for an identifier (handle or DID)
1093 fn slingshot_mini_doc_url(
1094 &self,
1095 base: &Uri<String>,
1096 identifier: &str,
1097 ) -> resolver::Result<Uri<String>> {
1098 let mut enc_id = EString::<Query>::new();
1099 enc_id.encode_str::<EncData>(identifier);
1100 let qs = format!("identifier={enc_id}");
1101 let url_str = format!(
1102 "{}://{}/xrpc/com.bad-example.identity.resolveMiniDoc?{}",
1103 base.scheme().as_str(),
1104 base.authority().map(|a| a.as_str()).unwrap_or(""),
1105 qs
1106 );
1107 Uri::parse(url_str)
1108 .map_err(|(e, _)| IdentityError::url(e))
1109 .map(|u| u.to_owned())
1110 }
1111
1112 #[cfg(feature = "cache")]
1113 async fn invalidate_handle_chain<S: BosStr + Sync>(&self, handle: &Handle<S>) {
1114 if let Some(caches) = &self.caches {
1115 let key = Handle::new_owned(handle.as_str()).expect("already validated handle");
1116 cache_impl::invalidate(&caches.handle_to_did, &key);
1117 }
1118 }
1119
1120 #[cfg(feature = "cache")]
1121 async fn invalidate_did_chain<S: BosStr + Sync>(&self, did: &Did<S>) {
1122 if let Some(caches) = &self.caches {
1123 let did_key = Did::new_owned(did.as_str()).expect("already validated DID");
1124 // Get doc before evicting to extract handles
1125 if let Some(doc_resp) = cache_impl::get(&caches.did_to_doc, &did_key) {
1126 let doc_resp_clone = (*doc_resp).clone();
1127 if let Ok(doc) = doc_resp_clone.parse() {
1128 if let Some(aliases) = &doc.also_known_as {
1129 for alias in aliases {
1130 if let Some(handle_str) = alias.as_ref().strip_prefix("at://") {
1131 if let Ok(handle) = Handle::new_owned(handle_str) {
1132 cache_impl::invalidate(&caches.handle_to_did, &handle);
1133 }
1134 }
1135 }
1136 }
1137 }
1138 }
1139 cache_impl::invalidate(&caches.did_to_doc, &did_key);
1140 }
1141 }
1142
1143 #[cfg(feature = "cache")]
1144 async fn invalidate_authority_chain(&self, authority: &str) {
1145 if let Some(caches) = &self.caches {
1146 let authority = SmolStr::from(authority);
1147 cache_impl::invalidate(&caches.authority_to_did, &authority);
1148 }
1149 }
1150
1151 #[cfg(feature = "cache")]
1152 async fn invalidate_lexicon_chain<S: BosStr + Sync>(
1153 &self,
1154 nsid: &jacquard_common::types::string::Nsid<S>,
1155 ) {
1156 if let Some(caches) = &self.caches {
1157 let nsid_key = Nsid::new_owned(nsid.as_str()).expect("already validated NSID");
1158 if let Some(schema) = cache_impl::get(&caches.nsid_to_schema, &nsid_key) {
1159 let authority = SmolStr::from(nsid.domain_authority());
1160 cache_impl::invalidate(&caches.authority_to_did, &authority);
1161 self.invalidate_did_chain(&schema.repo).await;
1162 }
1163 cache_impl::invalidate(&caches.nsid_to_schema, &nsid_key);
1164 }
1165 }
1166
1167 /// Fetch a minimal DID document via Slingshot's mini-doc endpoint using a generic at-identifier
1168 pub async fn fetch_mini_doc_via_slingshot_identifier<S: Bos<str> + AsRef<str> + Sync>(
1169 &self,
1170 identifier: &AtIdentifier<S>,
1171 ) -> resolver::Result<MiniDocResponse> {
1172 let base = match &self.opts.plc_source {
1173 PlcSource::Slingshot { base } => base.clone(),
1174 _ => {
1175 return Err(IdentityError::unsupported_did_method(
1176 "mini-doc requires Slingshot source",
1177 )
1178 .with_context(format!("resolving {}", identifier)));
1179 }
1180 };
1181 let url = self.slingshot_mini_doc_url(&base, identifier.as_str())?;
1182 let (buf, status) = self.get_json_bytes(url.borrow()).await?;
1183 Ok(MiniDocResponse {
1184 buffer: buf,
1185 status,
1186 identifier: SmolStr::from(identifier.as_str()),
1187 })
1188 }
1189}
1190
1191/// Slingshot mini-doc JSON response wrapper
1192#[derive(Clone)]
1193pub struct MiniDocResponse {
1194 buffer: Bytes,
1195 status: StatusCode,
1196 /// Identifier that was being resolved
1197 identifier: SmolStr,
1198}
1199
1200impl MiniDocResponse {
1201 /// Parse borrowed MiniDoc
1202 pub fn parse<'b>(&'b self) -> resolver::Result<MiniDoc<'b>> {
1203 if self.status.is_success() {
1204 serde_json::from_slice::<MiniDoc<'b>>(&self.buffer).map_err(IdentityError::from)
1205 } else {
1206 Err(IdentityError::http_status(self.status)
1207 .with_context(format!("fetching mini-doc for {}", self.identifier)))
1208 }
1209 }
1210}
1211
1212/// Resolver specialized for unauthenticated/public flows using reqwest and stateless XRPC
1213pub type PublicResolver = JacquardResolver<reqwest::Client>;
1214
1215impl Default for JacquardResolver<reqwest::Client> {
1216 /// Build a resolver with:
1217 /// - reqwest HTTP client
1218 /// - Public fallbacks enabled for handle resolution
1219 /// - default options (DNS enabled if compiled, public fallback for handles enabled)
1220 ///
1221 /// Example
1222 /// ```ignore
1223 /// use jacquard::identity::resolver::JacquardResolver;
1224 /// let resolver = JacquardResolver::default();
1225 /// ```
1226 fn default() -> Self {
1227 let http = reqwest::Client::new();
1228 let opts = ResolverOptions::default();
1229 let resolver = JacquardResolver::new(http, opts);
1230 #[cfg(feature = "dns")]
1231 let resolver = resolver.with_system_dns();
1232 #[cfg(feature = "cache")]
1233 let resolver = resolver.with_cache();
1234 resolver
1235 }
1236}
1237
1238/// Build a resolver configured to use Slingshot (`https://slingshot.microcosm.blue`) for PLC and
1239/// mini-doc fallbacks, unauthenticated by default.
1240pub fn slingshot_resolver_default() -> JacquardResolver<reqwest::Client> {
1241 let http = reqwest::Client::new();
1242 let mut opts = ResolverOptions::default();
1243 opts.plc_source = PlcSource::slingshot_default();
1244 let resolver = JacquardResolver::new(http, opts);
1245 #[cfg(feature = "dns")]
1246 let resolver = resolver.with_system_dns();
1247 #[cfg(feature = "cache")]
1248 let resolver = resolver.with_cache();
1249 resolver
1250}
1251
1252#[cfg(test)]
1253mod tests {
1254 use super::*;
1255
1256 #[test]
1257 fn did_web_urls() {
1258 let r = JacquardResolver::new(reqwest::Client::new(), ResolverOptions::default());
1259 assert_eq!(
1260 r.test_did_web_url_raw("did:web:example.com"),
1261 "https://example.com/.well-known/did.json"
1262 );
1263 assert_eq!(
1264 r.test_did_web_url_raw("did:web:example.com:user:alice"),
1265 "https://example.com/user/alice/did.json"
1266 );
1267 }
1268
1269 #[test]
1270 fn slingshot_mini_doc_url_build() {
1271 let r = JacquardResolver::new(reqwest::Client::new(), ResolverOptions::default());
1272 let base_uri = Uri::parse("https://slingshot.microcosm.blue")
1273 .unwrap()
1274 .to_owned();
1275 let url = r
1276 .slingshot_mini_doc_url(&base_uri, "bad-example.com")
1277 .unwrap();
1278 assert_eq!(
1279 url.as_str(),
1280 "https://slingshot.microcosm.blue/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=bad-example.com"
1281 );
1282 }
1283
1284 #[test]
1285 fn slingshot_mini_doc_parse_success() {
1286 let buf = Bytes::from_static(
1287 br#"{
1288 "did": "did:plc:hdhoaan3xa3jiuq4fg4mefid",
1289 "handle": "bad-example.com",
1290 "pds": "https://porcini.us-east.host.bsky.network",
1291 "signing_key": "zQ3shpq1g134o7HGDb86CtQFxnHqzx5pZWknrVX2Waum3fF6j"
1292}"#,
1293 );
1294 let resp = MiniDocResponse {
1295 buffer: buf,
1296 status: StatusCode::OK,
1297 identifier: SmolStr::new_static("bad-example.com"),
1298 };
1299 let doc = resp.parse().expect("parse mini-doc");
1300 assert_eq!(doc.did.as_str(), "did:plc:hdhoaan3xa3jiuq4fg4mefid");
1301 assert_eq!(doc.handle.as_str(), "bad-example.com");
1302 assert_eq!(
1303 doc.pds.as_ref(),
1304 "https://porcini.us-east.host.bsky.network"
1305 );
1306 assert!(doc.signing_key.as_ref().starts_with('z'));
1307 }
1308
1309 #[test]
1310 fn slingshot_mini_doc_parse_error_status() {
1311 let buf = Bytes::from_static(
1312 br#"{
1313 "error": "RecordNotFound",
1314 "message": "This record was deleted"
1315}"#,
1316 );
1317 let resp = MiniDocResponse {
1318 buffer: buf,
1319 status: StatusCode::BAD_REQUEST,
1320 identifier: SmolStr::new_static("bad-example.com"),
1321 };
1322 match resp.parse() {
1323 Err(e) => match e.kind() {
1324 resolver::IdentityErrorKind::HttpStatus(s) => {
1325 assert_eq!(*s, StatusCode::BAD_REQUEST)
1326 }
1327 _ => panic!("unexpected error kind: {:?}", e),
1328 },
1329 other => panic!("unexpected: {:?}", other),
1330 }
1331 }
1332
1333 #[test]
1334 fn did_web_resolution_basic() {
1335 // AC6.1: `did:web:example.com` resolves to `https://example.com/.well-known/did.json`
1336 let r = JacquardResolver::new(reqwest::Client::new(), ResolverOptions::default());
1337 assert_eq!(
1338 r.test_did_web_url_raw("did:web:example.com"),
1339 "https://example.com/.well-known/did.json"
1340 );
1341 }
1342
1343 #[test]
1344 fn did_web_resolution_with_path() {
1345 // AC6.1: `did:web:example.com:path:to` resolves to `https://example.com/path/to/did.json`
1346 // with correct percent-encoding
1347 let r = JacquardResolver::new(reqwest::Client::new(), ResolverOptions::default());
1348 assert_eq!(
1349 r.test_did_web_url_raw("did:web:example.com:path:to"),
1350 "https://example.com/path/to/did.json"
1351 );
1352 }
1353
1354 #[test]
1355 fn pds_endpoint_parsing_returns_uri() {
1356 // AC6.3: PDS endpoint parsing returns `Uri<String>` — verified by type system.
1357 // This test ensures that did_doc.pds_endpoint() returns the correct type.
1358 let buf = Bytes::from_static(
1359 b"{
1360 \"id\": \"did:plc:example\",
1361 \"service\": [
1362 {
1363 \"id\": \"#pds\",
1364 \"type\": \"AtprotoPersonalDataServer\",
1365 \"serviceEndpoint\": \"https://pds.example.com\"
1366 }
1367 ]
1368}",
1369 );
1370 let resp = resolver::DidDocResponse {
1371 buffer: buf,
1372 status: StatusCode::OK,
1373 requested: None,
1374 };
1375 let doc = resp.parse().expect("parse document");
1376 let pds = doc.pds_endpoint();
1377
1378 // Verify it returns Some(Uri<String>)
1379 assert!(pds.is_some());
1380 let pds_uri = pds.unwrap();
1381 assert_eq!(pds_uri.as_str(), "https://pds.example.com");
1382 }
1383}