frame-host 0.2.0

Frame host server and embedding seam — boots an application's frame-core component tree with an embedded liminal bus, announces the host's real application events on the bus, and serves the built frame page
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
//! 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`.
//! - `[bus]` — the embedded messaging-bus component (liminal). This section
//!   deserializes directly into liminal's own
//!   [`liminal_server::config::ServerConfig`], so the bus validates its own
//!   section (required fields, `deny_unknown_fields`, channel-schema loading).
//!   There are NO invented defaults for values the bus requires: a missing
//!   required value is a typed config error at startup.
//!
//! **Compatibility window:** the pre-rename section name `[liminal]` (with
//! `[liminal.websocket]`) still parses as an alias of `[bus]`, but every
//! acceptance logs a loud `tracing::warn!` deprecation naming the new section.
//! Declaring both sections is refused. The alias is removed after one window.
//!
//! Because the embedded bus's WebSocket address is derived from THIS one
//! configured section (see [`crate::embedded`]), the page's `busEndpoint`
//! and the server's actual bound listener can never drift — there is exactly
//! one source of truth.

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;

/// The raw `frame.toml` shape: the console section plus the embedded bus
/// section under its primary name (`[bus]`) or its deprecated alias
/// (`[liminal]`). Exactly one of the two must be present; [`FrameConfig::load`]
/// enforces that and logs the deprecation on the alias.
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawFrameConfig {
    /// Console HTTP server configuration.
    frame: FrameSection,
    /// Optional document-service binding (IRIDIUM-A3 R11).
    document: Option<DocumentSection>,
    /// Embedded bus component configuration — the primary section name.
    bus: Option<ServerConfig>,
    /// DEPRECATED alias of `[bus]`, kept for one compatibility window.
    liminal: Option<ServerConfig>,
}

/// The parsed `frame.toml`: the console section plus the embedded bus
/// section (liminal's own config type).
#[derive(Debug)]
pub struct FrameConfig {
    /// Console HTTP server configuration.
    pub frame: FrameSection,
    /// The optional `[document]` binding: when present, the host boots the
    /// document service (IRIDIUM-A3 R11) against the embedded bus.
    pub document: Option<DocumentSection>,
    /// Embedded bus component configuration — liminal's own config type,
    /// validated by liminal's own validator.
    pub bus: 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>,
}

/// The `[document]` section (IRIDIUM-A3 R11): the document binding and
/// EVERY policy value the authority requires — all declared, none
/// defaulted (A1 constraint 9; the house no-assumed-defaults law).
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DocumentSection {
    /// The bound document id (`[a-z0-9-]+`).
    pub id: String,
    /// The document language.
    pub language: String,
    /// The initial content source, resolved relative to the config file.
    pub content_path: PathBuf,
    /// The component instance id on every feed envelope (`[a-z0-9-]+`).
    pub component_id: String,
    /// The feed channel: the service publishes; every page subscribes.
    pub feed_channel: String,
    /// The authoring channel: pages publish; the service subscribes.
    pub authoring_channel: String,
    /// The frame-state store directory, resolved relative to the config
    /// file.
    pub state_dir: PathBuf,
    /// Lease expiry (§5.5 policy), milliseconds. Declared, no default.
    pub lease_expiry_ms: u64,
    /// The journal-length snapshot bound (T3(i) signal 3). Declared.
    pub journal_length_bound: u64,
    /// The quiesce debounce window (T3(i) signal 4), milliseconds.
    pub quiesce_window_ms: u64,
    /// The publisher-seat dark-mode presentation input (C5). Declared.
    pub dark_theme: bool,
    /// The component's cursor-blink interval (T4), milliseconds — a
    /// component policy value carried by frame.toml per constraint 12 and
    /// advertised to the page. Declared, no default.
    pub blink_interval_ms: u64,
    /// The publisher-seat syntax-theme presentation input (C5, amended
    /// 2026-07-21): capture-name → `#rrggbb` hex colour, TOML table.
    /// Optional — when absent, [`crate::doc_binding::DocBinding::boot`]
    /// falls back to the built-in default palette
    /// ([`crate::syntax_theme::default_syntax_theme`], copied from the
    /// proven `SYNTAX_THEME` in `examples/code-view-console/src/fake-feed.ts`).
    /// When present, this table REPLACES the built-in default WHOLESALE —
    /// there is no per-key merging with the default (merging would invent
    /// behaviour nobody asked for). Every declared value must be `#rrggbb`
    /// hex; [`FrameConfig::load`] fails loudly on a malformed or
    /// non-string entry rather than dropping it silently.
    #[serde(default)]
    pub syntax_theme: Option<BTreeMap<String, String>>,
}

/// The closed hex-colour grammar the wire enforces (`#rrggbb`, matching
/// `frame_editor_wire::snapshot`'s `validate_syntax_theme` and the
/// console's `hexColor` codec check), checked at config load so a doomed
/// theme value fails here, not at first encode.
fn is_hex_color(value: &str) -> bool {
    value.len() == 7
        && value.starts_with('#')
        && value[1..].bytes().all(|byte| byte.is_ascii_hexdigit())
}

/// The closed ASCII id grammar the wire enforces (`[a-z0-9-]+`), checked
/// at config load so a doomed id fails here, not at first encode.
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 {
    /// Loads and fully validates `frame.toml`.
    ///
    /// Accepts the embedded bus section as `[bus]` (primary) or `[liminal]`
    /// (deprecated alias — parsed with a loud `tracing::warn!` naming `[bus]`;
    /// never a silent fallback). Runs liminal's own validation over the bus
    /// section (channel `schema_ref` paths resolve relative to the config
    /// file's directory, exactly as standalone liminal resolves them), then
    /// refuses any bus shape the embedded frame server does not faithfully
    /// orchestrate.
    ///
    /// # Errors
    ///
    /// Returns a typed failure for an unreadable file, malformed TOML, a
    /// missing bus section, both section spellings present at once, a bus
    /// 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 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(),
                });
            }
        };
        // Faithful bus 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 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) => {
                // Resolve the declared paths relative to the config file,
                // exactly as the bus resolves its schema refs.
                if let Some(parent) = path.parent() {
                    if section.content_path.is_relative() {
                        section.content_path = parent.join(&section.content_path);
                    }
                    if section.state_dir.is_relative() {
                        section.state_dir = parent.join(&section.state_dir);
                    }
                }
                Some(section)
            }
        };
        let config = Self {
            frame,
            document,
            bus,
        };
        config.check_console_contract()?;
        config.check_embedded_mode()?;
        config.check_document_contract()?;
        Ok(config)
    }

    /// Refuses a `[document]` section the document service would refuse at
    /// boot: bad wire ids, zero policy values, or channels the embedded
    /// bus does not carry — every refusal typed and loud, here rather
    /// than mid-boot.
    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"
                    ),
                });
            }
        }
        // C8/R11 transport reality, verified at the pinned SDK (0.3.0):
        // the TCP channel-subscription client cannot present an auth token,
        // so the document service cannot subscribe a token-gated bus.
        // Refused loudly here; the upstream ask is recorded in the leg's
        // report.
        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(())
    }

    /// 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 bus 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.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(),
            });
        }
        // The console's primary channel must be one the embedded bus
        // 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
                .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 the bus 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.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(())
    }

    /// Refuses bus 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 bus
    /// 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.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(())
    }
}