use figment::Figment;
use figment::providers::Env;
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum ConfigError {
#[error("failed to load configuration: {0}")]
Figment(Box<figment::Error>),
#[error("configuration validation failed: {0}")]
Validation(String),
}
impl From<figment::Error> for ConfigError {
fn from(err: figment::Error) -> Self {
ConfigError::Figment(Box::new(err))
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ServiceConfig {
pub name: String,
pub environment: String,
pub host: String,
pub port: u16,
pub debug: bool,
pub log_level: String,
pub public_base_url: String,
}
impl Default for ServiceConfig {
fn default() -> Self {
Self {
name: "sunbeam-g2v".to_string(),
environment: "development".to_string(),
host: "0.0.0.0".to_string(),
port: 8080,
debug: false,
log_level: "info".to_string(),
public_base_url: String::new(),
}
}
}
impl ServiceConfig {
pub fn load() -> Result<Self, ConfigError> {
let config: Self = Figment::new().merge(Env::raw()).extract()?;
Ok(config)
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct DatabaseConfig {
pub url: String,
pub max_connections: u32,
pub connect_timeout: u64,
}
impl Default for DatabaseConfig {
fn default() -> Self {
Self {
url: "postgres://localhost:5432/sunbeam".to_string(),
max_connections: 10,
connect_timeout: 30,
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct NatsConfig {
pub url: String,
pub jetstream: bool,
pub lease_duration: u64,
pub auth_token: Option<String>,
}
impl Default for NatsConfig {
fn default() -> Self {
Self {
url: "nats://localhost:4222".to_string(),
jetstream: true,
lease_duration: 30,
auth_token: None,
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct AuthConfig {
pub authorization_read_url: String,
pub authorization_write_url: String,
pub system_tenant_ulid: String,
pub cookie_secure: bool,
pub cookie_samesite: String,
pub session_ttl_seconds: u64,
pub token_introspection_cache_ttl_seconds: u64,
pub state_cookie_secret: String,
}
impl Default for AuthConfig {
fn default() -> Self {
Self {
authorization_read_url: "http://localhost:4466".to_string(),
authorization_write_url: "http://localhost:4467".to_string(),
system_tenant_ulid: String::new(),
cookie_secure: true,
cookie_samesite: "strict".to_string(),
session_ttl_seconds: 3600,
token_introspection_cache_ttl_seconds: 60,
state_cookie_secret: String::new(),
}
}
}
impl AuthConfig {
pub fn validate(&self) -> Result<(), ConfigError> {
if !self.system_tenant_ulid.is_empty()
&& ulid::Ulid::from_string(&self.system_tenant_ulid).is_err()
{
return Err(ConfigError::Validation(
"system_tenant_ulid must be a valid ULID".into(),
));
}
if !self.state_cookie_secret.is_empty() {
if self.state_cookie_secret.len() < 32 {
return Err(ConfigError::Validation(
"state_cookie_secret must be at least 32 bytes".into(),
));
}
let lower = self.state_cookie_secret.to_ascii_lowercase();
if lower.contains("change") || lower.contains("secret") || lower.contains("default") {
return Err(ConfigError::Validation(
"state_cookie_secret appears to be a development placeholder; set a strong secret".into(),
));
}
}
Ok(())
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ObservabilityConfig {
pub metrics: bool,
pub tracing: bool,
pub prometheus_endpoint: String,
}
impl Default for ObservabilityConfig {
fn default() -> Self {
Self {
metrics: true,
tracing: true,
prometheus_endpoint: "/metrics".to_string(),
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct LimitsConfig {
pub max_requests_per_sec: u64,
pub burst_size: u64,
pub public_rate_limit_requests: u32,
pub public_rate_limit_window_seconds: u64,
}
impl Default for LimitsConfig {
fn default() -> Self {
Self {
max_requests_per_sec: 100,
burst_size: 50,
public_rate_limit_requests: 100,
public_rate_limit_window_seconds: 60,
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ElectionConfig {
pub enabled: bool,
pub strategy: String,
pub lease_duration: u64,
}
impl Default for ElectionConfig {
fn default() -> Self {
Self {
enabled: false,
strategy: "memory".to_string(),
lease_duration: 30,
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct VaultConfig {
pub url: String,
pub token: String,
pub kv_path: String,
pub lease_duration: u64,
}
impl Default for VaultConfig {
fn default() -> Self {
Self {
url: "http://localhost:8200".to_string(),
token: "root".to_string(),
kv_path: "secret/sunbeam".to_string(),
lease_duration: 30,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_service_config_default() {
let config = ServiceConfig::default();
assert_eq!(config.name, "sunbeam-g2v");
assert_eq!(config.environment, "development");
assert_eq!(config.host, "0.0.0.0");
assert_eq!(config.port, 8080);
assert!(!config.debug);
assert_eq!(config.log_level, "info");
assert_eq!(config.public_base_url, "");
}
#[test]
fn test_database_config_default() {
let config = DatabaseConfig::default();
assert_eq!(config.url, "postgres://localhost:5432/sunbeam");
assert_eq!(config.max_connections, 10);
assert_eq!(config.connect_timeout, 30);
}
#[test]
fn test_nats_config_default() {
let config = NatsConfig::default();
assert_eq!(config.url, "nats://localhost:4222");
assert!(config.jetstream);
assert!(config.auth_token.is_none());
}
#[test]
fn test_auth_config_default() {
let config = AuthConfig::default();
assert_eq!(config.authorization_read_url, "http://localhost:4466");
assert_eq!(config.authorization_write_url, "http://localhost:4467");
assert!(config.system_tenant_ulid.is_empty());
assert!(config.cookie_secure);
assert_eq!(config.cookie_samesite, "strict");
assert_eq!(config.session_ttl_seconds, 3600);
assert_eq!(config.token_introspection_cache_ttl_seconds, 60);
}
#[test]
fn test_auth_config_validate_accepts_empty() {
let config = AuthConfig::default();
assert!(config.validate().is_ok());
}
#[test]
fn test_auth_config_validate_rejects_invalid_ulid() {
let mut config = AuthConfig::default();
config.system_tenant_ulid = "not-a-ulid".into();
assert!(config.validate().is_err());
}
#[test]
fn test_auth_config_validate_rejects_short_secret() {
let mut config = AuthConfig::default();
config.state_cookie_secret = "short".into();
assert!(config.validate().is_err());
}
#[test]
fn test_auth_config_validate_rejects_placeholder_secret() {
let mut config = AuthConfig::default();
config.state_cookie_secret = "change-me-in-production-cookie-secret".into();
assert!(config.validate().is_err());
}
#[test]
fn test_observability_config_default() {
let config = ObservabilityConfig::default();
assert!(config.metrics);
assert!(config.tracing);
assert_eq!(config.prometheus_endpoint, "/metrics");
}
#[test]
fn test_limits_config_default() {
let config = LimitsConfig::default();
assert_eq!(config.max_requests_per_sec, 100);
assert_eq!(config.burst_size, 50);
assert_eq!(config.public_rate_limit_requests, 100);
assert_eq!(config.public_rate_limit_window_seconds, 60);
}
#[test]
fn test_election_config_default() {
let config = ElectionConfig::default();
assert!(!config.enabled);
assert_eq!(config.strategy, "memory");
assert_eq!(config.lease_duration, 30);
}
#[test]
fn test_vault_config_default() {
let config = VaultConfig::default();
assert_eq!(config.url, "http://localhost:8200");
assert_eq!(config.token, "root");
assert_eq!(config.kv_path, "secret/sunbeam");
}
}