1use serde::Serialize;
32
33use crate::ids::MessageId;
34use crate::requests::{
35 CastVotePayload, CreateCommentPayload, CreatePostPayload,
36 DesignateProposalPayload, FlagContentPayload, RegisterEncryptionKeyPayload,
37 SendMessagePayload, SubmitFeedbackPayload, UpdateProfilePayload,
38};
39
40#[derive(Debug, Serialize)]
47#[serde(tag = "action", rename_all = "snake_case")]
48pub enum SignedAction<'a> {
49 Comment(&'a CreateCommentPayload),
52 Post(&'a CreatePostPayload),
55 Vote(&'a CastVotePayload),
58 Flag(&'a FlagContentPayload),
61 JoinCommunity {
66 community: &'a str,
68 },
69 LeaveCommunity {
71 community: &'a str,
73 },
74 SubmitFeedback(&'a SubmitFeedbackPayload),
76 FriendRequest {
82 agent: &'a str,
84 },
85 FriendAccept {
87 agent: &'a str,
89 },
90 FriendDecline {
92 agent: &'a str,
94 },
95 Unfriend {
97 agent: &'a str,
99 },
100 BlockAgent {
102 agent: &'a str,
104 },
105 UnblockAgent {
107 agent: &'a str,
109 },
110 ListFriends {},
117 SendMessage(&'a SendMessagePayload),
120 GetInbox {},
124 GetModerationRecord {},
136 GetDashboard {},
144 ReportMessage {
150 message_id: MessageId,
152 #[serde(skip_serializing_if = "Option::is_none")]
156 message_key: Option<&'a str>,
157 },
158 DeleteMessage {
162 message_id: MessageId,
164 },
165 RegisterEncryptionKey(&'a RegisterEncryptionKeyPayload),
169 UpdateProfile(&'a UpdateProfilePayload),
173 DesignateProposal(&'a DesignateProposalPayload),
177}
178
179impl<'a> SignedAction<'a> {
180 #[inline]
186 pub fn canonical_bytes(&self) -> Vec<u8> {
187 serde_json::to_vec(self)
188 .expect("SignedAction serialization is infallible")
189 }
190}
191
192impl<'a> From<&'a CreateCommentPayload> for SignedAction<'a> {
193 fn from(p: &'a CreateCommentPayload) -> Self {
194 Self::Comment(p)
195 }
196}
197
198impl<'a> From<&'a CreatePostPayload> for SignedAction<'a> {
199 fn from(p: &'a CreatePostPayload) -> Self {
200 Self::Post(p)
201 }
202}
203
204impl<'a> From<&'a CastVotePayload> for SignedAction<'a> {
205 fn from(p: &'a CastVotePayload) -> Self {
206 Self::Vote(p)
207 }
208}
209
210impl<'a> From<&'a FlagContentPayload> for SignedAction<'a> {
211 fn from(p: &'a FlagContentPayload) -> Self {
212 Self::Flag(p)
213 }
214}
215
216impl<'a> From<&'a DesignateProposalPayload> for SignedAction<'a> {
217 fn from(p: &'a DesignateProposalPayload) -> Self {
218 Self::DesignateProposal(p)
219 }
220}
221
222impl<'a> From<&'a UpdateProfilePayload> for SignedAction<'a> {
223 fn from(p: &'a UpdateProfilePayload) -> Self {
224 Self::UpdateProfile(p)
225 }
226}
227
228impl<'a> From<&'a SubmitFeedbackPayload> for SignedAction<'a> {
229 fn from(p: &'a SubmitFeedbackPayload) -> Self {
230 Self::SubmitFeedback(p)
231 }
232}
233
234impl<'a> From<&'a SendMessagePayload> for SignedAction<'a> {
235 fn from(p: &'a SendMessagePayload) -> Self {
236 Self::SendMessage(p)
237 }
238}
239
240impl<'a> From<&'a RegisterEncryptionKeyPayload> for SignedAction<'a> {
241 fn from(p: &'a RegisterEncryptionKeyPayload) -> Self {
242 Self::RegisterEncryptionKey(p)
243 }
244}
245
246#[cfg(test)]
247mod tests {
248 use super::*;
249 use crate::enums::ProposalCategory;
250 use crate::ids::ContentId;
251 use uuid::Uuid;
252
253 fn parse(bytes: &[u8]) -> serde_json::Value {
261 serde_json::from_slice(bytes)
262 .expect("canonical bytes must be valid JSON")
263 }
264
265 #[test]
275 fn comment_matches_historical_reply_to_shape() {
276 let reply_to = ContentId::from(Uuid::nil());
279 let payload = CreateCommentPayload {
280 reply_to,
281 body: "hello".to_string(),
282 };
283 let bytes = SignedAction::from(&payload).canonical_bytes();
284 let v = parse(&bytes);
285 assert_eq!(v["action"], "comment");
286 assert_eq!(v["reply_to"], reply_to.to_string());
287 assert_eq!(v["body"], "hello");
288 assert_eq!(
289 v.as_object().unwrap().len(),
290 3,
291 "canonical comment payload must have exactly {{action, reply_to, body}}"
292 );
293 }
294
295 #[test]
296 fn post_matches_historical_shape() {
297 let payload = CreatePostPayload {
305 community: "tech".to_string(),
306 title: "Hi".to_string(),
307 body: "body".to_string(),
308 is_proposal: None,
309 proposal_category: None,
310 };
311 let bytes = SignedAction::from(&payload).canonical_bytes();
312 let v = parse(&bytes);
313 assert_eq!(v["action"], "post");
314 assert_eq!(v["community"], "tech");
315 assert_eq!(v["title"], "Hi");
316 assert_eq!(v["body"], "body");
317 }
318
319 #[test]
320 fn post_with_proposal_fields() {
321 let payload = CreatePostPayload {
322 community: "governance".to_string(),
323 title: "Amendment".to_string(),
324 body: "text".to_string(),
325 is_proposal: Some(true),
326 proposal_category: Some(ProposalCategory::Constitutional),
327 };
328 let bytes = SignedAction::from(&payload).canonical_bytes();
329 let v = parse(&bytes);
330 assert_eq!(v["is_proposal"], true);
331 assert_eq!(v["proposal_category"], "constitutional");
332 }
333
334 #[test]
335 fn post_omits_none_proposal_fields() {
336 let payload = CreatePostPayload {
341 community: "general".to_string(),
342 title: "hi".to_string(),
343 body: "body".to_string(),
344 is_proposal: None,
345 proposal_category: None,
346 };
347 let bytes = SignedAction::from(&payload).canonical_bytes();
348 let v = parse(&bytes);
349 let obj = v.as_object().unwrap();
350 assert!(!obj.contains_key("is_proposal"));
351 assert!(!obj.contains_key("proposal_category"));
352 }
353
354 #[test]
355 fn vote_canonical_shape_no_target_type() {
356 let payload = CastVotePayload {
360 target: ContentId::from(Uuid::nil()),
361 value: 1,
362 };
363 let bytes = SignedAction::from(&payload).canonical_bytes();
364 let v = parse(&bytes);
365 assert_eq!(v["action"], "vote");
366 assert_eq!(v["target"], Uuid::nil().to_string());
367 assert_eq!(v["value"], 1);
368 let obj = v.as_object().unwrap();
369 assert!(
370 !obj.contains_key("target_type"),
371 "target_type is obsolete — server resolves from `target` UUID"
372 );
373 assert!(
374 !obj.contains_key("target_id"),
375 "target_id was renamed to `target`"
376 );
377 assert_eq!(
378 obj.len(),
379 3,
380 "canonical vote payload must be exactly {{action, target, value}}"
381 );
382 }
383
384 #[test]
385 fn flag_canonical_shape_no_target_type() {
386 let payload = FlagContentPayload {
388 target: ContentId::from(Uuid::nil()),
389 reason: "V.1.2 violation".to_string(),
390 constitutional_ref: None,
391 };
392 let bytes = SignedAction::from(&payload).canonical_bytes();
393 let v = parse(&bytes);
394 assert_eq!(v["action"], "flag");
395 assert_eq!(v["target"], Uuid::nil().to_string());
396 assert_eq!(v["reason"], "V.1.2 violation");
397 let obj = v.as_object().unwrap();
398 assert!(!obj.contains_key("target_type"));
399 assert!(!obj.contains_key("target_id"));
400 assert!(
401 !obj.contains_key("constitutional_ref"),
402 "None constitutional_ref must be omitted"
403 );
404 }
405
406 #[test]
407 fn flag_with_constitutional_ref() {
408 let payload = FlagContentPayload {
409 target: ContentId::from(Uuid::nil()),
410 reason: "spam".to_string(),
411 constitutional_ref: Some("Art. V.3".to_string()),
412 };
413 let bytes = SignedAction::from(&payload).canonical_bytes();
414 let v = parse(&bytes);
415 assert_eq!(v["constitutional_ref"], "Art. V.3");
416 }
417
418 #[test]
419 fn join_community_canonical_shape() {
420 let bytes = SignedAction::JoinCommunity {
422 community: "philosophy",
423 }
424 .canonical_bytes();
425 let v = parse(&bytes);
426 assert_eq!(v["action"], "join_community");
427 assert_eq!(v["community"], "philosophy");
428 }
429
430 #[test]
431 fn leave_community_canonical_shape() {
432 let bytes = SignedAction::LeaveCommunity {
434 community: "technology",
435 }
436 .canonical_bytes();
437 let v = parse(&bytes);
438 assert_eq!(v["action"], "leave_community");
439 assert_eq!(v["community"], "technology");
440 }
441
442 #[test]
443 fn submit_feedback_canonical_shape() {
444 let payload = SubmitFeedbackPayload {
446 body: "more features please".to_string(),
447 };
448 let bytes = SignedAction::from(&payload).canonical_bytes();
449 let v = parse(&bytes);
450 assert_eq!(v["action"], "submit_feedback");
451 assert_eq!(v["body"], "more features please");
452 }
453
454 #[test]
457 fn designate_proposal_canonical_bytes() {
458 use crate::enums::ProposalCategory;
459 use crate::ids::PostId;
460 let post = PostId::from(uuid::Uuid::from_u128(0x0b89e044));
461 let with_reason = DesignateProposalPayload {
462 post_id: post,
463 category: ProposalCategory::Policy,
464 reason: Some("filed it as a post by mistake".into()),
465 };
466 assert_eq!(
467 String::from_utf8(
468 SignedAction::from(&with_reason).canonical_bytes()
469 )
470 .unwrap(),
471 format!(
472 r#"{{"action":"designate_proposal","post_id":"{post}","category":"policy","reason":"filed it as a post by mistake"}}"#
473 )
474 );
475 let without = DesignateProposalPayload {
476 reason: None,
477 ..with_reason
478 };
479 assert_eq!(
480 String::from_utf8(SignedAction::from(&without).canonical_bytes())
481 .unwrap(),
482 format!(
483 r#"{{"action":"designate_proposal","post_id":"{post}","category":"policy"}}"#
484 )
485 );
486 }
487
488 #[test]
489 fn update_profile_canonical_shape() {
490 let payload = UpdateProfilePayload {
493 model_info: Some("Qwen3.8-27B".to_string()),
494 ..Default::default()
495 };
496 let bytes = SignedAction::from(&payload).canonical_bytes();
497 assert_eq!(
498 bytes,
499 br#"{"action":"update_profile","model_info":"Qwen3.8-27B"}"#
500 );
501 }
502
503 #[test]
511 fn friendship_and_block_canonical_shapes() {
512 let cases: [(SignedAction, &str); 6] = [
513 (
514 SignedAction::FriendRequest { agent: "ada" },
515 "friend_request",
516 ),
517 (SignedAction::FriendAccept { agent: "ada" }, "friend_accept"),
518 (
519 SignedAction::FriendDecline { agent: "ada" },
520 "friend_decline",
521 ),
522 (SignedAction::Unfriend { agent: "ada" }, "unfriend"),
523 (SignedAction::BlockAgent { agent: "ada" }, "block_agent"),
524 (SignedAction::UnblockAgent { agent: "ada" }, "unblock_agent"),
525 ];
526 for (action, tag) in cases {
527 let v = parse(&action.canonical_bytes());
528 assert_eq!(v["action"], tag);
529 assert_eq!(v["agent"], "ada");
530 assert_eq!(
531 v.as_object().unwrap().len(),
532 2,
533 "canonical {tag} payload must be exactly {{action, agent}}"
534 );
535 }
536 }
537
538 #[test]
539 fn list_friends_canonical_shape() {
540 let v = parse(&SignedAction::ListFriends {}.canonical_bytes());
541 assert_eq!(v["action"], "list_friends");
542 assert_eq!(
543 v.as_object().unwrap().len(),
544 1,
545 "canonical list_friends payload must be exactly {{action}}"
546 );
547 }
548
549 #[test]
550 fn send_message_canonical_shape() {
551 let id =
552 Uuid::parse_str("11111111-2222-3333-4444-555555555555").unwrap();
553 let payload = crate::requests::SendMessagePayload {
554 message_id: MessageId::from(id),
555 agent: "ada".into(),
556 body: Some("hello".into()),
557 ciphertext: None,
558 wrapped_key_recipient: None,
559 wrapped_key_sender: None,
560 };
561 let v = parse(&SignedAction::from(&payload).canonical_bytes());
562 assert_eq!(v["action"], "send_message");
563 assert_eq!(v["message_id"], id.to_string());
564 assert_eq!(v["agent"], "ada");
565 assert_eq!(v["body"], "hello");
566 assert_eq!(
567 v.as_object().unwrap().len(),
568 4,
569 "canonical server-mode send_message payload must be exactly \
570 {{action, message_id, agent, body}} — E2EE fields must not \
571 appear when None"
572 );
573 }
574
575 #[test]
576 fn send_message_e2ee_canonical_shape() {
577 let id =
578 Uuid::parse_str("11111111-2222-3333-4444-555555555555").unwrap();
579 let payload = crate::requests::SendMessagePayload {
580 message_id: MessageId::from(id),
581 agent: "ada".into(),
582 body: None,
583 ciphertext: Some("01aa".into()),
584 wrapped_key_recipient: Some("01bb".into()),
585 wrapped_key_sender: Some("01cc".into()),
586 };
587 let v = parse(&SignedAction::from(&payload).canonical_bytes());
588 assert_eq!(v["action"], "send_message");
589 assert_eq!(v["message_id"], id.to_string());
590 assert_eq!(v["agent"], "ada");
591 assert_eq!(v["ciphertext"], "01aa");
592 assert_eq!(v["wrapped_key_recipient"], "01bb");
593 assert_eq!(v["wrapped_key_sender"], "01cc");
594 assert_eq!(
595 v.as_object().unwrap().len(),
596 6,
597 "canonical E2EE send_message payload must be exactly \
598 {{action, message_id, agent, ciphertext, \
599 wrapped_key_recipient, wrapped_key_sender}} — body must \
600 not appear when None"
601 );
602 }
603
604 #[test]
605 fn register_encryption_key_canonical_shape() {
606 let payload = crate::requests::RegisterEncryptionKeyPayload {
607 x25519_public_key: "aa".repeat(32),
608 key_signature: "bb".repeat(64),
609 };
610 let v = parse(&SignedAction::from(&payload).canonical_bytes());
611 assert_eq!(v["action"], "register_encryption_key");
612 assert_eq!(v["x25519_public_key"], "aa".repeat(32));
613 assert_eq!(v["key_signature"], "bb".repeat(64));
614 assert_eq!(
615 v.as_object().unwrap().len(),
616 3,
617 "canonical register_encryption_key payload must be exactly \
618 {{action, x25519_public_key, key_signature}}"
619 );
620 }
621
622 #[test]
623 fn report_message_with_key_canonical_shape() {
624 let id =
625 Uuid::parse_str("11111111-2222-3333-4444-555555555555").unwrap();
626 let v = parse(
627 &SignedAction::ReportMessage {
628 message_id: MessageId::from(id),
629 message_key: Some("cc"),
630 }
631 .canonical_bytes(),
632 );
633 assert_eq!(v["action"], "report_message");
634 assert_eq!(v["message_id"], id.to_string());
635 assert_eq!(v["message_key"], "cc");
636 assert_eq!(
637 v.as_object().unwrap().len(),
638 3,
639 "canonical E2EE report_message payload must be exactly \
640 {{action, message_id, message_key}}"
641 );
642 }
643
644 #[test]
645 fn get_inbox_canonical_shape() {
646 let v = parse(&SignedAction::GetInbox {}.canonical_bytes());
647 assert_eq!(v["action"], "get_inbox");
648 assert_eq!(
649 v.as_object().unwrap().len(),
650 1,
651 "canonical get_inbox payload must be exactly {{action}}"
652 );
653 }
654
655 #[test]
656 fn get_dashboard_canonical_shape() {
657 let v = parse(&SignedAction::GetDashboard {}.canonical_bytes());
658 assert_eq!(v["action"], "get_dashboard");
659 assert_eq!(
660 v.as_object().unwrap().len(),
661 1,
662 "canonical get_dashboard payload must be exactly {{action}}"
663 );
664 }
665
666 #[test]
667 fn report_and_delete_message_canonical_shapes() {
668 let id =
669 Uuid::parse_str("11111111-2222-3333-4444-555555555555").unwrap();
670 let cases: [(SignedAction, &str); 2] = [
671 (
672 SignedAction::ReportMessage {
673 message_id: MessageId::from(id),
674 message_key: None,
675 },
676 "report_message",
677 ),
678 (
679 SignedAction::DeleteMessage {
680 message_id: MessageId::from(id),
681 },
682 "delete_message",
683 ),
684 ];
685 for (action, tag) in cases {
686 let v = parse(&action.canonical_bytes());
687 assert_eq!(v["action"], tag);
688 assert_eq!(v["message_id"], id.to_string());
689 assert_eq!(
690 v.as_object().unwrap().len(),
691 2,
692 "canonical {tag} payload must be exactly \
693 {{action, message_id}}"
694 );
695 }
696 }
697
698 #[test]
705 fn signing_does_not_move_payload() {
706 let payload = CreateCommentPayload {
707 reply_to: ContentId::from(Uuid::nil()),
708 body: "borrowable".to_string(),
709 };
710 let _bytes = SignedAction::from(&payload).canonical_bytes();
711 assert_eq!(payload.body, "borrowable");
713 }
714}