//! 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, /// Public, CDN-served bucket for promoted image content (covers, gallery, /// item/project images). Same endpoint/credentials as `storage`, bucket /// overridden by `S3_PUBLIC_BUCKET`. Required in production (the CDN serves /// ONLY this bucket); `None` in dev when `S3_PUBLIC_BUCKET` is unset. pub public_storage: Option, /// Bucket holding the Alloy hotfix RPM repository: the `.rpm` files and the /// `createrepo_c` metadata that `dnf`/`rpm-ostree` fetch by path. Served /// beside the server by a GET/HEAD-only Caddy block, exactly as the CDN /// bucket is, so a published fix reaches machines with no deploy at all. /// /// Resolved from `RPM_S3_*` when a dedicated credential is provisioned, and /// otherwise from the main storage with the bucket overridden by /// `S3_RPM_BUCKET`. `None` when neither is set, which is every dev /// environment; the publish endpoint then answers 503 rather than 404, so /// "not configured here" never reads as "the route is gone". pub rpm_storage: Option, /// Public render base for [`Self::rpm_storage`] (e.g. /// `https://rpm.makenot.work`), the host the Caddy block answers on. Only /// used to tell an operator where a published object landed; nothing /// durable is written from it. `None` when `RPM_BASE_URL` is unset. pub rpm_base_url: 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, /// Base URL for CDN-served downloads (e.g., "https://cdn.makenot.work"). /// Required in every environment: it is the only render base for public /// image and media URLs. Point it at the raw public-bucket origin in dev if /// there is no edge in front. pub cdn_base_url: String, /// Hostname that serves creator custom pages (e.g. "u.makenot.work"). /// Cookieless and strict-CSP, isolated from the apex. Defaults to "u." + /// the host_url host; override via USER_PAGES_HOST. pub user_pages_host: Arc, /// Native build pipeline: SSH build hosts, trigger auth, and git repo /// paths (`BUILD_*`, `GIT_*`). pub build: BuildConfig, /// Postmark webhook authentication + inbound sender-auth policy (`POSTMARK_*`). pub email_webhooks: EmailWebhookConfig, /// Creator-tier + Fan+ Stripe price maps and the founder-window flag. pub creator_pricing: CreatorTierPricing, /// Sibling-service URLs and shared secrets: MT forum, WAM, internal API. pub integrations: IntegrationsConfig, /// 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 Makenotwork" (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, /// Rate-limit profile the router is built with. Production everywhere that /// is not a test; see [`crate::constants::RateLimits`]. pub rate_limits: crate::constants::RateLimits, } /// Native build pipeline configuration (`BUILD_*`, `GIT_*`). /// /// `git_repos_path` and `git_ssh_host` gate the in-app git browser and the /// SSH clone URL; the build host/token fields drive the remote build runner. #[derive(Clone)] pub struct BuildConfig { /// Bearer token for authenticating build trigger webhook requests (optional). pub trigger_token: Option, /// SSH host for Linux builds (e.g., "max@100.106.221.39"). pub host_linux: Option, /// SSH host for macOS builds (e.g., "max@100.64.x.x"). pub host_darwin: Option, /// Path to bare git repositories on disk (optional). Git browser disabled if unset. pub git_repos_path: Option, /// Hostname for git SSH clone URLs (e.g., "git.makenot.work"). Hidden when not set. pub git_ssh_host: Option, } /// Postmark webhook authentication + inbound sender-auth policy (`POSTMARK_*`). #[derive(Clone)] pub struct EmailWebhookConfig { /// Bearer token for authenticating Postmark webhook requests (optional). pub webhook_token: Option, /// Bearer token for authenticating Postmark broadcast stream webhooks (optional). pub broadcast_webhook_token: Option, /// Bearer token for authenticating the Postmark inbound email webhook (optional). pub inbound_webhook_token: Option, /// Enforce SPF/DKIM alignment on inbound email before trusting the `From` /// address as an MNW user's identity. Defaults to `true` (fail closed): a /// message whose `From` domain isn't SPF/DKIM-aligned is not attributed to /// the account that owns that address. Set `POSTMARK_ENFORCE_SENDER_AUTH=false` /// only to observe verdicts during rollout (logs but does not reject). pub enforce_sender_auth: bool, } /// Creator-tier and Fan+ Stripe price maps plus the founder-window flag. /// /// Missing annual/founder entries fall back per the checkout logic (annual → /// monthly, founder → sticker). #[derive(Clone)] pub struct CreatorTierPricing { /// Stripe Price ID for the Fan+ subscription ($8/mo). Enables Fan+ checkout when set. pub fan_plus_price_id: Option, /// Stripe Price IDs for creator tier subscriptions (monthly). Empty = disabled. pub tier_prices: HashMap, /// Stripe Price IDs for creator tier subscriptions, annual billing (10% off monthly × 12). pub tier_annual_prices: HashMap, /// Stripe Price IDs for *founder* creator tier subscriptions, monthly (50% off, locked for life). pub tier_founder_prices: HashMap, /// Stripe Price IDs for *founder* creator tier subscriptions, annual (10% off founder monthly × 12). pub tier_founder_annual_prices: HashMap, /// Whether the founder-pricing window is currently open. While true, new /// creator-tier subscriptions get founder prices and the user is marked /// `is_founder = true`. Defaults closed so a misconfigured env can't leak it. pub founder_window_open: bool, } /// URLs and shared secrets for sibling services (MT forum, WAM, internal API). #[derive(Clone)] pub struct IntegrationsConfig { /// Base URL of the Multithreaded forum instance. Enables the Forums tab when set. pub mt_base_url: Option, /// Base URL of the WAM ticket manager. Enables WAM ticketing when set. pub wam_url: Option, /// Shared secret for HMAC-signed internal API requests to MT (>=32 chars). pub internal_shared_secret: Option, /// Bearer token authenticating CLI SSH server → MNW internal API calls (>=32 chars). pub cli_service_token: Option, /// Bearer token authenticating inbound infra alerts (PoM/MT → `POST /// /api/internal/alerts`) (>=32 chars). Distinct from `cli_service_token` so /// a leak on a monitoring agent can't reach the CLI internal API. pub alerts_ingest_token: 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").is_ok_and(|u| u.starts_with("https://")); 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::Rng::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_"); // Public, CDN-served bucket: reuse the main storage endpoint/credentials // with the bucket overridden by S3_PUBLIC_BUCKET. Only the immutably-public // promoted image content lands here, so it carries a blanket public-read // policy while the main bucket stays private. `None` when either the main // storage or S3_PUBLIC_BUCKET is unset (dev); required in production below. let public_storage = std::env::var("S3_PUBLIC_BUCKET") .ok() .filter(|s| !s.is_empty()) .and_then(|bucket| { storage.as_ref().map(|s| StorageConfig { bucket, ..s.clone() }) }); // The RPM repo bucket. Two ways in, and the prefixed one wins: a // dedicated `RPM_S3_*` credential is the shape the provisioning task // (alloy `23f599d9`) hands over, and `S3_RPM_BUCKET` over the main // credentials is the same fallback `public_storage` takes above, so a // bucket in the same project needs one variable rather than five. let rpm_storage = StorageConfig::from_env_prefixed("RPM_S3_").or_else(|| { std::env::var("S3_RPM_BUCKET") .ok() .filter(|s| !s.is_empty()) .and_then(|bucket| { storage.as_ref().map(|s| StorageConfig { bucket, ..s.clone() }) }) }); let rpm_base_url = std::env::var("RPM_BASE_URL") .ok() .filter(|s| !s.is_empty()) .map(|s| s.trim_end_matches('/').to_string()); // 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::() .map_err(|_| { tracing::warn!( "ADMIN_USER_ID is set but is not a valid UserId, ignoring it; admin routes will return 404" ); }) .ok() }); // SyncKit JWT secret - optional, sync endpoints return 503 if unset. // When set it IS the HS256 symmetric signing key for SyncKit/OAuth // bearer tokens, so enforce the same >=32-char floor as SIGNING_SECRET: // a short value is offline-brute-forceable into token forgery. Fail // closed (refuse to start) rather than silently accepting a weak key. let synckit_jwt_secret = match std::env::var("SYNCKIT_JWT_SECRET") { Ok(secret) => { if secret.len() < 32 { return Err(ConfigError::WeakSynckitJwtSecret); } Some(secret) } Err(_) => None, }; // 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() .is_some_and(|v| v == "true" || v == "1"); // 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, REQUIRED everywhere. Without one, cover/download URLs // used to fall back to path-style presigned S3 URLs // (`{endpoint}/{bucket}/{key}`), a shape the cover_s3_key backfill // (migration 152) and other key-from-URL derivation do not expect. Worse, // that fallback minted a 24-hour URL for `projects.cover_image_url`, a // durable column, so every cover written on it died a day later. The // requirement is unconditional rather than production-only so the trap // cannot exist at all: dev points CDN_BASE_URL at the raw public-bucket // origin when there is no edge in front (ultra-fuzz Run 10 Sto S-1). let cdn_base_url = std::env::var("CDN_BASE_URL") .ok() .filter(|s| !s.is_empty()) .ok_or(ConfigError::MissingCdnBaseUrl)?; { let is_production = host == std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED) || std::env::var("HOST_URL").is_ok_and(|u| u.starts_with("https://")); // The CDN serves ONLY the public bucket; without it, promoted image // content has nowhere to land and covers/gallery would 404. Storage // must be configured (checked implicitly: public_storage is Some only // when both S3_PUBLIC_BUCKET and the main storage are set). if is_production && storage.is_some() && public_storage.is_none() { return Err(ConfigError::MissingPublicBucket); } } let user_pages_host = std::env::var("USER_PAGES_HOST") .ok() .filter(|h| !h.is_empty()) .unwrap_or_else(|| default_user_pages_host(&host_url)); // 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(); // Enforce inbound SPF/DKIM sender-auth by default; only an explicit // `false` disables it (observe-only during rollout). Fail closed so a // missing/typo'd value can't silently reopen the spoofing hole. let postmark_enforce_sender_auth = std::env::var("POSTMARK_ENFORCE_SENDER_AUTH") .ok() .is_none_or(|v| !(v == "false" || v == "0")); // Internal shared secret for MT communication. Bearer-token-equivalent, so // enforce the same >=32-char floor as the signing secrets, a short value // is offline-brute-forceable. Fail closed rather than boot on a weak secret. let internal_shared_secret = match std::env::var("INTERNAL_SHARED_SECRET") { Ok(secret) => { if secret.len() < 32 { return Err(ConfigError::WeakInternalSecret); } Some(secret) } Err(_) => None, }; // CLI service token for SSH server → internal API authentication. Same floor. let cli_service_token = match std::env::var("CLI_SERVICE_TOKEN") { Ok(secret) => { if secret.len() < 32 { return Err(ConfigError::WeakCliServiceToken); } Some(secret) } Err(_) => None, }; // Inbound infra-alert ingestion token (PoM/MT monitoring agents → // `POST /api/internal/alerts`). Same 32-char floor as the other service // secrets; deliberately separate from CLI_SERVICE_TOKEN. let alerts_ingest_token = match std::env::var("ALERTS_INGEST_TOKEN") { Ok(secret) => { if secret.len() < 32 { return Err(ConfigError::WeakAlertsIngestToken); } Some(secret) } Err(_) => None, }; // 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, public_storage, rpm_storage, rpm_base_url, stripe, admin_user_id, synckit_jwt_secret, scan, cdn_base_url, user_pages_host: Arc::from(user_pages_host), access_gate, sso, rate_limits: crate::constants::RateLimits::production(), build: BuildConfig { trigger_token: build_trigger_token, host_linux: build_host_linux, host_darwin: build_host_darwin, git_repos_path, git_ssh_host, }, email_webhooks: EmailWebhookConfig { webhook_token: postmark_webhook_token, broadcast_webhook_token: postmark_broadcast_webhook_token, inbound_webhook_token: postmark_inbound_webhook_token, enforce_sender_auth: postmark_enforce_sender_auth, }, creator_pricing: CreatorTierPricing { fan_plus_price_id, tier_prices: creator_tier_prices, tier_annual_prices: creator_tier_annual_prices, tier_founder_prices: creator_tier_founder_prices, tier_founder_annual_prices: creator_tier_founder_annual_prices, founder_window_open: creator_founder_window_open, }, integrations: IntegrationsConfig { mt_base_url, wam_url, internal_shared_secret, cli_service_token, alerts_ingest_token, }, }) } /// Get the socket address for the server to bind to pub fn socket_addr(&self) -> SocketAddr { SocketAddr::new(self.host, self.port) } /// Build the URL policy that gates every reference in creator custom pages. /// A page may reference the apex, the user-pages host, and the CDN, nothing /// else. The base origin is the user-pages host (where pages render). pub fn custom_pages_policy(&self) -> Option { let mut hosts = vec![self.user_pages_host.to_string()]; if let Some(apex) = host_of(&self.host_url) { hosts.push(apex); } if let Some(cdn) = host_of(&self.cdn_base_url) { hosts.push(cdn); } let base = format!("https://{}/", self.user_pages_host); crate::custom_pages::UrlPolicy::new(&base, hosts).ok() } } /// Extract the bare host from an absolute URL (no scheme/port/path). fn host_of(url: &str) -> Option { url::Url::parse(url) .ok() .and_then(|u| u.host_str().map(str::to_string)) } /// Default user-pages host: `u.` prefixed onto the host_url's host. fn default_user_pages_host(host_url: &str) -> String { host_of(host_url).map_or_else(|| "u.localhost".to_string(), |h| format!("u.{h}")) } 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. Defaults to /// [`DEFAULT_YARA_MIN_RULE_FILES`] (the size of the bundled corpus) so a /// silent drop, a dependency/format change that makes rules uncompilable, /// fails boot loudly rather than degrading coverage unnoticed. Set it /// explicitly when pointing `YARA_RULES_DIR` at a larger external corpus. pub yara_min_rule_files: usize, /// The number of bytes ClamAV actually scans per object, the operator's /// declared `min(MaxScanSize, MaxFileSize, StreamMaxLength)` from `clamd.conf`. /// /// clamd does NOT expose these limits over its socket (only `PING`/`VERSION`), /// so the server cannot probe them; the operator must declare the coverage. /// It gates whether ClamAV counts as a *full-file backstop* for the YARA /// prefix cap ([`crate::constants::SCAN_YARA_MAX_BYTES`]): only a file whose /// size is within this many bytes is treated as fully covered. `None` (the /// default) means "coverage unknown" and is fail-closed, any file above the /// YARA prefix is held for review rather than certified Clean on a /// possibly-partial ClamAV scan (ultra-fuzz Run #24 Security MODERATE). pub clamav_max_scan_bytes: Option, } /// Floor for [`ScanConfig::yara_min_rule_files`], matching the count of `.yar` /// files bundled in `server/yara-rules/`. Kept in sync by /// `scanning::yara::tests::shipped_corpus_is_healthy`, which fails if the /// bundled corpus count drifts from this value. Bumping the corpus means /// bumping this constant (and the test catches a forgotten bump). pub const DEFAULT_YARA_MIN_RULE_FILES: usize = 6; 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_or(true, |v| v != "false" && v != "0"); 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_or(true, |v| v != "false" && v != "0"), urlhaus_enabled: std::env::var("URLHAUS_ENABLED") .map_or(true, |v| v != "false" && v != "0"), 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: match std::env::var("YARA_MIN_RULE_FILES") { Ok(v) => v.parse().unwrap_or_else(|_| { tracing::warn!( value = %v, "YARA_MIN_RULE_FILES is set but is not a valid number, using default {}", DEFAULT_YARA_MIN_RULE_FILES ); DEFAULT_YARA_MIN_RULE_FILES }), Err(_) => DEFAULT_YARA_MIN_RULE_FILES, }, clamav_max_scan_bytes: match std::env::var("CLAMAV_MAX_SCAN_BYTES") { Ok(v) => v.parse::().map_or_else(|_| { tracing::warn!( value = %v, "CLAMAV_MAX_SCAN_BYTES is set but is not a valid number, treating ClamAV as no full-file backstop (large files held for review)" ); None }, Some), Err(_) => None, }, }) } } 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(|_| ""), ) .field("clamav_max_scan_bytes", &self.clamav_max_scan_bytes) .finish_non_exhaustive() } } /// 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.build.git_repos_path) .field( "postmark_webhook_token", &self .email_webhooks .webhook_token .as_ref() .map(|_| "[REDACTED]"), ) .field( "postmark_broadcast_webhook_token", &self .email_webhooks .broadcast_webhook_token .as_ref() .map(|_| "[REDACTED]"), ) .field("git_ssh_host", &self.build.git_ssh_host) .field("mt_base_url", &self.integrations.mt_base_url) .field("fan_plus_price_id", &self.creator_pricing.fan_plus_price_id) .field( "creator_tier_prices", &format!( "{} tiers configured", self.creator_pricing.tier_prices.len() ), ) .field( "creator_tier_annual_prices", &format!( "{} annual tiers configured", self.creator_pricing.tier_annual_prices.len() ), ) .field( "creator_tier_founder_prices", &format!( "{} founder tiers configured", self.creator_pricing.tier_founder_prices.len() ), ) .field( "creator_tier_founder_annual_prices", &format!( "{} founder annual tiers configured", self.creator_pricing.tier_founder_annual_prices.len() ), ) .field( "creator_founder_window_open", &self.creator_pricing.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("user_pages_host", &self.user_pages_host) .field( "postmark_inbound_webhook_token", &self .email_webhooks .inbound_webhook_token .as_ref() .map(|_| "[REDACTED]"), ) .field( "internal_shared_secret", &self .integrations .internal_shared_secret .as_ref() .map(|_| "[REDACTED]"), ) .field( "cli_service_token", &self .integrations .cli_service_token .as_ref() .map(|_| "[REDACTED]"), ) .field( "alerts_ingest_token", &self .integrations .alerts_ingest_token .as_ref() .map(|_| "[REDACTED]"), ) .field("wam_url", &self.integrations.wam_url) .field("access_gate", &self.access_gate) .field("sso", &self.sso.as_ref().map(|s| &s.provider_url)) .finish_non_exhaustive() } } 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, #[error("SYNCKIT_JWT_SECRET must be at least 32 characters long")] WeakSynckitJwtSecret, #[error("INTERNAL_SHARED_SECRET must be at least 32 characters long")] WeakInternalSecret, #[error("CLI_SERVICE_TOKEN must be at least 32 characters long")] WeakCliServiceToken, #[error("ALERTS_INGEST_TOKEN must be at least 32 characters long")] WeakAlertsIngestToken, #[error( "CDN_BASE_URL is required. It is the render base for every public image and media URL, and without it covers used to fall back to a 24-hour presigned URL written into a durable column. In dev, point it at the public bucket's origin. Set CDN_BASE_URL to your CDN origin." )] MissingCdnBaseUrl, #[error( "S3_PUBLIC_BUCKET is required in production when storage is configured. The CDN serves ONLY the public bucket; promoted image content (covers, gallery, item/project images) is copied there. Set S3_PUBLIC_BUCKET to the public, world-readable bucket name." )] MissingPublicBucket, } #[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", "S3_PUBLIC_BUCKET", "S3_RPM_BUCKET", "RPM_S3_ENDPOINT", "RPM_S3_BUCKET", "RPM_S3_ACCESS_KEY", "RPM_S3_SECRET_KEY", "RPM_S3_REGION", "RPM_BASE_URL", "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", "WAM_TOKEN", "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(std::sync::PoisonError::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() { 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) }, } } } } // ---- tests ---- #[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, public_storage: None, rpm_storage: None, rpm_base_url: None, stripe: None, admin_user_id: None, synckit_jwt_secret: None, scan: None, cdn_base_url: "https://cdn.localhost".to_string(), user_pages_host: Arc::from("u.localhost"), access_gate: AccessGate::Open, sso: None, rate_limits: crate::constants::RateLimits::production(), build: BuildConfig { trigger_token: None, host_linux: None, host_darwin: None, git_repos_path: None, git_ssh_host: None, }, email_webhooks: EmailWebhookConfig { webhook_token: None, broadcast_webhook_token: None, inbound_webhook_token: None, enforce_sender_auth: true, }, creator_pricing: CreatorTierPricing { fan_plus_price_id: None, tier_prices: HashMap::new(), tier_annual_prices: HashMap::new(), tier_founder_prices: HashMap::new(), tier_founder_annual_prices: HashMap::new(), founder_window_open: false, }, integrations: IntegrationsConfig { mt_base_url: None, wam_url: None, internal_shared_secret: None, cli_service_token: None, alerts_ingest_token: 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(); EnvGuard::clear_all(); // SAFETY: test-only, serialized by EnvGuard mutex unsafe { std::env::set_var("DATABASE_URL", "postgres://localhost/test_db"); std::env::set_var("CDN_BASE_URL", "https://cdn.test"); } 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(); EnvGuard::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(); EnvGuard::clear_all(); // SAFETY: test-only, serialized by EnvGuard mutex unsafe { std::env::set_var("DATABASE_URL", "postgres://localhost/test_db"); std::env::set_var("CDN_BASE_URL", "https://cdn.test"); 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_without_cdn_base_url_even_outside_production() { let guard = EnvGuard::new(); EnvGuard::clear_all(); // SAFETY: test-only, serialized by EnvGuard mutex unsafe { std::env::set_var("DATABASE_URL", "postgres://localhost/test_db"); std::env::set_var("SIGNING_SECRET", "x".repeat(32)); // pass the pre-CDN gate // No production indicator: HOST stays unset, so this is a dev config. // It must STILL fail. The requirement is unconditional precisely so // no environment can reach the old presigned fallback, which minted // a 24-hour URL into the durable `projects.cover_image_url` column. // CDN_BASE_URL deliberately unset. } let err = Config::from_env().unwrap_err(); assert!( matches!(err, ConfigError::MissingCdnBaseUrl), "expected MissingCdnBaseUrl, got: {err}" ); drop(guard); } #[test] fn from_env_accepts_production_with_cdn_base_url() { let guard = EnvGuard::new(); EnvGuard::clear_all(); // SAFETY: test-only, serialized by EnvGuard mutex unsafe { std::env::set_var("DATABASE_URL", "postgres://localhost/test_db"); std::env::set_var("CDN_BASE_URL", "https://cdn.test"); std::env::set_var("SIGNING_SECRET", "x".repeat(32)); std::env::set_var("HOST", "0.0.0.0"); std::env::set_var("CDN_BASE_URL", "https://cdn.makenot.work"); } let config = Config::from_env().expect("production config with CDN should succeed"); assert_eq!(config.cdn_base_url, "https://cdn.makenot.work"); drop(guard); } #[test] fn from_env_fails_with_https_host_url_without_signing_secret() { let guard = EnvGuard::new(); EnvGuard::clear_all(); // SAFETY: test-only, serialized by EnvGuard mutex unsafe { std::env::set_var("DATABASE_URL", "postgres://localhost/test_db"); std::env::set_var("CDN_BASE_URL", "https://cdn.test"); 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_fails_with_short_synckit_jwt_secret() { let guard = EnvGuard::new(); EnvGuard::clear_all(); // SAFETY: test-only, serialized by EnvGuard mutex unsafe { std::env::set_var("DATABASE_URL", "postgres://localhost/test_db"); std::env::set_var("CDN_BASE_URL", "https://cdn.test"); std::env::set_var("SIGNING_SECRET", "x".repeat(32)); // 31 chars, one under the floor. std::env::set_var("SYNCKIT_JWT_SECRET", "x".repeat(31)); } let err = Config::from_env().unwrap_err(); assert!( matches!(err, ConfigError::WeakSynckitJwtSecret), "expected WeakSynckitJwtSecret, got: {err}" ); drop(guard); } #[test] fn from_env_accepts_strong_synckit_jwt_secret() { let guard = EnvGuard::new(); EnvGuard::clear_all(); // SAFETY: test-only, serialized by EnvGuard mutex unsafe { std::env::set_var("DATABASE_URL", "postgres://localhost/test_db"); std::env::set_var("CDN_BASE_URL", "https://cdn.test"); std::env::set_var("SIGNING_SECRET", "x".repeat(32)); std::env::set_var("SYNCKIT_JWT_SECRET", "y".repeat(32)); } let config = Config::from_env().expect("32-char JWT secret should be accepted"); assert_eq!( config.synckit_jwt_secret.as_deref(), Some("y".repeat(32).as_str()) ); drop(guard); } #[test] fn from_env_uses_random_dev_secret_when_not_production() { let guard = EnvGuard::new(); EnvGuard::clear_all(); // SAFETY: test-only, serialized by EnvGuard mutex unsafe { std::env::set_var("DATABASE_URL", "postgres://localhost/test_db"); std::env::set_var("CDN_BASE_URL", "https://cdn.test"); // 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(); EnvGuard::clear_all(); // SAFETY: test-only, serialized by EnvGuard mutex unsafe { std::env::set_var("DATABASE_URL", "postgres://localhost/test_db"); std::env::set_var("CDN_BASE_URL", "https://cdn.test"); // 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(); EnvGuard::clear_all(); // SAFETY: test-only, serialized by EnvGuard mutex unsafe { std::env::set_var("DATABASE_URL", "postgres://localhost/test_db"); std::env::set_var("CDN_BASE_URL", "https://cdn.test"); 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(); EnvGuard::clear_all(); // SAFETY: test-only, serialized by EnvGuard mutex unsafe { std::env::set_var("DATABASE_URL", "postgres://localhost/test_db"); std::env::set_var("CDN_BASE_URL", "https://cdn.test"); // 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(); EnvGuard::clear_all(); // SAFETY: test-only, serialized by EnvGuard mutex unsafe { std::env::set_var("DATABASE_URL", "postgres://localhost/test_db"); std::env::set_var("CDN_BASE_URL", "https://cdn.test"); // 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(); EnvGuard::clear_all(); // SAFETY: test-only, serialized by EnvGuard mutex unsafe { std::env::set_var("DATABASE_URL", "postgres://localhost/test_db"); std::env::set_var("CDN_BASE_URL", "https://cdn.test"); 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(); EnvGuard::clear_all(); // SAFETY: test-only, serialized by EnvGuard mutex unsafe { std::env::set_var("DATABASE_URL", "postgres://localhost/test_db"); std::env::set_var("CDN_BASE_URL", "https://cdn.test"); 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(); EnvGuard::clear_all(); // SAFETY: test-only, serialized by EnvGuard mutex unsafe { std::env::set_var("DATABASE_URL", "postgres://localhost/test_db"); std::env::set_var("CDN_BASE_URL", "https://cdn.test"); 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(); EnvGuard::clear_all(); // SAFETY: test-only, serialized by EnvGuard mutex unsafe { std::env::set_var("DATABASE_URL", "postgres://localhost/test_db"); std::env::set_var("CDN_BASE_URL", "https://cdn.test"); 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(); EnvGuard::clear_all(); // SAFETY: test-only, serialized by EnvGuard mutex unsafe { std::env::set_var("DATABASE_URL", "postgres://localhost/test_db"); std::env::set_var("CDN_BASE_URL", "https://cdn.test"); } 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); } }