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