use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TenantTier {
Free,
Dev,
Pro,
Enterprise,
}
impl TenantTier {
#[allow(clippy::should_implement_trait)]
pub fn from_str(s: &str) -> Option<Self> {
match s.to_lowercase().as_str() {
"free" => Some(TenantTier::Free),
"dev" => Some(TenantTier::Dev),
"pro" => Some(TenantTier::Pro),
"enterprise" => Some(TenantTier::Enterprise),
_ => None,
}
}
pub fn as_str(&self) -> &'static str {
match self {
TenantTier::Free => "free",
TenantTier::Dev => "dev",
TenantTier::Pro => "pro",
TenantTier::Enterprise => "enterprise",
}
}
}
impl std::str::FromStr for TenantTier {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
TenantTier::from_str(s).ok_or_else(|| format!("unknown tenant tier: {}", s))
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TenantQuota {
pub tier: TenantTier,
pub requests_per_month: u64,
pub tokens_per_month: u64,
pub max_agents: u32,
pub requests_per_day: u64,
}
impl Default for TenantQuota {
fn default() -> Self {
Self::free()
}
}
impl TenantQuota {
pub fn free() -> Self {
Self {
tier: TenantTier::Free,
requests_per_month: 1_000,
tokens_per_month: 100_000,
max_agents: 1,
requests_per_day: 50,
}
}
pub fn dev() -> Self {
Self {
tier: TenantTier::Dev,
requests_per_month: 50_000,
tokens_per_month: 5_000_000,
max_agents: 10,
requests_per_day: 2_000,
}
}
pub fn pro() -> Self {
Self {
tier: TenantTier::Pro,
requests_per_month: 500_000,
tokens_per_month: 50_000_000,
max_agents: u32::MAX,
requests_per_day: 20_000,
}
}
pub fn enterprise() -> Self {
Self {
tier: TenantTier::Enterprise,
requests_per_month: u64::MAX,
tokens_per_month: u64::MAX,
max_agents: u32::MAX,
requests_per_day: u64::MAX,
}
}
pub fn from_tier(tier: &TenantTier) -> Self {
match tier {
TenantTier::Free => Self::free(),
TenantTier::Dev => Self::dev(),
TenantTier::Pro => Self::pro(),
TenantTier::Enterprise => Self::enterprise(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Tenant {
pub id: String,
pub name: String,
pub tier: TenantTier,
pub created_at: i64,
pub updated_at: i64,
}
impl Tenant {
pub fn new(id: String, name: String, tier: TenantTier) -> Self {
let now = chrono::Utc::now().timestamp();
Self {
id,
name,
tier,
created_at: now,
updated_at: now,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApiKey {
pub id: String,
pub tenant_id: String,
pub key_hash: String,
pub key_prefix: String,
pub name: String,
pub is_active: bool,
pub created_at: i64,
pub expires_at: Option<i64>,
}
impl ApiKey {
pub fn new(
id: String,
tenant_id: String,
key_hash: String,
key_prefix: String,
name: String,
) -> Self {
Self {
id,
tenant_id,
key_hash,
key_prefix,
name,
is_active: true,
created_at: chrono::Utc::now().timestamp(),
expires_at: None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum QuotaExceeded {
Monthly,
Daily,
}
impl QuotaExceeded {
pub fn message(self) -> &'static str {
match self {
Self::Monthly => "Monthly request quota exceeded",
Self::Daily => "Daily rate limit exceeded",
}
}
}
impl From<QuotaExceeded> for crate::types::AppError {
fn from(exceeded: QuotaExceeded) -> Self {
crate::types::AppError::RateLimited(exceeded.message().to_string())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TenantContext {
pub tenant_id: String,
pub tier: TenantTier,
pub quota: TenantQuota,
}
impl TenantContext {
pub fn new(tenant_id: String, tier: TenantTier) -> Self {
Self {
tenant_id,
tier,
quota: TenantQuota::from_tier(&tier),
}
}
pub fn admit(&self, monthly_requests: u64, daily_requests: u64) -> Result<(), QuotaExceeded> {
if monthly_requests >= self.quota.requests_per_month {
return Err(QuotaExceeded::Monthly);
}
if daily_requests >= self.quota.requests_per_day {
return Err(QuotaExceeded::Daily);
}
Ok(())
}
pub fn can_make_request(&self, monthly_requests: u64, daily_requests: u64) -> bool {
self.admit(monthly_requests, daily_requests).is_ok()
}
pub fn can_use_tokens(&self, monthly_tokens: u64, additional_tokens: u64) -> bool {
let Some(new_total) = monthly_tokens.checked_add(additional_tokens) else {
return false;
};
new_total <= self.quota.tokens_per_month
}
}
impl cordis::Service for TenantContext {
fn name(&self) -> &'static str { "tenant_context" }
fn init(&self, _ctx: &std::sync::Arc<cordis::Context>) -> cordis::ServiceInitFuture<'_> {
Box::pin(async { Ok(None) })
}
fn check(&self) -> bool { true }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_tier_from_str() {
assert_eq!(TenantTier::from_str("free"), Some(TenantTier::Free));
assert_eq!(TenantTier::from_str("dev"), Some(TenantTier::Dev));
assert_eq!(TenantTier::from_str("pro"), Some(TenantTier::Pro));
assert_eq!(
TenantTier::from_str("enterprise"),
Some(TenantTier::Enterprise)
);
assert_eq!(TenantTier::from_str("unknown"), None);
}
#[test]
fn test_tier_as_str() {
assert_eq!(TenantTier::Free.as_str(), "free");
assert_eq!(TenantTier::Dev.as_str(), "dev");
assert_eq!(TenantTier::Pro.as_str(), "pro");
assert_eq!(TenantTier::Enterprise.as_str(), "enterprise");
}
#[test]
fn test_free_quota() {
let quota = TenantQuota::free();
assert_eq!(quota.tier, TenantTier::Free);
assert_eq!(quota.requests_per_month, 1_000);
assert_eq!(quota.tokens_per_month, 100_000);
assert_eq!(quota.max_agents, 1);
assert_eq!(quota.requests_per_day, 50);
}
#[test]
fn test_dev_quota() {
let quota = TenantQuota::dev();
assert_eq!(quota.tier, TenantTier::Dev);
assert_eq!(quota.requests_per_month, 50_000);
assert_eq!(quota.tokens_per_month, 5_000_000);
assert_eq!(quota.max_agents, 10);
assert_eq!(quota.requests_per_day, 2_000);
}
#[test]
fn test_pro_quota() {
let quota = TenantQuota::pro();
assert_eq!(quota.tier, TenantTier::Pro);
assert_eq!(quota.requests_per_month, 500_000);
assert_eq!(quota.tokens_per_month, 50_000_000);
assert_eq!(quota.max_agents, u32::MAX);
assert_eq!(quota.requests_per_day, 20_000);
}
#[test]
fn test_enterprise_quota() {
let quota = TenantQuota::enterprise();
assert_eq!(quota.tier, TenantTier::Enterprise);
assert_eq!(quota.requests_per_month, u64::MAX);
assert_eq!(quota.tokens_per_month, u64::MAX);
}
#[test]
fn test_quota_from_tier() {
assert_eq!(
TenantQuota::from_tier(&TenantTier::Free).requests_per_month,
1_000
);
assert_eq!(
TenantQuota::from_tier(&TenantTier::Dev).requests_per_month,
50_000
);
assert_eq!(
TenantQuota::from_tier(&TenantTier::Pro).requests_per_month,
500_000
);
}
#[test]
fn test_tenant_context_can_make_request() {
let ctx = TenantContext::new("test".to_string(), TenantTier::Free);
assert!(ctx.can_make_request(0, 0));
assert!(ctx.can_make_request(999, 0));
assert!(ctx.can_make_request(0, 49));
assert!(!ctx.can_make_request(1000, 0));
assert!(!ctx.can_make_request(0, 50));
}
#[test]
fn test_tenant_context_admit_ok() {
let ctx = TenantContext::new("test".to_string(), TenantTier::Free);
assert!(ctx.admit(0, 0).is_ok());
assert!(ctx.admit(999, 49).is_ok());
}
#[test]
fn test_tenant_context_admit_monthly() {
let ctx = TenantContext::new("test".to_string(), TenantTier::Free);
assert_eq!(ctx.admit(1000, 0), Err(QuotaExceeded::Monthly));
assert_eq!(ctx.admit(1000, 50), Err(QuotaExceeded::Monthly));
}
#[test]
fn test_tenant_context_admit_daily() {
let ctx = TenantContext::new("test".to_string(), TenantTier::Free);
assert_eq!(ctx.admit(0, 50), Err(QuotaExceeded::Daily));
}
#[test]
fn test_tenant_context_admit_enterprise() {
let ctx = TenantContext::new("ent".to_string(), TenantTier::Enterprise);
assert!(ctx.admit(1_000_000, 1_000_000).is_ok());
let err: crate::types::AppError = QuotaExceeded::Monthly.into();
match err {
crate::types::AppError::RateLimited(msg) => {
assert_eq!(msg, "Monthly request quota exceeded");
}
other => panic!("expected RateLimited, got {other:?}"),
}
let err: crate::types::AppError = QuotaExceeded::Daily.into();
match err {
crate::types::AppError::RateLimited(msg) => {
assert_eq!(msg, "Daily rate limit exceeded");
}
other => panic!("expected RateLimited, got {other:?}"),
}
}
#[test]
fn test_tenant_context_can_use_tokens() {
let ctx = TenantContext::new("test".to_string(), TenantTier::Free);
assert!(ctx.can_use_tokens(0, 100_000));
assert!(ctx.can_use_tokens(50_000, 50_000));
assert!(!ctx.can_use_tokens(50_000, 50_001));
assert!(!ctx.can_use_tokens(100_000, 1));
}
#[test]
fn test_tenant_creation() {
let tenant = Tenant::new("t1".to_string(), "Test Tenant".to_string(), TenantTier::Dev);
assert_eq!(tenant.id, "t1");
assert_eq!(tenant.name, "Test Tenant");
assert_eq!(tenant.tier, TenantTier::Dev);
assert!(tenant.created_at > 0);
}
#[test]
fn test_api_key_creation() {
let key = ApiKey::new(
"k1".to_string(),
"t1".to_string(),
"hash123".to_string(),
"ares_abc".to_string(),
"Test Key".to_string(),
);
assert_eq!(key.id, "k1");
assert_eq!(key.tenant_id, "t1");
assert!(key.is_active);
assert!(key.created_at > 0);
}
#[test]
fn test_tenant_tier_serde_roundtrip() {
for tier in [
TenantTier::Free,
TenantTier::Dev,
TenantTier::Pro,
TenantTier::Enterprise,
] {
let json = serde_json::to_string(&tier).unwrap();
let parsed: TenantTier = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, tier);
assert_eq!(parsed.as_str(), tier.as_str());
}
}
#[test]
fn test_tenant_tier_partial_eq_and_copy() {
let a = TenantTier::Pro;
let b = a;
assert_eq!(a, b);
assert_ne!(a, TenantTier::Free);
assert!(format!("{:?}", a).contains("Pro"));
}
#[test]
fn test_tenant_tier_from_str_case_insensitive() {
assert_eq!(TenantTier::from_str("FREE"), Some(TenantTier::Free));
assert_eq!(TenantTier::from_str("Enterprise"), Some(TenantTier::Enterprise));
assert_eq!(TenantTier::from_str(""), None);
}
#[test]
fn test_tenant_quota_default_is_free() {
let default = TenantQuota::default();
let free = TenantQuota::free();
assert_eq!(default.tier, free.tier);
assert_eq!(default.requests_per_month, free.requests_per_month);
assert_eq!(default.tokens_per_month, free.tokens_per_month);
}
#[test]
fn test_tenant_quota_serde_roundtrip() {
let quota = TenantQuota::enterprise();
let parsed: TenantQuota =
serde_json::from_str(&serde_json::to_string("a).unwrap()).unwrap();
assert_eq!(parsed.tier, TenantTier::Enterprise);
assert_eq!(parsed.requests_per_month, u64::MAX);
assert_eq!(parsed.max_agents, u32::MAX);
}
#[test]
fn test_tenant_serde_roundtrip_unicode_name() {
let tenant = Tenant::new(
"t-unicode".into(),
"租户 🏢".into(),
TenantTier::Pro,
);
let parsed: Tenant =
serde_json::from_str(&serde_json::to_string(&tenant).unwrap()).unwrap();
assert_eq!(parsed.name, "租户 🏢");
assert_eq!(parsed.tier, TenantTier::Pro);
}
#[test]
fn test_api_key_serde_roundtrip_with_expiry() {
let key = ApiKey {
id: "k2".into(),
tenant_id: "t1".into(),
key_hash: "hash".into(),
key_prefix: "ares_".into(),
name: String::new(),
is_active: false,
created_at: 0,
expires_at: Some(i64::MAX),
};
let parsed: ApiKey =
serde_json::from_str(&serde_json::to_string(&key).unwrap()).unwrap();
assert!(!parsed.is_active);
assert_eq!(parsed.expires_at, Some(i64::MAX));
assert!(parsed.name.is_empty());
}
#[test]
fn test_tenant_context_serde_roundtrip() {
let ctx = TenantContext::new("tenant-1".into(), TenantTier::Dev);
let parsed: TenantContext =
serde_json::from_str(&serde_json::to_string(&ctx).unwrap()).unwrap();
assert_eq!(parsed.tenant_id, "tenant-1");
assert_eq!(parsed.tier, TenantTier::Dev);
assert_eq!(parsed.quota.requests_per_day, 2_000);
}
#[test]
fn test_tenant_context_token_overflow_boundary() {
let ctx = TenantContext::new("t".into(), TenantTier::Free);
assert!(!ctx.can_use_tokens(u64::MAX, 1));
assert!(ctx.can_use_tokens(0, 0));
assert!(!ctx.can_make_request(u64::MAX, 0));
}
#[test]
fn test_enterprise_context_unlimited_requests() {
let ctx = TenantContext::new("ent".into(), TenantTier::Enterprise);
assert!(ctx.can_make_request(u64::MAX - 1, u64::MAX - 1));
assert!(ctx.can_use_tokens(u64::MAX - 1, 1));
}
#[test]
fn test_enterprise_quota_full_limits() {
let quota = TenantQuota::enterprise();
assert_eq!(quota.max_agents, u32::MAX);
assert_eq!(quota.requests_per_day, u64::MAX);
}
#[test]
fn test_quota_from_tier_enterprise() {
let quota = TenantQuota::from_tier(&TenantTier::Enterprise);
assert_eq!(quota.tier, TenantTier::Enterprise);
assert_eq!(quota.requests_per_month, u64::MAX);
assert_eq!(quota.tokens_per_month, u64::MAX);
assert_eq!(quota.max_agents, u32::MAX);
assert_eq!(quota.requests_per_day, u64::MAX);
}
#[test]
fn test_tenant_tier_serde_json_is_lowercase() {
let json = serde_json::to_string(&TenantTier::Pro).unwrap();
assert_eq!(json, "\"pro\"");
assert_eq!(
serde_json::from_str::<TenantTier>("\"pro\"").unwrap(),
TenantTier::Pro
);
}
#[test]
fn test_tenant_tier_serde_rejects_unknown_variant() {
let err = serde_json::from_str::<TenantTier>("\"platinum\"").unwrap_err();
assert!(err.is_data());
}
#[test]
fn test_tier_from_str_rejects_whitespace_and_garbage() {
assert_eq!(TenantTier::from_str(" free"), None);
assert_eq!(TenantTier::from_str("free "), None);
assert_eq!(TenantTier::from_str("free\n"), None);
assert_eq!(TenantTier::from_str("pro "), None);
}
#[test]
fn test_api_key_serde_roundtrip_from_new() {
let key = ApiKey::new(
"k-new".into(),
"tenant".into(),
"hash".into(),
"ares_xyz".into(),
"Primary".into(),
);
let parsed: ApiKey =
serde_json::from_str(&serde_json::to_string(&key).unwrap()).unwrap();
assert_eq!(parsed.id, key.id);
assert_eq!(parsed.tenant_id, key.tenant_id);
assert_eq!(parsed.key_hash, key.key_hash);
assert_eq!(parsed.key_prefix, key.key_prefix);
assert_eq!(parsed.name, key.name);
assert!(parsed.is_active);
assert_eq!(parsed.created_at, key.created_at);
assert_eq!(parsed.expires_at, None);
}
#[test]
fn test_tenant_serde_preserves_timestamps_and_id() {
let tenant = Tenant {
id: "fixed-id".into(),
name: "Acme".into(),
tier: TenantTier::Dev,
created_at: 1_700_000_000,
updated_at: 1_700_000_001,
};
let parsed: Tenant =
serde_json::from_str(&serde_json::to_string(&tenant).unwrap()).unwrap();
assert_eq!(parsed.id, "fixed-id");
assert_eq!(parsed.created_at, 1_700_000_000);
assert_eq!(parsed.updated_at, 1_700_000_001);
}
#[test]
fn test_tenant_quota_serde_rejects_missing_field() {
let err = serde_json::from_str::<TenantQuota>(r#"{"tier":"free"}"#).unwrap_err();
assert!(err.is_data());
}
#[test]
fn test_tenant_quota_serde_all_tiers_roundtrip() {
for factory in [
TenantQuota::free,
TenantQuota::dev,
TenantQuota::pro,
TenantQuota::enterprise,
] {
let quota = factory();
let parsed: TenantQuota =
serde_json::from_str(&serde_json::to_string("a).unwrap()).unwrap();
assert_eq!(parsed.tier, quota.tier);
assert_eq!(parsed.requests_per_month, quota.requests_per_month);
assert_eq!(parsed.tokens_per_month, quota.tokens_per_month);
assert_eq!(parsed.max_agents, quota.max_agents);
assert_eq!(parsed.requests_per_day, quota.requests_per_day);
}
}
#[test]
fn test_can_make_request_exact_monthly_and_daily_boundaries() {
let ctx = TenantContext::new("t".into(), TenantTier::Free);
assert!(ctx.can_make_request(999, 49));
assert!(!ctx.can_make_request(1_000, 0));
assert!(!ctx.can_make_request(0, 50));
assert!(!ctx.can_make_request(1_000, 50));
}
#[test]
fn test_can_use_tokens_exact_monthly_ceiling() {
let ctx = TenantContext::new("t".into(), TenantTier::Free);
assert!(ctx.can_use_tokens(99_999, 1));
assert!(ctx.can_use_tokens(100_000, 0));
assert!(!ctx.can_use_tokens(100_000, 1));
assert!(!ctx.can_use_tokens(99_999, 2));
}
#[test]
fn test_dev_context_daily_request_boundary() {
let ctx = TenantContext::new("dev-tenant".into(), TenantTier::Dev);
assert!(ctx.can_make_request(0, 1_999));
assert!(!ctx.can_make_request(0, 2_000));
}
#[test]
fn test_tenant_context_clone_matches_original() {
let ctx = TenantContext::new("clone-me".into(), TenantTier::Pro);
let cloned = ctx.clone();
assert_eq!(cloned.tenant_id, ctx.tenant_id);
assert_eq!(cloned.tier, ctx.tier);
assert_eq!(cloned.quota.requests_per_month, ctx.quota.requests_per_month);
}
#[test]
fn test_enterprise_context_rejects_at_hard_ceiling() {
let ctx = TenantContext::new("ent".into(), TenantTier::Enterprise);
assert!(!ctx.can_make_request(u64::MAX, 0));
assert!(!ctx.can_make_request(0, u64::MAX));
assert!(!ctx.can_use_tokens(u64::MAX, 1));
}
#[test]
fn tenant_context_readable_via_cordis_intercept() {
use std::sync::Arc;
let root: Arc<cordis::Context> = cordis::Context::new_root();
assert!(root.get::<TenantContext>().is_none());
let tc = TenantContext::new("acme".into(), TenantTier::Pro);
let child = root.with_intercept(tc);
let retrieved = child.get::<TenantContext>().expect("intercept must make TenantContext readable");
assert_eq!(retrieved.tenant_id, "acme");
assert_eq!(retrieved.tier, TenantTier::Pro);
}
}