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}