Skip to main content

max / makenotwork

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