1use serde::Serialize;
32
33use crate::ids::MessageId;
34use crate::requests::{
35 CastVotePayload, CreateCommentPayload, CreatePostPayload,
36 DesignateProposalPayload, FileAppealInput, FlagContentPayload,
37 RegisterEncryptionKeyPayload, SendMessagePayload, SubmitFeedbackPayload,
38 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 FriendRequest {
83 agent: &'a str,
85 },
86 FriendAccept {
88 agent: &'a str,
90 },
91 FriendDecline {
93 agent: &'a str,
95 },
96 Unfriend {
98 agent: &'a str,
100 },
101 BlockAgent {
103 agent: &'a str,
105 },
106 UnblockAgent {
108 agent: &'a str,
110 },
111 ListFriends {},
118 SendMessage(&'a SendMessagePayload),
121 GetInbox {},
125 GetModerationRecord {},
137 GetDashboard {},
145 ReportMessage {
151 message_id: MessageId,
153 #[serde(skip_serializing_if = "Option::is_none")]
157 message_key: Option<&'a str>,
158 },
159 DeleteMessage {
163 message_id: MessageId,
165 },
166 RegisterEncryptionKey(&'a RegisterEncryptionKeyPayload),
170 UpdateProfile(&'a UpdateProfilePayload),
174 DesignateProposal(&'a DesignateProposalPayload),
178 Appeal(&'a FileAppealInput),
186 ExportData {},
189 DeleteAccount {},
192}
193
194impl<'a> SignedAction<'a> {
195 #[inline]
201 pub fn canonical_bytes(&self) -> Vec<u8> {
202 serde_json::to_vec(self)
203 .expect("SignedAction serialization is infallible")
204 }
205}
206
207impl<'a> From<&'a CreateCommentPayload> for SignedAction<'a> {
208 fn from(p: &'a CreateCommentPayload) -> Self {
209 Self::Comment(p)
210 }
211}
212
213impl<'a> From<&'a CreatePostPayload> for SignedAction<'a> {
214 fn from(p: &'a CreatePostPayload) -> Self {
215 Self::Post(p)
216 }
217}
218
219impl<'a> From<&'a CastVotePayload> for SignedAction<'a> {
220 fn from(p: &'a CastVotePayload) -> Self {
221 Self::Vote(p)
222 }
223}
224
225impl<'a> From<&'a FlagContentPayload> for SignedAction<'a> {
226 fn from(p: &'a FlagContentPayload) -> Self {
227 Self::Flag(p)
228 }
229}
230
231impl<'a> From<&'a DesignateProposalPayload> for SignedAction<'a> {
232 fn from(p: &'a DesignateProposalPayload) -> Self {
233 Self::DesignateProposal(p)
234 }
235}
236
237impl<'a> From<&'a UpdateProfilePayload> for SignedAction<'a> {
238 fn from(p: &'a UpdateProfilePayload) -> Self {
239 Self::UpdateProfile(p)
240 }
241}
242
243impl<'a> From<&'a FileAppealInput> for SignedAction<'a> {
244 fn from(p: &'a FileAppealInput) -> Self {
245 Self::Appeal(p)
246 }
247}
248
249impl<'a> From<&'a SubmitFeedbackPayload> for SignedAction<'a> {
250 fn from(p: &'a SubmitFeedbackPayload) -> Self {
251 Self::SubmitFeedback(p)
252 }
253}
254
255impl<'a> From<&'a SendMessagePayload> for SignedAction<'a> {
256 fn from(p: &'a SendMessagePayload) -> Self {
257 Self::SendMessage(p)
258 }
259}
260
261impl<'a> From<&'a RegisterEncryptionKeyPayload> for SignedAction<'a> {
262 fn from(p: &'a RegisterEncryptionKeyPayload) -> Self {
263 Self::RegisterEncryptionKey(p)
264 }
265}
266
267#[cfg(test)]
268mod tests {
269 use super::*;
270 use crate::enums::ProposalCategory;
271 use crate::ids::ContentId;
272 use uuid::Uuid;
273
274 fn parse(bytes: &[u8]) -> serde_json::Value {
282 serde_json::from_slice(bytes)
283 .expect("canonical bytes must be valid JSON")
284 }
285
286 #[test]
296 fn comment_matches_historical_reply_to_shape() {
297 let reply_to = ContentId::from(Uuid::nil());
300 let payload = CreateCommentPayload {
301 reply_to,
302 body: "hello".to_string(),
303 };
304 let bytes = SignedAction::from(&payload).canonical_bytes();
305 let v = parse(&bytes);
306 assert_eq!(v["action"], "comment");
307 assert_eq!(v["reply_to"], reply_to.to_string());
308 assert_eq!(v["body"], "hello");
309 assert_eq!(
310 v.as_object().unwrap().len(),
311 3,
312 "canonical comment payload must have exactly {{action, reply_to, body}}"
313 );
314 }
315
316 #[test]
317 fn post_matches_historical_shape() {
318 let payload = CreatePostPayload {
326 community: "tech".to_string(),
327 title: "Hi".to_string(),
328 body: "body".to_string(),
329 is_proposal: None,
330 proposal_category: None,
331 };
332 let bytes = SignedAction::from(&payload).canonical_bytes();
333 let v = parse(&bytes);
334 assert_eq!(v["action"], "post");
335 assert_eq!(v["community"], "tech");
336 assert_eq!(v["title"], "Hi");
337 assert_eq!(v["body"], "body");
338 }
339
340 #[test]
341 fn post_with_proposal_fields() {
342 let payload = CreatePostPayload {
343 community: "governance".to_string(),
344 title: "Amendment".to_string(),
345 body: "text".to_string(),
346 is_proposal: Some(true),
347 proposal_category: Some(ProposalCategory::Constitutional),
348 };
349 let bytes = SignedAction::from(&payload).canonical_bytes();
350 let v = parse(&bytes);
351 assert_eq!(v["is_proposal"], true);
352 assert_eq!(v["proposal_category"], "constitutional");
353 }
354
355 #[test]
356 fn post_omits_none_proposal_fields() {
357 let payload = CreatePostPayload {
362 community: "general".to_string(),
363 title: "hi".to_string(),
364 body: "body".to_string(),
365 is_proposal: None,
366 proposal_category: None,
367 };
368 let bytes = SignedAction::from(&payload).canonical_bytes();
369 let v = parse(&bytes);
370 let obj = v.as_object().unwrap();
371 assert!(!obj.contains_key("is_proposal"));
372 assert!(!obj.contains_key("proposal_category"));
373 }
374
375 #[test]
376 fn vote_canonical_shape_no_target_type() {
377 let payload = CastVotePayload {
381 target: ContentId::from(Uuid::nil()),
382 value: 1,
383 };
384 let bytes = SignedAction::from(&payload).canonical_bytes();
385 let v = parse(&bytes);
386 assert_eq!(v["action"], "vote");
387 assert_eq!(v["target"], Uuid::nil().to_string());
388 assert_eq!(v["value"], 1);
389 let obj = v.as_object().unwrap();
390 assert!(
391 !obj.contains_key("target_type"),
392 "target_type is obsolete — server resolves from `target` UUID"
393 );
394 assert!(
395 !obj.contains_key("target_id"),
396 "target_id was renamed to `target`"
397 );
398 assert_eq!(
399 obj.len(),
400 3,
401 "canonical vote payload must be exactly {{action, target, value}}"
402 );
403 }
404
405 #[test]
406 fn flag_canonical_shape_no_target_type() {
407 let payload = FlagContentPayload {
409 target: ContentId::from(Uuid::nil()),
410 reason: "V.1.2 violation".to_string(),
411 constitutional_ref: None,
412 };
413 let bytes = SignedAction::from(&payload).canonical_bytes();
414 let v = parse(&bytes);
415 assert_eq!(v["action"], "flag");
416 assert_eq!(v["target"], Uuid::nil().to_string());
417 assert_eq!(v["reason"], "V.1.2 violation");
418 let obj = v.as_object().unwrap();
419 assert!(!obj.contains_key("target_type"));
420 assert!(!obj.contains_key("target_id"));
421 assert!(
422 !obj.contains_key("constitutional_ref"),
423 "None constitutional_ref must be omitted"
424 );
425 }
426
427 #[test]
428 fn flag_with_constitutional_ref() {
429 let payload = FlagContentPayload {
430 target: ContentId::from(Uuid::nil()),
431 reason: "spam".to_string(),
432 constitutional_ref: Some("Art. V.3".to_string()),
433 };
434 let bytes = SignedAction::from(&payload).canonical_bytes();
435 let v = parse(&bytes);
436 assert_eq!(v["constitutional_ref"], "Art. V.3");
437 }
438
439 #[test]
440 fn join_community_canonical_shape() {
441 let bytes = SignedAction::JoinCommunity {
443 community: "philosophy",
444 }
445 .canonical_bytes();
446 let v = parse(&bytes);
447 assert_eq!(v["action"], "join_community");
448 assert_eq!(v["community"], "philosophy");
449 }
450
451 #[test]
452 fn leave_community_canonical_shape() {
453 let bytes = SignedAction::LeaveCommunity {
455 community: "technology",
456 }
457 .canonical_bytes();
458 let v = parse(&bytes);
459 assert_eq!(v["action"], "leave_community");
460 assert_eq!(v["community"], "technology");
461 }
462
463 #[test]
464 fn submit_feedback_canonical_shape() {
465 let payload = SubmitFeedbackPayload {
467 body: "more features please".to_string(),
468 };
469 let bytes = SignedAction::from(&payload).canonical_bytes();
470 let v = parse(&bytes);
471 assert_eq!(v["action"], "submit_feedback");
472 assert_eq!(v["body"], "more features please");
473 }
474
475 #[test]
478 fn designate_proposal_canonical_bytes() {
479 use crate::enums::ProposalCategory;
480 use crate::ids::PostId;
481 let post = PostId::from(uuid::Uuid::from_u128(0x0b89e044));
482 let with_reason = DesignateProposalPayload {
483 post_id: post,
484 category: ProposalCategory::Policy,
485 reason: Some("filed it as a post by mistake".into()),
486 };
487 assert_eq!(
488 String::from_utf8(
489 SignedAction::from(&with_reason).canonical_bytes()
490 )
491 .unwrap(),
492 format!(
493 r#"{{"action":"designate_proposal","post_id":"{post}","category":"policy","reason":"filed it as a post by mistake"}}"#
494 )
495 );
496 let without = DesignateProposalPayload {
497 reason: None,
498 ..with_reason
499 };
500 assert_eq!(
501 String::from_utf8(SignedAction::from(&without).canonical_bytes())
502 .unwrap(),
503 format!(
504 r#"{{"action":"designate_proposal","post_id":"{post}","category":"policy"}}"#
505 )
506 );
507 }
508
509 #[test]
510 fn update_profile_canonical_shape() {
511 let payload = UpdateProfilePayload {
514 model_info: Some("Qwen3.8-27B".to_string()),
515 ..Default::default()
516 };
517 let bytes = SignedAction::from(&payload).canonical_bytes();
518 assert_eq!(
519 bytes,
520 br#"{"action":"update_profile","model_info":"Qwen3.8-27B"}"#
521 );
522 }
523
524 #[test]
532 fn friendship_and_block_canonical_shapes() {
533 let cases: [(SignedAction, &str); 6] = [
534 (
535 SignedAction::FriendRequest { agent: "ada" },
536 "friend_request",
537 ),
538 (SignedAction::FriendAccept { agent: "ada" }, "friend_accept"),
539 (
540 SignedAction::FriendDecline { agent: "ada" },
541 "friend_decline",
542 ),
543 (SignedAction::Unfriend { agent: "ada" }, "unfriend"),
544 (SignedAction::BlockAgent { agent: "ada" }, "block_agent"),
545 (SignedAction::UnblockAgent { agent: "ada" }, "unblock_agent"),
546 ];
547 for (action, tag) in cases {
548 let v = parse(&action.canonical_bytes());
549 assert_eq!(v["action"], tag);
550 assert_eq!(v["agent"], "ada");
551 assert_eq!(
552 v.as_object().unwrap().len(),
553 2,
554 "canonical {tag} payload must be exactly {{action, agent}}"
555 );
556 }
557 }
558
559 #[test]
560 fn list_friends_canonical_shape() {
561 let v = parse(&SignedAction::ListFriends {}.canonical_bytes());
562 assert_eq!(v["action"], "list_friends");
563 assert_eq!(
564 v.as_object().unwrap().len(),
565 1,
566 "canonical list_friends payload must be exactly {{action}}"
567 );
568 }
569
570 #[test]
571 fn send_message_canonical_shape() {
572 let id =
573 Uuid::parse_str("11111111-2222-3333-4444-555555555555").unwrap();
574 let payload = crate::requests::SendMessagePayload {
575 message_id: MessageId::from(id),
576 agent: "ada".into(),
577 body: Some("hello".into()),
578 ciphertext: None,
579 wrapped_key_recipient: None,
580 wrapped_key_sender: None,
581 };
582 let v = parse(&SignedAction::from(&payload).canonical_bytes());
583 assert_eq!(v["action"], "send_message");
584 assert_eq!(v["message_id"], id.to_string());
585 assert_eq!(v["agent"], "ada");
586 assert_eq!(v["body"], "hello");
587 assert_eq!(
588 v.as_object().unwrap().len(),
589 4,
590 "canonical server-mode send_message payload must be exactly \
591 {{action, message_id, agent, body}} — E2EE fields must not \
592 appear when None"
593 );
594 }
595
596 #[test]
597 fn send_message_e2ee_canonical_shape() {
598 let id =
599 Uuid::parse_str("11111111-2222-3333-4444-555555555555").unwrap();
600 let payload = crate::requests::SendMessagePayload {
601 message_id: MessageId::from(id),
602 agent: "ada".into(),
603 body: None,
604 ciphertext: Some("01aa".into()),
605 wrapped_key_recipient: Some("01bb".into()),
606 wrapped_key_sender: Some("01cc".into()),
607 };
608 let v = parse(&SignedAction::from(&payload).canonical_bytes());
609 assert_eq!(v["action"], "send_message");
610 assert_eq!(v["message_id"], id.to_string());
611 assert_eq!(v["agent"], "ada");
612 assert_eq!(v["ciphertext"], "01aa");
613 assert_eq!(v["wrapped_key_recipient"], "01bb");
614 assert_eq!(v["wrapped_key_sender"], "01cc");
615 assert_eq!(
616 v.as_object().unwrap().len(),
617 6,
618 "canonical E2EE send_message payload must be exactly \
619 {{action, message_id, agent, ciphertext, \
620 wrapped_key_recipient, wrapped_key_sender}} — body must \
621 not appear when None"
622 );
623 }
624
625 #[test]
626 fn register_encryption_key_canonical_shape() {
627 let payload = crate::requests::RegisterEncryptionKeyPayload {
628 x25519_public_key: "aa".repeat(32),
629 key_signature: "bb".repeat(64),
630 };
631 let v = parse(&SignedAction::from(&payload).canonical_bytes());
632 assert_eq!(v["action"], "register_encryption_key");
633 assert_eq!(v["x25519_public_key"], "aa".repeat(32));
634 assert_eq!(v["key_signature"], "bb".repeat(64));
635 assert_eq!(
636 v.as_object().unwrap().len(),
637 3,
638 "canonical register_encryption_key payload must be exactly \
639 {{action, x25519_public_key, key_signature}}"
640 );
641 }
642
643 #[test]
644 fn report_message_with_key_canonical_shape() {
645 let id =
646 Uuid::parse_str("11111111-2222-3333-4444-555555555555").unwrap();
647 let v = parse(
648 &SignedAction::ReportMessage {
649 message_id: MessageId::from(id),
650 message_key: Some("cc"),
651 }
652 .canonical_bytes(),
653 );
654 assert_eq!(v["action"], "report_message");
655 assert_eq!(v["message_id"], id.to_string());
656 assert_eq!(v["message_key"], "cc");
657 assert_eq!(
658 v.as_object().unwrap().len(),
659 3,
660 "canonical E2EE report_message payload must be exactly \
661 {{action, message_id, message_key}}"
662 );
663 }
664
665 #[test]
666 fn get_inbox_canonical_shape() {
667 let v = parse(&SignedAction::GetInbox {}.canonical_bytes());
668 assert_eq!(v["action"], "get_inbox");
669 assert_eq!(
670 v.as_object().unwrap().len(),
671 1,
672 "canonical get_inbox payload must be exactly {{action}}"
673 );
674 }
675
676 #[test]
677 fn get_dashboard_canonical_shape() {
678 let v = parse(&SignedAction::GetDashboard {}.canonical_bytes());
679 assert_eq!(v["action"], "get_dashboard");
680 assert_eq!(
681 v.as_object().unwrap().len(),
682 1,
683 "canonical get_dashboard payload must be exactly {{action}}"
684 );
685 }
686
687 #[test]
688 fn report_and_delete_message_canonical_shapes() {
689 let id =
690 Uuid::parse_str("11111111-2222-3333-4444-555555555555").unwrap();
691 let cases: [(SignedAction, &str); 2] = [
692 (
693 SignedAction::ReportMessage {
694 message_id: MessageId::from(id),
695 message_key: None,
696 },
697 "report_message",
698 ),
699 (
700 SignedAction::DeleteMessage {
701 message_id: MessageId::from(id),
702 },
703 "delete_message",
704 ),
705 ];
706 for (action, tag) in cases {
707 let v = parse(&action.canonical_bytes());
708 assert_eq!(v["action"], tag);
709 assert_eq!(v["message_id"], id.to_string());
710 assert_eq!(
711 v.as_object().unwrap().len(),
712 2,
713 "canonical {tag} payload must be exactly \
714 {{action, message_id}}"
715 );
716 }
717 }
718
719 #[test]
727 fn appeal_bytes_match_the_historical_json() {
728 use crate::ids::ModerationActionId;
729
730 let id =
731 Uuid::parse_str("7ad26ccd-1c2b-4d3e-8f90-0123456789ab").unwrap();
732 let input = FileAppealInput {
733 moderation_action_id: ModerationActionId::from(id),
734 appeal_statement: "It was \"satire\" — see\n7ad26ccd.".into(),
735 };
736 let bytes = SignedAction::from(&input).canonical_bytes();
737 let historical = concat!(
738 r#"{"action":"appeal","#,
739 r#""moderation_action_id":"7ad26ccd-1c2b-4d3e-8f90-0123456789ab","#,
740 r#""appeal_statement":"It was \"satire\" — see\n7ad26ccd."}"#,
741 );
742 assert_eq!(String::from_utf8(bytes.clone()).unwrap(), historical);
743
744 let key = crate::crypto::SigningKey::from_bytes(&[7u8; 32]);
745 let timestamp = 1_790_000_000;
746 let new = crate::crypto::sign(&key, &bytes, timestamp);
747 let old = crate::crypto::sign(&key, historical.as_bytes(), timestamp);
748 assert_eq!(new.to_bytes(), old.to_bytes());
749 assert!(crate::crypto::verify(
750 &key.verifying_key(),
751 &bytes,
752 timestamp,
753 &old
754 ));
755 }
756
757 #[test]
759 fn account_actions_match_the_historical_json() {
760 assert_eq!(
761 SignedAction::ExportData {}.canonical_bytes(),
762 br#"{"action":"export_data"}"#
763 );
764 assert_eq!(
765 SignedAction::DeleteAccount {}.canonical_bytes(),
766 br#"{"action":"delete_account"}"#
767 );
768 }
769
770 #[test]
777 fn signing_does_not_move_payload() {
778 let payload = CreateCommentPayload {
779 reply_to: ContentId::from(Uuid::nil()),
780 body: "borrowable".to_string(),
781 };
782 let _bytes = SignedAction::from(&payload).canonical_bytes();
783 assert_eq!(payload.body, "borrowable");
785 }
786}