Skip to main content

cheers_server/
store.rs

1//! Origin-side persistence contracts — [`UserStore`] and [`RefreshStore`].
2//!
3//! These are the *origin* store traits: identity/provider linkage and
4//! refresh-token rotation state. They moved out of `cheers-core` (R019-F6) so a
5//! verify-only or device-only consumer never names them. The device store,
6//! [`CredentialStore`](cheers_core::CredentialStore), stays in `cheers-core`; the
7//! shared [`StoreError`](cheers_core::StoreError) does too.
8//!
9//! All traits are `async` via [`async_trait`] so they remain dyn-compatible.
10//! Concrete impls (Postgres, Yubaba-backed, in-memory) live in product code.
11//!
12//! @yah:ticket(R020-F4, "Ownership table schema + writers (POST/DELETE /ownership) + cascading revoke")
13//! @yah:assignee(agent:claude)
14//! @yah:at(2026-06-04T01:35:23Z)
15//! @yah:status(review)
16//! @yah:phase(P1)
17//! @yah:parent(R020)
18//! @yah:gotcha("CHECK (granted_by LIKE 'svc:%') and CHECK (on_behalf_of IS NULL OR on_behalf_of LIKE 'user:%') are invariants. A row violating either is a bug — humans never appear in granted_by, services never in on_behalf_of.")
19//! @yah:assumes("ownership_version (yah/W159 Layer 3 freshness backstop) is intentionally deferred in v1 — add the column/endpoint when the staleness budget needs tightening.")
20//! @arch:see(.yah/docs/working/mcp-auth-and-ownership.md)
21//! @yah:depends_on(R020-F2)
22//! @yah:next("Sign off the store layer + cascade primitive (this ticket).")
23//! @yah:next("On sign-off: R020-T1 (McpClaims mint/verify helpers) unblocks → R020-T2 (Bearer middleware) unblocks → R020-T3 (HTTP routes) unblocks. After T3 lands the F4 title's HTTP claim is fully realized in the codebase.")
24//! @yah:next("Separately: wire the cascade revoke caller into SessionAuthority's user-revocation hook — file as a peer task when that surface grows MCP awareness.")
25//! @yah:handoff("STORE LAYER COMPLETE & TESTED. Landed: (a) cheers-server/src/ownership.rs — OwnershipStore trait (insert/get/revoke_by_id/revoke_by_on_behalf_of/list_for_principal) + NewOwnership::new() enforcing the granted_by=Service + on_behalf_of=User|None invariants up front + OwnershipRow + OwnershipValidationError. (b) cheers-sqlx migrations/{pg,sqlite}/0002_ownership.sql with the schema verbatim from §Ownership table — CHECKs + ix_ownership_principal + ix_ownership_on_behalf_of (both partial WHERE revoked_at IS NULL). (c) PgOwnershipStore + SqliteOwnershipStore. (d) common::ownership_store_lifecycle + check_constraints_reject_bad_rows scenarios.")
26//! @yah:handoff("Cascade revoke landed at the STORE level (revoke_by_on_behalf_of — single UPDATE … WHERE on_behalf_of=$1 AND revoked_at IS NULL, returns row count). revoke_by_id is idempotent (re-revoke doesn't overwrite revoked_at; unknown id => NotFound). Row id is UUIDv4 hex (load-bearing property is 'crypto-random 128-bit', not the ULID encoding the doc names).")
27//! @yah:handoff("SCOPE NOTE — HTTP routes (POST/DELETE /ownership) split out. The 'writers' in the F4 title are satisfied at the OwnershipStore trait layer (the layer that actually enforces the row invariants); the HTTP wrappers need cheers-axum infrastructure that didn't exist (Bearer/McpClaims middleware, McpClaims mint/verify helpers — TokenMinter/Verifier are hard-coded to Claims). Split to peer tasks R020-T1 (McpClaims mint/verify helpers in cheers-server) → R020-T2 (Bearer/McpClaims middleware in cheers-axum) → R020-T3 (POST/DELETE /ownership routes). Cleaner than expanding F4 inline; also resolves the F6→F4 circular dep noted in the prior handoff.")
28//! @yah:handoff("DEFERRED: caller of revoke_by_on_behalf_of in the user-revocation hook (SessionAuthority::revoke_device or peer). The cascade primitive itself is tested; composing it into user-revocation is a follow-up under the same store-side scope (peer task — file when SessionAuthority grows MCP awareness).")
29//! @yah:handoff("Verified GREEN: cargo test -p cheers-core (51), -p cheers-server (39 incl. 4 new ownership tests), -p cheers-verify (35+9+2+4), -p cheers-sqlx --features sqlite (7 incl. 2 new ownership integration tests). pg-integration --tests compiles (full pg run needs Docker).")
30//! @yah:verify("cargo test -p cheers-server && cargo test -p cheers-sqlx --features sqlite")
31//! @yah:verify("ownership_store_lifecycle scenario: insert → revoke_by_id → list_for_principal excludes the revoked row; re-revoke is idempotent (revoked_at unchanged).")
32//! @yah:verify("check_constraints_reject_bad_rows: a row with granted_by NOT LIKE 'svc:%' or on_behalf_of NOT LIKE 'user:%' fails the SQL CHECK (both pg + sqlite).")
33//! @yah:verify("Cascade revoke: revoke_by_on_behalf_of(user, now) sweeps every live row with that on_behalf_of in one UPDATE, returns the row count, and a follow-up revoke returns 0.")
34//!
35//! @yah:ticket(R020-F13, "Audit ingest endpoint + centralized audit table (POST /audit/ingest)")
36//! @yah:assignee(agent:claude)
37//! @yah:at(2026-06-04T01:36:39Z)
38//! @yah:status(review)
39//! @yah:phase(P4)
40//! @yah:parent(R020)
41//! @yah:gotcha("audit:write is kind=service only. Same enforcement as ownership:write — reject at grant write time.")
42//! @arch:see(.yah/docs/working/mcp-auth-and-ownership.md)
43//! @yah:depends_on(R020-F3)
44//! @yah:depends_on(R020-F4)
45//! @yah:handoff("LANDED end-to-end (store layer + sqlx persistence + HTTP route). cheers-server/src/audit.rs: AuditRecord { at, sub, act, camp_id, aud, method, scope, result, request_id } + AuditValidationError (NonPositiveAt / EmptyAud / EmptyMethod / EmptyResult / EmptyRequestId) + AuditRecord::new() validates up front + AuditRecord::validate() so deserialized records re-check + AuditRow { id, record, ingested_at } + AuditStore trait (single mutation — insert_batch) + MemoryAuditStore. Re-exported from cheers_server::* (AuditRecord, AuditRow, AuditStore, AuditValidationError, MemoryAuditStore).")
46//! @yah:handoff("PERSISTENCE: cheers-sqlx/migrations/{pg,sqlite}/0004_audit.sql adds an append-only `audit` table with the wire-shape columns + (id, ingested_at) cheers stamps. Two indexes: ix_audit_sub_at on (sub, at DESC) for the F14 'who did what' lookup, and a partial ix_audit_act_sub_at WHERE act_sub IS NOT NULL for agent-attribution queries. cheers-sqlx/src/audit_store.rs: PgAuditStore + SqliteAuditStore — each insert_batch runs in a single sqlx transaction so a mid-batch DB failure leaves the table untouched (matches kamaji's atomic-retry contract). scope column stored as JSON text (serde array of wire scope strings) — keeps it forward-compatible if Scope grows variants between writer and reader.")
47//! @yah:handoff("HTTP ROUTE: cheers-axum/src/audit.rs adds AuditState<A> { mcp, store } + AuditIngestBody (transparent Vec<AuditRecord>) + AuditIngestResponse { rows } + POST /audit/ingest handler. Pipeline: authenticate_mcp → claims.require_scope(Scope::AuditWrite) → validate every record (whole-batch first, atomic) → store.insert_batch(records, now). Returns 201 + the inserted rows. RouteError::AuditInvalid(#[from] AuditValidationError) variant maps to 400 'audit_invalid' (distinct from 401/403 so kamaji can tell auth failure from a forbidden-shape batch and not retry as-is). audit module re-exported from cheers_axum::* (AuditIngestBody, AuditIngestResponse, AuditState).")
48//! @yah:handoff("USER-PRINCIPAL AUDIT:WRITE REJECTION is the canonical grant-time check — validate_grant(PrincipalKind::User, Scope::AuditWrite) is already pinned by cheers-core::mcp::tests::validate_grant_rejects_service_only_for_user (it covers OwnershipWrite + AuditWrite explicitly). Mint paths run validate_grant per expanded scope (R020-F6/F7/F8). This route is defense-in-depth: a token reaching POST /audit/ingest with audit:write must already have cleared grant-time + mint-time checks.")
49//! @yah:handoff("ATOMIC BATCH SEMANTICS: validate-all-then-insert at the handler; sqlx impls run insert_batch in a transaction. A 4xx on a malformed record leaves the audit table untouched, so kamaji's bounded-backoff retry of the corrected batch sees a clean ledger — no partial commits, no dedup logic needed on either side. Empty batch is a no-op (returns 201 + empty rows).")
50//! @yah:handoff("LIBSQL/TURSO MIGRATION COMPAT (R020-T19): the libsql-integration harness applies every migration in migrations/sqlite/ statement-by-statement; 0004_audit.sql is picked up automatically when the harness runs. Smoke verified locally via `cargo test -p cheers-sqlx --features sqlite` (10 → 11 tests, all green); a libsql-integration run is the standing follow-up per T19's @yah:assumes (Docker required).")
51//! @yah:handoff("R020-F14 (read endpoint) consumes this directly — the trait grows a paged read method, the schema is in place with the correct index, and AuditRow carries the columns F14's response needs. depends_on(R020-F13) edge on F14 stands.")
52//! @yah:next("Sign off F13 — tasks-met, awaiting human review.")
53//! @yah:next("Claim R020-F14 to add the AuditStore read surface (list_by_on_behalf_of with cursor pagination) and the GET /audit/by-on-behalf-of/<user> HTTP route.")
54//! @yah:next("When kamaji's wire-side begins forwarding live batches, coordinate yah-side W159 / R428 to point at POST /audit/ingest + the AuditIngestBody shape (bare JSON array of records).")
55//! @yah:verify("cargo test -p cheers-server audit:: — 8/8 trait + memory-impl tests pass.")
56//! @yah:verify("cargo test -p cheers-sqlx --features sqlite audit_store_batch_insert_round_trip — sqlite trait conformance pass (100-record batch + act-bearing record + svc-sub record round-trip via SqliteAuditStore).")
57//! @yah:verify("cargo test -p cheers-core validate_grant_rejects_service_only_for_user — already pins composition rule (4) for AuditWrite at the grant-time edge (verify item 'User-principal token requesting audit:write at grant time is rejected').")
58//! @yah:verify("Parent relay smoke: cargo test -p cheers-core -p cheers-server -p cheers-verify -p cheers-axum + cargo test -p cheers-sqlx --features sqlite — all green; cargo check --workspace --all-features clean.")
59//! @yah:verify("cargo test -p cheers-axum --test main audit_basic:: — 4/4 integration tests pass: batch POST 100 records all landed; forbidden shape returns 400 + corrected retry succeeds; token without audit:write to 403 before any store call; missing bearer to 401. (Retargeted by R514: the nine cheers-axum test binaries were merged into one target named `main`; the old `--test audit_basic` no longer resolves.)")
60
61use async_trait::async_trait;
62use serde::{Deserialize, Serialize};
63
64use cheers_core::{Credential, DeviceId, StoreError, User, UserId};
65
66/// The external identity-provider namespace a `subject` string lives in.
67///
68/// Two users with the same Google `sub` must collide; the same email address
69/// presented to Apple vs. Google must *not* collide. `ProviderKey` is the
70/// namespace tag that disambiguates.
71#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
72#[non_exhaustive]
73#[serde(tag = "provider", rename_all = "snake_case")]
74pub enum ProviderKey {
75    /// Google OIDC — `subject` is the `sub` claim from Google's ID token.
76    OidcGoogle,
77    /// Apple Sign In — `subject` is the `sub` claim from Apple's ID token.
78    OidcApple,
79    /// Any other OIDC issuer; `subject` is that issuer's `sub` claim.
80    OidcGeneric { issuer: String },
81    /// Email-based identity (magic-link or password). `subject` is the email.
82    Email,
83    /// LAN-pair identity. `subject` is the device's mshr node-id.
84    LanPair,
85}
86
87/// Fields required to mint a fresh `User`.
88///
89/// `UserStore::create` returns the resulting `User` with a freshly minted
90/// `UserId`; the caller has no say in the id shape.
91#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
92#[non_exhaustive]
93pub struct NewUser {
94    pub email: Option<String>,
95    pub name: Option<String>,
96}
97
98impl NewUser {
99    pub fn new() -> Self {
100        Self::default()
101    }
102
103    pub fn with_email(mut self, email: impl Into<String>) -> Self {
104        self.email = Some(email.into());
105        self
106    }
107
108    pub fn with_name(mut self, name: impl Into<String>) -> Self {
109        self.name = Some(name.into());
110        self
111    }
112}
113
114/// User identity + provider-link persistence.
115///
116/// One `User` may be reachable through multiple `(ProviderKey, subject)`
117/// pairs — that's what `link_provider` is for. `list_devices` and
118/// `revoke_device` are the session-management surface: products surface them
119/// to end-users on an /account/sessions page (P12).
120#[async_trait]
121pub trait UserStore: Send + Sync {
122    /// Look up the user reachable via `(provider, subject)`. `None` if no
123    /// such link exists.
124    async fn find_by_provider(
125        &self,
126        provider: &ProviderKey,
127        subject: &str,
128    ) -> Result<Option<User>, StoreError>;
129
130    /// Create a fresh user (no provider linked yet). Returns the user with
131    /// its newly minted `UserId`.
132    async fn create(&self, new_user: NewUser) -> Result<User, StoreError>;
133
134    /// Link `(provider, subject)` to an existing user. Returns
135    /// `StoreError::Conflict` if the link already exists for a *different*
136    /// user; idempotent if it matches.
137    async fn link_provider(
138        &self,
139        user_id: &UserId,
140        provider: &ProviderKey,
141        subject: &str,
142    ) -> Result<(), StoreError>;
143
144    /// Enumerate every device this user has signed in from (and not revoked).
145    async fn list_devices(&self, user_id: &UserId) -> Result<Vec<DeviceId>, StoreError>;
146
147    /// Revoke a device. The two halves of "revoked" are first-class (R019-F4):
148    /// block *new* sessions by revoking the device's refresh chains
149    /// ([`RefreshStore::revoke_chain`]), and kill an *in-flight* access token by
150    /// its `jti` via [`RevocationWriter::revoke`](crate::revocation::RevocationWriter::revoke);
151    /// the edge enforces the latter through
152    /// [`RevocationReader`](cheers_verify::RevocationReader). This method records
153    /// the device-level intent; composing those calls is
154    /// [`SessionAuthority`](crate::session::SessionAuthority)'s job.
155    async fn revoke_device(
156        &self,
157        user_id: &UserId,
158        device_id: &DeviceId,
159    ) -> Result<(), StoreError>;
160}
161
162/// One row in a refresh-token rotation chain.
163///
164/// This struct is the persistence contract the rotation impls
165/// ([`RefreshRotator`](crate::refresh::RefreshRotator)) write against. The
166/// struct is `#[non_exhaustive]` (so adding fields stays SemVer-clean for
167/// downstream consumers), which blocks external struct-literal construction —
168/// use [`RefreshTokenRecord::new`] from a [`RefreshStore`] impl that needs to
169/// build one (e.g. in a `get` query handler).
170#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
171#[non_exhaustive]
172pub struct RefreshTokenRecord {
173    /// Storage key for this token: the SHA-256 hash (hex) of the opaque secret
174    /// the client holds, computed by the rotator. The raw secret is never
175    /// persisted, so a read-only store leak yields hashes, not usable tokens.
176    /// `RefreshStore` impls treat this as an opaque primary key.
177    pub token: String,
178    /// Stable identifier shared across every token in this rotation chain.
179    pub chain_id: String,
180    /// The `token` (hash) this one rotated from (`None` for the chain root).
181    pub parent: Option<String>,
182    pub user_id: UserId,
183    pub device_id: DeviceId,
184    pub issued_at: i64,
185    pub expires_at: i64,
186    /// `true` once this token has minted a successor. Re-presenting a
187    /// consumed token MUST revoke the whole chain (replay).
188    pub consumed: bool,
189    /// `true` once the chain has been revoked — by replay detection,
190    /// explicit logout, or device revocation.
191    pub revoked: bool,
192}
193
194impl RefreshTokenRecord {
195    /// Construct a record from its constituent fields. Use this from external
196    /// [`RefreshStore`] impls (the struct is `#[non_exhaustive]`, so direct
197    /// struct-literal construction is crate-local only). New fields land here
198    /// behind the `#[non_exhaustive]` shield so existing callers stay green.
199    #[allow(clippy::too_many_arguments)]
200    pub fn new(
201        token: String,
202        chain_id: String,
203        parent: Option<String>,
204        user_id: UserId,
205        device_id: DeviceId,
206        issued_at: i64,
207        expires_at: i64,
208        consumed: bool,
209        revoked: bool,
210    ) -> Self {
211        Self {
212            token,
213            chain_id,
214            parent,
215            user_id,
216            device_id,
217            issued_at,
218            expires_at,
219            consumed,
220            revoked,
221        }
222    }
223}
224
225/// Persistence for refresh-token rotation chains.
226#[async_trait]
227pub trait RefreshStore: Send + Sync {
228    async fn put(&self, record: &RefreshTokenRecord) -> Result<(), StoreError>;
229    async fn get(&self, token: &str) -> Result<Option<RefreshTokenRecord>, StoreError>;
230    /// Atomically transition `token` from unconsumed to consumed.
231    ///
232    /// Returns `Ok(true)` iff *this* call flipped `consumed` false→true, and
233    /// `Ok(false)` if no unconsumed row matched — the token was already
234    /// consumed (the losing side of a concurrent rotation) or is absent. The
235    /// check-and-set MUST be a single atomic operation (a conditional
236    /// `UPDATE … WHERE consumed = FALSE`, a Lua CAS, a guarded map mutation);
237    /// a read-then-write lets two concurrent rotations of the same token both
238    /// observe `consumed = false` and each mint a live successor. The rotator
239    /// relies on the returned bool to detect that double-spend and revoke the
240    /// chain, so an impl that always returns `true` reopens the replay hole.
241    async fn mark_consumed(&self, token: &str) -> Result<bool, StoreError>;
242    /// Revoke every record in `chain_id`. Idempotent.
243    async fn revoke_chain(&self, chain_id: &str) -> Result<(), StoreError>;
244}
245
246/// Origin-side multi-passkey-per-user persistence for the WebAuthn relying-party
247/// flow (P7 / R014).
248///
249/// Each row stores one passkey [`Credential`] (binding
250/// [`DeviceBinding::Passkey`](cheers_core::DeviceBinding::Passkey), `material`
251/// the `serde_json`-encoded `webauthn-rs` `Passkey`). The trait sits on
252/// `cheers-core`'s [`Credential`] rather than `webauthn-rs::Passkey` so this
253/// crate stays free of webauthn-rs in its public API — products bridge with
254/// `cheers::passkey::passkey_to_credential` /
255/// `cheers::passkey::passkey_from_credential`.
256///
257/// A user holds zero-or-more passkey credentials (phone, laptop, security key);
258/// `(user_id, device_id)` is the unique key. Non-discoverable WebAuthn flow:
259/// products call [`list_for_user`](Self::list_for_user) to assemble the
260/// `allow_credentials` set before `start_authentication`, and
261/// [`update`](Self::update) after a successful ceremony if the signature
262/// counter advanced (see `cheers::passkey::apply_authentication_result`).
263///
264/// Long-lived credential material, indexed by user — belongs in the relational
265/// SQL store (see `cheers-sqlx`), never in redis.
266#[async_trait]
267pub trait PasskeyCredentialStore: Send + Sync {
268    /// Insert a fresh passkey credential. The credential's
269    /// [`user_id`](Credential::user_id) and [`device_id`](Credential::device_id)
270    /// fields are the unique key; [`StoreError::Conflict`] if the pair is
271    /// already taken.
272    async fn put(&self, cred: &Credential) -> Result<(), StoreError>;
273
274    /// Every passkey credential owned by `user_id`. Order is unspecified.
275    async fn list_for_user(&self, user_id: &UserId) -> Result<Vec<Credential>, StoreError>;
276
277    /// Remove the passkey credential at `(user_id, device_id)`.
278    /// [`StoreError::NotFound`] if no such row exists.
279    async fn delete(&self, user_id: &UserId, device_id: &DeviceId) -> Result<(), StoreError>;
280
281    /// Rewrite the stored material for the credential's
282    /// `(user_id, device_id)` pair. Use after
283    /// `apply_authentication_result` to persist an advanced counter / updated
284    /// backup flags. [`StoreError::NotFound`] if the pair was never `put`.
285    async fn update(&self, cred: &Credential) -> Result<(), StoreError>;
286}
287
288#[cfg(test)]
289mod tests {
290    //! Trait-shape smoke tests via tiny in-memory impls. The "real" memory
291    //! impls live in the `cheers` crate (R015-T3).
292
293    use super::*;
294    use std::collections::HashMap;
295    use std::sync::Mutex;
296
297    #[derive(Default)]
298    struct MemUserStore {
299        inner: Mutex<MemUserInner>,
300    }
301
302    #[derive(Default)]
303    struct MemUserInner {
304        next_id: u64,
305        users: HashMap<UserId, User>,
306        links: HashMap<(ProviderKey, String), UserId>,
307        devices: HashMap<UserId, Vec<DeviceId>>,
308    }
309
310    #[async_trait]
311    impl UserStore for MemUserStore {
312        async fn find_by_provider(
313            &self,
314            provider: &ProviderKey,
315            subject: &str,
316        ) -> Result<Option<User>, StoreError> {
317            let g = self.inner.lock().unwrap();
318            Ok(g.links
319                .get(&(provider.clone(), subject.to_owned()))
320                .and_then(|id| g.users.get(id).cloned()))
321        }
322
323        async fn create(&self, new_user: NewUser) -> Result<User, StoreError> {
324            let mut g = self.inner.lock().unwrap();
325            g.next_id += 1;
326            let id = UserId::new(format!("u-{}", g.next_id));
327            let mut u = User::new(id.clone());
328            u.email = new_user.email;
329            u.name = new_user.name;
330            g.users.insert(id, u.clone());
331            Ok(u)
332        }
333
334        async fn link_provider(
335            &self,
336            user_id: &UserId,
337            provider: &ProviderKey,
338            subject: &str,
339        ) -> Result<(), StoreError> {
340            let mut g = self.inner.lock().unwrap();
341            let key = (provider.clone(), subject.to_owned());
342            match g.links.get(&key) {
343                Some(existing) if existing == user_id => Ok(()),
344                Some(_) => Err(StoreError::Conflict),
345                None => {
346                    g.links.insert(key, user_id.clone());
347                    Ok(())
348                }
349            }
350        }
351
352        async fn list_devices(&self, user_id: &UserId) -> Result<Vec<DeviceId>, StoreError> {
353            let g = self.inner.lock().unwrap();
354            Ok(g.devices.get(user_id).cloned().unwrap_or_default())
355        }
356
357        async fn revoke_device(
358            &self,
359            user_id: &UserId,
360            device_id: &DeviceId,
361        ) -> Result<(), StoreError> {
362            let mut g = self.inner.lock().unwrap();
363            let v = g.devices.entry(user_id.clone()).or_default();
364            let before = v.len();
365            v.retain(|d| d != device_id);
366            if v.len() == before {
367                return Err(StoreError::NotFound);
368            }
369            Ok(())
370        }
371    }
372
373    #[derive(Default)]
374    struct MemRefreshStore(Mutex<HashMap<String, RefreshTokenRecord>>);
375
376    #[async_trait]
377    impl RefreshStore for MemRefreshStore {
378        async fn put(&self, record: &RefreshTokenRecord) -> Result<(), StoreError> {
379            self.0
380                .lock()
381                .unwrap()
382                .insert(record.token.clone(), record.clone());
383            Ok(())
384        }
385        async fn get(&self, token: &str) -> Result<Option<RefreshTokenRecord>, StoreError> {
386            Ok(self.0.lock().unwrap().get(token).cloned())
387        }
388        async fn mark_consumed(&self, token: &str) -> Result<bool, StoreError> {
389            // Atomic under the map lock: only the caller that observes
390            // `consumed == false` flips it and reports `true`.
391            let mut g = self.0.lock().unwrap();
392            match g.get_mut(token) {
393                Some(r) if !r.consumed => {
394                    r.consumed = true;
395                    Ok(true)
396                }
397                _ => Ok(false),
398            }
399        }
400        async fn revoke_chain(&self, chain_id: &str) -> Result<(), StoreError> {
401            let mut g = self.0.lock().unwrap();
402            for r in g.values_mut() {
403                if r.chain_id == chain_id {
404                    r.revoked = true;
405                }
406            }
407            Ok(())
408        }
409    }
410
411    #[test]
412    fn user_store_create_link_find() {
413        let s = MemUserStore::default();
414        pollster::block_on(async {
415            let u = s.create(NewUser::new().with_email("a@b")).await.unwrap();
416            assert_eq!(u.email.as_deref(), Some("a@b"));
417            s.link_provider(&u.id, &ProviderKey::OidcGoogle, "google-sub-1")
418                .await
419                .unwrap();
420            // Idempotent re-link on same user.
421            s.link_provider(&u.id, &ProviderKey::OidcGoogle, "google-sub-1")
422                .await
423                .unwrap();
424            // Conflict with a different user.
425            let u2 = s.create(NewUser::new()).await.unwrap();
426            assert!(matches!(
427                s.link_provider(&u2.id, &ProviderKey::OidcGoogle, "google-sub-1")
428                    .await,
429                Err(StoreError::Conflict)
430            ));
431            // Lookup succeeds.
432            let found = s
433                .find_by_provider(&ProviderKey::OidcGoogle, "google-sub-1")
434                .await
435                .unwrap()
436                .unwrap();
437            assert_eq!(found.id, u.id);
438        });
439    }
440
441    #[test]
442    fn refresh_store_consume_and_revoke_chain() {
443        let s = MemRefreshStore::default();
444        pollster::block_on(async {
445            let r = RefreshTokenRecord {
446                token: "tok-1".into(),
447                chain_id: "chain-A".into(),
448                parent: None,
449                user_id: UserId::new("u1"),
450                device_id: DeviceId::new("d1"),
451                issued_at: 100,
452                expires_at: 1_000,
453                consumed: false,
454                revoked: false,
455            };
456            s.put(&r).await.unwrap();
457            // First consume transitions the row and reports true; a second
458            // consume finds nothing unconsumed and reports false (this is the
459            // signal the rotator turns into replay detection).
460            assert!(s.mark_consumed("tok-1").await.unwrap());
461            assert!(!s.mark_consumed("tok-1").await.unwrap());
462            assert!(!s.mark_consumed("no-such-token").await.unwrap());
463            assert!(s.get("tok-1").await.unwrap().unwrap().consumed);
464            s.revoke_chain("chain-A").await.unwrap();
465            assert!(s.get("tok-1").await.unwrap().unwrap().revoked);
466        });
467    }
468
469    #[derive(Default)]
470    struct MemPasskeyCredentialStore(Mutex<HashMap<(UserId, DeviceId), Credential>>);
471
472    #[async_trait]
473    impl PasskeyCredentialStore for MemPasskeyCredentialStore {
474        async fn put(&self, cred: &Credential) -> Result<(), StoreError> {
475            let mut g = self.0.lock().unwrap();
476            let key = (cred.user_id.clone(), cred.device_id.clone());
477            if g.contains_key(&key) {
478                return Err(StoreError::Conflict);
479            }
480            g.insert(key, cred.clone());
481            Ok(())
482        }
483
484        async fn list_for_user(&self, user_id: &UserId) -> Result<Vec<Credential>, StoreError> {
485            let g = self.0.lock().unwrap();
486            Ok(g.iter()
487                .filter(|((u, _), _)| u == user_id)
488                .map(|(_, c)| c.clone())
489                .collect())
490        }
491
492        async fn delete(
493            &self,
494            user_id: &UserId,
495            device_id: &DeviceId,
496        ) -> Result<(), StoreError> {
497            let mut g = self.0.lock().unwrap();
498            g.remove(&(user_id.clone(), device_id.clone()))
499                .ok_or(StoreError::NotFound)
500                .map(|_| ())
501        }
502
503        async fn update(&self, cred: &Credential) -> Result<(), StoreError> {
504            let mut g = self.0.lock().unwrap();
505            let key = (cred.user_id.clone(), cred.device_id.clone());
506            if !g.contains_key(&key) {
507                return Err(StoreError::NotFound);
508            }
509            g.insert(key, cred.clone());
510            Ok(())
511        }
512    }
513
514    fn passkey_cred(user: &str, device: &str, material: &[u8]) -> Credential {
515        Credential::new(
516            UserId::new(user),
517            DeviceId::new(device),
518            cheers_core::DeviceBinding::Passkey,
519            material.to_vec(),
520        )
521    }
522
523    #[test]
524    fn passkey_store_put_list_update_delete() {
525        let s = MemPasskeyCredentialStore::default();
526        pollster::block_on(async {
527            // Empty list for a fresh user.
528            assert!(s
529                .list_for_user(&UserId::new("u1"))
530                .await
531                .unwrap()
532                .is_empty());
533
534            // Put a credential, list it.
535            let phone = passkey_cred("u1", "phone", b"v1");
536            s.put(&phone).await.unwrap();
537            let list = s.list_for_user(&UserId::new("u1")).await.unwrap();
538            assert_eq!(list, vec![phone.clone()]);
539
540            // Put a second credential for the same user.
541            let laptop = passkey_cred("u1", "laptop", b"v1-laptop");
542            s.put(&laptop).await.unwrap();
543            let mut list = s.list_for_user(&UserId::new("u1")).await.unwrap();
544            list.sort_by(|a, b| a.device_id.as_str().cmp(b.device_id.as_str()));
545            assert_eq!(list, vec![laptop.clone(), phone.clone()]);
546
547            // Re-putting the same (user, device) conflicts.
548            assert!(matches!(
549                s.put(&phone).await,
550                Err(StoreError::Conflict)
551            ));
552
553            // Update rewrites the material (counter advance).
554            let phone_v2 = passkey_cred("u1", "phone", b"v2");
555            s.update(&phone_v2).await.unwrap();
556            let mut list = s.list_for_user(&UserId::new("u1")).await.unwrap();
557            list.sort_by(|a, b| a.device_id.as_str().cmp(b.device_id.as_str()));
558            assert_eq!(list[1].material, b"v2");
559
560            // Update on a missing (user, device) is NotFound.
561            let ghost = passkey_cred("u1", "ghost", b"x");
562            assert!(matches!(
563                s.update(&ghost).await,
564                Err(StoreError::NotFound)
565            ));
566
567            // Delete works once.
568            s.delete(&UserId::new("u1"), &DeviceId::new("phone"))
569                .await
570                .unwrap();
571            assert!(matches!(
572                s.delete(&UserId::new("u1"), &DeviceId::new("phone"))
573                    .await,
574                Err(StoreError::NotFound)
575            ));
576
577            // Second user's credentials are unaffected.
578            let other = passkey_cred("u2", "phone", b"u2-v1");
579            s.put(&other).await.unwrap();
580            assert_eq!(
581                s.list_for_user(&UserId::new("u1")).await.unwrap(),
582                vec![laptop]
583            );
584            assert_eq!(
585                s.list_for_user(&UserId::new("u2")).await.unwrap(),
586                vec![other]
587            );
588        });
589    }
590
591    #[test]
592    fn traits_are_dyn_compatible() {
593        fn _u(_: &dyn UserStore) {}
594        fn _r(_: &dyn RefreshStore) {}
595        fn _p(_: &dyn PasskeyCredentialStore) {}
596    }
597}