Skip to main content

max / makenotwork

Group Config and AppState into cohesive sub-structs Config: regroup ~45 flat fields into 16 top-level fields plus four nested sub-configs — BuildConfig (build hosts/trigger/git), EmailWebhookConfig (Postmark tokens + sender-auth), CreatorTierPricing (tier price maps + founder window + Fan+), and IntegrationsConfig (MT/WAM URLs, internal secret, CLI token). from_env and the redacting Debug impl updated to match. AppState: bundle the S3 storage backends, in-memory caches, and concurrency limiters into AppStorage, AppCaches, and AppLimiters, collapsing ten top-level fields to three. All access sites, Config/AppState literals, and the test harness/load runner updated throughout. Purely structural; no behavior change. Also collapse a pre-existing collapsible-if nit in build.rs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-13 01:34 UTC
Commit: 1d912dc40e3b0dbdee28910099bdf9e99423c0c4
Parent: 4b82a6f
47 files changed, +502 insertions, -392 deletions
M server/build.rs +4 -4
@@ -154,10 +154,10 @@ fn hash_dir_js(dir: &Path, hasher: &mut DefaultHasher) {
154 154 for path in paths {
155 155 if path.is_dir() {
156 156 hash_dir_js(&path, hasher);
157 - } else if path.extension().and_then(|e| e.to_str()) == Some("js") {
158 - if let Ok(content) = fs::read(&path) {
159 - content.hash(hasher);
160 - }
157 + } else if path.extension().and_then(|e| e.to_str()) == Some("js")
158 + && let Ok(content) = fs::read(&path)
159 + {
160 + content.hash(hasher);
161 161 }
162 162 }
163 163 }
M server/src/auth.rs +63 -46
@@ -178,7 +178,7 @@ impl FromRequestParts<crate::AppState> for AuthUser {
178 178 // the DB on every request — if this session was validated within
179 179 // SESSION_TOUCH_CACHE_SECS, skip the query.
180 180 let cache_ttl = std::time::Duration::from_secs(constants::SESSION_TOUCH_CACHE_SECS);
181 - let cached = state.session_cache.get(&tracking_id)
181 + let cached = state.caches.session_cache.get(&tracking_id)
182 182 .map(|entry| entry.elapsed() < cache_ttl)
183 183 .unwrap_or(false);
184 184
@@ -191,7 +191,7 @@ impl FromRequestParts<crate::AppState> for AuthUser {
191 191 }
192 192 };
193 193 if !result.valid {
194 - state.session_cache.remove(&tracking_id);
194 + state.caches.session_cache.remove(&tracking_id);
195 195 let _ = session.flush().await;
196 196 return Err(AppError::Unauthorized);
197 197 }
@@ -208,7 +208,7 @@ impl FromRequestParts<crate::AppState> for AuthUser {
208 208 tracing::warn!(user_id = %user.id, error = ?e, "failed to update session with refreshed user state");
209 209 }
210 210 }
211 - state.session_cache.insert(tracking_id, Instant::now());
211 + state.caches.session_cache.insert(tracking_id, Instant::now());
212 212 }
213 213
214 214 // Record user_id in the current span so all downstream logs
@@ -324,7 +324,7 @@ impl FromRequestParts<crate::AppState> for MaybeUserVerified {
324 324 };
325 325
326 326 let cache_ttl = std::time::Duration::from_secs(constants::SESSION_TOUCH_CACHE_SECS);
327 - let cached = state.session_cache.get(&tracking_id)
327 + let cached = state.caches.session_cache.get(&tracking_id)
328 328 .map(|entry| entry.elapsed() < cache_ttl)
329 329 .unwrap_or(false);
330 330
@@ -337,7 +337,7 @@ impl FromRequestParts<crate::AppState> for MaybeUserVerified {
337 337 }
338 338 };
339 339 if !result.valid {
340 - state.session_cache.remove(&tracking_id);
340 + state.caches.session_cache.remove(&tracking_id);
341 341 let _ = session.flush().await;
342 342 return Ok(MaybeUserVerified(None));
343 343 }
@@ -351,7 +351,7 @@ impl FromRequestParts<crate::AppState> for MaybeUserVerified {
351 351 tracing::warn!(user_id = %user.id, error = ?e, "failed to update session with refreshed user state");
352 352 }
353 353 }
354 - state.session_cache.insert(tracking_id, Instant::now());
354 + state.caches.session_cache.insert(tracking_id, Instant::now());
355 355 }
356 356
357 357 tracing::Span::current().record("user_id", tracing::field::display(&user.id));
@@ -425,7 +425,7 @@ impl FromRequestParts<crate::AppState> for AdminUser {
425 425
426 426 /// Extractor for internal service-to-service auth (CLI SSH server → MNW API).
427 427 ///
428 - /// Validates `Authorization: Bearer {token}` against `config.cli_service_token`.
428 + /// Validates `Authorization: Bearer {token}` against `config.integrations.cli_service_token`.
429 429 /// Returns 401 if the token is missing/invalid, 503 if the token is not configured.
430 430 pub struct ServiceAuth;
431 431
@@ -436,7 +436,7 @@ impl FromRequestParts<crate::AppState> for ServiceAuth {
436 436 parts: &mut Parts,
437 437 state: &crate::AppState,
438 438 ) -> Result<Self, Self::Rejection> {
439 - let expected = state.config.cli_service_token.as_deref().ok_or_else(|| {
439 + let expected = state.config.integrations.cli_service_token.as_deref().ok_or_else(|| {
440 440 AppError::ServiceUnavailable("Internal API not configured".to_string())
441 441 })?;
442 442
@@ -832,6 +832,7 @@ pub fn require_admin(user: &SessionUser, config: &Config) -> Result<(), AppError
832 832 #[cfg(test)]
833 833 mod tests {
834 834 use super::*;
835 + use crate::config::{BuildConfig, CreatorTierPricing, EmailWebhookConfig, IntegrationsConfig};
835 836
836 837 #[test]
837 838 fn hash_password_produces_valid_hash() {
@@ -897,29 +898,37 @@ mod tests {
897 898 admin_user_id: Some(user.id),
898 899 synckit_jwt_secret: None,
899 900 scan: None,
900 - git_repos_path: None,
901 - postmark_webhook_token: None,
902 - postmark_broadcast_webhook_token: None,
903 - git_ssh_host: None,
904 - mt_base_url: None,
905 - fan_plus_price_id: None,
906 - creator_tier_prices: std::collections::HashMap::new(),
907 - creator_tier_annual_prices: std::collections::HashMap::new(),
908 - creator_tier_founder_prices: std::collections::HashMap::new(),
909 - creator_tier_founder_annual_prices: std::collections::HashMap::new(),
910 - creator_founder_window_open: false,
911 - build_trigger_token: None,
912 - build_host_linux: None,
913 - build_host_darwin: None,
914 901 cdn_base_url: None,
915 902 user_pages_host: std::sync::Arc::from("u.localhost"),
916 - postmark_inbound_webhook_token: None,
917 - postmark_enforce_sender_auth: true,
918 - internal_shared_secret: None,
919 - cli_service_token: None,
920 - wam_url: None,
921 903 access_gate: crate::config::AccessGate::Open,
922 904 sso: None,
905 + build: BuildConfig {
906 + trigger_token: None,
907 + host_linux: None,
908 + host_darwin: None,
909 + git_repos_path: None,
910 + git_ssh_host: None,
911 + },
912 + email_webhooks: EmailWebhookConfig {
913 + webhook_token: None,
914 + broadcast_webhook_token: None,
915 + inbound_webhook_token: None,
916 + enforce_sender_auth: true,
917 + },
918 + creator_pricing: CreatorTierPricing {
919 + fan_plus_price_id: None,
920 + tier_prices: std::collections::HashMap::new(),
921 + tier_annual_prices: std::collections::HashMap::new(),
922 + tier_founder_prices: std::collections::HashMap::new(),
923 + tier_founder_annual_prices: std::collections::HashMap::new(),
924 + founder_window_open: false,
925 + },
926 + integrations: IntegrationsConfig {
927 + mt_base_url: None,
928 + wam_url: None,
929 + internal_shared_secret: None,
930 + cli_service_token: None,
931 + },
923 932 };
924 933 assert!(require_admin(&user, &config).is_ok());
925 934 }
@@ -969,29 +978,37 @@ mod tests {
969 978 admin_user_id: None,
970 979 synckit_jwt_secret: None,
971 980 scan: None,
972 - git_repos_path: None,
973 - postmark_webhook_token: None,
974 - postmark_broadcast_webhook_token: None,
975 - git_ssh_host: None,
976 - mt_base_url: None,
977 - fan_plus_price_id: None,
978 - creator_tier_prices: std::collections::HashMap::new(),
979 - creator_tier_annual_prices: std::collections::HashMap::new(),
980 - creator_tier_founder_prices: std::collections::HashMap::new(),
981 - creator_tier_founder_annual_prices: std::collections::HashMap::new(),
982 - creator_founder_window_open: false,
983 - build_trigger_token: None,
984 - build_host_linux: None,
985 - build_host_darwin: None,
986 981 cdn_base_url: None,
987 982 user_pages_host: std::sync::Arc::from("u.localhost"),
988 - postmark_inbound_webhook_token: None,
989 - postmark_enforce_sender_auth: true,
990 - internal_shared_secret: None,
991 - cli_service_token: None,
992 - wam_url: None,
993 983 access_gate: crate::config::AccessGate::Open,
994 984 sso: None,
985 + build: BuildConfig {
986 + trigger_token: None,
987 + host_linux: None,
988 + host_darwin: None,
989 + git_repos_path: None,
990 + git_ssh_host: None,
991 + },
992 + email_webhooks: EmailWebhookConfig {
993 + webhook_token: None,
994 + broadcast_webhook_token: None,
995 + inbound_webhook_token: None,
996 + enforce_sender_auth: true,
997 + },
998 + creator_pricing: CreatorTierPricing {
999 + fan_plus_price_id: None,
1000 + tier_prices: std::collections::HashMap::new(),
1001 + tier_annual_prices: std::collections::HashMap::new(),
1002 + tier_founder_prices: std::collections::HashMap::new(),
1003 + tier_founder_annual_prices: std::collections::HashMap::new(),
1004 + founder_window_open: false,
1005 + },
1006 + integrations: IntegrationsConfig {
1007 + mt_base_url: None,
1008 + wam_url: None,
1009 + internal_shared_secret: None,
1010 + cli_service_token: None,
1011 + },
995 1012 };
996 1013 assert!(require_admin(&user, &config).is_err());
997 1014 }
@@ -107,8 +107,8 @@ pub fn rust_target(os: &str, arch: &str) -> Option<&'static str> {
107 107 /// Get the SSH build host for a target OS from config.
108 108 fn build_host_for_target<'a>(config: &'a crate::config::Config, os: &str) -> Option<&'a str> {
109 109 match os {
110 - "linux" => config.build_host_linux.as_deref(),
111 - "darwin" => config.build_host_darwin.as_deref(),
110 + "linux" => config.build.host_linux.as_deref(),
111 + "darwin" => config.build.host_darwin.as_deref(),
112 112 _ => None,
113 113 }
114 114 }
@@ -357,7 +357,7 @@ async fn run_build(state: &AppState, build: &DbBuild, config: &DbBuildConfig) {
357 357 // channel.
358 358 for (target_os, arch, s3_key, _signature) in &artifact_keys {
359 359 // Get file size from S3 via HEAD request (best-effort, use 0 if unavailable)
360 - let file_size = if let Some(s3) = state.synckit_s3.as_ref() {
360 + let file_size = if let Some(s3) = state.storage.synckit_s3.as_ref() {
361 361 s3.object_size(s3_key).await.ok().flatten().unwrap_or(0)
362 362 } else {
363 363 0
@@ -430,7 +430,7 @@ async fn execute_target(
430 430
431 431 let git_root = state
432 432 .config
433 - .git_repos_path
433 + .build.git_repos_path
434 434 .as_deref()
435 435 .ok_or("git_repos_path not configured")?;
436 436
@@ -546,7 +546,7 @@ async fn execute_target(
546 546 // parallel part uploads, keeping memory bounded regardless of artifact
547 547 // size.
548 548 let synckit_s3 = state
549 - .synckit_s3
549 + .storage.synckit_s3
550 550 .as_ref()
551 551 .ok_or("SyncKit storage not configured")?;
552 552
M server/src/config.rs +151 -118
@@ -36,50 +36,6 @@ pub struct Config {
36 36 pub synckit_jwt_secret: Option<String>,
37 37 /// File scanning configuration (optional)
38 38 pub scan: Option<ScanConfig>,
39 - /// Path to bare git repositories on disk (optional)
40 - pub git_repos_path: Option<String>,
41 - /// Bearer token for authenticating Postmark webhook requests (optional)
42 - pub postmark_webhook_token: Option<String>,
43 - /// Bearer token for authenticating Postmark broadcast stream webhooks (optional)
44 - pub postmark_broadcast_webhook_token: Option<String>,
45 - /// Hostname for git SSH clone URLs (e.g., "git.makenot.work"). Hidden when not set.
46 - pub git_ssh_host: Option<String>,
47 - /// Base URL of the Multithreaded forum instance (e.g., "https://forums.makenot.work").
48 - /// When set, enables the Forums tab on the user dashboard.
49 - pub mt_base_url: Option<String>,
50 - /// Stripe Price ID for the Fan+ subscription ($8/mo).
51 - /// When set, enables Fan+ subscription checkout.
52 - pub fan_plus_price_id: Option<String>,
53 - /// Stripe Price IDs for creator tier subscriptions (monthly).
54 - /// Maps each tier to its Stripe Price ID. Empty map = creator tiers disabled.
55 - pub creator_tier_prices: HashMap<CreatorTier, String>,
56 - /// Stripe Price IDs for creator tier subscriptions, annual billing.
57 - /// 10% off monthly × 12 — see `assumptions.toml` § annual_discount. Missing
58 - /// entries fall back to monthly: checkout silently downgrades the interval
59 - /// rather than erroring.
60 - pub creator_tier_annual_prices: HashMap<CreatorTier, String>,
61 - /// Stripe Price IDs for *founder* creator tier subscriptions, monthly
62 - /// (50% off, locked for life). Used during the founder window or for
63 - /// accounts whose `founder_locked_at` is set. Missing entries fall back to
64 - /// sticker prices in `creator_tier_prices`. See `project_founder_pricing.md`.
65 - pub creator_tier_founder_prices: HashMap<CreatorTier, String>,
66 - /// Stripe Price IDs for *founder* creator tier subscriptions, annual.
67 - /// 10% off founder monthly × 12. Missing entries fall back to founder
68 - /// monthly first, then sticker monthly. Checkout silently downgrades; no
69 - /// error response.
70 - pub creator_tier_founder_annual_prices: HashMap<CreatorTier, String>,
71 - /// Whether the founder-pricing window is currently open. While true, new
72 - /// creator-tier subscriptions get founder prices and the subscribing user
73 - /// is marked `is_founder = true`. Flip to false when the window closes
74 - /// (1,000 creators or exit-beta, whichever first); a separate admin action
75 - /// then sweeps `founder_locked_at` on active founder accounts.
76 - pub creator_founder_window_open: bool,
77 - /// Bearer token for authenticating build trigger webhook requests (optional).
78 - pub build_trigger_token: Option<String>,
79 - /// SSH host for Linux builds (e.g., "max@100.106.221.39").
80 - pub build_host_linux: Option<String>,
81 - /// SSH host for macOS builds (e.g., "max@100.64.x.x").
82 - pub build_host_darwin: Option<String>,
83 39 /// Base URL for CDN-served downloads (e.g., "https://cdn.makenot.work").
84 40 /// When set, free content downloads are served via CDN instead of presigned S3 URLs.
85 41 pub cdn_base_url: Option<String>,
@@ -87,24 +43,15 @@ pub struct Config {
87 43 /// Cookieless and strict-CSP, isolated from the apex. Defaults to "u." +
88 44 /// the host_url host; override via USER_PAGES_HOST.
89 45 pub user_pages_host: Arc<str>,
90 - /// Bearer token for authenticating Postmark inbound email webhook (optional).
91 - pub postmark_inbound_webhook_token: Option<String>,
92 - /// Enforce SPF/DKIM alignment on inbound email before trusting the `From`
93 - /// address as an MNW user's identity (issues, patches, replies). Defaults to
94 - /// `true` (fail closed): a message whose `From` domain isn't SPF/DKIM-aligned
95 - /// is not attributed to the account that owns that address, closing the
96 - /// inbound sender-spoofing hole. Set `POSTMARK_ENFORCE_SENDER_AUTH=false`
97 - /// only to observe verdicts during rollout (logs but does not reject).
98 - pub postmark_enforce_sender_auth: bool,
99 - /// Shared secret for HMAC-signed internal API requests to MT.
100 - /// Must match `INTERNAL_SHARED_SECRET` on the MT instance.
101 - pub internal_shared_secret: Option<String>,
102 - /// Bearer token for authenticating CLI SSH server → MNW internal API calls.
103 - /// When unset, internal API endpoints return 503.
104 - pub cli_service_token: Option<String>,
105 - /// Base URL of the WAM ticket manager (e.g., "http://100.x.x.x:7890").
106 - /// When set, operational events create WAM tickets for human triage.
107 - pub wam_url: Option<String>,
46 + /// Native build pipeline: SSH build hosts, trigger auth, and git repo
47 + /// paths (`BUILD_*`, `GIT_*`).
48 + pub build: BuildConfig,
49 + /// Postmark webhook authentication + inbound sender-auth policy (`POSTMARK_*`).
50 + pub email_webhooks: EmailWebhookConfig,
51 + /// Creator-tier + Fan+ Stripe price maps and the founder-window flag.
52 + pub creator_pricing: CreatorTierPricing,
53 + /// Sibling-service URLs and shared secrets: MT forum, WAM, internal API.
54 + pub integrations: IntegrationsConfig,
108 55 /// Site-wide access gate. `Open` (default) serves the public site as
109 56 /// normal. `FanPlusOrCreator` restricts the whole site to logged-in users
110 57 /// with a creator account or an active Fan+ subscription — used on the
@@ -118,6 +65,76 @@ pub struct Config {
118 65 pub sso: Option<SsoConfig>,
119 66 }
120 67
68 + /// Native build pipeline configuration (`BUILD_*`, `GIT_*`).
69 + ///
70 + /// `git_repos_path` and `git_ssh_host` gate the in-app git browser and the
71 + /// SSH clone URL; the build host/token fields drive the remote build runner.
72 + #[derive(Clone)]
73 + pub struct BuildConfig {
74 + /// Bearer token for authenticating build trigger webhook requests (optional).
75 + pub trigger_token: Option<String>,
76 + /// SSH host for Linux builds (e.g., "max@100.106.221.39").
77 + pub host_linux: Option<String>,
78 + /// SSH host for macOS builds (e.g., "max@100.64.x.x").
79 + pub host_darwin: Option<String>,
80 + /// Path to bare git repositories on disk (optional). Git browser disabled if unset.
81 + pub git_repos_path: Option<String>,
82 + /// Hostname for git SSH clone URLs (e.g., "git.makenot.work"). Hidden when not set.
83 + pub git_ssh_host: Option<String>,
84 + }
85 +
86 + /// Postmark webhook authentication + inbound sender-auth policy (`POSTMARK_*`).
87 + #[derive(Clone)]
88 + pub struct EmailWebhookConfig {
89 + /// Bearer token for authenticating Postmark webhook requests (optional).
90 + pub webhook_token: Option<String>,
91 + /// Bearer token for authenticating Postmark broadcast stream webhooks (optional).
92 + pub broadcast_webhook_token: Option<String>,
93 + /// Bearer token for authenticating the Postmark inbound email webhook (optional).
94 + pub inbound_webhook_token: Option<String>,
95 + /// Enforce SPF/DKIM alignment on inbound email before trusting the `From`
96 + /// address as an MNW user's identity. Defaults to `true` (fail closed): a
97 + /// message whose `From` domain isn't SPF/DKIM-aligned is not attributed to
98 + /// the account that owns that address. Set `POSTMARK_ENFORCE_SENDER_AUTH=false`
99 + /// only to observe verdicts during rollout (logs but does not reject).
100 + pub enforce_sender_auth: bool,
101 + }
102 +
103 + /// Creator-tier and Fan+ Stripe price maps plus the founder-window flag.
104 + ///
105 + /// Missing annual/founder entries fall back per the checkout logic (annual →
106 + /// monthly, founder → sticker); see `project_founder_pricing.md`.
107 + #[derive(Clone)]
108 + pub struct CreatorTierPricing {
109 + /// Stripe Price ID for the Fan+ subscription ($8/mo). Enables Fan+ checkout when set.
110 + pub fan_plus_price_id: Option<String>,
111 + /// Stripe Price IDs for creator tier subscriptions (monthly). Empty = disabled.
112 + pub tier_prices: HashMap<CreatorTier, String>,
113 + /// Stripe Price IDs for creator tier subscriptions, annual billing (10% off monthly × 12).
114 + pub tier_annual_prices: HashMap<CreatorTier, String>,
115 + /// Stripe Price IDs for *founder* creator tier subscriptions, monthly (50% off, locked for life).
116 + pub tier_founder_prices: HashMap<CreatorTier, String>,
117 + /// Stripe Price IDs for *founder* creator tier subscriptions, annual (10% off founder monthly × 12).
118 + pub tier_founder_annual_prices: HashMap<CreatorTier, String>,
119 + /// Whether the founder-pricing window is currently open. While true, new
120 + /// creator-tier subscriptions get founder prices and the user is marked
121 + /// `is_founder = true`. Defaults closed so a misconfigured env can't leak it.
122 + pub founder_window_open: bool,
123 + }
124 +
125 + /// URLs and shared secrets for sibling services (MT forum, WAM, internal API).
126 + #[derive(Clone)]
127 + pub struct IntegrationsConfig {
128 + /// Base URL of the Multithreaded forum instance. Enables the Forums tab when set.
129 + pub mt_base_url: Option<String>,
130 + /// Base URL of the WAM ticket manager. Enables WAM ticketing when set.
131 + pub wam_url: Option<String>,
132 + /// Shared secret for HMAC-signed internal API requests to MT (>=32 chars).
133 + pub internal_shared_secret: Option<String>,
134 + /// Bearer token authenticating CLI SSH server → MNW internal API calls (>=32 chars).
135 + pub cli_service_token: Option<String>,
136 + }
137 +
121 138 /// Upstream OAuth provider config for delegated login (`SSO_*`).
122 139 #[derive(Clone)]
123 140 pub struct SsoConfig {
@@ -453,29 +470,37 @@ impl Config {
453 470 admin_user_id,
454 471 synckit_jwt_secret,
455 472 scan,
456 - git_repos_path,
457 - postmark_webhook_token,
458 - postmark_broadcast_webhook_token,
459 - git_ssh_host,
460 - mt_base_url,
461 - fan_plus_price_id,
462 - creator_tier_prices,
463 - creator_tier_annual_prices,
464 - creator_tier_founder_prices,
465 - creator_tier_founder_annual_prices,
466 - creator_founder_window_open,
467 - build_trigger_token,
468 - build_host_linux,
469 - build_host_darwin,
470 473 cdn_base_url,
471 474 user_pages_host: Arc::from(user_pages_host),
472 - postmark_inbound_webhook_token,
473 - postmark_enforce_sender_auth,
474 - internal_shared_secret,
475 - cli_service_token,
476 - wam_url,
477 475 access_gate,
478 476 sso,
477 + build: BuildConfig {
478 + trigger_token: build_trigger_token,
479 + host_linux: build_host_linux,
480 + host_darwin: build_host_darwin,
481 + git_repos_path,
482 + git_ssh_host,
483 + },
484 + email_webhooks: EmailWebhookConfig {
485 + webhook_token: postmark_webhook_token,
486 + broadcast_webhook_token: postmark_broadcast_webhook_token,
487 + inbound_webhook_token: postmark_inbound_webhook_token,
488 + enforce_sender_auth: postmark_enforce_sender_auth,
489 + },
490 + creator_pricing: CreatorTierPricing {
491 + fan_plus_price_id,
492 + tier_prices: creator_tier_prices,
493 + tier_annual_prices: creator_tier_annual_prices,
494 + tier_founder_prices: creator_tier_founder_prices,
495 + tier_founder_annual_prices: creator_tier_founder_annual_prices,
496 + founder_window_open: creator_founder_window_open,
497 + },
498 + integrations: IntegrationsConfig {
499 + mt_base_url,
500 + wam_url,
501 + internal_shared_secret,
502 + cli_service_token,
503 + },
479 504 })
480 505 }
481 506
@@ -700,26 +725,26 @@ impl std::fmt::Debug for Config {
700 725 .field("admin_user_id", &self.admin_user_id)
701 726 .field("synckit_jwt_secret", &self.synckit_jwt_secret.as_ref().map(|_| "[REDACTED]"))
702 727 .field("scan", &self.scan)
703 - .field("git_repos_path", &self.git_repos_path)
704 - .field("postmark_webhook_token", &self.postmark_webhook_token.as_ref().map(|_| "[REDACTED]"))
705 - .field("postmark_broadcast_webhook_token", &self.postmark_broadcast_webhook_token.as_ref().map(|_| "[REDACTED]"))
706 - .field("git_ssh_host", &self.git_ssh_host)
707 - .field("mt_base_url", &self.mt_base_url)
708 - .field("fan_plus_price_id", &self.fan_plus_price_id)
709 - .field("creator_tier_prices", &format!("{} tiers configured", self.creator_tier_prices.len()))
710 - .field("creator_tier_annual_prices", &format!("{} annual tiers configured", self.creator_tier_annual_prices.len()))
711 - .field("creator_tier_founder_prices", &format!("{} founder tiers configured", self.creator_tier_founder_prices.len()))
712 - .field("creator_tier_founder_annual_prices", &format!("{} founder annual tiers configured", self.creator_tier_founder_annual_prices.len()))
713 - .field("creator_founder_window_open", &self.creator_founder_window_open)
714 - .field("build_trigger_token", &self.build_trigger_token.as_ref().map(|_| "[REDACTED]"))
715 - .field("build_host_linux", &self.build_host_linux)
716 - .field("build_host_darwin", &self.build_host_darwin)
728 + .field("git_repos_path", &self.build.git_repos_path)
729 + .field("postmark_webhook_token", &self.email_webhooks.webhook_token.as_ref().map(|_| "[REDACTED]"))
730 + .field("postmark_broadcast_webhook_token", &self.email_webhooks.broadcast_webhook_token.as_ref().map(|_| "[REDACTED]"))
731 + .field("git_ssh_host", &self.build.git_ssh_host)
732 + .field("mt_base_url", &self.integrations.mt_base_url)
733 + .field("fan_plus_price_id", &self.creator_pricing.fan_plus_price_id)
734 + .field("creator_tier_prices", &format!("{} tiers configured", self.creator_pricing.tier_prices.len()))
735 + .field("creator_tier_annual_prices", &format!("{} annual tiers configured", self.creator_pricing.tier_annual_prices.len()))
736 + .field("creator_tier_founder_prices", &format!("{} founder tiers configured", self.creator_pricing.tier_founder_prices.len()))
737 + .field("creator_tier_founder_annual_prices", &format!("{} founder annual tiers configured", self.creator_pricing.tier_founder_annual_prices.len()))
738 + .field("creator_founder_window_open", &self.creator_pricing.founder_window_open)
739 + .field("build_trigger_token", &self.build.trigger_token.as_ref().map(|_| "[REDACTED]"))
740 + .field("build_host_linux", &self.build.host_linux)
741 + .field("build_host_darwin", &self.build.host_darwin)
717 742 .field("cdn_base_url", &self.cdn_base_url)
718 743 .field("user_pages_host", &self.user_pages_host)
719 - .field("postmark_inbound_webhook_token", &self.postmark_inbound_webhook_token.as_ref().map(|_| "[REDACTED]"))
720 - .field("internal_shared_secret", &self.internal_shared_secret.as_ref().map(|_| "[REDACTED]"))
721 - .field("cli_service_token", &self.cli_service_token.as_ref().map(|_| "[REDACTED]"))
722 - .field("wam_url", &self.wam_url)
744 + .field("postmark_inbound_webhook_token", &self.email_webhooks.inbound_webhook_token.as_ref().map(|_| "[REDACTED]"))
745 + .field("internal_shared_secret", &self.integrations.internal_shared_secret.as_ref().map(|_| "[REDACTED]"))
746 + .field("cli_service_token", &self.integrations.cli_service_token.as_ref().map(|_| "[REDACTED]"))
747 + .field("wam_url", &self.integrations.wam_url)
723 748 .field("access_gate", &self.access_gate)
724 749 .field("sso", &self.sso.as_ref().map(|s| &s.provider_url))
725 750 .finish()
@@ -865,29 +890,37 @@ mod tests {
865 890 admin_user_id: None,
866 891 synckit_jwt_secret: None,
867 892 scan: None,
868 - git_repos_path: None,
869 - postmark_webhook_token: None,
870 - postmark_broadcast_webhook_token: None,
871 - git_ssh_host: None,
872 - mt_base_url: None,
873 - fan_plus_price_id: None,
874 - creator_tier_prices: HashMap::new(),
875 - creator_tier_annual_prices: HashMap::new(),
876 - creator_tier_founder_prices: HashMap::new(),
877 - creator_tier_founder_annual_prices: HashMap::new(),
878 - creator_founder_window_open: false,
879 - build_trigger_token: None,
880 - build_host_linux: None,
881 - build_host_darwin: None,
882 893 cdn_base_url: None,
883 894 user_pages_host: Arc::from("u.localhost"),
884 - postmark_inbound_webhook_token: None,
885 - postmark_enforce_sender_auth: true,
886 - internal_shared_secret: None,
887 - cli_service_token: None,
888 - wam_url: None,
889 895 access_gate: AccessGate::Open,
890 896 sso: None,
897 + build: BuildConfig {
898 + trigger_token: None,
899 + host_linux: None,
900 + host_darwin: None,
901 + git_repos_path: None,
902 + git_ssh_host: None,
903 + },
904 + email_webhooks: EmailWebhookConfig {
905 + webhook_token: None,
906 + broadcast_webhook_token: None,
907 + inbound_webhook_token: None,
908 + enforce_sender_auth: true,
909 + },
910 + creator_pricing: CreatorTierPricing {
911 + fan_plus_price_id: None,
912 + tier_prices: HashMap::new(),
913 + tier_annual_prices: HashMap::new(),
914 + tier_founder_prices: HashMap::new(),
915 + tier_founder_annual_prices: HashMap::new(),
916 + founder_window_open: false,
917 + },
918 + integrations: IntegrationsConfig {
919 + mt_base_url: None,
920 + wam_url: None,
921 + internal_shared_secret: None,
922 + cli_service_token: None,
923 + },
891 924 };
892 925 let addr = config.socket_addr();
893 926 assert_eq!(addr.port(), 8080);
@@ -26,7 +26,7 @@ pub async fn fetch_discussion_info(
26 26 let Some(ref mt) = state.mt_client else {
27 27 return (None, None);
28 28 };
29 - let Some(ref mt_base_url) = state.config.mt_base_url else {
29 + let Some(ref mt_base_url) = state.config.integrations.mt_base_url else {
30 30 return (None, None);
31 31 };
32 32
M server/src/lib.rs +62 -36
@@ -82,12 +82,9 @@ use webauthn_rs::Webauthn;
82 82 pub struct AppState {
83 83 pub db: sqlx::PgPool,
84 84 pub config: Config,
85 - pub s3: Option<Arc<dyn StorageBackend>>,
86 - pub synckit_s3: Option<Arc<dyn StorageBackend>>,
87 - /// Public, CDN-served bucket backend. Holds only promoted image content
88 - /// (covers, gallery, item/project images); the scan worker copies Clean
89 - /// image objects here cross-bucket. `None` when `S3_PUBLIC_BUCKET` is unset.
90 - pub public_s3: Option<Arc<dyn StorageBackend>>,
85 + /// S3-compatible storage backends: main private bucket, SyncKit blob
86 + /// bucket, and the public CDN-served bucket.
87 + pub storage: AppStorage,
91 88 pub stripe: Option<Arc<dyn PaymentProvider>>,
92 89 pub email: EmailClient,
93 90 pub docs: Arc<DocLoader>,
@@ -101,38 +98,17 @@ pub struct AppState {
101 98 pub syntax: Option<Arc<git::SyntaxHighlighter>>,
102 99 pub started_at: chrono::DateTime<chrono::Utc>,
103 100 pub start_instant: Instant,
104 - /// Cache of recently-validated session tracking IDs to skip per-request DB touch.
105 - /// Maps session tracking ID → last validated instant. Entries older than
106 - /// SESSION_TOUCH_CACHE_SECS are treated as expired.
107 - pub session_cache: Arc<DashMap<UserSessionId, Instant>>,
108 101 /// HTTP client for the Multithreaded internal API (community/thread provisioning).
109 102 pub mt_client: Option<mt_client::MtClient>,
110 103 /// HTTP client for the WAM ticket manager (operational alerts).
111 104 pub wam: Option<wam_client::WamClient>,
112 - /// Cache of verified custom domains → user IDs (populated on startup, updated on verify/delete).
113 - pub domain_cache: Arc<DashMap<String, db::UserId>>,
114 - /// Limits concurrent file scans to prevent memory exhaustion (each scan
115 - /// downloads up to SCAN_MAX_MEMORY_BYTES into RAM).
116 - pub scan_semaphore: Arc<tokio::sync::Semaphore>,
117 - /// Caps concurrent cache-miss DB lookups in `caddy-ask` so a flood of
118 - /// unknown-domain queries can't saturate the pool or drive ACME issuance.
119 - pub caddy_ask_semaphore: Arc<tokio::sync::Semaphore>,
120 - /// Caps concurrent git smart-HTTP clone/fetch responses. Each `git
121 - /// upload-pack` spawns a child process and streams a packfile that can be
122 - /// arbitrarily large for a big repo; without a permit, N concurrent clones
123 - /// fan out unbounded processes + memory. The packfile body is streamed (not
124 - /// buffered), so this bounds the process count, not a heap ceiling.
125 - pub git_smart_http_semaphore: Arc<tokio::sync::Semaphore>,
105 + /// Derived in-memory caches (sessions, custom domains, SyncKit SSE fan-out).
106 + pub caches: AppCaches,
107 + /// Concurrency limiters for memory-/process-heavy request paths.
108 + pub limiters: AppLimiters,
126 109 /// Unix timestamp when the server will restart (0 = no restart pending).
127 110 /// Set by the deploy script via the internal API before uploading a new binary.
128 111 pub restart_at: Arc<std::sync::atomic::AtomicI64>,
129 - /// SSE push notification channels for SyncKit subscribers.
130 - /// Key: (app_id, user_id), Value: broadcast sender carrying the new max
131 - /// `seq` after each push, so a subscriber already at that cursor can skip
132 - /// a redundant pull.
133 - pub sync_notify: Arc<DashMap<(SyncAppId, UserId), tokio::sync::broadcast::Sender<i64>>>,
134 - /// Concurrent SSE connection count per user (for rate limiting).
135 - pub sse_connections: Arc<DashMap<UserId, std::sync::atomic::AtomicUsize>>,
136 112 /// Prometheus metrics handle for rendering the admin dashboard. `None` in tests.
137 113 pub metrics_handle: Option<metrics_exporter_prometheus::PrometheusHandle>,
138 114 /// Bounded batcher for page-view UPSERTs. Replaces the previous
@@ -146,24 +122,74 @@ pub struct AppState {
146 122 pub bg: background::BackgroundTx,
147 123 }
148 124
125 + /// S3-compatible storage backends held by [`AppState`].
126 + #[derive(Clone)]
127 + pub struct AppStorage {
128 + /// Main private bucket (uploaded originals, staged content).
129 + pub s3: Option<Arc<dyn StorageBackend>>,
130 + /// Separate bucket for SyncKit blob storage.
131 + pub synckit_s3: Option<Arc<dyn StorageBackend>>,
132 + /// Public, CDN-served bucket. Holds only promoted image content (covers,
133 + /// gallery, item/project images); the scan worker copies Clean image
134 + /// objects here cross-bucket. `None` when `S3_PUBLIC_BUCKET` is unset.
135 + pub public_s3: Option<Arc<dyn StorageBackend>>,
136 + }
137 +
138 + /// Derived in-memory caches held by [`AppState`]. All are `Arc<DashMap>` so a
139 + /// clone of `AppState` shares one map across every handler task.
140 + #[derive(Clone)]
141 + pub struct AppCaches {
142 + /// Recently-validated session tracking IDs → last-validated instant, to
143 + /// skip a per-request DB touch. Entries older than SESSION_TOUCH_CACHE_SECS
144 + /// are treated as expired.
145 + pub session_cache: Arc<DashMap<UserSessionId, Instant>>,
146 + /// Verified custom domains → user IDs (populated on startup, updated on
147 + /// verify/delete). No TTL; invalidated explicitly on user deletion.
148 + pub domain_cache: Arc<DashMap<String, db::UserId>>,
149 + /// SyncKit SSE push channels. Key: (app_id, user_id); value: broadcast
150 + /// sender carrying the new max `seq` after each push so a subscriber already
151 + /// at that cursor can skip a redundant pull.
152 + pub sync_notify: Arc<DashMap<(SyncAppId, UserId), tokio::sync::broadcast::Sender<i64>>>,
153 + /// Concurrent SSE connection count per user (for rate limiting).
154 + pub sse_connections: Arc<DashMap<UserId, std::sync::atomic::AtomicUsize>>,
155 + }
156 +
157 + /// Concurrency limiters held by [`AppState`], guarding memory-/process-heavy
158 + /// request paths.
159 + #[derive(Clone)]
160 + pub struct AppLimiters {
161 + /// Limits concurrent file scans to prevent memory exhaustion (each scan
162 + /// downloads up to SCAN_MAX_MEMORY_BYTES into RAM).
163 + pub scan_semaphore: Arc<tokio::sync::Semaphore>,
164 + /// Caps concurrent cache-miss DB lookups in `caddy-ask` so a flood of
165 + /// unknown-domain queries can't saturate the pool or drive ACME issuance.
166 + pub caddy_ask_semaphore: Arc<tokio::sync::Semaphore>,
167 + /// Caps concurrent git smart-HTTP clone/fetch responses. Each `git
168 + /// upload-pack` spawns a child process and streams a packfile that can be
169 + /// arbitrarily large for a big repo; without a permit, N concurrent clones
170 + /// fan out unbounded processes + memory. The packfile body is streamed (not
171 + /// buffered), so this bounds the process count, not a heap ceiling.
172 + pub git_smart_http_semaphore: Arc<tokio::sync::Semaphore>,
173 + }
174 +
149 175 impl AppState {
150 176 /// Get the main S3 storage backend, or error if not configured.
151 177 pub fn require_s3(&self) -> error::Result<&Arc<dyn StorageBackend>> {
152 - self.s3
178 + self.storage.s3
153 179 .as_ref()
154 180 .ok_or_else(|| error::AppError::ServiceUnavailable("File storage is not configured".to_string()))
155 181 }
156 182
157 183 /// Get the SyncKit S3 storage backend, or error if not configured.
158 184 pub fn require_synckit_s3(&self) -> error::Result<&Arc<dyn StorageBackend>> {
159 - self.synckit_s3
185 + self.storage.synckit_s3
160 186 .as_ref()
161 187 .ok_or_else(|| error::AppError::ServiceUnavailable("SyncKit storage is not configured".to_string()))
162 188 }
163 189
164 190 /// Get the public (CDN-served) S3 storage backend, or error if not configured.
165 191 pub fn require_public_s3(&self) -> error::Result<&Arc<dyn StorageBackend>> {
166 - self.public_s3
192 + self.storage.public_s3
167 193 .as_ref()
168 194 .ok_or_else(|| error::AppError::ServiceUnavailable("Public storage bucket is not configured".to_string()))
169 195 }
@@ -178,7 +204,7 @@ impl AppState {
178 204 /// domain rows cascade at the DB layer; here we drop the matching cache entries.
179 205 pub async fn delete_user_account(&self, user_id: db::UserId) -> error::Result<()> {
180 206 db::users::delete_user(&self.db, user_id).await?;
181 - self.domain_cache.retain(|_, uid| *uid != user_id);
207 + self.caches.domain_cache.retain(|_, uid| *uid != user_id);
182 208 Ok(())
183 209 }
184 210 }
@@ -259,7 +285,7 @@ pub fn build_app(
259 285 .get("authorization")
260 286 .and_then(|v| v.to_str().ok())
261 287 .and_then(|v| v.strip_prefix("Bearer "));
262 - match (token, metrics_state.config.cli_service_token.as_deref()) {
288 + match (token, metrics_state.config.integrations.cli_service_token.as_deref()) {
263 289 (Some(t), Some(expected)) if crate::helpers::constant_time_compare(t, expected) => {
264 290 prom_handle.render().into_response()
265 291 }
M server/src/main.rs +28 -22
@@ -19,7 +19,7 @@ use makenotwork::email::{EmailClient, EmailConfig};
19 19 use makenotwork::payments::StripeClient;
20 20 use makenotwork::storage::S3Client;
21 21 use makenotwork::scanning::ScanPipeline;
22 - use makenotwork::{build_app, AppState};
22 + use makenotwork::{build_app, AppCaches, AppLimiters, AppState, AppStorage};
23 23 use webauthn_rs::WebauthnBuilder;
24 24
25 25 #[tokio::main]
@@ -403,8 +403,8 @@ async fn main() {
403 403 tracing::info!("WebAuthn initialized (rp_id={rp_id})");
404 404
405 405 // Initialize syntax highlighter if git repos path is configured
406 - let syntax = if config.git_repos_path.is_some() {
407 - tracing::info!(path = ?config.git_repos_path, "Git source browser enabled");
406 + let syntax = if config.build.git_repos_path.is_some() {
407 + tracing::info!(path = ?config.build.git_repos_path, "Git source browser enabled");
408 408 Some(std::sync::Arc::new(makenotwork::git::SyntaxHighlighter::new()))
409 409 } else {
410 410 tracing::info!("Git source browser not configured (GIT_REPOS_PATH unset)");
@@ -412,7 +412,7 @@ async fn main() {
412 412 };
413 413
414 414 // Construct MT client when both mt_base_url and internal_shared_secret are set
415 - let mt_client = match (&config.mt_base_url, &config.internal_shared_secret) {
415 + let mt_client = match (&config.integrations.mt_base_url, &config.integrations.internal_shared_secret) {
416 416 (Some(base_url), Some(secret)) => {
417 417 tracing::info!(base_url = %base_url, "MT integration enabled");
418 418 Some(makenotwork::mt_client::MtClient::new(base_url.clone(), secret.clone()))
@@ -424,7 +424,7 @@ async fn main() {
424 424 };
425 425
426 426 // WAM ticket manager client (tailnet-only, for operational alerts)
427 - let wam = config.wam_url.as_ref().map(|url| {
427 + let wam = config.integrations.wam_url.as_ref().map(|url| {
428 428 tracing::info!(url = %url, "WAM integration enabled");
429 429 makenotwork::wam_client::WamClient::new(url.clone())
430 430 });
@@ -455,9 +455,6 @@ async fn main() {
455 455 let state = AppState {
456 456 db,
457 457 config: config.clone(),
458 - s3,
459 - synckit_s3,
460 - public_s3,
461 458 stripe,
462 459 email,
463 460 docs,
@@ -477,30 +474,39 @@ async fn main() {
477 474 syntax,
478 475 started_at,
479 476 start_instant,
480 - session_cache: std::sync::Arc::new(dashmap::DashMap::new()),
481 477 mt_client,
482 478 wam,
483 - domain_cache,
484 - scan_semaphore: std::sync::Arc::new(tokio::sync::Semaphore::new(makenotwork::constants::SCAN_MAX_CONCURRENT)),
485 - caddy_ask_semaphore: std::sync::Arc::new(tokio::sync::Semaphore::new(makenotwork::constants::CADDY_ASK_MAX_CONCURRENT)),
486 - git_smart_http_semaphore: std::sync::Arc::new(tokio::sync::Semaphore::new(makenotwork::constants::GIT_SMART_HTTP_MAX_CONCURRENT)),
487 479 restart_at: std::sync::Arc::new(std::sync::atomic::AtomicI64::new(0)),
488 - sync_notify: std::sync::Arc::new(dashmap::DashMap::new()),
489 - sse_connections: std::sync::Arc::new(dashmap::DashMap::new()),
490 480 metrics_handle: Some(makenotwork::metrics::init()),
491 481 page_view_tx,
492 482 bg,
483 + storage: AppStorage {
484 + s3,
485 + synckit_s3,
486 + public_s3,
487 + },
488 + caches: AppCaches {
489 + session_cache: std::sync::Arc::new(dashmap::DashMap::new()),
490 + domain_cache,
491 + sync_notify: std::sync::Arc::new(dashmap::DashMap::new()),
492 + sse_connections: std::sync::Arc::new(dashmap::DashMap::new()),
493 + },
494 + limiters: AppLimiters {
495 + scan_semaphore: std::sync::Arc::new(tokio::sync::Semaphore::new(makenotwork::constants::SCAN_MAX_CONCURRENT)),
496 + caddy_ask_semaphore: std::sync::Arc::new(tokio::sync::Semaphore::new(makenotwork::constants::CADDY_ASK_MAX_CONCURRENT)),
497 + git_smart_http_semaphore: std::sync::Arc::new(tokio::sync::Semaphore::new(makenotwork::constants::GIT_SMART_HTTP_MAX_CONCURRENT)),
498 + },
493 499 };
494 500
495 501 // Log active features at startup
496 502 tracing::info!(
497 - s3 = state.s3.is_some(),
498 - synckit_s3 = state.synckit_s3.is_some(),
503 + s3 = state.storage.s3.is_some(),
504 + synckit_s3 = state.storage.synckit_s3.is_some(),
499 505 stripe = state.stripe.is_some(),
500 506 scanner = state.scanner.is_some(),
501 507 mt = state.mt_client.is_some(),
502 508 wam = state.wam.is_some(),
503 - git = state.config.git_repos_path.is_some(),
509 + git = state.config.build.git_repos_path.is_some(),
504 510 "Active features"
505 511 );
506 512
@@ -511,18 +517,18 @@ async fn main() {
511 517
512 518 // Start scan worker pool. Only meaningful if a scanner is configured;
513 519 // otherwise enqueue_scan_for never enqueues (trust-gate fast path).
514 - if let (Some(scanner), Some(s3_for_workers)) = (state.scanner.clone(), state.s3.clone()) {
520 + if let (Some(scanner), Some(s3_for_workers)) = (state.scanner.clone(), state.storage.s3.clone()) {
515 521 let scan_ctx = std::sync::Arc::new(makenotwork::scanning::worker::WorkerContext {
516 522 db: state.db.clone(),
517 523 s3: s3_for_workers,
518 524 pipeline: scanner,
519 - scan_semaphore: state.scan_semaphore.clone(),
525 + scan_semaphore: state.limiters.scan_semaphore.clone(),
520 526 wam: state.wam.clone(),
521 527 bg: state.bg.clone(),
522 528 cloudflare: makenotwork::cloudflare::CloudflarePurger::from_env(),
523 529 cdn_base_url: state.config.cdn_base_url.as_deref().map(std::sync::Arc::from),
524 - synckit_s3: state.synckit_s3.clone(),
525 - public_s3: state.public_s3.clone(),
530 + synckit_s3: state.storage.synckit_s3.clone(),
531 + public_s3: state.storage.public_s3.clone(),
526 532 });
527 533 let worker_count = makenotwork::constants::SCAN_WORKER_COUNT;
528 534 let worker_shutdown_rx = shutdown_tx.subscribe();
@@ -53,7 +53,7 @@ pub async fn run_health_check(state: &AppState) -> HealthSnapshot {
53 53 .is_ok();
54 54
55 55 // 2. S3 connectivity (skip if not configured)
56 - let s3_ok = match &state.s3 {
56 + let s3_ok = match &state.storage.s3 {
57 57 Some(s3) => match s3.check_connectivity().await {
58 58 Ok(()) => true,
59 59 Err(e) => {
@@ -169,7 +169,7 @@ async fn run_monitor_loop(state: AppState, mut shutdown_rx: watch::Receiver<()>)
169 169 // creator count, so we cache the result for 5 minutes rather
170 170 // than burning a multi-second query 2880×/day at 10k+ creators.
171 171 crate::metrics::record_db_pool_stats(&state.db);
172 - crate::metrics::record_domain_cache_size(state.domain_cache.len());
172 + crate::metrics::record_domain_cache_size(state.caches.domain_cache.len());
173 173 static STORAGE_FILL_LAST: std::sync::OnceLock<std::sync::Mutex<std::time::Instant>> =
174 174 std::sync::OnceLock::new();
175 175 const STORAGE_FILL_TTL: std::time::Duration = std::time::Duration::from_secs(300);
@@ -435,7 +435,7 @@ async fn run_monitor_loop(state: AppState, mut shutdown_rx: watch::Receiver<()>)
435 435 // Prune expired session cache entries every cycle
436 436 let cache_ttl = std::time::Duration::from_secs(constants::SESSION_TOUCH_CACHE_SECS);
437 437 state
438 - .session_cache
438 + .caches.session_cache
439 439 .retain(|_, validated_at| validated_at.elapsed() < cache_ttl);
440 440
441 441 // Daily prune/compaction (health history, sync log, OAuth cleanup) used
@@ -73,7 +73,7 @@ pub(super) async fn admin_decide_appeal(
73 73 super::moderation_service::FanoutMode::Background {
74 74 bg: &state.bg,
75 75 wam: state.wam.clone(),
76 - session_cache: &state.session_cache,
76 + session_cache: &state.caches.session_cache,
77 77 },
78 78 &db_user,
79 79 form.decision,
@@ -168,7 +168,7 @@ pub(super) async fn admin_suspend_user(
168 168 super::moderation_service::FanoutMode::Background {
169 169 bg: &state.bg,
170 170 wam: state.wam.clone(),
171 - session_cache: &state.session_cache,
171 + session_cache: &state.caches.session_cache,
172 172 },
173 173 &db_user,
174 174 admin_user.admin_id(),
@@ -197,7 +197,7 @@ pub(super) async fn admin_unsuspend_user(
197 197 super::moderation_service::FanoutMode::Background {
198 198 bg: &state.bg,
199 199 wam: state.wam.clone(),
200 - session_cache: &state.session_cache,
200 + session_cache: &state.caches.session_cache,
201 201 },
202 202 &db_user,
203 203 )
@@ -102,7 +102,7 @@ pub(super) async fn verify_domain(
102 102 // Mark verified in DB and update cache
103 103 db::custom_domains::mark_domain_verified(&state.db, cd.id).await?;
104 104 state
105 - .domain_cache
105 + .caches.domain_cache
106 106 .insert(cd.domain.clone(), session_user.id);
107 107
108 108 Ok(axum::response::Html("<p class=\"success\">Domain verified successfully. Reload to see changes.</p>".to_string()))
@@ -128,7 +128,7 @@ pub(super) async fn remove_domain(
128 128 db::custom_domains::delete_custom_domain(&state.db, id, session_user.id).await?;
129 129
130 130 // Remove from cache
131 - state.domain_cache.remove(&cd.domain);
131 + state.caches.domain_cache.remove(&cd.domain);
132 132
133 133 Ok(axum::http::StatusCode::NO_CONTENT)
134 134 }
@@ -202,7 +202,7 @@ pub(super) async fn caddy_ask(
202 202 }
203 203
204 204 // Fast path: cache hit, no DB, no semaphore.
205 - if state.domain_cache.contains_key(&domain) {
205 + if state.caches.domain_cache.contains_key(&domain) {
206 206 counter!("caddy_ask_total", "outcome" => "cache_hit").increment(1);
207 207 return axum::http::StatusCode::OK;
208 208 }
@@ -210,7 +210,7 @@ pub(super) async fn caddy_ask(
210 210 // Slow path: cap concurrent DB lookups. `try_acquire` is non-blocking;
211 211 // hitting the cap means we're already under pressure and want Caddy to
212 212 // retry rather than queue.
213 - let Ok(_permit) = state.caddy_ask_semaphore.try_acquire() else {
213 + let Ok(_permit) = state.limiters.caddy_ask_semaphore.try_acquire() else {
214 214 tracing::warn!(domain = %domain, "caddy-ask: cache-miss concurrency cap reached");
215 215 counter!("caddy_ask_total", "outcome" => "rejected_at_cap").increment(1);
216 216 return axum::http::StatusCode::SERVICE_UNAVAILABLE;
@@ -218,9 +218,9 @@ pub(super) async fn caddy_ask(
218 218
219 219 match db::custom_domains::get_verified_domain(&state.db, &domain).await {
220 220 Ok(Some(d)) => {
221 - state.domain_cache.insert(d.domain, d.user_id);
221 + state.caches.domain_cache.insert(d.domain, d.user_id);
222 222 // Update cache-size gauge after the insert so it reflects current state.
223 - gauge!("domain_cache_entries").set(state.domain_cache.len() as f64);
223 + gauge!("domain_cache_entries").set(state.caches.domain_cache.len() as f64);
224 224 counter!("caddy_ask_total", "outcome" => "miss_found").increment(1);
225 225 axum::http::StatusCode::OK
226 226 }
@@ -56,7 +56,7 @@ pub(in crate::routes::api) async fn export_content(
56 56 ) -> Result<Response> {
57 57 let is_htmx = is_htmx_request(&headers);
58 58
59 - let s3 = state.s3.as_ref().ok_or_else(|| {
59 + let s3 = state.storage.s3.as_ref().ok_or_else(|| {
60 60 AppError::ServiceUnavailable("File storage is not configured".to_string())
61 61 })?;
62 62
@@ -310,7 +310,7 @@ pub(super) async fn guest_download(
310 310 return Err(AppError::NotFound);
311 311 }
312 312
313 - let s3 = state.s3.as_ref()
313 + let s3 = state.storage.s3.as_ref()
314 314 .ok_or_else(|| AppError::ServiceUnavailable("File storage is not configured".to_string()))?;
315 315
316 316 let download_url = s3.presign_download(&crate::storage::S3Key::from_stored(&s3_key), Some(3600)).await?;
@@ -498,7 +498,7 @@ pub(super) async fn verify_domain(
498 498
499 499 if verified {
500 500 db::custom_domains::mark_domain_verified(&state.db, record.id).await?;
501 - state.domain_cache.insert(record.domain.clone(), actor.user_id());
501 + state.caches.domain_cache.insert(record.domain.clone(), actor.user_id());
502 502 Ok(Json(serde_json::json!({"verified": true, "message": "Domain verified"})))
503 503 } else {
504 504 Ok(Json(serde_json::json!({"verified": false, "message": format!("TXT record not found. Add _mnw-verify.{} = {}", record.domain, record.verification_token)})))
@@ -518,7 +518,7 @@ pub(super) async fn remove_domain(
518 518 .ok_or(AppError::NotFound)?;
519 519
520 520 db::custom_domains::delete_custom_domain(&state.db, record.id, actor.user_id()).await?;
521 - state.domain_cache.remove(&record.domain);
521 + state.caches.domain_cache.remove(&record.domain);
522 522
523 523 Ok(axum::http::StatusCode::NO_CONTENT)
524 524 }
@@ -133,7 +133,7 @@ pub(super) async fn git_authorize(
133 133 ) -> Result<impl IntoResponse> {
134 134 let git_root = state
135 135 .config
136 - .git_repos_path
136 + .build.git_repos_path
137 137 .as_deref()
138 138 .ok_or_else(|| AppError::ServiceUnavailable("Git hosting is not configured".to_string()))?;
139 139
@@ -384,7 +384,7 @@ pub(super) async fn delete_project(
384 384 && let Some(key) = crate::storage::extract_s3_key_from_url(
385 385 url,
386 386 state.config.cdn_base_url.as_deref(),
387 - state.s3.as_deref().map(|s| s.bucket()),
387 + state.storage.s3.as_deref().map(|s| s.bucket()),
388 388 state.config.storage.as_ref().map(|c| c.endpoint.as_str()),
389 389 )
390 390 {
@@ -512,7 +512,7 @@ pub(super) async fn create_repo(
512 512 // Need git_repos_path configured
513 513 let git_root = state
514 514 .config
515 - .git_repos_path
515 + .build.git_repos_path
516 516 .as_deref()
517 517 .ok_or_else(|| AppError::validation("Git repositories are not configured on this server".to_string()))?;
518 518
@@ -537,7 +537,7 @@ pub(super) async fn create_repo(
537 537 .context("init bare git repo")?;
538 538
539 539 // Install post-receive hook if build triggers are configured
540 - if let Some(token) = &state.config.build_trigger_token {
540 + if let Some(token) = &state.config.build.trigger_token {
541 541 let hooks_dir = repo_dir.join("hooks");
542 542 let hook_path = hooks_dir.join("post-receive");
543 543 let hook_content = crate::build_runner::post_receive_hook(token, &username, name);
@@ -222,7 +222,7 @@ pub(in crate::routes::api) async fn update_password(
222 222 db::sessions::delete_all_sessions_for_user(&state.db, user.id).await?
223 223 };
224 224 for id in &revoked_ids {
225 - state.session_cache.remove(id);
225 + state.caches.session_cache.remove(id);
226 226 }
227 227 if !revoked_ids.is_empty() {
228 228 tracing::info!(user_id = %user.id, revoked = revoked_ids.len(), event = "password_change_revoke_sessions", "Revoked other sessions on password change");
@@ -32,7 +32,7 @@ pub(in crate::routes::api) async fn revoke_session(
32 32 }
33 33
34 34 db::sessions::delete_user_session(&state.db, session_id, user.id).await?;
35 - state.session_cache.remove(&session_id);
35 + state.caches.session_cache.remove(&session_id);
36 36
37 37 // Re-render the sessions list
38 38 let sessions = db::sessions::get_user_sessions(&state.db, user.id).await?;
@@ -64,7 +64,7 @@ pub(in crate::routes::api) async fn revoke_other_sessions(
64 64 if let Some(current_id) = current_tracking_id {
65 65 let revoked_ids = db::sessions::delete_other_sessions(&state.db, current_id, user.id).await?;
66 66 for id in &revoked_ids {
67 - state.session_cache.remove(id);
67 + state.caches.session_cache.remove(id);
68 68 }
69 69 tracing::info!(user_id = %user.id, revoked = revoked_ids.len(), event = "revoke_other_sessions", "Revoked other sessions");
70 70 }
@@ -306,7 +306,7 @@ async fn logout_handler(
306 306 && let Err(e) = db::sessions::delete_session_by_id(&state.db, tracking_id, u.id).await {
307 307 tracing::warn!(tracking_id = %tracking_id, error = ?e, "failed to delete session tracking row on logout");
308 308 }
309 - state.session_cache.remove(&tracking_id);
309 + state.caches.session_cache.remove(&tracking_id);
310 310 }
311 311 logout_user(&session).await?;
312 312 Ok(Redirect::to("/"))
@@ -440,7 +440,7 @@ async fn hook_trigger(
440 440 // The hook file contains HMAC(token, owner:repo), not the raw token.
441 441 let trigger_token = state
442 442 .config
443 - .build_trigger_token
443 + .build.trigger_token
444 444 .as_deref()
445 445 .ok_or_else(|| AppError::ServiceUnavailable("Build triggers not configured".to_string()))?;
446 446