···18181919/// App-password session implementation with auto-refresh
2020pub mod credential_session;
2121+/// Agent error type
2222+pub mod error;
2123/// Token storage and on-disk persistence formats
2224pub mod token;
2325/// Trait for fetch-modify-put patterns on array-based endpoints
2426pub mod vec_update;
25272828+use crate::client::credential_session::{CredentialSession, SessionKey};
2929+use crate::client::vec_update::VecUpdate;
2630use core::future::Future;
2727-use jacquard_common::error::TransportError;
2828-pub use jacquard_common::error::{ClientError, XrpcResult};
3131+pub use error::*;
3232+#[cfg(feature = "api")]
3333+use jacquard_api::com_atproto::{
3434+ repo::{
3535+ create_record::CreateRecordOutput, delete_record::DeleteRecordOutput,
3636+ get_record::GetRecordResponse, put_record::PutRecordOutput,
3737+ },
3838+ server::{create_session::CreateSessionOutput, refresh_session::RefreshSessionOutput},
3939+};
4040+use jacquard_common::error::XrpcResult;
4141+pub use jacquard_common::error::{ClientError, XrpcResult as ClientResult};
2942use jacquard_common::http_client::HttpClient;
3043pub use jacquard_common::session::{MemorySessionStore, SessionStore, SessionStoreError};
3144use jacquard_common::types::blob::{Blob, MimeType};
···4962use jacquard_oauth::client::OAuthSession;
5063use jacquard_oauth::dpop::DpopExt;
5164use jacquard_oauth::resolver::OAuthResolver;
5252-5365use serde::Serialize;
6666+#[cfg(feature = "api")]
6767+use std::marker::Send;
6868+use std::option::Option;
5469pub use token::FileAuthStore;
55705656-use crate::client::credential_session::{CredentialSession, SessionKey};
5757-use crate::client::vec_update::VecUpdate;
7171+/// Identifies the active authentication mode for an agent/session.
7272+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7373+pub enum AgentKind {
7474+ /// App password (Bearer) session
7575+ AppPassword,
7676+ /// OAuth (DPoP) session
7777+ OAuth,
7878+}
58795959-use jacquard_common::error::{AuthError, DecodeError};
6060-use jacquard_common::types::nsid::Nsid;
6161-use jacquard_common::xrpc::GenericXrpcError;
8080+/// Common interface for stateful sessions used by the Agent wrapper.
8181+///
8282+/// Implemented by `CredentialSession` (app‑password) and `OAuthSession` (DPoP).
8383+#[cfg_attr(not(target_arch = "wasm32"), trait_variant::make(Send))]
8484+pub trait AgentSession: XrpcClient + HttpClient + Send + Sync {
8585+ /// Identify the kind of session.
8686+ fn session_kind(&self) -> AgentKind;
8787+ /// Return current DID and an optional session id (always Some for OAuth).
8888+ fn session_info(&self)
8989+ -> impl Future<Output = Option<(Did<'static>, Option<CowStr<'static>>)>>;
9090+ /// Current base endpoint.
9191+ fn endpoint(&self) -> impl Future<Output = url::Url>;
9292+ /// Override per-session call options.
9393+ fn set_options<'a>(&'a self, opts: CallOptions<'a>) -> impl Future<Output = ()>;
9494+ /// Refresh the session and return a fresh AuthorizationToken.
9595+ fn refresh(&self) -> impl Future<Output = ClientResult<AuthorizationToken<'static>>>;
9696+}
62976363-/// Error type for Agent convenience methods
6464-#[derive(Debug, thiserror::Error, miette::Diagnostic)]
6565-pub enum AgentError {
6666- /// Transport/network layer failure
6767- #[error(transparent)]
6868- #[diagnostic(transparent)]
6969- Client(#[from] ClientError),
9898+/// Alias for an agent over a credential (app‑password) session.
9999+pub type CredentialAgent<S, T> = Agent<CredentialSession<S, T>>;
100100+/// Alias for an agent over an OAuth (DPoP) session.
101101+pub type OAuthAgent<T, S> = Agent<OAuthSession<T, S>>;
701027171- /// No session available for operations requiring authentication
7272- #[error("No session available - cannot determine repo")]
7373- NoSession,
103103+/// BasicClient: in-memory store + public resolver over a credential session.
104104+pub type BasicClient = Agent<
105105+ CredentialSession<
106106+ MemorySessionStore<SessionKey, AtpSession>,
107107+ jacquard_identity::PublicResolver,
108108+ >,
109109+>;
741107575- /// Authentication error from XRPC layer
7676- #[error("Authentication error: {0}")]
7777- #[diagnostic(transparent)]
7878- Auth(
7979- #[from]
8080- #[diagnostic_source]
8181- AuthError,
8282- ),
111111+impl BasicClient {
112112+ /// Create an unauthenticated BasicClient for public API access.
113113+ ///
114114+ /// Uses an in-memory session store and public resolver. Suitable for
115115+ /// read-only operations on public data without authentication.
116116+ ///
117117+ /// # Example
118118+ ///
119119+ /// ```no_run
120120+ /// # use jacquard::client::BasicClient;
121121+ /// # use jacquard::types::string::AtUri;
122122+ /// # use jacquard_api::app_bsky::feed::post::Post;
123123+ /// use crate::jacquard::client::AgentSessionExt;
124124+ /// # #[tokio::main]
125125+ /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
126126+ /// let client = BasicClient::unauthenticated();
127127+ /// let uri = AtUri::new_static("at://did:plc:xyz/app.bsky.feed.post/3l5abc").unwrap();
128128+ /// let response = client.get_record::<Post<'_>>(&uri).await?;
129129+ /// # Ok(())
130130+ /// # }
131131+ /// ```
132132+ pub fn unauthenticated() -> Self {
133133+ use std::sync::Arc;
134134+ let http = reqwest::Client::new();
135135+ let resolver = jacquard_identity::PublicResolver::new(http, Default::default());
136136+ let store = MemorySessionStore::default();
137137+ let session = CredentialSession::new(Arc::new(store), Arc::new(resolver));
138138+ Agent::new(session)
139139+ }
140140+}
831418484- /// Generic XRPC error (InvalidRequest, etc.)
8585- #[error("XRPC error: {0}")]
8686- Generic(GenericXrpcError),
142142+impl Default for BasicClient {
143143+ fn default() -> Self {
144144+ Self::unauthenticated()
145145+ }
146146+}
871478888- /// Response deserialization failed
8989- #[error("Failed to decode response: {0}")]
9090- #[diagnostic(transparent)]
9191- Decode(
9292- #[from]
9393- #[diagnostic_source]
9494- DecodeError,
9595- ),
148148+/// MemoryCredentialSession: credential session with in memory store and identity resolver
149149+pub type MemoryCredentialSession = CredentialSession<
150150+ MemorySessionStore<SessionKey, AtpSession>,
151151+ jacquard_identity::PublicResolver,
152152+>;
961539797- /// Record operation failed with typed error from endpoint
9898- /// Context: which repo/collection/rkey we were operating on
9999- #[error("Record operation failed on {collection}/{rkey:?} in repo {repo}: {error}")]
100100- RecordOperation {
101101- /// The repository DID
102102- repo: Did<'static>,
103103- /// The collection NSID
104104- collection: Nsid<'static>,
105105- /// The record key
106106- rkey: RecordKey<Rkey<'static>>,
107107- /// The underlying error
108108- error: Box<dyn std::error::Error + Send + Sync>,
109109- },
154154+impl MemoryCredentialSession {
155155+ /// Create an unauthenticated MemoryCredentialSession.
156156+ ///
157157+ /// Uses an in memory store and a public resolver.
158158+ /// Equivalent to a BasicClient that isn't wrapped in Agent
159159+ pub fn unauthenticated() -> Self {
160160+ use std::sync::Arc;
161161+ let http = reqwest::Client::new();
162162+ let resolver = jacquard_identity::PublicResolver::new(http, Default::default());
163163+ let store = MemorySessionStore::default();
164164+ CredentialSession::new(Arc::new(store), Arc::new(resolver))
165165+ }
110166111111- /// Multi-step operation failed at sub-step (e.g., get failed in update_record)
112112- #[error("Operation failed at step '{step}': {error}")]
113113- SubOperation {
114114- /// Description of which step failed
115115- step: &'static str,
116116- /// The underlying error
117117- error: Box<dyn std::error::Error + Send + Sync>,
118118- },
167167+ /// Create a MemoryCredentialSession and authenticate with the provided details
168168+ ///
169169+ /// - `identifier`: handle (preferred), DID, or `https://` PDS base URL.
170170+ /// - `session_id`: optional session label; defaults to "session".
171171+ /// - Persists and activates the session, and updates the base endpoint to the user's PDS.
172172+ ///
173173+ /// # Example
174174+ /// ```no_run
175175+ /// # use jacquard::client::BasicClient;
176176+ /// # use jacquard::types::string::AtUri;
177177+ /// # use jacquard::api::app_bsky::feed::post::Post;
178178+ /// # use jacquard::types::string::Datetime;
179179+ /// # use jacquard::CowStr;
180180+ /// use jacquard::client::MemoryCredentialSession;
181181+ /// use jacquard::client::{Agent, AgentSessionExt};
182182+ /// # #[tokio::main]
183183+ /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
184184+ /// # let (identifier, password, post_text): (CowStr<'_>, CowStr<'_>, CowStr<'_>) = todo!();
185185+ /// let (session, _) = MemoryCredentialSession::authenticated(identifier, password, None).await?;
186186+ /// let agent = Agent::from(session);
187187+ /// let post = Post::builder().text(post_text).created_at(Datetime::now()).build();
188188+ /// let output = agent.create_record(post, None).await?;
189189+ /// # Ok(())
190190+ /// # }
191191+ /// ```
192192+ pub async fn authenticated(
193193+ identifier: CowStr<'_>,
194194+ password: CowStr<'_>,
195195+ session_id: Option<CowStr<'_>>,
196196+ ) -> ClientResult<(Self, AtpSession)> {
197197+ let session = MemoryCredentialSession::unauthenticated();
198198+ let auth = session
199199+ .login(identifier, password, session_id, None, None)
200200+ .await?;
201201+ Ok((session, auth))
202202+ }
119203}
120204121121-impl IntoStatic for AgentError {
122122- type Output = AgentError;
123123-124124- fn into_static(self) -> Self::Output {
125125- match self {
126126- AgentError::RecordOperation {
127127- repo,
128128- collection,
129129- rkey,
130130- error,
131131- } => AgentError::RecordOperation {
132132- repo: repo.into_static(),
133133- collection: collection.into_static(),
134134- rkey: rkey.into_static(),
135135- error,
136136- },
137137- AgentError::SubOperation { step, error } => AgentError::SubOperation { step, error },
138138- // Error types are already 'static
139139- AgentError::Client(e) => AgentError::Client(e),
140140- AgentError::NoSession => AgentError::NoSession,
141141- AgentError::Auth(e) => AgentError::Auth(e),
142142- AgentError::Generic(e) => AgentError::Generic(e),
143143- AgentError::Decode(e) => AgentError::Decode(e),
144144- }
205205+impl Default for MemoryCredentialSession {
206206+ fn default() -> Self {
207207+ MemoryCredentialSession::unauthenticated()
145208 }
146209}
147210···184247 }
185248}
186249187187-/// Identifies the active authentication mode for an agent/session.
188188-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
189189-pub enum AgentKind {
190190- /// App password (Bearer) session
191191- AppPassword,
192192- /// OAuth (DPoP) session
193193- OAuth,
194194-}
195195-196196-/// Common interface for stateful sessions used by the Agent wrapper.
197197-///
198198-/// Implemented by `CredentialSession` (app‑password) and `OAuthSession` (DPoP).
199199-#[cfg_attr(not(target_arch = "wasm32"), trait_variant::make(Send))]
200200-pub trait AgentSession: XrpcClient + HttpClient + Send + Sync {
201201- /// Identify the kind of session.
202202- fn session_kind(&self) -> AgentKind;
203203- /// Return current DID and an optional session id (always Some for OAuth).
204204- fn session_info(&self)
205205- -> impl Future<Output = Option<(Did<'static>, Option<CowStr<'static>>)>>;
206206- /// Current base endpoint.
207207- fn endpoint(&self) -> impl Future<Output = url::Url>;
208208- /// Override per-session call options.
209209- fn set_options<'a>(&'a self, opts: CallOptions<'a>) -> impl Future<Output = ()>;
210210- /// Refresh the session and return a fresh AuthorizationToken.
211211- fn refresh(&self) -> impl Future<Output = Result<AuthorizationToken<'static>, ClientError>>;
212212-}
213213-214214-impl<S, T, W> AgentSession for CredentialSession<S, T, W>
215215-where
216216- S: SessionStore<SessionKey, AtpSession> + Send + Sync + 'static,
217217- T: IdentityResolver + HttpClient + XrpcExt + Send + Sync + 'static,
218218- W: Send + Sync,
219219-{
220220- fn session_kind(&self) -> AgentKind {
221221- AgentKind::AppPassword
222222- }
223223- fn session_info(
224224- &self,
225225- ) -> impl Future<
226226- Output = std::option::Option<(
227227- jacquard_common::types::did::Did<'static>,
228228- std::option::Option<CowStr<'static>>,
229229- )>,
230230- > {
231231- async move {
232232- CredentialSession::<S, T, W>::session_info(self)
233233- .await
234234- .map(|(did, sid)| (did, Some(sid)))
235235- }
236236- }
237237- fn endpoint(&self) -> impl Future<Output = url::Url> {
238238- async move { CredentialSession::<S, T, W>::endpoint(self).await }
239239- }
240240- fn set_options<'a>(&'a self, opts: CallOptions<'a>) -> impl Future<Output = ()> {
241241- async move { CredentialSession::<S, T, W>::set_options(self, opts).await }
242242- }
243243- fn refresh(&self) -> impl Future<Output = Result<AuthorizationToken<'static>, ClientError>> {
244244- async move {
245245- Ok(CredentialSession::<S, T, W>::refresh(self)
246246- .await?
247247- .into_static())
248248- }
249249- }
250250-}
251251-252252-impl<T, S, W> AgentSession for OAuthSession<T, S, W>
253253-where
254254- S: ClientAuthStore + Send + Sync + 'static,
255255- T: OAuthResolver + DpopExt + XrpcExt + Send + Sync + 'static,
256256- W: Send + Sync,
257257-{
258258- fn session_kind(&self) -> AgentKind {
259259- AgentKind::OAuth
260260- }
261261- fn session_info(
262262- &self,
263263- ) -> impl Future<
264264- Output = std::option::Option<(
265265- jacquard_common::types::did::Did<'static>,
266266- std::option::Option<CowStr<'static>>,
267267- )>,
268268- > {
269269- async {
270270- let (did, sid) = OAuthSession::<T, S, W>::session_info(self).await;
271271- Some((did.into_static(), Some(sid.into_static())))
272272- }
273273- }
274274- fn endpoint(&self) -> impl Future<Output = url::Url> {
275275- async { self.endpoint().await }
276276- }
277277- fn set_options<'a>(&'a self, opts: CallOptions<'a>) -> impl Future<Output = ()> {
278278- async { self.set_options(opts).await }
279279- }
280280- fn refresh(&self) -> impl Future<Output = Result<AuthorizationToken<'static>, ClientError>> {
281281- async {
282282- self.refresh()
283283- .await
284284- .map(|t| t.into_static())
285285- .map_err(|e| ClientError::Transport(TransportError::Other(Box::new(e))))
286286- }
287287- }
288288-}
289289-290250/// Thin wrapper over a stateful session providing a uniform `XrpcClient`.
291251pub struct Agent<A: AgentSession> {
292252 inner: A,
···319279 }
320280321281 /// Refresh the session and return a fresh token.
322322- pub async fn refresh(&self) -> Result<AuthorizationToken<'static>, ClientError> {
282282+ pub async fn refresh(&self) -> ClientResult<AuthorizationToken<'static>> {
323283 self.inner.refresh().await
324284 }
325285}
326286327327-#[cfg(feature = "api")]
328328-use jacquard_api::com_atproto::{
329329- repo::{
330330- create_record::CreateRecordOutput, delete_record::DeleteRecordOutput,
331331- get_record::GetRecordResponse, put_record::PutRecordOutput,
332332- },
333333- server::{create_session::CreateSessionOutput, refresh_session::RefreshSessionOutput},
334334-};
335335-336336-/// doc
287287+/// Output type for a collection record retrieval operation
337288pub type CollectionOutput<'a, R> = <<R as Collection>::Record as XrpcResp>::Output<'a>;
338338-/// doc
289289+/// Error type for a collection record retrieval operation
339290pub type CollectionErr<'a, R> = <<R as Collection>::Record as XrpcResp>::Err<'a>;
340340-/// doc
291291+/// Response type for the get request of a vec update operation
341292pub type VecGetResponse<U> = <<U as VecUpdate>::GetRequest as XrpcRequest>::Response;
342342-/// doc
293293+/// Response type for the put request of a vec update operation
343294pub type VecPutResponse<U> = <<U as VecUpdate>::PutRequest as XrpcRequest>::Response;
295295+296296+type CollectionError<'a, R> = <<R as Collection>::Record as XrpcResp>::Err<'a>;
297297+298298+type VecUpdateGetError<'a, U> =
299299+ <<<U as VecUpdate>::GetRequest as XrpcRequest>::Response as XrpcResp>::Err<'a>;
300300+301301+type VecUpdatePutError<'a, U> =
302302+ <<<U as VecUpdate>::PutRequest as XrpcRequest>::Response as XrpcResp>::Err<'a>;
344303345304/// Extension trait providing convenience methods for common repository operations.
346305///
···423382 &self,
424383 record: R,
425384 rkey: Option<RecordKey<Rkey<'_>>>,
426426- ) -> impl Future<Output = Result<CreateRecordOutput<'static>, AgentError>>
385385+ ) -> impl Future<Output = Result<CreateRecordOutput<'static>>>
427386 where
428387 R: Collection + serde::Serialize,
429388 {
···435394 use jacquard_common::types::ident::AtIdentifier;
436395 use jacquard_common::types::value::to_data;
437396438438- let (did, _) = self.session_info().await.ok_or(AgentError::NoSession)?;
397397+ let (did, _) = self
398398+ .session_info()
399399+ .await
400400+ .ok_or_else(AgentError::no_session)?;
439401440440- let data = to_data(&record).map_err(|e| AgentError::SubOperation {
441441- step: "serialize record",
442442- error: Box::new(e),
443443- })?;
402402+ let data =
403403+ to_data(&record).map_err(|e| AgentError::sub_operation("serialize record", e))?;
444404445405 let request = CreateRecord::new()
446406 .repo(AtIdentifier::Did(did))
···451411452412 let response = self.send(request).await?;
453413 response.into_output().map_err(|e| match e {
454454- XrpcError::Auth(auth) => AgentError::Auth(auth),
455455- XrpcError::Generic(g) => AgentError::Generic(g),
456456- XrpcError::Decode(e) => AgentError::Decode(e),
457457- XrpcError::Xrpc(typed) => AgentError::SubOperation {
458458- step: "create record",
459459- error: Box::new(typed),
460460- },
414414+ XrpcError::Auth(auth) => AgentError::from(auth),
415415+ e @ (XrpcError::Generic(_) | XrpcError::Decode(_)) => AgentError::xrpc(e),
416416+ XrpcError::Xrpc(typed) => AgentError::sub_operation("create record", typed),
461417 })
462418 }
463419 }
···491447 fn get_record<R>(
492448 &self,
493449 uri: &AtUri<'_>,
494494- ) -> impl Future<Output = Result<Response<R::Record>, ClientError>>
450450+ ) -> impl Future<Output = ClientResult<Response<R::Record>>>
495451 where
496452 R: Collection,
497453 {
···503459 // Validate that URI's collection matches the expected type
504460 if let Some(uri_collection) = uri.collection() {
505461 if uri_collection.as_str() != R::nsid().as_str() {
506506- return Err(ClientError::Transport(TransportError::Other(
507507- format!(
462462+ return Err(ClientError::invalid_request(format!(
508463 "Collection mismatch: URI contains '{}' but type parameter expects '{}'",
509464 uri_collection,
510465 R::nsid()
511511- )
512512- .into(),
513513- )));
466466+ ))
467467+ .with_help("ensure the URI collection matches the record type"));
514468 }
515469 }
516470517471 let rkey = uri.rkey().ok_or_else(|| {
518518- ClientError::Transport(TransportError::Other("AtUri missing rkey".into()))
472472+ ClientError::invalid_request("AtUri missing rkey")
473473+ .with_help("ensure the URI includes a record key after the collection")
519474 })?;
520475521476 // Resolve authority (DID or handle) to get DID and PDS
···523478 let (repo_did, pds_url) = match uri.authority() {
524479 AtIdentifier::Did(did) => {
525480 let pds = self.pds_for_did(did).await.map_err(|e| {
526526- ClientError::Transport(TransportError::Other(
527527- format!("Failed to resolve PDS for {}: {}", did, e).into(),
528528- ))
481481+ ClientError::from(e)
482482+ .with_context("DID document resolution failed during record retrieval")
529483 })?;
530484 (did.clone(), pds)
531485 }
532486 AtIdentifier::Handle(handle) => self.pds_for_handle(handle).await.map_err(|e| {
533533- ClientError::Transport(TransportError::Other(
534534- format!("Failed to resolve handle {}: {}", handle, e).into(),
535535- ))
487487+ ClientError::from(e)
488488+ .with_context("handle resolution failed during record retrieval")
536489 })?,
537490 };
538491···545498 .build();
546499547500 let response: Response<GetRecordResponse> = {
548548- let http_request = xrpc::build_http_request(&pds_url, &request, &self.opts().await)
549549- .map_err(|e| ClientError::Transport(TransportError::from(e)))?;
501501+ let http_request =
502502+ xrpc::build_http_request(&pds_url, &request, &self.opts().await)?;
550503551504 let http_response = self
552505 .send_http(http_request)
553506 .await
554554- .map_err(|e| ClientError::Transport(TransportError::Other(Box::new(e))))?;
507507+ .map_err(|e| ClientError::transport(e))?;
555508556509 xrpc::process_response(http_response)
557510 }?;
···566519 fn fetch_record<R>(
567520 &self,
568521 uri: &RecordUri<'_, R>,
569569- ) -> impl Future<Output = Result<CollectionOutput<'static, R>, ClientError>>
522522+ ) -> impl Future<Output = Result<CollectionOutput<'static, R>>>
570523 where
571524 R: Collection,
572525 for<'a> CollectionOutput<'a, R>: IntoStatic<Output = CollectionOutput<'static, R>>,
573573- for<'a> CollectionErr<'a, R>: IntoStatic<Output = CollectionErr<'static, R>>,
526526+ for<'a> CollectionErr<'a, R>: IntoStatic<Output = CollectionErr<'static, R>> + Send + Sync,
574527 {
575528 let uri = uri.as_uri();
576529 async move {
577530 let response = self.get_record::<R>(uri).await?;
578531 let response: Response<R::Record> = response.transmute();
579579- let output = response
580580- .into_output()
581581- .map_err(|e| ClientError::Transport(TransportError::Other(e.to_string().into())))?;
582582- // TODO: fix this to use a better error lol
532532+ let output = response.into_output().map_err(|e| match e {
533533+ XrpcError::Auth(auth) => AgentError::from(auth),
534534+ e @ (XrpcError::Generic(_) | XrpcError::Decode(_)) => AgentError::xrpc(e),
535535+ XrpcError::Xrpc(typed) => {
536536+ AgentError::new(AgentErrorKind::SubOperation { step: "get record" }, None)
537537+ .with_details(typed.to_string())
538538+ }
539539+ })?;
583540 Ok(output)
584541 }
585542 }
···614571 &self,
615572 uri: &AtUri<'_>,
616573 f: impl FnOnce(&mut R),
617617- ) -> impl Future<Output = Result<PutRecordOutput<'static>, AgentError>>
574574+ ) -> impl Future<Output = Result<PutRecordOutput<'static>>>
618575 where
619576 R: Collection + Serialize,
620577 R: for<'a> From<CollectionOutput<'a, R>>,
578578+ for<'a> <CollectionError<'a, R> as IntoStatic>::Output:
579579+ IntoStatic + std::error::Error + Send + Sync,
580580+ for<'a> CollectionError<'a, R>: Send + Sync + std::error::Error + IntoStatic,
621581 {
622582 async move {
623583 #[cfg(feature = "tracing")]
···629589630590 // Parse to get R<'_> borrowing from response buffer
631591 let record = response.parse().map_err(|e| match e {
632632- XrpcError::Auth(auth) => AgentError::Auth(auth),
633633- XrpcError::Generic(g) => AgentError::Generic(g),
634634- XrpcError::Decode(e) => AgentError::Decode(e),
635635- XrpcError::Xrpc(typed) => AgentError::SubOperation {
636636- step: "get record",
637637- error: format!("{:?}", typed).into(),
638638- },
592592+ XrpcError::Auth(auth) => AgentError::from(auth),
593593+ e @ (XrpcError::Generic(_) | XrpcError::Decode(_)) => AgentError::xrpc(e),
594594+ XrpcError::Xrpc(typed) => {
595595+ AgentError::new(AgentErrorKind::SubOperation { step: "get record" }, None)
596596+ .with_details(typed.to_string())
597597+ }
639598 })?;
640599641600 // Convert to owned
···647606 // Put it back
648607 let rkey = uri
649608 .rkey()
650650- .ok_or(AgentError::SubOperation {
651651- step: "extract rkey",
652652- error: "AtUri missing rkey".into(),
609609+ .ok_or_else(|| {
610610+ AgentError::sub_operation(
611611+ "extract rkey",
612612+ std::io::Error::new(std::io::ErrorKind::InvalidInput, "AtUri missing rkey"),
613613+ )
653614 })?
654615 .clone()
655616 .into_static();
···664625 fn delete_record<R>(
665626 &self,
666627 rkey: RecordKey<Rkey<'_>>,
667667- ) -> impl Future<Output = Result<DeleteRecordOutput<'static>, AgentError>>
628628+ ) -> impl Future<Output = Result<DeleteRecordOutput<'static>>>
668629 where
669630 R: Collection,
670631 {
···675636 use jacquard_api::com_atproto::repo::delete_record::DeleteRecord;
676637 use jacquard_common::types::ident::AtIdentifier;
677638678678- let (did, _) = self.session_info().await.ok_or(AgentError::NoSession)?;
639639+ let (did, _) = self
640640+ .session_info()
641641+ .await
642642+ .ok_or_else(AgentError::no_session)?;
679643680644 let request = DeleteRecord::new()
681645 .repo(AtIdentifier::Did(did))
···685649686650 let response = self.send(request).await?;
687651 response.into_output().map_err(|e| match e {
688688- XrpcError::Auth(auth) => AgentError::Auth(auth),
689689- XrpcError::Generic(g) => AgentError::Generic(g),
690690- XrpcError::Decode(e) => AgentError::Decode(e),
691691- XrpcError::Xrpc(typed) => AgentError::SubOperation {
692692- step: "delete record",
693693- error: Box::new(typed),
694694- },
652652+ XrpcError::Auth(auth) => AgentError::from(auth),
653653+ e @ (XrpcError::Generic(_) | XrpcError::Decode(_)) => AgentError::xrpc(e),
654654+ XrpcError::Xrpc(typed) => AgentError::sub_operation("delete record", typed),
695655 })
696656 }
697657 }
···704664 &self,
705665 rkey: RecordKey<Rkey<'static>>,
706666 record: R,
707707- ) -> impl Future<Output = Result<PutRecordOutput<'static>, AgentError>>
667667+ ) -> impl Future<Output = Result<PutRecordOutput<'static>>>
708668 where
709669 R: Collection + serde::Serialize,
710670 {
···716676 use jacquard_common::types::ident::AtIdentifier;
717677 use jacquard_common::types::value::to_data;
718678719719- let (did, _) = self.session_info().await.ok_or(AgentError::NoSession)?;
679679+ let (did, _) = self
680680+ .session_info()
681681+ .await
682682+ .ok_or_else(AgentError::no_session)?;
720683721721- let data = to_data(&record).map_err(|e| AgentError::SubOperation {
722722- step: "serialize record",
723723- error: Box::new(e),
724724- })?;
684684+ let data =
685685+ to_data(&record).map_err(|e| AgentError::sub_operation("serialize record", e))?;
725686726687 let request = PutRecord::new()
727688 .repo(AtIdentifier::Did(did))
···732693733694 let response = self.send(request).await?;
734695 response.into_output().map_err(|e| match e {
735735- XrpcError::Auth(auth) => AgentError::Auth(auth),
736736- XrpcError::Generic(g) => AgentError::Generic(g),
737737- XrpcError::Decode(e) => AgentError::Decode(e),
738738- XrpcError::Xrpc(typed) => AgentError::SubOperation {
739739- step: "put record",
740740- error: Box::new(typed),
741741- },
696696+ XrpcError::Auth(auth) => AgentError::from(auth),
697697+ e @ (XrpcError::Generic(_) | XrpcError::Decode(_)) => AgentError::xrpc(e),
698698+ XrpcError::Xrpc(typed) => AgentError::sub_operation("put record", typed),
742699 })
743700 }
744701 }
···767724 &self,
768725 data: impl Into<bytes::Bytes>,
769726 mime_type: MimeType<'_>,
770770- ) -> impl Future<Output = Result<Blob<'static>, AgentError>> {
727727+ ) -> impl Future<Output = Result<Blob<'static>>> {
771728 async move {
772729 #[cfg(feature = "tracing")]
773730 let _span = tracing::debug_span!("upload_blob", mime_type = %mime_type).entered();
···783740784741 opts.extra_headers.push((
785742 CONTENT_TYPE,
786786- http::HeaderValue::from_str(mime_type.as_str()).map_err(|e| {
787787- AgentError::SubOperation {
788788- step: "set Content-Type header",
789789- error: Box::new(e),
790790- }
791791- })?,
743743+ http::HeaderValue::from_str(mime_type.as_str())
744744+ .map_err(|e| AgentError::sub_operation("set Content-Type header", e))?,
792745 ));
793746 let response = self.send_with_opts(request, opts).await?;
794747 let debug: serde_json::Value = serde_json::from_slice(response.buffer()).unwrap();
795748 println!("json: {}", serde_json::to_string_pretty(&debug).unwrap());
796749 let output = response.into_output().map_err(|e| match e {
797797- XrpcError::Auth(auth) => AgentError::Auth(auth),
798798- XrpcError::Generic(g) => AgentError::Generic(g),
799799- XrpcError::Decode(e) => AgentError::Decode(e),
800800- XrpcError::Xrpc(typed) => AgentError::SubOperation {
801801- step: "upload blob",
802802- error: Box::new(typed),
803803- },
750750+ XrpcError::Auth(auth) => AgentError::from(auth),
751751+ e @ (XrpcError::Generic(_) | XrpcError::Decode(_)) => AgentError::xrpc(e),
752752+ XrpcError::Xrpc(typed) => AgentError::sub_operation("upload blob", typed),
804753 })?;
805754 Ok(output.blob.blob().clone().into_static())
806755 }
···822771 fn update_vec<U>(
823772 &self,
824773 modify: impl FnOnce(&mut Vec<<U as VecUpdate>::Item>),
825825- ) -> impl Future<Output = Result<xrpc::Response<VecPutResponse<U>>, AgentError>>
774774+ ) -> impl Future<Output = Result<xrpc::Response<VecPutResponse<U>>>>
826775 where
827776 U: VecUpdate,
828777 <U as VecUpdate>::PutRequest: Send + Sync,
829778 <U as VecUpdate>::GetRequest: Send + Sync,
830779 VecGetResponse<U>: Send + Sync,
831780 VecPutResponse<U>: Send + Sync,
781781+ for<'a> VecUpdateGetError<'a, U>: Send + Sync + std::error::Error + IntoStatic,
782782+ for<'a> VecUpdatePutError<'a, U>: Send + Sync + std::error::Error + IntoStatic,
783783+ for<'a> <VecUpdateGetError<'a, U> as IntoStatic>::Output:
784784+ Send + Sync + std::error::Error + IntoStatic + 'static,
785785+ for<'a> <VecUpdatePutError<'a, U> as IntoStatic>::Output:
786786+ Send + Sync + std::error::Error + IntoStatic + 'static,
832787 {
833788 async {
834789 // Fetch current data
835790 let get_request = U::build_get();
836791 let response = self.send(get_request).await?;
837792 let output = response.parse().map_err(|e| match e {
838838- XrpcError::Auth(auth) => AgentError::Auth(auth),
839839- XrpcError::Generic(g) => AgentError::Generic(g),
840840- XrpcError::Decode(e) => AgentError::Decode(e),
841841- XrpcError::Xrpc(_) => AgentError::SubOperation {
842842- step: "get vec",
843843- error: format!("{:?}", e).into(),
844844- },
793793+ XrpcError::Auth(auth) => AgentError::from(auth),
794794+ e @ (XrpcError::Generic(_) | XrpcError::Decode(_)) => AgentError::xrpc(e),
795795+ XrpcError::Xrpc(typed) => {
796796+ AgentError::sub_operation("update vec", typed.into_static())
797797+ }
845798 })?;
846799847800 // Extract vec (converts to owned via IntoStatic)
···872825 fn update_vec_item<U>(
873826 &self,
874827 item: <U as VecUpdate>::Item,
875875- ) -> impl Future<Output = Result<xrpc::Response<VecPutResponse<U>>, AgentError>>
828828+ ) -> impl Future<Output = Result<xrpc::Response<VecPutResponse<U>>>>
876829 where
877830 U: VecUpdate,
878831 <U as VecUpdate>::PutRequest: Send + Sync,
879832 <U as VecUpdate>::GetRequest: Send + Sync,
880833 VecGetResponse<U>: Send + Sync,
881834 VecPutResponse<U>: Send + Sync,
835835+ for<'a> VecUpdateGetError<'a, U>: Send + Sync + std::error::Error + IntoStatic,
836836+ for<'a> VecUpdatePutError<'a, U>: Send + Sync + std::error::Error + IntoStatic,
837837+ for<'a> <VecUpdateGetError<'a, U> as IntoStatic>::Output:
838838+ Send + Sync + std::error::Error + IntoStatic + 'static,
839839+ for<'a> <VecUpdatePutError<'a, U> as IntoStatic>::Output:
840840+ Send + Sync + std::error::Error + IntoStatic + 'static,
882841 {
883842 async {
884843 self.update_vec::<U>(|vec| {
···896855#[cfg(feature = "api")]
897856impl<T: AgentSession + IdentityResolver> AgentSessionExt for T {}
898857858858+impl<S, T, W> AgentSession for CredentialSession<S, T, W>
859859+where
860860+ S: SessionStore<SessionKey, AtpSession> + Send + Sync + 'static,
861861+ T: IdentityResolver + HttpClient + XrpcExt + Send + Sync + 'static,
862862+ W: Send + Sync,
863863+{
864864+ fn session_kind(&self) -> AgentKind {
865865+ AgentKind::AppPassword
866866+ }
867867+ fn session_info(
868868+ &self,
869869+ ) -> impl Future<Output = Option<(Did<'static>, Option<CowStr<'static>>)>> {
870870+ async move {
871871+ CredentialSession::<S, T, W>::session_info(self)
872872+ .await
873873+ .map(|(did, sid)| (did, Some(sid)))
874874+ }
875875+ }
876876+ fn endpoint(&self) -> impl Future<Output = url::Url> {
877877+ async move { CredentialSession::<S, T, W>::endpoint(self).await }
878878+ }
879879+ fn set_options<'a>(&'a self, opts: CallOptions<'a>) -> impl Future<Output = ()> {
880880+ async move { CredentialSession::<S, T, W>::set_options(self, opts).await }
881881+ }
882882+ fn refresh(&self) -> impl Future<Output = ClientResult<AuthorizationToken<'static>>> {
883883+ async move {
884884+ Ok(CredentialSession::<S, T, W>::refresh(self)
885885+ .await?
886886+ .into_static())
887887+ }
888888+ }
889889+}
890890+891891+impl<T, S, W> AgentSession for OAuthSession<T, S, W>
892892+where
893893+ S: ClientAuthStore + Send + Sync + 'static,
894894+ T: OAuthResolver + DpopExt + XrpcExt + Send + Sync + 'static,
895895+ W: Send + Sync,
896896+{
897897+ fn session_kind(&self) -> AgentKind {
898898+ AgentKind::OAuth
899899+ }
900900+ fn session_info(
901901+ &self,
902902+ ) -> impl Future<Output = Option<(Did<'static>, Option<CowStr<'static>>)>> {
903903+ async {
904904+ let (did, sid) = OAuthSession::<T, S, W>::session_info(self).await;
905905+ Some((did.into_static(), Some(sid.into_static())))
906906+ }
907907+ }
908908+ fn endpoint(&self) -> impl Future<Output = url::Url> {
909909+ async { self.endpoint().await }
910910+ }
911911+ fn set_options<'a>(&'a self, opts: CallOptions<'a>) -> impl Future<Output = ()> {
912912+ async { self.set_options(opts).await }
913913+ }
914914+ fn refresh(&self) -> impl Future<Output = ClientResult<AuthorizationToken<'static>>> {
915915+ async {
916916+ self.refresh()
917917+ .await
918918+ .map(|t| t.into_static())
919919+ .map_err(|e| ClientError::transport(e).with_context("OAuth token refresh failed"))
920920+ }
921921+ }
922922+}
923923+899924impl<A: AgentSession> HttpClient for Agent<A> {
900925 type Error = <A as HttpClient>::Error;
901926···11031128 fn resolve_handle(
11041129 &self,
11051130 handle: &Handle<'_>,
11061106- ) -> impl Future<Output = Result<Did<'static>, IdentityError>> {
11311131+ ) -> impl Future<Output = core::result::Result<Did<'static>, IdentityError>> {
11071132 async { self.inner.resolve_handle(handle).await }
11081133 }
1109113411101135 fn resolve_did_doc(
11111136 &self,
11121137 did: &Did<'_>,
11131113- ) -> impl Future<Output = Result<DidDocResponse, IdentityError>> {
11381138+ ) -> impl Future<Output = core::result::Result<DidDocResponse, IdentityError>> {
11141139 async { self.inner.resolve_did_doc(did).await }
11151140 }
11161141}
···11341159 async { self.set_options(opts).await }
11351160 }
1136116111371137- fn refresh(&self) -> impl Future<Output = Result<AuthorizationToken<'static>, ClientError>> {
11621162+ fn refresh(&self) -> impl Future<Output = ClientResult<AuthorizationToken<'static>>> {
11381163 async { self.refresh().await }
11391164 }
11401165}
···11441169 Self::new(inner)
11451170 }
11461171}
11471147-11481148-/// Alias for an agent over a credential (app‑password) session.
11491149-pub type CredentialAgent<S, T> = Agent<CredentialSession<S, T>>;
11501150-/// Alias for an agent over an OAuth (DPoP) session.
11511151-pub type OAuthAgent<T, S> = Agent<OAuthSession<T, S>>;
11521152-11531153-/// BasicClient: in-memory store + public resolver over a credential session.
11541154-pub type BasicClient = Agent<
11551155- CredentialSession<
11561156- MemorySessionStore<SessionKey, AtpSession>,
11571157- jacquard_identity::PublicResolver,
11581158- >,
11591159->;
11601160-11611161-impl BasicClient {
11621162- /// Create an unauthenticated BasicClient for public API access.
11631163- ///
11641164- /// Uses an in-memory session store and public resolver. Suitable for
11651165- /// read-only operations on public data without authentication.
11661166- ///
11671167- /// # Example
11681168- ///
11691169- /// ```no_run
11701170- /// # use jacquard::client::BasicClient;
11711171- /// # use jacquard::types::string::AtUri;
11721172- /// # use jacquard_api::app_bsky::feed::post::Post;
11731173- /// use crate::jacquard::client::AgentSessionExt;
11741174- /// # #[tokio::main]
11751175- /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
11761176- /// let client = BasicClient::unauthenticated();
11771177- /// let uri = AtUri::new_static("at://did:plc:xyz/app.bsky.feed.post/3l5abc").unwrap();
11781178- /// let response = client.get_record::<Post<'_>>(&uri).await?;
11791179- /// # Ok(())
11801180- /// # }
11811181- /// ```
11821182- pub fn unauthenticated() -> Self {
11831183- use std::sync::Arc;
11841184- let http = reqwest::Client::new();
11851185- let resolver = jacquard_identity::PublicResolver::new(http, Default::default());
11861186- let store = MemorySessionStore::default();
11871187- let session = CredentialSession::new(Arc::new(store), Arc::new(resolver));
11881188- Agent::new(session)
11891189- }
11901190-}
11911191-11921192-impl Default for BasicClient {
11931193- fn default() -> Self {
11941194- Self::unauthenticated()
11951195- }
11961196-}
11971197-11981198-/// MemoryCredentialSession: credential session with in memory store and identity resolver
11991199-pub type MemoryCredentialSession = CredentialSession<
12001200- MemorySessionStore<SessionKey, AtpSession>,
12011201- jacquard_identity::PublicResolver,
12021202->;
12031203-12041204-impl MemoryCredentialSession {
12051205- /// Create an unauthenticated MemoryCredentialSession.
12061206- ///
12071207- /// Uses an in memory store and a public resolver.
12081208- /// Equivalent to a BasicClient that isn't wrapped in Agent
12091209- pub fn unauthenticated() -> Self {
12101210- use std::sync::Arc;
12111211- let http = reqwest::Client::new();
12121212- let resolver = jacquard_identity::PublicResolver::new(http, Default::default());
12131213- let store = MemorySessionStore::default();
12141214- CredentialSession::new(Arc::new(store), Arc::new(resolver))
12151215- }
12161216-12171217- /// Create a MemoryCredentialSession and authenticate with the provided details
12181218- ///
12191219- /// - `identifier`: handle (preferred), DID, or `https://` PDS base URL.
12201220- /// - `session_id`: optional session label; defaults to "session".
12211221- /// - Persists and activates the session, and updates the base endpoint to the user's PDS.
12221222- ///
12231223- /// # Example
12241224- /// ```no_run
12251225- /// # use jacquard::client::BasicClient;
12261226- /// # use jacquard::types::string::AtUri;
12271227- /// # use jacquard::api::app_bsky::feed::post::Post;
12281228- /// # use jacquard::types::string::Datetime;
12291229- /// # use jacquard::CowStr;
12301230- /// use jacquard::client::MemoryCredentialSession;
12311231- /// use jacquard::client::{Agent, AgentSessionExt};
12321232- /// # #[tokio::main]
12331233- /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
12341234- /// # let (identifier, password, post_text): (CowStr<'_>, CowStr<'_>, CowStr<'_>) = todo!();
12351235- /// let (session, _) = MemoryCredentialSession::authenticated(identifier, password, None).await?;
12361236- /// let agent = Agent::from(session);
12371237- /// let post = Post::builder().text(post_text).created_at(Datetime::now()).build();
12381238- /// let output = agent.create_record(post, None).await?;
12391239- /// # Ok(())
12401240- /// # }
12411241- /// ```
12421242- pub async fn authenticated(
12431243- identifier: CowStr<'_>,
12441244- password: CowStr<'_>,
12451245- session_id: Option<CowStr<'_>>,
12461246- ) -> Result<(Self, AtpSession), ClientError> {
12471247- let session = MemoryCredentialSession::unauthenticated();
12481248- let auth = session
12491249- .login(identifier, password, session_id, None, None)
12501250- .await?;
12511251- Ok((session, auth))
12521252- }
12531253-}
12541254-12551255-impl Default for MemoryCredentialSession {
12561256- fn default() -> Self {
12571257- MemoryCredentialSession::unauthenticated()
12581258- }
12591259-}
+97-81
crates/jacquard/src/client/credential_session.rs
···55};
66use jacquard_common::{
77 AuthorizationToken, CowStr, IntoStatic,
88- error::{AuthError, ClientError, TransportError, XrpcResult},
88+ error::{AuthError, ClientError, XrpcResult},
99 http_client::HttpClient,
1010 session::SessionStore,
1111 types::{did::Did, string::Handle},
···144144 T: HttpClient,
145145{
146146 /// Refresh the active session by calling `com.atproto.server.refreshSession`.
147147- pub async fn refresh(&self) -> Result<AuthorizationToken<'_>, ClientError> {
147147+ pub async fn refresh(&self) -> std::result::Result<AuthorizationToken<'_>, ClientError> {
148148 let key = self
149149 .key
150150 .read()
151151 .await
152152 .clone()
153153- .ok_or(ClientError::Auth(AuthError::NotAuthenticated))?;
153153+ .ok_or_else(|| ClientError::auth(AuthError::NotAuthenticated))?;
154154 let session = self.store.get(&key).await;
155155 let endpoint = self.endpoint().await;
156156 let mut opts = self.options.read().await.clone();
···163163 .await?;
164164 let refresh = response
165165 .parse()
166166- .map_err(|_| ClientError::Auth(AuthError::RefreshFailed))?;
166166+ .map_err(|_| ClientError::auth(AuthError::RefreshFailed)
167167+ .with_help("ensure refresh token is valid and not expired")
168168+ .with_url("com.atproto.server.refreshSession"))?;
167169168170 let new_session: AtpSession = refresh.into();
169171 let token = AuthorizationToken::Bearer(new_session.access_jwt.clone());
170172 self.store
171173 .set(key, new_session)
172174 .await
173173- .map_err(|_| ClientError::Auth(AuthError::RefreshFailed))?;
175175+ .map_err(|e| ClientError::from(e)
176176+ .with_context("failed to persist refreshed session to store"))?;
174177175178 Ok(token)
176179 }
···193196 session_id: Option<CowStr<'_>>,
194197 allow_takendown: Option<bool>,
195198 auth_factor_token: Option<CowStr<'_>>,
196196- ) -> Result<AtpSession, ClientError>
199199+ ) -> std::result::Result<AtpSession, ClientError>
197200 where
198201 S: Any + 'static,
199202 {
···205208 let pds = if identifier.as_ref().starts_with("http://")
206209 || identifier.as_ref().starts_with("https://")
207210 {
208208- Url::parse(identifier.as_ref()).map_err(|e| {
209209- ClientError::Transport(TransportError::InvalidRequest(e.to_string()))
210210- })?
211211+ Url::parse(identifier.as_ref())
212212+ .map_err(|e: url::ParseError| ClientError::from(e)
213213+ .with_help("identifier should be a valid https:// URL, handle, or DID"))?
211214 } else if identifier.as_ref().starts_with("did:") {
212212- let did = Did::new(identifier.as_ref()).map_err(|e| {
213213- ClientError::Transport(TransportError::InvalidRequest(format!(
214214- "invalid did: {:?}",
215215- e
216216- )))
217217- })?;
215215+ let did = Did::new(identifier.as_ref())
216216+ .map_err(|e| ClientError::invalid_request(format!("invalid did: {:?}", e))
217217+ .with_help("DID format should be did:method:identifier (e.g., did:plc:abc123)"))?;
218218 let resp = self
219219 .client
220220 .resolve_did_doc(&did)
221221 .await
222222- .map_err(|e| ClientError::Transport(TransportError::Other(Box::new(e))))?;
223223- resp.into_owned()
224224- .map_err(|e| ClientError::Transport(TransportError::Other(Box::new(e))))?
222222+ .map_err(|e| ClientError::from(e)
223223+ .with_context("DID document resolution failed during login"))?;
224224+ resp.into_owned()?
225225 .pds_endpoint()
226226- .ok_or_else(|| {
227227- ClientError::Transport(TransportError::InvalidRequest(
228228- "missing PDS endpoint".into(),
229229- ))
230230- })?
226226+ .ok_or_else(|| ClientError::invalid_request("missing PDS endpoint")
227227+ .with_help("DID document must include a PDS service endpoint"))?
231228 } else {
232229 // treat as handle
233233- let handle =
234234- jacquard_common::types::string::Handle::new(identifier.as_ref()).map_err(|e| {
235235- ClientError::Transport(TransportError::InvalidRequest(format!(
236236- "invalid handle: {:?}",
237237- e
238238- )))
239239- })?;
230230+ let handle = jacquard_common::types::string::Handle::new(identifier.as_ref())
231231+ .map_err(|e| ClientError::invalid_request(format!("invalid handle: {:?}", e))
232232+ .with_help("handle format should be domain.tld (e.g., alice.bsky.social)"))?;
240233 let did = self
241234 .client
242235 .resolve_handle(&handle)
243236 .await
244244- .map_err(|e| ClientError::Transport(TransportError::Other(Box::new(e))))?;
237237+ .map_err(|e| ClientError::from(e)
238238+ .with_context("handle resolution failed during login"))?;
245239 let resp = self
246240 .client
247241 .resolve_did_doc(&did)
248242 .await
249249- .map_err(|e| ClientError::Transport(TransportError::Other(Box::new(e))))?;
250250- resp.into_owned()
251251- .map_err(|e| ClientError::Transport(TransportError::Other(Box::new(e))))?
243243+ .map_err(|e| ClientError::from(e)
244244+ .with_context("DID document resolution failed during login"))?;
245245+ resp.into_owned()?
252246 .pds_endpoint()
253253- .ok_or_else(|| {
254254- ClientError::Transport(TransportError::InvalidRequest(
255255- "missing PDS endpoint".into(),
256256- ))
257257- })?
247247+ .ok_or_else(|| ClientError::invalid_request("missing PDS endpoint")
248248+ .with_help("DID document must include a PDS service endpoint"))?
258249 };
259250260251 // Build and send createSession
···275266 .await?;
276267 let out = resp
277268 .parse()
278278- .map_err(|_| ClientError::Auth(AuthError::NotAuthenticated))?;
269269+ .map_err(|_| ClientError::auth(AuthError::NotAuthenticated)
270270+ .with_help("check identifier and password are correct")
271271+ .with_url("com.atproto.server.createSession"))?;
279272 let session = AtpSession::from(out);
280273281274 let sid = session_id.unwrap_or_else(|| CowStr::new_static("session"));
···283276 self.store
284277 .set(key.clone(), session.clone())
285278 .await
286286- .map_err(|e| ClientError::Transport(TransportError::Other(Box::new(e))))?;
279279+ .map_err(|e| ClientError::from(e)
280280+ .with_context("failed to persist session to store"))?;
287281 // If using FileAuthStore, persist PDS for faster resume
288282 if let Some(file_store) =
289283 (&*self.store as &dyn Any).downcast_ref::<crate::client::token::FileAuthStore>()
···298292 }
299293300294 /// Restore a previously persisted app-password session and set base endpoint.
301301- pub async fn restore(&self, did: Did<'_>, session_id: CowStr<'_>) -> Result<(), ClientError>
295295+ pub async fn restore(
296296+ &self,
297297+ did: Did<'_>,
298298+ session_id: CowStr<'_>,
299299+ ) -> std::result::Result<(), ClientError>
302300 where
303301 S: Any + 'static,
304302 {
···309307310308 let key = (did.clone().into_static(), session_id.clone().into_static());
311309 let Some(sess) = self.store.get(&key).await else {
312312- return Err(ClientError::Auth(AuthError::NotAuthenticated));
310310+ return Err(ClientError::auth(AuthError::NotAuthenticated));
313311 };
314312 // Try to read cached PDS; otherwise resolve via DID
315313 let pds = if let Some(file_store) =
···323321 let resp = self
324322 .client
325323 .resolve_did_doc(&did)
326326- .await
327327- .map_err(|e| ClientError::Transport(TransportError::Other(Box::new(e))))?;
328328- resp.into_owned()
329329- .map_err(|e| ClientError::Transport(TransportError::Other(Box::new(e))))?
324324+ .await?;
325325+ resp.into_owned()?
330326 .pds_endpoint()
331331- .ok_or_else(|| {
332332- ClientError::Transport(TransportError::InvalidRequest(
333333- "missing PDS endpoint".into(),
334334- ))
335335- })?
327327+ .ok_or_else(|| ClientError::invalid_request("missing PDS endpoint")
328328+ .with_help("DID document must include a PDS service endpoint"))?
336329 });
337330338331 // Activate
···341334 // ensure store has the session (no-op if it existed)
342335 self.store
343336 .set((sess.did.clone(), session_id.into_static()), sess)
344344- .await
345345- .map_err(|e| ClientError::Transport(TransportError::Other(Box::new(e))))?;
337337+ .await?;
346338 if let Some(file_store) =
347339 (&*self.store as &dyn Any).downcast_ref::<crate::client::token::FileAuthStore>()
348340 {
···356348 &self,
357349 did: Did<'_>,
358350 session_id: CowStr<'_>,
359359- ) -> Result<(), ClientError>
351351+ ) -> std::result::Result<(), ClientError>
360352 where
361353 S: Any + 'static,
362354 {
363355 let key = (did.clone().into_static(), session_id.into_static());
364356 if self.store.get(&key).await.is_none() {
365365- return Err(ClientError::Auth(AuthError::NotAuthenticated));
357357+ return Err(ClientError::auth(AuthError::NotAuthenticated));
366358 }
367359 // Endpoint from store if cached, else resolve
368360 let pds = if let Some(file_store) =
···376368 let resp = self
377369 .client
378370 .resolve_did_doc(&did)
379379- .await
380380- .map_err(|e| ClientError::Transport(TransportError::Other(Box::new(e))))?;
381381- resp.into_owned()
382382- .map_err(|e| ClientError::Transport(TransportError::Other(Box::new(e))))?
371371+ .await?;
372372+ resp.into_owned()?
383373 .pds_endpoint()
384384- .ok_or_else(|| {
385385- ClientError::Transport(TransportError::InvalidRequest(
386386- "missing PDS endpoint".into(),
387387- ))
388388- })?
374374+ .ok_or_else(|| ClientError::invalid_request("missing PDS endpoint")
375375+ .with_help("DID document must include a PDS service endpoint"))?
389376 });
390377 *self.key.write().await = Some(key.clone());
391378 *self.endpoint.write().await = Some(pds);
···398385 }
399386400387 /// Clear and delete the current session from the store.
401401- pub async fn logout(&self) -> Result<(), ClientError> {
388388+ pub async fn logout(&self) -> std::result::Result<(), ClientError> {
402389 let Some(key) = self.key.read().await.clone() else {
403390 return Ok(());
404391 };
405392 self.store
406393 .del(&key)
407407- .await
408408- .map_err(|e| ClientError::Transport(TransportError::Other(Box::new(e))))?;
394394+ .await?;
409395 *self.key.write().await = None;
410396 Ok(())
411397 }
···484470#[inline]
485471fn is_expired<R: XrpcResp>(response: &XrpcResult<Response<R>>) -> bool {
486472 match response {
487487- Err(ClientError::Auth(AuthError::TokenExpired)) => true,
473473+ Err(e)
474474+ if matches!(
475475+ e.kind(),
476476+ jacquard_common::error::ClientErrorKind::Auth(AuthError::TokenExpired)
477477+ ) =>
478478+ {
479479+ true
480480+ }
488481 Ok(resp) => match resp.parse() {
489482 Err(XrpcError::Auth(AuthError::TokenExpired)) => true,
490483 _ => false,
···503496 async fn send_http_streaming(
504497 &self,
505498 request: http::Request<Vec<u8>>,
506506- ) -> core::result::Result<http::Response<jacquard_common::stream::ByteStream>, Self::Error> {
499499+ ) -> core::result::Result<http::Response<jacquard_common::stream::ByteStream>, Self::Error>
500500+ {
507501 self.client.send_http_streaming(request).await
508502 }
509503504504+ #[cfg(not(target_arch = "wasm32"))]
510505 async fn send_http_bidirectional<Str>(
511506 &self,
512507 parts: http::request::Parts,
513508 body: Str,
514509 ) -> core::result::Result<http::Response<jacquard_common::stream::ByteStream>, Self::Error>
515510 where
516516- Str: n0_future::Stream<Item = core::result::Result<bytes::Bytes, jacquard_common::StreamError>>
517517- + Send
511511+ Str: n0_future::Stream<
512512+ Item = core::result::Result<bytes::Bytes, jacquard_common::StreamError>,
513513+ > + Send
518514 + 'static,
515515+ {
516516+ self.client.send_http_bidirectional(parts, body).await
517517+ }
518518+519519+ #[cfg(target_arch = "wasm32")]
520520+ async fn send_http_bidirectional<Str>(
521521+ &self,
522522+ parts: http::request::Parts,
523523+ body: Str,
524524+ ) -> core::result::Result<http::Response<jacquard_common::stream::ByteStream>, Self::Error>
525525+ where
526526+ Str: n0_future::Stream<
527527+ Item = core::result::Result<bytes::Bytes, jacquard_common::StreamError>,
528528+ > + 'static,
519529 {
520530 self.client.send_http_bidirectional(parts, body).await
521531 }
···589599 <<Str as jacquard_common::xrpc::streaming::XrpcProcedureStream>::Response as jacquard_common::xrpc::streaming::XrpcStreamResp>::Frame<'static>: jacquard_common::xrpc::streaming::XrpcStreamResp,
590600 {
591601 use jacquard_common::StreamError;
592592- use n0_future::{StreamExt, TryStreamExt};
602602+ use n0_future::TryStreamExt;
593603594604 let base_uri = self.base_uri().await;
595605 let mut opts = self.options.read().await.clone();
···640650 .into_parts();
641651642652 let body_stream =
643643- jacquard_common::stream::ByteStream::new(stream.0.map_ok(|f| f.buffer).boxed());
653653+ jacquard_common::stream::ByteStream::new(Box::pin(stream.0.map_ok(|f| f.buffer)));
644654645655 // Clone the stream for potential retry
646656 let (body1, body2) = body_stream.tee();
···672682 http::HeaderValue::from_str(&format!("DPoP {}", t.as_ref()))
673683 }
674684 }
675675- .map_err(|e| StreamError::protocol(format!("Invalid authorization token: {}", e)))?;
685685+ .map_err(|e| {
686686+ StreamError::protocol(format!("Invalid authorization token: {}", e))
687687+ })?;
676688 builder = builder.header(http::header::AUTHORIZATION, hv);
677689 }
678690 if let Some(proxy) = &opts.atproto_proxy {
···704716 .await
705717 .map_err(StreamError::transport)?;
706718 let (resp_parts, resp_body) = response.into_parts();
707707- Ok(jacquard_common::xrpc::streaming::XrpcResponseStream::from_typed_parts(
708708- resp_parts, resp_body,
709709- ))
719719+ Ok(
720720+ jacquard_common::xrpc::streaming::XrpcResponseStream::from_typed_parts(
721721+ resp_parts, resp_body,
722722+ ),
723723+ )
710724 } else {
711711- Ok(jacquard_common::xrpc::streaming::XrpcResponseStream::from_typed_parts(
712712- resp_parts, resp_body,
713713- ))
725725+ Ok(
726726+ jacquard_common::xrpc::streaming::XrpcResponseStream::from_typed_parts(
727727+ resp_parts, resp_body,
728728+ ),
729729+ )
714730 }
715731 }
716732}
+279
crates/jacquard/src/client/error.rs
···11+use jacquard_common::error::{AuthError, ClientError};
22+use jacquard_common::types::did::Did;
33+use jacquard_common::types::nsid::Nsid;
44+use jacquard_common::types::string::{RecordKey, Rkey};
55+use jacquard_common::xrpc::XrpcError;
66+use jacquard_common::{Data, IntoStatic};
77+use smol_str::SmolStr;
88+99+/// Boxed error type for wrapping arbitrary errors
1010+pub type BoxError = Box<dyn std::error::Error + Send + Sync + 'static>;
1111+1212+/// Error type for Agent convenience methods
1313+#[derive(Debug, thiserror::Error, miette::Diagnostic)]
1414+#[error("{kind}")]
1515+pub struct AgentError {
1616+ #[diagnostic_source]
1717+ kind: AgentErrorKind,
1818+ #[source]
1919+ source: Option<BoxError>,
2020+ #[help]
2121+ help: Option<SmolStr>,
2222+ context: Option<SmolStr>,
2323+ url: Option<SmolStr>,
2424+ details: Option<SmolStr>,
2525+ location: Option<SmolStr>,
2626+ xrpc: Option<Data<'static>>,
2727+}
2828+2929+/// Error categories for Agent operations
3030+#[derive(Debug, thiserror::Error, miette::Diagnostic)]
3131+pub enum AgentErrorKind {
3232+ /// Transport/network layer failure
3333+ #[error("client error")]
3434+ #[diagnostic(code(jacquard::agent::client))]
3535+ Client,
3636+3737+ /// No session available for operations requiring authentication
3838+ #[error("no session available")]
3939+ #[diagnostic(
4040+ code(jacquard::agent::no_session),
4141+ help("ensure agent is authenticated before performing operations")
4242+ )]
4343+ NoSession,
4444+4545+ /// Authentication error from XRPC layer
4646+ #[error("auth error: {0}")]
4747+ #[diagnostic(code(jacquard::agent::auth))]
4848+ Auth(AuthError),
4949+5050+ /// Record operation failed with typed error from endpoint
5151+ #[error("record operation failed on {collection}/{rkey:?} in repo {repo}")]
5252+ #[diagnostic(code(jacquard::agent::record_operation))]
5353+ RecordOperation {
5454+ /// The repository DID
5555+ repo: Did<'static>,
5656+ /// The collection NSID
5757+ collection: Nsid<'static>,
5858+ /// The record key
5959+ rkey: RecordKey<Rkey<'static>>,
6060+ },
6161+6262+ /// Multi-step operation failed at sub-step (e.g., get failed in update_record)
6363+ #[error("operation failed at step '{step}'")]
6464+ #[diagnostic(code(jacquard::agent::sub_operation))]
6565+ SubOperation {
6666+ /// Description of which step failed
6767+ step: &'static str,
6868+ },
6969+ /// XRPC error
7070+ #[error("xrpc error")]
7171+ #[diagnostic(code(jacquard::agent::xrpc))]
7272+ XrpcError,
7373+}
7474+7575+impl AgentError {
7676+ /// Create a new error with the given kind and optional source
7777+ pub fn new(kind: AgentErrorKind, source: Option<BoxError>) -> Self {
7878+ Self {
7979+ kind,
8080+ source,
8181+ help: None,
8282+ context: None,
8383+ url: None,
8484+ details: None,
8585+ location: None,
8686+ xrpc: None,
8787+ }
8888+ }
8989+9090+ /// Get the error kind
9191+ pub fn kind(&self) -> &AgentErrorKind {
9292+ &self.kind
9393+ }
9494+9595+ /// Get the source error if present
9696+ pub fn source_err(&self) -> Option<&BoxError> {
9797+ self.source.as_ref()
9898+ }
9999+100100+ /// Get the context string if present
101101+ pub fn context(&self) -> Option<&str> {
102102+ self.context.as_ref().map(|s| s.as_str())
103103+ }
104104+105105+ /// Get the URL if present
106106+ pub fn url(&self) -> Option<&str> {
107107+ self.url.as_ref().map(|s| s.as_str())
108108+ }
109109+110110+ /// Get the details if present
111111+ pub fn details(&self) -> Option<&str> {
112112+ self.details.as_ref().map(|s| s.as_str())
113113+ }
114114+115115+ /// Get the location if present
116116+ pub fn location(&self) -> Option<&str> {
117117+ self.location.as_ref().map(|s| s.as_str())
118118+ }
119119+120120+ /// Add help text to this error
121121+ pub fn with_help(mut self, help: impl Into<SmolStr>) -> Self {
122122+ self.help = Some(help.into());
123123+ self
124124+ }
125125+126126+ /// Add context to this error
127127+ pub fn with_context(mut self, context: impl Into<SmolStr>) -> Self {
128128+ self.context = Some(context.into());
129129+ self
130130+ }
131131+132132+ /// Add URL to this error
133133+ pub fn with_url(mut self, url: impl Into<SmolStr>) -> Self {
134134+ self.url = Some(url.into());
135135+ self
136136+ }
137137+138138+ /// Add details to this error
139139+ pub fn with_details(mut self, details: impl Into<SmolStr>) -> Self {
140140+ self.details = Some(details.into());
141141+ self
142142+ }
143143+144144+ /// Add location to this error
145145+ pub fn with_location(mut self, location: impl Into<SmolStr>) -> Self {
146146+ self.location = Some(location.into());
147147+ self
148148+ }
149149+150150+ /// Add XRPC error data to this error for observability
151151+ pub fn with_xrpc<E>(mut self, xrpc: XrpcError<E>) -> Self
152152+ where
153153+ E: std::error::Error + jacquard_common::IntoStatic + serde::Serialize,
154154+ {
155155+ use jacquard_common::types::value::to_data;
156156+ // Attempt to serialize XrpcError to Data for observability
157157+ if let Ok(data) = to_data(&xrpc) {
158158+ self.xrpc = Some(data.into_static());
159159+ }
160160+ self
161161+ }
162162+163163+ /// Create an XRPC error with attached error data for observability
164164+ pub fn xrpc<E>(error: XrpcError<E>) -> Self
165165+ where
166166+ E: std::error::Error + jacquard_common::IntoStatic + serde::Serialize + Send + Sync,
167167+ <E as IntoStatic>::Output: IntoStatic + std::error::Error + Send + Sync,
168168+ {
169169+ use jacquard_common::types::value::to_data;
170170+ // Attempt to serialize XrpcError to Data for observability
171171+ if let Ok(data) = to_data(&error) {
172172+ let mut error = Self::new(
173173+ AgentErrorKind::XrpcError,
174174+ Some(Box::new(error.into_static())),
175175+ );
176176+ error.xrpc = Some(data.into_static());
177177+ error
178178+ } else {
179179+ Self::new(
180180+ AgentErrorKind::XrpcError,
181181+ Some(Box::new(error.into_static())),
182182+ )
183183+ }
184184+ }
185185+186186+ // Constructors
187187+188188+ /// Create a no session error
189189+ pub fn no_session() -> Self {
190190+ Self::new(AgentErrorKind::NoSession, None)
191191+ }
192192+193193+ /// Create a sub-operation error for multi-step operations
194194+ pub fn sub_operation(
195195+ step: &'static str,
196196+ source: impl std::error::Error + Send + Sync + 'static,
197197+ ) -> Self {
198198+ Self::new(
199199+ AgentErrorKind::SubOperation { step },
200200+ Some(Box::new(source)),
201201+ )
202202+ }
203203+204204+ /// Create a record operation error
205205+ pub fn record_operation(
206206+ repo: Did<'static>,
207207+ collection: Nsid<'static>,
208208+ rkey: RecordKey<Rkey<'static>>,
209209+ source: impl std::error::Error + Send + Sync + 'static,
210210+ ) -> Self {
211211+ Self::new(
212212+ AgentErrorKind::RecordOperation {
213213+ repo,
214214+ collection,
215215+ rkey,
216216+ },
217217+ Some(Box::new(source)),
218218+ )
219219+ }
220220+221221+ /// Create an authentication error
222222+ pub fn auth(auth_error: AuthError) -> Self {
223223+ Self::new(AgentErrorKind::Auth(auth_error), None)
224224+ }
225225+}
226226+227227+impl From<ClientError> for AgentError {
228228+ fn from(e: ClientError) -> Self {
229229+ Self::new(AgentErrorKind::Client, Some(Box::new(e)))
230230+ }
231231+}
232232+233233+impl From<AuthError> for AgentError {
234234+ fn from(e: AuthError) -> Self {
235235+ Self::new(AgentErrorKind::Auth(e), None)
236236+ .with_help("check authentication credentials and session state")
237237+ }
238238+}
239239+240240+/// Result type for Agent operations
241241+pub type Result<T> = core::result::Result<T, AgentError>;
242242+243243+impl IntoStatic for AgentError {
244244+ type Output = AgentError;
245245+246246+ fn into_static(self) -> Self::Output {
247247+ match self.kind {
248248+ AgentErrorKind::RecordOperation {
249249+ repo,
250250+ collection,
251251+ rkey,
252252+ } => Self {
253253+ kind: AgentErrorKind::RecordOperation {
254254+ repo: repo.into_static(),
255255+ collection: collection.into_static(),
256256+ rkey: rkey.into_static(),
257257+ },
258258+ source: self.source,
259259+ help: self.help,
260260+ context: self.context,
261261+ url: self.url,
262262+ details: self.details,
263263+ location: self.location,
264264+ xrpc: self.xrpc,
265265+ },
266266+ AgentErrorKind::Auth(auth) => Self {
267267+ kind: AgentErrorKind::Auth(auth.into_static()),
268268+ source: self.source,
269269+ help: self.help,
270270+ context: self.context,
271271+ url: self.url,
272272+ details: self.details,
273273+ location: self.location,
274274+ xrpc: self.xrpc,
275275+ },
276276+ _ => self,
277277+ }
278278+ }
279279+}
+3-3
crates/jacquard/src/moderation.rs
···22//!
33//! This is an attempt to semi-generalize the Bluesky moderation system. It avoids
44//! depending on their lexicons as much as reasonably possible. This works via a
55-//! trait, [`Labeled`], which represents things that have labels for moderation
55+//! trait, [`Labeled`][crate::moderation::Labeled], which represents things that have labels for moderation
66//! applied to them. This way the moderation application functions can operate
77//! primarily via the trait, and are thus generic over lexicon types, and are
88//! easy to use with your own types.
99//!
1010//! For more complex types which might have labels applied to components,
1111-//! there is the [`Moderateable`] trait. A mostly complete implementation for
1111+//! there is the [`Moderateable`][crate::moderation::Moderateable] trait. A mostly complete implementation for
1212//! `FeedViewPost` is available for reference. The trait method outputs a `Vec`
1313//! of tuples, where the first element is a string tag and the second is the
1414//! moderation decision for the tagged element. This lets application developers
···1616//! mostly match Bluesky behaviour (respecting "!hide", and such) by default.
1717//!
1818//! I've taken the time to go through the generated API bindings and implement
1919-//! the [`Labeled`] trait for a number of types. It's a fairly easy trait to
1919+//! the [`Labeled`][crate::moderation::Labeled] trait for a number of types. It's a fairly easy trait to
2020//! implement, just not really automatable.
2121//!
2222//!
+11-14
crates/jacquard/src/moderation/fetch.rs
···99};
1010use jacquard_api::com_atproto::label::{Label, query_labels::QueryLabels};
1111use jacquard_common::cowstr::ToCowStr;
1212-use jacquard_common::error::{ClientError, TransportError};
1212+use jacquard_common::error::ClientError;
1313use jacquard_common::types::collection::Collection;
1414use jacquard_common::types::string::Did;
1515use jacquard_common::types::uri::RecordUri;
···30303131 let response = client.send(request).await?;
3232 let output: GetServicesOutput<'static> = response.into_output().map_err(|e| match e {
3333- XrpcError::Auth(auth) => ClientError::Auth(auth),
3434- XrpcError::Generic(g) => {
3535- ClientError::Transport(TransportError::Other(g.to_string().into()))
3636- }
3737- XrpcError::Decode(e) => ClientError::Decode(e),
3838- XrpcError::Xrpc(typed) => {
3939- ClientError::Transport(TransportError::Other(format!("{:?}", typed).into()))
4040- }
3333+ XrpcError::Auth(auth) => ClientError::auth(auth),
3434+ XrpcError::Generic(g) => ClientError::decode(g.to_string()),
3535+ XrpcError::Decode(e) => ClientError::decode(format!("{:?}", e)),
3636+ XrpcError::Xrpc(typed) => ClientError::decode(format!("{:?}", typed)),
4137 })?;
42384339 let mut defs = LabelerDefs::new();
···8177pub async fn fetch_labeler_defs_direct(
8278 client: &(impl AgentSessionExt + Sync),
8379 dids: Vec<Did<'_>>,
8484-) -> Result<LabelerDefs<'static>, ClientError> {
8080+) -> Result<LabelerDefs<'static>, AgentError> {
8581 #[cfg(feature = "tracing")]
8682 let _span = tracing::debug_span!("fetch_labeler_defs_direct", count = dids.len()).entered();
8783···9086 for did in dids {
9187 let uri = format!("at://{}/app.bsky.labeler.service/self", did.as_str());
9288 let record_uri = Service::uri(uri).map_err(|e| {
9393- ClientError::Transport(TransportError::Other(format!("Invalid URI: {}", e).into()))
8989+ AgentError::from(ClientError::invalid_request(format!("Invalid URI: {}", e)))
9490 })?;
95919692 let output = client.fetch_record(&record_uri).await?;
···135131 .await?
136132 .into_output()
137133 .map_err(|e| match e {
138138- XrpcError::Generic(e) => AgentError::Generic(e),
139139- _ => unimplemented!(), // We know the error at this point is always GenericXrpcError
134134+ XrpcError::Auth(auth) => AgentError::from(auth),
135135+ e @ (XrpcError::Generic(_) | XrpcError::Decode(_)) => AgentError::xrpc(e),
136136+ XrpcError::Xrpc(typed) => AgentError::xrpc(XrpcError::Xrpc(typed)),
140137 })?;
141138 Ok((labels.labels, labels.cursor))
142139}
···157154where
158155 R: Collection + From<CollectionOutput<'static, R>>,
159156 for<'a> CollectionOutput<'a, R>: IntoStatic<Output = CollectionOutput<'static, R>>,
160160- for<'a> CollectionErr<'a, R>: IntoStatic<Output = CollectionErr<'static, R>>,
157157+ for<'a> CollectionErr<'a, R>: IntoStatic<Output = CollectionErr<'static, R>> + Send + Sync,
161158{
162159 let record: R = client.fetch_record(record_uri).await?.into();
163160 let (labels, _) =