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: 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/// Parse a comma-separated `CORS_ALLOWED_ORIGINS` value into trimmed, non-empty
151/// origins.
152#[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}