1use crate::error::{AppError, AppResult, ErrorDetail};
2use serde::de::DeserializeOwned;
3use serde::{Deserialize, Serialize};
4use std::collections::BTreeMap;
5use std::net::IpAddr;
6
7pub const DEFAULT_LINKED_MODULE_PROFILE: &str = "demo";
8pub const LENSO_COMPOSITION_PROFILE_ENV: &str = "LENSO_COMPOSITION_PROFILE";
9pub const LENSO_ALLOW_DEV_AUTH_ON_PUBLIC_BIND_ENV: &str = "LENSO_ALLOW_DEV_AUTH_ON_PUBLIC_BIND";
10
11#[derive(Debug, Clone, Deserialize, Serialize)]
12pub struct AppConfig {
13 pub service: ServiceConfig,
14 pub database: DatabaseConfig,
15 #[serde(default)]
16 pub redis: RedisConfig,
17 pub http: HttpConfig,
18 pub telemetry: TelemetryConfig,
19 pub auth: AuthConfig,
20 #[serde(default)]
21 pub console: ConsoleConfig,
22 #[serde(default)]
23 pub module_sources: ModuleSourcesConfig,
24 #[serde(default)]
25 pub modules: BTreeMap<String, ModuleConfig>,
26}
27
28impl AppConfig {
29 pub fn from_env() -> Self {
30 Self::try_from_env().expect("valid Lenso application configuration")
31 }
32
33 pub fn try_from_env() -> AppResult<Self> {
34 let _ = dotenvy::dotenv();
35 let service = ServiceConfig::default();
36 let http = HttpConfig::default();
37 validate_dev_auth_http_bind(&service, &http, dev_auth_public_bind_override_from_env())?;
38 Ok(Self {
39 module_sources: ModuleSourcesConfig::try_from_env_for_environment(
40 &service.environment,
41 )?,
42 service,
43 database: DatabaseConfig::from_env(),
44 redis: RedisConfig::from_env(),
45 http,
46 telemetry: TelemetryConfig::default(),
47 auth: AuthConfig::default(),
48 console: ConsoleConfig::default(),
49 modules: module_configs_from_env(),
50 })
51 }
52
53 pub fn module_local_config<T: DeserializeOwned>(&self, module_name: &str) -> AppResult<T> {
54 let values = self
55 .modules
56 .get(module_name)
57 .map(|config| config.values.clone())
58 .unwrap_or_default();
59 decode_module_local_config(module_name, &values)
60 }
61}
62
63#[derive(Debug, Clone, Deserialize, Serialize)]
64pub struct ServiceConfig {
65 pub name: String,
66 pub environment: String,
67}
68
69impl Default for ServiceConfig {
70 fn default() -> Self {
71 Self {
72 name: std::env::var("SERVICE_NAME").unwrap_or_else(|_| "lenso".to_owned()),
73 environment: std::env::var("APP_ENV").unwrap_or_else(|_| "local".to_owned()),
74 }
75 }
76}
77
78#[derive(Debug, Clone, Deserialize, Serialize)]
79pub struct DatabaseConfig {
80 pub url: String,
81 pub max_connections: u32,
82}
83
84impl DatabaseConfig {
85 fn from_env() -> Self {
86 Self {
87 url: std::env::var("DATABASE_URL")
88 .unwrap_or_else(|_| "postgres://lenso:lenso@localhost:5432/lenso".to_owned()),
89 max_connections: std::env::var("DATABASE_MAX_CONNECTIONS")
90 .ok()
91 .and_then(|value| value.parse().ok())
92 .unwrap_or(10),
93 }
94 }
95}
96
97#[derive(Debug, Clone, Default, Deserialize, Serialize)]
98pub struct RedisConfig {
99 pub url: Option<String>,
100}
101
102impl RedisConfig {
103 fn from_env() -> Self {
104 Self::from_url_value(std::env::var("REDIS_URL").ok().as_deref())
105 }
106
107 #[must_use]
108 pub fn from_url_value(value: Option<&str>) -> Self {
109 Self {
110 url: value
111 .map(str::trim)
112 .filter(|value| !value.is_empty())
113 .map(ToOwned::to_owned),
114 }
115 }
116}
117
118#[derive(Debug, Clone, Deserialize, Serialize)]
119pub struct HttpConfig {
120 pub host: String,
121 pub port: u16,
122 #[serde(default)]
125 pub cors_allowed_origins: Vec<String>,
126}
127
128impl Default for HttpConfig {
129 fn default() -> Self {
130 Self {
131 host: http_host_from_env_value(std::env::var("HTTP_HOST").ok()),
132 port: std::env::var("HTTP_PORT")
133 .ok()
134 .and_then(|value| value.parse().ok())
135 .unwrap_or(3000),
136 cors_allowed_origins: std::env::var("CORS_ALLOWED_ORIGINS").map_or_else(
137 |_| default_cors_allowed_origins(),
138 |value| parse_cors_allowed_origins(&value),
139 ),
140 }
141 }
142}
143
144fn http_host_from_env_value(value: Option<String>) -> String {
145 value.unwrap_or_else(|| "127.0.0.1".to_owned())
146}
147
148fn default_cors_allowed_origins() -> Vec<String> {
149 (5173..=5177)
150 .map(|port| format!("http://localhost:{port}"))
151 .collect()
152}
153
154#[must_use]
157pub fn parse_cors_allowed_origins(value: &str) -> Vec<String> {
158 value
159 .split(',')
160 .map(str::trim)
161 .filter(|origin| !origin.is_empty())
162 .map(ToOwned::to_owned)
163 .collect()
164}
165
166#[derive(Debug, Clone, Deserialize, Serialize)]
167pub struct TelemetryConfig {
168 pub log_level: String,
169 #[serde(default)]
170 pub log_format: LogFormat,
171 pub otlp_endpoint: Option<String>,
172}
173
174impl Default for TelemetryConfig {
175 fn default() -> Self {
176 Self {
177 log_level: std::env::var("RUST_LOG").unwrap_or_else(|_| "info".to_owned()),
178 log_format: std::env::var("LOG_FORMAT")
179 .ok()
180 .and_then(|value| LogFormat::from_env_value(&value))
181 .unwrap_or_default(),
182 otlp_endpoint: std::env::var("OTEL_EXPORTER_OTLP_ENDPOINT").ok(),
183 }
184 }
185}
186
187#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, PartialEq, Eq)]
188#[serde(rename_all = "snake_case")]
189pub enum LogFormat {
190 #[default]
191 Compact,
192 Json,
193}
194
195impl LogFormat {
196 pub fn from_env_value(value: &str) -> Option<Self> {
197 match value.trim().to_ascii_lowercase().as_str() {
198 "compact" | "terminal" | "text" => Some(Self::Compact),
199 "json" => Some(Self::Json),
200 _ => None,
201 }
202 }
203}
204
205#[derive(Debug, Clone, Default, Deserialize, Serialize)]
206pub struct AuthConfig {
207 pub issuer: Option<String>,
208 pub audience: Option<String>,
209}
210
211#[derive(Debug, Clone, Deserialize, Serialize)]
212pub struct ConsoleConfig {
213 pub dist_dir: String,
214 pub extensions_dir: String,
215}
216
217impl Default for ConsoleConfig {
218 fn default() -> Self {
219 Self {
220 dist_dir: std::env::var("LENSO_CONSOLE_DIST_DIR")
221 .unwrap_or_else(|_| ".lenso/console/dist".to_owned()),
222 extensions_dir: std::env::var("LENSO_CONSOLE_EXTENSIONS_DIR")
223 .unwrap_or_else(|_| ".lenso/console/extensions".to_owned()),
224 }
225 }
226}
227
228#[derive(Debug, Clone, Default, Deserialize, Serialize)]
229pub struct ModuleConfig {
230 #[serde(default)]
231 pub enabled: Option<bool>,
232 #[serde(flatten)]
233 pub values: BTreeMap<String, serde_json::Value>,
234}
235
236impl ModuleConfig {
237 #[must_use]
238 pub fn is_enabled(&self) -> bool {
239 self.enabled.unwrap_or(true)
240 }
241
242 pub fn local_config<T: DeserializeOwned>(&self, module_name: &str) -> AppResult<T> {
243 decode_module_local_config(module_name, &self.values)
244 }
245}
246
247fn module_configs_from_env() -> BTreeMap<String, ModuleConfig> {
248 let mut configs = BTreeMap::new();
249 for (key, value) in std::env::vars() {
250 if let Some((module_name, update)) = module_config_from_env_entry(&key, &value) {
251 merge_module_config(&mut configs, module_name, update);
252 }
253 }
254 configs
255}
256
257fn module_config_from_env_entry(key: &str, value: &str) -> Option<(String, ModuleConfig)> {
258 let rest = key.strip_prefix("LENSO_MODULE_")?;
259 if !rest.contains("__")
260 && let Some(module_name) = rest.strip_suffix("_ENABLED").and_then(module_env_name)
261 {
262 return Some((
263 module_name,
264 ModuleConfig {
265 enabled: Some(parse_bool_env(value)?),
266 values: BTreeMap::new(),
267 },
268 ));
269 }
270
271 let (module_name, config_key) = rest.split_once("__")?;
272 let module_name = module_env_name(module_name)?;
273 let config_key = module_value_env_key(config_key)?;
274 let mut values = BTreeMap::new();
275 values.insert(config_key, parse_module_env_value(value));
276 Some((
277 module_name,
278 ModuleConfig {
279 enabled: None,
280 values,
281 },
282 ))
283}
284
285fn merge_module_config(
286 configs: &mut BTreeMap<String, ModuleConfig>,
287 module_name: String,
288 update: ModuleConfig,
289) {
290 let config = configs.entry(module_name).or_default();
291 if update.enabled.is_some() {
292 config.enabled = update.enabled;
293 }
294 config.values.extend(update.values);
295}
296
297fn module_env_name(value: &str) -> Option<String> {
298 let name = value
299 .trim_matches('_')
300 .to_ascii_lowercase()
301 .replace('_', "-");
302 (!name.is_empty()).then_some(name)
303}
304
305fn module_value_env_key(value: &str) -> Option<String> {
306 let key = value.trim_matches('_').to_ascii_lowercase();
307 (!key.is_empty()).then_some(key)
308}
309
310fn parse_module_env_value(value: &str) -> serde_json::Value {
311 let trimmed = value.trim();
312 serde_json::from_str(trimmed).unwrap_or_else(|_| serde_json::json!(trimmed))
313}
314
315fn decode_module_local_config<T: DeserializeOwned>(
316 module_name: &str,
317 values: &BTreeMap<String, serde_json::Value>,
318) -> AppResult<T> {
319 let object = values
320 .iter()
321 .map(|(key, value)| (key.clone(), value.clone()))
322 .collect();
323 serde_json::from_value(serde_json::Value::Object(object)).map_err(|source| {
324 AppError::validation(
325 "Invalid module local configuration",
326 vec![ErrorDetail {
327 field: Some(format!("modules.{module_name}")),
328 reason: source.to_string(),
329 }],
330 )
331 })
332}
333
334fn parse_bool_env(value: &str) -> Option<bool> {
335 match value.trim().to_ascii_lowercase().as_str() {
336 "1" | "true" | "yes" | "on" => Some(true),
337 "0" | "false" | "no" | "off" => Some(false),
338 _ => None,
339 }
340}
341
342#[derive(Debug, Clone, Deserialize, Serialize)]
343pub struct ModuleSourcesConfig {
344 #[serde(default = "default_linked_module_profile")]
345 pub linked_profile: String,
346 #[serde(default)]
347 pub remote: Vec<RemoteModuleSourceConfig>,
348}
349
350impl ModuleSourcesConfig {
351 fn try_from_env_for_environment(environment: &str) -> AppResult<Self> {
352 Ok(Self {
353 linked_profile: linked_module_profile_from_env_value(
354 std::env::var(LENSO_COMPOSITION_PROFILE_ENV).ok().as_deref(),
355 environment,
356 )?,
357 remote: remote_module_sources_from_env(),
358 })
359 }
360}
361
362impl Default for ModuleSourcesConfig {
363 fn default() -> Self {
364 Self {
365 linked_profile: default_linked_module_profile(),
366 remote: Vec::new(),
367 }
368 }
369}
370
371fn default_linked_module_profile() -> String {
372 DEFAULT_LINKED_MODULE_PROFILE.to_owned()
373}
374
375fn linked_module_profile_from_env_value(
376 value: Option<&str>,
377 environment: &str,
378) -> AppResult<String> {
379 let Some(profile) = value.map(str::trim).filter(|value| !value.is_empty()) else {
380 if is_local_development_environment(environment) {
381 return Ok(DEFAULT_LINKED_MODULE_PROFILE.to_owned());
382 }
383 return Err(AppError::validation(
384 "Lenso composition profile is required outside local development",
385 vec![ErrorDetail {
386 field: Some(LENSO_COMPOSITION_PROFILE_ENV.to_owned()),
387 reason: format!(
388 "set {LENSO_COMPOSITION_PROFILE_ENV}=core or {LENSO_COMPOSITION_PROFILE_ENV}=demo when APP_ENV is `{}`",
389 environment.trim()
390 ),
391 }],
392 ));
393 };
394
395 Ok(profile.to_owned())
396}
397
398#[must_use]
399pub fn is_local_development_environment(environment: &str) -> bool {
400 matches!(
401 environment.trim().to_ascii_lowercase().as_str(),
402 "local" | "dev" | "development" | "test"
403 )
404}
405
406fn dev_auth_public_bind_override_from_env() -> bool {
407 std::env::var(LENSO_ALLOW_DEV_AUTH_ON_PUBLIC_BIND_ENV)
408 .ok()
409 .and_then(|value| parse_bool_env(&value))
410 .unwrap_or(false)
411}
412
413fn validate_dev_auth_http_bind(
414 service: &ServiceConfig,
415 http: &HttpConfig,
416 allow_public_bind: bool,
417) -> AppResult<()> {
418 if !is_local_development_environment(&service.environment)
419 || allow_public_bind
420 || is_loopback_http_host(&http.host)
421 {
422 return Ok(());
423 }
424
425 Err(AppError::validation(
426 "Development auth cannot listen on a public HTTP bind by default",
427 vec![ErrorDetail {
428 field: Some("HTTP_HOST".to_owned()),
429 reason: format!(
430 "set HTTP_HOST=127.0.0.1, set APP_ENV outside local development, or set {LENSO_ALLOW_DEV_AUTH_ON_PUBLIC_BIND_ENV}=true"
431 ),
432 }],
433 ))
434}
435
436fn is_loopback_http_host(host: &str) -> bool {
437 let host = host.trim().trim_start_matches('[').trim_end_matches(']');
438 host.eq_ignore_ascii_case("localhost")
439 || host
440 .parse::<IpAddr>()
441 .is_ok_and(|address| address.is_loopback())
442}
443
444#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
445pub struct RemoteModuleSourceConfig {
446 pub name: String,
447 pub base_url: String,
448 pub auth_token_env: Option<String>,
449 pub timeout_ms: u64,
450}
451
452fn remote_module_sources_from_env() -> Vec<RemoteModuleSourceConfig> {
453 let Some(raw) = std::env::var("REMOTE_MODULES").ok() else {
454 return Vec::new();
455 };
456
457 raw.split(',')
458 .filter_map(|entry| parse_remote_module_source(entry.trim()))
459 .collect()
460}
461
462fn parse_remote_module_source(entry: &str) -> Option<RemoteModuleSourceConfig> {
463 if entry.is_empty() {
464 return None;
465 }
466 let (name, base_url) = entry.split_once('=')?;
467 let name = name.trim();
468 let base_url = base_url.trim();
469 if name.is_empty() || base_url.is_empty() {
470 return None;
471 }
472
473 let env_prefix = name.replace('-', "_").to_ascii_uppercase();
474 let token_env = format!("REMOTE_MODULE_{env_prefix}_TOKEN");
475 let timeout_env = format!("REMOTE_MODULE_{env_prefix}_TIMEOUT_MS");
476
477 Some(RemoteModuleSourceConfig {
478 name: name.to_owned(),
479 base_url: base_url.trim_end_matches('/').to_owned(),
480 auth_token_env: Some(token_env),
481 timeout_ms: std::env::var(timeout_env)
482 .ok()
483 .and_then(|value| value.parse().ok())
484 .unwrap_or(5_000),
485 })
486}
487
488#[cfg(test)]
489mod tests {
490 use super::*;
491 use serde::Deserialize;
492
493 #[test]
494 fn module_sources_default_to_demo_linked_profile() {
495 let config = ModuleSourcesConfig::default();
496
497 assert_eq!(config.linked_profile, DEFAULT_LINKED_MODULE_PROFILE);
498 assert!(config.remote.is_empty());
499 }
500
501 #[test]
502 fn linked_module_profile_from_env_value_trims_empty_to_default() {
503 assert_eq!(
504 linked_module_profile_from_env_value(None, "local").expect("local default"),
505 DEFAULT_LINKED_MODULE_PROFILE
506 );
507 assert_eq!(
508 linked_module_profile_from_env_value(Some(" "), "development")
509 .expect("development default"),
510 DEFAULT_LINKED_MODULE_PROFILE
511 );
512 assert_eq!(
513 linked_module_profile_from_env_value(Some("core"), "production")
514 .expect("explicit profile"),
515 "core"
516 );
517 assert_eq!(
518 linked_module_profile_from_env_value(Some(" demo "), "production")
519 .expect("explicit profile"),
520 "demo"
521 );
522 }
523
524 #[test]
525 fn linked_module_profile_from_env_value_requires_explicit_profile_outside_local() {
526 let error = linked_module_profile_from_env_value(None, "production")
527 .expect_err("production requires explicit linked profile");
528
529 assert_eq!(error.code, crate::ErrorCode::Validation);
530 assert_eq!(
531 error.details[0].field.as_deref(),
532 Some(LENSO_COMPOSITION_PROFILE_ENV)
533 );
534 assert!(
535 error.details[0]
536 .reason
537 .contains("LENSO_COMPOSITION_PROFILE=core")
538 );
539 }
540
541 #[test]
542 fn local_environment_rejects_public_http_bind_without_override() {
543 let service = ServiceConfig {
544 environment: "local".to_owned(),
545 ..ServiceConfig::default()
546 };
547 let http = HttpConfig {
548 host: "0.0.0.0".to_owned(),
549 ..HttpConfig::default()
550 };
551
552 let error = validate_dev_auth_http_bind(&service, &http, false)
553 .expect_err("public bind must be explicit in local env");
554
555 assert_eq!(error.code, crate::ErrorCode::Validation);
556 assert_eq!(error.details[0].field.as_deref(), Some("HTTP_HOST"));
557 }
558
559 #[test]
560 fn local_environment_allows_loopback_http_bind() {
561 let service = ServiceConfig {
562 environment: "local".to_owned(),
563 ..ServiceConfig::default()
564 };
565 let http = HttpConfig {
566 host: "127.0.0.1".to_owned(),
567 ..HttpConfig::default()
568 };
569
570 validate_dev_auth_http_bind(&service, &http, false).expect("loopback bind is local only");
571 }
572
573 #[test]
574 fn http_config_default_host_value_is_loopback() {
575 assert_eq!(http_host_from_env_value(None), "127.0.0.1");
576 }
577
578 #[test]
579 fn module_sources_deserialize_missing_linked_profile_to_default() {
580 let config: ModuleSourcesConfig =
581 serde_json::from_value(serde_json::json!({ "remote": [] }))
582 .expect("module sources deserialize");
583
584 assert_eq!(config.linked_profile, DEFAULT_LINKED_MODULE_PROFILE);
585 assert!(config.remote.is_empty());
586 }
587
588 #[test]
589 fn parses_remote_module_source_entry() {
590 let config = parse_remote_module_source("remote-crm=http://localhost:4100/lenso/module/v1")
591 .expect("parse remote source");
592 assert_eq!(config.name, "remote-crm");
593 assert_eq!(config.base_url, "http://localhost:4100/lenso/module/v1");
594 assert_eq!(
595 config.auth_token_env.as_deref(),
596 Some("REMOTE_MODULE_REMOTE_CRM_TOKEN")
597 );
598 assert_eq!(config.timeout_ms, 5_000);
599 }
600
601 #[test]
602 fn ignores_malformed_remote_module_source_entry() {
603 assert!(parse_remote_module_source("").is_none());
604 assert!(parse_remote_module_source("missing-url").is_none());
605 assert!(parse_remote_module_source("=http://localhost:4100").is_none());
606 }
607
608 #[test]
609 fn module_config_from_env_entry_parses_enabled_override() {
610 let (name, config) =
611 module_config_from_env_entry("LENSO_MODULE_AUTH_PASSWORD_ENABLED", "false")
612 .expect("module enabled env should parse");
613
614 assert_eq!(name, "auth-password");
615 assert_eq!(config.enabled, Some(false));
616 }
617
618 #[test]
619 fn module_config_from_env_entry_parses_local_values() {
620 let (name, config) =
621 module_config_from_env_entry("LENSO_MODULE_AUTH_PASSWORD__JWT_TTL_HOURS", "12")
622 .expect("module local value env should parse");
623
624 assert_eq!(name, "auth-password");
625 assert_eq!(
626 config.values.get("jwt_ttl_hours"),
627 Some(&serde_json::json!(12))
628 );
629
630 let (_, config) =
631 module_config_from_env_entry("LENSO_MODULE_AUTH__PUBLIC_URL", "https://example.test")
632 .expect("module string value env should parse");
633 assert_eq!(
634 config.values.get("public_url"),
635 Some(&serde_json::json!("https://example.test"))
636 );
637
638 let (_, config) = module_config_from_env_entry("LENSO_MODULE_AUTH__ENABLED", "\"local\"")
639 .expect("module local enabled key should parse as a value");
640 assert_eq!(
641 config.values.get("enabled"),
642 Some(&serde_json::json!("local"))
643 );
644 assert_eq!(config.enabled, None);
645 }
646
647 #[test]
648 fn merge_module_config_keeps_enabled_and_values() {
649 let mut configs = BTreeMap::new();
650 let (_, enabled) = module_config_from_env_entry("LENSO_MODULE_AUTH_ENABLED", "false")
651 .expect("enabled parses");
652 let (_, local) = module_config_from_env_entry("LENSO_MODULE_AUTH__PUBLIC_URL", "\"/auth\"")
653 .expect("local value parses");
654
655 merge_module_config(&mut configs, "auth".to_owned(), enabled);
656 merge_module_config(&mut configs, "auth".to_owned(), local);
657
658 let config = configs.get("auth").expect("merged module config");
659 assert_eq!(config.enabled, Some(false));
660 assert_eq!(
661 config.values.get("public_url"),
662 Some(&serde_json::json!("/auth"))
663 );
664 }
665
666 #[derive(Debug, Deserialize, PartialEq)]
667 struct DemoModuleLocalConfig {
668 public_url: String,
669 #[serde(default)]
670 ttl_hours: u64,
671 }
672
673 #[test]
674 fn module_config_decodes_local_values() {
675 let (_, config) = module_config_from_env_entry("LENSO_MODULE_DEMO__PUBLIC_URL", "/demo")
676 .expect("local value parses");
677
678 let decoded: DemoModuleLocalConfig = config.local_config("demo").expect("decode config");
679
680 assert_eq!(
681 decoded,
682 DemoModuleLocalConfig {
683 public_url: "/demo".to_owned(),
684 ttl_hours: 0,
685 }
686 );
687 }
688
689 #[test]
690 fn redis_config_treats_empty_url_as_disabled() {
691 assert!(RedisConfig::from_url_value(None).url.is_none());
692 assert!(RedisConfig::from_url_value(Some(" ")).url.is_none());
693 assert_eq!(
694 RedisConfig::from_url_value(Some(" redis://localhost:6379/0 "))
695 .url
696 .as_deref(),
697 Some("redis://localhost:6379/0")
698 );
699 }
700}