Skip to main content

aurum_core/remote/
client.rs

1//! Policy-enforcing HTTP client for remote STT and cleanup (JOE-1587, JOE-1934).
2//!
3//! Auth, attribution headers, and official origins come from a named
4//! [`ProviderHttpPolicy`]. Timeouts, proxy, loopback, and custom-endpoint flags
5//! remain on [`RemotePolicy`].
6
7use super::policy::{normalize_request_path, OpenRouterHttpPolicy, ProviderHttpPolicy};
8use crate::error::{ProviderError, Result, UserError};
9use reqwest::{Client, Method, RequestBuilder, StatusCode};
10use std::sync::Arc;
11use std::time::Duration;
12use url::Url;
13
14/// Validated remote endpoint with trust classification.
15#[derive(Debug, Clone)]
16pub struct RemoteEndpoint {
17    pub base_url: String,
18    /// True when this matches an official origin of the selected provider policy.
19    pub is_official: bool,
20    /// True when credentials may be sent (official or explicit custom trust).
21    pub credentials_allowed: bool,
22    /// Provider id that validated this endpoint.
23    pub provider_id: String,
24}
25
26/// Policy knobs for building the shared client (timeouts / proxy / loopback).
27///
28/// Provider-specific trust (origins, auth, headers, paths) lives on
29/// [`ProviderHttpPolicy`], not here.
30#[derive(Debug, Clone)]
31pub struct RemotePolicy {
32    /// Connect timeout.
33    pub connect_timeout: Duration,
34    /// Total request timeout.
35    pub total_timeout: Duration,
36    /// When false (default), system proxy is not used.
37    pub use_system_proxy: bool,
38    /// When true, allow custom non-official HTTPS endpoints with credentials
39    /// (requires separate config opt-in; still provider-scoped).
40    pub allow_custom_credentialed_endpoint: bool,
41    /// When true, allow HTTP only for loopback hosts (tests).
42    pub allow_loopback_http: bool,
43}
44
45impl Default for RemotePolicy {
46    fn default() -> Self {
47        Self {
48            connect_timeout: Duration::from_secs(30),
49            total_timeout: Duration::from_secs(600),
50            use_system_proxy: false,
51            allow_custom_credentialed_endpoint: false,
52            allow_loopback_http: false,
53        }
54    }
55}
56
57/// Parse and validate a base URL under remote + provider policy.
58pub fn validate_endpoint(
59    raw: &str,
60    remote: &RemotePolicy,
61    provider: &dyn ProviderHttpPolicy,
62) -> Result<RemoteEndpoint> {
63    let trimmed = raw.trim().trim_end_matches('/');
64    if trimmed.is_empty() {
65        return Err(UserError::InvalidConfig {
66            reason: "remote base URL is empty".into(),
67        }
68        .into());
69    }
70    let url = Url::parse(trimmed).map_err(|e| UserError::InvalidConfig {
71        reason: format!("invalid remote base URL: {e}"),
72    })?;
73
74    if url.username() != "" || url.password().is_some() {
75        return Err(UserError::InvalidConfig {
76            reason: "remote base URL must not embed userinfo/credentials".into(),
77        }
78        .into());
79    }
80
81    let scheme = url.scheme();
82    let host = url.host_str().unwrap_or("").to_ascii_lowercase();
83    let is_loopback = matches!(host.as_str(), "127.0.0.1" | "localhost" | "::1");
84    let is_official = provider.is_official_origin(scheme, &host);
85
86    match scheme {
87        "https" => {}
88        "http" if remote.allow_loopback_http && is_loopback => {}
89        "http" => {
90            return Err(UserError::InvalidConfig {
91                reason: format!(
92                    "HTTP remote endpoints are only allowed for loopback test mode (got {trimmed})"
93                ),
94            }
95            .into());
96        }
97        other => {
98            return Err(UserError::InvalidConfig {
99                reason: format!("unsupported URL scheme '{other}' (use https)"),
100            }
101            .into());
102        }
103    }
104
105    let custom_ok = remote.allow_custom_credentialed_endpoint
106        && scheme == "https"
107        && provider.allows_custom_credentialed_endpoint();
108    let credentials_allowed =
109        is_official || (is_loopback && remote.allow_loopback_http) || custom_ok;
110    if !credentials_allowed {
111        return Err(UserError::InvalidConfig {
112            reason: format!(
113                "credentialed remote endpoint '{trimmed}' is not an official {} origin.\n  \
114                 Hint: {}",
115                provider.provider_id(),
116                provider.custom_endpoint_hint()
117            ),
118        }
119        .into());
120    }
121
122    Ok(RemoteEndpoint {
123        base_url: trimmed.to_string(),
124        is_official,
125        credentials_allowed,
126        provider_id: provider.provider_id().to_string(),
127    })
128}
129
130/// Hardened reqwest client shared by remote STT and cleanup.
131///
132/// Provider identity, origins, auth, and extra headers come from the attached
133/// [`ProviderHttpPolicy`]. Transport flags remain on [`RemotePolicy`].
134#[derive(Clone)]
135pub struct HardenedHttpClient {
136    http: Client,
137    endpoint: RemoteEndpoint,
138    remote_policy: RemotePolicy,
139    provider: Arc<dyn ProviderHttpPolicy>,
140}
141
142impl std::fmt::Debug for HardenedHttpClient {
143    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
144        f.debug_struct("HardenedHttpClient")
145            .field("base_url", &self.endpoint.base_url)
146            .field("is_official", &self.endpoint.is_official)
147            .field("provider_id", &self.endpoint.provider_id)
148            .finish()
149    }
150}
151
152impl HardenedHttpClient {
153    /// Build a client for an arbitrary named provider policy.
154    pub fn build(
155        base_url: Option<&str>,
156        remote: RemotePolicy,
157        provider: impl ProviderHttpPolicy + 'static,
158    ) -> Result<Self> {
159        Self::build_arc(base_url, remote, Arc::new(provider))
160    }
161
162    /// Build with a pre-wrapped policy (shared across clones).
163    pub fn build_arc(
164        base_url: Option<&str>,
165        remote: RemotePolicy,
166        provider: Arc<dyn ProviderHttpPolicy>,
167    ) -> Result<Self> {
168        let raw = base_url
169            .map(|s| s.trim())
170            .filter(|s| !s.is_empty())
171            .unwrap_or_else(|| provider.default_base_url());
172        let endpoint = validate_endpoint(raw, &remote, provider.as_ref())?;
173
174        let mut builder = Client::builder()
175            .user_agent(concat!("aurum-core/", env!("CARGO_PKG_VERSION")))
176            .connect_timeout(remote.connect_timeout)
177            .timeout(remote.total_timeout)
178            // Never follow redirects with credentials (JOE-1587).
179            .redirect(reqwest::redirect::Policy::none());
180
181        if !remote.use_system_proxy {
182            builder = builder.no_proxy();
183        }
184
185        let http = builder.build().map_err(|e| ProviderError::Network {
186            provider: provider.provider_id().into(),
187            reason: super::status::public_network_reason(&e),
188        })?;
189
190        Ok(Self {
191            http,
192            endpoint,
193            remote_policy: remote,
194            provider,
195        })
196    }
197
198    /// Convenience: OpenRouter policy (STT + cleanup default path).
199    pub fn openrouter(base_url: Option<&str>, remote: RemotePolicy) -> Result<Self> {
200        Self::build(base_url, remote, OpenRouterHttpPolicy)
201    }
202
203    pub fn endpoint(&self) -> &RemoteEndpoint {
204        &self.endpoint
205    }
206
207    pub fn base_url(&self) -> &str {
208        &self.endpoint.base_url
209    }
210
211    pub fn policy(&self) -> &RemotePolicy {
212        &self.remote_policy
213    }
214
215    pub fn provider_id(&self) -> &str {
216        self.provider.provider_id()
217    }
218
219    /// Build a request with policy auth + extra headers + shared request id.
220    pub fn request(&self, method: Method, path: &str, api_key: &str) -> Result<RequestBuilder> {
221        if !self.endpoint.credentials_allowed {
222            return Err(UserError::InvalidConfig {
223                reason: "credentials are not allowed for this endpoint under current policy".into(),
224            }
225            .into());
226        }
227
228        let Some(path) = normalize_request_path(path) else {
229            return Err(UserError::InvalidConfig {
230                reason: format!(
231                    "remote path is empty or contains disallowed segments ({})",
232                    self.provider.provider_id()
233                ),
234            }
235            .into());
236        };
237
238        if !self.provider.allows_path(path) {
239            return Err(UserError::InvalidConfig {
240                reason: format!(
241                    "path '{path}' is not allowed for provider {}",
242                    self.provider.provider_id()
243                ),
244            }
245            .into());
246        }
247
248        let url = format!("{}/{}", self.endpoint.base_url, path);
249        // Reject if path somehow rewrites host (defense in depth).
250        if let Ok(u) = Url::parse(&url) {
251            let base = Url::parse(&self.endpoint.base_url).ok();
252            if let Some(b) = base {
253                if u.origin() != b.origin() {
254                    return Err(UserError::InvalidConfig {
255                        reason: "request URL origin diverged from validated endpoint".into(),
256                    }
257                    .into());
258                }
259            }
260        }
261
262        let mut req = self.http.request(method, url);
263        req = self.provider.apply_auth(req, api_key);
264        req = self.provider.apply_extra_headers(req);
265        Ok(req.header(
266            "X-Request-Id",
267            format!(
268                "aurum-{}",
269                std::time::SystemTime::now()
270                    .duration_since(std::time::UNIX_EPOCH)
271                    .map(|d| d.as_millis())
272                    .unwrap_or(0)
273            ),
274        ))
275    }
276
277    pub fn get_raw(&self) -> &Client {
278        &self.http
279    }
280}
281
282/// Map HTTP status codes to typed provider errors.
283///
284/// Public reasons are **allowlisted only** (HTTP status + optional closed provider code).
285/// Arbitrary remote response bodies and free-form string codes are never echoed (JOE-1914 / JOE-1920).
286pub fn map_http_status(provider: &str, status: StatusCode, body: &str) -> Result<()> {
287    use super::status::public_http_reason;
288    let code = status.as_u16();
289    // Body is only scanned for a closed local code set — never free text.
290    let reason = public_http_reason(code, body);
291    match code {
292        200..=299 => Ok(()),
293        401 | 403 => Err(ProviderError::Auth {
294            provider: provider.into(),
295            reason,
296        }
297        .into()),
298        429 => Err(ProviderError::RateLimited {
299            provider: provider.into(),
300        }
301        .into()),
302        402 => Err(ProviderError::QuotaExceeded {
303            provider: provider.into(),
304            reason,
305        }
306        .into()),
307        _ => Err(ProviderError::Remote {
308            provider: provider.into(),
309            reason,
310        }
311        .into()),
312    }
313}
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318    use crate::remote::policy::{
319        ElevenLabsHttpPolicy, OpenAiHttpPolicy, OpenRouterHttpPolicy, XaiHttpPolicy,
320    };
321
322    #[test]
323    fn official_endpoint_ok() {
324        let ep = validate_endpoint(
325            "https://openrouter.ai/api/v1",
326            &RemotePolicy::default(),
327            &OpenRouterHttpPolicy,
328        )
329        .unwrap();
330        assert!(ep.is_official);
331        assert!(ep.credentials_allowed);
332        assert_eq!(ep.provider_id, "openrouter");
333    }
334
335    #[test]
336    fn foreign_host_rejected_by_default() {
337        let err = validate_endpoint(
338            "https://evil.example/api",
339            &RemotePolicy::default(),
340            &OpenRouterHttpPolicy,
341        )
342        .unwrap_err();
343        assert_eq!(err.exit_code(), 2);
344        assert!(
345            err.to_string().contains("allow_custom_endpoint")
346                || err.to_string().contains("official")
347        );
348    }
349
350    #[test]
351    fn openrouter_origin_not_official_for_openai_policy() {
352        let err = validate_endpoint(
353            "https://openrouter.ai/api/v1",
354            &RemotePolicy::default(),
355            &OpenAiHttpPolicy,
356        )
357        .unwrap_err();
358        assert!(err.to_string().contains("openai") || err.to_string().contains("official"));
359    }
360
361    #[test]
362    fn openai_official_ok() {
363        let ep = validate_endpoint(
364            "https://api.openai.com/v1",
365            &RemotePolicy::default(),
366            &OpenAiHttpPolicy,
367        )
368        .unwrap();
369        assert!(ep.is_official);
370    }
371
372    #[test]
373    fn elevenlabs_and_xai_official_ok() {
374        let el = validate_endpoint(
375            "https://api.elevenlabs.io",
376            &RemotePolicy::default(),
377            &ElevenLabsHttpPolicy,
378        )
379        .unwrap();
380        assert!(el.is_official);
381        let xai = validate_endpoint(
382            "https://api.x.ai/v1",
383            &RemotePolicy::default(),
384            &XaiHttpPolicy,
385        )
386        .unwrap();
387        assert!(xai.is_official);
388    }
389
390    #[test]
391    fn map_http_status_never_echoes_body_payload() {
392        let body = r#"{"error":{"message":"sk-or-v1-canary-should-not-appear","code":401}}"#;
393        let err =
394            map_http_status("openrouter", reqwest::StatusCode::UNAUTHORIZED, body).unwrap_err();
395        let msg = err.to_string();
396        assert!(!msg.contains("canary"));
397        assert!(!msg.contains("sk-or-v1"));
398        assert!(msg.contains("401") || msg.to_ascii_lowercase().contains("auth"));
399    }
400
401    #[test]
402    fn map_http_status_drops_unknown_string_provider_code() {
403        let body =
404            r#"{"error":{"message":"transcript: hello world secret","code":"no_endpoints"}}"#;
405        let err = map_http_status("openrouter", reqwest::StatusCode::NOT_FOUND, body).unwrap_err();
406        let msg = err.to_string();
407        assert!(!msg.contains("transcript"));
408        assert!(!msg.contains("hello world"));
409        assert!(!msg.contains("no_endpoints"));
410        assert!(msg.contains("404"));
411    }
412
413    #[test]
414    fn map_http_status_drops_credential_shaped_provider_code() {
415        let body =
416            r#"{"error":{"message":"x","code":"sk-or-v1-TESTCANARY-JOE1920-DO-NOT-USE-001"}}"#;
417        let err =
418            map_http_status("openrouter", reqwest::StatusCode::UNAUTHORIZED, body).unwrap_err();
419        let msg = err.to_string();
420        assert!(!msg.contains("TESTCANARY"));
421        assert!(!msg.contains("sk-or-v1"));
422    }
423
424    #[test]
425    fn custom_allowed_with_opt_in() {
426        let policy = RemotePolicy {
427            allow_custom_credentialed_endpoint: true,
428            ..Default::default()
429        };
430        let ep = validate_endpoint(
431            "https://compatible.example/v1",
432            &policy,
433            &OpenRouterHttpPolicy,
434        )
435        .unwrap();
436        assert!(!ep.is_official);
437        assert!(ep.credentials_allowed);
438    }
439
440    #[test]
441    fn rejects_userinfo() {
442        let err = validate_endpoint(
443            "https://user:pass@openrouter.ai/api/v1",
444            &RemotePolicy::default(),
445            &OpenRouterHttpPolicy,
446        )
447        .unwrap_err();
448        assert!(err.to_string().contains("userinfo") || err.to_string().contains("credential"));
449    }
450
451    #[test]
452    fn loopback_http_for_tests() {
453        let policy = RemotePolicy {
454            allow_loopback_http: true,
455            ..Default::default()
456        };
457        let ep = validate_endpoint("http://127.0.0.1:9", &policy, &OpenRouterHttpPolicy).unwrap();
458        assert!(ep.credentials_allowed);
459    }
460
461    #[test]
462    fn http_non_loopback_rejected() {
463        assert!(validate_endpoint(
464            "http://evil.example",
465            &RemotePolicy::default(),
466            &OpenRouterHttpPolicy
467        )
468        .is_err());
469    }
470
471    #[test]
472    fn request_applies_openrouter_headers_only() {
473        let client = HardenedHttpClient::openrouter(None, RemotePolicy::default()).unwrap();
474        let req = client
475            .request(Method::POST, "chat/completions", "sk-test")
476            .unwrap()
477            .build()
478            .unwrap();
479        assert!(req.headers().get("Authorization").is_some());
480        assert!(
481            req.headers().get("HTTP-Referer").is_some() || req.headers().get("Referer").is_some()
482        );
483        assert_eq!(
484            req.headers()
485                .get("X-OpenRouter-Title")
486                .unwrap()
487                .to_str()
488                .unwrap(),
489            "Aurum"
490        );
491        assert!(req.headers().get("X-Title").is_none());
492        assert!(req.headers().get("X-OpenRouter-Categories").is_some());
493        assert!(req.headers().get("X-Request-Id").is_some());
494        assert!(req.headers().get("xi-api-key").is_none());
495    }
496
497    #[test]
498    fn request_openai_has_no_openrouter_headers() {
499        let client =
500            HardenedHttpClient::build(None, RemotePolicy::default(), OpenAiHttpPolicy).unwrap();
501        let req = client
502            .request(Method::POST, "audio/transcriptions", "sk-test")
503            .unwrap()
504            .build()
505            .unwrap();
506        assert!(req.headers().get("Authorization").is_some());
507        assert!(req.headers().get("HTTP-Referer").is_none());
508        assert!(req.headers().get("X-Title").is_none());
509        assert!(req.headers().get("X-OpenRouter-Title").is_none());
510        assert!(req.headers().get("X-OpenRouter-Categories").is_none());
511        assert!(req.headers().get("xi-api-key").is_none());
512    }
513
514    #[test]
515    fn request_elevenlabs_uses_xi_api_key() {
516        let client =
517            HardenedHttpClient::build(None, RemotePolicy::default(), ElevenLabsHttpPolicy).unwrap();
518        let req = client
519            .request(Method::POST, "v1/text-to-speech/voice1", "el-key")
520            .unwrap()
521            .build()
522            .unwrap();
523        assert_eq!(
524            req.headers().get("xi-api-key").unwrap().to_str().unwrap(),
525            "el-key"
526        );
527        assert!(req.headers().get("Authorization").is_none());
528        assert!(req.headers().get("HTTP-Referer").is_none());
529        assert!(req.headers().get("X-Title").is_none());
530        assert!(req.headers().get("X-OpenRouter-Title").is_none());
531        assert!(req.headers().get("X-OpenRouter-Categories").is_none());
532    }
533
534    #[test]
535    fn request_rejects_disallowed_path() {
536        let client = HardenedHttpClient::openrouter(None, RemotePolicy::default()).unwrap();
537        let err = client
538            .request(Method::GET, "models", "sk-test")
539            .unwrap_err();
540        assert!(err.to_string().contains("not allowed") || err.to_string().contains("path"));
541    }
542
543    #[test]
544    fn default_base_url_per_provider() {
545        let or = HardenedHttpClient::openrouter(None, RemotePolicy::default()).unwrap();
546        assert!(or.base_url().contains("openrouter.ai"));
547        let oa =
548            HardenedHttpClient::build(None, RemotePolicy::default(), OpenAiHttpPolicy).unwrap();
549        assert!(oa.base_url().contains("api.openai.com"));
550    }
551}