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:status(open)
64//! @yah:assignee(agent:bundle-anthropic-ashguard)
65//! @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.")
66//!
67//! @yah:ticket(R517-F1, "UserStore::get(&UserId) — the missing user-by-id accessor")
68//! @yah:status(review)
69//! @yah:at(2026-09-10T07:32:34Z)
70//! @yah:assignee(agent:bundle-anthropic-ashguard)
71//! @yah:parent(R517)
72//! @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).")
73//! @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.")
74//! @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.")
75//! @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.")
76//! @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.")
77//! @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.")
78//! @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.")
79//! @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\".")
80//! @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.")
81
82use async_trait::async_trait;
83use serde::{Deserialize, Serialize};
84
85use cheers_core::{Credential, DeviceId, StoreError, User, UserId};
86
87/// The external identity-provider namespace a `subject` string lives in.
88///
89/// Two users with the same Google `sub` must collide; the same email address
90/// presented to Apple vs. Google must *not* collide. `ProviderKey` is the
91/// namespace tag that disambiguates.
92#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
93#[non_exhaustive]
94#[serde(tag = "provider", rename_all = "snake_case")]
95pub enum ProviderKey {
96    /// Google OIDC — `subject` is the `sub` claim from Google's ID token.
97    OidcGoogle,
98    /// Apple Sign In — `subject` is the `sub` claim from Apple's ID token.
99    OidcApple,
100    /// Any other OIDC issuer; `subject` is that issuer's `sub` claim.
101    OidcGeneric { issuer: String },
102    /// Email-based identity (magic-link or password). `subject` is the email.
103    Email,
104    /// LAN-pair identity. `subject` is the device's mshr node-id.
105    LanPair,
106}
107
108/// Fields required to mint a fresh `User`.
109///
110/// `UserStore::create` returns the resulting `User` with a freshly minted
111/// `UserId`; the caller has no say in the id shape.
112#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
113#[non_exhaustive]
114pub struct NewUser {
115    pub email: Option<String>,
116    pub name: Option<String>,
117}
118
119impl NewUser {
120    pub fn new() -> Self {
121        Self::default()
122    }
123
124    pub fn with_email(mut self, email: impl Into<String>) -> Self {
125        self.email = Some(email.into());
126        self
127    }
128
129    pub fn with_name(mut self, name: impl Into<String>) -> Self {
130        self.name = Some(name.into());
131        self
132    }
133}
134
135/// User identity + provider-link persistence.
136///
137/// One `User` may be reachable through multiple `(ProviderKey, subject)`
138/// pairs — that's what `link_provider` is for. `list_devices` and
139/// `revoke_device` are the session-management surface: products surface them
140/// to end-users on an /account/sessions page (P12).
141#[async_trait]
142pub trait UserStore: Send + Sync {
143    /// Look up a user by its `UserId`. `None` if no such user exists.
144    ///
145    /// This is the accessor a service reaches for when it already holds a
146    /// verified bearer: `Claims::sub` *is* a `UserId`, so everything past
147    /// authentication has the id and nothing else. Without this, recovering the
148    /// record behind a token meant re-deriving it from `(provider, subject)` —
149    /// which the token doesn't carry — or keying downstream state on `email`,
150    /// which is nullable on `User` and not the identity.
151    ///
152    /// A revoked *device* does not hide the user: revocation is per-device
153    /// (`revoke_device`), and the user row outlives it. `None` here means the
154    /// id names no user at all.
155    async fn get(&self, user_id: &UserId) -> Result<Option<User>, StoreError>;
156
157    /// Look up the user reachable via `(provider, subject)`. `None` if no
158    /// such link exists.
159    async fn find_by_provider(
160        &self,
161        provider: &ProviderKey,
162        subject: &str,
163    ) -> Result<Option<User>, StoreError>;
164
165    /// Create a fresh user (no provider linked yet). Returns the user with
166    /// its newly minted `UserId`.
167    async fn create(&self, new_user: NewUser) -> Result<User, StoreError>;
168
169    /// Link `(provider, subject)` to an existing user. Returns
170    /// `StoreError::Conflict` if the link already exists for a *different*
171    /// user; idempotent if it matches.
172    async fn link_provider(
173        &self,
174        user_id: &UserId,
175        provider: &ProviderKey,
176        subject: &str,
177    ) -> Result<(), StoreError>;
178
179    /// Enumerate every device this user has signed in from (and not revoked).
180    async fn list_devices(&self, user_id: &UserId) -> Result<Vec<DeviceId>, StoreError>;
181
182    /// Revoke a device. The two halves of "revoked" are first-class (R019-F4):
183    /// block *new* sessions by revoking the device's refresh chains
184    /// ([`RefreshStore::revoke_chain`]), and kill an *in-flight* access token by
185    /// its `jti` via [`RevocationWriter::revoke`](crate::revocation::RevocationWriter::revoke);
186    /// the edge enforces the latter through
187    /// [`RevocationReader`](cheers_verify::RevocationReader). This method records
188    /// the device-level intent; composing those calls is
189    /// [`SessionAuthority`](crate::session::SessionAuthority)'s job.
190    async fn revoke_device(
191        &self,
192        user_id: &UserId,
193        device_id: &DeviceId,
194    ) -> Result<(), StoreError>;
195}
196
197/// One row in a refresh-token rotation chain.
198///
199/// This struct is the persistence contract the rotation impls
200/// ([`RefreshRotator`](crate::refresh::RefreshRotator)) write against. The
201/// struct is `#[non_exhaustive]` (so adding fields stays SemVer-clean for
202/// downstream consumers), which blocks external struct-literal construction —
203/// use [`RefreshTokenRecord::new`] from a [`RefreshStore`] impl that needs to
204/// build one (e.g. in a `get` query handler).
205#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
206#[non_exhaustive]
207pub struct RefreshTokenRecord {
208    /// Storage key for this token: the SHA-256 hash (hex) of the opaque secret
209    /// the client holds, computed by the rotator. The raw secret is never
210    /// persisted, so a read-only store leak yields hashes, not usable tokens.
211    /// `RefreshStore` impls treat this as an opaque primary key.
212    pub token: String,
213    /// Stable identifier shared across every token in this rotation chain.
214    pub chain_id: String,
215    /// The `token` (hash) this one rotated from (`None` for the chain root).
216    pub parent: Option<String>,
217    pub user_id: UserId,
218    pub device_id: DeviceId,
219    pub issued_at: i64,
220    pub expires_at: i64,
221    /// `true` once this token has minted a successor. Re-presenting a
222    /// consumed token MUST revoke the whole chain (replay).
223    pub consumed: bool,
224    /// `true` once the chain has been revoked — by replay detection,
225    /// explicit logout, or device revocation.
226    pub revoked: bool,
227}
228
229impl RefreshTokenRecord {
230    /// Construct a record from its constituent fields. Use this from external
231    /// [`RefreshStore`] impls (the struct is `#[non_exhaustive]`, so direct
232    /// struct-literal construction is crate-local only). New fields land here
233    /// behind the `#[non_exhaustive]` shield so existing callers stay green.
234    #[allow(clippy::too_many_arguments)]
235    pub fn new(
236        token: String,
237        chain_id: String,
238        parent: Option<String>,
239        user_id: UserId,
240        device_id: DeviceId,
241        issued_at: i64,
242        expires_at: i64,
243        consumed: bool,
244        revoked: bool,
245    ) -> Self {
246        Self {
247            token,
248            chain_id,
249            parent,
250            user_id,
251            device_id,
252            issued_at,
253            expires_at,
254            consumed,
255            revoked,
256        }
257    }
258}
259
260/// Persistence for refresh-token rotation chains.
261#[async_trait]
262pub trait RefreshStore: Send + Sync {
263    async fn put(&self, record: &RefreshTokenRecord) -> Result<(), StoreError>;
264    async fn get(&self, token: &str) -> Result<Option<RefreshTokenRecord>, StoreError>;
265    /// Atomically transition `token` from unconsumed to consumed.
266    ///
267    /// Returns `Ok(true)` iff *this* call flipped `consumed` false→true, and
268    /// `Ok(false)` if no unconsumed row matched — the token was already
269    /// consumed (the losing side of a concurrent rotation) or is absent. The
270    /// check-and-set MUST be a single atomic operation (a conditional
271    /// `UPDATE … WHERE consumed = FALSE`, a Lua CAS, a guarded map mutation);
272    /// a read-then-write lets two concurrent rotations of the same token both
273    /// observe `consumed = false` and each mint a live successor. The rotator
274    /// relies on the returned bool to detect that double-spend and revoke the
275    /// chain, so an impl that always returns `true` reopens the replay hole.
276    async fn mark_consumed(&self, token: &str) -> Result<bool, StoreError>;
277    /// Revoke every record in `chain_id`. Idempotent.
278    async fn revoke_chain(&self, chain_id: &str) -> Result<(), StoreError>;
279}
280
281/// Origin-side multi-passkey-per-user persistence for the WebAuthn relying-party
282/// flow (P7 / R014).
283///
284/// Each row stores one passkey [`Credential`] (binding
285/// [`DeviceBinding::Passkey`](cheers_core::DeviceBinding::Passkey), `material`
286/// the `serde_json`-encoded `webauthn-rs` `Passkey`). The trait sits on
287/// `cheers-core`'s [`Credential`] rather than `webauthn-rs::Passkey` so this
288/// crate stays free of webauthn-rs in its public API — products bridge with
289/// `cheers::passkey::passkey_to_credential` /
290/// `cheers::passkey::passkey_from_credential`.
291///
292/// A user holds zero-or-more passkey credentials (phone, laptop, security key);
293/// `(user_id, device_id)` is the unique key. Non-discoverable WebAuthn flow:
294/// products call [`list_for_user`](Self::list_for_user) to assemble the
295/// `allow_credentials` set before `start_authentication`, and
296/// [`update`](Self::update) after a successful ceremony if the signature
297/// counter advanced (see `cheers::passkey::apply_authentication_result`).
298///
299/// Long-lived credential material, indexed by user — belongs in the relational
300/// SQL store (see `cheers-sqlx`), never in redis.
301#[async_trait]
302pub trait PasskeyCredentialStore: Send + Sync {
303    /// Insert a fresh passkey credential. The credential's
304    /// [`user_id`](Credential::user_id) and [`device_id`](Credential::device_id)
305    /// fields are the unique key; [`StoreError::Conflict`] if the pair is
306    /// already taken.
307    async fn put(&self, cred: &Credential) -> Result<(), StoreError>;
308
309    /// Every passkey credential owned by `user_id`. Order is unspecified.
310    async fn list_for_user(&self, user_id: &UserId) -> Result<Vec<Credential>, StoreError>;
311
312    /// Remove the passkey credential at `(user_id, device_id)`.
313    /// [`StoreError::NotFound`] if no such row exists.
314    async fn delete(&self, user_id: &UserId, device_id: &DeviceId) -> Result<(), StoreError>;
315
316    /// Rewrite the stored material for the credential's
317    /// `(user_id, device_id)` pair. Use after
318    /// `apply_authentication_result` to persist an advanced counter / updated
319    /// backup flags. [`StoreError::NotFound`] if the pair was never `put`.
320    async fn update(&self, cred: &Credential) -> Result<(), StoreError>;
321}
322
323#[cfg(test)]
324mod tests {
325    //! Trait-shape smoke tests via tiny in-memory impls. The "real" memory
326    //! impls live in the `cheers` crate (R015-T3).
327
328    use super::*;
329    use std::collections::HashMap;
330    use std::sync::Mutex;
331
332    #[derive(Default)]
333    struct MemUserStore {
334        inner: Mutex<MemUserInner>,
335    }
336
337    #[derive(Default)]
338    struct MemUserInner {
339        next_id: u64,
340        users: HashMap<UserId, User>,
341        links: HashMap<(ProviderKey, String), UserId>,
342        devices: HashMap<UserId, Vec<DeviceId>>,
343    }
344
345    #[async_trait]
346    impl UserStore for MemUserStore {
347        async fn get(&self, user_id: &UserId) -> Result<Option<User>, StoreError> {
348            let g = self.inner.lock().unwrap();
349            Ok(g.users.get(user_id).cloned())
350        }
351
352        async fn find_by_provider(
353            &self,
354            provider: &ProviderKey,
355            subject: &str,
356        ) -> Result<Option<User>, StoreError> {
357            let g = self.inner.lock().unwrap();
358            Ok(g.links
359                .get(&(provider.clone(), subject.to_owned()))
360                .and_then(|id| g.users.get(id).cloned()))
361        }
362
363        async fn create(&self, new_user: NewUser) -> Result<User, StoreError> {
364            let mut g = self.inner.lock().unwrap();
365            g.next_id += 1;
366            let id = UserId::new(format!("u-{}", g.next_id));
367            let mut u = User::new(id.clone());
368            u.email = new_user.email;
369            u.name = new_user.name;
370            g.users.insert(id, u.clone());
371            Ok(u)
372        }
373
374        async fn link_provider(
375            &self,
376            user_id: &UserId,
377            provider: &ProviderKey,
378            subject: &str,
379        ) -> Result<(), StoreError> {
380            let mut g = self.inner.lock().unwrap();
381            let key = (provider.clone(), subject.to_owned());
382            match g.links.get(&key) {
383                Some(existing) if existing == user_id => Ok(()),
384                Some(_) => Err(StoreError::Conflict),
385                None => {
386                    g.links.insert(key, user_id.clone());
387                    Ok(())
388                }
389            }
390        }
391
392        async fn list_devices(&self, user_id: &UserId) -> Result<Vec<DeviceId>, StoreError> {
393            let g = self.inner.lock().unwrap();
394            Ok(g.devices.get(user_id).cloned().unwrap_or_default())
395        }
396
397        async fn revoke_device(
398            &self,
399            user_id: &UserId,
400            device_id: &DeviceId,
401        ) -> Result<(), StoreError> {
402            let mut g = self.inner.lock().unwrap();
403            let v = g.devices.entry(user_id.clone()).or_default();
404            let before = v.len();
405            v.retain(|d| d != device_id);
406            if v.len() == before {
407                return Err(StoreError::NotFound);
408            }
409            Ok(())
410        }
411    }
412
413    #[derive(Default)]
414    struct MemRefreshStore(Mutex<HashMap<String, RefreshTokenRecord>>);
415
416    #[async_trait]
417    impl RefreshStore for MemRefreshStore {
418        async fn put(&self, record: &RefreshTokenRecord) -> Result<(), StoreError> {
419            self.0
420                .lock()
421                .unwrap()
422                .insert(record.token.clone(), record.clone());
423            Ok(())
424        }
425        async fn get(&self, token: &str) -> Result<Option<RefreshTokenRecord>, StoreError> {
426            Ok(self.0.lock().unwrap().get(token).cloned())
427        }
428        async fn mark_consumed(&self, token: &str) -> Result<bool, StoreError> {
429            // Atomic under the map lock: only the caller that observes
430            // `consumed == false` flips it and reports `true`.
431            let mut g = self.0.lock().unwrap();
432            match g.get_mut(token) {
433                Some(r) if !r.consumed => {
434                    r.consumed = true;
435                    Ok(true)
436                }
437                _ => Ok(false),
438            }
439        }
440        async fn revoke_chain(&self, chain_id: &str) -> Result<(), StoreError> {
441            let mut g = self.0.lock().unwrap();
442            for r in g.values_mut() {
443                if r.chain_id == chain_id {
444                    r.revoked = true;
445                }
446            }
447            Ok(())
448        }
449    }
450
451    #[test]
452    fn user_store_create_link_find() {
453        let s = MemUserStore::default();
454        pollster::block_on(async {
455            let u = s.create(NewUser::new().with_email("a@b")).await.unwrap();
456            assert_eq!(u.email.as_deref(), Some("a@b"));
457            s.link_provider(&u.id, &ProviderKey::OidcGoogle, "google-sub-1")
458                .await
459                .unwrap();
460            // Idempotent re-link on same user.
461            s.link_provider(&u.id, &ProviderKey::OidcGoogle, "google-sub-1")
462                .await
463                .unwrap();
464            // Conflict with a different user.
465            let u2 = s.create(NewUser::new()).await.unwrap();
466            assert!(matches!(
467                s.link_provider(&u2.id, &ProviderKey::OidcGoogle, "google-sub-1")
468                    .await,
469                Err(StoreError::Conflict)
470            ));
471            // Lookup succeeds.
472            let found = s
473                .find_by_provider(&ProviderKey::OidcGoogle, "google-sub-1")
474                .await
475                .unwrap()
476                .unwrap();
477            assert_eq!(found.id, u.id);
478        });
479    }
480
481    #[test]
482    fn user_store_get_by_id() {
483        let s = MemUserStore::default();
484        pollster::block_on(async {
485            // An id that names no user is a miss, not an error.
486            assert!(s.get(&UserId::new("nope")).await.unwrap().is_none());
487
488            let u = s
489                .create(NewUser::new().with_email("a@b").with_name("A"))
490                .await
491                .unwrap();
492            let got = s.get(&u.id).await.unwrap().expect("created user by id");
493            assert_eq!(got.id, u.id);
494            assert_eq!(got.email.as_deref(), Some("a@b"));
495            assert_eq!(got.name.as_deref(), Some("A"));
496
497            // The id discriminates between users.
498            let u2 = s.create(NewUser::new()).await.unwrap();
499            assert_ne!(u2.id, u.id);
500            assert_eq!(s.get(&u2.id).await.unwrap().map(|x| x.id), Some(u2.id));
501        });
502    }
503
504    #[test]
505    fn refresh_store_consume_and_revoke_chain() {
506        let s = MemRefreshStore::default();
507        pollster::block_on(async {
508            let r = RefreshTokenRecord {
509                token: "tok-1".into(),
510                chain_id: "chain-A".into(),
511                parent: None,
512                user_id: UserId::new("u1"),
513                device_id: DeviceId::new("d1"),
514                issued_at: 100,
515                expires_at: 1_000,
516                consumed: false,
517                revoked: false,
518            };
519            s.put(&r).await.unwrap();
520            // First consume transitions the row and reports true; a second
521            // consume finds nothing unconsumed and reports false (this is the
522            // signal the rotator turns into replay detection).
523            assert!(s.mark_consumed("tok-1").await.unwrap());
524            assert!(!s.mark_consumed("tok-1").await.unwrap());
525            assert!(!s.mark_consumed("no-such-token").await.unwrap());
526            assert!(s.get("tok-1").await.unwrap().unwrap().consumed);
527            s.revoke_chain("chain-A").await.unwrap();
528            assert!(s.get("tok-1").await.unwrap().unwrap().revoked);
529        });
530    }
531
532    #[derive(Default)]
533    struct MemPasskeyCredentialStore(Mutex<HashMap<(UserId, DeviceId), Credential>>);
534
535    #[async_trait]
536    impl PasskeyCredentialStore for MemPasskeyCredentialStore {
537        async fn put(&self, cred: &Credential) -> Result<(), StoreError> {
538            let mut g = self.0.lock().unwrap();
539            let key = (cred.user_id.clone(), cred.device_id.clone());
540            if g.contains_key(&key) {
541                return Err(StoreError::Conflict);
542            }
543            g.insert(key, cred.clone());
544            Ok(())
545        }
546
547        async fn list_for_user(&self, user_id: &UserId) -> Result<Vec<Credential>, StoreError> {
548            let g = self.0.lock().unwrap();
549            Ok(g.iter()
550                .filter(|((u, _), _)| u == user_id)
551                .map(|(_, c)| c.clone())
552                .collect())
553        }
554
555        async fn delete(
556            &self,
557            user_id: &UserId,
558            device_id: &DeviceId,
559        ) -> Result<(), StoreError> {
560            let mut g = self.0.lock().unwrap();
561            g.remove(&(user_id.clone(), device_id.clone()))
562                .ok_or(StoreError::NotFound)
563                .map(|_| ())
564        }
565
566        async fn update(&self, cred: &Credential) -> Result<(), StoreError> {
567            let mut g = self.0.lock().unwrap();
568            let key = (cred.user_id.clone(), cred.device_id.clone());
569            if !g.contains_key(&key) {
570                return Err(StoreError::NotFound);
571            }
572            g.insert(key, cred.clone());
573            Ok(())
574        }
575    }
576
577    fn passkey_cred(user: &str, device: &str, material: &[u8]) -> Credential {
578        Credential::new(
579            UserId::new(user),
580            DeviceId::new(device),
581            cheers_core::DeviceBinding::Passkey,
582            material.to_vec(),
583        )
584    }
585
586    #[test]
587    fn passkey_store_put_list_update_delete() {
588        let s = MemPasskeyCredentialStore::default();
589        pollster::block_on(async {
590            // Empty list for a fresh user.
591            assert!(s
592                .list_for_user(&UserId::new("u1"))
593                .await
594                .unwrap()
595                .is_empty());
596
597            // Put a credential, list it.
598            let phone = passkey_cred("u1", "phone", b"v1");
599            s.put(&phone).await.unwrap();
600            let list = s.list_for_user(&UserId::new("u1")).await.unwrap();
601            assert_eq!(list, vec![phone.clone()]);
602
603            // Put a second credential for the same user.
604            let laptop = passkey_cred("u1", "laptop", b"v1-laptop");
605            s.put(&laptop).await.unwrap();
606            let mut list = s.list_for_user(&UserId::new("u1")).await.unwrap();
607            list.sort_by(|a, b| a.device_id.as_str().cmp(b.device_id.as_str()));
608            assert_eq!(list, vec![laptop.clone(), phone.clone()]);
609
610            // Re-putting the same (user, device) conflicts.
611            assert!(matches!(
612                s.put(&phone).await,
613                Err(StoreError::Conflict)
614            ));
615
616            // Update rewrites the material (counter advance).
617            let phone_v2 = passkey_cred("u1", "phone", b"v2");
618            s.update(&phone_v2).await.unwrap();
619            let mut list = s.list_for_user(&UserId::new("u1")).await.unwrap();
620            list.sort_by(|a, b| a.device_id.as_str().cmp(b.device_id.as_str()));
621            assert_eq!(list[1].material, b"v2");
622
623            // Update on a missing (user, device) is NotFound.
624            let ghost = passkey_cred("u1", "ghost", b"x");
625            assert!(matches!(
626                s.update(&ghost).await,
627                Err(StoreError::NotFound)
628            ));
629
630            // Delete works once.
631            s.delete(&UserId::new("u1"), &DeviceId::new("phone"))
632                .await
633                .unwrap();
634            assert!(matches!(
635                s.delete(&UserId::new("u1"), &DeviceId::new("phone"))
636                    .await,
637                Err(StoreError::NotFound)
638            ));
639
640            // Second user's credentials are unaffected.
641            let other = passkey_cred("u2", "phone", b"u2-v1");
642            s.put(&other).await.unwrap();
643            assert_eq!(
644                s.list_for_user(&UserId::new("u1")).await.unwrap(),
645                vec![laptop]
646            );
647            assert_eq!(
648                s.list_for_user(&UserId::new("u2")).await.unwrap(),
649                vec![other]
650            );
651        });
652    }
653
654    #[test]
655    fn traits_are_dyn_compatible() {
656        fn _u(_: &dyn UserStore) {}
657        fn _r(_: &dyn RefreshStore) {}
658        fn _p(_: &dyn PasskeyCredentialStore) {}
659    }
660}