A better Rust ATProto crate
103
fork

Configure Feed

Select the types of activity you want to include in your feed.

at pretty-codegen 1362 lines 51 kB view raw
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}