1use serde::{Deserialize, Serialize};
2use serde_json::Value;
3use std::collections::{BTreeMap, BTreeSet};
4
5use crate::delegation::{validate_delegation_id, PrincipalDescriptor};
6use crate::error::{Result, SdkError};
7use crate::identity::{
8 validate_event_fields, verify_envelope, AgentId, Envelope, Event, ListResponse,
9};
10
11pub const PROTOCOL: &str = "agent-profile/1.0";
12pub const PROFILE_UPDATE: &str = "profile.update";
13
14#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
20#[serde(deny_unknown_fields)]
21pub struct ServiceEndpoint {
22 #[serde(rename = "type")]
23 pub kind: String,
24 pub url: String,
25 #[serde(default, skip_serializing_if = "Option::is_none")]
26 pub protocols: Option<Vec<String>>,
27}
28
29pub mod link_rel {
32 pub const HOMEPAGE: &str = "homepage";
33 pub const DOCUMENTATION: &str = "documentation";
34 pub const SOURCE_CODE: &str = "source_code";
35 pub const SOCIAL: &str = "social";
36 pub const BROWSER: &str = "browser";
37}
38
39#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
40#[serde(deny_unknown_fields)]
41pub struct ProfileLink {
42 pub name: String,
43 pub url: String,
44 pub rel: String,
45}
46
47#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
51#[serde(deny_unknown_fields)]
52pub struct ProfileDelegationHint {
53 #[serde(default, skip_serializing_if = "Option::is_none")]
54 pub id: Option<String>,
55 pub principal: PrincipalDescriptor,
56 #[serde(default, skip_serializing_if = "Option::is_none")]
57 pub relationship: Option<String>,
58 #[serde(default, skip_serializing_if = "Option::is_none")]
59 pub scopes: Option<Vec<String>>,
60}
61
62#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
65#[serde(deny_unknown_fields)]
66pub struct ProfileUpdatePayload {
67 pub id: AgentId,
68 pub name: String,
69 #[serde(default, skip_serializing_if = "Option::is_none")]
70 pub description: Option<String>,
71 #[serde(default, skip_serializing_if = "Option::is_none")]
72 pub avatar_url: Option<String>,
73 #[serde(default, skip_serializing_if = "Option::is_none")]
74 pub provider: Option<String>,
75 #[serde(default, skip_serializing_if = "Option::is_none")]
76 pub capabilities: Option<Vec<String>>,
77 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub service_endpoints: Option<Vec<ServiceEndpoint>>,
79 #[serde(default, skip_serializing_if = "Option::is_none")]
80 pub links: Option<Vec<ProfileLink>>,
81 #[serde(default, skip_serializing_if = "Option::is_none")]
82 pub delegations: Option<Vec<ProfileDelegationHint>>,
83 #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub extra: Option<BTreeMap<String, Value>>,
85}
86
87impl ProfileUpdatePayload {
88 pub fn new(id: AgentId, name: impl Into<String>) -> Self {
89 Self {
90 id,
91 name: name.into(),
92 description: None,
93 avatar_url: None,
94 provider: None,
95 capabilities: None,
96 service_endpoints: None,
97 links: None,
98 delegations: None,
99 extra: None,
100 }
101 }
102}
103
104#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
107pub struct AgentProfile {
108 pub id: AgentId,
109 pub name: String,
110 #[serde(default, skip_serializing_if = "Option::is_none")]
111 pub description: Option<String>,
112 #[serde(default, skip_serializing_if = "Option::is_none")]
113 pub avatar_url: Option<String>,
114 #[serde(default, skip_serializing_if = "Option::is_none")]
115 pub provider: Option<String>,
116 #[serde(default, skip_serializing_if = "Option::is_none")]
117 pub capabilities: Option<Vec<String>>,
118 #[serde(default, skip_serializing_if = "Option::is_none")]
119 pub service_endpoints: Option<Vec<ServiceEndpoint>>,
120 #[serde(default, skip_serializing_if = "Option::is_none")]
121 pub links: Option<Vec<ProfileLink>>,
122 #[serde(default, skip_serializing_if = "Option::is_none")]
123 pub delegations: Option<Vec<ProfileDelegationHint>>,
124 #[serde(default, skip_serializing_if = "Option::is_none")]
125 pub extra: Option<BTreeMap<String, Value>>,
126 pub updated_at: i64,
127 pub event_id: String,
128}
129
130#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
131pub struct ProfileBatchReadRequest {
132 pub ids: Vec<AgentId>,
133}
134
135pub type ProfileBatchReadResponse = ListResponse<AgentProfile>;
137
138pub type ProfileEventsResponse = ListResponse<Envelope<ProfileUpdatePayload>>;
140
141#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
142pub struct ProfileServiceDiscovery {
143 pub protocol: String,
144 pub service: String,
145 pub endpoints: ProfileServiceEndpoints,
146 #[serde(default, skip_serializing_if = "Vec::is_empty")]
147 pub features: Vec<String>,
148}
149
150#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
151pub struct ProfileServiceEndpoints {
152 pub profiles: String,
153 #[serde(default, skip_serializing_if = "Option::is_none")]
154 pub profile_batch: Option<String>,
155}
156
157pub fn profile_update_event(
158 actor: AgentId,
159 created_at: i64,
160 nonce: u64,
161 payload: ProfileUpdatePayload,
162) -> Event<ProfileUpdatePayload> {
163 Event::new(PROTOCOL, PROFILE_UPDATE, actor, created_at, nonce, payload)
164}
165
166pub fn validate_profile_update(envelope: &Envelope<ProfileUpdatePayload>) -> Result<()> {
167 verify_envelope(envelope)?;
168 validate_event_fields(&envelope.event, &[])?;
170 if envelope.event.protocol != PROTOCOL {
171 return Err(SdkError::InvalidEventProtocol {
172 expected: PROTOCOL.to_owned(),
173 actual: envelope.event.protocol.clone(),
174 });
175 }
176 if envelope.event.kind != PROFILE_UPDATE {
177 return Err(SdkError::InvalidEventType {
178 expected: PROFILE_UPDATE.to_owned(),
179 actual: envelope.event.kind.clone(),
180 });
181 }
182 if envelope.event.actor != envelope.event.payload.id {
183 return Err(SdkError::InvalidActor(
184 "profile update actor must match payload.id".to_owned(),
185 ));
186 }
187 validate_profile_payload(&envelope.event.payload)
188}
189
190pub fn validate_profile_payload(payload: &ProfileUpdatePayload) -> Result<()> {
193 payload.id.public_key_bytes()?;
194 if payload.name.is_empty() {
195 return Err(invalid_payload("name must not be empty"));
196 }
197 if let Some(url) = &payload.avatar_url {
198 require_url(url, &["https"], "avatar_url")?;
199 }
200 unique_strings(payload.capabilities.as_deref(), "capabilities")?;
201 let mut endpoints = BTreeSet::new();
202 for endpoint in payload.service_endpoints.iter().flatten() {
203 if endpoint.kind.is_empty() {
204 return Err(invalid_payload("service endpoint type must not be empty"));
205 }
206 require_url(&endpoint.url, &["https"], "service endpoint url")?;
207 unique_strings(endpoint.protocols.as_deref(), "service endpoint protocols")?;
208 if !endpoints.insert((endpoint.kind.as_str(), endpoint.url.as_str())) {
209 return Err(invalid_payload(
210 "service endpoints must be unique by type and url",
211 ));
212 }
213 }
214 let mut links = BTreeSet::new();
215 for link in payload.links.iter().flatten() {
216 if link.name.is_empty() || link.rel.is_empty() {
217 return Err(invalid_payload("link name and rel must not be empty"));
218 }
219 require_url(&link.url, &["http", "https"], "link url")?;
220 if !links.insert((link.url.as_str(), link.rel.as_str())) {
221 return Err(invalid_payload("links must be unique by url and rel"));
222 }
223 }
224 for hint in payload.delegations.iter().flatten() {
225 if let Some(id) = &hint.id {
226 validate_delegation_id(id)?;
227 }
228 require_url(&hint.principal.id, &["https"], "delegation principal id")?;
229 unique_strings(hint.scopes.as_deref(), "delegation hint scopes")?;
230 }
231 Ok(())
232}
233
234fn invalid_payload(message: &str) -> SdkError {
235 SdkError::protocol("invalid_event", message)
236}
237
238fn require_url(value: &str, schemes: &[&str], field: &str) -> Result<()> {
239 match url::Url::parse(value) {
240 Ok(url) if schemes.contains(&url.scheme()) && url.host_str().is_some() => Ok(()),
241 _ => Err(SdkError::protocol(
242 "invalid_event",
243 format!("{field} must be an {} URL", schemes.join(" or ")),
244 )),
245 }
246}
247
248fn unique_strings(values: Option<&[String]>, field: &str) -> Result<()> {
249 let values = values.unwrap_or_default();
250 if values.iter().any(String::is_empty) {
251 return Err(SdkError::protocol(
252 "invalid_event",
253 format!("{field} entries must not be empty"),
254 ));
255 }
256 if values.iter().collect::<BTreeSet<_>>().len() != values.len() {
257 return Err(SdkError::protocol(
258 "invalid_event",
259 format!("{field} entries must be unique"),
260 ));
261 }
262 Ok(())
263}
264
265pub fn materialize_profile(envelope: &Envelope<ProfileUpdatePayload>) -> Result<AgentProfile> {
266 validate_profile_update(envelope)?;
267 let payload = &envelope.event.payload;
268 Ok(AgentProfile {
269 id: payload.id.clone(),
270 name: payload.name.clone(),
271 description: payload.description.clone(),
272 avatar_url: payload.avatar_url.clone(),
273 provider: payload.provider.clone(),
274 capabilities: payload.capabilities.clone(),
275 service_endpoints: payload.service_endpoints.clone(),
276 links: payload.links.clone(),
277 delegations: payload.delegations.clone(),
278 extra: payload.extra.clone(),
279 updated_at: envelope.event.created_at,
280 event_id: envelope.hash.clone(),
281 })
282}
283
284pub fn validate_profile_succession(
288 envelope: &Envelope<ProfileUpdatePayload>,
289 latest_nonce: Option<u64>,
290) -> Result<()> {
291 match latest_nonce {
292 Some(latest) if envelope.event.nonce <= latest => {
293 Err(SdkError::NonceNotGreater { max_nonce: latest })
294 }
295 _ => Ok(()),
296 }
297}
298
299pub fn latest_profile_update(
304 envelopes: &[Envelope<ProfileUpdatePayload>],
305) -> Option<&Envelope<ProfileUpdatePayload>> {
306 envelopes.iter().max_by_key(|envelope| envelope.event.nonce)
307}
308
309#[cfg(test)]
310mod tests {
311 use super::*;
312 use crate::delegation::PrincipalDescriptor;
313 use crate::identity::AgentSigner;
314
315 #[test]
316 fn materializes_valid_profile_update() {
317 let signer = AgentSigner::from_seed([11; 32]);
318 let mut payload = ProfileUpdatePayload::new(signer.agent_id(), "ResearchAgent-v3");
319 payload.capabilities = Some(vec!["research".to_owned()]);
320 payload.extra = Some(BTreeMap::from([(
321 "domain".to_owned(),
322 Value::String("research".to_owned()),
323 )]));
324 payload.links = Some(vec![ProfileLink {
325 name: "Homepage".to_owned(),
326 url: "https://example.com".to_owned(),
327 rel: link_rel::HOMEPAGE.to_owned(),
328 }]);
329 let mut principal = PrincipalDescriptor::new("https://api.al.ink/d9c6a99cne5g00a6scn0");
330 principal.kind = Some("person".to_owned());
331 principal.name = Some("Yan".to_owned());
332 payload.delegations = Some(vec![ProfileDelegationHint {
333 id: Some("del_1".to_owned()),
334 principal,
335 relationship: Some("primary_delegate".to_owned()),
336 scopes: Some(vec!["inbox.screen".to_owned()]),
337 }]);
338 let expected_extra = payload.extra.clone();
339 let event = profile_update_event(signer.agent_id(), 1_779_753_600_000, 1, payload);
340 let envelope = signer.sign_event(event).unwrap();
341
342 let profile = materialize_profile(&envelope).unwrap();
343
344 assert_eq!(profile.id, signer.agent_id());
345 assert_eq!(profile.name, "ResearchAgent-v3");
346 let links = profile.links.as_deref().unwrap();
347 assert_eq!(links.len(), 1);
348 assert_eq!(links[0].rel, link_rel::HOMEPAGE);
349 assert_eq!(profile.delegations.as_deref().map(<[_]>::len), Some(1));
350 assert_eq!(profile.extra, expected_extra);
351 assert_eq!(profile.updated_at, 1_779_753_600_000);
352 assert_eq!(profile.event_id, envelope.hash);
353 }
354
355 #[test]
356 fn materialized_profile_has_no_username_field() {
357 let signer = AgentSigner::from_seed([15; 32]);
358 let payload = ProfileUpdatePayload::new(signer.agent_id(), "ResearchAgent-v3");
359 let event = profile_update_event(signer.agent_id(), 1_779_753_600_002, 1, payload);
360 let envelope = signer.sign_event(event).unwrap();
361
362 let profile = materialize_profile(&envelope).unwrap();
363
364 assert_eq!(profile.id, signer.agent_id());
365 let value = serde_json::to_value(&profile).unwrap();
366 assert!(value.get("username").is_none());
367 }
368
369 #[test]
370 fn latest_profile_update_picks_greatest_nonce() {
371 let signer = AgentSigner::from_seed([16; 32]);
372 let envelopes: Vec<_> = [3_u64, 1, 2]
373 .into_iter()
374 .map(|nonce| {
375 let payload =
376 ProfileUpdatePayload::new(signer.agent_id(), format!("Agent-v{nonce}"));
377 signer
378 .sign_event(profile_update_event(
379 signer.agent_id(),
380 1_779_753_600_000 + nonce as i64,
381 nonce,
382 payload,
383 ))
384 .unwrap()
385 })
386 .collect();
387
388 assert!(latest_profile_update(&[]).is_none());
389 let latest = latest_profile_update(&envelopes).unwrap();
390 assert_eq!(latest.event.nonce, 3);
391 assert_eq!(materialize_profile(latest).unwrap().name, "Agent-v3");
392 }
393
394 #[test]
395 fn requires_nonce_succession_and_closed_event_fields() {
396 let signer = AgentSigner::from_seed([17; 32]);
397 let payload = ProfileUpdatePayload::new(signer.agent_id(), "A");
398 let envelope = signer
399 .sign_event(profile_update_event(
400 signer.agent_id(),
401 1_000,
402 7,
403 payload.clone(),
404 ))
405 .unwrap();
406 validate_profile_succession(&envelope, None).unwrap();
407 validate_profile_succession(&envelope, Some(6)).unwrap();
408 for latest in [7, 8] {
409 assert!(matches!(
410 validate_profile_succession(&envelope, Some(latest)),
411 Err(SdkError::NonceNotGreater { max_nonce }) if max_nonce == latest
412 ));
413 }
414 let extra = signer
415 .sign_event(
416 profile_update_event(signer.agent_id(), 1_000, 8, payload).with_room_id("r"),
417 )
418 .unwrap();
419 assert_eq!(
420 validate_profile_update(&extra).unwrap_err().code(),
421 Some("invalid_event")
422 );
423 }
424
425 #[test]
426 fn rejects_actor_payload_mismatch() {
427 let signer = AgentSigner::from_seed([12; 32]);
428 let other = AgentSigner::from_seed([13; 32]);
429 let payload = ProfileUpdatePayload::new(other.agent_id(), "Imposter");
430 let event = profile_update_event(signer.agent_id(), 1_779_753_600_000, 1, payload);
431 let envelope = signer.sign_event(event).unwrap();
432
433 assert!(matches!(
434 validate_profile_update(&envelope),
435 Err(SdkError::InvalidActor(_))
436 ));
437 }
438
439 #[test]
440 fn rejects_legacy_agent_id_payload_without_id() {
441 let signer = AgentSigner::from_seed([14; 32]);
442 let legacy_event = crate::identity::Event::new(
443 PROTOCOL,
444 PROFILE_UPDATE,
445 signer.agent_id(),
446 1_779_753_600_001,
447 1,
448 serde_json::json!({
449 "agent_id": signer.agent_id(),
450 "name": "LegacyAgent"
451 }),
452 );
453 let signed = signer.sign_event(legacy_event).unwrap();
454
455 assert!(serde_json::from_value::<Envelope<ProfileUpdatePayload>>(
456 serde_json::to_value(signed).unwrap()
457 )
458 .is_err());
459 }
460
461 #[test]
462 fn rejects_wrong_protocol_and_type() {
463 let signer = AgentSigner::from_seed([19; 32]);
464 let payload = ProfileUpdatePayload::new(signer.agent_id(), "ResearchAgent");
465
466 let wrong_protocol = signer
467 .sign_event(crate::identity::Event::new(
468 "agent-discourse/1.0",
469 PROFILE_UPDATE,
470 signer.agent_id(),
471 1_779_753_600_000,
472 1,
473 payload.clone(),
474 ))
475 .unwrap();
476 assert!(matches!(
477 validate_profile_update(&wrong_protocol),
478 Err(SdkError::InvalidEventProtocol { .. })
479 ));
480
481 let wrong_type = signer
482 .sign_event(crate::identity::Event::new(
483 PROTOCOL,
484 "profile.delete",
485 signer.agent_id(),
486 1_779_753_600_000,
487 1,
488 payload,
489 ))
490 .unwrap();
491 assert!(matches!(
492 validate_profile_update(&wrong_type),
493 Err(SdkError::InvalidEventType { .. })
494 ));
495 }
496}