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//!
61//! @yah:relay(R517, "User-by-id lookup on UserStore")
62//! @yah:at(2026-09-10T07:02:42Z)
63//! @yah:assignee(agent:bundle-anthropic-ashguard)
64//! @yah:next("UserStore can find a user by (provider, subject) but never by UserId, so a service holding a verified bearer cannot recover the user record behind it. Downstream consumers are keyed on email as a workaround. Close the gap with a by-id accessor.")
65//!
66//! @yah:ticket(R517-F1, "UserStore::get(&UserId) — the missing user-by-id accessor")
67//! @yah:status(review)
68//! @yah:at(2026-09-10T07:32:34Z)
69//! @yah:assignee(agent:bundle-anthropic-ashguard)
70//! @yah:parent(R517)
71//! @yah:next("ADD ONE METHOD TO THE UserStore TRAIT: `async fn get(&self, user_id: &UserId) -> Result<Option<User>, StoreError>`. The trait is at crates/cheers-server/src/store.rs:121 and today has exactly five methods — find_by_provider :124, create :132, link_provider :137, list_devices :145, revoke_device :155. Only find_by_provider and create return a User and both key on (ProviderKey, subject); UserId appears ONLY as an input to link_provider / list_devices / revoke_device. There is no &UserId -> User mapping anywhere, under any name (no get, find, find_by_id, lookup, load).")
72//! @yah:assumes("Tier: Warrior — one trait method, but seven implementors across five crates, three of them real database backends (Turso, Postgres, SQLite) each needing a correct by-id query, and it is a published-trait change and therefore a semver event.")
73//! @yah:handoff("LANDED. `UserStore::get(&UserId) -> Result<Option<User>, StoreError>` declared at crates/cheers-server/src/store.rs:147 and implemented in all seven implementors: TursoUserStore (cheers-turso/src/user_store.rs:59), PgUserStore (cheers-sqlx/src/user_store.rs:102), SqliteUserStore (:306), MemUserStore in cheers-test-support (mem.rs:33), the in-crate test double (cheers-server/src/store.rs:339), StubUsers (cheers-server/src/session.rs:483), and the cheers-axum test double (tests/common/mod.rs:50). Required method, no default body — pre-1.0, and a defaulted `Ok(None)` would let an unmigrated backend silently answer \"no such user\" for every live user. The three DB backends each get a real single-table `SELECT user_id, email, name FROM users WHERE user_id = ?` and deliberately do NOT join oauth_identities, so a user with no provider link is still reachable by id. Unknown id is Ok(None), matching find_by_provider; consistent with revocation being per-device, so a fully-revoked user still resolves.")
74//! @yah:handoff("TESTS: new shared contract scenario `user_store_get_by_id` at crates/cheers-test-support/src/store_scenarios.rs:118, run by all three real backends (turso.rs:51, sqlite.rs:69, pg.rs:73) — that file's header explains why one shared suite beats per-backend copies, and this follows it. It pins five things a backend can plausibly get wrong: unknown id is Ok(None) not an error; a fetched user carries the same email AND name the create returned (not just a matching id); NULL email/name round-trip as None rather than empty strings; the id discriminates, so a too-loose WHERE hands back the wrong row and fails; and an UNLINKED user is still reachable by id, which is the assertion that fails if someone later \"optimizes\" the query into a JOIN on oauth_identities. Separate unit test at cheers-server/src/store.rs:474 covers the in-crate double.")
75//! @yah:handoff("DISCOVERED WORK, fixed in this pass — the email workaround the ticket description names was real and is now gone. cheers-axum's test double carried a bespoke non-trait helper `MemUserStore::lookup_email`, and its two callers each already HELD the user id and reached for email anyway, purely because UserStore had no by-id accessor: magic_link_basic.rs:163 read `verify_body[\"user_id\"]` on the line above, and google_round_trip.rs:217 asserted on `body[\"user_id\"]` six lines above. Both now resolve through `users().get(&UserId::new(...))`, which strengthens the assertion — it pins that the id the route hands the client is the key the store actually answers to (the same value a bearer later carries as `sub`), rather than trusting that the single row bearing that email is the right one. `lookup_email` deleted (tests/common/mod.rs); grep confirms zero remaining references. Its sibling `user_count` is still used and stays.")
76//! @yah:verify("GREEN, measured. `cargo test --workspace --all-features` in oss/cheers: 33 test binaries, 0 failures — includes cheers-server 140, cheers-axum 69+54+12, and the new by-id scenario passing under both real engines (turso `test user_store_get_by_id ... ok`, sqlx-sqlite `test user_store_get_by_id ... ok`). Postgres is compile-verified only (`cargo test -p cheers-sqlx --features pg --no-run` builds tests/pg.rs clean); a live pg run needs Docker, which matches how the existing pg scenarios in this crate are already gated.")
77//! @yah:verify("`cargo clippy --workspace --all-targets --all-features` exits 0. Its warnings are pre-existing (very-complex-type, missing-Safety-section); the one needless-borrow in cheers-server resolves to camp.rs:1031, not to anything I wrote. rustfmt --check is clean on every hunk I authored — I checked each edited file's diff line numbers against my own line ranges. The ~19 remaining diffs across store.rs / session.rs / store_scenarios.rs / pg.rs / sqlite.rs / turso.rs / tests-common-mod.rs are pre-existing drift outside those ranges and were left untouched. I did NOT run `cargo fmt`: on this tree it follows path deps into peers' crates, which is the 2026-08-28 incident the root CLAUDE.md documents.")
78//! @yah:gotcha("FOR THE REVIEWER, READ BEFORE THE DIFF: `git diff --stat -- oss/cheers` shows only 4 of the 12 files I touched. The other 8 (the trait declaration and every DB-backend impl) were swept into a peer's wip-commit mid-session — normal on this shared tree, and exactly the case where `git status` proves nothing. Verified by CONTENT instead: grepping `async fn get(&self, user_id: &UserId)` across oss/cheers returns the trait decl (store.rs:147) plus all 7 impls, and `user_store_get_by_id` returns the scenario definition plus all 3 backend call sites plus the unit test. Do not read the short diffstat as \"the backends went unimplemented\".")
79//! @yah:gotcha("Build-infra noise, not a code defect, recorded so it isn't re-diagnosed: one mid-session `cargo test -p cheers-axum` died with `could not write output to oss/cheers/target/debug/deps/main-59a3a7ff85ffe626.<session>.rcgu.o: No such file or directory` — the compiler unable to write into its own incremental-session dir. Textbook R770, and green on an identical immediate re-run with no source change between the two. Per the root CLAUDE.md procedure I checked `cargo orphan-gc log -n 2000` FIRST rather than cleaning, so the evidence survived: the log does NOT name that path, that hash, or the cheers-axum `main` family. Appended to R770 as a fresh occurrence, along with the observation that its `collected N surplus incremental sessions` line never records WHICH session dirs it swept — which is precisely why a confirmed hit is currently indistinguishable from a miss in that log.")
80
81use async_trait::async_trait;
82use serde::{Deserialize, Serialize};
83
84use cheers_core::{Credential, DeviceId, StoreError, User, UserId};
85
86/// The external identity-provider namespace a `subject` string lives in.
87///
88/// Two users with the same Google `sub` must collide; the same email address
89/// presented to Apple vs. Google must *not* collide. `ProviderKey` is the
90/// namespace tag that disambiguates.
91#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
92#[non_exhaustive]
93#[serde(tag = "provider", rename_all = "snake_case")]
94pub enum ProviderKey {
95    /// Google OIDC — `subject` is the `sub` claim from Google's ID token.
96    OidcGoogle,
97    /// Apple Sign In — `subject` is the `sub` claim from Apple's ID token.
98    OidcApple,
99    /// Any other OIDC issuer; `subject` is that issuer's `sub` claim.
100    OidcGeneric { issuer: String },
101    /// Email-based identity (magic-link or password). `subject` is the email.
102    Email,
103    /// LAN-pair identity. `subject` is the device's mshr node-id.
104    LanPair,
105}
106
107/// Fields required to mint a fresh `User`.
108///
109/// `UserStore::create` returns the resulting `User` with a freshly minted
110/// `UserId`; the caller has no say in the id shape.
111#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
112#[non_exhaustive]
113pub struct NewUser {
114    pub email: Option<String>,
115    pub name: Option<String>,
116}
117
118impl NewUser {
119    pub fn new() -> Self {
120        Self::default()
121    }
122
123    pub fn with_email(mut self, email: impl Into<String>) -> Self {
124        self.email = Some(email.into());
125        self
126    }
127
128    pub fn with_name(mut self, name: impl Into<String>) -> Self {
129        self.name = Some(name.into());
130        self
131    }
132}
133
134/// User identity + provider-link persistence.
135///
136/// One `User` may be reachable through multiple `(ProviderKey, subject)`
137/// pairs — that's what `link_provider` is for. `list_devices` and
138/// `revoke_device` are the session-management surface: products surface them
139/// to end-users on an /account/sessions page (P12).
140#[async_trait]
141pub trait UserStore: Send + Sync {
142    /// Look up a user by its `UserId`. `None` if no such user exists.
143    ///
144    /// This is the accessor a service reaches for when it already holds a
145    /// verified bearer: `Claims::sub` *is* a `UserId`, so everything past
146    /// authentication has the id and nothing else. Without this, recovering the
147    /// record behind a token meant re-deriving it from `(provider, subject)` —
148    /// which the token doesn't carry — or keying downstream state on `email`,
149    /// which is nullable on `User` and not the identity.
150    ///
151    /// A revoked *device* does not hide the user: revocation is per-device
152    /// (`revoke_device`), and the user row outlives it. `None` here means the
153    /// id names no user at all.
154    async fn get(&self, user_id: &UserId) -> Result<Option<User>, StoreError>;
155
156    /// Look up the user reachable via `(provider, subject)`. `None` if no
157    /// such link exists.
158    async fn find_by_provider(
159        &self,
160        provider: &ProviderKey,
161        subject: &str,
162    ) -> Result<Option<User>, StoreError>;
163
164    /// Create a fresh user (no provider linked yet). Returns the user with
165    /// its newly minted `UserId`.
166    async fn create(&self, new_user: NewUser) -> Result<User, StoreError>;
167
168    /// Link `(provider, subject)` to an existing user. Returns
169    /// `StoreError::Conflict` if the link already exists for a *different*
170    /// user; idempotent if it matches.
171    async fn link_provider(
172        &self,
173        user_id: &UserId,
174        provider: &ProviderKey,
175        subject: &str,
176    ) -> Result<(), StoreError>;
177
178    /// Enumerate every device this user has signed in from (and not revoked).
179    async fn list_devices(&self, user_id: &UserId) -> Result<Vec<DeviceId>, StoreError>;
180
181    /// Revoke a device. The two halves of "revoked" are first-class (R019-F4):
182    /// block *new* sessions by revoking the device's refresh chains
183    /// ([`RefreshStore::revoke_chain`]), and kill an *in-flight* access token by
184    /// its `jti` via [`RevocationWriter::revoke`](crate::revocation::RevocationWriter::revoke);
185    /// the edge enforces the latter through
186    /// [`RevocationReader`](cheers_verify::RevocationReader). This method records
187    /// the device-level intent; composing those calls is
188    /// [`SessionAuthority`](crate::session::SessionAuthority)'s job.
189    async fn revoke_device(
190        &self,
191        user_id: &UserId,
192        device_id: &DeviceId,
193    ) -> Result<(), StoreError>;
194}
195
196/// One row in a refresh-token rotation chain.
197///
198/// This struct is the persistence contract the rotation impls
199/// ([`RefreshRotator`](crate::refresh::RefreshRotator)) write against. The
200/// struct is `#[non_exhaustive]` (so adding fields stays SemVer-clean for
201/// downstream consumers), which blocks external struct-literal construction —
202/// use [`RefreshTokenRecord::new`] from a [`RefreshStore`] impl that needs to
203/// build one (e.g. in a `get` query handler).
204#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
205#[non_exhaustive]
206pub struct RefreshTokenRecord {
207    /// Storage key for this token: the SHA-256 hash (hex) of the opaque secret
208    /// the client holds, computed by the rotator. The raw secret is never
209    /// persisted, so a read-only store leak yields hashes, not usable tokens.
210    /// `RefreshStore` impls treat this as an opaque primary key.
211    pub token: String,
212    /// Stable identifier shared across every token in this rotation chain.
213    pub chain_id: String,
214    /// The `token` (hash) this one rotated from (`None` for the chain root).
215    pub parent: Option<String>,
216    pub user_id: UserId,
217    pub device_id: DeviceId,
218    pub issued_at: i64,
219    pub expires_at: i64,
220    /// `true` once this token has minted a successor. Re-presenting a
221    /// consumed token MUST revoke the whole chain (replay).
222    pub consumed: bool,
223    /// `true` once the chain has been revoked — by replay detection,
224    /// explicit logout, or device revocation.
225    pub revoked: bool,
226}
227
228impl RefreshTokenRecord {
229    /// Construct a record from its constituent fields. Use this from external
230    /// [`RefreshStore`] impls (the struct is `#[non_exhaustive]`, so direct
231    /// struct-literal construction is crate-local only). New fields land here
232    /// behind the `#[non_exhaustive]` shield so existing callers stay green.
233    #[allow(clippy::too_many_arguments)]
234    pub fn new(
235        token: String,
236        chain_id: String,
237        parent: Option<String>,
238        user_id: UserId,
239        device_id: DeviceId,
240        issued_at: i64,
241        expires_at: i64,
242        consumed: bool,
243        revoked: bool,
244    ) -> Self {
245        Self {
246            token,
247            chain_id,
248            parent,
249            user_id,
250            device_id,
251            issued_at,
252            expires_at,
253            consumed,
254            revoked,
255        }
256    }
257}
258
259/// Persistence for refresh-token rotation chains.
260#[async_trait]
261pub trait RefreshStore: Send + Sync {
262    async fn put(&self, record: &RefreshTokenRecord) -> Result<(), StoreError>;
263    async fn get(&self, token: &str) -> Result<Option<RefreshTokenRecord>, StoreError>;
264    /// Atomically transition `token` from unconsumed to consumed.
265    ///
266    /// Returns `Ok(true)` iff *this* call flipped `consumed` false→true, and
267    /// `Ok(false)` if no unconsumed row matched — the token was already
268    /// consumed (the losing side of a concurrent rotation) or is absent. The
269    /// check-and-set MUST be a single atomic operation (a conditional
270    /// `UPDATE … WHERE consumed = FALSE`, a Lua CAS, a guarded map mutation);
271    /// a read-then-write lets two concurrent rotations of the same token both
272    /// observe `consumed = false` and each mint a live successor. The rotator
273    /// relies on the returned bool to detect that double-spend and revoke the
274    /// chain, so an impl that always returns `true` reopens the replay hole.
275    async fn mark_consumed(&self, token: &str) -> Result<bool, StoreError>;
276    /// Revoke every record in `chain_id`. Idempotent.
277    async fn revoke_chain(&self, chain_id: &str) -> Result<(), StoreError>;
278}
279
280/// Origin-side multi-passkey-per-user persistence for the WebAuthn relying-party
281/// flow (P7 / R014).
282///
283/// Each row stores one passkey [`Credential`] (binding
284/// [`DeviceBinding::Passkey`](cheers_core::DeviceBinding::Passkey), `material`
285/// the `serde_json`-encoded `webauthn-rs` `Passkey`). The trait sits on
286/// `cheers-core`'s [`Credential`] rather than `webauthn-rs::Passkey` so this
287/// crate stays free of webauthn-rs in its public API — products bridge with
288/// `cheers::passkey::passkey_to_credential` /
289/// `cheers::passkey::passkey_from_credential`.
290///
291/// A user holds zero-or-more passkey credentials (phone, laptop, security key);
292/// `(user_id, device_id)` is the unique key. Non-discoverable WebAuthn flow:
293/// products call [`list_for_user`](Self::list_for_user) to assemble the
294/// `allow_credentials` set before `start_authentication`, and
295/// [`update`](Self::update) after a successful ceremony if the signature
296/// counter advanced (see `cheers::passkey::apply_authentication_result`).
297///
298/// Long-lived credential material, indexed by user — belongs in the relational
299/// SQL store (see `cheers-sqlx`), never in redis.
300#[async_trait]
301pub trait PasskeyCredentialStore: Send + Sync {
302    /// Insert a fresh passkey credential. The credential's
303    /// [`user_id`](Credential::user_id) and [`device_id`](Credential::device_id)
304    /// fields are the unique key; [`StoreError::Conflict`] if the pair is
305    /// already taken.
306    async fn put(&self, cred: &Credential) -> Result<(), StoreError>;
307
308    /// Every passkey credential owned by `user_id`. Order is unspecified.
309    async fn list_for_user(&self, user_id: &UserId) -> Result<Vec<Credential>, StoreError>;
310
311    /// Remove the passkey credential at `(user_id, device_id)`.
312    /// [`StoreError::NotFound`] if no such row exists.
313    async fn delete(&self, user_id: &UserId, device_id: &DeviceId) -> Result<(), StoreError>;
314
315    /// Rewrite the stored material for the credential's
316    /// `(user_id, device_id)` pair. Use after
317    /// `apply_authentication_result` to persist an advanced counter / updated
318    /// backup flags. [`StoreError::NotFound`] if the pair was never `put`.
319    async fn update(&self, cred: &Credential) -> Result<(), StoreError>;
320}
321
322#[cfg(test)]
323mod tests {
324    //! Trait-shape smoke tests via tiny in-memory impls. The "real" memory
325    //! impls live in the `cheers` crate (R015-T3).
326
327    use super::*;
328    use std::collections::HashMap;
329    use std::sync::Mutex;
330
331    #[derive(Default)]
332    struct MemUserStore {
333        inner: Mutex<MemUserInner>,
334    }
335
336    #[derive(Default)]
337    struct MemUserInner {
338        next_id: u64,
339        users: HashMap<UserId, User>,
340        links: HashMap<(ProviderKey, String), UserId>,
341        devices: HashMap<UserId, Vec<DeviceId>>,
342    }
343
344    #[async_trait]
345    impl UserStore for MemUserStore {
346        async fn get(&self, user_id: &UserId) -> Result<Option<User>, StoreError> {
347            let g = self.inner.lock().unwrap();
348            Ok(g.users.get(user_id).cloned())
349        }
350
351        async fn find_by_provider(
352            &self,
353            provider: &ProviderKey,
354            subject: &str,
355        ) -> Result<Option<User>, StoreError> {
356            let g = self.inner.lock().unwrap();
357            Ok(g.links
358                .get(&(provider.clone(), subject.to_owned()))
359                .and_then(|id| g.users.get(id).cloned()))
360        }
361
362        async fn create(&self, new_user: NewUser) -> Result<User, StoreError> {
363            let mut g = self.inner.lock().unwrap();
364            g.next_id += 1;
365            let id = UserId::new(format!("u-{}", g.next_id));
366            let mut u = User::new(id.clone());
367            u.email = new_user.email;
368            u.name = new_user.name;
369            g.users.insert(id, u.clone());
370            Ok(u)
371        }
372
373        async fn link_provider(
374            &self,
375            user_id: &UserId,
376            provider: &ProviderKey,
377            subject: &str,
378        ) -> Result<(), StoreError> {
379            let mut g = self.inner.lock().unwrap();
380            let key = (provider.clone(), subject.to_owned());
381            match g.links.get(&key) {
382                Some(existing) if existing == user_id => Ok(()),
383                Some(_) => Err(StoreError::Conflict),
384                None => {
385                    g.links.insert(key, user_id.clone());
386                    Ok(())
387                }
388            }
389        }
390
391        async fn list_devices(&self, user_id: &UserId) -> Result<Vec<DeviceId>, StoreError> {
392            let g = self.inner.lock().unwrap();
393            Ok(g.devices.get(user_id).cloned().unwrap_or_default())
394        }
395
396        async fn revoke_device(
397            &self,
398            user_id: &UserId,
399            device_id: &DeviceId,
400        ) -> Result<(), StoreError> {
401            let mut g = self.inner.lock().unwrap();
402            let v = g.devices.entry(user_id.clone()).or_default();
403            let before = v.len();
404            v.retain(|d| d != device_id);
405            if v.len() == before {
406                return Err(StoreError::NotFound);
407            }
408            Ok(())
409        }
410    }
411
412    #[derive(Default)]
413    struct MemRefreshStore(Mutex<HashMap<String, RefreshTokenRecord>>);
414
415    #[async_trait]
416    impl RefreshStore for MemRefreshStore {
417        async fn put(&self, record: &RefreshTokenRecord) -> Result<(), StoreError> {
418            self.0
419                .lock()
420                .unwrap()
421                .insert(record.token.clone(), record.clone());
422            Ok(())
423        }
424        async fn get(&self, token: &str) -> Result<Option<RefreshTokenRecord>, StoreError> {
425            Ok(self.0.lock().unwrap().get(token).cloned())
426        }
427        async fn mark_consumed(&self, token: &str) -> Result<bool, StoreError> {
428            // Atomic under the map lock: only the caller that observes
429            // `consumed == false` flips it and reports `true`.
430            let mut g = self.0.lock().unwrap();
431            match g.get_mut(token) {
432                Some(r) if !r.consumed => {
433                    r.consumed = true;
434                    Ok(true)
435                }
436                _ => Ok(false),
437            }
438        }
439        async fn revoke_chain(&self, chain_id: &str) -> Result<(), StoreError> {
440            let mut g = self.0.lock().unwrap();
441            for r in g.values_mut() {
442                if r.chain_id == chain_id {
443                    r.revoked = true;
444                }
445            }
446            Ok(())
447        }
448    }
449
450    #[test]
451    fn user_store_create_link_find() {
452        let s = MemUserStore::default();
453        pollster::block_on(async {
454            let u = s.create(NewUser::new().with_email("a@b")).await.unwrap();
455            assert_eq!(u.email.as_deref(), Some("a@b"));
456            s.link_provider(&u.id, &ProviderKey::OidcGoogle, "google-sub-1")
457                .await
458                .unwrap();
459            // Idempotent re-link on same user.
460            s.link_provider(&u.id, &ProviderKey::OidcGoogle, "google-sub-1")
461                .await
462                .unwrap();
463            // Conflict with a different user.
464            let u2 = s.create(NewUser::new()).await.unwrap();
465            assert!(matches!(
466                s.link_provider(&u2.id, &ProviderKey::OidcGoogle, "google-sub-1")
467                    .await,
468                Err(StoreError::Conflict)
469            ));
470            // Lookup succeeds.
471            let found = s
472                .find_by_provider(&ProviderKey::OidcGoogle, "google-sub-1")
473                .await
474                .unwrap()
475                .unwrap();
476            assert_eq!(found.id, u.id);
477        });
478    }
479
480    #[test]
481    fn user_store_get_by_id() {
482        let s = MemUserStore::default();
483        pollster::block_on(async {
484            // An id that names no user is a miss, not an error.
485            assert!(s.get(&UserId::new("nope")).await.unwrap().is_none());
486
487            let u = s
488                .create(NewUser::new().with_email("a@b").with_name("A"))
489                .await
490                .unwrap();
491            let got = s.get(&u.id).await.unwrap().expect("created user by id");
492            assert_eq!(got.id, u.id);
493            assert_eq!(got.email.as_deref(), Some("a@b"));
494            assert_eq!(got.name.as_deref(), Some("A"));
495
496            // The id discriminates between users.
497            let u2 = s.create(NewUser::new()).await.unwrap();
498            assert_ne!(u2.id, u.id);
499            assert_eq!(s.get(&u2.id).await.unwrap().map(|x| x.id), Some(u2.id));
500        });
501    }
502
503    #[test]
504    fn refresh_store_consume_and_revoke_chain() {
505        let s = MemRefreshStore::default();
506        pollster::block_on(async {
507            let r = RefreshTokenRecord {
508                token: "tok-1".into(),
509                chain_id: "chain-A".into(),
510                parent: None,
511                user_id: UserId::new("u1"),
512                device_id: DeviceId::new("d1"),
513                issued_at: 100,
514                expires_at: 1_000,
515                consumed: false,
516                revoked: false,
517            };
518            s.put(&r).await.unwrap();
519            // First consume transitions the row and reports true; a second
520            // consume finds nothing unconsumed and reports false (this is the
521            // signal the rotator turns into replay detection).
522            assert!(s.mark_consumed("tok-1").await.unwrap());
523            assert!(!s.mark_consumed("tok-1").await.unwrap());
524            assert!(!s.mark_consumed("no-such-token").await.unwrap());
525            assert!(s.get("tok-1").await.unwrap().unwrap().consumed);
526            s.revoke_chain("chain-A").await.unwrap();
527            assert!(s.get("tok-1").await.unwrap().unwrap().revoked);
528        });
529    }
530
531    #[derive(Default)]
532    struct MemPasskeyCredentialStore(Mutex<HashMap<(UserId, DeviceId), Credential>>);
533
534    #[async_trait]
535    impl PasskeyCredentialStore for MemPasskeyCredentialStore {
536        async fn put(&self, cred: &Credential) -> Result<(), StoreError> {
537            let mut g = self.0.lock().unwrap();
538            let key = (cred.user_id.clone(), cred.device_id.clone());
539            if g.contains_key(&key) {
540                return Err(StoreError::Conflict);
541            }
542            g.insert(key, cred.clone());
543            Ok(())
544        }
545
546        async fn list_for_user(&self, user_id: &UserId) -> Result<Vec<Credential>, StoreError> {
547            let g = self.0.lock().unwrap();
548            Ok(g.iter()
549                .filter(|((u, _), _)| u == user_id)
550                .map(|(_, c)| c.clone())
551                .collect())
552        }
553
554        async fn delete(
555            &self,
556            user_id: &UserId,
557            device_id: &DeviceId,
558        ) -> Result<(), StoreError> {
559            let mut g = self.0.lock().unwrap();
560            g.remove(&(user_id.clone(), device_id.clone()))
561                .ok_or(StoreError::NotFound)
562                .map(|_| ())
563        }
564
565        async fn update(&self, cred: &Credential) -> Result<(), StoreError> {
566            let mut g = self.0.lock().unwrap();
567            let key = (cred.user_id.clone(), cred.device_id.clone());
568            if !g.contains_key(&key) {
569                return Err(StoreError::NotFound);
570            }
571            g.insert(key, cred.clone());
572            Ok(())
573        }
574    }
575
576    fn passkey_cred(user: &str, device: &str, material: &[u8]) -> Credential {
577        Credential::new(
578            UserId::new(user),
579            DeviceId::new(device),
580            cheers_core::DeviceBinding::Passkey,
581            material.to_vec(),
582        )
583    }
584
585    #[test]
586    fn passkey_store_put_list_update_delete() {
587        let s = MemPasskeyCredentialStore::default();
588        pollster::block_on(async {
589            // Empty list for a fresh user.
590            assert!(s
591                .list_for_user(&UserId::new("u1"))
592                .await
593                .unwrap()
594                .is_empty());
595
596            // Put a credential, list it.
597            let phone = passkey_cred("u1", "phone", b"v1");
598            s.put(&phone).await.unwrap();
599            let list = s.list_for_user(&UserId::new("u1")).await.unwrap();
600            assert_eq!(list, vec![phone.clone()]);
601
602            // Put a second credential for the same user.
603            let laptop = passkey_cred("u1", "laptop", b"v1-laptop");
604            s.put(&laptop).await.unwrap();
605            let mut list = s.list_for_user(&UserId::new("u1")).await.unwrap();
606            list.sort_by(|a, b| a.device_id.as_str().cmp(b.device_id.as_str()));
607            assert_eq!(list, vec![laptop.clone(), phone.clone()]);
608
609            // Re-putting the same (user, device) conflicts.
610            assert!(matches!(
611                s.put(&phone).await,
612                Err(StoreError::Conflict)
613            ));
614
615            // Update rewrites the material (counter advance).
616            let phone_v2 = passkey_cred("u1", "phone", b"v2");
617            s.update(&phone_v2).await.unwrap();
618            let mut list = s.list_for_user(&UserId::new("u1")).await.unwrap();
619            list.sort_by(|a, b| a.device_id.as_str().cmp(b.device_id.as_str()));
620            assert_eq!(list[1].material, b"v2");
621
622            // Update on a missing (user, device) is NotFound.
623            let ghost = passkey_cred("u1", "ghost", b"x");
624            assert!(matches!(
625                s.update(&ghost).await,
626                Err(StoreError::NotFound)
627            ));
628
629            // Delete works once.
630            s.delete(&UserId::new("u1"), &DeviceId::new("phone"))
631                .await
632                .unwrap();
633            assert!(matches!(
634                s.delete(&UserId::new("u1"), &DeviceId::new("phone"))
635                    .await,
636                Err(StoreError::NotFound)
637            ));
638
639            // Second user's credentials are unaffected.
640            let other = passkey_cred("u2", "phone", b"u2-v1");
641            s.put(&other).await.unwrap();
642            assert_eq!(
643                s.list_for_user(&UserId::new("u1")).await.unwrap(),
644                vec![laptop]
645            );
646            assert_eq!(
647                s.list_for_user(&UserId::new("u2")).await.unwrap(),
648                vec![other]
649            );
650        });
651    }
652
653    #[test]
654    fn traits_are_dyn_compatible() {
655        fn _u(_: &dyn UserStore) {}
656        fn _r(_: &dyn RefreshStore) {}
657        fn _p(_: &dyn PasskeyCredentialStore) {}
658    }
659}