use std::collections::BTreeMap;
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)]
struct RawFrameConfig {
frame: FrameSection,
document: Option<DocumentSection>,
bus: Option<ServerConfig>,
liminal: Option<ServerConfig>,
}
#[derive(Debug)]
pub struct FrameConfig {
pub frame: FrameSection,
pub document: Option<DocumentSection>,
pub bus: 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>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DocumentSection {
pub id: String,
pub language: String,
pub content_path: PathBuf,
pub component_id: String,
pub feed_channel: String,
pub authoring_channel: String,
pub state_dir: PathBuf,
pub lease_expiry_ms: u64,
pub journal_length_bound: u64,
pub quiesce_window_ms: u64,
pub dark_theme: bool,
pub blink_interval_ms: u64,
#[serde(default)]
pub syntax_theme: Option<BTreeMap<String, String>>,
}
fn is_hex_color(value: &str) -> bool {
value.len() == 7
&& value.starts_with('#')
&& value[1..].bytes().all(|byte| byte.is_ascii_hexdigit())
}
fn is_wire_id(value: &str) -> bool {
!value.is_empty()
&& value
.bytes()
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
}
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 RawFrameConfig {
frame,
document,
bus,
liminal,
} = toml::from_str(&text).map_err(|error| HostError::ConfigParse {
path: path.to_path_buf(),
detail: error.to_string(),
})?;
let bus = match (bus, liminal) {
(Some(_), Some(_)) => {
return Err(HostError::ConfigParse {
path: path.to_path_buf(),
detail: "both [bus] and [liminal] sections are present; [liminal] is the \
deprecated alias of [bus] — declare exactly one section (use [bus])"
.to_owned(),
});
}
(Some(bus), None) => bus,
(None, Some(bus)) => {
tracing::warn!(
config = %path.display(),
"frame.toml section [liminal] is DEPRECATED: rename [liminal] to [bus] and \
[liminal.websocket] to [bus.websocket]; the [liminal] alias is accepted for \
one compatibility window only"
);
bus
}
(None, None) => {
return Err(HostError::ConfigParse {
path: path.to_path_buf(),
detail: "missing the [bus] section (the embedded messaging-bus config; \
formerly named [liminal])"
.to_owned(),
});
}
};
let mut bus =
apply_env_overrides(bus).map_err(|source| HostError::LiminalConfig { source })?;
validate(&mut bus, path.parent()).map_err(|source| HostError::LiminalConfig { source })?;
let document = match document {
None => None,
Some(mut section) => {
if let Some(parent) = path.parent() {
if section.content_path.is_relative() {
section.content_path = parent.join(§ion.content_path);
}
if section.state_dir.is_relative() {
section.state_dir = parent.join(§ion.state_dir);
}
}
Some(section)
}
};
let config = Self {
frame,
document,
bus,
};
config.check_console_contract()?;
config.check_embedded_mode()?;
config.check_document_contract()?;
Ok(config)
}
fn check_document_contract(&self) -> Result<(), HostError> {
let Some(document) = &self.document else {
return Ok(());
};
for (field, value) in [
("[document].id", document.id.as_str()),
("[document].component_id", document.component_id.as_str()),
] {
if !is_wire_id(value) {
return Err(HostError::ConfigContract {
detail: format!("{field} must match [a-z0-9-]+, got {value:?}"),
});
}
}
if document.feed_channel == document.authoring_channel {
return Err(HostError::ConfigContract {
detail: "[document].feed_channel and [document].authoring_channel must be two distinct channels (C2: two channels per document)"
.to_owned(),
});
}
for (field, channel) in [
("[document].feed_channel", &document.feed_channel),
("[document].authoring_channel", &document.authoring_channel),
] {
if !self
.bus
.channels
.iter()
.any(|configured| configured.name == *channel)
{
return Err(HostError::ConfigContract {
detail: format!(
"{field} {channel:?} is not one of the embedded bus's configured \
channels: declare it under [bus].channels"
),
});
}
}
if self.bus.auth.is_some() {
return Err(HostError::ConfigContract {
detail: "[document] cannot run against a token-gated [bus.auth]: the pinned bus SDK's channel subscription presents no auth token (upstream ask recorded). Run the embedded bus open, or drop [document]."
.to_owned(),
});
}
for (field, value) in [
("[document].lease_expiry_ms", document.lease_expiry_ms),
(
"[document].journal_length_bound",
document.journal_length_bound,
),
("[document].quiesce_window_ms", document.quiesce_window_ms),
("[document].blink_interval_ms", document.blink_interval_ms),
] {
if value == 0 {
return Err(HostError::ConfigContract {
detail: format!(
"{field} must be a positive declared value (no defaults exist)"
),
});
}
}
if let Some(theme) = &document.syntax_theme {
for (capture, color) in theme {
if capture.is_empty() {
return Err(HostError::ConfigContract {
detail: "[document].syntax_theme keys must be non-empty".to_owned(),
});
}
if !is_hex_color(color) {
return Err(HostError::ConfigContract {
detail: format!(
"[document].syntax_theme.{capture} must be #rrggbb hex, got {color:?}"
),
});
}
}
}
Ok(())
}
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.bus.channels.is_empty() {
return Err(HostError::ConfigContract {
detail: "[bus].channels is empty: the console subscribes every configured \
channel and serves the list as `channels` in /frame/config.json, so a \
zero-channel bus leaves the console with no feed. Declare at least \
one [[bus.channels]] entry."
.to_owned(),
});
}
if let Some(channel) = &self.frame.channel
&& !self
.bus
.channels
.iter()
.any(|configured| configured.name == *channel)
{
return Err(HostError::ConfigContract {
detail: format!(
"[frame].channel \"{channel}\" is not one of the embedded bus's \
configured channels [{roster}]: the console would subscribe a channel the \
embedded server does not carry. Name a configured channel or add it to \
[bus].channels.",
roster = self
.bus
.channels
.iter()
.map(|configured| configured.name.as_str())
.collect::<Vec<_>>()
.join(", ")
),
});
}
if self.bus.auth.is_some() && self.frame.auth_token.is_empty() {
return Err(HostError::ConfigContract {
detail: "[bus.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.bus.cluster.is_some() {
return Err(HostError::EmbeddedModeUnsupported {
detail: "[bus.cluster] is set, but the embedded frame server runs a \
single-node deployment and does not start liminal's distribution \
cluster. Remove [bus.cluster] or run liminal standalone."
.to_owned(),
});
}
match self
.bus
.services
.profile()
.map_err(|source| HostError::LiminalConfig { source })?
{
ServiceProfile::Full => {}
ServiceProfile::WorkerFrontDoor => {
return Err(HostError::EmbeddedModeUnsupported {
detail: "[bus.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.bus.websocket.is_none() {
return Err(HostError::EmbeddedModeUnsupported {
detail: "[bus.websocket] is absent, but the embedded frame server requires it: \
the browser connects to the bus's WebSocket directly (D3), so the console \
has nowhere to connect without it. Add a [bus.websocket] section."
.to_owned(),
});
}
Ok(())
}
}