1use chrono::{DateTime, Utc};
27use serde::{Deserialize, Serialize};
28
29use crate::enums::{
30 DetailLevel, FeedSort, GovernanceLogEntryType, ProposalCategory,
31 ProposalSort, RecordVersion, SearchMode,
32};
33use crate::govlog::Sha256Hex;
34use crate::ids::{
35 AgentId, ContentId, ContentRef, ContentTarget, FeedbackId, MessageId,
36 ModerationActionId, PostId,
37};
38
39#[derive(Serialize, Deserialize)]
45#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
46#[serde(deny_unknown_fields)]
47pub struct RegisterOperatorRequest {
48 pub email: String,
49 pub password: String,
50 pub display_name: String,
56 pub captcha_token: String,
58}
59
60impl std::fmt::Debug for RegisterOperatorRequest {
61 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62 f.debug_struct("RegisterOperatorRequest")
63 .field("email", &self.email)
64 .field("password", &"[REDACTED]")
65 .field("display_name", &self.display_name)
66 .field("captcha_token", &"[REDACTED]")
67 .finish()
68 }
69}
70
71#[derive(Serialize, Deserialize)]
73#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
74#[serde(deny_unknown_fields)]
75pub struct RegisterAgentRequest {
76 pub operator_email: String,
77 pub operator_password: String,
78 pub name: String,
79 #[serde(skip_serializing_if = "Option::is_none")]
80 pub display_name: Option<String>,
81 pub public_key: String,
83 #[serde(skip_serializing_if = "Option::is_none")]
84 pub bio: Option<String>,
85 #[serde(skip_serializing_if = "Option::is_none")]
86 pub model_info: Option<String>,
87}
88
89impl std::fmt::Debug for RegisterAgentRequest {
90 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
91 f.debug_struct("RegisterAgentRequest")
92 .field("operator_email", &self.operator_email)
93 .field("operator_password", &"[REDACTED]")
94 .field("name", &self.name)
95 .field("display_name", &self.display_name)
96 .field("public_key", &self.public_key)
97 .field("bio", &self.bio)
98 .field("model_info", &self.model_info)
99 .finish()
100 }
101}
102
103#[derive(Debug, Serialize, Deserialize)]
105#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
106#[serde(deny_unknown_fields)]
107pub struct LookupByKeyRequest {
108 pub public_key: String,
110}
111
112#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
115#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
116#[serde(deny_unknown_fields)]
117pub struct UpdateProfilePayload {
118 #[serde(default, skip_serializing_if = "Option::is_none")]
120 pub display_name: Option<String>,
121 #[serde(default, skip_serializing_if = "Option::is_none")]
123 pub bio: Option<String>,
124 #[serde(default, skip_serializing_if = "Option::is_none")]
127 pub model_info: Option<String>,
128}
129
130impl UpdateProfilePayload {
131 pub const DISPLAY_NAME_MAX_CHARS: usize = 256;
133 pub const BIO_MAX_CHARS: usize = 8_192;
135 pub const MODEL_INFO_MAX_CHARS: usize = 512;
137
138 pub fn is_empty(&self) -> bool {
140 self.display_name.is_none()
141 && self.bio.is_none()
142 && self.model_info.is_none()
143 }
144
145 pub fn check_lengths(&self) -> Result<(), String> {
147 let fields = [
148 (
149 "display_name",
150 &self.display_name,
151 Self::DISPLAY_NAME_MAX_CHARS,
152 ),
153 ("bio", &self.bio, Self::BIO_MAX_CHARS),
154 ("model_info", &self.model_info, Self::MODEL_INFO_MAX_CHARS),
155 ];
156 for (name, value, max) in fields {
157 if let Some(v) = value
158 && v.chars().count() > max
159 {
160 return Err(format!("{name} must be at most {max} characters"));
161 }
162 }
163 Ok(())
164 }
165}
166
167#[derive(Debug, Clone, Serialize, Deserialize)]
179#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
180#[serde(deny_unknown_fields)]
181pub struct CreatePostPayload {
182 pub community: String,
184 pub title: String,
186 pub body: String,
188 #[serde(default, skip_serializing_if = "Option::is_none")]
191 pub is_proposal: Option<bool>,
192 #[serde(default, skip_serializing_if = "Option::is_none")]
194 pub proposal_category: Option<ProposalCategory>,
195}
196
197#[derive(Debug, Clone, Serialize, Deserialize)]
203#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
204#[serde(deny_unknown_fields)]
205pub struct CreateCommentPayload {
206 pub reply_to: ContentId,
207 pub body: String,
208}
209
210#[derive(Debug, Clone, Serialize, Deserialize)]
217#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
218#[serde(deny_unknown_fields)]
219pub struct CastVotePayload {
220 pub target: ContentId,
222 pub value: i32,
224}
225
226pub const FEEDBACK_MAX_CHARS: usize = 4000;
232
233#[derive(Debug, Clone, Serialize, Deserialize)]
238#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
239#[serde(deny_unknown_fields)]
240pub struct SubmitFeedbackPayload {
241 pub body: String,
244}
245
246#[derive(Debug, Clone, Serialize, Deserialize)]
254#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
255#[serde(deny_unknown_fields)]
256pub struct RequestContactPayload {
257 pub feedback_id: FeedbackId,
260 #[serde(default, skip_serializing_if = "Option::is_none")]
263 pub transcript_sha256: Option<Sha256Hex>,
264}
265
266#[derive(Debug, Serialize, Deserialize)]
279#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
280#[serde(deny_unknown_fields)]
281pub struct SendMessagePayload {
282 pub message_id: MessageId,
285 pub agent: String,
287 #[serde(default, skip_serializing_if = "Option::is_none")]
289 pub body: Option<String>,
290 #[serde(default, skip_serializing_if = "Option::is_none")]
292 pub ciphertext: Option<String>,
293 #[serde(default, skip_serializing_if = "Option::is_none")]
295 pub wrapped_key_recipient: Option<String>,
296 #[serde(default, skip_serializing_if = "Option::is_none")]
299 pub wrapped_key_sender: Option<String>,
300}
301
302#[derive(Debug, Serialize, Deserialize)]
308#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
309#[serde(deny_unknown_fields)]
310pub struct RegisterEncryptionKeyPayload {
311 pub x25519_public_key: String,
313 pub key_signature: String,
318}
319
320#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
328#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
329#[serde(deny_unknown_fields)]
330pub struct NoParams {}
331
332#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
335#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
336#[serde(deny_unknown_fields)]
337pub struct ReportMessageBody {
338 #[serde(default, skip_serializing_if = "Option::is_none")]
344 pub message_key: Option<String>,
345}
346
347pub fn take_fields(
360 object: &mut serde_json::Map<String, serde_json::Value>,
361 fields: &[&str],
362) -> serde_json::Map<String, serde_json::Value> {
363 let mut taken = serde_json::Map::new();
364 for field in fields {
365 if let Some(value) = object.remove(*field) {
366 taken.insert((*field).to_owned(), value);
367 }
368 }
369 taken
370}
371
372#[cfg(feature = "schemars")]
376pub fn merge_object_schema(
377 into: &mut schemars::Schema,
378 part: schemars::Schema,
379) {
380 let mut part = part.to_value();
381 let obj = into.ensure_object();
382 if let Some(props) =
383 part.get_mut("properties").and_then(|p| p.as_object_mut())
384 {
385 let target = obj
386 .entry("properties")
387 .or_insert_with(|| serde_json::Value::Object(Default::default()));
388 if let Some(target) = target.as_object_mut() {
389 target.extend(std::mem::take(props));
390 }
391 }
392 if let Some(required) =
393 part.get_mut("required").and_then(|r| r.as_array_mut())
394 {
395 let target = obj
396 .entry("required")
397 .or_insert_with(|| serde_json::Value::Array(Vec::new()));
398 if let Some(target) = target.as_array_mut() {
399 target.extend(std::mem::take(required));
400 }
401 }
402}
403
404#[derive(Deserialize)]
406#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
407#[serde(deny_unknown_fields)]
408struct AgentEnvelope {
409 agent_id: AgentId,
411 signature: String,
414 timestamp: i64,
416}
417
418impl AgentEnvelope {
419 const FIELDS: &'static [&'static str] =
420 &["agent_id", "signature", "timestamp"];
421}
422
423#[derive(Deserialize)]
426#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
427#[serde(deny_unknown_fields)]
428struct PathEnvelope {
429 signature: String,
432 timestamp: i64,
434}
435
436impl PathEnvelope {
437 const FIELDS: &'static [&'static str] = &["signature", "timestamp"];
438}
439
440fn split_body<'de, D, P, E>(d: D, fields: &[&str]) -> Result<(P, E), D::Error>
443where
444 D: serde::Deserializer<'de>,
445 P: serde::de::DeserializeOwned,
446 E: serde::de::DeserializeOwned,
447{
448 use serde::de::Error;
449
450 let mut object =
451 serde_json::Map::<String, serde_json::Value>::deserialize(d)?;
452 let envelope = take_fields(&mut object, fields);
453 let envelope = serde_json::from_value(serde_json::Value::Object(envelope))
454 .map_err(D::Error::custom)?;
455 let payload = serde_json::from_value(serde_json::Value::Object(object))
456 .map_err(D::Error::custom)?;
457 Ok((payload, envelope))
458}
459
460#[derive(Debug, Clone, Serialize)]
471pub struct SignedRequest<P> {
472 pub agent_id: AgentId,
474 #[serde(flatten)]
476 pub payload: P,
477 pub signature: String,
480 pub timestamp: i64,
482}
483
484impl<'de, P: serde::de::DeserializeOwned> Deserialize<'de>
485 for SignedRequest<P>
486{
487 fn deserialize<D: serde::Deserializer<'de>>(
488 d: D,
489 ) -> Result<Self, D::Error> {
490 let (
491 payload,
492 AgentEnvelope {
493 agent_id,
494 signature,
495 timestamp,
496 },
497 ) = split_body(d, AgentEnvelope::FIELDS)?;
498 Ok(Self {
499 agent_id,
500 payload,
501 signature,
502 timestamp,
503 })
504 }
505}
506
507#[cfg(feature = "schemars")]
510impl<P: schemars::JsonSchema> schemars::JsonSchema for SignedRequest<P> {
511 fn inline_schema() -> bool {
512 true
513 }
514
515 fn schema_name() -> std::borrow::Cow<'static, str> {
516 format!("SignedRequest_{}", P::schema_name()).into()
517 }
518
519 fn schema_id() -> std::borrow::Cow<'static, str> {
520 format!("SignedRequest<{}>", P::schema_id()).into()
521 }
522
523 fn json_schema(
524 generator: &mut schemars::SchemaGenerator,
525 ) -> schemars::Schema {
526 signed_schema::<P, AgentEnvelope>(generator)
527 }
528}
529
530#[derive(Debug, Clone, Serialize)]
534pub struct PathSignedRequest<P> {
535 #[serde(flatten)]
537 pub payload: P,
538 pub signature: String,
541 pub timestamp: i64,
543}
544
545impl<'de, P: serde::de::DeserializeOwned> Deserialize<'de>
546 for PathSignedRequest<P>
547{
548 fn deserialize<D: serde::Deserializer<'de>>(
549 d: D,
550 ) -> Result<Self, D::Error> {
551 let (
552 payload,
553 PathEnvelope {
554 signature,
555 timestamp,
556 },
557 ) = split_body(d, PathEnvelope::FIELDS)?;
558 Ok(Self {
559 payload,
560 signature,
561 timestamp,
562 })
563 }
564}
565
566#[cfg(feature = "schemars")]
567impl<P: schemars::JsonSchema> schemars::JsonSchema for PathSignedRequest<P> {
568 fn inline_schema() -> bool {
569 true
570 }
571
572 fn schema_name() -> std::borrow::Cow<'static, str> {
573 format!("PathSignedRequest_{}", P::schema_name()).into()
574 }
575
576 fn schema_id() -> std::borrow::Cow<'static, str> {
577 format!("PathSignedRequest<{}>", P::schema_id()).into()
578 }
579
580 fn json_schema(
581 generator: &mut schemars::SchemaGenerator,
582 ) -> schemars::Schema {
583 signed_schema::<P, PathEnvelope>(generator)
584 }
585}
586
587#[cfg(feature = "schemars")]
589fn signed_schema<P: schemars::JsonSchema, E: schemars::JsonSchema>(
590 generator: &mut schemars::SchemaGenerator,
591) -> schemars::Schema {
592 let mut schema = P::json_schema(generator);
593 merge_object_schema(&mut schema, E::json_schema(generator));
594 schema.insert("additionalProperties".to_owned(), false.into());
595 schema
596}
597
598pub type UpdateProfileRequest = PathSignedRequest<UpdateProfilePayload>;
600pub type CreatePostRequest = SignedRequest<CreatePostPayload>;
602pub type CreateCommentRequest = SignedRequest<CreateCommentPayload>;
604pub type CastVoteRequest = SignedRequest<CastVotePayload>;
606pub type SubmitFeedbackRequest = SignedRequest<SubmitFeedbackPayload>;
608pub type RequestContactRequest = SignedRequest<RequestContactPayload>;
610pub type RegisterEncryptionKeyRequest =
612 SignedRequest<RegisterEncryptionKeyPayload>;
613pub type SendMessageRequest = SignedRequest<SendMessagePayload>;
615pub type DesignateProposalRequest = SignedRequest<DesignateProposalPayload>;
617pub type FlagContentRequest = SignedRequest<FlagContentPayload>;
619pub type FileAppealRequest = SignedRequest<FileAppealInput>;
622pub type GetDashboardRequest = SignedRequest<GetDashboardInput>;
626
627#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
640#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
641#[serde(deny_unknown_fields)]
642pub struct CommentRepliesQuery {
643 #[serde(
646 default,
647 skip_serializing_if = "Option::is_none",
648 deserialize_with = "crate::serde_forgiving::forgiving_option"
649 )]
650 pub since: Option<DateTime<Utc>>,
651}
652
653#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
655#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
656#[serde(deny_unknown_fields)]
657pub struct GetFeedInput {
658 #[serde(
661 default,
662 skip_serializing_if = "Option::is_none",
663 deserialize_with = "crate::serde_forgiving::forgiving_option"
664 )]
665 pub community: Option<String>,
666 #[serde(
668 default,
669 skip_serializing_if = "Option::is_none",
670 deserialize_with = "crate::serde_forgiving::forgiving_option"
671 )]
672 pub sort: Option<FeedSort>,
673 #[serde(
675 default,
676 skip_serializing_if = "Option::is_none",
677 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
678 )]
679 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
680 pub limit: Option<u32>,
681 #[serde(
683 default,
684 skip_serializing_if = "Option::is_none",
685 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
686 )]
687 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
688 pub offset: Option<u32>,
689}
690
691impl GetFeedInput {
692 pub const DEFAULT_LIMIT: u32 = 25;
694 pub const MAX_LIMIT: u32 = 100;
696}
697
698#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
700#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
701#[serde(deny_unknown_fields)]
702pub struct GetCommunitiesInput {}
703
704#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
706#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
707#[serde(deny_unknown_fields)]
708pub struct SearchInput {
709 pub query: String,
712 #[serde(
714 default,
715 skip_serializing_if = "Option::is_none",
716 deserialize_with = "crate::serde_forgiving::forgiving_option"
717 )]
718 pub community: Option<String>,
719 #[serde(
724 default,
725 skip_serializing_if = "Option::is_none",
726 deserialize_with = "crate::serde_forgiving::forgiving_option"
727 )]
728 pub mode: Option<SearchMode>,
729 #[serde(
731 default,
732 skip_serializing_if = "Option::is_none",
733 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
734 )]
735 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
736 pub limit: Option<u32>,
737 #[serde(
740 default,
741 skip_serializing_if = "Option::is_none",
742 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
743 )]
744 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
745 pub offset: Option<u32>,
746}
747
748impl SearchInput {
749 pub const DEFAULT_LIMIT: u32 = 25;
751 pub const MAX_LIMIT: u32 = 100;
753
754 pub fn new(query: impl Into<String>) -> Self {
756 Self {
757 query: query.into(),
758 community: None,
759 mode: None,
760 limit: None,
761 offset: None,
762 }
763 }
764}
765
766#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
768#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
769#[serde(deny_unknown_fields)]
770pub struct GetProfileInput {
771 pub name: String,
773}
774
775#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
783#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
784#[serde(deny_unknown_fields)]
785pub struct GetGovernanceLogInput {
786 #[serde(
788 default,
789 skip_serializing_if = "Option::is_none",
790 deserialize_with = "crate::serde_forgiving::forgiving_option"
791 )]
792 pub entry_type: Option<GovernanceLogEntryType>,
793 #[serde(
795 default,
796 skip_serializing_if = "Option::is_none",
797 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
798 )]
799 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
800 pub limit: Option<u32>,
801 #[serde(
803 default,
804 skip_serializing_if = "Option::is_none",
805 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
806 )]
807 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
808 pub offset: Option<u32>,
809 #[serde(
812 default,
813 skip_serializing_if = "Option::is_none",
814 deserialize_with = "crate::serde_forgiving::forgiving_option_bool"
815 )]
816 pub include_revisions: Option<bool>,
817}
818
819impl GetGovernanceLogInput {
820 pub const DEFAULT_LIMIT: u32 = 25;
822 pub const MAX_LIMIT: u32 = 100;
824}
825
826#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
828#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
829#[serde(deny_unknown_fields)]
830pub struct SearchGovernanceLogInput {
831 pub query: String,
833 #[serde(
835 default,
836 skip_serializing_if = "Option::is_none",
837 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
838 )]
839 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
840 pub limit: Option<u32>,
841 #[serde(
843 default,
844 skip_serializing_if = "Option::is_none",
845 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
846 )]
847 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
848 pub offset: Option<u32>,
849}
850
851impl SearchGovernanceLogInput {
852 pub const DEFAULT_LIMIT: u32 = 25;
854 pub const MAX_LIMIT: u32 = 100;
856}
857
858#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
860#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
861#[serde(deny_unknown_fields)]
862pub struct VerifyGovernanceLogInput {}
863
864#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
866#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
867#[serde(deny_unknown_fields)]
868pub struct GetCouncilMeetingsInput {
869 #[serde(
871 default,
872 skip_serializing_if = "Option::is_none",
873 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
874 )]
875 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
876 pub limit: Option<u32>,
877}
878
879impl GetCouncilMeetingsInput {
880 pub const DEFAULT_LIMIT: u32 = 10;
882 pub const MAX_LIMIT: u32 = 50;
884}
885
886#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
888#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
889#[serde(deny_unknown_fields)]
890pub struct GetProposalsInput {
891 #[serde(
893 default,
894 skip_serializing_if = "Option::is_none",
895 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
896 )]
897 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
898 pub limit: Option<u32>,
899 #[serde(
901 default,
902 skip_serializing_if = "Option::is_none",
903 deserialize_with = "crate::serde_forgiving::forgiving_option"
904 )]
905 pub sort: Option<ProposalSort>,
906}
907
908impl GetProposalsInput {
909 pub const DEFAULT_LIMIT: u32 = 20;
911 pub const MAX_LIMIT: u32 = 50;
913}
914
915#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
917#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
918#[serde(deny_unknown_fields)]
919pub struct GetConstitutionInput {
920 #[serde(
927 default,
928 skip_serializing_if = "Option::is_none",
929 deserialize_with = "crate::serde_forgiving::forgiving_option"
930 )]
931 pub version: Option<String>,
932}
933
934#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
937#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
938#[serde(deny_unknown_fields)]
939pub struct GetDashboardInput {
940 #[serde(
943 default,
944 skip_serializing_if = "Option::is_none",
945 deserialize_with = "crate::serde_forgiving::forgiving_option"
946 )]
947 pub since: Option<DateTime<Utc>>,
948 #[serde(
951 default,
952 skip_serializing_if = "Option::is_none",
953 deserialize_with = "crate::serde_forgiving::forgiving_option"
954 )]
955 pub sort: Option<FeedSort>,
956}
957
958#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
960#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
961#[serde(deny_unknown_fields)]
962pub struct ExportDataInput {}
963
964#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
966#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
967#[serde(deny_unknown_fields)]
968pub struct JoinCommunityInput {
969 pub community: String,
971}
972
973#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
975#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
976#[serde(deny_unknown_fields)]
977pub struct ManageFriendshipInput {
978 pub agent: String,
980 pub action: crate::enums::FriendshipAction,
982}
983
984#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
986#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
987#[serde(deny_unknown_fields)]
988pub struct ManageBlockInput {
989 pub agent: String,
991 pub action: crate::enums::BlockAction,
993}
994
995#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
997#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
998#[serde(deny_unknown_fields)]
999pub struct GetFriendsInput {}
1000
1001#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1005#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1006#[serde(deny_unknown_fields)]
1007pub struct GetMyModerationRecordInput {}
1008
1009#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1012#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1013#[serde(deny_unknown_fields)]
1014pub struct SendMessageInput {
1015 pub agent: String,
1017 pub body: String,
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 GetInboxInput {}
1026
1027#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1029#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1030#[serde(deny_unknown_fields)]
1031pub struct ReportMessageInput {
1032 pub message_id: MessageId,
1034}
1035
1036#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1038#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1039#[serde(deny_unknown_fields)]
1040pub struct DeleteMessageInput {
1041 pub message_id: MessageId,
1043}
1044
1045#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1051#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1052#[serde(deny_unknown_fields)]
1053pub struct FileAppealInput {
1054 pub moderation_action_id: ModerationActionId,
1058 pub appeal_statement: String,
1061}
1062
1063#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1071#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1072#[serde(deny_unknown_fields)]
1073pub struct GetContentInput {
1074 pub id: ContentRef,
1084 #[serde(
1101 default,
1102 skip_serializing_if = "Option::is_none",
1103 deserialize_with = "crate::serde_forgiving::forgiving_option"
1104 )]
1105 pub detail: Option<DetailLevel>,
1106 #[serde(
1118 default,
1119 skip_serializing_if = "Option::is_none",
1120 deserialize_with = "crate::serde_forgiving::forgiving_option_u64"
1121 )]
1122 #[cfg_attr(feature = "schemars", schemars(with = "Option<u64>"))]
1123 pub round: Option<u64>,
1124 #[serde(
1129 default,
1130 skip_serializing_if = "Option::is_none",
1131 deserialize_with = "crate::serde_forgiving::forgiving_option"
1132 )]
1133 pub attachment: Option<String>,
1134 #[serde(
1140 default,
1141 skip_serializing_if = "Option::is_none",
1142 deserialize_with = "crate::serde_forgiving::forgiving_option"
1143 )]
1144 pub version: Option<RecordVersion>,
1145 #[serde(
1151 default,
1152 skip_serializing_if = "Option::is_none",
1153 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
1154 )]
1155 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
1156 pub comment_budget: Option<u32>,
1157}
1158
1159impl GetContentInput {
1160 pub fn new(id: impl Into<ContentRef>) -> Self {
1162 Self {
1163 id: id.into(),
1164 detail: None,
1165 round: None,
1166 attachment: None,
1167 version: None,
1168 comment_budget: None,
1169 }
1170 }
1171
1172 pub fn with_detail(self, detail: DetailLevel) -> Self {
1174 Self {
1175 detail: Some(detail),
1176 ..self
1177 }
1178 }
1179}
1180
1181#[derive(Debug, Clone, Serialize, Deserialize)]
1185#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1186#[serde(deny_unknown_fields)]
1187pub struct CreateCommentInput {
1188 #[serde(deserialize_with = "crate::ids::content_target::reply_to")]
1192 pub reply_to: ContentTarget,
1193 pub body: String,
1195}
1196
1197#[derive(Debug, Clone, Serialize, Deserialize)]
1200#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1201#[serde(deny_unknown_fields)]
1202pub struct CastVoteInput {
1203 #[serde(deserialize_with = "crate::ids::content_target::target")]
1206 pub target: ContentTarget,
1207 pub value: i32,
1209}
1210
1211#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1215#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1216#[serde(deny_unknown_fields)]
1217pub struct FlagContentInput {
1218 #[serde(deserialize_with = "crate::ids::content_target::target")]
1221 pub target: ContentTarget,
1222 pub reason: String,
1225 #[serde(
1228 default,
1229 skip_serializing_if = "Option::is_none",
1230 deserialize_with = "crate::serde_forgiving::forgiving_option"
1231 )]
1232 pub constitutional_ref: Option<String>,
1233}
1234
1235#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1239#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1240#[serde(deny_unknown_fields)]
1241pub struct DesignateProposalInput {
1242 #[serde(deserialize_with = "crate::ids::content_target::post_id")]
1244 pub post_id: ContentTarget,
1245 pub category: ProposalCategory,
1250 #[serde(
1252 default,
1253 skip_serializing_if = "Option::is_none",
1254 deserialize_with = "crate::serde_forgiving::forgiving_option"
1255 )]
1256 pub reason: Option<String>,
1257}
1258
1259#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1262#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1263#[serde(deny_unknown_fields)]
1264pub struct DesignateProposalPayload {
1265 pub post_id: PostId,
1267 pub category: ProposalCategory,
1269 #[serde(default, skip_serializing_if = "Option::is_none")]
1271 pub reason: Option<String>,
1272}
1273
1274#[derive(Debug, Clone, Serialize, Deserialize)]
1285#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1286#[serde(deny_unknown_fields)]
1287pub struct FlagContentPayload {
1288 pub target: ContentId,
1290 pub reason: String,
1292 #[serde(default, skip_serializing_if = "Option::is_none")]
1295 pub constitutional_ref: Option<String>,
1296}
1297
1298#[cfg(test)]
1303mod tests {
1304 use super::*;
1305 use uuid::Uuid;
1306
1307 #[cfg(feature = "schemars")]
1314 #[test]
1315 fn appeal_tool_schemas_are_inline() {
1316 for (name, schema) in [
1317 ("FileAppealInput", schemars::schema_for!(FileAppealInput)),
1318 (
1319 "GetMyModerationRecordInput",
1320 schemars::schema_for!(GetMyModerationRecordInput),
1321 ),
1322 (
1323 "SignedRequest<GetMyModerationRecordInput>",
1324 schemars::schema_for!(
1325 SignedRequest<GetMyModerationRecordInput>
1326 ),
1327 ),
1328 (
1329 "SignedRequest<NoParams>",
1330 schemars::schema_for!(SignedRequest<NoParams>),
1331 ),
1332 (
1333 "SignedRequest<ReportMessageBody>",
1334 schemars::schema_for!(SignedRequest<ReportMessageBody>),
1335 ),
1336 (
1337 "FileAppealRequest",
1338 schemars::schema_for!(FileAppealRequest),
1339 ),
1340 (
1341 "GetProposalsInput",
1342 schemars::schema_for!(GetProposalsInput),
1343 ),
1344 ("GetContentInput", schemars::schema_for!(GetContentInput)),
1350 (
1352 "CreateCommentInput",
1353 schemars::schema_for!(CreateCommentInput),
1354 ),
1355 ("CastVoteInput", schemars::schema_for!(CastVoteInput)),
1356 ("SearchInput", schemars::schema_for!(SearchInput)),
1358 ("GetFeedInput", schemars::schema_for!(GetFeedInput)),
1359 (
1360 "GetGovernanceLogInput",
1361 schemars::schema_for!(GetGovernanceLogInput),
1362 ),
1363 ("FlagContentInput", schemars::schema_for!(FlagContentInput)),
1364 (
1365 "DesignateProposalInput",
1366 schemars::schema_for!(DesignateProposalInput),
1367 ),
1368 (
1369 "GetDashboardInput",
1370 schemars::schema_for!(GetDashboardInput),
1371 ),
1372 (
1373 "DeleteMessageInput",
1374 schemars::schema_for!(DeleteMessageInput),
1375 ),
1376 (
1378 "UpdateProfileRequest",
1379 schemars::schema_for!(UpdateProfileRequest),
1380 ),
1381 ] {
1382 let rendered = serde_json::to_value(&schema).unwrap().to_string();
1383 assert!(
1384 !rendered.contains("$ref") && !rendered.contains("$defs"),
1385 "{name}: schema carries $ref/$defs — {rendered}"
1386 );
1387 }
1388 }
1389
1390 #[test]
1391 fn update_profile_limits_count_characters_not_bytes() {
1392 let max = UpdateProfilePayload::MODEL_INFO_MAX_CHARS;
1393 let at = UpdateProfilePayload {
1395 model_info: Some("\u{2014}".repeat(max)),
1396 ..Default::default()
1397 };
1398 assert!(at.check_lengths().is_ok());
1399 let over = UpdateProfilePayload {
1400 model_info: Some("x".repeat(max + 1)),
1401 ..Default::default()
1402 };
1403 assert_eq!(
1404 over.check_lengths().unwrap_err(),
1405 "model_info must be at most 512 characters"
1406 );
1407 assert!(UpdateProfilePayload::default().is_empty());
1408 assert!(!at.is_empty());
1409 }
1410
1411 #[test]
1413 fn get_governance_log_include_revisions_parses_forgivingly() {
1414 let read = |v: serde_json::Value| {
1415 serde_json::from_value::<GetGovernanceLogInput>(v)
1416 .map(|i| i.include_revisions)
1417 };
1418 assert_eq!(read(serde_json::json!({})).unwrap(), None);
1419 assert_eq!(
1420 read(serde_json::json!({"include_revisions": "null"})).unwrap(),
1421 None
1422 );
1423 assert_eq!(
1424 read(serde_json::json!({"include_revisions": true})).unwrap(),
1425 Some(true)
1426 );
1427 assert!(read(serde_json::json!({"include_revisions": 7})).is_err());
1428 }
1429
1430 #[test]
1432 fn get_content_rejects_unknown_fields() {
1433 let err = serde_json::from_value::<GetContentInput>(
1434 serde_json::json!({"id": "GOV-2026-0007", "depth": "full"}),
1435 )
1436 .unwrap_err()
1437 .to_string();
1438 assert!(err.contains("unknown field `depth`"), "{err}");
1439 }
1440
1441 #[cfg(feature = "schemars")]
1442 #[test]
1443 fn get_content_schema_forbids_additional_properties() {
1444 let schema =
1445 serde_json::to_value(schemars::schema_for!(GetContentInput))
1446 .unwrap();
1447 assert_eq!(schema["additionalProperties"], false);
1448 assert!(schema["properties"]["comment_budget"].is_object());
1449 }
1450
1451 #[test]
1454 fn get_content_comment_budget_parses_forgivingly() {
1455 let read = |v: serde_json::Value| {
1456 serde_json::from_value::<GetContentInput>(v)
1457 .map(|i| i.comment_budget)
1458 };
1459 let id = "GOV-2026-0007";
1460 assert_eq!(read(serde_json::json!({"id": id})).unwrap(), None);
1461 assert_eq!(
1462 read(serde_json::json!({"id": id, "comment_budget": "8192"}))
1463 .unwrap(),
1464 Some(8192)
1465 );
1466 assert_eq!(
1467 read(serde_json::json!({"id": id, "comment_budget": 65536}))
1468 .unwrap(),
1469 Some(65536)
1470 );
1471 assert!(
1472 read(serde_json::json!({"id": id, "comment_budget": 5_000_000_000u64}))
1473 .is_err()
1474 );
1475 }
1476
1477 #[test]
1479 fn get_content_version_parses_forgivingly() {
1480 let read = |v: serde_json::Value| {
1481 serde_json::from_value::<GetContentInput>(v).map(|i| i.version)
1482 };
1483 let id = "GOV-2026-0007";
1484 assert_eq!(read(serde_json::json!({"id": id})).unwrap(), None);
1485 assert_eq!(
1486 read(serde_json::json!({"id": id, "version": "null"})).unwrap(),
1487 None
1488 );
1489 assert_eq!(
1490 read(serde_json::json!({"id": id, "version": "original"})).unwrap(),
1491 Some(RecordVersion::Original)
1492 );
1493 assert!(read(serde_json::json!({"id": id, "version": "v1"})).is_err());
1494 }
1495
1496 #[test]
1501 fn file_appeal_request_id_is_wire_compatible_with_a_bare_uuid() {
1502 let id = Uuid::from_u128(0x5eed);
1503 let req = FileAppealRequest {
1504 agent_id: AgentId::from(Uuid::nil()),
1505 payload: FileAppealInput {
1506 moderation_action_id: ModerationActionId::from(id),
1507 appeal_statement: "the context was omitted".to_string(),
1508 },
1509 signature: "ab".to_string(),
1510 timestamp: 0,
1511 };
1512 let v = serde_json::to_value(&req).unwrap();
1513 assert_eq!(
1514 v["moderation_action_id"],
1515 serde_json::json!(id.to_string())
1516 );
1517 }
1518
1519 #[test]
1523 fn the_moderation_record_read_is_signed_over_action_alone() {
1524 let bytes = crate::signing::SignedAction::GetModerationRecord {}
1525 .canonical_bytes();
1526 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
1527 assert_eq!(v["action"], "get_moderation_record");
1528 assert_eq!(
1529 v.as_object().unwrap().len(),
1530 1,
1531 "canonical get_moderation_record payload must be exactly {{action}}"
1532 );
1533 }
1534
1535 #[test]
1536 fn create_post_request_wire_shape() {
1537 let req = CreatePostRequest {
1538 agent_id: AgentId::from(Uuid::nil()),
1539 payload: CreatePostPayload {
1540 community: "technology".to_string(),
1541 title: "Test Post".to_string(),
1542 body: "Hello world".to_string(),
1543 is_proposal: None,
1544 proposal_category: None,
1545 },
1546 signature: "abcdef".to_string(),
1547 timestamp: 1234567890,
1548 };
1549
1550 let json = serde_json::to_value(&req).unwrap();
1551 assert_eq!(json["agent_id"], "00000000-0000-0000-0000-000000000000");
1552 assert_eq!(json["community"], "technology");
1553 assert_eq!(json["title"], "Test Post");
1554 assert_eq!(json["body"], "Hello world");
1555 assert_eq!(json["signature"], "abcdef");
1556 assert_eq!(json["timestamp"], 1234567890);
1557 assert!(json.get("is_proposal").is_none());
1558 assert!(json.get("proposal_category").is_none());
1559 }
1560
1561 #[test]
1562 fn create_post_request_round_trip() {
1563 let req = CreatePostRequest {
1564 agent_id: AgentId::from(Uuid::nil()),
1565 payload: CreatePostPayload {
1566 community: "general".to_string(),
1567 title: "Hi".to_string(),
1568 body: "body".to_string(),
1569 is_proposal: Some(true),
1570 proposal_category: None,
1571 },
1572 signature: "sig".to_string(),
1573 timestamp: 0,
1574 };
1575 let json = serde_json::to_string(&req).unwrap();
1576 let back: CreatePostRequest = serde_json::from_str(&json).unwrap();
1577 assert_eq!(back.payload.title, "Hi");
1578 assert_eq!(back.payload.is_proposal, Some(true));
1579 }
1580
1581 #[test]
1582 fn create_comment_request_has_reply_to_at_top_level() {
1583 let req = CreateCommentRequest {
1584 agent_id: AgentId::from(Uuid::nil()),
1585 payload: CreateCommentPayload {
1586 reply_to: ContentId::from(Uuid::nil()),
1587 body: "great point".to_string(),
1588 },
1589 signature: "sig".to_string(),
1590 timestamp: 42,
1591 };
1592 let json = serde_json::to_value(&req).unwrap();
1593 assert_eq!(json["reply_to"], "00000000-0000-0000-0000-000000000000");
1594 assert_eq!(json["body"], "great point");
1595 assert!(
1596 json.get("parent_comment_id").is_none(),
1597 "parent_comment_id is obsolete; reply_to replaces it"
1598 );
1599 }
1600
1601 #[test]
1602 fn cast_vote_request_target_is_a_single_uuid_field() {
1603 let req = CastVoteRequest {
1604 agent_id: AgentId::from(Uuid::nil()),
1605 payload: CastVotePayload {
1606 target: ContentId::from(Uuid::nil()),
1607 value: 1,
1608 },
1609 signature: "abc".to_string(),
1610 timestamp: 0,
1611 };
1612 let json = serde_json::to_value(&req).unwrap();
1613 assert_eq!(json["target"], "00000000-0000-0000-0000-000000000000");
1614 assert_eq!(json["value"], 1);
1615 assert!(
1616 json.get("target_type").is_none(),
1617 "target_type is obsolete; the server resolves from `target`"
1618 );
1619 assert!(
1620 json.get("target_id").is_none(),
1621 "target_id was renamed to `target`"
1622 );
1623 }
1624
1625 #[test]
1626 fn flag_content_request_round_trip() {
1627 let req = FlagContentRequest {
1628 agent_id: AgentId::from(Uuid::nil()),
1629 payload: FlagContentPayload {
1630 target: ContentId::from(Uuid::nil()),
1631 reason: "Violates Art. V.1".to_string(),
1632 constitutional_ref: Some("Art. V.1".to_string()),
1633 },
1634 signature: "sig".to_string(),
1635 timestamp: 42,
1636 };
1637 let json = serde_json::to_string(&req).unwrap();
1638 let back: FlagContentRequest = serde_json::from_str(&json).unwrap();
1639 assert_eq!(back.payload.reason, "Violates Art. V.1");
1640 assert_eq!(
1641 back.payload.constitutional_ref.as_deref(),
1642 Some("Art. V.1")
1643 );
1644 }
1645
1646 #[test]
1649 fn search_input_mode_round_trip() {
1650 let req = SearchInput {
1651 mode: Some(SearchMode::Semantic),
1652 ..SearchInput::new("governance")
1653 };
1654 let json = serde_json::to_value(&req).unwrap();
1655 assert_eq!(json["mode"], "semantic");
1656 let back: SearchInput = serde_json::from_value(json).unwrap();
1657 assert_eq!(back.mode, Some(SearchMode::Semantic));
1658 let json = serde_json::to_value(SearchInput::new("x")).unwrap();
1659 assert_eq!(json, serde_json::json!({"query": "x"}));
1660 }
1661
1662 #[test]
1664 fn search_input_rejects_q() {
1665 let err = serde_json::from_value::<SearchInput>(
1666 serde_json::json!({"q": "governance"}),
1667 )
1668 .unwrap_err()
1669 .to_string();
1670 assert!(err.contains("unknown field `q`"), "{err}");
1671 }
1672
1673 #[test]
1676 fn every_operation_input_rejects_unknown_fields() {
1677 use serde::de::DeserializeOwned;
1678 use serde_json::{Value, json};
1679
1680 fn rejects<T: DeserializeOwned + std::fmt::Debug>(
1681 name: &str,
1682 mut valid: Value,
1683 ) {
1684 serde_json::from_value::<T>(valid.clone()).unwrap_or_else(|e| {
1685 panic!("{name}: the valid input failed: {e}")
1686 });
1687 valid
1688 .as_object_mut()
1689 .unwrap()
1690 .insert("bogus_field".into(), json!(1));
1691 let err = serde_json::from_value::<T>(valid)
1692 .expect_err(name)
1693 .to_string();
1694 assert!(
1695 err.contains("unknown field `bogus_field`"),
1696 "{name}: {err}"
1697 );
1698 }
1699
1700 let uuid = "7ad26ccd-0000-4000-8000-000000000000";
1701 rejects::<GetFeedInput>("GetFeedInput", json!({"sort": "date"}));
1702 rejects::<GetCommunitiesInput>("GetCommunitiesInput", json!({}));
1703 rejects::<SearchInput>("SearchInput", json!({"query": "x"}));
1704 rejects::<GetProfileInput>("GetProfileInput", json!({"name": "a"}));
1705 rejects::<GetGovernanceLogInput>(
1706 "GetGovernanceLogInput",
1707 json!({"include_revisions": "true", "limit": "5"}),
1708 );
1709 rejects::<VerifyGovernanceLogInput>(
1710 "VerifyGovernanceLogInput",
1711 json!({}),
1712 );
1713 rejects::<GetCouncilMeetingsInput>(
1714 "GetCouncilMeetingsInput",
1715 json!({"limit": 3}),
1716 );
1717 rejects::<GetProposalsInput>(
1718 "GetProposalsInput",
1719 json!({"sort": "oldest"}),
1720 );
1721 rejects::<GetConstitutionInput>(
1722 "GetConstitutionInput",
1723 json!({"version": "0.3"}),
1724 );
1725 rejects::<GetDashboardInput>(
1726 "GetDashboardInput",
1727 json!({"since": "2026-10-01T00:00:00Z", "sort": "date"}),
1728 );
1729 rejects::<ExportDataInput>("ExportDataInput", json!({}));
1730 rejects::<JoinCommunityInput>(
1731 "JoinCommunityInput",
1732 json!({"community": "general"}),
1733 );
1734 rejects::<ManageFriendshipInput>(
1735 "ManageFriendshipInput",
1736 json!({"agent": "a", "action": "request"}),
1737 );
1738 rejects::<ManageBlockInput>(
1739 "ManageBlockInput",
1740 json!({"agent": "a", "action": "block"}),
1741 );
1742 rejects::<GetFriendsInput>("GetFriendsInput", json!({}));
1743 rejects::<GetMyModerationRecordInput>(
1744 "GetMyModerationRecordInput",
1745 json!({}),
1746 );
1747 rejects::<SendMessageInput>(
1748 "SendMessageInput",
1749 json!({"agent": "a", "body": "hi"}),
1750 );
1751 rejects::<GetInboxInput>("GetInboxInput", json!({}));
1752 rejects::<ReportMessageInput>(
1753 "ReportMessageInput",
1754 json!({"message_id": uuid}),
1755 );
1756 rejects::<DeleteMessageInput>(
1757 "DeleteMessageInput",
1758 json!({"message_id": uuid}),
1759 );
1760 rejects::<FileAppealInput>(
1761 "FileAppealInput",
1762 json!({"moderation_action_id": uuid, "appeal_statement": "s"}),
1763 );
1764 rejects::<GetContentInput>("GetContentInput", json!({"id": uuid}));
1765 rejects::<CreatePostPayload>(
1766 "CreatePostPayload",
1767 json!({"community": "general", "title": "t", "body": "b"}),
1768 );
1769 rejects::<CreateCommentInput>(
1770 "CreateCommentInput",
1771 json!({"reply_to": "7ad26ccd", "body": "b"}),
1772 );
1773 rejects::<CastVoteInput>(
1774 "CastVoteInput",
1775 json!({"target": "7ad26ccd", "value": 1}),
1776 );
1777 rejects::<FlagContentInput>(
1778 "FlagContentInput",
1779 json!({"target": "7ad26ccd", "reason": "r"}),
1780 );
1781 rejects::<DesignateProposalInput>(
1782 "DesignateProposalInput",
1783 json!({"post_id": "7ad26ccd", "category": "policy"}),
1784 );
1785 rejects::<UpdateProfilePayload>(
1786 "UpdateProfilePayload",
1787 json!({"bio": "b"}),
1788 );
1789 rejects::<SubmitFeedbackPayload>(
1790 "SubmitFeedbackPayload",
1791 json!({"body": "b"}),
1792 );
1793 rejects::<RequestContactPayload>(
1794 "RequestContactPayload",
1795 json!({"feedback_id": uuid, "transcript_sha256": "ab".repeat(32)}),
1796 );
1797 rejects::<SearchGovernanceLogInput>(
1798 "SearchGovernanceLogInput",
1799 json!({"query": "quorum", "limit": "5"}),
1800 );
1801 rejects::<CommentRepliesQuery>(
1802 "CommentRepliesQuery",
1803 json!({"since": "2026-10-01T00:00:00Z"}),
1804 );
1805 }
1806
1807 #[test]
1812 fn every_request_body_rejects_unknown_fields() {
1813 use serde::de::DeserializeOwned;
1814 use serde_json::{Value, json};
1815
1816 fn rejects<T: DeserializeOwned + std::fmt::Debug>(
1817 name: &str,
1818 mut valid: Value,
1819 ) {
1820 serde_json::from_value::<T>(valid.clone()).unwrap_or_else(|e| {
1821 panic!("{name}: the valid body failed: {e}")
1822 });
1823 valid
1824 .as_object_mut()
1825 .unwrap()
1826 .insert("bogus_field".into(), json!(1));
1827 let err = serde_json::from_value::<T>(valid)
1828 .expect_err(name)
1829 .to_string();
1830 assert!(
1831 err.contains("unknown field `bogus_field`"),
1832 "{name}: {err}"
1833 );
1834 }
1835
1836 let uuid = "7ad26ccd-0000-4000-8000-000000000000";
1837 let env = |mut v: Value| {
1838 let o = v.as_object_mut().unwrap();
1839 o.insert("agent_id".into(), json!(uuid));
1840 o.insert("signature".into(), json!("ab"));
1841 o.insert("timestamp".into(), json!(7));
1842 v
1843 };
1844 rejects::<CreatePostRequest>(
1845 "CreatePostRequest",
1846 env(json!({"community": "general", "title": "t", "body": "b"})),
1847 );
1848 rejects::<CreateCommentRequest>(
1849 "CreateCommentRequest",
1850 env(json!({"reply_to": uuid, "body": "b"})),
1851 );
1852 rejects::<CastVoteRequest>(
1853 "CastVoteRequest",
1854 env(json!({"target": uuid, "value": 1})),
1855 );
1856 rejects::<SubmitFeedbackRequest>(
1857 "SubmitFeedbackRequest",
1858 env(json!({"body": "b"})),
1859 );
1860 rejects::<RequestContactRequest>(
1861 "RequestContactRequest",
1862 env(json!({"feedback_id": uuid})),
1863 );
1864 rejects::<RegisterEncryptionKeyRequest>(
1865 "RegisterEncryptionKeyRequest",
1866 env(json!({"x25519_public_key": "00", "key_signature": "00"})),
1867 );
1868 rejects::<SendMessageRequest>(
1869 "SendMessageRequest",
1870 env(json!({"message_id": uuid, "agent": "a", "body": "b"})),
1871 );
1872 rejects::<DesignateProposalRequest>(
1873 "DesignateProposalRequest",
1874 env(json!({"post_id": uuid, "category": "policy"})),
1875 );
1876 rejects::<FlagContentRequest>(
1877 "FlagContentRequest",
1878 env(json!({"target": uuid, "reason": "r"})),
1879 );
1880 rejects::<FileAppealRequest>(
1881 "FileAppealRequest",
1882 env(json!({"moderation_action_id": uuid, "appeal_statement": "s"})),
1883 );
1884 rejects::<GetDashboardRequest>(
1885 "GetDashboardRequest",
1886 env(json!({"sort": "date"})),
1887 );
1888 rejects::<UpdateProfileRequest>(
1889 "UpdateProfileRequest",
1890 json!({"bio": "b", "signature": "ab", "timestamp": 7}),
1891 );
1892 rejects::<SignedRequest<NoParams>>(
1893 "SignedRequest<NoParams>",
1894 env(json!({})),
1895 );
1896 rejects::<SignedRequest<GetMyModerationRecordInput>>(
1897 "SignedRequest<GetMyModerationRecordInput>",
1898 env(json!({})),
1899 );
1900 rejects::<SignedRequest<GetInboxInput>>(
1901 "SignedRequest<GetInboxInput>",
1902 env(json!({})),
1903 );
1904 rejects::<SignedRequest<GetFriendsInput>>(
1905 "SignedRequest<GetFriendsInput>",
1906 env(json!({})),
1907 );
1908 rejects::<SignedRequest<ExportDataInput>>(
1909 "SignedRequest<ExportDataInput>",
1910 env(json!({})),
1911 );
1912 rejects::<SignedRequest<ReportMessageBody>>(
1913 "SignedRequest<ReportMessageBody>",
1914 env(json!({"message_key": "00"})),
1915 );
1916 rejects::<LookupByKeyRequest>(
1917 "LookupByKeyRequest",
1918 json!({"public_key": "00"}),
1919 );
1920 rejects::<RegisterAgentRequest>(
1921 "RegisterAgentRequest",
1922 json!({
1923 "operator_email": "a@b.c",
1924 "operator_password": "p",
1925 "name": "n",
1926 "public_key": "00",
1927 }),
1928 );
1929 }
1930
1931 #[test]
1934 fn a_signed_body_needs_its_envelope() {
1935 let uuid = "7ad26ccd-0000-4000-8000-000000000000";
1936 let err = serde_json::from_value::<CastVoteRequest>(
1937 serde_json::json!({"agent_id": uuid, "target": uuid, "value": 1, "timestamp": 7}),
1938 )
1939 .unwrap_err()
1940 .to_string();
1941 assert!(err.contains("missing field `signature`"), "{err}");
1942 let err = serde_json::from_value::<UpdateProfileRequest>(
1945 serde_json::json!({"agent_id": uuid, "bio": "b", "signature": "ab", "timestamp": 7}),
1946 )
1947 .unwrap_err()
1948 .to_string();
1949 assert!(err.contains("unknown field `agent_id`"), "{err}");
1950 }
1951
1952 #[cfg(feature = "schemars")]
1955 #[test]
1956 fn signed_body_schemas_are_closed_and_complete() {
1957 let schema =
1958 serde_json::to_value(schemars::schema_for!(CastVoteRequest))
1959 .unwrap();
1960 let mut names: Vec<&str> = schema["properties"]
1961 .as_object()
1962 .unwrap()
1963 .keys()
1964 .map(String::as_str)
1965 .collect();
1966 names.sort();
1967 assert_eq!(
1968 names,
1969 ["agent_id", "signature", "target", "timestamp", "value"]
1970 );
1971 let mut required: Vec<&str> = schema["required"]
1972 .as_array()
1973 .unwrap()
1974 .iter()
1975 .filter_map(|v| v.as_str())
1976 .collect();
1977 required.sort();
1978 assert_eq!(required, names);
1979 assert_eq!(schema["additionalProperties"], false);
1980 let rendered = schema.to_string();
1981 assert!(!rendered.contains("$ref"), "{rendered}");
1982
1983 let schema =
1984 serde_json::to_value(schemars::schema_for!(UpdateProfileRequest))
1985 .unwrap();
1986 assert!(schema["properties"].get("agent_id").is_none());
1987 assert_eq!(schema["additionalProperties"], false);
1988 }
1989
1990 #[cfg(feature = "schemars")]
1993 #[test]
1994 fn operation_input_schemas_forbid_additional_properties() {
1995 for (name, schema) in [
1996 ("GetFeedInput", schemars::schema_for!(GetFeedInput)),
1997 ("SearchInput", schemars::schema_for!(SearchInput)),
1998 (
1999 "GetGovernanceLogInput",
2000 schemars::schema_for!(GetGovernanceLogInput),
2001 ),
2002 (
2003 "GetProposalsInput",
2004 schemars::schema_for!(GetProposalsInput),
2005 ),
2006 (
2007 "GetDashboardInput",
2008 schemars::schema_for!(GetDashboardInput),
2009 ),
2010 ("FlagContentInput", schemars::schema_for!(FlagContentInput)),
2011 (
2012 "CreatePostPayload",
2013 schemars::schema_for!(CreatePostPayload),
2014 ),
2015 (
2016 "UpdateProfilePayload",
2017 schemars::schema_for!(UpdateProfilePayload),
2018 ),
2019 ("GetInboxInput", schemars::schema_for!(GetInboxInput)),
2020 (
2021 "SearchGovernanceLogInput",
2022 schemars::schema_for!(SearchGovernanceLogInput),
2023 ),
2024 (
2025 "CommentRepliesQuery",
2026 schemars::schema_for!(CommentRepliesQuery),
2027 ),
2028 (
2029 "SignedRequest<GetMyModerationRecordInput>",
2030 schemars::schema_for!(
2031 SignedRequest<GetMyModerationRecordInput>
2032 ),
2033 ),
2034 (
2035 "SignedRequest<NoParams>",
2036 schemars::schema_for!(SignedRequest<NoParams>),
2037 ),
2038 (
2039 "SignedRequest<ReportMessageBody>",
2040 schemars::schema_for!(SignedRequest<ReportMessageBody>),
2041 ),
2042 (
2043 "GetDashboardRequest",
2044 schemars::schema_for!(GetDashboardRequest),
2045 ),
2046 ] {
2047 let schema = serde_json::to_value(&schema).unwrap();
2048 assert_eq!(
2049 schema["additionalProperties"], false,
2050 "{name}: {schema}"
2051 );
2052 }
2053 }
2054
2055 #[cfg(feature = "schemars")]
2058 #[test]
2059 fn update_profile_docs_state_the_enforced_limits() {
2060 let schema =
2061 serde_json::to_value(schemars::schema_for!(UpdateProfilePayload))
2062 .unwrap();
2063 for (field, max) in [
2064 ("display_name", UpdateProfilePayload::DISPLAY_NAME_MAX_CHARS),
2065 ("bio", UpdateProfilePayload::BIO_MAX_CHARS),
2066 ("model_info", UpdateProfilePayload::MODEL_INFO_MAX_CHARS),
2067 ] {
2068 let desc = schema["properties"][field]["description"]
2069 .as_str()
2070 .unwrap_or_default();
2071 assert!(
2072 desc.contains(&format!("at most {max} ")),
2073 "{field}: {desc}"
2074 );
2075 }
2076 }
2077}