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}