A better Rust ATProto crate

credential session and agent primitive

Orual 5ac10ae3 278eb8f2

Changed files
+616 -167
crates
+22 -31
README.md
··· 18 18 19 19 ## Example 20 20 21 - Dead simple api client. Logs in, prints the latest 5 posts from your timeline. 21 + Dead simple API client. Logs in with an app password and prints the latest 5 posts from your timeline. 22 22 23 23 ```rust 24 + use std::sync::Arc; 24 25 use clap::Parser; 25 26 use jacquard::CowStr; 26 27 use jacquard::api::app_bsky::feed::get_timeline::GetTimeline; 27 - use jacquard::api::com_atproto::server::create_session::CreateSession; 28 - use jacquard::client::{BasicClient, Session}; 28 + use jacquard::client::credential_session::{CredentialSession, SessionKey}; 29 + use jacquard::client::{AtpSession, FileAuthStore, MemorySessionStore}; 30 + use jacquard::identity::PublicResolver as JacquardResolver; 29 31 use miette::IntoDiagnostic; 30 32 31 33 #[derive(Parser, Debug)] 32 34 #[command(author, version, about = "Jacquard - AT Protocol client demo")] 33 35 struct Args { 34 - /// Username/handle (e.g., alice.mosphere.at) 36 + /// Username/handle (e.g., alice.bsky.social) or DID 35 37 #[arg(short, long)] 36 38 username: CowStr<'static>, 37 - 38 - /// PDS URL (e.g., https://bsky.social) 39 - #[arg(long, default_value = "https://bsky.social")] 40 - pds: CowStr<'static>, 41 - 42 39 /// App password 43 40 #[arg(short, long)] 44 41 password: CowStr<'static>, ··· 48 45 async fn main() -> miette::Result<()> { 49 46 let args = Args::parse(); 50 47 51 - // Create HTTP client 52 - let base = url::Url::parse(&args.pds).into_diagnostic()?; 53 - let client = BasicClient::new(base); 48 + // Resolver + storage 49 + let resolver = Arc::new(JacquardResolver::default()); 50 + let store: Arc<MemorySessionStore<SessionKey, AtpSession>> = Arc::new(Default::default()); 51 + let client = Arc::new(resolver.clone()); 52 + let session = CredentialSession::new(store, client); 54 53 55 - // Create session 56 - let session = Session::from( 57 - client 58 - .send( 59 - CreateSession::new() 60 - .identifier(args.username) 61 - .password(args.password) 62 - .build(), 63 - ) 64 - .await? 65 - .into_output()?, 66 - ); 67 - 68 - println!("logged in as {} ({})", session.handle, session.did); 69 - client.set_session(session).await.into_diagnostic()?; 54 + // Login (resolves PDS automatically) and persist as (did, "session") 55 + session 56 + .login(args.username.clone(), args.password.clone(), None, None, None) 57 + .await 58 + .into_diagnostic()?; 70 59 71 60 // Fetch timeline 72 - println!("\nfetching timeline..."); 73 - let timeline = client 61 + let timeline = session 62 + .clone() 74 63 .send(GetTimeline::new().limit(5).build()) 75 - .await? 76 - .into_output()?; 64 + .await 65 + .into_diagnostic()? 66 + .into_output() 67 + .into_diagnostic()?; 77 68 78 69 println!("\ntimeline ({} posts):", timeline.feed.len()); 79 70 for (i, post) in timeline.feed.iter().enumerate() {
+174 -44
crates/jacquard/src/client.rs
··· 6 6 pub mod credential_session; 7 7 pub mod token; 8 8 9 + use core::future::Future; 10 + 11 + use jacquard_common::AuthorizationToken; 12 + use jacquard_common::error::TransportError; 9 13 pub use jacquard_common::error::{ClientError, XrpcResult}; 10 14 pub use jacquard_common::session::{MemorySessionStore, SessionStore, SessionStoreError}; 15 + use jacquard_common::types::xrpc::{CallOptions, Response, XrpcClient, XrpcRequest}; 11 16 use jacquard_common::{ 12 17 CowStr, IntoStatic, 13 18 types::string::{Did, Handle}, 14 19 }; 20 + use jacquard_common::{http_client::HttpClient, types::xrpc::XrpcExt}; 21 + use jacquard_identity::resolver::IdentityResolver; 22 + use jacquard_oauth::authstore::ClientAuthStore; 23 + use jacquard_oauth::client::OAuthSession; 24 + use jacquard_oauth::dpop::DpopExt; 25 + use jacquard_oauth::resolver::OAuthResolver; 15 26 pub use token::FileAuthStore; 27 + 28 + use crate::client::credential_session::{CredentialSession, SessionKey}; 16 29 17 30 pub(crate) const NSID_REFRESH_SESSION: &str = "com.atproto.server.refreshSession"; 18 31 ··· 64 77 } 65 78 } 66 79 67 - #[derive(Debug, Clone)] 68 - pub enum AuthSession { 69 - AppPassword(AtpSession), 70 - OAuth(jacquard_oauth::session::ClientSessionData<'static>), 80 + /// A unified indicator for the type of authenticated session. 81 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 82 + pub enum AgentKind { 83 + /// App password (Bearer) session 84 + AppPassword, 85 + /// OAuth (DPoP) session 86 + OAuth, 87 + } 88 + 89 + /// Common interface for stateful sessions used by the Agent wrapper. 90 + pub trait AgentSession: XrpcClient + HttpClient + Send + Sync { 91 + /// Identify the kind of session. 92 + fn session_kind(&self) -> AgentKind; 93 + /// Return current DID and an optional session id (always Some for OAuth). 94 + fn session_info( 95 + &self, 96 + ) -> core::pin::Pin< 97 + Box<dyn Future<Output = Option<(Did<'static>, Option<CowStr<'static>>)>> + Send + '_>, 98 + >; 99 + /// Current base endpoint. 100 + fn endpoint(&self) -> core::pin::Pin<Box<dyn Future<Output = url::Url> + Send + '_>>; 101 + /// Override per-session call options. 102 + fn set_options<'a>( 103 + &'a self, 104 + opts: CallOptions<'a>, 105 + ) -> core::pin::Pin<Box<dyn Future<Output = ()> + Send + 'a>>; 106 + /// Refresh the session and return a fresh AuthorizationToken. 107 + fn refresh( 108 + &self, 109 + ) -> core::pin::Pin< 110 + Box<dyn Future<Output = Result<AuthorizationToken<'static>, ClientError>> + Send + '_>, 111 + >; 71 112 } 72 113 73 - impl AuthSession { 74 - pub fn did(&self) -> &Did<'static> { 75 - match self { 76 - AuthSession::AppPassword(session) => &session.did, 77 - AuthSession::OAuth(session) => &session.token_set.sub, 78 - } 114 + impl<S, T> AgentSession for CredentialSession<S, T> 115 + where 116 + S: SessionStore<SessionKey, AtpSession> + Send + Sync + 'static, 117 + T: IdentityResolver + HttpClient + XrpcExt + Send + Sync + 'static, 118 + { 119 + fn session_kind(&self) -> AgentKind { 120 + AgentKind::AppPassword 121 + } 122 + fn session_info( 123 + &self, 124 + ) -> core::pin::Pin< 125 + Box<dyn Future<Output = Option<(Did<'static>, Option<CowStr<'static>>)>> + Send + '_>, 126 + > { 127 + Box::pin(async move { 128 + CredentialSession::<S, T>::session_info(self) 129 + .await 130 + .map(|(did, sid)| (did, Some(sid))) 131 + }) 132 + } 133 + fn endpoint(&self) -> core::pin::Pin<Box<dyn Future<Output = url::Url> + Send + '_>> { 134 + Box::pin(async move { CredentialSession::<S, T>::endpoint(self).await }) 135 + } 136 + fn set_options<'a>( 137 + &'a self, 138 + opts: CallOptions<'a>, 139 + ) -> core::pin::Pin<Box<dyn Future<Output = ()> + Send + 'a>> { 140 + Box::pin(async move { CredentialSession::<S, T>::set_options(self, opts).await }) 79 141 } 142 + fn refresh( 143 + &self, 144 + ) -> core::pin::Pin< 145 + Box<dyn Future<Output = Result<AuthorizationToken<'static>, ClientError>> + Send + '_>, 146 + > { 147 + Box::pin(async move { 148 + Ok(CredentialSession::<S, T>::refresh(self) 149 + .await? 150 + .into_static()) 151 + }) 152 + } 153 + } 80 154 81 - pub fn refresh_token(&self) -> Option<&CowStr<'static>> { 82 - match self { 83 - AuthSession::AppPassword(session) => Some(&session.refresh_jwt), 84 - AuthSession::OAuth(session) => session.token_set.refresh_token.as_ref(), 85 - } 155 + impl<T, S> AgentSession for OAuthSession<T, S> 156 + where 157 + S: ClientAuthStore + Send + Sync + 'static, 158 + T: OAuthResolver + DpopExt + XrpcExt + Send + Sync + 'static, 159 + { 160 + fn session_kind(&self) -> AgentKind { 161 + AgentKind::OAuth 86 162 } 163 + fn session_info( 164 + &self, 165 + ) -> core::pin::Pin< 166 + Box<dyn Future<Output = Option<(Did<'static>, Option<CowStr<'static>>)>> + Send + '_>, 167 + > { 168 + Box::pin(async move { 169 + let (did, sid) = OAuthSession::<T, S>::session_info(self).await; 170 + Some((did.into_static(), Some(sid.into_static()))) 171 + }) 172 + } 173 + fn endpoint(&self) -> core::pin::Pin<Box<dyn Future<Output = url::Url> + Send + '_>> { 174 + Box::pin(async move { self.endpoint().await }) 175 + } 176 + fn set_options<'a>( 177 + &'a self, 178 + opts: CallOptions<'a>, 179 + ) -> core::pin::Pin<Box<dyn Future<Output = ()> + Send + 'a>> { 180 + Box::pin(async move { self.set_options(opts).await }) 181 + } 182 + fn refresh( 183 + &self, 184 + ) -> core::pin::Pin< 185 + Box<dyn Future<Output = Result<AuthorizationToken<'static>, ClientError>> + Send + '_>, 186 + > { 187 + Box::pin(async move { 188 + self.refresh() 189 + .await 190 + .map(|t| t.into_static()) 191 + .map_err(|e| ClientError::Transport(TransportError::Other(Box::new(e)))) 192 + }) 193 + } 194 + } 87 195 88 - pub fn access_token(&self) -> &CowStr<'static> { 89 - match self { 90 - AuthSession::AppPassword(session) => &session.access_jwt, 91 - AuthSession::OAuth(session) => &session.token_set.access_token, 92 - } 196 + /// Thin wrapper that erases the concrete session type while preserving type-safety. 197 + pub struct Agent<A: AgentSession> { 198 + inner: A, 199 + } 200 + 201 + impl<A: AgentSession> Agent<A> { 202 + /// Wrap an existing session in an Agent. 203 + pub fn new(inner: A) -> Self { 204 + Self { inner } 205 + } 206 + 207 + /// Return the underlying session kind. 208 + pub fn kind(&self) -> AgentKind { 209 + self.inner.session_kind() 210 + } 211 + 212 + /// Return session info if available. 213 + pub async fn info(&self) -> Option<(Did<'static>, Option<CowStr<'static>>)> { 214 + self.inner.session_info().await 215 + } 216 + 217 + /// Get current endpoint. 218 + pub async fn endpoint(&self) -> url::Url { 219 + self.inner.endpoint().await 93 220 } 94 221 95 - pub fn set_refresh_token(&mut self, token: CowStr<'_>) { 96 - match self { 97 - AuthSession::AppPassword(session) => { 98 - session.refresh_jwt = token.into_static(); 99 - } 100 - AuthSession::OAuth(session) => { 101 - session.token_set.refresh_token = Some(token.into_static()); 102 - } 103 - } 222 + /// Override call options. 223 + pub async fn set_options(&self, opts: CallOptions<'_>) { 224 + self.inner.set_options(opts).await 104 225 } 105 226 106 - pub fn set_access_token(&mut self, token: CowStr<'_>) { 107 - match self { 108 - AuthSession::AppPassword(session) => { 109 - session.access_jwt = token.into_static(); 110 - } 111 - AuthSession::OAuth(session) => { 112 - session.token_set.access_token = token.into_static(); 113 - } 114 - } 227 + /// Refresh the session and return a fresh token. 228 + pub async fn refresh(&self) -> Result<AuthorizationToken<'static>, ClientError> { 229 + self.inner.refresh().await 115 230 } 116 231 } 117 232 118 - impl From<AtpSession> for AuthSession { 119 - fn from(session: AtpSession) -> Self { 120 - AuthSession::AppPassword(session) 233 + impl<A: AgentSession> HttpClient for Agent<A> { 234 + type Error = <A as HttpClient>::Error; 235 + 236 + fn send_http( 237 + &self, 238 + request: http::Request<Vec<u8>>, 239 + ) -> impl Future<Output = core::result::Result<http::Response<Vec<u8>>, Self::Error>> + Send 240 + { 241 + self.inner.send_http(request) 121 242 } 122 243 } 123 244 124 - impl From<jacquard_oauth::session::ClientSessionData<'static>> for AuthSession { 125 - fn from(session: jacquard_oauth::session::ClientSessionData<'static>) -> Self { 126 - AuthSession::OAuth(session) 245 + impl<A: AgentSession> XrpcClient for Agent<A> { 246 + fn base_uri(&self) -> url::Url { 247 + self.inner.base_uri() 248 + } 249 + fn opts(&self) -> impl Future<Output = CallOptions<'_>> { 250 + self.inner.opts() 251 + } 252 + fn send<R: XrpcRequest + Send>( 253 + self, 254 + request: &R, 255 + ) -> impl Future<Output = XrpcResult<Response<R>>> { 256 + async move { self.inner.send(request).await } 127 257 } 128 258 }
+237 -1
crates/jacquard/src/client/credential_session.rs
··· 14 14 use tokio::sync::RwLock; 15 15 use url::Url; 16 16 17 - use crate::client::{AtpSession, token::StoredSession}; 17 + use crate::client::AtpSession; 18 + use jacquard_identity::resolver::IdentityResolver; 19 + use std::any::Any; 18 20 19 21 pub type SessionKey = (Did<'static>, CowStr<'static>); 20 22 ··· 120 122 .map_err(|_| ClientError::Auth(jacquard_common::error::AuthError::RefreshFailed))?; 121 123 122 124 Ok(token) 125 + } 126 + } 127 + 128 + impl<S, T> CredentialSession<S, T> 129 + where 130 + S: SessionStore<SessionKey, AtpSession>, 131 + T: HttpClient + IdentityResolver + XrpcExt, 132 + { 133 + /// Resolve the user's PDS and create an app-password session. 134 + /// 135 + /// - `identifier`: handle (preferred), DID, or `https://` PDS base URL. 136 + /// - `session_id`: optional session label; defaults to "session". 137 + pub async fn login( 138 + &self, 139 + identifier: CowStr<'_>, 140 + password: CowStr<'_>, 141 + session_id: Option<CowStr<'_>>, 142 + allow_takendown: Option<bool>, 143 + auth_factor_token: Option<CowStr<'_>>, 144 + ) -> Result<AtpSession, ClientError> 145 + where 146 + S: Any + 'static, 147 + { 148 + // Resolve PDS base 149 + let pds = if identifier.as_ref().starts_with("http://") 150 + || identifier.as_ref().starts_with("https://") 151 + { 152 + Url::parse(identifier.as_ref()).map_err(|e| { 153 + ClientError::Transport(jacquard_common::error::TransportError::InvalidRequest( 154 + e.to_string(), 155 + )) 156 + })? 157 + } else if identifier.as_ref().starts_with("did:") { 158 + let did = Did::new(identifier.as_ref()).map_err(|e| { 159 + ClientError::Transport(jacquard_common::error::TransportError::InvalidRequest( 160 + format!("invalid did: {:?}", e), 161 + )) 162 + })?; 163 + let resp = self.client.resolve_did_doc(&did).await.map_err(|e| { 164 + ClientError::Transport(jacquard_common::error::TransportError::Other(Box::new(e))) 165 + })?; 166 + resp.into_owned() 167 + .map_err(|e| { 168 + ClientError::Transport(jacquard_common::error::TransportError::Other(Box::new( 169 + e, 170 + ))) 171 + })? 172 + .pds_endpoint() 173 + .ok_or_else(|| { 174 + ClientError::Transport(jacquard_common::error::TransportError::InvalidRequest( 175 + "missing PDS endpoint".into(), 176 + )) 177 + })? 178 + } else { 179 + // treat as handle 180 + let handle = 181 + jacquard_common::types::string::Handle::new(identifier.as_ref()).map_err(|e| { 182 + ClientError::Transport(jacquard_common::error::TransportError::InvalidRequest( 183 + format!("invalid handle: {:?}", e), 184 + )) 185 + })?; 186 + let did = self.client.resolve_handle(&handle).await.map_err(|e| { 187 + ClientError::Transport(jacquard_common::error::TransportError::Other(Box::new(e))) 188 + })?; 189 + let resp = self.client.resolve_did_doc(&did).await.map_err(|e| { 190 + ClientError::Transport(jacquard_common::error::TransportError::Other(Box::new(e))) 191 + })?; 192 + resp.into_owned() 193 + .map_err(|e| { 194 + ClientError::Transport(jacquard_common::error::TransportError::Other(Box::new( 195 + e, 196 + ))) 197 + })? 198 + .pds_endpoint() 199 + .ok_or_else(|| { 200 + ClientError::Transport(jacquard_common::error::TransportError::InvalidRequest( 201 + "missing PDS endpoint".into(), 202 + )) 203 + })? 204 + }; 205 + 206 + // Build and send createSession 207 + use std::collections::BTreeMap; 208 + let req = jacquard_api::com_atproto::server::create_session::CreateSession { 209 + allow_takendown, 210 + auth_factor_token, 211 + identifier: identifier.clone().into_static(), 212 + password: password.into_static(), 213 + extra_data: BTreeMap::new(), 214 + }; 215 + 216 + let resp = self 217 + .client 218 + .xrpc(pds.clone()) 219 + .with_options(self.options.read().await.clone()) 220 + .send(&req) 221 + .await?; 222 + let out = resp 223 + .into_output() 224 + .map_err(|_| ClientError::Auth(AuthError::NotAuthenticated))?; 225 + let session = AtpSession::from(out); 226 + 227 + let sid = session_id.unwrap_or_else(|| CowStr::new_static("session")); 228 + let key = (session.did.clone(), sid.into_static()); 229 + self.store 230 + .set(key.clone(), session.clone()) 231 + .await 232 + .map_err(|e| { 233 + ClientError::Transport(jacquard_common::error::TransportError::Other(Box::new(e))) 234 + })?; 235 + // If using FileAuthStore, persist PDS for faster resume 236 + if let Some(file_store) = 237 + (&*self.store as &dyn Any).downcast_ref::<crate::client::token::FileAuthStore>() 238 + { 239 + let _ = file_store.set_atp_pds(&key, &pds); 240 + } 241 + // Activate 242 + *self.key.write().await = Some(key); 243 + *self.endpoint.write().await = Some(pds); 244 + 245 + Ok(session) 246 + } 247 + 248 + /// Restore a previously persisted app-password session and set base endpoint. 249 + pub async fn restore(&self, did: Did<'_>, session_id: CowStr<'_>) -> Result<(), ClientError> 250 + where 251 + S: Any + 'static, 252 + { 253 + let key = (did.clone().into_static(), session_id.clone().into_static()); 254 + let Some(sess) = self.store.get(&key).await else { 255 + return Err(ClientError::Auth(AuthError::NotAuthenticated)); 256 + }; 257 + // Try to read cached PDS; otherwise resolve via DID 258 + let pds = if let Some(file_store) = 259 + (&*self.store as &dyn Any).downcast_ref::<crate::client::token::FileAuthStore>() 260 + { 261 + file_store.get_atp_pds(&key).ok().flatten().or_else(|| None) 262 + } else { 263 + None 264 + } 265 + .unwrap_or({ 266 + let resp = self.client.resolve_did_doc(&did).await.map_err(|e| { 267 + ClientError::Transport(jacquard_common::error::TransportError::Other(Box::new(e))) 268 + })?; 269 + resp.into_owned() 270 + .map_err(|e| { 271 + ClientError::Transport(jacquard_common::error::TransportError::Other(Box::new( 272 + e, 273 + ))) 274 + })? 275 + .pds_endpoint() 276 + .ok_or_else(|| { 277 + ClientError::Transport(jacquard_common::error::TransportError::InvalidRequest( 278 + "missing PDS endpoint".into(), 279 + )) 280 + })? 281 + }); 282 + 283 + // Activate 284 + *self.key.write().await = Some(key.clone()); 285 + *self.endpoint.write().await = Some(pds); 286 + // ensure store has the session (no-op if it existed) 287 + self.store 288 + .set((sess.did.clone(), session_id.into_static()), sess) 289 + .await 290 + .map_err(|e| { 291 + ClientError::Transport(jacquard_common::error::TransportError::Other(Box::new(e))) 292 + })?; 293 + if let Some(file_store) = 294 + (&*self.store as &dyn Any).downcast_ref::<crate::client::token::FileAuthStore>() 295 + { 296 + let _ = file_store.set_atp_pds(&key, &self.endpoint().await); 297 + } 298 + Ok(()) 299 + } 300 + 301 + /// Switch to a different stored session (and refresh endpoint from DID). 302 + pub async fn switch_session( 303 + &self, 304 + did: Did<'_>, 305 + session_id: CowStr<'_>, 306 + ) -> Result<(), ClientError> 307 + where 308 + S: Any + 'static, 309 + { 310 + let key = (did.clone().into_static(), session_id.into_static()); 311 + if self.store.get(&key).await.is_none() { 312 + return Err(ClientError::Auth(AuthError::NotAuthenticated)); 313 + } 314 + // Endpoint from store if cached, else resolve 315 + let pds = if let Some(file_store) = 316 + (&*self.store as &dyn Any).downcast_ref::<crate::client::token::FileAuthStore>() 317 + { 318 + file_store.get_atp_pds(&key).ok().flatten().or_else(|| None) 319 + } else { 320 + None 321 + } 322 + .unwrap_or({ 323 + let resp = self.client.resolve_did_doc(&did).await.map_err(|e| { 324 + ClientError::Transport(jacquard_common::error::TransportError::Other(Box::new(e))) 325 + })?; 326 + resp.into_owned() 327 + .map_err(|e| { 328 + ClientError::Transport(jacquard_common::error::TransportError::Other(Box::new( 329 + e, 330 + ))) 331 + })? 332 + .pds_endpoint() 333 + .ok_or_else(|| { 334 + ClientError::Transport(jacquard_common::error::TransportError::InvalidRequest( 335 + "missing PDS endpoint".into(), 336 + )) 337 + })? 338 + }); 339 + *self.key.write().await = Some(key.clone()); 340 + *self.endpoint.write().await = Some(pds); 341 + if let Some(file_store) = 342 + (&*self.store as &dyn Any).downcast_ref::<crate::client::token::FileAuthStore>() 343 + { 344 + let _ = file_store.set_atp_pds(&key, &self.endpoint().await); 345 + } 346 + Ok(()) 347 + } 348 + 349 + /// Clear and delete the current session from the store. 350 + pub async fn logout(&self) -> Result<(), ClientError> { 351 + let Some(key) = self.key.read().await.clone() else { 352 + return Ok(()); 353 + }; 354 + self.store.del(&key).await.map_err(|e| { 355 + ClientError::Transport(jacquard_common::error::TransportError::Other(Box::new(e))) 356 + })?; 357 + *self.key.write().await = None; 358 + Ok(()) 123 359 } 124 360 } 125 361
+114 -1
crates/jacquard/src/client/token.rs
··· 22 22 access_jwt: String, 23 23 refresh_jwt: String, 24 24 did: String, 25 - pds: String, 25 + #[serde(skip_serializing_if = "std::option::Option::is_none")] 26 + pds: Option<String>, 26 27 session_id: String, 27 28 handle: String, 28 29 } ··· 212 213 213 214 pub struct FileAuthStore(FileTokenStore); 214 215 216 + impl FileAuthStore { 217 + /// Create a new file-backed auth store wrapping `FileTokenStore`. 218 + pub fn new(path: impl AsRef<std::path::Path>) -> Self { 219 + Self(FileTokenStore::new(path)) 220 + } 221 + } 222 + 215 223 #[async_trait::async_trait] 216 224 impl jacquard_oauth::authstore::ClientAuthStore for FileAuthStore { 217 225 async fn get_session( ··· 305 313 } 306 314 } 307 315 } 316 + 317 + impl FileAuthStore { 318 + /// Update the persisted PDS endpoint for an app-password session (best-effort). 319 + pub fn set_atp_pds( 320 + &self, 321 + key: &crate::client::credential_session::SessionKey, 322 + pds: &Url, 323 + ) -> Result<(), SessionStoreError> { 324 + let key_str = format!("{}_{}", key.0, key.1); 325 + let file = std::fs::read_to_string(&self.0.path)?; 326 + let mut store: Value = serde_json::from_str(&file)?; 327 + if let Some(map) = store.as_object_mut() { 328 + if let Some(value) = map.get_mut(&key_str) { 329 + if let Some(obj) = value.as_object_mut() { 330 + obj.insert( 331 + "pds".to_string(), 332 + serde_json::Value::String(pds.to_string()), 333 + ); 334 + std::fs::write(&self.0.path, serde_json::to_string_pretty(&store)?)?; 335 + return Ok(()); 336 + } 337 + } 338 + } 339 + Err(SessionStoreError::Other("invalid store".into())) 340 + } 341 + 342 + /// Read the persisted PDS endpoint for an app-password session, if present. 343 + pub fn get_atp_pds( 344 + &self, 345 + key: &crate::client::credential_session::SessionKey, 346 + ) -> Result<Option<Url>, SessionStoreError> { 347 + let key_str = format!("{}_{}", key.0, key.1); 348 + let file = std::fs::read_to_string(&self.0.path)?; 349 + let store: Value = serde_json::from_str(&file)?; 350 + if let Some(value) = store.get(&key_str) { 351 + if let Some(obj) = value.as_object() { 352 + if let Some(serde_json::Value::String(pds)) = obj.get("pds") { 353 + return Ok(Url::parse(pds).ok()); 354 + } 355 + } 356 + } 357 + Ok(None) 358 + } 359 + } 360 + 361 + #[async_trait::async_trait] 362 + impl jacquard_common::session::SessionStore< 363 + crate::client::credential_session::SessionKey, 364 + crate::client::AtpSession, 365 + > for FileAuthStore 366 + { 367 + async fn get( 368 + &self, 369 + key: &crate::client::credential_session::SessionKey, 370 + ) -> Option<crate::client::AtpSession> { 371 + let key_str = format!("{}_{}", key.0, key.1); 372 + if let Some(StoredSession::Atp(stored)) = self.0.get(&key_str).await { 373 + Some(crate::client::AtpSession { 374 + access_jwt: stored.access_jwt.into(), 375 + refresh_jwt: stored.refresh_jwt.into(), 376 + did: stored.did.into(), 377 + handle: stored.handle.into(), 378 + }) 379 + } else { 380 + None 381 + } 382 + } 383 + 384 + async fn set( 385 + &self, 386 + key: crate::client::credential_session::SessionKey, 387 + session: crate::client::AtpSession, 388 + ) -> Result<(), jacquard_common::session::SessionStoreError> { 389 + let key_str = format!("{}_{}", key.0, key.1); 390 + let stored = StoredAtSession { 391 + access_jwt: session.access_jwt.to_string(), 392 + refresh_jwt: session.refresh_jwt.to_string(), 393 + did: session.did.to_string(), 394 + // pds endpoint is resolved on restore; do not persist 395 + pds: None, 396 + session_id: key.1.to_string(), 397 + handle: session.handle.to_string(), 398 + }; 399 + self.0.set(key_str, StoredSession::Atp(stored)).await 400 + } 401 + 402 + async fn del( 403 + &self, 404 + key: &crate::client::credential_session::SessionKey, 405 + ) -> Result<(), jacquard_common::session::SessionStoreError> { 406 + let key_str = format!("{}_{}", key.0, key.1); 407 + // Manual removal to mirror existing pattern 408 + let file = std::fs::read_to_string(&self.0.path)?; 409 + let mut store: serde_json::Value = serde_json::from_str(&file)?; 410 + if let Some(map) = store.as_object_mut() { 411 + map.remove(&key_str); 412 + std::fs::write(&self.0.path, serde_json::to_string_pretty(&store)?)?; 413 + Ok(()) 414 + } else { 415 + Err(jacquard_common::session::SessionStoreError::Other( 416 + "invalid store".into(), 417 + )) 418 + } 419 + } 420 + }
+38 -46
crates/jacquard/src/lib.rs
··· 17 17 //! 18 18 //! ## Example 19 19 //! 20 - //! Dead simple api client. Logs in, prints the latest 5 posts from your timeline. 20 + //! Dead simple API client: login with an app password, then fetch the latest 5 posts. 21 21 //! 22 22 //! ```no_run 23 23 //! # use clap::Parser; 24 24 //! # use jacquard::CowStr; 25 + //! use std::sync::Arc; 25 26 //! use jacquard::api::app_bsky::feed::get_timeline::GetTimeline; 26 - //! use jacquard::api::com_atproto::server::create_session::CreateSession; 27 - //! use jacquard::client::{BasicClient, AuthSession, AtpSession}; 27 + //! use jacquard::client::credential_session::{CredentialSession, SessionKey}; 28 + //! use jacquard::client::{AtpSession, FileAuthStore, MemorySessionStore}; 29 + //! use jacquard::identity::PublicResolver as JacquardResolver; 28 30 //! # use miette::IntoDiagnostic; 29 31 //! 30 32 //! # #[derive(Parser, Debug)] 31 33 //! # #[command(author, version, about = "Jacquard - AT Protocol client demo")] 32 34 //! # struct Args { 33 - //! # /// Username/handle (e.g., alice.mosphere.at) 35 + //! # /// Username/handle (e.g., alice.bsky.social) or DID 34 36 //! # #[arg(short, long)] 35 37 //! # username: CowStr<'static>, 36 38 //! # 37 - //! # /// PDS URL (e.g., https://bsky.social) 38 - //! # #[arg(long, default_value = "https://bsky.social")] 39 - //! # pds: CowStr<'static>, 40 - //! # 41 39 //! # /// App password 42 40 //! # #[arg(short, long)] 43 41 //! # password: CowStr<'static>, ··· 46 44 //! #[tokio::main] 47 45 //! async fn main() -> miette::Result<()> { 48 46 //! let args = Args::parse(); 49 - //! // Create HTTP client 50 - //! let url = url::Url::parse(&args.pds).unwrap(); 51 - //! let client = BasicClient::new(url); 52 - //! // Create session 53 - //! let session = AtpSession::from( 54 - //! client 55 - //! .send( 56 - //! CreateSession::new() 57 - //! .identifier(args.username) 58 - //! .password(args.password) 59 - //! .build(), 60 - //! ) 61 - //! .await? 62 - //! .into_output()?, 63 - //! ); 64 - //! client.set_session(session).await.unwrap(); 47 + //! // Resolver + storage 48 + //! let resolver = Arc::new(JacquardResolver::default()); 49 + //! let store: Arc<MemorySessionStore<SessionKey, AtpSession>> = Arc::new(Default::default()); 50 + //! let client = Arc::new(resolver.clone()); 51 + //! // Create session object with implicit public appview endpoint until login/restore 52 + //! let session = CredentialSession::new(store, client); 53 + //! // Log in (resolves PDS automatically) and persist as (did, "session") 54 + //! session 55 + //! .login(args.username.clone(), args.password.clone(), None, None, None) 56 + //! .await 57 + //! .into_diagnostic()?; 65 58 //! // Fetch timeline 66 - //! println!("\nfetching timeline..."); 67 - //! let timeline = client 59 + //! let timeline = session 60 + //! .clone() 68 61 //! .send(GetTimeline::new().limit(5).build()) 69 - //! .await? 70 - //! .into_output()?; 71 - //! println!("\ntimeline ({} posts):", timeline.feed.len()); 62 + //! .await 63 + //! .into_diagnostic()? 64 + //! .into_output() 65 + //! .into_diagnostic()?; 66 + //! println!("timeline ({} posts):", timeline.feed.len()); 72 67 //! for (i, post) in timeline.feed.iter().enumerate() { 73 - //! println!("\n{}. by {}", i + 1, post.post.author.handle); 74 - //! println!( 75 - //! " {}", 76 - //! serde_json::to_string_pretty(&post.post.record).into_diagnostic()? 77 - //! ); 68 + //! println!("{}. by {}", i + 1, post.post.author.handle); 78 69 //! } 79 70 //! Ok(()) 80 71 //! } ··· 110 101 //! Ok(()) 111 102 //! } 112 103 //! ``` 113 - //! - Stateful client: `AtClient<C, S>` holds a base `Url`, a transport, and a 114 - //! `SessionStore<AuthSession>` implementation. It automatically sets Authorization and can 115 - //! auto-refresh a session when expired, retrying once. 116 - //! - Convenience wrapper: `BasicClient` is an ergonomic newtype over 117 - //! `AtClient<reqwest::Client, MemorySessionStore<AuthSession>>` with a `new(Url)` constructor. 104 + //! - Stateful client (app-password): `CredentialSession<S, T>` where `S: SessionStore<(Did, CowStr), AtpSession>` and 105 + //! `T: IdentityResolver + HttpClient + XrpcExt`. It auto-attaches Authorization, refreshes on expiry, and updates the 106 + //! base endpoint to the user's PDS on login/restore. 118 107 //! 119 108 //! Per-request overrides (stateless) 120 109 //! ```no_run ··· 148 137 //! ``` 149 138 //! 150 139 //! Token storage: 151 - //! - Use `MemorySessionStore<AuthSession>` for ephemeral sessions, tests, and CLIs. 152 - //! - For persistence, `FileTokenStore` stores app-password sessions as JSON on disk. 153 - //! See `client::token::FileTokenStore` docs for details. 140 + //! - Use `MemorySessionStore<SessionKey, AtpSession>` for ephemeral sessions and tests. 141 + //! - For persistence, wrap the file store: `FileAuthStore::new(path)` implements SessionStore for app-password sessions 142 + //! and OAuth `ClientAuthStore` (unified on-disk map). 154 143 //! ```no_run 155 - //! use jacquard::client::{AtClient, FileTokenStore}; 156 - //! let base = url::Url::parse("https://bsky.social").unwrap(); 157 - //! let store = FileTokenStore::new("/tmp/jacquard-session.json"); 158 - //! let client = AtClient::new(reqwest::Client::new(), base, store); 144 + //! use std::sync::Arc; 145 + //! use jacquard::client::credential_session::{CredentialSession, SessionKey}; 146 + //! use jacquard::client::{AtpSession, FileAuthStore}; 147 + //! use jacquard::identity::PublicResolver; 148 + //! let store = Arc::new(FileAuthStore::new("/tmp/jacquard-session.json")); 149 + //! let client = Arc::new(PublicResolver::default()); 150 + //! let session = CredentialSession::new(store, client); 159 151 //! ``` 160 152 //! 161 153
+31 -44
crates/jacquard/src/main.rs
··· 1 + use std::sync::Arc; 1 2 use clap::Parser; 2 3 use jacquard::CowStr; 3 4 use jacquard::api::app_bsky::feed::get_timeline::GetTimeline; 4 - use jacquard::api::com_atproto::server::create_session::CreateSession; 5 - use jacquard::client::{AtpSession, BasicClient}; 6 - use jacquard::identity::resolver::IdentityResolver; 7 - use jacquard::identity::slingshot_resolver_default; 8 - use jacquard::types::string::Handle; 5 + use jacquard::client::credential_session::{CredentialSession, SessionKey}; 6 + use jacquard::client::{AtpSession, MemorySessionStore}; 7 + use jacquard::identity::PublicResolver as JacquardResolver; 8 + use jacquard::types::xrpc::XrpcClient; 9 9 use miette::IntoDiagnostic; 10 10 11 11 #[derive(Parser, Debug)] 12 12 #[command(author, version, about = "Jacquard - AT Protocol client demo")] 13 13 struct Args { 14 - /// Username/handle (e.g., alice.bsky.social) 14 + /// Username/handle (e.g., alice.bsky.social) or DID 15 15 #[arg(short, long)] 16 16 username: CowStr<'static>, 17 17 18 - /// PDS URL (e.g., https://bsky.social) 19 - #[arg(long, default_value = "https://bsky.social")] 20 - pds: CowStr<'static>, 21 - 22 18 /// App password 23 19 #[arg(short, long)] 24 20 password: CowStr<'static>, ··· 27 23 async fn main() -> miette::Result<()> { 28 24 let args = Args::parse(); 29 25 30 - // Resolve PDS for the handle using the Slingshot-enabled resolver 31 - let resolver = slingshot_resolver_default(); 32 - let handle = Handle::new(args.username.as_ref()).into_diagnostic()?; 33 - let (_did, pds_url) = resolver.pds_for_handle(&handle).await.into_diagnostic()?; 34 - // let client = BasicClient::new(pds_url); 26 + // Resolver + in-memory store 27 + let resolver = Arc::new(JacquardResolver::default()); 28 + let store: Arc<MemorySessionStore<SessionKey, AtpSession>> = Arc::new(Default::default()); 29 + let client = Arc::new(resolver.clone()); 30 + let session = CredentialSession::new(store, client); 35 31 36 - // // Create session 37 - // let session = AtpSession::from( 38 - // client 39 - // .send( 40 - // CreateSession::new() 41 - // .identifier(args.username) 42 - // .password(args.password) 43 - // .build(), 44 - // ) 45 - // .await? 46 - // .into_output()?, 47 - // ); 32 + // Login; resolves PDS from handle/DID automatically. Persisted under (did, "session"). 33 + let _ = session 34 + .login(args.username.clone(), args.password.clone(), None, None, None) 35 + .await 36 + .into_diagnostic()?; 48 37 49 - // println!("logged in as {} ({})", session.handle, session.did); 50 - // client.set_session(session.into()).await.into_diagnostic()?; 38 + // Fetch timeline 39 + let timeline = session 40 + .send(&GetTimeline::new().limit(5).build()) 41 + .await 42 + .into_diagnostic()? 43 + .into_output() 44 + .into_diagnostic()?; 51 45 52 - // // Fetch timeline 53 - // println!("\nfetching timeline..."); 54 - // let timeline = client 55 - // .send(GetTimeline::new().limit(5).build()) 56 - // .await? 57 - // .into_output()?; 58 - 59 - // println!("\ntimeline ({} posts):", timeline.feed.len()); 60 - // for (i, post) in timeline.feed.iter().enumerate() { 61 - // println!("\n{}. by {}", i + 1, post.post.author.handle); 62 - // println!( 63 - // " {}", 64 - // serde_json::to_string_pretty(&post.post.record).into_diagnostic()? 65 - // ); 66 - // } 46 + println!("\ntimeline ({} posts):", timeline.feed.len()); 47 + for (i, post) in timeline.feed.iter().enumerate() { 48 + println!("\n{}. by {}", i + 1, post.post.author.handle); 49 + println!( 50 + " {}", 51 + serde_json::to_string_pretty(&post.post.record).into_diagnostic()? 52 + ); 53 + } 67 54 68 55 Ok(()) 69 56 }