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;
129
130/// How long `OBJECT_RESULT_OUTPUT` keeps an overflowed stdout/stderr blob.
131///
132/// Seven days, not the thirty it used to be, because the blob is redundant
133/// almost immediately: the results projector derefs it into the row before
134/// the INSERT, so SQLite holds the text and the SPA reads it from there. What
135/// the window buys is one thing only — a `-WipeDb` re-projection replays
136/// `STREAM_RESULTS` and re-derefs, so an object that has aged out comes back
137/// as empty stdout on the rebuilt row.
138///
139/// Thirty days of copies nobody reads is what pushed the bucket into its cap,
140/// and the cap is a hard wall rather than an eviction trigger (#1321): once
141/// full, every result over the inline threshold became undeliverable
142/// fleet-wide. A week keeps the recovery path useful for any incident anyone
143/// is still investigating while cutting the standing volume by ~4x.
144///
145/// Bounded above by `STREAM_RESULTS`'s own 30-day window in any case: past
146/// that there is no message to replay, so a longer object window buys
147/// nothing.
148pub const DEFAULT_RESULT_OUTPUT_RETENTION_DAYS: u32 = 7;
149
150/// Upper bound accepted from an operator. Matches `STREAM_RESULTS`'s window —
151/// beyond it the messages are gone, so the objects would outlive the only
152/// thing that could ask for them.
153pub const MAX_RESULT_OUTPUT_RETENTION_DAYS: u32 = 30;
154pub const DEFAULT_AGENT_RELEASES_CAP_MIB: u32 = 2048;
155pub const DEFAULT_APP_PACKAGES_CAP_MIB: u32 = 5120;
156pub const DEFAULT_SCRIPTS_CAP_MIB: u32 = 256;
157pub const DEFAULT_COLLECTIONS_CAP_MIB: u32 = 5120;
158
159/// Upper bound per bucket (50 GiB) — the broker-wide `max_file_store`
160/// default, so a single bucket can never be configured to eat the whole
161/// file store by fat-finger. Enforced by the PUT handler and clamped in
162/// the `effective_*` accessors so a hand-written KV value stays bounded.
163pub const MAX_OBJECT_STORE_CAP_MIB: u32 = 51_200;
164
165/// Upper bound on the SUM of the five effective bucket caps (45.2 GiB):
166/// the broker-wide 50 GiB minus the ~4.8 GiB the streams reserve
167/// (INVENTORY 1024 + RESULTS 2048 + EXEC 64 + EVENTS 256 + AUDIT 512 +
168/// OBS_EVENTS 512 + NOTIFICATIONS 512 MiB — bootstrap.rs). Without an
169/// aggregate bound, five individually-legal caps could total 250 GiB and
170/// every `update_stream` would be refused by the broker (10047) — the KV
171/// document claiming caps the streams don't have. Enforced by the PUT
172/// handler on the merged document.
173pub const MAX_OBJECT_STORE_TOTAL_MIB: u32 = 46_272;
174
175impl ObjectStoreCaps {
176    fn effective(v: Option<u32>, default: u32) -> u32 {
177        v.unwrap_or(default).clamp(1, MAX_OBJECT_STORE_CAP_MIB)
178    }
179
180    pub fn effective_result_output_mib(&self) -> u32 {
181        Self::effective(self.result_output_mib, DEFAULT_RESULT_OUTPUT_CAP_MIB)
182    }
183    pub fn effective_agent_releases_mib(&self) -> u32 {
184        Self::effective(self.agent_releases_mib, DEFAULT_AGENT_RELEASES_CAP_MIB)
185    }
186    pub fn effective_app_packages_mib(&self) -> u32 {
187        Self::effective(self.app_packages_mib, DEFAULT_APP_PACKAGES_CAP_MIB)
188    }
189    pub fn effective_scripts_mib(&self) -> u32 {
190        Self::effective(self.scripts_mib, DEFAULT_SCRIPTS_CAP_MIB)
191    }
192    pub fn effective_collections_mib(&self) -> u32 {
193        Self::effective(self.collections_mib, DEFAULT_COLLECTIONS_CAP_MIB)
194    }
195
196    /// `(bucket, effective cap in MiB)` for every object store, in
197    /// bootstrap order — the reconcile loop's input.
198    pub fn effective_all(&self) -> [(&'static str, u32); 5] {
199        [
200            (
201                crate::kv::OBJECT_AGENT_RELEASES,
202                self.effective_agent_releases_mib(),
203            ),
204            (
205                crate::kv::OBJECT_APP_PACKAGES,
206                self.effective_app_packages_mib(),
207            ),
208            (crate::kv::OBJECT_SCRIPTS, self.effective_scripts_mib()),
209            (
210                crate::kv::OBJECT_RESULT_OUTPUT,
211                self.effective_result_output_mib(),
212            ),
213            (
214                crate::kv::OBJECT_COLLECTIONS,
215                self.effective_collections_mib(),
216            ),
217        ]
218    }
219}
220
221/// One operator-issued support code — the "裏コマンド" that reveals
222/// `client.unlock`-scoped jobs in the Client App (see
223/// [`crate::manifest::ClientHint::unlock`]).
224///
225/// **Only the argon2id hash is stored.** Verification needs no plaintext, so
226/// unlike the SMTP password (#884, which must stay in the HKLM registry
227/// because SMTP AUTH needs the real string) a support code can live in KV
228/// safely — an agent reads the hash to verify a typed code locally, which is
229/// what keeps unlocking working while the backend is down, exactly when the
230/// desk needs it most.
231///
232/// The plaintext exists only in transit: the operator types it once into the
233/// SPA, the backend hashes it, and no layer ever stores or returns it. The
234/// API blanks [`hash`](Self::hash) on the way out too — an operator rotating
235/// a code sets a new one, they never read the old one back.
236#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)]
237#[serde(default)]
238pub struct SupportCode {
239    /// The scope this code opens, matched byte-for-byte against a job's
240    /// `client.unlock`. Unique within the list; a slug (`[A-Za-z0-9._-]`).
241    pub scope: String,
242    /// argon2id PHC-format hash of the code. Empty ⇒ **no code configured**
243    /// for this scope, which fails closed (nothing can be unlocked with it) —
244    /// that's also what an API response looks like, since the hash is blanked
245    /// before it leaves the backend.
246    #[serde(skip_serializing_if = "String::is_empty")]
247    pub hash: String,
248    /// Human label for the code (`"ヘルプデスク一次窓口"`), shown in the
249    /// Client App's support-mode banner so the user can see which desk opened
250    /// their machine. `None` ⇒ the banner falls back to the scope slug.
251    #[serde(skip_serializing_if = "Option::is_none")]
252    pub label: Option<String>,
253    /// How long a grant from this code lasts, in minutes. `None` ⇒
254    /// [`DEFAULT_SUPPORT_UNLOCK_TTL_MINUTES`]. Clamped to
255    /// `1..=`[`MAX_SUPPORT_UNLOCK_TTL_MINUTES`].
256    #[serde(skip_serializing_if = "Option::is_none")]
257    pub ttl_minutes: Option<u32>,
258    /// Temporarily suspend the code without deleting it (and without having
259    /// to re-issue a new secret afterwards) — a disabled code never verifies.
260    /// `false` (the default) ⇒ live.
261    #[serde(skip_serializing_if = "std::ops::Not::not")]
262    pub disabled: bool,
263}
264
265impl SupportCode {
266    /// The grant window this code mints, in minutes: the configured value if
267    /// set, else [`DEFAULT_SUPPORT_UNLOCK_TTL_MINUTES`]. Floored at 1 and
268    /// clamped to [`MAX_SUPPORT_UNLOCK_TTL_MINUTES`] so a `0` (which would
269    /// mint an already-expired grant, i.e. an unlock that silently does
270    /// nothing) or an out-of-band giant value can't reach the grant store.
271    pub fn effective_ttl_minutes(&self) -> u32 {
272        self.ttl_minutes
273            .unwrap_or(DEFAULT_SUPPORT_UNLOCK_TTL_MINUTES)
274            .clamp(1, MAX_SUPPORT_UNLOCK_TTL_MINUTES)
275    }
276
277    /// Whether this entry can ever grant anything: live and carrying a hash.
278    /// Both halves fail closed — a blanked (API-redacted) or hand-cleared
279    /// hash opens nothing, and neither does a disabled code.
280    pub fn is_usable(&self) -> bool {
281        !self.disabled && !self.hash.is_empty()
282    }
283}
284
285/// The agent-readable projection of [`ServerSettings::support_codes`], stored
286/// under [`crate::kv::KEY_SUPPORT_CODES`] in `fleet_config`.
287///
288/// `server_settings` also carries secrets an agent has no business reading
289/// (the installer's NATS token, in clear at the KV layer, SMTP relay
290/// settings). The support-unlock check needs only the argon2id hashes, so the
291/// backend publishes just those here and agents read this instead. Keep it
292/// that way: [`Self::from_settings`] copies `support_codes` explicitly rather
293/// than cloning the settings and stripping fields, so a field added to
294/// `ServerSettings` later can never leak into this document by default.
295///
296/// Deliberately no `deny_unknown_fields`: a future field must not break
297/// agents that predate it.
298#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)]
299#[serde(default)]
300pub struct SupportCodesProjection {
301    pub support_codes: Vec<SupportCode>,
302}
303
304impl SupportCodesProjection {
305    pub fn from_settings(settings: &ServerSettings) -> Self {
306        Self {
307            support_codes: settings.support_codes.clone(),
308        }
309    }
310}
311
312/// Self-service agent-installer knobs (`GET /api/agents/installer`) —
313/// what the generated ZIP bakes in for a fresh PC. Kept server-side so the
314/// restricted "download user" (viewer + the `agent-install` feature only)
315/// never chooses — and never sees — these values.
316// PartialEq/Eq beyond the minimal derive list: `ServerSettings` derives
317// them, so every section must too.
318#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
319#[serde(default)]
320pub struct AgentInstallSection {
321    /// NATS URL baked into the installer's agent.toml. None → the backend's
322    /// own configured `[nats] url`.
323    #[serde(skip_serializing_if = "Option::is_none")]
324    pub nats_url: Option<String>,
325    /// NATS token baked into install-agent.ps1. WRITE-ONLY: redacted from
326    /// every GET response (GET /api/server-settings is viewer+).
327    #[serde(skip_serializing_if = "Option::is_none")]
328    pub nats_token: Option<String>,
329    /// Read-only indicator computed by redacted() so the SPA can show
330    /// "configured" without ever seeing the value. Never accepted from PUT.
331    #[serde(skip_deserializing)]
332    pub nats_token_set: bool,
333    /// Ask the generated Windows installer to pass `-RequireSignedCommands`
334    /// to `deploy-agent.ps1`, so a fresh agent starts enforcing signed
335    /// commands from first boot instead of leaving that as a manual
336    /// post-install step (#1155/#1165 day-1 gap).
337    ///
338    /// `None`/`Some(false)` → omitted, matching every other unset knob here.
339    /// `Some(true)` is honored by the installer ONLY when this backend also
340    /// has a command-signing key configured — see
341    /// `agent_installer::resolve_enforcement`. A backend with no key must
342    /// never generate an installer that turns on enforcement with an empty
343    /// keyring; that request is instead surfaced in the installer's
344    /// README.txt rather than silently dropped.
345    #[serde(skip_serializing_if = "Option::is_none")]
346    pub require_signed_commands: Option<bool>,
347}
348
349/// Value stored in the `server_settings` KV bucket under the single key
350/// [`crate::kv::KEY_SERVER_SETTINGS`]. Operator-editable, backend-side
351/// server configuration that isn't per-agent (so it doesn't belong in
352/// `agent_config`'s layered scopes) and isn't a fleet-wide switch every
353/// agent watches (so it doesn't belong in `fleet_config`). Managed via
354/// the SPA Settings page's "server settings" tab.
355///
356/// Every field is `Option<_>`: `None` (the default / the JSON value
357/// `null` / the field simply absent) means **unset — fall back to the
358/// built-in default** ([`ServerSettings::defaults`]), exactly like the
359/// agent layered-config scopes. The SPA renders the built-in default as a
360/// faint placeholder so a blank field shows what it resolves to, and when
361/// a real default is introduced here it appears in the UI (and takes
362/// effect for already-deployed-but-unset fleets) for free.
363///
364/// `#[serde(default)]` on the container keeps the document backward/forward
365/// compatible: a freshly-created or missing key decodes to all-`None`
366/// (pre-feature behaviour), an older backend reading a newer document
367/// ignores unknown fields, and a newer backend reading an older document
368/// fills the missing field with `None`. Keep that invariant — never add a
369/// field whose `None` doesn't mean "behave as before".
370#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)]
371#[serde(default)]
372pub struct ServerSettings {
373    /// Days a dead agent (one whose heartbeat stopped arriving) may
374    /// linger in the `agents` registry before the backend cleanup task
375    /// prunes its row.
376    ///
377    /// `None` (unset) falls back to the built-in default; with no default
378    /// configured that resolves to pruning **disabled** (see
379    /// [`ServerSettings::effective_agent_prune_days`]). A positive value
380    /// makes the cleanup sweep delete rows whose `last_heartbeat` is older
381    /// than that many days. The `agents` table is a projection of the
382    /// heartbeat stream, so a machine that's merely offline (not gone)
383    /// reappears on its next heartbeat (~30s cadence); only
384    /// genuinely-retired machines stay gone.
385    #[serde(skip_serializing_if = "Option::is_none")]
386    pub agent_prune_days: Option<u32>,
387
388    /// Agent group whose members are the trusted **controller-tier**
389    /// runners. A job with `tier: controller` (e.g. a `feed:` job that
390    /// fetches an external URL) is dispatched ONLY to members of this group;
391    /// `None` (unset) means controller-tier jobs run **nowhere** (fail-safe,
392    /// so an external fetch never lands on an employee endpoint by accident).
393    /// See `crate::manifest::Tier`. `None` ⇒ no controller runners
394    /// configured, which is the safe default for a fresh deployment.
395    #[serde(skip_serializing_if = "Option::is_none")]
396    pub controller_group: Option<String>,
397
398    /// Non-secret SMTP relay settings for outbound email (compliance-alert
399    /// notifications, account setup links, …). Lives here (#884) rather than
400    /// in `backend.toml` so an operator can edit it from the SPA without
401    /// shell access to the host.
402    ///
403    /// `None` (unset) ⇒ no relay configured, email is a no-op — the in-app
404    /// / NATS notification path is unaffected. The SMTP **password is
405    /// deliberately not here** (KV is
406    /// readable over NATS): it stays sourced from the `MailPassword`
407    /// registry secret / `$KANADE_MAIL_PASSWORD` and is combined with these
408    /// settings when the backend builds its `Mailer`. Changes take effect on
409    /// the **next backend restart** (the backend builds the `Mailer` once at
410    /// startup — no live rebuild), which is acceptable per the #884
411    /// discussion.
412    #[serde(skip_serializing_if = "Option::is_none")]
413    pub mail: Option<MailSection>,
414
415    /// Self-service agent-installer knobs — see [`AgentInstallSection`].
416    /// `None` (unset) ⇒ the installer falls back to this backend's own
417    /// `[nats] url` and embeds no token. The `nats_token` inside is
418    /// write-only: [`ServerSettings::redacted`] strips it from every
419    /// response and the PUT merge preserves a stored token across a
420    /// url-only edit (the same support-code trap: a form round-trip must
421    /// never silently clear a secret).
422    #[serde(skip_serializing_if = "Option::is_none")]
423    pub agent_install: Option<AgentInstallSection>,
424
425    /// Retention window (days) for collected file bundles — the `collect:`
426    /// job archives uploaded to the `collections` Object Store (#219).
427    ///
428    /// Unlike the other fields, this one has a **real built-in default**
429    /// ([`DEFAULT_COLLECT_RETENTION_DAYS`], 30 d): `None` (unset) falls back
430    /// to it, so a blank field preserves the historical behaviour rather than
431    /// disabling retention. A positive value tells the backend to reconcile
432    /// the Object Store's `max_age` to that many days (see
433    /// `bootstrap::reconcile_collect_retention`), applied at boot and again
434    /// whenever this document is saved from the SPA. Clamped to
435    /// [`MAX_COLLECT_RETENTION_DAYS`]. The bucket's `max_bytes` cap is
436    /// untouched, so extending the window can't make the store grow unbounded.
437    #[serde(skip_serializing_if = "Option::is_none")]
438    pub collect_retention_days: Option<u32>,
439
440    /// How long `OBJECT_RESULT_OUTPUT` keeps an overflowed stdout/stderr
441    /// blob, in days. Absent / null falls back to
442    /// [`DEFAULT_RESULT_OUTPUT_RETENTION_DAYS`].
443    ///
444    /// Unlike `collect_retention_days`, this window is NOT how long the data
445    /// survives — the results projector derefs each blob into the row before
446    /// the INSERT, so the text lives in SQLite and the SPA reads it from
447    /// there regardless. What it bounds is how long a `-WipeDb`
448    /// re-projection can still recover that text: replay re-derefs, and an
449    /// aged-out object comes back as empty stdout on the rebuilt row.
450    ///
451    /// So this trades recovery window against standing volume, and volume is
452    /// what filled the bucket into its cap (#1321 — a hard wall, not
453    /// eviction) and stopped result delivery fleet-wide. Clamped to
454    /// [`MAX_RESULT_OUTPUT_RETENTION_DAYS`], which is `STREAM_RESULTS`'s own
455    /// window: past it there is no message left to replay.
456    #[serde(skip_serializing_if = "Option::is_none")]
457    pub result_output_retention_days: Option<u32>,
458
459    /// How many hours a freshly-minted login token (SPA / CLI) stays valid
460    /// before the caller must re-authenticate. Read backend-side by the
461    /// login handler when it mints the JWT `exp`; changing it affects
462    /// **tokens minted after the change**, not already-issued ones.
463    ///
464    /// Like [`collect_retention_days`](Self::collect_retention_days) this
465    /// carries a **real built-in default** ([`DEFAULT_SESSION_TTL_HOURS`],
466    /// 24h): `None` (unset) falls back to it, so a blank field resolves to a
467    /// usable window rather than an instantly-expired token. Clamped to
468    /// `1..=`[`MAX_SESSION_TTL_HOURS`]. The SPA renders `24` as a faint
469    /// placeholder on a blank field.
470    #[serde(skip_serializing_if = "Option::is_none")]
471    pub session_ttl_hours: Option<u32>,
472
473    /// Days a `check_status` row may go without a fresh result before the
474    /// Compliance page treats it as **stale** and hides it (#1032②). A PC that
475    /// stops running a check — excluded from its target via a dynamic group,
476    /// decommissioned, or its schedule removed — stops refreshing its row's
477    /// `recorded_at`; once that timestamp is older than this window the row is
478    /// omitted from the default `/api/checks` view and its counts, so a machine
479    /// no longer in scope stops showing as permanently failing a check it never
480    /// runs.
481    ///
482    /// Like [`collect_retention_days`](Self::collect_retention_days) this carries
483    /// a **real built-in default** ([`DEFAULT_CHECK_STATUS_STALE_DAYS`], 30d), so
484    /// the feature works with no configuration. `0` **disables** staleness
485    /// (every row shown, the pre-feature behaviour); a positive value is the
486    /// cutoff, clamped to [`MAX_CHECK_STATUS_STALE_DAYS`]. The row is never
487    /// deleted — hiding is non-destructive so history and the compliance-alert
488    /// prior-status are preserved.
489    #[serde(skip_serializing_if = "Option::is_none")]
490    pub check_status_stale_days: Option<u32>,
491
492    /// Per-bucket disk caps (MiB) for the five NATS Object Stores (#1247)
493    /// — see [`ObjectStoreCaps`]. `None` (unset) ⇒ every bucket resolves
494    /// to its built-in default, so a blank field preserves the out-of-box
495    /// budget. Applied to the backing `OBJ_*` streams at backend boot and
496    /// whenever this document is saved from the SPA
497    /// (`bootstrap::reconcile_object_store_max_bytes`), which is also what
498    /// finally delivers caps to buckets created before the caps existed.
499    #[serde(skip_serializing_if = "Option::is_none")]
500    pub object_store_caps: Option<ObjectStoreCaps>,
501
502    /// Operator-issued support codes — the helpdesk "裏コマンド" that reveals
503    /// `client.unlock`-scoped jobs in the Client App. See [`SupportCode`].
504    ///
505    /// The one non-`Option` field in this document, because a list has an
506    /// honest empty state: `[]` (the default) means **no codes configured**,
507    /// so every `client.unlock` job stays hidden from everyone — the same
508    /// fail-closed "behave as before" the `None`s give the other fields.
509    ///
510    /// Unlike the rest of the document this is read by **agents** as well as
511    /// the backend (each agent verifies a typed code against these hashes
512    /// locally, so an unlock still works during a backend outage), and it is
513    /// **not** editable through the generic `PUT /api/server-settings` merge:
514    /// a secret gets its own set-and-forget endpoint so a redacted document
515    /// round-tripping through the SPA form can never blank a live code.
516    #[serde(skip_serializing_if = "Vec::is_empty")]
517    pub support_codes: Vec<SupportCode>,
518}
519
520impl ServerSettings {
521    /// Built-in defaults applied when a field is unset (`None`) in the
522    /// stored document. Most fields default to `None` (no fleet-meaningful
523    /// default — a blank prune window means "disabled" rather than some
524    /// arbitrary number of days). The exceptions carry real defaults so a
525    /// blank field still resolves to a sensible value:
526    /// [`collect_retention_days`](Self::collect_retention_days)
527    /// ([`DEFAULT_COLLECT_RETENTION_DAYS`], preserving the historical 30-day
528    /// retention), [`result_output_retention_days`](Self::result_output_retention_days)
529    /// ([`DEFAULT_RESULT_OUTPUT_RETENTION_DAYS`], 7 days — deliberately NOT
530    /// the historical 30, see that constant) and
531    /// [`session_ttl_hours`](Self::session_ttl_hours)
532    /// ([`DEFAULT_SESSION_TTL_HOURS`], 24h).
533    ///
534    /// Exposed via `GET /api/server-settings/defaults` so the SPA renders
535    /// these as faint placeholders (mirroring the agent layered-config
536    /// page's built-in floor). Introducing a real default is a one-line
537    /// change here that automatically shows up in the UI and applies to
538    /// every deployment that hasn't overridden the field.
539    pub fn defaults() -> Self {
540        Self {
541            agent_prune_days: None,
542            controller_group: None,
543            mail: None,
544            agent_install: None,
545            collect_retention_days: Some(DEFAULT_COLLECT_RETENTION_DAYS),
546            result_output_retention_days: Some(DEFAULT_RESULT_OUTPUT_RETENTION_DAYS),
547            session_ttl_hours: Some(DEFAULT_SESSION_TTL_HOURS),
548            check_status_stale_days: Some(DEFAULT_CHECK_STATUS_STALE_DAYS),
549            // Real per-bucket defaults, so the SPA renders them as faint
550            // placeholders and unset deployments are capped out of the box.
551            object_store_caps: Some(ObjectStoreCaps {
552                result_output_mib: Some(DEFAULT_RESULT_OUTPUT_CAP_MIB),
553                agent_releases_mib: Some(DEFAULT_AGENT_RELEASES_CAP_MIB),
554                app_packages_mib: Some(DEFAULT_APP_PACKAGES_CAP_MIB),
555                scripts_mib: Some(DEFAULT_SCRIPTS_CAP_MIB),
556                collections_mib: Some(DEFAULT_COLLECTIONS_CAP_MIB),
557            }),
558            // No built-in code: a deployment that configures none has no
559            // unlockable jobs, which is the only safe default for a secret.
560            support_codes: Vec::new(),
561        }
562    }
563
564    /// The caps document with every bucket resolved: stored values where
565    /// set, built-in defaults elsewhere, each clamped to
566    /// `1..=MAX_OBJECT_STORE_CAP_MIB` so an out-of-band KV write can't
567    /// reach the broker unsanitised.
568    pub fn effective_object_store_caps(&self) -> ObjectStoreCaps {
569        let c = self.object_store_caps.clone().unwrap_or_default();
570        ObjectStoreCaps {
571            result_output_mib: Some(c.effective_result_output_mib()),
572            agent_releases_mib: Some(c.effective_agent_releases_mib()),
573            app_packages_mib: Some(c.effective_app_packages_mib()),
574            scripts_mib: Some(c.effective_scripts_mib()),
575            collections_mib: Some(c.effective_collections_mib()),
576        }
577    }
578
579    /// The live code for `scope`, or `None` when the scope has no code, its
580    /// code is disabled, or the hash was blanked (an API-redacted document).
581    /// Every caller of this is a gate, so all three cases fail closed.
582    pub fn support_code(&self, scope: &str) -> Option<&SupportCode> {
583        self.support_codes
584            .iter()
585            .find(|c| c.scope == scope && c.is_usable())
586    }
587
588    /// The same document with every support-code hash blanked — what an HTTP
589    /// response is allowed to contain. Scope / label / TTL stay visible so
590    /// the SPA can list and manage the codes; the secret material never
591    /// leaves the backend, not even to an operator-authenticated caller.
592    /// (`GET /api/server-settings` is viewer+, so an unredacted document
593    /// would hand every read-only account an offline-crackable hash.)
594    ///
595    /// Same treatment for `agent_install.nats_token`: it is a live broker
596    /// credential baked into installer ZIPs, so it never leaves the backend
597    /// either — the response carries only `nats_token_set` (computed HERE,
598    /// never accepted from a client) so the SPA can render "configured".
599    #[must_use]
600    pub fn redacted(mut self) -> Self {
601        for c in &mut self.support_codes {
602            c.hash.clear();
603        }
604        if let Some(ai) = self.agent_install.as_mut() {
605            ai.nats_token_set = ai.nats_token.is_some();
606            ai.nats_token = None;
607        }
608        self
609    }
610
611    /// The configured controller-tier runner group, trimmed, or `None` when
612    /// unset / blank. `None` ⇒ controller-tier jobs run nowhere (fail-safe).
613    pub fn effective_controller_group(&self) -> Option<&str> {
614        self.controller_group
615            .as_deref()
616            .map(str::trim)
617            .filter(|g| !g.is_empty())
618    }
619
620    /// The effective dead-agent prune window in days: the stored value if
621    /// set, else the built-in default, else `0` (= pruning disabled). The
622    /// final `unwrap_or(0)` is the absent-everywhere floor, not a
623    /// user-facing default — the cleanup task treats `0` as "don't prune".
624    ///
625    /// Clamped to [`MAX_AGENT_PRUNE_DAYS`] so the cleanup task's
626    /// `now - Duration::days(n)` can never overflow `DateTime` (and panic
627    /// the task), even if a value larger than the PUT handler allows was
628    /// written to the KV out-of-band.
629    pub fn effective_agent_prune_days(&self) -> u32 {
630        self.agent_prune_days
631            .or(Self::defaults().agent_prune_days)
632            .unwrap_or(0)
633            .min(MAX_AGENT_PRUNE_DAYS)
634    }
635
636    /// The effective collect-bundle retention window in days: the stored
637    /// value if set, else the built-in default ([`DEFAULT_COLLECT_RETENTION_DAYS`]).
638    /// Floored at 1 and clamped to [`MAX_COLLECT_RETENTION_DAYS`] so an
639    /// out-of-band KV write can't reach the Object Store `max_age` unsanitised.
640    /// The floor matters specifically because NATS treats `max_age: 0` as
641    /// **unlimited** retention, not "evict immediately": a stray `0` would
642    /// silently make bundles never expire (defeating the auto-expire intent),
643    /// so we coerce it to the shortest real window (1 day) instead. The PUT
644    /// handler already rejects `0` / over-cap, so this is the belt-and-braces
645    /// path for a hand-edited KV value.
646    pub fn effective_collect_retention_days(&self) -> u32 {
647        self.collect_retention_days
648            .or(Self::defaults().collect_retention_days)
649            .unwrap_or(DEFAULT_COLLECT_RETENTION_DAYS)
650            .clamp(1, MAX_COLLECT_RETENTION_DAYS)
651    }
652
653    /// The effective `result_output` retention window in days: the stored
654    /// value if set, else the built-in default. Floored at 1 and clamped to
655    /// [`MAX_RESULT_OUTPUT_RETENTION_DAYS`] so a hand-written KV value can
656    /// neither disable retention outright nor outlive the stream it exists
657    /// to serve.
658    pub fn effective_result_output_retention_days(&self) -> u32 {
659        self.result_output_retention_days
660            .or(Self::defaults().result_output_retention_days)
661            .unwrap_or(DEFAULT_RESULT_OUTPUT_RETENTION_DAYS)
662            .clamp(1, MAX_RESULT_OUTPUT_RETENTION_DAYS)
663    }
664
665    /// The effective login-token lifetime in hours: the stored value if
666    /// set, else the built-in [`DEFAULT_SESSION_TTL_HOURS`]. Floored at 1
667    /// and clamped to [`MAX_SESSION_TTL_HOURS`] so a zero/absent/out-of-band
668    /// value can never mint an already-expired or overflow-inducing token.
669    /// The PUT handler already rejects `0` / over-cap, so this is the
670    /// belt-and-braces path for a hand-edited KV value.
671    pub fn effective_session_ttl_hours(&self) -> u32 {
672        self.session_ttl_hours
673            .or(Self::defaults().session_ttl_hours)
674            .unwrap_or(DEFAULT_SESSION_TTL_HOURS)
675            .clamp(1, MAX_SESSION_TTL_HOURS)
676    }
677
678    /// The effective check-staleness window in days: the stored value if set,
679    /// else the built-in [`DEFAULT_CHECK_STATUS_STALE_DAYS`]. **`0` means
680    /// disabled** (no row is ever hidden as stale) — deliberately NOT floored to
681    /// 1 (unlike collect/session), because 0 is a meaningful "show everything"
682    /// value here, the same convention as [`agent_prune_days`](Self::agent_prune_days).
683    /// Clamped to [`MAX_CHECK_STATUS_STALE_DAYS`] so an out-of-band KV write
684    /// can't overflow the `now - Duration::days(n)` cutoff math.
685    pub fn effective_check_status_stale_days(&self) -> u32 {
686        self.check_status_stale_days
687            .or(Self::defaults().check_status_stale_days)
688            .unwrap_or(DEFAULT_CHECK_STATUS_STALE_DAYS)
689            .min(MAX_CHECK_STATUS_STALE_DAYS)
690    }
691}
692
693#[cfg(test)]
694mod tests {
695    use super::*;
696
697    #[test]
698    fn default_is_unset() {
699        assert_eq!(ServerSettings::default().agent_prune_days, None);
700    }
701
702    #[test]
703    fn unset_resolves_to_disabled() {
704        // No stored value + no built-in default ⇒ effective 0 (disabled).
705        assert_eq!(ServerSettings::default().effective_agent_prune_days(), 0);
706    }
707
708    #[test]
709    fn stored_value_wins_over_default() {
710        let s = ServerSettings {
711            agent_prune_days: Some(30),
712            ..Default::default()
713        };
714        assert_eq!(s.effective_agent_prune_days(), 30);
715    }
716
717    #[test]
718    fn effective_clamps_to_max() {
719        // An out-of-band KV write larger than the PUT cap must not reach
720        // the cleanup task unclamped (else its DateTime subtraction panics).
721        let s = ServerSettings {
722            agent_prune_days: Some(u32::MAX),
723            ..Default::default()
724        };
725        assert_eq!(s.effective_agent_prune_days(), MAX_AGENT_PRUNE_DAYS);
726    }
727
728    #[test]
729    fn round_trips_through_json() {
730        let s = ServerSettings {
731            agent_prune_days: Some(30),
732            ..Default::default()
733        };
734        let json = serde_json::to_string(&s).unwrap();
735        assert_eq!(json, r#"{"agent_prune_days":30}"#);
736        let back: ServerSettings = serde_json::from_str(&json).unwrap();
737        assert_eq!(back, s);
738    }
739
740    #[test]
741    fn unset_serialises_to_empty_object() {
742        // `skip_serializing_if` keeps an all-unset doc minimal; it must
743        // round-trip back to all-`None`.
744        let s = ServerSettings::default();
745        let json = serde_json::to_string(&s).unwrap();
746        assert_eq!(json, "{}");
747        let back: ServerSettings = serde_json::from_str(&json).unwrap();
748        assert_eq!(back, s);
749    }
750
751    #[test]
752    fn explicit_null_decodes_to_unset() {
753        let s: ServerSettings = serde_json::from_str(r#"{"agent_prune_days":null}"#).unwrap();
754        assert_eq!(s.agent_prune_days, None);
755    }
756
757    #[test]
758    fn empty_object_decodes_to_default() {
759        // A freshly-created key (or one written by an older backend that
760        // didn't know this field) must read back as the pre-feature
761        // behaviour, not fail to decode.
762        let s: ServerSettings = serde_json::from_str("{}").unwrap();
763        assert_eq!(s, ServerSettings::default());
764    }
765
766    #[test]
767    fn controller_group_effective_trims_and_blank_is_unset() {
768        assert_eq!(ServerSettings::default().effective_controller_group(), None);
769        let s = ServerSettings {
770            controller_group: Some("  feed-runners ".into()),
771            ..Default::default()
772        };
773        assert_eq!(s.effective_controller_group(), Some("feed-runners"));
774        // A blank/whitespace value reads as unset (fail-safe: no runner).
775        let blank = ServerSettings {
776            controller_group: Some("   ".into()),
777            ..Default::default()
778        };
779        assert_eq!(blank.effective_controller_group(), None);
780    }
781
782    #[test]
783    fn controller_group_round_trips_and_omits_when_unset() {
784        let s = ServerSettings {
785            controller_group: Some("infra".into()),
786            ..Default::default()
787        };
788        let json = serde_json::to_string(&s).unwrap();
789        assert_eq!(json, r#"{"controller_group":"infra"}"#);
790        assert_eq!(serde_json::from_str::<ServerSettings>(&json).unwrap(), s);
791        // Unset controller_group is omitted (skip_serializing_if).
792        assert_eq!(
793            serde_json::to_string(&ServerSettings::default()).unwrap(),
794            "{}"
795        );
796    }
797
798    #[test]
799    fn mail_round_trips_and_omits_when_unset() {
800        use crate::config::{MailEncryption, MailSection};
801
802        // Unset mail is omitted (skip_serializing_if) — a mail-less doc
803        // stays minimal and decodes back to `None`.
804        assert_eq!(
805            serde_json::to_string(&ServerSettings::default()).unwrap(),
806            "{}"
807        );
808
809        let s = ServerSettings {
810            mail: Some(MailSection {
811                host: "smtp.example.com".into(),
812                port: 587,
813                encryption: MailEncryption::Starttls,
814                from: "kanade-noreply@example.com".into(),
815                username: Some("kanade-noreply".into()),
816            }),
817            ..Default::default()
818        };
819        let json = serde_json::to_string(&s).unwrap();
820        // Encryption serialises lowercase; the password is never present.
821        assert!(json.contains(r#""encryption":"starttls""#), "json: {json}");
822        assert!(!json.contains("password"), "password must never serialise");
823        assert_eq!(serde_json::from_str::<ServerSettings>(&json).unwrap(), s);
824    }
825
826    #[test]
827    fn mail_defaults_to_unset() {
828        assert_eq!(ServerSettings::default().mail, None);
829        // A doc written before this field existed (no `mail` key) decodes
830        // to `None` — email stays a no-op, the pre-feature behaviour.
831        let s: ServerSettings = serde_json::from_str(r#"{"agent_prune_days":7}"#).unwrap();
832        assert_eq!(s.mail, None);
833        assert_eq!(s.agent_prune_days, Some(7));
834    }
835
836    #[test]
837    fn collect_retention_unset_resolves_to_builtin_default() {
838        // Blank (the derived Default) must preserve the historical 30-day
839        // window, not disable retention.
840        assert_eq!(ServerSettings::default().collect_retention_days, None);
841        assert_eq!(
842            ServerSettings::default().effective_collect_retention_days(),
843            DEFAULT_COLLECT_RETENTION_DAYS,
844        );
845        // The defaults() document surfaces the real default so the SPA can
846        // render it as a placeholder.
847        assert_eq!(
848            ServerSettings::defaults().collect_retention_days,
849            Some(DEFAULT_COLLECT_RETENTION_DAYS),
850        );
851    }
852
853    #[test]
854    fn collect_retention_stored_value_wins() {
855        let s = ServerSettings {
856            collect_retention_days: Some(90),
857            ..Default::default()
858        };
859        assert_eq!(s.effective_collect_retention_days(), 90);
860    }
861
862    #[test]
863    fn collect_retention_effective_clamps_out_of_band_writes() {
864        // A hand-written KV value past the PUT cap (or 0) must be clamped so
865        // the reconciled Object Store max_age stays sane.
866        let big = ServerSettings {
867            collect_retention_days: Some(u32::MAX),
868            ..Default::default()
869        };
870        assert_eq!(
871            big.effective_collect_retention_days(),
872            MAX_COLLECT_RETENTION_DAYS,
873        );
874        let zero = ServerSettings {
875            collect_retention_days: Some(0),
876            ..Default::default()
877        };
878        assert_eq!(zero.effective_collect_retention_days(), 1);
879    }
880
881    #[test]
882    fn result_output_retention_defaults_to_a_week_not_the_old_month() {
883        // The built-in default IS the fix: existing buckets were born at 30
884        // days and reconcile down to this. If someone raises it back the
885        // bucket grows again, so the value is pinned rather than left to the
886        // constant's own definition.
887        assert_eq!(DEFAULT_RESULT_OUTPUT_RETENTION_DAYS, 7);
888        assert_eq!(
889            ServerSettings::default().effective_result_output_retention_days(),
890            7
891        );
892    }
893
894    #[test]
895    fn result_output_retention_is_capped_by_the_stream_it_serves() {
896        // Beyond STREAM_RESULTS' own window there is no message left to
897        // replay, so a longer object window keeps blobs nothing can ask for.
898        assert_eq!(MAX_RESULT_OUTPUT_RETENTION_DAYS, 30);
899        let big = ServerSettings {
900            result_output_retention_days: Some(u32::MAX),
901            ..Default::default()
902        };
903        assert_eq!(
904            big.effective_result_output_retention_days(),
905            MAX_RESULT_OUTPUT_RETENTION_DAYS
906        );
907        // …and 0 is floored rather than disabling retention, which would put
908        // the bucket straight back to unbounded growth.
909        let zero = ServerSettings {
910            result_output_retention_days: Some(0),
911            ..Default::default()
912        };
913        assert_eq!(zero.effective_result_output_retention_days(), 1);
914    }
915
916    #[test]
917    fn collect_retention_round_trips_and_omits_when_unset() {
918        let s = ServerSettings {
919            collect_retention_days: Some(90),
920            ..Default::default()
921        };
922        let json = serde_json::to_string(&s).unwrap();
923        assert_eq!(json, r#"{"collect_retention_days":90}"#);
924        assert_eq!(serde_json::from_str::<ServerSettings>(&json).unwrap(), s);
925        // Unset is omitted so a blank doc stays minimal.
926        assert!(
927            !serde_json::to_string(&ServerSettings::default())
928                .unwrap()
929                .contains("collect_retention_days")
930        );
931    }
932
933    #[test]
934    fn session_ttl_unset_resolves_to_builtin_default() {
935        // Blank (the derived Default) must resolve to the 24h default rather
936        // than 0 (which would mint already-expired tokens).
937        assert_eq!(ServerSettings::default().session_ttl_hours, None);
938        assert_eq!(
939            ServerSettings::default().effective_session_ttl_hours(),
940            DEFAULT_SESSION_TTL_HOURS,
941        );
942        // The defaults() document surfaces the real default so the SPA can
943        // render it as a placeholder.
944        assert_eq!(
945            ServerSettings::defaults().session_ttl_hours,
946            Some(DEFAULT_SESSION_TTL_HOURS),
947        );
948    }
949
950    #[test]
951    fn session_ttl_stored_value_wins() {
952        let s = ServerSettings {
953            session_ttl_hours: Some(72),
954            ..Default::default()
955        };
956        assert_eq!(s.effective_session_ttl_hours(), 72);
957    }
958
959    #[test]
960    fn session_ttl_effective_clamps_out_of_band_writes() {
961        // An out-of-band 0 must floor to 1 (else `now + 0h` is an
962        // instantly-expired token); a value past the cap clamps down so
963        // login's date math can't overflow.
964        let zero = ServerSettings {
965            session_ttl_hours: Some(0),
966            ..Default::default()
967        };
968        assert_eq!(zero.effective_session_ttl_hours(), 1);
969        let huge = ServerSettings {
970            session_ttl_hours: Some(u32::MAX),
971            ..Default::default()
972        };
973        assert_eq!(huge.effective_session_ttl_hours(), MAX_SESSION_TTL_HOURS);
974    }
975
976    #[test]
977    fn session_ttl_round_trips_and_omits_when_unset() {
978        let s = ServerSettings {
979            session_ttl_hours: Some(48),
980            ..Default::default()
981        };
982        let json = serde_json::to_string(&s).unwrap();
983        assert_eq!(json, r#"{"session_ttl_hours":48}"#);
984        assert_eq!(serde_json::from_str::<ServerSettings>(&json).unwrap(), s);
985        // Unset is omitted so a blank doc stays minimal.
986        assert!(
987            !serde_json::to_string(&ServerSettings::default())
988                .unwrap()
989                .contains("session_ttl_hours")
990        );
991    }
992
993    #[test]
994    fn check_stale_unset_resolves_to_builtin_default() {
995        // Blank (the derived Default) resolves to the 30-day default — ON out
996        // of the box, so the feature works without configuration.
997        assert_eq!(ServerSettings::default().check_status_stale_days, None);
998        assert_eq!(
999            ServerSettings::default().effective_check_status_stale_days(),
1000            DEFAULT_CHECK_STATUS_STALE_DAYS,
1001        );
1002        // defaults() surfaces the real default for the SPA placeholder.
1003        assert_eq!(
1004            ServerSettings::defaults().check_status_stale_days,
1005            Some(DEFAULT_CHECK_STATUS_STALE_DAYS),
1006        );
1007    }
1008
1009    #[test]
1010    fn check_stale_zero_disables() {
1011        // Explicit 0 means "disable staleness" (show everything) — NOT floored
1012        // to 1 like collect/session; same convention as agent_prune_days.
1013        let s = ServerSettings {
1014            check_status_stale_days: Some(0),
1015            ..Default::default()
1016        };
1017        assert_eq!(s.effective_check_status_stale_days(), 0);
1018    }
1019
1020    #[test]
1021    fn check_stale_stored_value_wins_and_clamps() {
1022        let s = ServerSettings {
1023            check_status_stale_days: Some(7),
1024            ..Default::default()
1025        };
1026        assert_eq!(s.effective_check_status_stale_days(), 7);
1027        let big = ServerSettings {
1028            check_status_stale_days: Some(u32::MAX),
1029            ..Default::default()
1030        };
1031        assert_eq!(
1032            big.effective_check_status_stale_days(),
1033            MAX_CHECK_STATUS_STALE_DAYS,
1034        );
1035    }
1036
1037    #[test]
1038    fn check_stale_round_trips_and_omits_when_unset() {
1039        let s = ServerSettings {
1040            check_status_stale_days: Some(14),
1041            ..Default::default()
1042        };
1043        let json = serde_json::to_string(&s).unwrap();
1044        assert_eq!(json, r#"{"check_status_stale_days":14}"#);
1045        assert_eq!(serde_json::from_str::<ServerSettings>(&json).unwrap(), s);
1046        assert!(
1047            !serde_json::to_string(&ServerSettings::default())
1048                .unwrap()
1049                .contains("check_status_stale_days")
1050        );
1051    }
1052
1053    #[test]
1054    fn support_codes_absent_by_default_and_omitted_from_the_wire() {
1055        // The pre-feature document must round-trip byte-identically: no
1056        // `support_codes` key, so an older backend / agent reading it sees
1057        // exactly what it saw before, and nothing is unlockable.
1058        let s = ServerSettings::default();
1059        assert!(s.support_codes.is_empty());
1060        assert_eq!(serde_json::to_string(&s).unwrap(), "{}");
1061        assert!(s.support_code("support").is_none());
1062    }
1063
1064    #[test]
1065    fn support_code_lookup_fails_closed() {
1066        let s = ServerSettings {
1067            support_codes: vec![
1068                SupportCode {
1069                    scope: "support".into(),
1070                    hash: "$argon2id$v=19$m=19456,t=2,p=1$c2FsdA$aGFzaA".into(),
1071                    label: Some("ヘルプデスク".into()),
1072                    ..Default::default()
1073                },
1074                SupportCode {
1075                    scope: "admin".into(),
1076                    hash: "$argon2id$v=19$m=19456,t=2,p=1$c2FsdA$aGFzaA".into(),
1077                    disabled: true,
1078                    ..Default::default()
1079                },
1080                SupportCode {
1081                    // Hash blanked — what an API-redacted document looks like.
1082                    // It must never be treated as a live code.
1083                    scope: "blank".into(),
1084                    ..Default::default()
1085                },
1086            ],
1087            ..Default::default()
1088        };
1089        assert!(s.support_code("support").is_some());
1090        assert!(s.support_code("admin").is_none(), "disabled must not match");
1091        assert!(
1092            s.support_code("blank").is_none(),
1093            "blank hash must not match"
1094        );
1095        assert!(s.support_code("nope").is_none());
1096    }
1097
1098    #[test]
1099    fn redacted_blanks_hashes_but_keeps_the_roster() {
1100        // The SPA still needs to see WHICH scopes exist (to list / rotate /
1101        // delete them); it must never see the hash.
1102        let s = ServerSettings {
1103            support_codes: vec![SupportCode {
1104                scope: "support".into(),
1105                hash: "$argon2id$secret".into(),
1106                label: Some("ヘルプデスク".into()),
1107                ttl_minutes: Some(30),
1108                disabled: false,
1109            }],
1110            ..Default::default()
1111        };
1112        let json = serde_json::to_string(&s.redacted()).unwrap();
1113        assert!(!json.contains("argon2"), "wire leaked the hash: {json}");
1114        assert!(!json.contains("hash"), "wire leaked the hash key: {json}");
1115        assert!(json.contains("\"scope\":\"support\""), "wire: {json}");
1116        assert!(json.contains("\"ttl_minutes\":30"), "wire: {json}");
1117    }
1118
1119    #[test]
1120    fn support_code_ttl_clamps() {
1121        let unset = SupportCode::default();
1122        assert_eq!(
1123            unset.effective_ttl_minutes(),
1124            DEFAULT_SUPPORT_UNLOCK_TTL_MINUTES
1125        );
1126        // 0 would mint an already-expired grant (an unlock that silently
1127        // does nothing) — floored to the shortest real window instead.
1128        let zero = SupportCode {
1129            ttl_minutes: Some(0),
1130            ..Default::default()
1131        };
1132        assert_eq!(zero.effective_ttl_minutes(), 1);
1133        let huge = SupportCode {
1134            ttl_minutes: Some(u32::MAX),
1135            ..Default::default()
1136        };
1137        assert_eq!(huge.effective_ttl_minutes(), MAX_SUPPORT_UNLOCK_TTL_MINUTES);
1138    }
1139
1140    #[test]
1141    fn accepts_unknown_fields_for_forward_compat() {
1142        // A newer backend may have added knobs this build doesn't know;
1143        // decoding must drop them rather than error.
1144        let json = r#"{"agent_prune_days":7,"some_future_knob":true}"#;
1145        let s: ServerSettings = serde_json::from_str(json).unwrap();
1146        assert_eq!(s.agent_prune_days, Some(7));
1147    }
1148
1149    #[test]
1150    fn object_store_caps_unset_resolves_to_builtin_defaults() {
1151        // Blank doc ⇒ every bucket capped at its built-in default — the
1152        // out-of-box fix for uncapped / drifted buckets (#1247).
1153        let s = ServerSettings::default();
1154        assert_eq!(s.object_store_caps, None);
1155        let c = s.effective_object_store_caps();
1156        assert_eq!(c.result_output_mib, Some(DEFAULT_RESULT_OUTPUT_CAP_MIB));
1157        assert_eq!(c.agent_releases_mib, Some(DEFAULT_AGENT_RELEASES_CAP_MIB));
1158        assert_eq!(c.app_packages_mib, Some(DEFAULT_APP_PACKAGES_CAP_MIB));
1159        assert_eq!(c.scripts_mib, Some(DEFAULT_SCRIPTS_CAP_MIB));
1160        assert_eq!(c.collections_mib, Some(DEFAULT_COLLECTIONS_CAP_MIB));
1161    }
1162
1163    #[test]
1164    fn object_store_caps_partial_override_keeps_other_defaults() {
1165        let s = ServerSettings {
1166            object_store_caps: Some(ObjectStoreCaps {
1167                app_packages_mib: Some(8192),
1168                ..Default::default()
1169            }),
1170            ..Default::default()
1171        };
1172        let c = s.effective_object_store_caps();
1173        assert_eq!(c.app_packages_mib, Some(8192));
1174        assert_eq!(c.scripts_mib, Some(DEFAULT_SCRIPTS_CAP_MIB));
1175    }
1176
1177    #[test]
1178    fn object_store_caps_clamps_out_of_band_writes() {
1179        let s = ServerSettings {
1180            object_store_caps: Some(ObjectStoreCaps {
1181                result_output_mib: Some(0),
1182                app_packages_mib: Some(u32::MAX),
1183                ..Default::default()
1184            }),
1185            ..Default::default()
1186        };
1187        let c = s.effective_object_store_caps();
1188        // 0 floors to 1 MiB — NATS treats max_bytes: 0 as unlimited, the
1189        // exact failure mode this feature exists to remove.
1190        assert_eq!(c.result_output_mib, Some(1));
1191        assert_eq!(c.app_packages_mib, Some(MAX_OBJECT_STORE_CAP_MIB));
1192    }
1193
1194    #[test]
1195    fn object_store_caps_round_trips_and_omits_when_unset() {
1196        let s = ServerSettings {
1197            object_store_caps: Some(ObjectStoreCaps {
1198                scripts_mib: Some(512),
1199                ..Default::default()
1200            }),
1201            ..Default::default()
1202        };
1203        let json = serde_json::to_string(&s).unwrap();
1204        assert_eq!(json, r#"{"object_store_caps":{"scripts_mib":512}}"#);
1205        assert_eq!(serde_json::from_str::<ServerSettings>(&json).unwrap(), s);
1206        assert!(
1207            !serde_json::to_string(&ServerSettings::default())
1208                .unwrap()
1209                .contains("object_store_caps")
1210        );
1211    }
1212
1213    #[test]
1214    fn agent_install_defaults_to_unset_and_omits_when_unset() {
1215        assert_eq!(ServerSettings::default().agent_install, None);
1216        assert_eq!(ServerSettings::defaults().agent_install, None);
1217        // A doc written before this field existed decodes to `None` — the
1218        // installer then falls back to the backend's own [nats] url, the
1219        // pre-feature behaviour.
1220        let s: ServerSettings = serde_json::from_str(r#"{"agent_prune_days":7}"#).unwrap();
1221        assert_eq!(s.agent_install, None);
1222        assert!(
1223            !serde_json::to_string(&ServerSettings::default())
1224                .unwrap()
1225                .contains("agent_install")
1226        );
1227    }
1228
1229    #[test]
1230    fn agent_install_round_trips() {
1231        let s = ServerSettings {
1232            agent_install: Some(AgentInstallSection {
1233                nats_url: Some("nats://broker.corp:4222".into()),
1234                nats_token: Some("s3cret".into()),
1235                nats_token_set: false,
1236                require_signed_commands: None,
1237            }),
1238            ..Default::default()
1239        };
1240        let json = serde_json::to_string(&s).unwrap();
1241        assert_eq!(
1242            json,
1243            r#"{"agent_install":{"nats_url":"nats://broker.corp:4222","nats_token":"s3cret","nats_token_set":false}}"#
1244        );
1245        assert_eq!(serde_json::from_str::<ServerSettings>(&json).unwrap(), s);
1246    }
1247
1248    #[test]
1249    fn agent_install_require_signed_commands_round_trips() {
1250        let s = ServerSettings {
1251            agent_install: Some(AgentInstallSection {
1252                require_signed_commands: Some(true),
1253                ..Default::default()
1254            }),
1255            ..Default::default()
1256        };
1257        let json = serde_json::to_string(&s).unwrap();
1258        assert_eq!(
1259            json,
1260            r#"{"agent_install":{"nats_token_set":false,"require_signed_commands":true}}"#
1261        );
1262        assert_eq!(serde_json::from_str::<ServerSettings>(&json).unwrap(), s);
1263    }
1264
1265    #[test]
1266    fn agent_install_token_set_is_never_accepted_from_the_wire() {
1267        // `skip_deserializing`: a client sending nats_token_set gets it
1268        // dropped, so the indicator can only ever come from redacted()
1269        // looking at the real stored token — never from a claim.
1270        let s: ServerSettings = serde_json::from_str(
1271            r#"{"agent_install":{"nats_url":"nats://b:4222","nats_token_set":true}}"#,
1272        )
1273        .unwrap();
1274        let ai = s.agent_install.unwrap();
1275        assert!(!ai.nats_token_set);
1276        assert_eq!(ai.nats_url.as_deref(), Some("nats://b:4222"));
1277    }
1278
1279    #[test]
1280    fn redacted_strips_the_install_token_but_reports_its_presence() {
1281        // GET /api/server-settings is viewer+, so the stored NATS token must
1282        // never survive redacted() — not even to an operator. The boolean is
1283        // all the SPA gets to render "configured".
1284        let with_token = ServerSettings {
1285            agent_install: Some(AgentInstallSection {
1286                nats_url: Some("nats://broker.corp:4222".into()),
1287                nats_token: Some("s3cret".into()),
1288                nats_token_set: false,
1289                require_signed_commands: None,
1290            }),
1291            ..Default::default()
1292        };
1293        let redacted = with_token.redacted();
1294        let ai = redacted.agent_install.as_ref().unwrap();
1295        assert_eq!(ai.nats_token, None, "token must not survive redacted()");
1296        assert!(ai.nats_token_set);
1297        // The URL is not a secret — it stays.
1298        assert_eq!(ai.nats_url.as_deref(), Some("nats://broker.corp:4222"));
1299        // And on the wire the token key is gone entirely (not null).
1300        let json = serde_json::to_string(&redacted).unwrap();
1301        assert!(!json.contains("s3cret"), "wire leaked the token: {json}");
1302        assert!(!json.contains("nats_token\""), "wire: {json}");
1303        assert!(json.contains(r#""nats_token_set":true"#), "wire: {json}");
1304
1305        // No token configured → indicator false, nothing to strip.
1306        let sans_token = ServerSettings {
1307            agent_install: Some(AgentInstallSection {
1308                nats_url: Some("nats://broker.corp:4222".into()),
1309                ..Default::default()
1310            }),
1311            ..Default::default()
1312        }
1313        .redacted();
1314        let ai = sans_token.agent_install.as_ref().unwrap();
1315        assert!(!ai.nats_token_set);
1316    }
1317}