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 });
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            token: "file-token".to_owned(),
442        });
443
444        let config =
445            apply_env_overrides_from(config, vec![env_pair("LIMINAL_AUTH_TOKEN", "env-token")])?;
446
447        let auth = config.auth.ok_or("auth section should remain present")?;
448        assert_eq!(auth.token, "env-token");
449
450        Ok(())
451    }
452
453    #[test]
454    fn auth_token_override_creates_missing_auth_section() -> Result<(), Box<dyn std::error::Error>>
455    {
456        let mut config = sample_config()?;
457        config.auth = None;
458
459        let config =
460            apply_env_overrides_from(config, vec![env_pair("LIMINAL_AUTH_TOKEN", "env-token")])?;
461
462        // Unlike cluster overrides, a scalar auth secret MAY fabricate the section.
463        let auth = config.auth.ok_or("auth section should have been created")?;
464        assert_eq!(auth.token, "env-token");
465
466        Ok(())
467    }
468
469    #[test]
470    fn absent_environment_variables_leave_config_unchanged()
471    -> Result<(), Box<dyn std::error::Error>> {
472        let config = sample_config()?;
473        let original_address = config.listen_address;
474        let original_health_address = config.health_listen_address;
475        let original_drain_timeout_ms = config.drain_timeout_ms;
476        let original_path = config.persistence_path.clone();
477        let original_cluster_name = config
478            .cluster
479            .as_ref()
480            .map(|cluster| cluster.node_name.clone());
481
482        let config = apply_env_overrides_from(config, Vec::new())?;
483
484        assert_eq!(config.listen_address, original_address);
485        assert_eq!(config.health_listen_address, original_health_address);
486        assert_eq!(config.drain_timeout_ms, original_drain_timeout_ms);
487        assert_eq!(config.persistence_path, original_path);
488        assert_eq!(
489            config
490                .cluster
491                .as_ref()
492                .map(|cluster| cluster.node_name.clone()),
493            original_cluster_name
494        );
495
496        Ok(())
497    }
498
499    #[test]
500    fn invalid_listen_address_override_returns_config_load()
501    -> Result<(), Box<dyn std::error::Error>> {
502        let config = sample_config()?;
503        let result = apply_env_overrides_from(
504            config,
505            vec![env_pair("LIMINAL_LISTEN_ADDRESS", "not-a-socket")],
506        );
507
508        assert!(matches!(result, Err(ServerError::ConfigLoad { .. })));
509
510        Ok(())
511    }
512
513    #[test]
514    fn invalid_health_listen_address_override_returns_config_load()
515    -> Result<(), Box<dyn std::error::Error>> {
516        let config = sample_config()?;
517        let result = apply_env_overrides_from(
518            config,
519            vec![env_pair("LIMINAL_HEALTH_LISTEN_ADDRESS", "not-a-socket")],
520        );
521
522        assert!(matches!(result, Err(ServerError::ConfigLoad { .. })));
523
524        Ok(())
525    }
526
527    #[test]
528    fn invalid_drain_timeout_override_returns_config_load() -> Result<(), Box<dyn std::error::Error>>
529    {
530        let config = sample_config()?;
531        let result = apply_env_overrides_from(
532            config,
533            vec![env_pair("LIMINAL_DRAIN_TIMEOUT_MS", "not-a-number")],
534        );
535
536        assert!(matches!(result, Err(ServerError::ConfigLoad { .. })));
537
538        Ok(())
539    }
540
541    #[test]
542    fn cluster_override_without_cluster_section_returns_validation_error()
543    -> Result<(), Box<dyn std::error::Error>> {
544        let mut config = sample_config()?;
545        config.cluster = None;
546        let result = apply_env_overrides_from(
547            config,
548            vec![env_pair("LIMINAL_CLUSTER_NODE_NAME", "node-b")],
549        );
550
551        assert!(matches!(result, Err(ServerError::ConfigValidation { .. })));
552
553        Ok(())
554    }
555
556    #[test]
557    fn file_then_env_then_validate_pipeline_gives_env_precedence()
558    -> Result<(), Box<dyn std::error::Error>> {
559        let toml = r#"
560listen_address = "127.0.0.1:8080"
561health_listen_address = "127.0.0.1:8081"
562drain_timeout_ms = 30000
563persistence_path = "/tmp"
564
565[[channels]]
566name = "orders"
567durable = true
568
569[[routing_rules]]
570source_channel = "orders"
571target_channel = "orders"
572"#;
573        let path = write_temp_config(toml)?;
574        let config = load_from_file(&path)?;
575        let mut config = apply_env_overrides_from(
576            config,
577            vec![env_pair("LIMINAL_LISTEN_ADDRESS", "0.0.0.0:9090")],
578        )?;
579        validate(&mut config, path.parent())?;
580        remove_temp_file(&path)?;
581
582        assert_eq!(config.listen_address, socket("0.0.0.0:9090")?);
583
584        Ok(())
585    }
586
587    // ---- LP-WS-TRANSPORT R1.1: [websocket] environment overrides ----
588
589    fn sample_config_with_websocket() -> Result<ServerConfig, Box<dyn std::error::Error>> {
590        let mut config = sample_config()?;
591        config.websocket = Some(crate::config::types::WebSocketConfig {
592            listen_address: socket("127.0.0.1:8082")?,
593            path: "/liminal".to_owned(),
594            allowed_origins: Vec::new(),
595            ping_interval_ms: None,
596        });
597        Ok(config)
598    }
599
600    #[test]
601    fn websocket_overrides_replace_declared_section_values()
602    -> Result<(), Box<dyn std::error::Error>> {
603        let config = sample_config_with_websocket()?;
604        let config = apply_env_overrides_from(
605            config,
606            vec![
607                env_pair("LIMINAL_WEBSOCKET_LISTEN_ADDRESS", "0.0.0.0:9292"),
608                env_pair("LIMINAL_WEBSOCKET_PATH", "/bridge"),
609                env_pair(
610                    "LIMINAL_WEBSOCKET_ALLOWED_ORIGINS",
611                    "https://a.example.com, https://b.example.com",
612                ),
613                env_pair("LIMINAL_WEBSOCKET_PING_INTERVAL_MS", "15000"),
614            ],
615        )?;
616        let websocket = config.websocket.ok_or("websocket section missing")?;
617        assert_eq!(websocket.listen_address, socket("0.0.0.0:9292")?);
618        assert_eq!(websocket.path, "/bridge");
619        assert_eq!(
620            websocket.allowed_origins,
621            vec![
622                "https://a.example.com".to_owned(),
623                "https://b.example.com".to_owned()
624            ]
625        );
626        assert_eq!(websocket.ping_interval_ms, Some(15_000));
627        Ok(())
628    }
629
630    #[test]
631    fn websocket_override_without_declared_section_is_refused()
632    -> Result<(), Box<dyn std::error::Error>> {
633        for (name, value) in [
634            ("LIMINAL_WEBSOCKET_LISTEN_ADDRESS", "0.0.0.0:9292"),
635            ("LIMINAL_WEBSOCKET_PATH", "/bridge"),
636            ("LIMINAL_WEBSOCKET_ALLOWED_ORIGINS", "https://a.example.com"),
637            ("LIMINAL_WEBSOCKET_PING_INTERVAL_MS", "15000"),
638        ] {
639            let config = sample_config()?;
640            let result = apply_env_overrides_from(config, vec![env_pair(name, value)]);
641            let Err(ServerError::ConfigValidation { message }) = result else {
642                return Err(format!("{name}: fabricating [websocket] must be refused").into());
643            };
644            assert!(
645                message.contains("[websocket]"),
646                "{name}: expected a section-required error, got: {message}"
647            );
648        }
649        Ok(())
650    }
651
652    #[test]
653    fn websocket_empty_origin_list_override_is_fail_closed()
654    -> Result<(), Box<dyn std::error::Error>> {
655        let config = sample_config_with_websocket()?;
656        let config = apply_env_overrides_from(
657            config,
658            vec![env_pair("LIMINAL_WEBSOCKET_ALLOWED_ORIGINS", "")],
659        )?;
660        let websocket = config.websocket.ok_or("websocket section missing")?;
661        assert!(websocket.allowed_origins.is_empty());
662        Ok(())
663    }
664
665    #[test]
666    fn websocket_origin_list_with_empty_entry_is_refused() -> Result<(), Box<dyn std::error::Error>>
667    {
668        let config = sample_config_with_websocket()?;
669        let result = apply_env_overrides_from(
670            config,
671            vec![env_pair(
672                "LIMINAL_WEBSOCKET_ALLOWED_ORIGINS",
673                "https://a.example.com,,https://b.example.com",
674            )],
675        );
676        let Err(ServerError::ConfigLoad { message }) = result else {
677            return Err("an empty origin entry must be a typed load error".into());
678        };
679        assert!(
680            message.contains("empty origin"),
681            "expected an empty-origin error, got: {message}"
682        );
683        Ok(())
684    }
685}