Built for people who think better out loud.
1use std::env;
2
3use thiserror::Error;
4
5/// Application configuration loaded from environment variables.
6///
7/// Usage:
8/// ```rust,no_run
9/// # use slipnote_backend::config::Config;
10/// let config = Config::from_env().expect("config");
11/// ```
12#[derive(Debug, Clone)]
13pub struct Config {
14 /// `OpenAI` API key used for Whisper requests.
15 pub openai_api_key: String,
16 /// `OpenAI` base URL.
17 pub openai_base_url: String,
18 /// Default Whisper model.
19 pub openai_whisper_model: String,
20 /// Default Whisper response format.
21 pub openai_whisper_response_format: String,
22 /// Address/port to bind the HTTP server to.
23 pub bind_addr: String,
24 /// Postgres connection string.
25 pub database_url: String,
26 /// Named environment used for logging and diagnostics.
27 pub slipnote_env: String,
28 /// Tail-sampling rate for request logs (0.0-1.0).
29 pub log_sample_rate: f64,
30 /// Estimated cost per audio second in dollars.
31 pub transcription_cost_per_second_dollars: f64,
32 /// Axiom ingest token (required in prod, optional in dev).
33 pub axiom_token: Option<String>,
34 /// Axiom dataset name (required in prod, optional in dev).
35 pub axiom_dataset: Option<String>,
36 /// Optional Axiom API URL override.
37 pub axiom_url: Option<String>,
38 /// OAuth client ID (typically the client metadata URL).
39 pub oauth_client_id: String,
40 /// OAuth redirect URI for callbacks.
41 pub oauth_redirect_uri: String,
42 /// OAuth client display name.
43 pub oauth_client_name: Option<String>,
44 /// OAuth client URI for metadata.
45 pub oauth_client_uri: Option<String>,
46 /// OAuth JWKS URI for signing keys.
47 pub oauth_jwks_uri: Option<String>,
48 /// OAuth signing key (did:key private key).
49 pub oauth_signing_key: String,
50 /// OAuth scopes (space-delimited).
51 pub oauth_scopes: String,
52 /// OAuth post-auth redirect path or URL.
53 pub oauth_post_auth_redirect: String,
54 /// Session cookie name.
55 pub oauth_cookie_name: String,
56 /// Session TTL in seconds.
57 pub oauth_session_ttl_seconds: i64,
58 /// PLC directory hostname.
59 pub plc_hostname: String,
60 /// Optional OAuth base URL used to derive client metadata/redirect URIs.
61 pub oauth_base_url: Option<String>,
62}
63
64/// Errors that can occur when loading configuration.
65#[derive(Debug, Error)]
66pub enum ConfigError {
67 #[error("missing required environment variable {0}")]
68 MissingEnvVar(&'static str),
69}
70
71impl Config {
72 /// Loads configuration from environment variables.
73 ///
74 /// Usage:
75 /// ```rust,no_run
76 /// # use slipnote_backend::config::Config;
77 /// let config = Config::from_env().expect("config");
78 /// ```
79 pub fn from_env() -> Result<Self, ConfigError> {
80 load_dotenv();
81 let require_axiom = !cfg!(debug_assertions);
82 let default_env = if cfg!(debug_assertions) {
83 "local"
84 } else {
85 "prod"
86 };
87
88 let oauth_base_url = env::var("OAUTH_BASE_URL")
89 .ok()
90 .map(|value| value.trim_end_matches('/').to_string());
91 let oauth_client_id = env::var("OAUTH_CLIENT_ID")
92 .ok()
93 .or_else(|| {
94 oauth_base_url
95 .as_ref()
96 .map(|base| format!("{base}/oauth/client-metadata.json"))
97 })
98 .ok_or(ConfigError::MissingEnvVar("OAUTH_CLIENT_ID"))?;
99 let oauth_redirect_uri = env::var("OAUTH_REDIRECT_URI")
100 .ok()
101 .or_else(|| oauth_base_url.as_ref().map(|base| format!("{base}/oauth/callback")))
102 .ok_or(ConfigError::MissingEnvVar("OAUTH_REDIRECT_URI"))?;
103 let oauth_jwks_uri = env::var("OAUTH_JWKS_URI")
104 .ok()
105 .or_else(|| {
106 oauth_base_url
107 .as_ref()
108 .map(|base| format!("{base}/.well-known/jwks.json"))
109 });
110 let oauth_post_auth_redirect = env::var("OAUTH_POST_AUTH_REDIRECT").ok();
111 let oauth_post_auth_redirect_route = env::var("OAUTH_POST_AUTH_REDIRECT_ROUTE").ok();
112 let oauth_post_auth_redirect = oauth_post_auth_redirect.or_else(|| {
113 if let Some(route) = oauth_post_auth_redirect_route {
114 if let Some(base) = oauth_base_url.as_ref() {
115 let trimmed_route = if route.starts_with('/') {
116 route
117 } else {
118 format!("/{route}")
119 };
120 return Some(format!("{base}{trimmed_route}"));
121 }
122 return Some(route);
123 }
124 oauth_base_url.clone()
125 });
126
127 Ok(Self {
128 openai_api_key: require_env("OPENAI_API_KEY")?,
129 openai_base_url: env_or("OPENAI_BASE_URL", "https://api.openai.com/v1"),
130 openai_whisper_model: env_or("OPENAI_WHISPER_MODEL", "whisper-1"),
131 openai_whisper_response_format: env_or(
132 "OPENAI_WHISPER_RESPONSE_FORMAT",
133 "verbose_json",
134 ),
135 bind_addr: env_or("SLIPNOTE_BIND_ADDR", "0.0.0.0:3001"),
136 database_url: require_env("DATABASE_URL")?,
137 slipnote_env: env_or("SLIPNOTE_ENV", default_env),
138 log_sample_rate: env_or_f64("SLIPNOTE_LOG_SAMPLE_RATE", 1.0),
139 transcription_cost_per_second_dollars: env_or_f64(
140 "SLIPNOTE_TRANSCRIPTION_COST_PER_SECOND_DOLLARS",
141 DEFAULT_TRANSCRIPTION_COST_PER_SECOND_DOLLARS,
142 ),
143 axiom_token: if require_axiom {
144 Some(require_env("AXIOM_TOKEN")?)
145 } else {
146 env::var("AXIOM_TOKEN").ok()
147 },
148 axiom_dataset: if require_axiom {
149 Some(require_env("AXIOM_DATASET")?)
150 } else {
151 env::var("AXIOM_DATASET").ok()
152 },
153 axiom_url: env::var("AXIOM_URL").ok(),
154 oauth_client_id,
155 oauth_redirect_uri,
156 oauth_client_name: env::var("OAUTH_CLIENT_NAME").ok(),
157 oauth_client_uri: env::var("OAUTH_CLIENT_URI")
158 .ok()
159 .or_else(|| oauth_base_url.clone()),
160 oauth_jwks_uri,
161 oauth_signing_key: require_env("OAUTH_SIGNING_KEY")?,
162 oauth_scopes: env_or("OAUTH_SCOPES", "atproto transition:generic"),
163 oauth_post_auth_redirect: oauth_post_auth_redirect
164 .unwrap_or_else(|| "/".to_string()),
165 oauth_cookie_name: env_or("OAUTH_COOKIE_NAME", "slipnote_session"),
166 oauth_session_ttl_seconds: env_or_i64("OAUTH_SESSION_TTL_SECONDS", 60 * 60 * 24 * 7),
167 plc_hostname: env_or("PLC_HOSTNAME", "plc.directory"),
168 oauth_base_url,
169 })
170 }
171}
172
173const DEFAULT_TRANSCRIPTION_COST_PER_SECOND_DOLLARS: f64 = 0.000_094_34;
174
175fn require_env(key: &'static str) -> Result<String, ConfigError> {
176 env::var(key).map_err(|_| ConfigError::MissingEnvVar(key))
177}
178
179fn env_or(key: &'static str, default_value: &'static str) -> String {
180 env::var(key).unwrap_or_else(|_| default_value.to_string())
181}
182
183fn env_or_f64(key: &'static str, default_value: f64) -> f64 {
184 env::var(key)
185 .ok()
186 .and_then(|value| value.parse::<f64>().ok())
187 .unwrap_or(default_value)
188}
189
190fn env_or_i64(key: &'static str, default_value: i64) -> i64 {
191 env::var(key)
192 .ok()
193 .and_then(|value| value.parse::<i64>().ok())
194 .unwrap_or(default_value)
195}
196fn load_dotenv() {
197 if env::var("SLIPNOTE_DISABLE_DOTENV").is_err() {
198 dotenvy::dotenv().ok();
199 }
200}
201
202#[cfg(test)]
203mod tests {
204 use super::*;
205 use std::sync::{Mutex, OnceLock};
206
207 static ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
208
209 fn with_env_lock<F: FnOnce()>(f: F) {
210 let lock = ENV_LOCK.get_or_init(|| Mutex::new(()));
211 let _guard = lock.lock().unwrap_or_else(|err| err.into_inner());
212 f();
213 }
214
215 struct EnvSnapshot {
216 entries: Vec<(&'static str, Option<String>)>,
217 }
218
219 impl EnvSnapshot {
220 fn capture(keys: &[&'static str]) -> Self {
221 let entries = keys
222 .iter()
223 .map(|key| (*key, env::var(key).ok()))
224 .collect();
225 Self { entries }
226 }
227 }
228
229 impl Drop for EnvSnapshot {
230 fn drop(&mut self) {
231 for (key, value) in self.entries.iter() {
232 match value {
233 Some(current) => set_env_var(key, current),
234 None => remove_env_var(key),
235 }
236 }
237 }
238 }
239
240 fn set_env_var(key: &str, value: &str) {
241 unsafe {
242 env::set_var(key, value);
243 }
244 }
245
246 fn remove_env_var(key: &str) {
247 unsafe {
248 env::remove_var(key);
249 }
250 }
251
252 #[test]
253 fn from_env_requires_api_key() {
254 with_env_lock(|| {
255 let _snapshot = EnvSnapshot::capture(&[
256 "OPENAI_API_KEY",
257 "SLIPNOTE_DISABLE_DOTENV",
258 "AXIOM_TOKEN",
259 "AXIOM_DATASET",
260 "AXIOM_URL",
261 "OAUTH_CLIENT_ID",
262 "OAUTH_REDIRECT_URI",
263 "OAUTH_SIGNING_KEY",
264 "OAUTH_BASE_URL",
265 ]);
266 set_env_var("SLIPNOTE_DISABLE_DOTENV", "1");
267 remove_env_var("OPENAI_API_KEY");
268 set_env_var("OAUTH_CLIENT_ID", "https://example.com/oauth/client-metadata.json");
269 set_env_var("OAUTH_REDIRECT_URI", "https://example.com/oauth/callback");
270 set_env_var("OAUTH_SIGNING_KEY", "did:key:z42tnbHmmnhF11nwSnp5kQJbcZQw2Vbw5WF3ABDSxPtDgU2o");
271 remove_env_var("OAUTH_BASE_URL");
272 let result = Config::from_env();
273 assert!(matches!(result, Err(ConfigError::MissingEnvVar("OPENAI_API_KEY"))));
274 });
275 }
276
277 #[test]
278 fn from_env_uses_defaults() {
279 with_env_lock(|| {
280 let _snapshot = EnvSnapshot::capture(&[
281 "OPENAI_API_KEY",
282 "OPENAI_BASE_URL",
283 "OPENAI_WHISPER_MODEL",
284 "OPENAI_WHISPER_RESPONSE_FORMAT",
285 "SLIPNOTE_BIND_ADDR",
286 "DATABASE_URL",
287 "SLIPNOTE_ENV",
288 "SLIPNOTE_LOG_SAMPLE_RATE",
289 "SLIPNOTE_TRANSCRIPTION_COST_PER_SECOND_DOLLARS",
290 "SLIPNOTE_DISABLE_DOTENV",
291 "AXIOM_TOKEN",
292 "AXIOM_DATASET",
293 "AXIOM_URL",
294 "OAUTH_CLIENT_ID",
295 "OAUTH_REDIRECT_URI",
296 "OAUTH_SIGNING_KEY",
297 "OAUTH_SCOPES",
298 "OAUTH_POST_AUTH_REDIRECT",
299 "OAUTH_COOKIE_NAME",
300 "OAUTH_SESSION_TTL_SECONDS",
301 "PLC_HOSTNAME",
302 "OAUTH_BASE_URL",
303 ]);
304 set_env_var("SLIPNOTE_DISABLE_DOTENV", "1");
305 set_env_var("OPENAI_API_KEY", "test-key");
306 remove_env_var("OPENAI_BASE_URL");
307 remove_env_var("OPENAI_WHISPER_MODEL");
308 remove_env_var("OPENAI_WHISPER_RESPONSE_FORMAT");
309 remove_env_var("SLIPNOTE_BIND_ADDR");
310 set_env_var("DATABASE_URL", "postgres://test:test@localhost:5432/test");
311 remove_env_var("SLIPNOTE_ENV");
312 remove_env_var("SLIPNOTE_LOG_SAMPLE_RATE");
313 remove_env_var("SLIPNOTE_TRANSCRIPTION_COST_PER_SECOND_DOLLARS");
314 set_env_var("OAUTH_CLIENT_ID", "https://example.com/oauth/client-metadata.json");
315 set_env_var("OAUTH_REDIRECT_URI", "https://example.com/oauth/callback");
316 set_env_var("OAUTH_SIGNING_KEY", "did:key:z42tnbHmmnhF11nwSnp5kQJbcZQw2Vbw5WF3ABDSxPtDgU2o");
317 remove_env_var("OAUTH_SCOPES");
318 remove_env_var("OAUTH_POST_AUTH_REDIRECT");
319 remove_env_var("OAUTH_COOKIE_NAME");
320 remove_env_var("OAUTH_SESSION_TTL_SECONDS");
321 remove_env_var("PLC_HOSTNAME");
322 remove_env_var("OAUTH_BASE_URL");
323
324 let config = Config::from_env().expect("config");
325 assert_eq!(config.openai_base_url, "https://api.openai.com/v1");
326 assert_eq!(config.openai_whisper_model, "whisper-1");
327 assert_eq!(config.openai_whisper_response_format, "verbose_json");
328 assert_eq!(config.bind_addr, "0.0.0.0:3001");
329 assert_eq!(
330 config.database_url,
331 "postgres://test:test@localhost:5432/test"
332 );
333 assert_eq!(config.slipnote_env, "local");
334 assert_eq!(config.log_sample_rate, 1.0);
335 assert_eq!(
336 config.transcription_cost_per_second_dollars,
337 DEFAULT_TRANSCRIPTION_COST_PER_SECOND_DOLLARS
338 );
339 assert_eq!(config.oauth_scopes, "atproto transition:generic");
340 assert_eq!(config.oauth_post_auth_redirect, "/");
341 assert_eq!(config.oauth_cookie_name, "slipnote_session");
342 assert_eq!(config.oauth_session_ttl_seconds, 60 * 60 * 24 * 7);
343 assert_eq!(config.plc_hostname, "plc.directory");
344 assert_eq!(config.oauth_base_url, None);
345 });
346 }
347}