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),
160/// `ENV` (`development|staging|production`, default `development`), and
161/// the issue #137 key-ring keys: `HARNESS_SECRET_REVOKED` (optional,
162/// comma-separated key ids whose tokens must be refused immediately) and
163/// `HARNESS_VENTURE` (optional label binding tokens to this venture, so
164/// one venture's links cannot verify in another that shares its secret by
165/// mistake).
166///
167/// Dummy secrets only, in tests:
168/// `HARNESS_SECRET = "test-secret-0123456789abcdef-0123"`.
169#[derive(Debug, Clone)]
170pub struct HarnessConfig {
171    pub harness_secret: String,
172    pub harness_secret_previous: Option<String>,
173    /// Key ids revoked at boot (issue #137). Ids and states, never
174    /// key material — this list is safe to log.
175    pub harness_secret_revoked: Vec<String>,
176    /// The venture binding label for issued tokens (issue #137).
177    pub harness_venture: Option<String>,
178    pub admin_token: Option<String>,
179    pub env: VentureEnv,
180}
181
182impl HarnessConfig {
183    /// # Errors
184    ///
185    /// One problem per invalid key, reported together: missing or short
186    /// `HARNESS_SECRET`, unknown `ENV` value.
187    pub fn from_config(config: &dyn Config) -> Result<Self, ConfigError> {
188        let mut errors = ConfigError::default();
189
190        let harness_secret = match config.get("HARNESS_SECRET") {
191            Some(secret) if secret.len() >= MIN_SECRET_BYTES => Some(secret),
192            Some(_) => {
193                errors.push(format!(
194                    "HARNESS_SECRET must be at least {MIN_SECRET_BYTES} bytes"
195                ));
196                None
197            }
198            None => {
199                errors.push(format!(
200                    "HARNESS_SECRET is required (min {MIN_SECRET_BYTES} bytes)"
201                ));
202                None
203            }
204        };
205        let env = match config.get("ENV").as_deref() {
206            None | Some("") => Some(VentureEnv::Development),
207            Some(raw) => {
208                let parsed = VentureEnv::parse(raw);
209                if parsed.is_none() {
210                    errors.push(format!(
211                        "ENV must be one of development|staging|production, got {raw:?}"
212                    ));
213                }
214                parsed
215            }
216        };
217
218        // Revoking "cur" is a secret rotation, not a ring state change,
219        // and silently signing nothing would take every confirm link
220        // down: the id is dropped here and named in the runbook.
221        let harness_secret_revoked: Vec<String> = config
222            .get("HARNESS_SECRET_REVOKED")
223            .map(|raw| {
224                raw.split(',')
225                    .map(str::trim)
226                    .filter(|kid| !kid.is_empty() && *kid != "cur")
227                    .map(<str>::to_owned)
228                    .collect()
229            })
230            .unwrap_or_default();
231
232        errors.into_result()?;
233        Ok(Self {
234            harness_secret: harness_secret.unwrap_or_default(),
235            harness_secret_previous: config.get("HARNESS_SECRET_PREVIOUS"),
236            harness_secret_revoked,
237            harness_venture: config
238                .get("HARNESS_VENTURE")
239                .map(|value| value.trim().to_owned())
240                .filter(|value| !value.is_empty()),
241            admin_token: config.get("ADMIN_TOKEN"),
242            env: env.unwrap_or_default(),
243        })
244    }
245
246    /// The HMAC signer for this configuration: a bounded key ring (ADR
247    /// 0006 as amended by ADR 0014, issue #137). `HARNESS_SECRET` is the
248    /// signing key, `HARNESS_SECRET_PREVIOUS` a verification-only key, and
249    /// every id named in `HARNESS_SECRET_REVOKED` is burned before the
250    /// ring is frozen. Tokens are bound to `HARNESS_VENTURE` and `ENV` so
251    /// a link minted in one venture or environment never verifies in
252    /// another.
253    ///
254    /// # Panics
255    ///
256    /// Only when `from_config` was bypassed with an invalid secret.
257    pub fn signer(&self) -> HmacSigner {
258        let mut ring = KeyRing::new();
259        ring.rotate_signing(Kid::Cur, self.harness_secret.clone().into_bytes())
260            .expect("from_config validated the secret");
261        if let Some(previous) = &self.harness_secret_previous {
262            let _ = ring.add_verifying_only(Kid::Prev, previous.clone().into_bytes());
263        }
264        for kid in &self.harness_secret_revoked {
265            let key = match kid.as_str() {
266                "prev" => Kid::Prev,
267                other => Kid::named(other.to_owned()),
268            };
269            ring.revoke(&key);
270        }
271        HmacSigner::from_ring(ring).with_binding(
272            self.harness_venture.clone(),
273            Some(self.env.as_str().to_owned()),
274        )
275    }
276}
277
278/// A `Config` backed by a map (tests, `fz doctor` with process env).
279#[derive(Debug, Clone, Default)]
280pub struct MapConfig(pub std::collections::HashMap<String, String>);
281
282impl MapConfig {
283    pub fn from_pairs(
284        pairs: impl IntoIterator<Item = (impl Into<String>, impl Into<String>)>,
285    ) -> Self {
286        Self(
287            pairs
288                .into_iter()
289                .map(|(k, v)| (k.into(), v.into()))
290                .collect(),
291        )
292    }
293}
294
295impl Config for MapConfig {
296    fn get(&self, key: &str) -> Option<String> {
297        self.0.get(key).cloned()
298    }
299}