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/// Per-bucket disk caps (MiB) for the five NATS Object Stores, reconciled
83/// onto each backing `OBJ_*` stream at backend boot and on save (#1247).
84/// `None` (unset) ⇒ the built-in default below, so a blank SPA field
85/// preserves the out-of-box budget and a deployment that never opens the
86/// Settings page is still capped.
87///
88/// Why these exist: `agent_releases` / `app_packages` / `scripts` were
89/// created with no caps at all, and caps added to code AFTER a bucket
90/// existed never reached the live broker (`ensure_object_store` tolerates
91/// the 10058 config-drift error rather than reconciling — which is how
92/// `OBJ_result_output` grew to 6.76 GB against a nominal 1 GiB cap).
93/// The reconcile path (`bootstrap::reconcile_object_store_max_bytes`) makes
94/// the configured value actually land, so these defaults are also the
95/// **fix** for already-drifted buckets, applied on the first boot after
96/// upgrade.
97#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)]
98#[serde(default)]
99pub struct ObjectStoreCaps {
100    /// `result_output` — overflow stdout/stderr blobs. Transport +
101    /// replay buffer (the projector derefs within seconds into SQLite),
102    /// so this is a runaway-output backstop, not a history budget.
103    #[serde(skip_serializing_if = "Option::is_none")]
104    pub result_output_mib: Option<u32>,
105    /// `agent_releases` — one exe per version (~100 MB each). Sized for
106    /// ~20 recent versions.
107    #[serde(skip_serializing_if = "Option::is_none")]
108    pub agent_releases_mib: Option<u32>,
109    /// `app_packages` — operator-curated installers; the largest bucket
110    /// in practice (1.5 GB at measurement time, uncapped).
111    #[serde(skip_serializing_if = "Option::is_none")]
112    pub app_packages_mib: Option<u32>,
113    /// `scripts` — manifest script bodies; tiny payloads, so the cap is
114    /// a cardinality backstop.
115    #[serde(skip_serializing_if = "Option::is_none")]
116    pub scripts_mib: Option<u32>,
117    /// `collections` — collect-job bundles. Its `max_age` has its own
118    /// knob ([`ServerSettings::collect_retention_days`]); this caps the
119    /// disk regardless of window.
120    #[serde(skip_serializing_if = "Option::is_none")]
121    pub collections_mib: Option<u32>,
122}
123
124/// Built-in per-bucket caps (MiB) — the values fresh buckets are born
125/// with and unset SPA fields resolve to. Total ≈ 13.5 GiB, comfortably
126/// inside the broker-wide `max_file_store: 50GB` once the ~5.3 GiB of
127/// stream reservations (bootstrap.rs) are accounted for.
128pub const DEFAULT_RESULT_OUTPUT_CAP_MIB: u32 = 1024;
129pub const DEFAULT_AGENT_RELEASES_CAP_MIB: u32 = 2048;
130pub const DEFAULT_APP_PACKAGES_CAP_MIB: u32 = 5120;
131pub const DEFAULT_SCRIPTS_CAP_MIB: u32 = 256;
132pub const DEFAULT_COLLECTIONS_CAP_MIB: u32 = 5120;
133
134/// Upper bound per bucket (50 GiB) — the broker-wide `max_file_store`
135/// default, so a single bucket can never be configured to eat the whole
136/// file store by fat-finger. Enforced by the PUT handler and clamped in
137/// the `effective_*` accessors so a hand-written KV value stays bounded.
138pub const MAX_OBJECT_STORE_CAP_MIB: u32 = 51_200;
139
140/// Upper bound on the SUM of the five effective bucket caps (45.2 GiB):
141/// the broker-wide 50 GiB minus the ~4.8 GiB the streams reserve
142/// (INVENTORY 1024 + RESULTS 2048 + EXEC 64 + EVENTS 256 + AUDIT 512 +
143/// OBS_EVENTS 512 + NOTIFICATIONS 512 MiB — bootstrap.rs). Without an
144/// aggregate bound, five individually-legal caps could total 250 GiB and
145/// every `update_stream` would be refused by the broker (10047) — the KV
146/// document claiming caps the streams don't have. Enforced by the PUT
147/// handler on the merged document.
148pub const MAX_OBJECT_STORE_TOTAL_MIB: u32 = 46_272;
149
150impl ObjectStoreCaps {
151    fn effective(v: Option<u32>, default: u32) -> u32 {
152        v.unwrap_or(default).clamp(1, MAX_OBJECT_STORE_CAP_MIB)
153    }
154
155    pub fn effective_result_output_mib(&self) -> u32 {
156        Self::effective(self.result_output_mib, DEFAULT_RESULT_OUTPUT_CAP_MIB)
157    }
158    pub fn effective_agent_releases_mib(&self) -> u32 {
159        Self::effective(self.agent_releases_mib, DEFAULT_AGENT_RELEASES_CAP_MIB)
160    }
161    pub fn effective_app_packages_mib(&self) -> u32 {
162        Self::effective(self.app_packages_mib, DEFAULT_APP_PACKAGES_CAP_MIB)
163    }
164    pub fn effective_scripts_mib(&self) -> u32 {
165        Self::effective(self.scripts_mib, DEFAULT_SCRIPTS_CAP_MIB)
166    }
167    pub fn effective_collections_mib(&self) -> u32 {
168        Self::effective(self.collections_mib, DEFAULT_COLLECTIONS_CAP_MIB)
169    }
170
171    /// `(bucket, effective cap in MiB)` for every object store, in
172    /// bootstrap order — the reconcile loop's input.
173    pub fn effective_all(&self) -> [(&'static str, u32); 5] {
174        [
175            (
176                crate::kv::OBJECT_AGENT_RELEASES,
177                self.effective_agent_releases_mib(),
178            ),
179            (
180                crate::kv::OBJECT_APP_PACKAGES,
181                self.effective_app_packages_mib(),
182            ),
183            (crate::kv::OBJECT_SCRIPTS, self.effective_scripts_mib()),
184            (
185                crate::kv::OBJECT_RESULT_OUTPUT,
186                self.effective_result_output_mib(),
187            ),
188            (
189                crate::kv::OBJECT_COLLECTIONS,
190                self.effective_collections_mib(),
191            ),
192        ]
193    }
194}
195
196/// One operator-issued support code — the "裏コマンド" that reveals
197/// `client.unlock`-scoped jobs in the Client App (see
198/// [`crate::manifest::ClientHint::unlock`]).
199///
200/// **Only the argon2id hash is stored.** Verification needs no plaintext, so
201/// unlike the SMTP password (#884, which must stay in the HKLM registry
202/// because SMTP AUTH needs the real string) a support code can live in KV
203/// safely — an agent reads the hash to verify a typed code locally, which is
204/// what keeps unlocking working while the backend is down, exactly when the
205/// desk needs it most.
206///
207/// The plaintext exists only in transit: the operator types it once into the
208/// SPA, the backend hashes it, and no layer ever stores or returns it. The
209/// API blanks [`hash`](Self::hash) on the way out too — an operator rotating
210/// a code sets a new one, they never read the old one back.
211#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)]
212#[serde(default)]
213pub struct SupportCode {
214    /// The scope this code opens, matched byte-for-byte against a job's
215    /// `client.unlock`. Unique within the list; a slug (`[A-Za-z0-9._-]`).
216    pub scope: String,
217    /// argon2id PHC-format hash of the code. Empty ⇒ **no code configured**
218    /// for this scope, which fails closed (nothing can be unlocked with it) —
219    /// that's also what an API response looks like, since the hash is blanked
220    /// before it leaves the backend.
221    #[serde(skip_serializing_if = "String::is_empty")]
222    pub hash: String,
223    /// Human label for the code (`"ヘルプデスク一次窓口"`), shown in the
224    /// Client App's support-mode banner so the user can see which desk opened
225    /// their machine. `None` ⇒ the banner falls back to the scope slug.
226    #[serde(skip_serializing_if = "Option::is_none")]
227    pub label: Option<String>,
228    /// How long a grant from this code lasts, in minutes. `None` ⇒
229    /// [`DEFAULT_SUPPORT_UNLOCK_TTL_MINUTES`]. Clamped to
230    /// `1..=`[`MAX_SUPPORT_UNLOCK_TTL_MINUTES`].
231    #[serde(skip_serializing_if = "Option::is_none")]
232    pub ttl_minutes: Option<u32>,
233    /// Temporarily suspend the code without deleting it (and without having
234    /// to re-issue a new secret afterwards) — a disabled code never verifies.
235    /// `false` (the default) ⇒ live.
236    #[serde(skip_serializing_if = "std::ops::Not::not")]
237    pub disabled: bool,
238}
239
240impl SupportCode {
241    /// The grant window this code mints, in minutes: the configured value if
242    /// set, else [`DEFAULT_SUPPORT_UNLOCK_TTL_MINUTES`]. Floored at 1 and
243    /// clamped to [`MAX_SUPPORT_UNLOCK_TTL_MINUTES`] so a `0` (which would
244    /// mint an already-expired grant, i.e. an unlock that silently does
245    /// nothing) or an out-of-band giant value can't reach the grant store.
246    pub fn effective_ttl_minutes(&self) -> u32 {
247        self.ttl_minutes
248            .unwrap_or(DEFAULT_SUPPORT_UNLOCK_TTL_MINUTES)
249            .clamp(1, MAX_SUPPORT_UNLOCK_TTL_MINUTES)
250    }
251
252    /// Whether this entry can ever grant anything: live and carrying a hash.
253    /// Both halves fail closed — a blanked (API-redacted) or hand-cleared
254    /// hash opens nothing, and neither does a disabled code.
255    pub fn is_usable(&self) -> bool {
256        !self.disabled && !self.hash.is_empty()
257    }
258}
259
260/// Value stored in the `server_settings` KV bucket under the single key
261/// [`crate::kv::KEY_SERVER_SETTINGS`]. Operator-editable, backend-side
262/// server configuration that isn't per-agent (so it doesn't belong in
263/// `agent_config`'s layered scopes) and isn't a fleet-wide switch every
264/// agent watches (so it doesn't belong in `fleet_config`). Managed via
265/// the SPA Settings page's "server settings" tab.
266///
267/// Every field is `Option<_>`: `None` (the default / the JSON value
268/// `null` / the field simply absent) means **unset — fall back to the
269/// built-in default** ([`ServerSettings::defaults`]), exactly like the
270/// agent layered-config scopes. The SPA renders the built-in default as a
271/// faint placeholder so a blank field shows what it resolves to, and when
272/// a real default is introduced here it appears in the UI (and takes
273/// effect for already-deployed-but-unset fleets) for free.
274///
275/// `#[serde(default)]` on the container keeps the document backward/forward
276/// compatible: a freshly-created or missing key decodes to all-`None`
277/// (pre-feature behaviour), an older backend reading a newer document
278/// ignores unknown fields, and a newer backend reading an older document
279/// fills the missing field with `None`. Keep that invariant — never add a
280/// field whose `None` doesn't mean "behave as before".
281#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)]
282#[serde(default)]
283pub struct ServerSettings {
284    /// Days a dead agent (one whose heartbeat stopped arriving) may
285    /// linger in the `agents` registry before the backend cleanup task
286    /// prunes its row.
287    ///
288    /// `None` (unset) falls back to the built-in default; with no default
289    /// configured that resolves to pruning **disabled** (see
290    /// [`ServerSettings::effective_agent_prune_days`]). A positive value
291    /// makes the cleanup sweep delete rows whose `last_heartbeat` is older
292    /// than that many days. The `agents` table is a projection of the
293    /// heartbeat stream, so a machine that's merely offline (not gone)
294    /// reappears on its next heartbeat (~30s cadence); only
295    /// genuinely-retired machines stay gone.
296    #[serde(skip_serializing_if = "Option::is_none")]
297    pub agent_prune_days: Option<u32>,
298
299    /// Agent group whose members are the trusted **controller-tier**
300    /// runners. A job with `tier: controller` (e.g. a `feed:` job that
301    /// fetches an external URL) is dispatched ONLY to members of this group;
302    /// `None` (unset) means controller-tier jobs run **nowhere** (fail-safe,
303    /// so an external fetch never lands on an employee endpoint by accident).
304    /// See `crate::manifest::Tier`. `None` ⇒ no controller runners
305    /// configured, which is the safe default for a fresh deployment.
306    #[serde(skip_serializing_if = "Option::is_none")]
307    pub controller_group: Option<String>,
308
309    /// Non-secret SMTP relay settings for outbound email (compliance-alert
310    /// notifications, account setup links, …). Lives here (#884) rather than
311    /// in `backend.toml` so an operator can edit it from the SPA without
312    /// shell access to the host.
313    ///
314    /// `None` (unset) ⇒ no relay configured, email is a no-op — the in-app
315    /// / NATS notification path is unaffected. The SMTP **password is
316    /// deliberately not here** (KV is
317    /// readable over NATS): it stays sourced from the `MailPassword`
318    /// registry secret / `$KANADE_MAIL_PASSWORD` and is combined with these
319    /// settings when the backend builds its `Mailer`. Changes take effect on
320    /// the **next backend restart** (the backend builds the `Mailer` once at
321    /// startup — no live rebuild), which is acceptable per the #884
322    /// discussion.
323    #[serde(skip_serializing_if = "Option::is_none")]
324    pub mail: Option<MailSection>,
325
326    /// Retention window (days) for collected file bundles — the `collect:`
327    /// job archives uploaded to the `collections` Object Store (#219).
328    ///
329    /// Unlike the other fields, this one has a **real built-in default**
330    /// ([`DEFAULT_COLLECT_RETENTION_DAYS`], 30 d): `None` (unset) falls back
331    /// to it, so a blank field preserves the historical behaviour rather than
332    /// disabling retention. A positive value tells the backend to reconcile
333    /// the Object Store's `max_age` to that many days (see
334    /// `bootstrap::reconcile_collect_retention`), applied at boot and again
335    /// whenever this document is saved from the SPA. Clamped to
336    /// [`MAX_COLLECT_RETENTION_DAYS`]. The bucket's `max_bytes` cap is
337    /// untouched, so extending the window can't make the store grow unbounded.
338    #[serde(skip_serializing_if = "Option::is_none")]
339    pub collect_retention_days: Option<u32>,
340
341    /// How many hours a freshly-minted login token (SPA / CLI) stays valid
342    /// before the caller must re-authenticate. Read backend-side by the
343    /// login handler when it mints the JWT `exp`; changing it affects
344    /// **tokens minted after the change**, not already-issued ones.
345    ///
346    /// Like [`collect_retention_days`](Self::collect_retention_days) this
347    /// carries a **real built-in default** ([`DEFAULT_SESSION_TTL_HOURS`],
348    /// 24h): `None` (unset) falls back to it, so a blank field resolves to a
349    /// usable window rather than an instantly-expired token. Clamped to
350    /// `1..=`[`MAX_SESSION_TTL_HOURS`]. The SPA renders `24` as a faint
351    /// placeholder on a blank field.
352    #[serde(skip_serializing_if = "Option::is_none")]
353    pub session_ttl_hours: Option<u32>,
354
355    /// Days a `check_status` row may go without a fresh result before the
356    /// Compliance page treats it as **stale** and hides it (#1032②). A PC that
357    /// stops running a check — excluded from its target via a dynamic group,
358    /// decommissioned, or its schedule removed — stops refreshing its row's
359    /// `recorded_at`; once that timestamp is older than this window the row is
360    /// omitted from the default `/api/checks` view and its counts, so a machine
361    /// no longer in scope stops showing as permanently failing a check it never
362    /// runs.
363    ///
364    /// Like [`collect_retention_days`](Self::collect_retention_days) this carries
365    /// a **real built-in default** ([`DEFAULT_CHECK_STATUS_STALE_DAYS`], 30d), so
366    /// the feature works with no configuration. `0` **disables** staleness
367    /// (every row shown, the pre-feature behaviour); a positive value is the
368    /// cutoff, clamped to [`MAX_CHECK_STATUS_STALE_DAYS`]. The row is never
369    /// deleted — hiding is non-destructive so history and the compliance-alert
370    /// prior-status are preserved.
371    #[serde(skip_serializing_if = "Option::is_none")]
372    pub check_status_stale_days: Option<u32>,
373
374    /// Per-bucket disk caps (MiB) for the five NATS Object Stores (#1247)
375    /// — see [`ObjectStoreCaps`]. `None` (unset) ⇒ every bucket resolves
376    /// to its built-in default, so a blank field preserves the out-of-box
377    /// budget. Applied to the backing `OBJ_*` streams at backend boot and
378    /// whenever this document is saved from the SPA
379    /// (`bootstrap::reconcile_object_store_max_bytes`), which is also what
380    /// finally delivers caps to buckets created before the caps existed.
381    #[serde(skip_serializing_if = "Option::is_none")]
382    pub object_store_caps: Option<ObjectStoreCaps>,
383
384    /// Operator-issued support codes — the helpdesk "裏コマンド" that reveals
385    /// `client.unlock`-scoped jobs in the Client App. See [`SupportCode`].
386    ///
387    /// The one non-`Option` field in this document, because a list has an
388    /// honest empty state: `[]` (the default) means **no codes configured**,
389    /// so every `client.unlock` job stays hidden from everyone — the same
390    /// fail-closed "behave as before" the `None`s give the other fields.
391    ///
392    /// Unlike the rest of the document this is read by **agents** as well as
393    /// the backend (each agent verifies a typed code against these hashes
394    /// locally, so an unlock still works during a backend outage), and it is
395    /// **not** editable through the generic `PUT /api/server-settings` merge:
396    /// a secret gets its own set-and-forget endpoint so a redacted document
397    /// round-tripping through the SPA form can never blank a live code.
398    #[serde(skip_serializing_if = "Vec::is_empty")]
399    pub support_codes: Vec<SupportCode>,
400}
401
402impl ServerSettings {
403    /// Built-in defaults applied when a field is unset (`None`) in the
404    /// stored document. Most fields default to `None` (no fleet-meaningful
405    /// default — a blank prune window means "disabled" rather than some
406    /// arbitrary number of days). The exceptions carry real defaults so a
407    /// blank field still resolves to a sensible value:
408    /// [`collect_retention_days`](Self::collect_retention_days)
409    /// ([`DEFAULT_COLLECT_RETENTION_DAYS`], preserving the historical 30-day
410    /// retention) and [`session_ttl_hours`](Self::session_ttl_hours)
411    /// ([`DEFAULT_SESSION_TTL_HOURS`], 24h).
412    ///
413    /// Exposed via `GET /api/server-settings/defaults` so the SPA renders
414    /// these as faint placeholders (mirroring the agent layered-config
415    /// page's built-in floor). Introducing a real default is a one-line
416    /// change here that automatically shows up in the UI and applies to
417    /// every deployment that hasn't overridden the field.
418    pub fn defaults() -> Self {
419        Self {
420            agent_prune_days: None,
421            controller_group: None,
422            mail: None,
423            collect_retention_days: Some(DEFAULT_COLLECT_RETENTION_DAYS),
424            session_ttl_hours: Some(DEFAULT_SESSION_TTL_HOURS),
425            check_status_stale_days: Some(DEFAULT_CHECK_STATUS_STALE_DAYS),
426            // Real per-bucket defaults, so the SPA renders them as faint
427            // placeholders and unset deployments are capped out of the box.
428            object_store_caps: Some(ObjectStoreCaps {
429                result_output_mib: Some(DEFAULT_RESULT_OUTPUT_CAP_MIB),
430                agent_releases_mib: Some(DEFAULT_AGENT_RELEASES_CAP_MIB),
431                app_packages_mib: Some(DEFAULT_APP_PACKAGES_CAP_MIB),
432                scripts_mib: Some(DEFAULT_SCRIPTS_CAP_MIB),
433                collections_mib: Some(DEFAULT_COLLECTIONS_CAP_MIB),
434            }),
435            // No built-in code: a deployment that configures none has no
436            // unlockable jobs, which is the only safe default for a secret.
437            support_codes: Vec::new(),
438        }
439    }
440
441    /// The caps document with every bucket resolved: stored values where
442    /// set, built-in defaults elsewhere, each clamped to
443    /// `1..=MAX_OBJECT_STORE_CAP_MIB` so an out-of-band KV write can't
444    /// reach the broker unsanitised.
445    pub fn effective_object_store_caps(&self) -> ObjectStoreCaps {
446        let c = self.object_store_caps.clone().unwrap_or_default();
447        ObjectStoreCaps {
448            result_output_mib: Some(c.effective_result_output_mib()),
449            agent_releases_mib: Some(c.effective_agent_releases_mib()),
450            app_packages_mib: Some(c.effective_app_packages_mib()),
451            scripts_mib: Some(c.effective_scripts_mib()),
452            collections_mib: Some(c.effective_collections_mib()),
453        }
454    }
455
456    /// The live code for `scope`, or `None` when the scope has no code, its
457    /// code is disabled, or the hash was blanked (an API-redacted document).
458    /// Every caller of this is a gate, so all three cases fail closed.
459    pub fn support_code(&self, scope: &str) -> Option<&SupportCode> {
460        self.support_codes
461            .iter()
462            .find(|c| c.scope == scope && c.is_usable())
463    }
464
465    /// The same document with every support-code hash blanked — what an HTTP
466    /// response is allowed to contain. Scope / label / TTL stay visible so
467    /// the SPA can list and manage the codes; the secret material never
468    /// leaves the backend, not even to an operator-authenticated caller.
469    /// (`GET /api/server-settings` is viewer+, so an unredacted document
470    /// would hand every read-only account an offline-crackable hash.)
471    #[must_use]
472    pub fn redacted(mut self) -> Self {
473        for c in &mut self.support_codes {
474            c.hash.clear();
475        }
476        self
477    }
478
479    /// The configured controller-tier runner group, trimmed, or `None` when
480    /// unset / blank. `None` ⇒ controller-tier jobs run nowhere (fail-safe).
481    pub fn effective_controller_group(&self) -> Option<&str> {
482        self.controller_group
483            .as_deref()
484            .map(str::trim)
485            .filter(|g| !g.is_empty())
486    }
487
488    /// The effective dead-agent prune window in days: the stored value if
489    /// set, else the built-in default, else `0` (= pruning disabled). The
490    /// final `unwrap_or(0)` is the absent-everywhere floor, not a
491    /// user-facing default — the cleanup task treats `0` as "don't prune".
492    ///
493    /// Clamped to [`MAX_AGENT_PRUNE_DAYS`] so the cleanup task's
494    /// `now - Duration::days(n)` can never overflow `DateTime` (and panic
495    /// the task), even if a value larger than the PUT handler allows was
496    /// written to the KV out-of-band.
497    pub fn effective_agent_prune_days(&self) -> u32 {
498        self.agent_prune_days
499            .or(Self::defaults().agent_prune_days)
500            .unwrap_or(0)
501            .min(MAX_AGENT_PRUNE_DAYS)
502    }
503
504    /// The effective collect-bundle retention window in days: the stored
505    /// value if set, else the built-in default ([`DEFAULT_COLLECT_RETENTION_DAYS`]).
506    /// Floored at 1 and clamped to [`MAX_COLLECT_RETENTION_DAYS`] so an
507    /// out-of-band KV write can't reach the Object Store `max_age` unsanitised.
508    /// The floor matters specifically because NATS treats `max_age: 0` as
509    /// **unlimited** retention, not "evict immediately": a stray `0` would
510    /// silently make bundles never expire (defeating the auto-expire intent),
511    /// so we coerce it to the shortest real window (1 day) instead. The PUT
512    /// handler already rejects `0` / over-cap, so this is the belt-and-braces
513    /// path for a hand-edited KV value.
514    pub fn effective_collect_retention_days(&self) -> u32 {
515        self.collect_retention_days
516            .or(Self::defaults().collect_retention_days)
517            .unwrap_or(DEFAULT_COLLECT_RETENTION_DAYS)
518            .clamp(1, MAX_COLLECT_RETENTION_DAYS)
519    }
520
521    /// The effective login-token lifetime in hours: the stored value if
522    /// set, else the built-in [`DEFAULT_SESSION_TTL_HOURS`]. Floored at 1
523    /// and clamped to [`MAX_SESSION_TTL_HOURS`] so a zero/absent/out-of-band
524    /// value can never mint an already-expired or overflow-inducing token.
525    /// The PUT handler already rejects `0` / over-cap, so this is the
526    /// belt-and-braces path for a hand-edited KV value.
527    pub fn effective_session_ttl_hours(&self) -> u32 {
528        self.session_ttl_hours
529            .or(Self::defaults().session_ttl_hours)
530            .unwrap_or(DEFAULT_SESSION_TTL_HOURS)
531            .clamp(1, MAX_SESSION_TTL_HOURS)
532    }
533
534    /// The effective check-staleness window in days: the stored value if set,
535    /// else the built-in [`DEFAULT_CHECK_STATUS_STALE_DAYS`]. **`0` means
536    /// disabled** (no row is ever hidden as stale) — deliberately NOT floored to
537    /// 1 (unlike collect/session), because 0 is a meaningful "show everything"
538    /// value here, the same convention as [`agent_prune_days`](Self::agent_prune_days).
539    /// Clamped to [`MAX_CHECK_STATUS_STALE_DAYS`] so an out-of-band KV write
540    /// can't overflow the `now - Duration::days(n)` cutoff math.
541    pub fn effective_check_status_stale_days(&self) -> u32 {
542        self.check_status_stale_days
543            .or(Self::defaults().check_status_stale_days)
544            .unwrap_or(DEFAULT_CHECK_STATUS_STALE_DAYS)
545            .min(MAX_CHECK_STATUS_STALE_DAYS)
546    }
547}
548
549#[cfg(test)]
550mod tests {
551    use super::*;
552
553    #[test]
554    fn default_is_unset() {
555        assert_eq!(ServerSettings::default().agent_prune_days, None);
556    }
557
558    #[test]
559    fn unset_resolves_to_disabled() {
560        // No stored value + no built-in default ⇒ effective 0 (disabled).
561        assert_eq!(ServerSettings::default().effective_agent_prune_days(), 0);
562    }
563
564    #[test]
565    fn stored_value_wins_over_default() {
566        let s = ServerSettings {
567            agent_prune_days: Some(30),
568            ..Default::default()
569        };
570        assert_eq!(s.effective_agent_prune_days(), 30);
571    }
572
573    #[test]
574    fn effective_clamps_to_max() {
575        // An out-of-band KV write larger than the PUT cap must not reach
576        // the cleanup task unclamped (else its DateTime subtraction panics).
577        let s = ServerSettings {
578            agent_prune_days: Some(u32::MAX),
579            ..Default::default()
580        };
581        assert_eq!(s.effective_agent_prune_days(), MAX_AGENT_PRUNE_DAYS);
582    }
583
584    #[test]
585    fn round_trips_through_json() {
586        let s = ServerSettings {
587            agent_prune_days: Some(30),
588            ..Default::default()
589        };
590        let json = serde_json::to_string(&s).unwrap();
591        assert_eq!(json, r#"{"agent_prune_days":30}"#);
592        let back: ServerSettings = serde_json::from_str(&json).unwrap();
593        assert_eq!(back, s);
594    }
595
596    #[test]
597    fn unset_serialises_to_empty_object() {
598        // `skip_serializing_if` keeps an all-unset doc minimal; it must
599        // round-trip back to all-`None`.
600        let s = ServerSettings::default();
601        let json = serde_json::to_string(&s).unwrap();
602        assert_eq!(json, "{}");
603        let back: ServerSettings = serde_json::from_str(&json).unwrap();
604        assert_eq!(back, s);
605    }
606
607    #[test]
608    fn explicit_null_decodes_to_unset() {
609        let s: ServerSettings = serde_json::from_str(r#"{"agent_prune_days":null}"#).unwrap();
610        assert_eq!(s.agent_prune_days, None);
611    }
612
613    #[test]
614    fn empty_object_decodes_to_default() {
615        // A freshly-created key (or one written by an older backend that
616        // didn't know this field) must read back as the pre-feature
617        // behaviour, not fail to decode.
618        let s: ServerSettings = serde_json::from_str("{}").unwrap();
619        assert_eq!(s, ServerSettings::default());
620    }
621
622    #[test]
623    fn controller_group_effective_trims_and_blank_is_unset() {
624        assert_eq!(ServerSettings::default().effective_controller_group(), None);
625        let s = ServerSettings {
626            controller_group: Some("  feed-runners ".into()),
627            ..Default::default()
628        };
629        assert_eq!(s.effective_controller_group(), Some("feed-runners"));
630        // A blank/whitespace value reads as unset (fail-safe: no runner).
631        let blank = ServerSettings {
632            controller_group: Some("   ".into()),
633            ..Default::default()
634        };
635        assert_eq!(blank.effective_controller_group(), None);
636    }
637
638    #[test]
639    fn controller_group_round_trips_and_omits_when_unset() {
640        let s = ServerSettings {
641            controller_group: Some("infra".into()),
642            ..Default::default()
643        };
644        let json = serde_json::to_string(&s).unwrap();
645        assert_eq!(json, r#"{"controller_group":"infra"}"#);
646        assert_eq!(serde_json::from_str::<ServerSettings>(&json).unwrap(), s);
647        // Unset controller_group is omitted (skip_serializing_if).
648        assert_eq!(
649            serde_json::to_string(&ServerSettings::default()).unwrap(),
650            "{}"
651        );
652    }
653
654    #[test]
655    fn mail_round_trips_and_omits_when_unset() {
656        use crate::config::{MailEncryption, MailSection};
657
658        // Unset mail is omitted (skip_serializing_if) — a mail-less doc
659        // stays minimal and decodes back to `None`.
660        assert_eq!(
661            serde_json::to_string(&ServerSettings::default()).unwrap(),
662            "{}"
663        );
664
665        let s = ServerSettings {
666            mail: Some(MailSection {
667                host: "smtp.example.com".into(),
668                port: 587,
669                encryption: MailEncryption::Starttls,
670                from: "kanade-noreply@example.com".into(),
671                username: Some("kanade-noreply".into()),
672            }),
673            ..Default::default()
674        };
675        let json = serde_json::to_string(&s).unwrap();
676        // Encryption serialises lowercase; the password is never present.
677        assert!(json.contains(r#""encryption":"starttls""#), "json: {json}");
678        assert!(!json.contains("password"), "password must never serialise");
679        assert_eq!(serde_json::from_str::<ServerSettings>(&json).unwrap(), s);
680    }
681
682    #[test]
683    fn mail_defaults_to_unset() {
684        assert_eq!(ServerSettings::default().mail, None);
685        // A doc written before this field existed (no `mail` key) decodes
686        // to `None` — email stays a no-op, the pre-feature behaviour.
687        let s: ServerSettings = serde_json::from_str(r#"{"agent_prune_days":7}"#).unwrap();
688        assert_eq!(s.mail, None);
689        assert_eq!(s.agent_prune_days, Some(7));
690    }
691
692    #[test]
693    fn collect_retention_unset_resolves_to_builtin_default() {
694        // Blank (the derived Default) must preserve the historical 30-day
695        // window, not disable retention.
696        assert_eq!(ServerSettings::default().collect_retention_days, None);
697        assert_eq!(
698            ServerSettings::default().effective_collect_retention_days(),
699            DEFAULT_COLLECT_RETENTION_DAYS,
700        );
701        // The defaults() document surfaces the real default so the SPA can
702        // render it as a placeholder.
703        assert_eq!(
704            ServerSettings::defaults().collect_retention_days,
705            Some(DEFAULT_COLLECT_RETENTION_DAYS),
706        );
707    }
708
709    #[test]
710    fn collect_retention_stored_value_wins() {
711        let s = ServerSettings {
712            collect_retention_days: Some(90),
713            ..Default::default()
714        };
715        assert_eq!(s.effective_collect_retention_days(), 90);
716    }
717
718    #[test]
719    fn collect_retention_effective_clamps_out_of_band_writes() {
720        // A hand-written KV value past the PUT cap (or 0) must be clamped so
721        // the reconciled Object Store max_age stays sane.
722        let big = ServerSettings {
723            collect_retention_days: Some(u32::MAX),
724            ..Default::default()
725        };
726        assert_eq!(
727            big.effective_collect_retention_days(),
728            MAX_COLLECT_RETENTION_DAYS,
729        );
730        let zero = ServerSettings {
731            collect_retention_days: Some(0),
732            ..Default::default()
733        };
734        assert_eq!(zero.effective_collect_retention_days(), 1);
735    }
736
737    #[test]
738    fn collect_retention_round_trips_and_omits_when_unset() {
739        let s = ServerSettings {
740            collect_retention_days: Some(90),
741            ..Default::default()
742        };
743        let json = serde_json::to_string(&s).unwrap();
744        assert_eq!(json, r#"{"collect_retention_days":90}"#);
745        assert_eq!(serde_json::from_str::<ServerSettings>(&json).unwrap(), s);
746        // Unset is omitted so a blank doc stays minimal.
747        assert!(
748            !serde_json::to_string(&ServerSettings::default())
749                .unwrap()
750                .contains("collect_retention_days")
751        );
752    }
753
754    #[test]
755    fn session_ttl_unset_resolves_to_builtin_default() {
756        // Blank (the derived Default) must resolve to the 24h default rather
757        // than 0 (which would mint already-expired tokens).
758        assert_eq!(ServerSettings::default().session_ttl_hours, None);
759        assert_eq!(
760            ServerSettings::default().effective_session_ttl_hours(),
761            DEFAULT_SESSION_TTL_HOURS,
762        );
763        // The defaults() document surfaces the real default so the SPA can
764        // render it as a placeholder.
765        assert_eq!(
766            ServerSettings::defaults().session_ttl_hours,
767            Some(DEFAULT_SESSION_TTL_HOURS),
768        );
769    }
770
771    #[test]
772    fn session_ttl_stored_value_wins() {
773        let s = ServerSettings {
774            session_ttl_hours: Some(72),
775            ..Default::default()
776        };
777        assert_eq!(s.effective_session_ttl_hours(), 72);
778    }
779
780    #[test]
781    fn session_ttl_effective_clamps_out_of_band_writes() {
782        // An out-of-band 0 must floor to 1 (else `now + 0h` is an
783        // instantly-expired token); a value past the cap clamps down so
784        // login's date math can't overflow.
785        let zero = ServerSettings {
786            session_ttl_hours: Some(0),
787            ..Default::default()
788        };
789        assert_eq!(zero.effective_session_ttl_hours(), 1);
790        let huge = ServerSettings {
791            session_ttl_hours: Some(u32::MAX),
792            ..Default::default()
793        };
794        assert_eq!(huge.effective_session_ttl_hours(), MAX_SESSION_TTL_HOURS);
795    }
796
797    #[test]
798    fn session_ttl_round_trips_and_omits_when_unset() {
799        let s = ServerSettings {
800            session_ttl_hours: Some(48),
801            ..Default::default()
802        };
803        let json = serde_json::to_string(&s).unwrap();
804        assert_eq!(json, r#"{"session_ttl_hours":48}"#);
805        assert_eq!(serde_json::from_str::<ServerSettings>(&json).unwrap(), s);
806        // Unset is omitted so a blank doc stays minimal.
807        assert!(
808            !serde_json::to_string(&ServerSettings::default())
809                .unwrap()
810                .contains("session_ttl_hours")
811        );
812    }
813
814    #[test]
815    fn check_stale_unset_resolves_to_builtin_default() {
816        // Blank (the derived Default) resolves to the 30-day default — ON out
817        // of the box, so the feature works without configuration.
818        assert_eq!(ServerSettings::default().check_status_stale_days, None);
819        assert_eq!(
820            ServerSettings::default().effective_check_status_stale_days(),
821            DEFAULT_CHECK_STATUS_STALE_DAYS,
822        );
823        // defaults() surfaces the real default for the SPA placeholder.
824        assert_eq!(
825            ServerSettings::defaults().check_status_stale_days,
826            Some(DEFAULT_CHECK_STATUS_STALE_DAYS),
827        );
828    }
829
830    #[test]
831    fn check_stale_zero_disables() {
832        // Explicit 0 means "disable staleness" (show everything) — NOT floored
833        // to 1 like collect/session; same convention as agent_prune_days.
834        let s = ServerSettings {
835            check_status_stale_days: Some(0),
836            ..Default::default()
837        };
838        assert_eq!(s.effective_check_status_stale_days(), 0);
839    }
840
841    #[test]
842    fn check_stale_stored_value_wins_and_clamps() {
843        let s = ServerSettings {
844            check_status_stale_days: Some(7),
845            ..Default::default()
846        };
847        assert_eq!(s.effective_check_status_stale_days(), 7);
848        let big = ServerSettings {
849            check_status_stale_days: Some(u32::MAX),
850            ..Default::default()
851        };
852        assert_eq!(
853            big.effective_check_status_stale_days(),
854            MAX_CHECK_STATUS_STALE_DAYS,
855        );
856    }
857
858    #[test]
859    fn check_stale_round_trips_and_omits_when_unset() {
860        let s = ServerSettings {
861            check_status_stale_days: Some(14),
862            ..Default::default()
863        };
864        let json = serde_json::to_string(&s).unwrap();
865        assert_eq!(json, r#"{"check_status_stale_days":14}"#);
866        assert_eq!(serde_json::from_str::<ServerSettings>(&json).unwrap(), s);
867        assert!(
868            !serde_json::to_string(&ServerSettings::default())
869                .unwrap()
870                .contains("check_status_stale_days")
871        );
872    }
873
874    #[test]
875    fn support_codes_absent_by_default_and_omitted_from_the_wire() {
876        // The pre-feature document must round-trip byte-identically: no
877        // `support_codes` key, so an older backend / agent reading it sees
878        // exactly what it saw before, and nothing is unlockable.
879        let s = ServerSettings::default();
880        assert!(s.support_codes.is_empty());
881        assert_eq!(serde_json::to_string(&s).unwrap(), "{}");
882        assert!(s.support_code("support").is_none());
883    }
884
885    #[test]
886    fn support_code_lookup_fails_closed() {
887        let s = ServerSettings {
888            support_codes: vec![
889                SupportCode {
890                    scope: "support".into(),
891                    hash: "$argon2id$v=19$m=19456,t=2,p=1$c2FsdA$aGFzaA".into(),
892                    label: Some("ヘルプデスク".into()),
893                    ..Default::default()
894                },
895                SupportCode {
896                    scope: "admin".into(),
897                    hash: "$argon2id$v=19$m=19456,t=2,p=1$c2FsdA$aGFzaA".into(),
898                    disabled: true,
899                    ..Default::default()
900                },
901                SupportCode {
902                    // Hash blanked — what an API-redacted document looks like.
903                    // It must never be treated as a live code.
904                    scope: "blank".into(),
905                    ..Default::default()
906                },
907            ],
908            ..Default::default()
909        };
910        assert!(s.support_code("support").is_some());
911        assert!(s.support_code("admin").is_none(), "disabled must not match");
912        assert!(
913            s.support_code("blank").is_none(),
914            "blank hash must not match"
915        );
916        assert!(s.support_code("nope").is_none());
917    }
918
919    #[test]
920    fn redacted_blanks_hashes_but_keeps_the_roster() {
921        // The SPA still needs to see WHICH scopes exist (to list / rotate /
922        // delete them); it must never see the hash.
923        let s = ServerSettings {
924            support_codes: vec![SupportCode {
925                scope: "support".into(),
926                hash: "$argon2id$secret".into(),
927                label: Some("ヘルプデスク".into()),
928                ttl_minutes: Some(30),
929                disabled: false,
930            }],
931            ..Default::default()
932        };
933        let json = serde_json::to_string(&s.redacted()).unwrap();
934        assert!(!json.contains("argon2"), "wire leaked the hash: {json}");
935        assert!(!json.contains("hash"), "wire leaked the hash key: {json}");
936        assert!(json.contains("\"scope\":\"support\""), "wire: {json}");
937        assert!(json.contains("\"ttl_minutes\":30"), "wire: {json}");
938    }
939
940    #[test]
941    fn support_code_ttl_clamps() {
942        let unset = SupportCode::default();
943        assert_eq!(
944            unset.effective_ttl_minutes(),
945            DEFAULT_SUPPORT_UNLOCK_TTL_MINUTES
946        );
947        // 0 would mint an already-expired grant (an unlock that silently
948        // does nothing) — floored to the shortest real window instead.
949        let zero = SupportCode {
950            ttl_minutes: Some(0),
951            ..Default::default()
952        };
953        assert_eq!(zero.effective_ttl_minutes(), 1);
954        let huge = SupportCode {
955            ttl_minutes: Some(u32::MAX),
956            ..Default::default()
957        };
958        assert_eq!(huge.effective_ttl_minutes(), MAX_SUPPORT_UNLOCK_TTL_MINUTES);
959    }
960
961    #[test]
962    fn accepts_unknown_fields_for_forward_compat() {
963        // A newer backend may have added knobs this build doesn't know;
964        // decoding must drop them rather than error.
965        let json = r#"{"agent_prune_days":7,"some_future_knob":true}"#;
966        let s: ServerSettings = serde_json::from_str(json).unwrap();
967        assert_eq!(s.agent_prune_days, Some(7));
968    }
969
970    #[test]
971    fn object_store_caps_unset_resolves_to_builtin_defaults() {
972        // Blank doc ⇒ every bucket capped at its built-in default — the
973        // out-of-box fix for uncapped / drifted buckets (#1247).
974        let s = ServerSettings::default();
975        assert_eq!(s.object_store_caps, None);
976        let c = s.effective_object_store_caps();
977        assert_eq!(c.result_output_mib, Some(DEFAULT_RESULT_OUTPUT_CAP_MIB));
978        assert_eq!(c.agent_releases_mib, Some(DEFAULT_AGENT_RELEASES_CAP_MIB));
979        assert_eq!(c.app_packages_mib, Some(DEFAULT_APP_PACKAGES_CAP_MIB));
980        assert_eq!(c.scripts_mib, Some(DEFAULT_SCRIPTS_CAP_MIB));
981        assert_eq!(c.collections_mib, Some(DEFAULT_COLLECTIONS_CAP_MIB));
982    }
983
984    #[test]
985    fn object_store_caps_partial_override_keeps_other_defaults() {
986        let s = ServerSettings {
987            object_store_caps: Some(ObjectStoreCaps {
988                app_packages_mib: Some(8192),
989                ..Default::default()
990            }),
991            ..Default::default()
992        };
993        let c = s.effective_object_store_caps();
994        assert_eq!(c.app_packages_mib, Some(8192));
995        assert_eq!(c.scripts_mib, Some(DEFAULT_SCRIPTS_CAP_MIB));
996    }
997
998    #[test]
999    fn object_store_caps_clamps_out_of_band_writes() {
1000        let s = ServerSettings {
1001            object_store_caps: Some(ObjectStoreCaps {
1002                result_output_mib: Some(0),
1003                app_packages_mib: Some(u32::MAX),
1004                ..Default::default()
1005            }),
1006            ..Default::default()
1007        };
1008        let c = s.effective_object_store_caps();
1009        // 0 floors to 1 MiB — NATS treats max_bytes: 0 as unlimited, the
1010        // exact failure mode this feature exists to remove.
1011        assert_eq!(c.result_output_mib, Some(1));
1012        assert_eq!(c.app_packages_mib, Some(MAX_OBJECT_STORE_CAP_MIB));
1013    }
1014
1015    #[test]
1016    fn object_store_caps_round_trips_and_omits_when_unset() {
1017        let s = ServerSettings {
1018            object_store_caps: Some(ObjectStoreCaps {
1019                scripts_mib: Some(512),
1020                ..Default::default()
1021            }),
1022            ..Default::default()
1023        };
1024        let json = serde_json::to_string(&s).unwrap();
1025        assert_eq!(json, r#"{"object_store_caps":{"scripts_mib":512}}"#);
1026        assert_eq!(serde_json::from_str::<ServerSettings>(&json).unwrap(), s);
1027        assert!(
1028            !serde_json::to_string(&ServerSettings::default())
1029                .unwrap()
1030                .contains("object_store_caps")
1031        );
1032    }
1033}