Skip to main content

platform_core/
config.rs

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    /// Origins permitted by CORS. Defaults to the local Runtime Console dev
123    /// ports; override with `CORS_ALLOWED_ORIGINS` (comma-separated).
124    #[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/// Parse a comma-separated `CORS_ALLOWED_ORIGINS` value into trimmed, non-empty
155/// origins.
156#[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}