A better Rust ATProto crate
1

Configure Feed

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

at main 53 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, 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}