Skip to main content

liminal_server/config/
types.rs

1use std::net::SocketAddr;
2use std::path::PathBuf;
3use std::time::Duration;
4
5use crate::ServerError;
6
7/// Declarative configuration for the standalone liminal server wrapper.
8#[derive(Debug, Clone, serde::Deserialize)]
9#[serde(deny_unknown_fields)]
10pub struct ServerConfig {
11    /// Socket address where the standalone server will listen for client traffic.
12    pub listen_address: SocketAddr,
13    /// Additional named wire addresses on an encrypted network, sharing admission.
14    #[serde(default)]
15    pub extra_listen_addresses: Vec<SocketAddr>,
16    /// Socket address where the health endpoint server will listen for probes.
17    pub health_listen_address: SocketAddr,
18    /// IGNORED SINCE 0.14.3. Kept only so an existing config file still loads.
19    ///
20    /// This was the maximum time shutdown would wait for existing connections
21    /// to hang up by themselves before closing them. Nothing reads it now.
22    ///
23    /// A drain exists to let in-flight requests finish. In this server every
24    /// write is durable and flushed BEFORE it is acknowledged, so at the
25    /// instant shutdown begins there is no in-flight request to finish and
26    /// nothing for the wait to buy. What it did in practice, on a deployment
27    /// whose connections are long-lived idle clients that never hang up on
28    /// their own, was spend its whole budget on every restart and then close
29    /// the connections anyway — 5 s per restart, measured on Tom Whiting's
30    /// estate on 2026-09-14. Shutdown now stops accepting, closes every
31    /// connection itself (an orderly close each peer reads as a shutdown), and
32    /// flushes durable state, with no timer in that path.
33    ///
34    /// Any value parses, including zero. Setting it changes nothing.
35    pub drain_timeout_ms: u64,
36    /// Channel topology definitions declared by the operator.
37    pub channels: Vec<ChannelDef>,
38    /// Declarative routing rules that connect configured channels.
39    pub routing_rules: Vec<RoutingRuleDef>,
40    /// Optional filesystem location for durable server state.
41    ///
42    /// A fresh boot surfaces `ConfigValidation` unless this path's PARENT
43    /// directory already exists: the haematite engine creates the store dir
44    /// exactly one level below a pre-existing parent it can fence (never
45    /// `create_dir_all` — deliberate upstream hardening). Create the parent;
46    /// the engine fences into it.
47    pub persistence_path: Option<PathBuf>,
48    /// Optional beamr distribution cluster membership configuration.
49    pub cluster: Option<ClusterConfig>,
50    /// Optional connection authentication configuration.
51    ///
52    /// When present, every client `Connect` handshake must carry a matching
53    /// `auth_token`; when absent the server is open (byte-identical to the
54    /// pre-auth behaviour). Not an ACL system — a single shared bearer token.
55    #[serde(default)]
56    pub auth: Option<AuthConfig>,
57    /// Service construction profile. Absent `[services]` (or an absent `profile`
58    /// key within it) defaults to `"full"`, so existing deployments build exactly
59    /// what they build today.
60    #[serde(default)]
61    pub services: ServicesConfig,
62    /// Operational bounds (§5). Absent `[limits]` (or any absent key within it)
63    /// defaults to the certifying-pair-signed numbers, so an operator who sets
64    /// nothing still runs bounded — "unlimited-by-silence is no longer a legal
65    /// state" (§5). Every value is a hard cap enforced by a typed refusal; a
66    /// zero (or otherwise invalid) value is a config validation error, never a
67    /// silent "unlimited".
68    #[serde(default)]
69    pub limits: LimitsConfig,
70    /// Optional WebSocket transport acceptor (LP-WS-TRANSPORT R1).
71    ///
72    /// When present the server binds a sibling WebSocket listener carrying the
73    /// canonical liminal wire protocol (one binary message per canonical frame)
74    /// alongside the main TCP listener. When absent NO HTTP/WebSocket listener
75    /// is started and the server behaves byte-identically to the pre-WebSocket
76    /// build. Every field inside is a deployment decision; the origin allow-list
77    /// FAILS CLOSED (an absent or empty list refuses every Origin-bearing
78    /// upgrade) and the keepalive ping interval is disabled unless explicitly
79    /// configured.
80    #[serde(default)]
81    pub websocket: Option<WebSocketConfig>,
82    /// `[open]`: how far an exchange with no `[auth]` reaches (ACCESS-011 R1).
83    /// Absent, open mode listens on loopback only, as ACCESS-010 left it; with
84    /// `reach = "network"` it listens where the operator writes. Refused
85    /// beside `[auth]`.
86    #[serde(default)]
87    pub open: Option<OpenConfig>,
88    /// `[[templates]]`: the services this exchange runs for its seats, written
89    /// at the top level (ACCESS-012 R1). An open exchange has no `[auth]` and
90    /// this is the only place its templates can be; a gated exchange may
91    /// write them here or under `[auth.lys]`, never both. The same shape as
92    /// `[[auth.lys.templates]]`.
93    #[serde(default)]
94    pub templates: crate::services::lifecycle::templates::TemplateSources,
95    /// `[agents]`: the agent registry's bounds, written at the top level
96    /// (ACCESS-013 R2). An open exchange has no `[auth]` and this is the only
97    /// place its registry can be; a gated exchange may write it here or under
98    /// `[auth.lys]`, never both.
99    #[serde(default)]
100    pub agents: Option<crate::agents::config::AgentsConfig>,
101    /// Participant lifecycle activation (LP gap closure, Part B).
102    ///
103    /// When present the server installs the production participant semantic
104    /// handler and advertises the participant capability bit on every
105    /// connection. Every field inside is REQUIRED and carries NO default:
106    /// participant lifecycle values are deployment decisions, and an absent
107    /// field is a typed startup error rather than an assumed number. When the
108    /// section is absent the participant capability stays disabled and the
109    /// server behaves byte-identically to the pre-activation build.
110    #[serde(default)]
111    pub participant: Option<ParticipantConfig>,
112}
113
114impl ServerConfig {
115    /// Returns the configured drain timeout, which shutdown IGNORES since
116    /// 0.14.3.
117    ///
118    /// Retained so an embedder that reads it keeps compiling, and so the value
119    /// an operator wrote can still be named in the shutdown log line that
120    /// reports it ignored. See [`ServerConfig::drain_timeout_ms`].
121    #[must_use]
122    pub const fn drain_timeout(&self) -> Duration {
123        Duration::from_millis(self.drain_timeout_ms)
124    }
125}
126
127/// Declarative channel definition loaded from server configuration.
128#[derive(Debug, Clone, serde::Deserialize)]
129#[serde(deny_unknown_fields)]
130pub struct ChannelDef {
131    /// Unique channel name used by routing rules and operators.
132    pub name: String,
133    /// Filesystem path to a JSON Schema document that validates every message
134    /// published to this channel.
135    ///
136    /// The path is resolved relative to the directory containing the config file
137    /// (absolute paths are used verbatim). Config validation reads and parses the
138    /// referenced document and stores the result in [`Self::loaded_schema`]; a
139    /// missing file, an unreadable file, or a file that is not valid JSON is an
140    /// accumulated validation error that stops startup.
141    ///
142    /// `None` means the channel has no schema: it keeps the permissive empty
143    /// schema (`{}`) that accepts any JSON payload.
144    #[serde(default)]
145    pub schema_ref: Option<PathBuf>,
146    /// Whether this channel requires durable persistence.
147    pub durable: bool,
148    /// Schema document loaded and parsed from [`Self::schema_ref`] during config
149    /// validation. Populated only by [`crate::config::validate`]; a directly
150    /// constructed [`ChannelDef`] that skips validation carries `None` here and is
151    /// therefore built with the permissive empty schema regardless of
152    /// [`Self::schema_ref`]. Never deserialized from the config file.
153    #[serde(skip)]
154    pub loaded_schema: Option<LoadedSchema>,
155}
156
157/// A channel's JSON Schema document as loaded from disk during config validation.
158///
159/// Carries both the parsed document (fed to the validation engine when the channel
160/// is built) and the raw file bytes (hashed into the protocol schema id advertised
161/// at subscribe time, so an SDK deriving ids from the same schema bytes converges).
162#[derive(Debug, Clone)]
163pub struct LoadedSchema {
164    /// Raw bytes of the schema file, hashed to derive the protocol schema id.
165    pub bytes: Vec<u8>,
166    /// Parsed JSON Schema document, fed to the channel's validation engine.
167    pub document: serde_json::Value,
168}
169
170/// Declarative routing rule definition loaded from server configuration.
171#[derive(Debug, Clone, serde::Deserialize)]
172#[serde(deny_unknown_fields)]
173pub struct RoutingRuleDef {
174    /// Source channel name from which messages are routed.
175    pub source_channel: String,
176    /// Target channel name to which matching messages are routed.
177    pub target_channel: String,
178    /// Optional predicate expression that filters routed messages.
179    pub predicate: Option<String>,
180}
181
182/// Public beamr distribution cookie, refused during configuration validation.
183/// Mirrors beamr's own [`beamr::distribution::DEFAULT_COOKIE`].
184pub const DEFAULT_COOKIE: &str = "beamr-cookie";
185
186/// Beamr distribution for copies of one server on a trusted network.
187///
188/// Federation provides the network between servers; a cluster joins replicas
189/// of the same server using an explicitly configured shared secret cookie.
190#[derive(Debug, Clone, serde::Deserialize)]
191#[serde(deny_unknown_fields)]
192pub struct ClusterConfig {
193    /// Unique node name advertised to the beamr distribution cluster.
194    pub node_name: String,
195    /// Socket address this node binds for inbound distribution links from peers.
196    ///
197    /// This is distinct from [`ServerConfig::listen_address`] (the client wire
198    /// port): a clustered node listens on two ports — one for clients, one for
199    /// peer distribution traffic.
200    pub listen_address: SocketAddr,
201    /// Seed node socket addresses used to join an existing cluster.
202    pub seed_nodes: Vec<SocketAddr>,
203    /// Required secret distribution handshake cookie. Every node in a cluster
204    /// must explicitly set the same cookie or the OTP handshake is rejected.
205    /// An omitted cookie fails deserialization; an empty cookie or the public
206    /// [`DEFAULT_COOKIE`] fails configuration validation.
207    pub cookie: String,
208}
209
210/// Connection authentication configuration.
211///
212/// A single shared bearer token compared (constant-time) against the `auth_token`
213/// carried on every client `Connect` handshake. This is the table-stakes access
214/// gate, not an ACL system: one token grants full access. Its absence (no `[auth]`
215/// section) is open mode (ACCESS-010): the exchange listens on loopback only,
216/// admits every connection whatever credential it presents, and asks Lys
217/// nothing; `[auth]` makes it gated, and a gated exchange never falls back to
218/// open.
219#[derive(Clone, serde::Deserialize)]
220#[serde(deny_unknown_fields)]
221pub struct AuthConfig {
222    /// Shared secret token a client must present in its `Connect` handshake. Must
223    /// be non-empty when the `[auth]` section is present (an empty token is a
224    /// config validation error, since it would gate nothing).
225    pub token: String,
226    /// Optional registry-minted pass verifier, additive beside the bearer.
227    #[serde(default)]
228    pub pass: Option<PassConfig>,
229    /// The Lys pass as a second credential beside the wire pass (ACCESS-001
230    /// R2): when set, a `Connect` carrying a Lys pass record is verified
231    /// offline against the issuer's published keys and decided per message.
232    /// Its `decisions` word (ACCESS-011 R2) changes only what a missing
233    /// grant does, never who the holder is, the bearer, or the wire pass.
234    #[serde(default)]
235    pub lys: Option<LysConfig>,
236    /// Refuse every worker registration from a connection that proved no
237    /// holder (ACCESS-004 R3): a bearer connection, or one before any pass.
238    /// Off by default, so a worker that dials with the bearer, as aion's does,
239    /// keeps registering; turning it on is the operator's act, and it needs
240    /// `[auth.pass]`, without which no connection could prove a holder.
241    #[serde(default)]
242    pub require_verified_worker: bool,
243}
244
245/// The token is a credential: its `Debug` names its length only
246/// (ACCESS-004 R5).
247impl std::fmt::Debug for AuthConfig {
248    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
249        formatter
250            .debug_struct("AuthConfig")
251            .field("token", &crate::auth_pass::ByteLength(self.token.len()))
252            .field("pass", &self.pass)
253            .field("lys", &self.lys)
254            .field("require_verified_worker", &self.require_verified_worker)
255            .finish()
256    }
257}
258
259/// Which of the two access modes a configuration names (ACCESS-010 R1).
260#[derive(Clone, Copy, Debug, PartialEq, Eq)]
261pub enum AccessMode {
262    /// No `[auth]`: loopback only, every connection admitted, nothing asked of Lys.
263    Open,
264    /// `[auth]` present: a bearer, a pass verifier or a Lys admitter decides.
265    Gated,
266}
267
268impl std::fmt::Display for AccessMode {
269    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
270        formatter.write_str(match self {
271            Self::Open => "open",
272            Self::Gated => "gated",
273        })
274    }
275}
276
277impl ServerConfig {
278    /// The access mode: open with no `[auth]`, gated with one.
279    #[must_use]
280    pub const fn access_mode(&self) -> AccessMode {
281        if self.auth.is_some() {
282            AccessMode::Gated
283        } else {
284            AccessMode::Open
285        }
286    }
287}
288
289/// Registry pass verification configuration. Every field is required when present.
290#[derive(Debug, Clone, serde::Deserialize)]
291#[serde(deny_unknown_fields)]
292pub struct PassConfig {
293    /// Registry Ed25519 verifying key as exactly 64 hexadecimal characters.
294    pub registry_verifying_key: String,
295    /// Maximum admitted server-clock skew in seconds.
296    pub maximum_clock_skew_seconds: u64,
297}
298
299/// WebSocket transport acceptor configuration (`[websocket]`, LP-WS-TRANSPORT R1).
300///
301/// The sibling WebSocket route is an explicit opt-in: the section itself must be
302/// present for any HTTP/WebSocket listener to start, and inside it the listen
303/// address and the single exact upgrade path are required with no defaults.
304///
305/// The deployment TLS contract (tear ruling Q1) is raw `ws://` behind a named
306/// TLS-terminating proxy that owns public `wss://` and certificates; liminal
307/// grows no TLS stack. Origin validation nonetheless belongs to this acceptor:
308/// [`Self::allowed_origins`] is the explicit allow-list checked on every
309/// Origin-bearing upgrade, and there is NO default list — absent or empty
310/// configuration fails closed for browser-origin upgrades while a native client
311/// that sends no `Origin` header may still upgrade (F6).
312///
313/// OPERATOR NOTE — the same deployment contract covers the pre-upgrade window
314/// (domain-owner ruling, 2026-07-18): the fronting proxy must ALSO enforce
315/// pre-upgrade read timeouts, handshake concurrency limits, and connection
316/// rate limits. Between TCP accept and a completed WebSocket upgrade this
317/// listener does not count the socket against `[limits] max_connections` and
318/// applies no read deadline of its own (only the fixed request-head size
319/// bound), so a deployment that exposes this port without the named proxy is
320/// out of contract on untrusted networks. A named handshake read-deadline
321/// config plus an in-flight handshake cap derived from the configured
322/// `max_connections` value is the ledgered post-demo hardening.
323#[derive(Debug, Clone, serde::Deserialize)]
324#[serde(deny_unknown_fields)]
325pub struct WebSocketConfig {
326    /// Socket address the WebSocket acceptor binds. Required; distinct from the
327    /// main wire listener, the health listener, and any cluster listener.
328    pub listen_address: SocketAddr,
329    /// The single exact HTTP request path that accepts WebSocket upgrades.
330    /// Required; must start with `/`. Every other path — and every ordinary
331    /// HTTP request — receives a small fixed non-success response and closes.
332    pub path: String,
333    /// Explicit browser-origin allow-list checked on every Origin-bearing
334    /// upgrade (F6). Entries are compared byte-exact against the request's
335    /// serialized `Origin` header value (RFC 6454 ASCII serialization, e.g.
336    /// `https://app.example.com`). Absent or empty means NO browser origin is
337    /// accepted (fail closed); native clients sending no `Origin` header are
338    /// unaffected.
339    #[serde(default)]
340    pub allowed_origins: Vec<String>,
341    /// CON-014 R2: the console served beside this exchange, admitted at every
342    /// address the machine is reached by. An Origin-bearing upgrade is also
343    /// admitted when the Origin is `http://host[:port]` or `https://host[:port]`,
344    /// its port (the explicit one, else the scheme's own) is listed here, and
345    /// its host is the host of the request's own `Host` header. A site at
346    /// another host on the same port is refused as today. Absent or empty adds
347    /// nothing.
348    #[serde(default)]
349    pub allowed_origin_ports: Vec<u16>,
350    /// Q-A transport-liveness keepalive: the server-side WebSocket Ping
351    /// interval in milliseconds. This is a precise LAW-1 carve-out — liveness
352    /// pings never mint application events, never re-arm application state, and
353    /// never serve as a source of truth; failure detection remains the socket's
354    /// typed terminal events. The bound is one ping per interval per
355    /// connection, so the idle cost is `interval x connection-count`. Absent
356    /// means pings are DISABLED, accepting proxy-idle-disconnect churn as the
357    /// documented consequence. A configured zero is a validation error.
358    #[serde(default)]
359    pub ping_interval_ms: Option<u64>,
360}
361
362/// Service construction profile selection (D2).
363///
364/// The `profile` value is carried as a raw string here rather than a typed enum so
365/// an unrecognised value is a config *validation* error with a helpful message
366/// (via [`Self::profile`]) rather than an opaque deserialization failure — matching
367/// how every other semantic config check surfaces. Absent `profile` defaults to
368/// `"full"`.
369#[derive(Debug, Clone, serde::Deserialize)]
370#[serde(deny_unknown_fields)]
371pub struct ServicesConfig {
372    /// Construction profile: `"full"` (the default, unchanged behaviour) or
373    /// `"worker-front-door"` (capability-scoped worker deployments).
374    #[serde(default = "default_service_profile")]
375    pub profile: String,
376    /// Row bounds for the server-owned durable streams.
377    #[serde(default)]
378    pub self_streams: SelfStreamsConfig,
379}
380
381/// Retained row bounds for each server-owned durable stream.
382#[derive(Debug, Clone, Copy, serde::Deserialize)]
383#[serde(deny_unknown_fields)]
384pub struct SelfStreamsConfig {
385    /// Maximum retained cost records.
386    #[serde(default = "default_cost_rows")]
387    pub cost_rows: u64,
388    /// Maximum retained lifecycle and connection events.
389    #[serde(default = "default_events_rows")]
390    pub events_rows: u64,
391}
392
393impl Default for SelfStreamsConfig {
394    fn default() -> Self {
395        Self {
396            cost_rows: default_cost_rows(),
397            events_rows: default_events_rows(),
398        }
399    }
400}
401
402const fn default_cost_rows() -> u64 {
403    10_000
404}
405
406const fn default_events_rows() -> u64 {
407    100_000
408}
409
410impl Default for ServicesConfig {
411    fn default() -> Self {
412        Self {
413            profile: default_service_profile(),
414            self_streams: SelfStreamsConfig::default(),
415        }
416    }
417}
418
419impl ServicesConfig {
420    /// Resolves the raw `profile` string into a typed [`ServiceProfile`].
421    ///
422    /// # Errors
423    /// Returns [`ServerError::ConfigValidation`] when the value is not a recognised
424    /// profile.
425    pub fn profile(&self) -> Result<ServiceProfile, ServerError> {
426        ServiceProfile::parse(&self.profile)
427    }
428}
429
430fn default_service_profile() -> String {
431    ServiceProfile::FULL.to_owned()
432}
433
434/// Which connection-services adapter the server constructs (D2).
435#[derive(Debug, Clone, Copy, PartialEq, Eq)]
436pub enum ServiceProfile {
437    /// Full channel/conversation/durability services — the default. Constructs the
438    /// haematite store, channel supervisor, conversation supervisor, and dedup
439    /// cache exactly as before.
440    Full,
441    /// Capability-scoped worker front door: the connection supervisor only, with no
442    /// channel/conversation/haematite machinery. Backs worker registration,
443    /// correlated push/reply, and notifier-consumed reserved publishes; ordinary
444    /// channel and conversation frames are rejected with a typed error frame.
445    WorkerFrontDoor,
446}
447
448impl ServiceProfile {
449    /// Config value selecting the full-service profile.
450    pub const FULL: &'static str = "full";
451    /// Config value selecting the worker-front-door profile.
452    pub const WORKER_FRONT_DOOR: &'static str = "worker-front-door";
453
454    /// Parses a `[services] profile` value into a typed profile.
455    ///
456    /// # Errors
457    /// Returns [`ServerError::ConfigValidation`] for any value other than
458    /// [`Self::FULL`] or [`Self::WORKER_FRONT_DOOR`].
459    pub fn parse(value: &str) -> Result<Self, ServerError> {
460        match value {
461            Self::FULL => Ok(Self::Full),
462            Self::WORKER_FRONT_DOOR => Ok(Self::WorkerFrontDoor),
463            other => Err(ServerError::ConfigValidation {
464                message: format!(
465                    "services.profile: unknown profile '{other}'; expected \"{}\" or \"{}\"",
466                    Self::FULL,
467                    Self::WORKER_FRONT_DOOR
468                ),
469            }),
470        }
471    }
472}
473
474mod agents;
475mod limits;
476mod lys;
477mod open;
478mod participant;
479mod templates;
480pub use crate::auth_membership::grant_stream::GrantStreamConfig;
481pub use limits::LimitsConfig;
482pub use lys::*;
483pub use open::{OpenConfig, Reach};
484pub use participant::{ParticipantConfig, PruningConfig};