Skip to main content

max / makenotwork

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