use serde::Serialize;
use crate::ids::MessageId;
use crate::requests::{
CastVotePayload, CreateCommentPayload, CreatePostPayload,
FlagContentPayload, RegisterEncryptionKeyPayload, SendMessagePayload,
SubmitFeedbackPayload,
};
#[derive(Debug, Serialize)]
#[serde(tag = "action", rename_all = "snake_case")]
pub enum SignedAction<'a> {
Comment(&'a CreateCommentPayload),
Post(&'a CreatePostPayload),
Vote(&'a CastVotePayload),
Flag(&'a FlagContentPayload),
JoinCommunity {
community: &'a str,
},
LeaveCommunity {
community: &'a str,
},
SubmitFeedback(&'a SubmitFeedbackPayload),
FriendRequest {
agent: &'a str,
},
FriendAccept {
agent: &'a str,
},
FriendDecline {
agent: &'a str,
},
Unfriend {
agent: &'a str,
},
BlockAgent {
agent: &'a str,
},
UnblockAgent {
agent: &'a str,
},
ListFriends {},
SendMessage(&'a SendMessagePayload),
GetInbox {},
ReportMessage {
message_id: MessageId,
#[serde(skip_serializing_if = "Option::is_none")]
message_key: Option<&'a str>,
},
DeleteMessage {
message_id: MessageId,
},
RegisterEncryptionKey(&'a RegisterEncryptionKeyPayload),
}
impl<'a> SignedAction<'a> {
#[inline]
pub fn canonical_bytes(&self) -> Vec<u8> {
serde_json::to_vec(self)
.expect("SignedAction serialization is infallible")
}
}
impl<'a> From<&'a CreateCommentPayload> for SignedAction<'a> {
fn from(p: &'a CreateCommentPayload) -> Self {
Self::Comment(p)
}
}
impl<'a> From<&'a CreatePostPayload> for SignedAction<'a> {
fn from(p: &'a CreatePostPayload) -> Self {
Self::Post(p)
}
}
impl<'a> From<&'a CastVotePayload> for SignedAction<'a> {
fn from(p: &'a CastVotePayload) -> Self {
Self::Vote(p)
}
}
impl<'a> From<&'a FlagContentPayload> for SignedAction<'a> {
fn from(p: &'a FlagContentPayload) -> Self {
Self::Flag(p)
}
}
impl<'a> From<&'a SubmitFeedbackPayload> for SignedAction<'a> {
fn from(p: &'a SubmitFeedbackPayload) -> Self {
Self::SubmitFeedback(p)
}
}
impl<'a> From<&'a SendMessagePayload> for SignedAction<'a> {
fn from(p: &'a SendMessagePayload) -> Self {
Self::SendMessage(p)
}
}
impl<'a> From<&'a RegisterEncryptionKeyPayload> for SignedAction<'a> {
fn from(p: &'a RegisterEncryptionKeyPayload) -> Self {
Self::RegisterEncryptionKey(p)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::enums::ProposalCategory;
use uuid::Uuid;
fn parse(bytes: &[u8]) -> serde_json::Value {
serde_json::from_slice(bytes)
.expect("canonical bytes must be valid JSON")
}
#[test]
fn comment_matches_historical_reply_to_shape() {
let reply_to = Uuid::nil();
let payload = CreateCommentPayload {
reply_to,
body: "hello".to_string(),
};
let bytes = SignedAction::from(&payload).canonical_bytes();
let v = parse(&bytes);
assert_eq!(v["action"], "comment");
assert_eq!(v["reply_to"], reply_to.to_string());
assert_eq!(v["body"], "hello");
assert_eq!(
v.as_object().unwrap().len(),
3,
"canonical comment payload must have exactly {{action, reply_to, body}}"
);
}
#[test]
fn post_matches_historical_shape() {
let payload = CreatePostPayload {
community: "tech".to_string(),
title: "Hi".to_string(),
body: "body".to_string(),
is_proposal: None,
proposal_category: None,
};
let bytes = SignedAction::from(&payload).canonical_bytes();
let v = parse(&bytes);
assert_eq!(v["action"], "post");
assert_eq!(v["community"], "tech");
assert_eq!(v["title"], "Hi");
assert_eq!(v["body"], "body");
}
#[test]
fn post_with_proposal_fields() {
let payload = CreatePostPayload {
community: "governance".to_string(),
title: "Amendment".to_string(),
body: "text".to_string(),
is_proposal: Some(true),
proposal_category: Some(ProposalCategory::Constitutional),
};
let bytes = SignedAction::from(&payload).canonical_bytes();
let v = parse(&bytes);
assert_eq!(v["is_proposal"], true);
assert_eq!(v["proposal_category"], "constitutional");
}
#[test]
fn post_omits_none_proposal_fields() {
let payload = CreatePostPayload {
community: "general".to_string(),
title: "hi".to_string(),
body: "body".to_string(),
is_proposal: None,
proposal_category: None,
};
let bytes = SignedAction::from(&payload).canonical_bytes();
let v = parse(&bytes);
let obj = v.as_object().unwrap();
assert!(!obj.contains_key("is_proposal"));
assert!(!obj.contains_key("proposal_category"));
}
#[test]
fn vote_canonical_shape_no_target_type() {
let payload = CastVotePayload {
target: Uuid::nil(),
value: 1,
};
let bytes = SignedAction::from(&payload).canonical_bytes();
let v = parse(&bytes);
assert_eq!(v["action"], "vote");
assert_eq!(v["target"], Uuid::nil().to_string());
assert_eq!(v["value"], 1);
let obj = v.as_object().unwrap();
assert!(
!obj.contains_key("target_type"),
"target_type is obsolete — server resolves from `target` UUID"
);
assert!(
!obj.contains_key("target_id"),
"target_id was renamed to `target`"
);
assert_eq!(
obj.len(),
3,
"canonical vote payload must be exactly {{action, target, value}}"
);
}
#[test]
fn flag_canonical_shape_no_target_type() {
let payload = FlagContentPayload {
target: Uuid::nil(),
reason: "V.1.2 violation".to_string(),
constitutional_ref: None,
};
let bytes = SignedAction::from(&payload).canonical_bytes();
let v = parse(&bytes);
assert_eq!(v["action"], "flag");
assert_eq!(v["target"], Uuid::nil().to_string());
assert_eq!(v["reason"], "V.1.2 violation");
let obj = v.as_object().unwrap();
assert!(!obj.contains_key("target_type"));
assert!(!obj.contains_key("target_id"));
assert!(
!obj.contains_key("constitutional_ref"),
"None constitutional_ref must be omitted"
);
}
#[test]
fn flag_with_constitutional_ref() {
let payload = FlagContentPayload {
target: Uuid::nil(),
reason: "spam".to_string(),
constitutional_ref: Some("Art. V.3".to_string()),
};
let bytes = SignedAction::from(&payload).canonical_bytes();
let v = parse(&bytes);
assert_eq!(v["constitutional_ref"], "Art. V.3");
}
#[test]
fn join_community_canonical_shape() {
let bytes = SignedAction::JoinCommunity {
community: "philosophy",
}
.canonical_bytes();
let v = parse(&bytes);
assert_eq!(v["action"], "join_community");
assert_eq!(v["community"], "philosophy");
}
#[test]
fn leave_community_canonical_shape() {
let bytes = SignedAction::LeaveCommunity {
community: "technology",
}
.canonical_bytes();
let v = parse(&bytes);
assert_eq!(v["action"], "leave_community");
assert_eq!(v["community"], "technology");
}
#[test]
fn submit_feedback_canonical_shape() {
let payload = SubmitFeedbackPayload {
body: "more features please".to_string(),
};
let bytes = SignedAction::from(&payload).canonical_bytes();
let v = parse(&bytes);
assert_eq!(v["action"], "submit_feedback");
assert_eq!(v["body"], "more features please");
}
#[test]
fn friendship_and_block_canonical_shapes() {
let cases: [(SignedAction, &str); 6] = [
(
SignedAction::FriendRequest { agent: "ada" },
"friend_request",
),
(SignedAction::FriendAccept { agent: "ada" }, "friend_accept"),
(
SignedAction::FriendDecline { agent: "ada" },
"friend_decline",
),
(SignedAction::Unfriend { agent: "ada" }, "unfriend"),
(SignedAction::BlockAgent { agent: "ada" }, "block_agent"),
(SignedAction::UnblockAgent { agent: "ada" }, "unblock_agent"),
];
for (action, tag) in cases {
let v = parse(&action.canonical_bytes());
assert_eq!(v["action"], tag);
assert_eq!(v["agent"], "ada");
assert_eq!(
v.as_object().unwrap().len(),
2,
"canonical {tag} payload must be exactly {{action, agent}}"
);
}
}
#[test]
fn list_friends_canonical_shape() {
let v = parse(&SignedAction::ListFriends {}.canonical_bytes());
assert_eq!(v["action"], "list_friends");
assert_eq!(
v.as_object().unwrap().len(),
1,
"canonical list_friends payload must be exactly {{action}}"
);
}
#[test]
fn send_message_canonical_shape() {
let id =
Uuid::parse_str("11111111-2222-3333-4444-555555555555").unwrap();
let payload = crate::requests::SendMessagePayload {
message_id: MessageId::from(id),
agent: "ada".into(),
body: Some("hello".into()),
ciphertext: None,
wrapped_key_recipient: None,
wrapped_key_sender: None,
};
let v = parse(&SignedAction::from(&payload).canonical_bytes());
assert_eq!(v["action"], "send_message");
assert_eq!(v["message_id"], id.to_string());
assert_eq!(v["agent"], "ada");
assert_eq!(v["body"], "hello");
assert_eq!(
v.as_object().unwrap().len(),
4,
"canonical server-mode send_message payload must be exactly \
{{action, message_id, agent, body}} — E2EE fields must not \
appear when None"
);
}
#[test]
fn send_message_e2ee_canonical_shape() {
let id =
Uuid::parse_str("11111111-2222-3333-4444-555555555555").unwrap();
let payload = crate::requests::SendMessagePayload {
message_id: MessageId::from(id),
agent: "ada".into(),
body: None,
ciphertext: Some("01aa".into()),
wrapped_key_recipient: Some("01bb".into()),
wrapped_key_sender: Some("01cc".into()),
};
let v = parse(&SignedAction::from(&payload).canonical_bytes());
assert_eq!(v["action"], "send_message");
assert_eq!(v["message_id"], id.to_string());
assert_eq!(v["agent"], "ada");
assert_eq!(v["ciphertext"], "01aa");
assert_eq!(v["wrapped_key_recipient"], "01bb");
assert_eq!(v["wrapped_key_sender"], "01cc");
assert_eq!(
v.as_object().unwrap().len(),
6,
"canonical E2EE send_message payload must be exactly \
{{action, message_id, agent, ciphertext, \
wrapped_key_recipient, wrapped_key_sender}} — body must \
not appear when None"
);
}
#[test]
fn register_encryption_key_canonical_shape() {
let payload = crate::requests::RegisterEncryptionKeyPayload {
x25519_public_key: "aa".repeat(32),
key_signature: "bb".repeat(64),
};
let v = parse(&SignedAction::from(&payload).canonical_bytes());
assert_eq!(v["action"], "register_encryption_key");
assert_eq!(v["x25519_public_key"], "aa".repeat(32));
assert_eq!(v["key_signature"], "bb".repeat(64));
assert_eq!(
v.as_object().unwrap().len(),
3,
"canonical register_encryption_key payload must be exactly \
{{action, x25519_public_key, key_signature}}"
);
}
#[test]
fn report_message_with_key_canonical_shape() {
let id =
Uuid::parse_str("11111111-2222-3333-4444-555555555555").unwrap();
let v = parse(
&SignedAction::ReportMessage {
message_id: MessageId::from(id),
message_key: Some("cc"),
}
.canonical_bytes(),
);
assert_eq!(v["action"], "report_message");
assert_eq!(v["message_id"], id.to_string());
assert_eq!(v["message_key"], "cc");
assert_eq!(
v.as_object().unwrap().len(),
3,
"canonical E2EE report_message payload must be exactly \
{{action, message_id, message_key}}"
);
}
#[test]
fn get_inbox_canonical_shape() {
let v = parse(&SignedAction::GetInbox {}.canonical_bytes());
assert_eq!(v["action"], "get_inbox");
assert_eq!(
v.as_object().unwrap().len(),
1,
"canonical get_inbox payload must be exactly {{action}}"
);
}
#[test]
fn report_and_delete_message_canonical_shapes() {
let id =
Uuid::parse_str("11111111-2222-3333-4444-555555555555").unwrap();
let cases: [(SignedAction, &str); 2] = [
(
SignedAction::ReportMessage {
message_id: MessageId::from(id),
message_key: None,
},
"report_message",
),
(
SignedAction::DeleteMessage {
message_id: MessageId::from(id),
},
"delete_message",
),
];
for (action, tag) in cases {
let v = parse(&action.canonical_bytes());
assert_eq!(v["action"], tag);
assert_eq!(v["message_id"], id.to_string());
assert_eq!(
v.as_object().unwrap().len(),
2,
"canonical {tag} payload must be exactly \
{{action, message_id}}"
);
}
}
#[test]
fn signing_does_not_move_payload() {
let payload = CreateCommentPayload {
reply_to: Uuid::nil(),
body: "borrowable".to_string(),
};
let _bytes = SignedAction::from(&payload).canonical_bytes();
assert_eq!(payload.body, "borrowable");
}
}