//! Value Objects for K-Notes Domain //! //! Newtypes that encapsulate validation logic, following the "parse, don't validate" pattern. //! These types can only be constructed if the input is valid, providing compile-time guarantees. use serde::{Deserialize, Deserializer, Serialize, Serializer}; use std::fmt; use thiserror::Error; use url::Url; use uuid::Uuid; pub type UserId = Uuid; // ============================================================================ // Validation Error // ============================================================================ /// Errors that occur when parsing/validating value objects #[derive(Debug, Error, Clone, PartialEq, Eq)] #[non_exhaustive] pub enum ValidationError { #[error("Invalid email format: {0}")] InvalidEmail(String), #[error("Password must be at least {min} characters, got {actual}")] PasswordTooShort { min: usize, actual: usize }, #[error("Invalid URL: {0}")] InvalidUrl(String), #[error("Value cannot be empty: {0}")] Empty(String), #[error("Secret too short: minimum {min} bytes required, got {actual}")] SecretTooShort { min: usize, actual: usize }, } // ============================================================================ // Email (using email_address crate for RFC-compliant validation) // ============================================================================ /// A validated email address using RFC-compliant validation. #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct Email(email_address::EmailAddress); impl Email { /// Create a new validated email address pub fn new(value: impl AsRef) -> Result { let value = value.as_ref().trim().to_lowercase(); let addr: email_address::EmailAddress = value .parse() .map_err(|_| ValidationError::InvalidEmail(value.clone()))?; Ok(Self(addr)) } /// Get the inner value pub fn into_inner(self) -> String { self.0.to_string() } } impl AsRef for Email { fn as_ref(&self) -> &str { self.0.as_ref() } } impl fmt::Display for Email { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", self.0) } } impl TryFrom for Email { type Error = ValidationError; fn try_from(value: String) -> Result { Self::new(value) } } impl TryFrom<&str> for Email { type Error = ValidationError; fn try_from(value: &str) -> Result { Self::new(value) } } impl Serialize for Email { fn serialize(&self, serializer: S) -> Result { serializer.serialize_str(self.0.as_ref()) } } impl<'de> Deserialize<'de> for Email { fn deserialize>(deserializer: D) -> Result { let s = String::deserialize(deserializer)?; Self::new(s).map_err(serde::de::Error::custom) } } // ============================================================================ // Password // ============================================================================ /// A validated password input (NOT the hash). /// /// Enforces minimum length of 6 characters. #[derive(Clone, PartialEq, Eq)] pub struct Password(String); /// Minimum password length (NIST recommendation) pub const MIN_PASSWORD_LENGTH: usize = 8; impl Password { pub fn new(value: impl Into) -> Result { let value = value.into(); if value.len() < MIN_PASSWORD_LENGTH { return Err(ValidationError::PasswordTooShort { min: MIN_PASSWORD_LENGTH, actual: value.len(), }); } Ok(Self(value)) } pub fn into_inner(self) -> String { self.0 } } impl AsRef for Password { fn as_ref(&self) -> &str { &self.0 } } // Intentionally hide password content in Debug impl fmt::Debug for Password { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "Password(***)") } } impl TryFrom for Password { type Error = ValidationError; fn try_from(value: String) -> Result { Self::new(value) } } impl TryFrom<&str> for Password { type Error = ValidationError; fn try_from(value: &str) -> Result { Self::new(value) } } impl<'de> Deserialize<'de> for Password { fn deserialize>(deserializer: D) -> Result { let s = String::deserialize(deserializer)?; Self::new(s).map_err(serde::de::Error::custom) } } // Note: Password should NOT implement Serialize to prevent accidental exposure // ============================================================================ // OIDC Configuration Newtypes // ============================================================================ /// OIDC Issuer URL - validated URL for the identity provider /// /// Stores the original string to preserve exact formatting (e.g., trailing slashes) /// since OIDC providers expect issuer URLs to match exactly. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(try_from = "String", into = "String")] pub struct IssuerUrl(String); impl IssuerUrl { pub fn new(value: impl AsRef) -> Result { let value = value.as_ref().trim().to_string(); // Validate URL format but store original string to preserve exact formatting Url::parse(&value).map_err(|e| ValidationError::InvalidUrl(e.to_string()))?; Ok(Self(value)) } } impl AsRef for IssuerUrl { fn as_ref(&self) -> &str { &self.0 } } impl fmt::Display for IssuerUrl { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", self.0) } } impl TryFrom for IssuerUrl { type Error = ValidationError; fn try_from(value: String) -> Result { Self::new(value) } } impl From for String { fn from(val: IssuerUrl) -> Self { val.0 } } /// OIDC Client Identifier #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(try_from = "String", into = "String")] pub struct ClientId(String); impl ClientId { pub fn new(value: impl Into) -> Result { let value = value.into().trim().to_string(); if value.is_empty() { return Err(ValidationError::Empty("client_id".to_string())); } Ok(Self(value)) } } impl AsRef for ClientId { fn as_ref(&self) -> &str { &self.0 } } impl fmt::Display for ClientId { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", self.0) } } impl TryFrom for ClientId { type Error = ValidationError; fn try_from(value: String) -> Result { Self::new(value) } } impl From for String { fn from(val: ClientId) -> Self { val.0 } } /// OIDC Client Secret - hidden in Debug output #[derive(Clone, PartialEq, Eq)] pub struct ClientSecret(String); impl ClientSecret { pub fn new(value: impl Into) -> Self { Self(value.into()) } /// Check if the secret is empty (for public clients) pub fn is_empty(&self) -> bool { self.0.trim().is_empty() } } impl AsRef for ClientSecret { fn as_ref(&self) -> &str { &self.0 } } impl fmt::Debug for ClientSecret { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "ClientSecret(***)") } } impl fmt::Display for ClientSecret { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "***") } } impl<'de> Deserialize<'de> for ClientSecret { fn deserialize>(deserializer: D) -> Result { let s = String::deserialize(deserializer)?; Ok(Self::new(s)) } } // Note: ClientSecret should NOT implement Serialize /// OAuth Redirect URL - validated URL #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(try_from = "String", into = "String")] pub struct RedirectUrl(Url); impl RedirectUrl { pub fn new(value: impl AsRef) -> Result { let value = value.as_ref().trim(); let url = Url::parse(value).map_err(|e| ValidationError::InvalidUrl(e.to_string()))?; Ok(Self(url)) } pub fn as_url(&self) -> &Url { &self.0 } } impl AsRef for RedirectUrl { fn as_ref(&self) -> &str { self.0.as_str() } } impl fmt::Display for RedirectUrl { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", self.0) } } impl TryFrom for RedirectUrl { type Error = ValidationError; fn try_from(value: String) -> Result { Self::new(value) } } impl From for String { fn from(val: RedirectUrl) -> Self { val.0.to_string() } } /// OIDC Resource Identifier (optional audience) #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(try_from = "String", into = "String")] pub struct ResourceId(String); impl ResourceId { pub fn new(value: impl Into) -> Result { let value = value.into().trim().to_string(); if value.is_empty() { return Err(ValidationError::Empty("resource_id".to_string())); } Ok(Self(value)) } } impl AsRef for ResourceId { fn as_ref(&self) -> &str { &self.0 } } impl fmt::Display for ResourceId { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", self.0) } } impl TryFrom for ResourceId { type Error = ValidationError; fn try_from(value: String) -> Result { Self::new(value) } } impl From for String { fn from(val: ResourceId) -> Self { val.0 } } // ============================================================================ // OIDC Flow Newtypes (for type-safe session storage) // ============================================================================ /// CSRF Token for OIDC state parameter #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct CsrfToken(String); impl CsrfToken { pub fn new(value: impl Into) -> Self { Self(value.into()) } } impl AsRef for CsrfToken { fn as_ref(&self) -> &str { &self.0 } } impl fmt::Display for CsrfToken { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", self.0) } } /// Nonce for OIDC ID token verification #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct OidcNonce(String); impl OidcNonce { pub fn new(value: impl Into) -> Self { Self(value.into()) } } impl AsRef for OidcNonce { fn as_ref(&self) -> &str { &self.0 } } impl fmt::Display for OidcNonce { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", self.0) } } /// PKCE Code Verifier #[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct PkceVerifier(String); impl PkceVerifier { pub fn new(value: impl Into) -> Self { Self(value.into()) } } impl AsRef for PkceVerifier { fn as_ref(&self) -> &str { &self.0 } } // Hide PKCE verifier in Debug (security) impl fmt::Debug for PkceVerifier { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "PkceVerifier(***)") } } /// OAuth2 Authorization Code #[derive(Clone, PartialEq, Eq)] pub struct AuthorizationCode(String); impl AuthorizationCode { pub fn new(value: impl Into) -> Self { Self(value.into()) } } impl AsRef for AuthorizationCode { fn as_ref(&self) -> &str { &self.0 } } // Hide authorization code in Debug (security) impl fmt::Debug for AuthorizationCode { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "AuthorizationCode(***)") } } impl<'de> Deserialize<'de> for AuthorizationCode { fn deserialize>(deserializer: D) -> Result { let s = String::deserialize(deserializer)?; Ok(Self::new(s)) } } /// Complete authorization URL data returned when starting OIDC flow #[derive(Debug, Clone)] pub struct AuthorizationUrlData { /// The URL to redirect the user to pub url: Url, /// CSRF token to store in session pub csrf_token: CsrfToken, /// Nonce to store in session pub nonce: OidcNonce, /// PKCE verifier to store in session pub pkce_verifier: PkceVerifier, } // ============================================================================ // Configuration Newtypes // ============================================================================ /// JWT signing secret with minimum length requirement pub const MIN_JWT_SECRET_LENGTH: usize = 32; #[derive(Clone, PartialEq, Eq)] pub struct JwtSecret(String); impl JwtSecret { pub fn new(value: impl Into, is_production: bool) -> Result { let value = value.into(); if is_production && value.len() < MIN_JWT_SECRET_LENGTH { return Err(ValidationError::SecretTooShort { min: MIN_JWT_SECRET_LENGTH, actual: value.len(), }); } Ok(Self(value)) } /// Create without validation (for development/testing) pub fn new_unchecked(value: impl Into) -> Self { Self(value.into()) } } impl AsRef for JwtSecret { fn as_ref(&self) -> &str { &self.0 } } impl fmt::Debug for JwtSecret { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "JwtSecret(***)") } } // ============================================================================ // Channel / Schedule types // ============================================================================ pub type ChannelId = Uuid; pub type SlotId = Uuid; pub type BlockId = Uuid; /// Opaque media item identifier — format is provider-specific internally. /// The domain never inspects the string; it just passes it back to the provider. #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] pub struct MediaItemId(String); impl MediaItemId { pub fn new(value: impl Into) -> Self { Self(value.into()) } pub fn into_inner(self) -> String { self.0 } } impl AsRef for MediaItemId { fn as_ref(&self) -> &str { &self.0 } } impl fmt::Display for MediaItemId { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", self.0) } } impl From for MediaItemId { fn from(s: String) -> Self { Self(s) } } impl From<&str> for MediaItemId { fn from(s: &str) -> Self { Self(s.to_string()) } } /// The broad category of a media item. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ContentType { Movie, Episode, Short, } /// Provider-agnostic filter for querying media items. /// /// Each field is optional — omitting it means "no constraint on this dimension". /// The `IMediaProvider` adapter interprets these fields in terms of its own API. #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct MediaFilter { pub content_type: Option, pub genres: Vec, /// Starting year of a decade: 1990 means 1990–1999. pub decade: Option, pub tags: Vec, pub min_duration_secs: Option, pub max_duration_secs: Option, /// Abstract groupings interpreted by each provider (Jellyfin library, Plex section, /// filesystem path, etc.). An empty list means "all available content". pub collections: Vec, } /// How the scheduling engine fills a time block with selected media items. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum FillStrategy { /// Greedy bin-packing: at each step pick the longest item that still fits, /// minimising dead air. Good for variety blocks. BestFit, /// Pick items in the order returned by the provider — ideal for series /// where episode sequence matters. Sequential, /// Shuffle the pool randomly then fill sequentially. Good for "shuffle play" channels. Random, } /// Controls when previously aired items become eligible to play again. /// /// An item is *on cooldown* if *either* threshold is met. /// `min_available_ratio` is a safety valve: if honouring the cooldown would /// leave fewer items than this fraction of the total pool, the cooldown is /// ignored and all items become eligible. This prevents small libraries from /// running completely dry. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RecyclePolicy { /// Do not replay an item within this many calendar days. pub cooldown_days: Option, /// Do not replay an item within this many schedule generations. pub cooldown_generations: Option, /// Always keep at least this fraction (0.0–1.0) of the matching pool /// available for selection, even if their cooldown has not yet expired. pub min_available_ratio: f32, } impl Default for RecyclePolicy { fn default() -> Self { Self { cooldown_days: Some(30), cooldown_generations: None, min_available_ratio: 0.2, } } } // ============================================================================ // Tests // ============================================================================ #[cfg(test)] mod tests { use super::*; mod email_tests { use super::*; #[test] fn test_valid_email() { assert!(Email::new("user@example.com").is_ok()); assert!(Email::new("USER@EXAMPLE.COM").is_ok()); // Should lowercase assert!(Email::new(" user@example.com ").is_ok()); // Should trim } #[test] fn test_email_normalizes() { let email = Email::new(" USER@EXAMPLE.COM ").unwrap(); assert_eq!(email.as_ref(), "user@example.com"); } #[test] fn test_invalid_email_no_at() { assert!(Email::new("userexample.com").is_err()); } #[test] fn test_invalid_email_no_domain() { assert!(Email::new("user@").is_err()); } #[test] fn test_invalid_email_no_local() { assert!(Email::new("@example.com").is_err()); } } mod password_tests { use super::*; #[test] fn test_valid_password() { assert!(Password::new("secret123").is_ok()); assert!(Password::new("12345678").is_ok()); // Exactly 8 chars } #[test] fn test_password_too_short() { assert!(Password::new("1234567").is_err()); // 7 chars assert!(Password::new("").is_err()); } #[test] fn test_password_debug_hides_content() { let password = Password::new("supersecret").unwrap(); let debug = format!("{:?}", password); assert!(!debug.contains("supersecret")); assert!(debug.contains("***")); } } mod oidc_tests { use super::*; #[test] fn test_issuer_url_valid() { assert!(IssuerUrl::new("https://auth.example.com").is_ok()); } #[test] fn test_issuer_url_invalid() { assert!(IssuerUrl::new("not-a-url").is_err()); } #[test] fn test_client_id_non_empty() { assert!(ClientId::new("my-client").is_ok()); assert!(ClientId::new("").is_err()); assert!(ClientId::new(" ").is_err()); } #[test] fn test_client_secret_hides_in_debug() { let secret = ClientSecret::new("super-secret"); let debug = format!("{:?}", secret); assert!(!debug.contains("super-secret")); assert!(debug.contains("***")); } } mod secret_tests { use super::*; #[test] fn test_jwt_secret_production_check() { let short = "short"; let long = "a".repeat(32); // Production mode enforces length assert!(JwtSecret::new(short, true).is_err()); assert!(JwtSecret::new(&long, true).is_ok()); // Development mode allows short secrets assert!(JwtSecret::new(short, false).is_ok()); } #[test] fn test_secrets_hide_in_debug() { let jwt = JwtSecret::new_unchecked("secret"); assert!(!format!("{:?}", jwt).contains("secret")); } } }