1//! Identity resolution utilities: DID and handle resolution, DID document fetch,
2//! and helpers for PDS endpoint discovery. See `identity::resolver` for details.
3//! Identity resolution: handle → DID and DID → document, with smart fallbacks.
4//!
5//! Fallback order (default):
6//! - Handle → DID: DNS TXT (if `dns` feature) → HTTPS well-known → PDS XRPC
7//! `resolveHandle` (when `pds_fallback` is configured) → public API fallback → Slingshot `resolveHandle` (if configured).
8//! - DID → Doc: did:web well-known → PLC/Slingshot HTTP → PDS XRPC `resolveDid` (when configured),
9//! then Slingshot mini‑doc (partial) if configured.
10//!
11//! Parsing returns a `DidDocResponse` so callers can borrow from the response buffer
12//! and optionally validate the document `id` against the requested DID.
13
14// use crate::CowStr; // not currently needed directly here
15pub mod resolver;
16
17use crate::resolver::{
18 DidDocResponse, DidStep, HandleStep, IdentityError, IdentityResolver, MiniDoc, PlcSource,
19 ResolverOptions,
20};
21use bytes::Bytes;
22use jacquard_api::com_atproto::identity::resolve_did;
23use jacquard_api::com_atproto::identity::resolve_handle::ResolveHandle;
24use jacquard_common::error::TransportError;
25use jacquard_common::http_client::HttpClient;
26use jacquard_common::types::did::Did;
27use jacquard_common::types::did_doc::DidDocument;
28use jacquard_common::types::ident::AtIdentifier;
29use jacquard_common::xrpc::XrpcExt;
30use jacquard_common::{IntoStatic, types::string::Handle};
31use percent_encoding::percent_decode_str;
32use reqwest::StatusCode;
33use url::{ParseError, Url};
34
35#[cfg(feature = "dns")]
36use hickory_resolver::{TokioAsyncResolver, config::ResolverConfig};
37
38/// Default resolver implementation with configurable fallback order.
39pub struct JacquardResolver {
40 http: reqwest::Client,
41 opts: ResolverOptions,
42 #[cfg(feature = "dns")]
43 dns: Option<TokioAsyncResolver>,
44}
45
46impl JacquardResolver {
47 /// Create a new instance of the default resolver with all options (except DNS) up front
48 pub fn new(http: reqwest::Client, opts: ResolverOptions) -> Self {
49 Self {
50 http,
51 opts,
52 #[cfg(feature = "dns")]
53 dns: None,
54 }
55 }
56
57 #[cfg(feature = "dns")]
58 /// Create a new instance of the default resolver with all options, plus default DNS, up front
59 pub fn new_dns(http: reqwest::Client, opts: ResolverOptions) -> Self {
60 Self {
61 http,
62 opts,
63 dns: Some(TokioAsyncResolver::tokio(
64 ResolverConfig::default(),
65 Default::default(),
66 )),
67 }
68 }
69
70 #[cfg(feature = "dns")]
71 /// Add default DNS resolution to the resolver
72 pub fn with_system_dns(mut self) -> Self {
73 self.dns = Some(TokioAsyncResolver::tokio(
74 ResolverConfig::default(),
75 Default::default(),
76 ));
77 self
78 }
79
80 /// Set PLC source (PLC directory or Slingshot)
81 pub fn with_plc_source(mut self, source: PlcSource) -> Self {
82 self.opts.plc_source = source;
83 self
84 }
85
86 /// Enable/disable public unauthenticated fallback for resolveHandle
87 pub fn with_public_fallback_for_handle(mut self, enable: bool) -> Self {
88 self.opts.public_fallback_for_handle = enable;
89 self
90 }
91
92 /// Enable/disable doc id validation
93 pub fn with_validate_doc_id(mut self, enable: bool) -> Self {
94 self.opts.validate_doc_id = enable;
95 self
96 }
97
98 /// Construct the well-known HTTPS URL for a `did:web` DID.
99 ///
100 /// - `did:web:example.com` → `https://example.com/.well-known/did.json`
101 /// - `did:web:example.com:user:alice` → `https://example.com/user/alice/did.json`
102 fn did_web_url(&self, did: &Did<'_>) -> Result<Url, IdentityError> {
103 // did:web:example.com[:path:segments]
104 let s = did.as_str();
105 let rest = s
106 .strip_prefix("did:web:")
107 .ok_or_else(|| IdentityError::UnsupportedDidMethod(s.to_string()))?;
108 let mut parts = rest.split(':');
109 let host = parts
110 .next()
111 .ok_or_else(|| IdentityError::UnsupportedDidMethod(s.to_string()))?;
112 let mut url = Url::parse(&format!("https://{host}/")).map_err(IdentityError::Url)?;
113 let path: Vec<&str> = parts.collect();
114 if path.is_empty() {
115 url.set_path(".well-known/did.json");
116 } else {
117 // Append path segments and did.json
118 let mut segments = url
119 .path_segments_mut()
120 .map_err(|_| IdentityError::Url(ParseError::SetHostOnCannotBeABaseUrl))?;
121 for seg in path {
122 // Minimally percent-decode each segment per spec guidance
123 let decoded = percent_decode_str(seg).decode_utf8_lossy();
124 segments.push(&decoded);
125 }
126 segments.push("did.json");
127 // drop segments
128 }
129 Ok(url)
130 }
131
132 #[cfg(test)]
133 fn test_did_web_url_raw(&self, s: &str) -> String {
134 let did = Did::new(s).unwrap();
135 self.did_web_url(&did).unwrap().to_string()
136 }
137
138 async fn get_json_bytes(&self, url: Url) -> Result<(Bytes, StatusCode), IdentityError> {
139 let resp = self
140 .http
141 .get(url)
142 .send()
143 .await
144 .map_err(TransportError::from)?;
145 let status = resp.status();
146 let buf = resp.bytes().await.map_err(TransportError::from)?;
147 Ok((buf, status))
148 }
149
150 async fn get_text(&self, url: Url) -> Result<String, IdentityError> {
151 let resp = self
152 .http
153 .get(url)
154 .send()
155 .await
156 .map_err(TransportError::from)?;
157 if resp.status() == StatusCode::OK {
158 Ok(resp.text().await.map_err(TransportError::from)?)
159 } else {
160 Err(IdentityError::Http(
161 resp.error_for_status().unwrap_err().into(),
162 ))
163 }
164 }
165
166 #[cfg(feature = "dns")]
167 async fn dns_txt(&self, name: &str) -> Result<Vec<String>, IdentityError> {
168 let Some(dns) = &self.dns else {
169 return Ok(vec![]);
170 };
171 let fqdn = format!("_atproto.{name}.");
172 let response = dns.txt_lookup(fqdn).await?;
173 let mut out = Vec::new();
174 for txt in response.iter() {
175 for data in txt.txt_data().iter() {
176 out.push(String::from_utf8_lossy(data).to_string());
177 }
178 }
179 Ok(out)
180 }
181
182 fn parse_atproto_did_body(body: &str) -> Result<Did<'static>, IdentityError> {
183 let line = body
184 .lines()
185 .find(|l| !l.trim().is_empty())
186 .ok_or(IdentityError::InvalidWellKnown)?;
187 let did = Did::new(line.trim()).map_err(|_| IdentityError::InvalidWellKnown)?;
188 Ok(did.into_static())
189 }
190}
191
192impl JacquardResolver {
193 /// Resolve handle to DID via a PDS XRPC call (stateless, unauth by default)
194 pub async fn resolve_handle_via_pds(
195 &self,
196 handle: &Handle<'_>,
197 ) -> Result<Did<'static>, IdentityError> {
198 let pds = match &self.opts.pds_fallback {
199 Some(u) => u.clone(),
200 None => return Err(IdentityError::InvalidWellKnown),
201 };
202 let req = ResolveHandle::new()
203 .handle(handle.clone().into_static())
204 .build();
205 let resp = self
206 .http
207 .xrpc(pds)
208 .send(&req)
209 .await
210 .map_err(|e| IdentityError::Xrpc(e.to_string()))?;
211 let out = resp
212 .parse()
213 .map_err(|e| IdentityError::Xrpc(e.to_string()))?;
214 Did::new_owned(out.did.as_str())
215 .map(|d| d.into_static())
216 .map_err(|_| IdentityError::InvalidWellKnown)
217 }
218
219 /// Fetch DID document via PDS resolveDid (returns owned DidDocument)
220 pub async fn fetch_did_doc_via_pds_owned(
221 &self,
222 did: &Did<'_>,
223 ) -> Result<DidDocument<'static>, IdentityError> {
224 let pds = match &self.opts.pds_fallback {
225 Some(u) => u.clone(),
226 None => return Err(IdentityError::InvalidWellKnown),
227 };
228 let req = resolve_did::ResolveDid::new().did(did.clone()).build();
229 let resp = self
230 .http
231 .xrpc(pds)
232 .send(&req)
233 .await
234 .map_err(|e| IdentityError::Xrpc(e.to_string()))?;
235 let out = resp
236 .parse()
237 .map_err(|e| IdentityError::Xrpc(e.to_string()))?;
238 let doc_json = serde_json::to_value(&out.did_doc)?;
239 let s = serde_json::to_string(&doc_json)?;
240 let doc_borrowed: DidDocument<'_> = serde_json::from_str(&s)?;
241 Ok(doc_borrowed.into_static())
242 }
243
244 /// Fetch a minimal DID document via a Slingshot mini-doc endpoint, if your PlcSource uses Slingshot.
245 /// Returns the raw response wrapper for borrowed parsing and validation.
246 pub async fn fetch_mini_doc_via_slingshot(
247 &self,
248 did: &Did<'_>,
249 ) -> Result<DidDocResponse, IdentityError> {
250 let base = match &self.opts.plc_source {
251 PlcSource::Slingshot { base } => base.clone(),
252 _ => {
253 return Err(IdentityError::UnsupportedDidMethod(
254 "mini-doc requires Slingshot source".into(),
255 ));
256 }
257 };
258 let mut url = base;
259 url.set_path("/xrpc/com.bad-example.identity.resolveMiniDoc");
260 if let Ok(qs) = serde_html_form::to_string(
261 &resolve_did::ResolveDid::new()
262 .did(did.clone().into_static())
263 .build(),
264 ) {
265 url.set_query(Some(&qs));
266 }
267 let (buf, status) = self.get_json_bytes(url).await?;
268 Ok(DidDocResponse {
269 buffer: buf,
270 status,
271 requested: Some(did.clone().into_static()),
272 })
273 }
274}
275
276impl IdentityResolver for JacquardResolver {
277 fn options(&self) -> &ResolverOptions {
278 &self.opts
279 }
280 async fn resolve_handle(&self, handle: &Handle<'_>) -> Result<Did<'static>, IdentityError> {
281 let host = handle.as_str();
282 for step in &self.opts.handle_order {
283 match step {
284 HandleStep::DnsTxt => {
285 #[cfg(feature = "dns")]
286 {
287 if let Ok(txts) = self.dns_txt(host).await {
288 for txt in txts {
289 if let Some(did_str) = txt.strip_prefix("did=") {
290 if let Ok(did) = Did::new(did_str) {
291 return Ok(did.into_static());
292 }
293 }
294 }
295 }
296 }
297 }
298 HandleStep::HttpsWellKnown => {
299 let url = Url::parse(&format!("https://{host}/.well-known/atproto-did"))?;
300 if let Ok(text) = self.get_text(url).await {
301 if let Ok(did) = Self::parse_atproto_did_body(&text) {
302 return Ok(did);
303 }
304 }
305 }
306 HandleStep::PdsResolveHandle => {
307 // Prefer PDS XRPC via stateless client
308 if let Ok(did) = self.resolve_handle_via_pds(handle).await {
309 return Ok(did);
310 }
311 // Public unauth fallback
312 if self.opts.public_fallback_for_handle {
313 if let Ok(mut url) = Url::parse("https://public.api.bsky.app") {
314 url.set_path("/xrpc/com.atproto.identity.resolveHandle");
315 if let Ok(qs) = serde_html_form::to_string(
316 &ResolveHandle::new().handle((*handle).clone()).build(),
317 ) {
318 url.set_query(Some(&qs));
319 } else {
320 continue;
321 }
322 if let Ok((buf, status)) = self.get_json_bytes(url).await {
323 if status.is_success() {
324 if let Ok(val) =
325 serde_json::from_slice::<serde_json::Value>(&buf)
326 {
327 if let Some(did_str) =
328 val.get("did").and_then(|v| v.as_str())
329 {
330 if let Ok(did) = Did::new_owned(did_str) {
331 return Ok(did.into_static());
332 }
333 }
334 }
335 }
336 }
337 }
338 }
339 // Non-auth path: if PlcSource is Slingshot, use its resolveHandle endpoint.
340 if let PlcSource::Slingshot { base } = &self.opts.plc_source {
341 let mut url = base.clone();
342 url.set_path("/xrpc/com.atproto.identity.resolveHandle");
343 if let Ok(qs) = serde_html_form::to_string(
344 &ResolveHandle::new().handle((*handle).clone()).build(),
345 ) {
346 url.set_query(Some(&qs));
347 } else {
348 continue;
349 }
350 if let Ok((buf, status)) = self.get_json_bytes(url).await {
351 if status.is_success() {
352 if let Ok(val) = serde_json::from_slice::<serde_json::Value>(&buf) {
353 if let Some(did_str) = val.get("did").and_then(|v| v.as_str()) {
354 if let Ok(did) = Did::new_owned(did_str) {
355 return Ok(did.into_static());
356 }
357 }
358 }
359 }
360 }
361 }
362 }
363 }
364 }
365 Err(IdentityError::InvalidWellKnown)
366 }
367
368 async fn resolve_did_doc(&self, did: &Did<'_>) -> Result<DidDocResponse, IdentityError> {
369 let s = did.as_str();
370 for step in &self.opts.did_order {
371 match step {
372 DidStep::DidWebHttps if s.starts_with("did:web:") => {
373 let url = self.did_web_url(did)?;
374 if let Ok((buf, status)) = self.get_json_bytes(url).await {
375 return Ok(DidDocResponse {
376 buffer: buf,
377 status,
378 requested: Some(did.clone().into_static()),
379 });
380 }
381 }
382 DidStep::PlcHttp if s.starts_with("did:plc:") => {
383 let url = match &self.opts.plc_source {
384 PlcSource::PlcDirectory { base } => {
385 // this is odd, the join screws up with the plc directory but NOT slingshot
386 Url::parse(&format!("{}{}", base, did.as_str())).expect("Invalid URL")
387 }
388 PlcSource::Slingshot { base } => base.join(did.as_str())?,
389 };
390 println!("Fetching DID document from {}", url);
391 if let Ok((buf, status)) = self.get_json_bytes(url).await {
392 return Ok(DidDocResponse {
393 buffer: buf,
394 status,
395 requested: Some(did.clone().into_static()),
396 });
397 }
398 }
399 DidStep::PdsResolveDid => {
400 // Try PDS XRPC for full DID doc
401 if let Ok(doc) = self.fetch_did_doc_via_pds_owned(did).await {
402 let buf = serde_json::to_vec(&doc).unwrap_or_default();
403 return Ok(DidDocResponse {
404 buffer: Bytes::from(buf),
405 status: StatusCode::OK,
406 requested: Some(did.clone().into_static()),
407 });
408 }
409 // Fallback: if Slingshot configured, return mini-doc response (partial doc)
410 if let PlcSource::Slingshot { base } = &self.opts.plc_source {
411 let url = self.slingshot_mini_doc_url(base, did.as_str())?;
412 let (buf, status) = self.get_json_bytes(url).await?;
413 return Ok(DidDocResponse {
414 buffer: buf,
415 status,
416 requested: Some(did.clone().into_static()),
417 });
418 }
419 }
420 _ => {}
421 }
422 }
423 Err(IdentityError::UnsupportedDidMethod(s.to_string()))
424 }
425}
426
427impl HttpClient for JacquardResolver {
428 async fn send_http(
429 &self,
430 request: http::Request<Vec<u8>>,
431 ) -> core::result::Result<http::Response<Vec<u8>>, Self::Error> {
432 self.http.send_http(request).await
433 }
434
435 type Error = reqwest::Error;
436}
437
438/// Warnings produced during identity checks that are not fatal
439#[derive(Debug, Clone, PartialEq, Eq)]
440pub enum IdentityWarning {
441 /// The DID doc did not contain the expected handle alias under alsoKnownAs
442 HandleAliasMismatch {
443 #[allow(missing_docs)]
444 expected: Handle<'static>,
445 },
446}
447
448impl JacquardResolver {
449 /// Resolve a handle to its DID, fetch the DID document, and return doc plus any warnings.
450 /// This applies the default equality check on the document id (error with doc if mismatch).
451 pub async fn resolve_handle_and_doc(
452 &self,
453 handle: &Handle<'_>,
454 ) -> Result<(Did<'static>, DidDocResponse, Vec<IdentityWarning>), IdentityError> {
455 let did = self.resolve_handle(handle).await?;
456 let resp = self.resolve_did_doc(&did).await?;
457 let resp_for_parse = resp.clone();
458 let doc_borrowed = resp_for_parse.parse()?;
459 if self.opts.validate_doc_id && doc_borrowed.id.as_str() != did.as_str() {
460 return Err(IdentityError::DocIdMismatch {
461 expected: did.clone().into_static(),
462 doc: doc_borrowed.clone().into_static(),
463 });
464 }
465 let mut warnings = Vec::new();
466 // Check handle alias presence (soft warning)
467 let expected_alias = format!("at://{}", handle.as_str());
468 let has_alias = doc_borrowed
469 .also_known_as
470 .as_ref()
471 .map(|v| v.iter().any(|s| s.as_ref() == expected_alias))
472 .unwrap_or(false);
473 if !has_alias {
474 warnings.push(IdentityWarning::HandleAliasMismatch {
475 expected: handle.clone().into_static(),
476 });
477 }
478 Ok((did, resp, warnings))
479 }
480
481 /// Build Slingshot mini-doc URL for an identifier (handle or DID)
482 fn slingshot_mini_doc_url(&self, base: &Url, identifier: &str) -> Result<Url, IdentityError> {
483 let mut url = base.clone();
484 url.set_path("/xrpc/com.bad-example.identity.resolveMiniDoc");
485 url.set_query(Some(&format!(
486 "identifier={}",
487 urlencoding::Encoded::new(identifier)
488 )));
489 Ok(url)
490 }
491
492 /// Fetch a minimal DID document via Slingshot's mini-doc endpoint using a generic at-identifier
493 pub async fn fetch_mini_doc_via_slingshot_identifier(
494 &self,
495 identifier: &AtIdentifier<'_>,
496 ) -> Result<MiniDocResponse, IdentityError> {
497 let base = match &self.opts.plc_source {
498 PlcSource::Slingshot { base } => base.clone(),
499 _ => {
500 return Err(IdentityError::UnsupportedDidMethod(
501 "mini-doc requires Slingshot source".into(),
502 ));
503 }
504 };
505 let url = self.slingshot_mini_doc_url(&base, identifier.as_str())?;
506 let (buf, status) = self.get_json_bytes(url).await?;
507 Ok(MiniDocResponse {
508 buffer: buf,
509 status,
510 })
511 }
512}
513
514/// Slingshot mini-doc JSON response wrapper
515#[derive(Clone)]
516pub struct MiniDocResponse {
517 buffer: Bytes,
518 status: StatusCode,
519}
520
521impl MiniDocResponse {
522 /// Parse borrowed MiniDoc
523 pub fn parse<'b>(&'b self) -> Result<MiniDoc<'b>, IdentityError> {
524 if self.status.is_success() {
525 serde_json::from_slice::<MiniDoc<'b>>(&self.buffer).map_err(IdentityError::from)
526 } else {
527 Err(IdentityError::HttpStatus(self.status))
528 }
529 }
530}
531
532/// Resolver specialized for unauthenticated/public flows using reqwest and stateless XRPC
533pub type PublicResolver = JacquardResolver;
534
535impl Default for PublicResolver {
536 /// Build a resolver with:
537 /// - reqwest HTTP client
538 /// - Public fallbacks enabled for handle resolution
539 /// - default options (DNS enabled if compiled, public fallback for handles enabled)
540 ///
541 /// Example
542 /// ```ignore
543 /// use jacquard::identity::resolver::PublicResolver;
544 /// let resolver = PublicResolver::default();
545 /// ```
546 fn default() -> Self {
547 let http = reqwest::Client::new();
548 let opts = ResolverOptions::default();
549 let resolver = JacquardResolver::new(http, opts);
550 #[cfg(feature = "dns")]
551 let resolver = resolver.with_system_dns();
552 resolver
553 }
554}
555
556/// Build a resolver configured to use Slingshot (`https://slingshot.microcosm.blue`) for PLC and
557/// mini-doc fallbacks, unauthenticated by default.
558pub fn slingshot_resolver_default() -> PublicResolver {
559 let http = reqwest::Client::new();
560 let mut opts = ResolverOptions::default();
561 opts.plc_source = PlcSource::slingshot_default();
562 let resolver = JacquardResolver::new(http, opts);
563 #[cfg(feature = "dns")]
564 let resolver = resolver.with_system_dns();
565 resolver
566}
567
568#[cfg(test)]
569mod tests {
570 use super::*;
571
572 #[test]
573 fn did_web_urls() {
574 let r = JacquardResolver::new(reqwest::Client::new(), ResolverOptions::default());
575 assert_eq!(
576 r.test_did_web_url_raw("did:web:example.com"),
577 "https://example.com/.well-known/did.json"
578 );
579 assert_eq!(
580 r.test_did_web_url_raw("did:web:example.com:user:alice"),
581 "https://example.com/user/alice/did.json"
582 );
583 }
584
585 #[test]
586 fn slingshot_mini_doc_url_build() {
587 let r = JacquardResolver::new(reqwest::Client::new(), ResolverOptions::default());
588 let base = Url::parse("https://slingshot.microcosm.blue").unwrap();
589 let url = r.slingshot_mini_doc_url(&base, "bad-example.com").unwrap();
590 assert_eq!(
591 url.as_str(),
592 "https://slingshot.microcosm.blue/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=bad-example.com"
593 );
594 }
595
596 #[test]
597 fn slingshot_mini_doc_parse_success() {
598 let buf = Bytes::from_static(
599 br#"{
600 "did": "did:plc:hdhoaan3xa3jiuq4fg4mefid",
601 "handle": "bad-example.com",
602 "pds": "https://porcini.us-east.host.bsky.network",
603 "signing_key": "zQ3shpq1g134o7HGDb86CtQFxnHqzx5pZWknrVX2Waum3fF6j"
604}"#,
605 );
606 let resp = MiniDocResponse {
607 buffer: buf,
608 status: StatusCode::OK,
609 };
610 let doc = resp.parse().expect("parse mini-doc");
611 assert_eq!(doc.did.as_str(), "did:plc:hdhoaan3xa3jiuq4fg4mefid");
612 assert_eq!(doc.handle.as_str(), "bad-example.com");
613 assert_eq!(
614 doc.pds.as_ref(),
615 "https://porcini.us-east.host.bsky.network"
616 );
617 assert!(doc.signing_key.as_ref().starts_with('z'));
618 }
619
620 #[test]
621 fn slingshot_mini_doc_parse_error_status() {
622 let buf = Bytes::from_static(
623 br#"{
624 "error": "RecordNotFound",
625 "message": "This record was deleted"
626}"#,
627 );
628 let resp = MiniDocResponse {
629 buffer: buf,
630 status: StatusCode::BAD_REQUEST,
631 };
632 match resp.parse() {
633 Err(IdentityError::HttpStatus(s)) => assert_eq!(s, StatusCode::BAD_REQUEST),
634 other => panic!("unexpected: {:?}", other),
635 }
636 }
637}