Skip to main content

max / makenotwork

41.1 KB · 999 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 /// Site-wide access gate. `Open` (default) serves the public site as
93 /// normal. `FanPlusOrCreator` restricts the whole site to logged-in users
94 /// with a creator account or an active Fan+ subscription — used on the
95 /// testnot.work staging mirror so it's reachable only by Fan+/creator
96 /// accounts. Off in production.
97 pub access_gate: AccessGate,
98 /// Upstream SSO provider for "Sign in with Makenot.work" (optional). When
99 /// set, the login page becomes a single button that authenticates against
100 /// `provider_url`'s OAuth endpoints instead of a local password form — used
101 /// on the testnot mirror so a password is only ever entered on production.
102 pub sso: Option<SsoConfig>,
103 }
104
105 /// Upstream OAuth provider config for delegated login (`SSO_*`).
106 #[derive(Clone)]
107 pub struct SsoConfig {
108 /// Base URL of the OAuth provider, e.g. `https://makenot.work` (no trailing slash).
109 pub provider_url: String,
110 /// `client_id` = the provider's registered `sync_apps.api_key` (raw key).
111 pub client_id: String,
112 /// SyncKit SDK key string sent on token exchange. Any non-empty string the
113 /// provider's `validate_synckit_key` accepts; identifies no billing slot
114 /// here — we discard the sync token and use only the returned `user_id`.
115 pub key: String,
116 }
117
118 impl SsoConfig {
119 /// Present only when all three `SSO_*` vars are set; otherwise `None`
120 /// (login falls back to the local password form).
121 pub fn from_env() -> Option<Self> {
122 let provider_url = std::env::var("SSO_PROVIDER_URL").ok()?;
123 let client_id = std::env::var("SSO_CLIENT_ID").ok()?;
124 let key = std::env::var("SSO_KEY").ok()?;
125 if provider_url.is_empty() || client_id.is_empty() || key.is_empty() {
126 return None;
127 }
128 Some(Self {
129 provider_url: provider_url.trim_end_matches('/').to_string(),
130 client_id,
131 key,
132 })
133 }
134 }
135
136 /// Site-wide access-gate mode (`ACCESS_GATE`).
137 #[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
138 pub enum AccessGate {
139 /// No gate — the public site is served to everyone (production default).
140 #[default]
141 Open,
142 /// Only logged-in creators or active Fan+ members may reach the site;
143 /// everyone else is bounced to login. A coarse pre-filter — per-route auth
144 /// still applies underneath.
145 FanPlusOrCreator,
146 }
147
148 /// S3-compatible storage configuration (Hetzner Object Storage)
149 #[derive(Clone)]
150 pub struct StorageConfig {
151 /// S3 endpoint URL (e.g., https://fsn1.your-objectstorage.com)
152 pub endpoint: String,
153 /// Bucket name
154 pub bucket: String,
155 /// Access key ID
156 pub access_key: String,
157 /// Secret access key
158 pub secret_key: String,
159 /// Region (e.g., fsn1)
160 pub region: String,
161 }
162
163 impl Config {
164 /// Load configuration from environment variables
165 pub fn from_env() -> Result<Self, ConfigError> {
166 let host: IpAddr = std::env::var("HOST")
167 .unwrap_or_else(|_| "127.0.0.1".to_string())
168 .parse()
169 .map_err(|_| ConfigError::InvalidHost)?;
170
171 let port: u16 = std::env::var("PORT")
172 .unwrap_or_else(|_| "3000".to_string())
173 .parse()
174 .map_err(|_| ConfigError::InvalidPort)?;
175
176 let database_url =
177 std::env::var("DATABASE_URL").map_err(|_| ConfigError::MissingDatabaseUrl)?;
178
179 let host_url = std::env::var("HOST_URL")
180 .unwrap_or_else(|_| format!("http://{}:{}", host, port));
181
182 // Secret key for signing tokens — required in production, random fallback in dev
183 let signing_secret = match std::env::var("SIGNING_SECRET") {
184 Ok(secret) => {
185 if secret.len() < 32 {
186 return Err(ConfigError::WeakSigningSecret);
187 }
188 secret
189 }
190 Err(_) => {
191 // If HOST is 0.0.0.0 or HOST_URL looks like production, refuse to start
192 let is_production = host == std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED)
193 || std::env::var("HOST_URL")
194 .map(|u| u.starts_with("https://"))
195 .unwrap_or(false);
196 if is_production {
197 return Err(ConfigError::MissingSigningSecret);
198 }
199 tracing::warn!("SIGNING_SECRET not set — using random value (dev mode only)");
200 let mut bytes = [0u8; 32];
201 rand::RngCore::fill_bytes(&mut rand::rng(), &mut bytes);
202 hex::encode(bytes)
203 }
204 };
205
206 // Load storage config - optional, returns None if not fully configured
207 let storage = StorageConfig::from_env();
208
209 // Load SyncKit blob storage config - separate S3 bucket
210 let synckit_storage = StorageConfig::from_env_prefixed("SYNCKIT_S3_");
211
212 // Load Stripe config - optional, returns None if not fully configured
213 let stripe = StripeConfig::from_env();
214
215 // Load admin user ID - optional, if unset admin routes return 404
216 let admin_user_id = std::env::var("ADMIN_USER_ID")
217 .ok()
218 .and_then(|s| s.parse::<UserId>().ok());
219
220 // SyncKit JWT secret - optional, sync endpoints return 503 if unset
221 let synckit_jwt_secret = std::env::var("SYNCKIT_JWT_SECRET").ok();
222
223 // File scanning - enabled by default, set SCAN_ENABLED=false to disable
224 let scan = ScanConfig::from_env();
225
226 // Git repos path - optional, git browser disabled if unset
227 let git_repos_path = std::env::var("GIT_REPOS_PATH").ok();
228
229 // Postmark webhook token - optional, webhook endpoint returns 401 if unset
230 let postmark_webhook_token = std::env::var("POSTMARK_WEBHOOK_TOKEN").ok();
231
232 // Postmark broadcast stream webhook token - optional, same endpoint accepts either token
233 let postmark_broadcast_webhook_token = std::env::var("POSTMARK_BROADCAST_WEBHOOK_TOKEN").ok();
234
235 // Git SSH host - optional, SSH clone URL hidden when unset
236 let git_ssh_host = std::env::var("GIT_SSH_HOST").ok();
237
238 // Multithreaded forum base URL - optional, Forums tab hidden when unset
239 let mt_base_url = std::env::var("MT_BASE_URL").ok();
240
241 // Fan+ Stripe Price ID - optional, Fan+ checkout disabled when unset
242 let fan_plus_price_id = std::env::var("FAN_PLUS_STRIPE_PRICE_ID").ok();
243
244 // Creator tier Stripe Price IDs - optional, creator tier checkout disabled when empty
245 let mut creator_tier_prices = HashMap::new();
246 if let Ok(v) = std::env::var("CREATOR_TIER_BASIC_PRICE_ID") {
247 creator_tier_prices.insert(CreatorTier::Basic, v);
248 }
249 if let Ok(v) = std::env::var("CREATOR_TIER_SMALL_FILES_PRICE_ID") {
250 creator_tier_prices.insert(CreatorTier::SmallFiles, v);
251 }
252 if let Ok(v) = std::env::var("CREATOR_TIER_BIG_FILES_PRICE_ID") {
253 creator_tier_prices.insert(CreatorTier::BigFiles, v);
254 }
255 if let Ok(v) = std::env::var("CREATOR_TIER_EVERYTHING_PRICE_ID") {
256 creator_tier_prices.insert(CreatorTier::Everything, v);
257 }
258
259 // Annual (10% off) sticker price IDs. Optional; checkout falls back to
260 // monthly when an annual price isn't configured for the tier.
261 let mut creator_tier_annual_prices = HashMap::new();
262 if let Ok(v) = std::env::var("CREATOR_TIER_BASIC_ANNUAL_PRICE_ID") {
263 creator_tier_annual_prices.insert(CreatorTier::Basic, v);
264 }
265 if let Ok(v) = std::env::var("CREATOR_TIER_SMALL_FILES_ANNUAL_PRICE_ID") {
266 creator_tier_annual_prices.insert(CreatorTier::SmallFiles, v);
267 }
268 if let Ok(v) = std::env::var("CREATOR_TIER_BIG_FILES_ANNUAL_PRICE_ID") {
269 creator_tier_annual_prices.insert(CreatorTier::BigFiles, v);
270 }
271 if let Ok(v) = std::env::var("CREATOR_TIER_EVERYTHING_ANNUAL_PRICE_ID") {
272 creator_tier_annual_prices.insert(CreatorTier::Everything, v);
273 }
274
275 // Founder-pricing price IDs - half the sticker rate, locked for life.
276 // Optional; tiers without a founder price fall back to sticker.
277 let mut creator_tier_founder_prices = HashMap::new();
278 if let Ok(v) = std::env::var("CREATOR_TIER_BASIC_FOUNDER_PRICE_ID") {
279 creator_tier_founder_prices.insert(CreatorTier::Basic, v);
280 }
281 if let Ok(v) = std::env::var("CREATOR_TIER_SMALL_FILES_FOUNDER_PRICE_ID") {
282 creator_tier_founder_prices.insert(CreatorTier::SmallFiles, v);
283 }
284 if let Ok(v) = std::env::var("CREATOR_TIER_BIG_FILES_FOUNDER_PRICE_ID") {
285 creator_tier_founder_prices.insert(CreatorTier::BigFiles, v);
286 }
287 if let Ok(v) = std::env::var("CREATOR_TIER_EVERYTHING_FOUNDER_PRICE_ID") {
288 creator_tier_founder_prices.insert(CreatorTier::Everything, v);
289 }
290
291 // Founder annual (10% off founder monthly × 12) price IDs.
292 let mut creator_tier_founder_annual_prices = HashMap::new();
293 if let Ok(v) = std::env::var("CREATOR_TIER_BASIC_FOUNDER_ANNUAL_PRICE_ID") {
294 creator_tier_founder_annual_prices.insert(CreatorTier::Basic, v);
295 }
296 if let Ok(v) = std::env::var("CREATOR_TIER_SMALL_FILES_FOUNDER_ANNUAL_PRICE_ID") {
297 creator_tier_founder_annual_prices.insert(CreatorTier::SmallFiles, v);
298 }
299 if let Ok(v) = std::env::var("CREATOR_TIER_BIG_FILES_FOUNDER_ANNUAL_PRICE_ID") {
300 creator_tier_founder_annual_prices.insert(CreatorTier::BigFiles, v);
301 }
302 if let Ok(v) = std::env::var("CREATOR_TIER_EVERYTHING_FOUNDER_ANNUAL_PRICE_ID") {
303 creator_tier_founder_annual_prices.insert(CreatorTier::Everything, v);
304 }
305
306 // Founder-window flag. Defaults to closed if unset so a misconfigured
307 // production env can't accidentally hand out founder pricing.
308 let creator_founder_window_open = std::env::var("CREATOR_FOUNDER_WINDOW_OPEN")
309 .ok()
310 .map(|v| v == "true" || v == "1")
311 .unwrap_or(false);
312
313 // Build pipeline - optional, build trigger endpoint returns 503 if unset
314 let build_trigger_token = std::env::var("BUILD_TRIGGER_TOKEN").ok();
315 let build_host_linux = std::env::var("BUILD_HOST_LINUX").ok();
316 let build_host_darwin = std::env::var("BUILD_HOST_DARWIN").ok();
317
318 // CDN base URL - optional, when unset all downloads use presigned S3 URLs
319 let cdn_base_url = std::env::var("CDN_BASE_URL").ok();
320
321 // Postmark inbound email webhook token - optional, inbound endpoint returns 401 if unset
322 let postmark_inbound_webhook_token = std::env::var("POSTMARK_INBOUND_WEBHOOK_TOKEN").ok();
323
324 // Internal shared secret for MT communication
325 let internal_shared_secret = std::env::var("INTERNAL_SHARED_SECRET").ok();
326
327 // CLI service token for SSH server → internal API authentication
328 let cli_service_token = std::env::var("CLI_SERVICE_TOKEN").ok();
329
330 // WAM ticket manager URL (tailnet, e.g. "http://100.x.x.x:7890")
331 let wam_url = std::env::var("WAM_URL").ok();
332
333 // Site-wide access gate. Only "fan_plus_or_creator" enables it; any
334 // other value (or unset) leaves the site open. Staging-only knob.
335 let access_gate = match std::env::var("ACCESS_GATE").as_deref() {
336 Ok("fan_plus_or_creator") => AccessGate::FanPlusOrCreator,
337 _ => AccessGate::Open,
338 };
339
340 let sso = SsoConfig::from_env();
341
342 Ok(Config {
343 host,
344 port,
345 database_url,
346 host_url: Arc::from(host_url),
347 signing_secret,
348 storage,
349 synckit_storage,
350 stripe,
351 admin_user_id,
352 synckit_jwt_secret,
353 scan,
354 git_repos_path,
355 postmark_webhook_token,
356 postmark_broadcast_webhook_token,
357 git_ssh_host,
358 mt_base_url,
359 fan_plus_price_id,
360 creator_tier_prices,
361 creator_tier_annual_prices,
362 creator_tier_founder_prices,
363 creator_tier_founder_annual_prices,
364 creator_founder_window_open,
365 build_trigger_token,
366 build_host_linux,
367 build_host_darwin,
368 cdn_base_url,
369 postmark_inbound_webhook_token,
370 internal_shared_secret,
371 cli_service_token,
372 wam_url,
373 access_gate,
374 sso,
375 })
376 }
377
378 /// Get the socket address for the server to bind to
379 pub fn socket_addr(&self) -> SocketAddr {
380 SocketAddr::new(self.host, self.port)
381 }
382 }
383
384 impl StorageConfig {
385 /// Load storage configuration from environment variables
386 /// Returns None if any required variable is missing (graceful degradation)
387 pub fn from_env() -> Option<Self> {
388 Self::from_env_prefixed("S3_")
389 }
390
391 /// Load storage configuration from prefixed environment variables.
392 /// e.g., prefix "SYNCKIT_S3_" reads SYNCKIT_S3_ENDPOINT, SYNCKIT_S3_BUCKET, etc.
393 pub fn from_env_prefixed(prefix: &str) -> Option<Self> {
394 let endpoint = std::env::var(format!("{prefix}ENDPOINT")).ok()?;
395 let bucket = std::env::var(format!("{prefix}BUCKET")).ok()?;
396 let access_key = std::env::var(format!("{prefix}ACCESS_KEY")).ok()?;
397 let secret_key = std::env::var(format!("{prefix}SECRET_KEY")).ok()?;
398 let region = std::env::var(format!("{prefix}REGION"))
399 .unwrap_or_else(|_| "us-east-1".to_string());
400
401 Some(StorageConfig {
402 endpoint,
403 bucket,
404 access_key,
405 secret_key,
406 region,
407 })
408 }
409 }
410
411 /// File scanning configuration
412 #[derive(Clone)]
413 pub struct ScanConfig {
414 /// Unix socket path for ClamAV daemon (optional)
415 pub clamav_socket: Option<String>,
416 /// Directory containing YARA rule files
417 pub yara_rules_dir: String,
418 /// Whether to enable MalwareBazaar hash lookups
419 pub malwarebazaar_enabled: bool,
420 /// Whether to enable URLhaus URL-reputation lookups
421 pub urlhaus_enabled: bool,
422 /// Shared abuse.ch Auth-Key (issued at https://auth.abuse.ch/). Required
423 /// for MalwareBazaar and URLhaus as of 2024+; without it both layers
424 /// fail-open and the dashboard surfaces them as degraded.
425 pub abuse_ch_auth_key: Option<String>,
426 /// MetaDefender Cloud API key (free tier at
427 /// <https://metadefender.com/account>). Second-opinion layer; only
428 /// invoked when another layer flagged the file as suspicious.
429 pub metadefender_api_key: Option<String>,
430 /// Minimum number of YARA rule files that must compile for the corpus to be
431 /// considered healthy. `0` disables the check (default). Set it to the known
432 /// deployed corpus size so a silent drop (a dependency/format change that
433 /// makes rules uncompilable) fails boot loudly rather than degrading
434 /// coverage unnoticed.
435 pub yara_min_rule_files: usize,
436 }
437
438 impl ScanConfig {
439 /// Load scan configuration from environment variables.
440 /// Returns Some if SCAN_ENABLED=true (default), None if explicitly disabled.
441 pub fn from_env() -> Option<Self> {
442 let enabled = std::env::var("SCAN_ENABLED")
443 .map(|v| v != "false" && v != "0")
444 .unwrap_or(true);
445
446 if !enabled {
447 return None;
448 }
449
450 Some(ScanConfig {
451 clamav_socket: std::env::var("CLAMAV_SOCKET").ok(),
452 yara_rules_dir: std::env::var("YARA_RULES_DIR")
453 .unwrap_or_else(|_| "yara-rules/".to_string()),
454 malwarebazaar_enabled: std::env::var("MALWAREBAZAAR_ENABLED")
455 .map(|v| v != "false" && v != "0")
456 .unwrap_or(true),
457 urlhaus_enabled: std::env::var("URLHAUS_ENABLED")
458 .map(|v| v != "false" && v != "0")
459 .unwrap_or(true),
460 abuse_ch_auth_key: std::env::var("ABUSE_CH_AUTH_KEY").ok().filter(|s| !s.is_empty()),
461 metadefender_api_key: std::env::var("METADEFENDER_API_KEY").ok().filter(|s| !s.is_empty()),
462 yara_min_rule_files: std::env::var("YARA_MIN_RULE_FILES")
463 .ok()
464 .and_then(|v| v.parse().ok())
465 .unwrap_or(0),
466 })
467 }
468 }
469
470 impl std::fmt::Debug for ScanConfig {
471 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
472 f.debug_struct("ScanConfig")
473 .field("clamav_socket", &self.clamav_socket)
474 .field("yara_rules_dir", &self.yara_rules_dir)
475 .field("malwarebazaar_enabled", &self.malwarebazaar_enabled)
476 .field("urlhaus_enabled", &self.urlhaus_enabled)
477 .field("abuse_ch_auth_key", &self.abuse_ch_auth_key.as_ref().map(|_| "<set>"))
478 .field("metadefender_api_key", &self.metadefender_api_key.as_ref().map(|_| "<set>"))
479 .finish()
480 }
481 }
482
483 /// Stripe payment configuration
484 #[derive(Clone)]
485 pub struct StripeConfig {
486 /// Stripe secret API key (sk_test_... or sk_live_...)
487 pub secret_key: String,
488 /// Webhook signing secrets for v1 snapshot events (whsec_...).
489 ///
490 /// A list to accommodate multiple Stripe endpoints (e.g. `mnw-connect`
491 /// for Connected-account events + `mnw-you` for platform events — Stripe
492 /// requires one endpoint per scope, and each endpoint has its own secret).
493 /// `verify_signature` accepts a match against any secret in the list.
494 /// Configured via `STRIPE_WEBHOOK_SECRET` as a comma-separated list.
495 pub webhook_secret: Vec<String>,
496 /// Webhook signing secret for v2 thin events (whsec_...)
497 /// Optional — v2 endpoint returns 503 if not set.
498 pub webhook_secret_v2: Option<String>,
499 }
500
501 impl StripeConfig {
502 /// Load Stripe configuration from environment variables
503 /// Returns None if any required variable is missing (graceful degradation)
504 pub fn from_env() -> Option<Self> {
505 let secret_key = std::env::var("STRIPE_SECRET_KEY").ok()?;
506 let webhook_secret: Vec<String> = std::env::var("STRIPE_WEBHOOK_SECRET").ok()?
507 .split(',')
508 .map(|s| s.trim().to_string())
509 .filter(|s| !s.is_empty())
510 .collect();
511 if webhook_secret.is_empty() { return None; }
512 let webhook_secret_v2 = std::env::var("STRIPE_WEBHOOK_SECRET_V2").ok();
513
514 Some(StripeConfig {
515 secret_key,
516 webhook_secret,
517 webhook_secret_v2,
518 })
519 }
520 }
521
522 impl std::fmt::Debug for Config {
523 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
524 f.debug_struct("Config")
525 .field("host", &self.host)
526 .field("port", &self.port)
527 .field("database_url", &"[REDACTED]")
528 .field("host_url", &self.host_url)
529 .field("signing_secret", &"[REDACTED]")
530 .field("storage", &self.storage)
531 .field("synckit_storage", &self.synckit_storage)
532 .field("stripe", &self.stripe)
533 .field("admin_user_id", &self.admin_user_id)
534 .field("synckit_jwt_secret", &self.synckit_jwt_secret.as_ref().map(|_| "[REDACTED]"))
535 .field("scan", &self.scan)
536 .field("git_repos_path", &self.git_repos_path)
537 .field("postmark_webhook_token", &self.postmark_webhook_token.as_ref().map(|_| "[REDACTED]"))
538 .field("postmark_broadcast_webhook_token", &self.postmark_broadcast_webhook_token.as_ref().map(|_| "[REDACTED]"))
539 .field("git_ssh_host", &self.git_ssh_host)
540 .field("mt_base_url", &self.mt_base_url)
541 .field("fan_plus_price_id", &self.fan_plus_price_id)
542 .field("creator_tier_prices", &format!("{} tiers configured", self.creator_tier_prices.len()))
543 .field("creator_tier_annual_prices", &format!("{} annual tiers configured", self.creator_tier_annual_prices.len()))
544 .field("creator_tier_founder_prices", &format!("{} founder tiers configured", self.creator_tier_founder_prices.len()))
545 .field("creator_tier_founder_annual_prices", &format!("{} founder annual tiers configured", self.creator_tier_founder_annual_prices.len()))
546 .field("creator_founder_window_open", &self.creator_founder_window_open)
547 .field("build_trigger_token", &self.build_trigger_token.as_ref().map(|_| "[REDACTED]"))
548 .field("build_host_linux", &self.build_host_linux)
549 .field("build_host_darwin", &self.build_host_darwin)
550 .field("cdn_base_url", &self.cdn_base_url)
551 .field("postmark_inbound_webhook_token", &self.postmark_inbound_webhook_token.as_ref().map(|_| "[REDACTED]"))
552 .field("internal_shared_secret", &self.internal_shared_secret.as_ref().map(|_| "[REDACTED]"))
553 .field("cli_service_token", &self.cli_service_token.as_ref().map(|_| "[REDACTED]"))
554 .field("wam_url", &self.wam_url)
555 .field("access_gate", &self.access_gate)
556 .field("sso", &self.sso.as_ref().map(|s| &s.provider_url))
557 .finish()
558 }
559 }
560
561 impl std::fmt::Debug for StorageConfig {
562 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
563 f.debug_struct("StorageConfig")
564 .field("endpoint", &self.endpoint)
565 .field("bucket", &self.bucket)
566 .field("access_key", &"[REDACTED]")
567 .field("secret_key", &"[REDACTED]")
568 .field("region", &self.region)
569 .finish()
570 }
571 }
572
573 impl std::fmt::Debug for StripeConfig {
574 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
575 f.debug_struct("StripeConfig")
576 .field("secret_key", &"[REDACTED]")
577 .field("webhook_secret", &"[REDACTED]")
578 .finish()
579 }
580 }
581
582 /// Configuration errors
583 #[derive(Debug, thiserror::Error)]
584 pub enum ConfigError {
585 #[error("Invalid HOST address")]
586 InvalidHost,
587 #[error("Invalid PORT number")]
588 InvalidPort,
589 #[error("DATABASE_URL environment variable is required")]
590 MissingDatabaseUrl,
591 #[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.")]
592 MissingSigningSecret,
593 #[error("SIGNING_SECRET must be at least 32 characters long")]
594 WeakSigningSecret,
595 }
596
597 #[cfg(test)]
598 mod tests {
599 use super::*;
600 use std::sync::Mutex;
601
602 /// Mutex to serialize tests that call Config::from_env(), since env vars are
603 /// process-global and concurrent mutation causes flaky failures.
604 static ENV_LOCK: Mutex<()> = Mutex::new(());
605
606 /// All env var keys that Config::from_env() reads. Used by the guard to
607 /// snapshot and restore state so tests don't leak into each other.
608 const CONFIG_ENV_VARS: &[&str] = &[
609 "HOST", "PORT", "DATABASE_URL", "HOST_URL", "SIGNING_SECRET",
610 "S3_ENDPOINT", "S3_BUCKET", "S3_ACCESS_KEY", "S3_SECRET_KEY", "S3_REGION",
611 "SYNCKIT_S3_ENDPOINT", "SYNCKIT_S3_BUCKET", "SYNCKIT_S3_ACCESS_KEY",
612 "SYNCKIT_S3_SECRET_KEY", "SYNCKIT_S3_REGION",
613 "STRIPE_SECRET_KEY", "STRIPE_WEBHOOK_SECRET", "STRIPE_WEBHOOK_SECRET_V2",
614 "ADMIN_USER_ID", "SYNCKIT_JWT_SECRET", "SCAN_ENABLED", "CLAMAV_SOCKET",
615 "YARA_RULES_DIR", "MALWAREBAZAAR_ENABLED", "URLHAUS_ENABLED",
616 "ABUSE_CH_AUTH_KEY", "METADEFENDER_API_KEY", "GIT_REPOS_PATH",
617 "POSTMARK_WEBHOOK_TOKEN", "POSTMARK_BROADCAST_WEBHOOK_TOKEN",
618 "GIT_SSH_HOST", "MT_BASE_URL", "FAN_PLUS_STRIPE_PRICE_ID",
619 "CREATOR_TIER_BASIC_PRICE_ID", "CREATOR_TIER_SMALL_FILES_PRICE_ID",
620 "CREATOR_TIER_BIG_FILES_PRICE_ID", "CREATOR_TIER_EVERYTHING_PRICE_ID",
621 "CREATOR_TIER_BASIC_ANNUAL_PRICE_ID", "CREATOR_TIER_SMALL_FILES_ANNUAL_PRICE_ID",
622 "CREATOR_TIER_BIG_FILES_ANNUAL_PRICE_ID", "CREATOR_TIER_EVERYTHING_ANNUAL_PRICE_ID",
623 "CREATOR_TIER_BASIC_FOUNDER_PRICE_ID", "CREATOR_TIER_SMALL_FILES_FOUNDER_PRICE_ID",
624 "CREATOR_TIER_BIG_FILES_FOUNDER_PRICE_ID", "CREATOR_TIER_EVERYTHING_FOUNDER_PRICE_ID",
625 "CREATOR_TIER_BASIC_FOUNDER_ANNUAL_PRICE_ID", "CREATOR_TIER_SMALL_FILES_FOUNDER_ANNUAL_PRICE_ID",
626 "CREATOR_TIER_BIG_FILES_FOUNDER_ANNUAL_PRICE_ID", "CREATOR_TIER_EVERYTHING_FOUNDER_ANNUAL_PRICE_ID",
627 "CREATOR_FOUNDER_WINDOW_OPEN",
628 "BUILD_TRIGGER_TOKEN", "BUILD_HOST_LINUX", "BUILD_HOST_DARWIN",
629 "CDN_BASE_URL", "POSTMARK_INBOUND_WEBHOOK_TOKEN",
630 "INTERNAL_SHARED_SECRET", "CLI_SERVICE_TOKEN", "WAM_URL", "ACCESS_GATE",
631 "SSO_PROVIDER_URL", "SSO_CLIENT_ID", "SSO_KEY",
632 ];
633
634 /// RAII guard that snapshots config-related env vars on creation and restores
635 /// them when dropped. Also holds the ENV_LOCK so tests run serially.
636 struct EnvGuard {
637 _lock: std::sync::MutexGuard<'static, ()>,
638 snapshot: Vec<(&'static str, Option<String>)>,
639 }
640
641 impl EnvGuard {
642 fn new() -> Self {
643 let lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
644 let snapshot = CONFIG_ENV_VARS
645 .iter()
646 .map(|&key| (key, std::env::var(key).ok()))
647 .collect();
648 Self { _lock: lock, snapshot }
649 }
650
651 /// Remove all config env vars so from_env() sees a clean slate.
652 fn clear_all(&self) {
653 for &key in CONFIG_ENV_VARS {
654 // SAFETY: test-only, serialized by mutex
655 unsafe { std::env::remove_var(key); }
656 }
657 }
658 }
659
660 impl Drop for EnvGuard {
661 fn drop(&mut self) {
662 for (key, val) in &self.snapshot {
663 match val {
664 // SAFETY: test-only, serialized by mutex
665 Some(v) => unsafe { std::env::set_var(key, v) },
666 None => unsafe { std::env::remove_var(key) },
667 }
668 }
669 }
670 }
671
672 // ---- existing tests (unchanged) ----
673
674 #[test]
675 fn socket_addr_combines_host_and_port() {
676 let config = Config {
677 host: "127.0.0.1".parse().unwrap(),
678 port: 8080,
679 database_url: "postgres://test".to_string(),
680 host_url: Arc::from("http://localhost:8080"),
681 signing_secret: "secret".to_string(),
682 storage: None,
683 synckit_storage: None,
684 stripe: None,
685 admin_user_id: None,
686 synckit_jwt_secret: None,
687 scan: None,
688 git_repos_path: None,
689 postmark_webhook_token: None,
690 postmark_broadcast_webhook_token: None,
691 git_ssh_host: None,
692 mt_base_url: None,
693 fan_plus_price_id: None,
694 creator_tier_prices: HashMap::new(),
695 creator_tier_annual_prices: HashMap::new(),
696 creator_tier_founder_prices: HashMap::new(),
697 creator_tier_founder_annual_prices: HashMap::new(),
698 creator_founder_window_open: false,
699 build_trigger_token: None,
700 build_host_linux: None,
701 build_host_darwin: None,
702 cdn_base_url: None,
703 postmark_inbound_webhook_token: None,
704 internal_shared_secret: None,
705 cli_service_token: None,
706 wam_url: None,
707 access_gate: AccessGate::Open,
708 sso: None,
709 };
710 let addr = config.socket_addr();
711 assert_eq!(addr.port(), 8080);
712 assert_eq!(addr.ip().to_string(), "127.0.0.1");
713 }
714
715 #[test]
716 fn config_error_display() {
717 assert_eq!(ConfigError::InvalidHost.to_string(), "Invalid HOST address");
718 assert_eq!(ConfigError::InvalidPort.to_string(), "Invalid PORT number");
719 assert!(ConfigError::MissingDatabaseUrl.to_string().contains("DATABASE_URL"));
720 }
721
722 // ---- from_env validation tests ----
723
724 #[test]
725 fn from_env_succeeds_with_required_vars() {
726 let guard = EnvGuard::new();
727 guard.clear_all();
728
729 // SAFETY: test-only, serialized by EnvGuard mutex
730 unsafe {
731 std::env::set_var("DATABASE_URL", "postgres://localhost/test_db");
732 }
733
734 let config = Config::from_env().expect("should succeed with DATABASE_URL set");
735 assert_eq!(config.database_url, "postgres://localhost/test_db");
736 // Defaults: host=127.0.0.1, port=3000
737 assert_eq!(config.host.to_string(), "127.0.0.1");
738 assert_eq!(config.port, 3000);
739 // Signing secret should be a random 64-char hex string in dev mode
740 assert!(!config.signing_secret.is_empty());
741 drop(guard);
742 }
743
744 #[test]
745 fn from_env_fails_without_database_url() {
746 let guard = EnvGuard::new();
747 guard.clear_all();
748
749 let err = Config::from_env().unwrap_err();
750 assert!(
751 matches!(err, ConfigError::MissingDatabaseUrl),
752 "expected MissingDatabaseUrl, got: {err}"
753 );
754 drop(guard);
755 }
756
757 #[test]
758 fn from_env_fails_in_production_without_signing_secret() {
759 let guard = EnvGuard::new();
760 guard.clear_all();
761
762 // SAFETY: test-only, serialized by EnvGuard mutex
763 unsafe {
764 std::env::set_var("DATABASE_URL", "postgres://localhost/test_db");
765 std::env::set_var("HOST", "0.0.0.0"); // production indicator
766 }
767
768 let err = Config::from_env().unwrap_err();
769 assert!(
770 matches!(err, ConfigError::MissingSigningSecret),
771 "expected MissingSigningSecret, got: {err}"
772 );
773 drop(guard);
774 }
775
776 #[test]
777 fn from_env_fails_with_https_host_url_without_signing_secret() {
778 let guard = EnvGuard::new();
779 guard.clear_all();
780
781 // SAFETY: test-only, serialized by EnvGuard mutex
782 unsafe {
783 std::env::set_var("DATABASE_URL", "postgres://localhost/test_db");
784 std::env::set_var("HOST_URL", "https://makenot.work"); // production indicator
785 }
786
787 let err = Config::from_env().unwrap_err();
788 assert!(
789 matches!(err, ConfigError::MissingSigningSecret),
790 "expected MissingSigningSecret, got: {err}"
791 );
792 drop(guard);
793 }
794
795 #[test]
796 fn from_env_uses_random_dev_secret_when_not_production() {
797 let guard = EnvGuard::new();
798 guard.clear_all();
799
800 // SAFETY: test-only, serialized by EnvGuard mutex
801 unsafe {
802 std::env::set_var("DATABASE_URL", "postgres://localhost/test_db");
803 // HOST defaults to 127.0.0.1, HOST_URL defaults to http://..., no SIGNING_SECRET
804 }
805
806 let config = Config::from_env().expect("should succeed in dev mode without SIGNING_SECRET");
807 // Should be a 64-char hex string (256-bit random)
808 assert_eq!(
809 config.signing_secret.len(), 64,
810 "expected 64-char hex signing secret, got length {}",
811 config.signing_secret.len()
812 );
813 assert!(
814 config.signing_secret.chars().all(|c| c.is_ascii_hexdigit()),
815 "expected hex signing secret, got: {}",
816 config.signing_secret
817 );
818 drop(guard);
819 }
820
821 #[test]
822 fn from_env_storage_none_when_partially_set() {
823 let guard = EnvGuard::new();
824 guard.clear_all();
825
826 // SAFETY: test-only, serialized by EnvGuard mutex
827 unsafe {
828 std::env::set_var("DATABASE_URL", "postgres://localhost/test_db");
829 // Set only some S3 vars — missing S3_SECRET_KEY and S3_ACCESS_KEY
830 std::env::set_var("S3_ENDPOINT", "https://fsn1.your-objectstorage.com");
831 std::env::set_var("S3_BUCKET", "test-bucket");
832 }
833
834 let config = Config::from_env().expect("should succeed");
835 assert!(
836 config.storage.is_none(),
837 "storage should be None when S3 vars are only partially set"
838 );
839 drop(guard);
840 }
841
842 #[test]
843 fn from_env_storage_some_when_fully_set() {
844 let guard = EnvGuard::new();
845 guard.clear_all();
846
847 // SAFETY: test-only, serialized by EnvGuard mutex
848 unsafe {
849 std::env::set_var("DATABASE_URL", "postgres://localhost/test_db");
850 std::env::set_var("S3_ENDPOINT", "https://fsn1.your-objectstorage.com");
851 std::env::set_var("S3_BUCKET", "test-bucket");
852 std::env::set_var("S3_ACCESS_KEY", "ak");
853 std::env::set_var("S3_SECRET_KEY", "sk");
854 }
855
856 let config = Config::from_env().expect("should succeed");
857 let storage = config.storage.expect("storage should be Some when all S3 vars set");
858 assert_eq!(storage.endpoint, "https://fsn1.your-objectstorage.com");
859 assert_eq!(storage.bucket, "test-bucket");
860 assert_eq!(storage.region, "us-east-1"); // default region
861 drop(guard);
862 }
863
864 #[test]
865 fn from_env_stripe_none_when_secret_key_missing() {
866 let guard = EnvGuard::new();
867 guard.clear_all();
868
869 // SAFETY: test-only, serialized by EnvGuard mutex
870 unsafe {
871 std::env::set_var("DATABASE_URL", "postgres://localhost/test_db");
872 // Set webhook secret but not secret key
873 std::env::set_var("STRIPE_WEBHOOK_SECRET", "whsec_test");
874 }
875
876 let config = Config::from_env().expect("should succeed");
877 assert!(
878 config.stripe.is_none(),
879 "stripe should be None when STRIPE_SECRET_KEY is missing"
880 );
881 drop(guard);
882 }
883
884 #[test]
885 fn from_env_stripe_none_when_webhook_secret_missing() {
886 let guard = EnvGuard::new();
887 guard.clear_all();
888
889 // SAFETY: test-only, serialized by EnvGuard mutex
890 unsafe {
891 std::env::set_var("DATABASE_URL", "postgres://localhost/test_db");
892 // Set secret key but not webhook secret
893 std::env::set_var("STRIPE_SECRET_KEY", "sk_test_abc");
894 }
895
896 let config = Config::from_env().expect("should succeed");
897 assert!(
898 config.stripe.is_none(),
899 "stripe should be None when STRIPE_WEBHOOK_SECRET is missing"
900 );
901 drop(guard);
902 }
903
904 #[test]
905 fn from_env_stripe_some_when_fully_set() {
906 let guard = EnvGuard::new();
907 guard.clear_all();
908
909 // SAFETY: test-only, serialized by EnvGuard mutex
910 unsafe {
911 std::env::set_var("DATABASE_URL", "postgres://localhost/test_db");
912 std::env::set_var("STRIPE_SECRET_KEY", "sk_test_abc");
913 std::env::set_var("STRIPE_WEBHOOK_SECRET", "whsec_test");
914 }
915
916 let config = Config::from_env().expect("should succeed");
917 let stripe = config.stripe.expect("stripe should be Some when fully configured");
918 assert_eq!(stripe.secret_key, "sk_test_abc");
919 assert_eq!(stripe.webhook_secret, vec!["whsec_test".to_string()]);
920 assert!(stripe.webhook_secret_v2.is_none());
921 drop(guard);
922 }
923
924 #[test]
925 fn from_env_invalid_host_rejected() {
926 let guard = EnvGuard::new();
927 guard.clear_all();
928
929 // SAFETY: test-only, serialized by EnvGuard mutex
930 unsafe {
931 std::env::set_var("DATABASE_URL", "postgres://localhost/test_db");
932 std::env::set_var("HOST", "not-an-ip");
933 }
934
935 let err = Config::from_env().unwrap_err();
936 assert!(
937 matches!(err, ConfigError::InvalidHost),
938 "expected InvalidHost, got: {err}"
939 );
940 drop(guard);
941 }
942
943 #[test]
944 fn from_env_invalid_port_rejected() {
945 let guard = EnvGuard::new();
946 guard.clear_all();
947
948 // SAFETY: test-only, serialized by EnvGuard mutex
949 unsafe {
950 std::env::set_var("DATABASE_URL", "postgres://localhost/test_db");
951 std::env::set_var("PORT", "not-a-number");
952 }
953
954 let err = Config::from_env().unwrap_err();
955 assert!(
956 matches!(err, ConfigError::InvalidPort),
957 "expected InvalidPort, got: {err}"
958 );
959 drop(guard);
960 }
961
962 #[test]
963 fn from_env_scan_disabled_when_explicitly_off() {
964 let guard = EnvGuard::new();
965 guard.clear_all();
966
967 // SAFETY: test-only, serialized by EnvGuard mutex
968 unsafe {
969 std::env::set_var("DATABASE_URL", "postgres://localhost/test_db");
970 std::env::set_var("SCAN_ENABLED", "false");
971 }
972
973 let config = Config::from_env().expect("should succeed");
974 assert!(
975 config.scan.is_none(),
976 "scan should be None when SCAN_ENABLED=false"
977 );
978 drop(guard);
979 }
980
981 #[test]
982 fn from_env_scan_enabled_by_default() {
983 let guard = EnvGuard::new();
984 guard.clear_all();
985
986 // SAFETY: test-only, serialized by EnvGuard mutex
987 unsafe {
988 std::env::set_var("DATABASE_URL", "postgres://localhost/test_db");
989 }
990
991 let config = Config::from_env().expect("should succeed");
992 assert!(
993 config.scan.is_some(),
994 "scan should be Some by default (enabled unless explicitly disabled)"
995 );
996 drop(guard);
997 }
998 }
999