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