jacquard_identity/
lib.rs

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// use crate::CowStr; // not currently needed directly here
69
70#![cfg_attr(target_arch = "wasm32", allow(unused))]
71pub mod lexicon_resolver;
72pub mod resolver;
73
74use crate::resolver::{
75    DidDocResponse, DidStep, HandleStep, IdentityError, IdentityResolver, MiniDoc, PlcSource,
76    ResolverOptions,
77};
78use bytes::Bytes;
79use jacquard_api::com_atproto::identity::resolve_did;
80use jacquard_api::com_atproto::identity::resolve_handle::ResolveHandle;
81#[cfg(feature = "streaming")]
82use jacquard_common::ByteStream;
83use jacquard_common::http_client::HttpClient;
84use jacquard_common::smol_str::ToSmolStr;
85use jacquard_common::types::did::Did;
86use jacquard_common::types::did_doc::DidDocument;
87use jacquard_common::types::ident::AtIdentifier;
88use jacquard_common::xrpc::XrpcExt;
89use jacquard_common::{IntoStatic, types::string::Handle};
90use percent_encoding::percent_decode_str;
91use reqwest::StatusCode;
92use url::{ParseError, Url};
93
94#[cfg(all(feature = "dns", not(target_family = "wasm")))]
95use {
96    hickory_resolver::{TokioAsyncResolver, config::ResolverConfig},
97    std::sync::Arc,
98};
99
100#[cfg(feature = "cache")]
101use {
102    crate::lexicon_resolver::ResolvedLexiconSchema,
103    jacquard_common::{smol_str::SmolStr, 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    pub handle_to_did: cache_impl::Cache<Handle<'static>, Did<'static>>,
286    pub did_to_doc: cache_impl::Cache<Did<'static>, Arc<DidDocResponse>>,
287    pub authority_to_did: cache_impl::Cache<SmolStr, Did<'static>>,
288    pub nsid_to_schema: cache_impl::Cache<Nsid<'static>, Arc<ResolvedLexiconSchema<'static>>>,
289}
290
291#[cfg(feature = "cache")]
292impl ResolverCaches {
293    pub fn new(config: &CacheConfig) -> Self {
294        Self {
295            handle_to_did: cache_impl::new_cache(
296                config.handle_to_did_capacity,
297                config.handle_to_did_ttl,
298            ),
299            did_to_doc: cache_impl::new_cache(config.did_to_doc_capacity, config.did_to_doc_ttl),
300            authority_to_did: cache_impl::new_cache(
301                config.authority_to_did_capacity,
302                config.authority_to_did_ttl,
303            ),
304            nsid_to_schema: cache_impl::new_cache(
305                config.nsid_to_schema_capacity,
306                config.nsid_to_schema_ttl,
307            ),
308        }
309    }
310}
311
312#[cfg(feature = "cache")]
313impl Default for ResolverCaches {
314    fn default() -> Self {
315        Self::new(&CacheConfig::default())
316    }
317}
318
319/// Default resolver implementation with configurable fallback order.
320#[derive(Clone)]
321pub struct JacquardResolver {
322    http: reqwest::Client,
323    opts: ResolverOptions,
324    #[cfg(feature = "dns")]
325    dns: Option<Arc<TokioAsyncResolver>>,
326    #[cfg(feature = "cache")]
327    caches: Option<ResolverCaches>,
328}
329
330impl JacquardResolver {
331    /// Create a new instance of the default resolver with all options (except DNS) up front
332    pub fn new(http: reqwest::Client, opts: ResolverOptions) -> Self {
333        // #[cfg(feature = "tracing")]
334        // tracing::info!(
335        //     public_fallback = opts.public_fallback_for_handle,
336        //     validate_doc_id = opts.validate_doc_id,
337        //     plc_source = ?opts.plc_source,
338        //     "jacquard resolver created"
339        // );
340
341        Self {
342            http,
343            opts,
344            #[cfg(feature = "dns")]
345            dns: None,
346            #[cfg(feature = "cache")]
347            caches: None,
348        }
349    }
350
351    #[cfg(feature = "dns")]
352    /// Create a new instance of the default resolver with all options, plus default DNS, up front
353    pub fn new_dns(http: reqwest::Client, opts: ResolverOptions) -> Self {
354        Self {
355            http,
356            opts,
357            dns: Some(Arc::new(TokioAsyncResolver::tokio(
358                ResolverConfig::default(),
359                Default::default(),
360            ))),
361            #[cfg(feature = "cache")]
362            caches: None,
363        }
364    }
365
366    #[cfg(feature = "dns")]
367    /// Add default DNS resolution to the resolver
368    pub fn with_system_dns(mut self) -> Self {
369        self.dns = Some(Arc::new(TokioAsyncResolver::tokio(
370            ResolverConfig::default(),
371            Default::default(),
372        )));
373        self
374    }
375
376    /// Set PLC source (PLC directory or Slingshot)
377    pub fn with_plc_source(mut self, source: PlcSource) -> Self {
378        self.opts.plc_source = source;
379        self
380    }
381
382    /// Enable/disable public unauthenticated fallback for resolveHandle
383    pub fn with_public_fallback_for_handle(mut self, enable: bool) -> Self {
384        self.opts.public_fallback_for_handle = enable;
385        self
386    }
387
388    /// Enable/disable doc id validation
389    pub fn with_validate_doc_id(mut self, enable: bool) -> Self {
390        self.opts.validate_doc_id = enable;
391        self
392    }
393
394    /// Set the HTTP request timeout. Pass `None` to disable timeout.
395    pub fn with_request_timeout(mut self, timeout: Option<n0_future::time::Duration>) -> Self {
396        self.opts.request_timeout = timeout;
397        self
398    }
399
400    #[cfg(feature = "cache")]
401    /// Enable caching with default configuration
402    pub fn with_cache(mut self) -> Self {
403        self.caches = Some(ResolverCaches::default());
404        self
405    }
406
407    #[cfg(feature = "cache")]
408    /// Enable caching with custom configuration
409    pub fn with_cache_config(mut self, config: CacheConfig) -> Self {
410        self.caches = Some(ResolverCaches::new(&config));
411        self
412    }
413
414    /// Construct the well-known HTTPS URL for a `did:web` DID.
415    ///
416    /// - `did:web:example.com` → `https://example.com/.well-known/did.json`
417    /// - `did:web:example.com:user:alice` → `https://example.com/user/alice/did.json`
418    fn did_web_url(&self, did: &Did<'_>) -> resolver::Result<Url> {
419        // did:web:example.com[:path:segments]
420        let s = did.as_str();
421        let rest = s
422            .strip_prefix("did:web:")
423            .ok_or_else(|| IdentityError::unsupported_did_method(s))?;
424        let mut parts = rest.split(':');
425        let host = parts
426            .next()
427            .ok_or_else(|| IdentityError::unsupported_did_method(s))?;
428        let mut url = Url::parse(&format!("https://{host}/"))?;
429        let path: Vec<&str> = parts.collect();
430        if path.is_empty() {
431            url.set_path(".well-known/did.json");
432        } else {
433            // Append path segments and did.json
434            let mut segments = url
435                .path_segments_mut()
436                .map_err(|_| IdentityError::url(ParseError::SetHostOnCannotBeABaseUrl))?;
437            for seg in path {
438                // Minimally percent-decode each segment per spec guidance
439                let decoded = percent_decode_str(seg).decode_utf8_lossy();
440                segments.push(&decoded);
441            }
442            segments.push("did.json");
443            // drop segments
444        }
445        Ok(url)
446    }
447
448    #[cfg(test)]
449    fn test_did_web_url_raw(&self, s: &str) -> String {
450        let did = Did::new(s).unwrap();
451        self.did_web_url(&did).unwrap().to_string()
452    }
453
454    async fn get_json_bytes(&self, url: Url) -> resolver::Result<(Bytes, StatusCode)> {
455        let resp = self.http.get(url).send().await?;
456        let status = resp.status();
457        let buf = resp.bytes().await?;
458        Ok((buf, status))
459    }
460
461    async fn get_text(&self, url: Url) -> resolver::Result<String> {
462        let u = url.to_smolstr();
463        let resp = self.http.get(url).send().await?;
464        if resp.status() == StatusCode::OK {
465            Ok(resp.text().await?)
466        } else {
467            Err(IdentityError::transport(
468                u,
469                resp.error_for_status().unwrap_err(),
470            ))
471        }
472    }
473
474    #[cfg(feature = "dns")]
475    async fn dns_txt(&self, name: &str) -> resolver::Result<Vec<String>> {
476        let Some(dns) = &self.dns else {
477            return Ok(vec![]);
478        };
479        let fqdn = format!("_atproto.{name}.");
480        let response = dns.txt_lookup(fqdn).await?;
481        let mut out = Vec::new();
482        for txt in response.iter() {
483            for data in txt.txt_data().iter() {
484                out.push(String::from_utf8_lossy(data).to_string());
485            }
486        }
487        Ok(out)
488    }
489
490    /// Query DNS via DNS-over-HTTPS using Cloudflare
491    pub async fn query_dns_doh(
492        &self,
493        name: &str,
494        record_type: &str,
495    ) -> resolver::Result<serde_json::Value> {
496        #[cfg(feature = "tracing")]
497        tracing::trace!("querying DNS via DoH: {} ({})", name, record_type);
498
499        let mut url = Url::parse("https://cloudflare-dns.com/dns-query")
500            .expect("hardcoded URL should be valid");
501
502        url.query_pairs_mut()
503            .append_pair("name", name)
504            .append_pair("type", record_type);
505
506        let response = self
507            .http
508            .get(url)
509            .header("Accept", "application/dns-json")
510            .send()
511            .await?;
512
513        let status = response.status();
514        if !status.is_success() {
515            return Err(IdentityError::http_status(status));
516        }
517
518        let json: serde_json::Value = response.json().await?;
519        Ok(json)
520    }
521
522    #[cfg(not(feature = "dns"))]
523    async fn dns_txt(&self, name: &str) -> resolver::Result<Vec<String>> {
524        let fqdn = format!("_atproto.{name}.");
525        let response = self
526            .query_dns_doh(&fqdn, "TXT")
527            .await
528            .map_err(|e| IdentityError::dns(e))?;
529
530        // Parse DoH JSON response
531        let answers = response
532            .get("Answer")
533            .and_then(|a| a.as_array())
534            .ok_or_else(|| {
535                IdentityError::invalid_well_known().with_context(format!(
536                    "couldn't parse cloudflare DoH answers looking for {name}"
537                ))
538            })?;
539
540        let mut results: Vec<String> = Vec::new();
541        for answer in answers {
542            if let Some(data) = answer.get("data").and_then(|d| d.as_str()) {
543                // TXT records are quoted in DNS responses, strip quotes
544                results.push(data.trim_matches('"').to_string())
545            }
546        }
547        Ok(results)
548    }
549
550    fn parse_atproto_did_body(body: &str) -> resolver::Result<Did<'static>> {
551        let line = body
552            .lines()
553            .find(|l| !l.trim().is_empty())
554            .ok_or_else(|| IdentityError::invalid_well_known())?;
555        let did = Did::new(line.trim()).map_err(|_| IdentityError::invalid_well_known())?;
556        Ok(did.into_static())
557    }
558}
559
560impl JacquardResolver {
561    /// Resolve handle to DID via a PDS XRPC call (stateless, unauth by default)
562    pub async fn resolve_handle_via_pds(
563        &self,
564        handle: &Handle<'_>,
565    ) -> resolver::Result<Did<'static>> {
566        let pds = match &self.opts.pds_fallback {
567            Some(u) => u.clone(),
568            None => return Err(IdentityError::invalid_well_known()),
569        };
570        let req = ResolveHandle::new()
571            .handle(handle.clone().into_static())
572            .build();
573        let resp = self
574            .http
575            .xrpc(pds)
576            .send(&req)
577            .await
578            .map_err(|e| IdentityError::xrpc(e.to_string()))?;
579        let out = resp
580            .parse()
581            .map_err(|e| IdentityError::xrpc(e.to_string()))?;
582        Did::new_owned(out.did.as_str())
583            .map(|d| d.into_static())
584            .map_err(|_| IdentityError::invalid_well_known())
585    }
586
587    /// Fetch DID document via PDS resolveDid (returns owned DidDocument)
588    pub async fn fetch_did_doc_via_pds_owned(
589        &self,
590        did: &Did<'_>,
591    ) -> resolver::Result<DidDocument<'static>> {
592        let pds = match &self.opts.pds_fallback {
593            Some(u) => u.clone(),
594            None => return Err(IdentityError::invalid_well_known()),
595        };
596        let req = resolve_did::ResolveDid::new().did(did.clone()).build();
597        let resp = self
598            .http
599            .xrpc(pds)
600            .send(&req)
601            .await
602            .map_err(|e| IdentityError::xrpc(e.to_string()))?;
603        let out = resp
604            .parse()
605            .map_err(|e| IdentityError::xrpc(e.to_string()))?;
606        let doc_json = serde_json::to_value(&out.did_doc)?;
607        let s = serde_json::to_string(&doc_json)?;
608        let doc_borrowed: DidDocument<'_> = serde_json::from_str(&s)?;
609        Ok(doc_borrowed.into_static())
610    }
611
612    /// Fetch a minimal DID document via a Slingshot mini-doc endpoint, if your PlcSource uses Slingshot.
613    /// Returns the raw response wrapper for borrowed parsing and validation.
614    pub async fn fetch_mini_doc_via_slingshot(
615        &self,
616        did: &Did<'_>,
617    ) -> resolver::Result<DidDocResponse> {
618        let base = match &self.opts.plc_source {
619            PlcSource::Slingshot { base } => base.clone(),
620            _ => {
621                return Err(IdentityError::unsupported_did_method(
622                    "mini-doc requires Slingshot source",
623                ));
624            }
625        };
626        let mut url = base;
627        url.set_path("/xrpc/com.bad-example.identity.resolveMiniDoc");
628        if let Ok(qs) = serde_html_form::to_string(
629            &resolve_did::ResolveDid::new()
630                .did(did.clone().into_static())
631                .build(),
632        ) {
633            url.set_query(Some(&qs));
634        }
635        let (buf, status) = self.get_json_bytes(url).await?;
636        Ok(DidDocResponse {
637            buffer: buf,
638            status,
639            requested: Some(did.clone().into_static()),
640        })
641    }
642}
643
644impl IdentityResolver for JacquardResolver {
645    fn options(&self) -> &ResolverOptions {
646        &self.opts
647    }
648    #[cfg_attr(feature = "tracing", tracing::instrument(level = "debug", skip(self), fields(handle = %handle)))]
649    async fn resolve_handle(&self, handle: &Handle<'_>) -> resolver::Result<Did<'static>> {
650        // Try cache first
651        #[cfg(feature = "cache")]
652        if let Some(caches) = &self.caches {
653            let key = handle.clone().into_static();
654            if let Some(did) = cache_impl::get(&caches.handle_to_did, &key) {
655                return Ok(did);
656            }
657        }
658
659        let host = handle.as_str();
660        let mut resolved_did: Option<Did<'static>> = None;
661
662        'outer: for step in &self.opts.handle_order {
663            match step {
664                HandleStep::DnsTxt => {
665                    if let Ok(txts) = self.dns_txt(host).await {
666                        for txt in txts {
667                            if let Some(did_str) = txt.strip_prefix("did=") {
668                                if let Ok(did) = Did::new(did_str) {
669                                    resolved_did = Some(did.into_static());
670                                    break 'outer;
671                                }
672                            }
673                        }
674                    }
675                }
676                HandleStep::HttpsWellKnown => {
677                    let url = Url::parse(&format!("https://{host}/.well-known/atproto-did"))?;
678                    if let Ok(text) = self.get_text(url).await {
679                        if let Ok(did) = Self::parse_atproto_did_body(&text) {
680                            resolved_did = Some(did);
681                            break 'outer;
682                        }
683                    }
684                }
685                HandleStep::PdsResolveHandle => {
686                    // Prefer PDS XRPC via stateless client
687                    if let Ok(did) = self.resolve_handle_via_pds(handle).await {
688                        resolved_did = Some(did);
689                        break 'outer;
690                    }
691                    // Public unauth fallback
692                    if self.opts.public_fallback_for_handle {
693                        if let Ok(mut url) = Url::parse("https://public.api.bsky.app") {
694                            url.set_path("/xrpc/com.atproto.identity.resolveHandle");
695                            if let Ok(qs) = serde_html_form::to_string(
696                                &ResolveHandle::new().handle((*handle).clone()).build(),
697                            ) {
698                                url.set_query(Some(&qs));
699                            } else {
700                                continue;
701                            }
702                            if let Ok((buf, status)) = self.get_json_bytes(url).await {
703                                if status.is_success() {
704                                    if let Ok(val) =
705                                        serde_json::from_slice::<serde_json::Value>(&buf)
706                                    {
707                                        if let Some(did_str) =
708                                            val.get("did").and_then(|v| v.as_str())
709                                        {
710                                            if let Ok(did) = Did::new_owned(did_str) {
711                                                resolved_did = Some(did.into_static());
712                                                break 'outer;
713                                            }
714                                        }
715                                    }
716                                }
717                            }
718                        }
719                    }
720                    // Non-auth path: if PlcSource is Slingshot, use its resolveHandle endpoint.
721                    if let PlcSource::Slingshot { base } = &self.opts.plc_source {
722                        let mut url = base.clone();
723                        url.set_path("/xrpc/com.atproto.identity.resolveHandle");
724                        if let Ok(qs) = serde_html_form::to_string(
725                            &ResolveHandle::new().handle((*handle).clone()).build(),
726                        ) {
727                            url.set_query(Some(&qs));
728                        } else {
729                            continue;
730                        }
731                        if let Ok((buf, status)) = self.get_json_bytes(url).await {
732                            if status.is_success() {
733                                if let Ok(val) = serde_json::from_slice::<serde_json::Value>(&buf) {
734                                    if let Some(did_str) = val.get("did").and_then(|v| v.as_str()) {
735                                        if let Ok(did) = Did::new_owned(did_str) {
736                                            resolved_did = Some(did.into_static());
737                                            break 'outer;
738                                        }
739                                    }
740                                }
741                            }
742                        }
743                    }
744                }
745            }
746        }
747
748        // Handle result
749        if let Some(did) = resolved_did {
750            // Cache successful resolution
751            #[cfg(feature = "cache")]
752            if let Some(caches) = &self.caches {
753                cache_impl::insert(
754                    &caches.handle_to_did,
755                    handle.clone().into_static(),
756                    did.clone(),
757                );
758            }
759            Ok(did)
760        } else {
761            // Invalidate on error
762            #[cfg(feature = "cache")]
763            self.invalidate_handle_chain(handle).await;
764            Err(IdentityError::invalid_well_known())
765        }
766    }
767
768    #[cfg_attr(feature = "tracing", tracing::instrument(level = "debug", skip(self), fields(did = %did)))]
769    async fn resolve_did_doc(&self, did: &Did<'_>) -> resolver::Result<DidDocResponse> {
770        // Try cache first
771        #[cfg(feature = "cache")]
772        if let Some(caches) = &self.caches {
773            let key = did.clone().into_static();
774            if let Some(doc_resp) = cache_impl::get(&caches.did_to_doc, &key) {
775                return Ok((*doc_resp).clone());
776            }
777        }
778
779        let s = did.as_str();
780        let mut resolved_doc: Option<DidDocResponse> = None;
781
782        'outer: for step in &self.opts.did_order {
783            match step {
784                DidStep::DidWebHttps if s.starts_with("did:web:") => {
785                    let url = self.did_web_url(did)?;
786                    if let Ok((buf, status)) = self.get_json_bytes(url).await {
787                        resolved_doc = Some(DidDocResponse {
788                            buffer: buf,
789                            status,
790                            requested: Some(did.clone().into_static()),
791                        });
792                        break 'outer;
793                    }
794                }
795                DidStep::PlcHttp if s.starts_with("did:plc:") => {
796                    let url = match &self.opts.plc_source {
797                        PlcSource::PlcDirectory { base } => {
798                            // this is odd, the join screws up with the plc directory but NOT slingshot
799                            Url::parse(&format!("{}{}", base, did.as_str())).expect("Invalid URL")
800                        }
801                        PlcSource::Slingshot { base } => base.join(did.as_str())?,
802                    };
803                    if let Ok((buf, status)) = self.get_json_bytes(url).await {
804                        resolved_doc = Some(DidDocResponse {
805                            buffer: buf,
806                            status,
807                            requested: Some(did.clone().into_static()),
808                        });
809                        break 'outer;
810                    }
811                }
812                DidStep::PdsResolveDid => {
813                    // Try PDS XRPC for full DID doc
814                    if let Ok(doc) = self.fetch_did_doc_via_pds_owned(did).await {
815                        let buf = serde_json::to_vec(&doc).unwrap_or_default();
816                        resolved_doc = Some(DidDocResponse {
817                            buffer: Bytes::from(buf),
818                            status: StatusCode::OK,
819                            requested: Some(did.clone().into_static()),
820                        });
821                        break 'outer;
822                    }
823                    // Fallback: if Slingshot configured, return mini-doc response (partial doc)
824                    if let PlcSource::Slingshot { base } = &self.opts.plc_source {
825                        let url = self.slingshot_mini_doc_url(base, did.as_str())?;
826                        let (buf, status) = self.get_json_bytes(url).await?;
827                        resolved_doc = Some(DidDocResponse {
828                            buffer: buf,
829                            status,
830                            requested: Some(did.clone().into_static()),
831                        });
832                        break 'outer;
833                    }
834                }
835                _ => {}
836            }
837        }
838
839        // Handle result
840        if let Some(doc_resp) = resolved_doc {
841            // Cache successful resolution
842            #[cfg(feature = "cache")]
843            if let Some(caches) = &self.caches {
844                cache_impl::insert(
845                    &caches.did_to_doc,
846                    did.clone().into_static(),
847                    Arc::new(doc_resp.clone()),
848                );
849            }
850            Ok(doc_resp)
851        } else {
852            // Invalidate on error
853            #[cfg(feature = "cache")]
854            self.invalidate_did_chain(did).await;
855            Err(IdentityError::unsupported_did_method(s))
856        }
857    }
858}
859
860impl HttpClient for JacquardResolver {
861    type Error = IdentityError;
862
863    async fn send_http(
864        &self,
865        request: http::Request<Vec<u8>>,
866    ) -> core::result::Result<http::Response<Vec<u8>>, Self::Error> {
867        let u = request.uri().clone();
868        match self.opts.request_timeout {
869            Some(duration) => n0_future::time::timeout(duration, self.http.send_http(request))
870                .await
871                .map_err(|_| IdentityError::timeout())?
872                .map_err(|e| IdentityError::transport(u.to_smolstr(), e)),
873            None => self
874                .http
875                .send_http(request)
876                .await
877                .map_err(|e| IdentityError::transport(u.to_smolstr(), e)),
878        }
879    }
880}
881
882#[cfg(feature = "streaming")]
883impl jacquard_common::http_client::HttpClientExt for JacquardResolver {
884    /// Send HTTP request and return streaming response
885    async fn send_http_streaming(
886        &self,
887        request: http::Request<Vec<u8>>,
888    ) -> Result<http::Response<ByteStream>, Self::Error> {
889        let u = request.uri().clone();
890        match self.opts.request_timeout {
891            Some(duration) => {
892                n0_future::time::timeout(duration, self.http.send_http_streaming(request))
893                    .await
894                    .map_err(|_| IdentityError::timeout())?
895                    .map_err(|e| IdentityError::transport(u.to_smolstr(), e))
896            }
897            None => self
898                .http
899                .send_http_streaming(request)
900                .await
901                .map_err(|e| IdentityError::transport(u.to_smolstr(), e)),
902        }
903    }
904
905    /// Send HTTP request with streaming body and receive streaming response
906    #[cfg(not(target_arch = "wasm32"))]
907    async fn send_http_bidirectional<S>(
908        &self,
909        parts: http::request::Parts,
910        body: S,
911    ) -> Result<http::Response<ByteStream>, Self::Error>
912    where
913        S: n0_future::Stream<Item = Result<bytes::Bytes, jacquard_common::StreamError>>
914            + Send
915            + 'static,
916    {
917        let u = parts.uri.clone();
918        match self.opts.request_timeout {
919            Some(duration) => {
920                n0_future::time::timeout(duration, self.http.send_http_bidirectional(parts, body))
921                    .await
922                    .map_err(|_| IdentityError::timeout())?
923                    .map_err(|e| IdentityError::transport(u.to_smolstr(), e))
924            }
925            None => self
926                .http
927                .send_http_bidirectional(parts, body)
928                .await
929                .map_err(|e| IdentityError::transport(u.to_smolstr(), e)),
930        }
931    }
932
933    /// Send HTTP request with streaming body and receive streaming response (WASM)
934    #[cfg(target_arch = "wasm32")]
935    async fn send_http_bidirectional<S>(
936        &self,
937        parts: http::request::Parts,
938        body: S,
939    ) -> Result<http::Response<ByteStream>, Self::Error>
940    where
941        S: n0_future::Stream<Item = Result<bytes::Bytes, jacquard_common::StreamError>> + 'static,
942    {
943        let u = parts.uri.clone();
944        match self.opts.request_timeout {
945            Some(duration) => {
946                n0_future::time::timeout(duration, self.http.send_http_bidirectional(parts, body))
947                    .await
948                    .map_err(|_| IdentityError::timeout())?
949                    .map_err(|e| IdentityError::transport(u.to_smolstr(), e))
950            }
951            None => self
952                .http
953                .send_http_bidirectional(parts, body)
954                .await
955                .map_err(|e| IdentityError::transport(u.to_smolstr(), e)),
956        }
957    }
958}
959
960/// Warnings produced during identity checks that are not fatal
961#[derive(Debug, Clone, PartialEq, Eq)]
962pub enum IdentityWarning {
963    /// The DID doc did not contain the expected handle alias under alsoKnownAs
964    HandleAliasMismatch {
965        #[allow(missing_docs)]
966        expected: Handle<'static>,
967    },
968}
969
970impl JacquardResolver {
971    /// Resolve a handle to its DID, fetch the DID document, and return doc plus any warnings.
972    /// This applies the default equality check on the document id (error with doc if mismatch).
973    pub async fn resolve_handle_and_doc(
974        &self,
975        handle: &Handle<'_>,
976    ) -> resolver::Result<(Did<'static>, DidDocResponse, Vec<IdentityWarning>)> {
977        let did = self.resolve_handle(handle).await?;
978        let resp = self.resolve_did_doc(&did).await?;
979        let resp_for_parse = resp.clone();
980        let doc_borrowed = resp_for_parse.parse()?;
981        if self.opts.validate_doc_id && doc_borrowed.id.as_str() != did.as_str() {
982            return Err(IdentityError::doc_id_mismatch(
983                did.clone().into_static(),
984                doc_borrowed.clone().into_static(),
985            ));
986        }
987        let mut warnings = Vec::new();
988        // Check handle alias presence (soft warning)
989        let has_alias = doc_borrowed
990            .also_known_as
991            .as_ref()
992            .map(|v| {
993                v.iter().any(|s| {
994                    let s = s.strip_prefix("at://").unwrap_or(s);
995                    s == handle.as_str()
996                })
997            })
998            .unwrap_or(false);
999        if !has_alias {
1000            warnings.push(IdentityWarning::HandleAliasMismatch {
1001                expected: handle.clone().into_static(),
1002            });
1003        }
1004        Ok((did, resp, warnings))
1005    }
1006
1007    /// Build Slingshot mini-doc URL for an identifier (handle or DID)
1008    fn slingshot_mini_doc_url(&self, base: &Url, identifier: &str) -> resolver::Result<Url> {
1009        let mut url = base.clone();
1010        url.set_path("/xrpc/com.bad-example.identity.resolveMiniDoc");
1011        url.set_query(Some(&format!(
1012            "identifier={}",
1013            urlencoding::Encoded::new(identifier)
1014        )));
1015        Ok(url)
1016    }
1017
1018    #[cfg(feature = "cache")]
1019    async fn invalidate_handle_chain(&self, handle: &Handle<'_>) {
1020        if let Some(caches) = &self.caches {
1021            let key = handle.clone().into_static();
1022            cache_impl::invalidate(&caches.handle_to_did, &key);
1023        }
1024    }
1025
1026    #[cfg(feature = "cache")]
1027    async fn invalidate_did_chain(&self, did: &Did<'_>) {
1028        if let Some(caches) = &self.caches {
1029            let did_key = did.clone().into_static();
1030            // Get doc before evicting to extract handles
1031            if let Some(doc_resp) = cache_impl::get(&caches.did_to_doc, &did_key) {
1032                let doc_resp_clone = (*doc_resp).clone();
1033                if let Ok(doc) = doc_resp_clone.parse() {
1034                    if let Some(aliases) = &doc.also_known_as {
1035                        for alias in aliases {
1036                            if let Some(handle_str) = alias.as_ref().strip_prefix("at://") {
1037                                if let Ok(handle) = Handle::new(handle_str) {
1038                                    let handle_key = handle.into_static();
1039                                    cache_impl::invalidate(&caches.handle_to_did, &handle_key);
1040                                }
1041                            }
1042                        }
1043                    }
1044                }
1045            }
1046            cache_impl::invalidate(&caches.did_to_doc, &did_key);
1047        }
1048    }
1049
1050    #[cfg(feature = "cache")]
1051    async fn invalidate_authority_chain(&self, authority: &str) {
1052        if let Some(caches) = &self.caches {
1053            let authority = SmolStr::from(authority);
1054            cache_impl::invalidate(&caches.authority_to_did, &authority);
1055        }
1056    }
1057
1058    #[cfg(feature = "cache")]
1059    async fn invalidate_lexicon_chain(&self, nsid: &jacquard_common::types::string::Nsid<'_>) {
1060        if let Some(caches) = &self.caches {
1061            let nsid_key = nsid.clone().into_static();
1062            if let Some(schema) = cache_impl::get(&caches.nsid_to_schema, &nsid_key) {
1063                let authority = SmolStr::from(nsid.domain_authority());
1064                cache_impl::invalidate(&caches.authority_to_did, &authority);
1065                self.invalidate_did_chain(&schema.repo).await;
1066            }
1067            cache_impl::invalidate(&caches.nsid_to_schema, &nsid_key);
1068        }
1069    }
1070
1071    /// Fetch a minimal DID document via Slingshot's mini-doc endpoint using a generic at-identifier
1072    pub async fn fetch_mini_doc_via_slingshot_identifier(
1073        &self,
1074        identifier: &AtIdentifier<'_>,
1075    ) -> resolver::Result<MiniDocResponse> {
1076        let base = match &self.opts.plc_source {
1077            PlcSource::Slingshot { base } => base.clone(),
1078            _ => {
1079                return Err(IdentityError::unsupported_did_method(
1080                    "mini-doc requires Slingshot source",
1081                ));
1082            }
1083        };
1084        let url = self.slingshot_mini_doc_url(&base, identifier.as_str())?;
1085        let (buf, status) = self.get_json_bytes(url).await?;
1086        Ok(MiniDocResponse {
1087            buffer: buf,
1088            status,
1089        })
1090    }
1091}
1092
1093/// Slingshot mini-doc JSON response wrapper
1094#[derive(Clone)]
1095pub struct MiniDocResponse {
1096    buffer: Bytes,
1097    status: StatusCode,
1098}
1099
1100impl MiniDocResponse {
1101    /// Parse borrowed MiniDoc
1102    pub fn parse<'b>(&'b self) -> resolver::Result<MiniDoc<'b>> {
1103        if self.status.is_success() {
1104            serde_json::from_slice::<MiniDoc<'b>>(&self.buffer).map_err(IdentityError::from)
1105        } else {
1106            Err(IdentityError::http_status(self.status))
1107        }
1108    }
1109}
1110
1111/// Resolver specialized for unauthenticated/public flows using reqwest and stateless XRPC
1112pub type PublicResolver = JacquardResolver;
1113
1114impl Default for PublicResolver {
1115    /// Build a resolver with:
1116    /// - reqwest HTTP client
1117    /// - Public fallbacks enabled for handle resolution
1118    /// - default options (DNS enabled if compiled, public fallback for handles enabled)
1119    ///
1120    /// Example
1121    /// ```ignore
1122    /// use jacquard::identity::resolver::PublicResolver;
1123    /// let resolver = PublicResolver::default();
1124    /// ```
1125    fn default() -> Self {
1126        let http = reqwest::Client::new();
1127        let opts = ResolverOptions::default();
1128        let resolver = JacquardResolver::new(http, opts);
1129        #[cfg(feature = "dns")]
1130        let resolver = resolver.with_system_dns();
1131        #[cfg(feature = "cache")]
1132        let resolver = resolver.with_cache();
1133        resolver
1134    }
1135}
1136
1137/// Build a resolver configured to use Slingshot (`https://slingshot.microcosm.blue`) for PLC and
1138/// mini-doc fallbacks, unauthenticated by default.
1139pub fn slingshot_resolver_default() -> PublicResolver {
1140    let http = reqwest::Client::new();
1141    let mut opts = ResolverOptions::default();
1142    opts.plc_source = PlcSource::slingshot_default();
1143    let resolver = JacquardResolver::new(http, opts);
1144    #[cfg(feature = "dns")]
1145    let resolver = resolver.with_system_dns();
1146    #[cfg(feature = "cache")]
1147    let resolver = resolver.with_cache();
1148    resolver
1149}
1150
1151#[cfg(test)]
1152mod tests {
1153    use super::*;
1154
1155    #[test]
1156    fn did_web_urls() {
1157        let r = JacquardResolver::new(reqwest::Client::new(), ResolverOptions::default());
1158        assert_eq!(
1159            r.test_did_web_url_raw("did:web:example.com"),
1160            "https://example.com/.well-known/did.json"
1161        );
1162        assert_eq!(
1163            r.test_did_web_url_raw("did:web:example.com:user:alice"),
1164            "https://example.com/user/alice/did.json"
1165        );
1166    }
1167
1168    #[test]
1169    fn slingshot_mini_doc_url_build() {
1170        let r = JacquardResolver::new(reqwest::Client::new(), ResolverOptions::default());
1171        let base = Url::parse("https://slingshot.microcosm.blue").unwrap();
1172        let url = r.slingshot_mini_doc_url(&base, "bad-example.com").unwrap();
1173        assert_eq!(
1174            url.as_str(),
1175            "https://slingshot.microcosm.blue/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=bad-example.com"
1176        );
1177    }
1178
1179    #[test]
1180    fn slingshot_mini_doc_parse_success() {
1181        let buf = Bytes::from_static(
1182            br#"{
1183  "did": "did:plc:hdhoaan3xa3jiuq4fg4mefid",
1184  "handle": "bad-example.com",
1185  "pds": "https://porcini.us-east.host.bsky.network",
1186  "signing_key": "zQ3shpq1g134o7HGDb86CtQFxnHqzx5pZWknrVX2Waum3fF6j"
1187}"#,
1188        );
1189        let resp = MiniDocResponse {
1190            buffer: buf,
1191            status: StatusCode::OK,
1192        };
1193        let doc = resp.parse().expect("parse mini-doc");
1194        assert_eq!(doc.did.as_str(), "did:plc:hdhoaan3xa3jiuq4fg4mefid");
1195        assert_eq!(doc.handle.as_str(), "bad-example.com");
1196        assert_eq!(
1197            doc.pds.as_ref(),
1198            "https://porcini.us-east.host.bsky.network"
1199        );
1200        assert!(doc.signing_key.as_ref().starts_with('z'));
1201    }
1202
1203    #[test]
1204    fn slingshot_mini_doc_parse_error_status() {
1205        let buf = Bytes::from_static(
1206            br#"{
1207  "error": "RecordNotFound",
1208  "message": "This record was deleted"
1209}"#,
1210        );
1211        let resp = MiniDocResponse {
1212            buffer: buf,
1213            status: StatusCode::BAD_REQUEST,
1214        };
1215        match resp.parse() {
1216            Err(e) => match e.kind() {
1217                resolver::IdentityErrorKind::HttpStatus(s) => {
1218                    assert_eq!(*s, StatusCode::BAD_REQUEST)
1219                }
1220                _ => panic!("unexpected error kind: {:?}", e),
1221            },
1222            other => panic!("unexpected: {:?}", other),
1223        }
1224    }
1225}