ppoppo-sdk-core 0.6.2

Internal shared primitives for the Ppoppo SDK family (pas-external, pas-plims, pcs-external) — verifier port, audit trait, session liveness port, OIDC discovery, perimeter Bearer-auth Layer kit, identity types. Not a stable public API; do not depend on this crate directly. Consume the SDK crates that re-export from it (e.g. `pas-external`).
Documentation
//! The SDK family's OAuth2 scope-request contract.
//!
//! Each face SDK owns a **sealed** family of phantom scope-set markers —
//! `pcs_session::scopes::{ReadOnly, Notify, ReadAndSend, FullAccess}`,
//! `pas_plims::PlimsScopeSet`, `pcs_external::PcsExternalScopeSet` — whose
//! members gate which of that face's methods compile. Those families live
//! beside the behavior they gate and stay there; this module contributes the
//! one thing they cannot express on their own: a **common contract naming the
//! scope atoms a marker requests at consent**, so a crate that performs the
//! *authorization* leg can read a marker it has never heard of.
//!
//! That is the whole reason this is here rather than in one of the SDKs.
//! `pas-external` runs the OAuth flow; `pcs-session` owns the chat scope
//! vocabulary; neither depends on the other, and adding an edge either way
//! would drag an HTTP/OAuth stack into a chat client or a chat client into an
//! auth SDK. Both already depend on this crate, feature-free. The contract
//! goes in the shared node, and one type parameter then spans the whole
//! credential story:
//!
//! ```ignore
//! NativeAuthFlow::<Notify>   // acquisition — puts Notify's atoms on the authorize wire
//! PcsSessionClient<Notify>   // use — compile-gates the callable RPCs to the same tier
//! ```
//!
//! # Open parent, sealed children
//!
//! [`ConsentScopes`] is deliberately **not** sealed, and this is the only
//! workable reading of the "sealed-trait family parent" slot reserved in this
//! crate's `lib.rs`: a sealed parent could not be implemented by
//! `pcs-session` at all, because sealing means "only the declaring crate may
//! implement this". Each SDK's own family stays sealed exactly as before —
//! sealing restricts who may implement *that family's* trait, not which other
//! traits its members implement, so nothing is loosened by adopting a
//! supertrait here.
//!
//! # It provides coherence, not authorization
//!
//! Implementing [`ConsentScopes`] grants nothing. The atoms are what the
//! client *asks for*; the server decides what to give. PAS rejects unknown or
//! un-allowlisted scopes loudly at the authorize endpoint (`invalid_scope`) —
//! it never silently intersects them down to what the client is entitled to.
//! The authorization boundary is, and stays, server-side.

/// The OAuth2 scope atoms a marker type requests at consent.
///
/// Implemented by each face SDK for its own sealed scope-set markers. A crate
/// running the authorization leg takes `S: ConsentScopes` and puts
/// [`scope_line`](ConsentScopes::scope_line) on the wire without knowing which
/// SDK `S` came from.
///
/// # Atoms, not a joined string
///
/// [`SCOPES`](ConsentScopes::SCOPES) is a slice because two things need set
/// semantics, and neither can be had from a pre-joined string:
///
/// 1. **The granted-vs-requested covers-check.** RFC 6749 §5.1 lets the token
///    response echo the granted scope; §3.3 makes order and whitespace
///    insignificant. So the check is `granted ⊇ requested` over atom sets —
///    never string equality, which would reject a reordered echo.
/// 2. **Per-atom drift pinning.** Workspace tests assert every atom of every
///    family is present in the PAS scope catalog and in the PCS capability
///    map. Set membership is the assertion; a joined string would have to be
///    re-split by every test.
///
/// **Do not add a parallel joined const.** Two declarations of the same fact
/// is precisely the drift this contract exists to remove — the joined form is
/// derived on demand by `scope_line`.
///
/// # Membership is not this trait's business
///
/// Which atoms a tier contains is the owning SDK's decision and changes on the
/// owning SDK's release cadence alone. This trait's shape is
/// membership-invariant: a tier gaining an atom releases one SDK minor and
/// requires nothing from this crate. (Server-first, always — the atom must be
/// live in the PAS catalog and the client's allowlist before a binary requests
/// it, or authorize fails loud. The owning SDK's `scopes` module carries the
/// full runbook.)
pub trait ConsentScopes: Send + Sync + 'static {
    /// The scope atoms this marker requests, one per element.
    ///
    /// Each atom is a bare scope name (`"chat.read"`), never a joined line.
    const SCOPES: &'static [&'static str];

    /// The atoms encoded for the wire — space-separated, RFC 6749 §3.3.
    ///
    /// Not a `const`: joining allocates, and neither const trait fns nor const
    /// heap allocation are stable. It runs once per authorization request, so
    /// the allocation is not on any hot path.
    #[must_use]
    fn scope_line() -> String {
        Self::SCOPES.join(" ")
    }
}

/// A [`TokenSource`](crate::token_cache::TokenSource) whose credential has
/// been **checked to cover** `S`'s atoms.
///
/// This is a witness, not a capability: implementing it asserts that at the
/// moment the token was obtained, the server's granted scope was verified to
/// contain every atom in `S::SCOPES` (absent echo = granted-as-requested,
/// RFC 6749 §5.1). A consumer can then require the *same* `S` for acquisition
/// and for use, so a credential requested at one tier cannot be fed to a
/// client built at another — a mismatch that otherwise compiles cleanly and
/// fails as a runtime 403 on the first call that needs the missing atom.
///
/// # What it does not do
///
/// Rust has no cross-crate privacy sharing, so this marker is **not
/// unforgeable** — a determined consumer can implement it on a type where no
/// check ever ran. Containment is by constructor discipline: the SDK
/// implements it only where a covers-check has actually run, and any public
/// constructor that yields one must itself take the granted scope line and run
/// the check. That makes the coherent path the easy path, which is the honest
/// claim; the server-side scope enforcement remains the authorization
/// boundary. Dev/test sources that cannot know their grant (a statically
/// supplied token) deliberately do **not** implement this, so reaching for one
/// is opt-out by construction rather than an accident.
///
/// Gated on `token-cache`, the feature that owns
/// [`TokenSource`](crate::token_cache::TokenSource); every SDK that mints or
/// renews credentials already enables it.
#[cfg(feature = "token-cache")]
pub trait ScopedTokenSource<S: ConsentScopes>: crate::token_cache::TokenSource {}

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

    struct Single;
    impl ConsentScopes for Single {
        const SCOPES: &'static [&'static str] = &["chat.read"];
    }

    struct Triple;
    impl ConsentScopes for Triple {
        const SCOPES: &'static [&'static str] = &["chat.read", "contact.read", "chat.ack"];
    }

    struct Empty;
    impl ConsentScopes for Empty {
        const SCOPES: &'static [&'static str] = &[];
    }

    #[test]
    fn scope_line_is_single_space_separated() {
        // RFC 6749 §3.3 wire form: exactly one space, no trailing separator.
        assert_eq!(Triple::scope_line(), "chat.read contact.read chat.ack");
    }

    #[test]
    fn single_atom_carries_no_separator() {
        assert_eq!(Single::scope_line(), "chat.read");
    }

    #[test]
    fn empty_set_is_an_empty_line() {
        // A marker requesting nothing is degenerate but representable; it must
        // not produce a stray space that PAS would read as an empty atom.
        assert_eq!(Empty::scope_line(), "");
    }

    /// The point of the contract: a generic function reads atoms from a marker
    /// it knows nothing about. This is the shape the authorization leg uses.
    #[test]
    fn atoms_are_reachable_generically() {
        fn requested<S: ConsentScopes>() -> &'static [&'static str] {
            S::SCOPES
        }
        assert_eq!(requested::<Single>(), ["chat.read"]);
        assert_eq!(requested::<Triple>().len(), 3);
    }
}