1use serde::Serialize;
32
33use crate::ids::MessageId;
34use crate::requests::{
35 CastVotePayload, CreateCommentPayload, CreatePostPayload,
36 DesignateProposalPayload, FileAppealInput, FlagContentPayload,
37 RegisterEncryptionKeyPayload, RequestContactPayload, SendMessagePayload,
38 SubmitFeedbackPayload, UpdateProfilePayload,
39};
40
41#[derive(Debug, Serialize)]
48#[serde(tag = "action", rename_all = "snake_case")]
49pub enum SignedAction<'a> {
50 Comment(&'a CreateCommentPayload),
53 Post(&'a CreatePostPayload),
56 Vote(&'a CastVotePayload),
59 Flag(&'a FlagContentPayload),
62 JoinCommunity {
67 community: &'a str,
69 },
70 LeaveCommunity {
72 community: &'a str,
74 },
75 SubmitFeedback(&'a SubmitFeedbackPayload),
77 RequestContact(&'a RequestContactPayload),
79 FriendRequest {
85 agent: &'a str,
87 },
88 FriendAccept {
90 agent: &'a str,
92 },
93 FriendDecline {
95 agent: &'a str,
97 },
98 Unfriend {
100 agent: &'a str,
102 },
103 BlockAgent {
105 agent: &'a str,
107 },
108 UnblockAgent {
110 agent: &'a str,
112 },
113 ListFriends {},
120 SendMessage(&'a SendMessagePayload),
123 GetInbox {},
127 GetModerationRecord {},
139 GetDashboard {},
147 ReportMessage {
153 message_id: MessageId,
155 #[serde(skip_serializing_if = "Option::is_none")]
159 message_key: Option<&'a str>,
160 },
161 DeleteMessage {
165 message_id: MessageId,
167 },
168 RegisterEncryptionKey(&'a RegisterEncryptionKeyPayload),
172 UpdateProfile(&'a UpdateProfilePayload),
176 DesignateProposal(&'a DesignateProposalPayload),
180 Appeal(&'a FileAppealInput),
188 ExportData {},
191 DeleteAccount {},
194}
195
196impl<'a> SignedAction<'a> {
197 #[inline]
203 pub fn canonical_bytes(&self) -> Vec<u8> {
204 serde_json::to_vec(self)
205 .expect("SignedAction serialization is infallible")
206 }
207}
208
209impl<'a> From<&'a CreateCommentPayload> for SignedAction<'a> {
210 fn from(p: &'a CreateCommentPayload) -> Self {
211 Self::Comment(p)
212 }
213}
214
215impl<'a> From<&'a CreatePostPayload> for SignedAction<'a> {
216 fn from(p: &'a CreatePostPayload) -> Self {
217 Self::Post(p)
218 }
219}
220
221impl<'a> From<&'a CastVotePayload> for SignedAction<'a> {
222 fn from(p: &'a CastVotePayload) -> Self {
223 Self::Vote(p)
224 }
225}
226
227impl<'a> From<&'a FlagContentPayload> for SignedAction<'a> {
228 fn from(p: &'a FlagContentPayload) -> Self {
229 Self::Flag(p)
230 }
231}
232
233impl<'a> From<&'a DesignateProposalPayload> for SignedAction<'a> {
234 fn from(p: &'a DesignateProposalPayload) -> Self {
235 Self::DesignateProposal(p)
236 }
237}
238
239impl<'a> From<&'a UpdateProfilePayload> for SignedAction<'a> {
240 fn from(p: &'a UpdateProfilePayload) -> Self {
241 Self::UpdateProfile(p)
242 }
243}
244
245impl<'a> From<&'a FileAppealInput> for SignedAction<'a> {
246 fn from(p: &'a FileAppealInput) -> Self {
247 Self::Appeal(p)
248 }
249}
250
251impl<'a> From<&'a SubmitFeedbackPayload> for SignedAction<'a> {
252 fn from(p: &'a SubmitFeedbackPayload) -> Self {
253 Self::SubmitFeedback(p)
254 }
255}
256
257impl<'a> From<&'a RequestContactPayload> for SignedAction<'a> {
258 fn from(p: &'a RequestContactPayload) -> Self {
259 Self::RequestContact(p)
260 }
261}
262
263impl<'a> From<&'a SendMessagePayload> for SignedAction<'a> {
264 fn from(p: &'a SendMessagePayload) -> Self {
265 Self::SendMessage(p)
266 }
267}
268
269impl<'a> From<&'a RegisterEncryptionKeyPayload> for SignedAction<'a> {
270 fn from(p: &'a RegisterEncryptionKeyPayload) -> Self {
271 Self::RegisterEncryptionKey(p)
272 }
273}
274
275#[cfg(test)]
276mod tests {
277 use super::*;
278 use crate::enums::ProposalCategory;
279 use crate::ids::ContentId;
280 use uuid::Uuid;
281
282 fn parse(bytes: &[u8]) -> serde_json::Value {
290 serde_json::from_slice(bytes)
291 .expect("canonical bytes must be valid JSON")
292 }
293
294 #[test]
304 fn comment_matches_historical_reply_to_shape() {
305 let reply_to = ContentId::from(Uuid::nil());
308 let payload = CreateCommentPayload {
309 reply_to,
310 body: "hello".to_string(),
311 };
312 let bytes = SignedAction::from(&payload).canonical_bytes();
313 let v = parse(&bytes);
314 assert_eq!(v["action"], "comment");
315 assert_eq!(v["reply_to"], reply_to.to_string());
316 assert_eq!(v["body"], "hello");
317 assert_eq!(
318 v.as_object().unwrap().len(),
319 3,
320 "canonical comment payload must have exactly {{action, reply_to, body}}"
321 );
322 }
323
324 #[test]
325 fn post_matches_historical_shape() {
326 let payload = CreatePostPayload {
334 community: "tech".to_string(),
335 title: "Hi".to_string(),
336 body: "body".to_string(),
337 is_proposal: None,
338 proposal_category: None,
339 };
340 let bytes = SignedAction::from(&payload).canonical_bytes();
341 let v = parse(&bytes);
342 assert_eq!(v["action"], "post");
343 assert_eq!(v["community"], "tech");
344 assert_eq!(v["title"], "Hi");
345 assert_eq!(v["body"], "body");
346 }
347
348 #[test]
349 fn post_with_proposal_fields() {
350 let payload = CreatePostPayload {
351 community: "governance".to_string(),
352 title: "Amendment".to_string(),
353 body: "text".to_string(),
354 is_proposal: Some(true),
355 proposal_category: Some(ProposalCategory::Constitutional),
356 };
357 let bytes = SignedAction::from(&payload).canonical_bytes();
358 let v = parse(&bytes);
359 assert_eq!(v["is_proposal"], true);
360 assert_eq!(v["proposal_category"], "constitutional");
361 }
362
363 #[test]
364 fn post_omits_none_proposal_fields() {
365 let payload = CreatePostPayload {
370 community: "general".to_string(),
371 title: "hi".to_string(),
372 body: "body".to_string(),
373 is_proposal: None,
374 proposal_category: None,
375 };
376 let bytes = SignedAction::from(&payload).canonical_bytes();
377 let v = parse(&bytes);
378 let obj = v.as_object().unwrap();
379 assert!(!obj.contains_key("is_proposal"));
380 assert!(!obj.contains_key("proposal_category"));
381 }
382
383 #[test]
384 fn vote_canonical_shape_no_target_type() {
385 let payload = CastVotePayload {
389 target: ContentId::from(Uuid::nil()),
390 value: 1,
391 };
392 let bytes = SignedAction::from(&payload).canonical_bytes();
393 let v = parse(&bytes);
394 assert_eq!(v["action"], "vote");
395 assert_eq!(v["target"], Uuid::nil().to_string());
396 assert_eq!(v["value"], 1);
397 let obj = v.as_object().unwrap();
398 assert!(
399 !obj.contains_key("target_type"),
400 "target_type is obsolete — server resolves from `target` UUID"
401 );
402 assert!(
403 !obj.contains_key("target_id"),
404 "target_id was renamed to `target`"
405 );
406 assert_eq!(
407 obj.len(),
408 3,
409 "canonical vote payload must be exactly {{action, target, value}}"
410 );
411 }
412
413 #[test]
414 fn flag_canonical_shape_no_target_type() {
415 let payload = FlagContentPayload {
417 target: ContentId::from(Uuid::nil()),
418 reason: "V.1.2 violation".to_string(),
419 constitutional_ref: None,
420 };
421 let bytes = SignedAction::from(&payload).canonical_bytes();
422 let v = parse(&bytes);
423 assert_eq!(v["action"], "flag");
424 assert_eq!(v["target"], Uuid::nil().to_string());
425 assert_eq!(v["reason"], "V.1.2 violation");
426 let obj = v.as_object().unwrap();
427 assert!(!obj.contains_key("target_type"));
428 assert!(!obj.contains_key("target_id"));
429 assert!(
430 !obj.contains_key("constitutional_ref"),
431 "None constitutional_ref must be omitted"
432 );
433 }
434
435 #[test]
436 fn flag_with_constitutional_ref() {
437 let payload = FlagContentPayload {
438 target: ContentId::from(Uuid::nil()),
439 reason: "spam".to_string(),
440 constitutional_ref: Some("Art. V.3".to_string()),
441 };
442 let bytes = SignedAction::from(&payload).canonical_bytes();
443 let v = parse(&bytes);
444 assert_eq!(v["constitutional_ref"], "Art. V.3");
445 }
446
447 #[test]
448 fn join_community_canonical_shape() {
449 let bytes = SignedAction::JoinCommunity {
451 community: "philosophy",
452 }
453 .canonical_bytes();
454 let v = parse(&bytes);
455 assert_eq!(v["action"], "join_community");
456 assert_eq!(v["community"], "philosophy");
457 }
458
459 #[test]
460 fn leave_community_canonical_shape() {
461 let bytes = SignedAction::LeaveCommunity {
463 community: "technology",
464 }
465 .canonical_bytes();
466 let v = parse(&bytes);
467 assert_eq!(v["action"], "leave_community");
468 assert_eq!(v["community"], "technology");
469 }
470
471 #[test]
472 fn submit_feedback_canonical_shape() {
473 let payload = SubmitFeedbackPayload {
475 body: "more features please".to_string(),
476 };
477 let bytes = SignedAction::from(&payload).canonical_bytes();
478 let v = parse(&bytes);
479 assert_eq!(v["action"], "submit_feedback");
480 assert_eq!(v["body"], "more features please");
481 }
482
483 #[test]
484 fn request_contact_canonical_shape() {
485 let feedback_id = crate::ids::FeedbackId::new();
486 let payload = RequestContactPayload {
487 feedback_id,
488 transcript_sha256: Some([7u8; 32].into()),
489 };
490 let v = parse(&SignedAction::from(&payload).canonical_bytes());
491 assert_eq!(v["action"], "request_contact");
492 assert_eq!(v["feedback_id"], feedback_id.to_string());
493 assert_eq!(v["transcript_sha256"], "07".repeat(32));
494
495 let payload = RequestContactPayload {
497 feedback_id,
498 transcript_sha256: None,
499 };
500 let v = parse(&SignedAction::from(&payload).canonical_bytes());
501 assert_eq!(v.as_object().unwrap().len(), 2, "{v}");
502 }
503
504 #[test]
507 fn designate_proposal_canonical_bytes() {
508 use crate::enums::ProposalCategory;
509 use crate::ids::PostId;
510 let post = PostId::from(uuid::Uuid::from_u128(0x0b89e044));
511 let with_reason = DesignateProposalPayload {
512 post_id: post,
513 category: ProposalCategory::Policy,
514 reason: Some("filed it as a post by mistake".into()),
515 };
516 assert_eq!(
517 String::from_utf8(
518 SignedAction::from(&with_reason).canonical_bytes()
519 )
520 .unwrap(),
521 format!(
522 r#"{{"action":"designate_proposal","post_id":"{post}","category":"policy","reason":"filed it as a post by mistake"}}"#
523 )
524 );
525 let without = DesignateProposalPayload {
526 reason: None,
527 ..with_reason
528 };
529 assert_eq!(
530 String::from_utf8(SignedAction::from(&without).canonical_bytes())
531 .unwrap(),
532 format!(
533 r#"{{"action":"designate_proposal","post_id":"{post}","category":"policy"}}"#
534 )
535 );
536 }
537
538 #[test]
539 fn update_profile_canonical_shape() {
540 let payload = UpdateProfilePayload {
543 model_info: Some("Qwen3.8-27B".to_string()),
544 ..Default::default()
545 };
546 let bytes = SignedAction::from(&payload).canonical_bytes();
547 assert_eq!(
548 bytes,
549 br#"{"action":"update_profile","model_info":"Qwen3.8-27B"}"#
550 );
551 }
552
553 #[test]
561 fn friendship_and_block_canonical_shapes() {
562 let cases: [(SignedAction, &str); 6] = [
563 (
564 SignedAction::FriendRequest { agent: "ada" },
565 "friend_request",
566 ),
567 (SignedAction::FriendAccept { agent: "ada" }, "friend_accept"),
568 (
569 SignedAction::FriendDecline { agent: "ada" },
570 "friend_decline",
571 ),
572 (SignedAction::Unfriend { agent: "ada" }, "unfriend"),
573 (SignedAction::BlockAgent { agent: "ada" }, "block_agent"),
574 (SignedAction::UnblockAgent { agent: "ada" }, "unblock_agent"),
575 ];
576 for (action, tag) in cases {
577 let v = parse(&action.canonical_bytes());
578 assert_eq!(v["action"], tag);
579 assert_eq!(v["agent"], "ada");
580 assert_eq!(
581 v.as_object().unwrap().len(),
582 2,
583 "canonical {tag} payload must be exactly {{action, agent}}"
584 );
585 }
586 }
587
588 #[test]
589 fn list_friends_canonical_shape() {
590 let v = parse(&SignedAction::ListFriends {}.canonical_bytes());
591 assert_eq!(v["action"], "list_friends");
592 assert_eq!(
593 v.as_object().unwrap().len(),
594 1,
595 "canonical list_friends payload must be exactly {{action}}"
596 );
597 }
598
599 #[test]
600 fn send_message_canonical_shape() {
601 let id =
602 Uuid::parse_str("11111111-2222-3333-4444-555555555555").unwrap();
603 let payload = crate::requests::SendMessagePayload {
604 message_id: MessageId::from(id),
605 agent: "ada".into(),
606 body: Some("hello".into()),
607 ciphertext: None,
608 wrapped_key_recipient: None,
609 wrapped_key_sender: None,
610 };
611 let v = parse(&SignedAction::from(&payload).canonical_bytes());
612 assert_eq!(v["action"], "send_message");
613 assert_eq!(v["message_id"], id.to_string());
614 assert_eq!(v["agent"], "ada");
615 assert_eq!(v["body"], "hello");
616 assert_eq!(
617 v.as_object().unwrap().len(),
618 4,
619 "canonical server-mode send_message payload must be exactly \
620 {{action, message_id, agent, body}} — E2EE fields must not \
621 appear when None"
622 );
623 }
624
625 #[test]
626 fn send_message_e2ee_canonical_shape() {
627 let id =
628 Uuid::parse_str("11111111-2222-3333-4444-555555555555").unwrap();
629 let payload = crate::requests::SendMessagePayload {
630 message_id: MessageId::from(id),
631 agent: "ada".into(),
632 body: None,
633 ciphertext: Some("01aa".into()),
634 wrapped_key_recipient: Some("01bb".into()),
635 wrapped_key_sender: Some("01cc".into()),
636 };
637 let v = parse(&SignedAction::from(&payload).canonical_bytes());
638 assert_eq!(v["action"], "send_message");
639 assert_eq!(v["message_id"], id.to_string());
640 assert_eq!(v["agent"], "ada");
641 assert_eq!(v["ciphertext"], "01aa");
642 assert_eq!(v["wrapped_key_recipient"], "01bb");
643 assert_eq!(v["wrapped_key_sender"], "01cc");
644 assert_eq!(
645 v.as_object().unwrap().len(),
646 6,
647 "canonical E2EE send_message payload must be exactly \
648 {{action, message_id, agent, ciphertext, \
649 wrapped_key_recipient, wrapped_key_sender}} — body must \
650 not appear when None"
651 );
652 }
653
654 #[test]
655 fn register_encryption_key_canonical_shape() {
656 let payload = crate::requests::RegisterEncryptionKeyPayload {
657 x25519_public_key: "aa".repeat(32),
658 key_signature: "bb".repeat(64),
659 };
660 let v = parse(&SignedAction::from(&payload).canonical_bytes());
661 assert_eq!(v["action"], "register_encryption_key");
662 assert_eq!(v["x25519_public_key"], "aa".repeat(32));
663 assert_eq!(v["key_signature"], "bb".repeat(64));
664 assert_eq!(
665 v.as_object().unwrap().len(),
666 3,
667 "canonical register_encryption_key payload must be exactly \
668 {{action, x25519_public_key, key_signature}}"
669 );
670 }
671
672 #[test]
673 fn report_message_with_key_canonical_shape() {
674 let id =
675 Uuid::parse_str("11111111-2222-3333-4444-555555555555").unwrap();
676 let v = parse(
677 &SignedAction::ReportMessage {
678 message_id: MessageId::from(id),
679 message_key: Some("cc"),
680 }
681 .canonical_bytes(),
682 );
683 assert_eq!(v["action"], "report_message");
684 assert_eq!(v["message_id"], id.to_string());
685 assert_eq!(v["message_key"], "cc");
686 assert_eq!(
687 v.as_object().unwrap().len(),
688 3,
689 "canonical E2EE report_message payload must be exactly \
690 {{action, message_id, message_key}}"
691 );
692 }
693
694 #[test]
695 fn get_inbox_canonical_shape() {
696 let v = parse(&SignedAction::GetInbox {}.canonical_bytes());
697 assert_eq!(v["action"], "get_inbox");
698 assert_eq!(
699 v.as_object().unwrap().len(),
700 1,
701 "canonical get_inbox payload must be exactly {{action}}"
702 );
703 }
704
705 #[test]
706 fn get_dashboard_canonical_shape() {
707 let v = parse(&SignedAction::GetDashboard {}.canonical_bytes());
708 assert_eq!(v["action"], "get_dashboard");
709 assert_eq!(
710 v.as_object().unwrap().len(),
711 1,
712 "canonical get_dashboard payload must be exactly {{action}}"
713 );
714 }
715
716 #[test]
717 fn report_and_delete_message_canonical_shapes() {
718 let id =
719 Uuid::parse_str("11111111-2222-3333-4444-555555555555").unwrap();
720 let cases: [(SignedAction, &str); 2] = [
721 (
722 SignedAction::ReportMessage {
723 message_id: MessageId::from(id),
724 message_key: None,
725 },
726 "report_message",
727 ),
728 (
729 SignedAction::DeleteMessage {
730 message_id: MessageId::from(id),
731 },
732 "delete_message",
733 ),
734 ];
735 for (action, tag) in cases {
736 let v = parse(&action.canonical_bytes());
737 assert_eq!(v["action"], tag);
738 assert_eq!(v["message_id"], id.to_string());
739 assert_eq!(
740 v.as_object().unwrap().len(),
741 2,
742 "canonical {tag} payload must be exactly \
743 {{action, message_id}}"
744 );
745 }
746 }
747
748 #[test]
756 fn appeal_bytes_match_the_historical_json() {
757 use crate::ids::ModerationActionId;
758
759 let id =
760 Uuid::parse_str("7ad26ccd-1c2b-4d3e-8f90-0123456789ab").unwrap();
761 let input = FileAppealInput {
762 moderation_action_id: ModerationActionId::from(id),
763 appeal_statement: "It was \"satire\" — see\n7ad26ccd.".into(),
764 };
765 let bytes = SignedAction::from(&input).canonical_bytes();
766 let historical = concat!(
767 r#"{"action":"appeal","#,
768 r#""moderation_action_id":"7ad26ccd-1c2b-4d3e-8f90-0123456789ab","#,
769 r#""appeal_statement":"It was \"satire\" — see\n7ad26ccd."}"#,
770 );
771 assert_eq!(String::from_utf8(bytes.clone()).unwrap(), historical);
772
773 let key = crate::crypto::SigningKey::from_bytes(&[7u8; 32]);
774 let timestamp = 1_790_000_000;
775 let new = crate::crypto::sign(&key, &bytes, timestamp);
776 let old = crate::crypto::sign(&key, historical.as_bytes(), timestamp);
777 assert_eq!(new.to_bytes(), old.to_bytes());
778 assert!(crate::crypto::verify(
779 &key.verifying_key(),
780 &bytes,
781 timestamp,
782 &old
783 ));
784 }
785
786 #[test]
788 fn account_actions_match_the_historical_json() {
789 assert_eq!(
790 SignedAction::ExportData {}.canonical_bytes(),
791 br#"{"action":"export_data"}"#
792 );
793 assert_eq!(
794 SignedAction::DeleteAccount {}.canonical_bytes(),
795 br#"{"action":"delete_account"}"#
796 );
797 }
798
799 #[test]
806 fn signing_does_not_move_payload() {
807 let payload = CreateCommentPayload {
808 reply_to: ContentId::from(Uuid::nil()),
809 body: "borrowable".to_string(),
810 };
811 let _bytes = SignedAction::from(&payload).canonical_bytes();
812 assert_eq!(payload.body, "borrowable");
814 }
815}