firstpass_proxy/
config.rs1use std::env;
5use std::num::NonZeroU32;
6
7use firstpass_core::{Config as RoutingConfig, Mode, PriceTable};
8
9const DEFAULT_PROMPT_SALT: &str = "firstpass-dev-salt";
13
14#[derive(Debug, Clone)]
16pub struct ProxyConfig {
17 pub bind: String,
19 pub upstream_anthropic: String,
21 pub upstream_openai: String,
23 pub db_path: String,
25 pub tenant_id: String,
29 pub require_auth: bool,
33 pub tenant_keys: crate::tenant_auth::TenantKeys,
42 pub prompt_salt: String,
44 pub mode: Mode,
46 pub routing: Option<RoutingConfig>,
49 pub prices: PriceTable,
51 pub max_concurrency: usize,
55 pub tenant_rate_per_sec: Option<NonZeroU32>,
59}
60
61const DEFAULT_MAX_CONCURRENCY: usize = 512;
63
64#[derive(Debug, thiserror::Error)]
66pub enum ConfigError {
67 #[error(
69 "FIRSTPASS_MODE={0:?} is not a known mode; set `observe` or `enforce`, or leave it unset"
70 )]
71 UnsupportedMode(String),
72
73 #[error("routing config error: {0}")]
75 Config(String),
76
77 #[error("tenant auth config error: {0}")]
80 Auth(#[from] crate::tenant_auth::AuthConfigError),
81}
82
83impl ProxyConfig {
84 pub fn from_env() -> Result<Self, ConfigError> {
90 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 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 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 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 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}