use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use liminal_server::config::{ServerConfig, ServiceProfile, apply_env_overrides, validate};
use serde::Deserialize;
use crate::error::HostError;
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FrameConfig {
pub frame: FrameSection,
pub liminal: ServerConfig,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FrameSection {
pub bind: SocketAddr,
pub assets: PathBuf,
pub auth_token: String,
#[serde(default)]
pub channel: Option<String>,
}
impl FrameConfig {
pub fn load(path: &Path) -> Result<Self, HostError> {
let text = std::fs::read_to_string(path).map_err(|source| HostError::ConfigRead {
path: path.to_path_buf(),
source,
})?;
let Self { frame, liminal } =
toml::from_str(&text).map_err(|error| HostError::ConfigParse {
path: path.to_path_buf(),
detail: error.to_string(),
})?;
let mut liminal =
apply_env_overrides(liminal).map_err(|source| HostError::LiminalConfig { source })?;
validate(&mut liminal, path.parent())
.map_err(|source| HostError::LiminalConfig { source })?;
let config = Self { frame, liminal };
config.check_console_contract()?;
config.check_embedded_mode()?;
Ok(config)
}
fn check_console_contract(&self) -> Result<(), HostError> {
if let Some(channel) = &self.frame.channel
&& channel.is_empty()
{
return Err(HostError::ConfigContract {
detail: "[frame].channel, when present, must be non-empty: the console refuses an \
empty channel; omit the key to use the SDK's default channel"
.to_owned(),
});
}
if self.liminal.channels.is_empty() {
return Err(HostError::ConfigContract {
detail: "[liminal].channels is empty: the console subscribes every configured \
channel and serves the list as `channels` in /frame/config.json, so a \
zero-channel liminal leaves the console with no feed. Declare at least \
one [[liminal.channels]] entry."
.to_owned(),
});
}
if let Some(channel) = &self.frame.channel
&& !self
.liminal
.channels
.iter()
.any(|configured| configured.name == *channel)
{
return Err(HostError::ConfigContract {
detail: format!(
"[frame].channel \"{channel}\" is not one of the embedded liminal's \
configured channels [{roster}]: the console would subscribe a channel the \
embedded server does not carry. Name a configured channel or add it to \
[liminal].channels.",
roster = self
.liminal
.channels
.iter()
.map(|configured| configured.name.as_str())
.collect::<Vec<_>>()
.join(", ")
),
});
}
if self.liminal.auth.is_some() && self.frame.auth_token.is_empty() {
return Err(HostError::ConfigContract {
detail: "[liminal.auth] gates the embedded server behind a token, but \
[frame].auth_token is empty: the page would present an empty token and be \
refused. Set [frame].auth_token to the token the page must present."
.to_owned(),
});
}
Ok(())
}
fn check_embedded_mode(&self) -> Result<(), HostError> {
if self.liminal.cluster.is_some() {
return Err(HostError::EmbeddedModeUnsupported {
detail: "[liminal.cluster] is set, but the embedded frame server runs a \
single-node deployment and does not start liminal's distribution \
cluster. Remove [liminal.cluster] or run liminal standalone."
.to_owned(),
});
}
match self
.liminal
.services
.profile()
.map_err(|source| HostError::LiminalConfig { source })?
{
ServiceProfile::Full => {}
ServiceProfile::WorkerFrontDoor => {
return Err(HostError::EmbeddedModeUnsupported {
detail:
"[liminal.services].profile is \"worker-front-door\", but the embedded \
frame server runs liminal's \"full\" profile (channels/conversations \
back the console feed). Remove the profile override or run liminal \
standalone."
.to_owned(),
});
}
}
if self.liminal.websocket.is_none() {
return Err(HostError::EmbeddedModeUnsupported {
detail:
"[liminal.websocket] is absent, but the embedded frame server requires it: \
the browser connects to liminal's WebSocket directly (D3), so the console \
has nowhere to connect without it. Add a [liminal.websocket] section."
.to_owned(),
});
}
Ok(())
}
}