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