Skip to main content

cheers_core/
store.rs

1//! Client-side persistence contract — [`CredentialStore`] + the shared
2//! [`StoreError`].
3//!
4//! `cheers-core` keeps only the *device-side* store trait: [`CredentialStore`],
5//! the opaque-blob credential storage a native client (keyring, encrypted-file,
6//! in-memory) implements. The *origin-side* store traits — `UserStore` and
7//! `RefreshStore` — moved to `cheers-server` (R019-F6), so a verify-only or
8//! device-only consumer never even names them. [`StoreError`] stays here: it's
9//! the shared error every store and the revocation traits return.
10//!
11//! All traits are `async` via [`async_trait`] so they remain dyn-compatible.
12//!
13//! `StoreError` here is the **adapter-facing** error; R007-T4 lands the
14//! workspace-wide error hierarchy and re-exports a unified type.
15//!
16//! @yah:ticket(R019-F4, "Revocation read/write split: RevocationWriter (origin) + RevocationReader (edge)")
17//! @yah:assignee(agent:claude)
18//! @yah:at(2026-05-26T17:52:56Z)
19//! @yah:status(review)
20//! @yah:parent(R019)
21//! @yah:next("Promote store.rs's 'cheers does not enforce revocation server-side; the product wires up the check' note into two traits: RevocationWriter { revoke(jti | chain) } (origin, Yubaba Redis/gossip) and RevocationReader { is_revoked(jti) } (edge, local replica / CF KV).")
22//! @yah:next("Eventually-consistent by documented contract; the short access-token TTL is the stated propagation bound. Wire revoke() into logout + UserStore::revoke_device + RefreshStore::revoke_chain.")
23//! @yah:next("Keyed on the token's jti — depends on the Claims.jti field added alongside the facades feature.")
24//! @yah:verify("cd external/cheers && cargo test -p cheers-core")
25//! @arch:see(.yah/docs/working/edge-verifiable-auth.md)
26//! @yah:handoff("Landed RevocationReader{is_revoked(jti)} + RevocationWriter{revoke(jti)} in new revocation.rs, exported from lib.rs. Reader = edge hot path (point membership check), Writer = origin cold path; both async + Send+Sync + dyn-compatible, mirroring the store.rs traits. The read/write split is the same capability-by-type discipline as TokenVerifier/TokenMinter.")
27//! @yah:handoff("Settled the 'revoke(jti | chain)' shape: the WRITER is jti-only. Chain/device revocation = RefreshStore::revoke_chain (blocks re-issue on the cold path) composed with per-jti revoke + natural expiry of in-flight access tokens within the access TTL. The module doc owns the full eventually-consistent contract (revoke propagates async; access-token TTL is the staleness bound; sound because auth has no cross-session OLTP). store.rs revoke_device doc promoted to point at the new traits.")
28//! @yah:handoff("jti landed on Claims (claims.rs) as F4's revocation key — nominally an F3 line-item, but F4 keys on it so it moved up. #[serde(default, skip_serializing_if=String::is_empty)] keeps the wire/cookie format byte-identical when unset; with_jti() builder; Claims::new() kept at 5 args so existing + cross-camp (mesofact R009) call sites still compile.")
29//! @yah:handoff("Verified GREEN: cargo test -p cheers-core (45 unit incl. 4 revocation + jti tests, 9 proptest, 3 doctest) + cargo check --workspace --all-features. NOTE: revocation.rs + store.rs doc-link to crate::session::* (SessionAuthority/EdgeVerifier/SessionPolicy), which land in R019-F3 — forward refs that resolve when F3 lands; cargo test/check don't validate intra-doc links, only cargo doc does.")
30//! @yah:handoff("Facade-level wiring (SessionAuthority composing revoke_chain + revoke; EdgeVerifier consulting is_revoked after signature check) is R019-F3 — picked up next per the maintainer's F4-first ordering.")
31
32use async_trait::async_trait;
33
34use crate::claims::Credential;
35
36/// Errors a store impl may return.
37#[derive(Debug, thiserror::Error)]
38#[non_exhaustive]
39pub enum StoreError {
40    #[error("not found")]
41    NotFound,
42    /// A unique constraint (e.g. provider+subject already linked, duplicate device).
43    #[error("conflict")]
44    Conflict,
45    /// Underlying backend failure (DB error, I/O, …). String to keep the
46    /// trait dyn-compatible without leaking concrete error types.
47    #[error("backend: {0}")]
48    Backend(String),
49}
50
51/// Opaque-blob credential storage, keyed by a caller-chosen string.
52///
53/// The one store trait the device tier needs: native-client features (P8)
54/// implement it over keyring, encrypted-file, or in-memory backends.
55/// `Credential::material` is the provider-specific blob; cheers-core does not
56/// interpret it. Kept in `cheers-core` (not `cheers-server`) because the client
57/// stores credentials without ever touching a token codec.
58#[async_trait]
59pub trait CredentialStore: Send + Sync {
60    async fn put(&self, key: &str, cred: &Credential) -> Result<(), StoreError>;
61    async fn get(&self, key: &str) -> Result<Option<Credential>, StoreError>;
62    async fn delete(&self, key: &str) -> Result<(), StoreError>;
63}
64
65#[cfg(test)]
66mod tests {
67    //! Trait-shape smoke test via a tiny in-memory impl. The "real" memory impls
68    //! live in the `cheers` crate (R015-T3).
69
70    use super::*;
71    use crate::claims::{DeviceBinding, DeviceId, UserId};
72    use std::collections::HashMap;
73    use std::sync::Mutex;
74
75    #[derive(Default)]
76    struct MemCredentialStore(Mutex<HashMap<String, Credential>>);
77
78    #[async_trait]
79    impl CredentialStore for MemCredentialStore {
80        async fn put(&self, key: &str, cred: &Credential) -> Result<(), StoreError> {
81            self.0.lock().unwrap().insert(key.to_owned(), cred.clone());
82            Ok(())
83        }
84        async fn get(&self, key: &str) -> Result<Option<Credential>, StoreError> {
85            Ok(self.0.lock().unwrap().get(key).cloned())
86        }
87        async fn delete(&self, key: &str) -> Result<(), StoreError> {
88            self.0
89                .lock()
90                .unwrap()
91                .remove(key)
92                .map(|_| ())
93                .ok_or(StoreError::NotFound)
94        }
95    }
96
97    fn cred(user: &str, device: &str) -> Credential {
98        Credential::new(
99            UserId::new(user),
100            DeviceId::new(device),
101            DeviceBinding::Passkey,
102            b"material".to_vec(),
103        )
104    }
105
106    #[test]
107    fn credential_store_put_get_delete() {
108        let s = MemCredentialStore::default();
109        pollster::block_on(async {
110            let c = cred("u1", "d1");
111            assert!(s.get("k").await.unwrap().is_none());
112            s.put("k", &c).await.unwrap();
113            assert_eq!(s.get("k").await.unwrap().unwrap(), c);
114            s.delete("k").await.unwrap();
115            assert!(matches!(s.delete("k").await, Err(StoreError::NotFound)));
116        });
117    }
118
119    #[test]
120    fn trait_is_dyn_compatible() {
121        fn _c(_: &dyn CredentialStore) {}
122    }
123}