use anyhow::{Result, anyhow};
use nostr_sdk::prelude::*;
use serde::{Deserialize, Serialize};
use std::time::Duration;
use super::nostr_client::{NostrClient, KIND_HUMAN_DELEGATION, KIND_HUMAN_REVOCATION};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DelegationValidation {
pub valid: bool,
pub expires_at: Option<String>,
pub scopes: Option<serde_json::Value>,
pub delegation_id: String,
pub revoked: bool,
pub human_npub: Option<String>,
pub error: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
struct DelegationContent {
agent_npub: String,
#[serde(default)]
scopes: Option<serde_json::Value>,
expires_at: Option<String>,
delegation_id: String,
}
pub struct DelegationValidator {
nostr_client: NostrClient,
}
impl DelegationValidator {
pub fn new(nostr_client: NostrClient) -> Self {
Self { nostr_client }
}
pub async fn validate_delegation(
&self,
agent_npub: &str,
delegation_id: &str,
) -> Result<DelegationValidation> {
let delegation_events = self.query_delegation_by_agent(agent_npub).await?;
if delegation_events.is_empty() {
return Ok(DelegationValidation {
valid: false,
expires_at: None,
scopes: None,
delegation_id: delegation_id.to_string(),
revoked: false,
human_npub: None,
error: Some("No delegation events found for this agent".to_string()),
});
}
let mut matching_event: Option<(Event, DelegationContent)> = None;
for event in delegation_events {
if let Ok(content) = serde_json::from_str::<DelegationContent>(&event.content) {
if content.delegation_id == delegation_id {
matching_event = Some((event, content));
break;
}
}
}
let (event, content) = match matching_event {
Some(m) => m,
None => {
return Ok(DelegationValidation {
valid: false,
expires_at: None,
scopes: None,
delegation_id: delegation_id.to_string(),
revoked: false,
human_npub: None,
error: Some(format!("No delegation found with delegation_id: {}", delegation_id)),
});
}
};
let human_npub = event.pubkey.to_bech32().ok();
let expired = if let Some(ref expires_at) = content.expires_at {
is_expired(expires_at)
} else {
false };
if expired {
return Ok(DelegationValidation {
valid: false,
expires_at: content.expires_at,
scopes: content.scopes,
delegation_id: delegation_id.to_string(),
revoked: false,
human_npub,
error: Some("Delegation has expired".to_string()),
});
}
let revoked = self.check_revocation_by_delegation_id(delegation_id).await?;
if revoked {
return Ok(DelegationValidation {
valid: false,
expires_at: content.expires_at,
scopes: content.scopes,
delegation_id: delegation_id.to_string(),
revoked: true,
human_npub,
error: Some("Delegation has been revoked".to_string()),
});
}
Ok(DelegationValidation {
valid: true,
expires_at: content.expires_at,
scopes: content.scopes,
delegation_id: delegation_id.to_string(),
revoked: false,
human_npub,
error: None,
})
}
pub async fn validate_any_delegation(
&self,
agent_npub: &str,
) -> Result<DelegationValidation> {
let delegation_events = self.query_delegation_by_agent(agent_npub).await?;
if delegation_events.is_empty() {
return Ok(DelegationValidation {
valid: false,
expires_at: None,
scopes: None,
delegation_id: String::new(),
revoked: false,
human_npub: None,
error: Some("No delegation events found for this agent".to_string()),
});
}
for event in delegation_events {
if let Ok(content) = serde_json::from_str::<DelegationContent>(&event.content) {
let expired = if let Some(ref expires_at) = content.expires_at {
is_expired(expires_at)
} else {
false
};
if expired {
continue;
}
let revoked = self.check_revocation_by_delegation_id(&content.delegation_id).await?;
if revoked {
continue;
}
return Ok(DelegationValidation {
valid: true,
expires_at: content.expires_at,
scopes: content.scopes,
delegation_id: content.delegation_id,
revoked: false,
human_npub: event.pubkey.to_bech32().ok(),
error: None,
});
}
}
Ok(DelegationValidation {
valid: false,
expires_at: None,
scopes: None,
delegation_id: String::new(),
revoked: false,
human_npub: None,
error: Some("All delegations are expired or revoked".to_string()),
})
}
async fn query_delegation_by_agent(&self, agent_npub: &str) -> Result<Vec<Event>> {
let agent_pubkey = PublicKey::from_bech32(agent_npub)
.or_else(|_| PublicKey::from_hex(agent_npub))
.map_err(|e| anyhow!("Invalid agent npub: {}", e))?;
let agent_npub_hex = agent_pubkey.to_hex();
let filter = Filter::new()
.kind(Kind::Custom(KIND_HUMAN_DELEGATION))
.custom_tag(SingleLetterTag::lowercase(Alphabet::P), vec![agent_npub_hex])
.limit(100);
let events = self.nostr_client
.inner_client()
.get_events_of(
vec![filter],
nostr_sdk::client::EventSource::relays(Some(Duration::from_secs(5))),
)
.await
.map_err(|e| anyhow!("Failed to query delegation events: {}", e))?;
Ok(events)
}
async fn check_revocation_by_delegation_id(&self, delegation_id: &str) -> Result<bool> {
let filter = Filter::new()
.kind(Kind::Custom(KIND_HUMAN_REVOCATION))
.custom_tag(SingleLetterTag::lowercase(Alphabet::D), vec![delegation_id.to_string()])
.limit(1);
let events = self.nostr_client
.inner_client()
.get_events_of(
vec![filter],
nostr_sdk::client::EventSource::relays(Some(Duration::from_secs(5))),
)
.await
.map_err(|e| anyhow!("Failed to query revocation events: {}", e))?;
Ok(!events.is_empty())
}
pub fn nostr_client(&self) -> &NostrClient {
&self.nostr_client
}
}
fn is_expired(expires_at: &str) -> bool {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
if let Ok(exp_unix) = expires_at.parse::<u64>() {
return exp_unix < now;
}
if let Some(exp_unix) = parse_iso8601(expires_at) {
return exp_unix < now;
}
false
}
fn parse_iso8601(s: &str) -> Option<u64> {
let s = s.trim_end_matches('Z').split('+').next()?;
let parts: Vec<&str> = s.split('T').collect();
if parts.len() != 2 {
return None;
}
let date_parts: Vec<u32> = parts[0].split('-').filter_map(|p| p.parse().ok()).collect();
let time_parts: Vec<u32> = parts[1].split(':').filter_map(|p| p.parse().ok()).collect();
if date_parts.len() != 3 || time_parts.len() < 2 {
return None;
}
let year = date_parts[0] as u64;
let month = date_parts[1] as u64;
let day = date_parts[2] as u64;
let hour = time_parts[0] as u64;
let minute = time_parts[1] as u64;
let second = time_parts.get(2).copied().unwrap_or(0) as u64;
let years_since_1970 = year.saturating_sub(1970);
let days = years_since_1970 * 365 + years_since_1970 / 4 + (month - 1) * 30 + day - 1;
Some(days * 86400 + hour * 3600 + minute * 60 + second)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_delegation_validation_serialization() {
let validation = DelegationValidation {
valid: true,
expires_at: Some("2026-12-31T23:59:59Z".to_string()),
scopes: Some(serde_json::json!({"login": true, "payment": false})),
delegation_id: "del_abc123".to_string(),
revoked: false,
human_npub: Some("npub1xyz...".to_string()),
error: None,
};
let json = serde_json::to_string(&validation).unwrap();
assert!(json.contains("\"valid\":true"));
assert!(json.contains("\"delegation_id\":\"del_abc123\""));
}
#[test]
fn test_is_expired_unix() {
assert!(is_expired("1577836800"));
assert!(!is_expired("1893456000"));
}
#[test]
fn test_is_expired_iso8601() {
assert!(is_expired("2020-01-01T00:00:00Z"));
assert!(!is_expired("2030-01-01T00:00:00Z"));
}
#[test]
fn test_delegation_content_parsing() {
let json = r#"{
"agent_npub": "npub1abc...",
"scopes": {"login": true},
"expires_at": "2026-12-31T23:59:59Z",
"delegation_id": "del_123"
}"#;
let content: DelegationContent = serde_json::from_str(json).unwrap();
assert_eq!(content.delegation_id, "del_123");
assert_eq!(content.agent_npub, "npub1abc...");
}
}