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<toml::Value>,
liminal: Option<toml::Value>,
}
#[derive(Debug)]
pub struct FrameConfig {
pub frame: FrameSection,
pub document: Option<DocumentSection>,
pub bus: ServerConfig,
pub page_origins_derived: bool,
pub ports_explicit: bool,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FrameSection {
#[serde(default)]
pub bind: Option<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,
#[serde(default = "default_lease_expiry_ms")]
pub lease_expiry_ms: u64,
#[serde(default = "default_journal_length_bound")]
pub journal_length_bound: u64,
#[serde(default = "default_quiesce_window_ms")]
pub quiesce_window_ms: u64,
#[serde(default = "default_dark_theme")]
pub dark_theme: bool,
#[serde(default = "default_blink_interval_ms")]
pub blink_interval_ms: u64,
#[serde(default)]
pub syntax_theme: Option<BTreeMap<String, String>>,
}
pub const DEFAULT_LEASE_EXPIRY_MS: u64 = 30_000;
pub const DEFAULT_JOURNAL_LENGTH_BOUND: u64 = 256;
pub const DEFAULT_QUIESCE_WINDOW_MS: u64 = 2_000;
pub const DEFAULT_DARK_THEME: bool = true;
pub const DEFAULT_BLINK_INTERVAL_MS: u64 = 530;
const fn default_lease_expiry_ms() -> u64 {
DEFAULT_LEASE_EXPIRY_MS
}
const fn default_journal_length_bound() -> u64 {
DEFAULT_JOURNAL_LENGTH_BOUND
}
const fn default_quiesce_window_ms() -> u64 {
DEFAULT_QUIESCE_WINDOW_MS
}
const fn default_dark_theme() -> bool {
DEFAULT_DARK_THEME
}
const fn default_blink_interval_ms() -> u64 {
DEFAULT_BLINK_INTERVAL_MS
}
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'-')
}
pub const DEFAULT_BUS_DRAIN_TIMEOUT_MS: i64 = 1_000;
pub const DEFAULT_WEBSOCKET_PATH: &str = "/liminal";
const OS_ASSIGNED: &str = "127.0.0.1:0";
fn apply_bus_defaults(mut bus: toml::Value, path: &Path) -> Result<toml::Value, HostError> {
let table = bus.as_table_mut().ok_or_else(|| HostError::ConfigParse {
path: path.to_path_buf(),
detail: "[bus] (or [liminal]) must be a TOML table".to_owned(),
})?;
table
.entry("drain_timeout_ms")
.or_insert_with(|| toml::Value::Integer(DEFAULT_BUS_DRAIN_TIMEOUT_MS));
if let Some(websocket_value) = table.get_mut("websocket") {
let websocket = websocket_value
.as_table_mut()
.ok_or_else(|| HostError::ConfigParse {
path: path.to_path_buf(),
detail: "[bus.websocket] (or [liminal.websocket]) must be a TOML table".to_owned(),
})?;
websocket
.entry("path")
.or_insert_with(|| toml::Value::String(DEFAULT_WEBSOCKET_PATH.to_owned()));
}
Ok(bus)
}
fn synthesize_portless_bus(channel: &str) -> toml::Value {
let mut channel_entry = toml::value::Table::new();
channel_entry.insert("name".to_owned(), toml::Value::String(channel.to_owned()));
channel_entry.insert("durable".to_owned(), toml::Value::Boolean(false));
let mut websocket = toml::value::Table::new();
websocket.insert(
"listen_address".to_owned(),
toml::Value::String(OS_ASSIGNED.to_owned()),
);
let mut table = toml::value::Table::new();
table.insert(
"listen_address".to_owned(),
toml::Value::String(OS_ASSIGNED.to_owned()),
);
table.insert(
"health_listen_address".to_owned(),
toml::Value::String(OS_ASSIGNED.to_owned()),
);
table.insert(
"channels".to_owned(),
toml::Value::Array(vec![toml::Value::Table(channel_entry)]),
);
table.insert("routing_rules".to_owned(), toml::Value::Array(Vec::new()));
table.insert("websocket".to_owned(), toml::Value::Table(websocket));
toml::Value::Table(table)
}
fn bus_states_allowed_origins(bus: &toml::Value) -> bool {
bus.as_table()
.and_then(|table| table.get("websocket"))
.and_then(toml::Value::as_table)
.is_some_and(|websocket| websocket.contains_key("allowed_origins"))
}
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, synthesized) = 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, false),
(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, false)
}
(None, None) => {
let Some(channel) = frame.channel.as_deref() else {
return Err(HostError::ConfigParse {
path: path.to_path_buf(),
detail: "no [bus] section and no [frame].channel: a portless frame.toml \
derives its embedded bus channel from [frame].channel, so state \
[frame].channel = \"<name>.events\" (or declare a full [bus] \
section to pin ports explicitly)"
.to_owned(),
});
};
(synthesize_portless_bus(channel), true)
}
};
let origins_stated = bus_states_allowed_origins(&bus);
let bus = apply_bus_defaults(bus, path)?;
let bus: ServerConfig =
bus.try_into()
.map_err(|error: toml::de::Error| HostError::ConfigParse {
path: path.to_path_buf(),
detail: error.to_string(),
})?;
let mut bus =
apply_env_overrides(bus).map_err(|source| HostError::LiminalConfig { source })?;
if !synthesized {
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 ports_explicit = frame.bind.is_some() && origins_stated;
let config = Self {
frame,
document,
bus,
page_origins_derived: !origins_stated,
ports_explicit,
};
config.check_console_contract()?;
config.check_embedded_mode()?;
config.check_document_contract()?;
Ok(config)
}
pub fn finalize_page_origins(&mut self, page_addr: SocketAddr) {
if !self.page_origins_derived {
return;
}
let Some(websocket) = self.bus.websocket.as_mut() else {
return;
};
let port = page_addr.port();
websocket.allowed_origins = vec![
format!("http://127.0.0.1:{port}"),
format!("http://localhost:{port}"),
];
}
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, default) in [
(
"[document].lease_expiry_ms",
document.lease_expiry_ms,
DEFAULT_LEASE_EXPIRY_MS,
),
(
"[document].journal_length_bound",
document.journal_length_bound,
DEFAULT_JOURNAL_LENGTH_BOUND,
),
(
"[document].quiesce_window_ms",
document.quiesce_window_ms,
DEFAULT_QUIESCE_WINDOW_MS,
),
(
"[document].blink_interval_ms",
document.blink_interval_ms,
DEFAULT_BLINK_INTERVAL_MS,
),
] {
if value == 0 {
return Err(HostError::ConfigContract {
detail: format!(
"{field} must be positive when declared explicitly; omit the key \
entirely to use the documented default of {default}"
),
});
}
}
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(())
}
}