1use std::env;
34use std::fmt;
35use std::path::PathBuf;
36
37use tracing::warn;
38
39#[derive(Debug, Clone)]
55pub struct ServerConfig {
56 pub database_url: Option<String>,
58 pub jwt_secret: String,
60 pub worker_token: String,
62 pub port: u16,
64 pub allowed_origins: Option<String>,
66 pub dashboard_dir: Option<PathBuf>,
68 pub webhook_url: Option<String>,
70 pub is_production: bool,
72 pub rate_limit_auth: Option<u32>,
75 pub rate_limit_general: Option<u32>,
78}
79
80#[derive(Debug, Clone)]
94pub struct ConfigError {
95 pub errors: Vec<String>,
97}
98
99impl ConfigError {
100 pub fn new(errors: Vec<String>) -> Self {
102 Self { errors }
103 }
104}
105
106impl fmt::Display for ConfigError {
107 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
108 writeln!(f, "configuration errors:")?;
109 for error in &self.errors {
110 writeln!(f, " - {error}")?;
111 }
112 Ok(())
113 }
114}
115
116impl std::error::Error for ConfigError {}
117
118const DEV_JWT_SECRET: &str = "ironflow-dev-secret";
119const DEV_WORKER_TOKEN: &str = "ironflow-dev-worker-token";
120
121fn parse_optional_u32(name: &str, default: u32, errors: &mut Vec<String>) -> Option<u32> {
125 match env::var(name).ok() {
126 Some(raw) => match raw.parse::<u32>() {
127 Ok(0) => None,
128 Ok(v) => Some(v),
129 Err(_) => {
130 errors.push(format!(
131 "{name} must be a valid u32 (0 to disable), got: {raw}"
132 ));
133 Some(default)
134 }
135 },
136 None => Some(default),
137 }
138}
139
140impl ServerConfig {
141 pub fn from_env() -> Result<Self, ConfigError> {
165 let is_production = env::var("IRONFLOW_ENV")
166 .map(|v| v.eq_ignore_ascii_case("production"))
167 .unwrap_or(false);
168
169 let mut errors = Vec::new();
170
171 let database_url = env::var("DATABASE_URL").ok();
172 if is_production && database_url.is_none() {
173 errors.push("DATABASE_URL is required in production".to_string());
174 }
175
176 let jwt_secret_env = env::var("JWT_SECRET").ok();
177 let jwt_secret = match jwt_secret_env {
178 Some(val) => val,
179 None if is_production => {
180 errors.push("JWT_SECRET is required in production".to_string());
181 String::new()
182 }
183 None => {
184 warn!("JWT_SECRET not set, using insecure dev default -- do NOT use in production");
185 DEV_JWT_SECRET.to_string()
186 }
187 };
188
189 let worker_token_env = env::var("WORKER_TOKEN").ok();
190 let worker_token = match worker_token_env {
191 Some(val) => val,
192 None if is_production => {
193 errors.push("WORKER_TOKEN is required in production".to_string());
194 String::new()
195 }
196 None => {
197 warn!(
198 "WORKER_TOKEN not set, using insecure dev default -- do NOT use in production"
199 );
200 DEV_WORKER_TOKEN.to_string()
201 }
202 };
203
204 let port = match env::var("PORT").ok() {
205 Some(raw) => raw.parse::<u16>().unwrap_or_else(|_| {
206 errors.push(format!("PORT must be a valid u16, got: {raw}"));
207 0
208 }),
209 None => 3000,
210 };
211
212 let allowed_origins = env::var("ALLOWED_ORIGINS").ok();
213 let dashboard_dir = env::var("DASHBOARD_DIR").ok().map(PathBuf::from);
214 let webhook_url = env::var("WEBHOOK_URL").ok();
215
216 let rate_limit_auth = parse_optional_u32("RATE_LIMIT_AUTH", 10, &mut errors);
217 let rate_limit_general = parse_optional_u32("RATE_LIMIT_GENERAL", 60, &mut errors);
218
219 if !errors.is_empty() {
220 return Err(ConfigError::new(errors));
221 }
222
223 Ok(Self {
224 database_url,
225 jwt_secret,
226 worker_token,
227 port,
228 allowed_origins,
229 dashboard_dir,
230 webhook_url,
231 is_production,
232 rate_limit_auth,
233 rate_limit_general,
234 })
235 }
236}
237
238#[cfg(test)]
239mod tests {
240 use std::sync::Mutex;
241
242 use super::*;
243
244 static ENV_LOCK: Mutex<()> = Mutex::new(());
246
247 unsafe fn clear_env() {
251 unsafe {
252 env::remove_var("IRONFLOW_ENV");
253 env::remove_var("DATABASE_URL");
254 env::remove_var("JWT_SECRET");
255 env::remove_var("WORKER_TOKEN");
256 env::remove_var("PORT");
257 env::remove_var("ALLOWED_ORIGINS");
258 env::remove_var("DASHBOARD_DIR");
259 env::remove_var("WEBHOOK_URL");
260 env::remove_var("RATE_LIMIT_AUTH");
261 env::remove_var("RATE_LIMIT_GENERAL");
262 }
263 }
264
265 #[test]
266 fn config_error_display_lists_all_errors() {
267 let err = ConfigError::new(vec![
268 "JWT_SECRET is required".to_string(),
269 "DATABASE_URL is required".to_string(),
270 ]);
271 let msg = err.to_string();
272 assert!(msg.contains("JWT_SECRET"));
273 assert!(msg.contains("DATABASE_URL"));
274 assert!(msg.contains("configuration errors:"));
275 }
276
277 #[test]
278 fn config_error_is_std_error() {
279 let err = ConfigError::new(vec!["test".to_string()]);
280 let _: &dyn std::error::Error = &err;
281 }
282
283 #[test]
284 fn default_dev_config_succeeds() {
285 let _guard = ENV_LOCK.lock().unwrap();
286 unsafe { clear_env() };
287
288 let config = ServerConfig::from_env().expect("dev config should succeed");
289 assert!(!config.is_production);
290 assert_eq!(config.port, 3000);
291 assert_eq!(config.jwt_secret, DEV_JWT_SECRET);
292 assert_eq!(config.worker_token, DEV_WORKER_TOKEN);
293 }
294
295 #[test]
296 fn production_without_secrets_fails() {
297 let _guard = ENV_LOCK.lock().unwrap();
298 unsafe {
299 clear_env();
300 env::set_var("IRONFLOW_ENV", "production");
301 }
302
303 let result = ServerConfig::from_env();
304 assert!(result.is_err());
305 let err = result.unwrap_err();
306 assert!(err.errors.len() >= 3);
307 assert!(err.errors.iter().any(|e| e.contains("DATABASE_URL")));
308 assert!(err.errors.iter().any(|e| e.contains("JWT_SECRET")));
309 assert!(err.errors.iter().any(|e| e.contains("WORKER_TOKEN")));
310
311 unsafe { env::remove_var("IRONFLOW_ENV") };
312 }
313
314 #[test]
315 fn invalid_port_returns_error() {
316 let _guard = ENV_LOCK.lock().unwrap();
317 unsafe {
318 clear_env();
319 env::set_var("PORT", "not-a-number");
320 }
321
322 let result = ServerConfig::from_env();
323 assert!(result.is_err());
324 let err = result.unwrap_err();
325 assert!(err.errors.iter().any(|e| e.contains("PORT")));
326
327 unsafe { env::remove_var("PORT") };
328 }
329
330 #[test]
331 fn default_rate_limits() {
332 let _guard = ENV_LOCK.lock().unwrap();
333 unsafe { clear_env() };
334
335 let config = ServerConfig::from_env().unwrap();
336 assert_eq!(config.rate_limit_auth, Some(10));
337 assert_eq!(config.rate_limit_general, Some(60));
338 }
339
340 #[test]
341 fn custom_rate_limits() {
342 let _guard = ENV_LOCK.lock().unwrap();
343 unsafe {
344 clear_env();
345 env::set_var("RATE_LIMIT_AUTH", "20");
346 env::set_var("RATE_LIMIT_GENERAL", "120");
347 }
348
349 let config = ServerConfig::from_env().unwrap();
350 assert_eq!(config.rate_limit_auth, Some(20));
351 assert_eq!(config.rate_limit_general, Some(120));
352
353 unsafe {
354 env::remove_var("RATE_LIMIT_AUTH");
355 env::remove_var("RATE_LIMIT_GENERAL");
356 }
357 }
358
359 #[test]
360 fn zero_rate_limit_disables() {
361 let _guard = ENV_LOCK.lock().unwrap();
362 unsafe {
363 clear_env();
364 env::set_var("RATE_LIMIT_AUTH", "0");
365 env::set_var("RATE_LIMIT_GENERAL", "0");
366 }
367
368 let config = ServerConfig::from_env().unwrap();
369 assert!(config.rate_limit_auth.is_none());
370 assert!(config.rate_limit_general.is_none());
371
372 unsafe {
373 env::remove_var("RATE_LIMIT_AUTH");
374 env::remove_var("RATE_LIMIT_GENERAL");
375 }
376 }
377
378 #[test]
379 fn invalid_rate_limit_returns_error() {
380 let _guard = ENV_LOCK.lock().unwrap();
381 unsafe {
382 clear_env();
383 env::set_var("RATE_LIMIT_AUTH", "not-a-number");
384 }
385
386 let result = ServerConfig::from_env();
387 assert!(result.is_err());
388 let err = result.unwrap_err();
389 assert!(err.errors.iter().any(|e| e.contains("RATE_LIMIT_AUTH")));
390
391 unsafe { env::remove_var("RATE_LIMIT_AUTH") };
392 }
393}