Skip to main content

mcpmesh_node/
config.rs

1//! The `config.toml` model. Every table and key here is real, implemented surface —
2//! docs/config.md is the operator-facing reference for all of it.
3use figment::{
4    Figment,
5    providers::{Format, Toml},
6};
7use serde::Deserialize;
8use std::collections::BTreeMap;
9use std::path::PathBuf;
10
11#[derive(Debug, Default, Deserialize)]
12#[serde(default)]
13pub struct Config {
14    pub identity: IdentityCfg,
15    pub network: NetworkCfg,
16    pub limits: LimitsCfg,
17    /// Roster-mode `[roster]` tunables: the degraded-expiry grace window, the roster URL +
18    /// poll interval, and the freshness bound — one `RosterState` machine consumes them all.
19    pub roster: RosterCfg,
20    /// `[services.<name>]` registry — each entry is a served MCP server plus its allow
21    /// list. Peers do NOT live in config; they live in the daemon's state store, so
22    /// there is no `[peers]` table here.
23    pub services: std::collections::BTreeMap<String, ServiceCfg>,
24}
25
26/// A `[services.<name>]` entry: exactly one backend kind (`run` xor `socket`) plus the
27/// nicknames/groups admitted to it. The xor is validated at access time via
28/// [`ServiceCfg::backend_result`] rather than at parse time, so a malformed entry is a
29/// per-service error, not a whole-config load failure.
30#[derive(Debug, Default, Deserialize)]
31#[serde(default)]
32pub struct ServiceCfg {
33    /// `run`: spawn this command per session (a stdio MCP server).
34    pub run: Option<Vec<String>>,
35    /// `socket`: dial this local UDS (an already-running MCP server).
36    pub socket: Option<String>,
37    /// STABLE principals admitted to this service (b64u:/eid:/roster names, #38 — never display nicknames).
38    pub allow: Vec<String>,
39    /// Per-service env vars for a `run` backend (#51). The `MCPMESH_PEER_*` identity vars win
40    /// over these. Ignored for a `socket` backend. Default empty.
41    pub env: BTreeMap<String, String>,
42    /// Working directory for a `run` backend (#51). Default: inherit the daemon's cwd.
43    pub cwd: Option<String>,
44}
45
46/// The resolved backend kind of a [`ServiceCfg`], borrowing the config as slices (no
47/// clone). `&[String]`/`&str` rather than `&Vec`/`&String` — idiomatic and gives the
48/// daemon's backend builders the most flexible borrow.
49#[derive(Debug)]
50pub enum Backend<'a> {
51    Run(&'a [String]),
52    Socket(&'a str),
53}
54
55impl ServiceCfg {
56    /// Resolve the backend, enforcing exactly-one-of `run`/`socket`. Both or neither is an
57    /// error — surfaced to the operator, never a silent default.
58    #[allow(dead_code)] // consumed by the daemon service wiring
59    pub fn backend_result(&self) -> Result<Backend<'_>, String> {
60        match (&self.run, &self.socket) {
61            (Some(cmd), None) => Ok(Backend::Run(cmd.as_slice())),
62            (None, Some(p)) => Ok(Backend::Socket(p.as_str())),
63            (Some(_), Some(_)) => Err("service has both run and socket".into()),
64            (None, None) => Err("service has neither run nor socket".into()),
65        }
66    }
67}
68
69#[derive(Debug, Default, Deserialize)]
70#[serde(default)]
71pub struct IdentityCfg {
72    pub device_key: Option<PathBuf>, // None → paths::default_device_key_path()
73    /// This device's suggested name for itself, carried in a minted pairing invite.
74    /// `None` → the daemon defaults to a short fingerprint of the endpoint id.
75    /// Additive (`#[serde(default)]` at the struct level).
76    pub nickname: Option<String>,
77    /// Roster mode: the org id this node joined (pinned at install/join).
78    pub org_id: Option<String>,
79    /// Roster mode: the pinned org-root public key, `b64u:`. The single trust anchor
80    /// roster signatures verify against. Pinned on first roster install / `join`.
81    pub org_root_pk: Option<String>,
82    /// Roster mode: this node's stable user_id in the org. Pinned at `join` (proposed)
83    /// and reconciled to the roster's authoritative value once installed.
84    pub user_id: Option<String>,
85    /// Roster mode: path to this person's user key. Minted by `join`; binds this
86    /// person's devices. `None` → paths::default_user_key_path() when needed.
87    pub user_key: Option<PathBuf>,
88}
89
90/// `[network]`. The knobs are exactly what `daemon::net_plan` implements —
91/// no aspirational surface:
92/// - `relay_mode = "default" | "custom" | "disabled"`. `"custom"` requires `relay_urls`
93///   (self-hosted iroh relays); `"disabled"` is the HERMETIC mode — no relay AND no
94///   discovery (localhost/tests).
95/// - `discovery_mode = "default" | "custom"`. `"custom"` requires `discovery_urls` —
96///   self-hosted pkarr relay URLs (e.g. an iroh-dns-server), used for BOTH publishing and
97///   resolving peer addresses in place of n0's DNS/pkarr. Ignored (off) when
98///   `relay_mode = "disabled"`.
99///
100/// Unknown modes or a `custom` without URLs are startup ERRORS (`net_plan`), never a silent
101/// fallback — a metadata-privacy knob must not quietly revert to public infrastructure.
102#[derive(Debug, Clone, Deserialize)]
103#[serde(default)]
104pub struct NetworkCfg {
105    pub relay_mode: String,
106    /// Self-hosted relay URLs, required when `relay_mode = "custom"`.
107    pub relay_urls: Vec<String>,
108    pub discovery_mode: String,
109    /// Self-hosted pkarr relay URLs, required when `discovery_mode = "custom"`.
110    pub discovery_urls: Vec<String>,
111    /// TESTING ONLY (#116): force application data over the RELAY even when a direct path exists.
112    ///
113    /// Requires the `unstable-relay-only` cargo feature. Without it this field still PARSES — a
114    /// config must stay portable between a test build and a production one — but is ignored with a
115    /// `warn!`. It is never a startup error: a testing switch must not brick a node, and it must
116    /// never be ignored SILENTLY, because believing you tested the relay when you did not is the
117    /// exact failure #116 reports.
118    ///
119    /// Selects the relay path; it does NOT prevent hole-punching (that is socket-level behaviour a
120    /// `PathSelector` cannot reach). A direct path may still form — it simply never carries data,
121    /// and `status` reports `relay` because #64 derives the path from `is_selected()`.
122    pub relay_only: bool,
123    /// `[network].presence_mode` (#89) — who gets a reachability pong on `mcpmesh/ping/1`.
124    ///
125    /// - `"paired"` (default): any paired peer, today's behaviour.
126    /// - `"granted"`: only a caller currently holding at least one service grant. This is what
127    ///   makes an embedder's per-peer sharing switch control presence too — revoking the last
128    ///   service takes presence with it, live, with no restart and no new verb.
129    /// - `"off"`: never pong.
130    ///
131    /// The arm is gated by PAIRING alone otherwise, so `service_allow_revoke` has no effect on it:
132    /// a peer whose every service was revoked still learns you are online right now, your RTT, your
133    /// `stack_version` and your app metadata, on demand and forever. The only lever was a full
134    /// unpair — a relationship-destroying action to express a privacy preference (#89).
135    ///
136    /// A refusal under `"off"`/`"granted"` matches the trust gate's, so this arm does not
137    /// distinguish "not paired" from "hidden" from "no grants".
138    ///
139    /// **This is NOT "appear offline".** It withholds the pong payload (`stack_version`, app
140    /// metadata, the caller's services) and makes our own probe report you unreachable. It does not
141    /// hide that the node is running: a QUIC application close implies a completed handshake,
142    /// `mcpmesh/pair/1` answers any stranger by design, and a paired peer still gets a served
143    /// `mcpmesh/mcp/1` session. Do not describe it to users as invisibility (#89 gate).
144    ///
145    /// Read at BOOT — changing the mode needs a restart. The per-peer effect under `"granted"` is
146    /// live, because grants are.
147    pub presence_mode: String,
148    /// QUIC idle timeout in seconds (#56) — how long a connection survives with NO traffic and no
149    /// keepalive before the transport closes it. `None` = iroh's default, **30s** on iroh 1.0.3.
150    ///
151    /// This is not "how long an idle session lives". iroh keepalives every 5s by default, so a held
152    /// session survives indefinitely while the process runs; this is what detects a peer that
153    /// VANISHED.
154    ///
155    /// **It is NEGOTIATED, not imposed.** QUIC takes the MINIMUM of the two peers' advertised
156    /// values (RFC 9000 §10.1), so raising this on one node achieves nothing against a peer still
157    /// on the default — the connection still times out at 30s. Raising it is only meaningful when
158    /// every node is configured together; lowering it works one-sidedly.
159    ///
160    /// `0` means "no timeout" from THIS side, which likewise yields the peer's value; against a
161    /// default peer that is still 30s. Only if both sides say `0` does a vanished peer go
162    /// undetected at the transport layer.
163    #[serde(default)]
164    pub idle_timeout_secs: Option<u64>,
165    /// QUIC keepalive interval in seconds (#56) — how often the transport PINGs an otherwise idle
166    /// connection. `None` = iroh's default, **5s** on iroh 1.0.3.
167    ///
168    /// Sets BOTH the connection-level and the per-path keepalive — setting only the former would
169    /// leave every path pinging at iroh's 5s regardless.
170    ///
171    /// A transport keepalive carries no method-bearing frame, so it does NOT consume a
172    /// `[limits].rate_limit_per_min` token — unlike an application-level heartbeat, which does.
173    ///
174    /// Must be less than the EFFECTIVE idle timeout — `idle_timeout_secs` if set, otherwise iroh's
175    /// 30s — or boot fails. Note that effective timeout is the negotiated minimum, so a value that
176    /// passes this check locally can still be too slow for a peer with a shorter one.
177    ///
178    /// **This can only LOWER the ping rate.** iroh caps the per-path keepalive at 5s and silently
179    /// discards anything larger, so a value above 5 would leave every path pinging at 5s anyway —
180    /// boot refuses it rather than pretend it took effect. There is no supported way to reduce
181    /// keepalive traffic on a metered link with iroh 1.0.3.
182    #[serde(default)]
183    pub keep_alive_secs: Option<u64>,
184}
185impl Default for NetworkCfg {
186    fn default() -> Self {
187        Self {
188            relay_mode: "default".into(),
189            relay_urls: Vec::new(),
190            discovery_mode: "default".into(),
191            discovery_urls: Vec::new(),
192            relay_only: false,
193            presence_mode: "paired".into(),
194            idle_timeout_secs: None,
195            keep_alive_secs: None,
196        }
197    }
198}
199
200/// `[limits]`. NOTE — the frame cap is deliberately NOT here: the 16 MiB `max_frame`
201/// default is a fixed CONSTANT at each wire (`mcpmesh_net::endpoint` for the mesh,
202/// `ipc::MAX_FRAME_BYTES` for the control socket, `backends::MAX_FRAME_BYTES` for local MCP
203/// servers), not a config tunable. A `max_frame` config field existed historically but was never
204/// threaded into any `FrameReader` (dead surface); threading it into the mesh path would widen
205/// `mcpmesh-net`'s public API for no demonstrated need, so the field was removed instead (serde
206/// ignores an unknown `max_frame` key in existing configs).
207#[derive(Debug, Deserialize)]
208#[serde(default)]
209pub struct LimitsCfg {
210    pub rate_limit_per_min: u32,
211    pub max_inflight: u32,
212    pub max_sessions: u32,
213    /// Per-authenticated-endpoint app-blob BYTE budget, bytes per minute (#84a).
214    ///
215    /// **0 = unlimited, and that is the default**, so an existing deployment is unchanged on
216    /// upgrade. The pre-existing blob limiter counts CONNECTIONS, which cannot see one granted
217    /// peer re-pulling a 4 GB blob on each of 60 connections a minute; this bounds the bytes.
218    ///
219    /// A peer that exceeds it gets its transfer ABORTED (retryable), not paced — pacing holds the
220    /// request open and turns a bandwidth problem into an unbounded-concurrency one.
221    ///
222    /// **Use 0 or at least 32768** (two chunks); a value in `1..32768` is FLOORED to 32768.
223    ///
224    /// Admission reserves one chunk before any bytes and the transfer then meters its own chunks,
225    /// so a sub-floor budget does not fail closed — it silently caps every servable blob at
226    /// roughly `budget - 16384` bytes and truncates anything larger. Measured: 20480 serves a
227    /// 4 KiB blob and nothing bigger. Two earlier drafts of this comment got that wrong, first
228    /// recommending the bricking value and then claiming it failed closed.
229    ///
230    /// Requires a restart: the limiter and the provider's event mask are both built once at boot.
231    pub blob_bytes_per_min: u64,
232    /// Audit-log retention window in calendar months (#88). **0 = keep forever, and that is the
233    /// default** — flipping today's keep-everything behavior to auto-deletion is a product call,
234    /// deliberately not made here. When N > 0, boot deletes monthly audit files older than the
235    /// last N months (the current month counts as month 1). Boot-time only: a long-running
236    /// daemon prunes on its next start; the `audit_prune` verb covers live needs.
237    pub audit_retain_months: u32,
238}
239impl Default for LimitsCfg {
240    fn default() -> Self {
241        Self {
242            rate_limit_per_min: 120,
243            max_inflight: 16,
244            max_sessions: 4,
245            blob_bytes_per_min: 0, // unlimited: opt-in, no behaviour change on upgrade
246            audit_retain_months: 0, // keep forever: opt-in, no behaviour change on upgrade
247        }
248    }
249}
250
251/// The default degraded-expiry grace window (`[roster].grace_period` default "72h").
252/// A stale roster keeps serving for this window past `expires_at` (with a warning) before it
253/// stops granting roster identity. Kept here so [`RosterCfg::default`] and the parse fallback
254/// share one source; the gate mirrors it as `roster::gate::DEFAULT_GRACE_SECS`.
255const DEFAULT_GRACE_SECS: i64 = 72 * 3600;
256
257/// The default freshness bound (`[roster].max_staleness`, default "24h" = 86400s). A roster
258/// this node has not re-confirmed current within this window degrades on the SAME `RosterState`
259/// machine as expiry (warnings within `grace`, then serving stops) — bounding adversarial staleness at
260/// `max_staleness + grace` independent of `expires_at`. Shared by [`RosterCfg::default`] + the parse
261/// fallback.
262const DEFAULT_MAX_STALENESS_SECS: i64 = 24 * 3600;
263
264/// The `[roster]` config table. `grace_period` is the degraded-expiry grace window — how
265/// long a roster past `expires_at` keeps serving (degraded, warning) before it stops. Additive
266/// (`#[serde(default)]`): a config with no `[roster]` table gets the 72h default.
267#[derive(Debug, Deserialize)]
268#[serde(default)]
269pub struct RosterCfg {
270    /// Degraded-expiry grace window: `"72h"` / `"24h"` / plain seconds (default "72h").
271    pub grace_period: String,
272    /// The pinned roster URL for the HTTPS poll. Operator-managed static hosting; also how a
273    /// joiner bootstraps its FIRST roster. `None` → no URL poll (manual installs only).
274    /// Additive (`#[serde(default)]`): a config with no `url` key gets `None`.
275    pub url: Option<String>,
276    /// How often to poll `url` (default "1h"). Total-parse like `grace_period` — an
277    /// unparseable value falls back to the hourly default rather than disabling the poll.
278    pub poll_interval: String,
279    /// The freshness bound (default "24h"): how long this node may go without re-confirming
280    /// the installed roster current (via a TLS URL poll ≥ installed, a gossip install, or a
281    /// manual install) before it degrades on the SAME `RosterState` machine as expiry. Total-parse
282    /// like `grace_period` (an unparseable value falls back to the 24h default — a typo never disables
283    /// the bound). Additive (`#[serde(default)]`): a config with no `max_staleness` key gets 24h.
284    pub max_staleness: String,
285}
286impl Default for RosterCfg {
287    fn default() -> Self {
288        Self {
289            grace_period: "72h".into(),
290            url: None,
291            poll_interval: "1h".into(),
292            max_staleness: "24h".into(),
293        }
294    }
295}
296
297impl RosterCfg {
298    /// The grace window in SECONDS. An absent or unparseable `grace_period` falls back to the 72h
299    /// default rather than erroring — an operator typo must never disable degraded serving, and a
300    /// grace window is advisory, not a security bound (revocation is enforced regardless of
301    /// degraded state).
302    ///
303    /// Two paths degrade on the ONE `RosterState` machine (`RosterView::state`, Approved →
304    /// DegradedGrace → DegradedStopped): expiry (`expires_at` + THIS grace window) and freshness
305    /// (`last_confirmed` + `max_staleness`). Once DegradedStopped, the gate stops granting roster
306    /// identity (fail-closed — revocation is still enforced); within grace, serving continues
307    /// with a warning (`daemon::warn_if_degraded_grace`).
308    pub fn grace_seconds(&self) -> i64 {
309        parse_duration(&self.grace_period).unwrap_or(DEFAULT_GRACE_SECS)
310    }
311
312    /// The URL poll interval in SECONDS (default 3600). Like [`grace_seconds`](Self::grace_seconds)
313    /// it is TOTAL — an absent/unparseable value falls back to the hourly default rather than
314    /// erroring, so an operator typo slows the poll to hourly instead of disabling freshness.
315    pub fn poll_interval_seconds(&self) -> i64 {
316        parse_duration(&self.poll_interval).unwrap_or(3600)
317    }
318
319    /// The freshness bound in SECONDS (default 86400 = 24h). Like [`grace_seconds`](Self::grace_seconds)
320    /// it is TOTAL — an absent/unparseable value falls back to the 24h default rather than erroring, so
321    /// an operator typo tightens/loosens to 24h instead of disabling the freshness bound.
322    pub fn max_staleness_seconds(&self) -> i64 {
323        parse_duration(&self.max_staleness).unwrap_or(DEFAULT_MAX_STALENESS_SECS)
324    }
325}
326
327/// Parse a duration string to SECONDS: a `d`/`h`/`m`/`s` suffix (days/hours/minutes/seconds) or a
328/// bare number (seconds). Trim + suffix-strip + checked multiply; rejects a
329/// negative/overflowing/garbage value as `Err` (the caller supplies the
330/// default). `u64` parse then a checked `i64` conversion: a negative grace is meaningless, so `-1`
331/// fails the `u64` parse and falls back to the default rather than becoming a negative window.
332// Reached only by the accessors above and the `org create --expires` porcelain
333// (`enrollcmd`, the operator-managed validity window — now across the crate seam, hence
334// `pub`; still `#[doc(hidden)]` at the module level). Pure parser — no state.
335pub fn parse_duration(s: &str) -> Result<i64, String> {
336    let s = s.trim();
337    let (num, mult) = if let Some(n) = s.strip_suffix('d') {
338        (n, 24 * 3600)
339    } else if let Some(n) = s.strip_suffix('h') {
340        (n, 3600)
341    } else if let Some(n) = s.strip_suffix('m') {
342        (n, 60)
343    } else if let Some(n) = s.strip_suffix('s') {
344        (n, 1)
345    } else {
346        (s, 1)
347    };
348    num.trim()
349        .parse::<u64>()
350        .ok()
351        .and_then(|v| v.checked_mul(mult))
352        .and_then(|v| i64::try_from(v).ok())
353        .ok_or_else(|| format!("unparseable duration: {s}"))
354}
355
356// figment::Error is ~208 bytes; boxing it would churn the API for a cold path.
357#[allow(clippy::result_large_err)]
358impl Config {
359    #[allow(dead_code)] // exercised by unit tests; config-string entry point for later tooling
360    pub fn from_toml_str(s: &str) -> Result<Self, figment::Error> {
361        Figment::new().merge(Toml::string(s)).extract()
362    }
363
364    /// Missing file → defaults (first run); malformed file → Err.
365    /// Callers must surface the Err — swallowing it silently reverts user choices.
366    pub fn load(path: &std::path::Path) -> Result<Self, figment::Error> {
367        Figment::new().merge(Toml::file(path)).extract()
368    }
369}
370
371#[cfg(test)]
372mod tests {
373    use super::*;
374
375    #[test]
376    fn empty_file_yields_spec_defaults() {
377        let c = Config::from_toml_str("").unwrap();
378        assert_eq!(c.network.relay_mode, "default");
379        assert_eq!(c.network.discovery_mode, "default");
380        assert_eq!(c.limits.rate_limit_per_min, 120);
381        assert_eq!(c.limits.max_inflight, 16);
382        assert_eq!(c.limits.max_sessions, 4);
383    }
384
385    #[test]
386    fn values_override_defaults() {
387        let c = Config::from_toml_str(
388            "[network]\nrelay_mode = \"disabled\"\n[limits]\nrate_limit_per_min = 60\n",
389        )
390        .unwrap();
391        assert_eq!(c.network.relay_mode, "disabled");
392        assert_eq!(c.limits.rate_limit_per_min, 60);
393        assert_eq!(c.limits.max_inflight, 16);
394    }
395
396    /// A legacy config carrying the removed `max_frame` key still loads (serde ignores unknown
397    /// fields) — the frame cap is a fixed constant now, not a tunable (see the `LimitsCfg` doc).
398    #[test]
399    fn legacy_max_frame_key_is_ignored_not_an_error() {
400        let c =
401            Config::from_toml_str("[limits]\nmax_frame = \"1MiB\"\nmax_sessions = 2\n").unwrap();
402        assert_eq!(c.limits.max_sessions, 2);
403    }
404
405    /// The self-hosting knobs parse: `custom` modes with their URL lists. (Validation —
406    /// custom-without-urls, unknown modes — lives in `daemon::net_plan`, tested there.)
407    #[test]
408    fn network_relay_and_discovery_urls_parse() {
409        let c = Config::from_toml_str(
410            "[network]\nrelay_mode = \"custom\"\nrelay_urls = [\"https://relay.acme.com\"]\n\
411             discovery_mode = \"custom\"\ndiscovery_urls = [\"https://dns.acme.com/pkarr\"]\n",
412        )
413        .unwrap();
414        assert_eq!(c.network.relay_mode, "custom");
415        assert_eq!(
416            c.network.relay_urls,
417            vec!["https://relay.acme.com".to_string()]
418        );
419        assert_eq!(c.network.discovery_mode, "custom");
420        assert_eq!(
421            c.network.discovery_urls,
422            vec!["https://dns.acme.com/pkarr".to_string()]
423        );
424        // Absent → empty lists (the defaults need no URLs).
425        let c = Config::from_toml_str("").unwrap();
426        assert!(c.network.relay_urls.is_empty() && c.network.discovery_urls.is_empty());
427    }
428
429    #[test]
430    fn missing_file_loads_defaults() {
431        let dir = tempfile::tempdir().unwrap();
432        let c = Config::load(&dir.path().join("nope.toml")).unwrap();
433        assert_eq!(c.network.relay_mode, "default");
434    }
435
436    #[test]
437    fn roster_url_and_poll_interval_parse_with_defaults() {
438        // No [roster] table → url None, poll 1h default.
439        let c = Config::from_toml_str("").unwrap();
440        assert!(c.roster.url.is_none());
441        assert_eq!(c.roster.poll_interval_seconds(), 3600);
442        // A configured url + poll interval.
443        let c = Config::from_toml_str(
444            "[roster]\nurl = \"https://intranet.acme.com/roster.json\"\npoll_interval = \"30m\"\n",
445        )
446        .unwrap();
447        assert_eq!(
448            c.roster.url.as_deref(),
449            Some("https://intranet.acme.com/roster.json")
450        );
451        assert_eq!(c.roster.poll_interval_seconds(), 30 * 60);
452        // An unparseable poll_interval falls back to the hourly default (never disables the poll).
453        let c = Config::from_toml_str("[roster]\npoll_interval = \"never\"\n").unwrap();
454        assert_eq!(c.roster.poll_interval_seconds(), 3600);
455        // The url is additive: setting only grace_period keeps url None + the default poll.
456        let c = Config::from_toml_str("[roster]\ngrace_period = \"24h\"\n").unwrap();
457        assert!(c.roster.url.is_none());
458        assert_eq!(c.roster.poll_interval_seconds(), 3600);
459    }
460
461    #[test]
462    fn roster_max_staleness_defaults_to_24h_and_parses() {
463        // No [roster] table → the 24h freshness bound (the default).
464        let c = Config::from_toml_str("").unwrap();
465        assert_eq!(c.roster.max_staleness_seconds(), 24 * 3600);
466        // A configured value parses (units, like grace_period).
467        let c = Config::from_toml_str("[roster]\nmax_staleness = \"6h\"\n").unwrap();
468        assert_eq!(c.roster.max_staleness_seconds(), 6 * 3600);
469        // An unparseable value falls back to the 24h default (never disables the freshness bound).
470        let c = Config::from_toml_str("[roster]\nmax_staleness = \"forever\"\n").unwrap();
471        assert_eq!(c.roster.max_staleness_seconds(), 24 * 3600);
472        // Additive: setting only grace_period keeps the 24h max_staleness default.
473        let c = Config::from_toml_str("[roster]\ngrace_period = \"48h\"\n").unwrap();
474        assert_eq!(c.roster.max_staleness_seconds(), 24 * 3600);
475    }
476
477    #[test]
478    fn roster_grace_defaults_to_72h_and_parses_units() {
479        // Absent `[roster]` → the 72h default.
480        let c = Config::from_toml_str("").unwrap();
481        assert_eq!(c.roster.grace_seconds(), 72 * 3600);
482        // Hours / days / minutes / seconds / bare-seconds all resolve to seconds.
483        for (body, want) in [
484            ("[roster]\ngrace_period = \"24h\"\n", 24 * 3600),
485            ("[roster]\ngrace_period = \"72h\"\n", 72 * 3600),
486            ("[roster]\ngrace_period = \"1d\"\n", 24 * 3600),
487            ("[roster]\ngrace_period = \"30m\"\n", 30 * 60),
488            ("[roster]\ngrace_period = \"90s\"\n", 90),
489            ("[roster]\ngrace_period = \"3600\"\n", 3600), // bare seconds
490        ] {
491            assert_eq!(
492                Config::from_toml_str(body).unwrap().roster.grace_seconds(),
493                want,
494                "{body}"
495            );
496        }
497    }
498
499    #[test]
500    fn roster_grace_unparseable_or_negative_falls_back_to_default() {
501        // A garbage / negative / overflowing grace never disables degraded serving — it defaults.
502        for body in [
503            "[roster]\ngrace_period = \"seventy-two hours\"\n",
504            "[roster]\ngrace_period = \"-5h\"\n",
505            "[roster]\ngrace_period = \"18446744073709551615d\"\n", // overflows the checked_mul
506            "[roster]\ngrace_period = \"\"\n",
507        ] {
508            assert_eq!(
509                Config::from_toml_str(body).unwrap().roster.grace_seconds(),
510                72 * 3600,
511                "{body}"
512            );
513        }
514    }
515
516    #[test]
517    fn services_parse_run_and_socket() {
518        let c = Config::from_toml_str(concat!(
519            "[services.notes]\nrun = [\"npx\", \"server\"]\nallow = [\"bob\"]\n",
520            "[services.kb]\nsocket = \"/run/kb.sock\"\nallow = [\"team-eng\"]\n",
521        ))
522        .unwrap();
523        let notes = c.services.get("notes").unwrap();
524        assert!(
525            matches!(notes.backend_result(), Ok(Backend::Run(cmd)) if cmd == &["npx".to_string(), "server".to_string()][..])
526        );
527        assert_eq!(notes.allow, vec!["bob".to_string()]);
528        assert!(
529            matches!(c.services.get("kb").unwrap().backend_result(), Ok(Backend::Socket(p)) if p == "/run/kb.sock")
530        );
531    }
532
533    #[test]
534    fn service_with_both_run_and_socket_is_an_error() {
535        let e = Config::from_toml_str("[services.x]\nrun=[\"a\"]\nsocket=\"/s\"\nallow=[]\n");
536        // exactly one backend kind is required — validate at access time.
537        assert!(
538            e.unwrap()
539                .services
540                .get("x")
541                .unwrap()
542                .backend_result()
543                .is_err()
544        );
545    }
546
547    #[test]
548    fn identity_reads_user_id_and_user_key() {
549        let toml = "[identity]\n\
550            org_id = \"acme\"\n\
551            org_root_pk = \"b64u:AAAA\"\n\
552            user_id = \"alice\"\n\
553            user_key = \"/home/alice/.config/mcpmesh/user.key\"\n";
554        let cfg: Config = toml::from_str(toml).unwrap();
555        assert_eq!(cfg.identity.user_id.as_deref(), Some("alice"));
556        assert_eq!(
557            cfg.identity.user_key.as_deref(),
558            Some(std::path::Path::new("/home/alice/.config/mcpmesh/user.key"))
559        );
560        // Absent → None (pure-pairing / operator-only node).
561        let bare: Config = toml::from_str("[identity]\n").unwrap();
562        assert!(bare.identity.user_id.is_none() && bare.identity.user_key.is_none());
563    }
564}