pas-external 4.0.1

Ppoppo Accounts System (PAS) external SDK -- OAuth2 PKCE, PASETO verification, Axum middleware, session liveness
Documentation
use derive_more::{Display, From, FromStr, Into};
use serde::{Deserialize, Serialize};
use ulid::Ulid;

use crate::error::Error;

/// PAS ppnum identifier (OAuth `sub` claim, ULID format).
///
/// Immutable, unique per Ppoppo account. Returned as `sub` in OAuth tokens.
/// Consumers store this as the sole link to PAS identity.
#[derive(
    Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Display, FromStr, From, Into,
)]
#[serde(transparent)]
pub struct PpnumId(pub Ulid);

/// Validated Ppoppo Number (≥11 digits, ASCII digits only).
///
/// Format matches PAS DB CHECK constraint `^[0-9]{11,}$`. Variable
/// length (11/15/19/...): 11 digits = independent ppnum, 15+ digits =
/// dependent (sub-agent hierarchy, +4 digits per nesting level).
/// Wire form (this type's `Display` impl) is the raw digit string
/// (`12312345678`). UI layers may render with hyphen grouping
/// (`123-1234-5678`); that formatting is the consumer's choice and
/// is NOT performed by `Display` or `as_str()`. The validator also
/// rejects hyphenated input on parse — only the wire form is accepted.
/// Prefix is band-allocated and carries no semantic meaning — class
/// is decided by `ppnums.entity_type` / `ppnums.number_class` columns
/// server-side, never by leading digits (PAS Constitution Principle III).
/// No upper length bound is enforced; trust PAS issuance.
///
/// Guaranteed valid by construction: holding a `Ppnum` proves the format is correct.
/// Use `"12312345678".parse::<Ppnum>()` or `Ppnum::try_from(string)` to create.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct Ppnum(String);

impl Ppnum {
    #[must_use]
    pub fn as_str(&self) -> &str {
        &self.0
    }
}

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

impl std::str::FromStr for Ppnum {
    type Err = Error;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        Self::try_from(s.to_owned())
    }
}

impl TryFrom<String> for Ppnum {
    type Error = Error;

    fn try_from(s: String) -> Result<Self, Self::Error> {
        // Matches PAS DB CHECK `^[0-9]{11,}$` exactly. Prefix is
        // band-allocated and carries no semantic meaning (Constitution
        // Principle III) — do not validate prefix here.
        if s.len() >= 11 && s.bytes().all(|b| b.is_ascii_digit()) {
            Ok(Self(s))
        } else {
            Err(Error::InvalidPpnum(s))
        }
    }
}

impl From<Ppnum> for String {
    fn from(p: Ppnum) -> Self {
        p.0
    }
}

/// Consumer-defined user identifier (opaque string).
///
/// Returned by [`AccountResolver::resolve`](crate::middleware::AccountResolver::resolve).
/// The consumer chooses the format (ULID, UUID, etc.).
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, Display, From, Into)]
#[serde(transparent)]
pub struct UserId(pub String);

/// Consumer-defined session identifier (opaque string).
///
/// Returned by [`SessionStore::create`](crate::middleware::SessionStore::create).
/// The consumer chooses the format (ULID, UUID, etc.).
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, Display, From, Into)]
#[serde(transparent)]
pub struct SessionId(pub String);

/// PASERK key identifier.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, Display, From, Into)]
#[serde(transparent)]
pub struct KeyId(pub String);

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
    use super::*;
    use static_assertions::assert_impl_all;

    assert_impl_all!(PpnumId: Send, Sync, Copy);
    assert_impl_all!(Ppnum: Send, Sync);
    assert_impl_all!(UserId: Send, Sync);
    assert_impl_all!(SessionId: Send, Sync);
    assert_impl_all!(KeyId: Send, Sync);

    #[test]
    fn valid_ppnum_independent_11_digits() {
        // Any prefix valid — band-allocated, prefix-agnostic
        assert!("12312345678".parse::<Ppnum>().is_ok()); // 100 band (canonical seed)
        assert!("77712345678".parse::<Ppnum>().is_ok()); // legacy 777 band
        assert!("00000000000".parse::<Ppnum>().is_ok()); // edge: all zeros
        assert!("99999999999".parse::<Ppnum>().is_ok());
    }

    #[test]
    fn valid_ppnum_dependent_variable_length() {
        // Sub-agent hierarchy: 11 + 4n digits
        assert!("123123456780001".parse::<Ppnum>().is_ok()); // 15 digits (depth 1)
        assert!("1231234567800010001".parse::<Ppnum>().is_ok()); // 19 digits (depth 2)
        assert!("12312345678000100010001".parse::<Ppnum>().is_ok()); // 23 digits (depth 3)
    }

    #[test]
    fn invalid_ppnum_too_short() {
        // matches! pins the variant — guards against silent collapse to
        // a generic Error variant in future refactors (consumers route
        // on InvalidPpnum to map to 400 BAD_REQUEST).
        assert!(matches!("1234567890".parse::<Ppnum>(), Err(Error::InvalidPpnum(_)))); // 10 digits
        assert!(matches!("123".parse::<Ppnum>(), Err(Error::InvalidPpnum(_)))); // 3 digits
        assert!(matches!("".parse::<Ppnum>(), Err(Error::InvalidPpnum(_)))); // 0 digits
    }

    #[test]
    fn invalid_ppnum_non_digits() {
        assert!(matches!("123abcdefgh".parse::<Ppnum>(), Err(Error::InvalidPpnum(_)))); // letters
        assert!(matches!("12312345678a".parse::<Ppnum>(), Err(Error::InvalidPpnum(_)))); // trailing letter
        assert!(matches!("123-1234-5678".parse::<Ppnum>(), Err(Error::InvalidPpnum(_)))); // hyphenated (display form, not wire)
        assert!(matches!("12312345678 ".parse::<Ppnum>(), Err(Error::InvalidPpnum(_)))); // trailing space
    }

    #[test]
    fn invalid_ppnum_unicode_digits() {
        // is_ascii_digit is byte-level (matches only 0x30..=0x39). Unicode
        // digit characters never pass. This test guards against a future
        // refactor that swaps to chars().any(|c| c.is_numeric()) which
        // would silently start accepting non-ASCII digits the DB rejects.
        assert!(matches!("12312345678".parse::<Ppnum>(), Err(Error::InvalidPpnum(_)))); // fullwidth (U+FF11..)
        assert!(matches!("١٢٣١٢٣٤٥٦٧٨".parse::<Ppnum>(), Err(Error::InvalidPpnum(_)))); // Eastern Arabic-Indic (U+0660..)
        assert!(matches!("১২৩১২৩৪৫৬৭৮".parse::<Ppnum>(), Err(Error::InvalidPpnum(_)))); // Bengali (U+09E6..)
    }

    #[test]
    fn ppnum_serde_rejects_invalid() {
        // Serde must run validation via try_from = "String" — guard
        // against a future migration to #[serde(transparent)] (the
        // pattern PpnumId uses) that would silently bypass.
        assert!(serde_json::from_str::<Ppnum>("\"123\"").is_err()); // too short
        assert!(serde_json::from_str::<Ppnum>("\"abc12345678\"").is_err()); // non-digit
        assert!(serde_json::from_str::<Ppnum>("\"\"").is_err()); // empty
        assert!(serde_json::from_str::<Ppnum>("\"123-1234-5678\"").is_err()); // hyphenated display form
    }

    #[test]
    fn ppnum_serde_roundtrip() {
        let ppnum: Ppnum = "12312345678".parse().unwrap();
        let json = serde_json::to_string(&ppnum).unwrap();
        assert_eq!(json, "\"12312345678\"");
        let parsed: Ppnum = serde_json::from_str(&json).unwrap();
        assert_eq!(parsed, ppnum);
    }

    #[test]
    fn ppnum_id_serde_roundtrip() {
        let id = PpnumId(Ulid::nil());
        let json = serde_json::to_string(&id).unwrap();
        let parsed: PpnumId = serde_json::from_str(&json).unwrap();
        assert_eq!(parsed, id);
    }

    #[test]
    fn user_id_from_string() {
        let id = UserId::from("user-123".to_string());
        assert_eq!(id.to_string(), "user-123");
    }

    #[test]
    fn session_id_from_string() {
        let id = SessionId::from("sess-abc".to_string());
        assert_eq!(id.to_string(), "sess-abc");
    }

    #[test]
    fn newtypes_prevent_mixing() {
        fn takes_user_id(_: &UserId) {}
        fn takes_session_id(_: &SessionId) {}

        let user = UserId::from("id".to_string());
        let session = SessionId::from("id".to_string());

        takes_user_id(&user);
        takes_session_id(&session);
        // takes_user_id(&session);  // Compile error!
        // takes_session_id(&user);  // Compile error!
    }
}