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