···1+//! Identity resolution: handle → DID and DID → document, with smart fallbacks.
2+//!
3+//! Fallback order (default):
4+//! - Handle → DID: DNS TXT (if `dns` feature) → HTTPS well-known → PDS XRPC
5+//! `resolveHandle` (when `pds_fallback` is configured) → public API fallback → Slingshot `resolveHandle` (if configured).
6+//! - DID → Doc: did:web well-known → PLC/Slingshot HTTP → PDS XRPC `resolveDid` (when configured),
7+//! then Slingshot mini‑doc (partial) if configured.
8+//!
9+//! Parsing returns a `DidDocResponse` so callers can borrow from the response buffer
10+//! and optionally validate the document `id` against the requested DID.
11+12+use std::collections::BTreeMap;
13+use std::str::FromStr;
14+15+use crate::error::TransportError;
16+use crate::types::did_doc::Service;
17+use crate::types::ident::AtIdentifier;
18+use crate::types::string::AtprotoStr;
19+use crate::types::uri::Uri;
20+use crate::types::value::Data;
21+use crate::{CowStr, IntoStatic};
22+use bon::Builder;
23+use bytes::Bytes;
24+use http::StatusCode;
25+use miette::Diagnostic;
26+use thiserror::Error;
27+use url::Url;
28+29+use crate::types::did_doc::DidDocument;
30+use crate::types::string::{Did, Handle};
31+use crate::types::value::AtDataError;
32+/// Errors that can occur during identity resolution.
33+///
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")]
43+ InvalidWellKnown,
44+ #[error("missing PDS endpoint in DID document")]
45+ MissingPdsEndpoint,
46+ #[error("HTTP error: {0}")]
47+ Http(#[from] TransportError),
48+ #[error("HTTP status {0}")]
49+ HttpStatus(StatusCode),
50+ #[error("XRPC error: {0}")]
51+ Xrpc(String),
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}")]
60+ InvalidDoc(String),
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")]
65+ DocIdMismatch {
66+ expected: Did<'static>,
67+ doc: DidDocument<'static>,
68+ },
69+}
70+71+/// Source to fetch PLC (did:plc) documents from.
72+///
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
80+ PlcDirectory {
81+ /// Base URL for the PLC directory
82+ base: Url,
83+ },
84+ /// Use the slingshot mini-docs service
85+ Slingshot {
86+ /// Base URL for the Slingshot service
87+ base: Url,
88+ },
89+}
90+91+impl Default for PlcSource {
92+ fn default() -> Self {
93+ Self::PlcDirectory {
94+ base: Url::parse("https://plc.directory/").expect("valid url"),
95+ }
96+ }
97+}
98+99+impl PlcSource {
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"),
104+ }
105+ }
106+}
107+108+/// DID Document fetch response for borrowed/owned parsing.
109+///
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.
115+#[derive(Clone)]
116+pub struct DidDocResponse {
117+ pub buffer: Bytes,
118+ pub status: StatusCode,
119+ /// Optional DID we intended to resolve; used for validation helpers
120+ pub requested: Option<Did<'static>>,
121+}
122+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+ if let Ok(doc) = serde_json::from_slice::<DidDocument<'b>>(&self.buffer) {
128+ Ok(doc)
129+ } else if let Ok(mini_doc) = serde_json::from_slice::<MiniDoc<'b>>(&self.buffer) {
130+ Ok(DidDocument {
131+ id: mini_doc.did,
132+ also_known_as: Some(vec![CowStr::from(mini_doc.handle)]),
133+ verification_method: None,
134+ service: Some(vec![Service {
135+ id: CowStr::new_static("#atproto_pds"),
136+ r#type: CowStr::new_static("AtprotoPersonalDataServer"),
137+ service_endpoint: Some(Data::String(AtprotoStr::Uri(Uri::Https(
138+ Url::from_str(&mini_doc.pds).unwrap(),
139+ )))),
140+ extra_data: BTreeMap::new(),
141+ }]),
142+ extra_data: BTreeMap::new(),
143+ })
144+ } else {
145+ Err(IdentityError::MissingPdsEndpoint)
146+ }
147+ } else {
148+ Err(IdentityError::HttpStatus(self.status))
149+ }
150+ }
151+152+ /// Parse and validate that the DID in the document matches the requested DID if present.
153+ ///
154+ /// On mismatch, returns an error that contains the owned document for inspection.
155+ pub fn parse_validated<'b>(&'b self) -> Result<DidDocument<'b>, IdentityError> {
156+ let doc = self.parse()?;
157+ if let Some(expected) = &self.requested {
158+ if doc.id.as_str() != expected.as_str() {
159+ return Err(IdentityError::DocIdMismatch {
160+ expected: expected.clone(),
161+ doc: doc.clone().into_static(),
162+ });
163+ }
164+ }
165+ Ok(doc)
166+ }
167+168+ /// Parse as owned DidDocument<'static>
169+ pub fn into_owned(self) -> Result<DidDocument<'static>, IdentityError> {
170+ if self.status.is_success() {
171+ if let Ok(doc) = serde_json::from_slice::<DidDocument<'_>>(&self.buffer) {
172+ Ok(doc.into_static())
173+ } else if let Ok(mini_doc) = serde_json::from_slice::<MiniDoc<'_>>(&self.buffer) {
174+ Ok(DidDocument {
175+ id: mini_doc.did,
176+ also_known_as: Some(vec![CowStr::from(mini_doc.handle)]),
177+ verification_method: None,
178+ service: Some(vec![Service {
179+ id: CowStr::new_static("#atproto_pds"),
180+ r#type: CowStr::new_static("AtprotoPersonalDataServer"),
181+ service_endpoint: Some(Data::String(AtprotoStr::Uri(Uri::Https(
182+ Url::from_str(&mini_doc.pds).unwrap(),
183+ )))),
184+ extra_data: BTreeMap::new(),
185+ }]),
186+ extra_data: BTreeMap::new(),
187+ }
188+ .into_static())
189+ } else {
190+ Err(IdentityError::MissingPdsEndpoint)
191+ }
192+ } else {
193+ Err(IdentityError::HttpStatus(self.status))
194+ }
195+ }
196+}
197+198+/// Slingshot mini-doc data (subset of DID doc info)
199+#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
200+#[serde(rename_all = "camelCase")]
201+#[allow(missing_docs)]
202+pub struct MiniDoc<'a> {
203+ #[serde(borrow)]
204+ pub did: Did<'a>,
205+ #[serde(borrow)]
206+ pub handle: Handle<'a>,
207+ #[serde(borrow)]
208+ pub pds: crate::CowStr<'a>,
209+ #[serde(borrow, rename = "signingKey", alias = "signing_key")]
210+ pub signing_key: crate::CowStr<'a>,
211+}
212+213+/// Handle → DID fallback step.
214+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
215+pub enum HandleStep {
216+ /// DNS TXT _atproto.\<handle\>
217+ DnsTxt,
218+ /// HTTPS GET https://\<handle\>/.well-known/atproto-did
219+ HttpsWellKnown,
220+ /// XRPC com.atproto.identity.resolveHandle against a provided PDS base
221+ PdsResolveHandle,
222+}
223+224+/// DID → Doc fallback step.
225+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
226+pub enum DidStep {
227+ /// For did:web: fetch from the well-known location
228+ DidWebHttps,
229+ /// For did:plc: fetch from PLC source
230+ PlcHttp,
231+ /// If a PDS base is known, ask it for the DID doc
232+ PdsResolveDid,
233+}
234+235+/// Configurable resolver options.
236+///
237+/// - `plc_source`: where to fetch did:plc documents (PLC Directory or Slingshot).
238+/// - `pds_fallback`: optional base URL of a PDS for XRPC fallbacks (stateless
239+/// XRPC over reqwest; authentication can be layered as needed).
240+/// - `handle_order`/`did_order`: ordered strategies for resolution.
241+/// - `validate_doc_id`: if true (default), convenience helpers validate doc `id` against the requested DID,
242+/// returning `DocIdMismatch` with the fetched document on mismatch.
243+/// - `public_fallback_for_handle`: if true (default), attempt
244+/// `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle` as an unauth fallback.
245+/// There is no public fallback for DID documents; when `PdsResolveDid` is chosen and the PDS XRPC
246+/// client fails, the resolver falls back to Slingshot mini-doc (partial) if `PlcSource::Slingshot` is configured.
247+#[derive(Debug, Clone, Builder)]
248+#[builder(start_fn = new)]
249+pub struct ResolverOptions {
250+ /// PLC data source (directory or slingshot)
251+ pub plc_source: PlcSource,
252+ /// Optional PDS base to use for fallbacks
253+ pub pds_fallback: Option<Url>,
254+ /// Order of attempts for handle → DID resolution
255+ pub handle_order: Vec<HandleStep>,
256+ /// Order of attempts for DID → Doc resolution
257+ pub did_order: Vec<DidStep>,
258+ /// Validate that fetched DID document id matches the requested DID
259+ pub validate_doc_id: bool,
260+ /// Allow public unauthenticated fallback for resolveHandle via public.api.bsky.app
261+ pub public_fallback_for_handle: bool,
262+}
263+264+impl Default for ResolverOptions {
265+ fn default() -> Self {
266+ // By default, prefer DNS then HTTPS for handles, then PDS fallback
267+ // For DID documents, prefer method-native sources, then PDS fallback
268+ Self::new()
269+ .plc_source(PlcSource::default())
270+ .handle_order(vec![
271+ HandleStep::DnsTxt,
272+ HandleStep::HttpsWellKnown,
273+ HandleStep::PdsResolveHandle,
274+ ])
275+ .did_order(vec![
276+ DidStep::DidWebHttps,
277+ DidStep::PlcHttp,
278+ DidStep::PdsResolveDid,
279+ ])
280+ .validate_doc_id(true)
281+ .public_fallback_for_handle(true)
282+ .build()
283+ }
284+}
285+286+/// Trait for identity resolution, for pluggable implementations.
287+///
288+/// The provided `DefaultResolver` supports:
289+/// - DNS TXT (`_atproto.<handle>`) when compiled with the `dns` feature
290+/// - HTTPS well-known for handles and `did:web`
291+/// - PLC directory or Slingshot for `did:plc`
292+/// - Slingshot `resolveHandle` (unauthenticated) when configured as the PLC source
293+/// - PDS fallbacks via helpers that use stateless XRPC on top of reqwest
294+#[async_trait::async_trait]
295+pub trait IdentityResolver {
296+ /// Access options for validation decisions in default methods
297+ fn options(&self) -> &ResolverOptions;
298+299+ /// Resolve handle
300+ async fn resolve_handle(&self, handle: &Handle<'_>) -> Result<Did<'static>, IdentityError>;
301+302+ /// Resolve DID document
303+ async fn resolve_did_doc(&self, did: &Did<'_>) -> Result<DidDocResponse, IdentityError>;
304+305+ /// Resolve DID doc from an identifier
306+ async fn resolve_ident(
307+ &self,
308+ actor: &AtIdentifier<'_>,
309+ ) -> Result<DidDocResponse, IdentityError> {
310+ match actor {
311+ AtIdentifier::Did(did) => self.resolve_did_doc(&did).await,
312+ AtIdentifier::Handle(handle) => {
313+ let did = self.resolve_handle(&handle).await?;
314+ self.resolve_did_doc(&did).await
315+ }
316+ }
317+ }
318+319+ /// Resolve DID doc from an identifier
320+ async fn resolve_ident_owned(
321+ &self,
322+ actor: &AtIdentifier<'_>,
323+ ) -> Result<DidDocument<'static>, IdentityError> {
324+ match actor {
325+ AtIdentifier::Did(did) => self.resolve_did_doc_owned(&did).await,
326+ AtIdentifier::Handle(handle) => {
327+ let did = self.resolve_handle(&handle).await?;
328+ self.resolve_did_doc_owned(&did).await
329+ }
330+ }
331+ }
332+333+ /// Resolve the DID document and return an owned version
334+ async fn resolve_did_doc_owned(
335+ &self,
336+ did: &Did<'_>,
337+ ) -> Result<DidDocument<'static>, IdentityError> {
338+ self.resolve_did_doc(did).await?.into_owned()
339+ }
340+ /// Return the PDS url for a DID
341+ async fn pds_for_did(&self, did: &Did<'_>) -> Result<Url, IdentityError> {
342+ let resp = self.resolve_did_doc(did).await?;
343+ let doc = resp.parse()?;
344+ // Default-on doc id equality check
345+ if self.options().validate_doc_id {
346+ if doc.id.as_str() != did.as_str() {
347+ return Err(IdentityError::DocIdMismatch {
348+ expected: did.clone().into_static(),
349+ doc: doc.clone().into_static(),
350+ });
351+ }
352+ }
353+ doc.pds_endpoint().ok_or(IdentityError::MissingPdsEndpoint)
354+ }
355+ /// Return the DIS and PDS url for a handle
356+ async fn pds_for_handle(
357+ &self,
358+ handle: &Handle<'_>,
359+ ) -> Result<(Did<'static>, Url), IdentityError> {
360+ let did = self.resolve_handle(handle).await?;
361+ let pds = self.pds_for_did(&did).await?;
362+ Ok((did, pds))
363+ }
364+}
365+366+#[cfg(test)]
367+mod tests {
368+ use super::*;
369+370+ #[test]
371+ fn parse_validated_ok() {
372+ let buf = Bytes::from_static(br#"{"id":"did:plc:alice"}"#);
373+ let requested = Did::new_owned("did:plc:alice").unwrap();
374+ let resp = DidDocResponse {
375+ buffer: buf,
376+ status: StatusCode::OK,
377+ requested: Some(requested),
378+ };
379+ let _doc = resp.parse_validated().expect("valid");
380+ }
381+382+ #[test]
383+ fn parse_validated_mismatch() {
384+ let buf = Bytes::from_static(br#"{"id":"did:plc:bob"}"#);
385+ let requested = Did::new_owned("did:plc:alice").unwrap();
386+ let resp = DidDocResponse {
387+ buffer: buf,
388+ status: StatusCode::OK,
389+ requested: Some(requested),
390+ };
391+ match resp.parse_validated() {
392+ Err(IdentityError::DocIdMismatch { expected, doc }) => {
393+ assert_eq!(expected.as_str(), "did:plc:alice");
394+ assert_eq!(doc.id.as_str(), "did:plc:bob");
395+ }
396+ other => panic!("unexpected result: {:?}", other),
397+ }
398+ }
399+}
+15
crates/jacquard-common/src/lib.rs
···13#[macro_use]
14/// Trait for taking ownership of most borrowed types in jacquard.
15pub mod into_static;
000016pub mod macros;
0017/// Baseline fundamental AT Protocol data types.
18pub mod types;
000000000
···13#[macro_use]
14/// Trait for taking ownership of most borrowed types in jacquard.
15pub mod into_static;
16+pub mod error;
17+/// HTTP client abstraction used by jacquard crates.
18+pub mod http_client;
19+pub mod ident_resolver;
20pub mod macros;
21+/// Generic session storage traits and utilities.
22+pub mod session;
23/// Baseline fundamental AT Protocol data types.
24pub mod types;
25+26+/// Authorization token types for XRPC requests.
27+#[derive(Debug, Clone)]
28+pub enum AuthorizationToken<'s> {
29+ /// Bearer token (access JWT, refresh JWT to refresh the session)
30+ Bearer(CowStr<'s>),
31+ /// DPoP token (proof-of-possession) for OAuth
32+ Dpop(CowStr<'s>),
33+}
···251/// detailing the specification for the type
252/// `source` is the source string, or part of it
253/// `kind` is the type of parsing error: `[StrParseKind]`
254-#[derive(Debug, thiserror::Error, miette::Diagnostic)]
255#[error("error in `{source}`: {kind}")]
256#[diagnostic(
257 url("https://atproto.com/specs/{spec}"),
···406}
407408/// Kinds of parsing errors for AT Protocol string types
409-#[derive(Debug, thiserror::Error, miette::Diagnostic)]
410pub enum StrParseKind {
411 /// Regex pattern validation failed
412 #[error("regex failure - {message}")]
···251/// detailing the specification for the type
252/// `source` is the source string, or part of it
253/// `kind` is the type of parsing error: `[StrParseKind]`
254+#[derive(Debug, thiserror::Error, miette::Diagnostic, PartialEq, Eq, Clone)]
255#[error("error in `{source}`: {kind}")]
256#[diagnostic(
257 url("https://atproto.com/specs/{spec}"),
···406}
407408/// Kinds of parsing errors for AT Protocol string types
409+#[derive(Debug, thiserror::Error, miette::Diagnostic, PartialEq, Eq, Clone)]
410pub enum StrParseKind {
411 /// Regex pattern validation failed
412 #[error("regex failure - {message}")]
···1+use crate::jose::create_signed_jwt;
2+use crate::jose::jws::RegisteredHeader;
3+use crate::jose::jwt::Claims;
4+use jacquard_common::CowStr;
5+use jose_jwa::{Algorithm, Signing};
6+use jose_jwk::{Class, EcCurves, crypto};
7+use jose_jwk::{Jwk, JwkSet, Key};
8+use smol_str::{SmolStr, ToSmolStr};
9+use std::collections::HashSet;
10+use thiserror::Error;
11+12+#[derive(Error, Debug)]
13+pub enum Error {
14+ #[error("duplicate kid: {0}")]
15+ DuplicateKid(String),
16+ #[error("keys must not be empty")]
17+ EmptyKeys,
18+ #[error("key must have a `kid`")]
19+ EmptyKid,
20+ #[error("no signing key found for algorithms: {0:?}")]
21+ NotFound(Vec<SmolStr>),
22+ #[error("key for signing must be a secret key")]
23+ PublicKey,
24+ #[error("crypto error: {0:?}")]
25+ JwkCrypto(crypto::Error),
26+ #[error(transparent)]
27+ SerdeJson(#[from] serde_json::Error),
28+}
29+30+pub type Result<T> = core::result::Result<T, Error>;
31+32+#[derive(Clone, Debug, Default, PartialEq, Eq)]
33+pub struct Keyset(Vec<Jwk>);
34+35+impl Keyset {
36+ const PREFERRED_SIGNING_ALGORITHMS: [&'static str; 9] = [
37+ "EdDSA", "ES256K", "ES256", "PS256", "PS384", "PS512", "HS256", "HS384", "HS512",
38+ ];
39+ pub fn public_jwks(&self) -> JwkSet {
40+ let mut keys = Vec::with_capacity(self.0.len());
41+ for mut key in self.0.clone() {
42+ match key.key {
43+ Key::Ec(ref mut ec) => {
44+ ec.d = None;
45+ }
46+ _ => unimplemented!(),
47+ }
48+ keys.push(key);
49+ }
50+ JwkSet { keys }
51+ }
52+ pub fn create_jwt(&self, algs: &[SmolStr], claims: Claims) -> Result<CowStr<'static>> {
53+ let Some(jwk) = self.find_key(algs, Class::Signing) else {
54+ return Err(Error::NotFound(algs.to_vec()));
55+ };
56+ self.create_jwt_with_key(jwk, claims)
57+ }
58+ fn find_key(&self, algs: &[SmolStr], cls: Class) -> Option<&Jwk> {
59+ let candidates = self
60+ .0
61+ .iter()
62+ .filter_map(|key| {
63+ if key.prm.cls.is_some_and(|c| c != cls) {
64+ return None;
65+ }
66+ let alg = match &key.key {
67+ Key::Ec(ec) => match ec.crv {
68+ EcCurves::P256 => "ES256",
69+ _ => unimplemented!(),
70+ },
71+ _ => unimplemented!(),
72+ };
73+ Some((alg, key)).filter(|(alg, _)| algs.contains(&alg.to_smolstr()))
74+ })
75+ .collect::<Vec<_>>();
76+ for pref_alg in Self::PREFERRED_SIGNING_ALGORITHMS {
77+ for (alg, key) in &candidates {
78+ if alg == &pref_alg {
79+ return Some(key);
80+ }
81+ }
82+ }
83+ None
84+ }
85+ fn create_jwt_with_key(&self, key: &Jwk, claims: Claims) -> Result<CowStr<'static>> {
86+ let kid = key.prm.kid.clone().unwrap();
87+ match crypto::Key::try_from(&key.key).map_err(Error::JwkCrypto)? {
88+ crypto::Key::P256(crypto::Kind::Secret(secret_key)) => {
89+ let mut header = RegisteredHeader::from(Algorithm::Signing(Signing::Es256));
90+ header.kid = Some(kid.into());
91+ Ok(create_signed_jwt(secret_key.into(), header.into(), claims)?)
92+ }
93+ _ => unimplemented!(),
94+ }
95+ }
96+}
97+98+impl TryFrom<Vec<Jwk>> for Keyset {
99+ type Error = Error;
100+101+ fn try_from(keys: Vec<Jwk>) -> Result<Self> {
102+ if keys.is_empty() {
103+ return Err(Error::EmptyKeys);
104+ }
105+ let mut v = Vec::with_capacity(keys.len());
106+ let mut hs = HashSet::with_capacity(keys.len());
107+ for key in keys {
108+ if let Some(kid) = key.prm.kid.clone() {
109+ if hs.contains(&kid) {
110+ return Err(Error::DuplicateKid(kid));
111+ }
112+ hs.insert(kid);
113+ // ensure that the key is a secret key
114+ if match crypto::Key::try_from(&key.key).map_err(Error::JwkCrypto)? {
115+ crypto::Key::P256(crypto::Kind::Public(_)) => true,
116+ crypto::Key::P256(crypto::Kind::Secret(_)) => false,
117+ _ => unimplemented!(),
118+ } {
119+ return Err(Error::PublicKey);
120+ }
121+ v.push(key);
122+ } else {
123+ return Err(Error::EmptyKid);
124+ }
125+ }
126+ Ok(Self(v))
127+ }
128+}
+14
crates/jacquard-oauth/src/lib.rs
···00000000000000
···1+//! Core OAuth 2.1 (AT Protocol profile) types and helpers for Jacquard.
2+//! Transport, discovery, and orchestration live in `jacquard`.
3+4+pub mod atproto;
5+pub mod dpop;
6+pub mod error;
7+pub mod jose;
8+pub mod keyset;
9+pub mod resolver;
10+pub mod scopes;
11+pub mod session;
12+pub mod types;
13+14+pub const FALLBACK_ALG: &str = "ES256";
···1-//! Identity resolution utilities: DID and handle resolution, DID document fetch,
2-//! and helpers for PDS endpoint discovery. See `identity::resolver` for details.
3-pub mod resolver;
···001//! Identity resolution: handle → DID and DID → document, with smart fallbacks.
2//!
3//! Fallback order (default):
···9//! Parsing returns a `DidDocResponse` so callers can borrow from the response buffer
10//! and optionally validate the document `id` against the requested DID.
1112-use std::collections::BTreeMap;
13-use std::str::FromStr;
14-15// use crate::CowStr; // not currently needed directly here
16-use crate::client::XrpcExt;
17-use bon::Builder;
18use bytes::Bytes;
19-use jacquard_common::types::did_doc::Service;
20-use jacquard_common::types::string::AtprotoStr;
21-use jacquard_common::types::uri::Uri;
22-use jacquard_common::types::value::Data;
23-use jacquard_common::{CowStr, IntoStatic};
24-use miette::Diagnostic;
0025use percent_encoding::percent_decode_str;
26use reqwest::StatusCode;
27-use thiserror::Error;
28use url::{ParseError, Url};
2930use crate::api::com_atproto::identity::{resolve_did, resolve_handle::ResolveHandle};
31use crate::types::did_doc::DidDocument;
32use crate::types::ident::AtIdentifier;
33use crate::types::string::{Did, Handle};
34-use crate::types::value::AtDataError;
3536#[cfg(feature = "dns")]
37use hickory_resolver::{TokioAsyncResolver, config::ResolverConfig};
3839-/// Errors that can occur during identity resolution.
40-///
41-/// Note: when validating a fetched DID document against a requested DID, a
42-/// `DocIdMismatch` error is returned that includes the owned document so callers
43-/// can inspect it and decide how to proceed.
44-#[derive(Debug, Error, Diagnostic)]
45-#[allow(missing_docs)]
46-pub enum IdentityError {
47- #[error("unsupported DID method: {0}")]
48- UnsupportedDidMethod(String),
49- #[error("invalid well-known atproto-did content")]
50- InvalidWellKnown,
51- #[error("missing PDS endpoint in DID document")]
52- MissingPdsEndpoint,
53- #[error("HTTP error: {0}")]
54- Http(#[from] reqwest::Error),
55- #[error("HTTP status {0}")]
56- HttpStatus(StatusCode),
57- #[error("XRPC error: {0}")]
58- Xrpc(String),
59- #[error("URL parse error: {0}")]
60- Url(#[from] url::ParseError),
61- #[error("DNS error: {0}")]
62- #[cfg(feature = "dns")]
63- Dns(#[from] hickory_resolver::error::ResolveError),
64- #[error("serialize/deserialize error: {0}")]
65- Serde(#[from] serde_json::Error),
66- #[error("invalid DID document: {0}")]
67- InvalidDoc(String),
68- #[error(transparent)]
69- Data(#[from] AtDataError),
70- /// DID document id did not match requested DID; includes the fetched document
71- #[error("DID doc id mismatch")]
72- DocIdMismatch {
73- expected: Did<'static>,
74- doc: DidDocument<'static>,
75- },
76-}
77-78-/// Source to fetch PLC (did:plc) documents from.
79-///
80-/// - `PlcDirectory`: uses the public PLC directory (default `https://plc.directory/`).
81-/// - `Slingshot`: uses Slingshot which also exposes convenience endpoints such as
82-/// `com.atproto.identity.resolveHandle` and a "mini-doc"
83-/// endpoint (`com.bad-example.identity.resolveMiniDoc`).
84-#[derive(Debug, Clone, PartialEq, Eq)]
85-pub enum PlcSource {
86- /// Use the public PLC directory
87- PlcDirectory {
88- /// Base URL for the PLC directory
89- base: Url,
90- },
91- /// Use the slingshot mini-docs service
92- Slingshot {
93- /// Base URL for the Slingshot service
94- base: Url,
95- },
96-}
97-98-impl Default for PlcSource {
99- fn default() -> Self {
100- Self::PlcDirectory {
101- base: Url::parse("https://plc.directory/").expect("valid url"),
102- }
103- }
104-}
105-106-impl PlcSource {
107- /// Default Slingshot source (`https://slingshot.microcosm.blue`)
108- pub fn slingshot_default() -> Self {
109- PlcSource::Slingshot {
110- base: Url::parse("https://slingshot.microcosm.blue").expect("valid url"),
111- }
112- }
113-}
114-115-/// DID Document fetch response for borrowed/owned parsing.
116-///
117-/// Carries the raw response bytes and the HTTP status, plus the requested DID
118-/// (if supplied) to enable validation. Use `parse()` to borrow from the buffer
119-/// or `parse_validated()` to also enforce that the doc `id` matches the
120-/// requested DID (returns a `DocIdMismatch` containing the fetched doc on
121-/// mismatch). Use `into_owned()` to parse into an owned document.
122-#[derive(Clone)]
123-pub struct DidDocResponse {
124- buffer: Bytes,
125- status: StatusCode,
126- /// Optional DID we intended to resolve; used for validation helpers
127- requested: Option<Did<'static>>,
128-}
129-130-impl DidDocResponse {
131- /// Parse as borrowed DidDocument<'_>
132- pub fn parse<'b>(&'b self) -> Result<DidDocument<'b>, IdentityError> {
133- if self.status.is_success() {
134- if let Ok(doc) = serde_json::from_slice::<DidDocument<'b>>(&self.buffer) {
135- Ok(doc)
136- } else if let Ok(mini_doc) = serde_json::from_slice::<MiniDoc<'b>>(&self.buffer) {
137- Ok(DidDocument {
138- id: mini_doc.did,
139- also_known_as: Some(vec![CowStr::from(mini_doc.handle)]),
140- verification_method: None,
141- service: Some(vec![Service {
142- id: CowStr::new_static("#atproto_pds"),
143- r#type: CowStr::new_static("AtprotoPersonalDataServer"),
144- service_endpoint: Some(Data::String(AtprotoStr::Uri(Uri::Https(
145- Url::from_str(&mini_doc.pds).unwrap(),
146- )))),
147- extra_data: BTreeMap::new(),
148- }]),
149- extra_data: BTreeMap::new(),
150- })
151- } else {
152- Err(IdentityError::MissingPdsEndpoint)
153- }
154- } else {
155- Err(IdentityError::HttpStatus(self.status))
156- }
157- }
158-159- /// Parse and validate that the DID in the document matches the requested DID if present.
160- ///
161- /// On mismatch, returns an error that contains the owned document for inspection.
162- pub fn parse_validated<'b>(&'b self) -> Result<DidDocument<'b>, IdentityError> {
163- let doc = self.parse()?;
164- if let Some(expected) = &self.requested {
165- if doc.id.as_str() != expected.as_str() {
166- return Err(IdentityError::DocIdMismatch {
167- expected: expected.clone(),
168- doc: doc.clone().into_static(),
169- });
170- }
171- }
172- Ok(doc)
173- }
174-175- /// Parse as owned DidDocument<'static>
176- pub fn into_owned(self) -> Result<DidDocument<'static>, IdentityError> {
177- if self.status.is_success() {
178- if let Ok(doc) = serde_json::from_slice::<DidDocument<'_>>(&self.buffer) {
179- Ok(doc.into_static())
180- } else if let Ok(mini_doc) = serde_json::from_slice::<MiniDoc<'_>>(&self.buffer) {
181- Ok(DidDocument {
182- id: mini_doc.did,
183- also_known_as: Some(vec![CowStr::from(mini_doc.handle)]),
184- verification_method: None,
185- service: Some(vec![Service {
186- id: CowStr::new_static("#atproto_pds"),
187- r#type: CowStr::new_static("AtprotoPersonalDataServer"),
188- service_endpoint: Some(Data::String(AtprotoStr::Uri(Uri::Https(
189- Url::from_str(&mini_doc.pds).unwrap(),
190- )))),
191- extra_data: BTreeMap::new(),
192- }]),
193- extra_data: BTreeMap::new(),
194- }
195- .into_static())
196- } else {
197- Err(IdentityError::MissingPdsEndpoint)
198- }
199- } else {
200- Err(IdentityError::HttpStatus(self.status))
201- }
202- }
203-}
204-205-/// Handle → DID fallback step.
206-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
207-pub enum HandleStep {
208- /// DNS TXT _atproto.\<handle\>
209- DnsTxt,
210- /// HTTPS GET https://\<handle\>/.well-known/atproto-did
211- HttpsWellKnown,
212- /// XRPC com.atproto.identity.resolveHandle against a provided PDS base
213- PdsResolveHandle,
214-}
215-216-/// DID → Doc fallback step.
217-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
218-pub enum DidStep {
219- /// For did:web: fetch from the well-known location
220- DidWebHttps,
221- /// For did:plc: fetch from PLC source
222- PlcHttp,
223- /// If a PDS base is known, ask it for the DID doc
224- PdsResolveDid,
225-}
226-227-/// Configurable resolver options.
228-///
229-/// - `plc_source`: where to fetch did:plc documents (PLC Directory or Slingshot).
230-/// - `pds_fallback`: optional base URL of a PDS for XRPC fallbacks (stateless
231-/// XRPC over reqwest; authentication can be layered as needed).
232-/// - `handle_order`/`did_order`: ordered strategies for resolution.
233-/// - `validate_doc_id`: if true (default), convenience helpers validate doc `id` against the requested DID,
234-/// returning `DocIdMismatch` with the fetched document on mismatch.
235-/// - `public_fallback_for_handle`: if true (default), attempt
236-/// `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle` as an unauth fallback.
237-/// There is no public fallback for DID documents; when `PdsResolveDid` is chosen and the PDS XRPC
238-/// client fails, the resolver falls back to Slingshot mini-doc (partial) if `PlcSource::Slingshot` is configured.
239-#[derive(Debug, Clone, Builder)]
240-#[builder(start_fn = new)]
241-pub struct ResolverOptions {
242- /// PLC data source (directory or slingshot)
243- pub plc_source: PlcSource,
244- /// Optional PDS base to use for fallbacks
245- pub pds_fallback: Option<Url>,
246- /// Order of attempts for handle → DID resolution
247- pub handle_order: Vec<HandleStep>,
248- /// Order of attempts for DID → Doc resolution
249- pub did_order: Vec<DidStep>,
250- /// Validate that fetched DID document id matches the requested DID
251- pub validate_doc_id: bool,
252- /// Allow public unauthenticated fallback for resolveHandle via public.api.bsky.app
253- pub public_fallback_for_handle: bool,
254-}
255-256-impl Default for ResolverOptions {
257- fn default() -> Self {
258- // By default, prefer DNS then HTTPS for handles, then PDS fallback
259- // For DID documents, prefer method-native sources, then PDS fallback
260- Self::new()
261- .plc_source(PlcSource::default())
262- .handle_order(vec![
263- HandleStep::DnsTxt,
264- HandleStep::HttpsWellKnown,
265- HandleStep::PdsResolveHandle,
266- ])
267- .did_order(vec![
268- DidStep::DidWebHttps,
269- DidStep::PlcHttp,
270- DidStep::PdsResolveDid,
271- ])
272- .validate_doc_id(true)
273- .public_fallback_for_handle(true)
274- .build()
275- }
276-}
277-278-/// Trait for identity resolution, for pluggable implementations.
279-///
280-/// The provided `DefaultResolver` supports:
281-/// - DNS TXT (`_atproto.<handle>`) when compiled with the `dns` feature
282-/// - HTTPS well-known for handles and `did:web`
283-/// - PLC directory or Slingshot for `did:plc`
284-/// - Slingshot `resolveHandle` (unauthenticated) when configured as the PLC source
285-/// - PDS fallbacks via helpers that use stateless XRPC on top of reqwest
286-#[async_trait::async_trait]
287-pub trait IdentityResolver {
288- /// Access options for validation decisions in default methods
289- fn options(&self) -> &ResolverOptions;
290-291- /// Resolve handle
292- async fn resolve_handle(&self, handle: &Handle<'_>) -> Result<Did<'static>, IdentityError>;
293-294- /// Resolve DID document
295- async fn resolve_did_doc(&self, did: &Did<'_>) -> Result<DidDocResponse, IdentityError>;
296-297- /// Resolve the DID document and return an owned version
298- async fn resolve_did_doc_owned(
299- &self,
300- did: &Did<'_>,
301- ) -> Result<DidDocument<'static>, IdentityError> {
302- self.resolve_did_doc(did).await?.into_owned()
303- }
304- /// reutrn the PDS url for a DID
305- async fn pds_for_did(&self, did: &Did<'_>) -> Result<Url, IdentityError> {
306- let resp = self.resolve_did_doc(did).await?;
307- let doc = resp.parse()?;
308- // Default-on doc id equality check
309- if self.options().validate_doc_id {
310- if doc.id.as_str() != did.as_str() {
311- return Err(IdentityError::DocIdMismatch {
312- expected: did.clone().into_static(),
313- doc: doc.clone().into_static(),
314- });
315- }
316- }
317- doc.pds_endpoint().ok_or(IdentityError::MissingPdsEndpoint)
318- }
319- /// Return the DIS and PDS url for a handle
320- async fn pds_for_handle(
321- &self,
322- handle: &Handle<'_>,
323- ) -> Result<(Did<'static>, Url), IdentityError> {
324- let did = self.resolve_handle(handle).await?;
325- let pds = self.pds_for_did(&did).await?;
326- Ok((did, pds))
327- }
328-}
329-330/// Default resolver implementation with configurable fallback order.
331pub struct DefaultResolver {
332 http: reqwest::Client,
···343 opts,
344 #[cfg(feature = "dns")]
345 dns: None,
0000000000000346 }
347 }
348···415 }
416417 async fn get_json_bytes(&self, url: Url) -> Result<(Bytes, StatusCode), IdentityError> {
418- let resp = self.http.get(url).send().await?;
00000419 let status = resp.status();
420- let buf = resp.bytes().await?;
421 Ok((buf, status))
422 }
423424 async fn get_text(&self, url: Url) -> Result<String, IdentityError> {
425- let resp = self.http.get(url).send().await?;
00000426 if resp.status() == StatusCode::OK {
427- Ok(resp.text().await?)
428 } else {
429- Err(IdentityError::Http(resp.error_for_status().unwrap_err()))
00430 }
431 }
432···684 }
685}
68600000000000687/// Warnings produced during identity checks that are not fatal
688#[derive(Debug, Clone, PartialEq, Eq)]
689pub enum IdentityWarning {
···778 }
779}
780781-/// Slingshot mini-doc data (subset of DID doc info)
782-#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
783-#[serde(rename_all = "camelCase")]
784-#[allow(missing_docs)]
785-pub struct MiniDoc<'a> {
786- #[serde(borrow)]
787- pub did: Did<'a>,
788- #[serde(borrow)]
789- pub handle: Handle<'a>,
790- #[serde(borrow)]
791- pub pds: crate::CowStr<'a>,
792- #[serde(borrow, rename = "signingKey", alias = "signing_key")]
793- pub signing_key: crate::CowStr<'a>,
000000000000000000000794}
795796#[cfg(test)]
···811 }
812813 #[test]
814- fn parse_validated_ok() {
815- let buf = Bytes::from_static(br#"{"id":"did:plc:alice"}"#);
816- let requested = Did::new_owned("did:plc:alice").unwrap();
817- let resp = DidDocResponse {
818- buffer: buf,
819- status: StatusCode::OK,
820- requested: Some(requested),
821- };
822- let _doc = resp.parse_validated().expect("valid");
823- }
824-825- #[test]
826- fn parse_validated_mismatch() {
827- let buf = Bytes::from_static(br#"{"id":"did:plc:bob"}"#);
828- let requested = Did::new_owned("did:plc:alice").unwrap();
829- let resp = DidDocResponse {
830- buffer: buf,
831- status: StatusCode::OK,
832- requested: Some(requested),
833- };
834- match resp.parse_validated() {
835- Err(IdentityError::DocIdMismatch { expected, doc }) => {
836- assert_eq!(expected.as_str(), "did:plc:alice");
837- assert_eq!(doc.id.as_str(), "did:plc:bob");
838- }
839- other => panic!("unexpected result: {:?}", other),
840- }
841- }
842-843- #[test]
844 fn slingshot_mini_doc_url_build() {
845 let r = DefaultResolver::new(reqwest::Client::new(), ResolverOptions::default());
846 let base = Url::parse("https://slingshot.microcosm.blue").unwrap();
···893 }
894 }
895}
896-897-/// Resolver specialized for unauthenticated/public flows using reqwest and stateless XRPC
898-pub type PublicResolver = DefaultResolver;
899-900-impl Default for PublicResolver {
901- /// Build a resolver with:
902- /// - reqwest HTTP client
903- /// - Public fallbacks enabled for handle resolution
904- /// - default options (DNS enabled if compiled, public fallback for handles enabled)
905- ///
906- /// Example
907- /// ```ignore
908- /// use jacquard::identity::resolver::PublicResolver;
909- /// let resolver = PublicResolver::default();
910- /// ```
911- fn default() -> Self {
912- let http = reqwest::Client::new();
913- let opts = ResolverOptions::default();
914- let resolver = DefaultResolver::new(http, opts);
915- #[cfg(feature = "dns")]
916- let resolver = resolver.with_system_dns();
917- resolver
918- }
919-}
920-921-/// Build a resolver configured to use Slingshot (`https://slingshot.microcosm.blue`) for PLC and
922-/// mini-doc fallbacks, unauthenticated by default.
923-pub fn slingshot_resolver_default() -> PublicResolver {
924- let http = reqwest::Client::new();
925- let mut opts = ResolverOptions::default();
926- opts.plc_source = PlcSource::slingshot_default();
927- let resolver = DefaultResolver::new(http, opts);
928- #[cfg(feature = "dns")]
929- let resolver = resolver.with_system_dns();
930- resolver
931-}
···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):
···11//! Parsing returns a `DidDocResponse` so callers can borrow from the response buffer
12//! and optionally validate the document `id` against the requested DID.
1300014// use crate::CowStr; // not currently needed directly here
15+016use bytes::Bytes;
17+use jacquard_common::IntoStatic;
18+use jacquard_common::error::TransportError;
19+use jacquard_common::http_client::HttpClient;
20+use jacquard_common::ident_resolver::{
21+ DidDocResponse, DidStep, HandleStep, IdentityError, IdentityResolver, MiniDoc, PlcSource,
22+ ResolverOptions,
23+};
24+use jacquard_common::types::xrpc::XrpcExt;
25use percent_encoding::percent_decode_str;
26use reqwest::StatusCode;
027use url::{ParseError, Url};
2829use crate::api::com_atproto::identity::{resolve_did, resolve_handle::ResolveHandle};
30use crate::types::did_doc::DidDocument;
31use crate::types::ident::AtIdentifier;
32use crate::types::string::{Did, Handle};
03334#[cfg(feature = "dns")]
35use hickory_resolver::{TokioAsyncResolver, config::ResolverConfig};
3600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000037/// Default resolver implementation with configurable fallback order.
38pub struct DefaultResolver {
39 http: reqwest::Client,
···50 opts,
51 #[cfg(feature = "dns")]
52 dns: None,
53+ }
54+ }
55+56+ #[cfg(feature = "dns")]
57+ /// Create a new instance of the default resolver with all options, plus default DNS, up front
58+ pub fn new_dns(http: reqwest::Client, opts: ResolverOptions) -> Self {
59+ Self {
60+ http,
61+ opts,
62+ dns: Some(TokioAsyncResolver::tokio(
63+ ResolverConfig::default(),
64+ Default::default(),
65+ )),
66 }
67 }
68···135 }
136137 async fn get_json_bytes(&self, url: Url) -> Result<(Bytes, StatusCode), IdentityError> {
138+ let resp = self
139+ .http
140+ .get(url)
141+ .send()
142+ .await
143+ .map_err(TransportError::from)?;
144 let status = resp.status();
145+ let buf = resp.bytes().await.map_err(TransportError::from)?;
146 Ok((buf, status))
147 }
148149 async fn get_text(&self, url: Url) -> Result<String, IdentityError> {
150+ let resp = self
151+ .http
152+ .get(url)
153+ .send()
154+ .await
155+ .map_err(TransportError::from)?;
156 if resp.status() == StatusCode::OK {
157+ Ok(resp.text().await.map_err(TransportError::from)?)
158 } else {
159+ Err(IdentityError::Http(
160+ resp.error_for_status().unwrap_err().into(),
161+ ))
162 }
163 }
164···416 }
417}
418419+impl HttpClient for DefaultResolver {
420+ async fn send_http(
421+ &self,
422+ request: http::Request<Vec<u8>>,
423+ ) -> core::result::Result<http::Response<Vec<u8>>, Self::Error> {
424+ self.http.send_http(request).await
425+ }
426+427+ type Error = reqwest::Error;
428+}
429+430/// Warnings produced during identity checks that are not fatal
431#[derive(Debug, Clone, PartialEq, Eq)]
432pub enum IdentityWarning {
···521 }
522}
523524+/// Resolver specialized for unauthenticated/public flows using reqwest and stateless XRPC
525+pub type PublicResolver = DefaultResolver;
526+527+impl Default for PublicResolver {
528+ /// Build a resolver with:
529+ /// - reqwest HTTP client
530+ /// - Public fallbacks enabled for handle resolution
531+ /// - default options (DNS enabled if compiled, public fallback for handles enabled)
532+ ///
533+ /// Example
534+ /// ```ignore
535+ /// use jacquard::identity::resolver::PublicResolver;
536+ /// let resolver = PublicResolver::default();
537+ /// ```
538+ fn default() -> Self {
539+ let http = reqwest::Client::new();
540+ let opts = ResolverOptions::default();
541+ let resolver = DefaultResolver::new(http, opts);
542+ #[cfg(feature = "dns")]
543+ let resolver = resolver.with_system_dns();
544+ resolver
545+ }
546+}
547+548+/// Build a resolver configured to use Slingshot (`https://slingshot.microcosm.blue`) for PLC and
549+/// mini-doc fallbacks, unauthenticated by default.
550+pub fn slingshot_resolver_default() -> PublicResolver {
551+ let http = reqwest::Client::new();
552+ let mut opts = ResolverOptions::default();
553+ opts.plc_source = PlcSource::slingshot_default();
554+ let resolver = DefaultResolver::new(http, opts);
555+ #[cfg(feature = "dns")]
556+ let resolver = resolver.with_system_dns();
557+ resolver
558}
559560#[cfg(test)]
···575 }
576577 #[test]
000000000000000000000000000000578 fn slingshot_mini_doc_url_build() {
579 let r = DefaultResolver::new(reqwest::Client::new(), ResolverOptions::default());
580 let base = Url::parse("https://slingshot.microcosm.blue").unwrap();
···627 }
628 }
629}
000000000000000000000000000000000000
+14-11
crates/jacquard/src/lib.rs
···24//! # use jacquard::CowStr;
25//! use jacquard::api::app_bsky::feed::get_timeline::GetTimeline;
26//! use jacquard::api::com_atproto::server::create_session::CreateSession;
27-//! use jacquard::client::{BasicClient, Session};
28//! # use miette::IntoDiagnostic;
29//!
30//! # #[derive(Parser, Debug)]
···50//! let url = url::Url::parse(&args.pds).unwrap();
51//! let client = BasicClient::new(url);
52//! // Create session
53-//! let session = Session::from(
54//! client
55//! .send(
56//! CreateSession::new()
···87//! optional `CallOptions` (auth, proxy, labelers, headers). Useful when you
88//! want to pass auth on each call or build advanced flows.
89//! ```no_run
90-//! # use jacquard::client::XrpcExt;
91//! # use jacquard::api::app_bsky::feed::get_author_feed::GetAuthorFeed;
92//! # use jacquard::types::ident::AtIdentifier;
093//! #
94//! #[tokio::main]
95-//! async fn main() -> anyhow::Result<()> {
96//! let http = reqwest::Client::new();
97-//! let base = url::Url::parse("https://public.api.bsky.app")?;
98//! let resp = http
99//! .xrpc(base)
100//! .send(
···110//! }
111//! ```
112//! - Stateful client: `AtClient<C, S>` holds a base `Url`, a transport, and a
113-//! `TokenStore` implementation. It automatically sets Authorization and can
114//! auto-refresh a session when expired, retrying once.
115//! - Convenience wrapper: `BasicClient` is an ergonomic newtype over
116-//! `AtClient<reqwest::Client, MemoryTokenStore>` with a `new(Url)` constructor.
117//!
118//! Per-request overrides (stateless)
119//! ```no_run
120-//! # use jacquard::client::{XrpcExt, AuthorizationToken};
0121//! # use jacquard::api::app_bsky::feed::get_author_feed::GetAuthorFeed;
122//! # use jacquard::types::ident::AtIdentifier;
123//! # use jacquard::CowStr;
···126//! #[tokio::main]
127//! async fn main() -> miette::Result<()> {
128//! let http = reqwest::Client::new();
129-//! let base = url::Url::parse("https://public.api.bsky.app")?;
130//! let resp = http
131//! .xrpc(base)
132//! .auth(AuthorizationToken::Bearer(CowStr::from("ACCESS_JWT")))
···146//! ```
147//!
148//! Token storage:
149-//! - Use `MemoryTokenStore` for ephemeral sessions, tests, and CLIs.
150-//! - For persistence, `FileTokenStore` stores session tokens as JSON on disk.
151//! See `client::token::FileTokenStore` docs for details.
152//! ```no_run
153//! use jacquard::client::{AtClient, FileTokenStore};
···161162/// XRPC client traits and basic implementation
163pub mod client;
0164165#[cfg(feature = "api")]
166/// If enabled, re-export the generated api crate
···24//! # use jacquard::CowStr;
25//! use jacquard::api::app_bsky::feed::get_timeline::GetTimeline;
26//! use jacquard::api::com_atproto::server::create_session::CreateSession;
27+//! use jacquard::client::{BasicClient, AuthSession, AtpSession};
28//! # use miette::IntoDiagnostic;
29//!
30//! # #[derive(Parser, Debug)]
···50//! let url = url::Url::parse(&args.pds).unwrap();
51//! let client = BasicClient::new(url);
52//! // Create session
53+//! let session = AtpSession::from(
54//! client
55//! .send(
56//! CreateSession::new()
···87//! optional `CallOptions` (auth, proxy, labelers, headers). Useful when you
88//! want to pass auth on each call or build advanced flows.
89//! ```no_run
90+//! # use jacquard::types::xrpc::XrpcExt;
91//! # use jacquard::api::app_bsky::feed::get_author_feed::GetAuthorFeed;
92//! # use jacquard::types::ident::AtIdentifier;
93+//! # use miette::IntoDiagnostic;
94//! #
95//! #[tokio::main]
96+//! async fn main() -> miette::Result<()> {
97//! let http = reqwest::Client::new();
98+//! let base = url::Url::parse("https://public.api.bsky.app").into_diagnostic()?;
99//! let resp = http
100//! .xrpc(base)
101//! .send(
···111//! }
112//! ```
113//! - Stateful client: `AtClient<C, S>` holds a base `Url`, a transport, and a
114+//! `SessionStore<AuthSession>` implementation. It automatically sets Authorization and can
115//! auto-refresh a session when expired, retrying once.
116//! - Convenience wrapper: `BasicClient` is an ergonomic newtype over
117+//! `AtClient<reqwest::Client, MemorySessionStore<AuthSession>>` with a `new(Url)` constructor.
118//!
119//! Per-request overrides (stateless)
120//! ```no_run
121+//! # use jacquard::AuthorizationToken;
122+//! # use jacquard::types::xrpc::XrpcExt;
123//! # use jacquard::api::app_bsky::feed::get_author_feed::GetAuthorFeed;
124//! # use jacquard::types::ident::AtIdentifier;
125//! # use jacquard::CowStr;
···128//! #[tokio::main]
129//! async fn main() -> miette::Result<()> {
130//! let http = reqwest::Client::new();
131+//! let base = url::Url::parse("https://public.api.bsky.app").into_diagnostic()?;
132//! let resp = http
133//! .xrpc(base)
134//! .auth(AuthorizationToken::Bearer(CowStr::from("ACCESS_JWT")))
···148//! ```
149//!
150//! Token storage:
151+//! - Use `MemorySessionStore<AuthSession>` for ephemeral sessions, tests, and CLIs.
152+//! - For persistence, `FileTokenStore` stores app-password sessions as JSON on disk.
153//! See `client::token::FileTokenStore` docs for details.
154//! ```no_run
155//! use jacquard::client::{AtClient, FileTokenStore};
···163164/// XRPC client traits and basic implementation
165pub mod client;
166+/// OAuth usage helpers (discovery, PAR, token exchange)
167168#[cfg(feature = "api")]
169/// If enabled, re-export the generated api crate