Skip to main content

aurum_core/remote/
client.rs

1//! Policy-enforcing HTTP client for remote STT and cleanup (JOE-1587).
2
3use crate::error::{ProviderError, Result, UserError};
4use reqwest::{Client, Method, RequestBuilder, StatusCode};
5use std::time::Duration;
6use url::Url;
7
8/// Official OpenRouter HTTPS origin (credentialed default).
9pub const DEFAULT_OPENROUTER_ORIGIN: &str = "https://openrouter.ai";
10
11/// Validated remote endpoint with trust classification.
12#[derive(Debug, Clone)]
13pub struct RemoteEndpoint {
14    pub base_url: String,
15    /// True when this is the official OpenRouter origin.
16    pub is_official: bool,
17    /// True when credentials may be sent (official or explicit custom trust).
18    pub credentials_allowed: bool,
19}
20
21/// Policy knobs for building the shared client.
22#[derive(Debug, Clone)]
23pub struct RemotePolicy {
24    /// Connect timeout.
25    pub connect_timeout: Duration,
26    /// Total request timeout.
27    pub total_timeout: Duration,
28    /// When false (default), system proxy is not used.
29    pub use_system_proxy: bool,
30    /// When true, allow custom non-OpenRouter HTTPS endpoints with credentials
31    /// (requires separate config opt-in).
32    pub allow_custom_credentialed_endpoint: bool,
33    /// When true, allow HTTP only for loopback hosts (tests).
34    pub allow_loopback_http: bool,
35}
36
37impl Default for RemotePolicy {
38    fn default() -> Self {
39        Self {
40            connect_timeout: Duration::from_secs(30),
41            total_timeout: Duration::from_secs(600),
42            use_system_proxy: false,
43            allow_custom_credentialed_endpoint: false,
44            allow_loopback_http: false,
45        }
46    }
47}
48
49/// Parse and validate a base URL under the remote policy.
50pub fn validate_endpoint(raw: &str, policy: &RemotePolicy) -> Result<RemoteEndpoint> {
51    let trimmed = raw.trim().trim_end_matches('/');
52    if trimmed.is_empty() {
53        return Err(UserError::InvalidConfig {
54            reason: "remote base URL is empty".into(),
55        }
56        .into());
57    }
58    let url = Url::parse(trimmed).map_err(|e| UserError::InvalidConfig {
59        reason: format!("invalid remote base URL: {e}"),
60    })?;
61
62    if url.username() != "" || url.password().is_some() {
63        return Err(UserError::InvalidConfig {
64            reason: "remote base URL must not embed userinfo/credentials".into(),
65        }
66        .into());
67    }
68
69    let scheme = url.scheme();
70    let host = url.host_str().unwrap_or("").to_ascii_lowercase();
71    let is_loopback = matches!(host.as_str(), "127.0.0.1" | "localhost" | "::1");
72    let is_official = host == "openrouter.ai" && scheme == "https";
73
74    match scheme {
75        "https" => {}
76        "http" if policy.allow_loopback_http && is_loopback => {}
77        "http" => {
78            return Err(UserError::InvalidConfig {
79                reason: format!(
80                    "HTTP remote endpoints are only allowed for loopback test mode (got {trimmed})"
81                ),
82            }
83            .into());
84        }
85        other => {
86            return Err(UserError::InvalidConfig {
87                reason: format!("unsupported URL scheme '{other}' (use https)"),
88            }
89            .into());
90        }
91    }
92
93    let credentials_allowed = is_official
94        || (is_loopback && policy.allow_loopback_http)
95        || (policy.allow_custom_credentialed_endpoint && scheme == "https");
96    if !credentials_allowed {
97        return Err(UserError::InvalidConfig {
98            reason: format!(
99                "credentialed remote endpoint '{trimmed}' is not the official OpenRouter origin.\n  \
100                 Hint: set openrouter.allow_custom_endpoint = true only for trusted compatible APIs, \
101                 or use {DEFAULT_OPENROUTER_ORIGIN}."
102            ),
103        }
104        .into());
105    }
106
107    Ok(RemoteEndpoint {
108        base_url: trimmed.to_string(),
109        is_official,
110        credentials_allowed,
111    })
112}
113
114/// Hardened reqwest client shared by STT and cleanup.
115#[derive(Clone)]
116pub struct HardenedHttpClient {
117    http: Client,
118    endpoint: RemoteEndpoint,
119    policy: RemotePolicy,
120}
121
122impl std::fmt::Debug for HardenedHttpClient {
123    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
124        f.debug_struct("HardenedHttpClient")
125            .field("base_url", &self.endpoint.base_url)
126            .field("is_official", &self.endpoint.is_official)
127            .finish()
128    }
129}
130
131impl HardenedHttpClient {
132    pub fn build(base_url: Option<&str>, policy: RemotePolicy) -> Result<Self> {
133        let raw = base_url
134            .map(|s| s.trim())
135            .filter(|s| !s.is_empty())
136            .unwrap_or("https://openrouter.ai/api/v1");
137        let endpoint = validate_endpoint(raw, &policy)?;
138
139        let mut builder = Client::builder()
140            .user_agent(concat!("aurum-core/", env!("CARGO_PKG_VERSION")))
141            .connect_timeout(policy.connect_timeout)
142            .timeout(policy.total_timeout)
143            // Never follow redirects with credentials (JOE-1587).
144            .redirect(reqwest::redirect::Policy::none());
145
146        if !policy.use_system_proxy {
147            builder = builder.no_proxy();
148        }
149
150        let http = builder.build().map_err(|e| ProviderError::Network {
151            provider: "remote".into(),
152            reason: e.to_string(),
153        })?;
154
155        Ok(Self {
156            http,
157            endpoint,
158            policy,
159        })
160    }
161
162    pub fn endpoint(&self) -> &RemoteEndpoint {
163        &self.endpoint
164    }
165
166    pub fn base_url(&self) -> &str {
167        &self.endpoint.base_url
168    }
169
170    pub fn policy(&self) -> &RemotePolicy {
171        &self.policy
172    }
173
174    /// Build a request with auth + standard OpenRouter headers.
175    pub fn request(&self, method: Method, path: &str, api_key: &str) -> Result<RequestBuilder> {
176        if !self.endpoint.credentials_allowed {
177            return Err(UserError::InvalidConfig {
178                reason: "credentials are not allowed for this endpoint under current policy".into(),
179            }
180            .into());
181        }
182        let path = path.trim_start_matches('/');
183        let url = format!("{}/{}", self.endpoint.base_url, path);
184        // Reject if path somehow rewrites host (defense in depth).
185        if let Ok(u) = Url::parse(&url) {
186            let base = Url::parse(&self.endpoint.base_url).ok();
187            if let Some(b) = base {
188                if u.origin() != b.origin() {
189                    return Err(UserError::InvalidConfig {
190                        reason: "request URL origin diverged from validated endpoint".into(),
191                    }
192                    .into());
193                }
194            }
195        }
196        Ok(self
197            .http
198            .request(method, url)
199            .header("Authorization", format!("Bearer {api_key}"))
200            .header("HTTP-Referer", "https://github.com/joe-broadhead/aurum")
201            .header("X-Title", "Aurum")
202            .header(
203                "X-Request-Id",
204                format!(
205                    "aurum-{}",
206                    std::time::SystemTime::now()
207                        .duration_since(std::time::UNIX_EPOCH)
208                        .map(|d| d.as_millis())
209                        .unwrap_or(0)
210                ),
211            ))
212    }
213
214    pub fn get_raw(&self) -> &Client {
215        &self.http
216    }
217}
218
219/// Map HTTP status codes to typed provider errors.
220///
221/// Public reasons are **allowlisted only** (HTTP status + optional closed provider code).
222/// Arbitrary remote response bodies and free-form string codes are never echoed (JOE-1914 / JOE-1920).
223pub fn map_http_status(provider: &str, status: StatusCode, body: &str) -> Result<()> {
224    use super::status::public_http_reason;
225    let code = status.as_u16();
226    // Body is only scanned for a closed local code set — never free text.
227    let reason = public_http_reason(code, body);
228    match code {
229        200..=299 => Ok(()),
230        401 | 403 => Err(ProviderError::Auth {
231            provider: provider.into(),
232            reason,
233        }
234        .into()),
235        429 => Err(ProviderError::RateLimited {
236            provider: provider.into(),
237        }
238        .into()),
239        402 => Err(ProviderError::QuotaExceeded {
240            provider: provider.into(),
241            reason,
242        }
243        .into()),
244        _ => Err(ProviderError::Remote {
245            provider: provider.into(),
246            reason,
247        }
248        .into()),
249    }
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255
256    #[test]
257    fn official_endpoint_ok() {
258        let ep =
259            validate_endpoint("https://openrouter.ai/api/v1", &RemotePolicy::default()).unwrap();
260        assert!(ep.is_official);
261        assert!(ep.credentials_allowed);
262    }
263
264    #[test]
265    fn foreign_host_rejected_by_default() {
266        let err =
267            validate_endpoint("https://evil.example/api", &RemotePolicy::default()).unwrap_err();
268        assert_eq!(err.exit_code(), 2);
269        assert!(
270            err.to_string().contains("allow_custom_endpoint")
271                || err.to_string().contains("official")
272        );
273    }
274
275    #[test]
276    fn map_http_status_never_echoes_body_payload() {
277        let body = r#"{"error":{"message":"sk-or-v1-canary-should-not-appear","code":401}}"#;
278        let err =
279            map_http_status("openrouter", reqwest::StatusCode::UNAUTHORIZED, body).unwrap_err();
280        let msg = err.to_string();
281        assert!(!msg.contains("canary"));
282        assert!(!msg.contains("sk-or-v1"));
283        assert!(msg.contains("401") || msg.to_ascii_lowercase().contains("auth"));
284    }
285
286    #[test]
287    fn map_http_status_drops_unknown_string_provider_code() {
288        let body =
289            r#"{"error":{"message":"transcript: hello world secret","code":"no_endpoints"}}"#;
290        let err = map_http_status("openrouter", reqwest::StatusCode::NOT_FOUND, body).unwrap_err();
291        let msg = err.to_string();
292        assert!(!msg.contains("transcript"));
293        assert!(!msg.contains("hello world"));
294        assert!(!msg.contains("no_endpoints"));
295        assert!(msg.contains("404"));
296    }
297
298    #[test]
299    fn map_http_status_drops_credential_shaped_provider_code() {
300        let body =
301            r#"{"error":{"message":"x","code":"sk-or-v1-TESTCANARY-JOE1920-DO-NOT-USE-001"}}"#;
302        let err =
303            map_http_status("openrouter", reqwest::StatusCode::UNAUTHORIZED, body).unwrap_err();
304        let msg = err.to_string();
305        assert!(!msg.contains("TESTCANARY"));
306        assert!(!msg.contains("sk-or-v1"));
307    }
308
309    #[test]
310    fn custom_allowed_with_opt_in() {
311        let policy = RemotePolicy {
312            allow_custom_credentialed_endpoint: true,
313            ..Default::default()
314        };
315        let ep = validate_endpoint("https://compatible.example/v1", &policy).unwrap();
316        assert!(!ep.is_official);
317        assert!(ep.credentials_allowed);
318    }
319
320    #[test]
321    fn rejects_userinfo() {
322        let err = validate_endpoint(
323            "https://user:pass@openrouter.ai/api/v1",
324            &RemotePolicy::default(),
325        )
326        .unwrap_err();
327        assert!(err.to_string().contains("userinfo") || err.to_string().contains("credential"));
328    }
329
330    #[test]
331    fn loopback_http_for_tests() {
332        let policy = RemotePolicy {
333            allow_loopback_http: true,
334            ..Default::default()
335        };
336        let ep = validate_endpoint("http://127.0.0.1:9", &policy).unwrap();
337        assert!(ep.credentials_allowed);
338    }
339
340    #[test]
341    fn http_non_loopback_rejected() {
342        assert!(validate_endpoint("http://evil.example", &RemotePolicy::default()).is_err());
343    }
344}