Skip to main content

cratefield_core/
config.rs

1//! Typed configuration: the [`Config`] trait and the error type that
2//! `Harness::build` uses to report every problem at once (issue #2), plus
3//! the [`ModuleConfig`] helper modules read keys through (issue #3).
4//!
5//! Keys are `SCREAMING_SNAKE`; module keys are prefixed with the module
6//! name, e.g. `EMAIL_SIGNUP_CONFIRM_TTL_DAYS`.
7
8use std::fmt;
9
10use crate::ports::signer::Kid;
11use crate::signer::{HmacSigner, KeyRing, MIN_SECRET_BYTES};
12use crate::venture::VentureEnv;
13
14/// Read-only key/value configuration, resolved per runtime from environment
15/// variables and secrets (Workers `Env`) or the process environment.
16///
17/// Keys are `SCREAMING_SNAKE`; module keys are prefixed with the module name,
18/// e.g. `EMAIL_SIGNUP_CONFIRM_TTL_DAYS`.
19pub trait Config: Send + Sync {
20    fn get(&self, key: &str) -> Option<String>;
21}
22
23/// A configuration that always returns `None` (tests, offline builds).
24#[derive(Debug, Default, Clone, Copy)]
25pub struct EmptyConfig;
26
27impl Config for EmptyConfig {
28    fn get(&self, _key: &str) -> Option<String> {
29        None
30    }
31}
32
33/// Accumulates every configuration problem so `Harness::build` can report
34/// them together instead of one at a time.
35#[derive(Debug, Default, Clone)]
36pub struct ConfigError {
37    /// Human-readable problem descriptions, one per line of output.
38    pub problems: Vec<String>,
39}
40
41impl ConfigError {
42    pub fn new() -> Self {
43        Self::default()
44    }
45
46    pub fn push(&mut self, problem: impl Into<String>) {
47        self.problems.push(problem.into());
48    }
49
50    pub fn is_empty(&self) -> bool {
51        self.problems.is_empty()
52    }
53
54    /// `Err(self)` when any problem was recorded.
55    ///
56    /// # Errors
57    ///
58    /// `Err` with every recorded problem joined in its `Display`.
59    pub fn into_result(self) -> Result<(), Self> {
60        if self.is_empty() { Ok(()) } else { Err(self) }
61    }
62}
63
64impl fmt::Display for ConfigError {
65    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
66        write!(f, "invalid harness configuration:")?;
67        for problem in &self.problems {
68            write!(f, "\n  - {problem}")?;
69        }
70        Ok(())
71    }
72}
73
74impl std::error::Error for ConfigError {}
75
76/// Typed view over a [`Config`] for one module: prefixes every key with the
77/// module name in `SCREAMING_SNAKE` and parses values with defaults
78/// (issue #3).
79///
80/// ```
81/// use cratefield_core::{Config, ModuleConfig};
82/// # struct MapConfig(std::collections::HashMap<String, String>);
83/// # impl Config for MapConfig {
84/// #     fn get(&self, key: &str) -> Option<String> {
85/// #         self.0.get(key).cloned()
86/// #     }
87/// # }
88/// let cfg = MapConfig(
89///     [("EMAIL_SIGNUP_CONFIRM_TTL_DAYS".to_string(), "3".to_string())]
90///         .into_iter()
91///         .collect(),
92/// );
93/// let module = ModuleConfig::new("email-signup", &cfg);
94/// assert_eq!(module.get_u32("CONFIRM_TTL_DAYS", 7), 3);
95/// assert_eq!(module.get_bool("DOUBLE_OPT_IN", true), true);
96/// assert_eq!(module.get_str("FROM_NAME", "Factory Zero"), "Factory Zero");
97/// ```
98pub struct ModuleConfig<'a> {
99    prefix: String,
100    config: &'a dyn Config,
101}
102
103fn screaming_snake(name: &str) -> String {
104    let mut out = String::with_capacity(name.len() + 8);
105    for ch in name.chars() {
106        if ch == '-' || ch == '_' {
107            out.push('_');
108        } else {
109            out.extend(ch.to_uppercase());
110        }
111    }
112    out
113}
114
115impl<'a> ModuleConfig<'a> {
116    pub fn new(module_name: &str, config: &'a dyn Config) -> Self {
117        Self {
118            prefix: screaming_snake(module_name),
119            config,
120        }
121    }
122
123    /// The fully-qualified key for a module-suffix key.
124    pub fn key(&self, suffix: &str) -> String {
125        format!("{}_{}", self.prefix, screaming_snake(suffix))
126    }
127
128    pub fn get_str(&self, key_suffix: &str, default: &str) -> String {
129        self.config
130            .get(&self.key(key_suffix))
131            .unwrap_or_else(|| default.to_string())
132    }
133
134    pub fn get_u32(&self, key_suffix: &str, default: u32) -> u32 {
135        self.config
136            .get(&self.key(key_suffix))
137            .and_then(|raw| raw.parse().ok())
138            .unwrap_or(default)
139    }
140
141    pub fn get_bool(&self, key_suffix: &str, default: bool) -> bool {
142        match self.config.get(&self.key(key_suffix)) {
143            Some(raw) => matches!(
144                raw.to_ascii_lowercase().as_str(),
145                "1" | "true" | "yes" | "on"
146            ),
147            None => default,
148        }
149    }
150
151    /// An explicitly-set string key, `None` when absent.
152    pub fn get_opt(&self, key_suffix: &str) -> Option<String> {
153        self.config.get(&self.key(key_suffix))
154    }
155}
156
157/// The harness-level keys, parsed once from the environment `Config`
158/// (issue #3): `HARNESS_SECRET` (required, ≥ 32 bytes),
159/// `HARNESS_SECRET_PREVIOUS` (optional), `ADMIN_TOKEN` (optional, ≥ 32
160/// bytes when set — issue #437),
161/// `ENV` (`development|staging|production`, default `development`), and
162/// the issue #137 key-ring keys: `HARNESS_SECRET_REVOKED` (optional,
163/// comma-separated key ids whose tokens must be refused immediately) and
164/// `HARNESS_VENTURE` (optional label binding tokens to this venture, so
165/// one venture's links cannot verify in another that shares its secret by
166/// mistake).
167///
168/// Dummy secrets only, in tests:
169/// `HARNESS_SECRET = "test-secret-0123456789abcdef-0123"`.
170#[derive(Debug, Clone)]
171pub struct HarnessConfig {
172    pub harness_secret: String,
173    pub harness_secret_previous: Option<String>,
174    /// Key ids revoked at boot (issue #137). Ids and states, never
175    /// key material — this list is safe to log.
176    pub harness_secret_revoked: Vec<String>,
177    /// The venture binding label for issued tokens (issue #137).
178    pub harness_venture: Option<String>,
179    pub admin_token: Option<String>,
180    pub env: VentureEnv,
181}
182
183impl HarnessConfig {
184    /// # Errors
185    ///
186    /// One problem per invalid key, reported together: missing or short
187    /// `HARNESS_SECRET`, a set-but-short `ADMIN_TOKEN` (issue #437),
188    /// unknown `ENV` value.
189    pub fn from_config(config: &dyn Config) -> Result<Self, ConfigError> {
190        let mut errors = ConfigError::default();
191
192        let harness_secret = match config.get("HARNESS_SECRET") {
193            Some(secret) if secret.len() >= MIN_SECRET_BYTES => Some(secret),
194            Some(_) => {
195                errors.push(format!(
196                    "HARNESS_SECRET must be at least {MIN_SECRET_BYTES} bytes"
197                ));
198                None
199            }
200            None => {
201                errors.push(format!(
202                    "HARNESS_SECRET is required (min {MIN_SECRET_BYTES} bytes)"
203                ));
204                None
205            }
206        };
207        let env = match config.get("ENV").as_deref() {
208            None | Some("") => Some(VentureEnv::Development),
209            Some(raw) => {
210                let parsed = VentureEnv::parse(raw);
211                if parsed.is_none() {
212                    errors.push(format!(
213                        "ENV must be one of development|staging|production, got {raw:?}"
214                    ));
215                }
216                parsed
217            }
218        };
219
220        // Revoking "cur" is a secret rotation, not a ring state change,
221        // and silently signing nothing would take every confirm link
222        // down: the id is dropped here and named in the runbook.
223        let harness_secret_revoked: Vec<String> = config
224            .get("HARNESS_SECRET_REVOKED")
225            .map(|raw| {
226                raw.split(',')
227                    .map(str::trim)
228                    .filter(|kid| !kid.is_empty() && *kid != "cur")
229                    .map(<str>::to_owned)
230                    .collect()
231            })
232            .unwrap_or_default();
233
234        // An absent token keeps admin disabled the way it always was:
235        // every admin route answers 401, and probing cannot tell that
236        // from a missing header. A present one is a bearer secret like
237        // `HARNESS_SECRET` is a signing key, and gets the same floor
238        // (issue #437) — the readiness gate refuses to serve `/admin`
239        // without a rate limiter, so the token it protects should not be
240        // brute-forceable in the first place.
241        let admin_token = match config.get("ADMIN_TOKEN") {
242            Some(token) if token.len() >= MIN_SECRET_BYTES => Some(token),
243            Some(_) => {
244                errors.push(format!(
245                    "ADMIN_TOKEN must be at least {MIN_SECRET_BYTES} bytes when set (remove it \
246                     to keep the admin routes disabled)"
247                ));
248                None
249            }
250            None => None,
251        };
252
253        errors.into_result()?;
254        Ok(Self {
255            harness_secret: harness_secret.unwrap_or_default(),
256            harness_secret_previous: config.get("HARNESS_SECRET_PREVIOUS"),
257            harness_secret_revoked,
258            harness_venture: config
259                .get("HARNESS_VENTURE")
260                .map(|value| value.trim().to_owned())
261                .filter(|value| !value.is_empty()),
262            admin_token,
263            env: env.unwrap_or_default(),
264        })
265    }
266
267    /// The HMAC signer for this configuration: a bounded key ring (ADR
268    /// 0006 as amended by ADR 0014, issue #137). `HARNESS_SECRET` is the
269    /// signing key, `HARNESS_SECRET_PREVIOUS` a verification-only key, and
270    /// every id named in `HARNESS_SECRET_REVOKED` is burned before the
271    /// ring is frozen. Tokens are bound to `HARNESS_VENTURE` and `ENV` so
272    /// a link minted in one venture or environment never verifies in
273    /// another.
274    ///
275    /// # Panics
276    ///
277    /// Only when `from_config` was bypassed with an invalid secret.
278    pub fn signer(&self) -> HmacSigner {
279        let mut ring = KeyRing::new();
280        ring.rotate_signing(Kid::Cur, self.harness_secret.clone().into_bytes())
281            .expect("from_config validated the secret");
282        if let Some(previous) = &self.harness_secret_previous {
283            let _ = ring.add_verifying_only(Kid::Prev, previous.clone().into_bytes());
284        }
285        for kid in &self.harness_secret_revoked {
286            let key = match kid.as_str() {
287                "prev" => Kid::Prev,
288                other => Kid::named(other.to_owned()),
289            };
290            ring.revoke(&key);
291        }
292        HmacSigner::from_ring(ring).with_binding(
293            self.harness_venture.clone(),
294            Some(self.env.as_str().to_owned()),
295        )
296    }
297}
298
299/// A `Config` backed by a map (tests, `fz doctor` with process env).
300#[derive(Debug, Clone, Default)]
301pub struct MapConfig(pub std::collections::HashMap<String, String>);
302
303impl MapConfig {
304    pub fn from_pairs(
305        pairs: impl IntoIterator<Item = (impl Into<String>, impl Into<String>)>,
306    ) -> Self {
307        Self(
308            pairs
309                .into_iter()
310                .map(|(k, v)| (k.into(), v.into()))
311                .collect(),
312        )
313    }
314}
315
316impl Config for MapConfig {
317    fn get(&self, key: &str) -> Option<String> {
318        self.0.get(key).cloned()
319    }
320}