cheers-core 0.8.21

Contract surface for cheers — identity types, error hierarchy, CredentialStore, and the keyless TokenMinter/TokenVerifier traits. No crypto, no I/O.
Documentation
//! Identifiers, device bindings, and the `Claims` carried by a verified session token.
//!
//! These types are the **mesofact ↔ cheers contract**: any change after the
//! mesofact integration (P11) ships requires a coordinated migration. Public
//! structs and enums are `#[non_exhaustive]` so adding fields or variants
//! later is not a SemVer-breaking change. Construct new values through the
//! provided constructors (and builder-style setters where present), not
//! struct literals.
//!
//! @yah:ticket(R020-F2, "Principal kinds: user|service|camp enum + Principal record in cheers-core")
//! @yah:assignee(agent:claude)
//! @yah:at(2026-06-04T01:35:04Z)
//! @yah:status(review)
//! @yah:phase(P1)
//! @yah:parent(R020)
//! @yah:next("Add PrincipalKind { User, Service, Camp } and Principal { id, kind, bound_to: Option<PrincipalId>, status, created_at } to cheers-core.")
//! @yah:next("Extend sub-claim parser to accept 'user:<id>' | 'svc:<id>' | 'camp:<id>' prefixes; reject unprefixed sub at parse time.")
//! @yah:verify("cargo test -p cheers-core")
//! @yah:verify("Roundtrip test: Principal { kind: Camp, bound_to: Some(user) } serializes/parses; bound_to=None on a Camp is a parse error.")
//! @arch:see(.yah/docs/working/mcp-auth-and-ownership.md)
//! @yah:depends_on(R019-F5)
//! @yah:handoff("Landed new module crates/cheers-core/src/principal.rs (exported from lib.rs): PrincipalKind { User, Service, Camp } + PrincipalId { kind, id } + PrincipalStatus { Active, Revoked } + Principal { id: PrincipalId, bound_to: Option<PrincipalId>, status, created_at } + PrincipalError + PrincipalIdParseError. All #[non_exhaustive].")
//! @yah:handoff("PrincipalId is the typed sub-claim — serializes transparent as 'user:<id>' | 'svc:<id>' | 'camp:<id>'. FromStr/Deserialize reject unprefixed input (MissingPrefix), unknown prefixes (UnknownPrefix incl. legacy 'service'/'agent'), and empty ids — so a session-shaped bare sub cannot silently be read as a user principal. PrincipalKind::prefix uses 'svc' (matches the doc), not 'service'.")
//! @yah:handoff("Principal invariants enforced in BOTH try_new and the Deserialize impl (via RawPrincipal intermediate): Camp ⇒ bound_to=Some(user:_); User/Service ⇒ bound_to=None; Camp bound_to that isn't a user is rejected. JSON omits bound_to when None (skip_serializing_if).")
//! @yah:handoff("Did NOT touch existing Claims.sub: UserId — that's the session contract; the MCP-claims shape (act/owns/camp_id/auth_strength) lands in R020-F3 alongside the Scope enum and will be where PrincipalId actually replaces a sub field. Foundation laid; R020-F3 builds on PrincipalId for its sub typing.")
//! @yah:handoff("Verified GREEN: cargo test -p cheers-core (33 unit incl. 17 new principal tests, 1 doctest), cargo test -p cheers-server (35+9+2+4+0 across binaries/integration), cargo test -p cheers-verify (clean). R020 parent verify smoke passes.")
//!
//! @yah:ticket(R020-F3, "Scope vocabulary as typed enum + composition rules in cheers-core")
//! @yah:assignee(agent:claude)
//! @yah:at(2026-06-04T01:35:12Z)
//! @yah:status(review)
//! @yah:phase(P1)
//! @yah:parent(R020)
//! @yah:next("Add Scope enum covering arch:* board:* camp:* cloud:* party:* subagent:* ownership:write audit:* per §Scope vocabulary.")
//! @yah:next("Enforce composition rules at grant/mint: no wildcards on the wire; <category>:admin does NOT imply read/write; ownership:write and audit:write are kind=service only; aud-scoping mandatory.")
//! @yah:next("Add MCP claim shapes alongside Scope: act { sub }, owns { service: [], arch_doc: [] }, camp_id, auth_strength enum { Bootstrap, UserFresh }.")
//! @yah:verify("cargo test -p cheers-core")
//! @yah:verify("Negative test: serializing a Scope list containing 'cloud:*' fails; granting ownership:write to a User principal returns a typed error.")
//! @yah:gotcha("A user-kind grant with ownership:write or audit:write must be rejected at write time, not just at mint. Rule (4) is a CHECK that lives in the grant API, not the mint path.")
//! @arch:see(.yah/docs/working/mcp-auth-and-ownership.md)
//! @yah:handoff("Landed new module crates/cheers-core/src/mcp.rs (exported from lib.rs): closed-vocabulary Scope enum (16 variants — arch/board/camp/cloud/party/subagent + ownership:write + audit:{read,write}), GrantError, validate_grant(), and the MCP claim shapes (Actor, Owns, AuthStrength, McpClaims). All #[non_exhaustive].")
//! @yah:handoff("Composition rules: (1) wildcards — enforced by Scope::from_str rejecting any '*' BEFORE the literal match, so a wildcard cannot be deserialized into a Vec<Scope> on the wire (tested via Vec<Scope> mid-list rejection). (3) <category>:admin distinct — enforced structurally: CampAdmin and CampRead are independent variants; a grant of one literally isn't a grant of the other; pinned with a test. (4) ownership:write + audit:write service-only — enforced by validate_grant(kind, scope), which rejects BOTH User and Camp (not just User — doc says 'kind=service only'). Service principals pass. (5) aud-scoping is documented as a mint-path concern, not a per-scope predicate.")
//! @yah:handoff("Scope serializes as the literal wire string ('cloud:deploy'), not the variant name — hand-rolled Serialize/Deserialize via as_wire()/FromStr, NOT serde rename. McpClaims.sub is a PrincipalId (R020-F2), so a token whose sub is bare 'alice' fails deserialize with the 'must be prefixed' message inherited from PrincipalId. Owns has explicit service+arch_doc Vec<String> fields PLUS #[serde(flatten)] extra: BTreeMap<String,Vec<String>> so adding a new resource kind in the ownership table doesn't break the wire contract.")
//! @yah:handoff("AuthStrength uses #[serde(rename_all=\"kebab-case\")] — Bootstrap→'bootstrap', UserFresh→'user-fresh' (matches the doc verbatim).")
//! @yah:handoff("Did NOT touch the existing Claims.sub: UserId (session contract). McpClaims is the peer for MCP-call tokens. R020-F4 (ownership table writers) bolts the ownership lookups onto cheers-server and reads them into Owns at mint.")
//! @yah:handoff("Verified GREEN: cargo test -p cheers-core (51 unit incl. 18 new mcp tests + 1 doctest), cargo test -p cheers-server (35+9+2+4+0), cargo test -p cheers-verify (clean). R020 parent verify smoke passes.")

use serde::{Deserialize, Serialize};

/// Stable user identifier — minted by `UserStore` on first sight of a credential.
///
/// Opaque to consumers; cheers does not interpret the inner string. Products
/// pick the shape (UUID, base32 ULID, …); cheers passes it through.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct UserId(String);

impl UserId {
    pub fn new(s: impl Into<String>) -> Self {
        Self(s.into())
    }

    pub fn as_str(&self) -> &str {
        &self.0
    }

    pub fn into_inner(self) -> String {
        self.0
    }
}

impl std::fmt::Display for UserId {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(&self.0)
    }
}

impl From<String> for UserId {
    fn from(s: String) -> Self {
        Self(s)
    }
}

impl From<&str> for UserId {
    fn from(s: &str) -> Self {
        Self(s.to_owned())
    }
}

/// Per-device identifier — minted on the first sign-in from a given device.
///
/// One user has many devices; one device has one `DeviceId` per user.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct DeviceId(String);

impl DeviceId {
    pub fn new(s: impl Into<String>) -> Self {
        Self(s.into())
    }

    pub fn as_str(&self) -> &str {
        &self.0
    }

    pub fn into_inner(self) -> String {
        self.0
    }
}

impl std::fmt::Display for DeviceId {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(&self.0)
    }
}

impl From<String> for DeviceId {
    fn from(s: String) -> Self {
        Self(s)
    }
}

impl From<&str> for DeviceId {
    fn from(s: &str) -> Self {
        Self(s.to_owned())
    }
}

/// How a device proved its identity to mint this session.
///
/// One variant per first-class provider in the build plan. `OidcGeneric`
/// is the escape hatch for ad-hoc OIDC issuers (e.g. enterprise SSO) that
/// aren't named providers.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum DeviceBinding {
    Passkey,
    OidcGoogle,
    OidcApple,
    OidcGeneric { issuer: String },
    EmailPassword,
    EmailMagicLink,
    LanPair,
}

/// Resolved user record returned by `UserStore`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct User {
    pub id: UserId,
    pub email: Option<String>,
    pub name: Option<String>,
}

impl User {
    pub fn new(id: UserId) -> Self {
        Self {
            id,
            email: None,
            name: None,
        }
    }

    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
    }
}

/// One stored proof-of-identity bound to a `(UserId, DeviceId)` pair.
///
/// The shape that `CredentialStore` reads and writes. The `binding` field
/// records *how* the credential was established; provider-specific secrets
/// live in `material` as an opaque byte blob (e.g. a passkey credential ID,
/// an Argon2id hash, a refresh-token chain root, …) whose interpretation is
/// owned by the provider that produced it.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct Credential {
    pub user_id: UserId,
    pub device_id: DeviceId,
    pub binding: DeviceBinding,
    pub material: Vec<u8>,
}

impl Credential {
    pub fn new(
        user_id: UserId,
        device_id: DeviceId,
        binding: DeviceBinding,
        material: Vec<u8>,
    ) -> Self {
        Self {
            user_id,
            device_id,
            binding,
            material,
        }
    }
}

/// Verified session claims — what a `TokenVerifier::verify` returns on success.
///
/// Stable shape; new fields land behind `#[non_exhaustive]`. Timestamps are
/// unix seconds (signed to leave room for pre-epoch sentinels in tests).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct Claims {
    pub sub: UserId,
    pub device: DeviceId,
    pub binding: DeviceBinding,
    pub issued_at: i64,
    pub expires_at: i64,
    /// Unique token id — the key the revocation set is keyed on (R019-F4).
    ///
    /// Empty means *unset / not individually revocable*; sessions minted through
    /// `cheers-server`'s `SessionAuthority` get a fresh value via
    /// [`with_jti`](Self::with_jti). `#[serde(default, skip_serializing_if)]`
    /// keeps the wire format byte-identical to a pre-`jti` token when unset —
    /// important for the mesofact cookie contract.
    #[serde(default, skip_serializing_if = "String::is_empty")]
    pub jti: String,
}

impl Claims {
    pub fn new(
        sub: UserId,
        device: DeviceId,
        binding: DeviceBinding,
        issued_at: i64,
        expires_at: i64,
    ) -> Self {
        Self {
            sub,
            device,
            binding,
            issued_at,
            expires_at,
            jti: String::new(),
        }
    }

    /// Attach a `jti` (the revocation key). Builder-style so existing
    /// five-arg [`new`](Self::new) call sites are unaffected.
    pub fn with_jti(mut self, jti: impl Into<String>) -> Self {
        self.jti = jti.into();
        self
    }

    /// `true` if `expires_at` is at or before `now` (unix seconds).
    pub fn is_expired_at(&self, now: i64) -> bool {
        self.expires_at <= now
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn user_id_roundtrips_through_string() {
        let u = UserId::new("alice");
        assert_eq!(u.as_str(), "alice");
        assert_eq!(u.to_string(), "alice");
        assert_eq!(UserId::from("alice"), u);
    }

    #[test]
    fn user_id_serde_is_transparent() {
        let u = UserId::new("u-123");
        let json = serde_json::to_string(&u).unwrap();
        assert_eq!(json, "\"u-123\"");
        let back: UserId = serde_json::from_str(&json).unwrap();
        assert_eq!(back, u);
    }

    #[test]
    fn device_binding_serializes_with_kind_tag() {
        let b = DeviceBinding::OidcGeneric {
            issuer: "https://idp.example".into(),
        };
        let json = serde_json::to_string(&b).unwrap();
        assert!(json.contains("\"kind\":\"oidc_generic\""));
        assert!(json.contains("\"issuer\":\"https://idp.example\""));
        let back: DeviceBinding = serde_json::from_str(&json).unwrap();
        assert_eq!(back, b);

        let unit = DeviceBinding::Passkey;
        let json = serde_json::to_string(&unit).unwrap();
        assert_eq!(json, "{\"kind\":\"passkey\"}");
    }

    #[test]
    fn user_builder_sets_optional_fields() {
        let u = User::new(UserId::new("u1"))
            .with_email("a@b")
            .with_name("Alice");
        assert_eq!(u.email.as_deref(), Some("a@b"));
        assert_eq!(u.name.as_deref(), Some("Alice"));
    }

    #[test]
    fn claims_expiry_check() {
        let c = Claims::new(
            UserId::new("u1"),
            DeviceId::new("d1"),
            DeviceBinding::Passkey,
            100,
            200,
        );
        assert!(!c.is_expired_at(199));
        assert!(c.is_expired_at(200));
        assert!(c.is_expired_at(201));
    }

    #[test]
    fn claims_jti_defaults_empty_and_omitted_from_wire() {
        let c = Claims::new(
            UserId::new("u1"),
            DeviceId::new("d1"),
            DeviceBinding::Passkey,
            100,
            200,
        );
        assert_eq!(c.jti, "");
        // Unset jti must not appear on the wire — keeps the cookie format
        // identical to a pre-jti token.
        let json = serde_json::to_string(&c).unwrap();
        assert!(!json.contains("jti"), "empty jti must be skipped: {json}");

        let c = c.with_jti("tok-123");
        assert_eq!(c.jti, "tok-123");
        let json = serde_json::to_string(&c).unwrap();
        assert!(json.contains("\"jti\":\"tok-123\""));
        let back: Claims = serde_json::from_str(&json).unwrap();
        assert_eq!(back, c);
    }

    #[test]
    fn claims_roundtrip_json() {
        let c = Claims::new(
            UserId::new("u1"),
            DeviceId::new("d1"),
            DeviceBinding::OidcGeneric {
                issuer: "https://idp".into(),
            },
            100,
            200,
        );
        let json = serde_json::to_string(&c).unwrap();
        let back: Claims = serde_json::from_str(&json).unwrap();
        assert_eq!(back, c);
    }

    #[test]
    fn credential_holds_opaque_material() {
        let cred = Credential::new(
            UserId::new("u1"),
            DeviceId::new("d1"),
            DeviceBinding::EmailPassword,
            b"argon2id$...".to_vec(),
        );
        assert_eq!(cred.material, b"argon2id$...");
    }
}