use chrono::{DateTime, Duration, Utc};
use parking_lot::Mutex;
use std::collections::HashMap;
pub const MIN_TTL: Duration = Duration::seconds(30);
pub const MAX_TTL: Duration = Duration::hours(1);
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StreamGrant {
pub token: String,
pub model: String,
pub expires_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum TokenRejection {
#[error("unknown stream token")]
Unknown,
#[error("stream token expired")]
Expired,
}
#[derive(Debug, Clone)]
struct Grant {
model: String,
expires_at: DateTime<Utc>,
}
#[derive(Debug, Default)]
pub struct StreamTokens {
grants: Mutex<HashMap<String, Grant>>,
}
impl StreamTokens {
pub fn mint(&self, model: &str, ttl: Duration, now: DateTime<Utc>) -> StreamGrant {
let ttl = ttl.clamp(MIN_TTL, MAX_TTL);
let token = crate::secrets::new_secret_hex();
let expires_at = now + ttl;
let mut grants = self.grants.lock();
grants.retain(|_, g| g.expires_at > now);
grants.insert(
crate::secrets::sha256_hex(&token),
Grant {
model: model.to_string(),
expires_at,
},
);
StreamGrant {
token,
model: model.to_string(),
expires_at,
}
}
pub fn check(&self, token: &str, now: DateTime<Utc>) -> Result<String, TokenRejection> {
let grants = self.grants.lock();
match grants.get(&crate::secrets::sha256_hex(token)) {
None => Err(TokenRejection::Unknown),
Some(g) if g.expires_at <= now => Err(TokenRejection::Expired),
Some(g) => Ok(g.model.clone()),
}
}
pub fn len(&self) -> usize {
self.grants.lock().len()
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{Duration, TimeZone, Utc};
fn t0() -> chrono::DateTime<Utc> {
Utc.with_ymd_and_hms(2026, 9, 26, 12, 0, 0).unwrap()
}
#[test]
fn a_minted_token_opens_its_model_until_it_expires() {
let tokens = StreamTokens::default();
let grant = tokens.mint("stt-a", Duration::minutes(10), t0());
assert_eq!(grant.model, "stt-a");
assert_eq!(grant.expires_at, t0() + Duration::minutes(10));
assert_eq!(grant.token.len(), 64, "256 bits, hex");
assert_eq!(
tokens.check(&grant.token, t0() + Duration::minutes(9)),
Ok("stt-a".to_string())
);
assert_eq!(
tokens.check(&grant.token, t0() + Duration::minutes(10)),
Err(TokenRejection::Expired)
);
}
#[test]
fn unknown_tokens_are_rejected() {
let tokens = StreamTokens::default();
assert_eq!(tokens.check("nope", t0()), Err(TokenRejection::Unknown));
}
#[test]
fn every_mint_is_a_new_token() {
let tokens = StreamTokens::default();
let a = tokens.mint("m", Duration::minutes(1), t0());
let b = tokens.mint("m", Duration::minutes(1), t0());
assert_ne!(a.token, b.token);
assert!(
tokens.check(&a.token, t0()).is_ok(),
"older tokens stay valid"
);
}
#[test]
fn ttl_is_clamped_to_the_allowed_range() {
let tokens = StreamTokens::default();
let long = tokens.mint("m", Duration::days(3), t0());
assert_eq!(long.expires_at, t0() + MAX_TTL);
let short = tokens.mint("m", Duration::seconds(-5), t0());
assert_eq!(short.expires_at, t0() + MIN_TTL);
}
#[test]
fn expired_tokens_are_pruned_on_mint() {
let tokens = StreamTokens::default();
tokens.mint("m", Duration::minutes(1), t0());
tokens.mint("m", Duration::minutes(1), t0() + Duration::hours(1));
assert_eq!(tokens.len(), 1);
}
#[test]
fn the_store_never_holds_the_token_itself() {
let tokens = StreamTokens::default();
let grant = tokens.mint("m", Duration::minutes(1), t0());
assert!(!format!("{tokens:?}").contains(&grant.token));
}
#[test]
fn rejections_read_well() {
assert_eq!(TokenRejection::Unknown.to_string(), "unknown stream token");
assert_eq!(TokenRejection::Expired.to_string(), "stream token expired");
}
}