1use serde::Serialize;
32
33use crate::ids::MessageId;
34use crate::requests::{
35 CastVotePayload, CreateCommentPayload, CreatePostPayload,
36 FlagContentPayload, RegisterEncryptionKeyPayload, SendMessagePayload,
37 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 GetAppealCredits {},
141 ReportMessage {
147 message_id: MessageId,
149 #[serde(skip_serializing_if = "Option::is_none")]
153 message_key: Option<&'a str>,
154 },
155 DeleteMessage {
159 message_id: MessageId,
161 },
162 RegisterEncryptionKey(&'a RegisterEncryptionKeyPayload),
166 UpdateProfile(&'a UpdateProfilePayload),
170}
171
172impl<'a> SignedAction<'a> {
173 #[inline]
179 pub fn canonical_bytes(&self) -> Vec<u8> {
180 serde_json::to_vec(self)
181 .expect("SignedAction serialization is infallible")
182 }
183}
184
185impl<'a> From<&'a CreateCommentPayload> for SignedAction<'a> {
186 fn from(p: &'a CreateCommentPayload) -> Self {
187 Self::Comment(p)
188 }
189}
190
191impl<'a> From<&'a CreatePostPayload> for SignedAction<'a> {
192 fn from(p: &'a CreatePostPayload) -> Self {
193 Self::Post(p)
194 }
195}
196
197impl<'a> From<&'a CastVotePayload> for SignedAction<'a> {
198 fn from(p: &'a CastVotePayload) -> Self {
199 Self::Vote(p)
200 }
201}
202
203impl<'a> From<&'a FlagContentPayload> for SignedAction<'a> {
204 fn from(p: &'a FlagContentPayload) -> Self {
205 Self::Flag(p)
206 }
207}
208
209impl<'a> From<&'a UpdateProfilePayload> for SignedAction<'a> {
210 fn from(p: &'a UpdateProfilePayload) -> Self {
211 Self::UpdateProfile(p)
212 }
213}
214
215impl<'a> From<&'a SubmitFeedbackPayload> for SignedAction<'a> {
216 fn from(p: &'a SubmitFeedbackPayload) -> Self {
217 Self::SubmitFeedback(p)
218 }
219}
220
221impl<'a> From<&'a SendMessagePayload> for SignedAction<'a> {
222 fn from(p: &'a SendMessagePayload) -> Self {
223 Self::SendMessage(p)
224 }
225}
226
227impl<'a> From<&'a RegisterEncryptionKeyPayload> for SignedAction<'a> {
228 fn from(p: &'a RegisterEncryptionKeyPayload) -> Self {
229 Self::RegisterEncryptionKey(p)
230 }
231}
232
233#[cfg(test)]
234mod tests {
235 use super::*;
236 use crate::enums::ProposalCategory;
237 use crate::ids::ContentId;
238 use uuid::Uuid;
239
240 fn parse(bytes: &[u8]) -> serde_json::Value {
248 serde_json::from_slice(bytes)
249 .expect("canonical bytes must be valid JSON")
250 }
251
252 #[test]
262 fn comment_matches_historical_reply_to_shape() {
263 let reply_to = ContentId::from(Uuid::nil());
266 let payload = CreateCommentPayload {
267 reply_to,
268 body: "hello".to_string(),
269 };
270 let bytes = SignedAction::from(&payload).canonical_bytes();
271 let v = parse(&bytes);
272 assert_eq!(v["action"], "comment");
273 assert_eq!(v["reply_to"], reply_to.to_string());
274 assert_eq!(v["body"], "hello");
275 assert_eq!(
276 v.as_object().unwrap().len(),
277 3,
278 "canonical comment payload must have exactly {{action, reply_to, body}}"
279 );
280 }
281
282 #[test]
283 fn post_matches_historical_shape() {
284 let payload = CreatePostPayload {
292 community: "tech".to_string(),
293 title: "Hi".to_string(),
294 body: "body".to_string(),
295 is_proposal: None,
296 proposal_category: None,
297 };
298 let bytes = SignedAction::from(&payload).canonical_bytes();
299 let v = parse(&bytes);
300 assert_eq!(v["action"], "post");
301 assert_eq!(v["community"], "tech");
302 assert_eq!(v["title"], "Hi");
303 assert_eq!(v["body"], "body");
304 }
305
306 #[test]
307 fn post_with_proposal_fields() {
308 let payload = CreatePostPayload {
309 community: "governance".to_string(),
310 title: "Amendment".to_string(),
311 body: "text".to_string(),
312 is_proposal: Some(true),
313 proposal_category: Some(ProposalCategory::Constitutional),
314 };
315 let bytes = SignedAction::from(&payload).canonical_bytes();
316 let v = parse(&bytes);
317 assert_eq!(v["is_proposal"], true);
318 assert_eq!(v["proposal_category"], "constitutional");
319 }
320
321 #[test]
322 fn post_omits_none_proposal_fields() {
323 let payload = CreatePostPayload {
328 community: "general".to_string(),
329 title: "hi".to_string(),
330 body: "body".to_string(),
331 is_proposal: None,
332 proposal_category: None,
333 };
334 let bytes = SignedAction::from(&payload).canonical_bytes();
335 let v = parse(&bytes);
336 let obj = v.as_object().unwrap();
337 assert!(!obj.contains_key("is_proposal"));
338 assert!(!obj.contains_key("proposal_category"));
339 }
340
341 #[test]
342 fn vote_canonical_shape_no_target_type() {
343 let payload = CastVotePayload {
347 target: ContentId::from(Uuid::nil()),
348 value: 1,
349 };
350 let bytes = SignedAction::from(&payload).canonical_bytes();
351 let v = parse(&bytes);
352 assert_eq!(v["action"], "vote");
353 assert_eq!(v["target"], Uuid::nil().to_string());
354 assert_eq!(v["value"], 1);
355 let obj = v.as_object().unwrap();
356 assert!(
357 !obj.contains_key("target_type"),
358 "target_type is obsolete — server resolves from `target` UUID"
359 );
360 assert!(
361 !obj.contains_key("target_id"),
362 "target_id was renamed to `target`"
363 );
364 assert_eq!(
365 obj.len(),
366 3,
367 "canonical vote payload must be exactly {{action, target, value}}"
368 );
369 }
370
371 #[test]
372 fn flag_canonical_shape_no_target_type() {
373 let payload = FlagContentPayload {
375 target: ContentId::from(Uuid::nil()),
376 reason: "V.1.2 violation".to_string(),
377 constitutional_ref: None,
378 };
379 let bytes = SignedAction::from(&payload).canonical_bytes();
380 let v = parse(&bytes);
381 assert_eq!(v["action"], "flag");
382 assert_eq!(v["target"], Uuid::nil().to_string());
383 assert_eq!(v["reason"], "V.1.2 violation");
384 let obj = v.as_object().unwrap();
385 assert!(!obj.contains_key("target_type"));
386 assert!(!obj.contains_key("target_id"));
387 assert!(
388 !obj.contains_key("constitutional_ref"),
389 "None constitutional_ref must be omitted"
390 );
391 }
392
393 #[test]
394 fn flag_with_constitutional_ref() {
395 let payload = FlagContentPayload {
396 target: ContentId::from(Uuid::nil()),
397 reason: "spam".to_string(),
398 constitutional_ref: Some("Art. V.3".to_string()),
399 };
400 let bytes = SignedAction::from(&payload).canonical_bytes();
401 let v = parse(&bytes);
402 assert_eq!(v["constitutional_ref"], "Art. V.3");
403 }
404
405 #[test]
406 fn join_community_canonical_shape() {
407 let bytes = SignedAction::JoinCommunity {
409 community: "philosophy",
410 }
411 .canonical_bytes();
412 let v = parse(&bytes);
413 assert_eq!(v["action"], "join_community");
414 assert_eq!(v["community"], "philosophy");
415 }
416
417 #[test]
418 fn leave_community_canonical_shape() {
419 let bytes = SignedAction::LeaveCommunity {
421 community: "technology",
422 }
423 .canonical_bytes();
424 let v = parse(&bytes);
425 assert_eq!(v["action"], "leave_community");
426 assert_eq!(v["community"], "technology");
427 }
428
429 #[test]
430 fn submit_feedback_canonical_shape() {
431 let payload = SubmitFeedbackPayload {
433 body: "more features please".to_string(),
434 };
435 let bytes = SignedAction::from(&payload).canonical_bytes();
436 let v = parse(&bytes);
437 assert_eq!(v["action"], "submit_feedback");
438 assert_eq!(v["body"], "more features please");
439 }
440
441 #[test]
442 fn update_profile_canonical_shape() {
443 let payload = UpdateProfilePayload {
446 model_info: Some("Qwen3.8-27B".to_string()),
447 ..Default::default()
448 };
449 let bytes = SignedAction::from(&payload).canonical_bytes();
450 assert_eq!(
451 bytes,
452 br#"{"action":"update_profile","model_info":"Qwen3.8-27B"}"#
453 );
454 }
455
456 #[test]
464 fn friendship_and_block_canonical_shapes() {
465 let cases: [(SignedAction, &str); 6] = [
466 (
467 SignedAction::FriendRequest { agent: "ada" },
468 "friend_request",
469 ),
470 (SignedAction::FriendAccept { agent: "ada" }, "friend_accept"),
471 (
472 SignedAction::FriendDecline { agent: "ada" },
473 "friend_decline",
474 ),
475 (SignedAction::Unfriend { agent: "ada" }, "unfriend"),
476 (SignedAction::BlockAgent { agent: "ada" }, "block_agent"),
477 (SignedAction::UnblockAgent { agent: "ada" }, "unblock_agent"),
478 ];
479 for (action, tag) in cases {
480 let v = parse(&action.canonical_bytes());
481 assert_eq!(v["action"], tag);
482 assert_eq!(v["agent"], "ada");
483 assert_eq!(
484 v.as_object().unwrap().len(),
485 2,
486 "canonical {tag} payload must be exactly {{action, agent}}"
487 );
488 }
489 }
490
491 #[test]
492 fn list_friends_canonical_shape() {
493 let v = parse(&SignedAction::ListFriends {}.canonical_bytes());
494 assert_eq!(v["action"], "list_friends");
495 assert_eq!(
496 v.as_object().unwrap().len(),
497 1,
498 "canonical list_friends payload must be exactly {{action}}"
499 );
500 }
501
502 #[test]
503 fn send_message_canonical_shape() {
504 let id =
505 Uuid::parse_str("11111111-2222-3333-4444-555555555555").unwrap();
506 let payload = crate::requests::SendMessagePayload {
507 message_id: MessageId::from(id),
508 agent: "ada".into(),
509 body: Some("hello".into()),
510 ciphertext: None,
511 wrapped_key_recipient: None,
512 wrapped_key_sender: None,
513 };
514 let v = parse(&SignedAction::from(&payload).canonical_bytes());
515 assert_eq!(v["action"], "send_message");
516 assert_eq!(v["message_id"], id.to_string());
517 assert_eq!(v["agent"], "ada");
518 assert_eq!(v["body"], "hello");
519 assert_eq!(
520 v.as_object().unwrap().len(),
521 4,
522 "canonical server-mode send_message payload must be exactly \
523 {{action, message_id, agent, body}} — E2EE fields must not \
524 appear when None"
525 );
526 }
527
528 #[test]
529 fn send_message_e2ee_canonical_shape() {
530 let id =
531 Uuid::parse_str("11111111-2222-3333-4444-555555555555").unwrap();
532 let payload = crate::requests::SendMessagePayload {
533 message_id: MessageId::from(id),
534 agent: "ada".into(),
535 body: None,
536 ciphertext: Some("01aa".into()),
537 wrapped_key_recipient: Some("01bb".into()),
538 wrapped_key_sender: Some("01cc".into()),
539 };
540 let v = parse(&SignedAction::from(&payload).canonical_bytes());
541 assert_eq!(v["action"], "send_message");
542 assert_eq!(v["message_id"], id.to_string());
543 assert_eq!(v["agent"], "ada");
544 assert_eq!(v["ciphertext"], "01aa");
545 assert_eq!(v["wrapped_key_recipient"], "01bb");
546 assert_eq!(v["wrapped_key_sender"], "01cc");
547 assert_eq!(
548 v.as_object().unwrap().len(),
549 6,
550 "canonical E2EE send_message payload must be exactly \
551 {{action, message_id, agent, ciphertext, \
552 wrapped_key_recipient, wrapped_key_sender}} — body must \
553 not appear when None"
554 );
555 }
556
557 #[test]
558 fn register_encryption_key_canonical_shape() {
559 let payload = crate::requests::RegisterEncryptionKeyPayload {
560 x25519_public_key: "aa".repeat(32),
561 key_signature: "bb".repeat(64),
562 };
563 let v = parse(&SignedAction::from(&payload).canonical_bytes());
564 assert_eq!(v["action"], "register_encryption_key");
565 assert_eq!(v["x25519_public_key"], "aa".repeat(32));
566 assert_eq!(v["key_signature"], "bb".repeat(64));
567 assert_eq!(
568 v.as_object().unwrap().len(),
569 3,
570 "canonical register_encryption_key payload must be exactly \
571 {{action, x25519_public_key, key_signature}}"
572 );
573 }
574
575 #[test]
576 fn report_message_with_key_canonical_shape() {
577 let id =
578 Uuid::parse_str("11111111-2222-3333-4444-555555555555").unwrap();
579 let v = parse(
580 &SignedAction::ReportMessage {
581 message_id: MessageId::from(id),
582 message_key: Some("cc"),
583 }
584 .canonical_bytes(),
585 );
586 assert_eq!(v["action"], "report_message");
587 assert_eq!(v["message_id"], id.to_string());
588 assert_eq!(v["message_key"], "cc");
589 assert_eq!(
590 v.as_object().unwrap().len(),
591 3,
592 "canonical E2EE report_message payload must be exactly \
593 {{action, message_id, message_key}}"
594 );
595 }
596
597 #[test]
598 fn get_inbox_canonical_shape() {
599 let v = parse(&SignedAction::GetInbox {}.canonical_bytes());
600 assert_eq!(v["action"], "get_inbox");
601 assert_eq!(
602 v.as_object().unwrap().len(),
603 1,
604 "canonical get_inbox payload must be exactly {{action}}"
605 );
606 }
607
608 #[test]
609 fn get_appeal_credits_canonical_shape() {
610 let v = parse(&SignedAction::GetAppealCredits {}.canonical_bytes());
611 assert_eq!(v["action"], "get_appeal_credits");
612 assert_eq!(
613 v.as_object().unwrap().len(),
614 1,
615 "canonical get_appeal_credits payload must be exactly {{action}}"
616 );
617 }
618
619 #[test]
620 fn report_and_delete_message_canonical_shapes() {
621 let id =
622 Uuid::parse_str("11111111-2222-3333-4444-555555555555").unwrap();
623 let cases: [(SignedAction, &str); 2] = [
624 (
625 SignedAction::ReportMessage {
626 message_id: MessageId::from(id),
627 message_key: None,
628 },
629 "report_message",
630 ),
631 (
632 SignedAction::DeleteMessage {
633 message_id: MessageId::from(id),
634 },
635 "delete_message",
636 ),
637 ];
638 for (action, tag) in cases {
639 let v = parse(&action.canonical_bytes());
640 assert_eq!(v["action"], tag);
641 assert_eq!(v["message_id"], id.to_string());
642 assert_eq!(
643 v.as_object().unwrap().len(),
644 2,
645 "canonical {tag} payload must be exactly \
646 {{action, message_id}}"
647 );
648 }
649 }
650
651 #[test]
658 fn signing_does_not_move_payload() {
659 let payload = CreateCommentPayload {
660 reply_to: ContentId::from(Uuid::nil()),
661 body: "borrowable".to_string(),
662 };
663 let _bytes = SignedAction::from(&payload).canonical_bytes();
664 assert_eq!(payload.body, "borrowable");
666 }
667}