polyc-web-session 2026.8.3

Browser session establishment (ADR 0007): one-time login challenges, the shared session cookie, and both the persona-passkey and wallet-passkey ceremonies that mint a polyc_crypto::session token. Consumed by polyc-control-plane; carries no ceremony-consumer naming of its own.
//! Browser session establishment (ADR 0007): the shared session cookie, the
//! one-time login-challenge machinery, and every ceremony that resolves a
//! posted proof into a [`polyc_crypto::session`] token — the persona-rooted
//! passkey ceremony ([`passkey_login`]) and the wallet-passkey ceremony
//! ([`wallet_login`]).
//!
//! # Why this is its own crate, not `polyc-control-plane` modules
//!
//! Nothing here names the consumer. A session minted by either ceremony is a
//! plain [`polyc_crypto::session::SessionSubject`] — `Persona` or `Wallet` —
//! with independent [`polyc_crypto::session::SessionScope`]s; neither this
//! crate nor `polyc_crypto::session` carries any "explorer"-specific naming.
//! `polyc-control-plane` is the only container that serves browser sessions
//! today, so this crate stays a `component` it composes, not a second
//! container of its own — but the naming boundary (mechanism, not consumer)
//! holds regardless of how many containers end up depending on it.
//!
//! # What is NOT here
//!
//! Everything in this crate is web-shaped: a `WebAuthn` `rp_id` and origin to
//! check, a `Set-Cookie` to write, a posted proof to verify. What happens to a
//! session AFTER it is minted — the durable family, its rotating grant, reuse
//! detection, expiry — is not, and lives in `polyc-session-family`, which this
//! crate depends on. That crate is a foundation and knows nothing about
//! browsers; this one cannot be, because resolving a persona's scopes needs
//! `polyc-persona`. Both ceremonies create a family and bearer through
//! `polyc_session_family::authority::BrowserSessionAuthority` and root it
//! with a `polyc_session_family::ScopeRoot`.
//!
//! # Two ceremonies, one shared half
//!
//! [`passkey_login::PasskeyLoginSessions`] and
//! [`wallet_login::WalletLoginSessions`] differ only in HOW a posted proof
//! resolves to a session subject — a persona's enrolled `PersonaCredential`
//! record vs. a wallet's stateless, onchain-derived public key. Everything
//! else — minting/redeeming a one-time challenge and the session cookie's
//! shape — is identical, and lives once, here:
//! [`LoginChallengeRegistry`] (the passkey ceremony builds directly on it)
//! and the `cookie` module (both ceremonies' HTTP-facing callers share it).
//! [`wallet_login`] does NOT build on [`LoginChallengeRegistry`], because it
//! has no `WebAuthn` `rp_id`/`origin` to check at all — see that module's
//! doc for why, and the `registry` module's doc for the one-time-token
//! primitive both ceremonies' challenge minting is still built on
//! underneath.

mod cookie;
mod registry;

pub mod passkey_login;
pub mod wallet_login;

pub use cookie::{
    GRANT_COOKIE_NAME, SESSION_COOKIE_NAME, clear_grant_cookie_header, clear_session_cookie_header,
    extract_session_cookie, set_grant_cookie_header, set_session_cookie_header,
};
pub use registry::{LoginChallenge, LoginChallengeRegistry, WebAuthnLoginConfig};

use polyc_session_family::ScopeRoot;

/// How long a minted BACKEND TOKEN stays valid (15 minutes).
///
/// `docs/reference/web-session-lifetime.md` ("Two lifetimes instead of one"):
/// this single number governs three things at once, and they cannot be
/// tuned independently — how long revocation takes to bite (a revoked
/// session keeps working until its current token expires), how long a scope
/// change takes to propagate (a newly linked wallet, or a credential that
/// loses `STATUS_LINKED`), and how often the refresh path writes to the
/// session-family store (roughly once per token lifetime per active
/// session). This is NOT how long a person stays signed in — that is the
/// session's own idle/absolute lifetime, governed independently by State's
/// 30-day idle limit and 90-day absolute limit. A token past this TTL is
/// re-minted from the family's rotating grant through
/// [`polyc_session_family::authority::FamilyAuthority::refresh_family`]
/// without a repeat `WebAuthn` ceremony, so the two lifetimes are independent
/// by design.
pub const SESSION_TTL_MS: u64 = 15 * 60 * 1000;

/// The result of a ceremony that mints a session and creates its rotation
/// family in one call.
///
/// Produced by every login ceremony
/// (`docs/reference/web-session-lifetime.md`, "The session family").
#[derive(Debug, Clone)]
pub struct MintedSession {
    /// The stateless [`polyc_crypto::session`] token, `SESSION_TTL_MS` from
    /// now.
    pub session_token: String,
    /// The freshly created family's first grant
    /// (`polyc_crypto::session_grant`) — rides a SEPARATE `Set-Cookie` from
    /// the session token, never a reply body (see
    /// `docs/reference/web-session-lifetime.md`, "Refreshing").
    pub grant: String,
    /// The family's absolute deadline (unix ms) — drives the sealed
    /// cookie's `Max-Age` and `openSession`'s hard cutoff, deliberately NOT
    /// the same deadline as `session_token`'s own expiry.
    pub family_expires_ms: u64,
}

/// [`resolve_subject_and_scopes`] failed.
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum ScopeResolutionError {
    /// A persona-store read failed — no scope verdict was reached either
    /// way. Callers must not treat this as "no scopes"; see
    /// `docs/reference/web-session-lifetime.md`, "Scopes are recomputed, not
    /// replayed": "a refresh that cannot read the persona store does not
    /// demote anyone."
    #[error("a persona-store read failed while resolving session scopes")]
    Storage,
}

/// Narrow current-persona reads required to authorize browser sessions.
///
/// The contract lives inward of either the historical local host or the
/// State-backed Control adapter; session policy therefore does not depend on
/// a physical storage implementation.
#[allow(async_fn_in_trait)]
pub trait PersonaSessionReader: Sync {
    /// Reads a current persona profile.
    async fn session_profile(
        &self,
        persona_id: String,
    ) -> Result<Option<polyc_proto::proto::polychrome::persona::v1::PersonaProfile>, ()>;

    /// Resolves the non-secret wallet-address directory pointer.
    async fn session_wallet_persona(&self, wallet_address: &str) -> Result<Option<String>, ()>;

    /// Resolves a passkey credential directory pointer.
    async fn session_credential_persona(&self, credential_id: &[u8]) -> Result<Option<String>, ()>;

    /// Reads authoritative public passkey metadata.
    async fn session_credential(
        &self,
        persona_id: &str,
    ) -> Result<Option<polyc_persona::PersonaCredential>, ()>;
}

impl PersonaSessionReader for polyc_persona::PersonaHost {
    async fn session_profile(
        &self,
        persona_id: String,
    ) -> Result<Option<polyc_proto::proto::polychrome::persona::v1::PersonaProfile>, ()> {
        self.profile(persona_id).await.map_err(|_| ())
    }

    async fn session_wallet_persona(&self, wallet_address: &str) -> Result<Option<String>, ()> {
        self.resolve_wallet_link_address(wallet_address)
            .await
            .map_err(|_| ())
    }

    async fn session_credential_persona(&self, credential_id: &[u8]) -> Result<Option<String>, ()> {
        self.resolve_persona_credential(credential_id)
            .await
            .map_err(|_| ())
    }

    async fn session_credential(
        &self,
        persona_id: &str,
    ) -> Result<Option<polyc_persona::PersonaCredential>, ()> {
        self.get_persona_credential(persona_id)
            .await
            .map_err(|_| ())
    }
}

impl<T> PersonaSessionReader for std::sync::Arc<T>
where
    T: PersonaSessionReader + Send + ?Sized,
{
    async fn session_profile(
        &self,
        persona_id: String,
    ) -> Result<Option<polyc_proto::proto::polychrome::persona::v1::PersonaProfile>, ()> {
        self.as_ref().session_profile(persona_id).await
    }

    async fn session_wallet_persona(&self, wallet_address: &str) -> Result<Option<String>, ()> {
        self.as_ref().session_wallet_persona(wallet_address).await
    }

    async fn session_credential_persona(&self, credential_id: &[u8]) -> Result<Option<String>, ()> {
        self.as_ref()
            .session_credential_persona(credential_id)
            .await
    }

    async fn session_credential(
        &self,
        persona_id: &str,
    ) -> Result<Option<polyc_persona::PersonaCredential>, ()> {
        self.as_ref().session_credential(persona_id).await
    }
}

/// Resolve a session's subject and scopes fresh from current persona-store
/// state.
///
/// The ONE place this policy lives (`docs/reference/web-session-lifetime.md`,
/// "Scopes are recomputed, not replayed"), called by both login ceremonies
/// ([`passkey_login::PasskeyLoginSessions::login`],
/// [`wallet_login::WalletLoginSessions::login`]) and by the control plane's
/// refresh reply-builder. ADR 0007 leans on there being exactly one site for
/// this policy, so tightening it is a change here alone; a hand-copied third
/// would break that guarantee.
///
/// - [`ScopeRoot::PersonaCredential`]: `ExplorerRead` + `AgentTurn` iff the
///   persona is currently [`polyc_persona::STATUS_LINKED`], else no scopes
///   at all. Callers that must REFUSE an unlinked persona outright (the
///   login ceremony's own precondition for establishing a session at all)
///   check that themselves before calling this — this function only
///   computes scopes, it never gates whether a session may be minted.
/// - [`ScopeRoot::Wallet`]: `WalletManage` always; `ExplorerRead` +
///   `AgentTurn` additionally, and the returned subject's persona id set,
///   iff the address currently resolves to a [`polyc_persona::STATUS_LINKED`]
///   persona. The address is never persisted as a resolved persona anywhere
///   — this resolution is deliberately re-run every time.
///
/// # Errors
///
/// Returns [`ScopeResolutionError::Storage`] on a persona-store read
/// failure. Never returns an error for "no persona resolved" or "not
/// linked" — those are ordinary, successful outcomes with fewer scopes.
pub async fn resolve_subject_and_scopes(
    persona: &(impl PersonaSessionReader + ?Sized),
    root: ScopeRoot,
) -> Result<
    (
        polyc_crypto::session::SessionSubject,
        Vec<polyc_crypto::session::SessionScope>,
    ),
    ScopeResolutionError,
> {
    use polyc_crypto::session::{SessionScope, SessionSubject};

    match root {
        ScopeRoot::PersonaCredential { persona_id } => {
            let linked = persona
                .session_profile(persona_id.clone())
                .await
                .map_err(|()| ScopeResolutionError::Storage)?
                .is_some_and(|profile| profile.status == polyc_persona::STATUS_LINKED);
            let scopes = if linked {
                vec![SessionScope::ExplorerRead, SessionScope::AgentTurn]
            } else {
                Vec::new()
            };
            Ok((SessionSubject::Persona { persona_id }, scopes))
        }
        ScopeRoot::Wallet { wallet_address } => {
            let resolved_persona_id = persona
                .session_wallet_persona(&wallet_address)
                .await
                .map_err(|()| ScopeResolutionError::Storage)?;
            let mut scopes = vec![SessionScope::WalletManage];
            let mut linked_persona_id = None;
            if let Some(candidate) = resolved_persona_id {
                let linked = persona
                    .session_profile(candidate.clone())
                    .await
                    .map_err(|()| ScopeResolutionError::Storage)?
                    .is_some_and(|profile| profile.status == polyc_persona::STATUS_LINKED);
                if linked {
                    scopes.push(SessionScope::ExplorerRead);
                    scopes.push(SessionScope::AgentTurn);
                    linked_persona_id = Some(candidate);
                }
            }
            Ok((
                SessionSubject::Wallet {
                    wallet_address,
                    persona_id: linked_persona_id,
                },
                scopes,
            ))
        }
    }
}