+22
-31
README.md
+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
+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
+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
+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
+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
+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
}