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