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;
33use std::collections::HashMap;
34use std::sync::Mutex;
35
36use crate::claims::Credential;
37
38/// Errors a store impl may return.
39#[derive(Debug, thiserror::Error)]
40#[non_exhaustive]
41pub enum StoreError {
42 #[error("not found")]
43 NotFound,
44 /// A unique constraint (e.g. provider+subject already linked, duplicate device).
45 #[error("conflict")]
46 Conflict,
47 /// Underlying backend failure (DB error, I/O, …). String to keep the
48 /// trait dyn-compatible without leaking concrete error types.
49 #[error("backend: {0}")]
50 Backend(String),
51}
52
53/// Opaque-blob credential storage, keyed by a caller-chosen string.
54///
55/// The one store trait the device tier needs: native-client features (P8)
56/// implement it over keyring, encrypted-file, or in-memory backends.
57/// `Credential::material` is the provider-specific blob; cheers-core does not
58/// interpret it. Kept in `cheers-core` (not `cheers-server`) because the client
59/// stores credentials without ever touching a token codec.
60#[async_trait]
61pub trait CredentialStore: Send + Sync {
62 async fn put(&self, key: &str, cred: &Credential) -> Result<(), StoreError>;
63 async fn get(&self, key: &str) -> Result<Option<Credential>, StoreError>;
64 async fn delete(&self, key: &str) -> Result<(), StoreError>;
65}
66
67/// Single-use tracking for magic-link tokens (`cheers::email::magic_link`).
68///
69/// Implementors should hold each `jti` until at least `expires_at` so a
70/// token cannot be replayed before it would have expired anyway. After
71/// expiry the entry can be GC'd — the codec's own expiry check will reject
72/// any token whose record is missing.
73///
74/// R727-B1: declared here (not in the `cheers` crate, where the magic-link
75/// codec lives) so `cheers-turso` and `cheers-sqlx` — which depend on
76/// `cheers-core` but not on `cheers` — can implement it directly. Re-exported
77/// from `cheers::email::magic_link` so existing call sites keep resolving.
78#[async_trait]
79pub trait UsedJtiStore: Send + Sync {
80 /// Atomically: if `jti` has not been seen, record it (with `expires_at`
81 /// for GC) and return `true`. If it has been seen, return `false`.
82 /// `Err(_)` is reserved for backend failures, not replay.
83 async fn try_mark_used(&self, jti: &str, expires_at: i64) -> Result<bool, String>;
84}
85
86/// In-process [`UsedJtiStore`] backed by a `Mutex<HashMap>`. For tests, dev,
87/// and single-replica deployments. Production multi-replica deployments
88/// want a shared backend — see `cheers_turso::TursoUsedJtiStore` /
89/// `cheers_sqlx::SqliteUsedJtiStore`.
90#[derive(Default)]
91pub struct MemoryUsedJtiStore {
92 inner: Mutex<HashMap<String, i64>>,
93}
94
95impl MemoryUsedJtiStore {
96 pub fn new() -> Self {
97 Self::default()
98 }
99
100 /// Drop entries whose `expires_at <= now`. Callers wire this on a timer
101 /// if they care about the unbounded-growth case.
102 pub fn gc(&self, now: i64) {
103 self.inner.lock().unwrap().retain(|_, exp| *exp > now);
104 }
105
106 pub fn len(&self) -> usize {
107 self.inner.lock().unwrap().len()
108 }
109
110 pub fn is_empty(&self) -> bool {
111 self.inner.lock().unwrap().is_empty()
112 }
113}
114
115#[async_trait]
116impl UsedJtiStore for MemoryUsedJtiStore {
117 async fn try_mark_used(&self, jti: &str, expires_at: i64) -> Result<bool, String> {
118 let mut g = self.inner.lock().unwrap();
119 if g.contains_key(jti) {
120 return Ok(false);
121 }
122 g.insert(jti.to_owned(), expires_at);
123 Ok(true)
124 }
125}
126
127#[cfg(test)]
128mod tests {
129 //! Trait-shape smoke test via a tiny in-memory impl. The "real" memory impls
130 //! live in the `cheers` crate (R015-T3).
131
132 use super::*;
133 use crate::claims::{DeviceBinding, DeviceId, UserId};
134 use std::collections::HashMap;
135 use std::sync::Mutex;
136
137 #[derive(Default)]
138 struct MemCredentialStore(Mutex<HashMap<String, Credential>>);
139
140 #[async_trait]
141 impl CredentialStore for MemCredentialStore {
142 async fn put(&self, key: &str, cred: &Credential) -> Result<(), StoreError> {
143 self.0.lock().unwrap().insert(key.to_owned(), cred.clone());
144 Ok(())
145 }
146 async fn get(&self, key: &str) -> Result<Option<Credential>, StoreError> {
147 Ok(self.0.lock().unwrap().get(key).cloned())
148 }
149 async fn delete(&self, key: &str) -> Result<(), StoreError> {
150 self.0
151 .lock()
152 .unwrap()
153 .remove(key)
154 .map(|_| ())
155 .ok_or(StoreError::NotFound)
156 }
157 }
158
159 fn cred(user: &str, device: &str) -> Credential {
160 Credential::new(
161 UserId::new(user),
162 DeviceId::new(device),
163 DeviceBinding::Passkey,
164 b"material".to_vec(),
165 )
166 }
167
168 #[test]
169 fn credential_store_put_get_delete() {
170 let s = MemCredentialStore::default();
171 pollster::block_on(async {
172 let c = cred("u1", "d1");
173 assert!(s.get("k").await.unwrap().is_none());
174 s.put("k", &c).await.unwrap();
175 assert_eq!(s.get("k").await.unwrap().unwrap(), c);
176 s.delete("k").await.unwrap();
177 assert!(matches!(s.delete("k").await, Err(StoreError::NotFound)));
178 });
179 }
180
181 #[test]
182 fn trait_is_dyn_compatible() {
183 fn _c(_: &dyn CredentialStore) {}
184 }
185}