frame-host 0.1.0

Frame host server — boots the frame-core host authority with an embedded liminal component and serves the built frame page
Documentation
//! The single `frame.toml` that describes the whole stack.
//!
//! One config file, two sections:
//!
//! - `[frame]` — the console HTTP server: `bind`, `assets`, `auth_token`
//!   (surfaced to the page verbatim; empty = open server), optional `channel`.
//! - `[liminal]` — the embedded liminal component. This section deserializes
//!   directly into liminal's own [`liminal_server::config::ServerConfig`], so
//!   liminal validates its own section (required fields, `deny_unknown_fields`,
//!   channel-schema loading). There are NO invented defaults for values liminal
//!   requires: a missing required value is a typed config error at startup.
//!
//! Because the embedded liminal's WebSocket address is derived from THIS one
//! configured section (see [`crate::embedded`]), the page's `liminalEndpoint`
//! and the server's actual bound listener can never drift — there is exactly
//! one source of truth.

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;

/// The parsed `frame.toml`: the console section plus the embedded liminal
/// section (liminal's own config type).
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FrameConfig {
    /// Console HTTP server configuration.
    pub frame: FrameSection,
    /// Embedded liminal component configuration — liminal's own config type,
    /// validated by liminal's own validator.
    pub liminal: ServerConfig,
}

/// The `[frame]` section: everything the console HTTP server needs.
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FrameSection {
    /// Socket address the console HTTP server binds, e.g. `127.0.0.1:4173`.
    pub bind: SocketAddr,
    /// Directory containing the static console bundle (`index.html` at root).
    pub assets: PathBuf,
    /// Bearer token surfaced to the page verbatim as `authToken`. Required key
    /// — the operator states it, the binary never invents it. An explicitly
    /// empty value (`auth_token = ""`) is legal and means an open server,
    /// matching the console's config contract.
    pub auth_token: String,
    /// Feed channel surfaced to the page as `channel`. Absent → the field is
    /// omitted and the page applies the SDK's default channel. When present it
    /// must be non-empty (the console refuses an empty channel).
    #[serde(default)]
    pub channel: Option<String>,
}

impl FrameConfig {
    /// Loads and fully validates `frame.toml`.
    ///
    /// Runs liminal's own validation over the `[liminal]` section (channel
    /// `schema_ref` paths resolve relative to the config file's directory,
    /// exactly as standalone liminal resolves them), then refuses any liminal
    /// shape the embedded frame server does not faithfully orchestrate.
    ///
    /// # Errors
    ///
    /// Returns a typed failure for an unreadable file, malformed TOML, a
    /// `[liminal]` section liminal's validator rejects, or an embedded-mode
    /// shape refusal.
    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(),
            })?;
        // Faithful liminal load: run liminal's OWN pipeline in liminal's OWN
        // order — environment overrides FIRST, then validation — exactly as
        // standalone liminal's `load_config` does. Omitting the env layer would
        // silently diverge: e.g. `LIMINAL_AUTH_TOKEN` (which liminal uses to
        // inject an `[auth]` section absent from the file) would be ignored,
        // downgrading a token-gated deployment to an open one with no warning.
        // Channel `schema_ref` paths resolve relative to the config file's
        // directory, matching standalone liminal.
        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)
    }

    /// Refuses `[frame]` values the console's own config contract would reject,
    /// so a doomed config fails loudly here rather than in the browser.
    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(),
            });
        }
        // The served /frame/config.json advertises the FULL configured channel
        // list (the console subscribes every one of them). A liminal section
        // with zero channels leaves the console with no feed to subscribe — a
        // doomed deployment refused here, not discovered as a browser-side
        // subscribe rejection.
        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(),
            });
        }
        // The console's primary channel must be one the embedded liminal
        // actually carries: the page refuses a `channels` roster that omits its
        // `channel`, so serving that pair is a composition error caught here.
        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 liminal gates connections behind a token but the page is handed an
        // empty one, the browser would present an empty token to a closed
        // server and be refused — a silent "page can't connect". Refuse loudly.
        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(())
    }

    /// Refuses liminal shapes the embedded frame server does not faithfully
    /// orchestrate.
    ///
    /// Embedded frame mode runs liminal's single-node **Full** profile with the
    /// **WebSocket transport required** (the browser is a direct liminal
    /// participant per D3, so it needs a WebSocket address). Clustered and
    /// worker-front-door deployments have their own boot orchestration in
    /// standalone liminal that this embedding does not reproduce; rather than
    /// silently mis-boot them, they are refused with a typed error.
    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(())
    }
}