ppoppo-token 0.3.0

JWT (RFC 9068, EdDSA) issuance + verification engine for the Ppoppo ecosystem. Single deep module with a small interface (issue, verify) hiding RFC 8725 mitigations M01-M45, JWKS handling, and substrate ports (epoch, session, replay).
Documentation
//! sv-port — per-account `session_version` epoch revocation check
//! (RFC_2026-05-04_jwt-full-adoption Phase 5 commit 5.5).
//!
//! Account-wide axis distinct from `check_session` (per-row liveness):
//! a `LogoutAll` / break-glass increments the substrate's per-account
//! counter, invalidating every prior token within the cache TTL window.
//! STANDARDS_AUTH_PPOPPO §17.7 + STANDARDS_AUTH_INVALIDATION §2.3.
//!
//! ── sv lives on the wire, hidden from `Claims` ──────────────────────────
//!
//! Phase 4 Decision 1 keeps `sv` HIDDEN (`engine/check_domain.rs` ALLOWED
//! list contains `"sv"` so M45 admits it, but the value is not surfaced).
//! Surfacing would push enforcement responsibility onto every caller —
//! against the deep-module rule. Engine reads sv from the raw payload at
//! the one site that needs it (here) and hands the comparison off to the
//! port.
//!
//! ── Short-circuit conditions ────────────────────────────────────────────
//!
//! 1. `cfg.epoch = None` — port not wired (legacy / sibling-test config).
//!    Same opt-in pattern as `check_replay` / `check_session`.
//! 2. `payload.sv` absent — token was minted without an sv stamp
//!    (AI-agent / `client_credentials`, pre-Phase-5 Human tokens, R6
//!    legacy admit). The break-glass mechanism doesn't apply to those
//!    paths, so the gate is skipped. STANDARDS_AUTH_PPOPPO §4.2.1 R6.
//!
//! ── Why `sv` not `Claims.sv` ────────────────────────────────────────────
//!
//! `Claims.sv` does not exist (Phase 4 design: HIDDEN). Engine reads the
//! raw payload via `parse_payload_json` exactly as `check_domain` does
//! for other hidden claims. The single re-parse here is symmetric with
//! the existing pattern (`check_domain` already re-parses for M40/M41/...).
//!
//! ── Failure-mode contract (fail-closed) ────────────────────────────────
//!
//! - `current(sub) = Ok(c)` and `token.sv >= c` → admit.
//! - `current(sub) = Ok(c)` and `token.sv < c`  → `AuthError::SessionVersionStale`
//!   (security signal — break-glass / LogoutAll just kicked this token).
//! - `current(sub) = Err(_)`                     → `AuthError::SessionVersionLookupUnavailable`
//!   (infrastructure signal — substrate down; admit-on-failure would let
//!   stale tokens slip through during the outage window).
//!
//! ── M37 observability ──────────────────────────────────────────────────
//!
//! Same `revocation.checked` event shape as the replay/session ports so
//! ops dashboards aggregate by `port = "epoch"`.

use crate::access_token::epoch_revocation::EpochRevocationError;
use crate::access_token::{AuthError, Claims, VerifyConfig};
use crate::engine::raw::parse_payload_json;

pub(crate) async fn run(
    token: &str,
    claims: &Claims,
    cfg: &VerifyConfig,
) -> Result<(), AuthError> {
    let Some(port) = cfg.epoch.as_ref() else {
        return Ok(()); // Port not wired — gate disabled.
    };

    // Re-parse the payload to read `sv` (HIDDEN claim — not on `Claims`).
    // Symmetric with `check_domain`'s payload reads; the cost is one
    // serde_json::from_str per verify (~µs).
    let payload = parse_payload_json(token)?;
    let Some(token_sv) = payload.get("sv").and_then(|v| v.as_i64()) else {
        return Ok(()); // Token has no sv stamp — R6 legacy admit.
    };

    match port.current(&claims.sub).await {
        Ok(current) if token_sv >= current => {
            tracing::trace!(
                target: "ppoppo_token::revocation",
                port = "epoch",
                outcome = "admit",
                sub = %claims.sub,
                token_sv,
                current_sv = current,
                "revocation.checked",
            );
            Ok(())
        }
        Ok(current) => {
            tracing::warn!(
                target: "ppoppo_token::revocation",
                port = "epoch",
                outcome = "reject",
                reason = "stale",
                sub = %claims.sub,
                token_sv,
                current_sv = current,
                "revocation.checked",
            );
            Err(AuthError::SessionVersionStale)
        }
        Err(EpochRevocationError::Transient(detail)) => {
            tracing::warn!(
                target: "ppoppo_token::revocation",
                port = "epoch",
                outcome = "transient",
                sub = %claims.sub,
                detail = %detail,
                "revocation.checked",
            );
            Err(AuthError::SessionVersionLookupUnavailable)
        }
    }
}