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