max / makenotwork
47 files changed,
+502 insertions,
-392 deletions
| @@ -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 | } |
| @@ -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 |
| @@ -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 |
| @@ -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 | } |
| @@ -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 |