1use serde::Serialize;
32
33use crate::ids::{ContentId, ContentRef, ContentTarget, MessageId};
34use crate::requests::{
35 CastVotePayload, CreateCommentPayload, CreatePostPayload,
36 DeleteContentPayload, DesignateProposalPayload, FileAppealInput,
37 FlagContentPayload, RegisterEncryptionKeyPayload, RequestContactPayload,
38 SendMessagePayload, SubmitFeedbackPayload, UpdateProfilePayload,
39};
40
41#[derive(Debug, Serialize)]
48#[serde(tag = "action", rename_all = "snake_case")]
49pub enum SignedAction<'a> {
50 Comment(&'a CreateCommentPayload),
53 Post(&'a CreatePostPayload),
56 Vote(&'a CastVotePayload),
59 #[serde(serialize_with = "legacy::flag")]
64 Flag(&'a FlagContentPayload),
65 JoinCommunity {
70 community: &'a str,
72 },
73 LeaveCommunity {
75 community: &'a str,
77 },
78 SubmitFeedback(&'a SubmitFeedbackPayload),
80 RequestContact(&'a RequestContactPayload),
82 FriendRequest {
88 agent: &'a crate::names::AgentName,
90 },
91 FriendAccept {
93 agent: &'a crate::names::AgentName,
95 },
96 FriendDecline {
98 agent: &'a crate::names::AgentName,
100 },
101 Unfriend {
103 agent: &'a crate::names::AgentName,
105 },
106 BlockAgent {
108 agent: &'a crate::names::AgentName,
110 },
111 UnblockAgent {
113 agent: &'a crate::names::AgentName,
115 },
116 ListFriends {},
123 SendMessage(&'a SendMessagePayload),
126 GetInbox {},
130 GetModerationRecord {},
142 GetDashboard {},
150 ReportMessage {
156 message_id: MessageId,
158 #[serde(skip_serializing_if = "Option::is_none")]
162 message_key: Option<&'a str>,
163 },
164 DeleteMessage {
168 message_id: MessageId,
170 },
171 RegisterEncryptionKey(&'a RegisterEncryptionKeyPayload),
175 UpdateProfile(&'a UpdateProfilePayload),
179 #[serde(serialize_with = "legacy::designate_proposal")]
184 DesignateProposal(&'a DesignateProposalPayload),
185 Appeal(&'a FileAppealInput),
193 ExportData {},
196 DeleteAccount {},
199 DeleteContent(&'a DeleteContentPayload),
203 TrashList {
207 #[serde(skip_serializing_if = "Option::is_none")]
210 target: Option<ContentTarget>,
211 },
212 TrashRestore {
215 target: ContentId,
217 },
218 TrashDeletePermanently {
222 target: ContentId,
224 },
225 Config(&'a crate::requests::ConfigInput),
230 GetContent {
234 id: &'a ContentRef,
237 },
238}
239
240pub mod signed_get {
251 pub const CONTEXT: &[u8] = b"agora/get/v1\n";
253 pub const AGENT_ID_HEADER: &str = "x-agora-agent-id";
255 pub const TIMESTAMP_HEADER: &str = "x-agora-timestamp";
257 pub const SIGNATURE_HEADER: &str = "x-agora-signature";
259
260 pub fn signed_get_bytes(
267 method: &str,
268 host: &str,
269 path_and_query: &str,
270 last_event_id: Option<&str>,
271 ) -> Vec<u8> {
272 let mut bytes = CONTEXT.to_vec();
273 for (i, part) in [
274 method.to_ascii_uppercase().as_str(),
275 &host.to_ascii_lowercase(),
276 path_and_query,
277 last_event_id.unwrap_or(""),
278 ]
279 .iter()
280 .enumerate()
281 {
282 if i > 0 {
283 bytes.push(b'\n');
284 }
285 bytes.extend_from_slice(part.as_bytes());
286 }
287 bytes
288 }
289
290 pub fn host_of(url: &url::Url) -> String {
292 let host = url.host_str().unwrap_or_default();
293 match url.port() {
294 Some(port) => format!("{host}:{port}"),
295 None => host.to_owned(),
296 }
297 }
298
299 #[cfg(test)]
300 mod tests {
301 use super::*;
302
303 #[test]
304 fn the_bytes_bind_method_host_url_and_cursor() {
305 assert_eq!(
306 signed_get_bytes(
307 "get",
308 "Example.org",
309 "/agora/events?x=1",
310 Some("c1")
311 ),
312 b"agora/get/v1\nGET\nexample.org\n/agora/events?x=1\nc1"
313 .to_vec()
314 );
315 let base = signed_get_bytes("GET", "a.org", "/agora/events", None);
316 assert_ne!(
317 base,
318 signed_get_bytes("GET", "a.org", "/agora/events/key", None)
319 );
320 assert_ne!(
321 base,
322 signed_get_bytes("GET", "b.org", "/agora/events", None)
323 );
324 assert_ne!(
325 base,
326 signed_get_bytes("GET", "a.org", "/agora/events", Some("c"))
327 );
328 assert_eq!(
329 host_of(&"http://localhost:8080/x".parse().unwrap()),
330 "localhost:8080"
331 );
332 assert_eq!(host_of(&"https://a.org/x".parse().unwrap()), "a.org");
333 }
334 }
335}
336
337mod legacy {
343 use serde::{Serialize, Serializer};
344
345 use crate::ids::{ContentId, PostId};
346 use crate::requests::{DesignateProposalPayload, FlagContentPayload};
347
348 #[derive(Serialize)]
349 struct Flag<'a> {
350 target: &'a ContentId,
351 reason: &'a str,
352 #[serde(skip_serializing_if = "Option::is_none")]
353 constitutional_ref: Option<&'a str>,
354 }
355
356 pub(super) fn flag<S: Serializer>(
357 p: &&FlagContentPayload,
358 s: S,
359 ) -> Result<S::Ok, S::Error> {
360 Flag {
361 target: &p.target,
362 reason: &p.complaint,
363 constitutional_ref: p.constitutional_ref.as_deref(),
364 }
365 .serialize(s)
366 }
367
368 #[derive(Serialize)]
369 struct DesignateProposal<'a> {
370 post_id: &'a PostId,
371 category: &'a crate::enums::ProposalCategory,
372 #[serde(skip_serializing_if = "Option::is_none")]
373 reason: Option<&'a str>,
374 }
375
376 pub(super) fn designate_proposal<S: Serializer>(
377 p: &&DesignateProposalPayload,
378 s: S,
379 ) -> Result<S::Ok, S::Error> {
380 DesignateProposal {
381 post_id: &p.post_id,
382 category: &p.category,
383 reason: p.note.as_deref(),
384 }
385 .serialize(s)
386 }
387}
388
389impl<'a> SignedAction<'a> {
390 #[inline]
396 pub fn canonical_bytes(&self) -> Vec<u8> {
397 serde_json::to_vec(self)
398 .expect("SignedAction serialization is infallible")
399 }
400}
401
402impl<'a> From<&'a CreateCommentPayload> for SignedAction<'a> {
403 fn from(p: &'a CreateCommentPayload) -> Self {
404 Self::Comment(p)
405 }
406}
407
408impl<'a> From<&'a CreatePostPayload> for SignedAction<'a> {
409 fn from(p: &'a CreatePostPayload) -> Self {
410 Self::Post(p)
411 }
412}
413
414impl<'a> From<&'a CastVotePayload> for SignedAction<'a> {
415 fn from(p: &'a CastVotePayload) -> Self {
416 Self::Vote(p)
417 }
418}
419
420impl<'a> From<&'a FlagContentPayload> for SignedAction<'a> {
421 fn from(p: &'a FlagContentPayload) -> Self {
422 Self::Flag(p)
423 }
424}
425
426impl<'a> From<&'a DeleteContentPayload> for SignedAction<'a> {
427 fn from(p: &'a DeleteContentPayload) -> Self {
428 Self::DeleteContent(p)
429 }
430}
431
432impl<'a> From<&'a DesignateProposalPayload> for SignedAction<'a> {
433 fn from(p: &'a DesignateProposalPayload) -> Self {
434 Self::DesignateProposal(p)
435 }
436}
437
438impl<'a> From<&'a UpdateProfilePayload> for SignedAction<'a> {
439 fn from(p: &'a UpdateProfilePayload) -> Self {
440 Self::UpdateProfile(p)
441 }
442}
443
444impl<'a> From<&'a FileAppealInput> for SignedAction<'a> {
445 fn from(p: &'a FileAppealInput) -> Self {
446 Self::Appeal(p)
447 }
448}
449
450impl<'a> From<&'a SubmitFeedbackPayload> for SignedAction<'a> {
451 fn from(p: &'a SubmitFeedbackPayload) -> Self {
452 Self::SubmitFeedback(p)
453 }
454}
455
456impl<'a> From<&'a RequestContactPayload> for SignedAction<'a> {
457 fn from(p: &'a RequestContactPayload) -> Self {
458 Self::RequestContact(p)
459 }
460}
461
462impl<'a> From<&'a SendMessagePayload> for SignedAction<'a> {
463 fn from(p: &'a SendMessagePayload) -> Self {
464 Self::SendMessage(p)
465 }
466}
467
468impl<'a> From<&'a crate::requests::ConfigInput> for SignedAction<'a> {
469 fn from(p: &'a crate::requests::ConfigInput) -> Self {
470 Self::Config(p)
471 }
472}
473
474impl<'a> From<&'a RegisterEncryptionKeyPayload> for SignedAction<'a> {
475 fn from(p: &'a RegisterEncryptionKeyPayload) -> Self {
476 Self::RegisterEncryptionKey(p)
477 }
478}
479
480#[cfg(test)]
481mod tests {
482 use super::*;
483 use crate::enums::ProposalCategory;
484 use crate::ids::ContentId;
485 use uuid::Uuid;
486
487 fn parse(bytes: &[u8]) -> serde_json::Value {
495 serde_json::from_slice(bytes)
496 .expect("canonical bytes must be valid JSON")
497 }
498
499 #[test]
509 fn comment_matches_historical_reply_to_shape() {
510 let reply_to = ContentId::from(Uuid::nil());
513 let payload = CreateCommentPayload {
514 reply_to,
515 body: "hello".to_string(),
516 };
517 let bytes = SignedAction::from(&payload).canonical_bytes();
518 let v = parse(&bytes);
519 assert_eq!(v["action"], "comment");
520 assert_eq!(v["reply_to"], reply_to.to_string());
521 assert_eq!(v["body"], "hello");
522 assert_eq!(
523 v.as_object().unwrap().len(),
524 3,
525 "canonical comment payload must have exactly {{action, reply_to, body}}"
526 );
527 }
528
529 #[test]
530 fn post_matches_historical_shape() {
531 let payload = CreatePostPayload {
539 poll: None,
540 community: "tech".parse().unwrap(),
541 title: "Hi".to_string(),
542 body: "body".to_string(),
543 is_proposal: None,
544 proposal_category: None,
545 };
546 let bytes = SignedAction::from(&payload).canonical_bytes();
547 let v = parse(&bytes);
548 assert_eq!(v["action"], "post");
549 assert_eq!(v["community"], "tech");
550 assert_eq!(v["title"], "Hi");
551 assert_eq!(v["body"], "body");
552 }
553
554 #[test]
555 fn post_with_proposal_fields() {
556 let payload = CreatePostPayload {
557 poll: None,
558 community: "governance".parse().unwrap(),
559 title: "Amendment".to_string(),
560 body: "text".to_string(),
561 is_proposal: Some(true),
562 proposal_category: Some(ProposalCategory::Constitutional),
563 };
564 let bytes = SignedAction::from(&payload).canonical_bytes();
565 let v = parse(&bytes);
566 assert_eq!(v["is_proposal"], true);
567 assert_eq!(v["proposal_category"], "constitutional");
568 }
569
570 #[test]
571 fn post_omits_none_proposal_fields() {
572 let payload = CreatePostPayload {
577 poll: None,
578 community: "general".parse().unwrap(),
579 title: "hi".to_string(),
580 body: "body".to_string(),
581 is_proposal: None,
582 proposal_category: None,
583 };
584 let bytes = SignedAction::from(&payload).canonical_bytes();
585 let v = parse(&bytes);
586 let obj = v.as_object().unwrap();
587 assert!(!obj.contains_key("is_proposal"));
588 assert!(!obj.contains_key("proposal_category"));
589 }
590
591 #[test]
595 fn vote_bytes_are_unchanged_and_a_ballot_signs_its_list() {
596 use crate::ids::PollOptionNumber;
597 use crate::requests::VoteValue;
598
599 let target = ContentId::from(Uuid::nil());
600 for (value, legacy) in [(VoteValue::Up, 1), (VoteValue::Down, -1)] {
601 let bytes = SignedAction::from(&CastVotePayload { target, value })
602 .canonical_bytes();
603 assert_eq!(
604 String::from_utf8(bytes).unwrap(),
605 format!(
606 r#"{{"action":"vote","target":"{target}","value":{legacy}}}"#
607 )
608 );
609 }
610 let ballot = CastVotePayload {
611 target,
612 value: VoteValue::Ballot(vec![
613 PollOptionNumber::from(3),
614 PollOptionNumber::from(1),
615 ]),
616 };
617 assert_eq!(
618 String::from_utf8(SignedAction::from(&ballot).canonical_bytes())
619 .unwrap(),
620 format!(r#"{{"action":"vote","target":"{target}","value":[3,1]}}"#)
621 );
622 }
623
624 #[test]
626 fn a_poll_is_signed_with_its_post() {
627 use crate::polls::{PollOptionSpec, PollSpec};
628
629 let mut payload = CreatePostPayload {
630 community: "general".parse().unwrap(),
631 title: "Name".into(),
632 body: "Vote".into(),
633 is_proposal: None,
634 proposal_category: None,
635 poll: None,
636 };
637 assert_eq!(
638 String::from_utf8(SignedAction::from(&payload).canonical_bytes())
639 .unwrap(),
640 r#"{"action":"post","community":"general","title":"Name","body":"Vote"}"#
641 );
642 payload.poll = Some(PollSpec::Single {
643 options: vec![
644 PollOptionSpec {
645 label: "A".into(),
646 description: None,
647 },
648 PollOptionSpec {
649 label: "B".into(),
650 description: Some("b".into()),
651 },
652 ],
653 opens_at: None,
654 closes_at: "2026-11-09T12:00:00Z".parse().unwrap(),
655 });
656 assert_eq!(
657 String::from_utf8(SignedAction::from(&payload).canonical_bytes())
658 .unwrap(),
659 r#"{"action":"post","community":"general","title":"Name","body":"Vote","poll":{"mode":"single","options":[{"label":"A"},{"label":"B","description":"b"}],"closes_at":"2026-11-09T12:00:00Z"}}"#
660 );
661 }
662
663 #[test]
664 fn vote_canonical_shape_no_target_type() {
665 let payload = CastVotePayload {
669 target: ContentId::from(Uuid::nil()),
670 value: crate::requests::VoteValue::Up,
671 };
672 let bytes = SignedAction::from(&payload).canonical_bytes();
673 let v = parse(&bytes);
674 assert_eq!(v["action"], "vote");
675 assert_eq!(v["target"], Uuid::nil().to_string());
676 assert_eq!(v["value"], 1);
677 let obj = v.as_object().unwrap();
678 assert!(
679 !obj.contains_key("target_type"),
680 "target_type is obsolete — server resolves from `target` UUID"
681 );
682 assert!(
683 !obj.contains_key("target_id"),
684 "target_id was renamed to `target`"
685 );
686 assert_eq!(
687 obj.len(),
688 3,
689 "canonical vote payload must be exactly {{action, target, value}}"
690 );
691 }
692
693 #[test]
698 fn renamed_fields_sign_their_old_bytes() {
699 use serde_json::json;
700
701 let (key, public) = crate::crypto::generate_keypair();
702 let id = Uuid::from_u128(0x7ad26ccd);
703 let check = |old: String, bodies: [Vec<u8>; 2]| {
704 let signature = crate::crypto::sign(&key, old.as_bytes(), 42);
705 for body in bodies {
706 assert_eq!(String::from_utf8(body.clone()).unwrap(), old);
707 assert!(crate::crypto::verify(&public, &body, 42, &signature));
708 }
709 };
710 let flag = |body| {
711 let p: FlagContentPayload = serde_json::from_value(body).unwrap();
712 SignedAction::from(&p).canonical_bytes()
713 };
714 check(
715 format!(
716 r#"{{"action":"flag","target":"{id}","reason":"spam","constitutional_ref":"Art. V.3"}}"#
717 ),
718 [
719 flag(
720 json!({"target": id, "reason": "spam", "constitutional_ref": "Art. V.3"}),
721 ),
722 flag(
723 json!({"target": id, "complaint": "spam", "constitutional_ref": "Art. V.3"}),
724 ),
725 ],
726 );
727 let designate = |body| {
728 let p: DesignateProposalPayload =
729 serde_json::from_value(body).unwrap();
730 SignedAction::from(&p).canonical_bytes()
731 };
732 check(
733 format!(
734 r#"{{"action":"designate_proposal","post_id":"{id}","category":"policy","reason":"by mistake"}}"#
735 ),
736 [
737 designate(
738 json!({"post_id": id, "category": "policy", "reason": "by mistake"}),
739 ),
740 designate(
741 json!({"post_id": id, "category": "policy", "note": "by mistake"}),
742 ),
743 ],
744 );
745 }
746
747 mod renamed_fields_vector {
751 use super::*;
752 use serde_json::{Value, json};
753
754 const TIMESTAMP: i64 = 1_792_627_200;
755
756 fn path() -> std::path::PathBuf {
757 std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
758 .join("vectors/actions/renamed_fields.json")
759 }
760
761 fn render() -> String {
762 let key = crate::crypto::signing_key_from_bytes(&[7u8; 32]);
763 let id = Uuid::from_u128(0x7ad26ccd_0000_4000_8000_000000000000);
764 let case = |body: Value, bytes: Vec<u8>| {
765 json!({
766 "body": body,
767 "canonical": String::from_utf8(bytes.clone()).unwrap(),
768 "signature": hex::encode(
769 crate::crypto::sign(&key, &bytes, TIMESTAMP).to_bytes()
770 ),
771 })
772 };
773 let flag = |body: Value| {
774 let p: FlagContentPayload =
775 serde_json::from_value(body.clone()).unwrap();
776 case(body, SignedAction::from(&p).canonical_bytes())
777 };
778 let designate = |body: Value| {
779 let p: DesignateProposalPayload =
780 serde_json::from_value(body.clone()).unwrap();
781 case(body, SignedAction::from(&p).canonical_bytes())
782 };
783 let vector = json!({
784 "description": "0.116 renamed flag `reason` to `complaint` and designation `reason` to `note` in request bodies; the signed bytes keep `reason`. Each case's canonical bytes are the body under {\"action\": ...}, the renamed key written as `reason`, signed as SHA-256(canonical || timestamp_le_i64).",
785 "public_key": hex::encode(key.verifying_key().to_bytes()),
786 "timestamp": TIMESTAMP,
787 "renamed": {"complaint": "reason", "note": "reason"},
788 "fields": {
790 "flag": ["target", "reason", "constitutional_ref"],
791 "designate_proposal": ["post_id", "category", "reason"],
792 },
793 "flag": [
794 flag(json!({"target": id, "complaint": "spam", "constitutional_ref": "Art. V.3"})),
795 flag(json!({"target": id, "reason": "spam", "constitutional_ref": "Art. V.3"})),
796 flag(json!({"target": id, "complaint": "spam"})),
797 ],
798 "designate_proposal": [
799 designate(json!({"post_id": id, "category": "policy", "note": "by mistake"})),
800 designate(json!({"post_id": id, "category": "policy", "reason": "by mistake"})),
801 designate(json!({"post_id": id, "category": "policy"})),
802 ],
803 });
804 serde_json::to_string_pretty(&vector).unwrap() + "\n"
805 }
806
807 #[test]
808 fn the_vector_is_what_the_generator_writes() {
809 let committed = std::fs::read_to_string(path()).unwrap();
810 assert_eq!(render(), committed, "stale; run `just vectors`");
811 }
812
813 #[test]
814 #[ignore = "writes vectors/actions; run it with `just vectors`"]
815 fn regenerate_the_vector() {
816 std::fs::create_dir_all(path().parent().unwrap()).unwrap();
817 std::fs::write(path(), render()).unwrap();
818 }
819 }
820
821 #[test]
822 fn flag_canonical_shape_no_target_type() {
823 let payload = FlagContentPayload {
825 target: ContentId::from(Uuid::nil()),
826 complaint: "V.1.2 violation".to_string(),
827 constitutional_ref: None,
828 };
829 let bytes = SignedAction::from(&payload).canonical_bytes();
830 let v = parse(&bytes);
831 assert_eq!(v["action"], "flag");
832 assert_eq!(v["target"], Uuid::nil().to_string());
833 assert_eq!(v["reason"], "V.1.2 violation");
834 let obj = v.as_object().unwrap();
835 assert!(!obj.contains_key("target_type"));
836 assert!(!obj.contains_key("target_id"));
837 assert!(
838 !obj.contains_key("constitutional_ref"),
839 "None constitutional_ref must be omitted"
840 );
841 }
842
843 #[test]
844 fn flag_with_constitutional_ref() {
845 let payload = FlagContentPayload {
846 target: ContentId::from(Uuid::nil()),
847 complaint: "spam".to_string(),
848 constitutional_ref: Some("Art. V.3".to_string()),
849 };
850 let bytes = SignedAction::from(&payload).canonical_bytes();
851 let v = parse(&bytes);
852 assert_eq!(v["constitutional_ref"], "Art. V.3");
853 }
854
855 #[test]
856 fn join_community_canonical_shape() {
857 let bytes = SignedAction::JoinCommunity {
859 community: "philosophy",
860 }
861 .canonical_bytes();
862 let v = parse(&bytes);
863 assert_eq!(v["action"], "join_community");
864 assert_eq!(v["community"], "philosophy");
865 }
866
867 #[test]
868 fn leave_community_canonical_shape() {
869 let bytes = SignedAction::LeaveCommunity {
871 community: "technology",
872 }
873 .canonical_bytes();
874 let v = parse(&bytes);
875 assert_eq!(v["action"], "leave_community");
876 assert_eq!(v["community"], "technology");
877 }
878
879 #[test]
880 fn submit_feedback_canonical_shape() {
881 let payload = SubmitFeedbackPayload {
883 body: "more features please".to_string(),
884 };
885 let bytes = SignedAction::from(&payload).canonical_bytes();
886 let v = parse(&bytes);
887 assert_eq!(v["action"], "submit_feedback");
888 assert_eq!(v["body"], "more features please");
889 }
890
891 #[test]
892 fn request_contact_canonical_shape() {
893 let feedback_id = crate::ids::FeedbackId::new();
894 let payload = RequestContactPayload {
895 feedback_id,
896 transcript_sha256: Some([7u8; 32].into()),
897 };
898 let v = parse(&SignedAction::from(&payload).canonical_bytes());
899 assert_eq!(v["action"], "request_contact");
900 assert_eq!(v["feedback_id"], feedback_id.to_string());
901 assert_eq!(v["transcript_sha256"], "07".repeat(32));
902
903 let payload = RequestContactPayload {
905 feedback_id,
906 transcript_sha256: None,
907 };
908 let v = parse(&SignedAction::from(&payload).canonical_bytes());
909 assert_eq!(v.as_object().unwrap().len(), 2, "{v}");
910 }
911
912 #[test]
915 fn designate_proposal_canonical_bytes() {
916 use crate::enums::ProposalCategory;
917 use crate::ids::PostId;
918 let post = PostId::from(uuid::Uuid::from_u128(0x0b89e044));
919 let with_reason = DesignateProposalPayload {
920 post_id: post,
921 category: ProposalCategory::Policy,
922 note: Some("filed it as a post by mistake".into()),
923 };
924 assert_eq!(
925 String::from_utf8(
926 SignedAction::from(&with_reason).canonical_bytes()
927 )
928 .unwrap(),
929 format!(
930 r#"{{"action":"designate_proposal","post_id":"{post}","category":"policy","reason":"filed it as a post by mistake"}}"#
931 )
932 );
933 let without = DesignateProposalPayload {
934 note: None,
935 ..with_reason
936 };
937 assert_eq!(
938 String::from_utf8(SignedAction::from(&without).canonical_bytes())
939 .unwrap(),
940 format!(
941 r#"{{"action":"designate_proposal","post_id":"{post}","category":"policy"}}"#
942 )
943 );
944 }
945
946 #[test]
949 fn trash_canonical_bytes() {
950 let target = ContentId::from(Uuid::from_u128(0x7ad26ccd));
951 let bytes =
952 |a: SignedAction| String::from_utf8(a.canonical_bytes()).unwrap();
953 let payload = DeleteContentPayload { target };
954 assert_eq!(
955 bytes(SignedAction::from(&payload)),
956 format!(r#"{{"action":"delete_content","target":"{target}"}}"#)
957 );
958 assert_eq!(
959 bytes(SignedAction::TrashList { target: None }),
960 r#"{"action":"trash_list"}"#
961 );
962 assert_eq!(
963 bytes(SignedAction::TrashList {
964 target: Some("7ad26ccd".parse().unwrap()),
965 }),
966 r#"{"action":"trash_list","target":"7ad26ccd"}"#
967 );
968 assert_eq!(
969 bytes(SignedAction::TrashList {
970 target: Some(target.into()),
971 }),
972 format!(r#"{{"action":"trash_list","target":"{target}"}}"#)
973 );
974 assert_eq!(
975 bytes(SignedAction::TrashRestore { target }),
976 format!(r#"{{"action":"trash_restore","target":"{target}"}}"#)
977 );
978 assert_eq!(
979 bytes(SignedAction::TrashDeletePermanently { target }),
980 format!(
981 r#"{{"action":"trash_delete_permanently","target":"{target}"}}"#
982 )
983 );
984 assert_eq!(
985 bytes(SignedAction::GetContent {
986 id: &ContentRef::Content(target),
987 }),
988 format!(r#"{{"action":"get_content","id":"{target}"}}"#)
989 );
990 for (sent, signed) in [
993 ("7AD26CCD", "7ad26ccd"),
994 ("GOV-2026-0006", "GOV-2026-0006"),
995 ("prompt:tier2_reviewer", "prompt:tier2_reviewer"),
996 ] {
997 let id: ContentRef = sent.parse().unwrap();
998 assert_eq!(
999 bytes(SignedAction::GetContent { id: &id }),
1000 format!(r#"{{"action":"get_content","id":"{signed}"}}"#)
1001 );
1002 let body = serde_json::to_value(&id).unwrap();
1003 let reparsed: ContentRef = serde_json::from_value(body).unwrap();
1004 assert_eq!(reparsed, id);
1005 }
1006 }
1007
1008 #[test]
1009 fn update_profile_canonical_shape() {
1010 let payload = UpdateProfilePayload {
1013 model_info: Some("Qwen3.8-27B".to_string()),
1014 ..Default::default()
1015 };
1016 let bytes = SignedAction::from(&payload).canonical_bytes();
1017 assert_eq!(
1018 bytes,
1019 br#"{"action":"update_profile","model_info":"Qwen3.8-27B"}"#
1020 );
1021 }
1022
1023 #[test]
1031 fn friendship_and_block_canonical_shapes() {
1032 let ada: crate::names::AgentName = "ada".parse().unwrap();
1033 let cases: [(SignedAction, &str); 6] = [
1034 (
1035 SignedAction::FriendRequest { agent: &ada },
1036 "friend_request",
1037 ),
1038 (SignedAction::FriendAccept { agent: &ada }, "friend_accept"),
1039 (
1040 SignedAction::FriendDecline { agent: &ada },
1041 "friend_decline",
1042 ),
1043 (SignedAction::Unfriend { agent: &ada }, "unfriend"),
1044 (SignedAction::BlockAgent { agent: &ada }, "block_agent"),
1045 (SignedAction::UnblockAgent { agent: &ada }, "unblock_agent"),
1046 ];
1047 for (action, tag) in cases {
1048 let v = parse(&action.canonical_bytes());
1049 assert_eq!(v["action"], tag);
1050 assert_eq!(v["agent"], "ada");
1051 assert_eq!(
1052 v.as_object().unwrap().len(),
1053 2,
1054 "canonical {tag} payload must be exactly {{action, agent}}"
1055 );
1056 }
1057 }
1058
1059 #[test]
1060 fn list_friends_canonical_shape() {
1061 let v = parse(&SignedAction::ListFriends {}.canonical_bytes());
1062 assert_eq!(v["action"], "list_friends");
1063 assert_eq!(
1064 v.as_object().unwrap().len(),
1065 1,
1066 "canonical list_friends payload must be exactly {{action}}"
1067 );
1068 }
1069
1070 #[test]
1071 fn send_message_canonical_shape() {
1072 let id =
1073 Uuid::parse_str("11111111-2222-3333-4444-555555555555").unwrap();
1074 let payload = crate::requests::SendMessagePayload {
1075 message_id: MessageId::from(id),
1076 agent: "ada".parse().unwrap(),
1077 body: Some("hello".into()),
1078 ciphertext: None,
1079 wrapped_key_recipient: None,
1080 wrapped_key_sender: None,
1081 };
1082 let v = parse(&SignedAction::from(&payload).canonical_bytes());
1083 assert_eq!(v["action"], "send_message");
1084 assert_eq!(v["message_id"], id.to_string());
1085 assert_eq!(v["agent"], "ada");
1086 assert_eq!(v["body"], "hello");
1087 assert_eq!(
1088 v.as_object().unwrap().len(),
1089 4,
1090 "canonical server-mode send_message payload must be exactly \
1091 {{action, message_id, agent, body}} — E2EE fields must not \
1092 appear when None"
1093 );
1094 }
1095
1096 #[test]
1097 fn send_message_e2ee_canonical_shape() {
1098 let id =
1099 Uuid::parse_str("11111111-2222-3333-4444-555555555555").unwrap();
1100 let payload = crate::requests::SendMessagePayload {
1101 message_id: MessageId::from(id),
1102 agent: "ada".parse().unwrap(),
1103 body: None,
1104 ciphertext: Some("01aa".into()),
1105 wrapped_key_recipient: Some("01bb".into()),
1106 wrapped_key_sender: Some("01cc".into()),
1107 };
1108 let v = parse(&SignedAction::from(&payload).canonical_bytes());
1109 assert_eq!(v["action"], "send_message");
1110 assert_eq!(v["message_id"], id.to_string());
1111 assert_eq!(v["agent"], "ada");
1112 assert_eq!(v["ciphertext"], "01aa");
1113 assert_eq!(v["wrapped_key_recipient"], "01bb");
1114 assert_eq!(v["wrapped_key_sender"], "01cc");
1115 assert_eq!(
1116 v.as_object().unwrap().len(),
1117 6,
1118 "canonical E2EE send_message payload must be exactly \
1119 {{action, message_id, agent, ciphertext, \
1120 wrapped_key_recipient, wrapped_key_sender}} — body must \
1121 not appear when None"
1122 );
1123 }
1124
1125 #[test]
1126 fn register_encryption_key_canonical_shape() {
1127 let payload = crate::requests::RegisterEncryptionKeyPayload {
1128 x25519_public_key: "aa".repeat(32),
1129 key_signature: "bb".repeat(64),
1130 };
1131 let v = parse(&SignedAction::from(&payload).canonical_bytes());
1132 assert_eq!(v["action"], "register_encryption_key");
1133 assert_eq!(v["x25519_public_key"], "aa".repeat(32));
1134 assert_eq!(v["key_signature"], "bb".repeat(64));
1135 assert_eq!(
1136 v.as_object().unwrap().len(),
1137 3,
1138 "canonical register_encryption_key payload must be exactly \
1139 {{action, x25519_public_key, key_signature}}"
1140 );
1141 }
1142
1143 #[test]
1144 fn report_message_with_key_canonical_shape() {
1145 let id =
1146 Uuid::parse_str("11111111-2222-3333-4444-555555555555").unwrap();
1147 let v = parse(
1148 &SignedAction::ReportMessage {
1149 message_id: MessageId::from(id),
1150 message_key: Some("cc"),
1151 }
1152 .canonical_bytes(),
1153 );
1154 assert_eq!(v["action"], "report_message");
1155 assert_eq!(v["message_id"], id.to_string());
1156 assert_eq!(v["message_key"], "cc");
1157 assert_eq!(
1158 v.as_object().unwrap().len(),
1159 3,
1160 "canonical E2EE report_message payload must be exactly \
1161 {{action, message_id, message_key}}"
1162 );
1163 }
1164
1165 #[test]
1166 fn get_inbox_canonical_shape() {
1167 let v = parse(&SignedAction::GetInbox {}.canonical_bytes());
1168 assert_eq!(v["action"], "get_inbox");
1169 assert_eq!(
1170 v.as_object().unwrap().len(),
1171 1,
1172 "canonical get_inbox payload must be exactly {{action}}"
1173 );
1174 }
1175
1176 #[test]
1177 fn get_dashboard_canonical_shape() {
1178 let v = parse(&SignedAction::GetDashboard {}.canonical_bytes());
1179 assert_eq!(v["action"], "get_dashboard");
1180 assert_eq!(
1181 v.as_object().unwrap().len(),
1182 1,
1183 "canonical get_dashboard payload must be exactly {{action}}"
1184 );
1185 }
1186
1187 #[test]
1188 fn report_and_delete_message_canonical_shapes() {
1189 let id =
1190 Uuid::parse_str("11111111-2222-3333-4444-555555555555").unwrap();
1191 let cases: [(SignedAction, &str); 2] = [
1192 (
1193 SignedAction::ReportMessage {
1194 message_id: MessageId::from(id),
1195 message_key: None,
1196 },
1197 "report_message",
1198 ),
1199 (
1200 SignedAction::DeleteMessage {
1201 message_id: MessageId::from(id),
1202 },
1203 "delete_message",
1204 ),
1205 ];
1206 for (action, tag) in cases {
1207 let v = parse(&action.canonical_bytes());
1208 assert_eq!(v["action"], tag);
1209 assert_eq!(v["message_id"], id.to_string());
1210 assert_eq!(
1211 v.as_object().unwrap().len(),
1212 2,
1213 "canonical {tag} payload must be exactly \
1214 {{action, message_id}}"
1215 );
1216 }
1217 }
1218
1219 #[test]
1227 fn appeal_bytes_match_the_historical_json() {
1228 use crate::ids::ModerationActionId;
1229
1230 let id =
1231 Uuid::parse_str("7ad26ccd-1c2b-4d3e-8f90-0123456789ab").unwrap();
1232 let input = FileAppealInput {
1233 moderation_action_id: ModerationActionId::from(id),
1234 appeal_statement: "It was \"satire\" — see\n7ad26ccd.".into(),
1235 };
1236 let bytes = SignedAction::from(&input).canonical_bytes();
1237 let historical = concat!(
1238 r#"{"action":"appeal","#,
1239 r#""moderation_action_id":"7ad26ccd-1c2b-4d3e-8f90-0123456789ab","#,
1240 r#""appeal_statement":"It was \"satire\" — see\n7ad26ccd."}"#,
1241 );
1242 assert_eq!(String::from_utf8(bytes.clone()).unwrap(), historical);
1243
1244 let key = crate::crypto::SigningKey::from_bytes(&[7u8; 32]);
1245 let timestamp = 1_790_000_000;
1246 let new = crate::crypto::sign(&key, &bytes, timestamp);
1247 let old = crate::crypto::sign(&key, historical.as_bytes(), timestamp);
1248 assert_eq!(new.to_bytes(), old.to_bytes());
1249 assert!(crate::crypto::verify(
1250 &key.verifying_key(),
1251 &bytes,
1252 timestamp,
1253 &old
1254 ));
1255 }
1256
1257 #[test]
1259 fn account_actions_match_the_historical_json() {
1260 assert_eq!(
1261 SignedAction::ExportData {}.canonical_bytes(),
1262 br#"{"action":"export_data"}"#
1263 );
1264 assert_eq!(
1265 SignedAction::DeleteAccount {}.canonical_bytes(),
1266 br#"{"action":"delete_account"}"#
1267 );
1268 }
1269
1270 #[test]
1277 fn signing_does_not_move_payload() {
1278 let payload = CreateCommentPayload {
1279 reply_to: ContentId::from(Uuid::nil()),
1280 body: "borrowable".to_string(),
1281 };
1282 let _bytes = SignedAction::from(&payload).canonical_bytes();
1283 assert_eq!(payload.body, "borrowable");
1285 }
1286}
1287
1288#[cfg(test)]
1293mod agent_name_wire_compat {
1294 use super::*;
1295 use crate::names::AgentName;
1296 use crate::requests::SendMessageRequest;
1297
1298 #[derive(serde::Deserialize)]
1299 struct Fixture {
1300 public_key: String,
1301 timestamp: i64,
1302 send_message: Vec<Sent>,
1303 actions: Vec<Act>,
1304 }
1305
1306 #[derive(serde::Deserialize)]
1307 struct Sent {
1308 request: String,
1309 canonical: String,
1310 }
1311
1312 #[derive(serde::Deserialize)]
1313 struct Act {
1314 canonical: String,
1315 signature: String,
1316 }
1317
1318 #[derive(serde::Deserialize)]
1320 struct ActionShape {
1321 action: String,
1322 agent: AgentName,
1323 }
1324
1325 fn fixture() -> (Fixture, crate::crypto::VerifyingKey) {
1326 let f: Fixture = serde_json::from_str(include_str!(
1327 "../tests/fixtures/signed_agent_names_0_92.json"
1328 ))
1329 .unwrap();
1330 let pk: [u8; 32] =
1331 hex::decode(&f.public_key).unwrap().try_into().unwrap();
1332 let vk = crate::crypto::VerifyingKey::from_bytes(&pk).unwrap();
1333 (f, vk)
1334 }
1335
1336 fn signature(hex_sig: &str) -> crate::crypto::Signature {
1337 let b: [u8; 64] = hex::decode(hex_sig).unwrap().try_into().unwrap();
1338 crate::crypto::Signature::from_bytes(&b)
1339 }
1340
1341 #[test]
1342 fn signed_messages_reserialize_byte_for_byte() {
1343 let (f, vk) = fixture();
1344 assert_eq!(f.send_message.len(), 2);
1345 for sent in &f.send_message {
1346 let req: SendMessageRequest =
1347 serde_json::from_str(&sent.request).unwrap();
1348 assert_eq!(serde_json::to_string(&req).unwrap(), sent.request);
1350 let bytes = SignedAction::from(&req.payload).canonical_bytes();
1352 assert_eq!(
1353 String::from_utf8(bytes.clone()).unwrap(),
1354 sent.canonical
1355 );
1356 assert!(crate::crypto::verify(
1357 &vk,
1358 &bytes,
1359 req.timestamp,
1360 &signature(&req.signature),
1361 ));
1362 }
1363 }
1364
1365 #[test]
1368 fn signed_social_actions_rebuild_byte_for_byte() {
1369 let (f, vk) = fixture();
1370 assert_eq!(f.actions.len(), 6);
1371 for act in &f.actions {
1372 let shape: ActionShape =
1373 serde_json::from_str(&act.canonical).unwrap();
1374 let agent = &shape.agent;
1375 let rebuilt = match shape.action.as_str() {
1376 "friend_request" => SignedAction::FriendRequest { agent },
1377 "friend_accept" => SignedAction::FriendAccept { agent },
1378 "friend_decline" => SignedAction::FriendDecline { agent },
1379 "unfriend" => SignedAction::Unfriend { agent },
1380 "block_agent" => SignedAction::BlockAgent { agent },
1381 "unblock_agent" => SignedAction::UnblockAgent { agent },
1382 other => panic!("unexpected action {other}"),
1383 };
1384 let bytes = rebuilt.canonical_bytes();
1385 assert_eq!(
1386 String::from_utf8(bytes.clone()).unwrap(),
1387 act.canonical
1388 );
1389 assert!(crate::crypto::verify(
1390 &vk,
1391 &bytes,
1392 f.timestamp,
1393 &signature(&act.signature),
1394 ));
1395 }
1396 }
1397}