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