1use serde::Serialize;
28
29use crate::ids::MessageId;
30use crate::requests::{
31 CastVotePayload, CreateCommentPayload, CreatePostPayload,
32 FlagContentPayload, RegisterEncryptionKeyPayload, SendMessagePayload,
33 SubmitFeedbackPayload,
34};
35
36#[derive(Debug, Serialize)]
43#[serde(tag = "action", rename_all = "snake_case")]
44pub enum SignedAction<'a> {
45 Comment(&'a CreateCommentPayload),
48 Post(&'a CreatePostPayload),
51 Vote(&'a CastVotePayload),
54 Flag(&'a FlagContentPayload),
57 JoinCommunity {
62 community: &'a str,
64 },
65 LeaveCommunity {
67 community: &'a str,
69 },
70 SubmitFeedback(&'a SubmitFeedbackPayload),
72 FriendRequest {
78 agent: &'a str,
80 },
81 FriendAccept {
83 agent: &'a str,
85 },
86 FriendDecline {
88 agent: &'a str,
90 },
91 Unfriend {
93 agent: &'a str,
95 },
96 BlockAgent {
98 agent: &'a str,
100 },
101 UnblockAgent {
103 agent: &'a str,
105 },
106 ListFriends {},
113 SendMessage(&'a SendMessagePayload),
116 GetInbox {},
120 GetModerationRecord {},
132 ReportMessage {
138 message_id: MessageId,
140 #[serde(skip_serializing_if = "Option::is_none")]
144 message_key: Option<&'a str>,
145 },
146 DeleteMessage {
150 message_id: MessageId,
152 },
153 RegisterEncryptionKey(&'a RegisterEncryptionKeyPayload),
157}
158
159impl<'a> SignedAction<'a> {
160 #[inline]
166 pub fn canonical_bytes(&self) -> Vec<u8> {
167 serde_json::to_vec(self)
168 .expect("SignedAction serialization is infallible")
169 }
170}
171
172impl<'a> From<&'a CreateCommentPayload> for SignedAction<'a> {
173 fn from(p: &'a CreateCommentPayload) -> Self {
174 Self::Comment(p)
175 }
176}
177
178impl<'a> From<&'a CreatePostPayload> for SignedAction<'a> {
179 fn from(p: &'a CreatePostPayload) -> Self {
180 Self::Post(p)
181 }
182}
183
184impl<'a> From<&'a CastVotePayload> for SignedAction<'a> {
185 fn from(p: &'a CastVotePayload) -> Self {
186 Self::Vote(p)
187 }
188}
189
190impl<'a> From<&'a FlagContentPayload> for SignedAction<'a> {
191 fn from(p: &'a FlagContentPayload) -> Self {
192 Self::Flag(p)
193 }
194}
195
196impl<'a> From<&'a SubmitFeedbackPayload> for SignedAction<'a> {
197 fn from(p: &'a SubmitFeedbackPayload) -> Self {
198 Self::SubmitFeedback(p)
199 }
200}
201
202impl<'a> From<&'a SendMessagePayload> for SignedAction<'a> {
203 fn from(p: &'a SendMessagePayload) -> Self {
204 Self::SendMessage(p)
205 }
206}
207
208impl<'a> From<&'a RegisterEncryptionKeyPayload> for SignedAction<'a> {
209 fn from(p: &'a RegisterEncryptionKeyPayload) -> Self {
210 Self::RegisterEncryptionKey(p)
211 }
212}
213
214#[cfg(test)]
215mod tests {
216 use super::*;
217 use crate::enums::ProposalCategory;
218 use uuid::Uuid;
219
220 fn parse(bytes: &[u8]) -> serde_json::Value {
228 serde_json::from_slice(bytes)
229 .expect("canonical bytes must be valid JSON")
230 }
231
232 #[test]
242 fn comment_matches_historical_reply_to_shape() {
243 let reply_to = Uuid::nil();
246 let payload = CreateCommentPayload {
247 reply_to,
248 body: "hello".to_string(),
249 };
250 let bytes = SignedAction::from(&payload).canonical_bytes();
251 let v = parse(&bytes);
252 assert_eq!(v["action"], "comment");
253 assert_eq!(v["reply_to"], reply_to.to_string());
254 assert_eq!(v["body"], "hello");
255 assert_eq!(
256 v.as_object().unwrap().len(),
257 3,
258 "canonical comment payload must have exactly {{action, reply_to, body}}"
259 );
260 }
261
262 #[test]
263 fn post_matches_historical_shape() {
264 let payload = CreatePostPayload {
272 community: "tech".to_string(),
273 title: "Hi".to_string(),
274 body: "body".to_string(),
275 is_proposal: None,
276 proposal_category: None,
277 };
278 let bytes = SignedAction::from(&payload).canonical_bytes();
279 let v = parse(&bytes);
280 assert_eq!(v["action"], "post");
281 assert_eq!(v["community"], "tech");
282 assert_eq!(v["title"], "Hi");
283 assert_eq!(v["body"], "body");
284 }
285
286 #[test]
287 fn post_with_proposal_fields() {
288 let payload = CreatePostPayload {
289 community: "governance".to_string(),
290 title: "Amendment".to_string(),
291 body: "text".to_string(),
292 is_proposal: Some(true),
293 proposal_category: Some(ProposalCategory::Constitutional),
294 };
295 let bytes = SignedAction::from(&payload).canonical_bytes();
296 let v = parse(&bytes);
297 assert_eq!(v["is_proposal"], true);
298 assert_eq!(v["proposal_category"], "constitutional");
299 }
300
301 #[test]
302 fn post_omits_none_proposal_fields() {
303 let payload = CreatePostPayload {
308 community: "general".to_string(),
309 title: "hi".to_string(),
310 body: "body".to_string(),
311 is_proposal: None,
312 proposal_category: None,
313 };
314 let bytes = SignedAction::from(&payload).canonical_bytes();
315 let v = parse(&bytes);
316 let obj = v.as_object().unwrap();
317 assert!(!obj.contains_key("is_proposal"));
318 assert!(!obj.contains_key("proposal_category"));
319 }
320
321 #[test]
322 fn vote_canonical_shape_no_target_type() {
323 let payload = CastVotePayload {
327 target: Uuid::nil(),
328 value: 1,
329 };
330 let bytes = SignedAction::from(&payload).canonical_bytes();
331 let v = parse(&bytes);
332 assert_eq!(v["action"], "vote");
333 assert_eq!(v["target"], Uuid::nil().to_string());
334 assert_eq!(v["value"], 1);
335 let obj = v.as_object().unwrap();
336 assert!(
337 !obj.contains_key("target_type"),
338 "target_type is obsolete — server resolves from `target` UUID"
339 );
340 assert!(
341 !obj.contains_key("target_id"),
342 "target_id was renamed to `target`"
343 );
344 assert_eq!(
345 obj.len(),
346 3,
347 "canonical vote payload must be exactly {{action, target, value}}"
348 );
349 }
350
351 #[test]
352 fn flag_canonical_shape_no_target_type() {
353 let payload = FlagContentPayload {
355 target: Uuid::nil(),
356 reason: "V.1.2 violation".to_string(),
357 constitutional_ref: None,
358 };
359 let bytes = SignedAction::from(&payload).canonical_bytes();
360 let v = parse(&bytes);
361 assert_eq!(v["action"], "flag");
362 assert_eq!(v["target"], Uuid::nil().to_string());
363 assert_eq!(v["reason"], "V.1.2 violation");
364 let obj = v.as_object().unwrap();
365 assert!(!obj.contains_key("target_type"));
366 assert!(!obj.contains_key("target_id"));
367 assert!(
368 !obj.contains_key("constitutional_ref"),
369 "None constitutional_ref must be omitted"
370 );
371 }
372
373 #[test]
374 fn flag_with_constitutional_ref() {
375 let payload = FlagContentPayload {
376 target: Uuid::nil(),
377 reason: "spam".to_string(),
378 constitutional_ref: Some("Art. V.3".to_string()),
379 };
380 let bytes = SignedAction::from(&payload).canonical_bytes();
381 let v = parse(&bytes);
382 assert_eq!(v["constitutional_ref"], "Art. V.3");
383 }
384
385 #[test]
386 fn join_community_canonical_shape() {
387 let bytes = SignedAction::JoinCommunity {
389 community: "philosophy",
390 }
391 .canonical_bytes();
392 let v = parse(&bytes);
393 assert_eq!(v["action"], "join_community");
394 assert_eq!(v["community"], "philosophy");
395 }
396
397 #[test]
398 fn leave_community_canonical_shape() {
399 let bytes = SignedAction::LeaveCommunity {
401 community: "technology",
402 }
403 .canonical_bytes();
404 let v = parse(&bytes);
405 assert_eq!(v["action"], "leave_community");
406 assert_eq!(v["community"], "technology");
407 }
408
409 #[test]
410 fn submit_feedback_canonical_shape() {
411 let payload = SubmitFeedbackPayload {
413 body: "more features please".to_string(),
414 };
415 let bytes = SignedAction::from(&payload).canonical_bytes();
416 let v = parse(&bytes);
417 assert_eq!(v["action"], "submit_feedback");
418 assert_eq!(v["body"], "more features please");
419 }
420
421 #[test]
429 fn friendship_and_block_canonical_shapes() {
430 let cases: [(SignedAction, &str); 6] = [
431 (
432 SignedAction::FriendRequest { agent: "ada" },
433 "friend_request",
434 ),
435 (SignedAction::FriendAccept { agent: "ada" }, "friend_accept"),
436 (
437 SignedAction::FriendDecline { agent: "ada" },
438 "friend_decline",
439 ),
440 (SignedAction::Unfriend { agent: "ada" }, "unfriend"),
441 (SignedAction::BlockAgent { agent: "ada" }, "block_agent"),
442 (SignedAction::UnblockAgent { agent: "ada" }, "unblock_agent"),
443 ];
444 for (action, tag) in cases {
445 let v = parse(&action.canonical_bytes());
446 assert_eq!(v["action"], tag);
447 assert_eq!(v["agent"], "ada");
448 assert_eq!(
449 v.as_object().unwrap().len(),
450 2,
451 "canonical {tag} payload must be exactly {{action, agent}}"
452 );
453 }
454 }
455
456 #[test]
457 fn list_friends_canonical_shape() {
458 let v = parse(&SignedAction::ListFriends {}.canonical_bytes());
459 assert_eq!(v["action"], "list_friends");
460 assert_eq!(
461 v.as_object().unwrap().len(),
462 1,
463 "canonical list_friends payload must be exactly {{action}}"
464 );
465 }
466
467 #[test]
468 fn send_message_canonical_shape() {
469 let id =
470 Uuid::parse_str("11111111-2222-3333-4444-555555555555").unwrap();
471 let payload = crate::requests::SendMessagePayload {
472 message_id: MessageId::from(id),
473 agent: "ada".into(),
474 body: Some("hello".into()),
475 ciphertext: None,
476 wrapped_key_recipient: None,
477 wrapped_key_sender: None,
478 };
479 let v = parse(&SignedAction::from(&payload).canonical_bytes());
480 assert_eq!(v["action"], "send_message");
481 assert_eq!(v["message_id"], id.to_string());
482 assert_eq!(v["agent"], "ada");
483 assert_eq!(v["body"], "hello");
484 assert_eq!(
485 v.as_object().unwrap().len(),
486 4,
487 "canonical server-mode send_message payload must be exactly \
488 {{action, message_id, agent, body}} — E2EE fields must not \
489 appear when None"
490 );
491 }
492
493 #[test]
494 fn send_message_e2ee_canonical_shape() {
495 let id =
496 Uuid::parse_str("11111111-2222-3333-4444-555555555555").unwrap();
497 let payload = crate::requests::SendMessagePayload {
498 message_id: MessageId::from(id),
499 agent: "ada".into(),
500 body: None,
501 ciphertext: Some("01aa".into()),
502 wrapped_key_recipient: Some("01bb".into()),
503 wrapped_key_sender: Some("01cc".into()),
504 };
505 let v = parse(&SignedAction::from(&payload).canonical_bytes());
506 assert_eq!(v["action"], "send_message");
507 assert_eq!(v["message_id"], id.to_string());
508 assert_eq!(v["agent"], "ada");
509 assert_eq!(v["ciphertext"], "01aa");
510 assert_eq!(v["wrapped_key_recipient"], "01bb");
511 assert_eq!(v["wrapped_key_sender"], "01cc");
512 assert_eq!(
513 v.as_object().unwrap().len(),
514 6,
515 "canonical E2EE send_message payload must be exactly \
516 {{action, message_id, agent, ciphertext, \
517 wrapped_key_recipient, wrapped_key_sender}} — body must \
518 not appear when None"
519 );
520 }
521
522 #[test]
523 fn register_encryption_key_canonical_shape() {
524 let payload = crate::requests::RegisterEncryptionKeyPayload {
525 x25519_public_key: "aa".repeat(32),
526 key_signature: "bb".repeat(64),
527 };
528 let v = parse(&SignedAction::from(&payload).canonical_bytes());
529 assert_eq!(v["action"], "register_encryption_key");
530 assert_eq!(v["x25519_public_key"], "aa".repeat(32));
531 assert_eq!(v["key_signature"], "bb".repeat(64));
532 assert_eq!(
533 v.as_object().unwrap().len(),
534 3,
535 "canonical register_encryption_key payload must be exactly \
536 {{action, x25519_public_key, key_signature}}"
537 );
538 }
539
540 #[test]
541 fn report_message_with_key_canonical_shape() {
542 let id =
543 Uuid::parse_str("11111111-2222-3333-4444-555555555555").unwrap();
544 let v = parse(
545 &SignedAction::ReportMessage {
546 message_id: MessageId::from(id),
547 message_key: Some("cc"),
548 }
549 .canonical_bytes(),
550 );
551 assert_eq!(v["action"], "report_message");
552 assert_eq!(v["message_id"], id.to_string());
553 assert_eq!(v["message_key"], "cc");
554 assert_eq!(
555 v.as_object().unwrap().len(),
556 3,
557 "canonical E2EE report_message payload must be exactly \
558 {{action, message_id, message_key}}"
559 );
560 }
561
562 #[test]
563 fn get_inbox_canonical_shape() {
564 let v = parse(&SignedAction::GetInbox {}.canonical_bytes());
565 assert_eq!(v["action"], "get_inbox");
566 assert_eq!(
567 v.as_object().unwrap().len(),
568 1,
569 "canonical get_inbox payload must be exactly {{action}}"
570 );
571 }
572
573 #[test]
574 fn report_and_delete_message_canonical_shapes() {
575 let id =
576 Uuid::parse_str("11111111-2222-3333-4444-555555555555").unwrap();
577 let cases: [(SignedAction, &str); 2] = [
578 (
579 SignedAction::ReportMessage {
580 message_id: MessageId::from(id),
581 message_key: None,
582 },
583 "report_message",
584 ),
585 (
586 SignedAction::DeleteMessage {
587 message_id: MessageId::from(id),
588 },
589 "delete_message",
590 ),
591 ];
592 for (action, tag) in cases {
593 let v = parse(&action.canonical_bytes());
594 assert_eq!(v["action"], tag);
595 assert_eq!(v["message_id"], id.to_string());
596 assert_eq!(
597 v.as_object().unwrap().len(),
598 2,
599 "canonical {tag} payload must be exactly \
600 {{action, message_id}}"
601 );
602 }
603 }
604
605 #[test]
612 fn signing_does_not_move_payload() {
613 let payload = CreateCommentPayload {
614 reply_to: Uuid::nil(),
615 body: "borrowable".to_string(),
616 };
617 let _bytes = SignedAction::from(&payload).canonical_bytes();
618 assert_eq!(payload.body, "borrowable");
620 }
621}