cairn_mod/config.rs
1//! Layered config loading.
2//!
3//! Precedence (lowest → highest): compiled-in defaults, then TOML file,
4//! then environment variables prefixed `CAIRN_`. CLI-flag overrides land
5//! once the clap surface grows subcommands.
6//!
7//! Per §5.1, signing-key private material is NEVER sourced through this
8//! path. `signing_key_path` points at a file; the bytes are read only by
9//! [`crate::signing_key::SigningKey::load_from_file`] with explicit
10//! permission, ownership, and env-var-reject checks.
11
12use figment::{
13 Figment,
14 providers::{Env, Format, Toml},
15};
16use serde::Deserialize;
17use std::collections::BTreeMap;
18use std::net::SocketAddr;
19use std::path::PathBuf;
20
21use crate::error::Result;
22
23/// Default bind address when `bind_addr` is absent from config.
24/// §F13: "expects reverse proxy for TLS." Loopback-only by default;
25/// operators knowingly opt into `0.0.0.0:3000` when they run without
26/// a reverse proxy on the same host.
27pub const DEFAULT_BIND_ADDR: &str = "127.0.0.1:3000";
28
29/// Top-level Cairn configuration.
30///
31/// Fields grow with features; the struct is `non_exhaustive` so additions
32/// are not a breaking change for downstream crates.
33#[derive(Debug, Clone, Deserialize)]
34#[non_exhaustive]
35pub struct Config {
36 /// The service DID Cairn runs as (§5.1).
37 pub service_did: String,
38 /// Publicly-reachable base URL where this Cairn instance serves HTTP
39 /// and WebSocket endpoints (e.g., `https://labeler.example`). Emitted
40 /// as the `serviceEndpoint` value in the `AtprotoLabeler` entry of
41 /// `/.well-known/did.json` so consumers can discover where to call.
42 ///
43 /// This is distinct from [`Self::bind_addr`] — typical production
44 /// deployments bind `127.0.0.1:3000` behind a reverse proxy but
45 /// advertise the public `https://labeler.example` URL here.
46 /// Validated at load time as a URL.
47 pub service_endpoint: String,
48 /// Where `cairn serve` binds its HTTP listener. Defaults to
49 /// [`DEFAULT_BIND_ADDR`] (`127.0.0.1:3000`) if omitted.
50 #[serde(default = "default_bind_addr")]
51 pub bind_addr: SocketAddr,
52 /// SQLite database file. Parent directory must exist; the file
53 /// itself is created on first run by
54 /// [`crate::storage::open`] alongside embedded migrations.
55 pub db_path: PathBuf,
56 /// Signing key file (§5.1). Mode 0600, owned by the running user,
57 /// hex-encoded 32-byte secp256k1 private key. Env-var delivery of
58 /// the key material is explicitly rejected — see
59 /// [`crate::signing_key::SIGNING_KEY_ENV_REJECTED`].
60 pub signing_key_path: PathBuf,
61 /// Admin-endpoint policy (§F12). Defaults to an empty table,
62 /// meaning `admin.applyLabel` accepts any label value ≤128 bytes
63 /// (matches the existing [`crate::AdminConfig`] default).
64 #[serde(default)]
65 pub admin: AdminConfigToml,
66 /// Labeler policy (§F1) — the `app.bsky.labeler.service` record
67 /// content. Required for `cairn publish-service-record`; other
68 /// subcommands don't consume it, so it's optional at load time.
69 /// When absent, `publish-service-record` surfaces a clear error.
70 #[serde(default)]
71 pub labeler: Option<LabelerConfigToml>,
72 /// Operator PDS auth surface (§F1 service record publishing).
73 /// The operator's identity is the DID that OWNS the labeler
74 /// account — distinct from moderators who authenticate to Cairn
75 /// (§5.2) and distinct from Cairn's own signing key (§5.1).
76 #[serde(default)]
77 pub operator: Option<OperatorConfigToml>,
78 /// Retention sweep execution policy (§F4 sweep task). Holds
79 /// schedule + batching knobs only — the cutoff itself
80 /// (`retention_days`) is owned by [`crate::SubscribeConfig`] so
81 /// the read-side floor and the sweep cutoff stay tied to a
82 /// single source of truth. Defaults match §F4 prose: enabled,
83 /// 04:00 UTC, 1000-row batches.
84 #[serde(default)]
85 pub retention: RetentionConfigToml,
86 /// Reason vocabulary for the v1.4 graduated-action moderation
87 /// model (§F20, #47). TOML projection of the operator's
88 /// `[moderation_reasons.<identifier>]` blocks; resolve to a
89 /// runtime [`crate::moderation::reasons::ReasonVocabulary`] via
90 /// `ReasonVocabulary::from_config`.
91 ///
92 /// Three states:
93 /// - `None` — operator declared no blocks; the resolver loads
94 /// shipped defaults (eight reasons covering common categories).
95 /// - `Some(empty)` — operator wrote a bare `[moderation_reasons]`
96 /// header with no sub-blocks; rejected at validate time as a
97 /// probable typo.
98 /// - `Some(non_empty)` — operator's vocabulary is the complete
99 /// set; defaults are NOT merged in.
100 #[serde(default)]
101 pub moderation_reasons: Option<BTreeMap<String, ReasonDefToml>>,
102 /// Strike policy for the v1.4 graduated-action moderation
103 /// model (§F20, #48). TOML projection of the operator's
104 /// `[strike_policy]` block; resolve to a runtime
105 /// [`crate::moderation::policy::StrikePolicy`] via
106 /// `StrikePolicy::from_config`.
107 ///
108 /// Two states:
109 /// - `None` — operator declared no block; the resolver returns
110 /// shipped defaults (threshold 3, curve [1, 2], linear decay
111 /// over 90 days, suspensions freeze decay).
112 /// - `Some(_)` — partial or full operator declaration. Per-field
113 /// serde defaults fill any unspecified sub-fields; the
114 /// resolved values are validated together (curve length
115 /// convention, strict-ascending curve, positive decay window).
116 #[serde(default)]
117 pub strike_policy: Option<StrikePolicyToml>,
118}
119
120/// TOML projection of one entry in
121/// [`Config::moderation_reasons`]. The runtime equivalent
122/// (with validated identifier) is
123/// [`crate::moderation::reasons::ReasonDef`].
124#[derive(Debug, Clone, Deserialize)]
125pub struct ReasonDefToml {
126 /// Strike weight applied at action time, before dampening.
127 /// Validated at config-load time as `>= 1`.
128 pub base_weight: u32,
129 /// When `true`, this reason bypasses the dampening curve
130 /// regardless of the subject's standing. Defaults to `false`
131 /// when omitted from the TOML.
132 #[serde(default)]
133 pub severe: bool,
134 /// Operator-facing label describing what the reason means.
135 /// Required and non-empty; validated at config-load time.
136 pub description: String,
137}
138
139/// TOML projection of [`crate::moderation::policy::StrikePolicy`].
140/// Each sub-field carries a `#[serde(default = "...")]` so a partial
141/// `[strike_policy]` declaration fills the rest from the shipped
142/// defaults. The fully-defaulted struct matches
143/// [`crate::moderation::policy::StrikePolicy::defaults`].
144#[derive(Debug, Clone, Deserialize)]
145pub struct StrikePolicyToml {
146 /// Strike count at or below which a subject is in good standing.
147 /// Default 3. Validated together with `dampening_curve` —
148 /// curve length must equal `max(0, threshold - 1)`. See
149 /// [`crate::moderation::policy`] module docs for the worked
150 /// example.
151 #[serde(default = "default_good_standing_threshold")]
152 pub good_standing_threshold: u32,
153 /// Per-position dampening weights for in-good-standing offenses.
154 /// Default `[1, 2]`. Validated as strictly ascending with each
155 /// entry `>= 1`; length tied to `good_standing_threshold`.
156 #[serde(default = "default_dampening_curve")]
157 pub dampening_curve: Vec<u32>,
158 /// Decay shape applied to each action's strike contribution as
159 /// time passes. Default [`crate::moderation::policy::DecayFunction::Linear`].
160 /// Unknown variants fail at deserialize time.
161 #[serde(default = "default_decay_function")]
162 pub decay_function: crate::moderation::policy::DecayFunction,
163 /// Window over which `decay_function` operates, in days. Default
164 /// 90. Validated as `>= 1`; no upper bound.
165 #[serde(default = "default_decay_window_days")]
166 pub decay_window_days: u32,
167 /// When `true`, decay halts while the subject has an active
168 /// `indef_suspension` action. Default `true`.
169 #[serde(default = "default_suspension_freezes_decay")]
170 pub suspension_freezes_decay: bool,
171 /// How long the `subject_strike_state` cache row remains
172 /// "fresh" before a reader should recompute via the decay
173 /// calculator (#55). Default 3600 (1 hour). Validated `>= 1`.
174 /// Has no effect on v1.4 read endpoints, which always
175 /// recompute from source-of-truth regardless; ships ahead of
176 /// v1.5+ consumers that may read the cache directly.
177 #[serde(default = "default_cache_freshness_window_seconds")]
178 pub cache_freshness_window_seconds: u32,
179}
180
181impl Default for StrikePolicyToml {
182 fn default() -> Self {
183 Self {
184 good_standing_threshold: default_good_standing_threshold(),
185 dampening_curve: default_dampening_curve(),
186 decay_function: default_decay_function(),
187 decay_window_days: default_decay_window_days(),
188 suspension_freezes_decay: default_suspension_freezes_decay(),
189 cache_freshness_window_seconds: default_cache_freshness_window_seconds(),
190 }
191 }
192}
193
194fn default_good_standing_threshold() -> u32 {
195 3
196}
197fn default_dampening_curve() -> Vec<u32> {
198 vec![1, 2]
199}
200fn default_decay_function() -> crate::moderation::policy::DecayFunction {
201 crate::moderation::policy::DecayFunction::Linear
202}
203fn default_decay_window_days() -> u32 {
204 90
205}
206fn default_suspension_freezes_decay() -> bool {
207 true
208}
209fn default_cache_freshness_window_seconds() -> u32 {
210 3600
211}
212
213/// TOML projection of [`crate::AdminConfig`]. Separate from the runtime
214/// type because (a) `AdminConfig` is constructed from a vector of owned
215/// strings and doesn't itself derive `Deserialize`, (b) keeping the
216/// wire shape here avoids coupling the server module to figment/serde.
217#[derive(Debug, Clone, Default, Deserialize)]
218pub struct AdminConfigToml {
219 /// Operator-declared label values. When `Some`, `applyLabel` only
220 /// accepts values in this set. When absent, any val ≤128 bytes is
221 /// accepted.
222 #[serde(default)]
223 pub label_values: Option<Vec<String>>,
224}
225
226/// Labeler policy (§F1, §6.4) — the content of the
227/// `app.bsky.labeler.service` record that `cairn publish-service-record`
228/// emits to the operator's PDS. Field-name mapping to the wire shape is
229/// via `#[serde(rename)]` at the runtime-side boundary in
230/// [`crate::service_record`]; TOML stays snake_case for operator ergonomics.
231#[derive(Debug, Clone, serde::Serialize, Deserialize)]
232pub struct LabelerConfigToml {
233 /// Short-name list of labels this instance will emit (§6.4
234 /// `policies.labelValues`). Every value here must also have a
235 /// matching definition in `label_value_definitions` unless it's a
236 /// global well-known value (§6.5). Publishing rejects if a non-
237 /// global identifier has no definition.
238 pub label_values: Vec<String>,
239 /// Per-label metadata entries (§6.4
240 /// `policies.labelValueDefinitions`). Empty vec is legal — means
241 /// only global values, no custom definitions.
242 #[serde(default)]
243 pub label_value_definitions: Vec<LabelValueDefinitionToml>,
244 /// Optional §6.4 `reasonTypes`. Typically the
245 /// `com.atproto.moderation.defs#reason*` set matching createReport
246 /// (§F11).
247 #[serde(default)]
248 pub reason_types: Vec<String>,
249 /// Optional §6.4 `subjectTypes` (e.g. `["account", "record"]`).
250 #[serde(default)]
251 pub subject_types: Vec<String>,
252 /// Optional §6.4 `subjectCollections` (e.g.
253 /// `["app.bsky.feed.post"]`).
254 #[serde(default)]
255 pub subject_collections: Vec<String>,
256}
257
258/// Single entry in `labelValueDefinitions`. §6.4 constraints:
259/// `severity` + `blurs` + `locales` required; `locales` must be
260/// non-empty; `default_setting` + `adult_only` optional.
261#[derive(Debug, Clone, serde::Serialize, Deserialize)]
262pub struct LabelValueDefinitionToml {
263 /// The label value this definition describes (matches an entry
264 /// in [`LabelerConfigToml::label_values`]).
265 pub identifier: String,
266 /// §6.4 severity — how consumers should weight this label.
267 pub severity: SeverityToml,
268 /// §6.4 blur policy — whether consumer UIs should obscure
269 /// content or media on a match.
270 pub blurs: BlursToml,
271 /// Optional §6.4 default consumer-side setting. Omit to let
272 /// consumers pick their own default.
273 #[serde(default)]
274 pub default_setting: Option<DefaultSettingToml>,
275 /// Optional §6.4 flag marking the label as 18+ only.
276 #[serde(default)]
277 pub adult_only: Option<bool>,
278 /// Non-empty list of localized display strings (§6.4 requires
279 /// ≥1 locale per definition).
280 pub locales: Vec<LocaleToml>,
281}
282
283/// §6.4 severity enum.
284#[derive(Debug, Clone, Copy, serde::Serialize, Deserialize)]
285#[serde(rename_all = "lowercase")]
286pub enum SeverityToml {
287 /// Informational; consumers typically don't gate on this.
288 Inform,
289 /// Alert the viewer; consumers generally surface a warning.
290 Alert,
291 /// No severity signal.
292 None,
293}
294
295/// §6.4 blur policy — what consumer UIs should obscure on a match.
296#[derive(Debug, Clone, Copy, serde::Serialize, Deserialize)]
297#[serde(rename_all = "lowercase")]
298pub enum BlursToml {
299 /// Blur the post / record body.
300 Content,
301 /// Blur embedded media only.
302 Media,
303 /// No blurring.
304 None,
305}
306
307/// §6.4 default consumer-side setting.
308#[derive(Debug, Clone, Copy, serde::Serialize, Deserialize)]
309#[serde(rename_all = "lowercase")]
310pub enum DefaultSettingToml {
311 /// Consumers default to showing the content with no treatment.
312 Ignore,
313 /// Consumers default to surfacing a warning.
314 Warn,
315 /// Consumers default to hiding the content.
316 Hide,
317}
318
319/// Localized display strings for a label value definition (§6.4).
320#[derive(Debug, Clone, serde::Serialize, Deserialize)]
321pub struct LocaleToml {
322 /// BCP-47 language tag (e.g. `"en"`, `"fr-CA"`).
323 pub lang: String,
324 /// Short display name shown in consumer UIs.
325 pub name: String,
326 /// Longer explanation, typically shown on tooltip / expand.
327 pub description: String,
328}
329
330/// TOML projection of [`crate::RetentionConfig`]. Field names match
331/// the runtime struct one-for-one; serde defaults mirror
332/// [`crate::RetentionConfig::default()`] so an absent `[retention]`
333/// block produces the §F4 default policy without operator opt-in.
334///
335/// `retention_days` is *not* on this struct — it lives under
336/// [`crate::SubscribeConfig`] and is consumed by both the read-side
337/// floor (`query_oldest_retained`) and the sweep cutoff. Splitting
338/// it would risk drift between the floor and the sweep window.
339#[derive(Debug, Clone, Deserialize)]
340pub struct RetentionConfigToml {
341 /// Master toggle for the scheduled sweep. Default `true`.
342 #[serde(default = "default_sweep_enabled")]
343 pub sweep_enabled: bool,
344 /// UTC hour-of-day (0..=23) for the scheduled sweep. Default 4.
345 /// Validated by [`Config::validate`].
346 #[serde(default = "default_sweep_run_at_utc_hour")]
347 pub sweep_run_at_utc_hour: u8,
348 /// Rows per DELETE transaction. Default 1000.
349 #[serde(default = "default_sweep_batch_size")]
350 pub sweep_batch_size: i64,
351}
352
353impl Default for RetentionConfigToml {
354 fn default() -> Self {
355 Self {
356 sweep_enabled: default_sweep_enabled(),
357 sweep_run_at_utc_hour: default_sweep_run_at_utc_hour(),
358 sweep_batch_size: default_sweep_batch_size(),
359 }
360 }
361}
362
363fn default_sweep_enabled() -> bool {
364 true
365}
366fn default_sweep_run_at_utc_hour() -> u8 {
367 4
368}
369fn default_sweep_batch_size() -> i64 {
370 1000
371}
372
373/// Operator-side PDS auth config (§F1). Scope is narrow — just the
374/// PDS URL + session file path. Named `[operator]` today; if future
375/// operator-identity fields land (contact, alerts, etc.) the table
376/// stays small enough to nest those in, or split to `[operator.pds]`
377/// then.
378#[derive(Debug, Clone, Deserialize)]
379pub struct OperatorConfigToml {
380 /// PDS base URL (e.g. `https://bsky.social`). No default — the
381 /// labeler owner's PDS varies per deployment.
382 pub pds_url: String,
383 /// On-disk path for the operator session file. Written by
384 /// `cairn operator-login`, read by `cairn publish-service-record`.
385 /// Same §5.3 invariants as the moderator session file (mode 0600,
386 /// owned by running user) via the shared
387 /// `crate::credential_file` helper.
388 pub session_path: std::path::PathBuf,
389}
390
391fn default_bind_addr() -> SocketAddr {
392 DEFAULT_BIND_ADDR
393 .parse()
394 .expect("DEFAULT_BIND_ADDR is a valid socket address")
395}
396
397impl Config {
398 /// Post-load validation run by [`Config::load`]. Exposed so call
399 /// sites that construct a `Config` directly (e.g., tests) can
400 /// share the same rule set.
401 pub fn validate(&self) -> Result<()> {
402 url::Url::parse(&self.service_endpoint).map_err(|e| {
403 crate::error::Error::Signing(format!("config.service_endpoint is not a valid URL: {e}"))
404 })?;
405 if self.retention.sweep_run_at_utc_hour >= 24 {
406 return Err(crate::error::Error::Signing(format!(
407 "config.retention.sweep_run_at_utc_hour={} is out of range (0..=23)",
408 self.retention.sweep_run_at_utc_hour
409 )));
410 }
411 if self.retention.sweep_batch_size <= 0 {
412 return Err(crate::error::Error::Signing(format!(
413 "config.retention.sweep_batch_size={} must be > 0",
414 self.retention.sweep_batch_size
415 )));
416 }
417 // Reason vocabulary (§F20, #47): build via the canonical
418 // resolver and discard. If the operator declared an empty
419 // [moderation_reasons] section, an invalid identifier, a
420 // zero base_weight, or an empty description, the resolver
421 // returns an Error::Signing here that surfaces as a
422 // config-load failure at startup.
423 let _ = crate::moderation::reasons::ReasonVocabulary::from_config(self)?;
424 // Strike policy (§F20, #48): same single-source-of-truth
425 // pattern. The resolver applies the curve-length convention
426 // (`max(0, threshold - 1)`), strict-ascending check, and
427 // decay-window-days >= 1 check. See
428 // [`crate::moderation::policy`] for the full validation
429 // rules.
430 let _ = crate::moderation::policy::StrikePolicy::from_config(self)?;
431 // Path existence of db_path / signing_key_path is checked at
432 // use time by storage::open and SigningKey::load_from_file —
433 // duplicating here would just double-fail and lose the
434 // specific cause.
435 Ok(())
436 }
437
438 /// Load configuration from the default TOML location + env
439 /// overrides (see [`Self::load_from`] for the full precedence
440 /// rules). The default location is `CAIRN_CONFIG` env var, or
441 /// `/etc/cairn/cairn.toml` if unset.
442 pub fn load() -> Result<Self> {
443 let toml_path: PathBuf = std::env::var_os("CAIRN_CONFIG")
444 .map(PathBuf::from)
445 .unwrap_or_else(|| PathBuf::from("/etc/cairn/cairn.toml"));
446 Self::load_from(Some(&toml_path))
447 }
448
449 /// Load configuration with an explicit TOML path (or `None` to
450 /// skip the file layer entirely and rely on env overrides).
451 ///
452 /// Sources, low to high precedence:
453 /// 1. Compiled-in defaults (`bind_addr` if unset, empty admin
454 /// table).
455 /// 2. `toml_path` if `Some` and the file exists.
456 /// 3. Environment variables prefixed `CAIRN_`
457 /// (e.g. `CAIRN_SERVICE_DID`).
458 ///
459 /// `cairn serve --config <path>` routes through this without
460 /// mutating process env (which is `unsafe` under Rust 2024 and
461 /// blocked by the crate's `#![forbid(unsafe_code)]`).
462 pub fn load_from(toml_path: Option<&std::path::Path>) -> Result<Self> {
463 let mut fig = Figment::new();
464 if let Some(p) = toml_path
465 && p.is_file()
466 {
467 fig = fig.merge(Toml::file(p));
468 }
469 fig = fig.merge(Env::prefixed("CAIRN_"));
470
471 let cfg: Config = fig.extract()?;
472 cfg.validate()?;
473 Ok(cfg)
474 }
475}
476
477impl From<AdminConfigToml> for crate::AdminConfig {
478 fn from(t: AdminConfigToml) -> Self {
479 // service_did / service_endpoint / declared_label_values are
480 // populated separately at admin_router-construction time
481 // (see `serve::run`) — they live elsewhere in `Config` and
482 // would needlessly couple [admin] to those fields if pulled
483 // through here.
484 crate::AdminConfig {
485 label_values: t.label_values,
486 ..Default::default()
487 }
488 }
489}
490
491impl From<RetentionConfigToml> for crate::RetentionConfig {
492 fn from(t: RetentionConfigToml) -> Self {
493 crate::RetentionConfig {
494 sweep_enabled: t.sweep_enabled,
495 sweep_run_at_utc_hour: t.sweep_run_at_utc_hour,
496 sweep_batch_size: t.sweep_batch_size,
497 }
498 }
499}