Skip to main content

max / makenotwork

61.8 KB · 1548 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 /// Public, CDN-served bucket for promoted image content (covers, gallery,
27 /// item/project images). Same endpoint/credentials as `storage`, bucket
28 /// overridden by `S3_PUBLIC_BUCKET`. Required in production (the CDN serves
29 /// ONLY this bucket); `None` in dev when `S3_PUBLIC_BUCKET` is unset.
30 pub public_storage: Option<StorageConfig>,
31 /// Stripe payment configuration (optional)
32 pub stripe: Option<StripeConfig>,
33 /// Admin user ID for waitlist management (optional)
34 pub admin_user_id: Option<UserId>,
35 /// JWT secret for SyncKit token signing (optional)
36 pub synckit_jwt_secret: Option<String>,
37 /// File scanning configuration (optional)
38 pub scan: Option<ScanConfig>,
39 /// Base URL for CDN-served downloads (e.g., "https://cdn.makenot.work").
40 /// Required in every environment: it is the only render base for public
41 /// image and media URLs. Point it at the raw public-bucket origin in dev if
42 /// there is no edge in front.
43 pub cdn_base_url: String,
44 /// Hostname that serves creator custom pages (e.g. "u.makenot.work").
45 /// Cookieless and strict-CSP, isolated from the apex. Defaults to "u." +
46 /// the host_url host; override via USER_PAGES_HOST.
47 pub user_pages_host: Arc<str>,
48 /// Native build pipeline: SSH build hosts, trigger auth, and git repo
49 /// paths (`BUILD_*`, `GIT_*`).
50 pub build: BuildConfig,
51 /// Postmark webhook authentication + inbound sender-auth policy (`POSTMARK_*`).
52 pub email_webhooks: EmailWebhookConfig,
53 /// Creator-tier + Fan+ Stripe price maps and the founder-window flag.
54 pub creator_pricing: CreatorTierPricing,
55 /// Sibling-service URLs and shared secrets: MT forum, WAM, internal API.
56 pub integrations: IntegrationsConfig,
57 /// Site-wide access gate. `Open` (default) serves the public site as
58 /// normal. `FanPlusOrCreator` restricts the whole site to logged-in users
59 /// with a creator account or an active Fan+ subscription, used on the
60 /// testnot.work staging mirror so it's reachable only by Fan+/creator
61 /// accounts. Off in production.
62 pub access_gate: AccessGate,
63 /// Upstream SSO provider for "Sign in with Makenotwork" (optional). When
64 /// set, the login page becomes a single button that authenticates against
65 /// `provider_url`'s OAuth endpoints instead of a local password form, used
66 /// on the testnot mirror so a password is only ever entered on production.
67 pub sso: Option<SsoConfig>,
68 /// Rate-limit profile the router is built with. Production everywhere that
69 /// is not a test; see [`crate::constants::RateLimits`].
70 pub rate_limits: crate::constants::RateLimits,
71 /// Which screens serve from the quasi description layer instead of Askama
72 /// (`QUASI_SCREENS`). See [`QuasiScreens`].
73 pub quasi_screens: QuasiScreens,
74 }
75
76 /// The wave-2 conversion switch, one screen at a time.
77 ///
78 /// Per screen and not global: a global flag means the first screen converted
79 /// cannot ship until the last one is, which turns a hundred-screen conversion
80 /// into one release. Per screen, each conversion ships the moment its parity
81 /// test is green and reverts by editing an env var rather than by a deploy.
82 ///
83 /// Read from `QUASI_SCREENS` as a comma-separated list of screen names. Unset
84 /// means empty means every screen still serves Askama, which is the state this
85 /// is meant to be in everywhere until Max flips one:
86 ///
87 /// ```text
88 /// QUASI_SCREENS=user_account,item_pricing
89 /// QUASI_SCREENS=* # everything, for local work
90 /// ```
91 ///
92 /// The names are the conversion's own, matching the screen inventory in wiki
93 /// `look-wave-2`. Nothing validates them against a registry: a typo disables a
94 /// screen rather than enabling the wrong one, which is the safe direction, and
95 /// a registry would have to be kept in step with a list that is still moving.
96 #[derive(Clone, Debug, Default, PartialEq, Eq)]
97 pub struct QuasiScreens {
98 all: bool,
99 names: std::collections::HashSet<String>,
100 }
101
102 impl QuasiScreens {
103 /// Parse the comma-separated form. Blanks and stray whitespace are ignored.
104 #[must_use]
105 pub fn parse(raw: &str) -> Self {
106 let mut all = false;
107 let mut names = std::collections::HashSet::new();
108 for part in raw.split(',') {
109 match part.trim() {
110 "" => {}
111 "*" => all = true,
112 name => {
113 names.insert(name.to_owned());
114 }
115 }
116 }
117 Self { all, names }
118 }
119
120 /// Whether this screen serves from the description layer.
121 #[must_use]
122 pub fn enabled(&self, screen: &str) -> bool {
123 self.all || self.names.contains(screen)
124 }
125
126 /// Whether any screen is converted. For the startup log line: a server
127 /// serving one screen differently from every other deployment is worth
128 /// saying out loud rather than discovering.
129 #[must_use]
130 pub fn any(&self) -> bool {
131 self.all || !self.names.is_empty()
132 }
133 }
134
135 /// Native build pipeline configuration (`BUILD_*`, `GIT_*`).
136 ///
137 /// `git_repos_path` and `git_ssh_host` gate the in-app git browser and the
138 /// SSH clone URL; the build host/token fields drive the remote build runner.
139 #[derive(Clone)]
140 pub struct BuildConfig {
141 /// Bearer token for authenticating build trigger webhook requests (optional).
142 pub trigger_token: Option<String>,
143 /// SSH host for Linux builds (e.g., "max@100.106.221.39").
144 pub host_linux: Option<String>,
145 /// SSH host for macOS builds (e.g., "max@100.64.x.x").
146 pub host_darwin: Option<String>,
147 /// Path to bare git repositories on disk (optional). Git browser disabled if unset.
148 pub git_repos_path: Option<String>,
149 /// Hostname for git SSH clone URLs (e.g., "git.makenot.work"). Hidden when not set.
150 pub git_ssh_host: Option<String>,
151 }
152
153 /// Postmark webhook authentication + inbound sender-auth policy (`POSTMARK_*`).
154 #[derive(Clone)]
155 pub struct EmailWebhookConfig {
156 /// Bearer token for authenticating Postmark webhook requests (optional).
157 pub webhook_token: Option<String>,
158 /// Bearer token for authenticating Postmark broadcast stream webhooks (optional).
159 pub broadcast_webhook_token: Option<String>,
160 /// Bearer token for authenticating the Postmark inbound email webhook (optional).
161 pub inbound_webhook_token: Option<String>,
162 /// Enforce SPF/DKIM alignment on inbound email before trusting the `From`
163 /// address as an MNW user's identity. Defaults to `true` (fail closed): a
164 /// message whose `From` domain isn't SPF/DKIM-aligned is not attributed to
165 /// the account that owns that address. Set `POSTMARK_ENFORCE_SENDER_AUTH=false`
166 /// only to observe verdicts during rollout (logs but does not reject).
167 pub enforce_sender_auth: bool,
168 }
169
170 /// Creator-tier and Fan+ Stripe price maps plus the founder-window flag.
171 ///
172 /// Missing annual/founder entries fall back per the checkout logic (annual →
173 /// monthly, founder → sticker).
174 #[derive(Clone)]
175 pub struct CreatorTierPricing {
176 /// Stripe Price ID for the Fan+ subscription ($8/mo). Enables Fan+ checkout when set.
177 pub fan_plus_price_id: Option<String>,
178 /// Stripe Price IDs for creator tier subscriptions (monthly). Empty = disabled.
179 pub tier_prices: HashMap<CreatorTier, String>,
180 /// Stripe Price IDs for creator tier subscriptions, annual billing (10% off monthly × 12).
181 pub tier_annual_prices: HashMap<CreatorTier, String>,
182 /// Stripe Price IDs for *founder* creator tier subscriptions, monthly (50% off, locked for life).
183 pub tier_founder_prices: HashMap<CreatorTier, String>,
184 /// Stripe Price IDs for *founder* creator tier subscriptions, annual (10% off founder monthly × 12).
185 pub tier_founder_annual_prices: HashMap<CreatorTier, String>,
186 /// Whether the founder-pricing window is currently open. While true, new
187 /// creator-tier subscriptions get founder prices and the user is marked
188 /// `is_founder = true`. Defaults closed so a misconfigured env can't leak it.
189 pub founder_window_open: bool,
190 }
191
192 /// URLs and shared secrets for sibling services (MT forum, WAM, internal API).
193 #[derive(Clone)]
194 pub struct IntegrationsConfig {
195 /// Base URL of the Multithreaded forum instance. Enables the Forums tab when set.
196 pub mt_base_url: Option<String>,
197 /// Base URL of the WAM ticket manager. Enables WAM ticketing when set.
198 pub wam_url: Option<String>,
199 /// Shared secret for HMAC-signed internal API requests to MT (>=32 chars).
200 pub internal_shared_secret: Option<String>,
201 /// Bearer token authenticating CLI SSH server → MNW internal API calls (>=32 chars).
202 pub cli_service_token: Option<String>,
203 /// Bearer token authenticating inbound infra alerts (PoM/MT → `POST
204 /// /api/internal/alerts`) (>=32 chars). Distinct from `cli_service_token` so
205 /// a leak on a monitoring agent can't reach the CLI internal API.
206 pub alerts_ingest_token: Option<String>,
207 }
208
209 /// Upstream OAuth provider config for delegated login (`SSO_*`).
210 #[derive(Clone)]
211 pub struct SsoConfig {
212 /// Base URL of the OAuth provider, e.g. `https://makenot.work` (no trailing slash).
213 pub provider_url: String,
214 /// `client_id` = the provider's registered `sync_apps.api_key` (raw key).
215 pub client_id: String,
216 /// SyncKit SDK key string sent on token exchange. Any non-empty string the
217 /// provider's `validate_synckit_key` accepts; identifies no billing slot
218 /// here, we discard the sync token and use only the returned `user_id`.
219 pub key: String,
220 }
221
222 impl SsoConfig {
223 /// Present only when all three `SSO_*` vars are set; otherwise `None`
224 /// (login falls back to the local password form).
225 pub fn from_env() -> Option<Self> {
226 let provider_url = std::env::var("SSO_PROVIDER_URL").ok()?;
227 let client_id = std::env::var("SSO_CLIENT_ID").ok()?;
228 let key = std::env::var("SSO_KEY").ok()?;
229 if provider_url.is_empty() || client_id.is_empty() || key.is_empty() {
230 return None;
231 }
232 Some(Self {
233 provider_url: provider_url.trim_end_matches('/').to_string(),
234 client_id,
235 key,
236 })
237 }
238 }
239
240 /// Site-wide access-gate mode (`ACCESS_GATE`).
241 #[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
242 pub enum AccessGate {
243 /// No gate, the public site is served to everyone (production default).
244 #[default]
245 Open,
246 /// Only logged-in creators or active Fan+ members may reach the site;
247 /// everyone else is bounced to login. A coarse pre-filter, per-route auth
248 /// still applies underneath.
249 FanPlusOrCreator,
250 }
251
252 /// S3-compatible storage configuration (Hetzner Object Storage)
253 #[derive(Clone)]
254 pub struct StorageConfig {
255 /// S3 endpoint URL (e.g., https://fsn1.your-objectstorage.com)
256 pub endpoint: String,
257 /// Bucket name
258 pub bucket: String,
259 /// Access key ID
260 pub access_key: String,
261 /// Secret access key
262 pub secret_key: String,
263 /// Region (e.g., fsn1)
264 pub region: String,
265 }
266
267 impl Config {
268 /// Load configuration from environment variables
269 pub fn from_env() -> Result<Self, ConfigError> {
270 let host: IpAddr = std::env::var("HOST")
271 .unwrap_or_else(|_| "127.0.0.1".to_string())
272 .parse()
273 .map_err(|_| ConfigError::InvalidHost)?;
274
275 let port: u16 = std::env::var("PORT")
276 .unwrap_or_else(|_| "3000".to_string())
277 .parse()
278 .map_err(|_| ConfigError::InvalidPort)?;
279
280 let database_url =
281 std::env::var("DATABASE_URL").map_err(|_| ConfigError::MissingDatabaseUrl)?;
282
283 let host_url =
284 std::env::var("HOST_URL").unwrap_or_else(|_| format!("http://{host}:{port}"));
285
286 // Secret key for signing tokens, required in production, random fallback in dev
287 let signing_secret = match std::env::var("SIGNING_SECRET") {
288 Ok(secret) => {
289 if secret.len() < 32 {
290 return Err(ConfigError::WeakSigningSecret);
291 }
292 secret
293 }
294 Err(_) => {
295 // If HOST is 0.0.0.0 or HOST_URL looks like production, refuse to start
296 let is_production = host == std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED)
297 || std::env::var("HOST_URL").is_ok_and(|u| u.starts_with("https://"));
298 if is_production {
299 return Err(ConfigError::MissingSigningSecret);
300 }
301 tracing::warn!("SIGNING_SECRET not set, using random value (dev mode only)");
302 let mut bytes = [0u8; 32];
303 rand::Rng::fill_bytes(&mut rand::rng(), &mut bytes);
304 hex::encode(bytes)
305 }
306 };
307
308 // Load storage config - optional, returns None if not fully configured
309 let storage = StorageConfig::from_env();
310
311 // Load SyncKit blob storage config - separate S3 bucket
312 let synckit_storage = StorageConfig::from_env_prefixed("SYNCKIT_S3_");
313
314 // Public, CDN-served bucket: reuse the main storage endpoint/credentials
315 // with the bucket overridden by S3_PUBLIC_BUCKET. Only the immutably-public
316 // promoted image content lands here, so it carries a blanket public-read
317 // policy while the main bucket stays private. `None` when either the main
318 // storage or S3_PUBLIC_BUCKET is unset (dev); required in production below.
319 let public_storage = std::env::var("S3_PUBLIC_BUCKET")
320 .ok()
321 .filter(|s| !s.is_empty())
322 .and_then(|bucket| {
323 storage.as_ref().map(|s| StorageConfig {
324 bucket,
325 ..s.clone()
326 })
327 });
328
329 // Load Stripe config - optional, returns None if not fully configured
330 let stripe = StripeConfig::from_env();
331
332 // Load admin user ID - optional, if unset admin routes return 404
333 let admin_user_id = std::env::var("ADMIN_USER_ID").ok().and_then(|s| {
334 s.parse::<UserId>()
335 .map_err(|_| {
336 tracing::warn!(
337 "ADMIN_USER_ID is set but is not a valid UserId, ignoring it; admin routes will return 404"
338 );
339 })
340 .ok()
341 });
342
343 // SyncKit JWT secret - optional, sync endpoints return 503 if unset.
344 // When set it IS the HS256 symmetric signing key for SyncKit/OAuth
345 // bearer tokens, so enforce the same >=32-char floor as SIGNING_SECRET:
346 // a short value is offline-brute-forceable into token forgery. Fail
347 // closed (refuse to start) rather than silently accepting a weak key.
348 let synckit_jwt_secret = match std::env::var("SYNCKIT_JWT_SECRET") {
349 Ok(secret) => {
350 if secret.len() < 32 {
351 return Err(ConfigError::WeakSynckitJwtSecret);
352 }
353 Some(secret)
354 }
355 Err(_) => None,
356 };
357
358 // File scanning - enabled by default, set SCAN_ENABLED=false to disable
359 let scan = ScanConfig::from_env();
360
361 // Git repos path - optional, git browser disabled if unset
362 let git_repos_path = std::env::var("GIT_REPOS_PATH").ok();
363
364 // Postmark webhook token - optional, webhook endpoint returns 401 if unset
365 let postmark_webhook_token = std::env::var("POSTMARK_WEBHOOK_TOKEN").ok();
366
367 // Postmark broadcast stream webhook token - optional, same endpoint accepts either token
368 let postmark_broadcast_webhook_token =
369 std::env::var("POSTMARK_BROADCAST_WEBHOOK_TOKEN").ok();
370
371 // Git SSH host - optional, SSH clone URL hidden when unset
372 let git_ssh_host = std::env::var("GIT_SSH_HOST").ok();
373
374 // Multithreaded forum base URL - optional, Forums tab hidden when unset
375 let mt_base_url = std::env::var("MT_BASE_URL").ok();
376
377 // Fan+ Stripe Price ID - optional, Fan+ checkout disabled when unset
378 let fan_plus_price_id = std::env::var("FAN_PLUS_STRIPE_PRICE_ID").ok();
379
380 // Creator tier Stripe Price IDs - optional, creator tier checkout disabled when empty
381 let mut creator_tier_prices = HashMap::new();
382 if let Ok(v) = std::env::var("CREATOR_TIER_BASIC_PRICE_ID") {
383 creator_tier_prices.insert(CreatorTier::Basic, v);
384 }
385 if let Ok(v) = std::env::var("CREATOR_TIER_SMALL_FILES_PRICE_ID") {
386 creator_tier_prices.insert(CreatorTier::SmallFiles, v);
387 }
388 if let Ok(v) = std::env::var("CREATOR_TIER_BIG_FILES_PRICE_ID") {
389 creator_tier_prices.insert(CreatorTier::BigFiles, v);
390 }
391 if let Ok(v) = std::env::var("CREATOR_TIER_EVERYTHING_PRICE_ID") {
392 creator_tier_prices.insert(CreatorTier::Everything, v);
393 }
394
395 // Annual (10% off) sticker price IDs. Optional; checkout falls back to
396 // monthly when an annual price isn't configured for the tier.
397 let mut creator_tier_annual_prices = HashMap::new();
398 if let Ok(v) = std::env::var("CREATOR_TIER_BASIC_ANNUAL_PRICE_ID") {
399 creator_tier_annual_prices.insert(CreatorTier::Basic, v);
400 }
401 if let Ok(v) = std::env::var("CREATOR_TIER_SMALL_FILES_ANNUAL_PRICE_ID") {
402 creator_tier_annual_prices.insert(CreatorTier::SmallFiles, v);
403 }
404 if let Ok(v) = std::env::var("CREATOR_TIER_BIG_FILES_ANNUAL_PRICE_ID") {
405 creator_tier_annual_prices.insert(CreatorTier::BigFiles, v);
406 }
407 if let Ok(v) = std::env::var("CREATOR_TIER_EVERYTHING_ANNUAL_PRICE_ID") {
408 creator_tier_annual_prices.insert(CreatorTier::Everything, v);
409 }
410
411 // Founder-pricing price IDs - half the sticker rate, locked for life.
412 // Optional; tiers without a founder price fall back to sticker.
413 let mut creator_tier_founder_prices = HashMap::new();
414 if let Ok(v) = std::env::var("CREATOR_TIER_BASIC_FOUNDER_PRICE_ID") {
415 creator_tier_founder_prices.insert(CreatorTier::Basic, v);
416 }
417 if let Ok(v) = std::env::var("CREATOR_TIER_SMALL_FILES_FOUNDER_PRICE_ID") {
418 creator_tier_founder_prices.insert(CreatorTier::SmallFiles, v);
419 }
420 if let Ok(v) = std::env::var("CREATOR_TIER_BIG_FILES_FOUNDER_PRICE_ID") {
421 creator_tier_founder_prices.insert(CreatorTier::BigFiles, v);
422 }
423 if let Ok(v) = std::env::var("CREATOR_TIER_EVERYTHING_FOUNDER_PRICE_ID") {
424 creator_tier_founder_prices.insert(CreatorTier::Everything, v);
425 }
426
427 // Founder annual (10% off founder monthly × 12) price IDs.
428 let mut creator_tier_founder_annual_prices = HashMap::new();
429 if let Ok(v) = std::env::var("CREATOR_TIER_BASIC_FOUNDER_ANNUAL_PRICE_ID") {
430 creator_tier_founder_annual_prices.insert(CreatorTier::Basic, v);
431 }
432 if let Ok(v) = std::env::var("CREATOR_TIER_SMALL_FILES_FOUNDER_ANNUAL_PRICE_ID") {
433 creator_tier_founder_annual_prices.insert(CreatorTier::SmallFiles, v);
434 }
435 if let Ok(v) = std::env::var("CREATOR_TIER_BIG_FILES_FOUNDER_ANNUAL_PRICE_ID") {
436 creator_tier_founder_annual_prices.insert(CreatorTier::BigFiles, v);
437 }
438 if let Ok(v) = std::env::var("CREATOR_TIER_EVERYTHING_FOUNDER_ANNUAL_PRICE_ID") {
439 creator_tier_founder_annual_prices.insert(CreatorTier::Everything, v);
440 }
441
442 // Founder-window flag. Defaults to closed if unset so a misconfigured
443 // production env can't accidentally hand out founder pricing.
444 let creator_founder_window_open = std::env::var("CREATOR_FOUNDER_WINDOW_OPEN")
445 .ok()
446 .is_some_and(|v| v == "true" || v == "1");
447
448 // Build pipeline - optional, build trigger endpoint returns 503 if unset
449 let build_trigger_token = std::env::var("BUILD_TRIGGER_TOKEN").ok();
450 let build_host_linux = std::env::var("BUILD_HOST_LINUX").ok();
451 let build_host_darwin = std::env::var("BUILD_HOST_DARWIN").ok();
452
453 // CDN base URL, REQUIRED everywhere. Without one, cover/download URLs
454 // used to fall back to path-style presigned S3 URLs
455 // (`{endpoint}/{bucket}/{key}`), a shape the cover_s3_key backfill
456 // (migration 152) and other key-from-URL derivation do not expect. Worse,
457 // that fallback minted a 24-hour URL for `projects.cover_image_url`, a
458 // durable column, so every cover written on it died a day later. The
459 // requirement is unconditional rather than production-only so the trap
460 // cannot exist at all: dev points CDN_BASE_URL at the raw public-bucket
461 // origin when there is no edge in front (ultra-fuzz Run 10 Sto S-1).
462 let cdn_base_url = std::env::var("CDN_BASE_URL")
463 .ok()
464 .filter(|s| !s.is_empty())
465 .ok_or(ConfigError::MissingCdnBaseUrl)?;
466 {
467 let is_production = host == std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED)
468 || std::env::var("HOST_URL").is_ok_and(|u| u.starts_with("https://"));
469 // The CDN serves ONLY the public bucket; without it, promoted image
470 // content has nowhere to land and covers/gallery would 404. Storage
471 // must be configured (checked implicitly: public_storage is Some only
472 // when both S3_PUBLIC_BUCKET and the main storage are set).
473 if is_production && storage.is_some() && public_storage.is_none() {
474 return Err(ConfigError::MissingPublicBucket);
475 }
476 }
477
478 let user_pages_host = std::env::var("USER_PAGES_HOST")
479 .ok()
480 .filter(|h| !h.is_empty())
481 .unwrap_or_else(|| default_user_pages_host(&host_url));
482
483 // Postmark inbound email webhook token - optional, inbound endpoint returns 401 if unset
484 let postmark_inbound_webhook_token = std::env::var("POSTMARK_INBOUND_WEBHOOK_TOKEN").ok();
485
486 // Enforce inbound SPF/DKIM sender-auth by default; only an explicit
487 // `false` disables it (observe-only during rollout). Fail closed so a
488 // missing/typo'd value can't silently reopen the spoofing hole.
489 let postmark_enforce_sender_auth = std::env::var("POSTMARK_ENFORCE_SENDER_AUTH")
490 .ok()
491 .is_none_or(|v| !(v == "false" || v == "0"));
492
493 // Internal shared secret for MT communication. Bearer-token-equivalent, so
494 // enforce the same >=32-char floor as the signing secrets, a short value
495 // is offline-brute-forceable. Fail closed rather than boot on a weak secret.
496 let internal_shared_secret = match std::env::var("INTERNAL_SHARED_SECRET") {
497 Ok(secret) => {
498 if secret.len() < 32 {
499 return Err(ConfigError::WeakInternalSecret);
500 }
501 Some(secret)
502 }
503 Err(_) => None,
504 };
505
506 // CLI service token for SSH server → internal API authentication. Same floor.
507 let cli_service_token = match std::env::var("CLI_SERVICE_TOKEN") {
508 Ok(secret) => {
509 if secret.len() < 32 {
510 return Err(ConfigError::WeakCliServiceToken);
511 }
512 Some(secret)
513 }
514 Err(_) => None,
515 };
516
517 // Inbound infra-alert ingestion token (PoM/MT monitoring agents →
518 // `POST /api/internal/alerts`). Same 32-char floor as the other service
519 // secrets; deliberately separate from CLI_SERVICE_TOKEN.
520 let alerts_ingest_token = match std::env::var("ALERTS_INGEST_TOKEN") {
521 Ok(secret) => {
522 if secret.len() < 32 {
523 return Err(ConfigError::WeakAlertsIngestToken);
524 }
525 Some(secret)
526 }
527 Err(_) => None,
528 };
529
530 // WAM ticket manager URL (tailnet, e.g. "http://100.x.x.x:7890")
531 let wam_url = std::env::var("WAM_URL").ok();
532
533 // Site-wide access gate. Only "fan_plus_or_creator" enables it; any
534 // other value (or unset) leaves the site open. Staging-only knob.
535 let access_gate = match std::env::var("ACCESS_GATE").as_deref() {
536 Ok("fan_plus_or_creator") => AccessGate::FanPlusOrCreator,
537 _ => AccessGate::Open,
538 };
539
540 let sso = SsoConfig::from_env();
541
542 Ok(Config {
543 host,
544 port,
545 database_url,
546 host_url: Arc::from(host_url),
547 signing_secret,
548 storage,
549 synckit_storage,
550 public_storage,
551 stripe,
552 admin_user_id,
553 synckit_jwt_secret,
554 scan,
555 cdn_base_url,
556 user_pages_host: Arc::from(user_pages_host),
557 access_gate,
558 sso,
559 rate_limits: crate::constants::RateLimits::production(),
560 quasi_screens: QuasiScreens::parse(&std::env::var("QUASI_SCREENS").unwrap_or_default()),
561 build: BuildConfig {
562 trigger_token: build_trigger_token,
563 host_linux: build_host_linux,
564 host_darwin: build_host_darwin,
565 git_repos_path,
566 git_ssh_host,
567 },
568 email_webhooks: EmailWebhookConfig {
569 webhook_token: postmark_webhook_token,
570 broadcast_webhook_token: postmark_broadcast_webhook_token,
571 inbound_webhook_token: postmark_inbound_webhook_token,
572 enforce_sender_auth: postmark_enforce_sender_auth,
573 },
574 creator_pricing: CreatorTierPricing {
575 fan_plus_price_id,
576 tier_prices: creator_tier_prices,
577 tier_annual_prices: creator_tier_annual_prices,
578 tier_founder_prices: creator_tier_founder_prices,
579 tier_founder_annual_prices: creator_tier_founder_annual_prices,
580 founder_window_open: creator_founder_window_open,
581 },
582 integrations: IntegrationsConfig {
583 mt_base_url,
584 wam_url,
585 internal_shared_secret,
586 cli_service_token,
587 alerts_ingest_token,
588 },
589 })
590 }
591
592 /// Get the socket address for the server to bind to
593 pub fn socket_addr(&self) -> SocketAddr {
594 SocketAddr::new(self.host, self.port)
595 }
596
597 /// Build the URL policy that gates every reference in creator custom pages.
598 /// A page may reference the apex, the user-pages host, and the CDN, nothing
599 /// else. The base origin is the user-pages host (where pages render).
600 pub fn custom_pages_policy(&self) -> Option<crate::custom_pages::UrlPolicy> {
601 let mut hosts = vec![self.user_pages_host.to_string()];
602 if let Some(apex) = host_of(&self.host_url) {
603 hosts.push(apex);
604 }
605 if let Some(cdn) = host_of(&self.cdn_base_url) {
606 hosts.push(cdn);
607 }
608 let base = format!("https://{}/", self.user_pages_host);
609 crate::custom_pages::UrlPolicy::new(&base, hosts).ok()
610 }
611 }
612
613 /// Extract the bare host from an absolute URL (no scheme/port/path).
614 fn host_of(url: &str) -> Option<String> {
615 url::Url::parse(url)
616 .ok()
617 .and_then(|u| u.host_str().map(str::to_string))
618 }
619
620 /// Default user-pages host: `u.` prefixed onto the host_url's host.
621 fn default_user_pages_host(host_url: &str) -> String {
622 host_of(host_url).map_or_else(|| "u.localhost".to_string(), |h| format!("u.{h}"))
623 }
624
625 impl StorageConfig {
626 /// Load storage configuration from environment variables
627 /// Returns None if any required variable is missing (graceful degradation)
628 pub fn from_env() -> Option<Self> {
629 Self::from_env_prefixed("S3_")
630 }
631
632 /// Load storage configuration from prefixed environment variables.
633 /// e.g., prefix "SYNCKIT_S3_" reads SYNCKIT_S3_ENDPOINT, SYNCKIT_S3_BUCKET, etc.
634 pub fn from_env_prefixed(prefix: &str) -> Option<Self> {
635 let endpoint = std::env::var(format!("{prefix}ENDPOINT")).ok()?;
636 let bucket = std::env::var(format!("{prefix}BUCKET")).ok()?;
637 let access_key = std::env::var(format!("{prefix}ACCESS_KEY")).ok()?;
638 let secret_key = std::env::var(format!("{prefix}SECRET_KEY")).ok()?;
639 let region =
640 std::env::var(format!("{prefix}REGION")).unwrap_or_else(|_| "us-east-1".to_string());
641
642 Some(StorageConfig {
643 endpoint,
644 bucket,
645 access_key,
646 secret_key,
647 region,
648 })
649 }
650 }
651
652 /// File scanning configuration
653 #[derive(Clone)]
654 pub struct ScanConfig {
655 /// Unix socket path for ClamAV daemon (optional)
656 pub clamav_socket: Option<String>,
657 /// Directory containing YARA rule files
658 pub yara_rules_dir: String,
659 /// Whether to enable MalwareBazaar hash lookups
660 pub malwarebazaar_enabled: bool,
661 /// Whether to enable URLhaus URL-reputation lookups
662 pub urlhaus_enabled: bool,
663 /// Shared abuse.ch Auth-Key (issued at https://auth.abuse.ch/). Required
664 /// for MalwareBazaar and URLhaus as of 2024+; without it both layers
665 /// fail-open and the dashboard surfaces them as degraded.
666 pub abuse_ch_auth_key: Option<String>,
667 /// MetaDefender Cloud API key (free tier at
668 /// <https://metadefender.com/account>). Second-opinion layer; only
669 /// invoked when another layer flagged the file as suspicious.
670 pub metadefender_api_key: Option<String>,
671 /// Minimum number of YARA rule files that must compile for the corpus to be
672 /// considered healthy. `0` disables the check. Defaults to
673 /// [`DEFAULT_YARA_MIN_RULE_FILES`] (the size of the bundled corpus) so a
674 /// silent drop, a dependency/format change that makes rules uncompilable,
675 /// fails boot loudly rather than degrading coverage unnoticed. Set it
676 /// explicitly when pointing `YARA_RULES_DIR` at a larger external corpus.
677 pub yara_min_rule_files: usize,
678 /// The number of bytes ClamAV actually scans per object, the operator's
679 /// declared `min(MaxScanSize, MaxFileSize, StreamMaxLength)` from `clamd.conf`.
680 ///
681 /// clamd does NOT expose these limits over its socket (only `PING`/`VERSION`),
682 /// so the server cannot probe them; the operator must declare the coverage.
683 /// It gates whether ClamAV counts as a *full-file backstop* for the YARA
684 /// prefix cap ([`crate::constants::SCAN_YARA_MAX_BYTES`]): only a file whose
685 /// size is within this many bytes is treated as fully covered. `None` (the
686 /// default) means "coverage unknown" and is fail-closed, any file above the
687 /// YARA prefix is held for review rather than certified Clean on a
688 /// possibly-partial ClamAV scan (ultra-fuzz Run #24 Security MODERATE).
689 pub clamav_max_scan_bytes: Option<u64>,
690 }
691
692 /// Floor for [`ScanConfig::yara_min_rule_files`], matching the count of `.yar`
693 /// files bundled in `server/yara-rules/`. Kept in sync by
694 /// `scanning::yara::tests::shipped_corpus_is_healthy`, which fails if the
695 /// bundled corpus count drifts from this value. Bumping the corpus means
696 /// bumping this constant (and the test catches a forgotten bump).
697 pub const DEFAULT_YARA_MIN_RULE_FILES: usize = 6;
698
699 impl ScanConfig {
700 /// Load scan configuration from environment variables.
701 /// Returns Some if SCAN_ENABLED=true (default), None if explicitly disabled.
702 pub fn from_env() -> Option<Self> {
703 let enabled = std::env::var("SCAN_ENABLED").map_or(true, |v| v != "false" && v != "0");
704
705 if !enabled {
706 return None;
707 }
708
709 Some(ScanConfig {
710 clamav_socket: std::env::var("CLAMAV_SOCKET").ok(),
711 yara_rules_dir: std::env::var("YARA_RULES_DIR")
712 .unwrap_or_else(|_| "yara-rules/".to_string()),
713 malwarebazaar_enabled: std::env::var("MALWAREBAZAAR_ENABLED")
714 .map_or(true, |v| v != "false" && v != "0"),
715 urlhaus_enabled: std::env::var("URLHAUS_ENABLED")
716 .map_or(true, |v| v != "false" && v != "0"),
717 abuse_ch_auth_key: std::env::var("ABUSE_CH_AUTH_KEY").ok().filter(|s| !s.is_empty()),
718 metadefender_api_key: std::env::var("METADEFENDER_API_KEY").ok().filter(|s| !s.is_empty()),
719 yara_min_rule_files: match std::env::var("YARA_MIN_RULE_FILES") {
720 Ok(v) => v.parse().unwrap_or_else(|_| {
721 tracing::warn!(
722 value = %v,
723 "YARA_MIN_RULE_FILES is set but is not a valid number, using default {}",
724 DEFAULT_YARA_MIN_RULE_FILES
725 );
726 DEFAULT_YARA_MIN_RULE_FILES
727 }),
728 Err(_) => DEFAULT_YARA_MIN_RULE_FILES,
729 },
730 clamav_max_scan_bytes: match std::env::var("CLAMAV_MAX_SCAN_BYTES") {
731 Ok(v) => v.parse::<u64>().map_or_else(|_| {
732 tracing::warn!(
733 value = %v,
734 "CLAMAV_MAX_SCAN_BYTES is set but is not a valid number, treating ClamAV as no full-file backstop (large files held for review)"
735 );
736 None
737 }, Some),
738 Err(_) => None,
739 },
740 })
741 }
742 }
743
744 impl std::fmt::Debug for ScanConfig {
745 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
746 f.debug_struct("ScanConfig")
747 .field("clamav_socket", &self.clamav_socket)
748 .field("yara_rules_dir", &self.yara_rules_dir)
749 .field("malwarebazaar_enabled", &self.malwarebazaar_enabled)
750 .field("urlhaus_enabled", &self.urlhaus_enabled)
751 .field(
752 "abuse_ch_auth_key",
753 &self.abuse_ch_auth_key.as_ref().map(|_| "<set>"),
754 )
755 .field(
756 "metadefender_api_key",
757 &self.metadefender_api_key.as_ref().map(|_| "<set>"),
758 )
759 .field("clamav_max_scan_bytes", &self.clamav_max_scan_bytes)
760 .finish_non_exhaustive()
761 }
762 }
763
764 /// Stripe payment configuration
765 #[derive(Clone)]
766 pub struct StripeConfig {
767 /// Stripe secret API key (sk_test_... or sk_live_...)
768 pub secret_key: String,
769 /// Webhook signing secrets for v1 snapshot events (whsec_...).
770 ///
771 /// A list to accommodate multiple Stripe endpoints (e.g. `mnw-connect`
772 /// for Connected-account events + `mnw-you` for platform events, Stripe
773 /// requires one endpoint per scope, and each endpoint has its own secret).
774 /// `verify_signature` accepts a match against any secret in the list.
775 /// Configured via `STRIPE_WEBHOOK_SECRET` as a comma-separated list.
776 pub webhook_secret: Vec<String>,
777 /// Webhook signing secret for v2 thin events (whsec_...)
778 /// Optional, v2 endpoint returns 503 if not set.
779 pub webhook_secret_v2: Option<String>,
780 }
781
782 impl StripeConfig {
783 /// Load Stripe configuration from environment variables
784 /// Returns None if any required variable is missing (graceful degradation)
785 pub fn from_env() -> Option<Self> {
786 let secret_key = std::env::var("STRIPE_SECRET_KEY").ok()?;
787 let webhook_secret: Vec<String> = std::env::var("STRIPE_WEBHOOK_SECRET")
788 .ok()?
789 .split(',')
790 .map(|s| s.trim().to_string())
791 .filter(|s| !s.is_empty())
792 .collect();
793 if webhook_secret.is_empty() {
794 return None;
795 }
796 let webhook_secret_v2 = std::env::var("STRIPE_WEBHOOK_SECRET_V2").ok();
797
798 Some(StripeConfig {
799 secret_key,
800 webhook_secret,
801 webhook_secret_v2,
802 })
803 }
804 }
805
806 impl std::fmt::Debug for Config {
807 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
808 f.debug_struct("Config")
809 .field("host", &self.host)
810 .field("port", &self.port)
811 .field("database_url", &"[REDACTED]")
812 .field("host_url", &self.host_url)
813 .field("signing_secret", &"[REDACTED]")
814 .field("storage", &self.storage)
815 .field("synckit_storage", &self.synckit_storage)
816 .field("stripe", &self.stripe)
817 .field("admin_user_id", &self.admin_user_id)
818 .field(
819 "synckit_jwt_secret",
820 &self.synckit_jwt_secret.as_ref().map(|_| "[REDACTED]"),
821 )
822 .field("scan", &self.scan)
823 .field("git_repos_path", &self.build.git_repos_path)
824 .field(
825 "postmark_webhook_token",
826 &self
827 .email_webhooks
828 .webhook_token
829 .as_ref()
830 .map(|_| "[REDACTED]"),
831 )
832 .field(
833 "postmark_broadcast_webhook_token",
834 &self
835 .email_webhooks
836 .broadcast_webhook_token
837 .as_ref()
838 .map(|_| "[REDACTED]"),
839 )
840 .field("git_ssh_host", &self.build.git_ssh_host)
841 .field("mt_base_url", &self.integrations.mt_base_url)
842 .field("fan_plus_price_id", &self.creator_pricing.fan_plus_price_id)
843 .field(
844 "creator_tier_prices",
845 &format!(
846 "{} tiers configured",
847 self.creator_pricing.tier_prices.len()
848 ),
849 )
850 .field(
851 "creator_tier_annual_prices",
852 &format!(
853 "{} annual tiers configured",
854 self.creator_pricing.tier_annual_prices.len()
855 ),
856 )
857 .field(
858 "creator_tier_founder_prices",
859 &format!(
860 "{} founder tiers configured",
861 self.creator_pricing.tier_founder_prices.len()
862 ),
863 )
864 .field(
865 "creator_tier_founder_annual_prices",
866 &format!(
867 "{} founder annual tiers configured",
868 self.creator_pricing.tier_founder_annual_prices.len()
869 ),
870 )
871 .field(
872 "creator_founder_window_open",
873 &self.creator_pricing.founder_window_open,
874 )
875 .field(
876 "build_trigger_token",
877 &self.build.trigger_token.as_ref().map(|_| "[REDACTED]"),
878 )
879 .field("build_host_linux", &self.build.host_linux)
880 .field("build_host_darwin", &self.build.host_darwin)
881 .field("cdn_base_url", &self.cdn_base_url)
882 .field("user_pages_host", &self.user_pages_host)
883 .field(
884 "postmark_inbound_webhook_token",
885 &self
886 .email_webhooks
887 .inbound_webhook_token
888 .as_ref()
889 .map(|_| "[REDACTED]"),
890 )
891 .field(
892 "internal_shared_secret",
893 &self
894 .integrations
895 .internal_shared_secret
896 .as_ref()
897 .map(|_| "[REDACTED]"),
898 )
899 .field(
900 "cli_service_token",
901 &self
902 .integrations
903 .cli_service_token
904 .as_ref()
905 .map(|_| "[REDACTED]"),
906 )
907 .field(
908 "alerts_ingest_token",
909 &self
910 .integrations
911 .alerts_ingest_token
912 .as_ref()
913 .map(|_| "[REDACTED]"),
914 )
915 .field("wam_url", &self.integrations.wam_url)
916 .field("access_gate", &self.access_gate)
917 .field("sso", &self.sso.as_ref().map(|s| &s.provider_url))
918 .finish_non_exhaustive()
919 }
920 }
921
922 impl std::fmt::Debug for StorageConfig {
923 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
924 f.debug_struct("StorageConfig")
925 .field("endpoint", &self.endpoint)
926 .field("bucket", &self.bucket)
927 .field("access_key", &"[REDACTED]")
928 .field("secret_key", &"[REDACTED]")
929 .field("region", &self.region)
930 .finish()
931 }
932 }
933
934 impl std::fmt::Debug for StripeConfig {
935 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
936 f.debug_struct("StripeConfig")
937 .field("secret_key", &"[REDACTED]")
938 .field("webhook_secret", &"[REDACTED]")
939 .finish()
940 }
941 }
942
943 /// Configuration errors
944 #[derive(Debug, thiserror::Error)]
945 pub enum ConfigError {
946 #[error("Invalid HOST address")]
947 InvalidHost,
948 #[error("Invalid PORT number")]
949 InvalidPort,
950 #[error("DATABASE_URL environment variable is required")]
951 MissingDatabaseUrl,
952 #[error(
953 "SIGNING_SECRET is required in production (HOST=0.0.0.0 or HTTPS HOST_URL detected). Set SIGNING_SECRET to a stable random string."
954 )]
955 MissingSigningSecret,
956 #[error("SIGNING_SECRET must be at least 32 characters long")]
957 WeakSigningSecret,
958 #[error("SYNCKIT_JWT_SECRET must be at least 32 characters long")]
959 WeakSynckitJwtSecret,
960 #[error("INTERNAL_SHARED_SECRET must be at least 32 characters long")]
961 WeakInternalSecret,
962 #[error("CLI_SERVICE_TOKEN must be at least 32 characters long")]
963 WeakCliServiceToken,
964 #[error("ALERTS_INGEST_TOKEN must be at least 32 characters long")]
965 WeakAlertsIngestToken,
966 #[error(
967 "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."
968 )]
969 MissingCdnBaseUrl,
970 #[error(
971 "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."
972 )]
973 MissingPublicBucket,
974 }
975
976 #[cfg(test)]
977 mod tests {
978 use super::*;
979 use std::sync::Mutex;
980
981 /// Mutex to serialize tests that call Config::from_env(), since env vars are
982 /// process-global and concurrent mutation causes flaky failures.
983 static ENV_LOCK: Mutex<()> = Mutex::new(());
984
985 /// All env var keys that Config::from_env() reads. Used by the guard to
986 /// snapshot and restore state so tests don't leak into each other.
987 const CONFIG_ENV_VARS: &[&str] = &[
988 "HOST",
989 "PORT",
990 "DATABASE_URL",
991 "HOST_URL",
992 "SIGNING_SECRET",
993 "S3_ENDPOINT",
994 "S3_BUCKET",
995 "S3_ACCESS_KEY",
996 "S3_SECRET_KEY",
997 "S3_REGION",
998 "S3_PUBLIC_BUCKET",
999 "SYNCKIT_S3_ENDPOINT",
1000 "SYNCKIT_S3_BUCKET",
1001 "SYNCKIT_S3_ACCESS_KEY",
1002 "SYNCKIT_S3_SECRET_KEY",
1003 "SYNCKIT_S3_REGION",
1004 "STRIPE_SECRET_KEY",
1005 "STRIPE_WEBHOOK_SECRET",
1006 "STRIPE_WEBHOOK_SECRET_V2",
1007 "ADMIN_USER_ID",
1008 "SYNCKIT_JWT_SECRET",
1009 "SCAN_ENABLED",
1010 "CLAMAV_SOCKET",
1011 "YARA_RULES_DIR",
1012 "MALWAREBAZAAR_ENABLED",
1013 "URLHAUS_ENABLED",
1014 "ABUSE_CH_AUTH_KEY",
1015 "METADEFENDER_API_KEY",
1016 "GIT_REPOS_PATH",
1017 "POSTMARK_WEBHOOK_TOKEN",
1018 "POSTMARK_BROADCAST_WEBHOOK_TOKEN",
1019 "GIT_SSH_HOST",
1020 "MT_BASE_URL",
1021 "FAN_PLUS_STRIPE_PRICE_ID",
1022 "CREATOR_TIER_BASIC_PRICE_ID",
1023 "CREATOR_TIER_SMALL_FILES_PRICE_ID",
1024 "CREATOR_TIER_BIG_FILES_PRICE_ID",
1025 "CREATOR_TIER_EVERYTHING_PRICE_ID",
1026 "CREATOR_TIER_BASIC_ANNUAL_PRICE_ID",
1027 "CREATOR_TIER_SMALL_FILES_ANNUAL_PRICE_ID",
1028 "CREATOR_TIER_BIG_FILES_ANNUAL_PRICE_ID",
1029 "CREATOR_TIER_EVERYTHING_ANNUAL_PRICE_ID",
1030 "CREATOR_TIER_BASIC_FOUNDER_PRICE_ID",
1031 "CREATOR_TIER_SMALL_FILES_FOUNDER_PRICE_ID",
1032 "CREATOR_TIER_BIG_FILES_FOUNDER_PRICE_ID",
1033 "CREATOR_TIER_EVERYTHING_FOUNDER_PRICE_ID",
1034 "CREATOR_TIER_BASIC_FOUNDER_ANNUAL_PRICE_ID",
1035 "CREATOR_TIER_SMALL_FILES_FOUNDER_ANNUAL_PRICE_ID",
1036 "CREATOR_TIER_BIG_FILES_FOUNDER_ANNUAL_PRICE_ID",
1037 "CREATOR_TIER_EVERYTHING_FOUNDER_ANNUAL_PRICE_ID",
1038 "CREATOR_FOUNDER_WINDOW_OPEN",
1039 "BUILD_TRIGGER_TOKEN",
1040 "BUILD_HOST_LINUX",
1041 "BUILD_HOST_DARWIN",
1042 "CDN_BASE_URL",
1043 "POSTMARK_INBOUND_WEBHOOK_TOKEN",
1044 "INTERNAL_SHARED_SECRET",
1045 "CLI_SERVICE_TOKEN",
1046 "WAM_URL",
1047 "WAM_TOKEN",
1048 "ACCESS_GATE",
1049 "SSO_PROVIDER_URL",
1050 "SSO_CLIENT_ID",
1051 "SSO_KEY",
1052 ];
1053
1054 /// RAII guard that snapshots config-related env vars on creation and restores
1055 /// them when dropped. Also holds the ENV_LOCK so tests run serially.
1056 struct EnvGuard {
1057 _lock: std::sync::MutexGuard<'static, ()>,
1058 snapshot: Vec<(&'static str, Option<String>)>,
1059 }
1060
1061 impl EnvGuard {
1062 fn new() -> Self {
1063 let lock = ENV_LOCK
1064 .lock()
1065 .unwrap_or_else(std::sync::PoisonError::into_inner);
1066 let snapshot = CONFIG_ENV_VARS
1067 .iter()
1068 .map(|&key| (key, std::env::var(key).ok()))
1069 .collect();
1070 Self {
1071 _lock: lock,
1072 snapshot,
1073 }
1074 }
1075
1076 /// Remove all config env vars so from_env() sees a clean slate.
1077 fn clear_all() {
1078 for &key in CONFIG_ENV_VARS {
1079 // SAFETY: test-only, serialized by mutex
1080 unsafe {
1081 std::env::remove_var(key);
1082 }
1083 }
1084 }
1085 }
1086
1087 impl Drop for EnvGuard {
1088 fn drop(&mut self) {
1089 for (key, val) in &self.snapshot {
1090 match val {
1091 // SAFETY: test-only, serialized by mutex
1092 Some(v) => unsafe { std::env::set_var(key, v) },
1093 None => unsafe { std::env::remove_var(key) },
1094 }
1095 }
1096 }
1097 }
1098
1099 // ---- existing tests (unchanged) ----
1100
1101 #[test]
1102 fn socket_addr_combines_host_and_port() {
1103 let config = Config {
1104 host: "127.0.0.1".parse().unwrap(),
1105 port: 8080,
1106 database_url: "postgres://test".to_string(),
1107 host_url: Arc::from("http://localhost:8080"),
1108 signing_secret: "secret".to_string(),
1109 storage: None,
1110 synckit_storage: None,
1111 public_storage: None,
1112 stripe: None,
1113 admin_user_id: None,
1114 synckit_jwt_secret: None,
1115 scan: None,
1116 cdn_base_url: "https://cdn.localhost".to_string(),
1117 user_pages_host: Arc::from("u.localhost"),
1118 access_gate: AccessGate::Open,
1119 sso: None,
1120 rate_limits: crate::constants::RateLimits::production(),
1121 quasi_screens: QuasiScreens::default(),
1122 build: BuildConfig {
1123 trigger_token: None,
1124 host_linux: None,
1125 host_darwin: None,
1126 git_repos_path: None,
1127 git_ssh_host: None,
1128 },
1129 email_webhooks: EmailWebhookConfig {
1130 webhook_token: None,
1131 broadcast_webhook_token: None,
1132 inbound_webhook_token: None,
1133 enforce_sender_auth: true,
1134 },
1135 creator_pricing: CreatorTierPricing {
1136 fan_plus_price_id: None,
1137 tier_prices: HashMap::new(),
1138 tier_annual_prices: HashMap::new(),
1139 tier_founder_prices: HashMap::new(),
1140 tier_founder_annual_prices: HashMap::new(),
1141 founder_window_open: false,
1142 },
1143 integrations: IntegrationsConfig {
1144 mt_base_url: None,
1145 wam_url: None,
1146 internal_shared_secret: None,
1147 cli_service_token: None,
1148 alerts_ingest_token: None,
1149 },
1150 };
1151 let addr = config.socket_addr();
1152 assert_eq!(addr.port(), 8080);
1153 assert_eq!(addr.ip().to_string(), "127.0.0.1");
1154 }
1155
1156 #[test]
1157 fn config_error_display() {
1158 assert_eq!(ConfigError::InvalidHost.to_string(), "Invalid HOST address");
1159 assert_eq!(ConfigError::InvalidPort.to_string(), "Invalid PORT number");
1160 assert!(
1161 ConfigError::MissingDatabaseUrl
1162 .to_string()
1163 .contains("DATABASE_URL")
1164 );
1165 }
1166
1167 // ---- from_env validation tests ----
1168
1169 #[test]
1170 fn from_env_succeeds_with_required_vars() {
1171 let guard = EnvGuard::new();
1172 EnvGuard::clear_all();
1173
1174 // SAFETY: test-only, serialized by EnvGuard mutex
1175 unsafe {
1176 std::env::set_var("DATABASE_URL", "postgres://localhost/test_db");
1177 std::env::set_var("CDN_BASE_URL", "https://cdn.test");
1178 }
1179
1180 let config = Config::from_env().expect("should succeed with DATABASE_URL set");
1181 assert_eq!(config.database_url, "postgres://localhost/test_db");
1182 // Defaults: host=127.0.0.1, port=3000
1183 assert_eq!(config.host.to_string(), "127.0.0.1");
1184 assert_eq!(config.port, 3000);
1185 // Signing secret should be a random 64-char hex string in dev mode
1186 assert!(!config.signing_secret.is_empty());
1187 drop(guard);
1188 }
1189
1190 #[test]
1191 fn from_env_fails_without_database_url() {
1192 let guard = EnvGuard::new();
1193 EnvGuard::clear_all();
1194
1195 let err = Config::from_env().unwrap_err();
1196 assert!(
1197 matches!(err, ConfigError::MissingDatabaseUrl),
1198 "expected MissingDatabaseUrl, got: {err}"
1199 );
1200 drop(guard);
1201 }
1202
1203 #[test]
1204 fn from_env_fails_in_production_without_signing_secret() {
1205 let guard = EnvGuard::new();
1206 EnvGuard::clear_all();
1207
1208 // SAFETY: test-only, serialized by EnvGuard mutex
1209 unsafe {
1210 std::env::set_var("DATABASE_URL", "postgres://localhost/test_db");
1211 std::env::set_var("CDN_BASE_URL", "https://cdn.test");
1212 std::env::set_var("HOST", "0.0.0.0"); // production indicator
1213 }
1214
1215 let err = Config::from_env().unwrap_err();
1216 assert!(
1217 matches!(err, ConfigError::MissingSigningSecret),
1218 "expected MissingSigningSecret, got: {err}"
1219 );
1220 drop(guard);
1221 }
1222
1223 #[test]
1224 fn from_env_fails_without_cdn_base_url_even_outside_production() {
1225 let guard = EnvGuard::new();
1226 EnvGuard::clear_all();
1227
1228 // SAFETY: test-only, serialized by EnvGuard mutex
1229 unsafe {
1230 std::env::set_var("DATABASE_URL", "postgres://localhost/test_db");
1231 std::env::set_var("SIGNING_SECRET", "x".repeat(32)); // pass the pre-CDN gate
1232 // No production indicator: HOST stays unset, so this is a dev config.
1233 // It must STILL fail. The requirement is unconditional precisely so
1234 // no environment can reach the old presigned fallback, which minted
1235 // a 24-hour URL into the durable `projects.cover_image_url` column.
1236 // CDN_BASE_URL deliberately unset.
1237 }
1238
1239 let err = Config::from_env().unwrap_err();
1240 assert!(
1241 matches!(err, ConfigError::MissingCdnBaseUrl),
1242 "expected MissingCdnBaseUrl, got: {err}"
1243 );
1244 drop(guard);
1245 }
1246
1247 #[test]
1248 fn from_env_accepts_production_with_cdn_base_url() {
1249 let guard = EnvGuard::new();
1250 EnvGuard::clear_all();
1251
1252 // SAFETY: test-only, serialized by EnvGuard mutex
1253 unsafe {
1254 std::env::set_var("DATABASE_URL", "postgres://localhost/test_db");
1255 std::env::set_var("CDN_BASE_URL", "https://cdn.test");
1256 std::env::set_var("SIGNING_SECRET", "x".repeat(32));
1257 std::env::set_var("HOST", "0.0.0.0");
1258 std::env::set_var("CDN_BASE_URL", "https://cdn.makenot.work");
1259 }
1260
1261 let config = Config::from_env().expect("production config with CDN should succeed");
1262 assert_eq!(config.cdn_base_url, "https://cdn.makenot.work");
1263 drop(guard);
1264 }
1265
1266 #[test]
1267 fn from_env_fails_with_https_host_url_without_signing_secret() {
1268 let guard = EnvGuard::new();
1269 EnvGuard::clear_all();
1270
1271 // SAFETY: test-only, serialized by EnvGuard mutex
1272 unsafe {
1273 std::env::set_var("DATABASE_URL", "postgres://localhost/test_db");
1274 std::env::set_var("CDN_BASE_URL", "https://cdn.test");
1275 std::env::set_var("HOST_URL", "https://makenot.work"); // production indicator
1276 }
1277
1278 let err = Config::from_env().unwrap_err();
1279 assert!(
1280 matches!(err, ConfigError::MissingSigningSecret),
1281 "expected MissingSigningSecret, got: {err}"
1282 );
1283 drop(guard);
1284 }
1285
1286 #[test]
1287 fn from_env_fails_with_short_synckit_jwt_secret() {
1288 let guard = EnvGuard::new();
1289 EnvGuard::clear_all();
1290
1291 // SAFETY: test-only, serialized by EnvGuard mutex
1292 unsafe {
1293 std::env::set_var("DATABASE_URL", "postgres://localhost/test_db");
1294 std::env::set_var("CDN_BASE_URL", "https://cdn.test");
1295 std::env::set_var("SIGNING_SECRET", "x".repeat(32));
1296 // 31 chars, one under the floor.
1297 std::env::set_var("SYNCKIT_JWT_SECRET", "x".repeat(31));
1298 }
1299
1300 let err = Config::from_env().unwrap_err();
1301 assert!(
1302 matches!(err, ConfigError::WeakSynckitJwtSecret),
1303 "expected WeakSynckitJwtSecret, got: {err}"
1304 );
1305 drop(guard);
1306 }
1307
1308 #[test]
1309 fn from_env_accepts_strong_synckit_jwt_secret() {
1310 let guard = EnvGuard::new();
1311 EnvGuard::clear_all();
1312
1313 // SAFETY: test-only, serialized by EnvGuard mutex
1314 unsafe {
1315 std::env::set_var("DATABASE_URL", "postgres://localhost/test_db");
1316 std::env::set_var("CDN_BASE_URL", "https://cdn.test");
1317 std::env::set_var("SIGNING_SECRET", "x".repeat(32));
1318 std::env::set_var("SYNCKIT_JWT_SECRET", "y".repeat(32));
1319 }
1320
1321 let config = Config::from_env().expect("32-char JWT secret should be accepted");
1322 assert_eq!(
1323 config.synckit_jwt_secret.as_deref(),
1324 Some("y".repeat(32).as_str())
1325 );
1326 drop(guard);
1327 }
1328
1329 #[test]
1330 fn from_env_uses_random_dev_secret_when_not_production() {
1331 let guard = EnvGuard::new();
1332 EnvGuard::clear_all();
1333
1334 // SAFETY: test-only, serialized by EnvGuard mutex
1335 unsafe {
1336 std::env::set_var("DATABASE_URL", "postgres://localhost/test_db");
1337 std::env::set_var("CDN_BASE_URL", "https://cdn.test");
1338 // HOST defaults to 127.0.0.1, HOST_URL defaults to http://..., no SIGNING_SECRET
1339 }
1340
1341 let config = Config::from_env().expect("should succeed in dev mode without SIGNING_SECRET");
1342 // Should be a 64-char hex string (256-bit random)
1343 assert_eq!(
1344 config.signing_secret.len(),
1345 64,
1346 "expected 64-char hex signing secret, got length {}",
1347 config.signing_secret.len()
1348 );
1349 assert!(
1350 config.signing_secret.chars().all(|c| c.is_ascii_hexdigit()),
1351 "expected hex signing secret, got: {}",
1352 config.signing_secret
1353 );
1354 drop(guard);
1355 }
1356
1357 #[test]
1358 fn from_env_storage_none_when_partially_set() {
1359 let guard = EnvGuard::new();
1360 EnvGuard::clear_all();
1361
1362 // SAFETY: test-only, serialized by EnvGuard mutex
1363 unsafe {
1364 std::env::set_var("DATABASE_URL", "postgres://localhost/test_db");
1365 std::env::set_var("CDN_BASE_URL", "https://cdn.test");
1366 // Set only some S3 vars, missing S3_SECRET_KEY and S3_ACCESS_KEY
1367 std::env::set_var("S3_ENDPOINT", "https://fsn1.your-objectstorage.com");
1368 std::env::set_var("S3_BUCKET", "test-bucket");
1369 }
1370
1371 let config = Config::from_env().expect("should succeed");
1372 assert!(
1373 config.storage.is_none(),
1374 "storage should be None when S3 vars are only partially set"
1375 );
1376 drop(guard);
1377 }
1378
1379 #[test]
1380 fn from_env_storage_some_when_fully_set() {
1381 let guard = EnvGuard::new();
1382 EnvGuard::clear_all();
1383
1384 // SAFETY: test-only, serialized by EnvGuard mutex
1385 unsafe {
1386 std::env::set_var("DATABASE_URL", "postgres://localhost/test_db");
1387 std::env::set_var("CDN_BASE_URL", "https://cdn.test");
1388 std::env::set_var("S3_ENDPOINT", "https://fsn1.your-objectstorage.com");
1389 std::env::set_var("S3_BUCKET", "test-bucket");
1390 std::env::set_var("S3_ACCESS_KEY", "ak");
1391 std::env::set_var("S3_SECRET_KEY", "sk");
1392 }
1393
1394 let config = Config::from_env().expect("should succeed");
1395 let storage = config
1396 .storage
1397 .expect("storage should be Some when all S3 vars set");
1398 assert_eq!(storage.endpoint, "https://fsn1.your-objectstorage.com");
1399 assert_eq!(storage.bucket, "test-bucket");
1400 assert_eq!(storage.region, "us-east-1"); // default region
1401 drop(guard);
1402 }
1403
1404 #[test]
1405 fn from_env_stripe_none_when_secret_key_missing() {
1406 let guard = EnvGuard::new();
1407 EnvGuard::clear_all();
1408
1409 // SAFETY: test-only, serialized by EnvGuard mutex
1410 unsafe {
1411 std::env::set_var("DATABASE_URL", "postgres://localhost/test_db");
1412 std::env::set_var("CDN_BASE_URL", "https://cdn.test");
1413 // Set webhook secret but not secret key
1414 std::env::set_var("STRIPE_WEBHOOK_SECRET", "whsec_test");
1415 }
1416
1417 let config = Config::from_env().expect("should succeed");
1418 assert!(
1419 config.stripe.is_none(),
1420 "stripe should be None when STRIPE_SECRET_KEY is missing"
1421 );
1422 drop(guard);
1423 }
1424
1425 #[test]
1426 fn from_env_stripe_none_when_webhook_secret_missing() {
1427 let guard = EnvGuard::new();
1428 EnvGuard::clear_all();
1429
1430 // SAFETY: test-only, serialized by EnvGuard mutex
1431 unsafe {
1432 std::env::set_var("DATABASE_URL", "postgres://localhost/test_db");
1433 std::env::set_var("CDN_BASE_URL", "https://cdn.test");
1434 // Set secret key but not webhook secret
1435 std::env::set_var("STRIPE_SECRET_KEY", "sk_test_abc");
1436 }
1437
1438 let config = Config::from_env().expect("should succeed");
1439 assert!(
1440 config.stripe.is_none(),
1441 "stripe should be None when STRIPE_WEBHOOK_SECRET is missing"
1442 );
1443 drop(guard);
1444 }
1445
1446 #[test]
1447 fn from_env_stripe_some_when_fully_set() {
1448 let guard = EnvGuard::new();
1449 EnvGuard::clear_all();
1450
1451 // SAFETY: test-only, serialized by EnvGuard mutex
1452 unsafe {
1453 std::env::set_var("DATABASE_URL", "postgres://localhost/test_db");
1454 std::env::set_var("CDN_BASE_URL", "https://cdn.test");
1455 std::env::set_var("STRIPE_SECRET_KEY", "sk_test_abc");
1456 std::env::set_var("STRIPE_WEBHOOK_SECRET", "whsec_test");
1457 }
1458
1459 let config = Config::from_env().expect("should succeed");
1460 let stripe = config
1461 .stripe
1462 .expect("stripe should be Some when fully configured");
1463 assert_eq!(stripe.secret_key, "sk_test_abc");
1464 assert_eq!(stripe.webhook_secret, vec!["whsec_test".to_string()]);
1465 assert!(stripe.webhook_secret_v2.is_none());
1466 drop(guard);
1467 }
1468
1469 #[test]
1470 fn from_env_invalid_host_rejected() {
1471 let guard = EnvGuard::new();
1472 EnvGuard::clear_all();
1473
1474 // SAFETY: test-only, serialized by EnvGuard mutex
1475 unsafe {
1476 std::env::set_var("DATABASE_URL", "postgres://localhost/test_db");
1477 std::env::set_var("CDN_BASE_URL", "https://cdn.test");
1478 std::env::set_var("HOST", "not-an-ip");
1479 }
1480
1481 let err = Config::from_env().unwrap_err();
1482 assert!(
1483 matches!(err, ConfigError::InvalidHost),
1484 "expected InvalidHost, got: {err}"
1485 );
1486 drop(guard);
1487 }
1488
1489 #[test]
1490 fn from_env_invalid_port_rejected() {
1491 let guard = EnvGuard::new();
1492 EnvGuard::clear_all();
1493
1494 // SAFETY: test-only, serialized by EnvGuard mutex
1495 unsafe {
1496 std::env::set_var("DATABASE_URL", "postgres://localhost/test_db");
1497 std::env::set_var("CDN_BASE_URL", "https://cdn.test");
1498 std::env::set_var("PORT", "not-a-number");
1499 }
1500
1501 let err = Config::from_env().unwrap_err();
1502 assert!(
1503 matches!(err, ConfigError::InvalidPort),
1504 "expected InvalidPort, got: {err}"
1505 );
1506 drop(guard);
1507 }
1508
1509 #[test]
1510 fn from_env_scan_disabled_when_explicitly_off() {
1511 let guard = EnvGuard::new();
1512 EnvGuard::clear_all();
1513
1514 // SAFETY: test-only, serialized by EnvGuard mutex
1515 unsafe {
1516 std::env::set_var("DATABASE_URL", "postgres://localhost/test_db");
1517 std::env::set_var("CDN_BASE_URL", "https://cdn.test");
1518 std::env::set_var("SCAN_ENABLED", "false");
1519 }
1520
1521 let config = Config::from_env().expect("should succeed");
1522 assert!(
1523 config.scan.is_none(),
1524 "scan should be None when SCAN_ENABLED=false"
1525 );
1526 drop(guard);
1527 }
1528
1529 #[test]
1530 fn from_env_scan_enabled_by_default() {
1531 let guard = EnvGuard::new();
1532 EnvGuard::clear_all();
1533
1534 // SAFETY: test-only, serialized by EnvGuard mutex
1535 unsafe {
1536 std::env::set_var("DATABASE_URL", "postgres://localhost/test_db");
1537 std::env::set_var("CDN_BASE_URL", "https://cdn.test");
1538 }
1539
1540 let config = Config::from_env().expect("should succeed");
1541 assert!(
1542 config.scan.is_some(),
1543 "scan should be Some by default (enabled unless explicitly disabled)"
1544 );
1545 drop(guard);
1546 }
1547 }
1548