Skip to main content

auth_cloudflare/
auth.rs

1//! Auth - Cloudflare account ID + API token → Workers AI endpoint resolution.
2
3use crate::error::CloudflareError;
4
5/// Base API host for Cloudflare client v4 endpoints.
6pub const API_BASE: &str = "https://api.cloudflare.com/client/v4";
7
8/// Env var holding the Cloudflare API token (`cfut_…` / `cfwt_…`).
9pub const TOKEN_ENV: &str = "CLOUDFLARE_API_TOKEN";
10
11/// Env var holding the (non-secret) Cloudflare account ID.
12pub const ACCOUNT_ENV: &str = "CLOUDFLARE_ACCOUNT_ID";
13
14/// Resolved Cloudflare account credentials.
15///
16/// The account ID is operational metadata, not a secret; the token IS a
17/// secret and is only ever echoed back through Bearer headers - never into
18/// Display/Debug output (see the manual impls below).
19#[derive(Clone)]
20pub struct AccountCredentials {
21	pub account_id: String,
22	pub api_token: String,
23}
24
25impl std::fmt::Debug for AccountCredentials {
26	fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
27		f.debug_struct("AccountCredentials")
28			.field("account_id", &self.account_id)
29			.field("api_token", &"<redacted>")
30			.finish()
31	}
32}
33
34impl std::fmt::Display for AccountCredentials {
35	fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
36		write!(f, "Cloudflare account {} (token redacted)", self.account_id)
37	}
38}
39
40impl AccountCredentials {
41	/// Resolve credentials from the environment.
42	///
43	/// Raises an actionable error naming the exact env var that is missing.
44	pub fn from_env() -> Result<Self, CloudflareError> {
45		let account_id = std::env::var(ACCOUNT_ENV).unwrap_or_default().trim().to_string();
46		if account_id.is_empty() {
47			return Err(CloudflareError::MissingEnv {
48				env_var: ACCOUNT_ENV,
49				hint: format!(
50					"export {ACCOUNT_ENV}=<your account id> - found under Workers & Pages → Overview → Account ID"
51				),
52			});
53		}
54		let api_token = std::env::var(TOKEN_ENV).unwrap_or_default().trim().to_string();
55		if api_token.is_empty() {
56			return Err(CloudflareError::MissingEnv {
57				env_var: TOKEN_ENV,
58				hint: format!(
59					"export {TOKEN_ENV}=<scoped api token> - create a custom token with Account → Workers AI → Write (some dashboards label it Edit)"
60				),
61			});
62		}
63		Ok(Self { account_id, api_token })
64	}
65
66	/// Validate credentials without requiring them (profile bootstrap).
67	pub fn from_env_lenient() -> Option<Self> {
68		let account_id = std::env::var(ACCOUNT_ENV).unwrap_or_default().trim().to_string();
69		let api_token = std::env::var(TOKEN_ENV).unwrap_or_default().trim().to_string();
70		if account_id.is_empty() || api_token.is_empty() {
71			None
72		} else {
73			Some(Self { account_id, api_token })
74		}
75	}
76}
77
78/// Workers AI auth-provider endpoints for one account.
79///
80/// Exactly one source of truth for every URL the plugin and the crates build.
81#[derive(Debug, Clone, PartialEq, Eq)]
82pub struct AuthProvider {
83	pub account_id: String,
84}
85
86impl AuthProvider {
87	/// Build the provider from an account ID (lenient - used by the Python
88	/// profile, which handles the missing-env case itself).
89	pub fn new(account_id: impl Into<String>) -> Self {
90		Self { account_id: account_id.into().trim().to_string() }
91	}
92
93	/// Build the provider from environment credentials, if present.
94	pub fn from_env_lenient() -> Option<Self> {
95		std::env::var(ACCOUNT_ENV).ok().filter(|v| !v.trim().is_empty()).map(Self::new)
96	}
97
98	/// OpenAI-compatible inference base URL - `hermes` appends
99	/// `/chat/completions` in Chat Completions mode.
100	pub fn base_url(&self) -> String {
101		format!("{API_BASE}/accounts/{}/ai/v1", self.account_id)
102	}
103
104	/// Model catalog endpoint (OpenRouter-compatible response shape).
105	pub fn models_url(&self) -> String {
106		format!(
107			"{API_BASE}/accounts/{}/ai/models/search?format=openrouter&per_page=1000",
108			self.account_id
109		)
110	}
111
112	/// Token verification endpoint - the plugin's health check.
113	pub fn verify_url(&self) -> String {
114		format!("{API_BASE}/user/tokens/verify")
115	}
116
117	/// Native (REST-style) model invocation path - kept for parity with the
118	/// user's existing curl workflow, NOT used by the OpenAI-compatible wire.
119	pub fn run_url(&self, model: &str) -> String {
120		format!("{API_BASE}/accounts/{}/ai/run/{}", self.account_id, model)
121	}
122
123	/// Account-scoped cache directory name (first 12 hex of sha256).
124	pub fn cache_slug(&self) -> String {
125		use std::fmt::Write;
126		// FNV-1a 64-bit - a stable, dependency-free stand-in for sha256[:12].
127		let mut hash: u64 = 0xcbf29ce484222325;
128		for byte in self.account_id.as_bytes() {
129			hash ^= u64::from(*byte);
130			hash = hash.wrapping_mul(0x100000001b3);
131		}
132		let mut slug = String::new();
133		let _ = write!(slug, "{hash:016x}");
134		slug
135	}
136}
137
138#[cfg(test)]
139mod tests {
140	use super::*;
141
142	#[test]
143	fn endpoints_are_stable() {
144		// Synthetic account ID - never a real account.
145		let provider = AuthProvider::new("00000000000000000000000000000000");
146		assert_eq!(
147			provider.base_url(),
148			"https://api.cloudflare.com/client/v4/accounts/00000000000000000000000000000000/ai/v1"
149		);
150		assert_eq!(
151			provider.models_url(),
152			"https://api.cloudflare.com/client/v4/accounts/00000000000000000000000000000000/ai/models/search?format=openrouter&per_page=1000"
153		);
154		assert_eq!(provider.verify_url(), "https://api.cloudflare.com/client/v4/user/tokens/verify");
155		assert_eq!(
156			provider.run_url("@cf/zai-org/glm-5.3-flash"),
157			"https://api.cloudflare.com/client/v4/accounts/00000000000000000000000000000000/ai/run/@cf/zai-org/glm-5.3-flash"
158		);
159	}
160
161	#[test]
162	fn cache_slug_is_hex_and_stable() {
163		let provider = AuthProvider::new("test-account");
164		let slug = provider.cache_slug();
165		assert_eq!(slug.len(), 16);
166		assert!(slug.chars().all(|c| c.is_ascii_hexdigit()));
167		assert_eq!(slug, AuthProvider::new("test-account").cache_slug());
168		assert_ne!(slug, AuthProvider::new("other-account").cache_slug());
169	}
170
171	#[test]
172	fn credentials_redact_token() {
173		let credentials = AccountCredentials { account_id: "acct".to_string(), api_token: "cfut_SECRET".to_string() };
174		let debug = format!("{credentials:?}");
175		assert!(!debug.contains("cfut_SECRET"), "Debug must redact the token");
176		let display = format!("{credentials}");
177		assert!(!display.contains("cfut_SECRET"), "Display must redact the token");
178	}
179}