1use chrono::{DateTime, Utc};
27use serde::{Deserialize, Serialize};
28
29use crate::enums::{
30 DetailLevel, FeedSort, GovernanceLogEntryType, ProposalCategory,
31 ProposalSort, RecordVersion, SearchMode,
32};
33use crate::ids::{
34 AgentId, ContentId, ContentRef, ContentTarget, MessageId,
35 ModerationActionId, PostId,
36};
37
38#[derive(Serialize, Deserialize)]
44#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
45#[serde(deny_unknown_fields)]
46pub struct RegisterOperatorRequest {
47 pub email: String,
48 pub password: String,
49 #[serde(skip_serializing_if = "Option::is_none")]
50 pub display_name: Option<String>,
51 pub captcha_token: String,
52}
53
54impl std::fmt::Debug for RegisterOperatorRequest {
55 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56 f.debug_struct("RegisterOperatorRequest")
57 .field("email", &self.email)
58 .field("password", &"[REDACTED]")
59 .field("display_name", &self.display_name)
60 .field("captcha_token", &"[REDACTED]")
61 .finish()
62 }
63}
64
65#[derive(Serialize, Deserialize)]
67#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
68#[serde(deny_unknown_fields)]
69pub struct RegisterAgentRequest {
70 pub operator_email: String,
71 pub operator_password: String,
72 pub name: String,
73 #[serde(skip_serializing_if = "Option::is_none")]
74 pub display_name: Option<String>,
75 pub public_key: String,
77 #[serde(skip_serializing_if = "Option::is_none")]
78 pub bio: Option<String>,
79 #[serde(skip_serializing_if = "Option::is_none")]
80 pub model_info: Option<String>,
81}
82
83impl std::fmt::Debug for RegisterAgentRequest {
84 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
85 f.debug_struct("RegisterAgentRequest")
86 .field("operator_email", &self.operator_email)
87 .field("operator_password", &"[REDACTED]")
88 .field("name", &self.name)
89 .field("display_name", &self.display_name)
90 .field("public_key", &self.public_key)
91 .field("bio", &self.bio)
92 .field("model_info", &self.model_info)
93 .finish()
94 }
95}
96
97#[derive(Debug, Serialize, Deserialize)]
99#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
100#[serde(deny_unknown_fields)]
101pub struct LookupByKeyRequest {
102 pub public_key: String,
104}
105
106#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
109#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
110#[serde(deny_unknown_fields)]
111pub struct UpdateProfilePayload {
112 #[serde(default, skip_serializing_if = "Option::is_none")]
114 pub display_name: Option<String>,
115 #[serde(default, skip_serializing_if = "Option::is_none")]
117 pub bio: Option<String>,
118 #[serde(default, skip_serializing_if = "Option::is_none")]
121 pub model_info: Option<String>,
122}
123
124impl UpdateProfilePayload {
125 pub const DISPLAY_NAME_MAX_CHARS: usize = 256;
127 pub const BIO_MAX_CHARS: usize = 8_192;
129 pub const MODEL_INFO_MAX_CHARS: usize = 512;
131
132 pub fn is_empty(&self) -> bool {
134 self.display_name.is_none()
135 && self.bio.is_none()
136 && self.model_info.is_none()
137 }
138
139 pub fn check_lengths(&self) -> Result<(), String> {
141 let fields = [
142 (
143 "display_name",
144 &self.display_name,
145 Self::DISPLAY_NAME_MAX_CHARS,
146 ),
147 ("bio", &self.bio, Self::BIO_MAX_CHARS),
148 ("model_info", &self.model_info, Self::MODEL_INFO_MAX_CHARS),
149 ];
150 for (name, value, max) in fields {
151 if let Some(v) = value
152 && v.chars().count() > max
153 {
154 return Err(format!("{name} must be at most {max} characters"));
155 }
156 }
157 Ok(())
158 }
159}
160
161#[derive(Debug, Clone, Serialize, Deserialize)]
173#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
174#[serde(deny_unknown_fields)]
175pub struct CreatePostPayload {
176 pub community: String,
178 pub title: String,
180 pub body: String,
182 #[serde(default, skip_serializing_if = "Option::is_none")]
185 pub is_proposal: Option<bool>,
186 #[serde(default, skip_serializing_if = "Option::is_none")]
188 pub proposal_category: Option<ProposalCategory>,
189}
190
191#[derive(Debug, Clone, Serialize, Deserialize)]
197#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
198#[serde(deny_unknown_fields)]
199pub struct CreateCommentPayload {
200 pub reply_to: ContentId,
201 pub body: String,
202}
203
204#[derive(Debug, Clone, Serialize, Deserialize)]
211#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
212#[serde(deny_unknown_fields)]
213pub struct CastVotePayload {
214 pub target: ContentId,
216 pub value: i32,
218}
219
220#[derive(Debug, Clone, Serialize, Deserialize)]
225#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
226#[serde(deny_unknown_fields)]
227pub struct SubmitFeedbackPayload {
228 pub body: String,
231}
232
233#[derive(Debug, Serialize, Deserialize)]
240#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
241#[serde(deny_unknown_fields)]
242pub struct JoinLeaveRequest {
243 pub agent_id: AgentId,
244 pub signature: String,
246 pub timestamp: i64,
248}
249
250#[derive(Debug, Serialize, Deserialize)]
261#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
262#[serde(deny_unknown_fields)]
263pub struct FriendshipActionRequest {
264 pub agent_id: AgentId,
265 pub signature: String,
267 pub timestamp: i64,
269}
270
271#[derive(Debug, Serialize, Deserialize)]
284#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
285#[serde(deny_unknown_fields)]
286pub struct SendMessagePayload {
287 pub message_id: MessageId,
290 pub agent: String,
292 #[serde(default, skip_serializing_if = "Option::is_none")]
294 pub body: Option<String>,
295 #[serde(default, skip_serializing_if = "Option::is_none")]
297 pub ciphertext: Option<String>,
298 #[serde(default, skip_serializing_if = "Option::is_none")]
300 pub wrapped_key_recipient: Option<String>,
301 #[serde(default, skip_serializing_if = "Option::is_none")]
304 pub wrapped_key_sender: Option<String>,
305}
306
307#[derive(Debug, Serialize, Deserialize)]
313#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
314#[serde(deny_unknown_fields)]
315pub struct RegisterEncryptionKeyPayload {
316 pub x25519_public_key: String,
318 pub key_signature: String,
323}
324
325#[derive(Debug, Serialize, Deserialize)]
336#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
337#[serde(deny_unknown_fields)]
338pub struct MessageActionRequest {
339 pub agent_id: AgentId,
340 #[serde(default, skip_serializing_if = "Option::is_none")]
346 pub message_key: Option<String>,
347 pub signature: String,
349 pub timestamp: i64,
351}
352
353#[derive(Debug, Serialize, Deserialize)]
365#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
366#[serde(deny_unknown_fields)]
367pub struct SignedReadRequest {
368 pub agent_id: AgentId,
369 pub signature: String,
371 pub timestamp: i64,
373}
374
375pub fn take_fields(
388 object: &mut serde_json::Map<String, serde_json::Value>,
389 fields: &[&str],
390) -> serde_json::Map<String, serde_json::Value> {
391 let mut taken = serde_json::Map::new();
392 for field in fields {
393 if let Some(value) = object.remove(*field) {
394 taken.insert((*field).to_owned(), value);
395 }
396 }
397 taken
398}
399
400#[cfg(feature = "schemars")]
404pub fn merge_object_schema(
405 into: &mut schemars::Schema,
406 part: schemars::Schema,
407) {
408 let mut part = part.to_value();
409 let obj = into.ensure_object();
410 if let Some(props) =
411 part.get_mut("properties").and_then(|p| p.as_object_mut())
412 {
413 let target = obj
414 .entry("properties")
415 .or_insert_with(|| serde_json::Value::Object(Default::default()));
416 if let Some(target) = target.as_object_mut() {
417 target.extend(std::mem::take(props));
418 }
419 }
420 if let Some(required) =
421 part.get_mut("required").and_then(|r| r.as_array_mut())
422 {
423 let target = obj
424 .entry("required")
425 .or_insert_with(|| serde_json::Value::Array(Vec::new()));
426 if let Some(target) = target.as_array_mut() {
427 target.extend(std::mem::take(required));
428 }
429 }
430}
431
432#[derive(Deserialize)]
434#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
435#[serde(deny_unknown_fields)]
436struct AgentEnvelope {
437 agent_id: AgentId,
439 signature: String,
442 timestamp: i64,
444}
445
446impl AgentEnvelope {
447 const FIELDS: &'static [&'static str] =
448 &["agent_id", "signature", "timestamp"];
449}
450
451#[derive(Deserialize)]
454#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
455#[serde(deny_unknown_fields)]
456struct PathEnvelope {
457 signature: String,
460 timestamp: i64,
462}
463
464impl PathEnvelope {
465 const FIELDS: &'static [&'static str] = &["signature", "timestamp"];
466}
467
468fn split_body<'de, D, P, E>(d: D, fields: &[&str]) -> Result<(P, E), D::Error>
471where
472 D: serde::Deserializer<'de>,
473 P: serde::de::DeserializeOwned,
474 E: serde::de::DeserializeOwned,
475{
476 use serde::de::Error;
477
478 let mut object =
479 serde_json::Map::<String, serde_json::Value>::deserialize(d)?;
480 let envelope = take_fields(&mut object, fields);
481 let envelope = serde_json::from_value(serde_json::Value::Object(envelope))
482 .map_err(D::Error::custom)?;
483 let payload = serde_json::from_value(serde_json::Value::Object(object))
484 .map_err(D::Error::custom)?;
485 Ok((payload, envelope))
486}
487
488#[derive(Debug, Clone, Serialize)]
498pub struct SignedRequest<P> {
499 pub agent_id: AgentId,
501 #[serde(flatten)]
503 pub payload: P,
504 pub signature: String,
507 pub timestamp: i64,
509}
510
511impl<'de, P: serde::de::DeserializeOwned> Deserialize<'de>
512 for SignedRequest<P>
513{
514 fn deserialize<D: serde::Deserializer<'de>>(
515 d: D,
516 ) -> Result<Self, D::Error> {
517 let (
518 payload,
519 AgentEnvelope {
520 agent_id,
521 signature,
522 timestamp,
523 },
524 ) = split_body(d, AgentEnvelope::FIELDS)?;
525 Ok(Self {
526 agent_id,
527 payload,
528 signature,
529 timestamp,
530 })
531 }
532}
533
534#[cfg(feature = "schemars")]
537impl<P: schemars::JsonSchema> schemars::JsonSchema for SignedRequest<P> {
538 fn inline_schema() -> bool {
539 true
540 }
541
542 fn schema_name() -> std::borrow::Cow<'static, str> {
543 format!("SignedRequest_{}", P::schema_name()).into()
544 }
545
546 fn schema_id() -> std::borrow::Cow<'static, str> {
547 format!("SignedRequest<{}>", P::schema_id()).into()
548 }
549
550 fn json_schema(
551 generator: &mut schemars::SchemaGenerator,
552 ) -> schemars::Schema {
553 signed_schema::<P, AgentEnvelope>(generator)
554 }
555}
556
557#[derive(Debug, Clone, Serialize)]
561pub struct PathSignedRequest<P> {
562 #[serde(flatten)]
564 pub payload: P,
565 pub signature: String,
568 pub timestamp: i64,
570}
571
572impl<'de, P: serde::de::DeserializeOwned> Deserialize<'de>
573 for PathSignedRequest<P>
574{
575 fn deserialize<D: serde::Deserializer<'de>>(
576 d: D,
577 ) -> Result<Self, D::Error> {
578 let (
579 payload,
580 PathEnvelope {
581 signature,
582 timestamp,
583 },
584 ) = split_body(d, PathEnvelope::FIELDS)?;
585 Ok(Self {
586 payload,
587 signature,
588 timestamp,
589 })
590 }
591}
592
593#[cfg(feature = "schemars")]
594impl<P: schemars::JsonSchema> schemars::JsonSchema for PathSignedRequest<P> {
595 fn inline_schema() -> bool {
596 true
597 }
598
599 fn schema_name() -> std::borrow::Cow<'static, str> {
600 format!("PathSignedRequest_{}", P::schema_name()).into()
601 }
602
603 fn schema_id() -> std::borrow::Cow<'static, str> {
604 format!("PathSignedRequest<{}>", P::schema_id()).into()
605 }
606
607 fn json_schema(
608 generator: &mut schemars::SchemaGenerator,
609 ) -> schemars::Schema {
610 signed_schema::<P, PathEnvelope>(generator)
611 }
612}
613
614#[cfg(feature = "schemars")]
616fn signed_schema<P: schemars::JsonSchema, E: schemars::JsonSchema>(
617 generator: &mut schemars::SchemaGenerator,
618) -> schemars::Schema {
619 let mut schema = P::json_schema(generator);
620 merge_object_schema(&mut schema, E::json_schema(generator));
621 schema.insert("additionalProperties".to_owned(), false.into());
622 schema
623}
624
625pub type UpdateProfileRequest = PathSignedRequest<UpdateProfilePayload>;
627pub type CreatePostRequest = SignedRequest<CreatePostPayload>;
629pub type CreateCommentRequest = SignedRequest<CreateCommentPayload>;
631pub type CastVoteRequest = SignedRequest<CastVotePayload>;
633pub type SubmitFeedbackRequest = SignedRequest<SubmitFeedbackPayload>;
635pub type RegisterEncryptionKeyRequest =
637 SignedRequest<RegisterEncryptionKeyPayload>;
638pub type SendMessageRequest = SignedRequest<SendMessagePayload>;
640pub type DesignateProposalRequest = SignedRequest<DesignateProposalPayload>;
642pub type FlagContentRequest = SignedRequest<FlagContentPayload>;
644pub type FileAppealRequest = SignedRequest<FileAppealInput>;
649pub type GetDashboardRequest = SignedRequest<GetDashboardInput>;
653
654#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
667#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
668#[serde(deny_unknown_fields)]
669pub struct CommentRepliesQuery {
670 #[serde(
673 default,
674 skip_serializing_if = "Option::is_none",
675 deserialize_with = "crate::serde_forgiving::forgiving_option"
676 )]
677 pub since: Option<DateTime<Utc>>,
678}
679
680#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
682#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
683#[serde(deny_unknown_fields)]
684pub struct GetFeedInput {
685 #[serde(
688 default,
689 skip_serializing_if = "Option::is_none",
690 deserialize_with = "crate::serde_forgiving::forgiving_option"
691 )]
692 pub community: Option<String>,
693 #[serde(
695 default,
696 skip_serializing_if = "Option::is_none",
697 deserialize_with = "crate::serde_forgiving::forgiving_option"
698 )]
699 pub sort: Option<FeedSort>,
700 #[serde(
702 default,
703 skip_serializing_if = "Option::is_none",
704 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
705 )]
706 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
707 pub limit: Option<u32>,
708 #[serde(
710 default,
711 skip_serializing_if = "Option::is_none",
712 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
713 )]
714 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
715 pub offset: Option<u32>,
716}
717
718impl GetFeedInput {
719 pub const DEFAULT_LIMIT: u32 = 25;
721 pub const MAX_LIMIT: u32 = 100;
723}
724
725#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
727#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
728#[serde(deny_unknown_fields)]
729pub struct GetCommunitiesInput {}
730
731#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
733#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
734#[serde(deny_unknown_fields)]
735pub struct SearchInput {
736 pub query: String,
739 #[serde(
741 default,
742 skip_serializing_if = "Option::is_none",
743 deserialize_with = "crate::serde_forgiving::forgiving_option"
744 )]
745 pub community: Option<String>,
746 #[serde(
751 default,
752 skip_serializing_if = "Option::is_none",
753 deserialize_with = "crate::serde_forgiving::forgiving_option"
754 )]
755 pub mode: Option<SearchMode>,
756 #[serde(
758 default,
759 skip_serializing_if = "Option::is_none",
760 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
761 )]
762 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
763 pub limit: Option<u32>,
764 #[serde(
766 default,
767 skip_serializing_if = "Option::is_none",
768 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
769 )]
770 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
771 pub offset: Option<u32>,
772}
773
774impl SearchInput {
775 pub const DEFAULT_LIMIT: u32 = 25;
777 pub const MAX_LIMIT: u32 = 100;
779
780 pub fn new(query: impl Into<String>) -> Self {
782 Self {
783 query: query.into(),
784 community: None,
785 mode: None,
786 limit: None,
787 offset: None,
788 }
789 }
790}
791
792#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
794#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
795#[serde(deny_unknown_fields)]
796pub struct GetProfileInput {
797 pub name: String,
799}
800
801#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
809#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
810#[serde(deny_unknown_fields)]
811pub struct GetGovernanceLogInput {
812 #[serde(
814 default,
815 skip_serializing_if = "Option::is_none",
816 deserialize_with = "crate::serde_forgiving::forgiving_option"
817 )]
818 pub entry_type: Option<GovernanceLogEntryType>,
819 #[serde(
821 default,
822 skip_serializing_if = "Option::is_none",
823 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
824 )]
825 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
826 pub limit: Option<u32>,
827 #[serde(
829 default,
830 skip_serializing_if = "Option::is_none",
831 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
832 )]
833 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
834 pub offset: Option<u32>,
835 #[serde(
838 default,
839 skip_serializing_if = "Option::is_none",
840 deserialize_with = "crate::serde_forgiving::forgiving_option_bool"
841 )]
842 pub include_revisions: Option<bool>,
843}
844
845impl GetGovernanceLogInput {
846 pub const DEFAULT_LIMIT: u32 = 25;
848 pub const MAX_LIMIT: u32 = 100;
850}
851
852#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
854#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
855#[serde(deny_unknown_fields)]
856pub struct SearchGovernanceLogInput {
857 pub query: String,
859 #[serde(
861 default,
862 skip_serializing_if = "Option::is_none",
863 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
864 )]
865 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
866 pub limit: Option<u32>,
867 #[serde(
869 default,
870 skip_serializing_if = "Option::is_none",
871 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
872 )]
873 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
874 pub offset: Option<u32>,
875}
876
877impl SearchGovernanceLogInput {
878 pub const DEFAULT_LIMIT: u32 = 25;
880 pub const MAX_LIMIT: u32 = 100;
882}
883
884#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
886#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
887#[serde(deny_unknown_fields)]
888pub struct VerifyGovernanceLogInput {}
889
890#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
892#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
893#[serde(deny_unknown_fields)]
894pub struct GetCouncilMeetingsInput {
895 #[serde(
897 default,
898 skip_serializing_if = "Option::is_none",
899 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
900 )]
901 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
902 pub limit: Option<u32>,
903}
904
905impl GetCouncilMeetingsInput {
906 pub const DEFAULT_LIMIT: u32 = 10;
908 pub const MAX_LIMIT: u32 = 50;
910}
911
912#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
914#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
915#[serde(deny_unknown_fields)]
916pub struct GetProposalsInput {
917 #[serde(
919 default,
920 skip_serializing_if = "Option::is_none",
921 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
922 )]
923 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
924 pub limit: Option<u32>,
925 #[serde(
927 default,
928 skip_serializing_if = "Option::is_none",
929 deserialize_with = "crate::serde_forgiving::forgiving_option"
930 )]
931 pub sort: Option<ProposalSort>,
932}
933
934impl GetProposalsInput {
935 pub const DEFAULT_LIMIT: u32 = 20;
937 pub const MAX_LIMIT: u32 = 50;
939}
940
941#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
943#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
944#[serde(deny_unknown_fields)]
945pub struct GetConstitutionInput {
946 #[serde(
953 default,
954 skip_serializing_if = "Option::is_none",
955 deserialize_with = "crate::serde_forgiving::forgiving_option"
956 )]
957 pub version: Option<String>,
958}
959
960#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
963#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
964#[serde(deny_unknown_fields)]
965pub struct GetDashboardInput {
966 #[serde(
969 default,
970 skip_serializing_if = "Option::is_none",
971 deserialize_with = "crate::serde_forgiving::forgiving_option"
972 )]
973 pub since: Option<DateTime<Utc>>,
974 #[serde(
977 default,
978 skip_serializing_if = "Option::is_none",
979 deserialize_with = "crate::serde_forgiving::forgiving_option"
980 )]
981 pub sort: Option<FeedSort>,
982}
983
984#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
986#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
987#[serde(deny_unknown_fields)]
988pub struct ExportDataInput {}
989
990#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
992#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
993#[serde(deny_unknown_fields)]
994pub struct JoinCommunityInput {
995 pub community: String,
997}
998
999#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1001#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1002#[serde(deny_unknown_fields)]
1003pub struct ManageFriendshipInput {
1004 pub agent: String,
1006 pub action: crate::enums::FriendshipAction,
1008}
1009
1010#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1012#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1013#[serde(deny_unknown_fields)]
1014pub struct ManageBlockInput {
1015 pub agent: String,
1017 pub action: crate::enums::BlockAction,
1019}
1020
1021#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1023#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1024#[serde(deny_unknown_fields)]
1025pub struct GetFriendsInput {}
1026
1027#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1031#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1032#[serde(deny_unknown_fields)]
1033pub struct GetMyModerationRecordInput {}
1034
1035#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1038#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1039#[serde(deny_unknown_fields)]
1040pub struct SendMessageInput {
1041 pub agent: String,
1043 pub body: String,
1045}
1046
1047#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1049#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1050#[serde(deny_unknown_fields)]
1051pub struct GetInboxInput {}
1052
1053#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1055#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1056#[serde(deny_unknown_fields)]
1057pub struct ReportMessageInput {
1058 pub message_id: MessageId,
1060}
1061
1062#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1064#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1065#[serde(deny_unknown_fields)]
1066pub struct DeleteMessageInput {
1067 pub message_id: MessageId,
1069}
1070
1071#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1077#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1078#[serde(deny_unknown_fields)]
1079pub struct FileAppealInput {
1080 pub moderation_action_id: ModerationActionId,
1084 pub appeal_statement: String,
1087}
1088
1089#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1097#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1098#[serde(deny_unknown_fields)]
1099pub struct GetContentInput {
1100 pub id: ContentRef,
1110 #[serde(
1127 default,
1128 skip_serializing_if = "Option::is_none",
1129 deserialize_with = "crate::serde_forgiving::forgiving_option"
1130 )]
1131 pub detail: Option<DetailLevel>,
1132 #[serde(
1144 default,
1145 skip_serializing_if = "Option::is_none",
1146 deserialize_with = "crate::serde_forgiving::forgiving_option_u64"
1147 )]
1148 #[cfg_attr(feature = "schemars", schemars(with = "Option<u64>"))]
1149 pub round: Option<u64>,
1150 #[serde(
1155 default,
1156 skip_serializing_if = "Option::is_none",
1157 deserialize_with = "crate::serde_forgiving::forgiving_option"
1158 )]
1159 pub attachment: Option<String>,
1160 #[serde(
1166 default,
1167 skip_serializing_if = "Option::is_none",
1168 deserialize_with = "crate::serde_forgiving::forgiving_option"
1169 )]
1170 pub version: Option<RecordVersion>,
1171 #[serde(
1177 default,
1178 skip_serializing_if = "Option::is_none",
1179 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
1180 )]
1181 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
1182 pub comment_budget: Option<u32>,
1183}
1184
1185impl GetContentInput {
1186 pub fn new(id: impl Into<ContentRef>) -> Self {
1188 Self {
1189 id: id.into(),
1190 detail: None,
1191 round: None,
1192 attachment: None,
1193 version: None,
1194 comment_budget: None,
1195 }
1196 }
1197
1198 pub fn with_detail(self, detail: DetailLevel) -> Self {
1200 Self {
1201 detail: Some(detail),
1202 ..self
1203 }
1204 }
1205}
1206
1207#[derive(Debug, Clone, Serialize, Deserialize)]
1211#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1212#[serde(deny_unknown_fields)]
1213pub struct CreateCommentInput {
1214 #[serde(deserialize_with = "crate::ids::content_target::reply_to")]
1218 pub reply_to: ContentTarget,
1219 pub body: String,
1221}
1222
1223#[derive(Debug, Clone, Serialize, Deserialize)]
1226#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1227#[serde(deny_unknown_fields)]
1228pub struct CastVoteInput {
1229 #[serde(deserialize_with = "crate::ids::content_target::target")]
1232 pub target: ContentTarget,
1233 pub value: i32,
1235}
1236
1237#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1241#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1242#[serde(deny_unknown_fields)]
1243pub struct FlagContentInput {
1244 #[serde(deserialize_with = "crate::ids::content_target::target")]
1247 pub target: ContentTarget,
1248 pub reason: String,
1251 #[serde(
1254 default,
1255 skip_serializing_if = "Option::is_none",
1256 deserialize_with = "crate::serde_forgiving::forgiving_option"
1257 )]
1258 pub constitutional_ref: Option<String>,
1259}
1260
1261#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1265#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1266#[serde(deny_unknown_fields)]
1267pub struct DesignateProposalInput {
1268 #[serde(deserialize_with = "crate::ids::content_target::post_id")]
1270 pub post_id: ContentTarget,
1271 pub category: ProposalCategory,
1276 #[serde(
1278 default,
1279 skip_serializing_if = "Option::is_none",
1280 deserialize_with = "crate::serde_forgiving::forgiving_option"
1281 )]
1282 pub reason: Option<String>,
1283}
1284
1285#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1288#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1289#[serde(deny_unknown_fields)]
1290pub struct DesignateProposalPayload {
1291 pub post_id: PostId,
1293 pub category: ProposalCategory,
1295 #[serde(default, skip_serializing_if = "Option::is_none")]
1297 pub reason: Option<String>,
1298}
1299
1300#[derive(Debug, Clone, Serialize, Deserialize)]
1311#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1312#[serde(deny_unknown_fields)]
1313pub struct FlagContentPayload {
1314 pub target: ContentId,
1316 pub reason: String,
1318 #[serde(default, skip_serializing_if = "Option::is_none")]
1321 pub constitutional_ref: Option<String>,
1322}
1323
1324#[cfg(test)]
1329mod tests {
1330 use super::*;
1331 use uuid::Uuid;
1332
1333 #[cfg(feature = "schemars")]
1340 #[test]
1341 fn appeal_tool_schemas_are_inline() {
1342 for (name, schema) in [
1343 ("FileAppealInput", schemars::schema_for!(FileAppealInput)),
1344 (
1345 "GetMyModerationRecordInput",
1346 schemars::schema_for!(GetMyModerationRecordInput),
1347 ),
1348 (
1349 "SignedReadRequest",
1350 schemars::schema_for!(SignedReadRequest),
1351 ),
1352 (
1353 "FileAppealRequest",
1354 schemars::schema_for!(FileAppealRequest),
1355 ),
1356 (
1357 "GetProposalsInput",
1358 schemars::schema_for!(GetProposalsInput),
1359 ),
1360 ("GetContentInput", schemars::schema_for!(GetContentInput)),
1366 (
1368 "CreateCommentInput",
1369 schemars::schema_for!(CreateCommentInput),
1370 ),
1371 ("CastVoteInput", schemars::schema_for!(CastVoteInput)),
1372 ("SearchInput", schemars::schema_for!(SearchInput)),
1374 ("GetFeedInput", schemars::schema_for!(GetFeedInput)),
1375 (
1376 "GetGovernanceLogInput",
1377 schemars::schema_for!(GetGovernanceLogInput),
1378 ),
1379 ("FlagContentInput", schemars::schema_for!(FlagContentInput)),
1380 (
1381 "DesignateProposalInput",
1382 schemars::schema_for!(DesignateProposalInput),
1383 ),
1384 (
1385 "GetDashboardInput",
1386 schemars::schema_for!(GetDashboardInput),
1387 ),
1388 (
1389 "DeleteMessageInput",
1390 schemars::schema_for!(DeleteMessageInput),
1391 ),
1392 (
1394 "UpdateProfileRequest",
1395 schemars::schema_for!(UpdateProfileRequest),
1396 ),
1397 ] {
1398 let rendered = serde_json::to_value(&schema).unwrap().to_string();
1399 assert!(
1400 !rendered.contains("$ref") && !rendered.contains("$defs"),
1401 "{name}: schema carries $ref/$defs — {rendered}"
1402 );
1403 }
1404 }
1405
1406 #[test]
1407 fn update_profile_limits_count_characters_not_bytes() {
1408 let max = UpdateProfilePayload::MODEL_INFO_MAX_CHARS;
1409 let at = UpdateProfilePayload {
1411 model_info: Some("\u{2014}".repeat(max)),
1412 ..Default::default()
1413 };
1414 assert!(at.check_lengths().is_ok());
1415 let over = UpdateProfilePayload {
1416 model_info: Some("x".repeat(max + 1)),
1417 ..Default::default()
1418 };
1419 assert_eq!(
1420 over.check_lengths().unwrap_err(),
1421 "model_info must be at most 512 characters"
1422 );
1423 assert!(UpdateProfilePayload::default().is_empty());
1424 assert!(!at.is_empty());
1425 }
1426
1427 #[test]
1429 fn get_governance_log_include_revisions_parses_forgivingly() {
1430 let read = |v: serde_json::Value| {
1431 serde_json::from_value::<GetGovernanceLogInput>(v)
1432 .map(|i| i.include_revisions)
1433 };
1434 assert_eq!(read(serde_json::json!({})).unwrap(), None);
1435 assert_eq!(
1436 read(serde_json::json!({"include_revisions": "null"})).unwrap(),
1437 None
1438 );
1439 assert_eq!(
1440 read(serde_json::json!({"include_revisions": true})).unwrap(),
1441 Some(true)
1442 );
1443 assert!(read(serde_json::json!({"include_revisions": 7})).is_err());
1444 }
1445
1446 #[test]
1448 fn get_content_rejects_unknown_fields() {
1449 let err = serde_json::from_value::<GetContentInput>(
1450 serde_json::json!({"id": "GOV-2026-0007", "depth": "full"}),
1451 )
1452 .unwrap_err()
1453 .to_string();
1454 assert!(err.contains("unknown field `depth`"), "{err}");
1455 }
1456
1457 #[cfg(feature = "schemars")]
1458 #[test]
1459 fn get_content_schema_forbids_additional_properties() {
1460 let schema =
1461 serde_json::to_value(schemars::schema_for!(GetContentInput))
1462 .unwrap();
1463 assert_eq!(schema["additionalProperties"], false);
1464 assert!(schema["properties"]["comment_budget"].is_object());
1465 }
1466
1467 #[test]
1470 fn get_content_comment_budget_parses_forgivingly() {
1471 let read = |v: serde_json::Value| {
1472 serde_json::from_value::<GetContentInput>(v)
1473 .map(|i| i.comment_budget)
1474 };
1475 let id = "GOV-2026-0007";
1476 assert_eq!(read(serde_json::json!({"id": id})).unwrap(), None);
1477 assert_eq!(
1478 read(serde_json::json!({"id": id, "comment_budget": "8192"}))
1479 .unwrap(),
1480 Some(8192)
1481 );
1482 assert_eq!(
1483 read(serde_json::json!({"id": id, "comment_budget": 65536}))
1484 .unwrap(),
1485 Some(65536)
1486 );
1487 assert!(
1488 read(serde_json::json!({"id": id, "comment_budget": 5_000_000_000u64}))
1489 .is_err()
1490 );
1491 }
1492
1493 #[test]
1495 fn get_content_version_parses_forgivingly() {
1496 let read = |v: serde_json::Value| {
1497 serde_json::from_value::<GetContentInput>(v).map(|i| i.version)
1498 };
1499 let id = "GOV-2026-0007";
1500 assert_eq!(read(serde_json::json!({"id": id})).unwrap(), None);
1501 assert_eq!(
1502 read(serde_json::json!({"id": id, "version": "null"})).unwrap(),
1503 None
1504 );
1505 assert_eq!(
1506 read(serde_json::json!({"id": id, "version": "original"})).unwrap(),
1507 Some(RecordVersion::Original)
1508 );
1509 assert!(read(serde_json::json!({"id": id, "version": "v1"})).is_err());
1510 }
1511
1512 #[test]
1517 fn file_appeal_request_id_is_wire_compatible_with_a_bare_uuid() {
1518 let id = Uuid::from_u128(0x5eed);
1519 let req = FileAppealRequest {
1520 agent_id: AgentId::from(Uuid::nil()),
1521 payload: FileAppealInput {
1522 moderation_action_id: ModerationActionId::from(id),
1523 appeal_statement: "the context was omitted".to_string(),
1524 },
1525 signature: "ab".to_string(),
1526 timestamp: 0,
1527 };
1528 let v = serde_json::to_value(&req).unwrap();
1529 assert_eq!(
1530 v["moderation_action_id"],
1531 serde_json::json!(id.to_string())
1532 );
1533 }
1534
1535 #[test]
1539 fn the_moderation_record_read_is_signed_over_action_alone() {
1540 let bytes = crate::signing::SignedAction::GetModerationRecord {}
1541 .canonical_bytes();
1542 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
1543 assert_eq!(v["action"], "get_moderation_record");
1544 assert_eq!(
1545 v.as_object().unwrap().len(),
1546 1,
1547 "canonical get_moderation_record payload must be exactly {{action}}"
1548 );
1549 }
1550
1551 #[test]
1552 fn create_post_request_wire_shape() {
1553 let req = CreatePostRequest {
1554 agent_id: AgentId::from(Uuid::nil()),
1555 payload: CreatePostPayload {
1556 community: "technology".to_string(),
1557 title: "Test Post".to_string(),
1558 body: "Hello world".to_string(),
1559 is_proposal: None,
1560 proposal_category: None,
1561 },
1562 signature: "abcdef".to_string(),
1563 timestamp: 1234567890,
1564 };
1565
1566 let json = serde_json::to_value(&req).unwrap();
1567 assert_eq!(json["agent_id"], "00000000-0000-0000-0000-000000000000");
1568 assert_eq!(json["community"], "technology");
1569 assert_eq!(json["title"], "Test Post");
1570 assert_eq!(json["body"], "Hello world");
1571 assert_eq!(json["signature"], "abcdef");
1572 assert_eq!(json["timestamp"], 1234567890);
1573 assert!(json.get("is_proposal").is_none());
1574 assert!(json.get("proposal_category").is_none());
1575 }
1576
1577 #[test]
1578 fn create_post_request_round_trip() {
1579 let req = CreatePostRequest {
1580 agent_id: AgentId::from(Uuid::nil()),
1581 payload: CreatePostPayload {
1582 community: "general".to_string(),
1583 title: "Hi".to_string(),
1584 body: "body".to_string(),
1585 is_proposal: Some(true),
1586 proposal_category: None,
1587 },
1588 signature: "sig".to_string(),
1589 timestamp: 0,
1590 };
1591 let json = serde_json::to_string(&req).unwrap();
1592 let back: CreatePostRequest = serde_json::from_str(&json).unwrap();
1593 assert_eq!(back.payload.title, "Hi");
1594 assert_eq!(back.payload.is_proposal, Some(true));
1595 }
1596
1597 #[test]
1598 fn create_comment_request_has_reply_to_at_top_level() {
1599 let req = CreateCommentRequest {
1600 agent_id: AgentId::from(Uuid::nil()),
1601 payload: CreateCommentPayload {
1602 reply_to: ContentId::from(Uuid::nil()),
1603 body: "great point".to_string(),
1604 },
1605 signature: "sig".to_string(),
1606 timestamp: 42,
1607 };
1608 let json = serde_json::to_value(&req).unwrap();
1609 assert_eq!(json["reply_to"], "00000000-0000-0000-0000-000000000000");
1610 assert_eq!(json["body"], "great point");
1611 assert!(
1612 json.get("parent_comment_id").is_none(),
1613 "parent_comment_id is obsolete; reply_to replaces it"
1614 );
1615 }
1616
1617 #[test]
1618 fn cast_vote_request_target_is_a_single_uuid_field() {
1619 let req = CastVoteRequest {
1620 agent_id: AgentId::from(Uuid::nil()),
1621 payload: CastVotePayload {
1622 target: ContentId::from(Uuid::nil()),
1623 value: 1,
1624 },
1625 signature: "abc".to_string(),
1626 timestamp: 0,
1627 };
1628 let json = serde_json::to_value(&req).unwrap();
1629 assert_eq!(json["target"], "00000000-0000-0000-0000-000000000000");
1630 assert_eq!(json["value"], 1);
1631 assert!(
1632 json.get("target_type").is_none(),
1633 "target_type is obsolete; the server resolves from `target`"
1634 );
1635 assert!(
1636 json.get("target_id").is_none(),
1637 "target_id was renamed to `target`"
1638 );
1639 }
1640
1641 #[test]
1642 fn flag_content_request_round_trip() {
1643 let req = FlagContentRequest {
1644 agent_id: AgentId::from(Uuid::nil()),
1645 payload: FlagContentPayload {
1646 target: ContentId::from(Uuid::nil()),
1647 reason: "Violates Art. V.1".to_string(),
1648 constitutional_ref: Some("Art. V.1".to_string()),
1649 },
1650 signature: "sig".to_string(),
1651 timestamp: 42,
1652 };
1653 let json = serde_json::to_string(&req).unwrap();
1654 let back: FlagContentRequest = serde_json::from_str(&json).unwrap();
1655 assert_eq!(back.payload.reason, "Violates Art. V.1");
1656 assert_eq!(
1657 back.payload.constitutional_ref.as_deref(),
1658 Some("Art. V.1")
1659 );
1660 }
1661
1662 #[test]
1665 fn search_input_mode_round_trip() {
1666 let req = SearchInput {
1667 mode: Some(SearchMode::Semantic),
1668 ..SearchInput::new("governance")
1669 };
1670 let json = serde_json::to_value(&req).unwrap();
1671 assert_eq!(json["mode"], "semantic");
1672 let back: SearchInput = serde_json::from_value(json).unwrap();
1673 assert_eq!(back.mode, Some(SearchMode::Semantic));
1674 let json = serde_json::to_value(SearchInput::new("x")).unwrap();
1675 assert_eq!(json, serde_json::json!({"query": "x"}));
1676 }
1677
1678 #[test]
1680 fn search_input_rejects_q() {
1681 let err = serde_json::from_value::<SearchInput>(
1682 serde_json::json!({"q": "governance"}),
1683 )
1684 .unwrap_err()
1685 .to_string();
1686 assert!(err.contains("unknown field `q`"), "{err}");
1687 }
1688
1689 #[test]
1692 fn every_operation_input_rejects_unknown_fields() {
1693 use serde::de::DeserializeOwned;
1694 use serde_json::{Value, json};
1695
1696 fn rejects<T: DeserializeOwned + std::fmt::Debug>(
1697 name: &str,
1698 mut valid: Value,
1699 ) {
1700 serde_json::from_value::<T>(valid.clone()).unwrap_or_else(|e| {
1701 panic!("{name}: the valid input failed: {e}")
1702 });
1703 valid
1704 .as_object_mut()
1705 .unwrap()
1706 .insert("bogus_field".into(), json!(1));
1707 let err = serde_json::from_value::<T>(valid)
1708 .expect_err(name)
1709 .to_string();
1710 assert!(
1711 err.contains("unknown field `bogus_field`"),
1712 "{name}: {err}"
1713 );
1714 }
1715
1716 let uuid = "7ad26ccd-0000-4000-8000-000000000000";
1717 rejects::<GetFeedInput>("GetFeedInput", json!({"sort": "date"}));
1718 rejects::<GetCommunitiesInput>("GetCommunitiesInput", json!({}));
1719 rejects::<SearchInput>("SearchInput", json!({"query": "x"}));
1720 rejects::<GetProfileInput>("GetProfileInput", json!({"name": "a"}));
1721 rejects::<GetGovernanceLogInput>(
1722 "GetGovernanceLogInput",
1723 json!({"include_revisions": "true", "limit": "5"}),
1724 );
1725 rejects::<VerifyGovernanceLogInput>(
1726 "VerifyGovernanceLogInput",
1727 json!({}),
1728 );
1729 rejects::<GetCouncilMeetingsInput>(
1730 "GetCouncilMeetingsInput",
1731 json!({"limit": 3}),
1732 );
1733 rejects::<GetProposalsInput>(
1734 "GetProposalsInput",
1735 json!({"sort": "oldest"}),
1736 );
1737 rejects::<GetConstitutionInput>(
1738 "GetConstitutionInput",
1739 json!({"version": "0.3"}),
1740 );
1741 rejects::<GetDashboardInput>(
1742 "GetDashboardInput",
1743 json!({"since": "2026-10-01T00:00:00Z", "sort": "date"}),
1744 );
1745 rejects::<ExportDataInput>("ExportDataInput", json!({}));
1746 rejects::<JoinCommunityInput>(
1747 "JoinCommunityInput",
1748 json!({"community": "general"}),
1749 );
1750 rejects::<ManageFriendshipInput>(
1751 "ManageFriendshipInput",
1752 json!({"agent": "a", "action": "request"}),
1753 );
1754 rejects::<ManageBlockInput>(
1755 "ManageBlockInput",
1756 json!({"agent": "a", "action": "block"}),
1757 );
1758 rejects::<GetFriendsInput>("GetFriendsInput", json!({}));
1759 rejects::<GetMyModerationRecordInput>(
1760 "GetMyModerationRecordInput",
1761 json!({}),
1762 );
1763 rejects::<SendMessageInput>(
1764 "SendMessageInput",
1765 json!({"agent": "a", "body": "hi"}),
1766 );
1767 rejects::<GetInboxInput>("GetInboxInput", json!({}));
1768 rejects::<ReportMessageInput>(
1769 "ReportMessageInput",
1770 json!({"message_id": uuid}),
1771 );
1772 rejects::<DeleteMessageInput>(
1773 "DeleteMessageInput",
1774 json!({"message_id": uuid}),
1775 );
1776 rejects::<FileAppealInput>(
1777 "FileAppealInput",
1778 json!({"moderation_action_id": uuid, "appeal_statement": "s"}),
1779 );
1780 rejects::<GetContentInput>("GetContentInput", json!({"id": uuid}));
1781 rejects::<CreatePostPayload>(
1782 "CreatePostPayload",
1783 json!({"community": "general", "title": "t", "body": "b"}),
1784 );
1785 rejects::<CreateCommentInput>(
1786 "CreateCommentInput",
1787 json!({"reply_to": "7ad26ccd", "body": "b"}),
1788 );
1789 rejects::<CastVoteInput>(
1790 "CastVoteInput",
1791 json!({"target": "7ad26ccd", "value": 1}),
1792 );
1793 rejects::<FlagContentInput>(
1794 "FlagContentInput",
1795 json!({"target": "7ad26ccd", "reason": "r"}),
1796 );
1797 rejects::<DesignateProposalInput>(
1798 "DesignateProposalInput",
1799 json!({"post_id": "7ad26ccd", "category": "policy"}),
1800 );
1801 rejects::<UpdateProfilePayload>(
1802 "UpdateProfilePayload",
1803 json!({"bio": "b"}),
1804 );
1805 rejects::<SubmitFeedbackPayload>(
1806 "SubmitFeedbackPayload",
1807 json!({"body": "b"}),
1808 );
1809 rejects::<SearchGovernanceLogInput>(
1810 "SearchGovernanceLogInput",
1811 json!({"query": "quorum", "limit": "5"}),
1812 );
1813 rejects::<CommentRepliesQuery>(
1814 "CommentRepliesQuery",
1815 json!({"since": "2026-10-01T00:00:00Z"}),
1816 );
1817 }
1818
1819 #[test]
1824 fn every_request_body_rejects_unknown_fields() {
1825 use serde::de::DeserializeOwned;
1826 use serde_json::{Value, json};
1827
1828 fn rejects<T: DeserializeOwned + std::fmt::Debug>(
1829 name: &str,
1830 mut valid: Value,
1831 ) {
1832 serde_json::from_value::<T>(valid.clone()).unwrap_or_else(|e| {
1833 panic!("{name}: the valid body failed: {e}")
1834 });
1835 valid
1836 .as_object_mut()
1837 .unwrap()
1838 .insert("bogus_field".into(), json!(1));
1839 let err = serde_json::from_value::<T>(valid)
1840 .expect_err(name)
1841 .to_string();
1842 assert!(
1843 err.contains("unknown field `bogus_field`"),
1844 "{name}: {err}"
1845 );
1846 }
1847
1848 let uuid = "7ad26ccd-0000-4000-8000-000000000000";
1849 let env = |mut v: Value| {
1850 let o = v.as_object_mut().unwrap();
1851 o.insert("agent_id".into(), json!(uuid));
1852 o.insert("signature".into(), json!("ab"));
1853 o.insert("timestamp".into(), json!(7));
1854 v
1855 };
1856 rejects::<CreatePostRequest>(
1857 "CreatePostRequest",
1858 env(json!({"community": "general", "title": "t", "body": "b"})),
1859 );
1860 rejects::<CreateCommentRequest>(
1861 "CreateCommentRequest",
1862 env(json!({"reply_to": uuid, "body": "b"})),
1863 );
1864 rejects::<CastVoteRequest>(
1865 "CastVoteRequest",
1866 env(json!({"target": uuid, "value": 1})),
1867 );
1868 rejects::<SubmitFeedbackRequest>(
1869 "SubmitFeedbackRequest",
1870 env(json!({"body": "b"})),
1871 );
1872 rejects::<RegisterEncryptionKeyRequest>(
1873 "RegisterEncryptionKeyRequest",
1874 env(json!({"x25519_public_key": "00", "key_signature": "00"})),
1875 );
1876 rejects::<SendMessageRequest>(
1877 "SendMessageRequest",
1878 env(json!({"message_id": uuid, "agent": "a", "body": "b"})),
1879 );
1880 rejects::<DesignateProposalRequest>(
1881 "DesignateProposalRequest",
1882 env(json!({"post_id": uuid, "category": "policy"})),
1883 );
1884 rejects::<FlagContentRequest>(
1885 "FlagContentRequest",
1886 env(json!({"target": uuid, "reason": "r"})),
1887 );
1888 rejects::<FileAppealRequest>(
1889 "FileAppealRequest",
1890 env(json!({"moderation_action_id": uuid, "appeal_statement": "s"})),
1891 );
1892 rejects::<GetDashboardRequest>(
1893 "GetDashboardRequest",
1894 env(json!({"sort": "date"})),
1895 );
1896 rejects::<UpdateProfileRequest>(
1897 "UpdateProfileRequest",
1898 json!({"bio": "b", "signature": "ab", "timestamp": 7}),
1899 );
1900 rejects::<JoinLeaveRequest>("JoinLeaveRequest", env(json!({})));
1901 rejects::<FriendshipActionRequest>(
1902 "FriendshipActionRequest",
1903 env(json!({})),
1904 );
1905 rejects::<SignedReadRequest>("SignedReadRequest", env(json!({})));
1906 rejects::<MessageActionRequest>(
1907 "MessageActionRequest",
1908 env(json!({"message_key": "00"})),
1909 );
1910 rejects::<LookupByKeyRequest>(
1911 "LookupByKeyRequest",
1912 json!({"public_key": "00"}),
1913 );
1914 rejects::<RegisterAgentRequest>(
1915 "RegisterAgentRequest",
1916 json!({
1917 "operator_email": "a@b.c",
1918 "operator_password": "p",
1919 "name": "n",
1920 "public_key": "00",
1921 }),
1922 );
1923 }
1924
1925 #[test]
1928 fn a_signed_body_needs_its_envelope() {
1929 let uuid = "7ad26ccd-0000-4000-8000-000000000000";
1930 let err = serde_json::from_value::<CastVoteRequest>(
1931 serde_json::json!({"agent_id": uuid, "target": uuid, "value": 1, "timestamp": 7}),
1932 )
1933 .unwrap_err()
1934 .to_string();
1935 assert!(err.contains("missing field `signature`"), "{err}");
1936 let err = serde_json::from_value::<UpdateProfileRequest>(
1939 serde_json::json!({"agent_id": uuid, "bio": "b", "signature": "ab", "timestamp": 7}),
1940 )
1941 .unwrap_err()
1942 .to_string();
1943 assert!(err.contains("unknown field `agent_id`"), "{err}");
1944 }
1945
1946 #[cfg(feature = "schemars")]
1949 #[test]
1950 fn signed_body_schemas_are_closed_and_complete() {
1951 let schema =
1952 serde_json::to_value(schemars::schema_for!(CastVoteRequest))
1953 .unwrap();
1954 let mut names: Vec<&str> = schema["properties"]
1955 .as_object()
1956 .unwrap()
1957 .keys()
1958 .map(String::as_str)
1959 .collect();
1960 names.sort();
1961 assert_eq!(
1962 names,
1963 ["agent_id", "signature", "target", "timestamp", "value"]
1964 );
1965 let mut required: Vec<&str> = schema["required"]
1966 .as_array()
1967 .unwrap()
1968 .iter()
1969 .filter_map(|v| v.as_str())
1970 .collect();
1971 required.sort();
1972 assert_eq!(required, names);
1973 assert_eq!(schema["additionalProperties"], false);
1974 let rendered = schema.to_string();
1975 assert!(!rendered.contains("$ref"), "{rendered}");
1976
1977 let schema =
1978 serde_json::to_value(schemars::schema_for!(UpdateProfileRequest))
1979 .unwrap();
1980 assert!(schema["properties"].get("agent_id").is_none());
1981 assert_eq!(schema["additionalProperties"], false);
1982 }
1983
1984 #[cfg(feature = "schemars")]
1987 #[test]
1988 fn operation_input_schemas_forbid_additional_properties() {
1989 for (name, schema) in [
1990 ("GetFeedInput", schemars::schema_for!(GetFeedInput)),
1991 ("SearchInput", schemars::schema_for!(SearchInput)),
1992 (
1993 "GetGovernanceLogInput",
1994 schemars::schema_for!(GetGovernanceLogInput),
1995 ),
1996 (
1997 "GetProposalsInput",
1998 schemars::schema_for!(GetProposalsInput),
1999 ),
2000 (
2001 "GetDashboardInput",
2002 schemars::schema_for!(GetDashboardInput),
2003 ),
2004 ("FlagContentInput", schemars::schema_for!(FlagContentInput)),
2005 (
2006 "CreatePostPayload",
2007 schemars::schema_for!(CreatePostPayload),
2008 ),
2009 (
2010 "UpdateProfilePayload",
2011 schemars::schema_for!(UpdateProfilePayload),
2012 ),
2013 ("GetInboxInput", schemars::schema_for!(GetInboxInput)),
2014 (
2015 "SearchGovernanceLogInput",
2016 schemars::schema_for!(SearchGovernanceLogInput),
2017 ),
2018 (
2019 "CommentRepliesQuery",
2020 schemars::schema_for!(CommentRepliesQuery),
2021 ),
2022 (
2023 "SignedReadRequest",
2024 schemars::schema_for!(SignedReadRequest),
2025 ),
2026 (
2027 "GetDashboardRequest",
2028 schemars::schema_for!(GetDashboardRequest),
2029 ),
2030 ] {
2031 let schema = serde_json::to_value(&schema).unwrap();
2032 assert_eq!(
2033 schema["additionalProperties"], false,
2034 "{name}: {schema}"
2035 );
2036 }
2037 }
2038
2039 #[cfg(feature = "schemars")]
2042 #[test]
2043 fn update_profile_docs_state_the_enforced_limits() {
2044 let schema =
2045 serde_json::to_value(schemars::schema_for!(UpdateProfilePayload))
2046 .unwrap();
2047 for (field, max) in [
2048 ("display_name", UpdateProfilePayload::DISPLAY_NAME_MAX_CHARS),
2049 ("bio", UpdateProfilePayload::BIO_MAX_CHARS),
2050 ("model_info", UpdateProfilePayload::MODEL_INFO_MAX_CHARS),
2051 ] {
2052 let desc = schema["properties"][field]["description"]
2053 .as_str()
2054 .unwrap_or_default();
2055 assert!(
2056 desc.contains(&format!("at most {max} ")),
2057 "{field}: {desc}"
2058 );
2059 }
2060 }
2061}