frame_host/config.rs
1//! The single `frame.toml` that describes the whole stack.
2//!
3//! One config file, two sections:
4//!
5//! - `[frame]` — the console HTTP server: `bind`, `assets`, `auth_token`
6//! (surfaced to the page verbatim; empty = open server), optional `channel`.
7//! - `[bus]` — the embedded messaging-bus component (liminal). This section
8//! deserializes into liminal's own
9//! [`liminal_server::config::ServerConfig`], so the bus validates its own
10//! section (required fields, `deny_unknown_fields`, channel-schema loading).
11//!
12//! **Defaults policy (BINDING ruling, 2026-07-21):** a required key that has
13//! only one value it could possibly hold is FORBIDDEN from being required —
14//! it defaults to that value, PROVEN from the deployed demos or the scaffold
15//! template, never invented. Genuinely deployment-specific values (bind
16//! addresses, file paths, channel names, document ids, auth tokens) stay
17//! required with the existing loud errors. Because `ServerConfig` and its
18//! nested `WebSocketConfig` are liminal's OWN external types (this crate
19//! depends on the published `liminal-server` crate and cannot add
20//! `#[serde(default)]` attributes to them), the handful of bus-section
21//! defaults this ruling adds (`drain_timeout_ms`, `[bus.websocket].path`,
22//! `[bus.websocket].allowed_origins`) are injected into the raw TOML table by
23//! this module's private `apply_bus_defaults` helper BEFORE the table is
24//! handed to liminal's own `ServerConfig` deserializer — a declared value is
25//! always left untouched and reaches liminal's own validation exactly as
26//! before. See `apply_bus_defaults` for the per-key sourcing.
27//!
28//! **Compatibility window:** the pre-rename section name `[liminal]` (with
29//! `[liminal.websocket]`) still parses as an alias of `[bus]`, but every
30//! acceptance logs a loud `tracing::warn!` deprecation naming the new section.
31//! Declaring both sections is refused. The alias is removed after one window.
32//!
33//! Because the embedded bus's WebSocket address is derived from THIS one
34//! configured section (see [`crate::embedded`]), the page's `busEndpoint`
35//! and the server's actual bound listener can never drift — there is exactly
36//! one source of truth.
37
38use std::collections::BTreeMap;
39use std::net::SocketAddr;
40use std::path::{Path, PathBuf};
41
42use liminal_server::config::{ServerConfig, ServiceProfile, apply_env_overrides, validate};
43use serde::Deserialize;
44
45use crate::error::HostError;
46
47/// The raw `frame.toml` shape: the console section plus the embedded bus
48/// section under its primary name (`[bus]`) or its deprecated alias
49/// (`[liminal]`). Exactly one of the two must be present; [`FrameConfig::load`]
50/// enforces that and logs the deprecation on the alias.
51///
52/// The bus sections are captured as raw [`toml::Value`] tables rather than
53/// `ServerConfig` directly: [`FrameConfig::load`] injects frame-host's own
54/// proven defaults into the table (see [`apply_bus_defaults`]) BEFORE handing
55/// it to liminal's own `ServerConfig` deserializer, since `ServerConfig` is
56/// an external type this crate cannot annotate.
57#[derive(Debug, Deserialize)]
58#[serde(deny_unknown_fields)]
59struct RawFrameConfig {
60 /// Console HTTP server configuration.
61 frame: FrameSection,
62 /// Optional document-service binding (IRIDIUM-A3 R11).
63 document: Option<DocumentSection>,
64 /// Embedded bus component configuration — the primary section name.
65 bus: Option<toml::Value>,
66 /// DEPRECATED alias of `[bus]`, kept for one compatibility window.
67 liminal: Option<toml::Value>,
68}
69
70/// The parsed `frame.toml`: the console section plus the embedded bus
71/// section (liminal's own config type).
72#[derive(Debug)]
73pub struct FrameConfig {
74 /// Console HTTP server configuration.
75 pub frame: FrameSection,
76 /// The optional `[document]` binding: when present, the host boots the
77 /// document service (IRIDIUM-A3 R11) against the embedded bus.
78 pub document: Option<DocumentSection>,
79 /// Embedded bus component configuration — liminal's own config type,
80 /// validated by liminal's own validator.
81 pub bus: ServerConfig,
82 /// Whether the embedded bus's `[bus.websocket].allowed_origins` list is
83 /// DERIVED (the operator did not state it) rather than stated verbatim.
84 ///
85 /// When `true`, [`Self::finalize_page_origins`] fills the list from the
86 /// page server's REAL bound address once it is known
87 /// ([`crate::page::PageServer::resolve`]) — both the `127.0.0.1` and the
88 /// `localhost` spelling — so the served page's own origin is always
89 /// allow-listed. When `false` the operator's explicit list is LAW and is
90 /// never touched (2026-07-22 portless ruling: stated config remains
91 /// authoritative, no silent fallback).
92 pub page_origins_derived: bool,
93 /// Whether the whole port topology is stated verbatim by the operator:
94 /// `[frame].bind` is present AND `[bus.websocket].allowed_origins` is
95 /// stated. Only a fully-stated topology has any port coherence to check
96 /// (`frame check`); a derived/portless one is coherent by construction.
97 pub ports_explicit: bool,
98}
99
100/// The `[frame]` section: everything the console HTTP server needs.
101#[derive(Debug, Deserialize)]
102#[serde(deny_unknown_fields)]
103pub struct FrameSection {
104 /// Socket address the console HTTP server binds, e.g. `127.0.0.1:4173`.
105 ///
106 /// Optional (2026-07-22 portless ruling). When present it is LAW: the page
107 /// server binds exactly this address and a taken port is a loud failure
108 /// (never a silent move). When absent the host prefers `127.0.0.1:6010` and
109 /// walks forward to the next free port, printing the chosen URL loudly on
110 /// boot — see [`crate::page::PageServer::resolve`]. Either way the page's
111 /// REAL bound address is what flows into the derived
112 /// `[bus.websocket].allowed_origins` and the boot log line, so the two can
113 /// never drift.
114 #[serde(default)]
115 pub bind: Option<SocketAddr>,
116 /// Directory containing the static console bundle (`index.html` at root).
117 pub assets: PathBuf,
118 /// Bearer token surfaced to the page verbatim as `authToken`. Required key
119 /// — the operator states it, the binary never invents it. An explicitly
120 /// empty value (`auth_token = ""`) is legal and means an open server,
121 /// matching the console's config contract.
122 pub auth_token: String,
123 /// Feed channel surfaced to the page as `channel`. Absent → the field is
124 /// omitted and the page applies the SDK's default channel. When present it
125 /// must be non-empty (the console refuses an empty channel).
126 #[serde(default)]
127 pub channel: Option<String>,
128}
129
130/// The `[document]` section (IRIDIUM-A3 R11): the document binding.
131///
132/// Deployment-identity fields (`id`, `language`, `content_path`,
133/// `component_id`, `feed_channel`, `authoring_channel`, `state_dir`) stay
134/// fully required — A1 constraint 9's no-assumed-defaults law still governs
135/// them, since each one is a genuinely deployment-specific decision with no
136/// single "the" value. The five pure tuning/presentation knobs below
137/// (`lease_expiry_ms`, `journal_length_bound`, `quiesce_window_ms`,
138/// `dark_theme`, `blink_interval_ms`) are amended by the 2026-07-21 BINDING
139/// ruling (the same amendment that gave `syntax_theme` its built-in default):
140/// every known deployment types the identical value, so requiring the
141/// operator to retype it is exactly the "stupid required key" the ruling
142/// forbids. `frame_authority::AuthorityConfig::declare` (constraint 9's
143/// actual enforcement point) still receives an explicit, concrete,
144/// non-invented value either way — the default only decides where that
145/// value comes from when the key is absent.
146#[derive(Debug, Deserialize)]
147#[serde(deny_unknown_fields)]
148pub struct DocumentSection {
149 /// The bound document id (`[a-z0-9-]+`).
150 pub id: String,
151 /// The document language.
152 pub language: String,
153 /// The initial content source, resolved relative to the config file.
154 pub content_path: PathBuf,
155 /// The component instance id on every feed envelope (`[a-z0-9-]+`).
156 pub component_id: String,
157 /// The feed channel: the service publishes; every page subscribes.
158 pub feed_channel: String,
159 /// The authoring channel: pages publish; the service subscribes.
160 pub authoring_channel: String,
161 /// The frame-state store directory, resolved relative to the config
162 /// file.
163 pub state_dir: PathBuf,
164 /// Lease expiry (§5.5 policy), milliseconds. Optional — absent defaults
165 /// to [`DEFAULT_LEASE_EXPIRY_MS`], the value every known
166 /// `[document]`-enabled deployment declares
167 /// (`examples/code-view-console/frame.toml`, matching the live deployed
168 /// demo `~/.frame-demo/code-view/frame.toml`). A present-but-zero value
169 /// still refuses loudly (`FrameConfig::check_document_contract`).
170 #[serde(default = "default_lease_expiry_ms")]
171 pub lease_expiry_ms: u64,
172 /// The journal-length snapshot bound (T3(i) signal 3). Optional —
173 /// absent defaults to [`DEFAULT_JOURNAL_LENGTH_BOUND`], sourced the same
174 /// way as [`Self::lease_expiry_ms`].
175 #[serde(default = "default_journal_length_bound")]
176 pub journal_length_bound: u64,
177 /// The quiesce debounce window (T3(i) signal 4), milliseconds. Optional
178 /// — absent defaults to [`DEFAULT_QUIESCE_WINDOW_MS`], sourced the same
179 /// way as [`Self::lease_expiry_ms`].
180 #[serde(default = "default_quiesce_window_ms")]
181 pub quiesce_window_ms: u64,
182 /// The publisher-seat dark-mode presentation input (C5). Optional —
183 /// absent defaults to [`DEFAULT_DARK_THEME`], sourced the same way as
184 /// [`Self::lease_expiry_ms`].
185 #[serde(default = "default_dark_theme")]
186 pub dark_theme: bool,
187 /// The component's cursor-blink interval (T4), milliseconds — a
188 /// component policy value carried by frame.toml per constraint 12 and
189 /// advertised to the page. Optional — absent defaults to
190 /// [`DEFAULT_BLINK_INTERVAL_MS`], sourced the same way as
191 /// [`Self::lease_expiry_ms`].
192 #[serde(default = "default_blink_interval_ms")]
193 pub blink_interval_ms: u64,
194 /// The publisher-seat syntax-theme presentation input (C5, amended
195 /// 2026-07-21): capture-name → `#rrggbb` hex colour, TOML table.
196 /// Optional — when absent, [`crate::doc_binding::DocBinding::boot`]
197 /// falls back to the built-in default palette
198 /// ([`crate::syntax_theme::default_syntax_theme`], copied from the
199 /// proven `SYNTAX_THEME` in `examples/code-view-console/src/fake-feed.ts`).
200 /// When present, this table REPLACES the built-in default WHOLESALE —
201 /// there is no per-key merging with the default (merging would invent
202 /// behaviour nobody asked for). Every declared value must be `#rrggbb`
203 /// hex; [`FrameConfig::load`] fails loudly on a malformed or
204 /// non-string entry rather than dropping it silently.
205 #[serde(default)]
206 pub syntax_theme: Option<BTreeMap<String, String>>,
207}
208
209/// Proven `[document].lease_expiry_ms` default (milliseconds), source:
210/// `examples/code-view-console/frame.toml` L36, matching the live deployed
211/// demo `~/.frame-demo/code-view/frame.toml`.
212pub const DEFAULT_LEASE_EXPIRY_MS: u64 = 30_000;
213/// Proven `[document].journal_length_bound` default, source:
214/// `examples/code-view-console/frame.toml` L37, matching the live deployed
215/// demo.
216pub const DEFAULT_JOURNAL_LENGTH_BOUND: u64 = 256;
217/// Proven `[document].quiesce_window_ms` default (milliseconds), source:
218/// `examples/code-view-console/frame.toml` L38, matching the live deployed
219/// demo.
220pub const DEFAULT_QUIESCE_WINDOW_MS: u64 = 2_000;
221/// Proven `[document].dark_theme` default, source:
222/// `examples/code-view-console/frame.toml` L39, matching the live deployed
223/// demo.
224pub const DEFAULT_DARK_THEME: bool = true;
225/// Proven `[document].blink_interval_ms` default (milliseconds), source:
226/// `examples/code-view-console/frame.toml` L40, matching the live deployed
227/// demo.
228pub const DEFAULT_BLINK_INTERVAL_MS: u64 = 530;
229
230const fn default_lease_expiry_ms() -> u64 {
231 DEFAULT_LEASE_EXPIRY_MS
232}
233const fn default_journal_length_bound() -> u64 {
234 DEFAULT_JOURNAL_LENGTH_BOUND
235}
236const fn default_quiesce_window_ms() -> u64 {
237 DEFAULT_QUIESCE_WINDOW_MS
238}
239const fn default_dark_theme() -> bool {
240 DEFAULT_DARK_THEME
241}
242const fn default_blink_interval_ms() -> u64 {
243 DEFAULT_BLINK_INTERVAL_MS
244}
245
246/// The closed hex-colour grammar the wire enforces (`#rrggbb`, matching
247/// `frame_editor_wire::snapshot`'s `validate_syntax_theme` and the
248/// console's `hexColor` codec check), checked at config load so a doomed
249/// theme value fails here, not at first encode.
250fn is_hex_color(value: &str) -> bool {
251 value.len() == 7
252 && value.starts_with('#')
253 && value[1..].bytes().all(|byte| byte.is_ascii_hexdigit())
254}
255
256/// The closed ASCII id grammar the wire enforces (`[a-z0-9-]+`), checked
257/// at config load so a doomed id fails here, not at first encode.
258fn is_wire_id(value: &str) -> bool {
259 !value.is_empty()
260 && value
261 .bytes()
262 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
263}
264
265/// Proven `[bus].drain_timeout_ms` default (milliseconds). Source: every
266/// known deployment types the identical value —
267/// `examples/code-view-console/frame.toml` L38 (`drain_timeout_ms = 1000`,
268/// matching the live deployed demo `~/.frame-demo/code-view/frame.toml`),
269/// `crates/frame-cli/templates/frame.toml` L19 (the scaffold every new app
270/// starts from), and `docs/guides/BUILDING-APPS.md`'s worked example.
271pub const DEFAULT_BUS_DRAIN_TIMEOUT_MS: i64 = 1_000;
272
273/// Proven `[bus.websocket].path` default. Source: every known deployment
274/// types the identical value — `examples/code-view-console/frame.toml`
275/// (matching the live deployed demo), `crates/frame-cli/templates/frame.toml`
276/// L27 (the scaffold every new app starts from), and
277/// `docs/guides/BUILDING-APPS.md`'s worked example. `WebSocketConfig::path`
278/// (liminal-server's own type) still requires the value to start with `/`;
279/// this default satisfies that grammar.
280pub const DEFAULT_WEBSOCKET_PATH: &str = "/liminal";
281
282/// The OS-assigned bind sentinel for an internal endpoint that carries no
283/// stated address (2026-07-22 portless ruling): binding `127.0.0.1:0` lets
284/// the kernel pick a free port, and [`crate::embedded::EmbeddedLiminal`]
285/// captures the REAL bound address via each listener's `local_addr`, which is
286/// what `/frame/config.json` advertises to the page. A synthesized portless
287/// `[bus]` uses this for its wire, health, and WebSocket listeners.
288const OS_ASSIGNED: &str = "127.0.0.1:0";
289
290/// Injects frame-host's own proven bus-section defaults into the raw
291/// `[bus]` (or `[liminal]` alias) TOML table BEFORE it is handed to
292/// liminal's own `ServerConfig` deserializer.
293///
294/// `ServerConfig` and its nested `WebSocketConfig` are liminal's OWN
295/// external types (this crate depends on the published `liminal-server`
296/// crate and cannot add `#[serde(default)]` attributes to them), so this is
297/// the frame-host-owned seam where the 2026-07-21 BINDING defaults ruling
298/// is enforced for the bus section: a key the operator DID declare is left
299/// byte-identical and reaches liminal's own validation exactly as before; a
300/// key the operator omitted gets the proven default documented at its call
301/// site.
302///
303/// Defaulted:
304/// - `drain_timeout_ms` → [`DEFAULT_BUS_DRAIN_TIMEOUT_MS`] (milliseconds).
305/// - `websocket.path` → [`DEFAULT_WEBSOCKET_PATH`], but ONLY when a
306/// `[bus.websocket]` table is already present — the section itself stays
307/// required (embedded frame mode has nowhere for the browser to connect
308/// without it; `FrameConfig::check_embedded_mode` still refuses an absent
309/// section).
310///
311/// `allowed_origins` is deliberately NOT defaulted here (2026-07-22 portless
312/// ruling): the served page's origin is the page server's REAL bound address,
313/// which is not known until [`crate::page::PageServer::resolve`] has walked to
314/// a free port. When the operator did not state the list,
315/// [`FrameConfig::load`] records [`FrameConfig::page_origins_derived`] and
316/// [`FrameConfig::finalize_page_origins`] fills it from that real address at
317/// boot — both the `127.0.0.1` and `localhost` spellings — before the bus
318/// binds. A stated list is LAW and never touched.
319///
320/// # Errors
321///
322/// Returns [`HostError::ConfigParse`] if `bus` (or a present `websocket`
323/// key inside it) is not a TOML table — a genuinely malformed shape that
324/// would fail liminal's own deserializer anyway; caught here with a
325/// frame-host-flavoured message instead of a generic serde one.
326fn apply_bus_defaults(mut bus: toml::Value, path: &Path) -> Result<toml::Value, HostError> {
327 let table = bus.as_table_mut().ok_or_else(|| HostError::ConfigParse {
328 path: path.to_path_buf(),
329 detail: "[bus] (or [liminal]) must be a TOML table".to_owned(),
330 })?;
331
332 table
333 .entry("drain_timeout_ms")
334 .or_insert_with(|| toml::Value::Integer(DEFAULT_BUS_DRAIN_TIMEOUT_MS));
335
336 if let Some(websocket_value) = table.get_mut("websocket") {
337 let websocket = websocket_value
338 .as_table_mut()
339 .ok_or_else(|| HostError::ConfigParse {
340 path: path.to_path_buf(),
341 detail: "[bus.websocket] (or [liminal.websocket]) must be a TOML table".to_owned(),
342 })?;
343 websocket
344 .entry("path")
345 .or_insert_with(|| toml::Value::String(DEFAULT_WEBSOCKET_PATH.to_owned()));
346 }
347
348 // The participant (conversation) transport, enabled by default
349 // (F-7b): the embedded bus carried only channel flows until the dev
350 // management conversation became its first in-process conversation
351 // consumer — without a [bus.participant] section liminal never
352 // negotiates the participant-v1 capability and every enrollment is
353 // refused (`ParticipantCapabilityRequired`). The defaults below are
354 // liminal's OWN production-test participant configuration (published
355 // `test_participant_config`, the values the F-0c spike recorded and
356 // every frame conversation suite runs against) — a section the
357 // operator declares is LAW and left untouched.
358 table
359 .entry("participant")
360 .or_insert_with(default_participant_table);
361
362 Ok(bus)
363}
364
365/// Liminal's own proven participant-transport configuration, as the raw
366/// TOML table [`apply_bus_defaults`] injects. Every field is stated —
367/// liminal's `ParticipantConfig` carries no serde defaults (its own
368/// no-assumed-defaults rule), so an injected section must be complete.
369fn default_participant_table() -> toml::Value {
370 let mut participant = toml::value::Table::new();
371 for (key, value) in [
372 ("wire_frame_limit", 65_536),
373 ("attach_receipt_ttl_ms", 60_000),
374 ("receipt_provenance_ttl_ms", 600_000),
375 ("max_live_attach_receipts_server", 1_024),
376 ("max_live_attach_receipts_per_participant", 8),
377 ("max_receipt_provenance_server", 4_096),
378 ("max_receipt_provenance_per_conversation", 256),
379 ("max_receipt_provenance_per_participant", 64),
380 ("max_retired_identity_slots_server", 1_024),
381 ("identity_slots", 4),
382 ("observer_recovery_max_entries", 64),
383 ("max_semantic_conversations_per_connection", 32),
384 ("max_ordinary_record_entries", 1),
385 ("max_ordinary_record_bytes", 131_072),
386 ("max_generated_marker_entries", 1),
387 ("max_generated_marker_bytes", 4_096),
388 ("mandatory_transaction_bound_entries", 4),
389 ("mandatory_transaction_bound_bytes", 16_384),
390 ("full_recovery_claim_entries", 4),
391 ("full_recovery_claim_bytes", 16_384),
392 ("retained_capacity_entries", 2_048),
393 ("retained_capacity_bytes", 16_777_216),
394 ("max_retained_record_rows", 1_024),
395 ("closure_episode_churn_limit", 1_024),
396 ] {
397 participant.insert(key.to_owned(), toml::Value::Integer(value));
398 }
399 toml::Value::Table(participant)
400}
401
402/// Synthesizes the raw `[bus]` TOML table for a PORTLESS scaffold — a
403/// `frame.toml` that states no `[bus]` section at all (2026-07-22 portless
404/// ruling).
405///
406/// The host owns the whole shape: the wire, health, and WebSocket listeners
407/// bind OS-assigned free ports ([`OS_ASSIGNED`]); the single channel is the
408/// application's own `[frame].channel` (the one place a portless scaffold
409/// names its channel); routing is empty; the WebSocket upgrade path is the
410/// proven [`DEFAULT_WEBSOCKET_PATH`]. `allowed_origins` is intentionally
411/// omitted here and derived from the real page address at boot (see
412/// [`apply_bus_defaults`]). Every field frame-host authors is correct by
413/// construction, so [`FrameConfig::load`] skips liminal's own port-non-zero
414/// validation for this synthesized shape — the `:0` sentinels are intentional
415/// and resolved by the OS at bind, exactly the state liminal's validator was
416/// written to forbid for an operator-authored file.
417///
418/// The channel name is the single deployment-specific value; it comes from
419/// the required `[frame].channel`. A portless scaffold with no `[frame].channel`
420/// has nothing to name its bus channel and is refused loudly by the caller.
421fn synthesize_portless_bus(channel: &str) -> toml::Value {
422 let mut channel_entry = toml::value::Table::new();
423 channel_entry.insert("name".to_owned(), toml::Value::String(channel.to_owned()));
424 channel_entry.insert("durable".to_owned(), toml::Value::Boolean(false));
425
426 let mut websocket = toml::value::Table::new();
427 websocket.insert(
428 "listen_address".to_owned(),
429 toml::Value::String(OS_ASSIGNED.to_owned()),
430 );
431
432 let mut table = toml::value::Table::new();
433 table.insert(
434 "listen_address".to_owned(),
435 toml::Value::String(OS_ASSIGNED.to_owned()),
436 );
437 table.insert(
438 "health_listen_address".to_owned(),
439 toml::Value::String(OS_ASSIGNED.to_owned()),
440 );
441 table.insert(
442 "channels".to_owned(),
443 toml::Value::Array(vec![toml::Value::Table(channel_entry)]),
444 );
445 table.insert("routing_rules".to_owned(), toml::Value::Array(Vec::new()));
446 table.insert("websocket".to_owned(), toml::Value::Table(websocket));
447 toml::Value::Table(table)
448}
449
450/// Whether the raw `[bus]` (or `[liminal]`) table states
451/// `[bus.websocket].allowed_origins` verbatim. A stated list is LAW; an
452/// absent one is derived from the real page address at boot.
453fn bus_states_allowed_origins(bus: &toml::Value) -> bool {
454 bus.as_table()
455 .and_then(|table| table.get("websocket"))
456 .and_then(toml::Value::as_table)
457 .is_some_and(|websocket| websocket.contains_key("allowed_origins"))
458}
459
460impl FrameConfig {
461 /// Loads and fully validates `frame.toml`.
462 ///
463 /// Accepts the embedded bus section as `[bus]` (primary), `[liminal]`
464 /// (deprecated alias — parsed with a loud `tracing::warn!` naming `[bus]`;
465 /// never a silent fallback), or ENTIRELY ABSENT (the portless scaffold,
466 /// 2026-07-22 ruling): a missing `[bus]` is synthesized wholesale by
467 /// frame-host (`synthesize_portless_bus`) with OS-assigned internal
468 /// ports and the single channel taken from `[frame].channel`. An
469 /// operator-authored bus runs liminal's own validation (channel
470 /// `schema_ref` paths resolve relative to the config file's directory,
471 /// exactly as standalone liminal resolves them); a synthesized one is
472 /// correct by construction and skips only liminal's port-non-zero check
473 /// (the `:0` sentinels are intentional). Either way frame-host then
474 /// refuses any bus shape the embedded frame server does not faithfully
475 /// orchestrate.
476 ///
477 /// # Errors
478 ///
479 /// Returns a typed failure for an unreadable file, malformed TOML, both
480 /// section spellings present at once, a portless config with no
481 /// `[frame].channel` to name its bus channel, an operator bus section
482 /// liminal's validator rejects, or an embedded-mode shape refusal.
483 pub fn load(path: &Path) -> Result<Self, HostError> {
484 let text = std::fs::read_to_string(path).map_err(|source| HostError::ConfigRead {
485 path: path.to_path_buf(),
486 source,
487 })?;
488 let RawFrameConfig {
489 frame,
490 document,
491 bus,
492 liminal,
493 } = toml::from_str(&text).map_err(|error| HostError::ConfigParse {
494 path: path.to_path_buf(),
495 detail: error.to_string(),
496 })?;
497 // `synthesized` is true when the operator stated no `[bus]`/`[liminal]`
498 // at all: frame-host authors the whole bus shape (OS-assigned ports,
499 // the `[frame].channel` channel) and, being correct by construction,
500 // skips liminal's operator-facing port-non-zero validation below.
501 let (bus, synthesized) = match (bus, liminal) {
502 (Some(_), Some(_)) => {
503 return Err(HostError::ConfigParse {
504 path: path.to_path_buf(),
505 detail: "both [bus] and [liminal] sections are present; [liminal] is the \
506 deprecated alias of [bus] — declare exactly one section (use [bus])"
507 .to_owned(),
508 });
509 }
510 (Some(bus), None) => (bus, false),
511 (None, Some(bus)) => {
512 tracing::warn!(
513 config = %path.display(),
514 "frame.toml section [liminal] is DEPRECATED: rename [liminal] to [bus] and \
515 [liminal.websocket] to [bus.websocket]; the [liminal] alias is accepted for \
516 one compatibility window only"
517 );
518 (bus, false)
519 }
520 (None, None) => {
521 // Portless scaffold: the single deployment-specific value the
522 // synthesized bus needs is the channel, and the one place a
523 // portless file names it is `[frame].channel`. With neither a
524 // `[bus]` nor a `[frame].channel`, the host has nothing to name
525 // its bus channel — a loud refusal naming the fix, never a
526 // silent invented channel.
527 let Some(channel) = frame.channel.as_deref() else {
528 return Err(HostError::ConfigParse {
529 path: path.to_path_buf(),
530 detail: "no [bus] section and no [frame].channel: a portless frame.toml \
531 derives its embedded bus channel from [frame].channel, so state \
532 [frame].channel = \"<name>.events\" (or declare a full [bus] \
533 section to pin ports explicitly)"
534 .to_owned(),
535 });
536 };
537 (synthesize_portless_bus(channel), true)
538 }
539 };
540 // Whether the operator stated `[bus.websocket].allowed_origins`
541 // verbatim: a stated list is LAW; an absent one is derived from the
542 // real page address at boot (`finalize_page_origins`). A synthesized
543 // portless bus never states it.
544 let origins_stated = bus_states_allowed_origins(&bus);
545 // Inject frame-host's own proven bus-section defaults into the raw
546 // table BEFORE liminal's ServerConfig deserializer sees it — see
547 // `apply_bus_defaults` for what is defaulted and why. A key the
548 // operator DID declare is left byte-identical.
549 let bus = apply_bus_defaults(bus, path)?;
550 let bus: ServerConfig =
551 bus.try_into()
552 .map_err(|error: toml::de::Error| HostError::ConfigParse {
553 path: path.to_path_buf(),
554 detail: error.to_string(),
555 })?;
556 // Faithful bus load: run liminal's OWN pipeline in liminal's OWN
557 // order — environment overrides FIRST, then validation — exactly as
558 // standalone liminal's `load_config` does. Omitting the env layer would
559 // silently diverge: e.g. `LIMINAL_AUTH_TOKEN` (which liminal uses to
560 // inject an `[auth]` section absent from the file) would be ignored,
561 // downgrading a token-gated deployment to an open one with no warning.
562 // Channel `schema_ref` paths resolve relative to the config file's
563 // directory, matching standalone liminal.
564 let mut bus =
565 apply_env_overrides(bus).map_err(|source| HostError::LiminalConfig { source })?;
566 // A synthesized portless bus intentionally binds OS-assigned `:0`
567 // ports, exactly the state liminal's operator-facing validator rejects
568 // (`listen_address: port must be non-zero`). Every other field is
569 // frame-host-authored and correct by construction (one schema-less
570 // channel, empty routing, the proven WebSocket path), so validating it
571 // would add nothing but a false rejection of the intended `:0`. An
572 // operator-authored bus still runs the full validation unchanged.
573 if !synthesized {
574 validate(&mut bus, path.parent())
575 .map_err(|source| HostError::LiminalConfig { source })?;
576 }
577 let document = match document {
578 None => None,
579 Some(mut section) => {
580 // Resolve the declared paths relative to the config file,
581 // exactly as the bus resolves its schema refs.
582 if let Some(parent) = path.parent() {
583 if section.content_path.is_relative() {
584 section.content_path = parent.join(§ion.content_path);
585 }
586 if section.state_dir.is_relative() {
587 section.state_dir = parent.join(§ion.state_dir);
588 }
589 }
590 Some(section)
591 }
592 };
593 let ports_explicit = frame.bind.is_some() && origins_stated;
594 let config = Self {
595 frame,
596 document,
597 bus,
598 page_origins_derived: !origins_stated,
599 ports_explicit,
600 };
601 config.check_console_contract()?;
602 config.check_embedded_mode()?;
603 config.check_document_contract()?;
604 Ok(config)
605 }
606
607 /// Fills the embedded bus's `[bus.websocket].allowed_origins` from the page
608 /// server's REAL bound address once it is known, but ONLY when the operator
609 /// did not state the list ([`Self::page_origins_derived`]).
610 ///
611 /// The served page's origin is `http://<page-addr>`; the browser presents
612 /// it on every WebSocket upgrade and liminal's acceptor checks it against
613 /// this allow-list (fail-closed when empty). Both the `127.0.0.1` and the
614 /// `localhost` spelling of the bound port are added — the exact fix for a
615 /// real deployment that died on a 127-vs-localhost Origin mismatch when
616 /// only one spelling was allow-listed. Call this AFTER
617 /// [`crate::page::PageServer::resolve`] and BEFORE the bus binds
618 /// ([`crate::application::Application::boot`]); a stated list is LAW and is
619 /// left untouched.
620 ///
621 /// Idempotent and side-effect-free when the list was stated: the operator's
622 /// bytes reach liminal's acceptor exactly as written.
623 pub fn finalize_page_origins(&mut self, page_addr: SocketAddr) {
624 if !self.page_origins_derived {
625 return;
626 }
627 let Some(websocket) = self.bus.websocket.as_mut() else {
628 return;
629 };
630 let port = page_addr.port();
631 websocket.allowed_origins = vec![
632 format!("http://127.0.0.1:{port}"),
633 format!("http://localhost:{port}"),
634 ];
635 }
636
637 /// Refuses a `[document]` section the document service would refuse at
638 /// boot: bad wire ids, zero policy values, or channels the embedded
639 /// bus does not carry — every refusal typed and loud, here rather
640 /// than mid-boot.
641 fn check_document_contract(&self) -> Result<(), HostError> {
642 let Some(document) = &self.document else {
643 return Ok(());
644 };
645 for (field, value) in [
646 ("[document].id", document.id.as_str()),
647 ("[document].component_id", document.component_id.as_str()),
648 ] {
649 if !is_wire_id(value) {
650 return Err(HostError::ConfigContract {
651 detail: format!("{field} must match [a-z0-9-]+, got {value:?}"),
652 });
653 }
654 }
655 if document.feed_channel == document.authoring_channel {
656 return Err(HostError::ConfigContract {
657 detail: "[document].feed_channel and [document].authoring_channel must be two distinct channels (C2: two channels per document)"
658 .to_owned(),
659 });
660 }
661 for (field, channel) in [
662 ("[document].feed_channel", &document.feed_channel),
663 ("[document].authoring_channel", &document.authoring_channel),
664 ] {
665 if !self
666 .bus
667 .channels
668 .iter()
669 .any(|configured| configured.name == *channel)
670 {
671 return Err(HostError::ConfigContract {
672 detail: format!(
673 "{field} {channel:?} is not one of the embedded bus's configured \
674 channels: declare it under [bus].channels"
675 ),
676 });
677 }
678 }
679 // C8/R11 transport reality, verified at the pinned SDK (0.3.0):
680 // the TCP channel-subscription client cannot present an auth token,
681 // so the document service cannot subscribe a token-gated bus.
682 // Refused loudly here; the upstream ask is recorded in the leg's
683 // report.
684 if self.bus.auth.is_some() {
685 return Err(HostError::ConfigContract {
686 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]."
687 .to_owned(),
688 });
689 }
690 for (field, value, default) in [
691 (
692 "[document].lease_expiry_ms",
693 document.lease_expiry_ms,
694 DEFAULT_LEASE_EXPIRY_MS,
695 ),
696 (
697 "[document].journal_length_bound",
698 document.journal_length_bound,
699 DEFAULT_JOURNAL_LENGTH_BOUND,
700 ),
701 (
702 "[document].quiesce_window_ms",
703 document.quiesce_window_ms,
704 DEFAULT_QUIESCE_WINDOW_MS,
705 ),
706 (
707 "[document].blink_interval_ms",
708 document.blink_interval_ms,
709 DEFAULT_BLINK_INTERVAL_MS,
710 ),
711 ] {
712 if value == 0 {
713 return Err(HostError::ConfigContract {
714 detail: format!(
715 "{field} must be positive when declared explicitly; omit the key \
716 entirely to use the documented default of {default}"
717 ),
718 });
719 }
720 }
721 if let Some(theme) = &document.syntax_theme {
722 for (capture, color) in theme {
723 if capture.is_empty() {
724 return Err(HostError::ConfigContract {
725 detail: "[document].syntax_theme keys must be non-empty".to_owned(),
726 });
727 }
728 if !is_hex_color(color) {
729 return Err(HostError::ConfigContract {
730 detail: format!(
731 "[document].syntax_theme.{capture} must be #rrggbb hex, got {color:?}"
732 ),
733 });
734 }
735 }
736 }
737 Ok(())
738 }
739
740 /// Refuses `[frame]` values the console's own config contract would reject,
741 /// so a doomed config fails loudly here rather than in the browser.
742 fn check_console_contract(&self) -> Result<(), HostError> {
743 if let Some(channel) = &self.frame.channel
744 && channel.is_empty()
745 {
746 return Err(HostError::ConfigContract {
747 detail: "[frame].channel, when present, must be non-empty: the console refuses an \
748 empty channel; omit the key to use the SDK's default channel"
749 .to_owned(),
750 });
751 }
752 // The served /frame/config.json advertises the FULL configured channel
753 // list (the console subscribes every one of them). A bus section
754 // with zero channels leaves the console with no feed to subscribe — a
755 // doomed deployment refused here, not discovered as a browser-side
756 // subscribe rejection.
757 if self.bus.channels.is_empty() {
758 return Err(HostError::ConfigContract {
759 detail: "[bus].channels is empty: the console subscribes every configured \
760 channel and serves the list as `channels` in /frame/config.json, so a \
761 zero-channel bus leaves the console with no feed. Declare at least \
762 one [[bus.channels]] entry."
763 .to_owned(),
764 });
765 }
766 // The console's primary channel must be one the embedded bus
767 // actually carries: the page refuses a `channels` roster that omits its
768 // `channel`, so serving that pair is a composition error caught here.
769 if let Some(channel) = &self.frame.channel
770 && !self
771 .bus
772 .channels
773 .iter()
774 .any(|configured| configured.name == *channel)
775 {
776 return Err(HostError::ConfigContract {
777 detail: format!(
778 "[frame].channel \"{channel}\" is not one of the embedded bus's \
779 configured channels [{roster}]: the console would subscribe a channel the \
780 embedded server does not carry. Name a configured channel or add it to \
781 [bus].channels.",
782 roster = self
783 .bus
784 .channels
785 .iter()
786 .map(|configured| configured.name.as_str())
787 .collect::<Vec<_>>()
788 .join(", ")
789 ),
790 });
791 }
792 // If the bus gates connections behind a token but the page is handed an
793 // empty one, the browser would present an empty token to a closed
794 // server and be refused — a silent "page can't connect". Refuse loudly.
795 if let Some(auth) = &self.bus.auth {
796 if self.frame.auth_token.is_empty() {
797 return Err(HostError::ConfigContract {
798 detail: "[bus.auth] gates the embedded server behind a token, but \
799 [frame].auth_token is empty: the page would present an empty token \
800 and be refused. Set [frame].auth_token to the token the page must \
801 present."
802 .to_owned(),
803 });
804 }
805 // The same doomed deployment with a non-empty wrong token: the
806 // served page presents [frame].auth_token on its WebSocket
807 // handshake and the embedded server validates it against
808 // [bus.auth].token, so a mismatch boots a healthy-looking host
809 // whose every browser is silently auth-refused (the announcer and
810 // document publisher connect with the bus token directly, so
811 // nothing fails host-side). The two fields have no legitimate
812 // reason to differ in embedded mode. Refuse loudly.
813 if self.frame.auth_token != auth.token {
814 return Err(HostError::ConfigContract {
815 detail: "[frame].auth_token does not match [bus.auth].token: the page \
816 presents [frame].auth_token to the embedded server, which validates \
817 against [bus.auth].token, so a mismatch means every browser is \
818 refused while the host boots healthy. Set both to the same token."
819 .to_owned(),
820 });
821 }
822 }
823 Ok(())
824 }
825
826 /// Refuses bus shapes the embedded frame server does not faithfully
827 /// orchestrate.
828 ///
829 /// Embedded frame mode runs liminal's single-node **Full** profile with the
830 /// **WebSocket transport required** (the browser is a direct bus
831 /// participant per D3, so it needs a WebSocket address). Clustered and
832 /// worker-front-door deployments have their own boot orchestration in
833 /// standalone liminal that this embedding does not reproduce; rather than
834 /// silently mis-boot them, they are refused with a typed error.
835 fn check_embedded_mode(&self) -> Result<(), HostError> {
836 if self.bus.cluster.is_some() {
837 return Err(HostError::EmbeddedModeUnsupported {
838 detail: "[bus.cluster] is set, but the embedded frame server runs a \
839 single-node deployment and does not start liminal's distribution \
840 cluster. Remove [bus.cluster] or run liminal standalone."
841 .to_owned(),
842 });
843 }
844 match self
845 .bus
846 .services
847 .profile()
848 .map_err(|source| HostError::LiminalConfig { source })?
849 {
850 ServiceProfile::Full => {}
851 ServiceProfile::WorkerFrontDoor => {
852 return Err(HostError::EmbeddedModeUnsupported {
853 detail: "[bus.services].profile is \"worker-front-door\", but the embedded \
854 frame server runs liminal's \"full\" profile (channels/conversations \
855 back the console feed). Remove the profile override or run liminal \
856 standalone."
857 .to_owned(),
858 });
859 }
860 }
861 if self.bus.websocket.is_none() {
862 return Err(HostError::EmbeddedModeUnsupported {
863 detail: "[bus.websocket] is absent, but the embedded frame server requires it: \
864 the browser connects to the bus's WebSocket directly (D3), so the console \
865 has nowhere to connect without it. Add a [bus.websocket] section."
866 .to_owned(),
867 });
868 }
869 Ok(())
870 }
871}