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
//! Per-issuance request payload.
//!
//! Phase 3 carved out the registered-claim core (`sub`, `client_id`, `ttl`,
//! `jti`); Phase 4 (this expansion) adds 9 domain claim fields that the
//! verifier's `engine/check_domain` mirrors. Every claim that varies per
//! token lives here, every claim that's stable across many tokens lives on
//! `IssueConfig`.
//!
//! Fields are `pub` rather than enclosed by accessors. The struct is a
//! data carrier (mirrors `Claims` on the verify side); a builder for one
//! optional field would be ceremony without payoff. Callers that adopt
//! struct-literal syntax (none today) will get compile errors when later
//! phases add fields, which is the right failure mode (silent defaulting
//! hides intent).
//!
//! ── Default-deny invariant ───────────────────────────────────────────────
//!
//! `admin`, `caps`, `dlg_depth`, `scopes` all default to "deny / empty / 0".
//! Callers MUST opt in explicitly via `.with_admin(true)` / `.with_caps(...)`
//! / etc. No issuance path can accidentally mint an admin token by
//! forgetting to set a flag — the absent default is the safe default.

use std::time::Duration;
use ulid::Ulid;

#[derive(Debug, Clone)]
pub struct IssueRequest {
    /// Subject — the principal the token is about (RFC 7519 §4.1.2).
    /// PAS-issued human tokens carry `ppnum_id` (ULID); AI-agent tokens
    /// carry the agent's ULID. Never empty.
    pub sub: String,

    /// `client_id` — the OAuth client whose credentials authorized this
    /// token (RFC 9068 §2.2). 1st-party flows use `"ppoppo-internal"`;
    /// External Developer flows use the registered OAuth client_id.
    pub client_id: String,

    /// Time-to-live from now. The engine computes `exp = iat + ttl` and
    /// emits both. Per-profile cap (24h access / 200d refresh) is
    /// enforced via M19 on the verify side.
    pub ttl: Duration,

    /// Optional caller-supplied `jti`. When `None`, `engine::encode::issue`
    /// generates a fresh ULID at issuance time. Tests pin a known ULID
    /// so assertions can match by exact value.
    pub jti: Option<Ulid>,

    // ── Phase 4 domain claims (M39–M45) ──────────────────────────────────
    /// `account_type` — principal class (M40). Whitelist `{human, ai_agent}`
    /// enforced verifier-side; absent for legacy tokens. PAS sets `"human"`
    /// on user-facing flows and `"ai_agent"` on client_credentials.
    pub account_type: Option<String>,

    /// `admin` — issue-time admin gate flag (M44). When `true`, the
    /// verifier additionally requires `active_ppnum` (or `sub` band
    /// fallback) to fall in an admin-allocated band — defense in depth
    /// against forged tokens with a stolen signing key.
    pub admin: bool,

    /// `caps` — capability list (M41). Default `[]` is the default-deny
    /// surface contract: a token with no capabilities cannot perform any
    /// privileged operation. Engine validates only that the wire shape
    /// is an array of strings; semantic enforcement is per-surface.
    pub caps: Vec<String>,

    /// `delegator` — delegating principal's `ppnum_id` (M40-adjacent).
    /// Set on tokens minted via Token Exchange flows to record who
    /// authorized the delegated session. Audit logs key off this field.
    /// (Wire name: `delegator`; the earlier `actor` name was retired —
    /// RFC 8693 reserves `actor` for token-exchange chain semantics that
    /// don't apply here.)
    pub delegator: Option<String>,

    /// `dlg_depth` — delegation chain depth (M43). 0 = original principal,
    /// each Token Exchange step increments by 1. Engine rejects > 4 to
    /// bound the audit-trail explosion of arbitrarily deep delegation.
    /// `u8` is intentional: there is no scenario where depth ≥ 256.
    pub dlg_depth: u8,

    /// `cid` — WebAuthn credential id that authenticated this session
    /// (passkey path only). Enables session-to-credential provenance for
    /// forensic analysis and selective-session-kill flows. Absent on
    /// every non-passkey path so audit logs distinguish authentication
    /// methods without a per-row lookup.
    pub cid: Option<String>,

    /// `sv` — per-account `session_version` snapshot (Human path only).
    /// Validators compare `token.sv >= cached(sv:{sub})` and reject
    /// stale tokens; the counter bumps inside the break-glass TX,
    /// invalidating all prior tokens within the consumer cache TTL.
    /// Absent on AI-agent and delegated tokens (no break-glass mechanism).
    pub sv: Option<u64>,

    /// `active_ppnum` — display ppnum (e.g. `123-1234-5678`). UI surfaces
    /// render this; `sub` is the immutable ULID and is the authorization
    /// axis. Absent on tokens that don't represent a human-facing
    /// session (raw machine tokens).
    pub active_ppnum: Option<String>,

    /// `scopes` — OAuth scope list (M42). Engine bounds the array to ≤ 256
    /// entries (RFC 8725-adjacent — bound the per-token audit surface).
    /// Default `[]` is "no externally-granted scope"; 1st-party flows
    /// emit a non-empty list (`profile`, `email`, etc).
    pub scopes: Vec<String>,

    /// `sid` — session row id (M36, Phase 5). When set, the verifier's
    /// `cfg.session_revocation::is_active(sub, sid)` query gates token
    /// admission against `user_sessions(sub, sid)` row liveness — row
    /// deletion = revocation per STANDARDS_JWT_DETAILS_MITIGATION §E.
    /// PAS issuance sets this on every Human-path token bound to a
    /// session row; AI-agent / machine flows leave it unset and the
    /// verifier short-circuits the gate. Wire shape: ULID string when
    /// present (matches `user_sessions.session_id` PK).
    pub sid: Option<String>,
}

impl IssueRequest {
    /// Construct a new request with the required fields. Domain claim
    /// fields default to "absent / empty / 0 / false" — every emission
    /// is opt-in via a `with_*` builder, so a caller who forgets to set
    /// `admin` cannot accidentally mint an admin token.
    pub fn new(sub: impl Into<String>, client_id: impl Into<String>, ttl: Duration) -> Self {
        Self {
            sub: sub.into(),
            client_id: client_id.into(),
            ttl,
            jti: None,
            account_type: None,
            admin: false,
            caps: Vec::new(),
            delegator: None,
            dlg_depth: 0,
            cid: None,
            sv: None,
            active_ppnum: None,
            scopes: Vec::new(),
            sid: None,
        }
    }

    /// Pin a specific `jti` instead of letting the engine generate one.
    /// Test-only escape hatch — production paths should never override.
    #[must_use]
    pub fn with_jti(mut self, jti: Ulid) -> Self {
        self.jti = Some(jti);
        self
    }

    /// Set `account_type` (M40). PAS issuance paths pass `"human"` or
    /// `"ai_agent"`; the verifier's whitelist (Phase 4 commit 4.2)
    /// rejects anything else.
    #[must_use]
    pub fn with_account_type(mut self, account_type: impl Into<String>) -> Self {
        self.account_type = Some(account_type.into());
        self
    }

    /// Set the admin gate flag (M44). Combined with `active_ppnum` band
    /// check on the verify side, this is the issue-time half of the
    /// admin-token defense in depth.
    #[must_use]
    pub fn with_admin(mut self, admin: bool) -> Self {
        self.admin = admin;
        self
    }

    /// Set the capability list (M41). An empty list (the default) means
    /// no privileged capabilities; surface code MUST positive-check.
    #[must_use]
    pub fn with_caps(mut self, caps: Vec<String>) -> Self {
        self.caps = caps;
        self
    }

    /// Set the delegating principal's `ppnum_id` (M40-adjacent). Token
    /// Exchange flows record the human authorizer here.
    #[must_use]
    pub fn with_delegator(mut self, delegator: impl Into<String>) -> Self {
        self.delegator = Some(delegator.into());
        self
    }

    /// Set the delegation chain depth (M43). 0 = direct, increments by 1
    /// per Token Exchange step; engine bounds at 4.
    #[must_use]
    pub fn with_dlg_depth(mut self, dlg_depth: u8) -> Self {
        self.dlg_depth = dlg_depth;
        self
    }

    /// Set the WebAuthn credential id (`cid`). Call this only on the
    /// passkey issuance path; other paths MUST leave it unset so audit
    /// logs distinguish authentication methods without a per-row lookup.
    #[must_use]
    pub fn with_credential_id(mut self, credential_id: impl Into<String>) -> Self {
        self.cid = Some(credential_id.into());
        self
    }

    /// Set the per-account `session_version` (Human entity path only).
    /// AI-agent and delegated paths MUST leave it unset — they have no
    /// break-glass mechanism, and emitting `sv = 0` would lock those
    /// tokens out the moment the human originator break-glasses.
    #[must_use]
    pub fn with_session_version(mut self, sv: u64) -> Self {
        self.sv = Some(sv);
        self
    }

    /// Set the display ppnum (`active_ppnum`). UI surfaces render this;
    /// `sub` remains the immutable ULID for authorization decisions.
    #[must_use]
    pub fn with_active_ppnum(mut self, active_ppnum: impl Into<String>) -> Self {
        self.active_ppnum = Some(active_ppnum.into());
        self
    }

    /// Set the OAuth scope list (M42). Engine bounds the array to ≤ 256
    /// entries on the verify side.
    #[must_use]
    pub fn with_scopes(mut self, scopes: Vec<String>) -> Self {
        self.scopes = scopes;
        self
    }

    /// Set the session row id (`sid` claim, M36 — Phase 5). Call this
    /// only on issuance paths bound to a `user_sessions` row (Human
    /// magic-link / passkey / refresh-cycle); AI-agent and machine
    /// paths MUST leave it unset so the verifier short-circuits the
    /// session-revocation gate. The verifier compares `(sub, sid)`
    /// against the substrate; row deletion = revocation per
    /// STANDARDS_JWT_DETAILS_MITIGATION §E.
    #[must_use]
    pub fn with_sid(mut self, sid: impl Into<String>) -> Self {
        self.sid = Some(sid.into());
        self
    }
}