Skip to main content

aurum_core/remote/
policy.rs

1//! Named provider HTTP policies for the remote transport (JOE-1934).
2//!
3//! Official origins, authentication scheme, and provider-specific headers live
4//! here — not in the shared hardened client. A policy is compiled/reviewed code,
5//! never an implicit trust decision from a free-form model id or base URL.
6
7use reqwest::RequestBuilder;
8
9/// How credentials are attached to outbound requests.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum AuthScheme {
12    /// `Authorization: Bearer <key>` (OpenRouter, OpenAI, xAI).
13    Bearer,
14    /// `xi-api-key: <key>` (ElevenLabs).
15    XiApiKey,
16}
17
18/// Provider-specific HTTP trust and header contract.
19///
20/// Implementations must be pure reviewed code. Credentials may only be applied
21/// after the transport has proven the final URL origin against this policy (or
22/// an explicit custom-endpoint opt-in under [`crate::remote::RemotePolicy`]).
23pub trait ProviderHttpPolicy: Send + Sync {
24    /// Stable provider id (`openrouter`, `openai`, `elevenlabs`, `xai`).
25    fn provider_id(&self) -> &'static str;
26
27    /// Official HTTPS origins (scheme + host, no path), e.g. `https://openrouter.ai`.
28    fn official_origins(&self) -> &'static [&'static str];
29
30    /// Default base URL used when the caller omits an explicit endpoint.
31    fn default_base_url(&self) -> &'static str;
32
33    /// Credential attachment scheme for this provider.
34    fn auth_scheme(&self) -> AuthScheme;
35
36    /// Attach the API key using this policy's auth scheme.
37    fn apply_auth(&self, req: RequestBuilder, api_key: &str) -> RequestBuilder {
38        match self.auth_scheme() {
39            AuthScheme::Bearer => req.header("Authorization", format!("Bearer {api_key}")),
40            AuthScheme::XiApiKey => req.header("xi-api-key", api_key.to_string()),
41        }
42    }
43
44    /// Provider-specific headers (never cross-applied to other providers).
45    fn apply_extra_headers(&self, req: RequestBuilder) -> RequestBuilder {
46        req
47    }
48
49    /// Whether `path` (relative to the validated base URL) is permitted.
50    ///
51    /// Paths are normalized: leading `/` stripped; `..`, backslash, and NUL rejected.
52    fn allows_path(&self, path: &str) -> bool;
53
54    /// True when `scheme`/`host` match an official origin for this policy.
55    fn is_official_origin(&self, scheme: &str, host: &str) -> bool {
56        if !scheme.eq_ignore_ascii_case("https") {
57            return false;
58        }
59        let host = host.to_ascii_lowercase();
60        self.official_origins().iter().any(|origin| {
61            url::Url::parse(origin)
62                .ok()
63                .and_then(|u| {
64                    let o_host = u.host_str()?.to_ascii_lowercase();
65                    Some(u.scheme() == "https" && o_host == host)
66                })
67                .unwrap_or(false)
68        })
69    }
70
71    /// Whether an explicit custom HTTPS endpoint may receive credentials when
72    /// [`crate::remote::RemotePolicy::allow_custom_credentialed_endpoint`] is set.
73    fn allows_custom_credentialed_endpoint(&self) -> bool {
74        true
75    }
76
77    /// Operator-facing hint when a credentialed origin is rejected.
78    fn custom_endpoint_hint(&self) -> String {
79        let id = self.provider_id();
80        let default = self.default_base_url();
81        format!(
82            "set {id}.allow_custom_endpoint = true only for trusted compatible APIs, \
83             or use {default}"
84        )
85    }
86}
87
88// ── Path helpers ────────────────────────────────────────────────────────────
89
90/// Normalize and reject dangerous path segments before allowlist checks.
91pub fn normalize_request_path(path: &str) -> Option<&str> {
92    let path = path.trim().trim_start_matches('/');
93    if path.is_empty() {
94        return None;
95    }
96    if path.contains("..") || path.contains('\\') || path.contains('\0') {
97        return None;
98    }
99    // Reject absolute URLs smuggled as "paths".
100    if path.contains("://") {
101        return None;
102    }
103    Some(path)
104}
105
106fn path_allowed(path: &str, exact: &[&str], prefixes: &[&str]) -> bool {
107    let Some(path) = normalize_request_path(path) else {
108        return false;
109    };
110    if exact.contains(&path) {
111        return true;
112    }
113    prefixes.iter().any(|p| {
114        path == *p
115            || path
116                .strip_prefix(p)
117                .is_some_and(|rest| rest.starts_with('/'))
118    })
119}
120
121// ── OpenRouter ──────────────────────────────────────────────────────────────
122
123/// Official OpenRouter origin and default API base.
124pub const OPENROUTER_ORIGIN: &str = "https://openrouter.ai";
125pub const OPENROUTER_DEFAULT_BASE: &str = "https://openrouter.ai/api/v1";
126
127/// OpenRouter [app attribution](https://openrouter.ai/docs/app-attribution) for Aurum.
128///
129/// `HTTP-Referer` is the primary app id in OpenRouter rankings; keep this URL stable.
130pub const OPENROUTER_APP_REFERER: &str = "https://github.com/joe-broadhead/aurum";
131/// Display name on openrouter.ai rankings / analytics (`X-OpenRouter-Title`).
132pub const OPENROUTER_APP_TITLE: &str = "Aurum";
133/// Marketplace categories (max 2 per request; lowercase hyphen-separated).
134pub const OPENROUTER_APP_CATEGORIES: &str = "audio-gen,cli-agent";
135
136/// OpenRouter HTTP policy (Bearer + attribution headers).
137#[derive(Debug, Default, Clone, Copy)]
138pub struct OpenRouterHttpPolicy;
139
140impl ProviderHttpPolicy for OpenRouterHttpPolicy {
141    fn provider_id(&self) -> &'static str {
142        "openrouter"
143    }
144
145    fn official_origins(&self) -> &'static [&'static str] {
146        &[OPENROUTER_ORIGIN]
147    }
148
149    fn default_base_url(&self) -> &'static str {
150        OPENROUTER_DEFAULT_BASE
151    }
152
153    fn auth_scheme(&self) -> AuthScheme {
154        AuthScheme::Bearer
155    }
156
157    fn apply_extra_headers(&self, req: RequestBuilder) -> RequestBuilder {
158        // https://openrouter.ai/docs/app-attribution
159        // HTTP-Referer is required for rankings; title alone does not create an app page.
160        req.header("HTTP-Referer", OPENROUTER_APP_REFERER)
161            .header("X-OpenRouter-Title", OPENROUTER_APP_TITLE)
162            .header("X-OpenRouter-Categories", OPENROUTER_APP_CATEGORIES)
163    }
164
165    fn allows_path(&self, path: &str) -> bool {
166        // STT + TTS speech + cleanup surfaces currently used by Aurum (JOE-1939).
167        path_allowed(
168            path,
169            &["chat/completions", "audio/transcriptions", "audio/speech"],
170            &[],
171        )
172    }
173
174    fn custom_endpoint_hint(&self) -> String {
175        format!(
176            "set openrouter.allow_custom_endpoint = true only for trusted compatible APIs, \
177             or use {OPENROUTER_ORIGIN}."
178        )
179    }
180}
181
182// ── OpenAI ──────────────────────────────────────────────────────────────────
183
184pub const OPENAI_ORIGIN: &str = "https://api.openai.com";
185pub const OPENAI_DEFAULT_BASE: &str = "https://api.openai.com/v1";
186
187/// OpenAI first-party HTTP policy (Bearer; no OpenRouter attribution headers).
188#[derive(Debug, Default, Clone, Copy)]
189pub struct OpenAiHttpPolicy;
190
191impl ProviderHttpPolicy for OpenAiHttpPolicy {
192    fn provider_id(&self) -> &'static str {
193        "openai"
194    }
195
196    fn official_origins(&self) -> &'static [&'static str] {
197        &[OPENAI_ORIGIN]
198    }
199
200    fn default_base_url(&self) -> &'static str {
201        OPENAI_DEFAULT_BASE
202    }
203
204    fn auth_scheme(&self) -> AuthScheme {
205        AuthScheme::Bearer
206    }
207
208    fn allows_path(&self, path: &str) -> bool {
209        path_allowed(
210            path,
211            &[
212                "chat/completions",
213                "audio/transcriptions",
214                "audio/translations",
215                "audio/speech",
216            ],
217            &[],
218        )
219    }
220}
221
222// ── ElevenLabs ──────────────────────────────────────────────────────────────
223
224pub const ELEVENLABS_ORIGIN: &str = "https://api.elevenlabs.io";
225pub const ELEVENLABS_DEFAULT_BASE: &str = "https://api.elevenlabs.io";
226
227/// ElevenLabs HTTP policy (`xi-api-key`; no Bearer / OpenRouter headers).
228#[derive(Debug, Default, Clone, Copy)]
229pub struct ElevenLabsHttpPolicy;
230
231impl ProviderHttpPolicy for ElevenLabsHttpPolicy {
232    fn provider_id(&self) -> &'static str {
233        "elevenlabs"
234    }
235
236    fn official_origins(&self) -> &'static [&'static str] {
237        &[ELEVENLABS_ORIGIN]
238    }
239
240    fn default_base_url(&self) -> &'static str {
241        ELEVENLABS_DEFAULT_BASE
242    }
243
244    fn auth_scheme(&self) -> AuthScheme {
245        AuthScheme::XiApiKey
246    }
247
248    fn allows_path(&self, path: &str) -> bool {
249        // Prefix allowlist: voice-id suffixes under TTS/STT routes.
250        path_allowed(
251            path,
252            &["v1/speech-to-text"],
253            &["v1/text-to-speech", "v1/speech-to-speech"],
254        )
255    }
256}
257
258// ── xAI ─────────────────────────────────────────────────────────────────────
259
260pub const XAI_ORIGIN: &str = "https://api.x.ai";
261pub const XAI_DEFAULT_BASE: &str = "https://api.x.ai/v1";
262
263/// xAI HTTP policy (Bearer; no OpenRouter attribution headers).
264#[derive(Debug, Default, Clone, Copy)]
265pub struct XaiHttpPolicy;
266
267impl ProviderHttpPolicy for XaiHttpPolicy {
268    fn provider_id(&self) -> &'static str {
269        "xai"
270    }
271
272    fn official_origins(&self) -> &'static [&'static str] {
273        &[XAI_ORIGIN]
274    }
275
276    fn default_base_url(&self) -> &'static str {
277        XAI_DEFAULT_BASE
278    }
279
280    fn auth_scheme(&self) -> AuthScheme {
281        AuthScheme::Bearer
282    }
283
284    fn allows_path(&self, path: &str) -> bool {
285        // Official Voice REST (JOE-1976): POST /v1/stt and POST /v1/tts only.
286        // OpenAI-shaped /audio/* and realtime/WebSocket remain denied.
287        path_allowed(path, &["stt", "tts"], &[])
288    }
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294    use reqwest::Client;
295
296    #[test]
297    fn openrouter_extra_headers_present() {
298        let p = OpenRouterHttpPolicy;
299        let req = p
300            .apply_extra_headers(Client::new().get("https://openrouter.ai/api/v1/x"))
301            .build()
302            .unwrap();
303        let headers = req.headers();
304        let names: Vec<_> = headers
305            .keys()
306            .map(|k| k.as_str().to_ascii_lowercase())
307            .collect();
308        assert!(names.iter().any(|n| n == "http-referer" || n == "referer"));
309        assert!(names.iter().any(|n| n == "x-openrouter-title"));
310        assert!(!names.iter().any(|n| n == "x-title"));
311        assert!(names.iter().any(|n| n == "x-openrouter-categories"));
312
313        let referer = headers
314            .get("HTTP-Referer")
315            .or_else(|| headers.get("Referer"))
316            .and_then(|v| v.to_str().ok())
317            .unwrap();
318        assert_eq!(referer, OPENROUTER_APP_REFERER);
319        assert_eq!(
320            headers.get("X-OpenRouter-Title").unwrap().to_str().unwrap(),
321            OPENROUTER_APP_TITLE
322        );
323        assert!(headers.get("X-Title").is_none());
324        assert_eq!(
325            headers
326                .get("X-OpenRouter-Categories")
327                .unwrap()
328                .to_str()
329                .unwrap(),
330            OPENROUTER_APP_CATEGORIES
331        );
332    }
333
334    #[test]
335    fn openrouter_headers_do_not_cross_to_openai() {
336        let p = OpenAiHttpPolicy;
337        let req = p
338            .apply_extra_headers(Client::new().get("https://api.openai.com/v1/x"))
339            .build()
340            .unwrap();
341        for name in req.headers().keys() {
342            let n = name.as_str().to_ascii_lowercase();
343            assert_ne!(n, "http-referer");
344            assert_ne!(n, "x-title");
345            assert_ne!(n, "x-openrouter-title");
346            assert_ne!(n, "x-openrouter-categories");
347            assert_ne!(n, "xi-api-key");
348        }
349    }
350
351    #[test]
352    fn openrouter_headers_do_not_cross_to_elevenlabs_or_xai() {
353        for req in [
354            ElevenLabsHttpPolicy
355                .apply_extra_headers(Client::new().get("https://api.elevenlabs.io/x"))
356                .build()
357                .unwrap(),
358            XaiHttpPolicy
359                .apply_extra_headers(Client::new().get("https://api.x.ai/v1/x"))
360                .build()
361                .unwrap(),
362        ] {
363            for name in req.headers().keys() {
364                let n = name.as_str().to_ascii_lowercase();
365                assert_ne!(n, "http-referer");
366                assert_ne!(n, "x-title");
367                assert_ne!(n, "x-openrouter-title");
368                assert_ne!(n, "x-openrouter-categories");
369            }
370        }
371    }
372
373    #[test]
374    fn auth_schemes_do_not_cross() {
375        let key = "test-key-canary";
376        let bearer = OpenAiHttpPolicy
377            .apply_auth(Client::new().get("https://api.openai.com/v1/x"), key)
378            .build()
379            .unwrap();
380        assert!(bearer.headers().get("Authorization").is_some());
381        assert!(bearer.headers().get("xi-api-key").is_none());
382
383        let xi = ElevenLabsHttpPolicy
384            .apply_auth(Client::new().get("https://api.elevenlabs.io/x"), key)
385            .build()
386            .unwrap();
387        assert!(xi.headers().get("xi-api-key").is_some());
388        assert!(xi.headers().get("Authorization").is_none());
389        assert!(!xi
390            .headers()
391            .get("xi-api-key")
392            .unwrap()
393            .to_str()
394            .unwrap()
395            .contains("Bearer"));
396    }
397
398    #[test]
399    fn official_origins_are_provider_scoped() {
400        assert!(OpenRouterHttpPolicy.is_official_origin("https", "openrouter.ai"));
401        assert!(!OpenRouterHttpPolicy.is_official_origin("https", "api.openai.com"));
402        assert!(!OpenRouterHttpPolicy.is_official_origin("http", "openrouter.ai"));
403
404        assert!(OpenAiHttpPolicy.is_official_origin("https", "api.openai.com"));
405        assert!(!OpenAiHttpPolicy.is_official_origin("https", "openrouter.ai"));
406
407        assert!(ElevenLabsHttpPolicy.is_official_origin("https", "api.elevenlabs.io"));
408        assert!(XaiHttpPolicy.is_official_origin("https", "api.x.ai"));
409        assert!(!XaiHttpPolicy.is_official_origin("https", "api.openai.com"));
410    }
411
412    #[test]
413    fn path_allowlists_reject_traversal_and_foreign() {
414        assert!(OpenRouterHttpPolicy.allows_path("chat/completions"));
415        assert!(OpenRouterHttpPolicy.allows_path("/audio/transcriptions"));
416        assert!(OpenRouterHttpPolicy.allows_path("audio/speech"));
417        assert!(!OpenRouterHttpPolicy.allows_path("../chat/completions"));
418        assert!(!OpenRouterHttpPolicy.allows_path("https://evil.example/x"));
419        assert!(!OpenRouterHttpPolicy.allows_path("models"));
420
421        assert!(OpenAiHttpPolicy.allows_path("audio/speech"));
422        assert!(!OpenAiHttpPolicy.allows_path("v1/text-to-speech/abc"));
423
424        assert!(ElevenLabsHttpPolicy.allows_path("v1/text-to-speech/voiceid"));
425        assert!(!ElevenLabsHttpPolicy.allows_path("chat/completions"));
426
427        assert!(XaiHttpPolicy.allows_path("stt"));
428        assert!(XaiHttpPolicy.allows_path("tts"));
429        assert!(!XaiHttpPolicy.allows_path("audio/transcriptions"));
430        assert!(!XaiHttpPolicy.allows_path("audio/speech"));
431        assert!(!XaiHttpPolicy.allows_path("chat/completions"));
432        assert!(!XaiHttpPolicy.allows_path("realtime"));
433    }
434
435    #[test]
436    fn provider_ids_stable() {
437        assert_eq!(OpenRouterHttpPolicy.provider_id(), "openrouter");
438        assert_eq!(OpenAiHttpPolicy.provider_id(), "openai");
439        assert_eq!(ElevenLabsHttpPolicy.provider_id(), "elevenlabs");
440        assert_eq!(XaiHttpPolicy.provider_id(), "xai");
441    }
442}