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