use chrono::{DateTime, Duration, FixedOffset};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use super::{
KEYSET_SIGNATURE_DOMAIN, LEASE_SIGNATURE_DOMAIN, parse_time, require_schema, signing_input,
signing_input_without_signature, validate_b64_len, validate_id,
};
use crate::{UtcTimestamp, ValidationError};
pub const LEASE_SCHEMA: &str = "leanctx.hive.lease/v1";
pub const LEASE_ENVELOPE_SCHEMA: &str = "leanctx.hive.lease-envelope/v1";
pub const KEYSET_SCHEMA: &str = "leanctx.hive.keyset/v1";
pub const MAX_LEASE_VALIDITY_HOURS: i64 = 24;
pub const MAX_LEASE_GRACE_DAYS: i64 = 7;
pub const MAX_LEASE_FUTURE_SKEW_MINUTES: i64 = 15;
pub const LEASE_RENEW_BEFORE_HOURS: i64 = 6;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum HivePlan {
#[serde(rename = "none")]
NoPlan,
Personal,
Team,
Enterprise,
#[serde(other)]
Unknown,
}
impl HivePlan {
pub fn display_name(self) -> &'static str {
match self {
Self::NoPlan => "No plan",
Self::Personal => "Personal",
Self::Team => "Team",
Self::Enterprise => "Enterprise",
Self::Unknown => "unknown",
}
}
pub fn product_name(self) -> &'static str {
match self {
Self::NoPlan => "No plan",
Self::Personal => "Personal",
Self::Team => "Team",
Self::Enterprise => "Enterprise",
Self::Unknown => "Unknown",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
pub enum HiveRight {
#[serde(rename = "knowledge.read")]
KnowledgeRead,
#[serde(rename = "knowledge.draft")]
KnowledgeDraft,
#[serde(rename = "knowledge.merge")]
KnowledgeMerge,
#[serde(rename = "metrics.upload")]
MetricsUpload,
#[serde(rename = "receipts.upload")]
ReceiptsUpload,
#[serde(rename = "policy.pull")]
PolicyPull,
#[serde(rename = "bus.relay")]
BusRelay,
#[serde(rename = "bus.control")]
BusControl,
#[serde(rename = "sealed")]
Sealed,
#[serde(rename = "runtime")]
Runtime,
#[serde(rename = "tasks.read")]
TasksRead,
#[serde(rename = "tasks.work")]
TasksWork,
#[serde(rename = "tasks.propose")]
TasksPropose,
#[serde(other)]
Unknown,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PolicySignerKeyV1 {
pub org: String,
pub ed25519_pub: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct HiveLeaseV1 {
pub schema: String,
pub lease_id: String,
pub org_id: String,
pub instance_id: String,
pub plan: HivePlan,
pub seats_limit: u32,
pub rights: Vec<HiveRight>,
pub policy_signer_keys: Vec<PolicySignerKeyV1>,
pub issued_at: UtcTimestamp,
pub expires_at: UtcTimestamp,
pub grace_until: UtcTimestamp,
pub server_time: UtcTimestamp,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub org_settings: Option<HiveOrgSettingsV1>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct HiveOrgSettingsV1 {
#[serde(default = "allowed")]
pub metrics_upload: bool,
#[serde(default = "allowed")]
pub receipts_upload: bool,
}
const fn allowed() -> bool {
true
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LeaseState {
Valid,
Grace,
Lapsed,
}
impl HiveLeaseV1 {
pub fn validate(&self) -> Result<(), ValidationError> {
require_schema(&self.schema, LEASE_SCHEMA)?;
validate_id(&self.lease_id, "lease_id")?;
validate_id(&self.org_id, "org_id")?;
validate_id(&self.instance_id, "instance_id")?;
if self.seats_limit == 0 {
return Err(ValidationError::new("seats_limit must be positive"));
}
if self.rights.len() > 64 || self.policy_signer_keys.len() > 16 {
return Err(ValidationError::new("lease lists are too long"));
}
for key in &self.policy_signer_keys {
validate_id(&key.org, "policy_signer_keys.org")?;
validate_b64_len(&key.ed25519_pub, 32, "policy_signer_keys.ed25519_pub")?;
}
let issued = parse_time(&self.issued_at)?;
let expires = parse_time(&self.expires_at)?;
let grace = parse_time(&self.grace_until)?;
parse_time(&self.server_time)?;
if expires <= issued || expires - issued > Duration::hours(MAX_LEASE_VALIDITY_HOURS) {
return Err(ValidationError::new(
"expires_at must be after issued_at and at most 24 h later",
));
}
if grace < expires || grace - expires > Duration::days(MAX_LEASE_GRACE_DAYS) {
return Err(ValidationError::new(
"grace_until must be within 7 days after expires_at",
));
}
Ok(())
}
pub fn grants(&self, right: HiveRight) -> bool {
right != HiveRight::Unknown && self.rights.contains(&right)
}
pub fn org_allows_metrics_upload(&self) -> bool {
self.org_settings
.is_none_or(|settings| settings.metrics_upload)
}
pub fn org_allows_receipts_upload(&self) -> bool {
self.org_settings
.is_none_or(|settings| settings.receipts_upload)
}
pub fn state_at(&self, now: DateTime<FixedOffset>) -> Result<LeaseState, ValidationError> {
let issued = parse_time(&self.issued_at)?;
if issued - now > Duration::minutes(MAX_LEASE_FUTURE_SKEW_MINUTES) {
return Ok(LeaseState::Lapsed);
}
Ok(if now < parse_time(&self.expires_at)? {
LeaseState::Valid
} else if now < parse_time(&self.grace_until)? {
LeaseState::Grace
} else {
LeaseState::Lapsed
})
}
pub fn renew_due(&self, now: DateTime<FixedOffset>) -> Result<bool, ValidationError> {
Ok(parse_time(&self.expires_at)? - now < Duration::hours(LEASE_RENEW_BEFORE_HOURS))
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct HiveLeaseEnvelopeV1 {
pub schema: String,
pub lease: Value,
pub kid: String,
pub signature: String,
}
impl HiveLeaseEnvelopeV1 {
pub fn validate_shape(&self) -> Result<(), ValidationError> {
require_schema(&self.schema, LEASE_ENVELOPE_SCHEMA)?;
validate_id(&self.kid, "kid")?;
validate_b64_len(&self.signature, 64, "signature")?;
if !self.lease.is_object() {
return Err(ValidationError::new("lease must be a JSON object"));
}
Ok(())
}
pub fn signing_input(&self) -> Result<Vec<u8>, ValidationError> {
signing_input(LEASE_SIGNATURE_DOMAIN, &self.lease)
}
pub fn signing_input_for(lease: &HiveLeaseV1) -> Result<Vec<u8>, ValidationError> {
lease.validate()?;
signing_input(LEASE_SIGNATURE_DOMAIN, &lease_value(lease)?)
}
pub fn seal(
lease: &HiveLeaseV1,
kid: impl Into<String>,
signature: impl Into<String>,
) -> Result<Self, ValidationError> {
lease.validate()?;
let envelope = Self {
schema: LEASE_ENVELOPE_SCHEMA.into(),
lease: lease_value(lease)?,
kid: kid.into(),
signature: signature.into(),
};
envelope.validate_shape()?;
Ok(envelope)
}
pub fn lease(&self) -> Result<HiveLeaseV1, ValidationError> {
let lease: HiveLeaseV1 = serde_json::from_value(self.lease.clone())
.map_err(|error| ValidationError::new(format!("lease: {error}")))?;
lease.validate()?;
Ok(lease)
}
}
fn lease_value(lease: &HiveLeaseV1) -> Result<Value, ValidationError> {
serde_json::to_value(lease).map_err(|error| ValidationError::new(format!("lease: {error}")))
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct HiveKeyV1 {
pub kid: String,
pub ed25519_pub: String,
pub not_after: UtcTimestamp,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct HiveKeySetV1 {
pub schema: String,
pub keys: Vec<HiveKeyV1>,
pub signature: String,
}
impl HiveKeySetV1 {
pub fn validate(&self) -> Result<(), ValidationError> {
require_schema(&self.schema, KEYSET_SCHEMA)?;
if self.keys.is_empty() || self.keys.len() > 16 {
return Err(ValidationError::new("keys must list 1-16 keys"));
}
let mut seen = std::collections::BTreeSet::new();
for key in &self.keys {
validate_id(&key.kid, "kid")?;
validate_b64_len(&key.ed25519_pub, 32, "ed25519_pub")?;
parse_time(&key.not_after)?;
if !seen.insert(key.kid.as_str()) {
return Err(ValidationError::new("kid repeats in key set"));
}
}
validate_b64_len(&self.signature, 64, "signature")
}
pub fn signing_input_raw(raw: &Value) -> Result<Vec<u8>, ValidationError> {
signing_input_without_signature(KEYSET_SIGNATURE_DOMAIN, raw)
}
pub fn signing_input(&self) -> Result<Vec<u8>, ValidationError> {
let value = serde_json::to_value(self)
.map_err(|error| ValidationError::new(format!("keyset: {error}")))?;
Self::signing_input_raw(&value)
}
pub fn key(&self, kid: &str) -> Option<&HiveKeyV1> {
self.keys.iter().find(|key| key.kid == kid)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn product_names_are_personal_team_enterprise() {
let names: Vec<&str> = [
HivePlan::NoPlan,
HivePlan::Personal,
HivePlan::Team,
HivePlan::Enterprise,
]
.into_iter()
.map(HivePlan::product_name)
.collect();
assert_eq!(names, ["No plan", "Personal", "Team", "Enterprise"]);
assert_eq!(
serde_json::to_value(HivePlan::Enterprise).unwrap(),
serde_json::json!("enterprise")
);
}
fn lease() -> HiveLeaseV1 {
HiveLeaseV1 {
schema: LEASE_SCHEMA.into(),
lease_id: "lse_1".into(),
org_id: "org_1".into(),
instance_id: "inst_1".into(),
plan: HivePlan::Team,
seats_limit: 25,
rights: vec![HiveRight::KnowledgeRead],
policy_signer_keys: vec![],
issued_at: UtcTimestamp::new("2026-10-20T12:00:00Z").unwrap(),
expires_at: UtcTimestamp::new("2026-10-21T12:00:00Z").unwrap(),
grace_until: UtcTimestamp::new("2026-10-28T12:00:00Z").unwrap(),
server_time: UtcTimestamp::new("2026-10-20T12:00:00Z").unwrap(),
org_settings: None,
}
}
fn at(value: &str) -> DateTime<FixedOffset> {
DateTime::parse_from_rfc3339(value).unwrap()
}
#[test]
fn plan_wire_ids_are_none_personal_team_enterprise() {
let plan = |s: &str| serde_json::from_value::<HivePlan>(serde_json::json!(s)).unwrap();
assert_eq!(plan("none"), HivePlan::NoPlan);
assert_eq!(plan("personal"), HivePlan::Personal);
assert_eq!(plan("none").display_name(), "No plan");
assert_eq!(plan("team").display_name(), "Team");
assert_eq!(plan("enterprise").display_name(), "Enterprise");
assert_eq!(plan("pro"), HivePlan::Unknown);
assert_eq!(plan("business"), HivePlan::Unknown);
assert_eq!(plan("community"), HivePlan::Unknown);
assert_eq!(
plan("galaxy"),
HivePlan::Unknown,
"new plans stay tolerated"
);
assert_eq!(serde_json::to_value(HivePlan::NoPlan).unwrap(), "none");
assert_eq!(
serde_json::to_value(HivePlan::Personal).unwrap(),
"personal"
);
}
#[test]
fn lease_moves_from_valid_to_grace_to_lapsed() {
let lease = lease();
assert_eq!(
lease.state_at(at("2026-10-21T11:59:59Z")).unwrap(),
LeaseState::Valid
);
assert_eq!(
lease.state_at(at("2026-10-21T12:00:00Z")).unwrap(),
LeaseState::Grace
);
assert_eq!(
lease.state_at(at("2026-10-28T12:00:00Z")).unwrap(),
LeaseState::Lapsed
);
assert_eq!(
lease.state_at(at("2026-10-20T11:44:59Z")).unwrap(),
LeaseState::Lapsed
);
assert!(lease.renew_due(at("2026-10-21T06:00:01Z")).unwrap());
assert!(!lease.renew_due(at("2026-10-21T05:59:59Z")).unwrap());
}
#[test]
fn lease_windows_are_bounded() {
let mut long = lease();
long.expires_at = UtcTimestamp::new("2026-10-21T12:00:01Z").unwrap();
assert!(long.validate().is_err());
let mut grace = lease();
grace.grace_until = UtcTimestamp::new("2026-10-28T12:00:01Z").unwrap();
assert!(grace.validate().is_err());
assert!(lease().validate().is_ok());
}
#[test]
fn signature_covers_unknown_lease_fields() {
let mut envelope =
HiveLeaseEnvelopeV1::seal(&lease(), "k1", "A".repeat(86) + "==").unwrap();
let before = envelope.signing_input().unwrap();
envelope.lease["future_field"] = Value::from(1);
assert_ne!(before, envelope.signing_input().unwrap());
assert!(envelope.lease().is_ok());
}
#[test]
fn unknown_rights_grant_nothing() {
let mut lease = lease();
lease.rights.push(HiveRight::Unknown);
assert!(!lease.grants(HiveRight::Unknown));
assert!(lease.grants(HiveRight::KnowledgeRead));
assert!(!lease.grants(HiveRight::KnowledgeMerge));
}
}