cheers-server 0.8.27

Origin-side surface for cheers — secret-key minter, symmetric codecs, UserStore/RefreshStore, refresh rotation, RevocationWriter, and the SessionAuthority facade.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
//! Origin-side persistence contracts — [`UserStore`] and [`RefreshStore`].
//!
//! These are the *origin* store traits: identity/provider linkage and
//! refresh-token rotation state. They moved out of `cheers-core` (R019-F6) so a
//! verify-only or device-only consumer never names them. The device store,
//! [`CredentialStore`](cheers_core::CredentialStore), stays in `cheers-core`; the
//! shared [`StoreError`](cheers_core::StoreError) does too.
//!
//! All traits are `async` via [`async_trait`] so they remain dyn-compatible.
//! Concrete impls (Postgres, Yubaba-backed, in-memory) live in product code.
//!
//! @yah:ticket(R020-F4, "Ownership table schema + writers (POST/DELETE /ownership) + cascading revoke")
//! @yah:assignee(agent:claude)
//! @yah:at(2026-06-04T01:35:23Z)
//! @yah:status(review)
//! @yah:phase(P1)
//! @yah:parent(R020)
//! @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.")
//! @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.")
//! @arch:see(.yah/docs/working/mcp-auth-and-ownership.md)
//! @yah:depends_on(R020-F2)
//! @yah:next("Sign off the store layer + cascade primitive (this ticket).")
//! @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.")
//! @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.")
//! @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.")
//! @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).")
//! @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.")
//! @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).")
//! @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).")
//! @yah:verify("cargo test -p cheers-server && cargo test -p cheers-sqlx --features sqlite")
//! @yah:verify("ownership_store_lifecycle scenario: insert → revoke_by_id → list_for_principal excludes the revoked row; re-revoke is idempotent (revoked_at unchanged).")
//! @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).")
//! @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.")
//!
//! @yah:ticket(R020-F13, "Audit ingest endpoint + centralized audit table (POST /audit/ingest)")
//! @yah:assignee(agent:claude)
//! @yah:at(2026-06-04T01:36:39Z)
//! @yah:status(review)
//! @yah:phase(P4)
//! @yah:parent(R020)
//! @yah:gotcha("audit:write is kind=service only. Same enforcement as ownership:write — reject at grant write time.")
//! @arch:see(.yah/docs/working/mcp-auth-and-ownership.md)
//! @yah:depends_on(R020-F3)
//! @yah:depends_on(R020-F4)
//! @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).")
//! @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.")
//! @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).")
//! @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.")
//! @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).")
//! @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).")
//! @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.")
//! @yah:next("Sign off F13 — tasks-met, awaiting human review.")
//! @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.")
//! @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).")
//! @yah:verify("cargo test -p cheers-server audit:: — 8/8 trait + memory-impl tests pass.")
//! @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).")
//! @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').")
//! @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.")
//! @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.)")

use async_trait::async_trait;
use serde::{Deserialize, Serialize};

use cheers_core::{Credential, DeviceId, StoreError, User, UserId};

/// The external identity-provider namespace a `subject` string lives in.
///
/// Two users with the same Google `sub` must collide; the same email address
/// presented to Apple vs. Google must *not* collide. `ProviderKey` is the
/// namespace tag that disambiguates.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[non_exhaustive]
#[serde(tag = "provider", rename_all = "snake_case")]
pub enum ProviderKey {
    /// Google OIDC — `subject` is the `sub` claim from Google's ID token.
    OidcGoogle,
    /// Apple Sign In — `subject` is the `sub` claim from Apple's ID token.
    OidcApple,
    /// Any other OIDC issuer; `subject` is that issuer's `sub` claim.
    OidcGeneric { issuer: String },
    /// Email-based identity (magic-link or password). `subject` is the email.
    Email,
    /// LAN-pair identity. `subject` is the device's mshr node-id.
    LanPair,
}

/// Fields required to mint a fresh `User`.
///
/// `UserStore::create` returns the resulting `User` with a freshly minted
/// `UserId`; the caller has no say in the id shape.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct NewUser {
    pub email: Option<String>,
    pub name: Option<String>,
}

impl NewUser {
    pub fn new() -> Self {
        Self::default()
    }

    pub fn with_email(mut self, email: impl Into<String>) -> Self {
        self.email = Some(email.into());
        self
    }

    pub fn with_name(mut self, name: impl Into<String>) -> Self {
        self.name = Some(name.into());
        self
    }
}

/// User identity + provider-link persistence.
///
/// One `User` may be reachable through multiple `(ProviderKey, subject)`
/// pairs — that's what `link_provider` is for. `list_devices` and
/// `revoke_device` are the session-management surface: products surface them
/// to end-users on an /account/sessions page (P12).
#[async_trait]
pub trait UserStore: Send + Sync {
    /// Look up the user reachable via `(provider, subject)`. `None` if no
    /// such link exists.
    async fn find_by_provider(
        &self,
        provider: &ProviderKey,
        subject: &str,
    ) -> Result<Option<User>, StoreError>;

    /// Create a fresh user (no provider linked yet). Returns the user with
    /// its newly minted `UserId`.
    async fn create(&self, new_user: NewUser) -> Result<User, StoreError>;

    /// Link `(provider, subject)` to an existing user. Returns
    /// `StoreError::Conflict` if the link already exists for a *different*
    /// user; idempotent if it matches.
    async fn link_provider(
        &self,
        user_id: &UserId,
        provider: &ProviderKey,
        subject: &str,
    ) -> Result<(), StoreError>;

    /// Enumerate every device this user has signed in from (and not revoked).
    async fn list_devices(&self, user_id: &UserId) -> Result<Vec<DeviceId>, StoreError>;

    /// Revoke a device. The two halves of "revoked" are first-class (R019-F4):
    /// block *new* sessions by revoking the device's refresh chains
    /// ([`RefreshStore::revoke_chain`]), and kill an *in-flight* access token by
    /// its `jti` via [`RevocationWriter::revoke`](crate::revocation::RevocationWriter::revoke);
    /// the edge enforces the latter through
    /// [`RevocationReader`](cheers_verify::RevocationReader). This method records
    /// the device-level intent; composing those calls is
    /// [`SessionAuthority`](crate::session::SessionAuthority)'s job.
    async fn revoke_device(
        &self,
        user_id: &UserId,
        device_id: &DeviceId,
    ) -> Result<(), StoreError>;
}

/// One row in a refresh-token rotation chain.
///
/// This struct is the persistence contract the rotation impls
/// ([`RefreshRotator`](crate::refresh::RefreshRotator)) write against. The
/// struct is `#[non_exhaustive]` (so adding fields stays SemVer-clean for
/// downstream consumers), which blocks external struct-literal construction —
/// use [`RefreshTokenRecord::new`] from a [`RefreshStore`] impl that needs to
/// build one (e.g. in a `get` query handler).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct RefreshTokenRecord {
    /// Storage key for this token: the SHA-256 hash (hex) of the opaque secret
    /// the client holds, computed by the rotator. The raw secret is never
    /// persisted, so a read-only store leak yields hashes, not usable tokens.
    /// `RefreshStore` impls treat this as an opaque primary key.
    pub token: String,
    /// Stable identifier shared across every token in this rotation chain.
    pub chain_id: String,
    /// The `token` (hash) this one rotated from (`None` for the chain root).
    pub parent: Option<String>,
    pub user_id: UserId,
    pub device_id: DeviceId,
    pub issued_at: i64,
    pub expires_at: i64,
    /// `true` once this token has minted a successor. Re-presenting a
    /// consumed token MUST revoke the whole chain (replay).
    pub consumed: bool,
    /// `true` once the chain has been revoked — by replay detection,
    /// explicit logout, or device revocation.
    pub revoked: bool,
}

impl RefreshTokenRecord {
    /// Construct a record from its constituent fields. Use this from external
    /// [`RefreshStore`] impls (the struct is `#[non_exhaustive]`, so direct
    /// struct-literal construction is crate-local only). New fields land here
    /// behind the `#[non_exhaustive]` shield so existing callers stay green.
    #[allow(clippy::too_many_arguments)]
    pub fn new(
        token: String,
        chain_id: String,
        parent: Option<String>,
        user_id: UserId,
        device_id: DeviceId,
        issued_at: i64,
        expires_at: i64,
        consumed: bool,
        revoked: bool,
    ) -> Self {
        Self {
            token,
            chain_id,
            parent,
            user_id,
            device_id,
            issued_at,
            expires_at,
            consumed,
            revoked,
        }
    }
}

/// Persistence for refresh-token rotation chains.
#[async_trait]
pub trait RefreshStore: Send + Sync {
    async fn put(&self, record: &RefreshTokenRecord) -> Result<(), StoreError>;
    async fn get(&self, token: &str) -> Result<Option<RefreshTokenRecord>, StoreError>;
    /// Atomically transition `token` from unconsumed to consumed.
    ///
    /// Returns `Ok(true)` iff *this* call flipped `consumed` false→true, and
    /// `Ok(false)` if no unconsumed row matched — the token was already
    /// consumed (the losing side of a concurrent rotation) or is absent. The
    /// check-and-set MUST be a single atomic operation (a conditional
    /// `UPDATE … WHERE consumed = FALSE`, a Lua CAS, a guarded map mutation);
    /// a read-then-write lets two concurrent rotations of the same token both
    /// observe `consumed = false` and each mint a live successor. The rotator
    /// relies on the returned bool to detect that double-spend and revoke the
    /// chain, so an impl that always returns `true` reopens the replay hole.
    async fn mark_consumed(&self, token: &str) -> Result<bool, StoreError>;
    /// Revoke every record in `chain_id`. Idempotent.
    async fn revoke_chain(&self, chain_id: &str) -> Result<(), StoreError>;
}

/// Origin-side multi-passkey-per-user persistence for the WebAuthn relying-party
/// flow (P7 / R014).
///
/// Each row stores one passkey [`Credential`] (binding
/// [`DeviceBinding::Passkey`](cheers_core::DeviceBinding::Passkey), `material`
/// the `serde_json`-encoded `webauthn-rs` `Passkey`). The trait sits on
/// `cheers-core`'s [`Credential`] rather than `webauthn-rs::Passkey` so this
/// crate stays free of webauthn-rs in its public API — products bridge with
/// `cheers::passkey::passkey_to_credential` /
/// `cheers::passkey::passkey_from_credential`.
///
/// A user holds zero-or-more passkey credentials (phone, laptop, security key);
/// `(user_id, device_id)` is the unique key. Non-discoverable WebAuthn flow:
/// products call [`list_for_user`](Self::list_for_user) to assemble the
/// `allow_credentials` set before `start_authentication`, and
/// [`update`](Self::update) after a successful ceremony if the signature
/// counter advanced (see `cheers::passkey::apply_authentication_result`).
///
/// Long-lived credential material, indexed by user — belongs in the relational
/// SQL store (see `cheers-sqlx`), never in redis.
#[async_trait]
pub trait PasskeyCredentialStore: Send + Sync {
    /// Insert a fresh passkey credential. The credential's
    /// [`user_id`](Credential::user_id) and [`device_id`](Credential::device_id)
    /// fields are the unique key; [`StoreError::Conflict`] if the pair is
    /// already taken.
    async fn put(&self, cred: &Credential) -> Result<(), StoreError>;

    /// Every passkey credential owned by `user_id`. Order is unspecified.
    async fn list_for_user(&self, user_id: &UserId) -> Result<Vec<Credential>, StoreError>;

    /// Remove the passkey credential at `(user_id, device_id)`.
    /// [`StoreError::NotFound`] if no such row exists.
    async fn delete(&self, user_id: &UserId, device_id: &DeviceId) -> Result<(), StoreError>;

    /// Rewrite the stored material for the credential's
    /// `(user_id, device_id)` pair. Use after
    /// `apply_authentication_result` to persist an advanced counter / updated
    /// backup flags. [`StoreError::NotFound`] if the pair was never `put`.
    async fn update(&self, cred: &Credential) -> Result<(), StoreError>;
}

#[cfg(test)]
mod tests {
    //! Trait-shape smoke tests via tiny in-memory impls. The "real" memory
    //! impls live in the `cheers` crate (R015-T3).

    use super::*;
    use std::collections::HashMap;
    use std::sync::Mutex;

    #[derive(Default)]
    struct MemUserStore {
        inner: Mutex<MemUserInner>,
    }

    #[derive(Default)]
    struct MemUserInner {
        next_id: u64,
        users: HashMap<UserId, User>,
        links: HashMap<(ProviderKey, String), UserId>,
        devices: HashMap<UserId, Vec<DeviceId>>,
    }

    #[async_trait]
    impl UserStore for MemUserStore {
        async fn find_by_provider(
            &self,
            provider: &ProviderKey,
            subject: &str,
        ) -> Result<Option<User>, StoreError> {
            let g = self.inner.lock().unwrap();
            Ok(g.links
                .get(&(provider.clone(), subject.to_owned()))
                .and_then(|id| g.users.get(id).cloned()))
        }

        async fn create(&self, new_user: NewUser) -> Result<User, StoreError> {
            let mut g = self.inner.lock().unwrap();
            g.next_id += 1;
            let id = UserId::new(format!("u-{}", g.next_id));
            let mut u = User::new(id.clone());
            u.email = new_user.email;
            u.name = new_user.name;
            g.users.insert(id, u.clone());
            Ok(u)
        }

        async fn link_provider(
            &self,
            user_id: &UserId,
            provider: &ProviderKey,
            subject: &str,
        ) -> Result<(), StoreError> {
            let mut g = self.inner.lock().unwrap();
            let key = (provider.clone(), subject.to_owned());
            match g.links.get(&key) {
                Some(existing) if existing == user_id => Ok(()),
                Some(_) => Err(StoreError::Conflict),
                None => {
                    g.links.insert(key, user_id.clone());
                    Ok(())
                }
            }
        }

        async fn list_devices(&self, user_id: &UserId) -> Result<Vec<DeviceId>, StoreError> {
            let g = self.inner.lock().unwrap();
            Ok(g.devices.get(user_id).cloned().unwrap_or_default())
        }

        async fn revoke_device(
            &self,
            user_id: &UserId,
            device_id: &DeviceId,
        ) -> Result<(), StoreError> {
            let mut g = self.inner.lock().unwrap();
            let v = g.devices.entry(user_id.clone()).or_default();
            let before = v.len();
            v.retain(|d| d != device_id);
            if v.len() == before {
                return Err(StoreError::NotFound);
            }
            Ok(())
        }
    }

    #[derive(Default)]
    struct MemRefreshStore(Mutex<HashMap<String, RefreshTokenRecord>>);

    #[async_trait]
    impl RefreshStore for MemRefreshStore {
        async fn put(&self, record: &RefreshTokenRecord) -> Result<(), StoreError> {
            self.0
                .lock()
                .unwrap()
                .insert(record.token.clone(), record.clone());
            Ok(())
        }
        async fn get(&self, token: &str) -> Result<Option<RefreshTokenRecord>, StoreError> {
            Ok(self.0.lock().unwrap().get(token).cloned())
        }
        async fn mark_consumed(&self, token: &str) -> Result<bool, StoreError> {
            // Atomic under the map lock: only the caller that observes
            // `consumed == false` flips it and reports `true`.
            let mut g = self.0.lock().unwrap();
            match g.get_mut(token) {
                Some(r) if !r.consumed => {
                    r.consumed = true;
                    Ok(true)
                }
                _ => Ok(false),
            }
        }
        async fn revoke_chain(&self, chain_id: &str) -> Result<(), StoreError> {
            let mut g = self.0.lock().unwrap();
            for r in g.values_mut() {
                if r.chain_id == chain_id {
                    r.revoked = true;
                }
            }
            Ok(())
        }
    }

    #[test]
    fn user_store_create_link_find() {
        let s = MemUserStore::default();
        pollster::block_on(async {
            let u = s.create(NewUser::new().with_email("a@b")).await.unwrap();
            assert_eq!(u.email.as_deref(), Some("a@b"));
            s.link_provider(&u.id, &ProviderKey::OidcGoogle, "google-sub-1")
                .await
                .unwrap();
            // Idempotent re-link on same user.
            s.link_provider(&u.id, &ProviderKey::OidcGoogle, "google-sub-1")
                .await
                .unwrap();
            // Conflict with a different user.
            let u2 = s.create(NewUser::new()).await.unwrap();
            assert!(matches!(
                s.link_provider(&u2.id, &ProviderKey::OidcGoogle, "google-sub-1")
                    .await,
                Err(StoreError::Conflict)
            ));
            // Lookup succeeds.
            let found = s
                .find_by_provider(&ProviderKey::OidcGoogle, "google-sub-1")
                .await
                .unwrap()
                .unwrap();
            assert_eq!(found.id, u.id);
        });
    }

    #[test]
    fn refresh_store_consume_and_revoke_chain() {
        let s = MemRefreshStore::default();
        pollster::block_on(async {
            let r = RefreshTokenRecord {
                token: "tok-1".into(),
                chain_id: "chain-A".into(),
                parent: None,
                user_id: UserId::new("u1"),
                device_id: DeviceId::new("d1"),
                issued_at: 100,
                expires_at: 1_000,
                consumed: false,
                revoked: false,
            };
            s.put(&r).await.unwrap();
            // First consume transitions the row and reports true; a second
            // consume finds nothing unconsumed and reports false (this is the
            // signal the rotator turns into replay detection).
            assert!(s.mark_consumed("tok-1").await.unwrap());
            assert!(!s.mark_consumed("tok-1").await.unwrap());
            assert!(!s.mark_consumed("no-such-token").await.unwrap());
            assert!(s.get("tok-1").await.unwrap().unwrap().consumed);
            s.revoke_chain("chain-A").await.unwrap();
            assert!(s.get("tok-1").await.unwrap().unwrap().revoked);
        });
    }

    #[derive(Default)]
    struct MemPasskeyCredentialStore(Mutex<HashMap<(UserId, DeviceId), Credential>>);

    #[async_trait]
    impl PasskeyCredentialStore for MemPasskeyCredentialStore {
        async fn put(&self, cred: &Credential) -> Result<(), StoreError> {
            let mut g = self.0.lock().unwrap();
            let key = (cred.user_id.clone(), cred.device_id.clone());
            if g.contains_key(&key) {
                return Err(StoreError::Conflict);
            }
            g.insert(key, cred.clone());
            Ok(())
        }

        async fn list_for_user(&self, user_id: &UserId) -> Result<Vec<Credential>, StoreError> {
            let g = self.0.lock().unwrap();
            Ok(g.iter()
                .filter(|((u, _), _)| u == user_id)
                .map(|(_, c)| c.clone())
                .collect())
        }

        async fn delete(
            &self,
            user_id: &UserId,
            device_id: &DeviceId,
        ) -> Result<(), StoreError> {
            let mut g = self.0.lock().unwrap();
            g.remove(&(user_id.clone(), device_id.clone()))
                .ok_or(StoreError::NotFound)
                .map(|_| ())
        }

        async fn update(&self, cred: &Credential) -> Result<(), StoreError> {
            let mut g = self.0.lock().unwrap();
            let key = (cred.user_id.clone(), cred.device_id.clone());
            if !g.contains_key(&key) {
                return Err(StoreError::NotFound);
            }
            g.insert(key, cred.clone());
            Ok(())
        }
    }

    fn passkey_cred(user: &str, device: &str, material: &[u8]) -> Credential {
        Credential::new(
            UserId::new(user),
            DeviceId::new(device),
            cheers_core::DeviceBinding::Passkey,
            material.to_vec(),
        )
    }

    #[test]
    fn passkey_store_put_list_update_delete() {
        let s = MemPasskeyCredentialStore::default();
        pollster::block_on(async {
            // Empty list for a fresh user.
            assert!(s
                .list_for_user(&UserId::new("u1"))
                .await
                .unwrap()
                .is_empty());

            // Put a credential, list it.
            let phone = passkey_cred("u1", "phone", b"v1");
            s.put(&phone).await.unwrap();
            let list = s.list_for_user(&UserId::new("u1")).await.unwrap();
            assert_eq!(list, vec![phone.clone()]);

            // Put a second credential for the same user.
            let laptop = passkey_cred("u1", "laptop", b"v1-laptop");
            s.put(&laptop).await.unwrap();
            let mut list = s.list_for_user(&UserId::new("u1")).await.unwrap();
            list.sort_by(|a, b| a.device_id.as_str().cmp(b.device_id.as_str()));
            assert_eq!(list, vec![laptop.clone(), phone.clone()]);

            // Re-putting the same (user, device) conflicts.
            assert!(matches!(
                s.put(&phone).await,
                Err(StoreError::Conflict)
            ));

            // Update rewrites the material (counter advance).
            let phone_v2 = passkey_cred("u1", "phone", b"v2");
            s.update(&phone_v2).await.unwrap();
            let mut list = s.list_for_user(&UserId::new("u1")).await.unwrap();
            list.sort_by(|a, b| a.device_id.as_str().cmp(b.device_id.as_str()));
            assert_eq!(list[1].material, b"v2");

            // Update on a missing (user, device) is NotFound.
            let ghost = passkey_cred("u1", "ghost", b"x");
            assert!(matches!(
                s.update(&ghost).await,
                Err(StoreError::NotFound)
            ));

            // Delete works once.
            s.delete(&UserId::new("u1"), &DeviceId::new("phone"))
                .await
                .unwrap();
            assert!(matches!(
                s.delete(&UserId::new("u1"), &DeviceId::new("phone"))
                    .await,
                Err(StoreError::NotFound)
            ));

            // Second user's credentials are unaffected.
            let other = passkey_cred("u2", "phone", b"u2-v1");
            s.put(&other).await.unwrap();
            assert_eq!(
                s.list_for_user(&UserId::new("u1")).await.unwrap(),
                vec![laptop]
            );
            assert_eq!(
                s.list_for_user(&UserId::new("u2")).await.unwrap(),
                vec![other]
            );
        });
    }

    #[test]
    fn traits_are_dyn_compatible() {
        fn _u(_: &dyn UserStore) {}
        fn _r(_: &dyn RefreshStore) {}
        fn _p(_: &dyn PasskeyCredentialStore) {}
    }
}