//! Server configuration loaded from environment variables use std::collections::HashMap; use std::net::{IpAddr, SocketAddr}; use std::sync::Arc; use crate::db::{CreatorTier, UserId}; #[derive(Clone)] pub struct Config { /// Server host address pub host: IpAddr, /// Server port pub port: u16, /// Database connection URL pub database_url: String, /// Public-facing host URL (e.g., "https://makenot.work" or "localhost:3000"). /// Stored as `Arc` so cloning into spawned tasks / templates is cheap. pub host_url: Arc, /// Secret key for signing tokens (password reset, email verification, etc.) pub signing_secret: String, /// S3-compatible storage configuration (optional) pub storage: Option, /// Separate S3 bucket for SyncKit blob storage (optional) pub synckit_storage: Option, /// Stripe payment configuration (optional) pub stripe: Option, /// Admin user ID for waitlist management (optional) pub admin_user_id: Option, /// JWT secret for SyncKit token signing (optional) pub synckit_jwt_secret: Option, /// File scanning configuration (optional) pub scan: Option, /// Path to bare git repositories on disk (optional) pub git_repos_path: Option, /// Bearer token for authenticating Postmark webhook requests (optional) pub postmark_webhook_token: Option, /// Bearer token for authenticating Postmark broadcast stream webhooks (optional) pub postmark_broadcast_webhook_token: Option, /// Hostname for git SSH clone URLs (e.g., "git.makenot.work"). Hidden when not set. pub git_ssh_host: Option, /// Base URL of the Multithreaded forum instance (e.g., "https://forums.makenot.work"). /// When set, enables the Forums tab on the user dashboard. pub mt_base_url: Option, /// Stripe Price ID for the Fan+ subscription ($8/mo). /// When set, enables Fan+ subscription checkout. pub fan_plus_price_id: Option, /// Stripe Price IDs for creator tier subscriptions (monthly). /// Maps each tier to its Stripe Price ID. Empty map = creator tiers disabled. pub creator_tier_prices: HashMap, /// Stripe Price IDs for creator tier subscriptions, annual billing. /// 10% off monthly × 12 — see `assumptions.toml` § annual_discount. Missing /// entries fall back to monthly: checkout silently downgrades the interval /// rather than erroring. pub creator_tier_annual_prices: HashMap, /// Stripe Price IDs for *founder* creator tier subscriptions, monthly /// (50% off, locked for life). Used during the founder window or for /// accounts whose `founder_locked_at` is set. Missing entries fall back to /// sticker prices in `creator_tier_prices`. See `project_founder_pricing.md`. pub creator_tier_founder_prices: HashMap, /// Stripe Price IDs for *founder* creator tier subscriptions, annual. /// 10% off founder monthly × 12. Missing entries fall back to founder /// monthly first, then sticker monthly. Checkout silently downgrades; no /// error response. pub creator_tier_founder_annual_prices: HashMap, /// Whether the founder-pricing window is currently open. While true, new /// creator-tier subscriptions get founder prices and the subscribing user /// is marked `is_founder = true`. Flip to false when the window closes /// (1,000 creators or exit-beta, whichever first); a separate admin action /// then sweeps `founder_locked_at` on active founder accounts. pub creator_founder_window_open: bool, /// Bearer token for authenticating build trigger webhook requests (optional). pub build_trigger_token: Option, /// SSH host for Linux builds (e.g., "max@100.106.221.39"). pub build_host_linux: Option, /// SSH host for macOS builds (e.g., "max@100.64.x.x"). pub build_host_darwin: Option, /// Base URL for CDN-served downloads (e.g., "https://cdn.makenot.work"). /// When set, free content downloads are served via CDN instead of presigned S3 URLs. pub cdn_base_url: Option, /// Bearer token for authenticating Postmark inbound email webhook (optional). pub postmark_inbound_webhook_token: Option, /// Shared secret for HMAC-signed internal API requests to MT. /// Must match `INTERNAL_SHARED_SECRET` on the MT instance. pub internal_shared_secret: Option, /// Bearer token for authenticating CLI SSH server → MNW internal API calls. /// When unset, internal API endpoints return 503. pub cli_service_token: Option, /// Base URL of the WAM ticket manager (e.g., "http://100.x.x.x:7890"). /// When set, operational events create WAM tickets for human triage. pub wam_url: Option, /// Site-wide access gate. `Open` (default) serves the public site as /// normal. `FanPlusOrCreator` restricts the whole site to logged-in users /// with a creator account or an active Fan+ subscription — used on the /// testnot.work staging mirror so it's reachable only by Fan+/creator /// accounts. Off in production. pub access_gate: AccessGate, /// Upstream SSO provider for "Sign in with Makenot.work" (optional). When /// set, the login page becomes a single button that authenticates against /// `provider_url`'s OAuth endpoints instead of a local password form — used /// on the testnot mirror so a password is only ever entered on production. pub sso: Option, } /// Upstream OAuth provider config for delegated login (`SSO_*`). #[derive(Clone)] pub struct SsoConfig { /// Base URL of the OAuth provider, e.g. `https://makenot.work` (no trailing slash). pub provider_url: String, /// `client_id` = the provider's registered `sync_apps.api_key` (raw key). pub client_id: String, /// SyncKit SDK key string sent on token exchange. Any non-empty string the /// provider's `validate_synckit_key` accepts; identifies no billing slot /// here — we discard the sync token and use only the returned `user_id`. pub key: String, } impl SsoConfig { /// Present only when all three `SSO_*` vars are set; otherwise `None` /// (login falls back to the local password form). pub fn from_env() -> Option { let provider_url = std::env::var("SSO_PROVIDER_URL").ok()?; let client_id = std::env::var("SSO_CLIENT_ID").ok()?; let key = std::env::var("SSO_KEY").ok()?; if provider_url.is_empty() || client_id.is_empty() || key.is_empty() { return None; } Some(Self { provider_url: provider_url.trim_end_matches('/').to_string(), client_id, key, }) } } /// Site-wide access-gate mode (`ACCESS_GATE`). #[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] pub enum AccessGate { /// No gate — the public site is served to everyone (production default). #[default] Open, /// Only logged-in creators or active Fan+ members may reach the site; /// everyone else is bounced to login. A coarse pre-filter — per-route auth /// still applies underneath. FanPlusOrCreator, } /// S3-compatible storage configuration (Hetzner Object Storage) #[derive(Clone)] pub struct StorageConfig { /// S3 endpoint URL (e.g., https://fsn1.your-objectstorage.com) pub endpoint: String, /// Bucket name pub bucket: String, /// Access key ID pub access_key: String, /// Secret access key pub secret_key: String, /// Region (e.g., fsn1) pub region: String, } impl Config { /// Load configuration from environment variables pub fn from_env() -> Result { let host: IpAddr = std::env::var("HOST") .unwrap_or_else(|_| "127.0.0.1".to_string()) .parse() .map_err(|_| ConfigError::InvalidHost)?; let port: u16 = std::env::var("PORT") .unwrap_or_else(|_| "3000".to_string()) .parse() .map_err(|_| ConfigError::InvalidPort)?; let database_url = std::env::var("DATABASE_URL").map_err(|_| ConfigError::MissingDatabaseUrl)?; let host_url = std::env::var("HOST_URL") .unwrap_or_else(|_| format!("http://{}:{}", host, port)); // Secret key for signing tokens — required in production, random fallback in dev let signing_secret = match std::env::var("SIGNING_SECRET") { Ok(secret) => { if secret.len() < 32 { return Err(ConfigError::WeakSigningSecret); } secret } Err(_) => { // If HOST is 0.0.0.0 or HOST_URL looks like production, refuse to start let is_production = host == std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED) || std::env::var("HOST_URL") .map(|u| u.starts_with("https://")) .unwrap_or(false); if is_production { return Err(ConfigError::MissingSigningSecret); } tracing::warn!("SIGNING_SECRET not set — using random value (dev mode only)"); let mut bytes = [0u8; 32]; rand::RngCore::fill_bytes(&mut rand::rng(), &mut bytes); hex::encode(bytes) } }; // Load storage config - optional, returns None if not fully configured let storage = StorageConfig::from_env(); // Load SyncKit blob storage config - separate S3 bucket let synckit_storage = StorageConfig::from_env_prefixed("SYNCKIT_S3_"); // Load Stripe config - optional, returns None if not fully configured let stripe = StripeConfig::from_env(); // Load admin user ID - optional, if unset admin routes return 404 let admin_user_id = std::env::var("ADMIN_USER_ID") .ok() .and_then(|s| s.parse::().ok()); // SyncKit JWT secret - optional, sync endpoints return 503 if unset let synckit_jwt_secret = std::env::var("SYNCKIT_JWT_SECRET").ok(); // File scanning - enabled by default, set SCAN_ENABLED=false to disable let scan = ScanConfig::from_env(); // Git repos path - optional, git browser disabled if unset let git_repos_path = std::env::var("GIT_REPOS_PATH").ok(); // Postmark webhook token - optional, webhook endpoint returns 401 if unset let postmark_webhook_token = std::env::var("POSTMARK_WEBHOOK_TOKEN").ok(); // Postmark broadcast stream webhook token - optional, same endpoint accepts either token let postmark_broadcast_webhook_token = std::env::var("POSTMARK_BROADCAST_WEBHOOK_TOKEN").ok(); // Git SSH host - optional, SSH clone URL hidden when unset let git_ssh_host = std::env::var("GIT_SSH_HOST").ok(); // Multithreaded forum base URL - optional, Forums tab hidden when unset let mt_base_url = std::env::var("MT_BASE_URL").ok(); // Fan+ Stripe Price ID - optional, Fan+ checkout disabled when unset let fan_plus_price_id = std::env::var("FAN_PLUS_STRIPE_PRICE_ID").ok(); // Creator tier Stripe Price IDs - optional, creator tier checkout disabled when empty let mut creator_tier_prices = HashMap::new(); if let Ok(v) = std::env::var("CREATOR_TIER_BASIC_PRICE_ID") { creator_tier_prices.insert(CreatorTier::Basic, v); } if let Ok(v) = std::env::var("CREATOR_TIER_SMALL_FILES_PRICE_ID") { creator_tier_prices.insert(CreatorTier::SmallFiles, v); } if let Ok(v) = std::env::var("CREATOR_TIER_BIG_FILES_PRICE_ID") { creator_tier_prices.insert(CreatorTier::BigFiles, v); } if let Ok(v) = std::env::var("CREATOR_TIER_EVERYTHING_PRICE_ID") { creator_tier_prices.insert(CreatorTier::Everything, v); } // Annual (10% off) sticker price IDs. Optional; checkout falls back to // monthly when an annual price isn't configured for the tier. let mut creator_tier_annual_prices = HashMap::new(); if let Ok(v) = std::env::var("CREATOR_TIER_BASIC_ANNUAL_PRICE_ID") { creator_tier_annual_prices.insert(CreatorTier::Basic, v); } if let Ok(v) = std::env::var("CREATOR_TIER_SMALL_FILES_ANNUAL_PRICE_ID") { creator_tier_annual_prices.insert(CreatorTier::SmallFiles, v); } if let Ok(v) = std::env::var("CREATOR_TIER_BIG_FILES_ANNUAL_PRICE_ID") { creator_tier_annual_prices.insert(CreatorTier::BigFiles, v); } if let Ok(v) = std::env::var("CREATOR_TIER_EVERYTHING_ANNUAL_PRICE_ID") { creator_tier_annual_prices.insert(CreatorTier::Everything, v); } // Founder-pricing price IDs - half the sticker rate, locked for life. // Optional; tiers without a founder price fall back to sticker. let mut creator_tier_founder_prices = HashMap::new(); if let Ok(v) = std::env::var("CREATOR_TIER_BASIC_FOUNDER_PRICE_ID") { creator_tier_founder_prices.insert(CreatorTier::Basic, v); } if let Ok(v) = std::env::var("CREATOR_TIER_SMALL_FILES_FOUNDER_PRICE_ID") { creator_tier_founder_prices.insert(CreatorTier::SmallFiles, v); } if let Ok(v) = std::env::var("CREATOR_TIER_BIG_FILES_FOUNDER_PRICE_ID") { creator_tier_founder_prices.insert(CreatorTier::BigFiles, v); } if let Ok(v) = std::env::var("CREATOR_TIER_EVERYTHING_FOUNDER_PRICE_ID") { creator_tier_founder_prices.insert(CreatorTier::Everything, v); } // Founder annual (10% off founder monthly × 12) price IDs. let mut creator_tier_founder_annual_prices = HashMap::new(); if let Ok(v) = std::env::var("CREATOR_TIER_BASIC_FOUNDER_ANNUAL_PRICE_ID") { creator_tier_founder_annual_prices.insert(CreatorTier::Basic, v); } if let Ok(v) = std::env::var("CREATOR_TIER_SMALL_FILES_FOUNDER_ANNUAL_PRICE_ID") { creator_tier_founder_annual_prices.insert(CreatorTier::SmallFiles, v); } if let Ok(v) = std::env::var("CREATOR_TIER_BIG_FILES_FOUNDER_ANNUAL_PRICE_ID") { creator_tier_founder_annual_prices.insert(CreatorTier::BigFiles, v); } if let Ok(v) = std::env::var("CREATOR_TIER_EVERYTHING_FOUNDER_ANNUAL_PRICE_ID") { creator_tier_founder_annual_prices.insert(CreatorTier::Everything, v); } // Founder-window flag. Defaults to closed if unset so a misconfigured // production env can't accidentally hand out founder pricing. let creator_founder_window_open = std::env::var("CREATOR_FOUNDER_WINDOW_OPEN") .ok() .map(|v| v == "true" || v == "1") .unwrap_or(false); // Build pipeline - optional, build trigger endpoint returns 503 if unset let build_trigger_token = std::env::var("BUILD_TRIGGER_TOKEN").ok(); let build_host_linux = std::env::var("BUILD_HOST_LINUX").ok(); let build_host_darwin = std::env::var("BUILD_HOST_DARWIN").ok(); // CDN base URL - optional, when unset all downloads use presigned S3 URLs let cdn_base_url = std::env::var("CDN_BASE_URL").ok(); // Postmark inbound email webhook token - optional, inbound endpoint returns 401 if unset let postmark_inbound_webhook_token = std::env::var("POSTMARK_INBOUND_WEBHOOK_TOKEN").ok(); // Internal shared secret for MT communication let internal_shared_secret = std::env::var("INTERNAL_SHARED_SECRET").ok(); // CLI service token for SSH server → internal API authentication let cli_service_token = std::env::var("CLI_SERVICE_TOKEN").ok(); // WAM ticket manager URL (tailnet, e.g. "http://100.x.x.x:7890") let wam_url = std::env::var("WAM_URL").ok(); // Site-wide access gate. Only "fan_plus_or_creator" enables it; any // other value (or unset) leaves the site open. Staging-only knob. let access_gate = match std::env::var("ACCESS_GATE").as_deref() { Ok("fan_plus_or_creator") => AccessGate::FanPlusOrCreator, _ => AccessGate::Open, }; let sso = SsoConfig::from_env(); Ok(Config { host, port, database_url, host_url: Arc::from(host_url), signing_secret, storage, synckit_storage, stripe, admin_user_id, synckit_jwt_secret, scan, git_repos_path, postmark_webhook_token, postmark_broadcast_webhook_token, git_ssh_host, mt_base_url, fan_plus_price_id, creator_tier_prices, creator_tier_annual_prices, creator_tier_founder_prices, creator_tier_founder_annual_prices, creator_founder_window_open, build_trigger_token, build_host_linux, build_host_darwin, cdn_base_url, postmark_inbound_webhook_token, internal_shared_secret, cli_service_token, wam_url, access_gate, sso, }) } /// Get the socket address for the server to bind to pub fn socket_addr(&self) -> SocketAddr { SocketAddr::new(self.host, self.port) } } impl StorageConfig { /// Load storage configuration from environment variables /// Returns None if any required variable is missing (graceful degradation) pub fn from_env() -> Option { Self::from_env_prefixed("S3_") } /// Load storage configuration from prefixed environment variables. /// e.g., prefix "SYNCKIT_S3_" reads SYNCKIT_S3_ENDPOINT, SYNCKIT_S3_BUCKET, etc. pub fn from_env_prefixed(prefix: &str) -> Option { let endpoint = std::env::var(format!("{prefix}ENDPOINT")).ok()?; let bucket = std::env::var(format!("{prefix}BUCKET")).ok()?; let access_key = std::env::var(format!("{prefix}ACCESS_KEY")).ok()?; let secret_key = std::env::var(format!("{prefix}SECRET_KEY")).ok()?; let region = std::env::var(format!("{prefix}REGION")) .unwrap_or_else(|_| "us-east-1".to_string()); Some(StorageConfig { endpoint, bucket, access_key, secret_key, region, }) } } /// File scanning configuration #[derive(Clone)] pub struct ScanConfig { /// Unix socket path for ClamAV daemon (optional) pub clamav_socket: Option, /// Directory containing YARA rule files pub yara_rules_dir: String, /// Whether to enable MalwareBazaar hash lookups pub malwarebazaar_enabled: bool, /// Whether to enable URLhaus URL-reputation lookups pub urlhaus_enabled: bool, /// Shared abuse.ch Auth-Key (issued at https://auth.abuse.ch/). Required /// for MalwareBazaar and URLhaus as of 2024+; without it both layers /// fail-open and the dashboard surfaces them as degraded. pub abuse_ch_auth_key: Option, /// MetaDefender Cloud API key (free tier at /// ). Second-opinion layer; only /// invoked when another layer flagged the file as suspicious. pub metadefender_api_key: Option, /// Minimum number of YARA rule files that must compile for the corpus to be /// considered healthy. `0` disables the check (default). Set it to the known /// deployed corpus size so a silent drop (a dependency/format change that /// makes rules uncompilable) fails boot loudly rather than degrading /// coverage unnoticed. pub yara_min_rule_files: usize, } impl ScanConfig { /// Load scan configuration from environment variables. /// Returns Some if SCAN_ENABLED=true (default), None if explicitly disabled. pub fn from_env() -> Option { let enabled = std::env::var("SCAN_ENABLED") .map(|v| v != "false" && v != "0") .unwrap_or(true); if !enabled { return None; } Some(ScanConfig { clamav_socket: std::env::var("CLAMAV_SOCKET").ok(), yara_rules_dir: std::env::var("YARA_RULES_DIR") .unwrap_or_else(|_| "yara-rules/".to_string()), malwarebazaar_enabled: std::env::var("MALWAREBAZAAR_ENABLED") .map(|v| v != "false" && v != "0") .unwrap_or(true), urlhaus_enabled: std::env::var("URLHAUS_ENABLED") .map(|v| v != "false" && v != "0") .unwrap_or(true), abuse_ch_auth_key: std::env::var("ABUSE_CH_AUTH_KEY").ok().filter(|s| !s.is_empty()), metadefender_api_key: std::env::var("METADEFENDER_API_KEY").ok().filter(|s| !s.is_empty()), yara_min_rule_files: std::env::var("YARA_MIN_RULE_FILES") .ok() .and_then(|v| v.parse().ok()) .unwrap_or(0), }) } } impl std::fmt::Debug for ScanConfig { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("ScanConfig") .field("clamav_socket", &self.clamav_socket) .field("yara_rules_dir", &self.yara_rules_dir) .field("malwarebazaar_enabled", &self.malwarebazaar_enabled) .field("urlhaus_enabled", &self.urlhaus_enabled) .field("abuse_ch_auth_key", &self.abuse_ch_auth_key.as_ref().map(|_| "")) .field("metadefender_api_key", &self.metadefender_api_key.as_ref().map(|_| "")) .finish() } } /// Stripe payment configuration #[derive(Clone)] pub struct StripeConfig { /// Stripe secret API key (sk_test_... or sk_live_...) pub secret_key: String, /// Webhook signing secrets for v1 snapshot events (whsec_...). /// /// A list to accommodate multiple Stripe endpoints (e.g. `mnw-connect` /// for Connected-account events + `mnw-you` for platform events — Stripe /// requires one endpoint per scope, and each endpoint has its own secret). /// `verify_signature` accepts a match against any secret in the list. /// Configured via `STRIPE_WEBHOOK_SECRET` as a comma-separated list. pub webhook_secret: Vec, /// Webhook signing secret for v2 thin events (whsec_...) /// Optional — v2 endpoint returns 503 if not set. pub webhook_secret_v2: Option, } impl StripeConfig { /// Load Stripe configuration from environment variables /// Returns None if any required variable is missing (graceful degradation) pub fn from_env() -> Option { let secret_key = std::env::var("STRIPE_SECRET_KEY").ok()?; let webhook_secret: Vec = std::env::var("STRIPE_WEBHOOK_SECRET").ok()? .split(',') .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) .collect(); if webhook_secret.is_empty() { return None; } let webhook_secret_v2 = std::env::var("STRIPE_WEBHOOK_SECRET_V2").ok(); Some(StripeConfig { secret_key, webhook_secret, webhook_secret_v2, }) } } impl std::fmt::Debug for Config { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("Config") .field("host", &self.host) .field("port", &self.port) .field("database_url", &"[REDACTED]") .field("host_url", &self.host_url) .field("signing_secret", &"[REDACTED]") .field("storage", &self.storage) .field("synckit_storage", &self.synckit_storage) .field("stripe", &self.stripe) .field("admin_user_id", &self.admin_user_id) .field("synckit_jwt_secret", &self.synckit_jwt_secret.as_ref().map(|_| "[REDACTED]")) .field("scan", &self.scan) .field("git_repos_path", &self.git_repos_path) .field("postmark_webhook_token", &self.postmark_webhook_token.as_ref().map(|_| "[REDACTED]")) .field("postmark_broadcast_webhook_token", &self.postmark_broadcast_webhook_token.as_ref().map(|_| "[REDACTED]")) .field("git_ssh_host", &self.git_ssh_host) .field("mt_base_url", &self.mt_base_url) .field("fan_plus_price_id", &self.fan_plus_price_id) .field("creator_tier_prices", &format!("{} tiers configured", self.creator_tier_prices.len())) .field("creator_tier_annual_prices", &format!("{} annual tiers configured", self.creator_tier_annual_prices.len())) .field("creator_tier_founder_prices", &format!("{} founder tiers configured", self.creator_tier_founder_prices.len())) .field("creator_tier_founder_annual_prices", &format!("{} founder annual tiers configured", self.creator_tier_founder_annual_prices.len())) .field("creator_founder_window_open", &self.creator_founder_window_open) .field("build_trigger_token", &self.build_trigger_token.as_ref().map(|_| "[REDACTED]")) .field("build_host_linux", &self.build_host_linux) .field("build_host_darwin", &self.build_host_darwin) .field("cdn_base_url", &self.cdn_base_url) .field("postmark_inbound_webhook_token", &self.postmark_inbound_webhook_token.as_ref().map(|_| "[REDACTED]")) .field("internal_shared_secret", &self.internal_shared_secret.as_ref().map(|_| "[REDACTED]")) .field("cli_service_token", &self.cli_service_token.as_ref().map(|_| "[REDACTED]")) .field("wam_url", &self.wam_url) .field("access_gate", &self.access_gate) .field("sso", &self.sso.as_ref().map(|s| &s.provider_url)) .finish() } } impl std::fmt::Debug for StorageConfig { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("StorageConfig") .field("endpoint", &self.endpoint) .field("bucket", &self.bucket) .field("access_key", &"[REDACTED]") .field("secret_key", &"[REDACTED]") .field("region", &self.region) .finish() } } impl std::fmt::Debug for StripeConfig { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("StripeConfig") .field("secret_key", &"[REDACTED]") .field("webhook_secret", &"[REDACTED]") .finish() } } /// Configuration errors #[derive(Debug, thiserror::Error)] pub enum ConfigError { #[error("Invalid HOST address")] InvalidHost, #[error("Invalid PORT number")] InvalidPort, #[error("DATABASE_URL environment variable is required")] MissingDatabaseUrl, #[error("SIGNING_SECRET is required in production (HOST=0.0.0.0 or HTTPS HOST_URL detected). Set SIGNING_SECRET to a stable random string.")] MissingSigningSecret, #[error("SIGNING_SECRET must be at least 32 characters long")] WeakSigningSecret, } #[cfg(test)] mod tests { use super::*; use std::sync::Mutex; /// Mutex to serialize tests that call Config::from_env(), since env vars are /// process-global and concurrent mutation causes flaky failures. static ENV_LOCK: Mutex<()> = Mutex::new(()); /// All env var keys that Config::from_env() reads. Used by the guard to /// snapshot and restore state so tests don't leak into each other. const CONFIG_ENV_VARS: &[&str] = &[ "HOST", "PORT", "DATABASE_URL", "HOST_URL", "SIGNING_SECRET", "S3_ENDPOINT", "S3_BUCKET", "S3_ACCESS_KEY", "S3_SECRET_KEY", "S3_REGION", "SYNCKIT_S3_ENDPOINT", "SYNCKIT_S3_BUCKET", "SYNCKIT_S3_ACCESS_KEY", "SYNCKIT_S3_SECRET_KEY", "SYNCKIT_S3_REGION", "STRIPE_SECRET_KEY", "STRIPE_WEBHOOK_SECRET", "STRIPE_WEBHOOK_SECRET_V2", "ADMIN_USER_ID", "SYNCKIT_JWT_SECRET", "SCAN_ENABLED", "CLAMAV_SOCKET", "YARA_RULES_DIR", "MALWAREBAZAAR_ENABLED", "URLHAUS_ENABLED", "ABUSE_CH_AUTH_KEY", "METADEFENDER_API_KEY", "GIT_REPOS_PATH", "POSTMARK_WEBHOOK_TOKEN", "POSTMARK_BROADCAST_WEBHOOK_TOKEN", "GIT_SSH_HOST", "MT_BASE_URL", "FAN_PLUS_STRIPE_PRICE_ID", "CREATOR_TIER_BASIC_PRICE_ID", "CREATOR_TIER_SMALL_FILES_PRICE_ID", "CREATOR_TIER_BIG_FILES_PRICE_ID", "CREATOR_TIER_EVERYTHING_PRICE_ID", "CREATOR_TIER_BASIC_ANNUAL_PRICE_ID", "CREATOR_TIER_SMALL_FILES_ANNUAL_PRICE_ID", "CREATOR_TIER_BIG_FILES_ANNUAL_PRICE_ID", "CREATOR_TIER_EVERYTHING_ANNUAL_PRICE_ID", "CREATOR_TIER_BASIC_FOUNDER_PRICE_ID", "CREATOR_TIER_SMALL_FILES_FOUNDER_PRICE_ID", "CREATOR_TIER_BIG_FILES_FOUNDER_PRICE_ID", "CREATOR_TIER_EVERYTHING_FOUNDER_PRICE_ID", "CREATOR_TIER_BASIC_FOUNDER_ANNUAL_PRICE_ID", "CREATOR_TIER_SMALL_FILES_FOUNDER_ANNUAL_PRICE_ID", "CREATOR_TIER_BIG_FILES_FOUNDER_ANNUAL_PRICE_ID", "CREATOR_TIER_EVERYTHING_FOUNDER_ANNUAL_PRICE_ID", "CREATOR_FOUNDER_WINDOW_OPEN", "BUILD_TRIGGER_TOKEN", "BUILD_HOST_LINUX", "BUILD_HOST_DARWIN", "CDN_BASE_URL", "POSTMARK_INBOUND_WEBHOOK_TOKEN", "INTERNAL_SHARED_SECRET", "CLI_SERVICE_TOKEN", "WAM_URL", "ACCESS_GATE", "SSO_PROVIDER_URL", "SSO_CLIENT_ID", "SSO_KEY", ]; /// RAII guard that snapshots config-related env vars on creation and restores /// them when dropped. Also holds the ENV_LOCK so tests run serially. struct EnvGuard { _lock: std::sync::MutexGuard<'static, ()>, snapshot: Vec<(&'static str, Option)>, } impl EnvGuard { fn new() -> Self { let lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); let snapshot = CONFIG_ENV_VARS .iter() .map(|&key| (key, std::env::var(key).ok())) .collect(); Self { _lock: lock, snapshot } } /// Remove all config env vars so from_env() sees a clean slate. fn clear_all(&self) { for &key in CONFIG_ENV_VARS { // SAFETY: test-only, serialized by mutex unsafe { std::env::remove_var(key); } } } } impl Drop for EnvGuard { fn drop(&mut self) { for (key, val) in &self.snapshot { match val { // SAFETY: test-only, serialized by mutex Some(v) => unsafe { std::env::set_var(key, v) }, None => unsafe { std::env::remove_var(key) }, } } } } // ---- existing tests (unchanged) ---- #[test] fn socket_addr_combines_host_and_port() { let config = Config { host: "127.0.0.1".parse().unwrap(), port: 8080, database_url: "postgres://test".to_string(), host_url: Arc::from("http://localhost:8080"), signing_secret: "secret".to_string(), storage: None, synckit_storage: None, stripe: None, admin_user_id: None, synckit_jwt_secret: None, scan: None, git_repos_path: None, postmark_webhook_token: None, postmark_broadcast_webhook_token: None, git_ssh_host: None, mt_base_url: None, fan_plus_price_id: None, creator_tier_prices: HashMap::new(), creator_tier_annual_prices: HashMap::new(), creator_tier_founder_prices: HashMap::new(), creator_tier_founder_annual_prices: HashMap::new(), creator_founder_window_open: false, build_trigger_token: None, build_host_linux: None, build_host_darwin: None, cdn_base_url: None, postmark_inbound_webhook_token: None, internal_shared_secret: None, cli_service_token: None, wam_url: None, access_gate: AccessGate::Open, sso: None, }; let addr = config.socket_addr(); assert_eq!(addr.port(), 8080); assert_eq!(addr.ip().to_string(), "127.0.0.1"); } #[test] fn config_error_display() { assert_eq!(ConfigError::InvalidHost.to_string(), "Invalid HOST address"); assert_eq!(ConfigError::InvalidPort.to_string(), "Invalid PORT number"); assert!(ConfigError::MissingDatabaseUrl.to_string().contains("DATABASE_URL")); } // ---- from_env validation tests ---- #[test] fn from_env_succeeds_with_required_vars() { let guard = EnvGuard::new(); guard.clear_all(); // SAFETY: test-only, serialized by EnvGuard mutex unsafe { std::env::set_var("DATABASE_URL", "postgres://localhost/test_db"); } let config = Config::from_env().expect("should succeed with DATABASE_URL set"); assert_eq!(config.database_url, "postgres://localhost/test_db"); // Defaults: host=127.0.0.1, port=3000 assert_eq!(config.host.to_string(), "127.0.0.1"); assert_eq!(config.port, 3000); // Signing secret should be a random 64-char hex string in dev mode assert!(!config.signing_secret.is_empty()); drop(guard); } #[test] fn from_env_fails_without_database_url() { let guard = EnvGuard::new(); guard.clear_all(); let err = Config::from_env().unwrap_err(); assert!( matches!(err, ConfigError::MissingDatabaseUrl), "expected MissingDatabaseUrl, got: {err}" ); drop(guard); } #[test] fn from_env_fails_in_production_without_signing_secret() { let guard = EnvGuard::new(); guard.clear_all(); // SAFETY: test-only, serialized by EnvGuard mutex unsafe { std::env::set_var("DATABASE_URL", "postgres://localhost/test_db"); std::env::set_var("HOST", "0.0.0.0"); // production indicator } let err = Config::from_env().unwrap_err(); assert!( matches!(err, ConfigError::MissingSigningSecret), "expected MissingSigningSecret, got: {err}" ); drop(guard); } #[test] fn from_env_fails_with_https_host_url_without_signing_secret() { let guard = EnvGuard::new(); guard.clear_all(); // SAFETY: test-only, serialized by EnvGuard mutex unsafe { std::env::set_var("DATABASE_URL", "postgres://localhost/test_db"); std::env::set_var("HOST_URL", "https://makenot.work"); // production indicator } let err = Config::from_env().unwrap_err(); assert!( matches!(err, ConfigError::MissingSigningSecret), "expected MissingSigningSecret, got: {err}" ); drop(guard); } #[test] fn from_env_uses_random_dev_secret_when_not_production() { let guard = EnvGuard::new(); guard.clear_all(); // SAFETY: test-only, serialized by EnvGuard mutex unsafe { std::env::set_var("DATABASE_URL", "postgres://localhost/test_db"); // HOST defaults to 127.0.0.1, HOST_URL defaults to http://..., no SIGNING_SECRET } let config = Config::from_env().expect("should succeed in dev mode without SIGNING_SECRET"); // Should be a 64-char hex string (256-bit random) assert_eq!( config.signing_secret.len(), 64, "expected 64-char hex signing secret, got length {}", config.signing_secret.len() ); assert!( config.signing_secret.chars().all(|c| c.is_ascii_hexdigit()), "expected hex signing secret, got: {}", config.signing_secret ); drop(guard); } #[test] fn from_env_storage_none_when_partially_set() { let guard = EnvGuard::new(); guard.clear_all(); // SAFETY: test-only, serialized by EnvGuard mutex unsafe { std::env::set_var("DATABASE_URL", "postgres://localhost/test_db"); // Set only some S3 vars — missing S3_SECRET_KEY and S3_ACCESS_KEY std::env::set_var("S3_ENDPOINT", "https://fsn1.your-objectstorage.com"); std::env::set_var("S3_BUCKET", "test-bucket"); } let config = Config::from_env().expect("should succeed"); assert!( config.storage.is_none(), "storage should be None when S3 vars are only partially set" ); drop(guard); } #[test] fn from_env_storage_some_when_fully_set() { let guard = EnvGuard::new(); guard.clear_all(); // SAFETY: test-only, serialized by EnvGuard mutex unsafe { std::env::set_var("DATABASE_URL", "postgres://localhost/test_db"); std::env::set_var("S3_ENDPOINT", "https://fsn1.your-objectstorage.com"); std::env::set_var("S3_BUCKET", "test-bucket"); std::env::set_var("S3_ACCESS_KEY", "ak"); std::env::set_var("S3_SECRET_KEY", "sk"); } let config = Config::from_env().expect("should succeed"); let storage = config.storage.expect("storage should be Some when all S3 vars set"); assert_eq!(storage.endpoint, "https://fsn1.your-objectstorage.com"); assert_eq!(storage.bucket, "test-bucket"); assert_eq!(storage.region, "us-east-1"); // default region drop(guard); } #[test] fn from_env_stripe_none_when_secret_key_missing() { let guard = EnvGuard::new(); guard.clear_all(); // SAFETY: test-only, serialized by EnvGuard mutex unsafe { std::env::set_var("DATABASE_URL", "postgres://localhost/test_db"); // Set webhook secret but not secret key std::env::set_var("STRIPE_WEBHOOK_SECRET", "whsec_test"); } let config = Config::from_env().expect("should succeed"); assert!( config.stripe.is_none(), "stripe should be None when STRIPE_SECRET_KEY is missing" ); drop(guard); } #[test] fn from_env_stripe_none_when_webhook_secret_missing() { let guard = EnvGuard::new(); guard.clear_all(); // SAFETY: test-only, serialized by EnvGuard mutex unsafe { std::env::set_var("DATABASE_URL", "postgres://localhost/test_db"); // Set secret key but not webhook secret std::env::set_var("STRIPE_SECRET_KEY", "sk_test_abc"); } let config = Config::from_env().expect("should succeed"); assert!( config.stripe.is_none(), "stripe should be None when STRIPE_WEBHOOK_SECRET is missing" ); drop(guard); } #[test] fn from_env_stripe_some_when_fully_set() { let guard = EnvGuard::new(); guard.clear_all(); // SAFETY: test-only, serialized by EnvGuard mutex unsafe { std::env::set_var("DATABASE_URL", "postgres://localhost/test_db"); std::env::set_var("STRIPE_SECRET_KEY", "sk_test_abc"); std::env::set_var("STRIPE_WEBHOOK_SECRET", "whsec_test"); } let config = Config::from_env().expect("should succeed"); let stripe = config.stripe.expect("stripe should be Some when fully configured"); assert_eq!(stripe.secret_key, "sk_test_abc"); assert_eq!(stripe.webhook_secret, vec!["whsec_test".to_string()]); assert!(stripe.webhook_secret_v2.is_none()); drop(guard); } #[test] fn from_env_invalid_host_rejected() { let guard = EnvGuard::new(); guard.clear_all(); // SAFETY: test-only, serialized by EnvGuard mutex unsafe { std::env::set_var("DATABASE_URL", "postgres://localhost/test_db"); std::env::set_var("HOST", "not-an-ip"); } let err = Config::from_env().unwrap_err(); assert!( matches!(err, ConfigError::InvalidHost), "expected InvalidHost, got: {err}" ); drop(guard); } #[test] fn from_env_invalid_port_rejected() { let guard = EnvGuard::new(); guard.clear_all(); // SAFETY: test-only, serialized by EnvGuard mutex unsafe { std::env::set_var("DATABASE_URL", "postgres://localhost/test_db"); std::env::set_var("PORT", "not-a-number"); } let err = Config::from_env().unwrap_err(); assert!( matches!(err, ConfigError::InvalidPort), "expected InvalidPort, got: {err}" ); drop(guard); } #[test] fn from_env_scan_disabled_when_explicitly_off() { let guard = EnvGuard::new(); guard.clear_all(); // SAFETY: test-only, serialized by EnvGuard mutex unsafe { std::env::set_var("DATABASE_URL", "postgres://localhost/test_db"); std::env::set_var("SCAN_ENABLED", "false"); } let config = Config::from_env().expect("should succeed"); assert!( config.scan.is_none(), "scan should be None when SCAN_ENABLED=false" ); drop(guard); } #[test] fn from_env_scan_enabled_by_default() { let guard = EnvGuard::new(); guard.clear_all(); // SAFETY: test-only, serialized by EnvGuard mutex unsafe { std::env::set_var("DATABASE_URL", "postgres://localhost/test_db"); } let config = Config::from_env().expect("should succeed"); assert!( config.scan.is_some(), "scan should be Some by default (enabled unless explicitly disabled)" ); drop(guard); } }