···
1
+
//! Identity resolution: handle → DID and DID → document, with smart fallbacks.
3
+
//! Fallback order (default):
4
+
//! - Handle → DID: DNS TXT (if `dns` feature) → HTTPS well-known → embedded XRPC
5
+
//! `resolveHandle` → public API fallback → Slingshot `resolveHandle` (if configured).
6
+
//! - DID → Doc: did:web well-known → PLC/slingshot HTTP → embedded XRPC `resolveDid`,
7
+
//! then Slingshot mini‑doc (partial) if configured.
9
+
//! Parsing returns a `DidDocResponse` so callers can borrow from the response buffer
10
+
//! and optionally validate the document `id` against the requested DID.
13
+
use crate::client::AuthenticatedClient;
16
+
use jacquard_common::IntoStatic;
17
+
use miette::Diagnostic;
18
+
use percent_encoding::percent_decode_str;
19
+
use reqwest::StatusCode;
20
+
use thiserror::Error;
21
+
use url::{ParseError, Url};
23
+
use crate::api::com_atproto::identity::{resolve_did, resolve_handle::ResolveHandle};
24
+
use crate::types::did_doc::DidDocument;
25
+
use crate::types::ident::AtIdentifier;
26
+
use crate::types::string::{Did, Handle};
27
+
use crate::types::value::AtDataError;
29
+
#[cfg(feature = "dns")]
30
+
use hickory_resolver::{TokioAsyncResolver, config::ResolverConfig};
32
+
/// Errors that can occur during identity resolution.
34
+
/// Note: when validating a fetched DID document against a requested DID, a
35
+
/// `DocIdMismatch` error is returned that includes the owned document so callers
36
+
/// can inspect it and decide how to proceed.
37
+
#[derive(Debug, Error, Diagnostic)]
38
+
#[allow(missing_docs)]
39
+
pub enum IdentityError {
40
+
#[error("unsupported DID method: {0}")]
41
+
UnsupportedDidMethod(String),
42
+
#[error("invalid well-known atproto-did content")]
44
+
#[error("missing PDS endpoint in DID document")]
46
+
#[error("HTTP error: {0}")]
47
+
Http(#[from] reqwest::Error),
48
+
#[error("HTTP status {0}")]
49
+
HttpStatus(StatusCode),
50
+
#[error("XRPC error: {0}")]
52
+
#[error("URL parse error: {0}")]
53
+
Url(#[from] url::ParseError),
54
+
#[error("DNS error: {0}")]
55
+
#[cfg(feature = "dns")]
56
+
Dns(#[from] hickory_resolver::error::ResolveError),
57
+
#[error("serialize/deserialize error: {0}")]
58
+
Serde(#[from] serde_json::Error),
59
+
#[error("invalid DID document: {0}")]
61
+
#[error(transparent)]
62
+
Data(#[from] AtDataError),
63
+
/// DID document id did not match requested DID; includes the fetched document
64
+
#[error("DID doc id mismatch")]
66
+
expected: Did<'static>,
67
+
doc: DidDocument<'static>,
71
+
/// Source to fetch PLC (did:plc) documents from.
73
+
/// - `PlcDirectory`: uses the public PLC directory (default `https://plc.directory/`).
74
+
/// - `Slingshot`: uses Slingshot which also exposes convenience endpoints such as
75
+
/// `com.atproto.identity.resolveHandle` and a "mini-doc"
76
+
/// endpoint (`com.bad-example.identity.resolveMiniDoc`).
77
+
#[derive(Debug, Clone, PartialEq, Eq)]
78
+
pub enum PlcSource {
79
+
/// Use the public PLC directory
81
+
/// Base URL for the PLC directory
84
+
/// Use the slingshot mini-docs service
86
+
/// Base URL for the Slingshot service
91
+
impl Default for PlcSource {
92
+
fn default() -> Self {
93
+
Self::PlcDirectory {
94
+
base: Url::parse("https://plc.directory/").expect("valid url"),
100
+
/// Default Slingshot source (`https://slingshot.microcosm.blue`)
101
+
pub fn slingshot_default() -> Self {
102
+
PlcSource::Slingshot {
103
+
base: Url::parse("https://slingshot.microcosm.blue").expect("valid url"),
108
+
/// DID Document fetch response for borrowed/owned parsing.
110
+
/// Carries the raw response bytes and the HTTP status, plus the requested DID
111
+
/// (if supplied) to enable validation. Use `parse()` to borrow from the buffer
112
+
/// or `parse_validated()` to also enforce that the doc `id` matches the
113
+
/// requested DID (returns a `DocIdMismatch` containing the fetched doc on
114
+
/// mismatch). Use `into_owned()` to parse into an owned document.
116
+
pub struct DidDocResponse {
118
+
status: StatusCode,
119
+
/// Optional DID we intended to resolve; used for validation helpers
120
+
requested: Option<Did<'static>>,
123
+
impl DidDocResponse {
124
+
/// Parse as borrowed DidDocument<'_>
125
+
pub fn parse<'b>(&'b self) -> Result<DidDocument<'b>, IdentityError> {
126
+
if self.status.is_success() {
127
+
serde_json::from_slice::<DidDocument<'b>>(&self.buffer).map_err(IdentityError::from)
129
+
Err(IdentityError::HttpStatus(self.status))
133
+
/// Parse and validate that the DID in the document matches the requested DID if present.
135
+
/// On mismatch, returns an error that contains the owned document for inspection.
136
+
pub fn parse_validated<'b>(&'b self) -> Result<DidDocument<'b>, IdentityError> {
137
+
let doc = self.parse()?;
138
+
if let Some(expected) = &self.requested {
139
+
if doc.id.as_str() != expected.as_str() {
140
+
return Err(IdentityError::DocIdMismatch {
141
+
expected: expected.clone(),
142
+
doc: doc.clone().into_static(),
149
+
/// Parse as owned DidDocument<'static>
150
+
pub fn into_owned(self) -> Result<DidDocument<'static>, IdentityError> {
151
+
if self.status.is_success() {
152
+
serde_json::from_slice::<DidDocument<'_>>(&self.buffer)
153
+
.map(|d| d.into_static())
154
+
.map_err(IdentityError::from)
156
+
Err(IdentityError::HttpStatus(self.status))
161
+
/// Handle → DID fallback step.
162
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
163
+
pub enum HandleStep {
164
+
/// DNS TXT _atproto.<handle>
166
+
/// HTTPS GET https://<handle>/.well-known/atproto-did
168
+
/// XRPC com.atproto.identity.resolveHandle against a provided PDS base
172
+
/// DID → Doc fallback step.
173
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
175
+
/// For did:web: fetch from the well-known location
177
+
/// For did:plc: fetch from PLC source
179
+
/// If a PDS base is known, ask it for the DID doc
183
+
/// Configurable resolver options.
185
+
/// - `plc_source`: where to fetch did:plc documents (PLC Directory or Slingshot).
186
+
/// - `pds_fallback`: optional base URL of a PDS for XRPC fallbacks (auth-aware
187
+
/// paths available via helpers that take an `XrpcClient`).
188
+
/// - `handle_order`/`did_order`: ordered strategies for resolution.
189
+
/// - `validate_doc_id`: if true (default), convenience helpers validate doc `id` against the requested DID,
190
+
/// returning `DocIdMismatch` with the fetched document on mismatch.
191
+
/// - `public_fallback_for_handle`: if true (default), attempt
192
+
/// `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle` as an unauth fallback.
193
+
/// There is no public fallback for DID documents; when `PdsResolveDid` is chosen and the embedded XRPC
194
+
/// client fails, the resolver falls back to Slingshot mini-doc (partial) if `PlcSource::Slingshot` is configured.
195
+
#[derive(Debug, Clone, Builder)]
196
+
#[builder(start_fn = new)]
197
+
pub struct ResolverOptions {
198
+
/// PLC data source (directory or slingshot)
199
+
pub plc_source: PlcSource,
200
+
/// Optional PDS base to use for fallbacks
201
+
pub pds_fallback: Option<Url>,
202
+
/// Order of attempts for handle → DID resolution
203
+
pub handle_order: Vec<HandleStep>,
204
+
/// Order of attempts for DID → Doc resolution
205
+
pub did_order: Vec<DidStep>,
206
+
/// Validate that fetched DID document id matches the requested DID
207
+
pub validate_doc_id: bool,
208
+
/// Allow public unauthenticated fallback for resolveHandle via public.api.bsky.app
209
+
pub public_fallback_for_handle: bool,
212
+
impl Default for ResolverOptions {
213
+
fn default() -> Self {
214
+
// By default, prefer DNS then HTTPS for handles, then PDS fallback
215
+
// For DID documents, prefer method-native sources, then PDS fallback
217
+
.plc_source(PlcSource::default())
218
+
.handle_order(vec![
219
+
HandleStep::DnsTxt,
220
+
HandleStep::HttpsWellKnown,
221
+
HandleStep::PdsResolveHandle,
224
+
DidStep::DidWebHttps,
226
+
DidStep::PdsResolveDid,
228
+
.validate_doc_id(true)
229
+
.public_fallback_for_handle(true)
234
+
/// Trait for identity resolution, for pluggable implementations.
236
+
/// The provided `DefaultResolver` supports:
237
+
/// - DNS TXT (`_atproto.<handle>`) when compiled with the `dns` feature
238
+
/// - HTTPS well-known for handles and `did:web`
239
+
/// - PLC directory or Slingshot for `did:plc`
240
+
/// - Slingshot `resolveHandle` (unauthenticated) when configured as the PLC source
241
+
/// - Auth-aware PDS fallbacks via helpers that accept an `XrpcClient`
242
+
#[async_trait::async_trait]
243
+
pub trait IdentityResolver {
244
+
/// Access options for validation decisions in default methods
245
+
fn options(&self) -> &ResolverOptions;
248
+
async fn resolve_handle(&self, handle: &Handle<'_>) -> Result<Did<'static>, IdentityError>;
250
+
/// Resolve DID document
251
+
async fn resolve_did_doc(&self, did: &Did<'_>) -> Result<DidDocResponse, IdentityError>;
252
+
async fn resolve_did_doc_owned(
255
+
) -> Result<DidDocument<'static>, IdentityError> {
256
+
self.resolve_did_doc(did).await?.into_owned()
258
+
async fn pds_for_did(&self, did: &Did<'_>) -> Result<Url, IdentityError> {
259
+
let resp = self.resolve_did_doc(did).await?;
260
+
let doc = resp.parse()?;
261
+
// Default-on doc id equality check
262
+
if self.options().validate_doc_id {
263
+
if doc.id.as_str() != did.as_str() {
264
+
return Err(IdentityError::DocIdMismatch {
265
+
expected: did.clone().into_static(),
266
+
doc: doc.clone().into_static(),
270
+
doc.pds_endpoint().ok_or(IdentityError::MissingPdsEndpoint)
272
+
async fn pds_for_handle(
274
+
handle: &Handle<'_>,
275
+
) -> Result<(Did<'static>, Url), IdentityError> {
276
+
let did = self.resolve_handle(handle).await?;
277
+
let pds = self.pds_for_did(&did).await?;
282
+
/// Default resolver implementation with configurable fallback order.
284
+
/// Behavior highlights:
285
+
/// - Handle resolution tries DNS TXT (if enabled via `dns` feature), then HTTPS
286
+
/// well-known, then Slingshot's unauthenticated `resolveHandle` when
287
+
/// `PlcSource::Slingshot` is configured.
288
+
/// - DID resolution tries did:web well-known for `did:web`, and the configured
289
+
/// PLC base (PLC directory or Slingshot) for `did:plc`.
290
+
/// - PDS-authenticated fallbacks (e.g., `resolveHandle`, `resolveDid` on a PDS)
291
+
/// are available via helper methods that accept a user-provided `XrpcClient`.
295
+
/// use jacquard::identity::resolver::{DefaultResolver, ResolverOptions};
296
+
/// use jacquard::client::{AuthenticatedClient, XrpcClient};
297
+
/// use jacquard::types::string::Handle;
298
+
/// use jacquard::CowStr;
300
+
/// // Build an auth-capable XRPC client (without a session it behaves like public/unauth)
301
+
/// let http = reqwest::Client::new();
302
+
/// let xrpc = AuthenticatedClient::new(http.clone(), CowStr::from("https://bsky.social"));
303
+
/// let resolver = DefaultResolver::new(http, xrpc, ResolverOptions::default());
305
+
/// // Resolve a handle to a DID
306
+
/// let did = tokio_test::block_on(async { resolver.resolve_handle(&Handle::new("bad-example.com").unwrap()).await }).unwrap();
308
+
pub struct DefaultResolver<C: crate::client::XrpcClient + Send + Sync> {
309
+
http: reqwest::Client,
311
+
opts: ResolverOptions,
312
+
#[cfg(feature = "dns")]
313
+
dns: Option<TokioAsyncResolver>,
316
+
impl<C: crate::client::XrpcClient + Send + Sync> DefaultResolver<C> {
317
+
pub fn new(http: reqwest::Client, xrpc: C, opts: ResolverOptions) -> Self {
322
+
#[cfg(feature = "dns")]
327
+
#[cfg(feature = "dns")]
328
+
pub fn with_system_dns(mut self) -> Self {
329
+
self.dns = Some(TokioAsyncResolver::tokio(
330
+
ResolverConfig::default(),
331
+
Default::default(),
336
+
/// Set PLC source (PLC directory or Slingshot)
340
+
/// use jacquard::identity::resolver::{DefaultResolver, ResolverOptions, PlcSource};
341
+
/// let http = reqwest::Client::new();
342
+
/// let xrpc = jacquard::client::AuthenticatedClient::new(http.clone(), jacquard::CowStr::from("https://public.api.bsky.app"));
343
+
/// let resolver = DefaultResolver::new(http, xrpc, ResolverOptions::default())
344
+
/// .with_plc_source(PlcSource::slingshot_default());
346
+
pub fn with_plc_source(mut self, source: PlcSource) -> Self {
347
+
self.opts.plc_source = source;
351
+
/// Enable/disable public unauthenticated fallback for resolveHandle
355
+
/// # use jacquard::identity::resolver::{DefaultResolver, ResolverOptions};
356
+
/// # let http = reqwest::Client::new();
357
+
/// # let xrpc = jacquard::client::AuthenticatedClient::new(http.clone(), jacquard::CowStr::from("https://public.api.bsky.app"));
358
+
/// let resolver = DefaultResolver::new(http, xrpc, ResolverOptions::default())
359
+
/// .with_public_fallback_for_handle(true);
361
+
pub fn with_public_fallback_for_handle(mut self, enable: bool) -> Self {
362
+
self.opts.public_fallback_for_handle = enable;
366
+
/// Enable/disable doc id validation
370
+
/// # use jacquard::identity::resolver::{DefaultResolver, ResolverOptions};
371
+
/// # let http = reqwest::Client::new();
372
+
/// # let xrpc = jacquard::client::AuthenticatedClient::new(http.clone(), jacquard::CowStr::from("https://public.api.bsky.app"));
373
+
/// let resolver = DefaultResolver::new(http, xrpc, ResolverOptions::default())
374
+
/// .with_validate_doc_id(true);
376
+
pub fn with_validate_doc_id(mut self, enable: bool) -> Self {
377
+
self.opts.validate_doc_id = enable;
381
+
/// Construct the well-known HTTPS URL for a `did:web` DID.
383
+
/// - `did:web:example.com` → `https://example.com/.well-known/did.json`
384
+
/// - `did:web:example.com:user:alice` → `https://example.com/user/alice/did.json`
385
+
fn did_web_url(&self, did: &Did<'_>) -> Result<Url, IdentityError> {
386
+
// did:web:example.com[:path:segments]
387
+
let s = did.as_str();
389
+
.strip_prefix("did:web:")
390
+
.ok_or_else(|| IdentityError::UnsupportedDidMethod(s.to_string()))?;
391
+
let mut parts = rest.split(':');
394
+
.ok_or_else(|| IdentityError::UnsupportedDidMethod(s.to_string()))?;
395
+
let mut url = Url::parse(&format!("https://{host}/")).map_err(IdentityError::Url)?;
396
+
let path: Vec<&str> = parts.collect();
397
+
if path.is_empty() {
398
+
url.set_path(".well-known/did.json");
400
+
// Append path segments and did.json
401
+
let mut segments = url
402
+
.path_segments_mut()
403
+
.map_err(|_| IdentityError::Url(ParseError::SetHostOnCannotBeABaseUrl))?;
405
+
// Minimally percent-decode each segment per spec guidance
406
+
let decoded = percent_decode_str(seg).decode_utf8_lossy();
407
+
segments.push(&decoded);
409
+
segments.push("did.json");
416
+
fn test_did_web_url_raw(&self, s: &str) -> String {
417
+
let did = Did::new(s).unwrap();
418
+
self.did_web_url(&did).unwrap().to_string()
421
+
async fn get_json_bytes(&self, url: Url) -> Result<(Bytes, StatusCode), IdentityError> {
422
+
let resp = self.http.get(url).send().await?;
423
+
let status = resp.status();
424
+
let buf = resp.bytes().await?;
428
+
async fn get_text(&self, url: Url) -> Result<String, IdentityError> {
429
+
let resp = self.http.get(url).send().await?;
430
+
if resp.status() == StatusCode::OK {
431
+
Ok(resp.text().await?)
433
+
Err(IdentityError::Http(resp.error_for_status().unwrap_err()))
437
+
#[cfg(feature = "dns")]
438
+
async fn dns_txt(&self, name: &str) -> Result<Vec<String>, IdentityError> {
439
+
let Some(dns) = &self.dns else {
442
+
let fqdn = format!("_atproto.{name}.");
443
+
let response = dns.txt_lookup(fqdn).await?;
444
+
let mut out = Vec::new();
445
+
for txt in response.iter() {
446
+
for data in txt.txt_data().iter() {
447
+
out.push(String::from_utf8_lossy(data).to_string());
453
+
fn parse_atproto_did_body(body: &str) -> Result<Did<'static>, IdentityError> {
456
+
.find(|l| !l.trim().is_empty())
457
+
.ok_or(IdentityError::InvalidWellKnown)?;
458
+
let did = Did::new(line.trim()).map_err(|_| IdentityError::InvalidWellKnown)?;
459
+
Ok(did.into_static())
463
+
impl<C: crate::client::XrpcClient + Send + Sync> DefaultResolver<C> {
464
+
/// Resolve handle to DID via a PDS XRPC client (auth-aware path)
465
+
pub async fn resolve_handle_via_pds(
467
+
handle: &Handle<'_>,
468
+
) -> Result<Did<'static>, IdentityError> {
469
+
let req = ResolveHandle::new().handle((*handle).clone()).build();
474
+
.map_err(|e| IdentityError::Xrpc(e.to_string()))?;
477
+
.map_err(|e| IdentityError::Xrpc(e.to_string()))?;
478
+
Did::new_owned(out.did.as_str())
479
+
.map(|d| d.into_static())
480
+
.map_err(|_| IdentityError::InvalidWellKnown)
483
+
/// Fetch DID document via PDS resolveDid (returns owned DidDocument)
484
+
pub async fn fetch_did_doc_via_pds_owned(
487
+
) -> Result<DidDocument<'static>, IdentityError> {
488
+
let req = resolve_did::ResolveDid::new().did(did.clone()).build();
493
+
.map_err(|e| IdentityError::Xrpc(e.to_string()))?;
496
+
.map_err(|e| IdentityError::Xrpc(e.to_string()))?;
497
+
let doc_json = serde_json::to_value(&out.did_doc)?;
498
+
let s = serde_json::to_string(&doc_json)?;
499
+
let doc_borrowed: DidDocument<'_> = serde_json::from_str(&s)?;
500
+
Ok(doc_borrowed.into_static())
503
+
/// Fetch a minimal DID document via a Slingshot mini-doc endpoint, if your PlcSource uses Slingshot.
504
+
/// Returns the raw response wrapper for borrowed parsing and validation.
505
+
pub async fn fetch_mini_doc_via_slingshot(
508
+
) -> Result<DidDocResponse, IdentityError> {
509
+
let base = match &self.opts.plc_source {
510
+
PlcSource::Slingshot { base } => base.clone(),
512
+
return Err(IdentityError::UnsupportedDidMethod(
513
+
"mini-doc requires Slingshot source".into(),
517
+
let mut url = base;
518
+
url.set_path("/xrpc/com.bad-example.identity.resolveMiniDoc");
520
+
serde_html_form::to_string(&resolve_did::ResolveDid::new().did(did.clone()).build())
522
+
url.set_query(Some(&qs));
524
+
let (buf, status) = self.get_json_bytes(url).await?;
525
+
Ok(DidDocResponse {
528
+
requested: Some(did.clone().into_static()),
533
+
#[async_trait::async_trait]
534
+
impl<C: crate::client::XrpcClient + Send + Sync> IdentityResolver for DefaultResolver<C> {
535
+
fn options(&self) -> &ResolverOptions {
538
+
async fn resolve_handle(&self, handle: &Handle<'_>) -> Result<Did<'static>, IdentityError> {
539
+
let host = handle.as_str();
540
+
for step in &self.opts.handle_order {
542
+
HandleStep::DnsTxt => {
543
+
#[cfg(feature = "dns")]
545
+
if let Ok(txts) = self.dns_txt(host).await {
547
+
if let Some(did_str) = txt.strip_prefix("did=") {
548
+
if let Ok(did) = Did::new(did_str) {
549
+
return Ok(did.into_static());
556
+
HandleStep::HttpsWellKnown => {
557
+
let url = Url::parse(&format!("https://{host}/.well-known/atproto-did"))?;
558
+
if let Ok(text) = self.get_text(url).await {
559
+
if let Ok(did) = Self::parse_atproto_did_body(&text) {
564
+
HandleStep::PdsResolveHandle => {
565
+
// Prefer embedded XRPC client
566
+
if let Ok(did) = self.resolve_handle_via_pds(handle).await {
569
+
// Public unauth fallback
570
+
if self.opts.public_fallback_for_handle {
571
+
if let Ok(mut url) = Url::parse("https://public.api.bsky.app") {
572
+
url.set_path("/xrpc/com.atproto.identity.resolveHandle");
573
+
if let Ok(qs) = serde_html_form::to_string(
574
+
&ResolveHandle::new().handle((*handle).clone()).build(),
576
+
url.set_query(Some(&qs));
580
+
if let Ok((buf, status)) = self.get_json_bytes(url).await {
581
+
if status.is_success() {
583
+
serde_json::from_slice::<serde_json::Value>(&buf)
585
+
if let Some(did_str) =
586
+
val.get("did").and_then(|v| v.as_str())
588
+
if let Ok(did) = Did::new_owned(did_str) {
589
+
return Ok(did.into_static());
597
+
// Non-auth path: if PlcSource is Slingshot, use its resolveHandle endpoint.
598
+
if let PlcSource::Slingshot { base } = &self.opts.plc_source {
599
+
let mut url = base.clone();
600
+
url.set_path("/xrpc/com.atproto.identity.resolveHandle");
601
+
if let Ok(qs) = serde_html_form::to_string(
602
+
&ResolveHandle::new().handle((*handle).clone()).build(),
604
+
url.set_query(Some(&qs));
608
+
if let Ok((buf, status)) = self.get_json_bytes(url).await {
609
+
if status.is_success() {
610
+
if let Ok(val) = serde_json::from_slice::<serde_json::Value>(&buf) {
611
+
if let Some(did_str) = val.get("did").and_then(|v| v.as_str()) {
612
+
if let Ok(did) = Did::new_owned(did_str) {
613
+
return Ok(did.into_static());
623
+
Err(IdentityError::InvalidWellKnown)
626
+
async fn resolve_did_doc(&self, did: &Did<'_>) -> Result<DidDocResponse, IdentityError> {
627
+
let s = did.as_str();
628
+
for step in &self.opts.did_order {
630
+
DidStep::DidWebHttps if s.starts_with("did:web:") => {
631
+
let url = self.did_web_url(did)?;
632
+
if let Ok((buf, status)) = self.get_json_bytes(url).await {
633
+
return Ok(DidDocResponse {
636
+
requested: Some(did.clone().into_static()),
640
+
DidStep::PlcHttp if s.starts_with("did:plc:") => {
641
+
let url = match &self.opts.plc_source {
642
+
PlcSource::PlcDirectory { base } => base.join(did.as_str())?,
643
+
PlcSource::Slingshot { base } => base.join(did.as_str())?,
645
+
if let Ok((buf, status)) = self.get_json_bytes(url).await {
646
+
return Ok(DidDocResponse {
649
+
requested: Some(did.clone().into_static()),
653
+
DidStep::PdsResolveDid => {
654
+
// Try embedded XRPC client for full DID doc
655
+
if let Ok(doc) = self.fetch_did_doc_via_pds_owned(did).await {
656
+
let buf = serde_json::to_vec(&doc).unwrap_or_default();
657
+
return Ok(DidDocResponse {
658
+
buffer: Bytes::from(buf),
659
+
status: StatusCode::OK,
660
+
requested: Some(did.clone().into_static()),
663
+
// Fallback: if Slingshot configured, return mini-doc response (partial doc)
664
+
if let PlcSource::Slingshot { base } = &self.opts.plc_source {
665
+
let url = self.slingshot_mini_doc_url(base, did.as_str())?;
666
+
let (buf, status) = self.get_json_bytes(url).await?;
667
+
return Ok(DidDocResponse {
670
+
requested: Some(did.clone().into_static()),
677
+
Err(IdentityError::UnsupportedDidMethod(s.to_string()))
681
+
/// Warnings produced during identity checks that are not fatal
682
+
#[derive(Debug, Clone, PartialEq, Eq)]
683
+
pub enum IdentityWarning {
684
+
/// The DID doc did not contain the expected handle alias under alsoKnownAs
685
+
HandleAliasMismatch { expected: Handle<'static> },
688
+
impl<C: crate::client::XrpcClient + Send + Sync> DefaultResolver<C> {
689
+
/// Resolve a handle to its DID, fetch the DID document, and return doc plus any warnings.
690
+
/// This applies the default equality check on the document id (error with doc if mismatch).
691
+
pub async fn resolve_handle_and_doc(
693
+
handle: &Handle<'_>,
694
+
) -> Result<(Did<'static>, DidDocResponse, Vec<IdentityWarning>), IdentityError> {
695
+
let did = self.resolve_handle(handle).await?;
696
+
let resp = self.resolve_did_doc(&did).await?;
697
+
let resp_for_parse = resp.clone();
698
+
let doc_borrowed = resp_for_parse.parse()?;
699
+
if self.opts.validate_doc_id && doc_borrowed.id.as_str() != did.as_str() {
700
+
return Err(IdentityError::DocIdMismatch {
701
+
expected: did.clone().into_static(),
702
+
doc: doc_borrowed.clone().into_static(),
705
+
let mut warnings = Vec::new();
706
+
// Check handle alias presence (soft warning)
707
+
let expected_alias = format!("at://{}", handle.as_str());
708
+
let has_alias = doc_borrowed
711
+
.map(|v| v.iter().any(|s| s.as_ref() == expected_alias))
714
+
warnings.push(IdentityWarning::HandleAliasMismatch {
715
+
expected: handle.clone().into_static(),
718
+
Ok((did, resp, warnings))
721
+
/// Build Slingshot mini-doc URL for an identifier (handle or DID)
722
+
fn slingshot_mini_doc_url(&self, base: &Url, identifier: &str) -> Result<Url, IdentityError> {
723
+
let mut url = base.clone();
724
+
url.set_path("/xrpc/com.bad-example.identity.resolveMiniDoc");
725
+
url.set_query(Some(&format!(
727
+
urlencoding::Encoded::new(identifier)
732
+
/// Fetch a minimal DID document via Slingshot's mini-doc endpoint using a generic at-identifier
733
+
pub async fn fetch_mini_doc_via_slingshot_identifier(
735
+
identifier: &AtIdentifier<'_>,
736
+
) -> Result<MiniDocResponse, IdentityError> {
737
+
let base = match &self.opts.plc_source {
738
+
PlcSource::Slingshot { base } => base.clone(),
740
+
return Err(IdentityError::UnsupportedDidMethod(
741
+
"mini-doc requires Slingshot source".into(),
745
+
let url = self.slingshot_mini_doc_url(&base, identifier.as_str())?;
746
+
let (buf, status) = self.get_json_bytes(url).await?;
747
+
Ok(MiniDocResponse {
754
+
/// Slingshot mini-doc JSON response wrapper
756
+
pub struct MiniDocResponse {
758
+
status: StatusCode,
761
+
impl MiniDocResponse {
762
+
/// Parse borrowed MiniDoc
763
+
pub fn parse<'b>(&'b self) -> Result<MiniDoc<'b>, IdentityError> {
764
+
if self.status.is_success() {
765
+
serde_json::from_slice::<MiniDoc<'b>>(&self.buffer).map_err(IdentityError::from)
767
+
Err(IdentityError::HttpStatus(self.status))
772
+
/// Slingshot mini-doc data (subset of DID doc info)
773
+
#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
774
+
#[serde(rename_all = "camelCase")]
775
+
pub struct MiniDoc<'a> {
779
+
pub handle: Handle<'a>,
781
+
pub pds: crate::CowStr<'a>,
782
+
#[serde(borrow, rename = "signingKey", alias = "signing_key")]
783
+
pub signing_key: crate::CowStr<'a>,
791
+
fn did_web_urls() {
792
+
let r = DefaultResolver::new(
793
+
reqwest::Client::new(),
795
+
ResolverOptions::default(),
798
+
r.test_did_web_url_raw("did:web:example.com"),
799
+
"https://example.com/.well-known/did.json"
802
+
r.test_did_web_url_raw("did:web:example.com:user:alice"),
803
+
"https://example.com/user/alice/did.json"
808
+
fn parse_validated_ok() {
809
+
let buf = Bytes::from_static(br#"{"id":"did:plc:alice"}"#);
810
+
let requested = Did::new_owned("did:plc:alice").unwrap();
811
+
let resp = DidDocResponse {
813
+
status: StatusCode::OK,
814
+
requested: Some(requested),
816
+
let _doc = resp.parse_validated().expect("valid");
820
+
fn parse_validated_mismatch() {
821
+
let buf = Bytes::from_static(br#"{"id":"did:plc:bob"}"#);
822
+
let requested = Did::new_owned("did:plc:alice").unwrap();
823
+
let resp = DidDocResponse {
825
+
status: StatusCode::OK,
826
+
requested: Some(requested),
828
+
match resp.parse_validated() {
829
+
Err(IdentityError::DocIdMismatch { expected, doc }) => {
830
+
assert_eq!(expected.as_str(), "did:plc:alice");
831
+
assert_eq!(doc.id.as_str(), "did:plc:bob");
833
+
other => panic!("unexpected result: {:?}", other),
838
+
fn slingshot_mini_doc_url_build() {
839
+
let r = DefaultResolver::new(
840
+
reqwest::Client::new(),
842
+
ResolverOptions::default(),
844
+
let base = Url::parse("https://slingshot.microcosm.blue").unwrap();
845
+
let url = r.slingshot_mini_doc_url(&base, "bad-example.com").unwrap();
848
+
"https://slingshot.microcosm.blue/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=bad-example.com"
853
+
fn slingshot_mini_doc_parse_success() {
854
+
let buf = Bytes::from_static(
856
+
"did": "did:plc:hdhoaan3xa3jiuq4fg4mefid",
857
+
"handle": "bad-example.com",
858
+
"pds": "https://porcini.us-east.host.bsky.network",
859
+
"signing_key": "zQ3shpq1g134o7HGDb86CtQFxnHqzx5pZWknrVX2Waum3fF6j"
862
+
let resp = MiniDocResponse {
864
+
status: StatusCode::OK,
866
+
let doc = resp.parse().expect("parse mini-doc");
867
+
assert_eq!(doc.did.as_str(), "did:plc:hdhoaan3xa3jiuq4fg4mefid");
868
+
assert_eq!(doc.handle.as_str(), "bad-example.com");
871
+
"https://porcini.us-east.host.bsky.network"
873
+
assert!(doc.signing_key.as_ref().starts_with('z'));
877
+
fn slingshot_mini_doc_parse_error_status() {
878
+
let buf = Bytes::from_static(
880
+
"error": "RecordNotFound",
881
+
"message": "This record was deleted"
884
+
let resp = MiniDocResponse {
886
+
status: StatusCode::BAD_REQUEST,
888
+
match resp.parse() {
889
+
Err(IdentityError::HttpStatus(s)) => assert_eq!(s, StatusCode::BAD_REQUEST),
890
+
other => panic!("unexpected: {:?}", other),
893
+
use crate::client::{HttpClient, XrpcClient};
895
+
use jacquard_common::CowStr;
898
+
client: reqwest::Client,
903
+
client: reqwest::Client::new(),
907
+
impl HttpClient for TestXrpc {
908
+
type Error = reqwest::Error;
909
+
async fn send_http(
911
+
request: Request<Vec<u8>>,
912
+
) -> Result<http::Response<Vec<u8>>, Self::Error> {
913
+
self.client.send_http(request).await
916
+
impl XrpcClient for TestXrpc {
917
+
fn base_uri(&self) -> CowStr<'_> {
918
+
CowStr::from("https://public.api.bsky.app")
923
+
/// Resolver specialized for unauthenticated/public flows using reqwest + AuthenticatedClient
924
+
pub type PublicResolver = DefaultResolver<AuthenticatedClient<reqwest::Client>>;
926
+
impl Default for PublicResolver {
927
+
/// Build a resolver with:
928
+
/// - reqwest HTTP client
929
+
/// - XRPC base https://public.api.bsky.app (unauthenticated)
930
+
/// - default options (DNS enabled if compiled, public fallback for handles enabled)
934
+
/// use jacquard::identity::resolver::PublicResolver;
935
+
/// let resolver = PublicResolver::default();
937
+
fn default() -> Self {
938
+
let http = reqwest::Client::new();
940
+
AuthenticatedClient::new(http.clone(), CowStr::from("https://public.api.bsky.app"));
941
+
let opts = ResolverOptions::default();
942
+
let resolver = DefaultResolver::new(http, xrpc, opts);
943
+
#[cfg(feature = "dns")]
944
+
let resolver = resolver.with_system_dns();
949
+
/// Build a resolver configured to use Slingshot (`https://slingshot.microcosm.blue`) for PLC and
950
+
/// mini-doc fallbacks, unauthenticated by default.
951
+
pub fn slingshot_resolver_default() -> PublicResolver {
952
+
let http = reqwest::Client::new();
953
+
let xrpc = AuthenticatedClient::new(http.clone(), CowStr::from("https://public.api.bsky.app"));
954
+
let mut opts = ResolverOptions::default();
955
+
opts.plc_source = PlcSource::slingshot_default();
956
+
let resolver = DefaultResolver::new(http, xrpc, opts);
957
+
#[cfg(feature = "dns")]
958
+
let resolver = resolver.with_system_dns();