A better Rust ATProto crate

credential session and agent primitive

Orual 5ac10ae3 278eb8f2

Changed files
+616 -167
crates
+22 -31
README.md
···
## Example
-
Dead simple api client. Logs in, prints the latest 5 posts from your timeline.
+
Dead simple API client. Logs in with an app password and prints the latest 5 posts from your timeline.
```rust
+
use std::sync::Arc;
use clap::Parser;
use jacquard::CowStr;
use jacquard::api::app_bsky::feed::get_timeline::GetTimeline;
-
use jacquard::api::com_atproto::server::create_session::CreateSession;
-
use jacquard::client::{BasicClient, Session};
+
use jacquard::client::credential_session::{CredentialSession, SessionKey};
+
use jacquard::client::{AtpSession, FileAuthStore, MemorySessionStore};
+
use jacquard::identity::PublicResolver as JacquardResolver;
use miette::IntoDiagnostic;
#[derive(Parser, Debug)]
#[command(author, version, about = "Jacquard - AT Protocol client demo")]
struct Args {
-
/// Username/handle (e.g., alice.mosphere.at)
+
/// Username/handle (e.g., alice.bsky.social) or DID
#[arg(short, long)]
username: CowStr<'static>,
-
-
/// PDS URL (e.g., https://bsky.social)
-
#[arg(long, default_value = "https://bsky.social")]
-
pds: CowStr<'static>,
-
/// App password
#[arg(short, long)]
password: CowStr<'static>,
···
async fn main() -> miette::Result<()> {
let args = Args::parse();
-
// Create HTTP client
-
let base = url::Url::parse(&args.pds).into_diagnostic()?;
-
let client = BasicClient::new(base);
+
// Resolver + storage
+
let resolver = Arc::new(JacquardResolver::default());
+
let store: Arc<MemorySessionStore<SessionKey, AtpSession>> = Arc::new(Default::default());
+
let client = Arc::new(resolver.clone());
+
let session = CredentialSession::new(store, client);
-
// Create session
-
let session = Session::from(
-
client
-
.send(
-
CreateSession::new()
-
.identifier(args.username)
-
.password(args.password)
-
.build(),
-
)
-
.await?
-
.into_output()?,
-
);
-
-
println!("logged in as {} ({})", session.handle, session.did);
-
client.set_session(session).await.into_diagnostic()?;
+
// Login (resolves PDS automatically) and persist as (did, "session")
+
session
+
.login(args.username.clone(), args.password.clone(), None, None, None)
+
.await
+
.into_diagnostic()?;
// Fetch timeline
-
println!("\nfetching timeline...");
-
let timeline = client
+
let timeline = session
+
.clone()
.send(GetTimeline::new().limit(5).build())
-
.await?
-
.into_output()?;
+
.await
+
.into_diagnostic()?
+
.into_output()
+
.into_diagnostic()?;
println!("\ntimeline ({} posts):", timeline.feed.len());
for (i, post) in timeline.feed.iter().enumerate() {
+174 -44
crates/jacquard/src/client.rs
···
pub mod credential_session;
pub mod token;
+
use core::future::Future;
+
+
use jacquard_common::AuthorizationToken;
+
use jacquard_common::error::TransportError;
pub use jacquard_common::error::{ClientError, XrpcResult};
pub use jacquard_common::session::{MemorySessionStore, SessionStore, SessionStoreError};
+
use jacquard_common::types::xrpc::{CallOptions, Response, XrpcClient, XrpcRequest};
use jacquard_common::{
CowStr, IntoStatic,
types::string::{Did, Handle},
};
+
use jacquard_common::{http_client::HttpClient, types::xrpc::XrpcExt};
+
use jacquard_identity::resolver::IdentityResolver;
+
use jacquard_oauth::authstore::ClientAuthStore;
+
use jacquard_oauth::client::OAuthSession;
+
use jacquard_oauth::dpop::DpopExt;
+
use jacquard_oauth::resolver::OAuthResolver;
pub use token::FileAuthStore;
+
+
use crate::client::credential_session::{CredentialSession, SessionKey};
pub(crate) const NSID_REFRESH_SESSION: &str = "com.atproto.server.refreshSession";
···
}
}
-
#[derive(Debug, Clone)]
-
pub enum AuthSession {
-
AppPassword(AtpSession),
-
OAuth(jacquard_oauth::session::ClientSessionData<'static>),
+
/// A unified indicator for the type of authenticated session.
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+
pub enum AgentKind {
+
/// App password (Bearer) session
+
AppPassword,
+
/// OAuth (DPoP) session
+
OAuth,
+
}
+
+
/// Common interface for stateful sessions used by the Agent wrapper.
+
pub trait AgentSession: XrpcClient + HttpClient + Send + Sync {
+
/// Identify the kind of session.
+
fn session_kind(&self) -> AgentKind;
+
/// Return current DID and an optional session id (always Some for OAuth).
+
fn session_info(
+
&self,
+
) -> core::pin::Pin<
+
Box<dyn Future<Output = Option<(Did<'static>, Option<CowStr<'static>>)>> + Send + '_>,
+
>;
+
/// Current base endpoint.
+
fn endpoint(&self) -> core::pin::Pin<Box<dyn Future<Output = url::Url> + Send + '_>>;
+
/// Override per-session call options.
+
fn set_options<'a>(
+
&'a self,
+
opts: CallOptions<'a>,
+
) -> core::pin::Pin<Box<dyn Future<Output = ()> + Send + 'a>>;
+
/// Refresh the session and return a fresh AuthorizationToken.
+
fn refresh(
+
&self,
+
) -> core::pin::Pin<
+
Box<dyn Future<Output = Result<AuthorizationToken<'static>, ClientError>> + Send + '_>,
+
>;
}
-
impl AuthSession {
-
pub fn did(&self) -> &Did<'static> {
-
match self {
-
AuthSession::AppPassword(session) => &session.did,
-
AuthSession::OAuth(session) => &session.token_set.sub,
-
}
+
impl<S, T> AgentSession for CredentialSession<S, T>
+
where
+
S: SessionStore<SessionKey, AtpSession> + Send + Sync + 'static,
+
T: IdentityResolver + HttpClient + XrpcExt + Send + Sync + 'static,
+
{
+
fn session_kind(&self) -> AgentKind {
+
AgentKind::AppPassword
+
}
+
fn session_info(
+
&self,
+
) -> core::pin::Pin<
+
Box<dyn Future<Output = Option<(Did<'static>, Option<CowStr<'static>>)>> + Send + '_>,
+
> {
+
Box::pin(async move {
+
CredentialSession::<S, T>::session_info(self)
+
.await
+
.map(|(did, sid)| (did, Some(sid)))
+
})
+
}
+
fn endpoint(&self) -> core::pin::Pin<Box<dyn Future<Output = url::Url> + Send + '_>> {
+
Box::pin(async move { CredentialSession::<S, T>::endpoint(self).await })
+
}
+
fn set_options<'a>(
+
&'a self,
+
opts: CallOptions<'a>,
+
) -> core::pin::Pin<Box<dyn Future<Output = ()> + Send + 'a>> {
+
Box::pin(async move { CredentialSession::<S, T>::set_options(self, opts).await })
}
+
fn refresh(
+
&self,
+
) -> core::pin::Pin<
+
Box<dyn Future<Output = Result<AuthorizationToken<'static>, ClientError>> + Send + '_>,
+
> {
+
Box::pin(async move {
+
Ok(CredentialSession::<S, T>::refresh(self)
+
.await?
+
.into_static())
+
})
+
}
+
}
-
pub fn refresh_token(&self) -> Option<&CowStr<'static>> {
-
match self {
-
AuthSession::AppPassword(session) => Some(&session.refresh_jwt),
-
AuthSession::OAuth(session) => session.token_set.refresh_token.as_ref(),
-
}
+
impl<T, S> AgentSession for OAuthSession<T, S>
+
where
+
S: ClientAuthStore + Send + Sync + 'static,
+
T: OAuthResolver + DpopExt + XrpcExt + Send + Sync + 'static,
+
{
+
fn session_kind(&self) -> AgentKind {
+
AgentKind::OAuth
}
+
fn session_info(
+
&self,
+
) -> core::pin::Pin<
+
Box<dyn Future<Output = Option<(Did<'static>, Option<CowStr<'static>>)>> + Send + '_>,
+
> {
+
Box::pin(async move {
+
let (did, sid) = OAuthSession::<T, S>::session_info(self).await;
+
Some((did.into_static(), Some(sid.into_static())))
+
})
+
}
+
fn endpoint(&self) -> core::pin::Pin<Box<dyn Future<Output = url::Url> + Send + '_>> {
+
Box::pin(async move { self.endpoint().await })
+
}
+
fn set_options<'a>(
+
&'a self,
+
opts: CallOptions<'a>,
+
) -> core::pin::Pin<Box<dyn Future<Output = ()> + Send + 'a>> {
+
Box::pin(async move { self.set_options(opts).await })
+
}
+
fn refresh(
+
&self,
+
) -> core::pin::Pin<
+
Box<dyn Future<Output = Result<AuthorizationToken<'static>, ClientError>> + Send + '_>,
+
> {
+
Box::pin(async move {
+
self.refresh()
+
.await
+
.map(|t| t.into_static())
+
.map_err(|e| ClientError::Transport(TransportError::Other(Box::new(e))))
+
})
+
}
+
}
-
pub fn access_token(&self) -> &CowStr<'static> {
-
match self {
-
AuthSession::AppPassword(session) => &session.access_jwt,
-
AuthSession::OAuth(session) => &session.token_set.access_token,
-
}
+
/// Thin wrapper that erases the concrete session type while preserving type-safety.
+
pub struct Agent<A: AgentSession> {
+
inner: A,
+
}
+
+
impl<A: AgentSession> Agent<A> {
+
/// Wrap an existing session in an Agent.
+
pub fn new(inner: A) -> Self {
+
Self { inner }
+
}
+
+
/// Return the underlying session kind.
+
pub fn kind(&self) -> AgentKind {
+
self.inner.session_kind()
+
}
+
+
/// Return session info if available.
+
pub async fn info(&self) -> Option<(Did<'static>, Option<CowStr<'static>>)> {
+
self.inner.session_info().await
+
}
+
+
/// Get current endpoint.
+
pub async fn endpoint(&self) -> url::Url {
+
self.inner.endpoint().await
}
-
pub fn set_refresh_token(&mut self, token: CowStr<'_>) {
-
match self {
-
AuthSession::AppPassword(session) => {
-
session.refresh_jwt = token.into_static();
-
}
-
AuthSession::OAuth(session) => {
-
session.token_set.refresh_token = Some(token.into_static());
-
}
-
}
+
/// Override call options.
+
pub async fn set_options(&self, opts: CallOptions<'_>) {
+
self.inner.set_options(opts).await
}
-
pub fn set_access_token(&mut self, token: CowStr<'_>) {
-
match self {
-
AuthSession::AppPassword(session) => {
-
session.access_jwt = token.into_static();
-
}
-
AuthSession::OAuth(session) => {
-
session.token_set.access_token = token.into_static();
-
}
-
}
+
/// Refresh the session and return a fresh token.
+
pub async fn refresh(&self) -> Result<AuthorizationToken<'static>, ClientError> {
+
self.inner.refresh().await
}
}
-
impl From<AtpSession> for AuthSession {
-
fn from(session: AtpSession) -> Self {
-
AuthSession::AppPassword(session)
+
impl<A: AgentSession> HttpClient for Agent<A> {
+
type Error = <A as HttpClient>::Error;
+
+
fn send_http(
+
&self,
+
request: http::Request<Vec<u8>>,
+
) -> impl Future<Output = core::result::Result<http::Response<Vec<u8>>, Self::Error>> + Send
+
{
+
self.inner.send_http(request)
}
}
-
impl From<jacquard_oauth::session::ClientSessionData<'static>> for AuthSession {
-
fn from(session: jacquard_oauth::session::ClientSessionData<'static>) -> Self {
-
AuthSession::OAuth(session)
+
impl<A: AgentSession> XrpcClient for Agent<A> {
+
fn base_uri(&self) -> url::Url {
+
self.inner.base_uri()
+
}
+
fn opts(&self) -> impl Future<Output = CallOptions<'_>> {
+
self.inner.opts()
+
}
+
fn send<R: XrpcRequest + Send>(
+
self,
+
request: &R,
+
) -> impl Future<Output = XrpcResult<Response<R>>> {
+
async move { self.inner.send(request).await }
}
}
+237 -1
crates/jacquard/src/client/credential_session.rs
···
use tokio::sync::RwLock;
use url::Url;
-
use crate::client::{AtpSession, token::StoredSession};
+
use crate::client::AtpSession;
+
use jacquard_identity::resolver::IdentityResolver;
+
use std::any::Any;
pub type SessionKey = (Did<'static>, CowStr<'static>);
···
.map_err(|_| ClientError::Auth(jacquard_common::error::AuthError::RefreshFailed))?;
Ok(token)
+
}
+
}
+
+
impl<S, T> CredentialSession<S, T>
+
where
+
S: SessionStore<SessionKey, AtpSession>,
+
T: HttpClient + IdentityResolver + XrpcExt,
+
{
+
/// Resolve the user's PDS and create an app-password session.
+
///
+
/// - `identifier`: handle (preferred), DID, or `https://` PDS base URL.
+
/// - `session_id`: optional session label; defaults to "session".
+
pub async fn login(
+
&self,
+
identifier: CowStr<'_>,
+
password: CowStr<'_>,
+
session_id: Option<CowStr<'_>>,
+
allow_takendown: Option<bool>,
+
auth_factor_token: Option<CowStr<'_>>,
+
) -> Result<AtpSession, ClientError>
+
where
+
S: Any + 'static,
+
{
+
// Resolve PDS base
+
let pds = if identifier.as_ref().starts_with("http://")
+
|| identifier.as_ref().starts_with("https://")
+
{
+
Url::parse(identifier.as_ref()).map_err(|e| {
+
ClientError::Transport(jacquard_common::error::TransportError::InvalidRequest(
+
e.to_string(),
+
))
+
})?
+
} else if identifier.as_ref().starts_with("did:") {
+
let did = Did::new(identifier.as_ref()).map_err(|e| {
+
ClientError::Transport(jacquard_common::error::TransportError::InvalidRequest(
+
format!("invalid did: {:?}", e),
+
))
+
})?;
+
let resp = self.client.resolve_did_doc(&did).await.map_err(|e| {
+
ClientError::Transport(jacquard_common::error::TransportError::Other(Box::new(e)))
+
})?;
+
resp.into_owned()
+
.map_err(|e| {
+
ClientError::Transport(jacquard_common::error::TransportError::Other(Box::new(
+
e,
+
)))
+
})?
+
.pds_endpoint()
+
.ok_or_else(|| {
+
ClientError::Transport(jacquard_common::error::TransportError::InvalidRequest(
+
"missing PDS endpoint".into(),
+
))
+
})?
+
} else {
+
// treat as handle
+
let handle =
+
jacquard_common::types::string::Handle::new(identifier.as_ref()).map_err(|e| {
+
ClientError::Transport(jacquard_common::error::TransportError::InvalidRequest(
+
format!("invalid handle: {:?}", e),
+
))
+
})?;
+
let did = self.client.resolve_handle(&handle).await.map_err(|e| {
+
ClientError::Transport(jacquard_common::error::TransportError::Other(Box::new(e)))
+
})?;
+
let resp = self.client.resolve_did_doc(&did).await.map_err(|e| {
+
ClientError::Transport(jacquard_common::error::TransportError::Other(Box::new(e)))
+
})?;
+
resp.into_owned()
+
.map_err(|e| {
+
ClientError::Transport(jacquard_common::error::TransportError::Other(Box::new(
+
e,
+
)))
+
})?
+
.pds_endpoint()
+
.ok_or_else(|| {
+
ClientError::Transport(jacquard_common::error::TransportError::InvalidRequest(
+
"missing PDS endpoint".into(),
+
))
+
})?
+
};
+
+
// Build and send createSession
+
use std::collections::BTreeMap;
+
let req = jacquard_api::com_atproto::server::create_session::CreateSession {
+
allow_takendown,
+
auth_factor_token,
+
identifier: identifier.clone().into_static(),
+
password: password.into_static(),
+
extra_data: BTreeMap::new(),
+
};
+
+
let resp = self
+
.client
+
.xrpc(pds.clone())
+
.with_options(self.options.read().await.clone())
+
.send(&req)
+
.await?;
+
let out = resp
+
.into_output()
+
.map_err(|_| ClientError::Auth(AuthError::NotAuthenticated))?;
+
let session = AtpSession::from(out);
+
+
let sid = session_id.unwrap_or_else(|| CowStr::new_static("session"));
+
let key = (session.did.clone(), sid.into_static());
+
self.store
+
.set(key.clone(), session.clone())
+
.await
+
.map_err(|e| {
+
ClientError::Transport(jacquard_common::error::TransportError::Other(Box::new(e)))
+
})?;
+
// If using FileAuthStore, persist PDS for faster resume
+
if let Some(file_store) =
+
(&*self.store as &dyn Any).downcast_ref::<crate::client::token::FileAuthStore>()
+
{
+
let _ = file_store.set_atp_pds(&key, &pds);
+
}
+
// Activate
+
*self.key.write().await = Some(key);
+
*self.endpoint.write().await = Some(pds);
+
+
Ok(session)
+
}
+
+
/// Restore a previously persisted app-password session and set base endpoint.
+
pub async fn restore(&self, did: Did<'_>, session_id: CowStr<'_>) -> Result<(), ClientError>
+
where
+
S: Any + 'static,
+
{
+
let key = (did.clone().into_static(), session_id.clone().into_static());
+
let Some(sess) = self.store.get(&key).await else {
+
return Err(ClientError::Auth(AuthError::NotAuthenticated));
+
};
+
// Try to read cached PDS; otherwise resolve via DID
+
let pds = if let Some(file_store) =
+
(&*self.store as &dyn Any).downcast_ref::<crate::client::token::FileAuthStore>()
+
{
+
file_store.get_atp_pds(&key).ok().flatten().or_else(|| None)
+
} else {
+
None
+
}
+
.unwrap_or({
+
let resp = self.client.resolve_did_doc(&did).await.map_err(|e| {
+
ClientError::Transport(jacquard_common::error::TransportError::Other(Box::new(e)))
+
})?;
+
resp.into_owned()
+
.map_err(|e| {
+
ClientError::Transport(jacquard_common::error::TransportError::Other(Box::new(
+
e,
+
)))
+
})?
+
.pds_endpoint()
+
.ok_or_else(|| {
+
ClientError::Transport(jacquard_common::error::TransportError::InvalidRequest(
+
"missing PDS endpoint".into(),
+
))
+
})?
+
});
+
+
// Activate
+
*self.key.write().await = Some(key.clone());
+
*self.endpoint.write().await = Some(pds);
+
// ensure store has the session (no-op if it existed)
+
self.store
+
.set((sess.did.clone(), session_id.into_static()), sess)
+
.await
+
.map_err(|e| {
+
ClientError::Transport(jacquard_common::error::TransportError::Other(Box::new(e)))
+
})?;
+
if let Some(file_store) =
+
(&*self.store as &dyn Any).downcast_ref::<crate::client::token::FileAuthStore>()
+
{
+
let _ = file_store.set_atp_pds(&key, &self.endpoint().await);
+
}
+
Ok(())
+
}
+
+
/// Switch to a different stored session (and refresh endpoint from DID).
+
pub async fn switch_session(
+
&self,
+
did: Did<'_>,
+
session_id: CowStr<'_>,
+
) -> Result<(), ClientError>
+
where
+
S: Any + 'static,
+
{
+
let key = (did.clone().into_static(), session_id.into_static());
+
if self.store.get(&key).await.is_none() {
+
return Err(ClientError::Auth(AuthError::NotAuthenticated));
+
}
+
// Endpoint from store if cached, else resolve
+
let pds = if let Some(file_store) =
+
(&*self.store as &dyn Any).downcast_ref::<crate::client::token::FileAuthStore>()
+
{
+
file_store.get_atp_pds(&key).ok().flatten().or_else(|| None)
+
} else {
+
None
+
}
+
.unwrap_or({
+
let resp = self.client.resolve_did_doc(&did).await.map_err(|e| {
+
ClientError::Transport(jacquard_common::error::TransportError::Other(Box::new(e)))
+
})?;
+
resp.into_owned()
+
.map_err(|e| {
+
ClientError::Transport(jacquard_common::error::TransportError::Other(Box::new(
+
e,
+
)))
+
})?
+
.pds_endpoint()
+
.ok_or_else(|| {
+
ClientError::Transport(jacquard_common::error::TransportError::InvalidRequest(
+
"missing PDS endpoint".into(),
+
))
+
})?
+
});
+
*self.key.write().await = Some(key.clone());
+
*self.endpoint.write().await = Some(pds);
+
if let Some(file_store) =
+
(&*self.store as &dyn Any).downcast_ref::<crate::client::token::FileAuthStore>()
+
{
+
let _ = file_store.set_atp_pds(&key, &self.endpoint().await);
+
}
+
Ok(())
+
}
+
+
/// Clear and delete the current session from the store.
+
pub async fn logout(&self) -> Result<(), ClientError> {
+
let Some(key) = self.key.read().await.clone() else {
+
return Ok(());
+
};
+
self.store.del(&key).await.map_err(|e| {
+
ClientError::Transport(jacquard_common::error::TransportError::Other(Box::new(e)))
+
})?;
+
*self.key.write().await = None;
+
Ok(())
}
}
+114 -1
crates/jacquard/src/client/token.rs
···
access_jwt: String,
refresh_jwt: String,
did: String,
-
pds: String,
+
#[serde(skip_serializing_if = "std::option::Option::is_none")]
+
pds: Option<String>,
session_id: String,
handle: String,
}
···
pub struct FileAuthStore(FileTokenStore);
+
impl FileAuthStore {
+
/// Create a new file-backed auth store wrapping `FileTokenStore`.
+
pub fn new(path: impl AsRef<std::path::Path>) -> Self {
+
Self(FileTokenStore::new(path))
+
}
+
}
+
#[async_trait::async_trait]
impl jacquard_oauth::authstore::ClientAuthStore for FileAuthStore {
async fn get_session(
···
}
}
}
+
+
impl FileAuthStore {
+
/// Update the persisted PDS endpoint for an app-password session (best-effort).
+
pub fn set_atp_pds(
+
&self,
+
key: &crate::client::credential_session::SessionKey,
+
pds: &Url,
+
) -> Result<(), SessionStoreError> {
+
let key_str = format!("{}_{}", key.0, key.1);
+
let file = std::fs::read_to_string(&self.0.path)?;
+
let mut store: Value = serde_json::from_str(&file)?;
+
if let Some(map) = store.as_object_mut() {
+
if let Some(value) = map.get_mut(&key_str) {
+
if let Some(obj) = value.as_object_mut() {
+
obj.insert(
+
"pds".to_string(),
+
serde_json::Value::String(pds.to_string()),
+
);
+
std::fs::write(&self.0.path, serde_json::to_string_pretty(&store)?)?;
+
return Ok(());
+
}
+
}
+
}
+
Err(SessionStoreError::Other("invalid store".into()))
+
}
+
+
/// Read the persisted PDS endpoint for an app-password session, if present.
+
pub fn get_atp_pds(
+
&self,
+
key: &crate::client::credential_session::SessionKey,
+
) -> Result<Option<Url>, SessionStoreError> {
+
let key_str = format!("{}_{}", key.0, key.1);
+
let file = std::fs::read_to_string(&self.0.path)?;
+
let store: Value = serde_json::from_str(&file)?;
+
if let Some(value) = store.get(&key_str) {
+
if let Some(obj) = value.as_object() {
+
if let Some(serde_json::Value::String(pds)) = obj.get("pds") {
+
return Ok(Url::parse(pds).ok());
+
}
+
}
+
}
+
Ok(None)
+
}
+
}
+
+
#[async_trait::async_trait]
+
impl jacquard_common::session::SessionStore<
+
crate::client::credential_session::SessionKey,
+
crate::client::AtpSession,
+
> for FileAuthStore
+
{
+
async fn get(
+
&self,
+
key: &crate::client::credential_session::SessionKey,
+
) -> Option<crate::client::AtpSession> {
+
let key_str = format!("{}_{}", key.0, key.1);
+
if let Some(StoredSession::Atp(stored)) = self.0.get(&key_str).await {
+
Some(crate::client::AtpSession {
+
access_jwt: stored.access_jwt.into(),
+
refresh_jwt: stored.refresh_jwt.into(),
+
did: stored.did.into(),
+
handle: stored.handle.into(),
+
})
+
} else {
+
None
+
}
+
}
+
+
async fn set(
+
&self,
+
key: crate::client::credential_session::SessionKey,
+
session: crate::client::AtpSession,
+
) -> Result<(), jacquard_common::session::SessionStoreError> {
+
let key_str = format!("{}_{}", key.0, key.1);
+
let stored = StoredAtSession {
+
access_jwt: session.access_jwt.to_string(),
+
refresh_jwt: session.refresh_jwt.to_string(),
+
did: session.did.to_string(),
+
// pds endpoint is resolved on restore; do not persist
+
pds: None,
+
session_id: key.1.to_string(),
+
handle: session.handle.to_string(),
+
};
+
self.0.set(key_str, StoredSession::Atp(stored)).await
+
}
+
+
async fn del(
+
&self,
+
key: &crate::client::credential_session::SessionKey,
+
) -> Result<(), jacquard_common::session::SessionStoreError> {
+
let key_str = format!("{}_{}", key.0, key.1);
+
// Manual removal to mirror existing pattern
+
let file = std::fs::read_to_string(&self.0.path)?;
+
let mut store: serde_json::Value = serde_json::from_str(&file)?;
+
if let Some(map) = store.as_object_mut() {
+
map.remove(&key_str);
+
std::fs::write(&self.0.path, serde_json::to_string_pretty(&store)?)?;
+
Ok(())
+
} else {
+
Err(jacquard_common::session::SessionStoreError::Other(
+
"invalid store".into(),
+
))
+
}
+
}
+
}
+38 -46
crates/jacquard/src/lib.rs
···
//!
//! ## Example
//!
-
//! Dead simple api client. Logs in, prints the latest 5 posts from your timeline.
+
//! Dead simple API client: login with an app password, then fetch the latest 5 posts.
//!
//! ```no_run
//! # use clap::Parser;
//! # use jacquard::CowStr;
+
//! use std::sync::Arc;
//! use jacquard::api::app_bsky::feed::get_timeline::GetTimeline;
-
//! use jacquard::api::com_atproto::server::create_session::CreateSession;
-
//! use jacquard::client::{BasicClient, AuthSession, AtpSession};
+
//! use jacquard::client::credential_session::{CredentialSession, SessionKey};
+
//! use jacquard::client::{AtpSession, FileAuthStore, MemorySessionStore};
+
//! use jacquard::identity::PublicResolver as JacquardResolver;
//! # use miette::IntoDiagnostic;
//!
//! # #[derive(Parser, Debug)]
//! # #[command(author, version, about = "Jacquard - AT Protocol client demo")]
//! # struct Args {
-
//! # /// Username/handle (e.g., alice.mosphere.at)
+
//! # /// Username/handle (e.g., alice.bsky.social) or DID
//! # #[arg(short, long)]
//! # username: CowStr<'static>,
//! #
-
//! # /// PDS URL (e.g., https://bsky.social)
-
//! # #[arg(long, default_value = "https://bsky.social")]
-
//! # pds: CowStr<'static>,
-
//! #
//! # /// App password
//! # #[arg(short, long)]
//! # password: CowStr<'static>,
···
//! #[tokio::main]
//! async fn main() -> miette::Result<()> {
//! let args = Args::parse();
-
//! // Create HTTP client
-
//! let url = url::Url::parse(&args.pds).unwrap();
-
//! let client = BasicClient::new(url);
-
//! // Create session
-
//! let session = AtpSession::from(
-
//! client
-
//! .send(
-
//! CreateSession::new()
-
//! .identifier(args.username)
-
//! .password(args.password)
-
//! .build(),
-
//! )
-
//! .await?
-
//! .into_output()?,
-
//! );
-
//! client.set_session(session).await.unwrap();
+
//! // Resolver + storage
+
//! let resolver = Arc::new(JacquardResolver::default());
+
//! let store: Arc<MemorySessionStore<SessionKey, AtpSession>> = Arc::new(Default::default());
+
//! let client = Arc::new(resolver.clone());
+
//! // Create session object with implicit public appview endpoint until login/restore
+
//! let session = CredentialSession::new(store, client);
+
//! // Log in (resolves PDS automatically) and persist as (did, "session")
+
//! session
+
//! .login(args.username.clone(), args.password.clone(), None, None, None)
+
//! .await
+
//! .into_diagnostic()?;
//! // Fetch timeline
-
//! println!("\nfetching timeline...");
-
//! let timeline = client
+
//! let timeline = session
+
//! .clone()
//! .send(GetTimeline::new().limit(5).build())
-
//! .await?
-
//! .into_output()?;
-
//! println!("\ntimeline ({} posts):", timeline.feed.len());
+
//! .await
+
//! .into_diagnostic()?
+
//! .into_output()
+
//! .into_diagnostic()?;
+
//! println!("timeline ({} posts):", timeline.feed.len());
//! for (i, post) in timeline.feed.iter().enumerate() {
-
//! println!("\n{}. by {}", i + 1, post.post.author.handle);
-
//! println!(
-
//! " {}",
-
//! serde_json::to_string_pretty(&post.post.record).into_diagnostic()?
-
//! );
+
//! println!("{}. by {}", i + 1, post.post.author.handle);
//! }
//! Ok(())
//! }
···
//! Ok(())
//! }
//! ```
-
//! - Stateful client: `AtClient<C, S>` holds a base `Url`, a transport, and a
-
//! `SessionStore<AuthSession>` implementation. It automatically sets Authorization and can
-
//! auto-refresh a session when expired, retrying once.
-
//! - Convenience wrapper: `BasicClient` is an ergonomic newtype over
-
//! `AtClient<reqwest::Client, MemorySessionStore<AuthSession>>` with a `new(Url)` constructor.
+
//! - Stateful client (app-password): `CredentialSession<S, T>` where `S: SessionStore<(Did, CowStr), AtpSession>` and
+
//! `T: IdentityResolver + HttpClient + XrpcExt`. It auto-attaches Authorization, refreshes on expiry, and updates the
+
//! base endpoint to the user's PDS on login/restore.
//!
//! Per-request overrides (stateless)
//! ```no_run
···
//! ```
//!
//! Token storage:
-
//! - Use `MemorySessionStore<AuthSession>` for ephemeral sessions, tests, and CLIs.
-
//! - For persistence, `FileTokenStore` stores app-password sessions as JSON on disk.
-
//! See `client::token::FileTokenStore` docs for details.
+
//! - Use `MemorySessionStore<SessionKey, AtpSession>` for ephemeral sessions and tests.
+
//! - For persistence, wrap the file store: `FileAuthStore::new(path)` implements SessionStore for app-password sessions
+
//! and OAuth `ClientAuthStore` (unified on-disk map).
//! ```no_run
-
//! use jacquard::client::{AtClient, FileTokenStore};
-
//! let base = url::Url::parse("https://bsky.social").unwrap();
-
//! let store = FileTokenStore::new("/tmp/jacquard-session.json");
-
//! let client = AtClient::new(reqwest::Client::new(), base, store);
+
//! use std::sync::Arc;
+
//! use jacquard::client::credential_session::{CredentialSession, SessionKey};
+
//! use jacquard::client::{AtpSession, FileAuthStore};
+
//! use jacquard::identity::PublicResolver;
+
//! let store = Arc::new(FileAuthStore::new("/tmp/jacquard-session.json"));
+
//! let client = Arc::new(PublicResolver::default());
+
//! let session = CredentialSession::new(store, client);
//! ```
//!
+31 -44
crates/jacquard/src/main.rs
···
+
use std::sync::Arc;
use clap::Parser;
use jacquard::CowStr;
use jacquard::api::app_bsky::feed::get_timeline::GetTimeline;
-
use jacquard::api::com_atproto::server::create_session::CreateSession;
-
use jacquard::client::{AtpSession, BasicClient};
-
use jacquard::identity::resolver::IdentityResolver;
-
use jacquard::identity::slingshot_resolver_default;
-
use jacquard::types::string::Handle;
+
use jacquard::client::credential_session::{CredentialSession, SessionKey};
+
use jacquard::client::{AtpSession, MemorySessionStore};
+
use jacquard::identity::PublicResolver as JacquardResolver;
+
use jacquard::types::xrpc::XrpcClient;
use miette::IntoDiagnostic;
#[derive(Parser, Debug)]
#[command(author, version, about = "Jacquard - AT Protocol client demo")]
struct Args {
-
/// Username/handle (e.g., alice.bsky.social)
+
/// Username/handle (e.g., alice.bsky.social) or DID
#[arg(short, long)]
username: CowStr<'static>,
-
/// PDS URL (e.g., https://bsky.social)
-
#[arg(long, default_value = "https://bsky.social")]
-
pds: CowStr<'static>,
-
/// App password
#[arg(short, long)]
password: CowStr<'static>,
···
async fn main() -> miette::Result<()> {
let args = Args::parse();
-
// Resolve PDS for the handle using the Slingshot-enabled resolver
-
let resolver = slingshot_resolver_default();
-
let handle = Handle::new(args.username.as_ref()).into_diagnostic()?;
-
let (_did, pds_url) = resolver.pds_for_handle(&handle).await.into_diagnostic()?;
-
// let client = BasicClient::new(pds_url);
+
// Resolver + in-memory store
+
let resolver = Arc::new(JacquardResolver::default());
+
let store: Arc<MemorySessionStore<SessionKey, AtpSession>> = Arc::new(Default::default());
+
let client = Arc::new(resolver.clone());
+
let session = CredentialSession::new(store, client);
-
// // Create session
-
// let session = AtpSession::from(
-
// client
-
// .send(
-
// CreateSession::new()
-
// .identifier(args.username)
-
// .password(args.password)
-
// .build(),
-
// )
-
// .await?
-
// .into_output()?,
-
// );
+
// Login; resolves PDS from handle/DID automatically. Persisted under (did, "session").
+
let _ = session
+
.login(args.username.clone(), args.password.clone(), None, None, None)
+
.await
+
.into_diagnostic()?;
-
// println!("logged in as {} ({})", session.handle, session.did);
-
// client.set_session(session.into()).await.into_diagnostic()?;
+
// Fetch timeline
+
let timeline = session
+
.send(&GetTimeline::new().limit(5).build())
+
.await
+
.into_diagnostic()?
+
.into_output()
+
.into_diagnostic()?;
-
// // Fetch timeline
-
// println!("\nfetching timeline...");
-
// let timeline = client
-
// .send(GetTimeline::new().limit(5).build())
-
// .await?
-
// .into_output()?;
-
-
// println!("\ntimeline ({} posts):", timeline.feed.len());
-
// for (i, post) in timeline.feed.iter().enumerate() {
-
// println!("\n{}. by {}", i + 1, post.post.author.handle);
-
// println!(
-
// " {}",
-
// serde_json::to_string_pretty(&post.post.record).into_diagnostic()?
-
// );
-
// }
+
println!("\ntimeline ({} posts):", timeline.feed.len());
+
for (i, post) in timeline.feed.iter().enumerate() {
+
println!("\n{}. by {}", i + 1, post.post.author.handle);
+
println!(
+
" {}",
+
serde_json::to_string_pretty(&post.post.record).into_diagnostic()?
+
);
+
}
Ok(())
}