1use std::env;
2use std::path::PathBuf;
3use std::str::FromStr;
4
5use anyhow::{Context, Result};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum AuthMode {
9 TrustedLocal,
10 Token,
11}
12
13impl AuthMode {
14 pub fn as_contract_mode(self) -> &'static str {
15 match self {
16 Self::TrustedLocal => "trusted_local",
17 Self::Token => "token",
18 }
19 }
20}
21
22impl FromStr for AuthMode {
23 type Err = anyhow::Error;
24
25 fn from_str(value: &str) -> Result<Self> {
26 match value.trim().to_ascii_lowercase().as_str() {
27 "trusted_local" | "trusted-local" | "local" => Ok(Self::TrustedLocal),
28 "token" => Ok(Self::Token),
29 other => {
30 anyhow::bail!("invalid auth mode '{other}'. expected one of: trusted_local, token")
31 }
32 }
33 }
34}
35
36#[derive(Debug, Clone)]
37pub struct ApiConfig {
38 pub host: String,
39 pub port: u16,
40 pub served_by: String,
41 pub auth_mode: AuthMode,
42 pub token: Option<String>,
43 pub idempotency_ttl_secs: u64,
44 pub workspace_root: PathBuf,
45 pub loop_process_interval_ms: u64,
46 pub ralph_command: String,
47}
48
49impl Default for ApiConfig {
50 fn default() -> Self {
51 let workspace_root = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
52
53 Self {
54 host: "127.0.0.1".to_string(),
55 port: 3000,
56 served_by: "ralph-api".to_string(),
57 auth_mode: AuthMode::TrustedLocal,
58 token: None,
59 idempotency_ttl_secs: 60 * 60,
60 workspace_root,
61 loop_process_interval_ms: 30_000,
62 ralph_command: "ralph".to_string(),
63 }
64 }
65}
66
67impl ApiConfig {
68 pub fn from_env() -> Result<Self> {
69 let mut config = Self::default();
70
71 if let Ok(host) = env::var("RALPH_API_HOST") {
72 config.host = host;
73 }
74
75 if let Ok(port) = env::var("RALPH_API_PORT") {
76 config.port = port
77 .parse::<u16>()
78 .with_context(|| format!("failed parsing RALPH_API_PORT='{port}' as u16"))?;
79 }
80
81 if let Ok(served_by) = env::var("RALPH_API_SERVED_BY") {
82 config.served_by = served_by;
83 }
84
85 if let Ok(mode) = env::var("RALPH_API_AUTH_MODE") {
86 config.auth_mode = mode.parse::<AuthMode>()?;
87 }
88
89 if let Ok(token) = env::var("RALPH_API_TOKEN")
90 && !token.trim().is_empty()
91 {
92 config.token = Some(token);
93 }
94
95 if let Ok(ttl) = env::var("RALPH_API_IDEMPOTENCY_TTL_SECS") {
96 config.idempotency_ttl_secs = ttl.parse::<u64>().with_context(|| {
97 format!("failed parsing RALPH_API_IDEMPOTENCY_TTL_SECS='{ttl}' as u64")
98 })?;
99 }
100
101 if let Ok(workspace_root) = env::var("RALPH_API_WORKSPACE_ROOT") {
102 config.workspace_root = PathBuf::from(workspace_root);
103 }
104
105 if let Ok(interval_ms) = env::var("RALPH_API_LOOP_PROCESS_INTERVAL_MS") {
106 config.loop_process_interval_ms = interval_ms.parse::<u64>().with_context(|| {
107 format!("failed parsing RALPH_API_LOOP_PROCESS_INTERVAL_MS='{interval_ms}' as u64")
108 })?;
109 }
110
111 if let Ok(ralph_command) = env::var("RALPH_API_RALPH_COMMAND")
112 && !ralph_command.trim().is_empty()
113 {
114 config.ralph_command = ralph_command;
115 }
116
117 config.validate()?;
118 Ok(config)
119 }
120
121 pub fn validate(&self) -> Result<()> {
122 if self.auth_mode == AuthMode::Token
123 && self
124 .token
125 .as_deref()
126 .is_none_or(|token| token.trim().is_empty())
127 {
128 anyhow::bail!("RALPH_API_TOKEN must be configured when auth mode is token");
129 }
130
131 if self.auth_mode == AuthMode::TrustedLocal && !is_loopback_host(&self.host) {
132 anyhow::bail!(
133 "trusted_local auth mode requires loopback host; set RALPH_API_HOST to 127.0.0.1/::1 (or localhost) or switch to token auth"
134 );
135 }
136
137 Ok(())
138 }
139}
140
141fn is_loopback_host(host: &str) -> bool {
142 let normalized = host
143 .trim()
144 .trim_matches('[')
145 .trim_matches(']')
146 .to_ascii_lowercase();
147
148 matches!(normalized.as_str(), "127.0.0.1" | "localhost" | "::1")
149}
150
151#[cfg(test)]
152mod tests {
153 use super::{ApiConfig, AuthMode};
154
155 #[test]
156 fn defaults_are_localhost_and_trusted_local() {
157 let config = ApiConfig::default();
158 assert_eq!(config.host, "127.0.0.1");
159 assert_eq!(config.auth_mode, AuthMode::TrustedLocal);
160 assert!(config.validate().is_ok());
161 }
162
163 #[test]
164 fn trusted_local_allows_ipv6_loopback() {
165 let mut config = ApiConfig::default();
166 config.host = "::1".to_string();
167 assert!(
168 config.validate().is_ok(),
169 "RALPH_API_HOST=::1 must be accepted by trusted_local"
170 );
171 }
172
173 #[test]
174 fn trusted_local_allows_bracketed_ipv6_loopback() {
175 let mut config = ApiConfig::default();
176 config.host = "[::1]".to_string();
177 assert!(
178 config.validate().is_ok(),
179 "RALPH_API_HOST=[::1] must be accepted by trusted_local"
180 );
181 }
182
183 #[test]
184 fn trusted_local_rejects_non_loopback_hosts() {
185 let mut config = ApiConfig::default();
186 config.host = "0.0.0.0".to_string();
187 assert!(config.validate().is_err());
188 }
189
190 #[test]
191 fn token_auth_allows_non_loopback_hosts() {
192 let mut config = ApiConfig::default();
193 config.host = "0.0.0.0".to_string();
194 config.auth_mode = AuthMode::Token;
195 config.token = Some("secret-token".to_string());
196
197 assert!(config.validate().is_ok());
198 }
199}