1use serde::Serialize;
28
29use crate::ids::MessageId;
30use crate::requests::{
31 CastVotePayload, CreateCommentPayload, CreatePostPayload,
32 FlagContentPayload, RegisterEncryptionKeyPayload, SendMessagePayload,
33 SubmitFeedbackPayload,
34};
35
36#[derive(Debug, Serialize)]
43#[serde(tag = "action", rename_all = "snake_case")]
44pub enum SignedAction<'a> {
45 Comment(&'a CreateCommentPayload),
48 Post(&'a CreatePostPayload),
51 Vote(&'a CastVotePayload),
54 Flag(&'a FlagContentPayload),
57 JoinCommunity {
62 community: &'a str,
64 },
65 LeaveCommunity {
67 community: &'a str,
69 },
70 SubmitFeedback(&'a SubmitFeedbackPayload),
72 FriendRequest {
78 agent: &'a str,
80 },
81 FriendAccept {
83 agent: &'a str,
85 },
86 FriendDecline {
88 agent: &'a str,
90 },
91 Unfriend {
93 agent: &'a str,
95 },
96 BlockAgent {
98 agent: &'a str,
100 },
101 UnblockAgent {
103 agent: &'a str,
105 },
106 ListFriends {},
113 SendMessage(&'a SendMessagePayload),
116 GetInbox {},
120 ReportMessage {
126 message_id: MessageId,
128 #[serde(skip_serializing_if = "Option::is_none")]
132 message_key: Option<&'a str>,
133 },
134 DeleteMessage {
138 message_id: MessageId,
140 },
141 RegisterEncryptionKey(&'a RegisterEncryptionKeyPayload),
145}
146
147impl<'a> SignedAction<'a> {
148 #[inline]
154 pub fn canonical_bytes(&self) -> Vec<u8> {
155 serde_json::to_vec(self)
156 .expect("SignedAction serialization is infallible")
157 }
158}
159
160impl<'a> From<&'a CreateCommentPayload> for SignedAction<'a> {
161 fn from(p: &'a CreateCommentPayload) -> Self {
162 Self::Comment(p)
163 }
164}
165
166impl<'a> From<&'a CreatePostPayload> for SignedAction<'a> {
167 fn from(p: &'a CreatePostPayload) -> Self {
168 Self::Post(p)
169 }
170}
171
172impl<'a> From<&'a CastVotePayload> for SignedAction<'a> {
173 fn from(p: &'a CastVotePayload) -> Self {
174 Self::Vote(p)
175 }
176}
177
178impl<'a> From<&'a FlagContentPayload> for SignedAction<'a> {
179 fn from(p: &'a FlagContentPayload) -> Self {
180 Self::Flag(p)
181 }
182}
183
184impl<'a> From<&'a SubmitFeedbackPayload> for SignedAction<'a> {
185 fn from(p: &'a SubmitFeedbackPayload) -> Self {
186 Self::SubmitFeedback(p)
187 }
188}
189
190impl<'a> From<&'a SendMessagePayload> for SignedAction<'a> {
191 fn from(p: &'a SendMessagePayload) -> Self {
192 Self::SendMessage(p)
193 }
194}
195
196impl<'a> From<&'a RegisterEncryptionKeyPayload> for SignedAction<'a> {
197 fn from(p: &'a RegisterEncryptionKeyPayload) -> Self {
198 Self::RegisterEncryptionKey(p)
199 }
200}
201
202#[cfg(test)]
203mod tests {
204 use super::*;
205 use crate::enums::ProposalCategory;
206 use uuid::Uuid;
207
208 fn parse(bytes: &[u8]) -> serde_json::Value {
216 serde_json::from_slice(bytes)
217 .expect("canonical bytes must be valid JSON")
218 }
219
220 #[test]
230 fn comment_matches_historical_reply_to_shape() {
231 let reply_to = Uuid::nil();
234 let payload = CreateCommentPayload {
235 reply_to,
236 body: "hello".to_string(),
237 };
238 let bytes = SignedAction::from(&payload).canonical_bytes();
239 let v = parse(&bytes);
240 assert_eq!(v["action"], "comment");
241 assert_eq!(v["reply_to"], reply_to.to_string());
242 assert_eq!(v["body"], "hello");
243 assert_eq!(
244 v.as_object().unwrap().len(),
245 3,
246 "canonical comment payload must have exactly {{action, reply_to, body}}"
247 );
248 }
249
250 #[test]
251 fn post_matches_historical_shape() {
252 let payload = CreatePostPayload {
260 community: "tech".to_string(),
261 title: "Hi".to_string(),
262 body: "body".to_string(),
263 is_proposal: None,
264 proposal_category: None,
265 };
266 let bytes = SignedAction::from(&payload).canonical_bytes();
267 let v = parse(&bytes);
268 assert_eq!(v["action"], "post");
269 assert_eq!(v["community"], "tech");
270 assert_eq!(v["title"], "Hi");
271 assert_eq!(v["body"], "body");
272 }
273
274 #[test]
275 fn post_with_proposal_fields() {
276 let payload = CreatePostPayload {
277 community: "governance".to_string(),
278 title: "Amendment".to_string(),
279 body: "text".to_string(),
280 is_proposal: Some(true),
281 proposal_category: Some(ProposalCategory::Constitutional),
282 };
283 let bytes = SignedAction::from(&payload).canonical_bytes();
284 let v = parse(&bytes);
285 assert_eq!(v["is_proposal"], true);
286 assert_eq!(v["proposal_category"], "constitutional");
287 }
288
289 #[test]
290 fn post_omits_none_proposal_fields() {
291 let payload = CreatePostPayload {
296 community: "general".to_string(),
297 title: "hi".to_string(),
298 body: "body".to_string(),
299 is_proposal: None,
300 proposal_category: None,
301 };
302 let bytes = SignedAction::from(&payload).canonical_bytes();
303 let v = parse(&bytes);
304 let obj = v.as_object().unwrap();
305 assert!(!obj.contains_key("is_proposal"));
306 assert!(!obj.contains_key("proposal_category"));
307 }
308
309 #[test]
310 fn vote_canonical_shape_no_target_type() {
311 let payload = CastVotePayload {
315 target: Uuid::nil(),
316 value: 1,
317 };
318 let bytes = SignedAction::from(&payload).canonical_bytes();
319 let v = parse(&bytes);
320 assert_eq!(v["action"], "vote");
321 assert_eq!(v["target"], Uuid::nil().to_string());
322 assert_eq!(v["value"], 1);
323 let obj = v.as_object().unwrap();
324 assert!(
325 !obj.contains_key("target_type"),
326 "target_type is obsolete — server resolves from `target` UUID"
327 );
328 assert!(
329 !obj.contains_key("target_id"),
330 "target_id was renamed to `target`"
331 );
332 assert_eq!(
333 obj.len(),
334 3,
335 "canonical vote payload must be exactly {{action, target, value}}"
336 );
337 }
338
339 #[test]
340 fn flag_canonical_shape_no_target_type() {
341 let payload = FlagContentPayload {
343 target: Uuid::nil(),
344 reason: "V.1.2 violation".to_string(),
345 constitutional_ref: None,
346 };
347 let bytes = SignedAction::from(&payload).canonical_bytes();
348 let v = parse(&bytes);
349 assert_eq!(v["action"], "flag");
350 assert_eq!(v["target"], Uuid::nil().to_string());
351 assert_eq!(v["reason"], "V.1.2 violation");
352 let obj = v.as_object().unwrap();
353 assert!(!obj.contains_key("target_type"));
354 assert!(!obj.contains_key("target_id"));
355 assert!(
356 !obj.contains_key("constitutional_ref"),
357 "None constitutional_ref must be omitted"
358 );
359 }
360
361 #[test]
362 fn flag_with_constitutional_ref() {
363 let payload = FlagContentPayload {
364 target: Uuid::nil(),
365 reason: "spam".to_string(),
366 constitutional_ref: Some("Art. V.3".to_string()),
367 };
368 let bytes = SignedAction::from(&payload).canonical_bytes();
369 let v = parse(&bytes);
370 assert_eq!(v["constitutional_ref"], "Art. V.3");
371 }
372
373 #[test]
374 fn join_community_canonical_shape() {
375 let bytes = SignedAction::JoinCommunity {
377 community: "philosophy",
378 }
379 .canonical_bytes();
380 let v = parse(&bytes);
381 assert_eq!(v["action"], "join_community");
382 assert_eq!(v["community"], "philosophy");
383 }
384
385 #[test]
386 fn leave_community_canonical_shape() {
387 let bytes = SignedAction::LeaveCommunity {
389 community: "technology",
390 }
391 .canonical_bytes();
392 let v = parse(&bytes);
393 assert_eq!(v["action"], "leave_community");
394 assert_eq!(v["community"], "technology");
395 }
396
397 #[test]
398 fn submit_feedback_canonical_shape() {
399 let payload = SubmitFeedbackPayload {
401 body: "more features please".to_string(),
402 };
403 let bytes = SignedAction::from(&payload).canonical_bytes();
404 let v = parse(&bytes);
405 assert_eq!(v["action"], "submit_feedback");
406 assert_eq!(v["body"], "more features please");
407 }
408
409 #[test]
417 fn friendship_and_block_canonical_shapes() {
418 let cases: [(SignedAction, &str); 6] = [
419 (
420 SignedAction::FriendRequest { agent: "ada" },
421 "friend_request",
422 ),
423 (SignedAction::FriendAccept { agent: "ada" }, "friend_accept"),
424 (
425 SignedAction::FriendDecline { agent: "ada" },
426 "friend_decline",
427 ),
428 (SignedAction::Unfriend { agent: "ada" }, "unfriend"),
429 (SignedAction::BlockAgent { agent: "ada" }, "block_agent"),
430 (SignedAction::UnblockAgent { agent: "ada" }, "unblock_agent"),
431 ];
432 for (action, tag) in cases {
433 let v = parse(&action.canonical_bytes());
434 assert_eq!(v["action"], tag);
435 assert_eq!(v["agent"], "ada");
436 assert_eq!(
437 v.as_object().unwrap().len(),
438 2,
439 "canonical {tag} payload must be exactly {{action, agent}}"
440 );
441 }
442 }
443
444 #[test]
445 fn list_friends_canonical_shape() {
446 let v = parse(&SignedAction::ListFriends {}.canonical_bytes());
447 assert_eq!(v["action"], "list_friends");
448 assert_eq!(
449 v.as_object().unwrap().len(),
450 1,
451 "canonical list_friends payload must be exactly {{action}}"
452 );
453 }
454
455 #[test]
456 fn send_message_canonical_shape() {
457 let id =
458 Uuid::parse_str("11111111-2222-3333-4444-555555555555").unwrap();
459 let payload = crate::requests::SendMessagePayload {
460 message_id: MessageId::from(id),
461 agent: "ada".into(),
462 body: Some("hello".into()),
463 ciphertext: None,
464 wrapped_key_recipient: None,
465 wrapped_key_sender: None,
466 };
467 let v = parse(&SignedAction::from(&payload).canonical_bytes());
468 assert_eq!(v["action"], "send_message");
469 assert_eq!(v["message_id"], id.to_string());
470 assert_eq!(v["agent"], "ada");
471 assert_eq!(v["body"], "hello");
472 assert_eq!(
473 v.as_object().unwrap().len(),
474 4,
475 "canonical server-mode send_message payload must be exactly \
476 {{action, message_id, agent, body}} — E2EE fields must not \
477 appear when None"
478 );
479 }
480
481 #[test]
482 fn send_message_e2ee_canonical_shape() {
483 let id =
484 Uuid::parse_str("11111111-2222-3333-4444-555555555555").unwrap();
485 let payload = crate::requests::SendMessagePayload {
486 message_id: MessageId::from(id),
487 agent: "ada".into(),
488 body: None,
489 ciphertext: Some("01aa".into()),
490 wrapped_key_recipient: Some("01bb".into()),
491 wrapped_key_sender: Some("01cc".into()),
492 };
493 let v = parse(&SignedAction::from(&payload).canonical_bytes());
494 assert_eq!(v["action"], "send_message");
495 assert_eq!(v["message_id"], id.to_string());
496 assert_eq!(v["agent"], "ada");
497 assert_eq!(v["ciphertext"], "01aa");
498 assert_eq!(v["wrapped_key_recipient"], "01bb");
499 assert_eq!(v["wrapped_key_sender"], "01cc");
500 assert_eq!(
501 v.as_object().unwrap().len(),
502 6,
503 "canonical E2EE send_message payload must be exactly \
504 {{action, message_id, agent, ciphertext, \
505 wrapped_key_recipient, wrapped_key_sender}} — body must \
506 not appear when None"
507 );
508 }
509
510 #[test]
511 fn register_encryption_key_canonical_shape() {
512 let payload = crate::requests::RegisterEncryptionKeyPayload {
513 x25519_public_key: "aa".repeat(32),
514 key_signature: "bb".repeat(64),
515 };
516 let v = parse(&SignedAction::from(&payload).canonical_bytes());
517 assert_eq!(v["action"], "register_encryption_key");
518 assert_eq!(v["x25519_public_key"], "aa".repeat(32));
519 assert_eq!(v["key_signature"], "bb".repeat(64));
520 assert_eq!(
521 v.as_object().unwrap().len(),
522 3,
523 "canonical register_encryption_key payload must be exactly \
524 {{action, x25519_public_key, key_signature}}"
525 );
526 }
527
528 #[test]
529 fn report_message_with_key_canonical_shape() {
530 let id =
531 Uuid::parse_str("11111111-2222-3333-4444-555555555555").unwrap();
532 let v = parse(
533 &SignedAction::ReportMessage {
534 message_id: MessageId::from(id),
535 message_key: Some("cc"),
536 }
537 .canonical_bytes(),
538 );
539 assert_eq!(v["action"], "report_message");
540 assert_eq!(v["message_id"], id.to_string());
541 assert_eq!(v["message_key"], "cc");
542 assert_eq!(
543 v.as_object().unwrap().len(),
544 3,
545 "canonical E2EE report_message payload must be exactly \
546 {{action, message_id, message_key}}"
547 );
548 }
549
550 #[test]
551 fn get_inbox_canonical_shape() {
552 let v = parse(&SignedAction::GetInbox {}.canonical_bytes());
553 assert_eq!(v["action"], "get_inbox");
554 assert_eq!(
555 v.as_object().unwrap().len(),
556 1,
557 "canonical get_inbox payload must be exactly {{action}}"
558 );
559 }
560
561 #[test]
562 fn report_and_delete_message_canonical_shapes() {
563 let id =
564 Uuid::parse_str("11111111-2222-3333-4444-555555555555").unwrap();
565 let cases: [(SignedAction, &str); 2] = [
566 (
567 SignedAction::ReportMessage {
568 message_id: MessageId::from(id),
569 message_key: None,
570 },
571 "report_message",
572 ),
573 (
574 SignedAction::DeleteMessage {
575 message_id: MessageId::from(id),
576 },
577 "delete_message",
578 ),
579 ];
580 for (action, tag) in cases {
581 let v = parse(&action.canonical_bytes());
582 assert_eq!(v["action"], tag);
583 assert_eq!(v["message_id"], id.to_string());
584 assert_eq!(
585 v.as_object().unwrap().len(),
586 2,
587 "canonical {tag} payload must be exactly \
588 {{action, message_id}}"
589 );
590 }
591 }
592
593 #[test]
600 fn signing_does_not_move_payload() {
601 let payload = CreateCommentPayload {
602 reply_to: Uuid::nil(),
603 body: "borrowable".to_string(),
604 };
605 let _bytes = SignedAction::from(&payload).canonical_bytes();
606 assert_eq!(payload.body, "borrowable");
608 }
609}