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::net::SocketAddr;
18use std::path::PathBuf;
19
20use crate::error::Result;
21
22/// Default bind address when `bind_addr` is absent from config.
23/// §F13: "expects reverse proxy for TLS." Loopback-only by default;
24/// operators knowingly opt into `0.0.0.0:3000` when they run without
25/// a reverse proxy on the same host.
26pub const DEFAULT_BIND_ADDR: &str = "127.0.0.1:3000";
27
28/// Top-level Cairn configuration.
29///
30/// Fields grow with features; the struct is `non_exhaustive` so additions
31/// are not a breaking change for downstream crates.
32#[derive(Debug, Clone, Deserialize)]
33#[non_exhaustive]
34pub struct Config {
35 /// The service DID Cairn runs as (§5.1).
36 pub service_did: String,
37 /// Publicly-reachable base URL where this Cairn instance serves HTTP
38 /// and WebSocket endpoints (e.g., `https://labeler.example`). Emitted
39 /// as the `serviceEndpoint` value in the `AtprotoLabeler` entry of
40 /// `/.well-known/did.json` so consumers can discover where to call.
41 ///
42 /// This is distinct from [`Self::bind_addr`] — typical production
43 /// deployments bind `127.0.0.1:3000` behind a reverse proxy but
44 /// advertise the public `https://labeler.example` URL here.
45 /// Validated at load time as a URL.
46 pub service_endpoint: String,
47 /// Where `cairn serve` binds its HTTP listener. Defaults to
48 /// [`DEFAULT_BIND_ADDR`] (`127.0.0.1:3000`) if omitted.
49 #[serde(default = "default_bind_addr")]
50 pub bind_addr: SocketAddr,
51 /// SQLite database file. Parent directory must exist; the file
52 /// itself is created on first run by
53 /// [`crate::storage::open`] alongside embedded migrations.
54 pub db_path: PathBuf,
55 /// Signing key file (§5.1). Mode 0600, owned by the running user,
56 /// hex-encoded 32-byte secp256k1 private key. Env-var delivery of
57 /// the key material is explicitly rejected — see
58 /// [`crate::signing_key::SIGNING_KEY_ENV_REJECTED`].
59 pub signing_key_path: PathBuf,
60 /// Admin-endpoint policy (§F12). Defaults to an empty table,
61 /// meaning `admin.applyLabel` accepts any label value ≤128 bytes
62 /// (matches the existing [`crate::AdminConfig`] default).
63 #[serde(default)]
64 pub admin: AdminConfigToml,
65 /// Labeler policy (§F1) — the `app.bsky.labeler.service` record
66 /// content. Required for `cairn publish-service-record`; other
67 /// subcommands don't consume it, so it's optional at load time.
68 /// When absent, `publish-service-record` surfaces a clear error.
69 #[serde(default)]
70 pub labeler: Option<LabelerConfigToml>,
71 /// Operator PDS auth surface (§F1 service record publishing).
72 /// The operator's identity is the DID that OWNS the labeler
73 /// account — distinct from moderators who authenticate to Cairn
74 /// (§5.2) and distinct from Cairn's own signing key (§5.1).
75 #[serde(default)]
76 pub operator: Option<OperatorConfigToml>,
77 /// Retention sweep execution policy (§F4 sweep task). Holds
78 /// schedule + batching knobs only — the cutoff itself
79 /// (`retention_days`) is owned by [`crate::SubscribeConfig`] so
80 /// the read-side floor and the sweep cutoff stay tied to a
81 /// single source of truth. Defaults match §F4 prose: enabled,
82 /// 04:00 UTC, 1000-row batches.
83 #[serde(default)]
84 pub retention: RetentionConfigToml,
85}
86
87/// TOML projection of [`crate::AdminConfig`]. Separate from the runtime
88/// type because (a) `AdminConfig` is constructed from a vector of owned
89/// strings and doesn't itself derive `Deserialize`, (b) keeping the
90/// wire shape here avoids coupling the server module to figment/serde.
91#[derive(Debug, Clone, Default, Deserialize)]
92pub struct AdminConfigToml {
93 /// Operator-declared label values. When `Some`, `applyLabel` only
94 /// accepts values in this set. When absent, any val ≤128 bytes is
95 /// accepted.
96 #[serde(default)]
97 pub label_values: Option<Vec<String>>,
98}
99
100/// Labeler policy (§F1, §6.4) — the content of the
101/// `app.bsky.labeler.service` record that `cairn publish-service-record`
102/// emits to the operator's PDS. Field-name mapping to the wire shape is
103/// via `#[serde(rename)]` at the runtime-side boundary in
104/// [`crate::service_record`]; TOML stays snake_case for operator ergonomics.
105#[derive(Debug, Clone, serde::Serialize, Deserialize)]
106pub struct LabelerConfigToml {
107 /// Short-name list of labels this instance will emit (§6.4
108 /// `policies.labelValues`). Every value here must also have a
109 /// matching definition in `label_value_definitions` unless it's a
110 /// global well-known value (§6.5). Publishing rejects if a non-
111 /// global identifier has no definition.
112 pub label_values: Vec<String>,
113 /// Per-label metadata entries (§6.4
114 /// `policies.labelValueDefinitions`). Empty vec is legal — means
115 /// only global values, no custom definitions.
116 #[serde(default)]
117 pub label_value_definitions: Vec<LabelValueDefinitionToml>,
118 /// Optional §6.4 `reasonTypes`. Typically the
119 /// `com.atproto.moderation.defs#reason*` set matching createReport
120 /// (§F11).
121 #[serde(default)]
122 pub reason_types: Vec<String>,
123 /// Optional §6.4 `subjectTypes` (e.g. `["account", "record"]`).
124 #[serde(default)]
125 pub subject_types: Vec<String>,
126 /// Optional §6.4 `subjectCollections` (e.g.
127 /// `["app.bsky.feed.post"]`).
128 #[serde(default)]
129 pub subject_collections: Vec<String>,
130}
131
132/// Single entry in `labelValueDefinitions`. §6.4 constraints:
133/// `severity` + `blurs` + `locales` required; `locales` must be
134/// non-empty; `default_setting` + `adult_only` optional.
135#[derive(Debug, Clone, serde::Serialize, Deserialize)]
136pub struct LabelValueDefinitionToml {
137 /// The label value this definition describes (matches an entry
138 /// in [`LabelerConfigToml::label_values`]).
139 pub identifier: String,
140 /// §6.4 severity — how consumers should weight this label.
141 pub severity: SeverityToml,
142 /// §6.4 blur policy — whether consumer UIs should obscure
143 /// content or media on a match.
144 pub blurs: BlursToml,
145 /// Optional §6.4 default consumer-side setting. Omit to let
146 /// consumers pick their own default.
147 #[serde(default)]
148 pub default_setting: Option<DefaultSettingToml>,
149 /// Optional §6.4 flag marking the label as 18+ only.
150 #[serde(default)]
151 pub adult_only: Option<bool>,
152 /// Non-empty list of localized display strings (§6.4 requires
153 /// ≥1 locale per definition).
154 pub locales: Vec<LocaleToml>,
155}
156
157/// §6.4 severity enum.
158#[derive(Debug, Clone, Copy, serde::Serialize, Deserialize)]
159#[serde(rename_all = "lowercase")]
160pub enum SeverityToml {
161 /// Informational; consumers typically don't gate on this.
162 Inform,
163 /// Alert the viewer; consumers generally surface a warning.
164 Alert,
165 /// No severity signal.
166 None,
167}
168
169/// §6.4 blur policy — what consumer UIs should obscure on a match.
170#[derive(Debug, Clone, Copy, serde::Serialize, Deserialize)]
171#[serde(rename_all = "lowercase")]
172pub enum BlursToml {
173 /// Blur the post / record body.
174 Content,
175 /// Blur embedded media only.
176 Media,
177 /// No blurring.
178 None,
179}
180
181/// §6.4 default consumer-side setting.
182#[derive(Debug, Clone, Copy, serde::Serialize, Deserialize)]
183#[serde(rename_all = "lowercase")]
184pub enum DefaultSettingToml {
185 /// Consumers default to showing the content with no treatment.
186 Ignore,
187 /// Consumers default to surfacing a warning.
188 Warn,
189 /// Consumers default to hiding the content.
190 Hide,
191}
192
193/// Localized display strings for a label value definition (§6.4).
194#[derive(Debug, Clone, serde::Serialize, Deserialize)]
195pub struct LocaleToml {
196 /// BCP-47 language tag (e.g. `"en"`, `"fr-CA"`).
197 pub lang: String,
198 /// Short display name shown in consumer UIs.
199 pub name: String,
200 /// Longer explanation, typically shown on tooltip / expand.
201 pub description: String,
202}
203
204/// TOML projection of [`crate::RetentionConfig`]. Field names match
205/// the runtime struct one-for-one; serde defaults mirror
206/// [`crate::RetentionConfig::default()`] so an absent `[retention]`
207/// block produces the §F4 default policy without operator opt-in.
208///
209/// `retention_days` is *not* on this struct — it lives under
210/// [`crate::SubscribeConfig`] and is consumed by both the read-side
211/// floor (`query_oldest_retained`) and the sweep cutoff. Splitting
212/// it would risk drift between the floor and the sweep window.
213#[derive(Debug, Clone, Deserialize)]
214pub struct RetentionConfigToml {
215 /// Master toggle for the scheduled sweep. Default `true`.
216 #[serde(default = "default_sweep_enabled")]
217 pub sweep_enabled: bool,
218 /// UTC hour-of-day (0..=23) for the scheduled sweep. Default 4.
219 /// Validated by [`Config::validate`].
220 #[serde(default = "default_sweep_run_at_utc_hour")]
221 pub sweep_run_at_utc_hour: u8,
222 /// Rows per DELETE transaction. Default 1000.
223 #[serde(default = "default_sweep_batch_size")]
224 pub sweep_batch_size: i64,
225}
226
227impl Default for RetentionConfigToml {
228 fn default() -> Self {
229 Self {
230 sweep_enabled: default_sweep_enabled(),
231 sweep_run_at_utc_hour: default_sweep_run_at_utc_hour(),
232 sweep_batch_size: default_sweep_batch_size(),
233 }
234 }
235}
236
237fn default_sweep_enabled() -> bool {
238 true
239}
240fn default_sweep_run_at_utc_hour() -> u8 {
241 4
242}
243fn default_sweep_batch_size() -> i64 {
244 1000
245}
246
247/// Operator-side PDS auth config (§F1). Scope is narrow — just the
248/// PDS URL + session file path. Named `[operator]` today; if future
249/// operator-identity fields land (contact, alerts, etc.) the table
250/// stays small enough to nest those in, or split to `[operator.pds]`
251/// then.
252#[derive(Debug, Clone, Deserialize)]
253pub struct OperatorConfigToml {
254 /// PDS base URL (e.g. `https://bsky.social`). No default — the
255 /// labeler owner's PDS varies per deployment.
256 pub pds_url: String,
257 /// On-disk path for the operator session file. Written by
258 /// `cairn operator-login`, read by `cairn publish-service-record`.
259 /// Same §5.3 invariants as the moderator session file (mode 0600,
260 /// owned by running user) via the shared
261 /// `crate::credential_file` helper.
262 pub session_path: std::path::PathBuf,
263}
264
265fn default_bind_addr() -> SocketAddr {
266 DEFAULT_BIND_ADDR
267 .parse()
268 .expect("DEFAULT_BIND_ADDR is a valid socket address")
269}
270
271impl Config {
272 /// Post-load validation run by [`Config::load`]. Exposed so call
273 /// sites that construct a `Config` directly (e.g., tests) can
274 /// share the same rule set.
275 pub fn validate(&self) -> Result<()> {
276 url::Url::parse(&self.service_endpoint).map_err(|e| {
277 crate::error::Error::Signing(format!("config.service_endpoint is not a valid URL: {e}"))
278 })?;
279 if self.retention.sweep_run_at_utc_hour >= 24 {
280 return Err(crate::error::Error::Signing(format!(
281 "config.retention.sweep_run_at_utc_hour={} is out of range (0..=23)",
282 self.retention.sweep_run_at_utc_hour
283 )));
284 }
285 if self.retention.sweep_batch_size <= 0 {
286 return Err(crate::error::Error::Signing(format!(
287 "config.retention.sweep_batch_size={} must be > 0",
288 self.retention.sweep_batch_size
289 )));
290 }
291 // Path existence of db_path / signing_key_path is checked at
292 // use time by storage::open and SigningKey::load_from_file —
293 // duplicating here would just double-fail and lose the
294 // specific cause.
295 Ok(())
296 }
297
298 /// Load configuration from the default TOML location + env
299 /// overrides (see [`Self::load_from`] for the full precedence
300 /// rules). The default location is `CAIRN_CONFIG` env var, or
301 /// `/etc/cairn/cairn.toml` if unset.
302 pub fn load() -> Result<Self> {
303 let toml_path: PathBuf = std::env::var_os("CAIRN_CONFIG")
304 .map(PathBuf::from)
305 .unwrap_or_else(|| PathBuf::from("/etc/cairn/cairn.toml"));
306 Self::load_from(Some(&toml_path))
307 }
308
309 /// Load configuration with an explicit TOML path (or `None` to
310 /// skip the file layer entirely and rely on env overrides).
311 ///
312 /// Sources, low to high precedence:
313 /// 1. Compiled-in defaults (`bind_addr` if unset, empty admin
314 /// table).
315 /// 2. `toml_path` if `Some` and the file exists.
316 /// 3. Environment variables prefixed `CAIRN_`
317 /// (e.g. `CAIRN_SERVICE_DID`).
318 ///
319 /// `cairn serve --config <path>` routes through this without
320 /// mutating process env (which is `unsafe` under Rust 2024 and
321 /// blocked by the crate's `#![forbid(unsafe_code)]`).
322 pub fn load_from(toml_path: Option<&std::path::Path>) -> Result<Self> {
323 let mut fig = Figment::new();
324 if let Some(p) = toml_path
325 && p.is_file()
326 {
327 fig = fig.merge(Toml::file(p));
328 }
329 fig = fig.merge(Env::prefixed("CAIRN_"));
330
331 let cfg: Config = fig.extract()?;
332 cfg.validate()?;
333 Ok(cfg)
334 }
335}
336
337impl From<AdminConfigToml> for crate::AdminConfig {
338 fn from(t: AdminConfigToml) -> Self {
339 // service_did / service_endpoint / declared_label_values are
340 // populated separately at admin_router-construction time
341 // (see `serve::run`) — they live elsewhere in `Config` and
342 // would needlessly couple [admin] to those fields if pulled
343 // through here.
344 crate::AdminConfig {
345 label_values: t.label_values,
346 ..Default::default()
347 }
348 }
349}
350
351impl From<RetentionConfigToml> for crate::RetentionConfig {
352 fn from(t: RetentionConfigToml) -> Self {
353 crate::RetentionConfig {
354 sweep_enabled: t.sweep_enabled,
355 sweep_run_at_utc_hour: t.sweep_run_at_utc_hour,
356 sweep_batch_size: t.sweep_batch_size,
357 }
358 }
359}