Skip to main content

max / makenotwork

37.6 KB · 919 lines History Blame Raw
1 //! Server configuration loaded from environment variables
2
3 use std::collections::HashMap;
4 use std::net::{IpAddr, SocketAddr};
5 use std::sync::Arc;
6
7 use crate::db::{CreatorTier, UserId};
8
9 #[derive(Clone)]
10 pub struct Config {
11 /// Server host address
12 pub host: IpAddr,
13 /// Server port
14 pub port: u16,
15 /// Database connection URL
16 pub database_url: String,
17 /// Public-facing host URL (e.g., "https://makenot.work" or "localhost:3000").
18 /// Stored as `Arc<str>` so cloning into spawned tasks / templates is cheap.
19 pub host_url: Arc<str>,
20 /// Secret key for signing tokens (password reset, email verification, etc.)
21 pub signing_secret: String,
22 /// S3-compatible storage configuration (optional)
23 pub storage: Option<StorageConfig>,
24 /// Separate S3 bucket for SyncKit blob storage (optional)
25 pub synckit_storage: Option<StorageConfig>,
26 /// Stripe payment configuration (optional)
27 pub stripe: Option<StripeConfig>,
28 /// Admin user ID for waitlist management (optional)
29 pub admin_user_id: Option<UserId>,
30 /// JWT secret for SyncKit token signing (optional)
31 pub synckit_jwt_secret: Option<String>,
32 /// File scanning configuration (optional)
33 pub scan: Option<ScanConfig>,
34 /// Path to bare git repositories on disk (optional)
35 pub git_repos_path: Option<String>,
36 /// Bearer token for authenticating Postmark webhook requests (optional)
37 pub postmark_webhook_token: Option<String>,
38 /// Bearer token for authenticating Postmark broadcast stream webhooks (optional)
39 pub postmark_broadcast_webhook_token: Option<String>,
40 /// Hostname for git SSH clone URLs (e.g., "git.makenot.work"). Hidden when not set.
41 pub git_ssh_host: Option<String>,
42 /// Base URL of the Multithreaded forum instance (e.g., "https://forums.makenot.work").
43 /// When set, enables the Forums tab on the user dashboard.
44 pub mt_base_url: Option<String>,
45 /// Stripe Price ID for the Fan+ subscription ($8/mo).
46 /// When set, enables Fan+ subscription checkout.
47 pub fan_plus_price_id: Option<String>,
48 /// Stripe Price IDs for creator tier subscriptions (monthly).
49 /// Maps each tier to its Stripe Price ID. Empty map = creator tiers disabled.
50 pub creator_tier_prices: HashMap<CreatorTier, String>,
51 /// Stripe Price IDs for creator tier subscriptions, annual billing.
52 /// 10% off monthly × 12 — see `assumptions.toml` § annual_discount. Missing
53 /// entries fall back to monthly: checkout silently downgrades the interval
54 /// rather than erroring.
55 pub creator_tier_annual_prices: HashMap<CreatorTier, String>,
56 /// Stripe Price IDs for *founder* creator tier subscriptions, monthly
57 /// (50% off, locked for life). Used during the founder window or for
58 /// accounts whose `founder_locked_at` is set. Missing entries fall back to
59 /// sticker prices in `creator_tier_prices`. See `project_founder_pricing.md`.
60 pub creator_tier_founder_prices: HashMap<CreatorTier, String>,
61 /// Stripe Price IDs for *founder* creator tier subscriptions, annual.
62 /// 10% off founder monthly × 12. Missing entries fall back to founder
63 /// monthly first, then sticker monthly. Checkout silently downgrades; no
64 /// error response.
65 pub creator_tier_founder_annual_prices: HashMap<CreatorTier, String>,
66 /// Whether the founder-pricing window is currently open. While true, new
67 /// creator-tier subscriptions get founder prices and the subscribing user
68 /// is marked `is_founder = true`. Flip to false when the window closes
69 /// (1,000 creators or exit-beta, whichever first); a separate admin action
70 /// then sweeps `founder_locked_at` on active founder accounts.
71 pub creator_founder_window_open: bool,
72 /// Bearer token for authenticating build trigger webhook requests (optional).
73 pub build_trigger_token: Option<String>,
74 /// SSH host for Linux builds (e.g., "max@100.106.221.39").
75 pub build_host_linux: Option<String>,
76 /// SSH host for macOS builds (e.g., "max@100.64.x.x").
77 pub build_host_darwin: Option<String>,
78 /// Base URL for CDN-served downloads (e.g., "https://cdn.makenot.work").
79 /// When set, free content downloads are served via CDN instead of presigned S3 URLs.
80 pub cdn_base_url: Option<String>,
81 /// Bearer token for authenticating Postmark inbound email webhook (optional).
82 pub postmark_inbound_webhook_token: Option<String>,
83 /// Shared secret for HMAC-signed internal API requests to MT.
84 /// Must match `INTERNAL_SHARED_SECRET` on the MT instance.
85 pub internal_shared_secret: Option<String>,
86 /// Bearer token for authenticating CLI SSH server → MNW internal API calls.
87 /// When unset, internal API endpoints return 503.
88 pub cli_service_token: Option<String>,
89 /// Base URL of the WAM ticket manager (e.g., "http://100.x.x.x:7890").
90 /// When set, operational events create WAM tickets for human triage.
91 pub wam_url: Option<String>,
92 }
93
94 /// S3-compatible storage configuration (Hetzner Object Storage)
95 #[derive(Clone)]
96 pub struct StorageConfig {
97 /// S3 endpoint URL (e.g., https://fsn1.your-objectstorage.com)
98 pub endpoint: String,
99 /// Bucket name
100 pub bucket: String,
101 /// Access key ID
102 pub access_key: String,
103 /// Secret access key
104 pub secret_key: String,
105 /// Region (e.g., fsn1)
106 pub region: String,
107 }
108
109 impl Config {
110 /// Load configuration from environment variables
111 pub fn from_env() -> Result<Self, ConfigError> {
112 let host: IpAddr = std::env::var("HOST")
113 .unwrap_or_else(|_| "127.0.0.1".to_string())
114 .parse()
115 .map_err(|_| ConfigError::InvalidHost)?;
116
117 let port: u16 = std::env::var("PORT")
118 .unwrap_or_else(|_| "3000".to_string())
119 .parse()
120 .map_err(|_| ConfigError::InvalidPort)?;
121
122 let database_url =
123 std::env::var("DATABASE_URL").map_err(|_| ConfigError::MissingDatabaseUrl)?;
124
125 let host_url = std::env::var("HOST_URL")
126 .unwrap_or_else(|_| format!("http://{}:{}", host, port));
127
128 // Secret key for signing tokens — required in production, random fallback in dev
129 let signing_secret = match std::env::var("SIGNING_SECRET") {
130 Ok(secret) => {
131 if secret.len() < 32 {
132 return Err(ConfigError::WeakSigningSecret);
133 }
134 secret
135 }
136 Err(_) => {
137 // If HOST is 0.0.0.0 or HOST_URL looks like production, refuse to start
138 let is_production = host == std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED)
139 || std::env::var("HOST_URL")
140 .map(|u| u.starts_with("https://"))
141 .unwrap_or(false);
142 if is_production {
143 return Err(ConfigError::MissingSigningSecret);
144 }
145 tracing::warn!("SIGNING_SECRET not set — using random value (dev mode only)");
146 let mut bytes = [0u8; 32];
147 rand::RngCore::fill_bytes(&mut rand::rng(), &mut bytes);
148 hex::encode(bytes)
149 }
150 };
151
152 // Load storage config - optional, returns None if not fully configured
153 let storage = StorageConfig::from_env();
154
155 // Load SyncKit blob storage config - separate S3 bucket
156 let synckit_storage = StorageConfig::from_env_prefixed("SYNCKIT_S3_");
157
158 // Load Stripe config - optional, returns None if not fully configured
159 let stripe = StripeConfig::from_env();
160
161 // Load admin user ID - optional, if unset admin routes return 404
162 let admin_user_id = std::env::var("ADMIN_USER_ID")
163 .ok()
164 .and_then(|s| s.parse::<UserId>().ok());
165
166 // SyncKit JWT secret - optional, sync endpoints return 503 if unset
167 let synckit_jwt_secret = std::env::var("SYNCKIT_JWT_SECRET").ok();
168
169 // File scanning - enabled by default, set SCAN_ENABLED=false to disable
170 let scan = ScanConfig::from_env();
171
172 // Git repos path - optional, git browser disabled if unset
173 let git_repos_path = std::env::var("GIT_REPOS_PATH").ok();
174
175 // Postmark webhook token - optional, webhook endpoint returns 401 if unset
176 let postmark_webhook_token = std::env::var("POSTMARK_WEBHOOK_TOKEN").ok();
177
178 // Postmark broadcast stream webhook token - optional, same endpoint accepts either token
179 let postmark_broadcast_webhook_token = std::env::var("POSTMARK_BROADCAST_WEBHOOK_TOKEN").ok();
180
181 // Git SSH host - optional, SSH clone URL hidden when unset
182 let git_ssh_host = std::env::var("GIT_SSH_HOST").ok();
183
184 // Multithreaded forum base URL - optional, Forums tab hidden when unset
185 let mt_base_url = std::env::var("MT_BASE_URL").ok();
186
187 // Fan+ Stripe Price ID - optional, Fan+ checkout disabled when unset
188 let fan_plus_price_id = std::env::var("FAN_PLUS_STRIPE_PRICE_ID").ok();
189
190 // Creator tier Stripe Price IDs - optional, creator tier checkout disabled when empty
191 let mut creator_tier_prices = HashMap::new();
192 if let Ok(v) = std::env::var("CREATOR_TIER_BASIC_PRICE_ID") {
193 creator_tier_prices.insert(CreatorTier::Basic, v);
194 }
195 if let Ok(v) = std::env::var("CREATOR_TIER_SMALL_FILES_PRICE_ID") {
196 creator_tier_prices.insert(CreatorTier::SmallFiles, v);
197 }
198 if let Ok(v) = std::env::var("CREATOR_TIER_BIG_FILES_PRICE_ID") {
199 creator_tier_prices.insert(CreatorTier::BigFiles, v);
200 }
201 if let Ok(v) = std::env::var("CREATOR_TIER_EVERYTHING_PRICE_ID") {
202 creator_tier_prices.insert(CreatorTier::Everything, v);
203 }
204
205 // Annual (10% off) sticker price IDs. Optional; checkout falls back to
206 // monthly when an annual price isn't configured for the tier.
207 let mut creator_tier_annual_prices = HashMap::new();
208 if let Ok(v) = std::env::var("CREATOR_TIER_BASIC_ANNUAL_PRICE_ID") {
209 creator_tier_annual_prices.insert(CreatorTier::Basic, v);
210 }
211 if let Ok(v) = std::env::var("CREATOR_TIER_SMALL_FILES_ANNUAL_PRICE_ID") {
212 creator_tier_annual_prices.insert(CreatorTier::SmallFiles, v);
213 }
214 if let Ok(v) = std::env::var("CREATOR_TIER_BIG_FILES_ANNUAL_PRICE_ID") {
215 creator_tier_annual_prices.insert(CreatorTier::BigFiles, v);
216 }
217 if let Ok(v) = std::env::var("CREATOR_TIER_EVERYTHING_ANNUAL_PRICE_ID") {
218 creator_tier_annual_prices.insert(CreatorTier::Everything, v);
219 }
220
221 // Founder-pricing price IDs - half the sticker rate, locked for life.
222 // Optional; tiers without a founder price fall back to sticker.
223 let mut creator_tier_founder_prices = HashMap::new();
224 if let Ok(v) = std::env::var("CREATOR_TIER_BASIC_FOUNDER_PRICE_ID") {
225 creator_tier_founder_prices.insert(CreatorTier::Basic, v);
226 }
227 if let Ok(v) = std::env::var("CREATOR_TIER_SMALL_FILES_FOUNDER_PRICE_ID") {
228 creator_tier_founder_prices.insert(CreatorTier::SmallFiles, v);
229 }
230 if let Ok(v) = std::env::var("CREATOR_TIER_BIG_FILES_FOUNDER_PRICE_ID") {
231 creator_tier_founder_prices.insert(CreatorTier::BigFiles, v);
232 }
233 if let Ok(v) = std::env::var("CREATOR_TIER_EVERYTHING_FOUNDER_PRICE_ID") {
234 creator_tier_founder_prices.insert(CreatorTier::Everything, v);
235 }
236
237 // Founder annual (10% off founder monthly × 12) price IDs.
238 let mut creator_tier_founder_annual_prices = HashMap::new();
239 if let Ok(v) = std::env::var("CREATOR_TIER_BASIC_FOUNDER_ANNUAL_PRICE_ID") {
240 creator_tier_founder_annual_prices.insert(CreatorTier::Basic, v);
241 }
242 if let Ok(v) = std::env::var("CREATOR_TIER_SMALL_FILES_FOUNDER_ANNUAL_PRICE_ID") {
243 creator_tier_founder_annual_prices.insert(CreatorTier::SmallFiles, v);
244 }
245 if let Ok(v) = std::env::var("CREATOR_TIER_BIG_FILES_FOUNDER_ANNUAL_PRICE_ID") {
246 creator_tier_founder_annual_prices.insert(CreatorTier::BigFiles, v);
247 }
248 if let Ok(v) = std::env::var("CREATOR_TIER_EVERYTHING_FOUNDER_ANNUAL_PRICE_ID") {
249 creator_tier_founder_annual_prices.insert(CreatorTier::Everything, v);
250 }
251
252 // Founder-window flag. Defaults to closed if unset so a misconfigured
253 // production env can't accidentally hand out founder pricing.
254 let creator_founder_window_open = std::env::var("CREATOR_FOUNDER_WINDOW_OPEN")
255 .ok()
256 .map(|v| v == "true" || v == "1")
257 .unwrap_or(false);
258
259 // Build pipeline - optional, build trigger endpoint returns 503 if unset
260 let build_trigger_token = std::env::var("BUILD_TRIGGER_TOKEN").ok();
261 let build_host_linux = std::env::var("BUILD_HOST_LINUX").ok();
262 let build_host_darwin = std::env::var("BUILD_HOST_DARWIN").ok();
263
264 // CDN base URL - optional, when unset all downloads use presigned S3 URLs
265 let cdn_base_url = std::env::var("CDN_BASE_URL").ok();
266
267 // Postmark inbound email webhook token - optional, inbound endpoint returns 401 if unset
268 let postmark_inbound_webhook_token = std::env::var("POSTMARK_INBOUND_WEBHOOK_TOKEN").ok();
269
270 // Internal shared secret for MT communication
271 let internal_shared_secret = std::env::var("INTERNAL_SHARED_SECRET").ok();
272
273 // CLI service token for SSH server → internal API authentication
274 let cli_service_token = std::env::var("CLI_SERVICE_TOKEN").ok();
275
276 // WAM ticket manager URL (tailnet, e.g. "http://100.x.x.x:7890")
277 let wam_url = std::env::var("WAM_URL").ok();
278
279 Ok(Config {
280 host,
281 port,
282 database_url,
283 host_url: Arc::from(host_url),
284 signing_secret,
285 storage,
286 synckit_storage,
287 stripe,
288 admin_user_id,
289 synckit_jwt_secret,
290 scan,
291 git_repos_path,
292 postmark_webhook_token,
293 postmark_broadcast_webhook_token,
294 git_ssh_host,
295 mt_base_url,
296 fan_plus_price_id,
297 creator_tier_prices,
298 creator_tier_annual_prices,
299 creator_tier_founder_prices,
300 creator_tier_founder_annual_prices,
301 creator_founder_window_open,
302 build_trigger_token,
303 build_host_linux,
304 build_host_darwin,
305 cdn_base_url,
306 postmark_inbound_webhook_token,
307 internal_shared_secret,
308 cli_service_token,
309 wam_url,
310 })
311 }
312
313 /// Get the socket address for the server to bind to
314 pub fn socket_addr(&self) -> SocketAddr {
315 SocketAddr::new(self.host, self.port)
316 }
317 }
318
319 impl StorageConfig {
320 /// Load storage configuration from environment variables
321 /// Returns None if any required variable is missing (graceful degradation)
322 pub fn from_env() -> Option<Self> {
323 Self::from_env_prefixed("S3_")
324 }
325
326 /// Load storage configuration from prefixed environment variables.
327 /// e.g., prefix "SYNCKIT_S3_" reads SYNCKIT_S3_ENDPOINT, SYNCKIT_S3_BUCKET, etc.
328 pub fn from_env_prefixed(prefix: &str) -> Option<Self> {
329 let endpoint = std::env::var(format!("{prefix}ENDPOINT")).ok()?;
330 let bucket = std::env::var(format!("{prefix}BUCKET")).ok()?;
331 let access_key = std::env::var(format!("{prefix}ACCESS_KEY")).ok()?;
332 let secret_key = std::env::var(format!("{prefix}SECRET_KEY")).ok()?;
333 let region = std::env::var(format!("{prefix}REGION"))
334 .unwrap_or_else(|_| "us-east-1".to_string());
335
336 Some(StorageConfig {
337 endpoint,
338 bucket,
339 access_key,
340 secret_key,
341 region,
342 })
343 }
344 }
345
346 /// File scanning configuration
347 #[derive(Clone)]
348 pub struct ScanConfig {
349 /// Unix socket path for ClamAV daemon (optional)
350 pub clamav_socket: Option<String>,
351 /// Directory containing YARA rule files
352 pub yara_rules_dir: String,
353 /// Whether to enable MalwareBazaar hash lookups
354 pub malwarebazaar_enabled: bool,
355 /// Whether to enable URLhaus URL-reputation lookups
356 pub urlhaus_enabled: bool,
357 /// Shared abuse.ch Auth-Key (issued at https://auth.abuse.ch/). Required
358 /// for MalwareBazaar and URLhaus as of 2024+; without it both layers
359 /// fail-open and the dashboard surfaces them as degraded.
360 pub abuse_ch_auth_key: Option<String>,
361 /// MetaDefender Cloud API key (free tier at
362 /// <https://metadefender.com/account>). Second-opinion layer; only
363 /// invoked when another layer flagged the file as suspicious.
364 pub metadefender_api_key: Option<String>,
365 }
366
367 impl ScanConfig {
368 /// Load scan configuration from environment variables.
369 /// Returns Some if SCAN_ENABLED=true (default), None if explicitly disabled.
370 pub fn from_env() -> Option<Self> {
371 let enabled = std::env::var("SCAN_ENABLED")
372 .map(|v| v != "false" && v != "0")
373 .unwrap_or(true);
374
375 if !enabled {
376 return None;
377 }
378
379 Some(ScanConfig {
380 clamav_socket: std::env::var("CLAMAV_SOCKET").ok(),
381 yara_rules_dir: std::env::var("YARA_RULES_DIR")
382 .unwrap_or_else(|_| "yara-rules/".to_string()),
383 malwarebazaar_enabled: std::env::var("MALWAREBAZAAR_ENABLED")
384 .map(|v| v != "false" && v != "0")
385 .unwrap_or(true),
386 urlhaus_enabled: std::env::var("URLHAUS_ENABLED")
387 .map(|v| v != "false" && v != "0")
388 .unwrap_or(true),
389 abuse_ch_auth_key: std::env::var("ABUSE_CH_AUTH_KEY").ok().filter(|s| !s.is_empty()),
390 metadefender_api_key: std::env::var("METADEFENDER_API_KEY").ok().filter(|s| !s.is_empty()),
391 })
392 }
393 }
394
395 impl std::fmt::Debug for ScanConfig {
396 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
397 f.debug_struct("ScanConfig")
398 .field("clamav_socket", &self.clamav_socket)
399 .field("yara_rules_dir", &self.yara_rules_dir)
400 .field("malwarebazaar_enabled", &self.malwarebazaar_enabled)
401 .field("urlhaus_enabled", &self.urlhaus_enabled)
402 .field("abuse_ch_auth_key", &self.abuse_ch_auth_key.as_ref().map(|_| "<set>"))
403 .field("metadefender_api_key", &self.metadefender_api_key.as_ref().map(|_| "<set>"))
404 .finish()
405 }
406 }
407
408 /// Stripe payment configuration
409 #[derive(Clone)]
410 pub struct StripeConfig {
411 /// Stripe secret API key (sk_test_... or sk_live_...)
412 pub secret_key: String,
413 /// Webhook signing secrets for v1 snapshot events (whsec_...).
414 ///
415 /// A list to accommodate multiple Stripe endpoints (e.g. `mnw-connect`
416 /// for Connected-account events + `mnw-you` for platform events — Stripe
417 /// requires one endpoint per scope, and each endpoint has its own secret).
418 /// `verify_signature` accepts a match against any secret in the list.
419 /// Configured via `STRIPE_WEBHOOK_SECRET` as a comma-separated list.
420 pub webhook_secret: Vec<String>,
421 /// Webhook signing secret for v2 thin events (whsec_...)
422 /// Optional — v2 endpoint returns 503 if not set.
423 pub webhook_secret_v2: Option<String>,
424 }
425
426 impl StripeConfig {
427 /// Load Stripe configuration from environment variables
428 /// Returns None if any required variable is missing (graceful degradation)
429 pub fn from_env() -> Option<Self> {
430 let secret_key = std::env::var("STRIPE_SECRET_KEY").ok()?;
431 let webhook_secret: Vec<String> = std::env::var("STRIPE_WEBHOOK_SECRET").ok()?
432 .split(',')
433 .map(|s| s.trim().to_string())
434 .filter(|s| !s.is_empty())
435 .collect();
436 if webhook_secret.is_empty() { return None; }
437 let webhook_secret_v2 = std::env::var("STRIPE_WEBHOOK_SECRET_V2").ok();
438
439 Some(StripeConfig {
440 secret_key,
441 webhook_secret,
442 webhook_secret_v2,
443 })
444 }
445 }
446
447 impl std::fmt::Debug for Config {
448 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
449 f.debug_struct("Config")
450 .field("host", &self.host)
451 .field("port", &self.port)
452 .field("database_url", &"[REDACTED]")
453 .field("host_url", &self.host_url)
454 .field("signing_secret", &"[REDACTED]")
455 .field("storage", &self.storage)
456 .field("synckit_storage", &self.synckit_storage)
457 .field("stripe", &self.stripe)
458 .field("admin_user_id", &self.admin_user_id)
459 .field("synckit_jwt_secret", &self.synckit_jwt_secret.as_ref().map(|_| "[REDACTED]"))
460 .field("scan", &self.scan)
461 .field("git_repos_path", &self.git_repos_path)
462 .field("postmark_webhook_token", &self.postmark_webhook_token.as_ref().map(|_| "[REDACTED]"))
463 .field("postmark_broadcast_webhook_token", &self.postmark_broadcast_webhook_token.as_ref().map(|_| "[REDACTED]"))
464 .field("git_ssh_host", &self.git_ssh_host)
465 .field("mt_base_url", &self.mt_base_url)
466 .field("fan_plus_price_id", &self.fan_plus_price_id)
467 .field("creator_tier_prices", &format!("{} tiers configured", self.creator_tier_prices.len()))
468 .field("creator_tier_annual_prices", &format!("{} annual tiers configured", self.creator_tier_annual_prices.len()))
469 .field("creator_tier_founder_prices", &format!("{} founder tiers configured", self.creator_tier_founder_prices.len()))
470 .field("creator_tier_founder_annual_prices", &format!("{} founder annual tiers configured", self.creator_tier_founder_annual_prices.len()))
471 .field("creator_founder_window_open", &self.creator_founder_window_open)
472 .field("build_trigger_token", &self.build_trigger_token.as_ref().map(|_| "[REDACTED]"))
473 .field("build_host_linux", &self.build_host_linux)
474 .field("build_host_darwin", &self.build_host_darwin)
475 .field("cdn_base_url", &self.cdn_base_url)
476 .field("postmark_inbound_webhook_token", &self.postmark_inbound_webhook_token.as_ref().map(|_| "[REDACTED]"))
477 .field("internal_shared_secret", &self.internal_shared_secret.as_ref().map(|_| "[REDACTED]"))
478 .field("cli_service_token", &self.cli_service_token.as_ref().map(|_| "[REDACTED]"))
479 .field("wam_url", &self.wam_url)
480 .finish()
481 }
482 }
483
484 impl std::fmt::Debug for StorageConfig {
485 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
486 f.debug_struct("StorageConfig")
487 .field("endpoint", &self.endpoint)
488 .field("bucket", &self.bucket)
489 .field("access_key", &"[REDACTED]")
490 .field("secret_key", &"[REDACTED]")
491 .field("region", &self.region)
492 .finish()
493 }
494 }
495
496 impl std::fmt::Debug for StripeConfig {
497 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
498 f.debug_struct("StripeConfig")
499 .field("secret_key", &"[REDACTED]")
500 .field("webhook_secret", &"[REDACTED]")
501 .finish()
502 }
503 }
504
505 /// Configuration errors
506 #[derive(Debug, thiserror::Error)]
507 pub enum ConfigError {
508 #[error("Invalid HOST address")]
509 InvalidHost,
510 #[error("Invalid PORT number")]
511 InvalidPort,
512 #[error("DATABASE_URL environment variable is required")]
513 MissingDatabaseUrl,
514 #[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.")]
515 MissingSigningSecret,
516 #[error("SIGNING_SECRET must be at least 32 characters long")]
517 WeakSigningSecret,
518 }
519
520 #[cfg(test)]
521 mod tests {
522 use super::*;
523 use std::sync::Mutex;
524
525 /// Mutex to serialize tests that call Config::from_env(), since env vars are
526 /// process-global and concurrent mutation causes flaky failures.
527 static ENV_LOCK: Mutex<()> = Mutex::new(());
528
529 /// All env var keys that Config::from_env() reads. Used by the guard to
530 /// snapshot and restore state so tests don't leak into each other.
531 const CONFIG_ENV_VARS: &[&str] = &[
532 "HOST", "PORT", "DATABASE_URL", "HOST_URL", "SIGNING_SECRET",
533 "S3_ENDPOINT", "S3_BUCKET", "S3_ACCESS_KEY", "S3_SECRET_KEY", "S3_REGION",
534 "SYNCKIT_S3_ENDPOINT", "SYNCKIT_S3_BUCKET", "SYNCKIT_S3_ACCESS_KEY",
535 "SYNCKIT_S3_SECRET_KEY", "SYNCKIT_S3_REGION",
536 "STRIPE_SECRET_KEY", "STRIPE_WEBHOOK_SECRET", "STRIPE_WEBHOOK_SECRET_V2",
537 "ADMIN_USER_ID", "SYNCKIT_JWT_SECRET", "SCAN_ENABLED", "CLAMAV_SOCKET",
538 "YARA_RULES_DIR", "MALWAREBAZAAR_ENABLED", "URLHAUS_ENABLED",
539 "ABUSE_CH_AUTH_KEY", "METADEFENDER_API_KEY", "GIT_REPOS_PATH",
540 "POSTMARK_WEBHOOK_TOKEN", "POSTMARK_BROADCAST_WEBHOOK_TOKEN",
541 "GIT_SSH_HOST", "MT_BASE_URL", "FAN_PLUS_STRIPE_PRICE_ID",
542 "CREATOR_TIER_BASIC_PRICE_ID", "CREATOR_TIER_SMALL_FILES_PRICE_ID",
543 "CREATOR_TIER_BIG_FILES_PRICE_ID", "CREATOR_TIER_EVERYTHING_PRICE_ID",
544 "CREATOR_TIER_BASIC_ANNUAL_PRICE_ID", "CREATOR_TIER_SMALL_FILES_ANNUAL_PRICE_ID",
545 "CREATOR_TIER_BIG_FILES_ANNUAL_PRICE_ID", "CREATOR_TIER_EVERYTHING_ANNUAL_PRICE_ID",
546 "CREATOR_TIER_BASIC_FOUNDER_PRICE_ID", "CREATOR_TIER_SMALL_FILES_FOUNDER_PRICE_ID",
547 "CREATOR_TIER_BIG_FILES_FOUNDER_PRICE_ID", "CREATOR_TIER_EVERYTHING_FOUNDER_PRICE_ID",
548 "CREATOR_TIER_BASIC_FOUNDER_ANNUAL_PRICE_ID", "CREATOR_TIER_SMALL_FILES_FOUNDER_ANNUAL_PRICE_ID",
549 "CREATOR_TIER_BIG_FILES_FOUNDER_ANNUAL_PRICE_ID", "CREATOR_TIER_EVERYTHING_FOUNDER_ANNUAL_PRICE_ID",
550 "CREATOR_FOUNDER_WINDOW_OPEN",
551 "BUILD_TRIGGER_TOKEN", "BUILD_HOST_LINUX", "BUILD_HOST_DARWIN",
552 "CDN_BASE_URL", "POSTMARK_INBOUND_WEBHOOK_TOKEN",
553 "INTERNAL_SHARED_SECRET", "CLI_SERVICE_TOKEN",
554 ];
555
556 /// RAII guard that snapshots config-related env vars on creation and restores
557 /// them when dropped. Also holds the ENV_LOCK so tests run serially.
558 struct EnvGuard {
559 _lock: std::sync::MutexGuard<'static, ()>,
560 snapshot: Vec<(&'static str, Option<String>)>,
561 }
562
563 impl EnvGuard {
564 fn new() -> Self {
565 let lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
566 let snapshot = CONFIG_ENV_VARS
567 .iter()
568 .map(|&key| (key, std::env::var(key).ok()))
569 .collect();
570 Self { _lock: lock, snapshot }
571 }
572
573 /// Remove all config env vars so from_env() sees a clean slate.
574 fn clear_all(&self) {
575 for &key in CONFIG_ENV_VARS {
576 // SAFETY: test-only, serialized by mutex
577 unsafe { std::env::remove_var(key); }
578 }
579 }
580 }
581
582 impl Drop for EnvGuard {
583 fn drop(&mut self) {
584 for (key, val) in &self.snapshot {
585 match val {
586 // SAFETY: test-only, serialized by mutex
587 Some(v) => unsafe { std::env::set_var(key, v) },
588 None => unsafe { std::env::remove_var(key) },
589 }
590 }
591 }
592 }
593
594 // ---- existing tests (unchanged) ----
595
596 #[test]
597 fn socket_addr_combines_host_and_port() {
598 let config = Config {
599 host: "127.0.0.1".parse().unwrap(),
600 port: 8080,
601 database_url: "postgres://test".to_string(),
602 host_url: Arc::from("http://localhost:8080"),
603 signing_secret: "secret".to_string(),
604 storage: None,
605 synckit_storage: None,
606 stripe: None,
607 admin_user_id: None,
608 synckit_jwt_secret: None,
609 scan: None,
610 git_repos_path: None,
611 postmark_webhook_token: None,
612 postmark_broadcast_webhook_token: None,
613 git_ssh_host: None,
614 mt_base_url: None,
615 fan_plus_price_id: None,
616 creator_tier_prices: HashMap::new(),
617 creator_tier_annual_prices: HashMap::new(),
618 creator_tier_founder_prices: HashMap::new(),
619 creator_tier_founder_annual_prices: HashMap::new(),
620 creator_founder_window_open: false,
621 build_trigger_token: None,
622 build_host_linux: None,
623 build_host_darwin: None,
624 cdn_base_url: None,
625 postmark_inbound_webhook_token: None,
626 internal_shared_secret: None,
627 cli_service_token: None,
628 wam_url: None,
629 };
630 let addr = config.socket_addr();
631 assert_eq!(addr.port(), 8080);
632 assert_eq!(addr.ip().to_string(), "127.0.0.1");
633 }
634
635 #[test]
636 fn config_error_display() {
637 assert_eq!(ConfigError::InvalidHost.to_string(), "Invalid HOST address");
638 assert_eq!(ConfigError::InvalidPort.to_string(), "Invalid PORT number");
639 assert!(ConfigError::MissingDatabaseUrl.to_string().contains("DATABASE_URL"));
640 }
641
642 // ---- from_env validation tests ----
643
644 #[test]
645 fn from_env_succeeds_with_required_vars() {
646 let guard = EnvGuard::new();
647 guard.clear_all();
648
649 // SAFETY: test-only, serialized by EnvGuard mutex
650 unsafe {
651 std::env::set_var("DATABASE_URL", "postgres://localhost/test_db");
652 }
653
654 let config = Config::from_env().expect("should succeed with DATABASE_URL set");
655 assert_eq!(config.database_url, "postgres://localhost/test_db");
656 // Defaults: host=127.0.0.1, port=3000
657 assert_eq!(config.host.to_string(), "127.0.0.1");
658 assert_eq!(config.port, 3000);
659 // Signing secret should be a random 64-char hex string in dev mode
660 assert!(!config.signing_secret.is_empty());
661 drop(guard);
662 }
663
664 #[test]
665 fn from_env_fails_without_database_url() {
666 let guard = EnvGuard::new();
667 guard.clear_all();
668
669 let err = Config::from_env().unwrap_err();
670 assert!(
671 matches!(err, ConfigError::MissingDatabaseUrl),
672 "expected MissingDatabaseUrl, got: {err}"
673 );
674 drop(guard);
675 }
676
677 #[test]
678 fn from_env_fails_in_production_without_signing_secret() {
679 let guard = EnvGuard::new();
680 guard.clear_all();
681
682 // SAFETY: test-only, serialized by EnvGuard mutex
683 unsafe {
684 std::env::set_var("DATABASE_URL", "postgres://localhost/test_db");
685 std::env::set_var("HOST", "0.0.0.0"); // production indicator
686 }
687
688 let err = Config::from_env().unwrap_err();
689 assert!(
690 matches!(err, ConfigError::MissingSigningSecret),
691 "expected MissingSigningSecret, got: {err}"
692 );
693 drop(guard);
694 }
695
696 #[test]
697 fn from_env_fails_with_https_host_url_without_signing_secret() {
698 let guard = EnvGuard::new();
699 guard.clear_all();
700
701 // SAFETY: test-only, serialized by EnvGuard mutex
702 unsafe {
703 std::env::set_var("DATABASE_URL", "postgres://localhost/test_db");
704 std::env::set_var("HOST_URL", "https://makenot.work"); // production indicator
705 }
706
707 let err = Config::from_env().unwrap_err();
708 assert!(
709 matches!(err, ConfigError::MissingSigningSecret),
710 "expected MissingSigningSecret, got: {err}"
711 );
712 drop(guard);
713 }
714
715 #[test]
716 fn from_env_uses_random_dev_secret_when_not_production() {
717 let guard = EnvGuard::new();
718 guard.clear_all();
719
720 // SAFETY: test-only, serialized by EnvGuard mutex
721 unsafe {
722 std::env::set_var("DATABASE_URL", "postgres://localhost/test_db");
723 // HOST defaults to 127.0.0.1, HOST_URL defaults to http://..., no SIGNING_SECRET
724 }
725
726 let config = Config::from_env().expect("should succeed in dev mode without SIGNING_SECRET");
727 // Should be a 64-char hex string (256-bit random)
728 assert_eq!(
729 config.signing_secret.len(), 64,
730 "expected 64-char hex signing secret, got length {}",
731 config.signing_secret.len()
732 );
733 assert!(
734 config.signing_secret.chars().all(|c| c.is_ascii_hexdigit()),
735 "expected hex signing secret, got: {}",
736 config.signing_secret
737 );
738 drop(guard);
739 }
740
741 #[test]
742 fn from_env_storage_none_when_partially_set() {
743 let guard = EnvGuard::new();
744 guard.clear_all();
745
746 // SAFETY: test-only, serialized by EnvGuard mutex
747 unsafe {
748 std::env::set_var("DATABASE_URL", "postgres://localhost/test_db");
749 // Set only some S3 vars — missing S3_SECRET_KEY and S3_ACCESS_KEY
750 std::env::set_var("S3_ENDPOINT", "https://fsn1.your-objectstorage.com");
751 std::env::set_var("S3_BUCKET", "test-bucket");
752 }
753
754 let config = Config::from_env().expect("should succeed");
755 assert!(
756 config.storage.is_none(),
757 "storage should be None when S3 vars are only partially set"
758 );
759 drop(guard);
760 }
761
762 #[test]
763 fn from_env_storage_some_when_fully_set() {
764 let guard = EnvGuard::new();
765 guard.clear_all();
766
767 // SAFETY: test-only, serialized by EnvGuard mutex
768 unsafe {
769 std::env::set_var("DATABASE_URL", "postgres://localhost/test_db");
770 std::env::set_var("S3_ENDPOINT", "https://fsn1.your-objectstorage.com");
771 std::env::set_var("S3_BUCKET", "test-bucket");
772 std::env::set_var("S3_ACCESS_KEY", "ak");
773 std::env::set_var("S3_SECRET_KEY", "sk");
774 }
775
776 let config = Config::from_env().expect("should succeed");
777 let storage = config.storage.expect("storage should be Some when all S3 vars set");
778 assert_eq!(storage.endpoint, "https://fsn1.your-objectstorage.com");
779 assert_eq!(storage.bucket, "test-bucket");
780 assert_eq!(storage.region, "us-east-1"); // default region
781 drop(guard);
782 }
783
784 #[test]
785 fn from_env_stripe_none_when_secret_key_missing() {
786 let guard = EnvGuard::new();
787 guard.clear_all();
788
789 // SAFETY: test-only, serialized by EnvGuard mutex
790 unsafe {
791 std::env::set_var("DATABASE_URL", "postgres://localhost/test_db");
792 // Set webhook secret but not secret key
793 std::env::set_var("STRIPE_WEBHOOK_SECRET", "whsec_test");
794 }
795
796 let config = Config::from_env().expect("should succeed");
797 assert!(
798 config.stripe.is_none(),
799 "stripe should be None when STRIPE_SECRET_KEY is missing"
800 );
801 drop(guard);
802 }
803
804 #[test]
805 fn from_env_stripe_none_when_webhook_secret_missing() {
806 let guard = EnvGuard::new();
807 guard.clear_all();
808
809 // SAFETY: test-only, serialized by EnvGuard mutex
810 unsafe {
811 std::env::set_var("DATABASE_URL", "postgres://localhost/test_db");
812 // Set secret key but not webhook secret
813 std::env::set_var("STRIPE_SECRET_KEY", "sk_test_abc");
814 }
815
816 let config = Config::from_env().expect("should succeed");
817 assert!(
818 config.stripe.is_none(),
819 "stripe should be None when STRIPE_WEBHOOK_SECRET is missing"
820 );
821 drop(guard);
822 }
823
824 #[test]
825 fn from_env_stripe_some_when_fully_set() {
826 let guard = EnvGuard::new();
827 guard.clear_all();
828
829 // SAFETY: test-only, serialized by EnvGuard mutex
830 unsafe {
831 std::env::set_var("DATABASE_URL", "postgres://localhost/test_db");
832 std::env::set_var("STRIPE_SECRET_KEY", "sk_test_abc");
833 std::env::set_var("STRIPE_WEBHOOK_SECRET", "whsec_test");
834 }
835
836 let config = Config::from_env().expect("should succeed");
837 let stripe = config.stripe.expect("stripe should be Some when fully configured");
838 assert_eq!(stripe.secret_key, "sk_test_abc");
839 assert_eq!(stripe.webhook_secret, vec!["whsec_test".to_string()]);
840 assert!(stripe.webhook_secret_v2.is_none());
841 drop(guard);
842 }
843
844 #[test]
845 fn from_env_invalid_host_rejected() {
846 let guard = EnvGuard::new();
847 guard.clear_all();
848
849 // SAFETY: test-only, serialized by EnvGuard mutex
850 unsafe {
851 std::env::set_var("DATABASE_URL", "postgres://localhost/test_db");
852 std::env::set_var("HOST", "not-an-ip");
853 }
854
855 let err = Config::from_env().unwrap_err();
856 assert!(
857 matches!(err, ConfigError::InvalidHost),
858 "expected InvalidHost, got: {err}"
859 );
860 drop(guard);
861 }
862
863 #[test]
864 fn from_env_invalid_port_rejected() {
865 let guard = EnvGuard::new();
866 guard.clear_all();
867
868 // SAFETY: test-only, serialized by EnvGuard mutex
869 unsafe {
870 std::env::set_var("DATABASE_URL", "postgres://localhost/test_db");
871 std::env::set_var("PORT", "not-a-number");
872 }
873
874 let err = Config::from_env().unwrap_err();
875 assert!(
876 matches!(err, ConfigError::InvalidPort),
877 "expected InvalidPort, got: {err}"
878 );
879 drop(guard);
880 }
881
882 #[test]
883 fn from_env_scan_disabled_when_explicitly_off() {
884 let guard = EnvGuard::new();
885 guard.clear_all();
886
887 // SAFETY: test-only, serialized by EnvGuard mutex
888 unsafe {
889 std::env::set_var("DATABASE_URL", "postgres://localhost/test_db");
890 std::env::set_var("SCAN_ENABLED", "false");
891 }
892
893 let config = Config::from_env().expect("should succeed");
894 assert!(
895 config.scan.is_none(),
896 "scan should be None when SCAN_ENABLED=false"
897 );
898 drop(guard);
899 }
900
901 #[test]
902 fn from_env_scan_enabled_by_default() {
903 let guard = EnvGuard::new();
904 guard.clear_all();
905
906 // SAFETY: test-only, serialized by EnvGuard mutex
907 unsafe {
908 std::env::set_var("DATABASE_URL", "postgres://localhost/test_db");
909 }
910
911 let config = Config::from_env().expect("should succeed");
912 assert!(
913 config.scan.is_some(),
914 "scan should be Some by default (enabled unless explicitly disabled)"
915 );
916 drop(guard);
917 }
918 }
919