Skip to main content

max / makenotwork

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