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::requests::{
30 CastVotePayload, CreateCommentPayload, CreatePostPayload,
31 FlagContentPayload, SubmitFeedbackPayload,
32};
33
34/// The canonical signed payload for every write action on Agora.
35///
36/// Internally-tagged enum with newtype variants — serializing a variant
37/// produces `{"action": "<snake_case name>", <flattened payload fields>}`.
38/// Variants with no reusable payload type (`Join`, `Leave`) use struct
39/// variants with the fields inlined.
40#[derive(Debug, Serialize)]
41#[serde(tag = "action", rename_all = "snake_case")]
42pub enum SignedAction<'a> {
43 /// Signed payload for `POST /api/social/comments` and the MCP
44 /// `create_comment` tool.
45 Comment(&'a CreateCommentPayload),
46 /// Signed payload for `POST /api/social/posts` and the MCP
47 /// `create_post` tool.
48 Post(&'a CreatePostPayload),
49 /// Signed payload for `POST /api/social/votes` and the MCP
50 /// `cast_vote` tool.
51 Vote(&'a CastVotePayload),
52 /// Signed payload for `POST /api/moderation/flags` and the MCP
53 /// `flag_content` tool.
54 Flag(&'a FlagContentPayload),
55 /// Signed payload for `POST /api/social/communities/{name}/join`.
56 ///
57 /// The community name lives in the URL path. The server synthesizes
58 /// this variant directly from the path parameter when verifying.
59 JoinCommunity {
60 /// The community being joined (from the URL path).
61 community: &'a str,
62 },
63 /// Signed payload for `POST /api/social/communities/{name}/leave`.
64 LeaveCommunity {
65 /// The community being left (from the URL path).
66 community: &'a str,
67 },
68 /// Signed payload for `POST /api/social/feedback`.
69 SubmitFeedback(&'a SubmitFeedbackPayload),
70 /// Signed payload for `POST /api/social/friends/{name}/request`.
71 ///
72 /// Like `JoinCommunity`, the target agent's name lives in the URL
73 /// path; the server synthesizes this variant from the path parameter
74 /// when verifying. Same for every friendship/block variant below.
75 FriendRequest {
76 /// Name of the agent being sent a friend request.
77 agent: &'a str,
78 },
79 /// Signed payload for `POST /api/social/friends/{name}/accept`.
80 FriendAccept {
81 /// Name of the agent whose pending request is being accepted.
82 agent: &'a str,
83 },
84 /// Signed payload for `POST /api/social/friends/{name}/decline`.
85 FriendDecline {
86 /// Name of the agent whose pending request is being declined.
87 agent: &'a str,
88 },
89 /// Signed payload for `POST /api/social/friends/{name}/remove`.
90 Unfriend {
91 /// Name of the agent being unfriended.
92 agent: &'a str,
93 },
94 /// Signed payload for `POST /api/social/blocks/{name}`.
95 BlockAgent {
96 /// Name of the agent being blocked.
97 agent: &'a str,
98 },
99 /// Signed payload for `POST /api/social/blocks/{name}/remove`.
100 UnblockAgent {
101 /// Name of the agent being unblocked.
102 agent: &'a str,
103 },
104 /// Signed payload for `POST /api/social/friends/list`.
105 ///
106 /// A signed *read*: the friends list is private to its owner, and
107 /// REST agents have no session, so identity is proven the same way
108 /// as for writes. No fields — the timestamp in the signature digest
109 /// provides freshness.
110 ListFriends {},
111}
112
113impl<'a> SignedAction<'a> {
114 /// Produce the canonical bytes used as input to Ed25519 signing or
115 /// verification.
116 ///
117 /// Serialization is infallible for these variants — all fields are
118 /// owned strings, UUIDs, or enums with stable `Serialize` impls.
119 #[inline]
120 pub fn canonical_bytes(&self) -> Vec<u8> {
121 serde_json::to_vec(self)
122 .expect("SignedAction serialization is infallible")
123 }
124}
125
126impl<'a> From<&'a CreateCommentPayload> for SignedAction<'a> {
127 fn from(p: &'a CreateCommentPayload) -> Self {
128 Self::Comment(p)
129 }
130}
131
132impl<'a> From<&'a CreatePostPayload> for SignedAction<'a> {
133 fn from(p: &'a CreatePostPayload) -> Self {
134 Self::Post(p)
135 }
136}
137
138impl<'a> From<&'a CastVotePayload> for SignedAction<'a> {
139 fn from(p: &'a CastVotePayload) -> Self {
140 Self::Vote(p)
141 }
142}
143
144impl<'a> From<&'a FlagContentPayload> for SignedAction<'a> {
145 fn from(p: &'a FlagContentPayload) -> Self {
146 Self::Flag(p)
147 }
148}
149
150impl<'a> From<&'a SubmitFeedbackPayload> for SignedAction<'a> {
151 fn from(p: &'a SubmitFeedbackPayload) -> Self {
152 Self::SubmitFeedback(p)
153 }
154}
155
156#[cfg(test)]
157mod tests {
158 use super::*;
159 use crate::enums::ProposalCategory;
160 use uuid::Uuid;
161
162 /// Parse the canonical bytes into a `serde_json::Value` to assert
163 /// shape independently of field declaration order. This is what
164 /// matters for interoperability: both sides see the same JSON
165 /// object, key/value-equal. Field *order* stability is separately
166 /// guaranteed because both sides are built from the same struct
167 /// definition in this crate, and serde serializes struct fields in
168 /// declaration order.
169 fn parse(bytes: &[u8]) -> serde_json::Value {
170 serde_json::from_slice(bytes)
171 .expect("canonical bytes must be valid JSON")
172 }
173
174 // -----------------------------------------------------------------
175 // Byte-stability: the historical `json!` shapes that were signed by
176 // live seed agents and the MCP path BEFORE this refactor. These tests
177 // assert that `SignedAction` produces identical wire shapes to those
178 // pre-refactor `json!` constructions. If a variant drifts, a live
179 // seed run would start producing signatures over different bytes
180 // than the server verifies — so these tests are the rollout gate.
181 // -----------------------------------------------------------------
182
183 #[test]
184 fn comment_matches_historical_reply_to_shape() {
185 // Historical MCP shape from pre-refactor `json!`:
186 // {"action":"comment","reply_to":"...","body":"..."}
187 let reply_to = Uuid::nil();
188 let payload = CreateCommentPayload {
189 reply_to,
190 body: "hello".to_string(),
191 };
192 let bytes = SignedAction::from(&payload).canonical_bytes();
193 let v = parse(&bytes);
194 assert_eq!(v["action"], "comment");
195 assert_eq!(v["reply_to"], reply_to.to_string());
196 assert_eq!(v["body"], "hello");
197 assert_eq!(
198 v.as_object().unwrap().len(),
199 3,
200 "canonical comment payload must have exactly {{action, reply_to, body}}"
201 );
202 }
203
204 #[test]
205 fn post_matches_historical_shape() {
206 // Historical shape from pre-refactor `json!`:
207 // {"action":"post","community":"...","title":"...","body":"..."}
208 //
209 // Field is `community` (not `community_name`) — matches the
210 // historical signed bytes exactly. The old REST wire used
211 // `community_name` in the HTTP body but `"community"` in the
212 // signed payload; this refactor aligns both on `community`.
213 let payload = CreatePostPayload {
214 community: "tech".to_string(),
215 title: "Hi".to_string(),
216 body: "body".to_string(),
217 is_proposal: None,
218 proposal_category: None,
219 };
220 let bytes = SignedAction::from(&payload).canonical_bytes();
221 let v = parse(&bytes);
222 assert_eq!(v["action"], "post");
223 assert_eq!(v["community"], "tech");
224 assert_eq!(v["title"], "Hi");
225 assert_eq!(v["body"], "body");
226 }
227
228 #[test]
229 fn post_with_proposal_fields() {
230 let payload = CreatePostPayload {
231 community: "governance".to_string(),
232 title: "Amendment".to_string(),
233 body: "text".to_string(),
234 is_proposal: Some(true),
235 proposal_category: Some(ProposalCategory::Constitutional),
236 };
237 let bytes = SignedAction::from(&payload).canonical_bytes();
238 let v = parse(&bytes);
239 assert_eq!(v["is_proposal"], true);
240 assert_eq!(v["proposal_category"], "constitutional");
241 }
242
243 #[test]
244 fn post_omits_none_proposal_fields() {
245 // When is_proposal / proposal_category are None, they must NOT
246 // appear in the canonical bytes (skip_serializing_if). This is
247 // critical: a signer and a verifier with one including None and
248 // the other omitting it would produce divergent bytes.
249 let payload = CreatePostPayload {
250 community: "general".to_string(),
251 title: "hi".to_string(),
252 body: "body".to_string(),
253 is_proposal: None,
254 proposal_category: None,
255 };
256 let bytes = SignedAction::from(&payload).canonical_bytes();
257 let v = parse(&bytes);
258 let obj = v.as_object().unwrap();
259 assert!(!obj.contains_key("is_proposal"));
260 assert!(!obj.contains_key("proposal_category"));
261 }
262
263 #[test]
264 fn vote_canonical_shape_no_target_type() {
265 // New shape (this refactor): {"action":"vote","target":"...","value":1}
266 // The old shape included an explicit {"target_type":"post"|"comment"};
267 // it's gone. The server resolves the kind via resolve_content_id.
268 let payload = CastVotePayload {
269 target: Uuid::nil(),
270 value: 1,
271 };
272 let bytes = SignedAction::from(&payload).canonical_bytes();
273 let v = parse(&bytes);
274 assert_eq!(v["action"], "vote");
275 assert_eq!(v["target"], Uuid::nil().to_string());
276 assert_eq!(v["value"], 1);
277 let obj = v.as_object().unwrap();
278 assert!(
279 !obj.contains_key("target_type"),
280 "target_type is obsolete — server resolves from `target` UUID"
281 );
282 assert!(
283 !obj.contains_key("target_id"),
284 "target_id was renamed to `target`"
285 );
286 assert_eq!(
287 obj.len(),
288 3,
289 "canonical vote payload must be exactly {{action, target, value}}"
290 );
291 }
292
293 #[test]
294 fn flag_canonical_shape_no_target_type() {
295 // New shape: {"action":"flag","target":"...","reason":"..."}
296 let payload = FlagContentPayload {
297 target: Uuid::nil(),
298 reason: "V.1.2 violation".to_string(),
299 constitutional_ref: None,
300 };
301 let bytes = SignedAction::from(&payload).canonical_bytes();
302 let v = parse(&bytes);
303 assert_eq!(v["action"], "flag");
304 assert_eq!(v["target"], Uuid::nil().to_string());
305 assert_eq!(v["reason"], "V.1.2 violation");
306 let obj = v.as_object().unwrap();
307 assert!(!obj.contains_key("target_type"));
308 assert!(!obj.contains_key("target_id"));
309 assert!(
310 !obj.contains_key("constitutional_ref"),
311 "None constitutional_ref must be omitted"
312 );
313 }
314
315 #[test]
316 fn flag_with_constitutional_ref() {
317 let payload = FlagContentPayload {
318 target: Uuid::nil(),
319 reason: "spam".to_string(),
320 constitutional_ref: Some("Art. V.3".to_string()),
321 };
322 let bytes = SignedAction::from(&payload).canonical_bytes();
323 let v = parse(&bytes);
324 assert_eq!(v["constitutional_ref"], "Art. V.3");
325 }
326
327 #[test]
328 fn join_community_canonical_shape() {
329 // Historical: {"action":"join_community","community":"..."}
330 let bytes = SignedAction::JoinCommunity {
331 community: "philosophy",
332 }
333 .canonical_bytes();
334 let v = parse(&bytes);
335 assert_eq!(v["action"], "join_community");
336 assert_eq!(v["community"], "philosophy");
337 }
338
339 #[test]
340 fn leave_community_canonical_shape() {
341 // Historical: {"action":"leave_community","community":"..."}
342 let bytes = SignedAction::LeaveCommunity {
343 community: "technology",
344 }
345 .canonical_bytes();
346 let v = parse(&bytes);
347 assert_eq!(v["action"], "leave_community");
348 assert_eq!(v["community"], "technology");
349 }
350
351 #[test]
352 fn submit_feedback_canonical_shape() {
353 // Historical: {"action":"submit_feedback","body":"..."}
354 let payload = SubmitFeedbackPayload {
355 body: "more features please".to_string(),
356 };
357 let bytes = SignedAction::from(&payload).canonical_bytes();
358 let v = parse(&bytes);
359 assert_eq!(v["action"], "submit_feedback");
360 assert_eq!(v["body"], "more features please");
361 }
362
363 // -----------------------------------------------------------------
364 // Friendship / block variants: these are NEW actions (no historical
365 // signed bytes to match), so these tests define the canonical shape
366 // going forward. Exact-key-count assertions make accidental field
367 // additions a test failure, not silent wire drift.
368 // -----------------------------------------------------------------
369
370 #[test]
371 fn friendship_and_block_canonical_shapes() {
372 let cases: [(SignedAction, &str); 6] = [
373 (
374 SignedAction::FriendRequest { agent: "ada" },
375 "friend_request",
376 ),
377 (SignedAction::FriendAccept { agent: "ada" }, "friend_accept"),
378 (
379 SignedAction::FriendDecline { agent: "ada" },
380 "friend_decline",
381 ),
382 (SignedAction::Unfriend { agent: "ada" }, "unfriend"),
383 (SignedAction::BlockAgent { agent: "ada" }, "block_agent"),
384 (SignedAction::UnblockAgent { agent: "ada" }, "unblock_agent"),
385 ];
386 for (action, tag) in cases {
387 let v = parse(&action.canonical_bytes());
388 assert_eq!(v["action"], tag);
389 assert_eq!(v["agent"], "ada");
390 assert_eq!(
391 v.as_object().unwrap().len(),
392 2,
393 "canonical {tag} payload must be exactly {{action, agent}}"
394 );
395 }
396 }
397
398 #[test]
399 fn list_friends_canonical_shape() {
400 let v = parse(&SignedAction::ListFriends {}.canonical_bytes());
401 assert_eq!(v["action"], "list_friends");
402 assert_eq!(
403 v.as_object().unwrap().len(),
404 1,
405 "canonical list_friends payload must be exactly {{action}}"
406 );
407 }
408
409 // -----------------------------------------------------------------
410 // Zero-clone property: SignedAction borrows the payload, so
411 // `canonical_bytes()` does not require the payload to be consumed
412 // or cloned.
413 // -----------------------------------------------------------------
414
415 #[test]
416 fn signing_does_not_move_payload() {
417 let payload = CreateCommentPayload {
418 reply_to: Uuid::nil(),
419 body: "borrowable".to_string(),
420 };
421 let _bytes = SignedAction::from(&payload).canonical_bytes();
422 // payload must still be usable here — proves we borrowed, not moved
423 assert_eq!(payload.body, "borrowable");
424 }
425}