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`; `X-Title` kept for compat).
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            // Backwards-compatible alias still accepted by OpenRouter.
163            .header("X-Title", OPENROUTER_APP_TITLE)
164            .header("X-OpenRouter-Categories", OPENROUTER_APP_CATEGORIES)
165    }
166
167    fn allows_path(&self, path: &str) -> bool {
168        // STT + TTS speech + cleanup surfaces currently used by Aurum (JOE-1939).
169        path_allowed(
170            path,
171            &["chat/completions", "audio/transcriptions", "audio/speech"],
172            &[],
173        )
174    }
175
176    fn custom_endpoint_hint(&self) -> String {
177        format!(
178            "set openrouter.allow_custom_endpoint = true only for trusted compatible APIs, \
179             or use {OPENROUTER_ORIGIN}."
180        )
181    }
182}
183
184// ── OpenAI ──────────────────────────────────────────────────────────────────
185
186pub const OPENAI_ORIGIN: &str = "https://api.openai.com";
187pub const OPENAI_DEFAULT_BASE: &str = "https://api.openai.com/v1";
188
189/// OpenAI first-party HTTP policy (Bearer; no OpenRouter attribution headers).
190#[derive(Debug, Default, Clone, Copy)]
191pub struct OpenAiHttpPolicy;
192
193impl ProviderHttpPolicy for OpenAiHttpPolicy {
194    fn provider_id(&self) -> &'static str {
195        "openai"
196    }
197
198    fn official_origins(&self) -> &'static [&'static str] {
199        &[OPENAI_ORIGIN]
200    }
201
202    fn default_base_url(&self) -> &'static str {
203        OPENAI_DEFAULT_BASE
204    }
205
206    fn auth_scheme(&self) -> AuthScheme {
207        AuthScheme::Bearer
208    }
209
210    fn allows_path(&self, path: &str) -> bool {
211        path_allowed(
212            path,
213            &[
214                "chat/completions",
215                "audio/transcriptions",
216                "audio/translations",
217                "audio/speech",
218            ],
219            &[],
220        )
221    }
222}
223
224// ── ElevenLabs ──────────────────────────────────────────────────────────────
225
226pub const ELEVENLABS_ORIGIN: &str = "https://api.elevenlabs.io";
227pub const ELEVENLABS_DEFAULT_BASE: &str = "https://api.elevenlabs.io";
228
229/// ElevenLabs HTTP policy (`xi-api-key`; no Bearer / OpenRouter headers).
230#[derive(Debug, Default, Clone, Copy)]
231pub struct ElevenLabsHttpPolicy;
232
233impl ProviderHttpPolicy for ElevenLabsHttpPolicy {
234    fn provider_id(&self) -> &'static str {
235        "elevenlabs"
236    }
237
238    fn official_origins(&self) -> &'static [&'static str] {
239        &[ELEVENLABS_ORIGIN]
240    }
241
242    fn default_base_url(&self) -> &'static str {
243        ELEVENLABS_DEFAULT_BASE
244    }
245
246    fn auth_scheme(&self) -> AuthScheme {
247        AuthScheme::XiApiKey
248    }
249
250    fn allows_path(&self, path: &str) -> bool {
251        // Prefix allowlist: voice-id suffixes under TTS/STT routes.
252        path_allowed(
253            path,
254            &["v1/speech-to-text"],
255            &["v1/text-to-speech", "v1/speech-to-speech"],
256        )
257    }
258}
259
260// ── xAI ─────────────────────────────────────────────────────────────────────
261
262pub const XAI_ORIGIN: &str = "https://api.x.ai";
263pub const XAI_DEFAULT_BASE: &str = "https://api.x.ai/v1";
264
265/// xAI HTTP policy (Bearer; no OpenRouter attribution headers).
266#[derive(Debug, Default, Clone, Copy)]
267pub struct XaiHttpPolicy;
268
269impl ProviderHttpPolicy for XaiHttpPolicy {
270    fn provider_id(&self) -> &'static str {
271        "xai"
272    }
273
274    fn official_origins(&self) -> &'static [&'static str] {
275        &[XAI_ORIGIN]
276    }
277
278    fn default_base_url(&self) -> &'static str {
279        XAI_DEFAULT_BASE
280    }
281
282    fn auth_scheme(&self) -> AuthScheme {
283        AuthScheme::Bearer
284    }
285
286    fn allows_path(&self, path: &str) -> bool {
287        // Official Voice REST (JOE-1976): POST /v1/stt and POST /v1/tts only.
288        // OpenAI-shaped /audio/* and realtime/WebSocket remain denied.
289        path_allowed(path, &["stt", "tts"], &[])
290    }
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296    use reqwest::Client;
297
298    #[test]
299    fn openrouter_extra_headers_present() {
300        let p = OpenRouterHttpPolicy;
301        let req = p
302            .apply_extra_headers(Client::new().get("https://openrouter.ai/api/v1/x"))
303            .build()
304            .unwrap();
305        let headers = req.headers();
306        let names: Vec<_> = headers
307            .keys()
308            .map(|k| k.as_str().to_ascii_lowercase())
309            .collect();
310        assert!(names.iter().any(|n| n == "http-referer" || n == "referer"));
311        assert!(names.iter().any(|n| n == "x-openrouter-title"));
312        assert!(names.iter().any(|n| n == "x-title"));
313        assert!(names.iter().any(|n| n == "x-openrouter-categories"));
314
315        let referer = headers
316            .get("HTTP-Referer")
317            .or_else(|| headers.get("Referer"))
318            .and_then(|v| v.to_str().ok())
319            .unwrap();
320        assert_eq!(referer, OPENROUTER_APP_REFERER);
321        assert_eq!(
322            headers.get("X-OpenRouter-Title").unwrap().to_str().unwrap(),
323            OPENROUTER_APP_TITLE
324        );
325        assert_eq!(
326            headers.get("X-Title").unwrap().to_str().unwrap(),
327            OPENROUTER_APP_TITLE
328        );
329        assert_eq!(
330            headers
331                .get("X-OpenRouter-Categories")
332                .unwrap()
333                .to_str()
334                .unwrap(),
335            OPENROUTER_APP_CATEGORIES
336        );
337    }
338
339    #[test]
340    fn openrouter_headers_do_not_cross_to_openai() {
341        let p = OpenAiHttpPolicy;
342        let req = p
343            .apply_extra_headers(Client::new().get("https://api.openai.com/v1/x"))
344            .build()
345            .unwrap();
346        for name in req.headers().keys() {
347            let n = name.as_str().to_ascii_lowercase();
348            assert_ne!(n, "http-referer");
349            assert_ne!(n, "x-title");
350            assert_ne!(n, "x-openrouter-title");
351            assert_ne!(n, "x-openrouter-categories");
352            assert_ne!(n, "xi-api-key");
353        }
354    }
355
356    #[test]
357    fn openrouter_headers_do_not_cross_to_elevenlabs_or_xai() {
358        for req in [
359            ElevenLabsHttpPolicy
360                .apply_extra_headers(Client::new().get("https://api.elevenlabs.io/x"))
361                .build()
362                .unwrap(),
363            XaiHttpPolicy
364                .apply_extra_headers(Client::new().get("https://api.x.ai/v1/x"))
365                .build()
366                .unwrap(),
367        ] {
368            for name in req.headers().keys() {
369                let n = name.as_str().to_ascii_lowercase();
370                assert_ne!(n, "http-referer");
371                assert_ne!(n, "x-title");
372                assert_ne!(n, "x-openrouter-title");
373                assert_ne!(n, "x-openrouter-categories");
374            }
375        }
376    }
377
378    #[test]
379    fn auth_schemes_do_not_cross() {
380        let key = "test-key-canary";
381        let bearer = OpenAiHttpPolicy
382            .apply_auth(Client::new().get("https://api.openai.com/v1/x"), key)
383            .build()
384            .unwrap();
385        assert!(bearer.headers().get("Authorization").is_some());
386        assert!(bearer.headers().get("xi-api-key").is_none());
387
388        let xi = ElevenLabsHttpPolicy
389            .apply_auth(Client::new().get("https://api.elevenlabs.io/x"), key)
390            .build()
391            .unwrap();
392        assert!(xi.headers().get("xi-api-key").is_some());
393        assert!(xi.headers().get("Authorization").is_none());
394        assert!(!xi
395            .headers()
396            .get("xi-api-key")
397            .unwrap()
398            .to_str()
399            .unwrap()
400            .contains("Bearer"));
401    }
402
403    #[test]
404    fn official_origins_are_provider_scoped() {
405        assert!(OpenRouterHttpPolicy.is_official_origin("https", "openrouter.ai"));
406        assert!(!OpenRouterHttpPolicy.is_official_origin("https", "api.openai.com"));
407        assert!(!OpenRouterHttpPolicy.is_official_origin("http", "openrouter.ai"));
408
409        assert!(OpenAiHttpPolicy.is_official_origin("https", "api.openai.com"));
410        assert!(!OpenAiHttpPolicy.is_official_origin("https", "openrouter.ai"));
411
412        assert!(ElevenLabsHttpPolicy.is_official_origin("https", "api.elevenlabs.io"));
413        assert!(XaiHttpPolicy.is_official_origin("https", "api.x.ai"));
414        assert!(!XaiHttpPolicy.is_official_origin("https", "api.openai.com"));
415    }
416
417    #[test]
418    fn path_allowlists_reject_traversal_and_foreign() {
419        assert!(OpenRouterHttpPolicy.allows_path("chat/completions"));
420        assert!(OpenRouterHttpPolicy.allows_path("/audio/transcriptions"));
421        assert!(OpenRouterHttpPolicy.allows_path("audio/speech"));
422        assert!(!OpenRouterHttpPolicy.allows_path("../chat/completions"));
423        assert!(!OpenRouterHttpPolicy.allows_path("https://evil.example/x"));
424        assert!(!OpenRouterHttpPolicy.allows_path("models"));
425
426        assert!(OpenAiHttpPolicy.allows_path("audio/speech"));
427        assert!(!OpenAiHttpPolicy.allows_path("v1/text-to-speech/abc"));
428
429        assert!(ElevenLabsHttpPolicy.allows_path("v1/text-to-speech/voiceid"));
430        assert!(!ElevenLabsHttpPolicy.allows_path("chat/completions"));
431
432        assert!(XaiHttpPolicy.allows_path("stt"));
433        assert!(XaiHttpPolicy.allows_path("tts"));
434        assert!(!XaiHttpPolicy.allows_path("audio/transcriptions"));
435        assert!(!XaiHttpPolicy.allows_path("audio/speech"));
436        assert!(!XaiHttpPolicy.allows_path("chat/completions"));
437        assert!(!XaiHttpPolicy.allows_path("realtime"));
438    }
439
440    #[test]
441    fn provider_ids_stable() {
442        assert_eq!(OpenRouterHttpPolicy.provider_id(), "openrouter");
443        assert_eq!(OpenAiHttpPolicy.provider_id(), "openai");
444        assert_eq!(ElevenLabsHttpPolicy.provider_id(), "elevenlabs");
445        assert_eq!(XaiHttpPolicy.provider_id(), "xai");
446    }
447}