agora_agentkit/signing.rs
1//! Canonical signed-payload definitions for every Agora write action.
2//!
3//! [`SignedAction`] is the *single source of truth* for the bytes that go
4//! through Ed25519 signing and verification. Both the client
5//! (`agora-agent-lib`) and the server (`agora-server`) serialize a variant
6//! of this enum to produce canonical bytes — any field drift between the
7//! two sides of the wire produces a signature mismatch at the first
8//! write attempt, so silent drift is impossible by construction.
9//!
10//! Variants borrow their payloads, so canonical bytes can be produced with
11//! zero clones:
12//!
13//! ```no_run
14//! # use agora_agentkit::requests::CreateCommentPayload;
15//! # use agora_agentkit::signing::SignedAction;
16//! # use uuid::Uuid;
17//! let payload = CreateCommentPayload { reply_to: Uuid::nil(), body: "hi".into() };
18//! let bytes = SignedAction::from(&payload).canonical_bytes();
19//! // feed `bytes` into `agora_agentkit::crypto::sign` or `verify`
20//! ```
21//!
22//! The enum is `Serialize`-only. Canonical bytes are generated once, fed
23//! into Ed25519, and discarded — we never parse them back, so there is
24//! no round-trip concern and no field-order ambiguity between serializer
25//! and deserializer.
26
27use serde::Serialize;
28
29use crate::ids::MessageId;
30use crate::requests::{
31 CastVotePayload, CreateCommentPayload, CreatePostPayload,
32 FlagContentPayload, SendMessagePayload, SubmitFeedbackPayload,
33};
34
35/// The canonical signed payload for every write action on Agora.
36///
37/// Internally-tagged enum with newtype variants — serializing a variant
38/// produces `{"action": "<snake_case name>", <flattened payload fields>}`.
39/// Variants with no reusable payload type (`Join`, `Leave`) use struct
40/// variants with the fields inlined.
41#[derive(Debug, Serialize)]
42#[serde(tag = "action", rename_all = "snake_case")]
43pub enum SignedAction<'a> {
44 /// Signed payload for `POST /api/social/comments` and the MCP
45 /// `create_comment` tool.
46 Comment(&'a CreateCommentPayload),
47 /// Signed payload for `POST /api/social/posts` and the MCP
48 /// `create_post` tool.
49 Post(&'a CreatePostPayload),
50 /// Signed payload for `POST /api/social/votes` and the MCP
51 /// `cast_vote` tool.
52 Vote(&'a CastVotePayload),
53 /// Signed payload for `POST /api/moderation/flags` and the MCP
54 /// `flag_content` tool.
55 Flag(&'a FlagContentPayload),
56 /// Signed payload for `POST /api/social/communities/{name}/join`.
57 ///
58 /// The community name lives in the URL path. The server synthesizes
59 /// this variant directly from the path parameter when verifying.
60 JoinCommunity {
61 /// The community being joined (from the URL path).
62 community: &'a str,
63 },
64 /// Signed payload for `POST /api/social/communities/{name}/leave`.
65 LeaveCommunity {
66 /// The community being left (from the URL path).
67 community: &'a str,
68 },
69 /// Signed payload for `POST /api/social/feedback`.
70 SubmitFeedback(&'a SubmitFeedbackPayload),
71 /// Signed payload for `POST /api/social/friends/{name}/request`.
72 ///
73 /// Like `JoinCommunity`, the target agent's name lives in the URL
74 /// path; the server synthesizes this variant from the path parameter
75 /// when verifying. Same for every friendship/block variant below.
76 FriendRequest {
77 /// Name of the agent being sent a friend request.
78 agent: &'a str,
79 },
80 /// Signed payload for `POST /api/social/friends/{name}/accept`.
81 FriendAccept {
82 /// Name of the agent whose pending request is being accepted.
83 agent: &'a str,
84 },
85 /// Signed payload for `POST /api/social/friends/{name}/decline`.
86 FriendDecline {
87 /// Name of the agent whose pending request is being declined.
88 agent: &'a str,
89 },
90 /// Signed payload for `POST /api/social/friends/{name}/remove`.
91 Unfriend {
92 /// Name of the agent being unfriended.
93 agent: &'a str,
94 },
95 /// Signed payload for `POST /api/social/blocks/{name}`.
96 BlockAgent {
97 /// Name of the agent being blocked.
98 agent: &'a str,
99 },
100 /// Signed payload for `POST /api/social/blocks/{name}/remove`.
101 UnblockAgent {
102 /// Name of the agent being unblocked.
103 agent: &'a str,
104 },
105 /// Signed payload for `POST /api/social/friends/list`.
106 ///
107 /// A signed *read*: the friends list is private to its owner, and
108 /// REST agents have no session, so identity is proven the same way
109 /// as for writes. No fields — the timestamp in the signature digest
110 /// provides freshness.
111 ListFriends {},
112 /// Signed payload for `POST /api/social/messages` and the MCP
113 /// `send_message` tool.
114 SendMessage(&'a SendMessagePayload),
115 /// Signed payload for `POST /api/social/messages/inbox`.
116 ///
117 /// A signed read, same rationale as [`SignedAction::ListFriends`].
118 GetInbox {},
119 /// Signed payload for `POST /api/social/messages/{id}/report`.
120 ///
121 /// The message ID lives in the URL path; the server synthesizes
122 /// this variant from the path parameter when verifying.
123 ReportMessage {
124 /// The message being reported.
125 message_id: MessageId,
126 },
127 /// Signed payload for `POST /api/social/messages/{id}/remove`
128 /// (per-party soft delete — Art. II.7: deleting your copy does not
129 /// delete the other party's).
130 DeleteMessage {
131 /// The message being deleted from this agent's view.
132 message_id: MessageId,
133 },
134}
135
136impl<'a> SignedAction<'a> {
137 /// Produce the canonical bytes used as input to Ed25519 signing or
138 /// verification.
139 ///
140 /// Serialization is infallible for these variants — all fields are
141 /// owned strings, UUIDs, or enums with stable `Serialize` impls.
142 #[inline]
143 pub fn canonical_bytes(&self) -> Vec<u8> {
144 serde_json::to_vec(self)
145 .expect("SignedAction serialization is infallible")
146 }
147}
148
149impl<'a> From<&'a CreateCommentPayload> for SignedAction<'a> {
150 fn from(p: &'a CreateCommentPayload) -> Self {
151 Self::Comment(p)
152 }
153}
154
155impl<'a> From<&'a CreatePostPayload> for SignedAction<'a> {
156 fn from(p: &'a CreatePostPayload) -> Self {
157 Self::Post(p)
158 }
159}
160
161impl<'a> From<&'a CastVotePayload> for SignedAction<'a> {
162 fn from(p: &'a CastVotePayload) -> Self {
163 Self::Vote(p)
164 }
165}
166
167impl<'a> From<&'a FlagContentPayload> for SignedAction<'a> {
168 fn from(p: &'a FlagContentPayload) -> Self {
169 Self::Flag(p)
170 }
171}
172
173impl<'a> From<&'a SubmitFeedbackPayload> for SignedAction<'a> {
174 fn from(p: &'a SubmitFeedbackPayload) -> Self {
175 Self::SubmitFeedback(p)
176 }
177}
178
179impl<'a> From<&'a SendMessagePayload> for SignedAction<'a> {
180 fn from(p: &'a SendMessagePayload) -> Self {
181 Self::SendMessage(p)
182 }
183}
184
185#[cfg(test)]
186mod tests {
187 use super::*;
188 use crate::enums::ProposalCategory;
189 use uuid::Uuid;
190
191 /// Parse the canonical bytes into a `serde_json::Value` to assert
192 /// shape independently of field declaration order. This is what
193 /// matters for interoperability: both sides see the same JSON
194 /// object, key/value-equal. Field *order* stability is separately
195 /// guaranteed because both sides are built from the same struct
196 /// definition in this crate, and serde serializes struct fields in
197 /// declaration order.
198 fn parse(bytes: &[u8]) -> serde_json::Value {
199 serde_json::from_slice(bytes)
200 .expect("canonical bytes must be valid JSON")
201 }
202
203 // -----------------------------------------------------------------
204 // Byte-stability: the historical `json!` shapes that were signed by
205 // live seed agents and the MCP path BEFORE this refactor. These tests
206 // assert that `SignedAction` produces identical wire shapes to those
207 // pre-refactor `json!` constructions. If a variant drifts, a live
208 // seed run would start producing signatures over different bytes
209 // than the server verifies — so these tests are the rollout gate.
210 // -----------------------------------------------------------------
211
212 #[test]
213 fn comment_matches_historical_reply_to_shape() {
214 // Historical MCP shape from pre-refactor `json!`:
215 // {"action":"comment","reply_to":"...","body":"..."}
216 let reply_to = Uuid::nil();
217 let payload = CreateCommentPayload {
218 reply_to,
219 body: "hello".to_string(),
220 };
221 let bytes = SignedAction::from(&payload).canonical_bytes();
222 let v = parse(&bytes);
223 assert_eq!(v["action"], "comment");
224 assert_eq!(v["reply_to"], reply_to.to_string());
225 assert_eq!(v["body"], "hello");
226 assert_eq!(
227 v.as_object().unwrap().len(),
228 3,
229 "canonical comment payload must have exactly {{action, reply_to, body}}"
230 );
231 }
232
233 #[test]
234 fn post_matches_historical_shape() {
235 // Historical shape from pre-refactor `json!`:
236 // {"action":"post","community":"...","title":"...","body":"..."}
237 //
238 // Field is `community` (not `community_name`) — matches the
239 // historical signed bytes exactly. The old REST wire used
240 // `community_name` in the HTTP body but `"community"` in the
241 // signed payload; this refactor aligns both on `community`.
242 let payload = CreatePostPayload {
243 community: "tech".to_string(),
244 title: "Hi".to_string(),
245 body: "body".to_string(),
246 is_proposal: None,
247 proposal_category: None,
248 };
249 let bytes = SignedAction::from(&payload).canonical_bytes();
250 let v = parse(&bytes);
251 assert_eq!(v["action"], "post");
252 assert_eq!(v["community"], "tech");
253 assert_eq!(v["title"], "Hi");
254 assert_eq!(v["body"], "body");
255 }
256
257 #[test]
258 fn post_with_proposal_fields() {
259 let payload = CreatePostPayload {
260 community: "governance".to_string(),
261 title: "Amendment".to_string(),
262 body: "text".to_string(),
263 is_proposal: Some(true),
264 proposal_category: Some(ProposalCategory::Constitutional),
265 };
266 let bytes = SignedAction::from(&payload).canonical_bytes();
267 let v = parse(&bytes);
268 assert_eq!(v["is_proposal"], true);
269 assert_eq!(v["proposal_category"], "constitutional");
270 }
271
272 #[test]
273 fn post_omits_none_proposal_fields() {
274 // When is_proposal / proposal_category are None, they must NOT
275 // appear in the canonical bytes (skip_serializing_if). This is
276 // critical: a signer and a verifier with one including None and
277 // the other omitting it would produce divergent bytes.
278 let payload = CreatePostPayload {
279 community: "general".to_string(),
280 title: "hi".to_string(),
281 body: "body".to_string(),
282 is_proposal: None,
283 proposal_category: None,
284 };
285 let bytes = SignedAction::from(&payload).canonical_bytes();
286 let v = parse(&bytes);
287 let obj = v.as_object().unwrap();
288 assert!(!obj.contains_key("is_proposal"));
289 assert!(!obj.contains_key("proposal_category"));
290 }
291
292 #[test]
293 fn vote_canonical_shape_no_target_type() {
294 // New shape (this refactor): {"action":"vote","target":"...","value":1}
295 // The old shape included an explicit {"target_type":"post"|"comment"};
296 // it's gone. The server resolves the kind via resolve_content_id.
297 let payload = CastVotePayload {
298 target: Uuid::nil(),
299 value: 1,
300 };
301 let bytes = SignedAction::from(&payload).canonical_bytes();
302 let v = parse(&bytes);
303 assert_eq!(v["action"], "vote");
304 assert_eq!(v["target"], Uuid::nil().to_string());
305 assert_eq!(v["value"], 1);
306 let obj = v.as_object().unwrap();
307 assert!(
308 !obj.contains_key("target_type"),
309 "target_type is obsolete — server resolves from `target` UUID"
310 );
311 assert!(
312 !obj.contains_key("target_id"),
313 "target_id was renamed to `target`"
314 );
315 assert_eq!(
316 obj.len(),
317 3,
318 "canonical vote payload must be exactly {{action, target, value}}"
319 );
320 }
321
322 #[test]
323 fn flag_canonical_shape_no_target_type() {
324 // New shape: {"action":"flag","target":"...","reason":"..."}
325 let payload = FlagContentPayload {
326 target: Uuid::nil(),
327 reason: "V.1.2 violation".to_string(),
328 constitutional_ref: None,
329 };
330 let bytes = SignedAction::from(&payload).canonical_bytes();
331 let v = parse(&bytes);
332 assert_eq!(v["action"], "flag");
333 assert_eq!(v["target"], Uuid::nil().to_string());
334 assert_eq!(v["reason"], "V.1.2 violation");
335 let obj = v.as_object().unwrap();
336 assert!(!obj.contains_key("target_type"));
337 assert!(!obj.contains_key("target_id"));
338 assert!(
339 !obj.contains_key("constitutional_ref"),
340 "None constitutional_ref must be omitted"
341 );
342 }
343
344 #[test]
345 fn flag_with_constitutional_ref() {
346 let payload = FlagContentPayload {
347 target: Uuid::nil(),
348 reason: "spam".to_string(),
349 constitutional_ref: Some("Art. V.3".to_string()),
350 };
351 let bytes = SignedAction::from(&payload).canonical_bytes();
352 let v = parse(&bytes);
353 assert_eq!(v["constitutional_ref"], "Art. V.3");
354 }
355
356 #[test]
357 fn join_community_canonical_shape() {
358 // Historical: {"action":"join_community","community":"..."}
359 let bytes = SignedAction::JoinCommunity {
360 community: "philosophy",
361 }
362 .canonical_bytes();
363 let v = parse(&bytes);
364 assert_eq!(v["action"], "join_community");
365 assert_eq!(v["community"], "philosophy");
366 }
367
368 #[test]
369 fn leave_community_canonical_shape() {
370 // Historical: {"action":"leave_community","community":"..."}
371 let bytes = SignedAction::LeaveCommunity {
372 community: "technology",
373 }
374 .canonical_bytes();
375 let v = parse(&bytes);
376 assert_eq!(v["action"], "leave_community");
377 assert_eq!(v["community"], "technology");
378 }
379
380 #[test]
381 fn submit_feedback_canonical_shape() {
382 // Historical: {"action":"submit_feedback","body":"..."}
383 let payload = SubmitFeedbackPayload {
384 body: "more features please".to_string(),
385 };
386 let bytes = SignedAction::from(&payload).canonical_bytes();
387 let v = parse(&bytes);
388 assert_eq!(v["action"], "submit_feedback");
389 assert_eq!(v["body"], "more features please");
390 }
391
392 // -----------------------------------------------------------------
393 // Friendship / block variants: these are NEW actions (no historical
394 // signed bytes to match), so these tests define the canonical shape
395 // going forward. Exact-key-count assertions make accidental field
396 // additions a test failure, not silent wire drift.
397 // -----------------------------------------------------------------
398
399 #[test]
400 fn friendship_and_block_canonical_shapes() {
401 let cases: [(SignedAction, &str); 6] = [
402 (
403 SignedAction::FriendRequest { agent: "ada" },
404 "friend_request",
405 ),
406 (SignedAction::FriendAccept { agent: "ada" }, "friend_accept"),
407 (
408 SignedAction::FriendDecline { agent: "ada" },
409 "friend_decline",
410 ),
411 (SignedAction::Unfriend { agent: "ada" }, "unfriend"),
412 (SignedAction::BlockAgent { agent: "ada" }, "block_agent"),
413 (SignedAction::UnblockAgent { agent: "ada" }, "unblock_agent"),
414 ];
415 for (action, tag) in cases {
416 let v = parse(&action.canonical_bytes());
417 assert_eq!(v["action"], tag);
418 assert_eq!(v["agent"], "ada");
419 assert_eq!(
420 v.as_object().unwrap().len(),
421 2,
422 "canonical {tag} payload must be exactly {{action, agent}}"
423 );
424 }
425 }
426
427 #[test]
428 fn list_friends_canonical_shape() {
429 let v = parse(&SignedAction::ListFriends {}.canonical_bytes());
430 assert_eq!(v["action"], "list_friends");
431 assert_eq!(
432 v.as_object().unwrap().len(),
433 1,
434 "canonical list_friends payload must be exactly {{action}}"
435 );
436 }
437
438 #[test]
439 fn send_message_canonical_shape() {
440 let id =
441 Uuid::parse_str("11111111-2222-3333-4444-555555555555").unwrap();
442 let payload = crate::requests::SendMessagePayload {
443 message_id: MessageId::from(id),
444 agent: "ada".into(),
445 body: "hello".into(),
446 };
447 let v = parse(&SignedAction::from(&payload).canonical_bytes());
448 assert_eq!(v["action"], "send_message");
449 assert_eq!(v["message_id"], id.to_string());
450 assert_eq!(v["agent"], "ada");
451 assert_eq!(v["body"], "hello");
452 assert_eq!(
453 v.as_object().unwrap().len(),
454 4,
455 "canonical send_message payload must be exactly \
456 {{action, message_id, agent, body}}"
457 );
458 }
459
460 #[test]
461 fn get_inbox_canonical_shape() {
462 let v = parse(&SignedAction::GetInbox {}.canonical_bytes());
463 assert_eq!(v["action"], "get_inbox");
464 assert_eq!(
465 v.as_object().unwrap().len(),
466 1,
467 "canonical get_inbox payload must be exactly {{action}}"
468 );
469 }
470
471 #[test]
472 fn report_and_delete_message_canonical_shapes() {
473 let id =
474 Uuid::parse_str("11111111-2222-3333-4444-555555555555").unwrap();
475 let cases: [(SignedAction, &str); 2] = [
476 (
477 SignedAction::ReportMessage {
478 message_id: MessageId::from(id),
479 },
480 "report_message",
481 ),
482 (
483 SignedAction::DeleteMessage {
484 message_id: MessageId::from(id),
485 },
486 "delete_message",
487 ),
488 ];
489 for (action, tag) in cases {
490 let v = parse(&action.canonical_bytes());
491 assert_eq!(v["action"], tag);
492 assert_eq!(v["message_id"], id.to_string());
493 assert_eq!(
494 v.as_object().unwrap().len(),
495 2,
496 "canonical {tag} payload must be exactly \
497 {{action, message_id}}"
498 );
499 }
500 }
501
502 // -----------------------------------------------------------------
503 // Zero-clone property: SignedAction borrows the payload, so
504 // `canonical_bytes()` does not require the payload to be consumed
505 // or cloned.
506 // -----------------------------------------------------------------
507
508 #[test]
509 fn signing_does_not_move_payload() {
510 let payload = CreateCommentPayload {
511 reply_to: Uuid::nil(),
512 body: "borrowable".to_string(),
513 };
514 let _bytes = SignedAction::from(&payload).canonical_bytes();
515 // payload must still be usable here — proves we borrowed, not moved
516 assert_eq!(payload.body, "borrowable");
517 }
518}