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