1#![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#[cfg(feature = "cache")]
116mod cache_impl {
117 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(feature = "cache")]
203#[derive(Clone, Debug)]
204pub struct CacheConfig {
205 pub handle_to_did_capacity: u64,
207 pub handle_to_did_ttl: Duration,
209 pub did_to_doc_capacity: u64,
211 pub did_to_doc_ttl: Duration,
213 pub authority_to_did_capacity: u64,
215 pub authority_to_did_ttl: Duration,
217 pub nsid_to_schema_capacity: u64,
219 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 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 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 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 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#[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#[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 pub fn new(http: reqwest::Client, opts: ResolverOptions) -> Self {
333 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 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 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 pub fn with_plc_source(mut self, source: PlcSource) -> Self {
378 self.opts.plc_source = source;
379 self
380 }
381
382 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 pub fn with_validate_doc_id(mut self, enable: bool) -> Self {
390 self.opts.validate_doc_id = enable;
391 self
392 }
393
394 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 pub fn with_cache(mut self) -> Self {
403 self.caches = Some(ResolverCaches::default());
404 self
405 }
406
407 #[cfg(feature = "cache")]
408 pub fn with_cache_config(mut self, config: CacheConfig) -> Self {
410 self.caches = Some(ResolverCaches::new(&config));
411 self
412 }
413
414 fn did_web_url(&self, did: &Did<'_>) -> resolver::Result<Url> {
419 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 let mut segments = url
435 .path_segments_mut()
436 .map_err(|_| IdentityError::url(ParseError::SetHostOnCannotBeABaseUrl))?;
437 for seg in path {
438 let decoded = percent_decode_str(seg).decode_utf8_lossy();
440 segments.push(&decoded);
441 }
442 segments.push("did.json");
443 }
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 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 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 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 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 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 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 #[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 if let Ok(did) = self.resolve_handle_via_pds(handle).await {
688 resolved_did = Some(did);
689 break 'outer;
690 }
691 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 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 if let Some(did) = resolved_did {
750 #[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 #[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 #[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 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 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 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 if let Some(doc_resp) = resolved_doc {
841 #[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 #[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 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 #[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 #[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#[derive(Debug, Clone, PartialEq, Eq)]
962pub enum IdentityWarning {
963 HandleAliasMismatch {
965 #[allow(missing_docs)]
966 expected: Handle<'static>,
967 },
968}
969
970impl JacquardResolver {
971 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 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 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 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 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#[derive(Clone)]
1095pub struct MiniDocResponse {
1096 buffer: Bytes,
1097 status: StatusCode,
1098}
1099
1100impl MiniDocResponse {
1101 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
1111pub type PublicResolver = JacquardResolver;
1113
1114impl Default for PublicResolver {
1115 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
1137pub 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}