Skip to main content

kanade_shared/wire/
server_settings.rs

1use serde::{Deserialize, Serialize};
2
3use crate::config::MailSection;
4
5/// Upper bound on [`ServerSettings::agent_prune_days`] (100 years). A
6/// value this large already means "effectively never", and it keeps the
7/// cleanup task's `now - Duration::days(n)` subtraction comfortably inside
8/// `chrono::DateTime`'s representable range — `DateTime - Duration` panics
9/// on overflow, and an unbounded `u32` (~11.7 M years) would trip it.
10/// Enforced two ways: the PUT handler rejects a larger value, and
11/// [`ServerSettings::effective_agent_prune_days`] clamps to it so even a
12/// hand-written KV value can never panic the cleanup task.
13pub const MAX_AGENT_PRUNE_DAYS: u32 = 36_500;
14
15/// Built-in retention window (days) for the `collections` Object Store —
16/// the file bundles a `collect:` job uploads (#219). This is the value the
17/// bootstrap creates a fresh bucket with, and the fallback
18/// [`ServerSettings::effective_collect_retention_days`] resolves to when the
19/// operator hasn't overridden it. Kept here (not just in `bootstrap`) so the
20/// wire default and the bucket's initial `max_age` share one source of truth.
21pub const DEFAULT_COLLECT_RETENTION_DAYS: u32 = 30;
22
23/// Upper bound on [`ServerSettings::collect_retention_days`] (10 years).
24/// Bundles are debugging / audit artifacts, so an operator has no reason to
25/// keep them longer than this; the ceiling stops a fat-fingered value from
26/// pushing the `collections` Object Store's `max_age` to something absurd.
27/// (The bucket's `max_bytes` cap still bounds actual disk regardless.)
28/// Enforced by the PUT handler and clamped in
29/// [`ServerSettings::effective_collect_retention_days`] so even a
30/// hand-written KV value stays sane.
31pub const MAX_COLLECT_RETENTION_DAYS: u32 = 3650;
32
33/// Built-in default for [`ServerSettings::session_ttl_hours`] — how long a
34/// freshly-minted SPA/CLI login token stays valid when the operator hasn't
35/// overridden it. 24h balances "don't re-auth mid-workday" against a
36/// bounded window for a leaked token (the DB row is re-checked every
37/// request, so `disable` still takes effect immediately regardless).
38pub const DEFAULT_SESSION_TTL_HOURS: u32 = 24;
39
40/// Upper bound on [`ServerSettings::session_ttl_hours`] (365 days). Keeps
41/// login's `Utc::now() + Duration::hours(n)` comfortably inside `chrono`'s
42/// representable range (its nanosecond `i64` caps out ~292 years, so an
43/// unclamped `u32` of hours could overflow) and stops an operator pinning a
44/// practically-immortal session. Enforced by the PUT handler and clamped in
45/// [`ServerSettings::effective_session_ttl_hours`] so even a hand-written
46/// KV value stays bounded.
47pub const MAX_SESSION_TTL_HOURS: u32 = 8_760;
48
49/// Built-in default for [`ServerSettings::check_status_stale_days`] (#1032②) —
50/// how many days a `check_status` row may go without a fresh result before the
51/// Compliance view treats it as **stale** and hides it (a PC that stopped
52/// running a check: excluded from its target via a dynamic group, decommissioned,
53/// or its schedule removed). 30 days is comfortably longer than a typical daily
54/// / weekly compliance cadence, so an in-scope PC is never mistakenly hidden,
55/// while a genuinely out-of-scope PC's frozen row drops out within the month.
56/// A real default (like `collect_retention_days`) so the feature works out of
57/// the box; the operator shortens it for prompter hiding or sets `0` to disable.
58pub const DEFAULT_CHECK_STATUS_STALE_DAYS: u32 = 30;
59
60/// Upper bound on [`ServerSettings::check_status_stale_days`] (10 years).
61/// Staleness is purely a display cutoff (`now - Duration::days(n)` compared to a
62/// row's `recorded_at`), so the ceiling just keeps that subtraction inside
63/// `chrono::DateTime`'s range and stops an absurd value; enforced by the PUT
64/// handler and clamped in [`ServerSettings::effective_check_status_stale_days`].
65pub const MAX_CHECK_STATUS_STALE_DAYS: u32 = 3650;
66
67/// Built-in default for [`SupportCode::ttl_minutes`] — how long an unlock
68/// grant lasts when the operator hasn't set a per-code window. 15 minutes is
69/// about the length of a helpdesk call: long enough that the desk isn't
70/// re-typing the code mid-troubleshoot, short enough that a machine left
71/// unattended after the call re-locks on its own.
72pub const DEFAULT_SUPPORT_UNLOCK_TTL_MINUTES: u32 = 15;
73
74/// Upper bound on [`SupportCode::ttl_minutes`] (8 hours). An unlock grant is
75/// standing permission for an end user's machine to run privileged jobs, so
76/// it must not outlive a working day even by operator error — a longer window
77/// is a `visible_to` targeting decision, not an unlock one. Enforced by the
78/// support-code endpoint and clamped in [`SupportCode::effective_ttl_minutes`]
79/// so a hand-written KV value stays bounded too.
80pub const MAX_SUPPORT_UNLOCK_TTL_MINUTES: u32 = 480;
81
82/// One operator-issued support code — the "裏コマンド" that reveals
83/// `client.unlock`-scoped jobs in the Client App (see
84/// [`crate::manifest::ClientHint::unlock`]).
85///
86/// **Only the argon2id hash is stored.** Verification needs no plaintext, so
87/// unlike the SMTP password (#884, which must stay in the HKLM registry
88/// because SMTP AUTH needs the real string) a support code can live in KV
89/// safely — an agent reads the hash to verify a typed code locally, which is
90/// what keeps unlocking working while the backend is down, exactly when the
91/// desk needs it most.
92///
93/// The plaintext exists only in transit: the operator types it once into the
94/// SPA, the backend hashes it, and no layer ever stores or returns it. The
95/// API blanks [`hash`](Self::hash) on the way out too — an operator rotating
96/// a code sets a new one, they never read the old one back.
97#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)]
98#[serde(default)]
99pub struct SupportCode {
100    /// The scope this code opens, matched byte-for-byte against a job's
101    /// `client.unlock`. Unique within the list; a slug (`[A-Za-z0-9._-]`).
102    pub scope: String,
103    /// argon2id PHC-format hash of the code. Empty ⇒ **no code configured**
104    /// for this scope, which fails closed (nothing can be unlocked with it) —
105    /// that's also what an API response looks like, since the hash is blanked
106    /// before it leaves the backend.
107    #[serde(skip_serializing_if = "String::is_empty")]
108    pub hash: String,
109    /// Human label for the code (`"ヘルプデスク一次窓口"`), shown in the
110    /// Client App's support-mode banner so the user can see which desk opened
111    /// their machine. `None` ⇒ the banner falls back to the scope slug.
112    #[serde(skip_serializing_if = "Option::is_none")]
113    pub label: Option<String>,
114    /// How long a grant from this code lasts, in minutes. `None` ⇒
115    /// [`DEFAULT_SUPPORT_UNLOCK_TTL_MINUTES`]. Clamped to
116    /// `1..=`[`MAX_SUPPORT_UNLOCK_TTL_MINUTES`].
117    #[serde(skip_serializing_if = "Option::is_none")]
118    pub ttl_minutes: Option<u32>,
119    /// Temporarily suspend the code without deleting it (and without having
120    /// to re-issue a new secret afterwards) — a disabled code never verifies.
121    /// `false` (the default) ⇒ live.
122    #[serde(skip_serializing_if = "std::ops::Not::not")]
123    pub disabled: bool,
124}
125
126impl SupportCode {
127    /// The grant window this code mints, in minutes: the configured value if
128    /// set, else [`DEFAULT_SUPPORT_UNLOCK_TTL_MINUTES`]. Floored at 1 and
129    /// clamped to [`MAX_SUPPORT_UNLOCK_TTL_MINUTES`] so a `0` (which would
130    /// mint an already-expired grant, i.e. an unlock that silently does
131    /// nothing) or an out-of-band giant value can't reach the grant store.
132    pub fn effective_ttl_minutes(&self) -> u32 {
133        self.ttl_minutes
134            .unwrap_or(DEFAULT_SUPPORT_UNLOCK_TTL_MINUTES)
135            .clamp(1, MAX_SUPPORT_UNLOCK_TTL_MINUTES)
136    }
137
138    /// Whether this entry can ever grant anything: live and carrying a hash.
139    /// Both halves fail closed — a blanked (API-redacted) or hand-cleared
140    /// hash opens nothing, and neither does a disabled code.
141    pub fn is_usable(&self) -> bool {
142        !self.disabled && !self.hash.is_empty()
143    }
144}
145
146/// Value stored in the `server_settings` KV bucket under the single key
147/// [`crate::kv::KEY_SERVER_SETTINGS`]. Operator-editable, backend-side
148/// server configuration that isn't per-agent (so it doesn't belong in
149/// `agent_config`'s layered scopes) and isn't a fleet-wide switch every
150/// agent watches (so it doesn't belong in `fleet_config`). Managed via
151/// the SPA Settings page's "server settings" tab.
152///
153/// Every field is `Option<_>`: `None` (the default / the JSON value
154/// `null` / the field simply absent) means **unset — fall back to the
155/// built-in default** ([`ServerSettings::defaults`]), exactly like the
156/// agent layered-config scopes. The SPA renders the built-in default as a
157/// faint placeholder so a blank field shows what it resolves to, and when
158/// a real default is introduced here it appears in the UI (and takes
159/// effect for already-deployed-but-unset fleets) for free.
160///
161/// `#[serde(default)]` on the container keeps the document backward/forward
162/// compatible: a freshly-created or missing key decodes to all-`None`
163/// (pre-feature behaviour), an older backend reading a newer document
164/// ignores unknown fields, and a newer backend reading an older document
165/// fills the missing field with `None`. Keep that invariant — never add a
166/// field whose `None` doesn't mean "behave as before".
167#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)]
168#[serde(default)]
169pub struct ServerSettings {
170    /// Days a dead agent (one whose heartbeat stopped arriving) may
171    /// linger in the `agents` registry before the backend cleanup task
172    /// prunes its row.
173    ///
174    /// `None` (unset) falls back to the built-in default; with no default
175    /// configured that resolves to pruning **disabled** (see
176    /// [`ServerSettings::effective_agent_prune_days`]). A positive value
177    /// makes the cleanup sweep delete rows whose `last_heartbeat` is older
178    /// than that many days. The `agents` table is a projection of the
179    /// heartbeat stream, so a machine that's merely offline (not gone)
180    /// reappears on its next heartbeat (~30s cadence); only
181    /// genuinely-retired machines stay gone.
182    #[serde(skip_serializing_if = "Option::is_none")]
183    pub agent_prune_days: Option<u32>,
184
185    /// Agent group whose members are the trusted **controller-tier**
186    /// runners. A job with `tier: controller` (e.g. a `feed:` job that
187    /// fetches an external URL) is dispatched ONLY to members of this group;
188    /// `None` (unset) means controller-tier jobs run **nowhere** (fail-safe,
189    /// so an external fetch never lands on an employee endpoint by accident).
190    /// See `crate::manifest::Tier`. `None` ⇒ no controller runners
191    /// configured, which is the safe default for a fresh deployment.
192    #[serde(skip_serializing_if = "Option::is_none")]
193    pub controller_group: Option<String>,
194
195    /// Non-secret SMTP relay settings for outbound email (compliance-alert
196    /// notifications, account setup links, …). Lives here (#884) rather than
197    /// in `backend.toml` so an operator can edit it from the SPA without
198    /// shell access to the host.
199    ///
200    /// `None` (unset) ⇒ no relay configured, email is a no-op — the in-app
201    /// / NATS notification path is unaffected. The SMTP **password is
202    /// deliberately not here** (KV is
203    /// readable over NATS): it stays sourced from the `MailPassword`
204    /// registry secret / `$KANADE_MAIL_PASSWORD` and is combined with these
205    /// settings when the backend builds its `Mailer`. Changes take effect on
206    /// the **next backend restart** (the backend builds the `Mailer` once at
207    /// startup — no live rebuild), which is acceptable per the #884
208    /// discussion.
209    #[serde(skip_serializing_if = "Option::is_none")]
210    pub mail: Option<MailSection>,
211
212    /// Retention window (days) for collected file bundles — the `collect:`
213    /// job archives uploaded to the `collections` Object Store (#219).
214    ///
215    /// Unlike the other fields, this one has a **real built-in default**
216    /// ([`DEFAULT_COLLECT_RETENTION_DAYS`], 30 d): `None` (unset) falls back
217    /// to it, so a blank field preserves the historical behaviour rather than
218    /// disabling retention. A positive value tells the backend to reconcile
219    /// the Object Store's `max_age` to that many days (see
220    /// `bootstrap::reconcile_collect_retention`), applied at boot and again
221    /// whenever this document is saved from the SPA. Clamped to
222    /// [`MAX_COLLECT_RETENTION_DAYS`]. The bucket's `max_bytes` cap is
223    /// untouched, so extending the window can't make the store grow unbounded.
224    #[serde(skip_serializing_if = "Option::is_none")]
225    pub collect_retention_days: Option<u32>,
226
227    /// How many hours a freshly-minted login token (SPA / CLI) stays valid
228    /// before the caller must re-authenticate. Read backend-side by the
229    /// login handler when it mints the JWT `exp`; changing it affects
230    /// **tokens minted after the change**, not already-issued ones.
231    ///
232    /// Like [`collect_retention_days`](Self::collect_retention_days) this
233    /// carries a **real built-in default** ([`DEFAULT_SESSION_TTL_HOURS`],
234    /// 24h): `None` (unset) falls back to it, so a blank field resolves to a
235    /// usable window rather than an instantly-expired token. Clamped to
236    /// `1..=`[`MAX_SESSION_TTL_HOURS`]. The SPA renders `24` as a faint
237    /// placeholder on a blank field.
238    #[serde(skip_serializing_if = "Option::is_none")]
239    pub session_ttl_hours: Option<u32>,
240
241    /// Days a `check_status` row may go without a fresh result before the
242    /// Compliance page treats it as **stale** and hides it (#1032②). A PC that
243    /// stops running a check — excluded from its target via a dynamic group,
244    /// decommissioned, or its schedule removed — stops refreshing its row's
245    /// `recorded_at`; once that timestamp is older than this window the row is
246    /// omitted from the default `/api/checks` view and its counts, so a machine
247    /// no longer in scope stops showing as permanently failing a check it never
248    /// runs.
249    ///
250    /// Like [`collect_retention_days`](Self::collect_retention_days) this carries
251    /// a **real built-in default** ([`DEFAULT_CHECK_STATUS_STALE_DAYS`], 30d), so
252    /// the feature works with no configuration. `0` **disables** staleness
253    /// (every row shown, the pre-feature behaviour); a positive value is the
254    /// cutoff, clamped to [`MAX_CHECK_STATUS_STALE_DAYS`]. The row is never
255    /// deleted — hiding is non-destructive so history and the compliance-alert
256    /// prior-status are preserved.
257    #[serde(skip_serializing_if = "Option::is_none")]
258    pub check_status_stale_days: Option<u32>,
259
260    /// Operator-issued support codes — the helpdesk "裏コマンド" that reveals
261    /// `client.unlock`-scoped jobs in the Client App. See [`SupportCode`].
262    ///
263    /// The one non-`Option` field in this document, because a list has an
264    /// honest empty state: `[]` (the default) means **no codes configured**,
265    /// so every `client.unlock` job stays hidden from everyone — the same
266    /// fail-closed "behave as before" the `None`s give the other fields.
267    ///
268    /// Unlike the rest of the document this is read by **agents** as well as
269    /// the backend (each agent verifies a typed code against these hashes
270    /// locally, so an unlock still works during a backend outage), and it is
271    /// **not** editable through the generic `PUT /api/server-settings` merge:
272    /// a secret gets its own set-and-forget endpoint so a redacted document
273    /// round-tripping through the SPA form can never blank a live code.
274    #[serde(skip_serializing_if = "Vec::is_empty")]
275    pub support_codes: Vec<SupportCode>,
276}
277
278impl ServerSettings {
279    /// Built-in defaults applied when a field is unset (`None`) in the
280    /// stored document. Most fields default to `None` (no fleet-meaningful
281    /// default — a blank prune window means "disabled" rather than some
282    /// arbitrary number of days). The exceptions carry real defaults so a
283    /// blank field still resolves to a sensible value:
284    /// [`collect_retention_days`](Self::collect_retention_days)
285    /// ([`DEFAULT_COLLECT_RETENTION_DAYS`], preserving the historical 30-day
286    /// retention) and [`session_ttl_hours`](Self::session_ttl_hours)
287    /// ([`DEFAULT_SESSION_TTL_HOURS`], 24h).
288    ///
289    /// Exposed via `GET /api/server-settings/defaults` so the SPA renders
290    /// these as faint placeholders (mirroring the agent layered-config
291    /// page's built-in floor). Introducing a real default is a one-line
292    /// change here that automatically shows up in the UI and applies to
293    /// every deployment that hasn't overridden the field.
294    pub fn defaults() -> Self {
295        Self {
296            agent_prune_days: None,
297            controller_group: None,
298            mail: None,
299            collect_retention_days: Some(DEFAULT_COLLECT_RETENTION_DAYS),
300            session_ttl_hours: Some(DEFAULT_SESSION_TTL_HOURS),
301            check_status_stale_days: Some(DEFAULT_CHECK_STATUS_STALE_DAYS),
302            // No built-in code: a deployment that configures none has no
303            // unlockable jobs, which is the only safe default for a secret.
304            support_codes: Vec::new(),
305        }
306    }
307
308    /// The live code for `scope`, or `None` when the scope has no code, its
309    /// code is disabled, or the hash was blanked (an API-redacted document).
310    /// Every caller of this is a gate, so all three cases fail closed.
311    pub fn support_code(&self, scope: &str) -> Option<&SupportCode> {
312        self.support_codes
313            .iter()
314            .find(|c| c.scope == scope && c.is_usable())
315    }
316
317    /// The same document with every support-code hash blanked — what an HTTP
318    /// response is allowed to contain. Scope / label / TTL stay visible so
319    /// the SPA can list and manage the codes; the secret material never
320    /// leaves the backend, not even to an operator-authenticated caller.
321    /// (`GET /api/server-settings` is viewer+, so an unredacted document
322    /// would hand every read-only account an offline-crackable hash.)
323    #[must_use]
324    pub fn redacted(mut self) -> Self {
325        for c in &mut self.support_codes {
326            c.hash.clear();
327        }
328        self
329    }
330
331    /// The configured controller-tier runner group, trimmed, or `None` when
332    /// unset / blank. `None` ⇒ controller-tier jobs run nowhere (fail-safe).
333    pub fn effective_controller_group(&self) -> Option<&str> {
334        self.controller_group
335            .as_deref()
336            .map(str::trim)
337            .filter(|g| !g.is_empty())
338    }
339
340    /// The effective dead-agent prune window in days: the stored value if
341    /// set, else the built-in default, else `0` (= pruning disabled). The
342    /// final `unwrap_or(0)` is the absent-everywhere floor, not a
343    /// user-facing default — the cleanup task treats `0` as "don't prune".
344    ///
345    /// Clamped to [`MAX_AGENT_PRUNE_DAYS`] so the cleanup task's
346    /// `now - Duration::days(n)` can never overflow `DateTime` (and panic
347    /// the task), even if a value larger than the PUT handler allows was
348    /// written to the KV out-of-band.
349    pub fn effective_agent_prune_days(&self) -> u32 {
350        self.agent_prune_days
351            .or(Self::defaults().agent_prune_days)
352            .unwrap_or(0)
353            .min(MAX_AGENT_PRUNE_DAYS)
354    }
355
356    /// The effective collect-bundle retention window in days: the stored
357    /// value if set, else the built-in default ([`DEFAULT_COLLECT_RETENTION_DAYS`]).
358    /// Floored at 1 and clamped to [`MAX_COLLECT_RETENTION_DAYS`] so an
359    /// out-of-band KV write can't reach the Object Store `max_age` unsanitised.
360    /// The floor matters specifically because NATS treats `max_age: 0` as
361    /// **unlimited** retention, not "evict immediately": a stray `0` would
362    /// silently make bundles never expire (defeating the auto-expire intent),
363    /// so we coerce it to the shortest real window (1 day) instead. The PUT
364    /// handler already rejects `0` / over-cap, so this is the belt-and-braces
365    /// path for a hand-edited KV value.
366    pub fn effective_collect_retention_days(&self) -> u32 {
367        self.collect_retention_days
368            .or(Self::defaults().collect_retention_days)
369            .unwrap_or(DEFAULT_COLLECT_RETENTION_DAYS)
370            .clamp(1, MAX_COLLECT_RETENTION_DAYS)
371    }
372
373    /// The effective login-token lifetime in hours: the stored value if
374    /// set, else the built-in [`DEFAULT_SESSION_TTL_HOURS`]. Floored at 1
375    /// and clamped to [`MAX_SESSION_TTL_HOURS`] so a zero/absent/out-of-band
376    /// value can never mint an already-expired or overflow-inducing token.
377    /// The PUT handler already rejects `0` / over-cap, so this is the
378    /// belt-and-braces path for a hand-edited KV value.
379    pub fn effective_session_ttl_hours(&self) -> u32 {
380        self.session_ttl_hours
381            .or(Self::defaults().session_ttl_hours)
382            .unwrap_or(DEFAULT_SESSION_TTL_HOURS)
383            .clamp(1, MAX_SESSION_TTL_HOURS)
384    }
385
386    /// The effective check-staleness window in days: the stored value if set,
387    /// else the built-in [`DEFAULT_CHECK_STATUS_STALE_DAYS`]. **`0` means
388    /// disabled** (no row is ever hidden as stale) — deliberately NOT floored to
389    /// 1 (unlike collect/session), because 0 is a meaningful "show everything"
390    /// value here, the same convention as [`agent_prune_days`](Self::agent_prune_days).
391    /// Clamped to [`MAX_CHECK_STATUS_STALE_DAYS`] so an out-of-band KV write
392    /// can't overflow the `now - Duration::days(n)` cutoff math.
393    pub fn effective_check_status_stale_days(&self) -> u32 {
394        self.check_status_stale_days
395            .or(Self::defaults().check_status_stale_days)
396            .unwrap_or(DEFAULT_CHECK_STATUS_STALE_DAYS)
397            .min(MAX_CHECK_STATUS_STALE_DAYS)
398    }
399}
400
401#[cfg(test)]
402mod tests {
403    use super::*;
404
405    #[test]
406    fn default_is_unset() {
407        assert_eq!(ServerSettings::default().agent_prune_days, None);
408    }
409
410    #[test]
411    fn unset_resolves_to_disabled() {
412        // No stored value + no built-in default ⇒ effective 0 (disabled).
413        assert_eq!(ServerSettings::default().effective_agent_prune_days(), 0);
414    }
415
416    #[test]
417    fn stored_value_wins_over_default() {
418        let s = ServerSettings {
419            agent_prune_days: Some(30),
420            ..Default::default()
421        };
422        assert_eq!(s.effective_agent_prune_days(), 30);
423    }
424
425    #[test]
426    fn effective_clamps_to_max() {
427        // An out-of-band KV write larger than the PUT cap must not reach
428        // the cleanup task unclamped (else its DateTime subtraction panics).
429        let s = ServerSettings {
430            agent_prune_days: Some(u32::MAX),
431            ..Default::default()
432        };
433        assert_eq!(s.effective_agent_prune_days(), MAX_AGENT_PRUNE_DAYS);
434    }
435
436    #[test]
437    fn round_trips_through_json() {
438        let s = ServerSettings {
439            agent_prune_days: Some(30),
440            ..Default::default()
441        };
442        let json = serde_json::to_string(&s).unwrap();
443        assert_eq!(json, r#"{"agent_prune_days":30}"#);
444        let back: ServerSettings = serde_json::from_str(&json).unwrap();
445        assert_eq!(back, s);
446    }
447
448    #[test]
449    fn unset_serialises_to_empty_object() {
450        // `skip_serializing_if` keeps an all-unset doc minimal; it must
451        // round-trip back to all-`None`.
452        let s = ServerSettings::default();
453        let json = serde_json::to_string(&s).unwrap();
454        assert_eq!(json, "{}");
455        let back: ServerSettings = serde_json::from_str(&json).unwrap();
456        assert_eq!(back, s);
457    }
458
459    #[test]
460    fn explicit_null_decodes_to_unset() {
461        let s: ServerSettings = serde_json::from_str(r#"{"agent_prune_days":null}"#).unwrap();
462        assert_eq!(s.agent_prune_days, None);
463    }
464
465    #[test]
466    fn empty_object_decodes_to_default() {
467        // A freshly-created key (or one written by an older backend that
468        // didn't know this field) must read back as the pre-feature
469        // behaviour, not fail to decode.
470        let s: ServerSettings = serde_json::from_str("{}").unwrap();
471        assert_eq!(s, ServerSettings::default());
472    }
473
474    #[test]
475    fn controller_group_effective_trims_and_blank_is_unset() {
476        assert_eq!(ServerSettings::default().effective_controller_group(), None);
477        let s = ServerSettings {
478            controller_group: Some("  feed-runners ".into()),
479            ..Default::default()
480        };
481        assert_eq!(s.effective_controller_group(), Some("feed-runners"));
482        // A blank/whitespace value reads as unset (fail-safe: no runner).
483        let blank = ServerSettings {
484            controller_group: Some("   ".into()),
485            ..Default::default()
486        };
487        assert_eq!(blank.effective_controller_group(), None);
488    }
489
490    #[test]
491    fn controller_group_round_trips_and_omits_when_unset() {
492        let s = ServerSettings {
493            controller_group: Some("infra".into()),
494            ..Default::default()
495        };
496        let json = serde_json::to_string(&s).unwrap();
497        assert_eq!(json, r#"{"controller_group":"infra"}"#);
498        assert_eq!(serde_json::from_str::<ServerSettings>(&json).unwrap(), s);
499        // Unset controller_group is omitted (skip_serializing_if).
500        assert_eq!(
501            serde_json::to_string(&ServerSettings::default()).unwrap(),
502            "{}"
503        );
504    }
505
506    #[test]
507    fn mail_round_trips_and_omits_when_unset() {
508        use crate::config::{MailEncryption, MailSection};
509
510        // Unset mail is omitted (skip_serializing_if) — a mail-less doc
511        // stays minimal and decodes back to `None`.
512        assert_eq!(
513            serde_json::to_string(&ServerSettings::default()).unwrap(),
514            "{}"
515        );
516
517        let s = ServerSettings {
518            mail: Some(MailSection {
519                host: "smtp.example.com".into(),
520                port: 587,
521                encryption: MailEncryption::Starttls,
522                from: "kanade-noreply@example.com".into(),
523                username: Some("kanade-noreply".into()),
524            }),
525            ..Default::default()
526        };
527        let json = serde_json::to_string(&s).unwrap();
528        // Encryption serialises lowercase; the password is never present.
529        assert!(json.contains(r#""encryption":"starttls""#), "json: {json}");
530        assert!(!json.contains("password"), "password must never serialise");
531        assert_eq!(serde_json::from_str::<ServerSettings>(&json).unwrap(), s);
532    }
533
534    #[test]
535    fn mail_defaults_to_unset() {
536        assert_eq!(ServerSettings::default().mail, None);
537        // A doc written before this field existed (no `mail` key) decodes
538        // to `None` — email stays a no-op, the pre-feature behaviour.
539        let s: ServerSettings = serde_json::from_str(r#"{"agent_prune_days":7}"#).unwrap();
540        assert_eq!(s.mail, None);
541        assert_eq!(s.agent_prune_days, Some(7));
542    }
543
544    #[test]
545    fn collect_retention_unset_resolves_to_builtin_default() {
546        // Blank (the derived Default) must preserve the historical 30-day
547        // window, not disable retention.
548        assert_eq!(ServerSettings::default().collect_retention_days, None);
549        assert_eq!(
550            ServerSettings::default().effective_collect_retention_days(),
551            DEFAULT_COLLECT_RETENTION_DAYS,
552        );
553        // The defaults() document surfaces the real default so the SPA can
554        // render it as a placeholder.
555        assert_eq!(
556            ServerSettings::defaults().collect_retention_days,
557            Some(DEFAULT_COLLECT_RETENTION_DAYS),
558        );
559    }
560
561    #[test]
562    fn collect_retention_stored_value_wins() {
563        let s = ServerSettings {
564            collect_retention_days: Some(90),
565            ..Default::default()
566        };
567        assert_eq!(s.effective_collect_retention_days(), 90);
568    }
569
570    #[test]
571    fn collect_retention_effective_clamps_out_of_band_writes() {
572        // A hand-written KV value past the PUT cap (or 0) must be clamped so
573        // the reconciled Object Store max_age stays sane.
574        let big = ServerSettings {
575            collect_retention_days: Some(u32::MAX),
576            ..Default::default()
577        };
578        assert_eq!(
579            big.effective_collect_retention_days(),
580            MAX_COLLECT_RETENTION_DAYS,
581        );
582        let zero = ServerSettings {
583            collect_retention_days: Some(0),
584            ..Default::default()
585        };
586        assert_eq!(zero.effective_collect_retention_days(), 1);
587    }
588
589    #[test]
590    fn collect_retention_round_trips_and_omits_when_unset() {
591        let s = ServerSettings {
592            collect_retention_days: Some(90),
593            ..Default::default()
594        };
595        let json = serde_json::to_string(&s).unwrap();
596        assert_eq!(json, r#"{"collect_retention_days":90}"#);
597        assert_eq!(serde_json::from_str::<ServerSettings>(&json).unwrap(), s);
598        // Unset is omitted so a blank doc stays minimal.
599        assert!(
600            !serde_json::to_string(&ServerSettings::default())
601                .unwrap()
602                .contains("collect_retention_days")
603        );
604    }
605
606    #[test]
607    fn session_ttl_unset_resolves_to_builtin_default() {
608        // Blank (the derived Default) must resolve to the 24h default rather
609        // than 0 (which would mint already-expired tokens).
610        assert_eq!(ServerSettings::default().session_ttl_hours, None);
611        assert_eq!(
612            ServerSettings::default().effective_session_ttl_hours(),
613            DEFAULT_SESSION_TTL_HOURS,
614        );
615        // The defaults() document surfaces the real default so the SPA can
616        // render it as a placeholder.
617        assert_eq!(
618            ServerSettings::defaults().session_ttl_hours,
619            Some(DEFAULT_SESSION_TTL_HOURS),
620        );
621    }
622
623    #[test]
624    fn session_ttl_stored_value_wins() {
625        let s = ServerSettings {
626            session_ttl_hours: Some(72),
627            ..Default::default()
628        };
629        assert_eq!(s.effective_session_ttl_hours(), 72);
630    }
631
632    #[test]
633    fn session_ttl_effective_clamps_out_of_band_writes() {
634        // An out-of-band 0 must floor to 1 (else `now + 0h` is an
635        // instantly-expired token); a value past the cap clamps down so
636        // login's date math can't overflow.
637        let zero = ServerSettings {
638            session_ttl_hours: Some(0),
639            ..Default::default()
640        };
641        assert_eq!(zero.effective_session_ttl_hours(), 1);
642        let huge = ServerSettings {
643            session_ttl_hours: Some(u32::MAX),
644            ..Default::default()
645        };
646        assert_eq!(huge.effective_session_ttl_hours(), MAX_SESSION_TTL_HOURS);
647    }
648
649    #[test]
650    fn session_ttl_round_trips_and_omits_when_unset() {
651        let s = ServerSettings {
652            session_ttl_hours: Some(48),
653            ..Default::default()
654        };
655        let json = serde_json::to_string(&s).unwrap();
656        assert_eq!(json, r#"{"session_ttl_hours":48}"#);
657        assert_eq!(serde_json::from_str::<ServerSettings>(&json).unwrap(), s);
658        // Unset is omitted so a blank doc stays minimal.
659        assert!(
660            !serde_json::to_string(&ServerSettings::default())
661                .unwrap()
662                .contains("session_ttl_hours")
663        );
664    }
665
666    #[test]
667    fn check_stale_unset_resolves_to_builtin_default() {
668        // Blank (the derived Default) resolves to the 30-day default — ON out
669        // of the box, so the feature works without configuration.
670        assert_eq!(ServerSettings::default().check_status_stale_days, None);
671        assert_eq!(
672            ServerSettings::default().effective_check_status_stale_days(),
673            DEFAULT_CHECK_STATUS_STALE_DAYS,
674        );
675        // defaults() surfaces the real default for the SPA placeholder.
676        assert_eq!(
677            ServerSettings::defaults().check_status_stale_days,
678            Some(DEFAULT_CHECK_STATUS_STALE_DAYS),
679        );
680    }
681
682    #[test]
683    fn check_stale_zero_disables() {
684        // Explicit 0 means "disable staleness" (show everything) — NOT floored
685        // to 1 like collect/session; same convention as agent_prune_days.
686        let s = ServerSettings {
687            check_status_stale_days: Some(0),
688            ..Default::default()
689        };
690        assert_eq!(s.effective_check_status_stale_days(), 0);
691    }
692
693    #[test]
694    fn check_stale_stored_value_wins_and_clamps() {
695        let s = ServerSettings {
696            check_status_stale_days: Some(7),
697            ..Default::default()
698        };
699        assert_eq!(s.effective_check_status_stale_days(), 7);
700        let big = ServerSettings {
701            check_status_stale_days: Some(u32::MAX),
702            ..Default::default()
703        };
704        assert_eq!(
705            big.effective_check_status_stale_days(),
706            MAX_CHECK_STATUS_STALE_DAYS,
707        );
708    }
709
710    #[test]
711    fn check_stale_round_trips_and_omits_when_unset() {
712        let s = ServerSettings {
713            check_status_stale_days: Some(14),
714            ..Default::default()
715        };
716        let json = serde_json::to_string(&s).unwrap();
717        assert_eq!(json, r#"{"check_status_stale_days":14}"#);
718        assert_eq!(serde_json::from_str::<ServerSettings>(&json).unwrap(), s);
719        assert!(
720            !serde_json::to_string(&ServerSettings::default())
721                .unwrap()
722                .contains("check_status_stale_days")
723        );
724    }
725
726    #[test]
727    fn support_codes_absent_by_default_and_omitted_from_the_wire() {
728        // The pre-feature document must round-trip byte-identically: no
729        // `support_codes` key, so an older backend / agent reading it sees
730        // exactly what it saw before, and nothing is unlockable.
731        let s = ServerSettings::default();
732        assert!(s.support_codes.is_empty());
733        assert_eq!(serde_json::to_string(&s).unwrap(), "{}");
734        assert!(s.support_code("support").is_none());
735    }
736
737    #[test]
738    fn support_code_lookup_fails_closed() {
739        let s = ServerSettings {
740            support_codes: vec![
741                SupportCode {
742                    scope: "support".into(),
743                    hash: "$argon2id$v=19$m=19456,t=2,p=1$c2FsdA$aGFzaA".into(),
744                    label: Some("ヘルプデスク".into()),
745                    ..Default::default()
746                },
747                SupportCode {
748                    scope: "admin".into(),
749                    hash: "$argon2id$v=19$m=19456,t=2,p=1$c2FsdA$aGFzaA".into(),
750                    disabled: true,
751                    ..Default::default()
752                },
753                SupportCode {
754                    // Hash blanked — what an API-redacted document looks like.
755                    // It must never be treated as a live code.
756                    scope: "blank".into(),
757                    ..Default::default()
758                },
759            ],
760            ..Default::default()
761        };
762        assert!(s.support_code("support").is_some());
763        assert!(s.support_code("admin").is_none(), "disabled must not match");
764        assert!(
765            s.support_code("blank").is_none(),
766            "blank hash must not match"
767        );
768        assert!(s.support_code("nope").is_none());
769    }
770
771    #[test]
772    fn redacted_blanks_hashes_but_keeps_the_roster() {
773        // The SPA still needs to see WHICH scopes exist (to list / rotate /
774        // delete them); it must never see the hash.
775        let s = ServerSettings {
776            support_codes: vec![SupportCode {
777                scope: "support".into(),
778                hash: "$argon2id$secret".into(),
779                label: Some("ヘルプデスク".into()),
780                ttl_minutes: Some(30),
781                disabled: false,
782            }],
783            ..Default::default()
784        };
785        let json = serde_json::to_string(&s.redacted()).unwrap();
786        assert!(!json.contains("argon2"), "wire leaked the hash: {json}");
787        assert!(!json.contains("hash"), "wire leaked the hash key: {json}");
788        assert!(json.contains("\"scope\":\"support\""), "wire: {json}");
789        assert!(json.contains("\"ttl_minutes\":30"), "wire: {json}");
790    }
791
792    #[test]
793    fn support_code_ttl_clamps() {
794        let unset = SupportCode::default();
795        assert_eq!(
796            unset.effective_ttl_minutes(),
797            DEFAULT_SUPPORT_UNLOCK_TTL_MINUTES
798        );
799        // 0 would mint an already-expired grant (an unlock that silently
800        // does nothing) — floored to the shortest real window instead.
801        let zero = SupportCode {
802            ttl_minutes: Some(0),
803            ..Default::default()
804        };
805        assert_eq!(zero.effective_ttl_minutes(), 1);
806        let huge = SupportCode {
807            ttl_minutes: Some(u32::MAX),
808            ..Default::default()
809        };
810        assert_eq!(huge.effective_ttl_minutes(), MAX_SUPPORT_UNLOCK_TTL_MINUTES);
811    }
812
813    #[test]
814    fn accepts_unknown_fields_for_forward_compat() {
815        // A newer backend may have added knobs this build doesn't know;
816        // decoding must drop them rather than error.
817        let json = r#"{"agent_prune_days":7,"some_future_knob":true}"#;
818        let s: ServerSettings = serde_json::from_str(json).unwrap();
819        assert_eq!(s.agent_prune_days, Some(7));
820    }
821}