diff --git a/config/env/development.env b/config/env/development.env index 3f9515ba8..7a6eccbbd 100644 --- a/config/env/development.env +++ b/config/env/development.env @@ -89,7 +89,6 @@ FLUXER_GATEWAY_PRESENCE_PUSH_BUFFER_MAX_BYTES=1048576 FLUXER_ADMIN_PORT=3020 FLUXER_ADMIN_BASE_PATH=/admin FLUXER_ADMIN_SECRET_KEY_BASE=dev-admin-secret-key-base -FLUXER_ADMIN_OAUTH_CLIENT_ID=1234567890123456789 FLUXER_ADMIN_OAUTH_CLIENT_SECRET=dev-admin-oauth-secret FLUXER_ADMIN_OAUTH_REDIRECT_URI=http://localhost:8088/admin/oauth2_callback FLUXER_MARKETING_PORT=3010 @@ -99,8 +98,8 @@ FLUXER_MARKETING_SECRET_KEY_BASE=dev-marketing-secret-key-base FLUXER_SUDO_MODE_SECRET=dev-sudo-secret FLUXER_CONNECTION_INITIATION_SECRET=dev-connection-initiation-secret -FLUXER_VAPID_PUBLIC_KEY=dev-vapid-public-key -FLUXER_VAPID_PRIVATE_KEY=dev-vapid-private-key +FLUXER_VAPID_PUBLIC_KEY=BHIbdKs24FdPkOQS7hbeg3adceLS0IqlKsn71ywEe6kbeopeFFiG3lkvJac7BVqkuk7mxwEa555O2FXV3HLt56w +FLUXER_VAPID_PRIVATE_KEY=cs24JvXSxHiqJQgkJNocJFAdzJpPmpfU9xD-fDpn3tw FLUXER_VAPID_EMAIL=dev@localhost FLUXER_PASSKEY_RP_NAME='Fluxer Dev' FLUXER_PASSKEY_RP_ID=localhost diff --git a/fluxer_admin/src/config/mod.rs b/fluxer_admin/src/config/mod.rs index 6abbd1df8..79c971509 100644 --- a/fluxer_admin/src/config/mod.rs +++ b/fluxer_admin/src/config/mod.rs @@ -120,6 +120,10 @@ impl AdminConfig { pub fn is_production(&self) -> bool { self.env == RuntimeEnv::Production } + + pub fn secure_cookies(&self) -> bool { + self.admin_endpoint.starts_with("https://") + } } impl RuntimeEnv { diff --git a/fluxer_admin/src/middleware/csrf.rs b/fluxer_admin/src/middleware/csrf.rs index 8aeffcfb7..2818c63ba 100644 --- a/fluxer_admin/src/middleware/csrf.rs +++ b/fluxer_admin/src/middleware/csrf.rs @@ -28,7 +28,7 @@ pub async fn csrf_protection( let config = state.config(); let secret = config.secret_key_base.clone(); let admin_endpoint = config.admin_endpoint.clone(); - let is_production = config.is_production(); + let secure_cookies = config.secure_cookies(); let user_id = request .extensions() @@ -76,17 +76,17 @@ pub async fn csrf_protection( let mut response = next.run(request).await; - let cookie_name = if is_production { + let cookie_name = if secure_cookies { HOST_CSRF_COOKIE_NAME } else { CSRF_COOKIE_NAME }; - let secure = if is_production { "; Secure" } else { "" }; + let secure = if secure_cookies { "; Secure" } else { "" }; let cookie_value = format!("{cookie_name}={token}; Path=/; SameSite=Lax; HttpOnly{secure}"); if let Ok(value) = HeaderValue::from_str(&cookie_value) { response.headers_mut().append(header::SET_COOKIE, value); } - if is_production + if secure_cookies && let Ok(value) = HeaderValue::from_str(&format!( "{CSRF_COOKIE_NAME}=; Path=/; SameSite=Lax; HttpOnly; Max-Age=0" )) @@ -199,6 +199,81 @@ pub fn get_csrf_token(request: &Request) -> String { #[cfg(test)] mod tests { use super::*; + use crate::config::{AdminConfig, ProxyConfig, RuntimeEnv}; + use crate::state::AppState; + use axum::{Router, middleware::from_fn_with_state, routing::get}; + use tower::ServiceExt; + + fn state_with_admin_endpoint(admin_endpoint: &str) -> AppState { + AppState::new(AdminConfig { + env: RuntimeEnv::Production, + host: String::new(), + port: 3020, + secret_key_base: "test-secret".to_owned(), + base_path: String::new(), + api_endpoint: String::new(), + media_endpoint: String::new(), + static_cdn_endpoint: String::new(), + admin_endpoint: admin_endpoint.to_owned(), + web_app_endpoint: String::new(), + kv_url: String::new(), + oauth_client_id: String::new(), + oauth_client_secret: String::new(), + oauth_redirect_uri: String::new(), + build_version: "test".to_owned(), + release_channel: String::new(), + self_hosted: false, + proxy: ProxyConfig { + trust_client_ip_header: false, + client_ip_header_name: String::new(), + }, + }) + } + + async fn csrf_cookies(admin_endpoint: &str) -> Vec { + let state = state_with_admin_endpoint(admin_endpoint); + let app = Router::new() + .route("/", get(|| async { "ok" })) + .layer(from_fn_with_state(state, csrf_protection)); + let response = app + .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap()) + .await + .expect("router responds"); + response + .headers() + .get_all(header::SET_COOKIE) + .iter() + .filter_map(|value| value.to_str().ok()) + .map(|value| value.to_owned()) + .collect() + } + + #[tokio::test] + async fn https_admin_endpoint_sets_a_host_prefixed_secure_cookie() { + let cookies = csrf_cookies("https://example.com/admin").await; + assert!( + cookies + .iter() + .any(|cookie| cookie.starts_with("__Host-csrf_token=") + && cookie.contains("; Secure")), + "expected a secure __Host- cookie, got {cookies:?}" + ); + } + + #[tokio::test] + async fn http_admin_endpoint_sets_a_plain_cookie_without_secure() { + let cookies = csrf_cookies("http://example.com/admin").await; + assert!( + cookies + .iter() + .any(|cookie| cookie.starts_with("csrf_token=") && !cookie.contains("Secure")), + "expected a plain csrf_token cookie, got {cookies:?}" + ); + assert!( + !cookies.iter().any(|cookie| cookie.contains("__Host-")), + "expected no __Host- cookie, got {cookies:?}" + ); + } #[test] fn oauth2_callback_is_exempt() { diff --git a/fluxer_admin/src/middleware/flash.rs b/fluxer_admin/src/middleware/flash.rs index f46edeb87..b9b01aac2 100644 --- a/fluxer_admin/src/middleware/flash.rs +++ b/fluxer_admin/src/middleware/flash.rs @@ -92,9 +92,9 @@ pub fn clear_flash_cookie(response: &mut Response) { } } -pub fn redirect_with_flash(url: &str, flash: FlashData, is_production: bool) -> Response { +pub fn redirect_with_flash(url: &str, flash: FlashData, secure: bool) -> Response { let encoded = serialize_flash(&flash); - let secure_flag = if is_production { "; Secure" } else { "" }; + let secure_flag = if secure { "; Secure" } else { "" }; let cookie_value = format!( "{FLASH_COOKIE_NAME}={encoded}; Path=/; HttpOnly; SameSite=Lax; Max-Age=60{secure_flag}" ); diff --git a/fluxer_admin/src/routes/admin.rs b/fluxer_admin/src/routes/admin.rs index 4a788e017..34dce3162 100644 --- a/fluxer_admin/src/routes/admin.rs +++ b/fluxer_admin/src/routes/admin.rs @@ -119,7 +119,7 @@ async fn admin_api_keys_post( return flash::redirect_with_flash( &format!("{base}/admin-api-keys"), flash, - config.is_production(), + config.secure_cookies(), ); } } @@ -128,7 +128,7 @@ async fn admin_api_keys_post( flash::redirect_with_flash( &format!("{base}/admin-api-keys"), FlashData::success(format!("API key action '{action}' completed.")), - config.is_production(), + config.secure_cookies(), ) } @@ -143,7 +143,7 @@ fn admin_api_key_flash_response( flash::redirect_with_flash( &format!("{}/admin-api-keys", config.base_path), flash_data, - config.is_production(), + config.secure_cookies(), ) } } diff --git a/fluxer_admin/src/routes/applications.rs b/fluxer_admin/src/routes/applications.rs index 35858763d..932ffc439 100644 --- a/fluxer_admin/src/routes/applications.rs +++ b/fluxer_admin/src/routes/applications.rs @@ -151,7 +151,7 @@ async fn application_detail_post( return flash::redirect_with_flash( &format!("{base}/applications/{application_id}"), FlashData::error("Invalid form data"), - config.is_production(), + config.secure_cookies(), ); } }; @@ -178,6 +178,6 @@ async fn application_detail_post( flash::redirect_with_flash( &format!("{base}/applications/{application_id}"), flash, - config.is_production(), + config.secure_cookies(), ) } diff --git a/fluxer_admin/src/routes/auth.rs b/fluxer_admin/src/routes/auth.rs index c83341b04..bcd6c5a16 100644 --- a/fluxer_admin/src/routes/auth.rs +++ b/fluxer_admin/src/routes/auth.rs @@ -187,7 +187,7 @@ async fn oauth2_callback_finish( let session_cookie_value = session::create_session(&user.id, &token.access_token, &config.secret_key_base); - let secure = if config.is_production() { + let secure = if config.secure_cookies() { "; Secure" } else { "" diff --git a/fluxer_admin/src/routes/codes.rs b/fluxer_admin/src/routes/codes.rs index 033da2363..7a7dc2de6 100644 --- a/fluxer_admin/src/routes/codes.rs +++ b/fluxer_admin/src/routes/codes.rs @@ -82,7 +82,7 @@ async fn gift_codes_post( return flash::redirect_with_flash( &format!("{base}/gift-codes"), FlashData::error("Invalid form data"), - config.is_production(), + config.secure_cookies(), ); } }; @@ -99,14 +99,14 @@ async fn gift_codes_post( .and_then(|s| s.parse::().ok()) .unwrap_or(1); let client = AdminApiClient::new(state.http_client(), config, &auth.0.session); - let is_prod = config.is_production(); + let secure_cookies = config.secure_cookies(); match client.generate_gift_codes(count, dur_type, dur_qty).await { Ok(result) => { let codes = result.codes.join(","); flash::redirect_with_flash( &format!("{base}/gift-codes?codes={codes}"), FlashData::success(format!("{} gift code(s) generated", result.codes.len())), - is_prod, + secure_cookies, ) } Err(error) => { @@ -114,7 +114,7 @@ async fn gift_codes_post( flash::redirect_with_flash( &format!("{base}/gift-codes"), FlashData::error("Failed to generate gift codes"), - is_prod, + secure_cookies, ) } } diff --git a/fluxer_admin/src/routes/discovery.rs b/fluxer_admin/src/routes/discovery.rs index 20375d263..442b5d662 100644 --- a/fluxer_admin/src/routes/discovery.rs +++ b/fluxer_admin/src/routes/discovery.rs @@ -105,7 +105,7 @@ async fn discovery_approve( return flash::redirect_with_flash( &format!("{base}/discovery"), FlashData::error("Invalid form data"), - config.is_production(), + config.secure_cookies(), ); } }; @@ -115,7 +115,7 @@ async fn discovery_approve( return flash::redirect_with_flash( &format!("{base}/discovery"), FlashData::error("Guild ID is required"), - config.is_production(), + config.secure_cookies(), ); } }; @@ -131,7 +131,7 @@ async fn discovery_approve( flash::redirect_with_flash( &format!("{base}/discovery?tab=pending"), flash, - config.is_production(), + config.secure_cookies(), ) } @@ -149,7 +149,7 @@ async fn discovery_reject( return flash::redirect_with_flash( &format!("{base}/discovery"), FlashData::error("Invalid form data"), - config.is_production(), + config.secure_cookies(), ); } }; @@ -159,7 +159,7 @@ async fn discovery_reject( return flash::redirect_with_flash( &format!("{base}/discovery"), FlashData::error("Guild ID is required"), - config.is_production(), + config.secure_cookies(), ); } }; @@ -173,7 +173,7 @@ async fn discovery_reject( flash::redirect_with_flash( &format!("{base}/discovery?tab=pending"), flash, - config.is_production(), + config.secure_cookies(), ) } @@ -191,7 +191,7 @@ async fn discovery_remove( return flash::redirect_with_flash( &format!("{base}/discovery"), FlashData::error("Invalid form data"), - config.is_production(), + config.secure_cookies(), ); } }; @@ -201,7 +201,7 @@ async fn discovery_remove( return flash::redirect_with_flash( &format!("{base}/discovery"), FlashData::error("Guild ID is required"), - config.is_production(), + config.secure_cookies(), ); } }; @@ -215,6 +215,6 @@ async fn discovery_remove( flash::redirect_with_flash( &format!("{base}/discovery?tab=listed"), flash, - config.is_production(), + config.secure_cookies(), ) } diff --git a/fluxer_admin/src/routes/guilds.rs b/fluxer_admin/src/routes/guilds.rs index b69dc1010..187848f68 100644 --- a/fluxer_admin/src/routes/guilds.rs +++ b/fluxer_admin/src/routes/guilds.rs @@ -191,7 +191,7 @@ async fn guild_detail_post( return flash::redirect_with_flash( &format!("{base}/guilds/{guild_id}"), FlashData::error("Invalid form data"), - config.is_production(), + config.secure_cookies(), ); } }; @@ -203,7 +203,7 @@ async fn guild_detail_post( } else { format!("{base}/guilds/{guild_id}?tab={tab}") }; - flash::redirect_with_flash(&redirect, flash, config.is_production()) + flash::redirect_with_flash(&redirect, flash, config.secure_cookies()) } async fn dispatch_guild_action( diff --git a/fluxer_admin/src/routes/jobs.rs b/fluxer_admin/src/routes/jobs.rs index 077e8403b..d2f9a37fc 100644 --- a/fluxer_admin/src/routes/jobs.rs +++ b/fluxer_admin/src/routes/jobs.rs @@ -187,7 +187,7 @@ async fn job_detail_post( return flash::redirect_with_flash( &format!("{base}/jobs/{job_id}"), FlashData::error("Invalid form data"), - config.is_production(), + config.secure_cookies(), ); } }; @@ -214,7 +214,7 @@ async fn job_detail_post( flash::redirect_with_flash( &format!("{base}/jobs/{job_id}"), flash, - config.is_production(), + config.secure_cookies(), ) } diff --git a/fluxer_admin/src/routes/message_actions.rs b/fluxer_admin/src/routes/message_actions.rs index ca82d1ecd..da9a06e9f 100644 --- a/fluxer_admin/src/routes/message_actions.rs +++ b/fluxer_admin/src/routes/message_actions.rs @@ -48,7 +48,7 @@ pub(crate) async fn messages_post( return flash::redirect_with_flash( &format!("{base}/messages"), FlashData::error("Invalid form data"), - config.is_production(), + config.secure_cookies(), ); } }; @@ -164,7 +164,7 @@ pub(crate) async fn system_dms_post( return flash::redirect_with_flash( &format!("{base}/system-dms"), FlashData::error("Invalid form data"), - config.is_production(), + config.secure_cookies(), ); } }; @@ -184,7 +184,11 @@ pub(crate) async fn system_dms_post( } else { FlashData::error("Recipients and content are required") }; - flash::redirect_with_flash(&format!("{base}/system-dms"), flash, config.is_production()) + flash::redirect_with_flash( + &format!("{base}/system-dms"), + flash, + config.secure_cookies(), + ) } pub(crate) async fn bulk_actions_post( @@ -201,7 +205,7 @@ pub(crate) async fn bulk_actions_post( return flash::redirect_with_flash( &format!("{base}/bulk-actions"), FlashData::error("Invalid form data"), - config.is_production(), + config.secure_cookies(), ); } }; @@ -278,7 +282,7 @@ pub(crate) async fn bulk_actions_post( return flash::redirect_with_flash( &format!("{base}/bulk-actions"), FlashData::error("Unknown bulk action"), - config.is_production(), + config.secure_cookies(), ); } }; @@ -290,7 +294,7 @@ pub(crate) async fn bulk_actions_post( flash::redirect_with_flash( &format!("{base}/bulk-actions"), FlashData::success("Bulk action submitted"), - config.is_production(), + config.secure_cookies(), ) } } @@ -299,7 +303,7 @@ pub(crate) async fn bulk_actions_post( flash::redirect_with_flash( &format!("{base}/bulk-actions"), FlashData::error("Failed to submit bulk action"), - config.is_production(), + config.secure_cookies(), ) } } @@ -405,14 +409,14 @@ pub(crate) async fn archives_download( Ok(_) => flash::redirect_with_flash( &format!("{base}/archives"), FlashData::error("Archive download URL was empty"), - config.is_production(), + config.secure_cookies(), ), Err(error) => { tracing::warn!(%error, "admin API request failed: get archive download URL"); flash::redirect_with_flash( &format!("{base}/archives"), FlashData::error("Failed to create archive download URL"), - config.is_production(), + config.secure_cookies(), ) } } diff --git a/fluxer_admin/src/routes/reports.rs b/fluxer_admin/src/routes/reports.rs index 57d7b3376..0a64d8f8b 100644 --- a/fluxer_admin/src/routes/reports.rs +++ b/fluxer_admin/src/routes/reports.rs @@ -195,7 +195,7 @@ async fn report_resolve( return flash::redirect_with_flash( &format!("{base}/reports/{report_id}"), FlashData::error("Invalid form data"), - config.is_production(), + config.secure_cookies(), ); } }; @@ -212,7 +212,7 @@ async fn report_resolve( flash::redirect_with_flash( &format!("{base}/reports/{report_id}"), FlashData::success("Report resolved"), - config.is_production(), + config.secure_cookies(), ) } Err(error) => { @@ -223,7 +223,7 @@ async fn report_resolve( flash::redirect_with_flash( &format!("{base}/reports/{report_id}"), FlashData::error("Failed to resolve report"), - config.is_production(), + config.secure_cookies(), ) } } diff --git a/fluxer_admin/src/routes/system_actions.rs b/fluxer_admin/src/routes/system_actions.rs index 647c32dd2..c939d5946 100644 --- a/fluxer_admin/src/routes/system_actions.rs +++ b/fluxer_admin/src/routes/system_actions.rs @@ -44,8 +44,8 @@ pub struct ActionQuery { pub rule: Option, } -pub fn redirect_back_with_flash(base: &str, path: &str, fd: FlashData, prod: bool) -> Response { - flash::redirect_with_flash(&format!("{base}{path}"), fd, prod) +pub fn redirect_back_with_flash(base: &str, path: &str, fd: FlashData, secure: bool) -> Response { + flash::redirect_with_flash(&format!("{base}{path}"), fd, secure) } pub async fn gateway_post( @@ -63,7 +63,7 @@ pub async fn gateway_post( base, "/gateway", FlashData::error("Invalid form data"), - config.is_production(), + config.secure_cookies(), ); } }; @@ -80,7 +80,7 @@ pub async fn gateway_post( } else { FlashData::error("Unknown gateway action") }; - redirect_back_with_flash(base, "/gateway", flash, config.is_production()) + redirect_back_with_flash(base, "/gateway", flash, config.secure_cookies()) } pub async fn search_index_post( @@ -97,7 +97,7 @@ pub async fn search_index_post( base, "/search-index", FlashData::error("Invalid form data"), - config.is_production(), + config.secure_cookies(), ); } }; @@ -114,7 +114,7 @@ pub async fn search_index_post( flash::redirect_with_flash( &format!("{base}/search-index?job_id={job_id}"), FlashData::success("Search index refresh started"), - config.is_production(), + config.secure_cookies(), ) } Err(error) => { @@ -123,7 +123,7 @@ pub async fn search_index_post( base, "/search-index", FlashData::error("Failed to start search index refresh"), - config.is_production(), + config.secure_cookies(), ) } }; @@ -132,7 +132,7 @@ pub async fn search_index_post( base, "/search-index", FlashData::error("Index type is required"), - config.is_production(), + config.secure_cookies(), ) } @@ -157,7 +157,7 @@ pub async fn instance_config_post( base, "/instance-config", flash, - config.is_production(), + config.secure_cookies(), ); } }; @@ -320,7 +320,7 @@ pub async fn instance_config_post( if htmx::is_htmx_request(&headers) { return htmx::toast_response(&flash); } - redirect_back_with_flash(base, "/instance-config", flash, config.is_production()) + redirect_back_with_flash(base, "/instance-config", flash, config.secure_cookies()) } fn render_registration_url_list_response( @@ -866,13 +866,13 @@ pub async fn limit_config_post( base, "/limit-config", FlashData::error("Invalid form data"), - config.is_production(), + config.secure_cookies(), ); } }; let client = AdminApiClient::new(state.http_client(), config, &auth.0.session); let action = aq.action.as_deref().unwrap_or(""); - let is_prod = config.is_production(); + let secure_cookies = config.secure_cookies(); let current = match client.get_limit_config().await { Ok(current) => current, Err(error) => { @@ -881,7 +881,7 @@ pub async fn limit_config_post( base, "/limit-config", FlashData::error("Failed to fetch current limit configuration"), - is_prod, + secure_cookies, ); } }; @@ -895,7 +895,7 @@ pub async fn limit_config_post( base, "/limit-config", FlashData::error("Rule not found"), - is_prod, + secure_cookies, ); } }; @@ -908,7 +908,7 @@ pub async fn limit_config_post( base, "/limit-config", FlashData::error("Rule not found"), - is_prod, + secure_cookies, ); }; let fallback = current @@ -923,7 +923,7 @@ pub async fn limit_config_post( "Limit configuration updated", "Failed to update limit configuration", ); - return redirect_back_with_flash(base, "/limit-config", flash, is_prod); + return redirect_back_with_flash(base, "/limit-config", flash, secure_cookies); } "delete" => { let rule_id = match aq.rule.as_deref().and_then(clean_string) { @@ -933,7 +933,7 @@ pub async fn limit_config_post( base, "/limit-config", FlashData::error("Rule not found"), - is_prod, + secure_cookies, ); } }; @@ -942,7 +942,7 @@ pub async fn limit_config_post( base, "/limit-config", FlashData::error("The default rule cannot be deleted"), - is_prod, + secure_cookies, ); } let old_len = limit_config.rules.len(); @@ -952,14 +952,14 @@ pub async fn limit_config_post( base, "/limit-config", FlashData::error("Rule not found"), - is_prod, + secure_cookies, ); } let request = LimitConfigUpdateRequest { limit_config }; let result = client.update_limit_config(&request).await; let flash = limit_config_result(result, "Limit rule deleted", "Failed to delete limit rule"); - return redirect_back_with_flash(base, "/limit-config", flash, is_prod); + return redirect_back_with_flash(base, "/limit-config", flash, secure_cookies); } "create" => { let rule_id = match form.clean("rule_id") { @@ -969,7 +969,7 @@ pub async fn limit_config_post( base, "/limit-config", FlashData::error("Rule ID is required"), - is_prod, + secure_cookies, ); } }; @@ -978,7 +978,7 @@ pub async fn limit_config_post( base, "/limit-config", FlashData::error("The default rule ID is reserved"), - is_prod, + secure_cookies, ); } if limit_config.rules.iter().any(|rule| rule.id == rule_id) { @@ -986,7 +986,7 @@ pub async fn limit_config_post( base, "/limit-config", FlashData::error("Rule ID already exists"), - is_prod, + secure_cookies, ); } let limits = current.defaults.get("default").cloned().unwrap_or_default(); @@ -1000,7 +1000,7 @@ pub async fn limit_config_post( let result = client.update_limit_config(&request).await; let flash = limit_config_result(result, "Limit rule created", "Failed to create limit rule"); - return redirect_back_with_flash(base, "/limit-config", flash, is_prod); + return redirect_back_with_flash(base, "/limit-config", flash, secure_cookies); } _ => {} } @@ -1008,7 +1008,7 @@ pub async fn limit_config_post( base, "/limit-config", FlashData::success("Limit config updated"), - is_prod, + secure_cookies, ) } diff --git a/fluxer_admin/src/routes/users.rs b/fluxer_admin/src/routes/users.rs index 2c230b4d4..c4bc402f0 100644 --- a/fluxer_admin/src/routes/users.rs +++ b/fluxer_admin/src/routes/users.rs @@ -189,7 +189,7 @@ async fn user_detail_post( return flash::redirect_with_flash( &format!("{base}/users/{user_id}"), flash, - config.is_production(), + config.secure_cookies(), ); } }; @@ -208,7 +208,7 @@ async fn user_detail_post( { return htmx::toast_response(&outcome.flash); } - flash::redirect_with_flash(&redirect, outcome.flash, config.is_production()) + flash::redirect_with_flash(&redirect, outcome.flash, config.secure_cookies()) } async fn user_tab( diff --git a/fluxer_admin/src/routes/voice_actions.rs b/fluxer_admin/src/routes/voice_actions.rs index 7fe1b8138..ddbd53801 100644 --- a/fluxer_admin/src/routes/voice_actions.rs +++ b/fluxer_admin/src/routes/voice_actions.rs @@ -160,7 +160,7 @@ pub(crate) async fn voice_regions_post( flash::redirect_with_flash( &format!("{base}/voice-regions"), flash_from_level(level, &msg), - config.is_production(), + config.secure_cookies(), ) } @@ -232,7 +232,7 @@ pub(crate) async fn voice_servers_post( flash::redirect_with_flash( &redirect_url, flash_from_level(level, &msg), - config.is_production(), + config.secure_cookies(), ) } diff --git a/fluxer_admin/tests/htmx_acceptance.rs b/fluxer_admin/tests/htmx_acceptance.rs index 830d19fda..c983ec242 100644 --- a/fluxer_admin/tests/htmx_acceptance.rs +++ b/fluxer_admin/tests/htmx_acceptance.rs @@ -708,11 +708,11 @@ fn csrf_cookie(headers: &HeaderMap) -> Option { .iter() .filter_map(|value| value.to_str().ok()) .find_map(|value| { - value - .split(';') - .next() - .and_then(|pair| pair.strip_prefix("csrf_token=")) - .map(str::to_owned) + let pair = value.split(';').next()?; + let token = pair + .strip_prefix("__Host-csrf_token=") + .or_else(|| pair.strip_prefix("csrf_token="))?; + (!token.is_empty()).then(|| token.to_owned()) }) } diff --git a/fluxer_api/src/api/Config.test.ts b/fluxer_api/src/api/Config.test.ts index 100192961..44b70c7a6 100644 --- a/fluxer_api/src/api/Config.test.ts +++ b/fluxer_api/src/api/Config.test.ts @@ -1,9 +1,10 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import {loadConfig, resetConfig} from '@fluxer/config/src/ConfigLoader'; +import type {MasterConfig} from '@fluxer/config/src/MasterConfig'; import {createServer} from '@fluxer/hono/src/Server'; import {Hono} from 'hono'; -import {afterAll, afterEach, describe, expect, test, vi} from 'vitest'; +import {afterAll, afterEach, beforeAll, describe, expect, it, test, vi} from 'vitest'; import {buildAPIConfigFromMaster, buildAPIServerOptions} from './Config'; interface ListeningServer { @@ -63,3 +64,50 @@ describe('buildAPIServerOptions', () => { expect(server.headersTimeout).toBe(45_000); }); }); + +function withUploadRelaySecret(master: MasterConfig, secretBase64: string): MasterConfig { + return { + ...master, + services: { + ...master.services, + media_proxy: { + ...master.services.media_proxy, + upload_relay: { + ...master.services.media_proxy.upload_relay, + secret_base64: secretBase64, + }, + }, + }, + }; +} + +describe('buildAPIConfigFromMaster upload relay secret', () => { + let master: MasterConfig; + beforeAll(async () => { + master = await loadConfig(); + }); + + it('refuses to build without FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64', () => { + expect(() => buildAPIConfigFromMaster(withUploadRelaySecret(master, ''))).toThrow( + /FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64/, + ); + }); + + it('refuses a secret that decodes to fewer than 32 bytes', () => { + const secret = Buffer.alloc(16, 7).toString('base64'); + expect(() => buildAPIConfigFromMaster(withUploadRelaySecret(master, secret))).toThrow(/at least 32 bytes/); + }); + + it('accepts a secret that decodes to 32 bytes', () => { + const secret = Buffer.alloc(32, 7).toString('base64'); + expect( + buildAPIConfigFromMaster(withUploadRelaySecret(master, secret)).mediaProxy.uploadRelay.relaySecretBase64, + ).toBe(secret); + }); + + it('reads the relay secret from the loaded config rather than the environment', () => { + expect(buildAPIConfigFromMaster(master).mediaProxy.uploadRelay.relaySecretBase64).toBe( + master.services.media_proxy.upload_relay.secret_base64, + ); + }); +}); diff --git a/fluxer_api/src/api/Config.ts b/fluxer_api/src/api/Config.ts index a75d21834..a241d8274 100644 --- a/fluxer_api/src/api/Config.ts +++ b/fluxer_api/src/api/Config.ts @@ -113,17 +113,18 @@ function mapPushProviderApps( project_id?: string; }> | undefined, + configName: string, ): APIConfig['push']['apns']['apps'] { - return (apps ?? []).flatMap((app) => { - if (!app.app_id) return []; - return [ - { - appId: app.app_id, - topic: app.topic, - environment: app.environment, - projectId: app.project_id, - }, - ]; + return (apps ?? []).map((app) => { + if (!app.app_id) { + throw new Error(`${configName} contains an entry with no app_id`); + } + return { + appId: app.app_id, + topic: app.topic, + environment: app.environment, + projectId: app.project_id, + }; }); } @@ -139,7 +140,13 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig { serviceName: 'api', }); const uploadRelayConfig = master.services.media_proxy.upload_relay; - const uploadRelaySecretBase64 = process.env.FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64 ?? ''; + const uploadRelaySecretBase64 = uploadRelayConfig.secret_base64; + if (uploadRelaySecretBase64.length === 0) { + throw new Error('FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64 is required for the API'); + } + if (Buffer.from(uploadRelaySecretBase64, 'base64').length < 32) { + throw new Error('FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64 must decode to at least 32 bytes'); + } if (!s3Config) { throw new Error('S3 configuration is required for the API'); } @@ -149,7 +156,6 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig { downloads: '', reports: '', harvests: '', - static: '', }; if (master.database.backend === 'cassandra' && !cassandraSource) { throw new Error('Cassandra configuration is required.'); @@ -494,7 +500,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig { privateKey: master.integrations.push.apns.private_key, privateKeyPath: master.integrations.push.apns.private_key_path, defaultEnvironment: master.integrations.push.apns.default_environment ?? 'production', - apps: mapPushProviderApps(master.integrations.push.apns.apps), + apps: mapPushProviderApps(master.integrations.push.apns.apps, 'FLUXER_PUSH_APNS_APPS'), }, fcm: { enabled: master.integrations.push.fcm.enabled, @@ -504,7 +510,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig { privateKeyPath: master.integrations.push.fcm.private_key_path, serviceAccountJsonPath: master.integrations.push.fcm.service_account_json_path, tokenUri: master.integrations.push.fcm.token_uri ?? 'https://oauth2.googleapis.com/token', - apps: mapPushProviderApps(master.integrations.push.fcm.apps), + apps: mapPushProviderApps(master.integrations.push.fcm.apps, 'FLUXER_PUSH_FCM_APPS'), }, }, worker: { diff --git a/fluxer_api/src/api/bluesky/BlueskyOAuthService.test.ts b/fluxer_api/src/api/bluesky/BlueskyOAuthService.test.ts new file mode 100644 index 000000000..72611ab93 --- /dev/null +++ b/fluxer_api/src/api/bluesky/BlueskyOAuthService.test.ts @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {generateKeyPairSync} from 'node:crypto'; +import {describe, expect, it} from 'vitest'; +import type {BlueskyOAuthConfig} from '../config/APIConfig'; +import {MockKVProvider} from '../test/mocks/MockKVProvider'; +import {BlueskyOAuthService} from './BlueskyOAuthService'; + +const API_PUBLIC_ENDPOINT = 'https://chat.example.com'; + +function generateSigningKey(): string { + const {privateKey} = generateKeyPairSync('ec', { + namedCurve: 'P-256', + privateKeyEncoding: {type: 'pkcs8', format: 'pem'}, + publicKeyEncoding: {type: 'spki', format: 'pem'}, + }); + return privateKey; +} + +function buildConfig(overrides: Partial = {}): BlueskyOAuthConfig { + return { + enabled: true, + client_name: 'Fluxer', + client_uri: '', + logo_uri: '', + tos_uri: '', + policy_uri: '', + keys: [{kid: 'test-key', private_key: generateSigningKey()}], + ...overrides, + }; +} + +async function serveClientMetadata(config: BlueskyOAuthConfig): Promise> { + const service = await BlueskyOAuthService.create(config, new MockKVProvider(), API_PUBLIC_ENDPOINT); + return JSON.parse(JSON.stringify(service.clientMetadata)) as Record; +} + +describe('BlueskyOAuthService', () => { + it('omits the legal URLs from the served client document when none are configured', async () => { + const metadata = await serveClientMetadata(buildConfig()); + + expect(metadata).not.toHaveProperty('tos_uri'); + expect(metadata).not.toHaveProperty('policy_uri'); + expect(metadata).not.toHaveProperty('logo_uri'); + expect(metadata.client_id).toBe(`${API_PUBLIC_ENDPOINT}/connections/bluesky/client-metadata.json`); + }); + + it('echoes configured legal URLs verbatim', async () => { + const metadata = await serveClientMetadata( + buildConfig({ + tos_uri: 'https://chat.example.com/terms', + policy_uri: 'https://chat.example.com/privacy', + }), + ); + + expect(metadata.tos_uri).toBe('https://chat.example.com/terms'); + expect(metadata.policy_uri).toBe('https://chat.example.com/privacy'); + }); +}); diff --git a/fluxer_api/src/api/config/APIConfig.ts b/fluxer_api/src/api/config/APIConfig.ts index dfd4132d0..c38e0a870 100644 --- a/fluxer_api/src/api/config/APIConfig.ts +++ b/fluxer_api/src/api/config/APIConfig.ts @@ -153,7 +153,6 @@ export interface APIConfig { reports: string; harvests: string; downloads: string; - static: string; }; }; s3Downloads: ResolvedDownloadsProvider; diff --git a/fluxer_api/src/api/download/DownloadService.ts b/fluxer_api/src/api/download/DownloadService.ts index 4fd040b97..89e64f6cc 100644 --- a/fluxer_api/src/api/download/DownloadService.ts +++ b/fluxer_api/src/api/download/DownloadService.ts @@ -61,7 +61,6 @@ const MAX_DESKTOP_OBJECTS_PER_PREFIX = 10_000; const DESKTOP_BUCKET_PREFIX = 'desktop'; const DESKTOP_TEST_BUCKET_PREFIX = 'desktop-test'; const DOWNLOAD_KEY_ALLOWED_PREFIXES = [`${DESKTOP_BUCKET_PREFIX}/`, `${DESKTOP_TEST_BUCKET_PREFIX}/`]; -const DEFAULT_API_CLIENT_BASE_URL = 'https://api.fluxer.app'; const GITHUB_RELEASE_DOWNLOAD_BASE_URL = 'https://github.com/fluxerapp/fluxer/releases/download'; const GITHUB_RELEASE_MARKER_DIRECTORY = 'github-releases'; @@ -687,11 +686,7 @@ export class DownloadService { } private buildBaseUrl(baseUrl?: string): string { - const configuredBaseUrl = (baseUrl ?? Config.endpoints.apiClient).trim(); - if (configuredBaseUrl.length > 0) { - return configuredBaseUrl.replace(/\/+$/u, ''); - } - return DEFAULT_API_CLIENT_BASE_URL; + return (baseUrl ?? Config.endpoints.apiClient).trim().replace(/\/+$/u, ''); } private buildDesktopVersionUrl(params: { diff --git a/fluxer_api/src/api/download/tests/DownloadServiceBaseUrl.test.ts b/fluxer_api/src/api/download/tests/DownloadServiceBaseUrl.test.ts new file mode 100644 index 000000000..1a746f133 --- /dev/null +++ b/fluxer_api/src/api/download/tests/DownloadServiceBaseUrl.test.ts @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {Readable} from 'node:stream'; +import {describe, expect, it} from 'vitest'; +import {Config} from '../../Config'; +import type {IStorageService} from '../../infrastructure/IStorageService'; +import {DownloadService} from '../DownloadService'; + +const PREFIX = 'desktop/stable/darwin/x64'; +const MANIFEST_KEY = `${PREFIX}/manifest.json`; +const FILENAME = 'Fluxer-1.3.0-mac-universal.dmg'; +const SHA256 = 'a'.repeat(64); + +const LATEST_PARAMS = { + channel: 'stable', + plat: 'darwin', + arch: 'x64', +} as const; + +const MANIFEST_BODY = JSON.stringify({ + channel: 'stable', + platform: 'darwin', + arch: 'x64', + version: '1.3.0', + pub_date: '2026-08-17T00:00:00Z', + files: {dmg: {filename: FILENAME, sha256: SHA256}}, +}); + +function createService() { + const objectKeys = [`${PREFIX}/${FILENAME}`]; + const storageService = { + streamObject: async (params: {key: string}) => { + if (params.key !== MANIFEST_KEY) { + return null; + } + const buffer = Buffer.from(MANIFEST_BODY, 'utf8'); + return {body: Readable.from([buffer]), contentLength: buffer.byteLength}; + }, + listObjects: async () => objectKeys.map((key) => ({key})), + getObjectMetadata: async (_bucket: string, key: string) => + objectKeys.includes(key) ? {contentLength: 1, contentType: 'application/x-apple-diskimage'} : null, + } as unknown as IStorageService; + return new DownloadService(storageService); +} + +describe('desktop download base url', () => { + it('builds artifact urls from the configured client API endpoint', async () => { + const version = await createService().getLatestDesktopVersion({...LATEST_PARAMS}); + const base = Config.endpoints.apiClient.replace(/\/+$/u, ''); + expect(base.length).toBeGreaterThan(0); + expect(version?.files.dmg?.url).toBe(`${base}/dl/desktop/stable/darwin/x64/1.3.0/dmg`); + expect(version?.files.dmg?.checksum_url).toBe(`${base}/dl/desktop/stable/darwin/x64/1.3.0/dmg.sha256`); + }); + + it('prefers an explicit base url and strips its trailing slashes', async () => { + const version = await createService().getLatestDesktopVersion({ + ...LATEST_PARAMS, + baseUrl: 'https://chat.example.com/api//', + }); + expect(version?.files.dmg?.url).toBe('https://chat.example.com/api/dl/desktop/stable/darwin/x64/1.3.0/dmg'); + }); +}); diff --git a/fluxer_api/src/api/infrastructure/StorageService.test.ts b/fluxer_api/src/api/infrastructure/StorageService.test.ts index a8cf27b23..297ee9a19 100644 --- a/fluxer_api/src/api/infrastructure/StorageService.test.ts +++ b/fluxer_api/src/api/infrastructure/StorageService.test.ts @@ -44,7 +44,6 @@ interface S3BucketConfigOverrides { downloads?: string; reports?: string; harvests?: string; - static?: string; } interface S3ConfigOverrides { diff --git a/fluxer_api/src/api/instance/InstanceConfigRepository.test.ts b/fluxer_api/src/api/instance/InstanceConfigRepository.test.ts index 1a57cf90a..4f603f1d7 100644 --- a/fluxer_api/src/api/instance/InstanceConfigRepository.test.ts +++ b/fluxer_api/src/api/instance/InstanceConfigRepository.test.ts @@ -115,6 +115,38 @@ describe('InstanceConfigRepository', () => { }); }); + it('reports the effective captcha provider as none while the selected pair is incomplete', async () => { + const executor = new CountingInMemoryCassandraQueryExecutor(); + setCassandraQueryExecutorForTesting(executor); + const kvProvider = new MockKVProvider(); + const repository = createRepository(kvProvider); + + await repository.setInstanceIntegrationsConfig({ + captcha: { + provider: 'turnstile', + hcaptcha_site_key: 'hcaptcha-site-key', + hcaptcha_secret_key: 'hcaptcha-secret-key', + }, + }); + + await expect(repository.getEffectiveCaptchaConfig()).resolves.toMatchObject({ + enabled: false, + provider: 'none', + }); + + await repository.setInstanceIntegrationsConfig({ + captcha: { + turnstile_site_key: 'turnstile-site-key', + turnstile_secret_key: 'turnstile-secret-key', + }, + }); + + await expect(repository.getEffectiveCaptchaConfig()).resolves.toMatchObject({ + enabled: true, + provider: 'turnstile', + }); + }); + it('uses the registration URL id as the admin-visible registration code', async () => { const executor = new CountingInMemoryCassandraQueryExecutor(); setCassandraQueryExecutorForTesting(executor); diff --git a/fluxer_api/src/api/instance/InstanceConfigRepository.ts b/fluxer_api/src/api/instance/InstanceConfigRepository.ts index 7fda95978..3fa26bf78 100644 --- a/fluxer_api/src/api/instance/InstanceConfigRepository.ts +++ b/fluxer_api/src/api/instance/InstanceConfigRepository.ts @@ -1262,8 +1262,8 @@ export class InstanceConfigRepository { ? Boolean(turnstileSiteKey && turnstileSecretKey) : false; return { - enabled: provider !== 'none' && providerReady, - provider, + enabled: providerReady, + provider: providerReady ? provider : 'none', hcaptcha_site_key: hcaptchaSiteKey, hcaptcha_secret_key: hcaptchaSecretKey, turnstile_site_key: turnstileSiteKey, diff --git a/fluxer_api/src/api/instance/InstanceController.test.ts b/fluxer_api/src/api/instance/InstanceController.test.ts new file mode 100644 index 000000000..1d9f6d05b --- /dev/null +++ b/fluxer_api/src/api/instance/InstanceController.test.ts @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {Hono} from 'hono'; +import {afterEach, describe, expect, it} from 'vitest'; +import type {SsoService} from '../auth/services/SsoService'; +import {setCassandraQueryExecutorForTesting} from '../database/CassandraQueryExecution'; +import type {LimitConfigService} from '../limits/LimitConfigService'; +import {InMemoryCassandraQueryExecutor} from '../test/InMemoryCassandraQueryExecutor'; +import {MockKVProvider} from '../test/mocks/MockKVProvider'; +import type {HonoEnv} from '../types/HonoEnv'; +import {InstanceConfigRepository} from './InstanceConfigRepository'; +import {InstanceController} from './InstanceController'; + +interface DiscoveryCaptcha { + provider: string; + hcaptcha_site_key: string | null; + turnstile_site_key: string | null; +} + +describe('InstanceController discovery captcha', () => { + const repositories: Array = []; + + afterEach(() => { + for (const repository of repositories) { + repository.shutdown(); + } + repositories.length = 0; + }); + + function createRepository(): InstanceConfigRepository { + setCassandraQueryExecutorForTesting(new InMemoryCassandraQueryExecutor()); + const repository = new InstanceConfigRepository(new MockKVProvider()); + repositories.push(repository); + return repository; + } + + function createApp(repository: InstanceConfigRepository): Hono { + const app = new Hono({strict: true}); + app.use('*', async (ctx, next) => { + ctx.set('instanceConfigRepository', repository); + ctx.set('limitConfigService', { + getConfigWireFormat: () => ({version: 2, traitDefinitions: [], rules: [], defaultsHash: 'test'}), + } as unknown as LimitConfigService); + ctx.set('ssoService', { + getPublicStatus: async () => ({ + enabled: false, + enforced: false, + display_name: null, + redirect_uri: '', + }), + } as unknown as SsoService); + await next(); + }); + InstanceController(app); + return app; + } + + async function readCaptcha(repository: InstanceConfigRepository): Promise { + const response = await createApp(repository).request('http://localhost/.well-known/fluxer'); + expect(response.status).toBe(200); + return ((await response.json()) as {captcha: DiscoveryCaptcha}).captcha; + } + + it('advertises no provider and no site key while the selected pair is incomplete', async () => { + const repository = createRepository(); + await repository.setInstanceIntegrationsConfig({ + captcha: { + provider: 'turnstile', + hcaptcha_site_key: 'hcaptcha-site-key', + hcaptcha_secret_key: 'hcaptcha-secret-key', + }, + }); + + await expect(readCaptcha(repository)).resolves.toEqual({ + provider: 'none', + hcaptcha_site_key: null, + turnstile_site_key: null, + }); + }); + + it('advertises only the site key that matches the named provider', async () => { + const repository = createRepository(); + await repository.setInstanceIntegrationsConfig({ + captcha: { + provider: 'turnstile', + hcaptcha_site_key: 'hcaptcha-site-key', + hcaptcha_secret_key: 'hcaptcha-secret-key', + turnstile_site_key: 'turnstile-site-key', + turnstile_secret_key: 'turnstile-secret-key', + }, + }); + + await expect(readCaptcha(repository)).resolves.toEqual({ + provider: 'turnstile', + hcaptcha_site_key: null, + turnstile_site_key: 'turnstile-site-key', + }); + }); +}); diff --git a/fluxer_api/src/api/instance/InstanceController.ts b/fluxer_api/src/api/instance/InstanceController.ts index 4d43fa7ea..e4dea5c3b 100644 --- a/fluxer_api/src/api/instance/InstanceController.ts +++ b/fluxer_api/src/api/instance/InstanceController.ts @@ -57,8 +57,8 @@ function buildDiscoveryStaticInput( }, captcha: { provider: runtime.captcha.provider, - hcaptcha_site_key: runtime.captcha.hcaptcha_site_key, - turnstile_site_key: runtime.captcha.turnstile_site_key, + hcaptcha_site_key: runtime.captcha.provider === 'hcaptcha' ? runtime.captcha.hcaptcha_site_key : null, + turnstile_site_key: runtime.captcha.provider === 'turnstile' ? runtime.captcha.turnstile_site_key : null, }, features: { voice_enabled: Config.voice.enabled, diff --git a/fluxer_api/src/api/middleware/CaptchaMiddleware.ts b/fluxer_api/src/api/middleware/CaptchaMiddleware.ts index 4336b1670..f26a8c904 100644 --- a/fluxer_api/src/api/middleware/CaptchaMiddleware.ts +++ b/fluxer_api/src/api/middleware/CaptchaMiddleware.ts @@ -45,7 +45,7 @@ function resolveCaptchaProvider( } const secretKey = resolveProviderSecret(config, requestedProvider); if (!secretKey) { - throw new Error(`Captcha provider ${requestedProvider} is enabled but has no configured secret key`); + throw new InvalidCaptchaError(); } return createCaptchaProvider({mode: requestedProvider, secretKey}); } diff --git a/fluxer_api/src/api/middleware/tests/CaptchaProviderHeader.test.ts b/fluxer_api/src/api/middleware/tests/CaptchaProviderHeader.test.ts new file mode 100644 index 000000000..dfc9c8613 --- /dev/null +++ b/fluxer_api/src/api/middleware/tests/CaptchaProviderHeader.test.ts @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {AppErrorHandler} from '@fluxer/errors/src/domains/core/ErrorHandlers'; +import {Hono} from 'hono'; +import {afterEach, beforeEach, describe, expect, it} from 'vitest'; +import {Config} from '../../Config'; +import type {InstanceCaptchaEffectiveConfig, InstanceConfigRepository} from '../../instance/InstanceConfigRepository'; +import type {HonoEnv} from '../../types/HonoEnv'; +import {CaptchaMiddleware} from '../CaptchaMiddleware'; + +const HCAPTCHA_ONLY: InstanceCaptchaEffectiveConfig = { + enabled: true, + provider: 'hcaptcha', + hcaptcha_site_key: 'hcaptcha-site-key', + hcaptcha_secret_key: 'hcaptcha-secret-key', + turnstile_site_key: null, + turnstile_secret_key: null, +}; + +function createHarness( + captcha: InstanceCaptchaEffectiveConfig, +): (headers: Record) => Promise { + const repository = { + getEffectiveCaptchaConfig: async () => captcha, + } as unknown as InstanceConfigRepository; + const app = new Hono(); + app.use(async (ctx, next) => { + ctx.set('instanceConfigRepository', repository); + await next(); + }); + app.use(CaptchaMiddleware); + app.post('/auth/register', (ctx) => ctx.text('ok')); + app.onError(AppErrorHandler); + return async (headers) => app.request('http://localhost/auth/register', {method: 'POST', headers}); +} + +describe('CaptchaMiddleware provider header', () => { + let previousTestModeEnabled: boolean; + + beforeEach(() => { + previousTestModeEnabled = Config.dev.testModeEnabled; + Config.dev.testModeEnabled = false; + }); + + afterEach(() => { + Config.dev.testModeEnabled = previousTestModeEnabled; + }); + + it('rejects a header naming a provider the instance holds no secret key for with 400 INVALID_CAPTCHA', async () => { + const request = createHarness(HCAPTCHA_ONLY); + const response = await request({'x-captcha-token': 'solution', 'x-captcha-type': 'turnstile'}); + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({code: 'INVALID_CAPTCHA'}); + }); + + it('rejects a request with no proof with 400 CAPTCHA_REQUIRED', async () => { + const request = createHarness(HCAPTCHA_ONLY); + const response = await request({}); + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({code: 'CAPTCHA_REQUIRED'}); + }); +}); diff --git a/fluxer_api/src/api/openapi/OpenAPIController.ts b/fluxer_api/src/api/openapi/OpenAPIController.ts index 88f576f9f..36a683b74 100644 --- a/fluxer_api/src/api/openapi/OpenAPIController.ts +++ b/fluxer_api/src/api/openapi/OpenAPIController.ts @@ -2,18 +2,29 @@ import * as fs from 'node:fs'; import type {Hono} from 'hono'; +import {Config} from '../Config'; import {RateLimitMiddleware} from '../middleware/RateLimitMiddleware'; import {RateLimitConfigs} from '../RateLimitConfig'; import type {HonoEnv} from '../types/HonoEnv'; import {resolveAssetPath} from '../utils/AssetPaths'; const SPEC_PATH = resolveAssetPath('openapi', 'openapi.json'); -const SPEC_BODY = fs.readFileSync(SPEC_PATH, 'utf-8'); +const SPEC_DOCUMENT = JSON.parse(fs.readFileSync(SPEC_PATH, 'utf-8')) as Record; + +let specBody: string | null = null; + +export function buildOpenAPISpecBody(apiClientEndpoint: string): string { + return JSON.stringify({ + ...SPEC_DOCUMENT, + servers: [{url: `${apiClientEndpoint.trim().replace(/\/+$/u, '')}/v1`, description: 'This deployment'}], + }); +} export function OpenAPIController(app: Hono): void { app.get('/openapi.json', RateLimitMiddleware(RateLimitConfigs.INSTANCE_INFO), (ctx) => { + specBody ??= buildOpenAPISpecBody(Config.endpoints.apiClient); ctx.header('Access-Control-Allow-Origin', '*'); ctx.header('Content-Type', 'application/json; charset=utf-8'); - return ctx.body(SPEC_BODY); + return ctx.body(specBody); }); } diff --git a/fluxer_api/src/api/openapi/tests/OpenAPIServerEntry.test.ts b/fluxer_api/src/api/openapi/tests/OpenAPIServerEntry.test.ts new file mode 100644 index 000000000..efa81fd3d --- /dev/null +++ b/fluxer_api/src/api/openapi/tests/OpenAPIServerEntry.test.ts @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {Hono} from 'hono'; +import {describe, expect, it} from 'vitest'; +import {Config} from '../../Config'; +import type {HonoEnv} from '../../types/HonoEnv'; +import {buildOpenAPISpecBody, OpenAPIController} from '../OpenAPIController'; + +interface ServerEntry { + url: string; + description: string; +} + +function parseServers(body: string): Array { + return (JSON.parse(body) as {servers: Array}).servers; +} + +describe('OpenAPI server entry', () => { + it('declares the deployment client API endpoint', () => { + expect(parseServers(buildOpenAPISpecBody('https://chat.example.com/api'))).toEqual([ + {url: 'https://chat.example.com/api/v1', description: 'This deployment'}, + ]); + }); + + it('strips trailing slashes from the configured endpoint', () => { + expect(parseServers(buildOpenAPISpecBody('https://chat.example.com/api//'))[0].url).toBe( + 'https://chat.example.com/api/v1', + ); + }); + + it('serves the configured endpoint instead of an upstream host', async () => { + const app = new Hono(); + OpenAPIController(app); + const response = await app.request('/openapi.json'); + expect(response.status).toBe(200); + const spec = (await response.json()) as {servers: Array; paths: Record}; + expect(spec.servers).toEqual([ + {url: `${Config.endpoints.apiClient.replace(/\/+$/u, '')}/v1`, description: 'This deployment'}, + ]); + expect(spec.servers[0].url).not.toContain('api.fluxer.app'); + expect(Object.keys(spec.paths).length).toBeGreaterThan(0); + }); +}); diff --git a/fluxer_api/src/api/test/Setup.ts b/fluxer_api/src/api/test/Setup.ts index f850f870b..f872d48e8 100644 --- a/fluxer_api/src/api/test/Setup.ts +++ b/fluxer_api/src/api/test/Setup.ts @@ -68,11 +68,10 @@ function setDefaultTestEnv(): void { FLUXER_APP_PROXY_PORT: '8773', FLUXER_GATEWAY_MEDIA_PROXY_ENDPOINT: 'http://127.0.0.1:8088/media', FLUXER_GATEWAY_RPC_AUTH_TOKEN: 'test-gateway-rpc-token', - FLUXER_GATEWAY_PUSH_ENABLED: 'false', FLUXER_SUDO_MODE_SECRET: 'test-sudo-secret', FLUXER_CONNECTION_INITIATION_SECRET: 'test-connection-secret', - FLUXER_VAPID_PUBLIC_KEY: 'test-vapid-public-key', - FLUXER_VAPID_PRIVATE_KEY: 'test-vapid-private-key', + FLUXER_VAPID_PUBLIC_KEY: 'BB76bTFIuoqmxJtTfZX0yGTn1f_qu9H03B_nkj8OyExJFkN7Y-HBZZzShnHZoEhXKc5ZRy3jFu7OkBbnaQG-4aw', + FLUXER_VAPID_PRIVATE_KEY: 'Xgi-3P8J-I3Q6U1HlCcXMuc_tKLGAM9nIfznX3Hz68o', FLUXER_VAPID_EMAIL: 'test@example.com', FLUXER_PASSKEY_RP_NAME: 'Fluxer Test', FLUXER_PASSKEY_RP_ID: 'localhost', diff --git a/fluxer_gateway/src/gateway/fluxer_gateway_config.erl b/fluxer_gateway/src/gateway/fluxer_gateway_config.erl index 9dab9b6a8..b38c08a79 100644 --- a/fluxer_gateway/src/gateway/fluxer_gateway_config.erl +++ b/fluxer_gateway/src/gateway/fluxer_gateway_config.erl @@ -398,22 +398,27 @@ characters_to_binary_or_default(Value, Default) -> -spec env_int(string(), integer()) -> integer(). env_int(Name, Default) -> - parse_env_int(os:getenv(Name), Default). + parse_env_int(Name, os:getenv(Name), Default). --spec parse_env_int(false | string(), integer()) -> integer(). -parse_env_int(false, Default) -> +-spec parse_env_int(string(), false | string(), integer()) -> integer(). +parse_env_int(_Name, false, Default) -> Default; -parse_env_int("", Default) -> +parse_env_int(_Name, "", Default) -> Default; -parse_env_int(Value, Default) -> - parse_int(Value, Default). +parse_env_int(Name, Value, Default) -> + parse_int(Name, Value, Default). --spec parse_int(string(), integer()) -> integer(). -parse_int(Value, Default) -> - try list_to_integer(Value) of - Parsed -> Parsed - catch - error:badarg -> Default +-spec parse_int(string(), string(), integer()) -> integer(). +parse_int(Name, Value, Default) -> + case string:trim(Value) of + "" -> + Default; + Trimmed -> + try list_to_integer(Trimmed) of + Parsed -> Parsed + catch + error:badarg -> erlang:error({invalid_integer_env, Name, Value}) + end end. -spec env_bool(string(), boolean()) -> boolean(). @@ -573,7 +578,7 @@ parse_node_list([], _Remaining, Acc) -> parse_node_list([Peer | Rest], Remaining, Acc) -> case gateway_node_name:from_string(Peer) of {ok, Node} -> parse_node_list(Rest, Remaining - 1, [Node | Acc]); - error -> parse_node_list(Rest, Remaining, Acc) + error -> erlang:error({invalid_cluster_static_peer, Peer}) end. -spec normalize_log_level(term()) -> log_level() | undefined. diff --git a/fluxer_gateway/src/push/push.erl b/fluxer_gateway/src/push/push.erl index 3a7aabf01..eefc28a1b 100644 --- a/fluxer_gateway/src/push/push.erl +++ b/fluxer_gateway/src/push/push.erl @@ -906,13 +906,15 @@ schedule_eviction() -> maybe_warn_vapid_misconfigured(true) -> Public = fluxer_gateway_env:get(vapid_public_key), Private = fluxer_gateway_env:get(vapid_private_key), - case - is_binary(Public) andalso is_binary(Private) andalso - byte_size(Public) > 0 andalso byte_size(Private) > 0 - of - true -> - ok; - false -> + case {Public, Private} of + {Public0, Private0} when + is_binary(Public0), + is_binary(Private0), + byte_size(Public0) > 0, + byte_size(Private0) > 0 + -> + warn_unless_vapid_pair_valid(Public0, Private0); + _ -> logger:error( "Push: push_enabled=true but VAPID keys are missing or empty; " "all web push notifications will be silently dropped" @@ -922,6 +924,21 @@ maybe_warn_vapid_misconfigured(true) -> maybe_warn_vapid_misconfigured(_) -> ok. +-spec warn_unless_vapid_pair_valid(binary(), binary()) -> ok. +warn_unless_vapid_pair_valid(Public, Private) -> + try + push_utils:assert_vapid_pair(Public, Private) + catch + _:Reason -> + logger:error( + "Push: FLUXER_VAPID_PUBLIC_KEY and FLUXER_VAPID_PRIVATE_KEY are not a " + "valid base64url P-256 pair; expected a 65-byte 0x04-prefixed point and " + "a 32-byte scalar; all web push notifications will be silently dropped", + #{reason => Reason} + ), + ok + end. + -spec env_boolean(atom()) -> boolean(). env_boolean(Key) -> case fluxer_gateway_env:get(Key) of diff --git a/fluxer_gateway/src/push/push_utils.erl b/fluxer_gateway/src/push/push_utils.erl index cd6c3c307..43f8c3bc8 100644 --- a/fluxer_gateway/src/push/push_utils.erl +++ b/fluxer_gateway/src/push/push_utils.erl @@ -9,6 +9,7 @@ get_default_avatar_url/1, extract_origin/1, generate_vapid_token/3, + assert_vapid_pair/2, generate_jwt_from_pem/3, base64url_encode/1, base64url_decode/1, @@ -166,6 +167,31 @@ build_ec_jwk(PrivRaw, PubRaw) -> unwrap_jwk({JW, _Fields}) -> JW; unwrap_jwk(JW) -> JW. +-spec assert_vapid_pair(binary(), binary()) -> ok. +assert_vapid_pair(PublicKeyB64Url, PrivateKeyB64Url) -> + ensure_crypto_started(), + PubRaw = decode_or_error(PublicKeyB64Url, invalid_public_key), + PrivRaw = decode_or_error(PrivateKeyB64Url, invalid_private_key), + case {PubRaw, PrivRaw} of + {<<4, _:64/binary>>, <<_:32/binary>>} -> + assert_vapid_scalar_derives_point(PublicKeyB64Url, PubRaw, PrivRaw); + _ -> + erlang:error({vapid_keys_malformed, byte_size(PubRaw), byte_size(PrivRaw)}) + end. + +-spec assert_vapid_scalar_derives_point(binary(), binary(), binary()) -> ok. +assert_vapid_scalar_derives_point(PublicKeyB64Url, PubRaw, PrivRaw) -> + Derived = + try crypto:generate_key(ecdh, prime256v1, PrivRaw) of + {Point, _} -> Point + catch + _:_ -> undefined + end, + case Derived of + PubRaw -> ok; + _ -> erlang:error({vapid_keys_mismatched, PublicKeyB64Url}) + end. + -spec sign_and_compact(term(), map(), map()) -> binary(). sign_and_compact(JWK, Header, Claims) -> JWS = jose_jwt:sign(JWK, Header, Claims), diff --git a/fluxer_gateway/test/fluxer_gateway_config_tests.erl b/fluxer_gateway/test/fluxer_gateway_config_tests.erl index 21b4d425a..5e2777116 100644 --- a/fluxer_gateway/test/fluxer_gateway_config_tests.erl +++ b/fluxer_gateway/test/fluxer_gateway_config_tests.erl @@ -45,7 +45,7 @@ cluster_overrides_test() -> maps:get(cluster_static_peers, Config) ). -cluster_static_peers_filters_invalid_node_names_test() -> +cluster_static_peers_rejects_invalid_node_names_test() -> LongPeer = list_to_binary(lists:duplicate(260, $a)), RawConfig = #{ <<"services">> => #{ @@ -60,6 +60,20 @@ cluster_static_peers_filters_invalid_node_names_test() -> } } }, + ?assertError( + {invalid_cluster_static_peer, "invalid peer@127.0.0.2"}, + fluxer_gateway_config:build_config(RawConfig) + ). + +cluster_static_peers_accepts_valid_node_names_test() -> + RawConfig = #{ + <<"services">> => #{ + <<"gateway">> => #{ + <<"cluster_static_peers">> => + <<"valid_peer@127.0.0.1,other-valid@node.local">> + } + } + }, Config = fluxer_gateway_config:build_config(RawConfig), ?assertEqual( [list_to_atom("valid_peer@127.0.0.1"), list_to_atom("other-valid@node.local")], @@ -140,6 +154,20 @@ rpc_concurrency_keys_are_independent_test() -> end ). +env_int_rejects_a_non_integer_value_test() -> + with_env("FLUXER_GATEWAY_HTTP_RPC_MAX_CONCURRENCY", "abc", fun() -> + ?assertError( + {invalid_integer_env, "FLUXER_GATEWAY_HTTP_RPC_MAX_CONCURRENCY", "abc"}, + fluxer_gateway_config:load() + ) + end). + +env_int_falls_back_to_the_default_for_an_empty_value_test() -> + with_env("FLUXER_GATEWAY_HTTP_RPC_MAX_CONCURRENCY", "", fun() -> + Config = fluxer_gateway_config:load(), + ?assertEqual(512, maps:get(gateway_http_rpc_max_concurrency, Config)) + end). + rpc_concurrency_key_defaults_test() -> Config = fluxer_gateway_config:build_config(#{}), ?assertEqual(512, maps:get(gateway_nats_rpc_max_handlers, Config)), diff --git a/fluxer_gateway/test/push_tests.erl b/fluxer_gateway/test/push_tests.erl index ea4bc1fd0..7d66a3ff5 100644 --- a/fluxer_gateway/test/push_tests.erl +++ b/fluxer_gateway/test/push_tests.erl @@ -160,6 +160,30 @@ prefetch_user_guild_settings_skips_direct_messages_test() -> end), ?assertEqual([], settings_requests()). +init_logs_a_mismatched_vapid_pair_test() -> + with_push_env( + fun() -> + {Pub, _} = generate_vapid_pair(), + {_, OtherPriv} = generate_vapid_pair(), + patch_vapid(true, Pub, OtherPriv), + {ok, Pid} = with_captured_logs(fun() -> push:start_link() end), + ?assert(is_process_alive(Pid)), + ?assert(any_error_log_mentions("FLUXER_VAPID_PUBLIC_KEY")), + gen_server:stop(Pid) + end + ). + +init_accepts_a_matching_vapid_pair_test() -> + with_push_env( + fun() -> + {Pub, Priv} = generate_vapid_pair(), + patch_vapid(true, Pub, Priv), + {ok, Pid} = push:start_link(), + ?assert(is_process_alive(Pid)), + gen_server:stop(Pid) + end + ). + with_rpc_client_stub(Result, Fun) -> Self = self(), ok = meck:new(rpc_client, [passthrough, no_link]), @@ -184,6 +208,70 @@ settings_requests() -> [] end. +with_push_env(Fun) -> + push_ets_cache:init(), + push_worker_pool:init_counter(), + OldConfig = fluxer_gateway_env:get_map(), + OldTrap = erlang:process_flag(trap_exit, true), + try + Fun() + after + _ = erlang:process_flag(trap_exit, OldTrap), + flush_exit_signals(), + _ = fluxer_gateway_env:update(fun(_) -> OldConfig end) + end. + +with_captured_logs(Fun) -> + Self = self(), + ok = logger:add_primary_filter( + capture_logs, { + fun(Event, Pid) -> + Pid ! {captured_log, Event}, + stop + end, + Self + } + ), + try + Fun() + after + _ = logger:remove_primary_filter(capture_logs) + end. + +any_error_log_mentions(Needle) -> + receive + {captured_log, #{level := error, msg := {string, Message}}} -> + case string:find(Message, Needle) of + nomatch -> any_error_log_mentions(Needle); + _ -> true + end; + {captured_log, _} -> + any_error_log_mentions(Needle) + after 0 -> + false + end. + +patch_vapid(Enabled, Pub, Priv) -> + _ = fluxer_gateway_env:patch(#{ + push_enabled => Enabled, + vapid_public_key => push_utils:base64url_encode(Pub), + vapid_private_key => push_utils:base64url_encode(Priv) + }), + ok. + +generate_vapid_pair() -> + case crypto:generate_key(ecdh, prime256v1) of + {<<4, _:64/binary>> = Pub, <<_:32/binary>> = Priv} -> {Pub, Priv}; + _ -> generate_vapid_pair() + end. + +flush_exit_signals() -> + receive + {'EXIT', _, _} -> flush_exit_signals() + after 0 -> + ok + end. + erase_persistent_term(Key) -> try persistent_term:erase(Key) of _ -> ok diff --git a/fluxer_gateway/test/push_utils_tests.erl b/fluxer_gateway/test/push_utils_tests.erl index d0e79ca24..a7dfec0b6 100644 --- a/fluxer_gateway/test/push_utils_tests.erl +++ b/fluxer_gateway/test/push_utils_tests.erl @@ -56,3 +56,50 @@ hkdf_expand_test() -> Info = <<"test info">>, Result = push_utils:hkdf_expand(IKM, Salt, Info, 32), ?assertEqual(32, byte_size(Result)). + +assert_vapid_pair_accepts_generated_pair_test() -> + {Pub, Priv} = generate_vapid_pair(), + ?assertEqual( + ok, + push_utils:assert_vapid_pair( + push_utils:base64url_encode(Pub), + push_utils:base64url_encode(Priv) + ) + ). + +assert_vapid_pair_rejects_mismatched_scalar_test() -> + {Pub, _} = generate_vapid_pair(), + {_, OtherPriv} = generate_vapid_pair(), + ?assertError( + {vapid_keys_mismatched, _}, + push_utils:assert_vapid_pair( + push_utils:base64url_encode(Pub), + push_utils:base64url_encode(OtherPriv) + ) + ). + +assert_vapid_pair_rejects_malformed_public_point_test() -> + {Pub, Priv} = generate_vapid_pair(), + ?assertError( + {vapid_keys_malformed, 64, 32}, + push_utils:assert_vapid_pair( + push_utils:base64url_encode(binary:part(Pub, 0, 64)), + push_utils:base64url_encode(Priv) + ) + ). + +assert_vapid_pair_rejects_short_scalar_test() -> + {Pub, Priv} = generate_vapid_pair(), + ?assertError( + {vapid_keys_malformed, 65, 31}, + push_utils:assert_vapid_pair( + push_utils:base64url_encode(Pub), + push_utils:base64url_encode(binary:part(Priv, 0, 31)) + ) + ). + +generate_vapid_pair() -> + case crypto:generate_key(ecdh, prime256v1) of + {<<4, _:64/binary>> = Pub, <<_:32/binary>> = Priv} -> {Pub, Priv}; + _ -> generate_vapid_pair() + end. diff --git a/packages/config/src/ConfigLoader.ts b/packages/config/src/ConfigLoader.ts index c5b740178..6ea59002e 100644 --- a/packages/config/src/ConfigLoader.ts +++ b/packages/config/src/ConfigLoader.ts @@ -1,5 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +import {createPrivateKey, createPublicKey} from 'node:crypto'; import {buildNamedFluxerEnvOverrides} from '@fluxer/config/src/config_loader/EnvironmentOverrides'; import { type DerivedEndpoints, @@ -41,6 +42,7 @@ function defaultConfig(): MasterConfig { media: '', static_cdn: '', admin: '', + docs: '', marketing: '', invite: '', gift: '', @@ -90,7 +92,6 @@ function defaultConfig(): MasterConfig { downloads: 'fluxer-downloads', reports: 'fluxer-reports', harvests: 'fluxer-harvests', - static: 'fluxer-static', }, }, services: { @@ -130,14 +131,14 @@ function defaultConfig(): MasterConfig { mode: 'upload', upload_relay: { endpoint: 'http://localhost:8088/media', - max_body_bytes: 268_435_456, + secret_base64: '', + max_body_bytes: 524_288_000, token_ttl_secs: 900, keep_direct_countries: [], }, }, gateway: { port: 8771, - push_enabled: false, rpc_auth_token: '', }, admin: { @@ -163,7 +164,7 @@ function defaultConfig(): MasterConfig { sso_allow_private_addresses: false, passkeys: { rp_name: 'Fluxer', - rp_id: 'fluxer.app', + rp_id: '', additional_allowed_origins: DEFAULT_PASSKEY_ORIGINS, }, vapid: { @@ -172,12 +173,12 @@ function defaultConfig(): MasterConfig { email: '', }, bluesky: { - enabled: true, + enabled: false, client_name: 'Fluxer', client_uri: '', logo_uri: '', - tos_uri: 'https://fluxer.app/terms', - policy_uri: 'https://fluxer.app/privacy', + tos_uri: '', + policy_uri: '', keys: [], }, }, @@ -335,6 +336,22 @@ function requireString(value: string | undefined, envName: string): void { } } +function validateUploadRelaySecret(value: string, mode: string): void { + const trimmed = value.trim(); + if (trimmed.length === 0) { + if (mode === 'upload') { + throw new Error('FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64 is required in upload mode'); + } + return; + } + if (!/^[A-Za-z0-9+/]+={0,2}$/u.test(trimmed)) { + throw new Error('FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64 must be base64'); + } + if (Buffer.from(trimmed, 'base64').length < 32) { + throw new Error('FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64 must decode to at least 32 bytes'); + } +} + function assertBoolean(value: unknown, envName: string): asserts value is boolean { if (typeof value !== 'boolean') { throw new Error(`${envName} must be true or false`); @@ -353,6 +370,35 @@ function assertIdentifier(value: string, envName: string): void { } } +function validateVapidConfig(config: MasterConfig): void { + requireString(config.auth.vapid.public_key, 'FLUXER_VAPID_PUBLIC_KEY'); + requireString(config.auth.vapid.private_key, 'FLUXER_VAPID_PRIVATE_KEY'); + const pub = Buffer.from(config.auth.vapid.public_key, 'base64url'); + const priv = Buffer.from(config.auth.vapid.private_key, 'base64url'); + if (pub.length !== 65 || pub[0] !== 0x04) { + throw new Error('FLUXER_VAPID_PUBLIC_KEY must be the base64url 65-byte uncompressed P-256 point'); + } + if (priv.length !== 32) { + throw new Error('FLUXER_VAPID_PRIVATE_KEY must be the base64url 32-byte P-256 scalar'); + } + const jwk = { + kty: 'EC', + crv: 'P-256', + x: pub.subarray(1, 33).toString('base64url'), + y: pub.subarray(33, 65).toString('base64url'), + d: priv.toString('base64url'), + }; + let derived: {x?: string; y?: string}; + try { + derived = createPublicKey(createPrivateKey({key: jwk, format: 'jwk'})).export({format: 'jwk'}); + } catch { + throw new Error('FLUXER_VAPID_PRIVATE_KEY does not match FLUXER_VAPID_PUBLIC_KEY'); + } + if (derived.x !== jwk.x || derived.y !== jwk.y) { + throw new Error('FLUXER_VAPID_PRIVATE_KEY does not match FLUXER_VAPID_PUBLIC_KEY'); + } +} + function validatePostgresConfig(config: MasterConfig): void { const postgres = config.database.postgres; assertIntegerInRange(postgres.port, 'FLUXER_POSTGRES_PORT', 1, 65535); @@ -380,6 +426,24 @@ function validatePostgresConfig(config: MasterConfig): void { } } +function validateCaptchaConfig(config: MasterConfig): void { + const captcha = config.integrations.captcha; + if (!captcha.enabled) { + return; + } + if (captcha.provider === 'hcaptcha') { + requireString(captcha.hcaptcha?.site_key, 'FLUXER_CAPTCHA_HCAPTCHA_SITE_KEY'); + requireString(captcha.hcaptcha?.secret_key, 'FLUXER_CAPTCHA_HCAPTCHA_SECRET_KEY'); + return; + } + if (captcha.provider === 'turnstile') { + requireString(captcha.turnstile?.site_key, 'FLUXER_CAPTCHA_TURNSTILE_SITE_KEY'); + requireString(captcha.turnstile?.secret_key, 'FLUXER_CAPTCHA_TURNSTILE_SECRET_KEY'); + return; + } + throw new Error('FLUXER_CAPTCHA_PROVIDER must be hcaptcha or turnstile when FLUXER_CAPTCHA_ENABLED is true'); +} + function validateApiWorkerConfig(config: MasterConfig): void { const worker = config.services.api?.worker; if (!worker) { @@ -412,6 +476,7 @@ function normalizeConfig(config: MasterConfig): MasterConfig { 'FLUXER_ABUSE_DIRECT_CONTACT_SPAM_ACTION', ); validatePostgresConfig(config); + validateCaptchaConfig(config); validateApiWorkerConfig(config); assertIntegerInRange(config.services.api.max_inflight_requests, 'FLUXER_API_MAX_INFLIGHT_REQUESTS', 1, 100_000); assertIntegerInRange(config.services.api.headers_timeout_ms, 'FLUXER_API_HEADERS_TIMEOUT_MS', 1_000, 3_600_000); @@ -419,11 +484,11 @@ function normalizeConfig(config: MasterConfig): MasterConfig { requireString(config.domain.base_domain, 'FLUXER_BASE_DOMAIN'); requireString(config.auth.sudo_mode_secret, 'FLUXER_SUDO_MODE_SECRET'); requireString(config.auth.connection_initiation_secret, 'FLUXER_CONNECTION_INITIATION_SECRET'); - requireString(config.auth.vapid.public_key, 'FLUXER_VAPID_PUBLIC_KEY'); - requireString(config.auth.vapid.private_key, 'FLUXER_VAPID_PRIVATE_KEY'); + validateVapidConfig(config); requireString(config.s3?.access_key_id, 'FLUXER_S3_ACCESS_KEY_ID'); requireString(config.s3?.secret_access_key, 'FLUXER_S3_SECRET_ACCESS_KEY'); requireString(config.services.media_proxy.secret_key, 'FLUXER_MEDIA_PROXY_SECRET_KEY'); + validateUploadRelaySecret(config.services.media_proxy.upload_relay.secret_base64, config.services.media_proxy.mode); requireString(config.services.admin.secret_key_base, 'FLUXER_ADMIN_SECRET_KEY_BASE'); requireString(config.services.admin.oauth_client_secret, 'FLUXER_ADMIN_OAUTH_CLIENT_SECRET'); if (!config.instance.self_hosted) { @@ -463,15 +528,37 @@ function applyPublicPort(config: MasterConfig, endpoints: DerivedEndpoints): Mas }; } +function resolveAppOrigin(appEndpoint: string): string { + try { + return new URL(appEndpoint).origin; + } catch { + throw new Error(`FLUXER_APP_ENDPOINT must be a valid URL: ${appEndpoint}`); + } +} + +function applyPasskeyDefaults(config: MasterConfig, endpoints: DerivedEndpoints): void { + const passkeys = config.auth.passkeys; + if (passkeys.rp_id.trim().length === 0) { + passkeys.rp_id = config.domain.base_domain; + } + if (passkeys.additional_allowed_origins.length === 0) { + passkeys.additional_allowed_origins = [resolveAppOrigin(endpoints.app)]; + } +} + export async function loadConfig(): Promise { if (cachedConfig) { return cachedConfig; } - const merged = mergeConfig(defaultConfig(), buildNamedFluxerEnvOverrides(process.env)); + const overrides = buildNamedFluxerEnvOverrides(process.env); + const merged = mergeConfig(defaultConfig(), overrides); const normalized = normalizeConfig(merged); const derived = deriveEndpointsFromDomain(normalized.domain); const endpoints = {...derived, ...(normalized.endpoint_overrides ?? {})}; - cachedConfig = applyPublicPort(normalized, endpoints); + requireString(endpoints.api_client, 'FLUXER_API_CLIENT_ENDPOINT'); + const withPublicPort = applyPublicPort(normalized, endpoints); + applyPasskeyDefaults(withPublicPort, withPublicPort.endpoints); + cachedConfig = withPublicPort; return cachedConfig; } diff --git a/packages/config/src/EndpointDerivation.ts b/packages/config/src/EndpointDerivation.ts index facd3081a..871c59e23 100644 --- a/packages/config/src/EndpointDerivation.ts +++ b/packages/config/src/EndpointDerivation.ts @@ -1,5 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +const DOCS_ENDPOINT = 'https://fluxer.dev'; + export interface DomainConfig { base_domain: string; public_scheme: 'http' | 'https'; @@ -19,6 +21,7 @@ export interface DerivedEndpoints { media: string; static_cdn: string; admin: string; + docs: string; marketing: string; invite: string; gift: string; @@ -83,6 +86,7 @@ export function deriveDomain( | 'media' | 'static_cdn' | 'admin' + | 'docs' | 'marketing' | 'invite' | 'gift', @@ -113,6 +117,7 @@ export function deriveEndpointsFromDomain(config: DomainConfig): DerivedEndpoint ? buildUrl('https', deriveDomain('static_cdn', config), undefined) : buildUrl(public_scheme, deriveDomain('static_cdn', config), public_port), admin: buildUrl(public_scheme, deriveDomain('admin', config), public_port, '/admin'), + docs: DOCS_ENDPOINT, marketing: buildUrl(public_scheme, deriveDomain('marketing', config), public_port, '/marketing'), invite: buildUrl(public_scheme, deriveDomain('invite', config), public_port, '/invite'), gift: buildUrl(public_scheme, deriveDomain('gift', config), public_port, '/gift'), diff --git a/packages/config/src/MasterConfig.ts b/packages/config/src/MasterConfig.ts index a8162a85c..dba2a9390 100644 --- a/packages/config/src/MasterConfig.ts +++ b/packages/config/src/MasterConfig.ts @@ -77,7 +77,6 @@ export interface MasterConfig { downloads: string; reports: string; harvests: string; - static: string; }; }; s3_downloads?: { @@ -146,6 +145,7 @@ export interface MasterConfig { mode: string; upload_relay: { endpoint: string; + secret_base64: string; max_body_bytes: number; token_ttl_secs: number; keep_direct_countries: Array; @@ -156,7 +156,6 @@ export interface MasterConfig { rpc_auth_token?: string; media_proxy_endpoint?: string; api_rpc_endpoint?: string; - push_enabled: boolean; }; admin: { port: number; diff --git a/packages/config/src/__tests__/ConfigLoader.test.ts b/packages/config/src/__tests__/ConfigLoader.test.ts index 0ca684610..8859cc5d5 100644 --- a/packages/config/src/__tests__/ConfigLoader.test.ts +++ b/packages/config/src/__tests__/ConfigLoader.test.ts @@ -1,5 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +import {generateKeyPairSync} from 'node:crypto'; import {getConfig, loadConfig, resetConfig} from '@fluxer/config/src/ConfigLoader'; import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest'; @@ -18,6 +19,7 @@ const MINIMAL_ENV: Record = { FLUXER_S3_ACCESS_KEY_ID: 'test-key', FLUXER_S3_SECRET_ACCESS_KEY: 'test-secret', FLUXER_MEDIA_PROXY_SECRET_KEY: 'test-media-secret', + FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64: 'AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=', FLUXER_ADMIN_SECRET_KEY_BASE: 'test-admin-secret', FLUXER_ADMIN_OAUTH_CLIENT_SECRET: 'test-admin-oauth-secret', FLUXER_MARKETING_SECRET_KEY_BASE: 'test-marketing-secret', @@ -26,10 +28,22 @@ const MINIMAL_ENV: Record = { FLUXER_GATEWAY_RPC_AUTH_TOKEN: 'test-gateway-token', FLUXER_SUDO_MODE_SECRET: 'test-sudo-secret', FLUXER_CONNECTION_INITIATION_SECRET: 'test-connection-secret', - FLUXER_VAPID_PUBLIC_KEY: 'test-vapid-public-key', - FLUXER_VAPID_PRIVATE_KEY: 'test-vapid-private-key', + FLUXER_VAPID_PUBLIC_KEY: 'BB76bTFIuoqmxJtTfZX0yGTn1f_qu9H03B_nkj8OyExJFkN7Y-HBZZzShnHZoEhXKc5ZRy3jFu7OkBbnaQG-4aw', + FLUXER_VAPID_PRIVATE_KEY: 'Xgi-3P8J-I3Q6U1HlCcXMuc_tKLGAM9nIfznX3Hz68o', }; +function generateVapidPair(): {publicKey: string; privateKey: string} { + const {privateKey} = generateKeyPairSync('ec', {namedCurve: 'prime256v1'}); + const jwk = privateKey.export({format: 'jwk'}); + const x = Buffer.from(jwk.x ?? '', 'base64url'); + const y = Buffer.from(jwk.y ?? '', 'base64url'); + const d = Buffer.from(jwk.d ?? '', 'base64url'); + return { + publicKey: Buffer.concat([Buffer.from([0x04]), x, y]).toString('base64url'), + privateKey: d.toString('base64url'), + }; +} + function stubMinimalEnv(overrides: Record = {}): void { for (const [key, value] of Object.entries({...MINIMAL_ENV, ...overrides})) { vi.stubEnv(key, value); @@ -201,6 +215,51 @@ describe('ConfigLoader', () => { ]); }); + test('rejects an empty client API endpoint override', async () => { + stubMinimalEnv({FLUXER_API_CLIENT_ENDPOINT: ''}); + await expect(loadConfig()).rejects.toThrow('FLUXER_API_CLIENT_ENDPOINT is required'); + }); + + test('defaults the passkey relying party to the deployment domain', async () => { + stubMinimalEnv({ + FLUXER_BASE_DOMAIN: 'chat.example.com', + FLUXER_PUBLIC_SCHEME: 'https', + FLUXER_PUBLIC_PORT: '443', + }); + + const config = await loadConfig(); + + expect(config.auth.passkeys.rp_id).toBe('chat.example.com'); + }); + + test('derives the passkey origin only when the operator clears the default list', async () => { + stubMinimalEnv({ + FLUXER_BASE_DOMAIN: 'chat.example.com', + FLUXER_PUBLIC_SCHEME: 'https', + FLUXER_PUBLIC_PORT: '443', + FLUXER_PASSKEY_ADDITIONAL_ALLOWED_ORIGINS: '', + }); + + const config = await loadConfig(); + + expect(config.auth.passkeys.additional_allowed_origins).toEqual(['https://chat.example.com']); + }); + + test('keeps explicit passkey relying party values', async () => { + stubMinimalEnv({ + FLUXER_BASE_DOMAIN: 'chat.example.com', + FLUXER_PUBLIC_SCHEME: 'https', + FLUXER_PUBLIC_PORT: '443', + FLUXER_PASSKEY_RP_ID: 'example.com', + FLUXER_PASSKEY_ADDITIONAL_ALLOWED_ORIGINS: 'https://example.com,https://app.example.com', + }); + + const config = await loadConfig(); + + expect(config.auth.passkeys.rp_id).toBe('example.com'); + expect(config.auth.passkeys.additional_allowed_origins).toEqual(['https://example.com', 'https://app.example.com']); + }); + test('parses typed named environment variables', async () => { stubMinimalEnv({ FLUXER_API_PORT: '9090', @@ -212,7 +271,6 @@ describe('ConfigLoader', () => { FLUXER_POSTGRES_PREPARED_STATEMENTS: 'false', FLUXER_API_WORKER_MODE: 'single_task', FLUXER_API_WORKER_TASK: 'processStripeWebhook', - FLUXER_GATEWAY_PUSH_ENABLED: 'false', FLUXER_ACCOUNT_POLICY_DSL: '{"version":1,"id":"env_policy","rules":[]}', FLUXER_LIVEKIT_ENABLED: 'true', FLUXER_LIVEKIT_DEFAULT_REGION: @@ -230,7 +288,6 @@ describe('ConfigLoader', () => { expect(config.database.postgres.prepared_statements).toBe(false); expect(config.services.api.worker?.mode).toBe('single_task'); expect(config.services.api.worker?.task).toBe('processStripeWebhook'); - expect(config.services.gateway.push_enabled).toBe(false); expect(config.integrations.risk_integration.account_policy_dsl).toEqual({ version: 1, id: 'env_policy', @@ -239,6 +296,14 @@ describe('ConfigLoader', () => { expect(config.integrations.voice.default_region?.id).toBe('local'); }); + test('ignores FLUXER_GATEWAY_PUSH_ENABLED, which only the gateway reads', async () => { + stubMinimalEnv({FLUXER_GATEWAY_PUSH_ENABLED: 'false'}); + + const config = await loadConfig(); + + expect(config.services.gateway).not.toHaveProperty('push_enabled'); + }); + test('rejects single task worker mode without task env', async () => { stubMinimalEnv({FLUXER_API_WORKER_MODE: 'single_task'}); await expect(loadConfig()).rejects.toThrow('FLUXER_API_WORKER_TASK'); @@ -249,6 +314,16 @@ describe('ConfigLoader', () => { await expect(loadConfig()).rejects.toThrow('FLUXER_POSTGRES_PORT'); }); + test('rejects a non-integer port', async () => { + stubMinimalEnv({FLUXER_API_PORT: '80a'}); + await expect(loadConfig()).rejects.toThrow('FLUXER_API_PORT must be an integer, got "80a"'); + }); + + test('rejects malformed JSON for a JSON-shaped variable', async () => { + stubMinimalEnv({FLUXER_LIVEKIT_DEFAULT_REGION: '{bad'}); + await expect(loadConfig()).rejects.toThrow('FLUXER_LIVEKIT_DEFAULT_REGION must be valid JSON'); + }); + test('keeps Postgres prepared statements on by default', async () => { stubMinimalEnv(); expect((await loadConfig()).database.postgres.prepared_statements).toBe(true); @@ -283,6 +358,16 @@ describe('ConfigLoader', () => { await expect(loadConfig()).rejects.toThrow('FLUXER_API_REQUEST_TIMEOUT_MS'); }); + test('applies the KV mode from the environment', async () => { + stubMinimalEnv({FLUXER_KV_MODE: 'cluster'}); + expect((await loadConfig()).internal.kv_mode).toBe('cluster'); + }); + + test('rejects an unknown KV mode', async () => { + stubMinimalEnv({FLUXER_KV_MODE: 'sentinel'}); + await expect(loadConfig()).rejects.toThrow('Invalid FLUXER_KV_MODE: sentinel'); + }); + test('rejects unsafe production Postgres defaults', async () => { stubMinimalEnv({FLUXER_ENV: 'production'}); await expect(loadConfig()).rejects.toThrow('FLUXER_POSTGRES_HOST'); @@ -405,6 +490,158 @@ describe('ConfigLoader', () => { }); }); + test('rejects an enabled captcha with no keys for the selected provider', async () => { + stubMinimalEnv({FLUXER_CAPTCHA_ENABLED: 'true', FLUXER_CAPTCHA_PROVIDER: 'hcaptcha'}); + await expect(loadConfig()).rejects.toThrow('FLUXER_CAPTCHA_HCAPTCHA_SITE_KEY is required'); + }); + + test('rejects an enabled captcha with a site key but no secret key', async () => { + stubMinimalEnv({ + FLUXER_CAPTCHA_ENABLED: 'true', + FLUXER_CAPTCHA_PROVIDER: 'turnstile', + FLUXER_CAPTCHA_TURNSTILE_SITE_KEY: 'turnstile-site-key', + }); + await expect(loadConfig()).rejects.toThrow('FLUXER_CAPTCHA_TURNSTILE_SECRET_KEY is required'); + }); + + test('rejects an enabled captcha with no provider', async () => { + stubMinimalEnv({FLUXER_CAPTCHA_ENABLED: 'true'}); + await expect(loadConfig()).rejects.toThrow( + 'FLUXER_CAPTCHA_PROVIDER must be hcaptcha or turnstile when FLUXER_CAPTCHA_ENABLED is true', + ); + }); + + test('accepts an enabled captcha with both keys for the selected provider', async () => { + stubMinimalEnv({ + FLUXER_CAPTCHA_ENABLED: 'true', + FLUXER_CAPTCHA_PROVIDER: 'hcaptcha', + FLUXER_CAPTCHA_HCAPTCHA_SITE_KEY: 'hcaptcha-site-key', + FLUXER_CAPTCHA_HCAPTCHA_SECRET_KEY: 'hcaptcha-secret-key', + }); + + const config = await loadConfig(); + + expect(config.integrations.captcha.enabled).toBe(true); + expect(config.integrations.captcha.hcaptcha?.secret_key).toBe('hcaptcha-secret-key'); + }); + + test('leaves a disabled captcha unvalidated', async () => { + stubMinimalEnv({FLUXER_CAPTCHA_PROVIDER: 'hcaptcha'}); + expect((await loadConfig()).integrations.captcha.enabled).toBe(false); + }); + + test('leaves Bluesky login off with no legal URLs by default', async () => { + stubMinimalEnv(); + + const config = await loadConfig(); + + expect(config.auth.bluesky.enabled).toBe(false); + expect(config.auth.bluesky.tos_uri).toBe(''); + expect(config.auth.bluesky.policy_uri).toBe(''); + expect(config.auth.bluesky.keys).toEqual([]); + }); + + test('applies explicit Bluesky legal URLs from the environment', async () => { + stubMinimalEnv({ + FLUXER_AUTH_BLUESKY_ENABLED: 'true', + FLUXER_AUTH_BLUESKY_TOS_URI: 'https://chat.example.com/terms', + FLUXER_AUTH_BLUESKY_POLICY_URI: 'https://chat.example.com/privacy', + }); + + const config = await loadConfig(); + + expect(config.auth.bluesky.enabled).toBe(true); + expect(config.auth.bluesky.tos_uri).toBe('https://chat.example.com/terms'); + expect(config.auth.bluesky.policy_uri).toBe('https://chat.example.com/privacy'); + }); + + test('reads the upload relay secret through the override table', async () => { + const secret = Buffer.alloc(32, 9).toString('base64'); + stubMinimalEnv({FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64: secret}); + + const config = await loadConfig(); + + expect(config.services.media_proxy.upload_relay.secret_base64).toBe(secret); + }); + + test('defaults the upload relay body limit to the media proxy ceiling', async () => { + stubMinimalEnv(); + expect((await loadConfig()).services.media_proxy.upload_relay.max_body_bytes).toBe(524_288_000); + }); + + test('rejects a missing upload relay secret in upload mode', async () => { + stubMinimalEnv(); + vi.stubEnv('FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64', ''); + + await expect(loadConfig()).rejects.toThrow( + 'FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64 is required in upload mode', + ); + }); + + test('rejects a non-base64 upload relay secret', async () => { + stubMinimalEnv({FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64: 'not base64!'}); + await expect(loadConfig()).rejects.toThrow('FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64 must be base64'); + }); + + test('rejects an upload relay secret shorter than 32 bytes', async () => { + stubMinimalEnv({FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64: Buffer.alloc(16, 7).toString('base64')}); + await expect(loadConfig()).rejects.toThrow( + 'FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64 must decode to at least 32 bytes', + ); + }); + + test('leaves the upload relay secret optional outside upload mode', async () => { + stubMinimalEnv({FLUXER_MEDIA_PROXY_MODE: 'mp'}); + vi.stubEnv('FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64', ''); + + const config = await loadConfig(); + + expect(config.services.media_proxy.upload_relay.secret_base64).toBe(''); + }); + + test('rejects a VAPID public key that is not a 65-byte uncompressed point', async () => { + const {privateKey} = generateVapidPair(); + stubMinimalEnv({ + FLUXER_VAPID_PUBLIC_KEY: Buffer.alloc(64, 4).toString('base64url'), + FLUXER_VAPID_PRIVATE_KEY: privateKey, + }); + await expect(loadConfig()).rejects.toThrow( + 'FLUXER_VAPID_PUBLIC_KEY must be the base64url 65-byte uncompressed P-256 point', + ); + }); + + test('rejects a VAPID private key that is not a 32-byte scalar', async () => { + const {publicKey} = generateVapidPair(); + stubMinimalEnv({ + FLUXER_VAPID_PUBLIC_KEY: publicKey, + FLUXER_VAPID_PRIVATE_KEY: Buffer.alloc(31, 9).toString('base64url'), + }); + await expect(loadConfig()).rejects.toThrow('FLUXER_VAPID_PRIVATE_KEY must be the base64url 32-byte P-256 scalar'); + }); + + test('rejects a well formed VAPID scalar that does not derive the public point', async () => { + const {publicKey} = generateVapidPair(); + const other = generateVapidPair(); + stubMinimalEnv({ + FLUXER_VAPID_PUBLIC_KEY: publicKey, + FLUXER_VAPID_PRIVATE_KEY: other.privateKey, + }); + await expect(loadConfig()).rejects.toThrow('FLUXER_VAPID_PRIVATE_KEY does not match FLUXER_VAPID_PUBLIC_KEY'); + }); + + test('accepts a generated VAPID pair', async () => { + const pair = generateVapidPair(); + stubMinimalEnv({ + FLUXER_VAPID_PUBLIC_KEY: pair.publicKey, + FLUXER_VAPID_PRIVATE_KEY: pair.privateKey, + }); + + const config = await loadConfig(); + + expect(config.auth.vapid.public_key).toBe(pair.publicKey); + expect(config.auth.vapid.private_key).toBe(pair.privateKey); + }); + test('requires a complete environment', async () => { vi.stubEnv('FLUXER_ENV', 'test'); await expect(loadConfig()).rejects.toThrow(); diff --git a/packages/config/src/__tests__/EnvironmentOverrides.test.ts b/packages/config/src/__tests__/EnvironmentOverrides.test.ts index 34d03f44f..7179132f7 100644 --- a/packages/config/src/__tests__/EnvironmentOverrides.test.ts +++ b/packages/config/src/__tests__/EnvironmentOverrides.test.ts @@ -31,8 +31,8 @@ describe('parseEnvValue', () => { test('parses JSON arrays', () => { expect(parseEnvValue('[1, 2, 3]')).toEqual([1, 2, 3]); }); - test('returns raw string for invalid JSON-like values', () => { - expect(parseEnvValue('{not json}')).toBe('{not json}'); + test('rejects invalid JSON-like values', () => { + expect(() => parseEnvValue('{not json}')).toThrow('must be valid JSON'); }); test('returns raw string for plain strings', () => { expect(parseEnvValue('hello')).toBe('hello'); @@ -106,4 +106,53 @@ describe('buildNamedFluxerEnvOverrides', () => { integrations: {stripe: {prices: {monthly_usd: 'price_monthly_usd'}}}, }); }); + + test('maps the internal scheme and KV provider names', () => { + expect(buildNamedFluxerEnvOverrides({FLUXER_INTERNAL_SCHEME: 'https', FLUXER_KV_PROVIDER: 'redis'})).toMatchObject({ + domain: {internal_scheme: 'https'}, + internal: {kv_provider: 'redis'}, + }); + }); + + test('rejects a non-integer value for an integer override', () => { + expect(() => buildNamedFluxerEnvOverrides({FLUXER_API_PORT: '80a'})).toThrow( + 'FLUXER_API_PORT must be an integer, got "80a"', + ); + }); + + test('leaves the default in place for a blank integer override', () => { + expect(buildNamedFluxerEnvOverrides({FLUXER_API_PORT: ''})).toEqual({}); + }); + + test('the canonical name wins over its alias regardless of declaration order', () => { + expect( + buildNamedFluxerEnvOverrides({ + FLUXER_MEDIA_PROXY_ENDPOINT: 'http://alias', + FLUXER_INTERNAL_MEDIA_PROXY_ENDPOINT: 'http://canonical', + FLUXER_NATS_CORE_URL: 'nats://alias', + FLUXER_NATS_URL: 'nats://canonical', + }), + ).toMatchObject({ + internal: {media_proxy: 'http://canonical'}, + services: {nats: {core_url: 'nats://canonical'}}, + }); + }); + + test('an alias alone still applies', () => { + expect( + buildNamedFluxerEnvOverrides({ + FLUXER_MEDIA_PROXY_ENDPOINT: 'http://alias', + FLUXER_NATS_CORE_URL: 'nats://alias', + }), + ).toMatchObject({ + internal: {media_proxy: 'http://alias'}, + services: {nats: {core_url: 'nats://alias'}}, + }); + }); + + test('rejects malformed JSON for a JSON-shaped override', () => { + expect(() => buildNamedFluxerEnvOverrides({FLUXER_LIVEKIT_DEFAULT_REGION: '{bad'})).toThrow( + 'FLUXER_LIVEKIT_DEFAULT_REGION must be valid JSON', + ); + }); }); diff --git a/packages/config/src/__tests__/S3DownloadsProvider.test.ts b/packages/config/src/__tests__/S3DownloadsProvider.test.ts index a8a298404..4e925f3dd 100644 --- a/packages/config/src/__tests__/S3DownloadsProvider.test.ts +++ b/packages/config/src/__tests__/S3DownloadsProvider.test.ts @@ -11,7 +11,7 @@ const base: Pick['s3'] = { region: 'us-east-1', access_key_id: 'MAIN_KEY', secret_access_key: 'MAIN_SECRET', - buckets: {cdn: 'cdn', uploads: 'uploads', downloads: 'downloads', reports: 'r', harvests: 'h', static: 's'}, + buckets: {cdn: 'cdn', uploads: 'uploads', downloads: 'downloads', reports: 'r', harvests: 'h'}, }; describe('resolveDownloadsProvider', () => { diff --git a/packages/config/src/config_loader/EnvironmentOverrides.ts b/packages/config/src/config_loader/EnvironmentOverrides.ts index 251ac371a..dccb8a84c 100644 --- a/packages/config/src/config_loader/EnvironmentOverrides.ts +++ b/packages/config/src/config_loader/EnvironmentOverrides.ts @@ -15,7 +15,8 @@ const NAMED_FLUXER_ENV_OVERRIDES: Record = { FLUXER_ENV: {path: ['env']}, FLUXER_BASE_DOMAIN: {path: ['domain', 'base_domain']}, FLUXER_PUBLIC_SCHEME: {path: ['domain', 'public_scheme']}, - FLUXER_PUBLIC_PORT: {path: ['domain', 'public_port'], parse: parseEnvValue}, + FLUXER_INTERNAL_SCHEME: {path: ['domain', 'internal_scheme']}, + FLUXER_PUBLIC_PORT: {path: ['domain', 'public_port'], parse: parseInteger}, FLUXER_STATIC_CDN_DOMAIN: {path: ['domain', 'static_cdn_domain']}, FLUXER_INVITE_DOMAIN: {path: ['domain', 'invite_domain']}, FLUXER_GIFT_DOMAIN: {path: ['domain', 'gift_domain']}, @@ -26,34 +27,36 @@ const NAMED_FLUXER_ENV_OVERRIDES: Record = { FLUXER_MEDIA_ENDPOINT: {path: ['endpoint_overrides', 'media']}, FLUXER_STATIC_CDN_ENDPOINT: {path: ['endpoint_overrides', 'static_cdn']}, FLUXER_ADMIN_ENDPOINT: {path: ['endpoint_overrides', 'admin']}, + FLUXER_DOCS_ENDPOINT: {path: ['endpoint_overrides', 'docs']}, FLUXER_MARKETING_ENDPOINT: {path: ['endpoint_overrides', 'marketing']}, FLUXER_INVITE_ENDPOINT: {path: ['endpoint_overrides', 'invite']}, FLUXER_GIFT_ENDPOINT: {path: ['endpoint_overrides', 'gift']}, FLUXER_TRUST_CLIENT_IP_HEADER: {path: ['proxy', 'trust_client_ip_header'], parse: parseEnvValue}, FLUXER_CLIENT_IP_HEADER_NAME: {path: ['proxy', 'client_ip_header']}, FLUXER_CASSANDRA_HOSTS: {path: ['database', 'cassandra', 'hosts'], parse: parseCsv}, - FLUXER_CASSANDRA_PORT: {path: ['database', 'cassandra', 'port'], parse: parseEnvValue}, + FLUXER_CASSANDRA_PORT: {path: ['database', 'cassandra', 'port'], parse: parseInteger}, FLUXER_CASSANDRA_KEYSPACE: {path: ['database', 'cassandra', 'keyspace']}, FLUXER_CASSANDRA_LOCAL_DC: {path: ['database', 'cassandra', 'local_dc']}, FLUXER_CASSANDRA_USERNAME: {path: ['database', 'cassandra', 'username']}, FLUXER_CASSANDRA_PASSWORD: {path: ['database', 'cassandra', 'password']}, FLUXER_POSTGRES_URL: {path: ['database', 'postgres', 'url']}, FLUXER_POSTGRES_HOST: {path: ['database', 'postgres', 'host']}, - FLUXER_POSTGRES_PORT: {path: ['database', 'postgres', 'port'], parse: parseEnvValue}, + FLUXER_POSTGRES_PORT: {path: ['database', 'postgres', 'port'], parse: parseInteger}, FLUXER_POSTGRES_DATABASE: {path: ['database', 'postgres', 'database']}, FLUXER_POSTGRES_USERNAME: {path: ['database', 'postgres', 'username']}, FLUXER_POSTGRES_PASSWORD: {path: ['database', 'postgres', 'password']}, FLUXER_POSTGRES_SSL: {path: ['database', 'postgres', 'ssl'], parse: parseEnvValue}, FLUXER_POSTGRES_SSL_CA: {path: ['database', 'postgres', 'ssl_ca']}, - FLUXER_POSTGRES_MAX_CONNECTIONS: {path: ['database', 'postgres', 'max_connections'], parse: parseEnvValue}, + FLUXER_POSTGRES_MAX_CONNECTIONS: {path: ['database', 'postgres', 'max_connections'], parse: parseInteger}, FLUXER_POSTGRES_KV_TABLE: {path: ['database', 'postgres', 'kv_table']}, FLUXER_POSTGRES_PREPARED_STATEMENTS: {path: ['database', 'postgres', 'prepared_statements'], parse: parseEnvValue}, FLUXER_DATABASE_BACKEND: {path: ['database', 'backend']}, FLUXER_KV_URL: {path: ['internal', 'kv']}, + FLUXER_KV_PROVIDER: {path: ['internal', 'kv_provider']}, + FLUXER_KV_MODE: {path: ['internal', 'kv_mode']}, FLUXER_INTERNAL_API_ENDPOINT: {path: ['internal', 'api']}, FLUXER_INTERNAL_GATEWAY_ENDPOINT: {path: ['internal', 'gateway']}, FLUXER_INTERNAL_MEDIA_PROXY_ENDPOINT: {path: ['internal', 'media_proxy']}, - FLUXER_MEDIA_PROXY_ENDPOINT: {path: ['internal', 'media_proxy']}, FLUXER_S3_ENDPOINT: {path: ['s3', 'endpoint']}, FLUXER_S3_PUBLIC_ENDPOINT: {path: ['s3', 'presigned_url_base']}, FLUXER_S3_FORCE_PATH_STYLE: {path: ['s3', 'force_path_style'], parse: parseEnvValue}, @@ -65,7 +68,6 @@ const NAMED_FLUXER_ENV_OVERRIDES: Record = { FLUXER_S3_BUCKET_DOWNLOADS: {path: ['s3', 'buckets', 'downloads']}, FLUXER_S3_BUCKET_REPORTS: {path: ['s3', 'buckets', 'reports']}, FLUXER_S3_BUCKET_HARVESTS: {path: ['s3', 'buckets', 'harvests']}, - FLUXER_S3_BUCKET_STATIC: {path: ['s3', 'buckets', 'static']}, FLUXER_S3_DOWNLOADS_ENDPOINT: {path: ['s3_downloads', 'endpoint']}, FLUXER_S3_DOWNLOADS_PUBLIC_ENDPOINT: {path: ['s3_downloads', 'presigned_url_base']}, FLUXER_S3_DOWNLOADS_FORCE_PATH_STYLE: {path: ['s3_downloads', 'force_path_style'], parse: parseEnvValue}, @@ -73,13 +75,12 @@ const NAMED_FLUXER_ENV_OVERRIDES: Record = { FLUXER_S3_DOWNLOADS_ACCESS_KEY_ID: {path: ['s3_downloads', 'access_key_id']}, FLUXER_S3_DOWNLOADS_SECRET_ACCESS_KEY: {path: ['s3_downloads', 'secret_access_key']}, FLUXER_NATS_URL: {path: ['services', 'nats', 'core_url']}, - FLUXER_NATS_CORE_URL: {path: ['services', 'nats', 'core_url']}, FLUXER_NATS_JETSTREAM_URL: {path: ['services', 'nats', 'jetstream_url']}, FLUXER_NATS_AUTH_TOKEN: {path: ['services', 'nats', 'auth_token']}, - FLUXER_API_PORT: {path: ['services', 'api', 'port'], parse: parseEnvValue}, - FLUXER_API_HEADERS_TIMEOUT_MS: {path: ['services', 'api', 'headers_timeout_ms'], parse: parseEnvValue}, - FLUXER_API_REQUEST_TIMEOUT_MS: {path: ['services', 'api', 'request_timeout_ms'], parse: parseEnvValue}, - FLUXER_API_MAX_INFLIGHT_REQUESTS: {path: ['services', 'api', 'max_inflight_requests'], parse: parseEnvValue}, + FLUXER_API_PORT: {path: ['services', 'api', 'port'], parse: parseInteger}, + FLUXER_API_HEADERS_TIMEOUT_MS: {path: ['services', 'api', 'headers_timeout_ms'], parse: parseInteger}, + FLUXER_API_REQUEST_TIMEOUT_MS: {path: ['services', 'api', 'request_timeout_ms'], parse: parseInteger}, + FLUXER_API_MAX_INFLIGHT_REQUESTS: {path: ['services', 'api', 'max_inflight_requests'], parse: parseInteger}, FLUXER_API_IP_BAN_EXEMPT_IPS: {path: ['services', 'api', 'ip_ban_exempt_ips'], parse: parseCsv}, FLUXER_API_DESKTOP_GITHUB_REDIRECT_COUNTRIES: { path: ['services', 'api', 'desktop_github_redirect_countries'], @@ -110,27 +111,27 @@ const NAMED_FLUXER_ENV_OVERRIDES: Record = { }, FLUXER_API_WORKER_VOICE_RECONCILIATION_INTERVAL_MS: { path: ['services', 'api', 'worker', 'voice_reconciliation', 'interval_ms'], - parse: parseEnvValue, + parse: parseInteger, }, FLUXER_API_WORKER_VOICE_RECONCILIATION_STAGGER_DELAY_MS: { path: ['services', 'api', 'worker', 'voice_reconciliation', 'stagger_delay_ms'], - parse: parseEnvValue, + parse: parseInteger, }, FLUXER_API_WORKER_VOICE_RECONCILIATION_LOCK_TTL_SECONDS: { path: ['services', 'api', 'worker', 'voice_reconciliation', 'lock_ttl_seconds'], - parse: parseEnvValue, + parse: parseInteger, }, FLUXER_API_WORKER_VOICE_RECONCILIATION_CADENCE_TTL_SECONDS: { path: ['services', 'api', 'worker', 'voice_reconciliation', 'cadence_ttl_seconds'], - parse: parseEnvValue, + parse: parseInteger, }, FLUXER_API_WORKER_VOICE_RECONCILIATION_GATEWAY_ONLY_GRACE_MS: { path: ['services', 'api', 'worker', 'voice_reconciliation', 'gateway_only_grace_ms'], - parse: parseEnvValue, + parse: parseInteger, }, FLUXER_API_WORKER_VOICE_RECONCILIATION_LIVEKIT_ONLY_GRACE_MS: { path: ['services', 'api', 'worker', 'voice_reconciliation', 'livekit_only_grace_ms'], - parse: parseEnvValue, + parse: parseInteger, }, FLUXER_API_WORKER_LANE_CONCURRENCY_OVERRIDES: { path: ['services', 'api', 'worker', 'lane_concurrency_overrides'], @@ -151,15 +152,15 @@ const NAMED_FLUXER_ENV_OVERRIDES: Record = { }, FLUXER_API_EMBEDS_CACHE_DEFAULT_TTL_SECONDS: { path: ['services', 'api', 'embeds', 'cache_default_ttl_seconds'], - parse: parseEnvValue, + parse: parseInteger, }, FLUXER_API_EMBEDS_CACHE_MAX_TTL_SECONDS: { path: ['services', 'api', 'embeds', 'cache_max_ttl_seconds'], - parse: parseEnvValue, + parse: parseInteger, }, FLUXER_API_EMBEDS_CACHE_MIN_TTL_SECONDS: { path: ['services', 'api', 'embeds', 'cache_min_ttl_seconds'], - parse: parseEnvValue, + parse: parseInteger, }, FLUXER_API_EMBEDS_CACHE_RESPECT_REMOTE_TTL: { path: ['services', 'api', 'embeds', 'cache_respect_remote_ttl'], @@ -170,58 +171,58 @@ const NAMED_FLUXER_ENV_OVERRIDES: Record = { parse: parseEnvValue, }, FLUXER_MEDIA_PROXY_HOST: {path: ['services', 'media_proxy', 'host']}, - FLUXER_MEDIA_PROXY_PORT: {path: ['services', 'media_proxy', 'port'], parse: parseEnvValue}, + FLUXER_MEDIA_PROXY_PORT: {path: ['services', 'media_proxy', 'port'], parse: parseInteger}, FLUXER_MEDIA_PROXY_SECRET_KEY: {path: ['services', 'media_proxy', 'secret_key']}, FLUXER_MEDIA_PROXY_MODE: {path: ['services', 'media_proxy', 'mode']}, FLUXER_MEDIA_PROXY_UPLOAD_RELAY_ENDPOINT: {path: ['services', 'media_proxy', 'upload_relay', 'endpoint']}, + FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64: {path: ['services', 'media_proxy', 'upload_relay', 'secret_base64']}, FLUXER_MEDIA_PROXY_UPLOAD_RELAY_MAX_BODY_BYTES: { path: ['services', 'media_proxy', 'upload_relay', 'max_body_bytes'], - parse: parseEnvValue, + parse: parseInteger, }, FLUXER_MEDIA_PROXY_UPLOAD_RELAY_TOKEN_TTL_SECS: { path: ['services', 'media_proxy', 'upload_relay', 'token_ttl_secs'], - parse: parseEnvValue, + parse: parseInteger, }, FLUXER_MEDIA_PROXY_UPLOAD_RELAY_KEEP_DIRECT_COUNTRIES: { path: ['services', 'media_proxy', 'upload_relay', 'keep_direct_countries'], parse: parseCsv, }, - FLUXER_ADMIN_PORT: {path: ['services', 'admin', 'port'], parse: parseEnvValue}, + FLUXER_ADMIN_PORT: {path: ['services', 'admin', 'port'], parse: parseInteger}, FLUXER_ADMIN_BASE_PATH: {path: ['services', 'admin', 'base_path']}, FLUXER_ADMIN_SECRET_KEY_BASE: {path: ['services', 'admin', 'secret_key_base']}, FLUXER_ADMIN_OAUTH_CLIENT_SECRET: {path: ['services', 'admin', 'oauth_client_secret']}, FLUXER_MARKETING_HOST: {path: ['services', 'marketing', 'host']}, - FLUXER_MARKETING_PORT: {path: ['services', 'marketing', 'port'], parse: parseEnvValue}, + FLUXER_MARKETING_PORT: {path: ['services', 'marketing', 'port'], parse: parseInteger}, FLUXER_MARKETING_BASE_PATH: {path: ['services', 'marketing', 'base_path']}, FLUXER_MARKETING_SECRET_KEY_BASE: {path: ['services', 'marketing', 'secret_key_base']}, - FLUXER_APP_PROXY_PORT: {path: ['services', 'app_proxy', 'port'], parse: parseEnvValue}, + FLUXER_APP_PROXY_PORT: {path: ['services', 'app_proxy', 'port'], parse: parseInteger}, FLUXER_STATIC_DIR: {path: ['services', 'app_proxy', 'assets_dir']}, - FLUXER_GATEWAY_PORT: {path: ['services', 'gateway', 'port'], parse: parseEnvValue}, + FLUXER_GATEWAY_PORT: {path: ['services', 'gateway', 'port'], parse: parseInteger}, FLUXER_GATEWAY_ROLE: {path: ['services', 'gateway', 'gateway_role']}, FLUXER_GATEWAY_MEDIA_PROXY_ENDPOINT: {path: ['services', 'gateway', 'media_proxy_endpoint']}, FLUXER_GATEWAY_API_RPC_ENDPOINT: {path: ['services', 'gateway', 'api_rpc_endpoint']}, FLUXER_GATEWAY_RPC_AUTH_TOKEN: {path: ['services', 'gateway', 'rpc_auth_token']}, - FLUXER_GATEWAY_PUSH_ENABLED: {path: ['services', 'gateway', 'push_enabled'], parse: parseEnvValue}, FLUXER_GATEWAY_LOGGER_LEVEL: {path: ['services', 'gateway', 'logger_level']}, FLUXER_GATEWAY_HTTP_FAILURE_THRESHOLD: { path: ['services', 'gateway', 'gateway_http_failure_threshold'], - parse: parseEnvValue, + parse: parseInteger, }, FLUXER_GATEWAY_HTTP_RECOVERY_TIMEOUT_MS: { path: ['services', 'gateway', 'gateway_http_recovery_timeout_ms'], - parse: parseEnvValue, + parse: parseInteger, }, FLUXER_GATEWAY_HTTP_RPC_MAX_CONCURRENCY: { path: ['services', 'gateway', 'gateway_http_rpc_max_concurrency'], - parse: parseEnvValue, + parse: parseInteger, }, FLUXER_GATEWAY_NATS_RPC_MAX_HANDLERS: { path: ['services', 'gateway', 'gateway_nats_rpc_max_handlers'], - parse: parseEnvValue, + parse: parseInteger, }, FLUXER_GATEWAY_SHUTDOWN_DRAIN_WAIT_MS: { path: ['services', 'gateway', 'shutdown_drain_wait_ms'], - parse: parseEnvValue, + parse: parseInteger, }, FLUXER_GATEWAY_CLUSTER_ENABLED: {path: ['services', 'gateway', 'cluster_enabled'], parse: parseEnvValue}, FLUXER_GATEWAY_CLUSTER_DISCOVERY_DNS_NAME: {path: ['services', 'gateway', 'cluster_discovery_dns_name']}, @@ -230,7 +231,7 @@ const NAMED_FLUXER_ENV_OVERRIDES: Record = { }, FLUXER_GATEWAY_CLUSTER_DISCOVERY_POLL_INTERVAL_MS: { path: ['services', 'gateway', 'cluster_discovery_poll_interval_ms'], - parse: parseEnvValue, + parse: parseInteger, }, FLUXER_SUDO_MODE_SECRET: {path: ['auth', 'sudo_mode_secret']}, FLUXER_CONNECTION_INITIATION_SECRET: {path: ['auth', 'connection_initiation_secret']}, @@ -258,7 +259,7 @@ const NAMED_FLUXER_ENV_OVERRIDES: Record = { FLUXER_EMAIL_APP_BASE_URL: {path: ['integrations', 'email', 'app_base_url']}, FLUXER_EMAIL_WEBHOOK_SECRET: {path: ['integrations', 'email', 'webhook_secret']}, FLUXER_EMAIL_SMTP_HOST: {path: ['integrations', 'email', 'smtp', 'host']}, - FLUXER_EMAIL_SMTP_PORT: {path: ['integrations', 'email', 'smtp', 'port'], parse: parseEnvValue}, + FLUXER_EMAIL_SMTP_PORT: {path: ['integrations', 'email', 'smtp', 'port'], parse: parseInteger}, FLUXER_EMAIL_SMTP_USERNAME: {path: ['integrations', 'email', 'smtp', 'username']}, FLUXER_EMAIL_SMTP_PASSWORD: {path: ['integrations', 'email', 'smtp', 'password']}, FLUXER_EMAIL_SMTP_SECURE: {path: ['integrations', 'email', 'smtp', 'secure'], parse: parseEnvValue}, @@ -330,14 +331,14 @@ const NAMED_FLUXER_ENV_OVERRIDES: Record = { FLUXER_NCMEC_REPORTER_EMAIL: {path: ['integrations', 'ncmec', 'reporter_email']}, FLUXER_CLAMAV_ENABLED: {path: ['integrations', 'clamav', 'enabled'], parse: parseEnvValue}, FLUXER_CLAMAV_HOST: {path: ['integrations', 'clamav', 'host']}, - FLUXER_CLAMAV_PORT: {path: ['integrations', 'clamav', 'port'], parse: parseEnvValue}, + FLUXER_CLAMAV_PORT: {path: ['integrations', 'clamav', 'port'], parse: parseInteger}, FLUXER_CLAMAV_FAIL_OPEN: {path: ['integrations', 'clamav', 'fail_open'], parse: parseEnvValue}, FLUXER_KLIPY_API_KEY: {path: ['integrations', 'klipy', 'api_key']}, FLUXER_YOUTUBE_API_KEY: {path: ['integrations', 'youtube', 'api_key']}, FLUXER_BUNNY_PURGE_ENABLED: {path: ['integrations', 'bunny', 'purge_enabled'], parse: parseEnvValue}, FLUXER_BLOCKLIST_FEEDS_ENABLED: {path: ['integrations', 'blocklist_feeds', 'enabled'], parse: parseEnvValue}, FLUXER_BUNNY_API_KEY: {path: ['integrations', 'bunny', 'api_key']}, - FLUXER_BUNNY_PULL_ZONE_ID: {path: ['integrations', 'bunny', 'pull_zone_id'], parse: parseEnvValue}, + FLUXER_BUNNY_PULL_ZONE_ID: {path: ['integrations', 'bunny', 'pull_zone_id'], parse: parseInteger}, FLUXER_RISK_INTEGRATION_ENABLED: {path: ['integrations', 'risk_integration', 'enabled'], parse: parseEnvValue}, FLUXER_RISK_IPINFO_API_KEY: {path: ['integrations', 'risk_integration', 'ipinfo_api_key']}, FLUXER_ACCOUNT_POLICY_DSL: { @@ -354,7 +355,7 @@ const NAMED_FLUXER_ENV_OVERRIDES: Record = { }, FLUXER_RISK_TOR_REVERSE_DNS_TIMEOUT_MS: { path: ['integrations', 'risk_integration', 'tor', 'reverse_dns_timeout_ms'], - parse: parseEnvValue, + parse: parseInteger, }, FLUXER_PUSH_APNS_ENABLED: {path: ['integrations', 'push', 'apns', 'enabled'], parse: parseEnvValue}, FLUXER_PUSH_APNS_TEAM_ID: {path: ['integrations', 'push', 'apns', 'team_id']}, @@ -401,18 +402,18 @@ const NAMED_FLUXER_ENV_OVERRIDES: Record = { }, FLUXER_ABUSE_DIRECT_CONTACT_SPAM_DISTINCT_TARGET_THRESHOLD: { path: ['instance', 'abuse_policy', 'direct_contact_spam', 'distinct_target_threshold'], - parse: parseEnvValue, + parse: parseInteger, }, FLUXER_ABUSE_DIRECT_CONTACT_SPAM_TARGET_WINDOW_MS: { path: ['instance', 'abuse_policy', 'direct_contact_spam', 'target_window_ms'], - parse: parseEnvValue, + parse: parseInteger, }, FLUXER_ABUSE_DIRECT_CONTACT_SPAM_ACTION: { path: ['instance', 'abuse_policy', 'direct_contact_spam', 'action'], }, FLUXER_DISCOVERY_ENABLED: {path: ['discovery', 'enabled'], parse: parseEnvValue}, - FLUXER_DISCOVERY_MIN_MEMBER_COUNT: {path: ['discovery', 'min_member_count'], parse: parseEnvValue}, - FLUXER_DELETION_GRACE_PERIOD_HOURS: {path: ['deletion_grace_period_hours'], parse: parseEnvValue}, + FLUXER_DISCOVERY_MIN_MEMBER_COUNT: {path: ['discovery', 'min_member_count'], parse: parseInteger}, + FLUXER_DELETION_GRACE_PERIOD_HOURS: {path: ['deletion_grace_period_hours'], parse: parseInteger}, FLUXER_RELAX_REGISTRATION_RATE_LIMITS: {path: ['dev', 'relax_registration_rate_limits'], parse: parseEnvValue}, FLUXER_DISABLE_RATE_LIMITS: {path: ['dev', 'disable_rate_limits'], parse: parseEnvValue}, FLUXER_TEST_MODE_ENABLED: {path: ['dev', 'test_mode_enabled'], parse: parseEnvValue}, @@ -467,16 +468,27 @@ export function parseEnvValue(raw: string): unknown { if (/^-?\d+\.\d+$/.test(trimmed)) { return Number.parseFloat(trimmed); } - if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']'))) { + if (trimmed.startsWith('{') || trimmed.startsWith('[')) { try { return JSON.parse(trimmed); - } catch { - return raw; + } catch (error) { + throw new Error(`must be valid JSON: ${error instanceof Error ? error.message : String(error)}`); } } return raw; } +function parseInteger(raw: string): number | undefined { + const trimmed = raw.trim(); + if (trimmed.length === 0) { + return undefined; + } + if (!/^-?\d+$/.test(trimmed)) { + throw new Error(`must be an integer, got ${JSON.stringify(raw)}`); + } + return Number.parseInt(trimmed, 10); +} + function parseCsv(raw: string): Array { return raw .split(',') @@ -500,14 +512,29 @@ export function setNestedValue(target: ConfigContainer, keys: Array = { + FLUXER_INTERNAL_MEDIA_PROXY_ENDPOINT: 'FLUXER_MEDIA_PROXY_ENDPOINT', + FLUXER_NATS_URL: 'FLUXER_NATS_CORE_URL', +}; + export function buildNamedFluxerEnvOverrides(env: NodeJS.ProcessEnv): ConfigObject { const overrides: ConfigObject = {}; for (const [envKey, mapping] of Object.entries(NAMED_FLUXER_ENV_OVERRIDES)) { - const raw = env[envKey]; + const alias = NAMED_FLUXER_ENV_ALIASES[envKey]; + const raw = env[envKey] ?? (alias === undefined ? undefined : env[alias]); if (raw === undefined) { continue; } - setNestedValue(overrides, mapping.path, (mapping.parse ?? ((value: string) => value))(raw)); + let parsed: unknown; + try { + parsed = (mapping.parse ?? ((value: string) => value))(raw); + } catch (error) { + throw new Error(`${envKey} ${error instanceof Error ? error.message : String(error)}`); + } + if (parsed === undefined) { + continue; + } + setNestedValue(overrides, mapping.path, parsed); } return overrides; }