1use chrono::{DateTime, Duration, FixedOffset};
18use serde::{Deserialize, Deserializer, Serialize};
19use serde_json::Value;
20
21use super::{
22 KEYSET_SIGNATURE_DOMAIN, LEASE_SIGNATURE_DOMAIN, parse_time, require_schema, signing_input,
23 signing_input_without_signature, validate_b64_len, validate_id,
24};
25use crate::{UtcTimestamp, ValidationError};
26
27pub const LEASE_SCHEMA: &str = "leanctx.hive.lease/v1";
28pub const LEASE_ENVELOPE_SCHEMA: &str = "leanctx.hive.lease-envelope/v1";
29pub const KEYSET_SCHEMA: &str = "leanctx.hive.keyset/v1";
30pub const KEYSET_V3_SCHEMA: &str = "leanctx.hive.keyset/v3";
32pub const LEASE_KEY_PURPOSE: &str = "policy";
34
35pub const MAX_LEASE_VALIDITY_HOURS: i64 = 24;
37pub const MAX_LEASE_GRACE_DAYS: i64 = 7;
39pub const MAX_LEASE_FUTURE_SKEW_MINUTES: i64 = 15;
41pub const LEASE_RENEW_BEFORE_HOURS: i64 = 6;
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
46#[serde(rename_all = "snake_case")]
47pub enum HivePlan {
48 #[serde(rename = "none")]
50 NoPlan,
51 Personal,
52 Team,
53 Enterprise,
54 #[serde(other)]
55 Unknown,
56}
57
58impl HivePlan {
59 pub fn display_name(self) -> &'static str {
61 match self {
62 Self::NoPlan => "No plan",
63 Self::Personal => "Personal",
64 Self::Team => "Team",
65 Self::Enterprise => "Enterprise",
66 Self::Unknown => "unknown",
67 }
68 }
69
70 pub fn product_name(self) -> &'static str {
72 match self {
73 Self::NoPlan => "No plan",
74 Self::Personal => "Personal",
75 Self::Team => "Team",
76 Self::Enterprise => "Enterprise",
77 Self::Unknown => "Unknown",
78 }
79 }
80}
81
82#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
84pub enum HiveRight {
85 #[serde(rename = "knowledge.read")]
86 KnowledgeRead,
87 #[serde(rename = "knowledge.draft")]
88 KnowledgeDraft,
89 #[serde(rename = "knowledge.merge")]
90 KnowledgeMerge,
91 #[serde(rename = "metrics.upload")]
92 MetricsUpload,
93 #[serde(rename = "receipts.upload")]
94 ReceiptsUpload,
95 #[serde(rename = "policy.pull")]
96 PolicyPull,
97 #[serde(rename = "bus.relay")]
98 BusRelay,
99 #[serde(rename = "bus.control")]
100 BusControl,
101 #[serde(rename = "sealed")]
103 Sealed,
104 #[serde(rename = "runtime")]
107 Runtime,
108 #[serde(rename = "tasks.read")]
110 TasksRead,
111 #[serde(rename = "tasks.work")]
113 TasksWork,
114 #[serde(rename = "tasks.propose")]
116 TasksPropose,
117 #[serde(rename = "models.subscription")]
121 ModelsSubscription,
122 #[serde(other)]
123 Unknown,
124}
125
126#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
128pub struct HiveLeaseV1 {
129 pub schema: String,
130 pub lease_id: String,
131 pub org_id: String,
132 pub instance_id: String,
133 pub plan: HivePlan,
134 pub seats_limit: u32,
138 pub rights: Vec<HiveRight>,
139 pub issued_at: UtcTimestamp,
140 pub expires_at: UtcTimestamp,
141 pub grace_until: UtcTimestamp,
142 pub server_time: UtcTimestamp,
144 #[serde(default, skip_serializing_if = "Option::is_none")]
147 pub org_settings: Option<HiveOrgSettingsV1>,
148}
149
150#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
154pub struct HiveOrgSettingsV1 {
155 #[serde(default = "allowed")]
156 pub metrics_upload: bool,
157 #[serde(default = "allowed")]
158 pub receipts_upload: bool,
159}
160
161const fn allowed() -> bool {
162 true
163}
164
165#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
167#[serde(rename_all = "snake_case")]
168pub enum LeaseState {
169 Valid,
170 Grace,
171 Lapsed,
172}
173
174impl HiveLeaseV1 {
175 pub fn validate(&self) -> Result<(), ValidationError> {
176 require_schema(&self.schema, LEASE_SCHEMA)?;
177 validate_id(&self.lease_id, "lease_id")?;
178 validate_id(&self.org_id, "org_id")?;
179 validate_id(&self.instance_id, "instance_id")?;
180 if self.seats_limit == 0 {
181 return Err(ValidationError::new("seats_limit must be positive"));
182 }
183 if self.rights.len() > 64 {
184 return Err(ValidationError::new("lease lists are too long"));
185 }
186 let issued = parse_time(&self.issued_at)?;
187 let expires = parse_time(&self.expires_at)?;
188 let grace = parse_time(&self.grace_until)?;
189 parse_time(&self.server_time)?;
190 if expires <= issued || expires - issued > Duration::hours(MAX_LEASE_VALIDITY_HOURS) {
191 return Err(ValidationError::new(
192 "expires_at must be after issued_at and at most 24 h later",
193 ));
194 }
195 if grace < expires || grace - expires > Duration::days(MAX_LEASE_GRACE_DAYS) {
196 return Err(ValidationError::new(
197 "grace_until must be within 7 days after expires_at",
198 ));
199 }
200 Ok(())
201 }
202
203 pub fn grants(&self, right: HiveRight) -> bool {
205 right != HiveRight::Unknown && self.rights.contains(&right)
206 }
207
208 pub fn org_allows_metrics_upload(&self) -> bool {
210 self.org_settings
211 .is_none_or(|settings| settings.metrics_upload)
212 }
213
214 pub fn org_allows_receipts_upload(&self) -> bool {
216 self.org_settings
217 .is_none_or(|settings| settings.receipts_upload)
218 }
219
220 pub fn state_at(&self, now: DateTime<FixedOffset>) -> Result<LeaseState, ValidationError> {
223 let issued = parse_time(&self.issued_at)?;
224 if issued - now > Duration::minutes(MAX_LEASE_FUTURE_SKEW_MINUTES) {
225 return Ok(LeaseState::Lapsed);
226 }
227 Ok(if now < parse_time(&self.expires_at)? {
228 LeaseState::Valid
229 } else if now < parse_time(&self.grace_until)? {
230 LeaseState::Grace
231 } else {
232 LeaseState::Lapsed
233 })
234 }
235
236 pub fn renew_due(&self, now: DateTime<FixedOffset>) -> Result<bool, ValidationError> {
238 Ok(parse_time(&self.expires_at)? - now < Duration::hours(LEASE_RENEW_BEFORE_HOURS))
239 }
240}
241
242#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
247pub struct HiveLeaseEnvelopeV1 {
248 pub schema: String,
249 pub lease: Value,
250 pub kid: String,
252 pub signature: String,
254}
255
256impl HiveLeaseEnvelopeV1 {
257 pub fn validate_shape(&self) -> Result<(), ValidationError> {
259 require_schema(&self.schema, LEASE_ENVELOPE_SCHEMA)?;
260 validate_id(&self.kid, "kid")?;
261 validate_b64_len(&self.signature, 64, "signature")?;
262 if !self.lease.is_object() {
263 return Err(ValidationError::new("lease must be a JSON object"));
264 }
265 Ok(())
266 }
267
268 pub fn signing_input(&self) -> Result<Vec<u8>, ValidationError> {
270 signing_input(LEASE_SIGNATURE_DOMAIN, &self.lease)
271 }
272
273 pub fn signing_input_for(lease: &HiveLeaseV1) -> Result<Vec<u8>, ValidationError> {
275 lease.validate()?;
276 signing_input(LEASE_SIGNATURE_DOMAIN, &lease_value(lease)?)
277 }
278
279 pub fn seal(
281 lease: &HiveLeaseV1,
282 kid: impl Into<String>,
283 signature: impl Into<String>,
284 ) -> Result<Self, ValidationError> {
285 lease.validate()?;
286 let envelope = Self {
287 schema: LEASE_ENVELOPE_SCHEMA.into(),
288 lease: lease_value(lease)?,
289 kid: kid.into(),
290 signature: signature.into(),
291 };
292 envelope.validate_shape()?;
293 Ok(envelope)
294 }
295
296 pub fn lease(&self) -> Result<HiveLeaseV1, ValidationError> {
298 let lease: HiveLeaseV1 = serde_json::from_value(self.lease.clone())
299 .map_err(|error| ValidationError::new(format!("lease: {error}")))?;
300 lease.validate()?;
301 Ok(lease)
302 }
303}
304
305fn lease_value(lease: &HiveLeaseV1) -> Result<Value, ValidationError> {
306 serde_json::to_value(lease).map_err(|error| ValidationError::new(format!("lease: {error}")))
307}
308
309#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
311pub struct HiveKeyV1 {
312 pub kid: String,
313 pub ed25519_pub: String,
315 pub not_after: UtcTimestamp,
318}
319
320#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
324pub struct HiveKeySetV1 {
325 pub schema: String,
326 pub keys: Vec<HiveKeyV1>,
327 pub signature: String,
329}
330
331impl HiveKeySetV1 {
332 pub fn validate(&self) -> Result<(), ValidationError> {
333 require_schema(&self.schema, KEYSET_SCHEMA)?;
334 if self.keys.is_empty() || self.keys.len() > 16 {
335 return Err(ValidationError::new("keys must list 1-16 keys"));
336 }
337 let mut seen = std::collections::BTreeSet::new();
338 for key in &self.keys {
339 validate_id(&key.kid, "kid")?;
340 validate_b64_len(&key.ed25519_pub, 32, "ed25519_pub")?;
341 parse_time(&key.not_after)?;
342 if !seen.insert(key.kid.as_str()) {
343 return Err(ValidationError::new("kid repeats in key set"));
344 }
345 }
346 validate_b64_len(&self.signature, 64, "signature")
347 }
348
349 pub fn signing_input_raw(raw: &Value) -> Result<Vec<u8>, ValidationError> {
351 signing_input_without_signature(KEYSET_SIGNATURE_DOMAIN, raw)
352 }
353
354 pub fn signing_input(&self) -> Result<Vec<u8>, ValidationError> {
356 let value = serde_json::to_value(self)
357 .map_err(|error| ValidationError::new(format!("keyset: {error}")))?;
358 Self::signing_input_raw(&value)
359 }
360
361 pub fn key(&self, kid: &str) -> Option<&HiveKeyV1> {
363 self.keys.iter().find(|key| key.kid == kid)
364 }
365}
366
367#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
370pub struct HiveKeyV3 {
371 pub key_id: String,
373 pub public_key: String,
375 pub purposes: Vec<String>,
376 pub created_at: u64,
378 pub revoked_at: Option<u64>,
380}
381
382#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
384pub struct HiveRootSignatureV3 {
385 pub key_id: String,
386 pub signature: String,
388}
389
390#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
394pub struct HiveKeySetV3 {
395 pub schema: String,
396 pub keys: Vec<HiveKeyV3>,
397 pub signatures: Vec<HiveRootSignatureV3>,
398 pub signature: String,
399}
400
401fn lower_hex(value: &str, len: usize) -> bool {
402 value.len() == len
403 && value
404 .bytes()
405 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
406}
407
408impl HiveKeySetV3 {
409 pub fn validate(&self) -> Result<(), ValidationError> {
410 require_schema(&self.schema, KEYSET_V3_SCHEMA)?;
411 if self.keys.is_empty() || self.keys.len() > 64 {
412 return Err(ValidationError::new("keys must list 1-64 keys"));
413 }
414 let mut seen = std::collections::BTreeSet::new();
415 for key in &self.keys {
416 if !lower_hex(&key.key_id, 16) || !seen.insert(key.key_id.as_str()) {
417 return Err(ValidationError::new(
418 "key_id must be 16 lowercase hex characters, once per set",
419 ));
420 }
421 if key.public_key.len() != 43
422 || !key
423 .public_key
424 .bytes()
425 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_'))
426 {
427 return Err(ValidationError::new(
428 "public_key must be base64url of 32 bytes",
429 ));
430 }
431 let mut purposes = std::collections::BTreeSet::new();
432 if key.purposes.is_empty() || !key.purposes.iter().all(|p| purposes.insert(p)) {
433 return Err(ValidationError::new("purposes must list each purpose once"));
434 }
435 if key.revoked_at.is_some_and(|at| at <= key.created_at) {
436 return Err(ValidationError::new("revoked_at must follow created_at"));
437 }
438 }
439 if self.signatures.is_empty() || self.signatures.len() > 2 {
440 return Err(ValidationError::new(
441 "signatures must list 1-2 root signatures",
442 ));
443 }
444 if !self
445 .signatures
446 .iter()
447 .all(|s| lower_hex(&s.key_id, 16) && lower_hex(&s.signature, 128))
448 {
449 return Err(ValidationError::new(
450 "a root signature is a 16-hex key_id and a 128-hex signature",
451 ));
452 }
453 validate_b64_len(&self.signature, 64, "signature")
454 }
455
456 pub fn lease_key(&self, kid: &str) -> Option<&HiveKeyV3> {
459 self.keys
460 .iter()
461 .find(|key| key.key_id == kid && key.purposes.iter().any(|p| p == LEASE_KEY_PURPOSE))
462 }
463}
464
465#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
470#[serde(untagged)]
471pub enum EnrollmentKeySet {
472 V1(HiveKeySetV1),
473 V3(HiveKeySetV3),
474}
475
476impl<'de> Deserialize<'de> for EnrollmentKeySet {
477 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
478 use serde::de::Error as _;
479 let raw = Value::deserialize(deserializer)?;
480 if raw["schema"] == KEYSET_V3_SCHEMA {
481 serde_json::from_value(raw)
482 .map(Self::V3)
483 .map_err(D::Error::custom)
484 } else {
485 serde_json::from_value(raw)
486 .map(Self::V1)
487 .map_err(D::Error::custom)
488 }
489 }
490}
491
492impl EnrollmentKeySet {
493 pub fn validate(&self) -> Result<(), ValidationError> {
494 match self {
495 Self::V1(set) => set.validate(),
496 Self::V3(set) => set.validate(),
497 }
498 }
499
500 pub fn signs_leases_as(&self, kid: &str) -> bool {
502 match self {
503 Self::V1(set) => set.key(kid).is_some(),
504 Self::V3(set) => set.lease_key(kid).is_some(),
505 }
506 }
507}
508
509#[cfg(test)]
510mod tests {
511 use super::*;
512
513 #[test]
514 fn product_names_are_personal_team_enterprise() {
515 let names: Vec<&str> = [
516 HivePlan::NoPlan,
517 HivePlan::Personal,
518 HivePlan::Team,
519 HivePlan::Enterprise,
520 ]
521 .into_iter()
522 .map(HivePlan::product_name)
523 .collect();
524 assert_eq!(names, ["No plan", "Personal", "Team", "Enterprise"]);
525 assert_eq!(
527 serde_json::to_value(HivePlan::Enterprise).unwrap(),
528 serde_json::json!("enterprise")
529 );
530 }
531
532 fn lease() -> HiveLeaseV1 {
533 HiveLeaseV1 {
534 schema: LEASE_SCHEMA.into(),
535 lease_id: "lse_1".into(),
536 org_id: "org_1".into(),
537 instance_id: "inst_1".into(),
538 plan: HivePlan::Team,
539 seats_limit: 25,
540 rights: vec![HiveRight::KnowledgeRead],
541 issued_at: UtcTimestamp::new("2026-10-20T12:00:00Z").unwrap(),
542 expires_at: UtcTimestamp::new("2026-10-21T12:00:00Z").unwrap(),
543 grace_until: UtcTimestamp::new("2026-10-28T12:00:00Z").unwrap(),
544 server_time: UtcTimestamp::new("2026-10-20T12:00:00Z").unwrap(),
545 org_settings: None,
546 }
547 }
548
549 fn at(value: &str) -> DateTime<FixedOffset> {
550 DateTime::parse_from_rfc3339(value).unwrap()
551 }
552
553 #[test]
556 fn plan_wire_ids_are_none_personal_team_enterprise() {
557 let plan = |s: &str| serde_json::from_value::<HivePlan>(serde_json::json!(s)).unwrap();
558 assert_eq!(plan("none"), HivePlan::NoPlan);
559 assert_eq!(plan("personal"), HivePlan::Personal);
560 assert_eq!(plan("none").display_name(), "No plan");
561 assert_eq!(plan("team").display_name(), "Team");
562 assert_eq!(plan("enterprise").display_name(), "Enterprise");
563 assert_eq!(plan("pro"), HivePlan::Unknown);
565 assert_eq!(plan("business"), HivePlan::Unknown);
566 assert_eq!(plan("community"), HivePlan::Unknown);
567 assert_eq!(
568 plan("galaxy"),
569 HivePlan::Unknown,
570 "new plans stay tolerated"
571 );
572 assert_eq!(serde_json::to_value(HivePlan::NoPlan).unwrap(), "none");
573 assert_eq!(
574 serde_json::to_value(HivePlan::Personal).unwrap(),
575 "personal"
576 );
577 }
578
579 #[test]
580 fn lease_moves_from_valid_to_grace_to_lapsed() {
581 let lease = lease();
582 assert_eq!(
583 lease.state_at(at("2026-10-21T11:59:59Z")).unwrap(),
584 LeaseState::Valid
585 );
586 assert_eq!(
587 lease.state_at(at("2026-10-21T12:00:00Z")).unwrap(),
588 LeaseState::Grace
589 );
590 assert_eq!(
591 lease.state_at(at("2026-10-28T12:00:00Z")).unwrap(),
592 LeaseState::Lapsed
593 );
594 assert_eq!(
595 lease.state_at(at("2026-10-20T11:44:59Z")).unwrap(),
596 LeaseState::Lapsed
597 );
598 assert!(lease.renew_due(at("2026-10-21T06:00:01Z")).unwrap());
599 assert!(!lease.renew_due(at("2026-10-21T05:59:59Z")).unwrap());
600 }
601
602 #[test]
603 fn lease_windows_are_bounded() {
604 let mut long = lease();
605 long.expires_at = UtcTimestamp::new("2026-10-21T12:00:01Z").unwrap();
606 assert!(long.validate().is_err());
607 let mut grace = lease();
608 grace.grace_until = UtcTimestamp::new("2026-10-28T12:00:01Z").unwrap();
609 assert!(grace.validate().is_err());
610 assert!(lease().validate().is_ok());
611 }
612
613 #[test]
614 fn signature_covers_unknown_lease_fields() {
615 let mut envelope =
616 HiveLeaseEnvelopeV1::seal(&lease(), "k1", "A".repeat(86) + "==").unwrap();
617 let before = envelope.signing_input().unwrap();
618 envelope.lease["future_field"] = Value::from(1);
619 assert_ne!(before, envelope.signing_input().unwrap());
620 assert!(envelope.lease().is_ok());
621 }
622
623 #[test]
624 fn unknown_rights_grant_nothing() {
625 let mut lease = lease();
626 lease.rights.push(HiveRight::Unknown);
627 assert!(!lease.grants(HiveRight::Unknown));
628 assert!(lease.grants(HiveRight::KnowledgeRead));
629 assert!(!lease.grants(HiveRight::KnowledgeMerge));
630 }
631}