1use std::fmt;
8
9use chrono::{DateTime, Utc};
10use serde_json::{Map, Value};
11
12const DEVICE_CODE: &str = "device_code";
13const USER_CODE: &str = "user_code";
14const VERIFICATION_URI: &str = "verification_uri";
15const VERIFICATION_URI_COMPLETE: &str = "verification_uri_complete";
16const MAX_OAUTH_FIELD_BYTES: usize = 64 * 1024;
17const MAX_VERIFICATION_URL_BYTES: usize = 8 * 1024;
18const PORTAL_HOST: &str = "portal.nousresearch.com";
19
20#[derive(Clone, PartialEq, Eq)]
25pub struct DeviceCode {
26 pub device_code: String,
27 pub user_code: String,
28 pub verification_uri: String,
29 pub verification_uri_complete: String,
30 pub expires_in: u64,
31 pub interval: u64,
32}
33
34impl fmt::Debug for DeviceCode {
35 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
36 f.debug_struct("DeviceCode")
37 .field("device_code", &"<redacted>")
38 .field("user_code", &"<redacted>")
39 .field("verification_uri", &self.verification_uri)
40 .field("verification_uri_complete", &"<redacted>")
41 .field("expires_in", &self.expires_in)
42 .field("interval", &self.interval)
43 .finish()
44 }
45}
46
47#[derive(Clone, PartialEq, Eq)]
52pub struct TokenResponse {
53 pub access_token: String,
54 pub refresh_token: String,
55 pub token_type: String,
56 pub expires_in: u64,
57}
58
59impl fmt::Debug for TokenResponse {
60 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
61 f.debug_struct("TokenResponse")
62 .field("access_token", &"<redacted>")
63 .field("refresh_token", &"<redacted>")
64 .field("token_type", &self.token_type)
65 .field("expires_in", &self.expires_in)
66 .finish()
67 }
68}
69
70#[derive(Debug, Clone, PartialEq)]
75pub struct AccountSnapshot {
76 pub plan: Option<String>,
77 pub tier: Option<i64>,
78 pub monthly_credits: Option<f64>,
79 pub credits_remaining: Option<f64>,
80 pub purchased_credits_remaining: Option<f64>,
81 pub total_usable_credits: Option<f64>,
82 pub rollover_credits: Option<f64>,
83 pub current_period_end: Option<DateTime<Utc>>,
84}
85
86impl Eq for AccountSnapshot {}
88
89impl AccountSnapshot {
90 pub fn usage_percent(&self) -> Option<f64> {
93 let monthly = self.monthly_credits?;
94 let remaining = self.credits_remaining?;
95 if !monthly.is_finite() || !remaining.is_finite() || monthly <= 0.0 || remaining < 0.0 {
96 return None;
97 }
98 Some(((monthly - remaining) / monthly * 100.0).clamp(0.0, 100.0))
99 }
100}
101
102pub fn parse_device_code(value: &Value) -> Result<DeviceCode, String> {
104 let object = object(value, "device-code response")?;
105 let device_code = required_nonempty_string(object, DEVICE_CODE)?;
106 let user_code = required_nonempty_string(object, USER_CODE)?;
107 let verification_uri = required_https_url(object, VERIFICATION_URI)?;
108 let verification_uri_complete = required_https_url(object, VERIFICATION_URI_COMPLETE)?;
109 let expires_in = required_positive_u64(object, "expires_in")?;
110 let interval = required_positive_u64(object, "interval")?;
111
112 Ok(DeviceCode {
113 device_code,
114 user_code,
115 verification_uri,
116 verification_uri_complete,
117 expires_in,
118 interval,
119 })
120}
121
122pub fn parse_token(value: &Value) -> Result<TokenResponse, String> {
124 let object = object(value, "token response")?;
125 let access_token = required_nonempty_string(object, "access_token")?;
126 let refresh_token = required_nonempty_string(object, "refresh_token")?;
127 let token_type = match object.get("token_type") {
128 Some(_) => required_nonempty_string(object, "token_type")?,
129 None => "Bearer".to_string(),
130 };
131 if !token_type.eq_ignore_ascii_case("bearer") {
132 return Err("token response has unsupported token_type".into());
133 }
134 let expires_in = required_positive_u64(object, "expires_in")?;
135
136 Ok(TokenResponse {
137 access_token,
138 refresh_token,
139 token_type,
140 expires_in,
141 })
142}
143
144pub fn parse_account(value: &Value) -> Result<AccountSnapshot, String> {
146 let object = object(value, "account response")?;
147 if object.contains_key("error") || object.contains_key("errors") {
148 return Err("account response is an error envelope".into());
149 }
150
151 let mut sources = Vec::with_capacity(3);
152 if let Some(subscription) = object.get("subscription").and_then(Value::as_object) {
153 sources.push(subscription);
154 }
155 if let Some(access) = object.get("paid_service_access").and_then(Value::as_object) {
156 sources.push(access);
157 }
158 sources.push(object);
159
160 let plan = optional_nonempty_string(&sources, &["plan", "plan_name", "planName"])?;
161 let tier =
162 optional_nonnegative_i64(&sources, &["tier", "subscription_tier", "subscriptionTier"])?;
163 let monthly_credits = optional_credit(&sources, &["monthly_credits", "monthlyCredits"])?;
164 let credits_remaining = optional_credit(
165 &sources,
166 &[
167 "credits_remaining",
168 "creditsRemaining",
169 "subscription_credits_remaining",
170 "subscriptionCreditsRemaining",
171 ],
172 )?;
173 let purchased_credits_remaining = optional_credit(
174 &sources,
175 &[
176 "purchased_credits_remaining",
177 "purchasedCreditsRemaining",
178 "top_up_credits_remaining",
179 "topUpCreditsRemaining",
180 ],
181 )?;
182 let total_usable_credits = optional_credit(
183 &sources,
184 ["total_usable_credits", "totalUsableCredits"].as_slice(),
185 )?;
186 let rollover_credits = optional_credit(
187 &sources,
188 &[
189 "rollover_credits",
190 "rolloverCredits",
191 "additional_credits",
192 "additionalCredits",
193 ],
194 )?;
195 let current_period_end = optional_timestamp(
196 &sources,
197 &[
198 "period_end",
199 "current_period_end",
200 "currentPeriodEnd",
201 "renewal_at",
202 "renewalAt",
203 ],
204 )?;
205
206 let has_known_field = [
210 &["plan", "plan_name", "planName"][..],
211 &["tier", "subscription_tier", "subscriptionTier"][..],
212 &["monthly_credits", "monthlyCredits"][..],
213 &[
214 "credits_remaining",
215 "creditsRemaining",
216 "subscription_credits_remaining",
217 "subscriptionCreditsRemaining",
218 ][..],
219 &["total_usable_credits", "totalUsableCredits"][..],
220 &[
221 "purchased_credits_remaining",
222 "purchasedCreditsRemaining",
223 "top_up_credits_remaining",
224 "topUpCreditsRemaining",
225 ][..],
226 &[
227 "rollover_credits",
228 "rolloverCredits",
229 "additional_credits",
230 "additionalCredits",
231 ][..],
232 &[
233 "period_end",
234 "current_period_end",
235 "currentPeriodEnd",
236 "renewal_at",
237 "renewalAt",
238 ][..],
239 ]
240 .into_iter()
241 .flatten()
242 .any(|key| sources.iter().any(|source| source.contains_key(*key)))
243 || object.get("subscription").is_some_and(Value::is_object)
244 || object
245 .get("paid_service_access")
246 .is_some_and(Value::is_object);
247 if !has_known_field {
248 return Err("account response has no supported display fields".into());
249 }
250
251 Ok(AccountSnapshot {
252 plan,
253 tier,
254 monthly_credits,
255 credits_remaining,
256 purchased_credits_remaining,
257 total_usable_credits,
258 rollover_credits,
259 current_period_end,
260 })
261}
262
263fn object<'a>(value: &'a Value, name: &str) -> Result<&'a Map<String, Value>, String> {
264 value
265 .as_object()
266 .ok_or_else(|| format!("{name} must be a JSON object"))
267}
268
269fn required_nonempty_string(object: &Map<String, Value>, field: &str) -> Result<String, String> {
270 let value = object
271 .get(field)
272 .ok_or_else(|| format!("missing required field `{field}`"))?;
273 let text = value
274 .as_str()
275 .ok_or_else(|| format!("field `{field}` must be a string"))?;
276 if text.trim().is_empty()
277 || text.len() > MAX_OAUTH_FIELD_BYTES
278 || text.chars().any(char::is_control)
279 {
280 return Err(format!("field `{field}` must be non-empty"));
281 }
282 Ok(text.to_owned())
283}
284
285fn required_https_url(object: &Map<String, Value>, field: &str) -> Result<String, String> {
286 let value = required_nonempty_string(object, field)?;
287 let parsed =
288 reqwest::Url::parse(&value).map_err(|_| format!("field `{field}` must be an HTTPS URL"))?;
289 if value.len() > MAX_VERIFICATION_URL_BYTES
290 || parsed.scheme() != "https"
291 || parsed.host_str() != Some(PORTAL_HOST)
292 || parsed.port_or_known_default() != Some(443)
293 || !parsed.username().is_empty()
294 || parsed.password().is_some()
295 {
296 return Err(format!("field `{field}` must be an HTTPS URL"));
297 }
298 Ok(value)
299}
300
301fn required_positive_u64(object: &Map<String, Value>, field: &str) -> Result<u64, String> {
302 let value = object
303 .get(field)
304 .ok_or_else(|| format!("missing required field `{field}`"))?;
305 let number = match value {
306 Value::Number(number) => number.as_u64(),
307 Value::String(text) => text.trim().parse::<u64>().ok(),
308 _ => None,
309 }
310 .ok_or_else(|| format!("field `{field}` must be a positive integer"))?;
311 if number == 0 {
312 return Err(format!("field `{field}` must be positive"));
313 }
314 Ok(number)
315}
316
317fn first<'a>(objects: &[&'a Map<String, Value>], fields: &[&str]) -> Option<&'a Value> {
318 objects
319 .iter()
320 .find_map(|object| fields.iter().find_map(|field| object.get(*field)))
321}
322
323fn optional_nonempty_string(
324 objects: &[&Map<String, Value>],
325 fields: &[&str],
326) -> Result<Option<String>, String> {
327 let Some(value) = first(objects, fields) else {
328 return Ok(None);
329 };
330 if value.is_null() {
331 return Ok(None);
332 }
333 let text = value
334 .as_str()
335 .ok_or_else(|| "account text field must be a string".to_string())?;
336 if text.trim().is_empty() || text.chars().any(char::is_control) {
337 return Err("account text field must be non-empty".into());
338 }
339 Ok(Some(text.to_owned()))
340}
341
342fn optional_nonnegative_i64(
343 objects: &[&Map<String, Value>],
344 fields: &[&str],
345) -> Result<Option<i64>, String> {
346 let Some(value) = first(objects, fields) else {
347 return Ok(None);
348 };
349 if value.is_null() {
350 return Ok(None);
351 }
352 let number = value
353 .as_i64()
354 .ok_or_else(|| "account tier must be a non-negative integer".to_string())?;
355 if number < 0 {
356 return Err("account tier cannot be negative".into());
357 }
358 Ok(Some(number))
359}
360
361fn optional_credit(
362 objects: &[&Map<String, Value>],
363 fields: &[&str],
364) -> Result<Option<f64>, String> {
365 let Some(value) = first(objects, fields) else {
366 return Ok(None);
367 };
368 if value.is_null() {
369 return Ok(None);
370 }
371 let number = match value {
372 Value::Number(number) => number
373 .as_f64()
374 .ok_or_else(|| "account credit is not a finite number".to_string())?,
375 Value::String(text) => text
376 .trim()
377 .parse::<f64>()
378 .map_err(|_| "account credit is not numeric".to_string())?,
379 _ => return Err("account credit must be a number".into()),
380 };
381 if !number.is_finite() || number < 0.0 {
382 return Err("account credit must be finite and non-negative".into());
383 }
384 Ok(Some(number))
385}
386
387fn optional_timestamp(
388 objects: &[&Map<String, Value>],
389 fields: &[&str],
390) -> Result<Option<DateTime<Utc>>, String> {
391 let Some(value) = first(objects, fields) else {
392 return Ok(None);
393 };
394 if value.is_null() {
395 return Ok(None);
396 }
397 let text = value
398 .as_str()
399 .ok_or_else(|| "account period end must be an RFC3339 string".to_string())?;
400 DateTime::parse_from_rfc3339(text)
401 .map(|date| date.with_timezone(&Utc))
402 .map(Some)
403 .map_err(|_| "account period end is not a valid RFC3339 timestamp".into())
404}
405
406#[cfg(test)]
407mod tests {
408 use super::*;
409
410 #[test]
411 fn debug_output_redacts_device_and_token_values() {
412 let device = DeviceCode {
413 device_code: "test-device-secret".into(),
414 user_code: "test-user-secret".into(),
415 verification_uri: "https://portal.nousresearch.com/device".into(),
416 verification_uri_complete:
417 "https://portal.nousresearch.com/device?code=test-user-secret".into(),
418 expires_in: 900,
419 interval: 5,
420 };
421 let token = TokenResponse {
422 access_token: "test-access-secret".into(),
423 refresh_token: "test-refresh-secret".into(),
424 token_type: "Bearer".into(),
425 expires_in: 3600,
426 };
427 let output = format!("{device:?} {token:?}");
428 assert!(!output.contains("test-device-secret"));
429 assert!(!output.contains("test-user-secret"));
430 assert!(!output.contains("test-access-secret"));
431 assert!(!output.contains("test-refresh-secret"));
432 }
433
434 #[test]
435 fn account_snapshot_only_retains_display_safe_fields() {
436 let value = serde_json::json!({
437 "plan": "Pro",
438 "user_id": "test-user-id",
439 "organization_id": "test-org-id",
440 "monthly_credits": 10.0,
441 });
442 let snapshot = parse_account(&value).unwrap();
443 let debug = format!("{snapshot:?}");
444 assert!(debug.contains("Pro"));
445 assert!(!debug.contains("test-user-id"));
446 assert!(!debug.contains("test-org-id"));
447 }
448
449 #[test]
450 fn oauth_urls_are_structural_and_secret_fields_are_bounded() {
451 let mut device = serde_json::json!({
452 "device_code": "test-device",
453 "user_code": "TEST",
454 "verification_uri": "https://portal.nousresearch.com/device",
455 "verification_uri_complete": "https://portal.nousresearch.com/device?code=TEST",
456 "expires_in": 900,
457 "interval": 5
458 });
459 assert!(parse_device_code(&device).is_ok());
460
461 device["verification_uri_complete"] =
462 serde_json::json!("https://user@portal.nousresearch.com/device");
463 assert!(parse_device_code(&device).is_err());
464 device["verification_uri_complete"] =
465 serde_json::json!("https://portal.nousresearch.com.evil.test/device");
466 assert!(parse_device_code(&device).is_err());
467
468 let token = serde_json::json!({
469 "access_token": "x".repeat(MAX_OAUTH_FIELD_BYTES + 1),
470 "refresh_token": "test-refresh",
471 "expires_in": 3600
472 });
473 assert!(parse_token(&token).is_err());
474 }
475}