Skip to main content

liminal_server/config/
env.rs

1use std::ffi::{OsStr, OsString};
2use std::net::SocketAddr;
3use std::path::PathBuf;
4
5use crate::ServerError;
6
7use super::types::ServerConfig;
8
9const ENV_PREFIX: &str = "LIMINAL_";
10const LISTEN_ADDRESS: &str = "LIMINAL_LISTEN_ADDRESS";
11const HEALTH_LISTEN_ADDRESS: &str = "LIMINAL_HEALTH_LISTEN_ADDRESS";
12const DRAIN_TIMEOUT_MS: &str = "LIMINAL_DRAIN_TIMEOUT_MS";
13const PERSISTENCE_PATH: &str = "LIMINAL_PERSISTENCE_PATH";
14const CLUSTER_NODE_NAME: &str = "LIMINAL_CLUSTER_NODE_NAME";
15const CLUSTER_SEED_NODES: &str = "LIMINAL_CLUSTER_SEED_NODES";
16const CLUSTER_LISTEN_ADDRESS: &str = "LIMINAL_CLUSTER_LISTEN_ADDRESS";
17const CLUSTER_COOKIE: &str = "LIMINAL_CLUSTER_COOKIE";
18const AUTH_TOKEN: &str = "LIMINAL_AUTH_TOKEN";
19const WEBSOCKET_LISTEN_ADDRESS: &str = "LIMINAL_WEBSOCKET_LISTEN_ADDRESS";
20const WEBSOCKET_PATH: &str = "LIMINAL_WEBSOCKET_PATH";
21const WEBSOCKET_ALLOWED_ORIGINS: &str = "LIMINAL_WEBSOCKET_ALLOWED_ORIGINS";
22const WEBSOCKET_PING_INTERVAL_MS: &str = "LIMINAL_WEBSOCKET_PING_INTERVAL_MS";
23
24/// Applies supported `LIMINAL_` environment variable overrides to a config.
25///
26/// Absent variables leave the supplied configuration unchanged. Supported
27/// variables are `LIMINAL_LISTEN_ADDRESS`, `LIMINAL_HEALTH_LISTEN_ADDRESS`,
28/// `LIMINAL_DRAIN_TIMEOUT_MS`, `LIMINAL_PERSISTENCE_PATH`,
29/// `LIMINAL_CLUSTER_NODE_NAME`, `LIMINAL_CLUSTER_SEED_NODES`,
30/// `LIMINAL_CLUSTER_LISTEN_ADDRESS`, `LIMINAL_CLUSTER_COOKIE`,
31/// `LIMINAL_AUTH_TOKEN`, `LIMINAL_WEBSOCKET_LISTEN_ADDRESS`,
32/// `LIMINAL_WEBSOCKET_PATH`, `LIMINAL_WEBSOCKET_ALLOWED_ORIGINS`, and
33/// `LIMINAL_WEBSOCKET_PING_INTERVAL_MS`.
34///
35/// Unlike the cluster and websocket overrides — which refuse to fabricate a
36/// `[cluster]`/`[websocket]` section that the file did not declare (a
37/// partially-specified section is unsafe) —
38/// `LIMINAL_AUTH_TOKEN` MAY create the `[auth]` section when the file omits it.
39/// The auth section is a single scalar secret with no other required fields, so a
40/// deployment can inject the token purely from the environment (the idiomatic way
41/// to supply a secret) without also pinning it in a committed config file; an empty
42/// value still fails the later non-empty validation, so this cannot silently create
43/// a no-op gate.
44///
45/// # Errors
46///
47/// Returns [`ServerError`] when a present environment variable cannot be parsed
48/// or targets cluster configuration that was not declared in the file.
49pub fn apply_env_overrides(config: ServerConfig) -> Result<ServerConfig, ServerError> {
50    apply_env_overrides_from(config, std::env::vars_os())
51}
52
53pub(crate) fn apply_env_overrides_from<I>(
54    mut config: ServerConfig,
55    variables: I,
56) -> Result<ServerConfig, ServerError>
57where
58    I: IntoIterator<Item = (OsString, OsString)>,
59{
60    for (key, value) in variables {
61        let Some(key) = key.to_str() else {
62            continue;
63        };
64
65        if !key.starts_with(ENV_PREFIX) {
66            continue;
67        }
68
69        match key {
70            LISTEN_ADDRESS => {
71                config.listen_address = parse_socket_addr(LISTEN_ADDRESS, &value)?;
72            }
73            HEALTH_LISTEN_ADDRESS => {
74                config.health_listen_address = parse_socket_addr(HEALTH_LISTEN_ADDRESS, &value)?;
75            }
76            DRAIN_TIMEOUT_MS => {
77                config.drain_timeout_ms = parse_u64(DRAIN_TIMEOUT_MS, &value)?;
78            }
79            PERSISTENCE_PATH => {
80                config.persistence_path = Some(PathBuf::from(value));
81            }
82            CLUSTER_NODE_NAME => {
83                let node_name = env_string(CLUSTER_NODE_NAME, &value)?;
84                cluster_required(&mut config, CLUSTER_NODE_NAME)?.node_name = node_name;
85            }
86            CLUSTER_SEED_NODES => {
87                let seed_nodes = parse_seed_nodes(&value)?;
88                cluster_required(&mut config, CLUSTER_SEED_NODES)?.seed_nodes = seed_nodes;
89            }
90            CLUSTER_LISTEN_ADDRESS => {
91                let listen_address = parse_socket_addr(CLUSTER_LISTEN_ADDRESS, &value)?;
92                cluster_required(&mut config, CLUSTER_LISTEN_ADDRESS)?.listen_address =
93                    listen_address;
94            }
95            CLUSTER_COOKIE => {
96                let cookie = env_string(CLUSTER_COOKIE, &value)?;
97                cluster_required(&mut config, CLUSTER_COOKIE)?.cookie = cookie;
98            }
99            WEBSOCKET_LISTEN_ADDRESS => {
100                let listen_address = parse_socket_addr(WEBSOCKET_LISTEN_ADDRESS, &value)?;
101                websocket_required(&mut config, WEBSOCKET_LISTEN_ADDRESS)?.listen_address =
102                    listen_address;
103            }
104            WEBSOCKET_PATH => {
105                let path = env_string(WEBSOCKET_PATH, &value)?;
106                websocket_required(&mut config, WEBSOCKET_PATH)?.path = path;
107            }
108            WEBSOCKET_ALLOWED_ORIGINS => {
109                let allowed_origins = parse_allowed_origins(&value)?;
110                websocket_required(&mut config, WEBSOCKET_ALLOWED_ORIGINS)?.allowed_origins =
111                    allowed_origins;
112            }
113            WEBSOCKET_PING_INTERVAL_MS => {
114                let interval = parse_u64(WEBSOCKET_PING_INTERVAL_MS, &value)?;
115                websocket_required(&mut config, WEBSOCKET_PING_INTERVAL_MS)?.ping_interval_ms =
116                    Some(interval);
117            }
118            AUTH_TOKEN => {
119                // A single scalar secret is allowed to create the `[auth]` section
120                // even when the file omits it (see the module-level note); an empty
121                // value is left to fail the non-empty auth validation, not rejected
122                // here, so precedence and error reporting stay uniform.
123                let token = env_string(AUTH_TOKEN, &value)?;
124                config.auth = Some(super::types::AuthConfig { token, pass: None });
125            }
126            _ => {}
127        }
128    }
129
130    Ok(config)
131}
132
133fn parse_socket_addr(name: &str, value: &OsStr) -> Result<SocketAddr, ServerError> {
134    let value = env_string(name, value)?;
135    value.parse::<SocketAddr>().map_err(|error| {
136        config_load(format!(
137            "environment variable {name} must be a socket address: {error}"
138        ))
139    })
140}
141
142fn parse_u64(name: &str, value: &OsStr) -> Result<u64, ServerError> {
143    let value = env_string(name, value)?;
144    value.parse::<u64>().map_err(|error| {
145        config_load(format!(
146            "environment variable {name} must be an unsigned integer: {error}"
147        ))
148    })
149}
150
151fn parse_seed_nodes(value: &OsStr) -> Result<Vec<SocketAddr>, ServerError> {
152    let value = env_string(CLUSTER_SEED_NODES, value)?;
153    if value.trim().is_empty() {
154        return Ok(Vec::new());
155    }
156
157    value
158        .split(',')
159        .enumerate()
160        .map(|(index, candidate)| parse_seed_node(index, candidate))
161        .collect()
162}
163
164fn parse_seed_node(index: usize, candidate: &str) -> Result<SocketAddr, ServerError> {
165    let candidate = candidate.trim();
166    if candidate.is_empty() {
167        return Err(config_load(format!(
168            "environment variable {CLUSTER_SEED_NODES} contains an empty seed node at position {}",
169            index + 1
170        )));
171    }
172
173    candidate.parse::<SocketAddr>().map_err(|error| {
174        config_load(format!(
175            "environment variable {CLUSTER_SEED_NODES} contains invalid seed node '{}' at position {}: {error}",
176            candidate,
177            index + 1
178        ))
179    })
180}
181
182fn env_string(name: &str, value: &OsStr) -> Result<String, ServerError> {
183    value.to_str().map(str::to_owned).ok_or_else(|| {
184        config_load(format!(
185            "environment variable {name} contains non-Unicode data"
186        ))
187    })
188}
189
190/// Parses the comma-separated `LIMINAL_WEBSOCKET_ALLOWED_ORIGINS` list. An
191/// empty value yields the empty (fail-closed) list; entries are trimmed and an
192/// empty entry between commas is a typed error rather than a silently dropped
193/// origin. Semantic origin-shape checks stay in config validation so file- and
194/// environment-sourced lists are held to the identical rules.
195fn parse_allowed_origins(value: &OsStr) -> Result<Vec<String>, ServerError> {
196    let value = env_string(WEBSOCKET_ALLOWED_ORIGINS, value)?;
197    if value.trim().is_empty() {
198        return Ok(Vec::new());
199    }
200    value
201        .split(',')
202        .enumerate()
203        .map(|(index, candidate)| {
204            let candidate = candidate.trim();
205            if candidate.is_empty() {
206                Err(config_load(format!(
207                    "environment variable {WEBSOCKET_ALLOWED_ORIGINS} contains an empty origin \
208                     at position {}",
209                    index + 1
210                )))
211            } else {
212                Ok(candidate.to_owned())
213            }
214        })
215        .collect()
216}
217
218/// Mirrors [`cluster_required`]: the `[websocket]` section has two required
219/// fields with no defaults, so the environment may adjust a declared section but
220/// must never fabricate a partially-specified acceptor.
221fn websocket_required<'a>(
222    config: &'a mut ServerConfig,
223    name: &str,
224) -> Result<&'a mut super::types::WebSocketConfig, ServerError> {
225    config
226        .websocket
227        .as_mut()
228        .ok_or_else(|| ServerError::ConfigValidation {
229            message: format!(
230                "environment variable {name} requires a [websocket] section in the configuration \
231                 file"
232            ),
233        })
234}
235
236fn cluster_required<'a>(
237    config: &'a mut ServerConfig,
238    name: &str,
239) -> Result<&'a mut super::types::ClusterConfig, ServerError> {
240    config
241        .cluster
242        .as_mut()
243        .ok_or_else(|| ServerError::ConfigValidation {
244            message: format!(
245                "environment variable {name} requires a [cluster] section in the configuration file"
246            ),
247        })
248}
249
250const fn config_load(message: String) -> ServerError {
251    ServerError::ConfigLoad { message }
252}
253
254#[cfg(test)]
255mod tests {
256    use std::ffi::OsString;
257    use std::net::SocketAddr;
258    use std::path::{Path, PathBuf};
259
260    use crate::ServerError;
261
262    use super::apply_env_overrides_from;
263    use crate::config::types::{ChannelDef, ClusterConfig, RoutingRuleDef, ServerConfig};
264    use crate::config::{load_from_file, validate};
265
266    fn socket(address: &str) -> Result<SocketAddr, Box<dyn std::error::Error>> {
267        Ok(address.parse()?)
268    }
269
270    fn sample_config() -> Result<ServerConfig, Box<dyn std::error::Error>> {
271        Ok(ServerConfig {
272            listen_address: socket("127.0.0.1:8080")?,
273            health_listen_address: socket("127.0.0.1:8081")?,
274            drain_timeout_ms: 30_000,
275            channels: vec![ChannelDef {
276                name: "orders".to_owned(),
277                schema_ref: None,
278                durable: true,
279                loaded_schema: None,
280            }],
281            routing_rules: vec![RoutingRuleDef {
282                source_channel: "orders".to_owned(),
283                target_channel: "orders".to_owned(),
284                predicate: None,
285            }],
286            persistence_path: Some(PathBuf::from("/tmp")),
287            cluster: Some(ClusterConfig {
288                node_name: "node-a".to_owned(),
289                listen_address: socket("127.0.0.1:9000")?,
290                seed_nodes: vec![socket("127.0.0.1:9001")?],
291                cookie: "test-cookie".to_owned(),
292            }),
293            auth: None,
294            services: crate::config::types::ServicesConfig::default(),
295            limits: crate::config::types::LimitsConfig::default(),
296            participant: None,
297            websocket: None,
298        })
299    }
300
301    fn env_pair(name: &str, value: &str) -> (OsString, OsString) {
302        (OsString::from(name), OsString::from(value))
303    }
304
305    fn write_temp_config(contents: &str) -> Result<PathBuf, Box<dyn std::error::Error>> {
306        let path = std::env::temp_dir().join(format!(
307            "liminal-server-env-pipeline-{}.toml",
308            std::process::id()
309        ));
310        std::fs::write(&path, contents)?;
311        Ok(path)
312    }
313
314    fn remove_temp_file(path: &Path) -> Result<(), Box<dyn std::error::Error>> {
315        if path.exists() {
316            std::fs::remove_file(path)?;
317        }
318        Ok(())
319    }
320
321    #[test]
322    fn listen_address_override_replaces_file_value() -> Result<(), Box<dyn std::error::Error>> {
323        let config = sample_config()?;
324        let config = apply_env_overrides_from(
325            config,
326            vec![env_pair("LIMINAL_LISTEN_ADDRESS", "0.0.0.0:9090")],
327        )?;
328
329        assert_eq!(config.listen_address, socket("0.0.0.0:9090")?);
330
331        Ok(())
332    }
333
334    #[test]
335    fn health_listen_address_override_replaces_file_value() -> Result<(), Box<dyn std::error::Error>>
336    {
337        let config = sample_config()?;
338        let config = apply_env_overrides_from(
339            config,
340            vec![env_pair("LIMINAL_HEALTH_LISTEN_ADDRESS", "0.0.0.0:9191")],
341        )?;
342
343        assert_eq!(config.health_listen_address, socket("0.0.0.0:9191")?);
344
345        Ok(())
346    }
347
348    #[test]
349    fn drain_timeout_override_replaces_file_value() -> Result<(), Box<dyn std::error::Error>> {
350        let config = sample_config()?;
351        let config =
352            apply_env_overrides_from(config, vec![env_pair("LIMINAL_DRAIN_TIMEOUT_MS", "1250")])?;
353
354        assert_eq!(config.drain_timeout_ms, 1250);
355
356        Ok(())
357    }
358
359    #[test]
360    fn persistence_path_override_replaces_file_value() -> Result<(), Box<dyn std::error::Error>> {
361        let config = sample_config()?;
362        let config = apply_env_overrides_from(
363            config,
364            vec![env_pair("LIMINAL_PERSISTENCE_PATH", "/var/lib/liminal")],
365        )?;
366
367        assert_eq!(
368            config.persistence_path.as_deref(),
369            Some(Path::new("/var/lib/liminal"))
370        );
371
372        Ok(())
373    }
374
375    #[test]
376    fn cluster_overrides_replace_existing_cluster_values() -> Result<(), Box<dyn std::error::Error>>
377    {
378        let config = sample_config()?;
379        let config = apply_env_overrides_from(
380            config,
381            vec![
382                env_pair("LIMINAL_CLUSTER_NODE_NAME", "node-b"),
383                env_pair(
384                    "LIMINAL_CLUSTER_SEED_NODES",
385                    "127.0.0.1:9100, 127.0.0.1:9200",
386                ),
387            ],
388        )?;
389
390        let Some(cluster) = config.cluster else {
391            return Err("cluster config should remain present".into());
392        };
393        assert_eq!(cluster.node_name, "node-b");
394        assert_eq!(cluster.seed_nodes.len(), 2);
395        assert_eq!(cluster.seed_nodes[0], socket("127.0.0.1:9100")?);
396        assert_eq!(cluster.seed_nodes[1], socket("127.0.0.1:9200")?);
397
398        Ok(())
399    }
400
401    #[test]
402    fn cluster_listen_address_and_cookie_overrides_replace_values()
403    -> Result<(), Box<dyn std::error::Error>> {
404        let config = sample_config()?;
405        let config = apply_env_overrides_from(
406            config,
407            vec![
408                env_pair("LIMINAL_CLUSTER_LISTEN_ADDRESS", "127.0.0.1:9500"),
409                env_pair("LIMINAL_CLUSTER_COOKIE", "override-cookie"),
410            ],
411        )?;
412
413        let Some(cluster) = config.cluster else {
414            return Err("cluster config should remain present".into());
415        };
416        assert_eq!(cluster.listen_address, socket("127.0.0.1:9500")?);
417        assert_eq!(cluster.cookie, "override-cookie");
418
419        Ok(())
420    }
421
422    #[test]
423    fn cluster_listen_address_override_without_cluster_section_returns_validation_error()
424    -> Result<(), Box<dyn std::error::Error>> {
425        let mut config = sample_config()?;
426        config.cluster = None;
427        let result = apply_env_overrides_from(
428            config,
429            vec![env_pair("LIMINAL_CLUSTER_LISTEN_ADDRESS", "127.0.0.1:9500")],
430        );
431
432        assert!(matches!(result, Err(ServerError::ConfigValidation { .. })));
433
434        Ok(())
435    }
436
437    #[test]
438    fn auth_token_override_replaces_existing_token() -> Result<(), Box<dyn std::error::Error>> {
439        let mut config = sample_config()?;
440        config.auth = Some(crate::config::types::AuthConfig {
441            pass: None,
442            token: "file-token".to_owned(),
443        });
444
445        let config =
446            apply_env_overrides_from(config, vec![env_pair("LIMINAL_AUTH_TOKEN", "env-token")])?;
447
448        let auth = config.auth.ok_or("auth section should remain present")?;
449        assert_eq!(auth.token, "env-token");
450
451        Ok(())
452    }
453
454    #[test]
455    fn auth_token_override_creates_missing_auth_section() -> Result<(), Box<dyn std::error::Error>>
456    {
457        let mut config = sample_config()?;
458        config.auth = None;
459
460        let config =
461            apply_env_overrides_from(config, vec![env_pair("LIMINAL_AUTH_TOKEN", "env-token")])?;
462
463        // Unlike cluster overrides, a scalar auth secret MAY fabricate the section.
464        let auth = config.auth.ok_or("auth section should have been created")?;
465        assert_eq!(auth.token, "env-token");
466
467        Ok(())
468    }
469
470    #[test]
471    fn absent_environment_variables_leave_config_unchanged()
472    -> Result<(), Box<dyn std::error::Error>> {
473        let config = sample_config()?;
474        let original_address = config.listen_address;
475        let original_health_address = config.health_listen_address;
476        let original_drain_timeout_ms = config.drain_timeout_ms;
477        let original_path = config.persistence_path.clone();
478        let original_cluster_name = config
479            .cluster
480            .as_ref()
481            .map(|cluster| cluster.node_name.clone());
482
483        let config = apply_env_overrides_from(config, Vec::new())?;
484
485        assert_eq!(config.listen_address, original_address);
486        assert_eq!(config.health_listen_address, original_health_address);
487        assert_eq!(config.drain_timeout_ms, original_drain_timeout_ms);
488        assert_eq!(config.persistence_path, original_path);
489        assert_eq!(
490            config
491                .cluster
492                .as_ref()
493                .map(|cluster| cluster.node_name.clone()),
494            original_cluster_name
495        );
496
497        Ok(())
498    }
499
500    #[test]
501    fn invalid_listen_address_override_returns_config_load()
502    -> Result<(), Box<dyn std::error::Error>> {
503        let config = sample_config()?;
504        let result = apply_env_overrides_from(
505            config,
506            vec![env_pair("LIMINAL_LISTEN_ADDRESS", "not-a-socket")],
507        );
508
509        assert!(matches!(result, Err(ServerError::ConfigLoad { .. })));
510
511        Ok(())
512    }
513
514    #[test]
515    fn invalid_health_listen_address_override_returns_config_load()
516    -> Result<(), Box<dyn std::error::Error>> {
517        let config = sample_config()?;
518        let result = apply_env_overrides_from(
519            config,
520            vec![env_pair("LIMINAL_HEALTH_LISTEN_ADDRESS", "not-a-socket")],
521        );
522
523        assert!(matches!(result, Err(ServerError::ConfigLoad { .. })));
524
525        Ok(())
526    }
527
528    #[test]
529    fn invalid_drain_timeout_override_returns_config_load() -> Result<(), Box<dyn std::error::Error>>
530    {
531        let config = sample_config()?;
532        let result = apply_env_overrides_from(
533            config,
534            vec![env_pair("LIMINAL_DRAIN_TIMEOUT_MS", "not-a-number")],
535        );
536
537        assert!(matches!(result, Err(ServerError::ConfigLoad { .. })));
538
539        Ok(())
540    }
541
542    #[test]
543    fn cluster_override_without_cluster_section_returns_validation_error()
544    -> Result<(), Box<dyn std::error::Error>> {
545        let mut config = sample_config()?;
546        config.cluster = None;
547        let result = apply_env_overrides_from(
548            config,
549            vec![env_pair("LIMINAL_CLUSTER_NODE_NAME", "node-b")],
550        );
551
552        assert!(matches!(result, Err(ServerError::ConfigValidation { .. })));
553
554        Ok(())
555    }
556
557    #[test]
558    fn file_then_env_then_validate_pipeline_gives_env_precedence()
559    -> Result<(), Box<dyn std::error::Error>> {
560        let toml = r#"
561listen_address = "127.0.0.1:8080"
562health_listen_address = "127.0.0.1:8081"
563drain_timeout_ms = 30000
564persistence_path = "/tmp"
565
566[[channels]]
567name = "orders"
568durable = true
569
570[[routing_rules]]
571source_channel = "orders"
572target_channel = "orders"
573"#;
574        let path = write_temp_config(toml)?;
575        let config = load_from_file(&path)?;
576        let mut config = apply_env_overrides_from(
577            config,
578            vec![env_pair("LIMINAL_LISTEN_ADDRESS", "0.0.0.0:9090")],
579        )?;
580        validate(&mut config, path.parent())?;
581        remove_temp_file(&path)?;
582
583        assert_eq!(config.listen_address, socket("0.0.0.0:9090")?);
584
585        Ok(())
586    }
587
588    // ---- LP-WS-TRANSPORT R1.1: [websocket] environment overrides ----
589
590    fn sample_config_with_websocket() -> Result<ServerConfig, Box<dyn std::error::Error>> {
591        let mut config = sample_config()?;
592        config.websocket = Some(crate::config::types::WebSocketConfig {
593            listen_address: socket("127.0.0.1:8082")?,
594            path: "/liminal".to_owned(),
595            allowed_origins: Vec::new(),
596            ping_interval_ms: None,
597        });
598        Ok(config)
599    }
600
601    #[test]
602    fn websocket_overrides_replace_declared_section_values()
603    -> Result<(), Box<dyn std::error::Error>> {
604        let config = sample_config_with_websocket()?;
605        let config = apply_env_overrides_from(
606            config,
607            vec![
608                env_pair("LIMINAL_WEBSOCKET_LISTEN_ADDRESS", "0.0.0.0:9292"),
609                env_pair("LIMINAL_WEBSOCKET_PATH", "/bridge"),
610                env_pair(
611                    "LIMINAL_WEBSOCKET_ALLOWED_ORIGINS",
612                    "https://a.example.com, https://b.example.com",
613                ),
614                env_pair("LIMINAL_WEBSOCKET_PING_INTERVAL_MS", "15000"),
615            ],
616        )?;
617        let websocket = config.websocket.ok_or("websocket section missing")?;
618        assert_eq!(websocket.listen_address, socket("0.0.0.0:9292")?);
619        assert_eq!(websocket.path, "/bridge");
620        assert_eq!(
621            websocket.allowed_origins,
622            vec![
623                "https://a.example.com".to_owned(),
624                "https://b.example.com".to_owned()
625            ]
626        );
627        assert_eq!(websocket.ping_interval_ms, Some(15_000));
628        Ok(())
629    }
630
631    #[test]
632    fn websocket_override_without_declared_section_is_refused()
633    -> Result<(), Box<dyn std::error::Error>> {
634        for (name, value) in [
635            ("LIMINAL_WEBSOCKET_LISTEN_ADDRESS", "0.0.0.0:9292"),
636            ("LIMINAL_WEBSOCKET_PATH", "/bridge"),
637            ("LIMINAL_WEBSOCKET_ALLOWED_ORIGINS", "https://a.example.com"),
638            ("LIMINAL_WEBSOCKET_PING_INTERVAL_MS", "15000"),
639        ] {
640            let config = sample_config()?;
641            let result = apply_env_overrides_from(config, vec![env_pair(name, value)]);
642            let Err(ServerError::ConfigValidation { message }) = result else {
643                return Err(format!("{name}: fabricating [websocket] must be refused").into());
644            };
645            assert!(
646                message.contains("[websocket]"),
647                "{name}: expected a section-required error, got: {message}"
648            );
649        }
650        Ok(())
651    }
652
653    #[test]
654    fn websocket_empty_origin_list_override_is_fail_closed()
655    -> Result<(), Box<dyn std::error::Error>> {
656        let config = sample_config_with_websocket()?;
657        let config = apply_env_overrides_from(
658            config,
659            vec![env_pair("LIMINAL_WEBSOCKET_ALLOWED_ORIGINS", "")],
660        )?;
661        let websocket = config.websocket.ok_or("websocket section missing")?;
662        assert!(websocket.allowed_origins.is_empty());
663        Ok(())
664    }
665
666    #[test]
667    fn websocket_origin_list_with_empty_entry_is_refused() -> Result<(), Box<dyn std::error::Error>>
668    {
669        let config = sample_config_with_websocket()?;
670        let result = apply_env_overrides_from(
671            config,
672            vec![env_pair(
673                "LIMINAL_WEBSOCKET_ALLOWED_ORIGINS",
674                "https://a.example.com,,https://b.example.com",
675            )],
676        );
677        let Err(ServerError::ConfigLoad { message }) = result else {
678            return Err("an empty origin entry must be a typed load error".into());
679        };
680        assert!(
681            message.contains("empty origin"),
682            "expected an empty-origin error, got: {message}"
683        );
684        Ok(())
685    }
686}