Skip to main content

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}
78
79/// TOML projection of [`crate::AdminConfig`]. Separate from the runtime
80/// type because (a) `AdminConfig` is constructed from a vector of owned
81/// strings and doesn't itself derive `Deserialize`, (b) keeping the
82/// wire shape here avoids coupling the server module to figment/serde.
83#[derive(Debug, Clone, Default, Deserialize)]
84pub struct AdminConfigToml {
85    /// Operator-declared label values. When `Some`, `applyLabel` only
86    /// accepts values in this set. When absent, any val ≤128 bytes is
87    /// accepted.
88    #[serde(default)]
89    pub label_values: Option<Vec<String>>,
90}
91
92/// Labeler policy (§F1, §6.4) — the content of the
93/// `app.bsky.labeler.service` record that `cairn publish-service-record`
94/// emits to the operator's PDS. Field-name mapping to the wire shape is
95/// via `#[serde(rename)]` at the runtime-side boundary in
96/// [`crate::service_record`]; TOML stays snake_case for operator ergonomics.
97#[derive(Debug, Clone, serde::Serialize, Deserialize)]
98pub struct LabelerConfigToml {
99    /// Short-name list of labels this instance will emit (§6.4
100    /// `policies.labelValues`). Every value here must also have a
101    /// matching definition in `label_value_definitions` unless it's a
102    /// global well-known value (§6.5). Publishing rejects if a non-
103    /// global identifier has no definition.
104    pub label_values: Vec<String>,
105    /// Per-label metadata entries (§6.4
106    /// `policies.labelValueDefinitions`). Empty vec is legal — means
107    /// only global values, no custom definitions.
108    #[serde(default)]
109    pub label_value_definitions: Vec<LabelValueDefinitionToml>,
110    /// Optional §6.4 `reasonTypes`. Typically the
111    /// `com.atproto.moderation.defs#reason*` set matching createReport
112    /// (§F11).
113    #[serde(default)]
114    pub reason_types: Vec<String>,
115    /// Optional §6.4 `subjectTypes` (e.g. `["account", "record"]`).
116    #[serde(default)]
117    pub subject_types: Vec<String>,
118    /// Optional §6.4 `subjectCollections` (e.g.
119    /// `["app.bsky.feed.post"]`).
120    #[serde(default)]
121    pub subject_collections: Vec<String>,
122}
123
124/// Single entry in `labelValueDefinitions`. §6.4 constraints:
125/// `severity` + `blurs` + `locales` required; `locales` must be
126/// non-empty; `default_setting` + `adult_only` optional.
127#[derive(Debug, Clone, serde::Serialize, Deserialize)]
128pub struct LabelValueDefinitionToml {
129    /// The label value this definition describes (matches an entry
130    /// in [`LabelerConfigToml::label_values`]).
131    pub identifier: String,
132    /// §6.4 severity — how consumers should weight this label.
133    pub severity: SeverityToml,
134    /// §6.4 blur policy — whether consumer UIs should obscure
135    /// content or media on a match.
136    pub blurs: BlursToml,
137    /// Optional §6.4 default consumer-side setting. Omit to let
138    /// consumers pick their own default.
139    #[serde(default)]
140    pub default_setting: Option<DefaultSettingToml>,
141    /// Optional §6.4 flag marking the label as 18+ only.
142    #[serde(default)]
143    pub adult_only: Option<bool>,
144    /// Non-empty list of localized display strings (§6.4 requires
145    /// ≥1 locale per definition).
146    pub locales: Vec<LocaleToml>,
147}
148
149/// §6.4 severity enum.
150#[derive(Debug, Clone, Copy, serde::Serialize, Deserialize)]
151#[serde(rename_all = "lowercase")]
152pub enum SeverityToml {
153    /// Informational; consumers typically don't gate on this.
154    Inform,
155    /// Alert the viewer; consumers generally surface a warning.
156    Alert,
157    /// No severity signal.
158    None,
159}
160
161/// §6.4 blur policy — what consumer UIs should obscure on a match.
162#[derive(Debug, Clone, Copy, serde::Serialize, Deserialize)]
163#[serde(rename_all = "lowercase")]
164pub enum BlursToml {
165    /// Blur the post / record body.
166    Content,
167    /// Blur embedded media only.
168    Media,
169    /// No blurring.
170    None,
171}
172
173/// §6.4 default consumer-side setting.
174#[derive(Debug, Clone, Copy, serde::Serialize, Deserialize)]
175#[serde(rename_all = "lowercase")]
176pub enum DefaultSettingToml {
177    /// Consumers default to showing the content with no treatment.
178    Ignore,
179    /// Consumers default to surfacing a warning.
180    Warn,
181    /// Consumers default to hiding the content.
182    Hide,
183}
184
185/// Localized display strings for a label value definition (§6.4).
186#[derive(Debug, Clone, serde::Serialize, Deserialize)]
187pub struct LocaleToml {
188    /// BCP-47 language tag (e.g. `"en"`, `"fr-CA"`).
189    pub lang: String,
190    /// Short display name shown in consumer UIs.
191    pub name: String,
192    /// Longer explanation, typically shown on tooltip / expand.
193    pub description: String,
194}
195
196/// Operator-side PDS auth config (§F1). Scope is narrow — just the
197/// PDS URL + session file path. Named `[operator]` today; if future
198/// operator-identity fields land (contact, alerts, etc.) the table
199/// stays small enough to nest those in, or split to `[operator.pds]`
200/// then.
201#[derive(Debug, Clone, Deserialize)]
202pub struct OperatorConfigToml {
203    /// PDS base URL (e.g. `https://bsky.social`). No default — the
204    /// labeler owner's PDS varies per deployment.
205    pub pds_url: String,
206    /// On-disk path for the operator session file. Written by
207    /// `cairn operator-login`, read by `cairn publish-service-record`.
208    /// Same §5.3 invariants as the moderator session file (mode 0600,
209    /// owned by running user) via the shared
210    /// `crate::credential_file` helper.
211    pub session_path: std::path::PathBuf,
212}
213
214fn default_bind_addr() -> SocketAddr {
215    DEFAULT_BIND_ADDR
216        .parse()
217        .expect("DEFAULT_BIND_ADDR is a valid socket address")
218}
219
220impl Config {
221    /// Post-load validation run by [`Config::load`]. Exposed so call
222    /// sites that construct a `Config` directly (e.g., tests) can
223    /// share the same rule set.
224    pub fn validate(&self) -> Result<()> {
225        url::Url::parse(&self.service_endpoint).map_err(|e| {
226            crate::error::Error::Signing(format!("config.service_endpoint is not a valid URL: {e}"))
227        })?;
228        // Path existence of db_path / signing_key_path is checked at
229        // use time by storage::open and SigningKey::load_from_file —
230        // duplicating here would just double-fail and lose the
231        // specific cause.
232        Ok(())
233    }
234
235    /// Load configuration from the default TOML location + env
236    /// overrides (see [`Self::load_from`] for the full precedence
237    /// rules). The default location is `CAIRN_CONFIG` env var, or
238    /// `/etc/cairn/cairn.toml` if unset.
239    pub fn load() -> Result<Self> {
240        let toml_path: PathBuf = std::env::var_os("CAIRN_CONFIG")
241            .map(PathBuf::from)
242            .unwrap_or_else(|| PathBuf::from("/etc/cairn/cairn.toml"));
243        Self::load_from(Some(&toml_path))
244    }
245
246    /// Load configuration with an explicit TOML path (or `None` to
247    /// skip the file layer entirely and rely on env overrides).
248    ///
249    /// Sources, low to high precedence:
250    /// 1. Compiled-in defaults (`bind_addr` if unset, empty admin
251    ///    table).
252    /// 2. `toml_path` if `Some` and the file exists.
253    /// 3. Environment variables prefixed `CAIRN_`
254    ///    (e.g. `CAIRN_SERVICE_DID`).
255    ///
256    /// `cairn serve --config <path>` routes through this without
257    /// mutating process env (which is `unsafe` under Rust 2024 and
258    /// blocked by the crate's `#![forbid(unsafe_code)]`).
259    pub fn load_from(toml_path: Option<&std::path::Path>) -> Result<Self> {
260        let mut fig = Figment::new();
261        if let Some(p) = toml_path
262            && p.is_file()
263        {
264            fig = fig.merge(Toml::file(p));
265        }
266        fig = fig.merge(Env::prefixed("CAIRN_"));
267
268        let cfg: Config = fig.extract()?;
269        cfg.validate()?;
270        Ok(cfg)
271    }
272}
273
274impl From<AdminConfigToml> for crate::AdminConfig {
275    fn from(t: AdminConfigToml) -> Self {
276        crate::AdminConfig {
277            label_values: t.label_values,
278        }
279    }
280}