···11+//! Minimal HTTP client abstraction shared across crates.
22+33+use std::fmt::Display;
44+use std::future::Future;
55+use std::sync::Arc;
66+77+/// HTTP client trait for sending raw HTTP requests.
88+pub trait HttpClient {
99+ /// Error type returned by the HTTP client
1010+ type Error: std::error::Error + Display + Send + Sync + 'static;
1111+ /// Send an HTTP request and return the response.
1212+ fn send_http(
1313+ &self,
1414+ request: http::Request<Vec<u8>>,
1515+ ) -> impl Future<Output = core::result::Result<http::Response<Vec<u8>>, Self::Error>> + Send;
1616+}
1717+1818+#[cfg(feature = "reqwest-client")]
1919+impl HttpClient for reqwest::Client {
2020+ type Error = reqwest::Error;
2121+2222+ async fn send_http(
2323+ &self,
2424+ request: http::Request<Vec<u8>>,
2525+ ) -> core::result::Result<http::Response<Vec<u8>>, Self::Error> {
2626+ // Convert http::Request to reqwest::Request
2727+ let (parts, body) = request.into_parts();
2828+2929+ let mut req = self.request(parts.method, parts.uri.to_string()).body(body);
3030+3131+ // Copy headers
3232+ for (name, value) in parts.headers.iter() {
3333+ req = req.header(name.as_str(), value.as_bytes());
3434+ }
3535+3636+ // Send request
3737+ let resp = req.send().await?;
3838+3939+ // Convert reqwest::Response to http::Response
4040+ let mut builder = http::Response::builder().status(resp.status());
4141+4242+ // Copy headers
4343+ for (name, value) in resp.headers().iter() {
4444+ builder = builder.header(name.as_str(), value.as_bytes());
4545+ }
4646+4747+ // Read body
4848+ let body = resp.bytes().await?.to_vec();
4949+5050+ Ok(builder.body(body).expect("Failed to build response"))
5151+ }
5252+}
5353+5454+impl<T: HttpClient> HttpClient for Arc<T> {
5555+ type Error = T::Error;
5656+5757+ fn send_http(
5858+ &self,
5959+ request: http::Request<Vec<u8>>,
6060+ ) -> impl Future<Output = core::result::Result<http::Response<Vec<u8>>, Self::Error>> + Send
6161+ {
6262+ self.as_ref().send_http(request)
6363+ }
6464+}
+399
crates/jacquard-common/src/ident_resolver.rs
···11+//! Identity resolution: handle → DID and DID → document, with smart fallbacks.
22+//!
33+//! Fallback order (default):
44+//! - Handle → DID: DNS TXT (if `dns` feature) → HTTPS well-known → PDS XRPC
55+//! `resolveHandle` (when `pds_fallback` is configured) → public API fallback → Slingshot `resolveHandle` (if configured).
66+//! - DID → Doc: did:web well-known → PLC/Slingshot HTTP → PDS XRPC `resolveDid` (when configured),
77+//! then Slingshot mini‑doc (partial) if configured.
88+//!
99+//! Parsing returns a `DidDocResponse` so callers can borrow from the response buffer
1010+//! and optionally validate the document `id` against the requested DID.
1111+1212+use std::collections::BTreeMap;
1313+use std::str::FromStr;
1414+1515+use crate::error::TransportError;
1616+use crate::types::did_doc::Service;
1717+use crate::types::ident::AtIdentifier;
1818+use crate::types::string::AtprotoStr;
1919+use crate::types::uri::Uri;
2020+use crate::types::value::Data;
2121+use crate::{CowStr, IntoStatic};
2222+use bon::Builder;
2323+use bytes::Bytes;
2424+use http::StatusCode;
2525+use miette::Diagnostic;
2626+use thiserror::Error;
2727+use url::Url;
2828+2929+use crate::types::did_doc::DidDocument;
3030+use crate::types::string::{Did, Handle};
3131+use crate::types::value::AtDataError;
3232+/// Errors that can occur during identity resolution.
3333+///
3434+/// Note: when validating a fetched DID document against a requested DID, a
3535+/// `DocIdMismatch` error is returned that includes the owned document so callers
3636+/// can inspect it and decide how to proceed.
3737+#[derive(Debug, Error, Diagnostic)]
3838+#[allow(missing_docs)]
3939+pub enum IdentityError {
4040+ #[error("unsupported DID method: {0}")]
4141+ UnsupportedDidMethod(String),
4242+ #[error("invalid well-known atproto-did content")]
4343+ InvalidWellKnown,
4444+ #[error("missing PDS endpoint in DID document")]
4545+ MissingPdsEndpoint,
4646+ #[error("HTTP error: {0}")]
4747+ Http(#[from] TransportError),
4848+ #[error("HTTP status {0}")]
4949+ HttpStatus(StatusCode),
5050+ #[error("XRPC error: {0}")]
5151+ Xrpc(String),
5252+ #[error("URL parse error: {0}")]
5353+ Url(#[from] url::ParseError),
5454+ #[error("DNS error: {0}")]
5555+ #[cfg(feature = "dns")]
5656+ Dns(#[from] hickory_resolver::error::ResolveError),
5757+ #[error("serialize/deserialize error: {0}")]
5858+ Serde(#[from] serde_json::Error),
5959+ #[error("invalid DID document: {0}")]
6060+ InvalidDoc(String),
6161+ #[error(transparent)]
6262+ Data(#[from] AtDataError),
6363+ /// DID document id did not match requested DID; includes the fetched document
6464+ #[error("DID doc id mismatch")]
6565+ DocIdMismatch {
6666+ expected: Did<'static>,
6767+ doc: DidDocument<'static>,
6868+ },
6969+}
7070+7171+/// Source to fetch PLC (did:plc) documents from.
7272+///
7373+/// - `PlcDirectory`: uses the public PLC directory (default `https://plc.directory/`).
7474+/// - `Slingshot`: uses Slingshot which also exposes convenience endpoints such as
7575+/// `com.atproto.identity.resolveHandle` and a "mini-doc"
7676+/// endpoint (`com.bad-example.identity.resolveMiniDoc`).
7777+#[derive(Debug, Clone, PartialEq, Eq)]
7878+pub enum PlcSource {
7979+ /// Use the public PLC directory
8080+ PlcDirectory {
8181+ /// Base URL for the PLC directory
8282+ base: Url,
8383+ },
8484+ /// Use the slingshot mini-docs service
8585+ Slingshot {
8686+ /// Base URL for the Slingshot service
8787+ base: Url,
8888+ },
8989+}
9090+9191+impl Default for PlcSource {
9292+ fn default() -> Self {
9393+ Self::PlcDirectory {
9494+ base: Url::parse("https://plc.directory/").expect("valid url"),
9595+ }
9696+ }
9797+}
9898+9999+impl PlcSource {
100100+ /// Default Slingshot source (`https://slingshot.microcosm.blue`)
101101+ pub fn slingshot_default() -> Self {
102102+ PlcSource::Slingshot {
103103+ base: Url::parse("https://slingshot.microcosm.blue").expect("valid url"),
104104+ }
105105+ }
106106+}
107107+108108+/// DID Document fetch response for borrowed/owned parsing.
109109+///
110110+/// Carries the raw response bytes and the HTTP status, plus the requested DID
111111+/// (if supplied) to enable validation. Use `parse()` to borrow from the buffer
112112+/// or `parse_validated()` to also enforce that the doc `id` matches the
113113+/// requested DID (returns a `DocIdMismatch` containing the fetched doc on
114114+/// mismatch). Use `into_owned()` to parse into an owned document.
115115+#[derive(Clone)]
116116+pub struct DidDocResponse {
117117+ pub buffer: Bytes,
118118+ pub status: StatusCode,
119119+ /// Optional DID we intended to resolve; used for validation helpers
120120+ pub requested: Option<Did<'static>>,
121121+}
122122+123123+impl DidDocResponse {
124124+ /// Parse as borrowed DidDocument<'_>
125125+ pub fn parse<'b>(&'b self) -> Result<DidDocument<'b>, IdentityError> {
126126+ if self.status.is_success() {
127127+ if let Ok(doc) = serde_json::from_slice::<DidDocument<'b>>(&self.buffer) {
128128+ Ok(doc)
129129+ } else if let Ok(mini_doc) = serde_json::from_slice::<MiniDoc<'b>>(&self.buffer) {
130130+ Ok(DidDocument {
131131+ id: mini_doc.did,
132132+ also_known_as: Some(vec![CowStr::from(mini_doc.handle)]),
133133+ verification_method: None,
134134+ service: Some(vec![Service {
135135+ id: CowStr::new_static("#atproto_pds"),
136136+ r#type: CowStr::new_static("AtprotoPersonalDataServer"),
137137+ service_endpoint: Some(Data::String(AtprotoStr::Uri(Uri::Https(
138138+ Url::from_str(&mini_doc.pds).unwrap(),
139139+ )))),
140140+ extra_data: BTreeMap::new(),
141141+ }]),
142142+ extra_data: BTreeMap::new(),
143143+ })
144144+ } else {
145145+ Err(IdentityError::MissingPdsEndpoint)
146146+ }
147147+ } else {
148148+ Err(IdentityError::HttpStatus(self.status))
149149+ }
150150+ }
151151+152152+ /// Parse and validate that the DID in the document matches the requested DID if present.
153153+ ///
154154+ /// On mismatch, returns an error that contains the owned document for inspection.
155155+ pub fn parse_validated<'b>(&'b self) -> Result<DidDocument<'b>, IdentityError> {
156156+ let doc = self.parse()?;
157157+ if let Some(expected) = &self.requested {
158158+ if doc.id.as_str() != expected.as_str() {
159159+ return Err(IdentityError::DocIdMismatch {
160160+ expected: expected.clone(),
161161+ doc: doc.clone().into_static(),
162162+ });
163163+ }
164164+ }
165165+ Ok(doc)
166166+ }
167167+168168+ /// Parse as owned DidDocument<'static>
169169+ pub fn into_owned(self) -> Result<DidDocument<'static>, IdentityError> {
170170+ if self.status.is_success() {
171171+ if let Ok(doc) = serde_json::from_slice::<DidDocument<'_>>(&self.buffer) {
172172+ Ok(doc.into_static())
173173+ } else if let Ok(mini_doc) = serde_json::from_slice::<MiniDoc<'_>>(&self.buffer) {
174174+ Ok(DidDocument {
175175+ id: mini_doc.did,
176176+ also_known_as: Some(vec![CowStr::from(mini_doc.handle)]),
177177+ verification_method: None,
178178+ service: Some(vec![Service {
179179+ id: CowStr::new_static("#atproto_pds"),
180180+ r#type: CowStr::new_static("AtprotoPersonalDataServer"),
181181+ service_endpoint: Some(Data::String(AtprotoStr::Uri(Uri::Https(
182182+ Url::from_str(&mini_doc.pds).unwrap(),
183183+ )))),
184184+ extra_data: BTreeMap::new(),
185185+ }]),
186186+ extra_data: BTreeMap::new(),
187187+ }
188188+ .into_static())
189189+ } else {
190190+ Err(IdentityError::MissingPdsEndpoint)
191191+ }
192192+ } else {
193193+ Err(IdentityError::HttpStatus(self.status))
194194+ }
195195+ }
196196+}
197197+198198+/// Slingshot mini-doc data (subset of DID doc info)
199199+#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
200200+#[serde(rename_all = "camelCase")]
201201+#[allow(missing_docs)]
202202+pub struct MiniDoc<'a> {
203203+ #[serde(borrow)]
204204+ pub did: Did<'a>,
205205+ #[serde(borrow)]
206206+ pub handle: Handle<'a>,
207207+ #[serde(borrow)]
208208+ pub pds: crate::CowStr<'a>,
209209+ #[serde(borrow, rename = "signingKey", alias = "signing_key")]
210210+ pub signing_key: crate::CowStr<'a>,
211211+}
212212+213213+/// Handle → DID fallback step.
214214+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
215215+pub enum HandleStep {
216216+ /// DNS TXT _atproto.\<handle\>
217217+ DnsTxt,
218218+ /// HTTPS GET https://\<handle\>/.well-known/atproto-did
219219+ HttpsWellKnown,
220220+ /// XRPC com.atproto.identity.resolveHandle against a provided PDS base
221221+ PdsResolveHandle,
222222+}
223223+224224+/// DID → Doc fallback step.
225225+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
226226+pub enum DidStep {
227227+ /// For did:web: fetch from the well-known location
228228+ DidWebHttps,
229229+ /// For did:plc: fetch from PLC source
230230+ PlcHttp,
231231+ /// If a PDS base is known, ask it for the DID doc
232232+ PdsResolveDid,
233233+}
234234+235235+/// Configurable resolver options.
236236+///
237237+/// - `plc_source`: where to fetch did:plc documents (PLC Directory or Slingshot).
238238+/// - `pds_fallback`: optional base URL of a PDS for XRPC fallbacks (stateless
239239+/// XRPC over reqwest; authentication can be layered as needed).
240240+/// - `handle_order`/`did_order`: ordered strategies for resolution.
241241+/// - `validate_doc_id`: if true (default), convenience helpers validate doc `id` against the requested DID,
242242+/// returning `DocIdMismatch` with the fetched document on mismatch.
243243+/// - `public_fallback_for_handle`: if true (default), attempt
244244+/// `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle` as an unauth fallback.
245245+/// There is no public fallback for DID documents; when `PdsResolveDid` is chosen and the PDS XRPC
246246+/// client fails, the resolver falls back to Slingshot mini-doc (partial) if `PlcSource::Slingshot` is configured.
247247+#[derive(Debug, Clone, Builder)]
248248+#[builder(start_fn = new)]
249249+pub struct ResolverOptions {
250250+ /// PLC data source (directory or slingshot)
251251+ pub plc_source: PlcSource,
252252+ /// Optional PDS base to use for fallbacks
253253+ pub pds_fallback: Option<Url>,
254254+ /// Order of attempts for handle → DID resolution
255255+ pub handle_order: Vec<HandleStep>,
256256+ /// Order of attempts for DID → Doc resolution
257257+ pub did_order: Vec<DidStep>,
258258+ /// Validate that fetched DID document id matches the requested DID
259259+ pub validate_doc_id: bool,
260260+ /// Allow public unauthenticated fallback for resolveHandle via public.api.bsky.app
261261+ pub public_fallback_for_handle: bool,
262262+}
263263+264264+impl Default for ResolverOptions {
265265+ fn default() -> Self {
266266+ // By default, prefer DNS then HTTPS for handles, then PDS fallback
267267+ // For DID documents, prefer method-native sources, then PDS fallback
268268+ Self::new()
269269+ .plc_source(PlcSource::default())
270270+ .handle_order(vec![
271271+ HandleStep::DnsTxt,
272272+ HandleStep::HttpsWellKnown,
273273+ HandleStep::PdsResolveHandle,
274274+ ])
275275+ .did_order(vec![
276276+ DidStep::DidWebHttps,
277277+ DidStep::PlcHttp,
278278+ DidStep::PdsResolveDid,
279279+ ])
280280+ .validate_doc_id(true)
281281+ .public_fallback_for_handle(true)
282282+ .build()
283283+ }
284284+}
285285+286286+/// Trait for identity resolution, for pluggable implementations.
287287+///
288288+/// The provided `DefaultResolver` supports:
289289+/// - DNS TXT (`_atproto.<handle>`) when compiled with the `dns` feature
290290+/// - HTTPS well-known for handles and `did:web`
291291+/// - PLC directory or Slingshot for `did:plc`
292292+/// - Slingshot `resolveHandle` (unauthenticated) when configured as the PLC source
293293+/// - PDS fallbacks via helpers that use stateless XRPC on top of reqwest
294294+#[async_trait::async_trait]
295295+pub trait IdentityResolver {
296296+ /// Access options for validation decisions in default methods
297297+ fn options(&self) -> &ResolverOptions;
298298+299299+ /// Resolve handle
300300+ async fn resolve_handle(&self, handle: &Handle<'_>) -> Result<Did<'static>, IdentityError>;
301301+302302+ /// Resolve DID document
303303+ async fn resolve_did_doc(&self, did: &Did<'_>) -> Result<DidDocResponse, IdentityError>;
304304+305305+ /// Resolve DID doc from an identifier
306306+ async fn resolve_ident(
307307+ &self,
308308+ actor: &AtIdentifier<'_>,
309309+ ) -> Result<DidDocResponse, IdentityError> {
310310+ match actor {
311311+ AtIdentifier::Did(did) => self.resolve_did_doc(&did).await,
312312+ AtIdentifier::Handle(handle) => {
313313+ let did = self.resolve_handle(&handle).await?;
314314+ self.resolve_did_doc(&did).await
315315+ }
316316+ }
317317+ }
318318+319319+ /// Resolve DID doc from an identifier
320320+ async fn resolve_ident_owned(
321321+ &self,
322322+ actor: &AtIdentifier<'_>,
323323+ ) -> Result<DidDocument<'static>, IdentityError> {
324324+ match actor {
325325+ AtIdentifier::Did(did) => self.resolve_did_doc_owned(&did).await,
326326+ AtIdentifier::Handle(handle) => {
327327+ let did = self.resolve_handle(&handle).await?;
328328+ self.resolve_did_doc_owned(&did).await
329329+ }
330330+ }
331331+ }
332332+333333+ /// Resolve the DID document and return an owned version
334334+ async fn resolve_did_doc_owned(
335335+ &self,
336336+ did: &Did<'_>,
337337+ ) -> Result<DidDocument<'static>, IdentityError> {
338338+ self.resolve_did_doc(did).await?.into_owned()
339339+ }
340340+ /// Return the PDS url for a DID
341341+ async fn pds_for_did(&self, did: &Did<'_>) -> Result<Url, IdentityError> {
342342+ let resp = self.resolve_did_doc(did).await?;
343343+ let doc = resp.parse()?;
344344+ // Default-on doc id equality check
345345+ if self.options().validate_doc_id {
346346+ if doc.id.as_str() != did.as_str() {
347347+ return Err(IdentityError::DocIdMismatch {
348348+ expected: did.clone().into_static(),
349349+ doc: doc.clone().into_static(),
350350+ });
351351+ }
352352+ }
353353+ doc.pds_endpoint().ok_or(IdentityError::MissingPdsEndpoint)
354354+ }
355355+ /// Return the DIS and PDS url for a handle
356356+ async fn pds_for_handle(
357357+ &self,
358358+ handle: &Handle<'_>,
359359+ ) -> Result<(Did<'static>, Url), IdentityError> {
360360+ let did = self.resolve_handle(handle).await?;
361361+ let pds = self.pds_for_did(&did).await?;
362362+ Ok((did, pds))
363363+ }
364364+}
365365+366366+#[cfg(test)]
367367+mod tests {
368368+ use super::*;
369369+370370+ #[test]
371371+ fn parse_validated_ok() {
372372+ let buf = Bytes::from_static(br#"{"id":"did:plc:alice"}"#);
373373+ let requested = Did::new_owned("did:plc:alice").unwrap();
374374+ let resp = DidDocResponse {
375375+ buffer: buf,
376376+ status: StatusCode::OK,
377377+ requested: Some(requested),
378378+ };
379379+ let _doc = resp.parse_validated().expect("valid");
380380+ }
381381+382382+ #[test]
383383+ fn parse_validated_mismatch() {
384384+ let buf = Bytes::from_static(br#"{"id":"did:plc:bob"}"#);
385385+ let requested = Did::new_owned("did:plc:alice").unwrap();
386386+ let resp = DidDocResponse {
387387+ buffer: buf,
388388+ status: StatusCode::OK,
389389+ requested: Some(requested),
390390+ };
391391+ match resp.parse_validated() {
392392+ Err(IdentityError::DocIdMismatch { expected, doc }) => {
393393+ assert_eq!(expected.as_str(), "did:plc:alice");
394394+ assert_eq!(doc.id.as_str(), "did:plc:bob");
395395+ }
396396+ other => panic!("unexpected result: {:?}", other),
397397+ }
398398+ }
399399+}
+15
crates/jacquard-common/src/lib.rs
···1313#[macro_use]
1414/// Trait for taking ownership of most borrowed types in jacquard.
1515pub mod into_static;
1616+pub mod error;
1717+/// HTTP client abstraction used by jacquard crates.
1818+pub mod http_client;
1919+pub mod ident_resolver;
1620pub mod macros;
2121+/// Generic session storage traits and utilities.
2222+pub mod session;
1723/// Baseline fundamental AT Protocol data types.
1824pub mod types;
2525+2626+/// Authorization token types for XRPC requests.
2727+#[derive(Debug, Clone)]
2828+pub enum AuthorizationToken<'s> {
2929+ /// Bearer token (access JWT, refresh JWT to refresh the session)
3030+ Bearer(CowStr<'s>),
3131+ /// DPoP token (proof-of-possession) for OAuth
3232+ Dpop(CowStr<'s>),
3333+}
···251251/// detailing the specification for the type
252252/// `source` is the source string, or part of it
253253/// `kind` is the type of parsing error: `[StrParseKind]`
254254-#[derive(Debug, thiserror::Error, miette::Diagnostic)]
254254+#[derive(Debug, thiserror::Error, miette::Diagnostic, PartialEq, Eq, Clone)]
255255#[error("error in `{source}`: {kind}")]
256256#[diagnostic(
257257 url("https://atproto.com/specs/{spec}"),
···406406}
407407408408/// Kinds of parsing errors for AT Protocol string types
409409-#[derive(Debug, thiserror::Error, miette::Diagnostic)]
409409+#[derive(Debug, thiserror::Error, miette::Diagnostic, PartialEq, Eq, Clone)]
410410pub enum StrParseKind {
411411 /// Regex pattern validation failed
412412 #[error("regex failure - {message}")]
+446-1
crates/jacquard-common/src/types/xrpc.rs
···11+use bytes::Bytes;
22+use http::{
33+ HeaderName, HeaderValue, Request, StatusCode,
44+ header::{AUTHORIZATION, CONTENT_TYPE},
55+};
16use serde::{Deserialize, Serialize};
22-use std::error::Error;
77+use smol_str::SmolStr;
38use std::fmt::{self, Debug};
99+use std::{error::Error, marker::PhantomData};
1010+use url::Url;
411512use crate::IntoStatic;
1313+use crate::error::TransportError;
1414+use crate::http_client::HttpClient;
615use crate::types::value::Data;
1616+use crate::{AuthorizationToken, error::AuthError};
1717+use crate::{CowStr, error::XrpcResult};
718819/// Error type for encoding XRPC requests
920#[derive(Debug, thiserror::Error, miette::Diagnostic)]
···103114 GenericError(self.0.into_static())
104115 }
105116}
117117+118118+/// Per-request options for XRPC calls.
119119+#[derive(Debug, Default, Clone)]
120120+pub struct CallOptions<'a> {
121121+ /// Optional Authorization to apply (`Bearer` or `DPoP`).
122122+ pub auth: Option<AuthorizationToken<'a>>,
123123+ /// `atproto-proxy` header value.
124124+ pub atproto_proxy: Option<CowStr<'a>>,
125125+ /// `atproto-accept-labelers` header values.
126126+ pub atproto_accept_labelers: Option<Vec<CowStr<'a>>>,
127127+ /// Extra headers to attach to this request.
128128+ pub extra_headers: Vec<(HeaderName, HeaderValue)>,
129129+}
130130+131131+/// Extension for stateless XRPC calls on any `HttpClient`.
132132+///
133133+/// Example
134134+/// ```ignore
135135+/// use jacquard::client::XrpcExt;
136136+/// use jacquard::api::app_bsky::feed::get_author_feed::GetAuthorFeed;
137137+/// use jacquard::types::ident::AtIdentifier;
138138+/// use miette::IntoDiagnostic;
139139+///
140140+/// #[tokio::main]
141141+/// async fn main() -> miette::Result<()> {
142142+/// let http = reqwest::Client::new();
143143+/// let base = url::Url::parse("https://public.api.bsky.app")?;
144144+/// let resp = http
145145+/// .xrpc(base)
146146+/// .send(
147147+/// GetAuthorFeed::new()
148148+/// .actor(AtIdentifier::new_static("pattern.atproto.systems").unwrap())
149149+/// .limit(5)
150150+/// .build(),
151151+/// )
152152+/// .await?;
153153+/// let out = resp.into_output()?;
154154+/// println!("author feed:\n{}", serde_json::to_string_pretty(&out).into_diagnostic()?);
155155+/// Ok(())
156156+/// }
157157+/// ```
158158+pub trait XrpcExt: HttpClient {
159159+ /// Start building an XRPC call for the given base URL.
160160+ fn xrpc<'a>(&'a self, base: Url) -> XrpcCall<'a, Self>
161161+ where
162162+ Self: Sized,
163163+ {
164164+ XrpcCall {
165165+ client: self,
166166+ base,
167167+ opts: CallOptions::default(),
168168+ }
169169+ }
170170+}
171171+172172+impl<T: HttpClient> XrpcExt for T {}
173173+174174+/// Stateless XRPC call builder.
175175+///
176176+/// Example (per-request overrides)
177177+/// ```ignore
178178+/// use jacquard::client::{XrpcExt, AuthorizationToken};
179179+/// use jacquard::api::app_bsky::feed::get_author_feed::GetAuthorFeed;
180180+/// use jacquard::types::ident::AtIdentifier;
181181+/// use jacquard::CowStr;
182182+/// use miette::IntoDiagnostic;
183183+///
184184+/// #[tokio::main]
185185+/// async fn main() -> miette::Result<()> {
186186+/// let http = reqwest::Client::new();
187187+/// let base = url::Url::parse("https://public.api.bsky.app")?;
188188+/// let resp = http
189189+/// .xrpc(base)
190190+/// .auth(AuthorizationToken::Bearer(CowStr::from("ACCESS_JWT")))
191191+/// .accept_labelers(vec![CowStr::from("did:plc:labelerid")])
192192+/// .header(http::header::USER_AGENT, http::HeaderValue::from_static("jacquard-example"))
193193+/// .send(
194194+/// GetAuthorFeed::new()
195195+/// .actor(AtIdentifier::new_static("pattern.atproto.systems").unwrap())
196196+/// .limit(5)
197197+/// .build(),
198198+/// )
199199+/// .await?;
200200+/// let out = resp.into_output()?;
201201+/// println!("{}", serde_json::to_string_pretty(&out).into_diagnostic()?);
202202+/// Ok(())
203203+/// }
204204+/// ```
205205+pub struct XrpcCall<'a, C: HttpClient> {
206206+ pub(crate) client: &'a C,
207207+ pub(crate) base: Url,
208208+ pub(crate) opts: CallOptions<'a>,
209209+}
210210+211211+impl<'a, C: HttpClient> XrpcCall<'a, C> {
212212+ /// Apply Authorization to this call.
213213+ pub fn auth(mut self, token: AuthorizationToken<'a>) -> Self {
214214+ self.opts.auth = Some(token);
215215+ self
216216+ }
217217+ /// Set `atproto-proxy` header for this call.
218218+ pub fn proxy(mut self, proxy: CowStr<'a>) -> Self {
219219+ self.opts.atproto_proxy = Some(proxy);
220220+ self
221221+ }
222222+ /// Set `atproto-accept-labelers` header(s) for this call.
223223+ pub fn accept_labelers(mut self, labelers: Vec<CowStr<'a>>) -> Self {
224224+ self.opts.atproto_accept_labelers = Some(labelers);
225225+ self
226226+ }
227227+ /// Add an extra header.
228228+ pub fn header(mut self, name: HeaderName, value: HeaderValue) -> Self {
229229+ self.opts.extra_headers.push((name, value));
230230+ self
231231+ }
232232+ /// Replace the builder's options entirely.
233233+ pub fn with_options(mut self, opts: CallOptions<'a>) -> Self {
234234+ self.opts = opts;
235235+ self
236236+ }
237237+238238+ /// Send the given typed XRPC request and return a response wrapper.
239239+ pub async fn send<R: XrpcRequest + Send>(self, request: R) -> XrpcResult<Response<R>> {
240240+ let http_request = build_http_request(&self.base, &request, &self.opts)
241241+ .map_err(crate::error::TransportError::from)?;
242242+243243+ let http_response = self
244244+ .client
245245+ .send_http(http_request)
246246+ .await
247247+ .map_err(|e| crate::error::TransportError::Other(Box::new(e)))?;
248248+249249+ let status = http_response.status();
250250+ let buffer = Bytes::from(http_response.into_body());
251251+252252+ if !status.is_success() && !matches!(status.as_u16(), 400 | 401) {
253253+ return Err(crate::error::HttpError {
254254+ status,
255255+ body: Some(buffer),
256256+ }
257257+ .into());
258258+ }
259259+260260+ Ok(Response::new(buffer, status))
261261+ }
262262+}
263263+264264+/// HTTP headers commonly used in XRPC requests
265265+pub enum Header {
266266+ /// Content-Type header
267267+ ContentType,
268268+ /// Authorization header
269269+ Authorization,
270270+ /// `atproto-proxy` header - specifies which service (app server or other atproto service) the user's PDS should forward requests to as appropriate.
271271+ ///
272272+ /// See: <https://atproto.com/specs/xrpc#service-proxying>
273273+ AtprotoProxy,
274274+ /// `atproto-accept-labelers` header used by clients to request labels from specific labelers to be included and applied in the response. See [label](https://atproto.com/specs/label) specification for details.
275275+ AtprotoAcceptLabelers,
276276+}
277277+278278+impl From<Header> for HeaderName {
279279+ fn from(value: Header) -> Self {
280280+ match value {
281281+ Header::ContentType => CONTENT_TYPE,
282282+ Header::Authorization => AUTHORIZATION,
283283+ Header::AtprotoProxy => HeaderName::from_static("atproto-proxy"),
284284+ Header::AtprotoAcceptLabelers => HeaderName::from_static("atproto-accept-labelers"),
285285+ }
286286+ }
287287+}
288288+289289+/// Build an HTTP request for an XRPC call given base URL and options
290290+pub fn build_http_request<R: XrpcRequest>(
291291+ base: &Url,
292292+ req: &R,
293293+ opts: &CallOptions<'_>,
294294+) -> core::result::Result<Request<Vec<u8>>, crate::error::TransportError> {
295295+ let mut url = base.clone();
296296+ let mut path = url.path().trim_end_matches('/').to_owned();
297297+ path.push_str("/xrpc/");
298298+ path.push_str(R::NSID);
299299+ url.set_path(&path);
300300+301301+ if let XrpcMethod::Query = R::METHOD {
302302+ let qs = serde_html_form::to_string(&req)
303303+ .map_err(|e| crate::error::TransportError::InvalidRequest(e.to_string()))?;
304304+ if !qs.is_empty() {
305305+ url.set_query(Some(&qs));
306306+ } else {
307307+ url.set_query(None);
308308+ }
309309+ }
310310+311311+ let method = match R::METHOD {
312312+ XrpcMethod::Query => http::Method::GET,
313313+ XrpcMethod::Procedure(_) => http::Method::POST,
314314+ };
315315+316316+ let mut builder = Request::builder().method(method).uri(url.as_str());
317317+318318+ if let XrpcMethod::Procedure(encoding) = R::METHOD {
319319+ builder = builder.header(Header::ContentType, encoding);
320320+ }
321321+ builder = builder.header(http::header::ACCEPT, R::OUTPUT_ENCODING);
322322+323323+ if let Some(token) = &opts.auth {
324324+ let hv = match token {
325325+ AuthorizationToken::Bearer(t) => {
326326+ HeaderValue::from_str(&format!("Bearer {}", t.as_ref()))
327327+ }
328328+ AuthorizationToken::Dpop(t) => HeaderValue::from_str(&format!("DPoP {}", t.as_ref())),
329329+ }
330330+ .map_err(|e| {
331331+ TransportError::InvalidRequest(format!("Invalid authorization token: {}", e))
332332+ })?;
333333+ builder = builder.header(Header::Authorization, hv);
334334+ }
335335+336336+ if let Some(proxy) = &opts.atproto_proxy {
337337+ builder = builder.header(Header::AtprotoProxy, proxy.as_ref());
338338+ }
339339+ if let Some(labelers) = &opts.atproto_accept_labelers {
340340+ if !labelers.is_empty() {
341341+ let joined = labelers
342342+ .iter()
343343+ .map(|s| s.as_ref())
344344+ .collect::<Vec<_>>()
345345+ .join(", ");
346346+ builder = builder.header(Header::AtprotoAcceptLabelers, joined);
347347+ }
348348+ }
349349+ for (name, value) in &opts.extra_headers {
350350+ builder = builder.header(name, value);
351351+ }
352352+353353+ let body = if let XrpcMethod::Procedure(_) = R::METHOD {
354354+ req.encode_body()
355355+ .map_err(|e| TransportError::InvalidRequest(e.to_string()))?
356356+ } else {
357357+ vec![]
358358+ };
359359+360360+ builder
361361+ .body(body)
362362+ .map_err(|e| TransportError::InvalidRequest(e.to_string()))
363363+}
364364+365365+/// XRPC response wrapper that owns the response buffer
366366+///
367367+/// Allows borrowing from the buffer when parsing to avoid unnecessary allocations.
368368+/// Supports both borrowed parsing (with `parse()`) and owned parsing (with `into_output()`).
369369+pub struct Response<R: XrpcRequest> {
370370+ buffer: Bytes,
371371+ status: StatusCode,
372372+ _marker: PhantomData<R>,
373373+}
374374+375375+impl<R: XrpcRequest> Response<R> {
376376+ /// Create a new response from a buffer and status code
377377+ pub fn new(buffer: Bytes, status: StatusCode) -> Self {
378378+ Self {
379379+ buffer,
380380+ status,
381381+ _marker: PhantomData,
382382+ }
383383+ }
384384+385385+ /// Get the HTTP status code
386386+ pub fn status(&self) -> StatusCode {
387387+ self.status
388388+ }
389389+390390+ /// Parse the response, borrowing from the internal buffer
391391+ pub fn parse(&self) -> Result<R::Output<'_>, XrpcError<R::Err<'_>>> {
392392+ // Use a helper to make lifetime inference work
393393+ fn parse_output<'b, R: XrpcRequest>(
394394+ buffer: &'b [u8],
395395+ ) -> Result<R::Output<'b>, serde_json::Error> {
396396+ serde_json::from_slice(buffer)
397397+ }
398398+399399+ fn parse_error<'b, R: XrpcRequest>(
400400+ buffer: &'b [u8],
401401+ ) -> Result<R::Err<'b>, serde_json::Error> {
402402+ serde_json::from_slice(buffer)
403403+ }
404404+405405+ // 200: parse as output
406406+ if self.status.is_success() {
407407+ match parse_output::<R>(&self.buffer) {
408408+ Ok(output) => Ok(output),
409409+ Err(e) => Err(XrpcError::Decode(e)),
410410+ }
411411+ // 400: try typed XRPC error, fallback to generic error
412412+ } else if self.status.as_u16() == 400 {
413413+ match parse_error::<R>(&self.buffer) {
414414+ Ok(error) => Err(XrpcError::Xrpc(error)),
415415+ Err(_) => {
416416+ // Fallback to generic error (InvalidRequest, ExpiredToken, etc.)
417417+ match serde_json::from_slice::<GenericXrpcError>(&self.buffer) {
418418+ Ok(generic) => {
419419+ // Map auth-related errors to AuthError
420420+ match generic.error.as_str() {
421421+ "ExpiredToken" => Err(XrpcError::Auth(AuthError::TokenExpired)),
422422+ "InvalidToken" => Err(XrpcError::Auth(AuthError::InvalidToken)),
423423+ _ => Err(XrpcError::Generic(generic)),
424424+ }
425425+ }
426426+ Err(e) => Err(XrpcError::Decode(e)),
427427+ }
428428+ }
429429+ }
430430+ // 401: always auth error
431431+ } else {
432432+ match serde_json::from_slice::<GenericXrpcError>(&self.buffer) {
433433+ Ok(generic) => match generic.error.as_str() {
434434+ "ExpiredToken" => Err(XrpcError::Auth(AuthError::TokenExpired)),
435435+ "InvalidToken" => Err(XrpcError::Auth(AuthError::InvalidToken)),
436436+ _ => Err(XrpcError::Auth(AuthError::NotAuthenticated)),
437437+ },
438438+ Err(e) => Err(XrpcError::Decode(e)),
439439+ }
440440+ }
441441+ }
442442+443443+ /// Parse the response into an owned output
444444+ pub fn into_output(self) -> Result<R::Output<'static>, XrpcError<R::Err<'static>>>
445445+ where
446446+ for<'a> R::Output<'a>: IntoStatic<Output = R::Output<'static>>,
447447+ for<'a> R::Err<'a>: IntoStatic<Output = R::Err<'static>>,
448448+ {
449449+ // Use a helper to make lifetime inference work
450450+ fn parse_output<'b, R: XrpcRequest>(
451451+ buffer: &'b [u8],
452452+ ) -> Result<R::Output<'b>, serde_json::Error> {
453453+ serde_json::from_slice(buffer)
454454+ }
455455+456456+ fn parse_error<'b, R: XrpcRequest>(
457457+ buffer: &'b [u8],
458458+ ) -> Result<R::Err<'b>, serde_json::Error> {
459459+ serde_json::from_slice(buffer)
460460+ }
461461+462462+ // 200: parse as output
463463+ if self.status.is_success() {
464464+ match parse_output::<R>(&self.buffer) {
465465+ Ok(output) => Ok(output.into_static()),
466466+ Err(e) => Err(XrpcError::Decode(e)),
467467+ }
468468+ // 400: try typed XRPC error, fallback to generic error
469469+ } else if self.status.as_u16() == 400 {
470470+ match parse_error::<R>(&self.buffer) {
471471+ Ok(error) => Err(XrpcError::Xrpc(error.into_static())),
472472+ Err(_) => {
473473+ // Fallback to generic error (InvalidRequest, ExpiredToken, etc.)
474474+ match serde_json::from_slice::<GenericXrpcError>(&self.buffer) {
475475+ Ok(generic) => {
476476+ // Map auth-related errors to AuthError
477477+ match generic.error.as_ref() {
478478+ "ExpiredToken" => Err(XrpcError::Auth(AuthError::TokenExpired)),
479479+ "InvalidToken" => Err(XrpcError::Auth(AuthError::InvalidToken)),
480480+ _ => Err(XrpcError::Generic(generic)),
481481+ }
482482+ }
483483+ Err(e) => Err(XrpcError::Decode(e)),
484484+ }
485485+ }
486486+ }
487487+ // 401: always auth error
488488+ } else {
489489+ match serde_json::from_slice::<GenericXrpcError>(&self.buffer) {
490490+ Ok(generic) => match generic.error.as_ref() {
491491+ "ExpiredToken" => Err(XrpcError::Auth(AuthError::TokenExpired)),
492492+ "InvalidToken" => Err(XrpcError::Auth(AuthError::InvalidToken)),
493493+ _ => Err(XrpcError::Auth(AuthError::NotAuthenticated)),
494494+ },
495495+ Err(e) => Err(XrpcError::Decode(e)),
496496+ }
497497+ }
498498+ }
499499+500500+ /// Get the raw buffer
501501+ pub fn buffer(&self) -> &Bytes {
502502+ &self.buffer
503503+ }
504504+}
505505+506506+/// Generic XRPC error format for untyped errors like InvalidRequest
507507+///
508508+/// Used when the error doesn't match the endpoint's specific error enum
509509+#[derive(Debug, Clone, Deserialize)]
510510+pub struct GenericXrpcError {
511511+ /// Error code (e.g., "InvalidRequest")
512512+ pub error: SmolStr,
513513+ /// Optional error message with details
514514+ pub message: Option<SmolStr>,
515515+}
516516+517517+impl std::fmt::Display for GenericXrpcError {
518518+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
519519+ if let Some(msg) = &self.message {
520520+ write!(f, "{}: {}", self.error, msg)
521521+ } else {
522522+ write!(f, "{}", self.error)
523523+ }
524524+ }
525525+}
526526+527527+impl std::error::Error for GenericXrpcError {}
528528+529529+/// XRPC-specific errors returned from endpoints
530530+///
531531+/// Represents errors returned in the response body
532532+/// Type parameter `E` is the endpoint's specific error enum type.
533533+#[derive(Debug, thiserror::Error, miette::Diagnostic)]
534534+pub enum XrpcError<E: std::error::Error + IntoStatic> {
535535+ /// Typed XRPC error from the endpoint's specific error enum
536536+ #[error("XRPC error: {0}")]
537537+ Xrpc(E),
538538+539539+ /// Authentication error (ExpiredToken, InvalidToken, etc.)
540540+ #[error("Authentication error: {0}")]
541541+ Auth(#[from] AuthError),
542542+543543+ /// Generic XRPC error not in the endpoint's error enum (e.g., InvalidRequest)
544544+ #[error("XRPC error: {0}")]
545545+ Generic(GenericXrpcError),
546546+547547+ /// Failed to decode the response body
548548+ #[error("Failed to decode response: {0}")]
549549+ Decode(#[from] serde_json::Error),
550550+}
···11+use crate::jose::create_signed_jwt;
22+use crate::jose::jws::RegisteredHeader;
33+use crate::jose::jwt::Claims;
44+use jacquard_common::CowStr;
55+use jose_jwa::{Algorithm, Signing};
66+use jose_jwk::{Class, EcCurves, crypto};
77+use jose_jwk::{Jwk, JwkSet, Key};
88+use smol_str::{SmolStr, ToSmolStr};
99+use std::collections::HashSet;
1010+use thiserror::Error;
1111+1212+#[derive(Error, Debug)]
1313+pub enum Error {
1414+ #[error("duplicate kid: {0}")]
1515+ DuplicateKid(String),
1616+ #[error("keys must not be empty")]
1717+ EmptyKeys,
1818+ #[error("key must have a `kid`")]
1919+ EmptyKid,
2020+ #[error("no signing key found for algorithms: {0:?}")]
2121+ NotFound(Vec<SmolStr>),
2222+ #[error("key for signing must be a secret key")]
2323+ PublicKey,
2424+ #[error("crypto error: {0:?}")]
2525+ JwkCrypto(crypto::Error),
2626+ #[error(transparent)]
2727+ SerdeJson(#[from] serde_json::Error),
2828+}
2929+3030+pub type Result<T> = core::result::Result<T, Error>;
3131+3232+#[derive(Clone, Debug, Default, PartialEq, Eq)]
3333+pub struct Keyset(Vec<Jwk>);
3434+3535+impl Keyset {
3636+ const PREFERRED_SIGNING_ALGORITHMS: [&'static str; 9] = [
3737+ "EdDSA", "ES256K", "ES256", "PS256", "PS384", "PS512", "HS256", "HS384", "HS512",
3838+ ];
3939+ pub fn public_jwks(&self) -> JwkSet {
4040+ let mut keys = Vec::with_capacity(self.0.len());
4141+ for mut key in self.0.clone() {
4242+ match key.key {
4343+ Key::Ec(ref mut ec) => {
4444+ ec.d = None;
4545+ }
4646+ _ => unimplemented!(),
4747+ }
4848+ keys.push(key);
4949+ }
5050+ JwkSet { keys }
5151+ }
5252+ pub fn create_jwt(&self, algs: &[SmolStr], claims: Claims) -> Result<CowStr<'static>> {
5353+ let Some(jwk) = self.find_key(algs, Class::Signing) else {
5454+ return Err(Error::NotFound(algs.to_vec()));
5555+ };
5656+ self.create_jwt_with_key(jwk, claims)
5757+ }
5858+ fn find_key(&self, algs: &[SmolStr], cls: Class) -> Option<&Jwk> {
5959+ let candidates = self
6060+ .0
6161+ .iter()
6262+ .filter_map(|key| {
6363+ if key.prm.cls.is_some_and(|c| c != cls) {
6464+ return None;
6565+ }
6666+ let alg = match &key.key {
6767+ Key::Ec(ec) => match ec.crv {
6868+ EcCurves::P256 => "ES256",
6969+ _ => unimplemented!(),
7070+ },
7171+ _ => unimplemented!(),
7272+ };
7373+ Some((alg, key)).filter(|(alg, _)| algs.contains(&alg.to_smolstr()))
7474+ })
7575+ .collect::<Vec<_>>();
7676+ for pref_alg in Self::PREFERRED_SIGNING_ALGORITHMS {
7777+ for (alg, key) in &candidates {
7878+ if alg == &pref_alg {
7979+ return Some(key);
8080+ }
8181+ }
8282+ }
8383+ None
8484+ }
8585+ fn create_jwt_with_key(&self, key: &Jwk, claims: Claims) -> Result<CowStr<'static>> {
8686+ let kid = key.prm.kid.clone().unwrap();
8787+ match crypto::Key::try_from(&key.key).map_err(Error::JwkCrypto)? {
8888+ crypto::Key::P256(crypto::Kind::Secret(secret_key)) => {
8989+ let mut header = RegisteredHeader::from(Algorithm::Signing(Signing::Es256));
9090+ header.kid = Some(kid.into());
9191+ Ok(create_signed_jwt(secret_key.into(), header.into(), claims)?)
9292+ }
9393+ _ => unimplemented!(),
9494+ }
9595+ }
9696+}
9797+9898+impl TryFrom<Vec<Jwk>> for Keyset {
9999+ type Error = Error;
100100+101101+ fn try_from(keys: Vec<Jwk>) -> Result<Self> {
102102+ if keys.is_empty() {
103103+ return Err(Error::EmptyKeys);
104104+ }
105105+ let mut v = Vec::with_capacity(keys.len());
106106+ let mut hs = HashSet::with_capacity(keys.len());
107107+ for key in keys {
108108+ if let Some(kid) = key.prm.kid.clone() {
109109+ if hs.contains(&kid) {
110110+ return Err(Error::DuplicateKid(kid));
111111+ }
112112+ hs.insert(kid);
113113+ // ensure that the key is a secret key
114114+ if match crypto::Key::try_from(&key.key).map_err(Error::JwkCrypto)? {
115115+ crypto::Key::P256(crypto::Kind::Public(_)) => true,
116116+ crypto::Key::P256(crypto::Kind::Secret(_)) => false,
117117+ _ => unimplemented!(),
118118+ } {
119119+ return Err(Error::PublicKey);
120120+ }
121121+ v.push(key);
122122+ } else {
123123+ return Err(Error::EmptyKid);
124124+ }
125125+ }
126126+ Ok(Self(v))
127127+ }
128128+}
+14
crates/jacquard-oauth/src/lib.rs
···11+//! Core OAuth 2.1 (AT Protocol profile) types and helpers for Jacquard.
22+//! Transport, discovery, and orchestration live in `jacquard`.
33+44+pub mod atproto;
55+pub mod dpop;
66+pub mod error;
77+pub mod jose;
88+pub mod keyset;
99+pub mod resolver;
1010+pub mod scopes;
1111+pub mod session;
1212+pub mod types;
1313+1414+pub const FALLBACK_ALG: &str = "ES256";
+214
crates/jacquard-oauth/src/resolver.rs
···11+use crate::types::{OAuthAuthorizationServerMetadata, OAuthProtectedResourceMetadata};
22+use http::{Request, StatusCode};
33+use jacquard_common::IntoStatic;
44+use jacquard_common::ident_resolver::{IdentityError, IdentityResolver};
55+use jacquard_common::types::did_doc::DidDocument;
66+use jacquard_common::types::ident::AtIdentifier;
77+use jacquard_common::{http_client::HttpClient, types::did::Did};
88+use url::Url;
99+1010+#[derive(thiserror::Error, Debug, miette::Diagnostic)]
1111+pub enum ResolverError {
1212+ #[error("resource not found")]
1313+ NotFound,
1414+ #[error("invalid at identifier: {0}")]
1515+ AtIdentifier(String),
1616+ #[error("invalid did: {0}")]
1717+ Did(String),
1818+ #[error("invalid did document: {0}")]
1919+ DidDocument(String),
2020+ #[error("protected resource metadata is invalid: {0}")]
2121+ ProtectedResourceMetadata(String),
2222+ #[error("authorization server metadata is invalid: {0}")]
2323+ AuthorizationServerMetadata(String),
2424+ #[error("error resolving identity: {0}")]
2525+ IdentityResolverError(#[from] IdentityError),
2626+ #[error("unsupported did method: {0:?}")]
2727+ UnsupportedDidMethod(Did<'static>),
2828+ #[error(transparent)]
2929+ Http(#[from] http::Error),
3030+ #[error("http client error: {0}")]
3131+ HttpClient(Box<dyn std::error::Error + Send + Sync + 'static>),
3232+ #[error("http status: {0:?}")]
3333+ HttpStatus(StatusCode),
3434+ #[error(transparent)]
3535+ SerdeJson(#[from] serde_json::Error),
3636+ #[error(transparent)]
3737+ SerdeHtmlForm(#[from] serde_html_form::ser::Error),
3838+ #[error(transparent)]
3939+ Uri(#[from] url::ParseError),
4040+}
4141+4242+#[async_trait::async_trait]
4343+pub trait OAuthResolver: IdentityResolver + HttpClient {
4444+ async fn resolve_oauth(
4545+ &self,
4646+ input: &str,
4747+ ) -> Result<
4848+ (
4949+ OAuthAuthorizationServerMetadata<'static>,
5050+ Option<DidDocument<'static>>,
5151+ ),
5252+ ResolverError,
5353+ > {
5454+ // Allow using an entryway, or PDS url, directly as login input (e.g.
5555+ // when the user forgot their handle, or when the handle does not
5656+ // resolve to a DID)
5757+ Ok(if input.starts_with("https://") {
5858+ let url = Url::parse(input).map_err(|_| ResolverError::NotFound)?;
5959+ (self.resolve_from_service(&url).await?, None)
6060+ } else {
6161+ let (metadata, identity) = self.resolve_from_identity(input).await?;
6262+ (metadata, Some(identity))
6363+ })
6464+ }
6565+ async fn resolve_from_service(
6666+ &self,
6767+ input: &Url,
6868+ ) -> Result<OAuthAuthorizationServerMetadata<'static>, ResolverError> {
6969+ // Assume first that input is a PDS URL (as required by ATPROTO)
7070+ if let Ok(metadata) = self.get_resource_server_metadata(input).await {
7171+ return Ok(metadata);
7272+ }
7373+ // Fallback to trying to fetch as an issuer (Entryway)
7474+ self.get_authorization_server_metadata(input).await
7575+ }
7676+ async fn resolve_from_identity(
7777+ &self,
7878+ input: &str,
7979+ ) -> Result<
8080+ (
8181+ OAuthAuthorizationServerMetadata<'static>,
8282+ DidDocument<'static>,
8383+ ),
8484+ ResolverError,
8585+ > {
8686+ let actor = AtIdentifier::new(input)
8787+ .map_err(|e| ResolverError::AtIdentifier(format!("{:?}", e)))?;
8888+ let identity = self.resolve_ident_owned(&actor).await?;
8989+ if let Some(pds) = &identity.pds_endpoint() {
9090+ let metadata = self.get_resource_server_metadata(pds).await?;
9191+ Ok((metadata, identity))
9292+ } else {
9393+ Err(ResolverError::DidDocument(format!("Did doc lacking pds")))
9494+ }
9595+ }
9696+ async fn get_authorization_server_metadata(
9797+ &self,
9898+ issuer: &Url,
9999+ ) -> Result<OAuthAuthorizationServerMetadata<'static>, ResolverError> {
100100+ Ok(resolve_authorization_server(self, issuer).await?)
101101+ }
102102+ async fn get_resource_server_metadata(
103103+ &self,
104104+ pds: &Url,
105105+ ) -> Result<OAuthAuthorizationServerMetadata<'static>, ResolverError> {
106106+ let rs_metadata = resolve_protected_resource_info(self, pds).await?;
107107+ // ATPROTO requires one, and only one, authorization server entry
108108+ // > That document MUST contain a single item in the authorization_servers array.
109109+ // https://github.com/bluesky-social/proposals/tree/main/0004-oauth#server-metadata
110110+ let issuer = match &rs_metadata.authorization_servers {
111111+ Some(servers) if !servers.is_empty() => {
112112+ if servers.len() > 1 {
113113+ return Err(ResolverError::ProtectedResourceMetadata(format!(
114114+ "unable to determine authorization server for PDS: {pds}"
115115+ )));
116116+ }
117117+ &servers[0]
118118+ }
119119+ _ => {
120120+ return Err(ResolverError::ProtectedResourceMetadata(format!(
121121+ "no authorization server found for PDS: {pds}"
122122+ )));
123123+ }
124124+ };
125125+ let as_metadata = self.get_authorization_server_metadata(issuer).await?;
126126+ // https://datatracker.ietf.org/doc/html/draft-ietf-oauth-resource-metadata-08#name-authorization-server-metada
127127+ if let Some(protected_resources) = &as_metadata.protected_resources {
128128+ if !protected_resources.contains(&rs_metadata.resource) {
129129+ return Err(ResolverError::AuthorizationServerMetadata(format!(
130130+ "pds {pds} does not protected by issuer: {issuer}",
131131+ )));
132132+ }
133133+ }
134134+135135+ // TODO: atproot specific validation?
136136+ // https://github.com/bluesky-social/proposals/tree/main/0004-oauth#server-metadata
137137+ //
138138+ // eg.
139139+ // https://drafts.aaronpk.com/draft-parecki-oauth-client-id-metadata-document/draft-parecki-oauth-client-id-metadata-document.html
140140+ // if as_metadata.client_id_metadata_document_supported != Some(true) {
141141+ // return Err(Error::AuthorizationServerMetadata(format!(
142142+ // "authorization server does not support client_id_metadata_document: {issuer}"
143143+ // )));
144144+ // }
145145+146146+ Ok(as_metadata)
147147+ }
148148+}
149149+150150+pub async fn resolve_authorization_server<T: HttpClient + ?Sized>(
151151+ client: &T,
152152+ server: &Url,
153153+) -> Result<OAuthAuthorizationServerMetadata<'static>, ResolverError> {
154154+ let url = server
155155+ .join("/.well-known/oauth-authorization-server")
156156+ .map_err(|e| ResolverError::HttpClient(e.into()))?;
157157+158158+ let req = Request::builder()
159159+ .uri(url.to_string())
160160+ .body(Vec::new())
161161+ .map_err(|e| ResolverError::HttpClient(e.into()))?;
162162+ let res = client
163163+ .send_http(req)
164164+ .await
165165+ .map_err(|e| ResolverError::HttpClient(e.into()))?;
166166+ if res.status() == StatusCode::OK {
167167+ let metadata = serde_json::from_slice::<OAuthAuthorizationServerMetadata>(res.body())
168168+ .map_err(ResolverError::SerdeJson)?;
169169+ // https://datatracker.ietf.org/doc/html/rfc8414#section-3.3
170170+ if metadata.issuer == server.as_str() {
171171+ Ok(metadata.into_static())
172172+ } else {
173173+ Err(ResolverError::AuthorizationServerMetadata(format!(
174174+ "invalid issuer: {}",
175175+ metadata.issuer
176176+ )))
177177+ }
178178+ } else {
179179+ Err(ResolverError::HttpStatus(res.status()))
180180+ }
181181+}
182182+183183+pub async fn resolve_protected_resource_info<T: HttpClient + ?Sized>(
184184+ client: &T,
185185+ server: &Url,
186186+) -> Result<OAuthProtectedResourceMetadata<'static>, ResolverError> {
187187+ let url = server
188188+ .join("/.well-known/oauth-protected-resource")
189189+ .map_err(|e| ResolverError::HttpClient(e.into()))?;
190190+191191+ let req = Request::builder()
192192+ .uri(url.to_string())
193193+ .body(Vec::new())
194194+ .map_err(|e| ResolverError::HttpClient(e.into()))?;
195195+ let res = client
196196+ .send_http(req)
197197+ .await
198198+ .map_err(|e| ResolverError::HttpClient(e.into()))?;
199199+ if res.status() == StatusCode::OK {
200200+ let metadata = serde_json::from_slice::<OAuthProtectedResourceMetadata>(res.body())
201201+ .map_err(ResolverError::SerdeJson)?;
202202+ // https://datatracker.ietf.org/doc/html/rfc8414#section-3.3
203203+ if metadata.resource == server.as_str() {
204204+ Ok(metadata.into_static())
205205+ } else {
206206+ Err(ResolverError::AuthorizationServerMetadata(format!(
207207+ "invalid resource: {}",
208208+ metadata.resource
209209+ )))
210210+ }
211211+ } else {
212212+ Err(ResolverError::HttpStatus(res.status()))
213213+ }
214214+}
+1969
crates/jacquard-oauth/src/scopes.rs
···11+//! AT Protocol OAuth scopes module
22+//! Derived from https://tangled.org/@smokesignal.events/atproto-identity-rs/raw/main/crates/atproto-oauth/src/scopes.rs
33+//!
44+//! This module provides comprehensive support for AT Protocol OAuth scopes,
55+//! including parsing, serialization, normalization, and permission checking.
66+//!
77+//! Scopes in AT Protocol follow a prefix-based format with optional query parameters:
88+//! - `account`: Access to account information (email, repo, status)
99+//! - `identity`: Access to identity information (handle)
1010+//! - `blob`: Access to blob operations with mime type constraints
1111+//! - `repo`: Repository operations with collection and action constraints
1212+//! - `rpc`: RPC method access with lexicon and audience constraints
1313+//! - `atproto`: Required scope to indicate that other AT Protocol scopes will be used
1414+//! - `transition`: Migration operations (generic or email)
1515+//!
1616+//! Standard OpenID Connect scopes (no suffixes or query parameters):
1717+//! - `openid`: Required for OpenID Connect authentication
1818+//! - `profile`: Access to user profile information
1919+//! - `email`: Access to user email address
2020+2121+use std::collections::{BTreeMap, BTreeSet};
2222+use std::fmt;
2323+use std::str::FromStr;
2424+2525+use jacquard_common::types::did::Did;
2626+use jacquard_common::types::nsid::Nsid;
2727+use jacquard_common::types::string::AtStrError;
2828+use jacquard_common::{CowStr, IntoStatic};
2929+use smol_str::{SmolStr, ToSmolStr};
3030+3131+/// Represents an AT Protocol OAuth scope
3232+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
3333+pub enum Scope<'s> {
3434+ /// Account scope for accessing account information
3535+ Account(AccountScope),
3636+ /// Identity scope for accessing identity information
3737+ Identity(IdentityScope),
3838+ /// Blob scope for blob operations with mime type constraints
3939+ Blob(BlobScope<'s>),
4040+ /// Repository scope for collection operations
4141+ Repo(RepoScope<'s>),
4242+ /// RPC scope for method access
4343+ Rpc(RpcScope<'s>),
4444+ /// AT Protocol scope - required to indicate that other AT Protocol scopes will be used
4545+ Atproto,
4646+ /// Transition scope for migration operations
4747+ Transition(TransitionScope),
4848+ /// OpenID Connect scope - required for OpenID Connect authentication
4949+ OpenId,
5050+ /// Profile scope - access to user profile information
5151+ Profile,
5252+ /// Email scope - access to user email address
5353+ Email,
5454+}
5555+5656+impl IntoStatic for Scope<'_> {
5757+ type Output = Scope<'static>;
5858+5959+ fn into_static(self) -> Self::Output {
6060+ match self {
6161+ Scope::Account(scope) => Scope::Account(scope),
6262+ Scope::Identity(scope) => Scope::Identity(scope),
6363+ Scope::Blob(scope) => Scope::Blob(scope.into_static()),
6464+ Scope::Repo(scope) => Scope::Repo(scope.into_static()),
6565+ Scope::Rpc(scope) => Scope::Rpc(scope.into_static()),
6666+ Scope::Atproto => Scope::Atproto,
6767+ Scope::Transition(scope) => Scope::Transition(scope),
6868+ Scope::OpenId => Scope::OpenId,
6969+ Scope::Profile => Scope::Profile,
7070+ Scope::Email => Scope::Email,
7171+ }
7272+ }
7373+}
7474+7575+/// Account scope attributes
7676+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
7777+pub struct AccountScope {
7878+ /// The account resource type
7979+ pub resource: AccountResource,
8080+ /// The action permission level
8181+ pub action: AccountAction,
8282+}
8383+8484+/// Account resource types
8585+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
8686+pub enum AccountResource {
8787+ /// Email access
8888+ Email,
8989+ /// Repository access
9090+ Repo,
9191+ /// Status access
9292+ Status,
9393+}
9494+9595+/// Account action permissions
9696+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
9797+pub enum AccountAction {
9898+ /// Read-only access
9999+ Read,
100100+ /// Management access (includes read)
101101+ Manage,
102102+}
103103+104104+/// Identity scope attributes
105105+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
106106+pub enum IdentityScope {
107107+ /// Handle access
108108+ Handle,
109109+ /// All identity access (wildcard)
110110+ All,
111111+}
112112+113113+/// Transition scope types
114114+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
115115+pub enum TransitionScope {
116116+ /// Generic transition operations
117117+ Generic,
118118+ /// Email transition operations
119119+ Email,
120120+}
121121+122122+/// Blob scope with mime type constraints
123123+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
124124+pub struct BlobScope<'s> {
125125+ /// Accepted mime types
126126+ pub accept: BTreeSet<MimePattern<'s>>,
127127+}
128128+129129+impl IntoStatic for BlobScope<'_> {
130130+ type Output = BlobScope<'static>;
131131+132132+ fn into_static(self) -> Self::Output {
133133+ BlobScope {
134134+ accept: self.accept.into_iter().map(|p| p.into_static()).collect(),
135135+ }
136136+ }
137137+}
138138+139139+/// MIME type pattern for blob scope
140140+#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
141141+pub enum MimePattern<'s> {
142142+ /// Match all types
143143+ All,
144144+ /// Match all subtypes of a type (e.g., "image/*")
145145+ TypeWildcard(CowStr<'s>),
146146+ /// Exact mime type match
147147+ Exact(CowStr<'s>),
148148+}
149149+150150+impl IntoStatic for MimePattern<'_> {
151151+ type Output = MimePattern<'static>;
152152+153153+ fn into_static(self) -> Self::Output {
154154+ match self {
155155+ MimePattern::All => MimePattern::All,
156156+ MimePattern::TypeWildcard(s) => MimePattern::TypeWildcard(s.into_static()),
157157+ MimePattern::Exact(s) => MimePattern::Exact(s.into_static()),
158158+ }
159159+ }
160160+}
161161+162162+/// Repository scope with collection and action constraints
163163+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
164164+pub struct RepoScope<'s> {
165165+ /// Collection NSID or wildcard
166166+ pub collection: RepoCollection<'s>,
167167+ /// Allowed actions
168168+ pub actions: BTreeSet<RepoAction>,
169169+}
170170+171171+impl IntoStatic for RepoScope<'_> {
172172+ type Output = RepoScope<'static>;
173173+174174+ fn into_static(self) -> Self::Output {
175175+ RepoScope {
176176+ collection: self.collection.into_static(),
177177+ actions: self.actions,
178178+ }
179179+ }
180180+}
181181+182182+/// Repository collection identifier
183183+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
184184+pub enum RepoCollection<'s> {
185185+ /// All collections (wildcard)
186186+ All,
187187+ /// Specific collection NSID
188188+ Nsid(Nsid<'s>),
189189+}
190190+191191+impl IntoStatic for RepoCollection<'_> {
192192+ type Output = RepoCollection<'static>;
193193+194194+ fn into_static(self) -> Self::Output {
195195+ match self {
196196+ RepoCollection::All => RepoCollection::All,
197197+ RepoCollection::Nsid(nsid) => RepoCollection::Nsid(nsid.into_static()),
198198+ }
199199+ }
200200+}
201201+202202+/// Repository actions
203203+#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
204204+pub enum RepoAction {
205205+ /// Create records
206206+ Create,
207207+ /// Update records
208208+ Update,
209209+ /// Delete records
210210+ Delete,
211211+}
212212+213213+/// RPC scope with lexicon method and audience constraints
214214+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
215215+pub struct RpcScope<'s> {
216216+ /// Lexicon methods (NSIDs or wildcard)
217217+ pub lxm: BTreeSet<RpcLexicon<'s>>,
218218+ /// Audiences (DIDs or wildcard)
219219+ pub aud: BTreeSet<RpcAudience<'s>>,
220220+}
221221+222222+impl IntoStatic for RpcScope<'_> {
223223+ type Output = RpcScope<'static>;
224224+225225+ fn into_static(self) -> Self::Output {
226226+ RpcScope {
227227+ lxm: self.lxm.into_iter().map(|s| s.into_static()).collect(),
228228+ aud: self.aud.into_iter().map(|s| s.into_static()).collect(),
229229+ }
230230+ }
231231+}
232232+233233+/// RPC lexicon identifier
234234+#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
235235+pub enum RpcLexicon<'s> {
236236+ /// All lexicons (wildcard)
237237+ All,
238238+ /// Specific lexicon NSID
239239+ Nsid(Nsid<'s>),
240240+}
241241+242242+impl IntoStatic for RpcLexicon<'_> {
243243+ type Output = RpcLexicon<'static>;
244244+245245+ fn into_static(self) -> Self::Output {
246246+ match self {
247247+ RpcLexicon::All => RpcLexicon::All,
248248+ RpcLexicon::Nsid(nsid) => RpcLexicon::Nsid(nsid.into_static()),
249249+ }
250250+ }
251251+}
252252+253253+/// RPC audience identifier
254254+#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
255255+pub enum RpcAudience<'s> {
256256+ /// All audiences (wildcard)
257257+ All,
258258+ /// Specific DID
259259+ Did(Did<'s>),
260260+}
261261+262262+impl IntoStatic for RpcAudience<'_> {
263263+ type Output = RpcAudience<'static>;
264264+265265+ fn into_static(self) -> Self::Output {
266266+ match self {
267267+ RpcAudience::All => RpcAudience::All,
268268+ RpcAudience::Did(did) => RpcAudience::Did(did.into_static()),
269269+ }
270270+ }
271271+}
272272+273273+impl<'s> Scope<'s> {
274274+ /// Parse multiple space-separated scopes from a string
275275+ ///
276276+ /// # Examples
277277+ /// ```
278278+ /// # use jacquard_oauth::scopes::Scope;
279279+ /// let scopes = Scope::parse_multiple("atproto repo:*").unwrap();
280280+ /// assert_eq!(scopes.len(), 2);
281281+ /// ```
282282+ pub fn parse_multiple(s: &'s str) -> Result<Vec<Self>, ParseError> {
283283+ if s.trim().is_empty() {
284284+ return Ok(Vec::new());
285285+ }
286286+287287+ let mut scopes = Vec::new();
288288+ for scope_str in s.split_whitespace() {
289289+ scopes.push(Self::parse(scope_str)?);
290290+ }
291291+292292+ Ok(scopes)
293293+ }
294294+295295+ /// Parse multiple space-separated scopes and return the minimal set needed
296296+ ///
297297+ /// This method removes duplicate scopes and scopes that are already granted
298298+ /// by other scopes in the list, returning only the minimal set of scopes needed.
299299+ ///
300300+ /// # Examples
301301+ /// ```
302302+ /// # use jacquard_oauth::scopes::Scope;
303303+ /// // repo:* grants repo:foo.bar, so only repo:* is kept
304304+ /// let scopes = Scope::parse_multiple_reduced("atproto repo:app.bsky.feed.post repo:*").unwrap();
305305+ /// assert_eq!(scopes.len(), 2); // atproto and repo:*
306306+ /// ```
307307+ pub fn parse_multiple_reduced(s: &'s str) -> Result<Vec<Self>, ParseError> {
308308+ let all_scopes = Self::parse_multiple(s)?;
309309+310310+ if all_scopes.is_empty() {
311311+ return Ok(Vec::new());
312312+ }
313313+314314+ let mut result: Vec<Self> = Vec::new();
315315+316316+ for scope in all_scopes {
317317+ // Check if this scope is already granted by something in the result
318318+ let mut is_granted = false;
319319+ for existing in &result {
320320+ if existing.grants(&scope) && existing != &scope {
321321+ is_granted = true;
322322+ break;
323323+ }
324324+ }
325325+326326+ if is_granted {
327327+ continue; // Skip this scope, it's already covered
328328+ }
329329+330330+ // Check if this scope grants any existing scopes in the result
331331+ let mut indices_to_remove = Vec::new();
332332+ for (i, existing) in result.iter().enumerate() {
333333+ if scope.grants(existing) && &scope != existing {
334334+ indices_to_remove.push(i);
335335+ }
336336+ }
337337+338338+ // Remove scopes that are granted by the new scope (in reverse order to maintain indices)
339339+ for i in indices_to_remove.into_iter().rev() {
340340+ result.remove(i);
341341+ }
342342+343343+ // Add the new scope if it's not a duplicate
344344+ if !result.contains(&scope) {
345345+ result.push(scope);
346346+ }
347347+ }
348348+349349+ Ok(result)
350350+ }
351351+352352+ /// Serialize a list of scopes into a space-separated OAuth scopes string
353353+ ///
354354+ /// The scopes are sorted alphabetically by their string representation to ensure
355355+ /// consistent output regardless of input order.
356356+ ///
357357+ /// # Examples
358358+ /// ```
359359+ /// # use jacquard_oauth::scopes::Scope;
360360+ /// let scopes = vec![
361361+ /// Scope::parse("repo:*").unwrap(),
362362+ /// Scope::parse("atproto").unwrap(),
363363+ /// Scope::parse("account:email").unwrap(),
364364+ /// ];
365365+ /// let result = Scope::serialize_multiple(&scopes);
366366+ /// assert_eq!(result, "account:email atproto repo:*");
367367+ /// ```
368368+ pub fn serialize_multiple(scopes: &[Self]) -> CowStr<'static> {
369369+ if scopes.is_empty() {
370370+ return CowStr::default();
371371+ }
372372+373373+ let mut serialized: Vec<String> = scopes.iter().map(|scope| scope.to_string()).collect();
374374+375375+ serialized.sort();
376376+ serialized.join(" ").into()
377377+ }
378378+379379+ /// Remove a scope from a list of scopes
380380+ ///
381381+ /// Returns a new vector with all instances of the specified scope removed.
382382+ /// If the scope doesn't exist in the list, returns a copy of the original list.
383383+ ///
384384+ /// # Examples
385385+ /// ```
386386+ /// # use jacquard_oauth::scopes::Scope;
387387+ /// let scopes = vec![
388388+ /// Scope::parse("repo:*").unwrap(),
389389+ /// Scope::parse("atproto").unwrap(),
390390+ /// Scope::parse("account:email").unwrap(),
391391+ /// ];
392392+ /// let to_remove = Scope::parse("atproto").unwrap();
393393+ /// let result = Scope::remove_scope(&scopes, &to_remove);
394394+ /// assert_eq!(result.len(), 2);
395395+ /// assert!(!result.contains(&to_remove));
396396+ /// ```
397397+ pub fn remove_scope(scopes: &[Self], scope_to_remove: &Self) -> Vec<Self> {
398398+ scopes
399399+ .iter()
400400+ .filter(|s| *s != scope_to_remove)
401401+ .cloned()
402402+ .collect()
403403+ }
404404+405405+ /// Parse a scope from a string
406406+ pub fn parse(s: &'s str) -> Result<Self, ParseError> {
407407+ // Determine the prefix first by checking for known prefixes
408408+ let prefixes = [
409409+ "account",
410410+ "identity",
411411+ "blob",
412412+ "repo",
413413+ "rpc",
414414+ "atproto",
415415+ "transition",
416416+ "openid",
417417+ "profile",
418418+ "email",
419419+ ];
420420+ let mut found_prefix = None;
421421+ let mut suffix = None;
422422+423423+ for prefix in &prefixes {
424424+ if let Some(remainder) = s.strip_prefix(prefix)
425425+ && (remainder.is_empty()
426426+ || remainder.starts_with(':')
427427+ || remainder.starts_with('?'))
428428+ {
429429+ found_prefix = Some(*prefix);
430430+ if let Some(stripped) = remainder.strip_prefix(':') {
431431+ suffix = Some(stripped);
432432+ } else if remainder.starts_with('?') {
433433+ suffix = Some(remainder);
434434+ } else {
435435+ suffix = None;
436436+ }
437437+ break;
438438+ }
439439+ }
440440+441441+ let prefix = found_prefix.ok_or_else(|| {
442442+ // If no known prefix found, extract what looks like a prefix for error reporting
443443+ let end = s.find(':').or_else(|| s.find('?')).unwrap_or(s.len());
444444+ ParseError::UnknownPrefix(s[..end].to_string())
445445+ })?;
446446+447447+ match prefix {
448448+ "account" => Self::parse_account(suffix),
449449+ "identity" => Self::parse_identity(suffix),
450450+ "blob" => Self::parse_blob(suffix),
451451+ "repo" => Self::parse_repo(suffix),
452452+ "rpc" => Self::parse_rpc(suffix),
453453+ "atproto" => Self::parse_atproto(suffix),
454454+ "transition" => Self::parse_transition(suffix),
455455+ "openid" => Self::parse_openid(suffix),
456456+ "profile" => Self::parse_profile(suffix),
457457+ "email" => Self::parse_email(suffix),
458458+ _ => Err(ParseError::UnknownPrefix(prefix.to_string())),
459459+ }
460460+ }
461461+462462+ fn parse_account(suffix: Option<&'s str>) -> Result<Self, ParseError> {
463463+ let (resource_str, params) = match suffix {
464464+ Some(s) => {
465465+ if let Some(pos) = s.find('?') {
466466+ (&s[..pos], Some(&s[pos + 1..]))
467467+ } else {
468468+ (s, None)
469469+ }
470470+ }
471471+ None => return Err(ParseError::MissingResource),
472472+ };
473473+474474+ let resource = match resource_str {
475475+ "email" => AccountResource::Email,
476476+ "repo" => AccountResource::Repo,
477477+ "status" => AccountResource::Status,
478478+ _ => return Err(ParseError::InvalidResource(resource_str.to_string())),
479479+ };
480480+481481+ let action = if let Some(params) = params {
482482+ let parsed_params = parse_query_string(params);
483483+ match parsed_params
484484+ .get("action")
485485+ .and_then(|v| v.first())
486486+ .map(|s| s.as_ref())
487487+ {
488488+ Some("read") => AccountAction::Read,
489489+ Some("manage") => AccountAction::Manage,
490490+ Some(other) => return Err(ParseError::InvalidAction(other.to_string())),
491491+ None => AccountAction::Read,
492492+ }
493493+ } else {
494494+ AccountAction::Read
495495+ };
496496+497497+ Ok(Scope::Account(AccountScope { resource, action }))
498498+ }
499499+500500+ fn parse_identity(suffix: Option<&'s str>) -> Result<Self, ParseError> {
501501+ let scope = match suffix {
502502+ Some("handle") => IdentityScope::Handle,
503503+ Some("*") => IdentityScope::All,
504504+ Some(other) => return Err(ParseError::InvalidResource(other.to_string())),
505505+ None => return Err(ParseError::MissingResource),
506506+ };
507507+508508+ Ok(Scope::Identity(scope))
509509+ }
510510+511511+ fn parse_blob(suffix: Option<&'s str>) -> Result<Self, ParseError> {
512512+ let mut accept = BTreeSet::new();
513513+514514+ match suffix {
515515+ Some(s) if s.starts_with('?') => {
516516+ let params = parse_query_string(&s[1..]);
517517+ if let Some(values) = params.get("accept") {
518518+ for value in values {
519519+ accept.insert(MimePattern::from_str(value)?);
520520+ }
521521+ }
522522+ }
523523+ Some(s) => {
524524+ accept.insert(MimePattern::from_str(s)?);
525525+ }
526526+ None => {
527527+ accept.insert(MimePattern::All);
528528+ }
529529+ }
530530+531531+ if accept.is_empty() {
532532+ accept.insert(MimePattern::All);
533533+ }
534534+535535+ Ok(Scope::Blob(BlobScope { accept }))
536536+ }
537537+538538+ fn parse_repo(suffix: Option<&'s str>) -> Result<Self, ParseError> {
539539+ let (collection_str, params) = match suffix {
540540+ Some(s) => {
541541+ if let Some(pos) = s.find('?') {
542542+ (Some(&s[..pos]), Some(&s[pos + 1..]))
543543+ } else {
544544+ (Some(s), None)
545545+ }
546546+ }
547547+ None => (None, None),
548548+ };
549549+550550+ let collection = match collection_str {
551551+ Some("*") | None => RepoCollection::All,
552552+ Some(nsid) => RepoCollection::Nsid(Nsid::new(nsid)?),
553553+ };
554554+555555+ let mut actions = BTreeSet::new();
556556+ if let Some(params) = params {
557557+ let parsed_params = parse_query_string(params);
558558+ if let Some(values) = parsed_params.get("action") {
559559+ for value in values {
560560+ match value.as_ref() {
561561+ "create" => {
562562+ actions.insert(RepoAction::Create);
563563+ }
564564+ "update" => {
565565+ actions.insert(RepoAction::Update);
566566+ }
567567+ "delete" => {
568568+ actions.insert(RepoAction::Delete);
569569+ }
570570+ "*" => {
571571+ actions.insert(RepoAction::Create);
572572+ actions.insert(RepoAction::Update);
573573+ actions.insert(RepoAction::Delete);
574574+ }
575575+ other => return Err(ParseError::InvalidAction(other.to_string())),
576576+ }
577577+ }
578578+ }
579579+ }
580580+581581+ if actions.is_empty() {
582582+ actions.insert(RepoAction::Create);
583583+ actions.insert(RepoAction::Update);
584584+ actions.insert(RepoAction::Delete);
585585+ }
586586+587587+ Ok(Scope::Repo(RepoScope {
588588+ collection,
589589+ actions,
590590+ }))
591591+ }
592592+593593+ fn parse_rpc(suffix: Option<&'s str>) -> Result<Self, ParseError> {
594594+ let mut lxm = BTreeSet::new();
595595+ let mut aud = BTreeSet::new();
596596+597597+ match suffix {
598598+ Some("*") => {
599599+ lxm.insert(RpcLexicon::All);
600600+ aud.insert(RpcAudience::All);
601601+ }
602602+ Some(s) if s.starts_with('?') => {
603603+ let params = parse_query_string(&s[1..]);
604604+605605+ if let Some(values) = params.get("lxm") {
606606+ for value in values {
607607+ if value.as_ref() == "*" {
608608+ lxm.insert(RpcLexicon::All);
609609+ } else {
610610+ lxm.insert(RpcLexicon::Nsid(Nsid::new(value)?.into_static()));
611611+ }
612612+ }
613613+ }
614614+615615+ if let Some(values) = params.get("aud") {
616616+ for value in values {
617617+ if value.as_ref() == "*" {
618618+ aud.insert(RpcAudience::All);
619619+ } else {
620620+ aud.insert(RpcAudience::Did(Did::new(value)?.into_static()));
621621+ }
622622+ }
623623+ }
624624+ }
625625+ Some(s) => {
626626+ // Check if there's a query string in the suffix
627627+ if let Some(pos) = s.find('?') {
628628+ let nsid = &s[..pos];
629629+ let params = parse_query_string(&s[pos + 1..]);
630630+631631+ lxm.insert(RpcLexicon::Nsid(Nsid::new(nsid)?.into_static()));
632632+633633+ if let Some(values) = params.get("aud") {
634634+ for value in values {
635635+ if value.as_ref() == "*" {
636636+ aud.insert(RpcAudience::All);
637637+ } else {
638638+ aud.insert(RpcAudience::Did(Did::new(value)?.into_static()));
639639+ }
640640+ }
641641+ }
642642+ } else {
643643+ lxm.insert(RpcLexicon::Nsid(Nsid::new(s)?.into_static()));
644644+ }
645645+ }
646646+ None => {}
647647+ }
648648+649649+ if lxm.is_empty() {
650650+ lxm.insert(RpcLexicon::All);
651651+ }
652652+ if aud.is_empty() {
653653+ aud.insert(RpcAudience::All);
654654+ }
655655+656656+ Ok(Scope::Rpc(RpcScope { lxm, aud }))
657657+ }
658658+659659+ fn parse_atproto(suffix: Option<&str>) -> Result<Self, ParseError> {
660660+ if suffix.is_some() {
661661+ return Err(ParseError::InvalidResource(
662662+ "atproto scope does not accept suffixes".to_string(),
663663+ ));
664664+ }
665665+ Ok(Scope::Atproto)
666666+ }
667667+668668+ fn parse_transition(suffix: Option<&str>) -> Result<Self, ParseError> {
669669+ let scope = match suffix {
670670+ Some("generic") => TransitionScope::Generic,
671671+ Some("email") => TransitionScope::Email,
672672+ Some(other) => return Err(ParseError::InvalidResource(other.to_string())),
673673+ None => return Err(ParseError::MissingResource),
674674+ };
675675+676676+ Ok(Scope::Transition(scope))
677677+ }
678678+679679+ fn parse_openid(suffix: Option<&str>) -> Result<Self, ParseError> {
680680+ if suffix.is_some() {
681681+ return Err(ParseError::InvalidResource(
682682+ "openid scope does not accept suffixes".to_string(),
683683+ ));
684684+ }
685685+ Ok(Scope::OpenId)
686686+ }
687687+688688+ fn parse_profile(suffix: Option<&str>) -> Result<Self, ParseError> {
689689+ if suffix.is_some() {
690690+ return Err(ParseError::InvalidResource(
691691+ "profile scope does not accept suffixes".to_string(),
692692+ ));
693693+ }
694694+ Ok(Scope::Profile)
695695+ }
696696+697697+ fn parse_email(suffix: Option<&str>) -> Result<Self, ParseError> {
698698+ if suffix.is_some() {
699699+ return Err(ParseError::InvalidResource(
700700+ "email scope does not accept suffixes".to_string(),
701701+ ));
702702+ }
703703+ Ok(Scope::Email)
704704+ }
705705+706706+ /// Convert the scope to its normalized string representation
707707+ pub fn to_string_normalized(&self) -> String {
708708+ match self {
709709+ Scope::Account(scope) => {
710710+ let resource = match scope.resource {
711711+ AccountResource::Email => "email",
712712+ AccountResource::Repo => "repo",
713713+ AccountResource::Status => "status",
714714+ };
715715+716716+ match scope.action {
717717+ AccountAction::Read => format!("account:{}", resource),
718718+ AccountAction::Manage => format!("account:{}?action=manage", resource),
719719+ }
720720+ }
721721+ Scope::Identity(scope) => match scope {
722722+ IdentityScope::Handle => "identity:handle".to_string(),
723723+ IdentityScope::All => "identity:*".to_string(),
724724+ },
725725+ Scope::Blob(scope) => {
726726+ if scope.accept.len() == 1 {
727727+ if let Some(pattern) = scope.accept.iter().next() {
728728+ match pattern {
729729+ MimePattern::All => "blob:*/*".to_string(),
730730+ MimePattern::TypeWildcard(t) => format!("blob:{}/*", t),
731731+ MimePattern::Exact(mime) => format!("blob:{}", mime),
732732+ }
733733+ } else {
734734+ "blob:*/*".to_string()
735735+ }
736736+ } else {
737737+ let mut params = Vec::new();
738738+ for pattern in &scope.accept {
739739+ match pattern {
740740+ MimePattern::All => params.push("accept=*/*".to_string()),
741741+ MimePattern::TypeWildcard(t) => params.push(format!("accept={}/*", t)),
742742+ MimePattern::Exact(mime) => params.push(format!("accept={}", mime)),
743743+ }
744744+ }
745745+ params.sort();
746746+ format!("blob?{}", params.join("&"))
747747+ }
748748+ }
749749+ Scope::Repo(scope) => {
750750+ let collection = match &scope.collection {
751751+ RepoCollection::All => "*",
752752+ RepoCollection::Nsid(nsid) => nsid,
753753+ };
754754+755755+ if scope.actions.len() == 3 {
756756+ format!("repo:{}", collection)
757757+ } else {
758758+ let mut params = Vec::new();
759759+ for action in &scope.actions {
760760+ match action {
761761+ RepoAction::Create => params.push("action=create"),
762762+ RepoAction::Update => params.push("action=update"),
763763+ RepoAction::Delete => params.push("action=delete"),
764764+ }
765765+ }
766766+ format!("repo:{}?{}", collection, params.join("&"))
767767+ }
768768+ }
769769+ Scope::Rpc(scope) => {
770770+ if scope.lxm.len() == 1
771771+ && scope.lxm.contains(&RpcLexicon::All)
772772+ && scope.aud.len() == 1
773773+ && scope.aud.contains(&RpcAudience::All)
774774+ {
775775+ "rpc:*".to_string()
776776+ } else if scope.lxm.len() == 1
777777+ && scope.aud.len() == 1
778778+ && scope.aud.contains(&RpcAudience::All)
779779+ {
780780+ if let Some(lxm) = scope.lxm.iter().next() {
781781+ match lxm {
782782+ RpcLexicon::All => "rpc:*".to_string(),
783783+ RpcLexicon::Nsid(nsid) => format!("rpc:{}", nsid),
784784+ }
785785+ } else {
786786+ "rpc:*".to_string()
787787+ }
788788+ } else {
789789+ let mut params = Vec::new();
790790+791791+ for lxm in &scope.lxm {
792792+ match lxm {
793793+ RpcLexicon::All => params.push("lxm=*".to_string()),
794794+ RpcLexicon::Nsid(nsid) => params.push(format!("lxm={}", nsid)),
795795+ }
796796+ }
797797+798798+ for aud in &scope.aud {
799799+ match aud {
800800+ RpcAudience::All => params.push("aud=*".to_string()),
801801+ RpcAudience::Did(did) => params.push(format!("aud={}", did)),
802802+ }
803803+ }
804804+805805+ params.sort();
806806+807807+ if params.is_empty() {
808808+ "rpc:*".to_string()
809809+ } else {
810810+ format!("rpc?{}", params.join("&"))
811811+ }
812812+ }
813813+ }
814814+ Scope::Atproto => "atproto".to_string(),
815815+ Scope::Transition(scope) => match scope {
816816+ TransitionScope::Generic => "transition:generic".to_string(),
817817+ TransitionScope::Email => "transition:email".to_string(),
818818+ },
819819+ Scope::OpenId => "openid".to_string(),
820820+ Scope::Profile => "profile".to_string(),
821821+ Scope::Email => "email".to_string(),
822822+ }
823823+ }
824824+825825+ /// Check if this scope grants the permissions of another scope
826826+ pub fn grants(&self, other: &Scope) -> bool {
827827+ match (self, other) {
828828+ // Atproto only grants itself (it's a required scope, not a permission grant)
829829+ (Scope::Atproto, Scope::Atproto) => true,
830830+ (Scope::Atproto, _) => false,
831831+ // Nothing else grants atproto
832832+ (_, Scope::Atproto) => false,
833833+ // Transition scopes only grant themselves
834834+ (Scope::Transition(a), Scope::Transition(b)) => a == b,
835835+ // Other scopes don't grant transition scopes
836836+ (_, Scope::Transition(_)) => false,
837837+ (Scope::Transition(_), _) => false,
838838+ // OpenID Connect scopes only grant themselves
839839+ (Scope::OpenId, Scope::OpenId) => true,
840840+ (Scope::OpenId, _) => false,
841841+ (_, Scope::OpenId) => false,
842842+ (Scope::Profile, Scope::Profile) => true,
843843+ (Scope::Profile, _) => false,
844844+ (_, Scope::Profile) => false,
845845+ (Scope::Email, Scope::Email) => true,
846846+ (Scope::Email, _) => false,
847847+ (_, Scope::Email) => false,
848848+ (Scope::Account(a), Scope::Account(b)) => {
849849+ a.resource == b.resource
850850+ && matches!(
851851+ (a.action, b.action),
852852+ (AccountAction::Manage, _) | (AccountAction::Read, AccountAction::Read)
853853+ )
854854+ }
855855+ (Scope::Identity(a), Scope::Identity(b)) => matches!(
856856+ (a, b),
857857+ (IdentityScope::All, _) | (IdentityScope::Handle, IdentityScope::Handle)
858858+ ),
859859+ (Scope::Blob(a), Scope::Blob(b)) => {
860860+ for b_pattern in &b.accept {
861861+ let mut granted = false;
862862+ for a_pattern in &a.accept {
863863+ if a_pattern.grants(b_pattern) {
864864+ granted = true;
865865+ break;
866866+ }
867867+ }
868868+ if !granted {
869869+ return false;
870870+ }
871871+ }
872872+ true
873873+ }
874874+ (Scope::Repo(a), Scope::Repo(b)) => {
875875+ let collection_match = match (&a.collection, &b.collection) {
876876+ (RepoCollection::All, _) => true,
877877+ (RepoCollection::Nsid(a_nsid), RepoCollection::Nsid(b_nsid)) => {
878878+ a_nsid == b_nsid
879879+ }
880880+ _ => false,
881881+ };
882882+883883+ if !collection_match {
884884+ return false;
885885+ }
886886+887887+ b.actions.is_subset(&a.actions) || a.actions.len() == 3
888888+ }
889889+ (Scope::Rpc(a), Scope::Rpc(b)) => {
890890+ let lxm_match = if a.lxm.contains(&RpcLexicon::All) {
891891+ true
892892+ } else {
893893+ b.lxm.iter().all(|b_lxm| match b_lxm {
894894+ RpcLexicon::All => false,
895895+ RpcLexicon::Nsid(_) => a.lxm.contains(b_lxm),
896896+ })
897897+ };
898898+899899+ let aud_match = if a.aud.contains(&RpcAudience::All) {
900900+ true
901901+ } else {
902902+ b.aud.iter().all(|b_aud| match b_aud {
903903+ RpcAudience::All => false,
904904+ RpcAudience::Did(_) => a.aud.contains(b_aud),
905905+ })
906906+ };
907907+908908+ lxm_match && aud_match
909909+ }
910910+ _ => false,
911911+ }
912912+ }
913913+}
914914+915915+impl MimePattern<'_> {
916916+ fn grants(&self, other: &MimePattern) -> bool {
917917+ match (self, other) {
918918+ (MimePattern::All, _) => true,
919919+ (MimePattern::TypeWildcard(a_type), MimePattern::TypeWildcard(b_type)) => {
920920+ a_type == b_type
921921+ }
922922+ (MimePattern::TypeWildcard(a_type), MimePattern::Exact(b_mime)) => {
923923+ b_mime.starts_with(&format!("{}/", a_type))
924924+ }
925925+ (MimePattern::Exact(a), MimePattern::Exact(b)) => a == b,
926926+ _ => false,
927927+ }
928928+ }
929929+}
930930+931931+impl FromStr for MimePattern<'_> {
932932+ type Err = ParseError;
933933+934934+ fn from_str(s: &str) -> Result<Self, Self::Err> {
935935+ if s == "*/*" {
936936+ Ok(MimePattern::All)
937937+ } else if let Some(stripped) = s.strip_suffix("/*") {
938938+ Ok(MimePattern::TypeWildcard(CowStr::Owned(
939939+ stripped.to_smolstr(),
940940+ )))
941941+ } else if s.contains('/') {
942942+ Ok(MimePattern::Exact(CowStr::Owned(s.to_smolstr())))
943943+ } else {
944944+ Err(ParseError::InvalidMimeType(s.to_string()))
945945+ }
946946+ }
947947+}
948948+949949+impl FromStr for Scope<'_> {
950950+ type Err = ParseError;
951951+952952+ fn from_str(s: &str) -> Result<Scope<'static>, Self::Err> {
953953+ match Scope::parse(s) {
954954+ Ok(parsed) => Ok(parsed.into_static()),
955955+ Err(e) => Err(e),
956956+ }
957957+ }
958958+}
959959+960960+impl fmt::Display for Scope<'_> {
961961+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
962962+ write!(f, "{}", self.to_string_normalized())
963963+ }
964964+}
965965+966966+/// Parse a query string into a map of keys to lists of values
967967+fn parse_query_string(query: &str) -> BTreeMap<SmolStr, Vec<CowStr<'static>>> {
968968+ let mut params = BTreeMap::new();
969969+970970+ for pair in query.split('&') {
971971+ if let Some(pos) = pair.find('=') {
972972+ let key = &pair[..pos];
973973+ let value = &pair[pos + 1..];
974974+ params
975975+ .entry(key.to_smolstr())
976976+ .or_insert_with(Vec::new)
977977+ .push(CowStr::Owned(value.to_smolstr()));
978978+ }
979979+ }
980980+981981+ params
982982+}
983983+984984+/// Error type for scope parsing
985985+#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error, miette::Diagnostic)]
986986+pub enum ParseError {
987987+ /// Unknown scope prefix
988988+ UnknownPrefix(String),
989989+ /// Missing required resource
990990+ MissingResource,
991991+ /// Invalid resource type
992992+ InvalidResource(String),
993993+ /// Invalid action type
994994+ InvalidAction(String),
995995+ /// Invalid MIME type
996996+ InvalidMimeType(String),
997997+ ParseError(#[from] AtStrError),
998998+}
999999+10001000+impl fmt::Display for ParseError {
10011001+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
10021002+ match self {
10031003+ ParseError::UnknownPrefix(prefix) => write!(f, "Unknown scope prefix: {}", prefix),
10041004+ ParseError::MissingResource => write!(f, "Missing required resource"),
10051005+ ParseError::InvalidResource(resource) => write!(f, "Invalid resource: {}", resource),
10061006+ ParseError::InvalidAction(action) => write!(f, "Invalid action: {}", action),
10071007+ ParseError::InvalidMimeType(mime) => write!(f, "Invalid MIME type: {}", mime),
10081008+ ParseError::ParseError(err) => write!(f, "Parse error: {}", err),
10091009+ }
10101010+ }
10111011+}
10121012+10131013+#[cfg(test)]
10141014+mod tests {
10151015+ use super::*;
10161016+10171017+ #[test]
10181018+ fn test_account_scope_parsing() {
10191019+ let scope = Scope::parse("account:email").unwrap();
10201020+ assert_eq!(
10211021+ scope,
10221022+ Scope::Account(AccountScope {
10231023+ resource: AccountResource::Email,
10241024+ action: AccountAction::Read,
10251025+ })
10261026+ );
10271027+10281028+ let scope = Scope::parse("account:repo?action=manage").unwrap();
10291029+ assert_eq!(
10301030+ scope,
10311031+ Scope::Account(AccountScope {
10321032+ resource: AccountResource::Repo,
10331033+ action: AccountAction::Manage,
10341034+ })
10351035+ );
10361036+10371037+ let scope = Scope::parse("account:status?action=read").unwrap();
10381038+ assert_eq!(
10391039+ scope,
10401040+ Scope::Account(AccountScope {
10411041+ resource: AccountResource::Status,
10421042+ action: AccountAction::Read,
10431043+ })
10441044+ );
10451045+ }
10461046+10471047+ #[test]
10481048+ fn test_identity_scope_parsing() {
10491049+ let scope = Scope::parse("identity:handle").unwrap();
10501050+ assert_eq!(scope, Scope::Identity(IdentityScope::Handle));
10511051+10521052+ let scope = Scope::parse("identity:*").unwrap();
10531053+ assert_eq!(scope, Scope::Identity(IdentityScope::All));
10541054+ }
10551055+10561056+ #[test]
10571057+ fn test_blob_scope_parsing() {
10581058+ let scope = Scope::parse("blob:*/*").unwrap();
10591059+ let mut accept = BTreeSet::new();
10601060+ accept.insert(MimePattern::All);
10611061+ assert_eq!(scope, Scope::Blob(BlobScope { accept }));
10621062+10631063+ let scope = Scope::parse("blob:image/png").unwrap();
10641064+ let mut accept = BTreeSet::new();
10651065+ accept.insert(MimePattern::Exact(CowStr::new_static("image/png")));
10661066+ assert_eq!(scope, Scope::Blob(BlobScope { accept }));
10671067+10681068+ let scope = Scope::parse("blob?accept=image/png&accept=image/jpeg").unwrap();
10691069+ let mut accept = BTreeSet::new();
10701070+ accept.insert(MimePattern::Exact(CowStr::new_static("image/png")));
10711071+ accept.insert(MimePattern::Exact(CowStr::new_static("image/jpeg")));
10721072+ assert_eq!(scope, Scope::Blob(BlobScope { accept }));
10731073+10741074+ let scope = Scope::parse("blob:image/*").unwrap();
10751075+ let mut accept = BTreeSet::new();
10761076+ accept.insert(MimePattern::TypeWildcard(CowStr::new_static("image")));
10771077+ assert_eq!(scope, Scope::Blob(BlobScope { accept }));
10781078+ }
10791079+10801080+ #[test]
10811081+ fn test_repo_scope_parsing() {
10821082+ let scope = Scope::parse("repo:*?action=create").unwrap();
10831083+ let mut actions = BTreeSet::new();
10841084+ actions.insert(RepoAction::Create);
10851085+ assert_eq!(
10861086+ scope,
10871087+ Scope::Repo(RepoScope {
10881088+ collection: RepoCollection::All,
10891089+ actions,
10901090+ })
10911091+ );
10921092+10931093+ let scope = Scope::parse("repo:app.bsky.feed.post?action=create&action=update").unwrap();
10941094+ let mut actions = BTreeSet::new();
10951095+ actions.insert(RepoAction::Create);
10961096+ actions.insert(RepoAction::Update);
10971097+ assert_eq!(
10981098+ scope,
10991099+ Scope::Repo(RepoScope {
11001100+ collection: RepoCollection::Nsid(Nsid::new_static("app.bsky.feed.post").unwrap()),
11011101+ actions,
11021102+ })
11031103+ );
11041104+11051105+ let scope = Scope::parse("repo:app.bsky.feed.post").unwrap();
11061106+ let mut actions = BTreeSet::new();
11071107+ actions.insert(RepoAction::Create);
11081108+ actions.insert(RepoAction::Update);
11091109+ actions.insert(RepoAction::Delete);
11101110+ assert_eq!(
11111111+ scope,
11121112+ Scope::Repo(RepoScope {
11131113+ collection: RepoCollection::Nsid(Nsid::new_static("app.bsky.feed.post").unwrap()),
11141114+ actions,
11151115+ })
11161116+ );
11171117+ }
11181118+11191119+ #[test]
11201120+ fn test_rpc_scope_parsing() {
11211121+ let scope = Scope::parse("rpc:*").unwrap();
11221122+ let mut lxm = BTreeSet::new();
11231123+ let mut aud = BTreeSet::new();
11241124+ lxm.insert(RpcLexicon::All);
11251125+ aud.insert(RpcAudience::All);
11261126+ assert_eq!(scope, Scope::Rpc(RpcScope { lxm, aud }));
11271127+11281128+ let scope = Scope::parse("rpc:com.example.service").unwrap();
11291129+ let mut lxm = BTreeSet::new();
11301130+ let mut aud = BTreeSet::new();
11311131+ lxm.insert(RpcLexicon::Nsid(
11321132+ Nsid::new_static("com.example.service").unwrap(),
11331133+ ));
11341134+ aud.insert(RpcAudience::All);
11351135+ assert_eq!(scope, Scope::Rpc(RpcScope { lxm, aud }));
11361136+11371137+ let scope =
11381138+ Scope::parse("rpc:com.example.service?aud=did:plc:yfvwmnlztr4dwkb7hwz55r2g").unwrap();
11391139+ let mut lxm = BTreeSet::new();
11401140+ let mut aud = BTreeSet::new();
11411141+ lxm.insert(RpcLexicon::Nsid(
11421142+ Nsid::new_static("com.example.service").unwrap(),
11431143+ ));
11441144+ aud.insert(RpcAudience::Did(
11451145+ Did::new_static("did:plc:yfvwmnlztr4dwkb7hwz55r2g").unwrap(),
11461146+ ));
11471147+ assert_eq!(scope, Scope::Rpc(RpcScope { lxm, aud }));
11481148+11491149+ let scope =
11501150+ Scope::parse("rpc?lxm=com.example.method1&lxm=com.example.method2&aud=did:plc:yfvwmnlztr4dwkb7hwz55r2g")
11511151+ .unwrap();
11521152+ let mut lxm = BTreeSet::new();
11531153+ let mut aud = BTreeSet::new();
11541154+ lxm.insert(RpcLexicon::Nsid(
11551155+ Nsid::new_static("com.example.method1").unwrap(),
11561156+ ));
11571157+ lxm.insert(RpcLexicon::Nsid(
11581158+ Nsid::new_static("com.example.method2").unwrap(),
11591159+ ));
11601160+ aud.insert(RpcAudience::Did(
11611161+ Did::new_static("did:plc:yfvwmnlztr4dwkb7hwz55r2g").unwrap(),
11621162+ ));
11631163+ assert_eq!(scope, Scope::Rpc(RpcScope { lxm, aud }));
11641164+ }
11651165+11661166+ #[test]
11671167+ fn test_scope_normalization() {
11681168+ let tests = vec![
11691169+ ("account:email", "account:email"),
11701170+ ("account:email?action=read", "account:email"),
11711171+ ("account:email?action=manage", "account:email?action=manage"),
11721172+ ("blob:image/png", "blob:image/png"),
11731173+ (
11741174+ "blob?accept=image/jpeg&accept=image/png",
11751175+ "blob?accept=image/jpeg&accept=image/png",
11761176+ ),
11771177+ ("repo:app.bsky.feed.post", "repo:app.bsky.feed.post"),
11781178+ (
11791179+ "repo:app.bsky.feed.post?action=create",
11801180+ "repo:app.bsky.feed.post?action=create",
11811181+ ),
11821182+ ("rpc:*", "rpc:*"),
11831183+ ];
11841184+11851185+ for (input, expected) in tests {
11861186+ let scope = Scope::parse(input).unwrap();
11871187+ assert_eq!(scope.to_string_normalized(), expected);
11881188+ }
11891189+ }
11901190+11911191+ #[test]
11921192+ fn test_account_scope_grants() {
11931193+ let manage = Scope::parse("account:email?action=manage").unwrap();
11941194+ let read = Scope::parse("account:email?action=read").unwrap();
11951195+ let other_read = Scope::parse("account:repo?action=read").unwrap();
11961196+11971197+ assert!(manage.grants(&read));
11981198+ assert!(manage.grants(&manage));
11991199+ assert!(!read.grants(&manage));
12001200+ assert!(read.grants(&read));
12011201+ assert!(!read.grants(&other_read));
12021202+ }
12031203+12041204+ #[test]
12051205+ fn test_identity_scope_grants() {
12061206+ let all = Scope::parse("identity:*").unwrap();
12071207+ let handle = Scope::parse("identity:handle").unwrap();
12081208+12091209+ assert!(all.grants(&handle));
12101210+ assert!(all.grants(&all));
12111211+ assert!(!handle.grants(&all));
12121212+ assert!(handle.grants(&handle));
12131213+ }
12141214+12151215+ #[test]
12161216+ fn test_blob_scope_grants() {
12171217+ let all = Scope::parse("blob:*/*").unwrap();
12181218+ let image_all = Scope::parse("blob:image/*").unwrap();
12191219+ let image_png = Scope::parse("blob:image/png").unwrap();
12201220+ let text_plain = Scope::parse("blob:text/plain").unwrap();
12211221+12221222+ assert!(all.grants(&image_all));
12231223+ assert!(all.grants(&image_png));
12241224+ assert!(all.grants(&text_plain));
12251225+ assert!(image_all.grants(&image_png));
12261226+ assert!(!image_all.grants(&text_plain));
12271227+ assert!(!image_png.grants(&image_all));
12281228+ }
12291229+12301230+ #[test]
12311231+ fn test_repo_scope_grants() {
12321232+ let all_all = Scope::parse("repo:*").unwrap();
12331233+ let all_create = Scope::parse("repo:*?action=create").unwrap();
12341234+ let specific_all = Scope::parse("repo:app.bsky.feed.post").unwrap();
12351235+ let specific_create = Scope::parse("repo:app.bsky.feed.post?action=create").unwrap();
12361236+ let other_create = Scope::parse("repo:pub.leaflet.publication?action=create").unwrap();
12371237+12381238+ assert!(all_all.grants(&all_create));
12391239+ assert!(all_all.grants(&specific_all));
12401240+ assert!(all_all.grants(&specific_create));
12411241+ assert!(all_create.grants(&all_create));
12421242+ assert!(!all_create.grants(&specific_all));
12431243+ assert!(specific_all.grants(&specific_create));
12441244+ assert!(!specific_create.grants(&specific_all));
12451245+ assert!(!specific_create.grants(&other_create));
12461246+ }
12471247+12481248+ #[test]
12491249+ fn test_rpc_scope_grants() {
12501250+ let all = Scope::parse("rpc:*").unwrap();
12511251+ let specific_lxm = Scope::parse("rpc:com.example.service").unwrap();
12521252+ let specific_both = Scope::parse("rpc:com.example.service?aud=did:example:123").unwrap();
12531253+12541254+ assert!(all.grants(&specific_lxm));
12551255+ assert!(all.grants(&specific_both));
12561256+ assert!(specific_lxm.grants(&specific_both));
12571257+ assert!(!specific_both.grants(&specific_lxm));
12581258+ assert!(!specific_both.grants(&all));
12591259+ }
12601260+12611261+ #[test]
12621262+ fn test_cross_scope_grants() {
12631263+ let account = Scope::parse("account:email").unwrap();
12641264+ let identity = Scope::parse("identity:handle").unwrap();
12651265+12661266+ assert!(!account.grants(&identity));
12671267+ assert!(!identity.grants(&account));
12681268+ }
12691269+12701270+ #[test]
12711271+ fn test_parse_errors() {
12721272+ assert!(matches!(
12731273+ Scope::parse("unknown:test"),
12741274+ Err(ParseError::UnknownPrefix(_))
12751275+ ));
12761276+12771277+ assert!(matches!(
12781278+ Scope::parse("account"),
12791279+ Err(ParseError::MissingResource)
12801280+ ));
12811281+12821282+ assert!(matches!(
12831283+ Scope::parse("account:invalid"),
12841284+ Err(ParseError::InvalidResource(_))
12851285+ ));
12861286+12871287+ assert!(matches!(
12881288+ Scope::parse("account:email?action=invalid"),
12891289+ Err(ParseError::InvalidAction(_))
12901290+ ));
12911291+ }
12921292+12931293+ #[test]
12941294+ fn test_query_parameter_sorting() {
12951295+ let scope =
12961296+ Scope::parse("blob?accept=image/png&accept=application/pdf&accept=image/jpeg").unwrap();
12971297+ let normalized = scope.to_string_normalized();
12981298+ assert!(normalized.contains("accept=application/pdf"));
12991299+ assert!(normalized.contains("accept=image/jpeg"));
13001300+ assert!(normalized.contains("accept=image/png"));
13011301+ let pdf_pos = normalized.find("accept=application/pdf").unwrap();
13021302+ let jpeg_pos = normalized.find("accept=image/jpeg").unwrap();
13031303+ let png_pos = normalized.find("accept=image/png").unwrap();
13041304+ assert!(pdf_pos < jpeg_pos);
13051305+ assert!(jpeg_pos < png_pos);
13061306+ }
13071307+13081308+ #[test]
13091309+ fn test_repo_action_wildcard() {
13101310+ let scope = Scope::parse("repo:app.bsky.feed.post?action=*").unwrap();
13111311+ let mut actions = BTreeSet::new();
13121312+ actions.insert(RepoAction::Create);
13131313+ actions.insert(RepoAction::Update);
13141314+ actions.insert(RepoAction::Delete);
13151315+ assert_eq!(
13161316+ scope,
13171317+ Scope::Repo(RepoScope {
13181318+ collection: RepoCollection::Nsid(Nsid::new_static("app.bsky.feed.post").unwrap()),
13191319+ actions,
13201320+ })
13211321+ );
13221322+ }
13231323+13241324+ #[test]
13251325+ fn test_multiple_blob_accepts() {
13261326+ let scope = Scope::parse("blob?accept=image/*&accept=text/plain").unwrap();
13271327+ assert!(scope.grants(&Scope::parse("blob:image/png").unwrap()));
13281328+ assert!(scope.grants(&Scope::parse("blob:text/plain").unwrap()));
13291329+ assert!(!scope.grants(&Scope::parse("blob:application/json").unwrap()));
13301330+ }
13311331+13321332+ #[test]
13331333+ fn test_rpc_default_wildcards() {
13341334+ let scope = Scope::parse("rpc").unwrap();
13351335+ let mut lxm = BTreeSet::new();
13361336+ let mut aud = BTreeSet::new();
13371337+ lxm.insert(RpcLexicon::All);
13381338+ aud.insert(RpcAudience::All);
13391339+ assert_eq!(scope, Scope::Rpc(RpcScope { lxm, aud }));
13401340+ }
13411341+13421342+ #[test]
13431343+ fn test_atproto_scope_parsing() {
13441344+ let scope = Scope::parse("atproto").unwrap();
13451345+ assert_eq!(scope, Scope::Atproto);
13461346+13471347+ // Atproto should not accept suffixes
13481348+ assert!(Scope::parse("atproto:something").is_err());
13491349+ assert!(Scope::parse("atproto?param=value").is_err());
13501350+ }
13511351+13521352+ #[test]
13531353+ fn test_transition_scope_parsing() {
13541354+ let scope = Scope::parse("transition:generic").unwrap();
13551355+ assert_eq!(scope, Scope::Transition(TransitionScope::Generic));
13561356+13571357+ let scope = Scope::parse("transition:email").unwrap();
13581358+ assert_eq!(scope, Scope::Transition(TransitionScope::Email));
13591359+13601360+ // Test invalid transition types
13611361+ assert!(matches!(
13621362+ Scope::parse("transition:invalid"),
13631363+ Err(ParseError::InvalidResource(_))
13641364+ ));
13651365+13661366+ // Test missing suffix
13671367+ assert!(matches!(
13681368+ Scope::parse("transition"),
13691369+ Err(ParseError::MissingResource)
13701370+ ));
13711371+13721372+ // Test transition doesn't accept query parameters
13731373+ assert!(matches!(
13741374+ Scope::parse("transition:generic?param=value"),
13751375+ Err(ParseError::InvalidResource(_))
13761376+ ));
13771377+ }
13781378+13791379+ #[test]
13801380+ fn test_atproto_scope_normalization() {
13811381+ let scope = Scope::parse("atproto").unwrap();
13821382+ assert_eq!(scope.to_string_normalized(), "atproto");
13831383+ }
13841384+13851385+ #[test]
13861386+ fn test_transition_scope_normalization() {
13871387+ let tests = vec![
13881388+ ("transition:generic", "transition:generic"),
13891389+ ("transition:email", "transition:email"),
13901390+ ];
13911391+13921392+ for (input, expected) in tests {
13931393+ let scope = Scope::parse(input).unwrap();
13941394+ assert_eq!(scope.to_string_normalized(), expected);
13951395+ }
13961396+ }
13971397+13981398+ #[test]
13991399+ fn test_atproto_scope_grants() {
14001400+ let atproto = Scope::parse("atproto").unwrap();
14011401+ let account = Scope::parse("account:email").unwrap();
14021402+ let identity = Scope::parse("identity:handle").unwrap();
14031403+ let blob = Scope::parse("blob:image/png").unwrap();
14041404+ let repo = Scope::parse("repo:app.bsky.feed.post").unwrap();
14051405+ let rpc = Scope::parse("rpc:com.example.service").unwrap();
14061406+ let transition_generic = Scope::parse("transition:generic").unwrap();
14071407+ let transition_email = Scope::parse("transition:email").unwrap();
14081408+14091409+ // Atproto only grants itself (it's a required scope, not a permission grant)
14101410+ assert!(atproto.grants(&atproto));
14111411+ assert!(!atproto.grants(&account));
14121412+ assert!(!atproto.grants(&identity));
14131413+ assert!(!atproto.grants(&blob));
14141414+ assert!(!atproto.grants(&repo));
14151415+ assert!(!atproto.grants(&rpc));
14161416+ assert!(!atproto.grants(&transition_generic));
14171417+ assert!(!atproto.grants(&transition_email));
14181418+14191419+ // Nothing else grants atproto
14201420+ assert!(!account.grants(&atproto));
14211421+ assert!(!identity.grants(&atproto));
14221422+ assert!(!blob.grants(&atproto));
14231423+ assert!(!repo.grants(&atproto));
14241424+ assert!(!rpc.grants(&atproto));
14251425+ assert!(!transition_generic.grants(&atproto));
14261426+ assert!(!transition_email.grants(&atproto));
14271427+ }
14281428+14291429+ #[test]
14301430+ fn test_transition_scope_grants() {
14311431+ let transition_generic = Scope::parse("transition:generic").unwrap();
14321432+ let transition_email = Scope::parse("transition:email").unwrap();
14331433+ let account = Scope::parse("account:email").unwrap();
14341434+14351435+ // Transition scopes only grant themselves
14361436+ assert!(transition_generic.grants(&transition_generic));
14371437+ assert!(transition_email.grants(&transition_email));
14381438+ assert!(!transition_generic.grants(&transition_email));
14391439+ assert!(!transition_email.grants(&transition_generic));
14401440+14411441+ // Transition scopes don't grant other scope types
14421442+ assert!(!transition_generic.grants(&account));
14431443+ assert!(!transition_email.grants(&account));
14441444+14451445+ // Other scopes don't grant transition scopes
14461446+ assert!(!account.grants(&transition_generic));
14471447+ assert!(!account.grants(&transition_email));
14481448+ }
14491449+14501450+ #[test]
14511451+ fn test_parse_multiple() {
14521452+ // Test parsing multiple scopes
14531453+ let scopes = Scope::parse_multiple("atproto repo:*").unwrap();
14541454+ assert_eq!(scopes.len(), 2);
14551455+ assert_eq!(scopes[0], Scope::Atproto);
14561456+ assert_eq!(
14571457+ scopes[1],
14581458+ Scope::Repo(RepoScope {
14591459+ collection: RepoCollection::All,
14601460+ actions: {
14611461+ let mut actions = BTreeSet::new();
14621462+ actions.insert(RepoAction::Create);
14631463+ actions.insert(RepoAction::Update);
14641464+ actions.insert(RepoAction::Delete);
14651465+ actions
14661466+ }
14671467+ })
14681468+ );
14691469+14701470+ // Test with more scopes
14711471+ let scopes = Scope::parse_multiple("account:email identity:handle blob:image/png").unwrap();
14721472+ assert_eq!(scopes.len(), 3);
14731473+ assert!(matches!(scopes[0], Scope::Account(_)));
14741474+ assert!(matches!(scopes[1], Scope::Identity(_)));
14751475+ assert!(matches!(scopes[2], Scope::Blob(_)));
14761476+14771477+ // Test with complex scopes
14781478+ let scopes = Scope::parse_multiple(
14791479+ "account:email?action=manage repo:app.bsky.feed.post?action=create transition:email",
14801480+ )
14811481+ .unwrap();
14821482+ assert_eq!(scopes.len(), 3);
14831483+14841484+ // Test empty string
14851485+ let scopes = Scope::parse_multiple("").unwrap();
14861486+ assert_eq!(scopes.len(), 0);
14871487+14881488+ // Test whitespace only
14891489+ let scopes = Scope::parse_multiple(" ").unwrap();
14901490+ assert_eq!(scopes.len(), 0);
14911491+14921492+ // Test with extra whitespace
14931493+ let scopes = Scope::parse_multiple(" atproto repo:* ").unwrap();
14941494+ assert_eq!(scopes.len(), 2);
14951495+14961496+ // Test single scope
14971497+ let scopes = Scope::parse_multiple("atproto").unwrap();
14981498+ assert_eq!(scopes.len(), 1);
14991499+ assert_eq!(scopes[0], Scope::Atproto);
15001500+15011501+ // Test error propagation
15021502+ assert!(Scope::parse_multiple("atproto invalid:scope").is_err());
15031503+ assert!(Scope::parse_multiple("account:invalid repo:*").is_err());
15041504+ }
15051505+15061506+ #[test]
15071507+ fn test_parse_multiple_reduced() {
15081508+ // Test repo scope reduction - wildcard grants specific
15091509+ let scopes =
15101510+ Scope::parse_multiple_reduced("atproto repo:app.bsky.feed.post repo:*").unwrap();
15111511+ assert_eq!(scopes.len(), 2);
15121512+ assert!(scopes.contains(&Scope::Atproto));
15131513+ assert!(scopes.contains(&Scope::Repo(RepoScope {
15141514+ collection: RepoCollection::All,
15151515+ actions: {
15161516+ let mut actions = BTreeSet::new();
15171517+ actions.insert(RepoAction::Create);
15181518+ actions.insert(RepoAction::Update);
15191519+ actions.insert(RepoAction::Delete);
15201520+ actions
15211521+ }
15221522+ })));
15231523+15241524+ // Test reverse order - should get same result
15251525+ let scopes =
15261526+ Scope::parse_multiple_reduced("atproto repo:* repo:app.bsky.feed.post").unwrap();
15271527+ assert_eq!(scopes.len(), 2);
15281528+ assert!(scopes.contains(&Scope::Atproto));
15291529+ assert!(scopes.contains(&Scope::Repo(RepoScope {
15301530+ collection: RepoCollection::All,
15311531+ actions: {
15321532+ let mut actions = BTreeSet::new();
15331533+ actions.insert(RepoAction::Create);
15341534+ actions.insert(RepoAction::Update);
15351535+ actions.insert(RepoAction::Delete);
15361536+ actions
15371537+ }
15381538+ })));
15391539+15401540+ // Test account scope reduction - manage grants read
15411541+ let scopes =
15421542+ Scope::parse_multiple_reduced("account:email account:email?action=manage").unwrap();
15431543+ assert_eq!(scopes.len(), 1);
15441544+ assert_eq!(
15451545+ scopes[0],
15461546+ Scope::Account(AccountScope {
15471547+ resource: AccountResource::Email,
15481548+ action: AccountAction::Manage,
15491549+ })
15501550+ );
15511551+15521552+ // Test identity scope reduction - wildcard grants specific
15531553+ let scopes = Scope::parse_multiple_reduced("identity:handle identity:*").unwrap();
15541554+ assert_eq!(scopes.len(), 1);
15551555+ assert_eq!(scopes[0], Scope::Identity(IdentityScope::All));
15561556+15571557+ // Test blob scope reduction - wildcard grants specific
15581558+ let scopes = Scope::parse_multiple_reduced("blob:image/png blob:image/* blob:*/*").unwrap();
15591559+ assert_eq!(scopes.len(), 1);
15601560+ let mut accept = BTreeSet::new();
15611561+ accept.insert(MimePattern::All);
15621562+ assert_eq!(scopes[0], Scope::Blob(BlobScope { accept }));
15631563+15641564+ // Test no reduction needed - different scope types
15651565+ let scopes =
15661566+ Scope::parse_multiple_reduced("account:email identity:handle blob:image/png").unwrap();
15671567+ assert_eq!(scopes.len(), 3);
15681568+15691569+ // Test repo action reduction
15701570+ let scopes = Scope::parse_multiple_reduced(
15711571+ "repo:app.bsky.feed.post?action=create repo:app.bsky.feed.post",
15721572+ )
15731573+ .unwrap();
15741574+ assert_eq!(scopes.len(), 1);
15751575+ assert_eq!(
15761576+ scopes[0],
15771577+ Scope::Repo(RepoScope {
15781578+ collection: RepoCollection::Nsid(Nsid::new_static("app.bsky.feed.post").unwrap()),
15791579+ actions: {
15801580+ let mut actions = BTreeSet::new();
15811581+ actions.insert(RepoAction::Create);
15821582+ actions.insert(RepoAction::Update);
15831583+ actions.insert(RepoAction::Delete);
15841584+ actions
15851585+ }
15861586+ })
15871587+ );
15881588+15891589+ // Test RPC scope reduction
15901590+ let scopes = Scope::parse_multiple_reduced(
15911591+ "rpc:com.example.service?aud=did:example:123 rpc:com.example.service rpc:*",
15921592+ )
15931593+ .unwrap();
15941594+ assert_eq!(scopes.len(), 1);
15951595+ assert_eq!(
15961596+ scopes[0],
15971597+ Scope::Rpc(RpcScope {
15981598+ lxm: {
15991599+ let mut lxm = BTreeSet::new();
16001600+ lxm.insert(RpcLexicon::All);
16011601+ lxm
16021602+ },
16031603+ aud: {
16041604+ let mut aud = BTreeSet::new();
16051605+ aud.insert(RpcAudience::All);
16061606+ aud
16071607+ }
16081608+ })
16091609+ );
16101610+16111611+ // Test duplicate removal
16121612+ let scopes = Scope::parse_multiple_reduced("atproto atproto atproto").unwrap();
16131613+ assert_eq!(scopes.len(), 1);
16141614+ assert_eq!(scopes[0], Scope::Atproto);
16151615+16161616+ // Test transition scopes - only grant themselves
16171617+ let scopes = Scope::parse_multiple_reduced("transition:generic transition:email").unwrap();
16181618+ assert_eq!(scopes.len(), 2);
16191619+ assert!(scopes.contains(&Scope::Transition(TransitionScope::Generic)));
16201620+ assert!(scopes.contains(&Scope::Transition(TransitionScope::Email)));
16211621+16221622+ // Test empty input
16231623+ let scopes = Scope::parse_multiple_reduced("").unwrap();
16241624+ assert_eq!(scopes.len(), 0);
16251625+16261626+ // Test complex scenario with multiple reductions
16271627+ let scopes = Scope::parse_multiple_reduced(
16281628+ "account:email?action=manage account:email account:repo account:repo?action=read identity:* identity:handle"
16291629+ ).unwrap();
16301630+ assert_eq!(scopes.len(), 3);
16311631+ // Should have: account:email?action=manage, account:repo, identity:*
16321632+ assert!(scopes.contains(&Scope::Account(AccountScope {
16331633+ resource: AccountResource::Email,
16341634+ action: AccountAction::Manage,
16351635+ })));
16361636+ assert!(scopes.contains(&Scope::Account(AccountScope {
16371637+ resource: AccountResource::Repo,
16381638+ action: AccountAction::Read,
16391639+ })));
16401640+ assert!(scopes.contains(&Scope::Identity(IdentityScope::All)));
16411641+16421642+ // Test that atproto doesn't grant other scopes (per recent change)
16431643+ let scopes = Scope::parse_multiple_reduced("atproto account:email repo:*").unwrap();
16441644+ assert_eq!(scopes.len(), 3);
16451645+ assert!(scopes.contains(&Scope::Atproto));
16461646+ assert!(scopes.contains(&Scope::Account(AccountScope {
16471647+ resource: AccountResource::Email,
16481648+ action: AccountAction::Read,
16491649+ })));
16501650+ assert!(scopes.contains(&Scope::Repo(RepoScope {
16511651+ collection: RepoCollection::All,
16521652+ actions: {
16531653+ let mut actions = BTreeSet::new();
16541654+ actions.insert(RepoAction::Create);
16551655+ actions.insert(RepoAction::Update);
16561656+ actions.insert(RepoAction::Delete);
16571657+ actions
16581658+ }
16591659+ })));
16601660+ }
16611661+16621662+ #[test]
16631663+ fn test_openid_connect_scope_parsing() {
16641664+ // Test OpenID scope
16651665+ let scope = Scope::parse("openid").unwrap();
16661666+ assert_eq!(scope, Scope::OpenId);
16671667+16681668+ // Test Profile scope
16691669+ let scope = Scope::parse("profile").unwrap();
16701670+ assert_eq!(scope, Scope::Profile);
16711671+16721672+ // Test Email scope
16731673+ let scope = Scope::parse("email").unwrap();
16741674+ assert_eq!(scope, Scope::Email);
16751675+16761676+ // Test that they don't accept suffixes
16771677+ assert!(Scope::parse("openid:something").is_err());
16781678+ assert!(Scope::parse("profile:something").is_err());
16791679+ assert!(Scope::parse("email:something").is_err());
16801680+16811681+ // Test that they don't accept query parameters
16821682+ assert!(Scope::parse("openid?param=value").is_err());
16831683+ assert!(Scope::parse("profile?param=value").is_err());
16841684+ assert!(Scope::parse("email?param=value").is_err());
16851685+ }
16861686+16871687+ #[test]
16881688+ fn test_openid_connect_scope_normalization() {
16891689+ let scope = Scope::parse("openid").unwrap();
16901690+ assert_eq!(scope.to_string_normalized(), "openid");
16911691+16921692+ let scope = Scope::parse("profile").unwrap();
16931693+ assert_eq!(scope.to_string_normalized(), "profile");
16941694+16951695+ let scope = Scope::parse("email").unwrap();
16961696+ assert_eq!(scope.to_string_normalized(), "email");
16971697+ }
16981698+16991699+ #[test]
17001700+ fn test_openid_connect_scope_grants() {
17011701+ let openid = Scope::parse("openid").unwrap();
17021702+ let profile = Scope::parse("profile").unwrap();
17031703+ let email = Scope::parse("email").unwrap();
17041704+ let account = Scope::parse("account:email").unwrap();
17051705+17061706+ // OpenID Connect scopes only grant themselves
17071707+ assert!(openid.grants(&openid));
17081708+ assert!(!openid.grants(&profile));
17091709+ assert!(!openid.grants(&email));
17101710+ assert!(!openid.grants(&account));
17111711+17121712+ assert!(profile.grants(&profile));
17131713+ assert!(!profile.grants(&openid));
17141714+ assert!(!profile.grants(&email));
17151715+ assert!(!profile.grants(&account));
17161716+17171717+ assert!(email.grants(&email));
17181718+ assert!(!email.grants(&openid));
17191719+ assert!(!email.grants(&profile));
17201720+ assert!(!email.grants(&account));
17211721+17221722+ // Other scopes don't grant OpenID Connect scopes
17231723+ assert!(!account.grants(&openid));
17241724+ assert!(!account.grants(&profile));
17251725+ assert!(!account.grants(&email));
17261726+ }
17271727+17281728+ #[test]
17291729+ fn test_parse_multiple_with_openid_connect() {
17301730+ let scopes = Scope::parse_multiple("openid profile email atproto").unwrap();
17311731+ assert_eq!(scopes.len(), 4);
17321732+ assert_eq!(scopes[0], Scope::OpenId);
17331733+ assert_eq!(scopes[1], Scope::Profile);
17341734+ assert_eq!(scopes[2], Scope::Email);
17351735+ assert_eq!(scopes[3], Scope::Atproto);
17361736+17371737+ // Test with mixed scopes
17381738+ let scopes = Scope::parse_multiple("openid account:email profile repo:*").unwrap();
17391739+ assert_eq!(scopes.len(), 4);
17401740+ assert!(scopes.contains(&Scope::OpenId));
17411741+ assert!(scopes.contains(&Scope::Profile));
17421742+ }
17431743+17441744+ #[test]
17451745+ fn test_parse_multiple_reduced_with_openid_connect() {
17461746+ // OpenID Connect scopes don't grant each other, so no reduction
17471747+ let scopes = Scope::parse_multiple_reduced("openid profile email openid").unwrap();
17481748+ assert_eq!(scopes.len(), 3);
17491749+ assert!(scopes.contains(&Scope::OpenId));
17501750+ assert!(scopes.contains(&Scope::Profile));
17511751+ assert!(scopes.contains(&Scope::Email));
17521752+17531753+ // Mixed with other scopes
17541754+ let scopes = Scope::parse_multiple_reduced(
17551755+ "openid account:email account:email?action=manage profile",
17561756+ )
17571757+ .unwrap();
17581758+ assert_eq!(scopes.len(), 3);
17591759+ assert!(scopes.contains(&Scope::OpenId));
17601760+ assert!(scopes.contains(&Scope::Profile));
17611761+ assert!(scopes.contains(&Scope::Account(AccountScope {
17621762+ resource: AccountResource::Email,
17631763+ action: AccountAction::Manage,
17641764+ })));
17651765+ }
17661766+17671767+ #[test]
17681768+ fn test_serialize_multiple() {
17691769+ // Test empty list
17701770+ let scopes: Vec<Scope> = vec![];
17711771+ assert_eq!(Scope::serialize_multiple(&scopes), "");
17721772+17731773+ // Test single scope
17741774+ let scopes = vec![Scope::Atproto];
17751775+ assert_eq!(Scope::serialize_multiple(&scopes), "atproto");
17761776+17771777+ // Test multiple scopes - should be sorted alphabetically
17781778+ let scopes = vec![
17791779+ Scope::parse("repo:*").unwrap(),
17801780+ Scope::Atproto,
17811781+ Scope::parse("account:email").unwrap(),
17821782+ ];
17831783+ assert_eq!(
17841784+ Scope::serialize_multiple(&scopes),
17851785+ "account:email atproto repo:*"
17861786+ );
17871787+17881788+ // Test that sorting is consistent regardless of input order
17891789+ let scopes = vec![
17901790+ Scope::parse("identity:handle").unwrap(),
17911791+ Scope::parse("blob:image/png").unwrap(),
17921792+ Scope::parse("account:repo?action=manage").unwrap(),
17931793+ ];
17941794+ assert_eq!(
17951795+ Scope::serialize_multiple(&scopes),
17961796+ "account:repo?action=manage blob:image/png identity:handle"
17971797+ );
17981798+17991799+ // Test with OpenID Connect scopes
18001800+ let scopes = vec![Scope::Email, Scope::OpenId, Scope::Profile, Scope::Atproto];
18011801+ assert_eq!(
18021802+ Scope::serialize_multiple(&scopes),
18031803+ "atproto email openid profile"
18041804+ );
18051805+18061806+ // Test with complex scopes including query parameters
18071807+ let scopes = vec![
18081808+ Scope::parse("rpc:com.example.service?aud=did:plc:yfvwmnlztr4dwkb7hwz55r2g&lxm=com.example.method")
18091809+ .unwrap(),
18101810+ Scope::parse("repo:app.bsky.feed.post?action=create&action=update").unwrap(),
18111811+ Scope::parse("blob:image/*?accept=image/png&accept=image/jpeg").unwrap(),
18121812+ ];
18131813+ let result = Scope::serialize_multiple(&scopes);
18141814+ // The result should be sorted alphabetically
18151815+ // Note: RPC scope with query params is serialized as "rpc?aud=...&lxm=..."
18161816+ assert!(result.starts_with("blob:"));
18171817+ assert!(result.contains(" repo:"));
18181818+ assert!(
18191819+ result.contains("rpc?aud=did:plc:yfvwmnlztr4dwkb7hwz55r2g&lxm=com.example.service")
18201820+ );
18211821+18221822+ // Test with transition scopes
18231823+ let scopes = vec![
18241824+ Scope::Transition(TransitionScope::Email),
18251825+ Scope::Transition(TransitionScope::Generic),
18261826+ Scope::Atproto,
18271827+ ];
18281828+ assert_eq!(
18291829+ Scope::serialize_multiple(&scopes),
18301830+ "atproto transition:email transition:generic"
18311831+ );
18321832+18331833+ // Test duplicates - they remain in the output (caller's responsibility to dedupe if needed)
18341834+ let scopes = vec![
18351835+ Scope::Atproto,
18361836+ Scope::Atproto,
18371837+ Scope::parse("account:email").unwrap(),
18381838+ ];
18391839+ assert_eq!(
18401840+ Scope::serialize_multiple(&scopes),
18411841+ "account:email atproto atproto"
18421842+ );
18431843+18441844+ // Test normalization is preserved in serialization
18451845+ let scopes = vec![Scope::parse("blob?accept=image/png&accept=image/jpeg").unwrap()];
18461846+ // Should normalize query parameters alphabetically
18471847+ assert_eq!(
18481848+ Scope::serialize_multiple(&scopes),
18491849+ "blob?accept=image/jpeg&accept=image/png"
18501850+ );
18511851+ }
18521852+18531853+ #[test]
18541854+ fn test_serialize_multiple_roundtrip() {
18551855+ // Test that parse_multiple and serialize_multiple are inverses (when sorted)
18561856+ let original = "account:email atproto blob:image/png identity:handle repo:*";
18571857+ let scopes = Scope::parse_multiple(original).unwrap();
18581858+ let serialized = Scope::serialize_multiple(&scopes);
18591859+ assert_eq!(serialized, original);
18601860+18611861+ // Test with complex scopes
18621862+ let original = "account:repo?action=manage blob?accept=image/jpeg&accept=image/png rpc:*";
18631863+ let scopes = Scope::parse_multiple(original).unwrap();
18641864+ let serialized = Scope::serialize_multiple(&scopes);
18651865+ // Parse again to verify it's valid
18661866+ let reparsed = Scope::parse_multiple(&serialized).unwrap();
18671867+ assert_eq!(scopes, reparsed);
18681868+18691869+ // Test with OpenID Connect scopes
18701870+ let original = "email openid profile";
18711871+ let scopes = Scope::parse_multiple(original).unwrap();
18721872+ let serialized = Scope::serialize_multiple(&scopes);
18731873+ assert_eq!(serialized, original);
18741874+ }
18751875+18761876+ #[test]
18771877+ fn test_remove_scope() {
18781878+ // Test removing a scope that exists
18791879+ let scopes = vec![
18801880+ Scope::parse("repo:*").unwrap(),
18811881+ Scope::Atproto,
18821882+ Scope::parse("account:email").unwrap(),
18831883+ ];
18841884+ let to_remove = Scope::Atproto;
18851885+ let result = Scope::remove_scope(&scopes, &to_remove);
18861886+ assert_eq!(result.len(), 2);
18871887+ assert!(!result.contains(&to_remove));
18881888+ assert!(result.contains(&Scope::parse("repo:*").unwrap()));
18891889+ assert!(result.contains(&Scope::parse("account:email").unwrap()));
18901890+18911891+ // Test removing a scope that doesn't exist
18921892+ let scopes = vec![
18931893+ Scope::parse("repo:*").unwrap(),
18941894+ Scope::parse("account:email").unwrap(),
18951895+ ];
18961896+ let to_remove = Scope::parse("identity:handle").unwrap();
18971897+ let result = Scope::remove_scope(&scopes, &to_remove);
18981898+ assert_eq!(result.len(), 2);
18991899+ assert_eq!(result, scopes);
19001900+19011901+ // Test removing from empty list
19021902+ let scopes: Vec<Scope> = vec![];
19031903+ let to_remove = Scope::Atproto;
19041904+ let result = Scope::remove_scope(&scopes, &to_remove);
19051905+ assert_eq!(result.len(), 0);
19061906+19071907+ // Test removing all instances of a duplicate scope
19081908+ let scopes = vec![
19091909+ Scope::Atproto,
19101910+ Scope::parse("account:email").unwrap(),
19111911+ Scope::Atproto,
19121912+ Scope::parse("repo:*").unwrap(),
19131913+ Scope::Atproto,
19141914+ ];
19151915+ let to_remove = Scope::Atproto;
19161916+ let result = Scope::remove_scope(&scopes, &to_remove);
19171917+ assert_eq!(result.len(), 2);
19181918+ assert!(!result.contains(&to_remove));
19191919+ assert!(result.contains(&Scope::parse("account:email").unwrap()));
19201920+ assert!(result.contains(&Scope::parse("repo:*").unwrap()));
19211921+19221922+ // Test removing complex scopes with query parameters
19231923+ let scopes = vec![
19241924+ Scope::parse("account:email?action=manage").unwrap(),
19251925+ Scope::parse("blob?accept=image/png&accept=image/jpeg").unwrap(),
19261926+ Scope::parse("rpc:com.example.service?aud=did:example:123").unwrap(),
19271927+ ];
19281928+ let to_remove = Scope::parse("blob?accept=image/jpeg&accept=image/png").unwrap(); // Note: normalized order
19291929+ let result = Scope::remove_scope(&scopes, &to_remove);
19301930+ assert_eq!(result.len(), 2);
19311931+ assert!(!result.contains(&to_remove));
19321932+19331933+ // Test with OpenID Connect scopes
19341934+ let scopes = vec![Scope::OpenId, Scope::Profile, Scope::Email, Scope::Atproto];
19351935+ let to_remove = Scope::Profile;
19361936+ let result = Scope::remove_scope(&scopes, &to_remove);
19371937+ assert_eq!(result.len(), 3);
19381938+ assert!(!result.contains(&to_remove));
19391939+ assert!(result.contains(&Scope::OpenId));
19401940+ assert!(result.contains(&Scope::Email));
19411941+ assert!(result.contains(&Scope::Atproto));
19421942+19431943+ // Test with transition scopes
19441944+ let scopes = vec![
19451945+ Scope::Transition(TransitionScope::Generic),
19461946+ Scope::Transition(TransitionScope::Email),
19471947+ Scope::Atproto,
19481948+ ];
19491949+ let to_remove = Scope::Transition(TransitionScope::Email);
19501950+ let result = Scope::remove_scope(&scopes, &to_remove);
19511951+ assert_eq!(result.len(), 2);
19521952+ assert!(!result.contains(&to_remove));
19531953+ assert!(result.contains(&Scope::Transition(TransitionScope::Generic)));
19541954+ assert!(result.contains(&Scope::Atproto));
19551955+19561956+ // Test that only exact matches are removed
19571957+ let scopes = vec![
19581958+ Scope::parse("account:email").unwrap(),
19591959+ Scope::parse("account:email?action=manage").unwrap(),
19601960+ Scope::parse("account:repo").unwrap(),
19611961+ ];
19621962+ let to_remove = Scope::parse("account:email").unwrap();
19631963+ let result = Scope::remove_scope(&scopes, &to_remove);
19641964+ assert_eq!(result.len(), 2);
19651965+ assert!(!result.contains(&Scope::parse("account:email").unwrap()));
19661966+ assert!(result.contains(&Scope::parse("account:email?action=manage").unwrap()));
19671967+ assert!(result.contains(&Scope::parse("account:repo").unwrap()));
19681968+ }
19691969+}
···11-use bytes::Bytes;
22-use http::{HeaderName, HeaderValue};
33-use url::Url;
44-55-use crate::CowStr;
66-use crate::client::{self as super_mod, Response, error};
77-use crate::client::{AuthorizationToken, HttpClient};
88-use jacquard_common::types::xrpc::XrpcRequest;
99-1010-/// Per-request options for XRPC calls.
1111-#[derive(Debug, Default, Clone)]
1212-pub struct CallOptions<'a> {
1313- /// Optional Authorization to apply (`Bearer` or `DPoP`).
1414- pub auth: Option<AuthorizationToken<'a>>,
1515- /// `atproto-proxy` header value.
1616- pub atproto_proxy: Option<CowStr<'a>>,
1717- /// `atproto-accept-labelers` header values.
1818- pub atproto_accept_labelers: Option<Vec<CowStr<'a>>>,
1919- /// Extra headers to attach to this request.
2020- pub extra_headers: Vec<(HeaderName, HeaderValue)>,
2121-}
2222-2323-/// Extension for stateless XRPC calls on any `HttpClient`.
2424-///
2525-/// Example
2626-/// ```ignore
2727-/// use jacquard::client::XrpcExt;
2828-/// use jacquard::api::app_bsky::feed::get_author_feed::GetAuthorFeed;
2929-/// use jacquard::types::ident::AtIdentifier;
3030-/// use miette::IntoDiagnostic;
3131-///
3232-/// #[tokio::main]
3333-/// async fn main() -> miette::Result<()> {
3434-/// let http = reqwest::Client::new();
3535-/// let base = url::Url::parse("https://public.api.bsky.app")?;
3636-/// let resp = http
3737-/// .xrpc(base)
3838-/// .send(
3939-/// GetAuthorFeed::new()
4040-/// .actor(AtIdentifier::new_static("pattern.atproto.systems").unwrap())
4141-/// .limit(5)
4242-/// .build(),
4343-/// )
4444-/// .await?;
4545-/// let out = resp.into_output()?;
4646-/// println!("author feed:\n{}", serde_json::to_string_pretty(&out).into_diagnostic()?);
4747-/// Ok(())
4848-/// }
4949-/// ```
5050-pub trait XrpcExt: HttpClient {
5151- /// Start building an XRPC call for the given base URL.
5252- fn xrpc<'a>(&'a self, base: Url) -> XrpcCall<'a, Self>
5353- where
5454- Self: Sized,
5555- {
5656- XrpcCall {
5757- client: self,
5858- base,
5959- opts: CallOptions::default(),
6060- }
6161- }
6262-}
6363-6464-impl<T: HttpClient> XrpcExt for T {}
6565-6666-/// Stateless XRPC call builder.
6767-///
6868-/// Example (per-request overrides)
6969-/// ```ignore
7070-/// use jacquard::client::{XrpcExt, AuthorizationToken};
7171-/// use jacquard::api::app_bsky::feed::get_author_feed::GetAuthorFeed;
7272-/// use jacquard::types::ident::AtIdentifier;
7373-/// use jacquard::CowStr;
7474-/// use miette::IntoDiagnostic;
7575-///
7676-/// #[tokio::main]
7777-/// async fn main() -> miette::Result<()> {
7878-/// let http = reqwest::Client::new();
7979-/// let base = url::Url::parse("https://public.api.bsky.app")?;
8080-/// let resp = http
8181-/// .xrpc(base)
8282-/// .auth(AuthorizationToken::Bearer(CowStr::from("ACCESS_JWT")))
8383-/// .accept_labelers(vec![CowStr::from("did:plc:labelerid")])
8484-/// .header(http::header::USER_AGENT, http::HeaderValue::from_static("jacquard-example"))
8585-/// .send(
8686-/// GetAuthorFeed::new()
8787-/// .actor(AtIdentifier::new_static("pattern.atproto.systems").unwrap())
8888-/// .limit(5)
8989-/// .build(),
9090-/// )
9191-/// .await?;
9292-/// let out = resp.into_output()?;
9393-/// println!("{}", serde_json::to_string_pretty(&out).into_diagnostic()?);
9494-/// Ok(())
9595-/// }
9696-/// ```
9797-pub struct XrpcCall<'a, C: HttpClient> {
9898- pub(crate) client: &'a C,
9999- pub(crate) base: Url,
100100- pub(crate) opts: CallOptions<'a>,
101101-}
102102-103103-impl<'a, C: HttpClient> XrpcCall<'a, C> {
104104- /// Apply Authorization to this call.
105105- pub fn auth(mut self, token: AuthorizationToken<'a>) -> Self {
106106- self.opts.auth = Some(token);
107107- self
108108- }
109109- /// Set `atproto-proxy` header for this call.
110110- pub fn proxy(mut self, proxy: CowStr<'a>) -> Self {
111111- self.opts.atproto_proxy = Some(proxy);
112112- self
113113- }
114114- /// Set `atproto-accept-labelers` header(s) for this call.
115115- pub fn accept_labelers(mut self, labelers: Vec<CowStr<'a>>) -> Self {
116116- self.opts.atproto_accept_labelers = Some(labelers);
117117- self
118118- }
119119- /// Add an extra header.
120120- pub fn header(mut self, name: HeaderName, value: HeaderValue) -> Self {
121121- self.opts.extra_headers.push((name, value));
122122- self
123123- }
124124- /// Replace the builder's options entirely.
125125- pub fn with_options(mut self, opts: CallOptions<'a>) -> Self {
126126- self.opts = opts;
127127- self
128128- }
129129-130130- /// Send the given typed XRPC request and return a response wrapper.
131131- pub async fn send<R: XrpcRequest + Send>(self, request: R) -> super_mod::Result<Response<R>> {
132132- let http_request = super_mod::build_http_request(&self.base, &request, &self.opts)
133133- .map_err(error::TransportError::from)?;
134134-135135- let http_response = self
136136- .client
137137- .send_http(http_request)
138138- .await
139139- .map_err(|e| error::TransportError::Other(Box::new(e)))?;
140140-141141- let status = http_response.status();
142142- let buffer = Bytes::from(http_response.into_body());
143143-144144- if !status.is_success() && !matches!(status.as_u16(), 400 | 401) {
145145- return Err(error::HttpError {
146146- status,
147147- body: Some(buffer),
148148- }
149149- .into());
150150- }
151151-152152- Ok(Response::new(buffer, status))
153153- }
154154-}
-3
crates/jacquard/src/identity/mod.rs
···11-//! Identity resolution utilities: DID and handle resolution, DID document fetch,
22-//! and helpers for PDS endpoint discovery. See `identity::resolver` for details.
33-pub mod resolver;
···11+//! Identity resolution utilities: DID and handle resolution, DID document fetch,
22+//! and helpers for PDS endpoint discovery. See `identity::resolver` for details.
13//! Identity resolution: handle → DID and DID → document, with smart fallbacks.
24//!
35//! Fallback order (default):
···911//! Parsing returns a `DidDocResponse` so callers can borrow from the response buffer
1012//! and optionally validate the document `id` against the requested DID.
11131212-use std::collections::BTreeMap;
1313-use std::str::FromStr;
1414-1514// use crate::CowStr; // not currently needed directly here
1616-use crate::client::XrpcExt;
1717-use bon::Builder;
1515+1816use bytes::Bytes;
1919-use jacquard_common::types::did_doc::Service;
2020-use jacquard_common::types::string::AtprotoStr;
2121-use jacquard_common::types::uri::Uri;
2222-use jacquard_common::types::value::Data;
2323-use jacquard_common::{CowStr, IntoStatic};
2424-use miette::Diagnostic;
1717+use jacquard_common::IntoStatic;
1818+use jacquard_common::error::TransportError;
1919+use jacquard_common::http_client::HttpClient;
2020+use jacquard_common::ident_resolver::{
2121+ DidDocResponse, DidStep, HandleStep, IdentityError, IdentityResolver, MiniDoc, PlcSource,
2222+ ResolverOptions,
2323+};
2424+use jacquard_common::types::xrpc::XrpcExt;
2525use percent_encoding::percent_decode_str;
2626use reqwest::StatusCode;
2727-use thiserror::Error;
2827use url::{ParseError, Url};
29283029use crate::api::com_atproto::identity::{resolve_did, resolve_handle::ResolveHandle};
3130use crate::types::did_doc::DidDocument;
3231use crate::types::ident::AtIdentifier;
3332use crate::types::string::{Did, Handle};
3434-use crate::types::value::AtDataError;
35333634#[cfg(feature = "dns")]
3735use hickory_resolver::{TokioAsyncResolver, config::ResolverConfig};
38363939-/// Errors that can occur during identity resolution.
4040-///
4141-/// Note: when validating a fetched DID document against a requested DID, a
4242-/// `DocIdMismatch` error is returned that includes the owned document so callers
4343-/// can inspect it and decide how to proceed.
4444-#[derive(Debug, Error, Diagnostic)]
4545-#[allow(missing_docs)]
4646-pub enum IdentityError {
4747- #[error("unsupported DID method: {0}")]
4848- UnsupportedDidMethod(String),
4949- #[error("invalid well-known atproto-did content")]
5050- InvalidWellKnown,
5151- #[error("missing PDS endpoint in DID document")]
5252- MissingPdsEndpoint,
5353- #[error("HTTP error: {0}")]
5454- Http(#[from] reqwest::Error),
5555- #[error("HTTP status {0}")]
5656- HttpStatus(StatusCode),
5757- #[error("XRPC error: {0}")]
5858- Xrpc(String),
5959- #[error("URL parse error: {0}")]
6060- Url(#[from] url::ParseError),
6161- #[error("DNS error: {0}")]
6262- #[cfg(feature = "dns")]
6363- Dns(#[from] hickory_resolver::error::ResolveError),
6464- #[error("serialize/deserialize error: {0}")]
6565- Serde(#[from] serde_json::Error),
6666- #[error("invalid DID document: {0}")]
6767- InvalidDoc(String),
6868- #[error(transparent)]
6969- Data(#[from] AtDataError),
7070- /// DID document id did not match requested DID; includes the fetched document
7171- #[error("DID doc id mismatch")]
7272- DocIdMismatch {
7373- expected: Did<'static>,
7474- doc: DidDocument<'static>,
7575- },
7676-}
7777-7878-/// Source to fetch PLC (did:plc) documents from.
7979-///
8080-/// - `PlcDirectory`: uses the public PLC directory (default `https://plc.directory/`).
8181-/// - `Slingshot`: uses Slingshot which also exposes convenience endpoints such as
8282-/// `com.atproto.identity.resolveHandle` and a "mini-doc"
8383-/// endpoint (`com.bad-example.identity.resolveMiniDoc`).
8484-#[derive(Debug, Clone, PartialEq, Eq)]
8585-pub enum PlcSource {
8686- /// Use the public PLC directory
8787- PlcDirectory {
8888- /// Base URL for the PLC directory
8989- base: Url,
9090- },
9191- /// Use the slingshot mini-docs service
9292- Slingshot {
9393- /// Base URL for the Slingshot service
9494- base: Url,
9595- },
9696-}
9797-9898-impl Default for PlcSource {
9999- fn default() -> Self {
100100- Self::PlcDirectory {
101101- base: Url::parse("https://plc.directory/").expect("valid url"),
102102- }
103103- }
104104-}
105105-106106-impl PlcSource {
107107- /// Default Slingshot source (`https://slingshot.microcosm.blue`)
108108- pub fn slingshot_default() -> Self {
109109- PlcSource::Slingshot {
110110- base: Url::parse("https://slingshot.microcosm.blue").expect("valid url"),
111111- }
112112- }
113113-}
114114-115115-/// DID Document fetch response for borrowed/owned parsing.
116116-///
117117-/// Carries the raw response bytes and the HTTP status, plus the requested DID
118118-/// (if supplied) to enable validation. Use `parse()` to borrow from the buffer
119119-/// or `parse_validated()` to also enforce that the doc `id` matches the
120120-/// requested DID (returns a `DocIdMismatch` containing the fetched doc on
121121-/// mismatch). Use `into_owned()` to parse into an owned document.
122122-#[derive(Clone)]
123123-pub struct DidDocResponse {
124124- buffer: Bytes,
125125- status: StatusCode,
126126- /// Optional DID we intended to resolve; used for validation helpers
127127- requested: Option<Did<'static>>,
128128-}
129129-130130-impl DidDocResponse {
131131- /// Parse as borrowed DidDocument<'_>
132132- pub fn parse<'b>(&'b self) -> Result<DidDocument<'b>, IdentityError> {
133133- if self.status.is_success() {
134134- if let Ok(doc) = serde_json::from_slice::<DidDocument<'b>>(&self.buffer) {
135135- Ok(doc)
136136- } else if let Ok(mini_doc) = serde_json::from_slice::<MiniDoc<'b>>(&self.buffer) {
137137- Ok(DidDocument {
138138- id: mini_doc.did,
139139- also_known_as: Some(vec![CowStr::from(mini_doc.handle)]),
140140- verification_method: None,
141141- service: Some(vec![Service {
142142- id: CowStr::new_static("#atproto_pds"),
143143- r#type: CowStr::new_static("AtprotoPersonalDataServer"),
144144- service_endpoint: Some(Data::String(AtprotoStr::Uri(Uri::Https(
145145- Url::from_str(&mini_doc.pds).unwrap(),
146146- )))),
147147- extra_data: BTreeMap::new(),
148148- }]),
149149- extra_data: BTreeMap::new(),
150150- })
151151- } else {
152152- Err(IdentityError::MissingPdsEndpoint)
153153- }
154154- } else {
155155- Err(IdentityError::HttpStatus(self.status))
156156- }
157157- }
158158-159159- /// Parse and validate that the DID in the document matches the requested DID if present.
160160- ///
161161- /// On mismatch, returns an error that contains the owned document for inspection.
162162- pub fn parse_validated<'b>(&'b self) -> Result<DidDocument<'b>, IdentityError> {
163163- let doc = self.parse()?;
164164- if let Some(expected) = &self.requested {
165165- if doc.id.as_str() != expected.as_str() {
166166- return Err(IdentityError::DocIdMismatch {
167167- expected: expected.clone(),
168168- doc: doc.clone().into_static(),
169169- });
170170- }
171171- }
172172- Ok(doc)
173173- }
174174-175175- /// Parse as owned DidDocument<'static>
176176- pub fn into_owned(self) -> Result<DidDocument<'static>, IdentityError> {
177177- if self.status.is_success() {
178178- if let Ok(doc) = serde_json::from_slice::<DidDocument<'_>>(&self.buffer) {
179179- Ok(doc.into_static())
180180- } else if let Ok(mini_doc) = serde_json::from_slice::<MiniDoc<'_>>(&self.buffer) {
181181- Ok(DidDocument {
182182- id: mini_doc.did,
183183- also_known_as: Some(vec![CowStr::from(mini_doc.handle)]),
184184- verification_method: None,
185185- service: Some(vec![Service {
186186- id: CowStr::new_static("#atproto_pds"),
187187- r#type: CowStr::new_static("AtprotoPersonalDataServer"),
188188- service_endpoint: Some(Data::String(AtprotoStr::Uri(Uri::Https(
189189- Url::from_str(&mini_doc.pds).unwrap(),
190190- )))),
191191- extra_data: BTreeMap::new(),
192192- }]),
193193- extra_data: BTreeMap::new(),
194194- }
195195- .into_static())
196196- } else {
197197- Err(IdentityError::MissingPdsEndpoint)
198198- }
199199- } else {
200200- Err(IdentityError::HttpStatus(self.status))
201201- }
202202- }
203203-}
204204-205205-/// Handle → DID fallback step.
206206-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
207207-pub enum HandleStep {
208208- /// DNS TXT _atproto.\<handle\>
209209- DnsTxt,
210210- /// HTTPS GET https://\<handle\>/.well-known/atproto-did
211211- HttpsWellKnown,
212212- /// XRPC com.atproto.identity.resolveHandle against a provided PDS base
213213- PdsResolveHandle,
214214-}
215215-216216-/// DID → Doc fallback step.
217217-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
218218-pub enum DidStep {
219219- /// For did:web: fetch from the well-known location
220220- DidWebHttps,
221221- /// For did:plc: fetch from PLC source
222222- PlcHttp,
223223- /// If a PDS base is known, ask it for the DID doc
224224- PdsResolveDid,
225225-}
226226-227227-/// Configurable resolver options.
228228-///
229229-/// - `plc_source`: where to fetch did:plc documents (PLC Directory or Slingshot).
230230-/// - `pds_fallback`: optional base URL of a PDS for XRPC fallbacks (stateless
231231-/// XRPC over reqwest; authentication can be layered as needed).
232232-/// - `handle_order`/`did_order`: ordered strategies for resolution.
233233-/// - `validate_doc_id`: if true (default), convenience helpers validate doc `id` against the requested DID,
234234-/// returning `DocIdMismatch` with the fetched document on mismatch.
235235-/// - `public_fallback_for_handle`: if true (default), attempt
236236-/// `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle` as an unauth fallback.
237237-/// There is no public fallback for DID documents; when `PdsResolveDid` is chosen and the PDS XRPC
238238-/// client fails, the resolver falls back to Slingshot mini-doc (partial) if `PlcSource::Slingshot` is configured.
239239-#[derive(Debug, Clone, Builder)]
240240-#[builder(start_fn = new)]
241241-pub struct ResolverOptions {
242242- /// PLC data source (directory or slingshot)
243243- pub plc_source: PlcSource,
244244- /// Optional PDS base to use for fallbacks
245245- pub pds_fallback: Option<Url>,
246246- /// Order of attempts for handle → DID resolution
247247- pub handle_order: Vec<HandleStep>,
248248- /// Order of attempts for DID → Doc resolution
249249- pub did_order: Vec<DidStep>,
250250- /// Validate that fetched DID document id matches the requested DID
251251- pub validate_doc_id: bool,
252252- /// Allow public unauthenticated fallback for resolveHandle via public.api.bsky.app
253253- pub public_fallback_for_handle: bool,
254254-}
255255-256256-impl Default for ResolverOptions {
257257- fn default() -> Self {
258258- // By default, prefer DNS then HTTPS for handles, then PDS fallback
259259- // For DID documents, prefer method-native sources, then PDS fallback
260260- Self::new()
261261- .plc_source(PlcSource::default())
262262- .handle_order(vec![
263263- HandleStep::DnsTxt,
264264- HandleStep::HttpsWellKnown,
265265- HandleStep::PdsResolveHandle,
266266- ])
267267- .did_order(vec![
268268- DidStep::DidWebHttps,
269269- DidStep::PlcHttp,
270270- DidStep::PdsResolveDid,
271271- ])
272272- .validate_doc_id(true)
273273- .public_fallback_for_handle(true)
274274- .build()
275275- }
276276-}
277277-278278-/// Trait for identity resolution, for pluggable implementations.
279279-///
280280-/// The provided `DefaultResolver` supports:
281281-/// - DNS TXT (`_atproto.<handle>`) when compiled with the `dns` feature
282282-/// - HTTPS well-known for handles and `did:web`
283283-/// - PLC directory or Slingshot for `did:plc`
284284-/// - Slingshot `resolveHandle` (unauthenticated) when configured as the PLC source
285285-/// - PDS fallbacks via helpers that use stateless XRPC on top of reqwest
286286-#[async_trait::async_trait]
287287-pub trait IdentityResolver {
288288- /// Access options for validation decisions in default methods
289289- fn options(&self) -> &ResolverOptions;
290290-291291- /// Resolve handle
292292- async fn resolve_handle(&self, handle: &Handle<'_>) -> Result<Did<'static>, IdentityError>;
293293-294294- /// Resolve DID document
295295- async fn resolve_did_doc(&self, did: &Did<'_>) -> Result<DidDocResponse, IdentityError>;
296296-297297- /// Resolve the DID document and return an owned version
298298- async fn resolve_did_doc_owned(
299299- &self,
300300- did: &Did<'_>,
301301- ) -> Result<DidDocument<'static>, IdentityError> {
302302- self.resolve_did_doc(did).await?.into_owned()
303303- }
304304- /// reutrn the PDS url for a DID
305305- async fn pds_for_did(&self, did: &Did<'_>) -> Result<Url, IdentityError> {
306306- let resp = self.resolve_did_doc(did).await?;
307307- let doc = resp.parse()?;
308308- // Default-on doc id equality check
309309- if self.options().validate_doc_id {
310310- if doc.id.as_str() != did.as_str() {
311311- return Err(IdentityError::DocIdMismatch {
312312- expected: did.clone().into_static(),
313313- doc: doc.clone().into_static(),
314314- });
315315- }
316316- }
317317- doc.pds_endpoint().ok_or(IdentityError::MissingPdsEndpoint)
318318- }
319319- /// Return the DIS and PDS url for a handle
320320- async fn pds_for_handle(
321321- &self,
322322- handle: &Handle<'_>,
323323- ) -> Result<(Did<'static>, Url), IdentityError> {
324324- let did = self.resolve_handle(handle).await?;
325325- let pds = self.pds_for_did(&did).await?;
326326- Ok((did, pds))
327327- }
328328-}
329329-33037/// Default resolver implementation with configurable fallback order.
33138pub struct DefaultResolver {
33239 http: reqwest::Client,
···34350 opts,
34451 #[cfg(feature = "dns")]
34552 dns: None,
5353+ }
5454+ }
5555+5656+ #[cfg(feature = "dns")]
5757+ /// Create a new instance of the default resolver with all options, plus default DNS, up front
5858+ pub fn new_dns(http: reqwest::Client, opts: ResolverOptions) -> Self {
5959+ Self {
6060+ http,
6161+ opts,
6262+ dns: Some(TokioAsyncResolver::tokio(
6363+ ResolverConfig::default(),
6464+ Default::default(),
6565+ )),
34666 }
34767 }
34868···415135 }
416136417137 async fn get_json_bytes(&self, url: Url) -> Result<(Bytes, StatusCode), IdentityError> {
418418- let resp = self.http.get(url).send().await?;
138138+ let resp = self
139139+ .http
140140+ .get(url)
141141+ .send()
142142+ .await
143143+ .map_err(TransportError::from)?;
419144 let status = resp.status();
420420- let buf = resp.bytes().await?;
145145+ let buf = resp.bytes().await.map_err(TransportError::from)?;
421146 Ok((buf, status))
422147 }
423148424149 async fn get_text(&self, url: Url) -> Result<String, IdentityError> {
425425- let resp = self.http.get(url).send().await?;
150150+ let resp = self
151151+ .http
152152+ .get(url)
153153+ .send()
154154+ .await
155155+ .map_err(TransportError::from)?;
426156 if resp.status() == StatusCode::OK {
427427- Ok(resp.text().await?)
157157+ Ok(resp.text().await.map_err(TransportError::from)?)
428158 } else {
429429- Err(IdentityError::Http(resp.error_for_status().unwrap_err()))
159159+ Err(IdentityError::Http(
160160+ resp.error_for_status().unwrap_err().into(),
161161+ ))
430162 }
431163 }
432164···684416 }
685417}
686418419419+impl HttpClient for DefaultResolver {
420420+ async fn send_http(
421421+ &self,
422422+ request: http::Request<Vec<u8>>,
423423+ ) -> core::result::Result<http::Response<Vec<u8>>, Self::Error> {
424424+ self.http.send_http(request).await
425425+ }
426426+427427+ type Error = reqwest::Error;
428428+}
429429+687430/// Warnings produced during identity checks that are not fatal
688431#[derive(Debug, Clone, PartialEq, Eq)]
689432pub enum IdentityWarning {
···778521 }
779522}
780523781781-/// Slingshot mini-doc data (subset of DID doc info)
782782-#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
783783-#[serde(rename_all = "camelCase")]
784784-#[allow(missing_docs)]
785785-pub struct MiniDoc<'a> {
786786- #[serde(borrow)]
787787- pub did: Did<'a>,
788788- #[serde(borrow)]
789789- pub handle: Handle<'a>,
790790- #[serde(borrow)]
791791- pub pds: crate::CowStr<'a>,
792792- #[serde(borrow, rename = "signingKey", alias = "signing_key")]
793793- pub signing_key: crate::CowStr<'a>,
524524+/// Resolver specialized for unauthenticated/public flows using reqwest and stateless XRPC
525525+pub type PublicResolver = DefaultResolver;
526526+527527+impl Default for PublicResolver {
528528+ /// Build a resolver with:
529529+ /// - reqwest HTTP client
530530+ /// - Public fallbacks enabled for handle resolution
531531+ /// - default options (DNS enabled if compiled, public fallback for handles enabled)
532532+ ///
533533+ /// Example
534534+ /// ```ignore
535535+ /// use jacquard::identity::resolver::PublicResolver;
536536+ /// let resolver = PublicResolver::default();
537537+ /// ```
538538+ fn default() -> Self {
539539+ let http = reqwest::Client::new();
540540+ let opts = ResolverOptions::default();
541541+ let resolver = DefaultResolver::new(http, opts);
542542+ #[cfg(feature = "dns")]
543543+ let resolver = resolver.with_system_dns();
544544+ resolver
545545+ }
546546+}
547547+548548+/// Build a resolver configured to use Slingshot (`https://slingshot.microcosm.blue`) for PLC and
549549+/// mini-doc fallbacks, unauthenticated by default.
550550+pub fn slingshot_resolver_default() -> PublicResolver {
551551+ let http = reqwest::Client::new();
552552+ let mut opts = ResolverOptions::default();
553553+ opts.plc_source = PlcSource::slingshot_default();
554554+ let resolver = DefaultResolver::new(http, opts);
555555+ #[cfg(feature = "dns")]
556556+ let resolver = resolver.with_system_dns();
557557+ resolver
794558}
795559796560#[cfg(test)]
···811575 }
812576813577 #[test]
814814- fn parse_validated_ok() {
815815- let buf = Bytes::from_static(br#"{"id":"did:plc:alice"}"#);
816816- let requested = Did::new_owned("did:plc:alice").unwrap();
817817- let resp = DidDocResponse {
818818- buffer: buf,
819819- status: StatusCode::OK,
820820- requested: Some(requested),
821821- };
822822- let _doc = resp.parse_validated().expect("valid");
823823- }
824824-825825- #[test]
826826- fn parse_validated_mismatch() {
827827- let buf = Bytes::from_static(br#"{"id":"did:plc:bob"}"#);
828828- let requested = Did::new_owned("did:plc:alice").unwrap();
829829- let resp = DidDocResponse {
830830- buffer: buf,
831831- status: StatusCode::OK,
832832- requested: Some(requested),
833833- };
834834- match resp.parse_validated() {
835835- Err(IdentityError::DocIdMismatch { expected, doc }) => {
836836- assert_eq!(expected.as_str(), "did:plc:alice");
837837- assert_eq!(doc.id.as_str(), "did:plc:bob");
838838- }
839839- other => panic!("unexpected result: {:?}", other),
840840- }
841841- }
842842-843843- #[test]
844578 fn slingshot_mini_doc_url_build() {
845579 let r = DefaultResolver::new(reqwest::Client::new(), ResolverOptions::default());
846580 let base = Url::parse("https://slingshot.microcosm.blue").unwrap();
···893627 }
894628 }
895629}
896896-897897-/// Resolver specialized for unauthenticated/public flows using reqwest and stateless XRPC
898898-pub type PublicResolver = DefaultResolver;
899899-900900-impl Default for PublicResolver {
901901- /// Build a resolver with:
902902- /// - reqwest HTTP client
903903- /// - Public fallbacks enabled for handle resolution
904904- /// - default options (DNS enabled if compiled, public fallback for handles enabled)
905905- ///
906906- /// Example
907907- /// ```ignore
908908- /// use jacquard::identity::resolver::PublicResolver;
909909- /// let resolver = PublicResolver::default();
910910- /// ```
911911- fn default() -> Self {
912912- let http = reqwest::Client::new();
913913- let opts = ResolverOptions::default();
914914- let resolver = DefaultResolver::new(http, opts);
915915- #[cfg(feature = "dns")]
916916- let resolver = resolver.with_system_dns();
917917- resolver
918918- }
919919-}
920920-921921-/// Build a resolver configured to use Slingshot (`https://slingshot.microcosm.blue`) for PLC and
922922-/// mini-doc fallbacks, unauthenticated by default.
923923-pub fn slingshot_resolver_default() -> PublicResolver {
924924- let http = reqwest::Client::new();
925925- let mut opts = ResolverOptions::default();
926926- opts.plc_source = PlcSource::slingshot_default();
927927- let resolver = DefaultResolver::new(http, opts);
928928- #[cfg(feature = "dns")]
929929- let resolver = resolver.with_system_dns();
930930- resolver
931931-}
+14-11
crates/jacquard/src/lib.rs
···2424//! # use jacquard::CowStr;
2525//! use jacquard::api::app_bsky::feed::get_timeline::GetTimeline;
2626//! use jacquard::api::com_atproto::server::create_session::CreateSession;
2727-//! use jacquard::client::{BasicClient, Session};
2727+//! use jacquard::client::{BasicClient, AuthSession, AtpSession};
2828//! # use miette::IntoDiagnostic;
2929//!
3030//! # #[derive(Parser, Debug)]
···5050//! let url = url::Url::parse(&args.pds).unwrap();
5151//! let client = BasicClient::new(url);
5252//! // Create session
5353-//! let session = Session::from(
5353+//! let session = AtpSession::from(
5454//! client
5555//! .send(
5656//! CreateSession::new()
···8787//! optional `CallOptions` (auth, proxy, labelers, headers). Useful when you
8888//! want to pass auth on each call or build advanced flows.
8989//! ```no_run
9090-//! # use jacquard::client::XrpcExt;
9090+//! # use jacquard::types::xrpc::XrpcExt;
9191//! # use jacquard::api::app_bsky::feed::get_author_feed::GetAuthorFeed;
9292//! # use jacquard::types::ident::AtIdentifier;
9393+//! # use miette::IntoDiagnostic;
9394//! #
9495//! #[tokio::main]
9595-//! async fn main() -> anyhow::Result<()> {
9696+//! async fn main() -> miette::Result<()> {
9697//! let http = reqwest::Client::new();
9797-//! let base = url::Url::parse("https://public.api.bsky.app")?;
9898+//! let base = url::Url::parse("https://public.api.bsky.app").into_diagnostic()?;
9899//! let resp = http
99100//! .xrpc(base)
100101//! .send(
···110111//! }
111112//! ```
112113//! - Stateful client: `AtClient<C, S>` holds a base `Url`, a transport, and a
113113-//! `TokenStore` implementation. It automatically sets Authorization and can
114114+//! `SessionStore<AuthSession>` implementation. It automatically sets Authorization and can
114115//! auto-refresh a session when expired, retrying once.
115116//! - Convenience wrapper: `BasicClient` is an ergonomic newtype over
116116-//! `AtClient<reqwest::Client, MemoryTokenStore>` with a `new(Url)` constructor.
117117+//! `AtClient<reqwest::Client, MemorySessionStore<AuthSession>>` with a `new(Url)` constructor.
117118//!
118119//! Per-request overrides (stateless)
119120//! ```no_run
120120-//! # use jacquard::client::{XrpcExt, AuthorizationToken};
121121+//! # use jacquard::AuthorizationToken;
122122+//! # use jacquard::types::xrpc::XrpcExt;
121123//! # use jacquard::api::app_bsky::feed::get_author_feed::GetAuthorFeed;
122124//! # use jacquard::types::ident::AtIdentifier;
123125//! # use jacquard::CowStr;
···126128//! #[tokio::main]
127129//! async fn main() -> miette::Result<()> {
128130//! let http = reqwest::Client::new();
129129-//! let base = url::Url::parse("https://public.api.bsky.app")?;
131131+//! let base = url::Url::parse("https://public.api.bsky.app").into_diagnostic()?;
130132//! let resp = http
131133//! .xrpc(base)
132134//! .auth(AuthorizationToken::Bearer(CowStr::from("ACCESS_JWT")))
···146148//! ```
147149//!
148150//! Token storage:
149149-//! - Use `MemoryTokenStore` for ephemeral sessions, tests, and CLIs.
150150-//! - For persistence, `FileTokenStore` stores session tokens as JSON on disk.
151151+//! - Use `MemorySessionStore<AuthSession>` for ephemeral sessions, tests, and CLIs.
152152+//! - For persistence, `FileTokenStore` stores app-password sessions as JSON on disk.
151153//! See `client::token::FileTokenStore` docs for details.
152154//! ```no_run
153155//! use jacquard::client::{AtClient, FileTokenStore};
···161163162164/// XRPC client traits and basic implementation
163165pub mod client;
166166+/// OAuth usage helpers (discovery, PAR, token exchange)
164167165168#[cfg(feature = "api")]
166169/// If enabled, re-export the generated api crate