Skip to main content

firstpass_proxy/
config.rs

1//! Proxy configuration, loaded from the environment (SPEC §7.4: agent-first, zero-config
2//! by default — every knob has a sane local default and can be overridden by env var).
3
4use std::env;
5use std::num::NonZeroU32;
6
7use firstpass_core::{Config as RoutingConfig, Mode, PriceTable};
8
9/// The built-in prompt salt used when `FIRSTPASS_PROMPT_SALT` is unset. Fine for local
10/// development; an operator should set their own before handling real traffic, since a
11/// shared default salt makes `prompt_hash` comparable/guessable across installs.
12const DEFAULT_PROMPT_SALT: &str = "firstpass-dev-salt";
13
14/// Runtime configuration for the proxy.
15#[derive(Debug, Clone)]
16pub struct ProxyConfig {
17    /// Address to bind the HTTP server to, e.g. `127.0.0.1:8080`.
18    pub bind: String,
19    /// Base URL of the upstream Anthropic-compatible API.
20    pub upstream_anthropic: String,
21    /// Base URL of the upstream OpenAI-compatible API.
22    pub upstream_openai: String,
23    /// Path to the SQLite trace database.
24    pub db_path: String,
25    /// Tenant identifier stamped on every trace when `require_auth` is off (single-operator
26    /// default). When `require_auth` is on, the per-request tenant comes from the verified API
27    /// key instead, and this is only the fallback for operator-scoped tooling.
28    pub tenant_id: String,
29    /// Multi-tenant auth gate (ADR 0004 §D1). Default `false` — single-operator behavior is
30    /// byte-identical: no auth checks, tenant is the static [`Self::tenant_id`]. Set via
31    /// `FIRSTPASS_REQUIRE_AUTH`. **Experimental / pre-external-review (ADR 0004 §D7).**
32    pub require_auth: bool,
33    /// Per-tenant Argon2id API-key hashes (ADR 0004 §D1). Empty unless configured via
34    /// `FIRSTPASS_TENANT_KEYS` (path to a JSON `{ tenant_id: hash }`) or `FIRSTPASS_TENANT_KEYS_JSON`
35    /// (inline JSON). Only consulted when `require_auth` is on.
36    ///
37    /// The stored `hash` is the Argon2id hash of a tenant's **secret** (make one with
38    /// `TenantKeys::hash_key`). A tenant then authenticates with the key `<tenant_id>.<secret>` —
39    /// the tenant id names which hash to verify, so a request does exactly one Argon2 check. Tenant
40    /// ids must not contain a `.` (the first `.` splits id from secret).
41    pub tenant_keys: crate::tenant_auth::TenantKeys,
42    /// Salt mixed into the prompt hash so raw prompt text never touches storage.
43    pub prompt_salt: String,
44    /// Default mode when no routing config matches (`observe` unless overridden).
45    pub mode: Mode,
46    /// Optional declarative routing config (§8.4). When present, its routes decide the mode,
47    /// ladder, and gates per request; enforce routes activate the escalation engine.
48    pub routing: Option<RoutingConfig>,
49    /// Model pricing table used to cost each attempt.
50    pub prices: PriceTable,
51    /// Max in-flight requests before new ones queue behind the concurrency limiter
52    /// (`FIRSTPASS_MAX_CONCURRENCY`, default 512). A load-shed valve, not a timeout — it never
53    /// severs an in-flight SSE stream.
54    pub max_concurrency: usize,
55    /// Per-tenant request rate limit, requests/sec (ADR 0004 §D6). `None` (the default) means
56    /// unlimited — single-operator and existing deployments are unaffected. Set via
57    /// `FIRSTPASS_TENANT_RATE_PER_SEC`; only enforced when configured.
58    pub tenant_rate_per_sec: Option<NonZeroU32>,
59}
60
61/// Default for [`ProxyConfig::max_concurrency`] when `FIRSTPASS_MAX_CONCURRENCY` is unset.
62const DEFAULT_MAX_CONCURRENCY: usize = 512;
63
64/// Errors that prevent the proxy from starting.
65#[derive(Debug, thiserror::Error)]
66pub enum ConfigError {
67    /// `FIRSTPASS_MODE` named an unknown mode (valid: `observe`, `enforce`).
68    #[error(
69        "FIRSTPASS_MODE={0:?} is not a known mode; set `observe` or `enforce`, or leave it unset"
70    )]
71    UnsupportedMode(String),
72
73    /// The routing config file could not be read or parsed.
74    #[error("routing config error: {0}")]
75    Config(String),
76
77    /// The multi-tenant auth config (`FIRSTPASS_TENANT_KEYS`) is invalid (ADR 0004 §D1). The
78    /// message never echoes hash material — only the structural problem.
79    #[error("tenant auth config error: {0}")]
80    Auth(#[from] crate::tenant_auth::AuthConfigError),
81}
82
83impl ProxyConfig {
84    /// Load configuration from environment variables, falling back to local defaults.
85    ///
86    /// # Errors
87    /// [`ConfigError::UnsupportedMode`] for an unknown `FIRSTPASS_MODE`; [`ConfigError::Config`]
88    /// if the `FIRSTPASS_CONFIG` routing file cannot be read or parsed.
89    pub fn from_env() -> Result<Self, ConfigError> {
90        // Read the routing config file (if any) here, then hand its *content* to the pure
91        // `from_lookup` seam via the synthetic `FIRSTPASS_CONFIG_TOML` key — keeping file I/O
92        // out of the unit-testable path.
93        let routing_toml = match env::var("FIRSTPASS_CONFIG").ok() {
94            Some(path) => Some(
95                std::fs::read_to_string(&path)
96                    .map_err(|e| ConfigError::Config(format!("reading {path}: {e}")))?,
97            ),
98            None => None,
99        };
100        // Read the tenant-keys file (if any) here and hand its *content* to `from_lookup` via the
101        // synthetic `FIRSTPASS_TENANT_KEYS_JSON` key, keeping file I/O out of the testable path.
102        // An inline `FIRSTPASS_TENANT_KEYS_JSON` in the real env takes precedence if both are set.
103        let tenant_keys_json = match env::var("FIRSTPASS_TENANT_KEYS").ok() {
104            Some(path) => Some(
105                std::fs::read_to_string(&path)
106                    .map_err(|e| ConfigError::Config(format!("reading {path}: {e}")))?,
107            ),
108            None => None,
109        };
110        Self::from_lookup(|key| match key {
111            "FIRSTPASS_CONFIG_TOML" => routing_toml.clone(),
112            "FIRSTPASS_TENANT_KEYS_JSON" => env::var(key).ok().or_else(|| tenant_keys_json.clone()),
113            other => env::var(other).ok(),
114        })
115    }
116
117    /// Load configuration from an arbitrary key lookup — the seam `from_env` uses, exposed
118    /// so tests can exercise defaulting/validation without touching real process env vars.
119    /// The routing config is supplied inline via the `FIRSTPASS_CONFIG_TOML` key.
120    ///
121    /// # Errors
122    /// [`ConfigError::UnsupportedMode`] for an unknown `FIRSTPASS_MODE`; [`ConfigError::Config`]
123    /// if the inline routing TOML fails to parse.
124    pub fn from_lookup(lookup: impl Fn(&str) -> Option<String>) -> Result<Self, ConfigError> {
125        let bind = lookup("FIRSTPASS_BIND").unwrap_or_else(|| "127.0.0.1:8080".to_owned());
126        let upstream_anthropic = lookup("FIRSTPASS_UPSTREAM_ANTHROPIC")
127            .unwrap_or_else(|| "https://api.anthropic.com".to_owned());
128        let upstream_openai = lookup("FIRSTPASS_UPSTREAM_OPENAI")
129            .unwrap_or_else(|| "https://api.openai.com".to_owned());
130        let db_path = lookup("FIRSTPASS_DB").unwrap_or_else(|| "firstpass.db".to_owned());
131        let tenant_id = lookup("FIRSTPASS_TENANT").unwrap_or_else(|| "default".to_owned());
132        let prompt_salt = lookup("FIRSTPASS_PROMPT_SALT").unwrap_or_else(|| {
133            tracing::warn!(
134                "FIRSTPASS_PROMPT_SALT is unset — using the built-in dev default; \
135                 set a real secret before handling production traffic"
136            );
137            DEFAULT_PROMPT_SALT.to_owned()
138        });
139        let mode_str = lookup("FIRSTPASS_MODE").unwrap_or_else(|| "observe".to_owned());
140        let mode = match mode_str.as_str() {
141            "observe" => Mode::Observe,
142            "enforce" => Mode::Enforce,
143            other => return Err(ConfigError::UnsupportedMode(other.to_owned())),
144        };
145        let routing = match lookup("FIRSTPASS_CONFIG_TOML") {
146            Some(toml) => {
147                Some(RoutingConfig::parse(&toml).map_err(|e| ConfigError::Config(e.to_string()))?)
148            }
149            None => None,
150        };
151        let max_concurrency = match lookup("FIRSTPASS_MAX_CONCURRENCY") {
152            Some(s) => s.parse().map_err(|e| {
153                ConfigError::Config(format!("FIRSTPASS_MAX_CONCURRENCY={s:?}: {e}"))
154            })?,
155            None => DEFAULT_MAX_CONCURRENCY,
156        };
157
158        // Multi-tenant auth (ADR 0004 §D1) — default OFF. Anything other than an explicit truthy
159        // value leaves single-operator behavior byte-identical.
160        let require_auth = lookup("FIRSTPASS_REQUIRE_AUTH")
161            .map(|s| {
162                matches!(
163                    s.trim().to_ascii_lowercase().as_str(),
164                    "1" | "true" | "yes" | "on"
165                )
166            })
167            .unwrap_or(false);
168        let tenant_keys = match lookup("FIRSTPASS_TENANT_KEYS_JSON") {
169            Some(json) => crate::tenant_auth::TenantKeys::from_json(&json)?,
170            None => crate::tenant_auth::TenantKeys::default(),
171        };
172        if require_auth && tenant_keys.is_empty() {
173            tracing::warn!(
174                "FIRSTPASS_REQUIRE_AUTH is on but no tenant keys are configured — every request \
175                 will be rejected with 401 until FIRSTPASS_TENANT_KEYS is set"
176            );
177        }
178
179        // Per-tenant rate limiting (ADR 0004 §D6) — default OFF (unlimited).
180        let tenant_rate_per_sec = match lookup("FIRSTPASS_TENANT_RATE_PER_SEC") {
181            Some(s) => Some(s.trim().parse::<NonZeroU32>().map_err(|e| {
182                ConfigError::Config(format!("FIRSTPASS_TENANT_RATE_PER_SEC={s:?}: {e}"))
183            })?),
184            None => None,
185        };
186
187        Ok(Self {
188            bind,
189            upstream_anthropic,
190            upstream_openai,
191            db_path,
192            tenant_id,
193            require_auth,
194            tenant_keys,
195            prompt_salt,
196            mode,
197            routing,
198            prices: PriceTable::defaults(),
199            max_concurrency,
200            tenant_rate_per_sec,
201        })
202    }
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208
209    #[test]
210    fn defaults_are_sane_when_unset() {
211        let cfg = ProxyConfig::from_lookup(|_| None).unwrap();
212        assert_eq!(cfg.bind, "127.0.0.1:8080");
213        assert_eq!(cfg.upstream_anthropic, "https://api.anthropic.com");
214        assert_eq!(cfg.db_path, "firstpass.db");
215        assert_eq!(cfg.tenant_id, "default");
216        assert_eq!(cfg.prompt_salt, DEFAULT_PROMPT_SALT);
217        assert_eq!(cfg.mode, Mode::Observe);
218        assert_eq!(cfg.max_concurrency, DEFAULT_MAX_CONCURRENCY);
219    }
220
221    #[test]
222    fn max_concurrency_is_parsed_from_env() {
223        let cfg = ProxyConfig::from_lookup(|key| {
224            (key == "FIRSTPASS_MAX_CONCURRENCY").then(|| "64".to_owned())
225        })
226        .unwrap();
227        assert_eq!(cfg.max_concurrency, 64);
228    }
229
230    #[test]
231    fn bad_max_concurrency_is_an_error() {
232        let result = ProxyConfig::from_lookup(|key| {
233            (key == "FIRSTPASS_MAX_CONCURRENCY").then(|| "not-a-number".to_owned())
234        });
235        assert!(matches!(result, Err(ConfigError::Config(_))));
236    }
237
238    #[test]
239    fn overrides_are_applied() {
240        let cfg = ProxyConfig::from_lookup(|key| match key {
241            "FIRSTPASS_BIND" => Some("0.0.0.0:9090".to_owned()),
242            "FIRSTPASS_TENANT" => Some("acme".to_owned()),
243            _ => None,
244        })
245        .unwrap();
246        assert_eq!(cfg.bind, "0.0.0.0:9090");
247        assert_eq!(cfg.tenant_id, "acme");
248    }
249
250    #[test]
251    fn enforce_mode_is_accepted() {
252        let cfg =
253            ProxyConfig::from_lookup(|key| (key == "FIRSTPASS_MODE").then(|| "enforce".to_owned()))
254                .unwrap();
255        assert_eq!(cfg.mode, Mode::Enforce);
256    }
257
258    #[test]
259    fn unknown_mode_is_rejected() {
260        let result =
261            ProxyConfig::from_lookup(|key| (key == "FIRSTPASS_MODE").then(|| "banana".to_owned()));
262        assert!(matches!(result, Err(ConfigError::UnsupportedMode(m)) if m == "banana"));
263    }
264
265    #[test]
266    fn routing_config_parses_inline() {
267        let toml = r#"
268[[route]]
269match = { task_kind = "code_edit" }
270mode = "enforce"
271ladder = ["anthropic/claude-haiku-4-5", "anthropic/claude-sonnet-5"]
272gates = ["non-empty"]
273"#;
274        let cfg = ProxyConfig::from_lookup(|key| match key {
275            "FIRSTPASS_CONFIG_TOML" => Some(toml.to_owned()),
276            _ => None,
277        })
278        .unwrap();
279        let routing = cfg.routing.expect("routing config present");
280        assert_eq!(routing.routes.len(), 1);
281        assert_eq!(routing.routes[0].mode, Mode::Enforce);
282    }
283
284    #[test]
285    fn bad_routing_config_is_an_error() {
286        let result = ProxyConfig::from_lookup(|key| match key {
287            "FIRSTPASS_CONFIG_TOML" => Some("this is not valid = = toml".to_owned()),
288            _ => None,
289        });
290        assert!(matches!(result, Err(ConfigError::Config(_))));
291    }
292}