1use chrono::{DateTime, Duration, FixedOffset};
14use serde::{Deserialize, Serialize};
15use serde_json::Value;
16
17use super::{
18 KEYSET_SIGNATURE_DOMAIN, LEASE_SIGNATURE_DOMAIN, parse_time, require_schema, signing_input,
19 signing_input_without_signature, validate_b64_len, validate_id,
20};
21use crate::{UtcTimestamp, ValidationError};
22
23pub const LEASE_SCHEMA: &str = "leanctx.hive.lease/v1";
24pub const LEASE_ENVELOPE_SCHEMA: &str = "leanctx.hive.lease-envelope/v1";
25pub const KEYSET_SCHEMA: &str = "leanctx.hive.keyset/v1";
26
27pub const MAX_LEASE_VALIDITY_HOURS: i64 = 24;
29pub const MAX_LEASE_GRACE_DAYS: i64 = 7;
31pub const MAX_LEASE_FUTURE_SKEW_MINUTES: i64 = 15;
33pub const LEASE_RENEW_BEFORE_HOURS: i64 = 6;
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
38#[serde(rename_all = "snake_case")]
39pub enum HivePlan {
40 #[serde(rename = "none")]
42 NoPlan,
43 Personal,
44 Team,
45 Enterprise,
46 #[serde(other)]
47 Unknown,
48}
49
50impl HivePlan {
51 pub fn display_name(self) -> &'static str {
53 match self {
54 Self::NoPlan => "No plan",
55 Self::Personal => "Personal",
56 Self::Team => "Team",
57 Self::Enterprise => "Enterprise",
58 Self::Unknown => "unknown",
59 }
60 }
61
62 pub fn product_name(self) -> &'static str {
64 match self {
65 Self::NoPlan => "No plan",
66 Self::Personal => "Personal",
67 Self::Team => "Team",
68 Self::Enterprise => "Enterprise",
69 Self::Unknown => "Unknown",
70 }
71 }
72}
73
74#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
76pub enum HiveRight {
77 #[serde(rename = "knowledge.read")]
78 KnowledgeRead,
79 #[serde(rename = "knowledge.draft")]
80 KnowledgeDraft,
81 #[serde(rename = "knowledge.merge")]
82 KnowledgeMerge,
83 #[serde(rename = "metrics.upload")]
84 MetricsUpload,
85 #[serde(rename = "receipts.upload")]
86 ReceiptsUpload,
87 #[serde(rename = "policy.pull")]
88 PolicyPull,
89 #[serde(rename = "bus.relay")]
90 BusRelay,
91 #[serde(rename = "bus.control")]
92 BusControl,
93 #[serde(rename = "sealed")]
94 Sealed,
95 #[serde(rename = "runtime")]
98 Runtime,
99 #[serde(rename = "tasks.read")]
101 TasksRead,
102 #[serde(rename = "tasks.work")]
104 TasksWork,
105 #[serde(rename = "tasks.propose")]
107 TasksPropose,
108 #[serde(other)]
109 Unknown,
110}
111
112#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
114pub struct PolicySignerKeyV1 {
115 pub org: String,
116 pub ed25519_pub: String,
118}
119
120#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
122pub struct HiveLeaseV1 {
123 pub schema: String,
124 pub lease_id: String,
125 pub org_id: String,
126 pub instance_id: String,
127 pub plan: HivePlan,
128 pub seats_limit: u32,
132 pub rights: Vec<HiveRight>,
133 pub policy_signer_keys: Vec<PolicySignerKeyV1>,
134 pub issued_at: UtcTimestamp,
135 pub expires_at: UtcTimestamp,
136 pub grace_until: UtcTimestamp,
137 pub server_time: UtcTimestamp,
139 #[serde(default, skip_serializing_if = "Option::is_none")]
142 pub org_settings: Option<HiveOrgSettingsV1>,
143}
144
145#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
149pub struct HiveOrgSettingsV1 {
150 #[serde(default = "allowed")]
151 pub metrics_upload: bool,
152 #[serde(default = "allowed")]
153 pub receipts_upload: bool,
154}
155
156const fn allowed() -> bool {
157 true
158}
159
160#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
162#[serde(rename_all = "snake_case")]
163pub enum LeaseState {
164 Valid,
165 Grace,
166 Lapsed,
167}
168
169impl HiveLeaseV1 {
170 pub fn validate(&self) -> Result<(), ValidationError> {
171 require_schema(&self.schema, LEASE_SCHEMA)?;
172 validate_id(&self.lease_id, "lease_id")?;
173 validate_id(&self.org_id, "org_id")?;
174 validate_id(&self.instance_id, "instance_id")?;
175 if self.seats_limit == 0 {
176 return Err(ValidationError::new("seats_limit must be positive"));
177 }
178 if self.rights.len() > 64 || self.policy_signer_keys.len() > 16 {
179 return Err(ValidationError::new("lease lists are too long"));
180 }
181 for key in &self.policy_signer_keys {
182 validate_id(&key.org, "policy_signer_keys.org")?;
183 validate_b64_len(&key.ed25519_pub, 32, "policy_signer_keys.ed25519_pub")?;
184 }
185 let issued = parse_time(&self.issued_at)?;
186 let expires = parse_time(&self.expires_at)?;
187 let grace = parse_time(&self.grace_until)?;
188 parse_time(&self.server_time)?;
189 if expires <= issued || expires - issued > Duration::hours(MAX_LEASE_VALIDITY_HOURS) {
190 return Err(ValidationError::new(
191 "expires_at must be after issued_at and at most 24 h later",
192 ));
193 }
194 if grace < expires || grace - expires > Duration::days(MAX_LEASE_GRACE_DAYS) {
195 return Err(ValidationError::new(
196 "grace_until must be within 7 days after expires_at",
197 ));
198 }
199 Ok(())
200 }
201
202 pub fn grants(&self, right: HiveRight) -> bool {
204 right != HiveRight::Unknown && self.rights.contains(&right)
205 }
206
207 pub fn org_allows_metrics_upload(&self) -> bool {
209 self.org_settings
210 .is_none_or(|settings| settings.metrics_upload)
211 }
212
213 pub fn org_allows_receipts_upload(&self) -> bool {
215 self.org_settings
216 .is_none_or(|settings| settings.receipts_upload)
217 }
218
219 pub fn state_at(&self, now: DateTime<FixedOffset>) -> Result<LeaseState, ValidationError> {
222 let issued = parse_time(&self.issued_at)?;
223 if issued - now > Duration::minutes(MAX_LEASE_FUTURE_SKEW_MINUTES) {
224 return Ok(LeaseState::Lapsed);
225 }
226 Ok(if now < parse_time(&self.expires_at)? {
227 LeaseState::Valid
228 } else if now < parse_time(&self.grace_until)? {
229 LeaseState::Grace
230 } else {
231 LeaseState::Lapsed
232 })
233 }
234
235 pub fn renew_due(&self, now: DateTime<FixedOffset>) -> Result<bool, ValidationError> {
237 Ok(parse_time(&self.expires_at)? - now < Duration::hours(LEASE_RENEW_BEFORE_HOURS))
238 }
239}
240
241#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
246pub struct HiveLeaseEnvelopeV1 {
247 pub schema: String,
248 pub lease: Value,
249 pub kid: String,
251 pub signature: String,
253}
254
255impl HiveLeaseEnvelopeV1 {
256 pub fn validate_shape(&self) -> Result<(), ValidationError> {
258 require_schema(&self.schema, LEASE_ENVELOPE_SCHEMA)?;
259 validate_id(&self.kid, "kid")?;
260 validate_b64_len(&self.signature, 64, "signature")?;
261 if !self.lease.is_object() {
262 return Err(ValidationError::new("lease must be a JSON object"));
263 }
264 Ok(())
265 }
266
267 pub fn signing_input(&self) -> Result<Vec<u8>, ValidationError> {
269 signing_input(LEASE_SIGNATURE_DOMAIN, &self.lease)
270 }
271
272 pub fn signing_input_for(lease: &HiveLeaseV1) -> Result<Vec<u8>, ValidationError> {
274 lease.validate()?;
275 signing_input(LEASE_SIGNATURE_DOMAIN, &lease_value(lease)?)
276 }
277
278 pub fn seal(
280 lease: &HiveLeaseV1,
281 kid: impl Into<String>,
282 signature: impl Into<String>,
283 ) -> Result<Self, ValidationError> {
284 lease.validate()?;
285 let envelope = Self {
286 schema: LEASE_ENVELOPE_SCHEMA.into(),
287 lease: lease_value(lease)?,
288 kid: kid.into(),
289 signature: signature.into(),
290 };
291 envelope.validate_shape()?;
292 Ok(envelope)
293 }
294
295 pub fn lease(&self) -> Result<HiveLeaseV1, ValidationError> {
297 let lease: HiveLeaseV1 = serde_json::from_value(self.lease.clone())
298 .map_err(|error| ValidationError::new(format!("lease: {error}")))?;
299 lease.validate()?;
300 Ok(lease)
301 }
302}
303
304fn lease_value(lease: &HiveLeaseV1) -> Result<Value, ValidationError> {
305 serde_json::to_value(lease).map_err(|error| ValidationError::new(format!("lease: {error}")))
306}
307
308#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
310pub struct HiveKeyV1 {
311 pub kid: String,
312 pub ed25519_pub: String,
314 pub not_after: UtcTimestamp,
317}
318
319#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
323pub struct HiveKeySetV1 {
324 pub schema: String,
325 pub keys: Vec<HiveKeyV1>,
326 pub signature: String,
328}
329
330impl HiveKeySetV1 {
331 pub fn validate(&self) -> Result<(), ValidationError> {
332 require_schema(&self.schema, KEYSET_SCHEMA)?;
333 if self.keys.is_empty() || self.keys.len() > 16 {
334 return Err(ValidationError::new("keys must list 1-16 keys"));
335 }
336 let mut seen = std::collections::BTreeSet::new();
337 for key in &self.keys {
338 validate_id(&key.kid, "kid")?;
339 validate_b64_len(&key.ed25519_pub, 32, "ed25519_pub")?;
340 parse_time(&key.not_after)?;
341 if !seen.insert(key.kid.as_str()) {
342 return Err(ValidationError::new("kid repeats in key set"));
343 }
344 }
345 validate_b64_len(&self.signature, 64, "signature")
346 }
347
348 pub fn signing_input_raw(raw: &Value) -> Result<Vec<u8>, ValidationError> {
350 signing_input_without_signature(KEYSET_SIGNATURE_DOMAIN, raw)
351 }
352
353 pub fn signing_input(&self) -> Result<Vec<u8>, ValidationError> {
355 let value = serde_json::to_value(self)
356 .map_err(|error| ValidationError::new(format!("keyset: {error}")))?;
357 Self::signing_input_raw(&value)
358 }
359
360 pub fn key(&self, kid: &str) -> Option<&HiveKeyV1> {
362 self.keys.iter().find(|key| key.kid == kid)
363 }
364}
365
366#[cfg(test)]
367mod tests {
368 use super::*;
369
370 #[test]
371 fn product_names_are_personal_team_enterprise() {
372 let names: Vec<&str> = [
373 HivePlan::NoPlan,
374 HivePlan::Personal,
375 HivePlan::Team,
376 HivePlan::Enterprise,
377 ]
378 .into_iter()
379 .map(HivePlan::product_name)
380 .collect();
381 assert_eq!(names, ["No plan", "Personal", "Team", "Enterprise"]);
382 assert_eq!(
384 serde_json::to_value(HivePlan::Enterprise).unwrap(),
385 serde_json::json!("enterprise")
386 );
387 }
388
389 fn lease() -> HiveLeaseV1 {
390 HiveLeaseV1 {
391 schema: LEASE_SCHEMA.into(),
392 lease_id: "lse_1".into(),
393 org_id: "org_1".into(),
394 instance_id: "inst_1".into(),
395 plan: HivePlan::Team,
396 seats_limit: 25,
397 rights: vec![HiveRight::KnowledgeRead],
398 policy_signer_keys: vec![],
399 issued_at: UtcTimestamp::new("2026-10-20T12:00:00Z").unwrap(),
400 expires_at: UtcTimestamp::new("2026-10-21T12:00:00Z").unwrap(),
401 grace_until: UtcTimestamp::new("2026-10-28T12:00:00Z").unwrap(),
402 server_time: UtcTimestamp::new("2026-10-20T12:00:00Z").unwrap(),
403 org_settings: None,
404 }
405 }
406
407 fn at(value: &str) -> DateTime<FixedOffset> {
408 DateTime::parse_from_rfc3339(value).unwrap()
409 }
410
411 #[test]
414 fn plan_wire_ids_are_none_personal_team_enterprise() {
415 let plan = |s: &str| serde_json::from_value::<HivePlan>(serde_json::json!(s)).unwrap();
416 assert_eq!(plan("none"), HivePlan::NoPlan);
417 assert_eq!(plan("personal"), HivePlan::Personal);
418 assert_eq!(plan("none").display_name(), "No plan");
419 assert_eq!(plan("team").display_name(), "Team");
420 assert_eq!(plan("enterprise").display_name(), "Enterprise");
421 assert_eq!(plan("pro"), HivePlan::Unknown);
423 assert_eq!(plan("business"), HivePlan::Unknown);
424 assert_eq!(plan("community"), HivePlan::Unknown);
425 assert_eq!(
426 plan("galaxy"),
427 HivePlan::Unknown,
428 "new plans stay tolerated"
429 );
430 assert_eq!(serde_json::to_value(HivePlan::NoPlan).unwrap(), "none");
431 assert_eq!(
432 serde_json::to_value(HivePlan::Personal).unwrap(),
433 "personal"
434 );
435 }
436
437 #[test]
438 fn lease_moves_from_valid_to_grace_to_lapsed() {
439 let lease = lease();
440 assert_eq!(
441 lease.state_at(at("2026-10-21T11:59:59Z")).unwrap(),
442 LeaseState::Valid
443 );
444 assert_eq!(
445 lease.state_at(at("2026-10-21T12:00:00Z")).unwrap(),
446 LeaseState::Grace
447 );
448 assert_eq!(
449 lease.state_at(at("2026-10-28T12:00:00Z")).unwrap(),
450 LeaseState::Lapsed
451 );
452 assert_eq!(
453 lease.state_at(at("2026-10-20T11:44:59Z")).unwrap(),
454 LeaseState::Lapsed
455 );
456 assert!(lease.renew_due(at("2026-10-21T06:00:01Z")).unwrap());
457 assert!(!lease.renew_due(at("2026-10-21T05:59:59Z")).unwrap());
458 }
459
460 #[test]
461 fn lease_windows_are_bounded() {
462 let mut long = lease();
463 long.expires_at = UtcTimestamp::new("2026-10-21T12:00:01Z").unwrap();
464 assert!(long.validate().is_err());
465 let mut grace = lease();
466 grace.grace_until = UtcTimestamp::new("2026-10-28T12:00:01Z").unwrap();
467 assert!(grace.validate().is_err());
468 assert!(lease().validate().is_ok());
469 }
470
471 #[test]
472 fn signature_covers_unknown_lease_fields() {
473 let mut envelope =
474 HiveLeaseEnvelopeV1::seal(&lease(), "k1", "A".repeat(86) + "==").unwrap();
475 let before = envelope.signing_input().unwrap();
476 envelope.lease["future_field"] = Value::from(1);
477 assert_ne!(before, envelope.signing_input().unwrap());
478 assert!(envelope.lease().is_ok());
479 }
480
481 #[test]
482 fn unknown_rights_grant_nothing() {
483 let mut lease = lease();
484 lease.rights.push(HiveRight::Unknown);
485 assert!(!lease.grants(HiveRight::Unknown));
486 assert!(lease.grants(HiveRight::KnowledgeRead));
487 assert!(!lease.grants(HiveRight::KnowledgeMerge));
488 }
489}