Skip to main content

jacquard_common/
session.rs

1//! Generic session storage traits and utilities.
2
3use alloc::boxed::Box;
4use alloc::collections::BTreeMap;
5use alloc::string::String;
6use alloc::sync::Arc;
7use alloc::vec::Vec;
8use core::error::Error as StdError;
9use core::fmt;
10use core::future::Future;
11use core::hash::Hash;
12#[cfg(feature = "std")]
13use miette::Diagnostic;
14use serde::{Deserialize, Serialize};
15use serde_json::Value;
16use smol_str::SmolStr;
17
18use crate::bos::{BosStr, DefaultStr};
19use crate::types::{did::Did, handle::Handle};
20
21#[cfg(feature = "std")]
22use std::path::{Path, PathBuf};
23
24// Use tokio's RwLock with std, maitake-sync's async RwLock for no_std
25#[cfg(not(feature = "std"))]
26use maitake_sync::RwLock;
27#[cfg(feature = "std")]
28use tokio::sync::RwLock;
29
30/// Errors emitted by session stores.
31#[derive(Debug, thiserror::Error)]
32#[cfg_attr(feature = "std", derive(Diagnostic))]
33#[non_exhaustive]
34pub enum SessionStoreError {
35    /// Filesystem or I/O error
36    #[cfg(feature = "std")]
37    #[error("I/O error: {0}")]
38    #[cfg_attr(feature = "std", diagnostic(code(jacquard::session_store::io)))]
39    Io(#[from] std::io::Error),
40    /// Serialization error (e.g., JSON)
41    #[error("serialization error: {0}")]
42    #[cfg_attr(feature = "std", diagnostic(code(jacquard::session_store::serde)))]
43    Serde(#[from] serde_json::Error),
44    /// Any other error from a backend implementation
45    #[error(transparent)]
46    #[cfg_attr(feature = "std", diagnostic(code(jacquard::session_store::other)))]
47    Other(#[from] Box<dyn StdError + Send + Sync>),
48}
49
50/// Shared storage key for app-password and OAuth sessions.
51#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
52pub struct SessionKey {
53    /// Account DID.
54    pub did: Did,
55    /// Store-local session identifier.
56    pub session_id: SmolStr,
57}
58
59impl SessionKey {
60    /// Create a new session key.
61    pub fn new(did: Did, session_id: impl Into<SmolStr>) -> Self {
62        Self {
63            did,
64            session_id: session_id.into(),
65        }
66    }
67
68    /// Borrow the account DID.
69    pub fn did(&self) -> Did<&str> {
70        self.did.borrow()
71    }
72
73    /// Borrow the session identifier.
74    pub fn session_id(&self) -> &str {
75        self.session_id.as_str()
76    }
77}
78
79impl fmt::Display for SessionKey {
80    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
81        write!(f, "{}/{}", self.did, self.session_id)
82    }
83}
84
85impl From<(Did, SmolStr)> for SessionKey {
86    fn from((did, session_id): (Did, SmolStr)) -> Self {
87        Self { did, session_id }
88    }
89}
90
91impl From<SessionKey> for (Did, SmolStr) {
92    fn from(key: SessionKey) -> Self {
93        (key.did, key.session_id)
94    }
95}
96
97impl SessionHint<DefaultStr> {
98    /// Build a session hint that matches any session.
99    pub fn any() -> Self {
100        SessionHint::Any
101    }
102
103    /// Build a session hint that matches a specific key.
104    pub fn key(key: SessionKey) -> Self {
105        SessionHint::Key(key)
106    }
107
108    /// Build a session hint that matches a login identifier.
109    pub fn identifier(identifier: DefaultStr) -> Self {
110        SessionHint::Identifier(identifier)
111    }
112
113    /// Build a session hint that matches a DID.
114    pub fn did(did: Did<DefaultStr>) -> Self {
115        SessionHint::Did(did)
116    }
117
118    /// Build a session hint that matches a handle.
119    pub fn handle(handle: Handle<DefaultStr>) -> Self {
120        SessionHint::Handle(handle)
121    }
122}
123
124impl<'a> SessionHint<&'a str> {
125    /// Build a borrowed session hint from CLI/login input.
126    ///
127    /// DIDs and handles become addressable session hints. Other inputs are kept as login
128    /// identifiers, which can start authentication but do not match resolver-free stores.
129    pub fn from_input(input: &'a str) -> Self {
130        if let Ok(did) = Did::new(input) {
131            SessionHint::Did(did)
132        } else if let Ok(handle) = Handle::new(input) {
133            SessionHint::Handle(handle)
134        } else {
135            SessionHint::Identifier(input)
136        }
137    }
138
139    /// Build a borrowed session hint from optional CLI/login input.
140    ///
141    /// Missing input means "resume any existing session".
142    pub fn from_optional_input(input: Option<&'a str>) -> Self {
143        match input {
144            Some(input) => Self::from_input(input),
145            None => SessionHint::Any,
146        }
147    }
148}
149
150/// Resolver-free hint for choosing a stored session.
151///
152/// Matching in `jacquard-common` is intentionally key-only and does not perform identity
153/// resolution. [`SessionHint::Handle`] cannot be matched from [`SessionKey`] values alone and
154/// returns no match in [`match_session_key`]; higher-level stores may add handle-aware matching
155/// when they have typed records containing handle metadata.
156#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
157pub enum SessionHint<S: BosStr = DefaultStr> {
158    /// Use any available session.
159    Any,
160    /// Use the first session for the given DID.
161    Did(Did<S>),
162    /// Use a session for the given handle, if a higher-level matcher can resolve it.
163    Handle(Handle<S>),
164    /// Use this exact key.
165    Key(SessionKey),
166    /// Login/start-auth identifier that is not necessarily session-addressable.
167    ///
168    /// Examples include an email address, explicit PDS/entryway URL, or
169    /// application-specific login input. Default resolver-free selectors do not
170    /// match this as an existing session.
171    Identifier(S),
172}
173
174/// Match a session key using only resolver-free key data.
175pub fn match_session_key<I, S>(hint: &SessionHint<S>, keys: I) -> Option<SessionKey>
176where
177    I: IntoIterator<Item = SessionKey>,
178    S: BosStr,
179{
180    match hint {
181        SessionHint::Any => keys.into_iter().next(),
182        SessionHint::Did(did) => keys
183            .into_iter()
184            .find(|key| key.did.as_str() == did.as_ref()),
185        SessionHint::Handle(_) | SessionHint::Identifier(_) => None,
186        SessionHint::Key(target) => keys.into_iter().find(|key| key == target),
187    }
188}
189
190/// Selects a session from a hint, optionally returning richer implementation-specific data.
191///
192/// This trait is intentionally separate from [`SessionStore`]. Simple implementations may select
193/// by enumerating store keys and filtering, while database-backed or otherwise indexed
194/// implementations can resolve [`SessionHint::Key`] or [`SessionHint::Did`] without a full scan.
195/// Higher-level crates can also implement selectors that resolve [`SessionHint::Handle`] using an
196/// identity resolver and return metadata such as cached endpoints alongside the selected key.
197#[cfg_attr(not(target_arch = "wasm32"), trait_variant::make(Send))]
198pub trait SessionSelector<M>: Send + Sync {
199    /// Error returned by this selector.
200    type Error;
201
202    /// Select a matching session, if one exists.
203    fn select_session<S: BosStr + Send + Sync>(
204        &self,
205        hint: &SessionHint<S>,
206    ) -> impl Future<Output = Result<Option<M>, Self::Error>>;
207}
208
209/// Pluggable storage for arbitrary session records.
210#[cfg_attr(not(target_arch = "wasm32"), trait_variant::make(Send))]
211pub trait SessionStore<K, T>: Send + Sync
212where
213    K: Eq + Hash,
214    T: Clone,
215{
216    /// Get the current session if present.
217    fn get(&self, key: &K) -> impl Future<Output = Option<T>>;
218    /// Persist the given session.
219    fn set(&self, key: K, session: T) -> impl Future<Output = Result<(), SessionStoreError>>;
220    /// Delete the given session.
221    fn del(&self, key: &K) -> impl Future<Output = Result<(), SessionStoreError>>;
222    /// List known session keys when the backend supports enumeration.
223    fn list_keys(&self) -> impl Future<Output = Result<Vec<K>, SessionStoreError>>
224    where
225        K: Clone,
226    {
227        async { Ok(Vec::new()) }
228    }
229}
230
231/// In-memory session store suitable for short-lived sessions and tests.
232#[derive(Clone)]
233pub struct MemorySessionStore<K, T>(Arc<RwLock<BTreeMap<K, T>>>);
234
235impl<K, T> Default for MemorySessionStore<K, T> {
236    fn default() -> Self {
237        Self(Arc::new(RwLock::new(BTreeMap::new())))
238    }
239}
240
241impl<K, T> SessionStore<K, T> for MemorySessionStore<K, T>
242where
243    K: Eq + Hash + Send + Sync + Ord,
244    T: Clone + Send + Sync,
245{
246    async fn get(&self, key: &K) -> Option<T> {
247        self.0.read().await.get(key).cloned()
248    }
249    async fn set(&self, key: K, session: T) -> Result<(), SessionStoreError> {
250        self.0.write().await.insert(key, session);
251        Ok(())
252    }
253    async fn del(&self, key: &K) -> Result<(), SessionStoreError> {
254        self.0.write().await.remove(key);
255        Ok(())
256    }
257
258    async fn list_keys(&self) -> Result<Vec<K>, SessionStoreError>
259    where
260        K: Clone,
261    {
262        Ok(self.0.read().await.keys().cloned().collect())
263    }
264}
265
266impl<T> SessionSelector<SessionKey> for MemorySessionStore<SessionKey, T>
267where
268    T: Clone + Send + Sync,
269{
270    type Error = SessionStoreError;
271
272    async fn select_session<S: BosStr + Send + Sync>(
273        &self,
274        hint: &SessionHint<S>,
275    ) -> Result<Option<SessionKey>, Self::Error> {
276        Ok(match_session_key(hint, self.list_keys().await?))
277    }
278}
279
280/// File-backed token store using a JSON file.
281///
282/// NOT secure, only suitable for development.
283///
284/// Example
285/// ```ignore
286/// use jacquard::client::{AtClient, FileTokenStore};
287/// let base = jacquard_common::deps::fluent_uri::Uri::parse("https://bsky.social").unwrap().to_owned();
288/// let store = FileTokenStore::new("/tmp/jacquard-session.json");
289/// let client = AtClient::new(reqwest::Client::new(), base, store);
290/// ```
291#[cfg(feature = "std")]
292#[derive(Clone, Debug)]
293pub struct FileTokenStore {
294    /// Path to the JSON file.
295    pub path: PathBuf,
296}
297
298#[cfg(feature = "std")]
299impl FileTokenStore {
300    /// Create a new file token store at the given path.
301    ///
302    /// Creates parent directories and initializes an empty JSON object if the file doesn't exist.
303    ///
304    /// # Errors
305    ///
306    /// Returns an error if:
307    /// - Parent directories cannot be created
308    /// - The file cannot be written
309    pub fn try_new(path: impl AsRef<Path>) -> Result<Self, SessionStoreError> {
310        let path = path.as_ref();
311
312        // Create parent directories if they exist and don't already exist
313        if let Some(parent) = path.parent() {
314            if !parent.as_os_str().is_empty() && !parent.exists() {
315                std::fs::create_dir_all(parent)?;
316            }
317        }
318
319        // Initialize empty JSON object if file doesn't exist
320        if !path.exists() {
321            std::fs::write(path, b"{}")?;
322        }
323
324        Ok(Self {
325            path: path.to_path_buf(),
326        })
327    }
328
329    /// Create a new file token store at the given path.
330    ///
331    /// # Panics
332    ///
333    /// Panics if parent directories cannot be created or the file cannot be written.
334    /// Prefer [`try_new`](Self::try_new) for fallible construction.
335    pub fn new(path: impl AsRef<Path>) -> Self {
336        Self::try_new(path).expect("failed to initialize FileTokenStore")
337    }
338}
339
340#[cfg(feature = "std")]
341impl FileTokenStore {
342    /// Read a JSON value by string key.
343    pub fn get_value(&self, key: &str) -> Result<Option<Value>, SessionStoreError> {
344        let file = std::fs::read_to_string(&self.path)?;
345        let store: Value = serde_json::from_str(&file)?;
346        Ok(store.get(key).cloned())
347    }
348
349    /// Insert or replace a JSON value by string key.
350    pub fn set_value(&self, key: impl Into<String>, value: Value) -> Result<(), SessionStoreError> {
351        let file = std::fs::read_to_string(&self.path)?;
352        let mut store: Value = serde_json::from_str(&file)?;
353        if let Some(store) = store.as_object_mut() {
354            store.insert(key.into(), value);
355            std::fs::write(&self.path, serde_json::to_string_pretty(&store)?)?;
356            Ok(())
357        } else {
358            Err(SessionStoreError::Other("invalid store".into()))
359        }
360    }
361
362    /// Remove a JSON value by string key.
363    pub fn remove_value(&self, key: &str) -> Result<(), SessionStoreError> {
364        let file = std::fs::read_to_string(&self.path)?;
365        let mut store: Value = serde_json::from_str(&file)?;
366        if let Some(store) = store.as_object_mut() {
367            store.remove(key);
368            std::fs::write(&self.path, serde_json::to_string_pretty(&store)?)?;
369            Ok(())
370        } else {
371            Err(SessionStoreError::Other("invalid store".into()))
372        }
373    }
374
375    /// Return all JSON object entries in the store.
376    pub fn entries(&self) -> Result<Vec<(String, Value)>, SessionStoreError> {
377        let file = std::fs::read_to_string(&self.path)?;
378        let store: Value = serde_json::from_str(&file)?;
379        if let Some(store) = store.as_object() {
380            Ok(store
381                .iter()
382                .map(|(key, value)| (key.clone(), value.clone()))
383                .collect())
384        } else {
385            Err(SessionStoreError::Other("invalid store".into()))
386        }
387    }
388}
389
390#[cfg(test)]
391mod tests {
392    use super::*;
393    use alloc::string::ToString;
394
395    #[test]
396    fn session_key_display_uses_slash_separator() {
397        let did = Did::new_static("did:plc:alice").unwrap();
398        let key = SessionKey::new(did, "session_1");
399        assert_eq!(key.to_string(), "did:plc:alice/session_1");
400    }
401
402    #[tokio::test]
403    async fn memory_store_lists_keys() {
404        let store = MemorySessionStore::<SessionKey, String>::default();
405        let key = SessionKey::new(Did::new_static("did:plc:alice").unwrap(), "session");
406        store.set(key.clone(), "value".to_string()).await.unwrap();
407        assert_eq!(store.list_keys().await.unwrap(), vec![key]);
408    }
409
410    struct EmptyStore;
411
412    impl SessionStore<SessionKey, String> for EmptyStore {
413        async fn get(&self, _key: &SessionKey) -> Option<String> {
414            None
415        }
416
417        async fn set(&self, _key: SessionKey, _session: String) -> Result<(), SessionStoreError> {
418            Ok(())
419        }
420
421        async fn del(&self, _key: &SessionKey) -> Result<(), SessionStoreError> {
422            Ok(())
423        }
424    }
425
426    #[tokio::test]
427    async fn default_list_keys_is_empty() {
428        assert!(EmptyStore.list_keys().await.unwrap().is_empty());
429    }
430
431    #[test]
432    fn match_session_key_is_resolver_free() {
433        let alice = SessionKey::new(Did::new_static("did:plc:alice").unwrap(), "a");
434        let bob = SessionKey::new(Did::new_static("did:plc:bob").unwrap(), "b");
435        let keys = vec![alice.clone(), bob.clone()];
436
437        assert_eq!(
438            match_session_key(&SessionHint::any(), keys.clone()),
439            Some(alice.clone())
440        );
441        assert_eq!(
442            match_session_key(&SessionHint::Did(bob.did.clone()), keys.clone()),
443            Some(bob.clone())
444        );
445        assert_eq!(
446            match_session_key(&SessionHint::key(bob.clone()), keys.clone()),
447            Some(bob.clone())
448        );
449        assert_eq!(
450            match_session_key(
451                &SessionHint::key(SessionKey::new(
452                    Did::new_static("did:plc:carol").unwrap(),
453                    "c",
454                )),
455                keys.clone(),
456            ),
457            None
458        );
459        assert_eq!(match_session_key(&SessionHint::any(), Vec::new()), None);
460        assert_eq!(
461            match_session_key(
462                &SessionHint::<DefaultStr>::Handle(
463                    Handle::new_static("alice.example.com").unwrap()
464                ),
465                keys.clone(),
466            ),
467            None
468        );
469        assert_eq!(
470            match_session_key(
471                &SessionHint::Identifier(SmolStr::new("alice@example.com")),
472                keys
473            ),
474            None
475        );
476    }
477}