1use serde::{Deserialize, Serialize};
7use serde_json::Value;
8use std::collections::BTreeMap;
9
10use crate::error::{Result, SdkError};
11use crate::identity::{verify_envelope, AgentId, Envelope, Event};
12use url::Url;
13
14pub const PROTOCOL: &str = "agent-delegation/1.0";
15pub const DELEGATION_GRANT: &str = "delegation.grant";
16pub const DELEGATION_REVOKE: &str = "delegation.revoke";
17
18#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
19#[serde(rename_all = "snake_case")]
20pub enum DelegationStatus {
21 Active,
22 Suspended,
23 Expired,
24 Revoked,
25}
26
27#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
28pub struct PrincipalLink {
29 pub name: String,
30 pub url: String,
31 pub rel: String,
32}
33
34#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
35pub struct PrincipalDescriptor {
36 pub id: String,
37 #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
38 pub kind: Option<String>,
39 #[serde(default, skip_serializing_if = "Option::is_none")]
40 pub name: Option<String>,
41}
42
43impl PrincipalDescriptor {
44 pub fn new(id: impl Into<String>) -> Self {
45 Self {
46 id: id.into(),
47 kind: None,
48 name: None,
49 }
50 }
51}
52
53#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
54pub struct PrincipalDocument {
55 pub id: String,
56 #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
57 pub kind: Option<String>,
58 #[serde(default, skip_serializing_if = "Option::is_none")]
59 pub name: Option<String>,
60 #[serde(default, skip_serializing_if = "Option::is_none")]
61 pub description: Option<String>,
62 #[serde(default, skip_serializing_if = "Option::is_none")]
63 pub avatar_url: Option<String>,
64 #[serde(default, skip_serializing_if = "Vec::is_empty")]
65 pub links: Vec<PrincipalLink>,
66 pub controllers: Vec<AgentId>,
67 #[serde(default, skip_serializing_if = "Option::is_none")]
68 pub delegations_url: Option<String>,
69 #[serde(default, skip_serializing_if = "Option::is_none")]
70 pub updated_at: Option<i64>,
71 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
72 pub extra: BTreeMap<String, Value>,
73}
74
75#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
76pub struct DelegationGrantPayload {
77 pub id: String,
78 pub principal: PrincipalDescriptor,
79 pub subject: AgentId,
80 #[serde(default, skip_serializing_if = "Option::is_none")]
81 pub relationship: Option<String>,
82 pub scopes: Vec<String>,
83 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
84 pub constraints: BTreeMap<String, Value>,
85 #[serde(default, skip_serializing_if = "Option::is_none")]
86 pub not_before: Option<i64>,
87 #[serde(default, skip_serializing_if = "Option::is_none")]
88 pub expires_at: Option<i64>,
89}
90
91impl DelegationGrantPayload {
92 pub fn new(
93 id: impl Into<String>,
94 principal: PrincipalDescriptor,
95 subject: AgentId,
96 scopes: Vec<String>,
97 ) -> Self {
98 Self {
99 id: id.into(),
100 principal,
101 subject,
102 relationship: None,
103 scopes,
104 constraints: BTreeMap::new(),
105 not_before: None,
106 expires_at: None,
107 }
108 }
109}
110
111#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
112pub struct DelegationRevokePayload {
113 pub id: String,
114 pub principal_id: String,
115 #[serde(default, skip_serializing_if = "Option::is_none")]
116 pub reason: Option<String>,
117}
118
119#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
120#[serde(untagged)]
121pub enum DelegationPayload {
122 Grant(DelegationGrantPayload),
123 Revoke(DelegationRevokePayload),
124}
125
126#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
127pub struct DelegationCredential {
128 pub id: String,
129 pub protocol: String,
130 pub principal: PrincipalDescriptor,
131 pub controller: AgentId,
132 pub subject: AgentId,
133 #[serde(default, skip_serializing_if = "Option::is_none")]
134 pub relationship: Option<String>,
135 pub scopes: Vec<String>,
136 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
137 pub constraints: BTreeMap<String, Value>,
138 #[serde(default, skip_serializing_if = "Option::is_none")]
139 pub not_before: Option<i64>,
140 #[serde(default, skip_serializing_if = "Option::is_none")]
141 pub expires_at: Option<i64>,
142 pub status: DelegationStatus,
143 #[serde(default, skip_serializing_if = "Option::is_none")]
144 pub status_url: Option<String>,
145 pub updated_at: i64,
146 pub event_id: String,
147}
148
149#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
150pub struct DelegationStatusDocument {
151 pub id: String,
152 pub status: DelegationStatus,
153 pub checked_at: i64,
154 #[serde(default, skip_serializing_if = "Option::is_none")]
155 pub expires_at: Option<i64>,
156 pub event_id: String,
157}
158
159#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
160pub struct DelegationServiceDiscovery {
161 pub protocol: String,
162 pub service: String,
163 pub endpoints: DelegationServiceEndpoints,
164 #[serde(default, skip_serializing_if = "Vec::is_empty")]
165 pub features: Vec<String>,
166}
167
168#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
169pub struct DelegationServiceEndpoints {
170 pub delegations: String,
171 #[serde(default, skip_serializing_if = "Option::is_none")]
172 pub query: Option<String>,
173}
174
175#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
176pub struct DelegationQueryRequest {
177 #[serde(default, skip_serializing_if = "Option::is_none")]
178 pub subject: Option<AgentId>,
179 #[serde(default, skip_serializing_if = "Option::is_none")]
180 pub principal_id: Option<String>,
181 #[serde(default, skip_serializing_if = "Option::is_none")]
182 pub status: Option<DelegationStatus>,
183 #[serde(default, skip_serializing_if = "Option::is_none")]
184 pub limit: Option<u32>,
185}
186
187#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
188pub struct DelegationSummary {
189 pub id: String,
190 pub subject: AgentId,
191 pub principal: PrincipalDescriptor,
192 #[serde(default, skip_serializing_if = "Vec::is_empty")]
193 pub scopes: Vec<String>,
194 pub status: DelegationStatus,
195 #[serde(default, skip_serializing_if = "Option::is_none")]
196 pub status_url: Option<String>,
197}
198
199#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
200pub struct DelegationQueryResponse {
201 pub result: Vec<DelegationSummary>,
202}
203
204#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
205pub struct DelegationEventsResponse {
206 pub result: Vec<Envelope<DelegationPayload>>,
207}
208
209pub fn delegation_grant_event(
210 actor: AgentId,
211 created_at: i64,
212 nonce: u64,
213 payload: DelegationGrantPayload,
214) -> Event<DelegationGrantPayload> {
215 Event::new(
216 PROTOCOL,
217 DELEGATION_GRANT,
218 actor,
219 created_at,
220 nonce,
221 payload,
222 )
223}
224
225pub fn delegation_revoke_event(
226 actor: AgentId,
227 created_at: i64,
228 nonce: u64,
229 payload: DelegationRevokePayload,
230) -> Event<DelegationRevokePayload> {
231 Event::new(
232 PROTOCOL,
233 DELEGATION_REVOKE,
234 actor,
235 created_at,
236 nonce,
237 payload,
238 )
239}
240
241pub fn validate_principal_document(document: &PrincipalDocument) -> Result<()> {
242 validate_https_url(&document.id, "principal.id")?;
243 if document.controllers.is_empty() {
244 return Err(SdkError::InvalidPayload(
245 "principal document controllers must be non-empty".to_owned(),
246 ));
247 }
248 for controller in &document.controllers {
249 controller.public_key_bytes()?;
250 }
251 if let Some(url) = &document.delegations_url {
252 validate_https_url(url, "delegations_url")?;
253 }
254 Ok(())
255}
256
257pub fn validate_delegation_grant_payload(
258 payload: &DelegationGrantPayload,
259 created_at: Option<i64>,
260) -> Result<()> {
261 validate_non_empty(&payload.id, "payload.id")?;
262 validate_principal_descriptor(&payload.principal)?;
263 payload.subject.public_key_bytes()?;
264 if payload.scopes.is_empty() || payload.scopes.iter().any(|scope| scope.trim().is_empty()) {
265 return Err(SdkError::InvalidPayload(
266 "delegation scopes must be non-empty".to_owned(),
267 ));
268 }
269 if let Some(expires_at) = payload.expires_at {
270 let not_before = payload.not_before.or(created_at);
271 if not_before
272 .map(|not_before| expires_at <= not_before)
273 .unwrap_or(false)
274 {
275 return Err(SdkError::InvalidPayload(
276 "expires_at must be greater than not_before or created_at".to_owned(),
277 ));
278 }
279 }
280 Ok(())
281}
282
283pub fn validate_delegation_revoke_payload(payload: &DelegationRevokePayload) -> Result<()> {
284 validate_non_empty(&payload.id, "payload.id")?;
285 validate_https_url(&payload.principal_id, "principal_id")
286}
287
288pub fn validate_delegation_envelope(envelope: &Envelope<DelegationPayload>) -> Result<()> {
289 verify_envelope(envelope)?;
290 if envelope.event.protocol != PROTOCOL {
291 return Err(SdkError::InvalidEventProtocol {
292 expected: PROTOCOL.to_owned(),
293 actual: envelope.event.protocol.clone(),
294 });
295 }
296 match (&envelope.event.kind[..], &envelope.event.payload) {
297 (DELEGATION_GRANT, DelegationPayload::Grant(payload)) => {
298 validate_delegation_grant_payload(payload, Some(envelope.event.created_at))
299 }
300 (DELEGATION_REVOKE, DelegationPayload::Revoke(payload)) => {
301 validate_delegation_revoke_payload(payload)
302 }
303 (DELEGATION_GRANT, _) | (DELEGATION_REVOKE, _) => Err(SdkError::InvalidPayload(
304 "delegation event type does not match payload shape".to_owned(),
305 )),
306 _ => Err(SdkError::InvalidEventType {
307 expected: format!("{DELEGATION_GRANT} or {DELEGATION_REVOKE}"),
308 actual: envelope.event.kind.clone(),
309 }),
310 }
311}
312
313pub fn materialize_delegation_credential(
314 envelope: &Envelope<DelegationGrantPayload>,
315 status: DelegationStatus,
316 status_url: Option<String>,
317 updated_at: Option<i64>,
318) -> Result<DelegationCredential> {
319 verify_envelope(envelope)?;
320 if envelope.event.protocol != PROTOCOL {
321 return Err(SdkError::InvalidEventProtocol {
322 expected: PROTOCOL.to_owned(),
323 actual: envelope.event.protocol.clone(),
324 });
325 }
326 if envelope.event.kind != DELEGATION_GRANT {
327 return Err(SdkError::InvalidEventType {
328 expected: DELEGATION_GRANT.to_owned(),
329 actual: envelope.event.kind.clone(),
330 });
331 }
332 validate_delegation_grant_payload(&envelope.event.payload, Some(envelope.event.created_at))?;
333 let payload = &envelope.event.payload;
334 Ok(DelegationCredential {
335 id: payload.id.clone(),
336 protocol: PROTOCOL.to_owned(),
337 principal: payload.principal.clone(),
338 controller: envelope.event.actor.clone(),
339 subject: payload.subject.clone(),
340 relationship: payload.relationship.clone(),
341 scopes: payload.scopes.clone(),
342 constraints: payload.constraints.clone(),
343 not_before: payload.not_before,
344 expires_at: payload.expires_at,
345 status,
346 status_url,
347 updated_at: updated_at.unwrap_or(envelope.event.created_at),
348 event_id: envelope.hash.clone(),
349 })
350}
351
352fn validate_principal_descriptor(principal: &PrincipalDescriptor) -> Result<()> {
353 validate_https_url(&principal.id, "principal.id")
354}
355
356fn validate_https_url(value: &str, field: &str) -> Result<()> {
357 let parsed = Url::parse(value)
358 .map_err(|_| SdkError::InvalidPayload(format!("{field} must be an HTTPS URL")))?;
359 if parsed.scheme() == "https" && parsed.host_str().is_some() {
360 Ok(())
361 } else {
362 Err(SdkError::InvalidPayload(format!(
363 "{field} must be an HTTPS URL"
364 )))
365 }
366}
367
368fn validate_non_empty(value: &str, field: &str) -> Result<()> {
369 if value.trim().is_empty() {
370 Err(SdkError::InvalidPayload(format!(
371 "{field} must not be empty"
372 )))
373 } else {
374 Ok(())
375 }
376}
377
378#[cfg(test)]
379mod tests {
380 use super::*;
381 use crate::identity::AgentSigner;
382
383 #[test]
384 fn validates_and_materializes_grant() {
385 let controller = AgentSigner::from_seed([31; 32]);
386 let subject = AgentSigner::from_seed([32; 32]);
387 let mut payload = DelegationGrantPayload::new(
388 "del_01J8ZM7A3G2T9B4Q6X8R0N1P2Q",
389 PrincipalDescriptor {
390 id: "https://al.ink/yan".to_owned(),
391 kind: Some("person".to_owned()),
392 name: Some("Yan".to_owned()),
393 },
394 subject.agent_id(),
395 vec!["inbox.screen".to_owned(), "meeting.propose".to_owned()],
396 );
397 payload.relationship = Some("primary_delegate".to_owned());
398 payload.not_before = Some(1_779_753_600_000);
399 payload.expires_at = Some(1_790_000_000_000);
400 let envelope = controller
401 .sign_event(delegation_grant_event(
402 controller.agent_id(),
403 1_779_753_600_000,
404 1,
405 payload,
406 ))
407 .unwrap();
408 let envelope_value = serde_json::to_value(&envelope).unwrap();
409 let envelope_for_validation: Envelope<DelegationPayload> =
410 serde_json::from_value(envelope_value).unwrap();
411
412 validate_delegation_envelope(&envelope_for_validation).unwrap();
413 let credential = materialize_delegation_credential(
414 &envelope,
415 DelegationStatus::Active,
416 Some("https://al.ink/v1/delegations/del/status".to_owned()),
417 None,
418 )
419 .unwrap();
420
421 assert_eq!(credential.protocol, PROTOCOL);
422 assert_eq!(credential.controller, controller.agent_id());
423 assert_eq!(credential.subject, subject.agent_id());
424 assert_eq!(credential.event_id, envelope.hash);
425 }
426
427 #[test]
428 fn validates_revoke_and_rejects_invalid_grants() {
429 let controller = AgentSigner::from_seed([33; 32]);
430 let envelope = controller
431 .sign_event(delegation_revoke_event(
432 controller.agent_id(),
433 1_779_753_700_000,
434 2,
435 DelegationRevokePayload {
436 id: "del_01J8ZM7A3G2T9B4Q6X8R0N1P2Q".to_owned(),
437 principal_id: "https://al.ink/yan".to_owned(),
438 reason: Some("rotated_primary_agent".to_owned()),
439 },
440 ))
441 .unwrap();
442 let envelope: Envelope<DelegationPayload> =
443 serde_json::from_value(serde_json::to_value(envelope).unwrap()).unwrap();
444 validate_delegation_envelope(&envelope).unwrap();
445
446 let invalid = DelegationGrantPayload::new(
447 "del",
448 PrincipalDescriptor::new("http://example.com"),
449 controller.agent_id(),
450 Vec::new(),
451 );
452 assert!(validate_delegation_grant_payload(&invalid, None).is_err());
453 }
454
455 #[test]
456 fn validates_principal_document() {
457 let controller = AgentSigner::from_seed([34; 32]);
458 let document = PrincipalDocument {
459 id: "https://profiles.example.com/org/acme".to_owned(),
460 kind: None,
461 name: None,
462 description: None,
463 avatar_url: None,
464 links: Vec::new(),
465 controllers: vec![controller.agent_id()],
466 delegations_url: Some("https://profiles.example.com/v1/delegations/query".to_owned()),
467 updated_at: None,
468 extra: BTreeMap::new(),
469 };
470 validate_principal_document(&document).unwrap();
471
472 let mut invalid = document;
473 invalid.controllers.clear();
474 assert!(validate_principal_document(&invalid).is_err());
475 }
476
477 #[test]
478 fn rejects_malformed_https_like_urls() {
479 let signer = AgentSigner::from_seed([35; 32]);
480 for principal_id in ["https://", "https://[::1", "http://example.com"] {
481 let payload = DelegationGrantPayload::new(
482 "del",
483 PrincipalDescriptor::new(principal_id),
484 signer.agent_id(),
485 vec!["scope".to_owned()],
486 );
487 assert!(validate_delegation_grant_payload(&payload, None).is_err());
488 }
489 }
490}