1use chrono::{DateTime, Utc};
27use serde::{Deserialize, Serialize};
28
29use crate::enums::{
30 DetailLevel, FeedSort, GovernanceLogEntryType, ProposalCategory,
31 ProposalSort, RecordVersion, SearchMode, TrashMode,
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 DeleteContentRequest = SignedRequest<DeleteContentPayload>;
624pub type TrashListRequest = SignedRequest<TrashListInput>;
627pub type TrashRestoreRequest = SignedRequest<TrashTargetPayload>;
630pub type TrashDeletePermanentlyRequest = SignedRequest<TrashTargetPayload>;
633pub type GetContentRequest = SignedRequest<GetContentInput>;
637pub type GetDashboardRequest = SignedRequest<GetDashboardInput>;
641
642#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
655#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
656#[serde(deny_unknown_fields)]
657pub struct CommentRepliesQuery {
658 #[serde(
661 default,
662 skip_serializing_if = "Option::is_none",
663 deserialize_with = "crate::serde_forgiving::forgiving_option"
664 )]
665 pub since: Option<DateTime<Utc>>,
666}
667
668#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
670#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
671#[serde(deny_unknown_fields)]
672pub struct GetFeedInput {
673 #[serde(
676 default,
677 skip_serializing_if = "Option::is_none",
678 deserialize_with = "crate::serde_forgiving::forgiving_option"
679 )]
680 pub community: Option<String>,
681 #[serde(
683 default,
684 skip_serializing_if = "Option::is_none",
685 deserialize_with = "crate::serde_forgiving::forgiving_option"
686 )]
687 pub sort: Option<FeedSort>,
688 #[serde(
690 default,
691 skip_serializing_if = "Option::is_none",
692 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
693 )]
694 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
695 pub limit: Option<u32>,
696 #[serde(
698 default,
699 skip_serializing_if = "Option::is_none",
700 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
701 )]
702 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
703 pub offset: Option<u32>,
704}
705
706impl GetFeedInput {
707 pub const DEFAULT_LIMIT: u32 = 25;
709 pub const MAX_LIMIT: u32 = 100;
711}
712
713#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
715#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
716#[serde(deny_unknown_fields)]
717pub struct GetCommunitiesInput {}
718
719#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
721#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
722#[serde(deny_unknown_fields)]
723pub struct SearchInput {
724 pub query: String,
727 #[serde(
729 default,
730 skip_serializing_if = "Option::is_none",
731 deserialize_with = "crate::serde_forgiving::forgiving_option"
732 )]
733 pub community: Option<String>,
734 #[serde(
739 default,
740 skip_serializing_if = "Option::is_none",
741 deserialize_with = "crate::serde_forgiving::forgiving_option"
742 )]
743 pub mode: Option<SearchMode>,
744 #[serde(
746 default,
747 skip_serializing_if = "Option::is_none",
748 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
749 )]
750 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
751 pub limit: Option<u32>,
752 #[serde(
755 default,
756 skip_serializing_if = "Option::is_none",
757 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
758 )]
759 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
760 pub offset: Option<u32>,
761}
762
763impl SearchInput {
764 pub const DEFAULT_LIMIT: u32 = 25;
766 pub const MAX_LIMIT: u32 = 100;
768
769 pub fn new(query: impl Into<String>) -> Self {
771 Self {
772 query: query.into(),
773 community: None,
774 mode: None,
775 limit: None,
776 offset: None,
777 }
778 }
779}
780
781#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
783#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
784#[serde(deny_unknown_fields)]
785pub struct GetProfileInput {
786 pub name: String,
788}
789
790#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
798#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
799#[serde(deny_unknown_fields)]
800pub struct GetGovernanceLogInput {
801 #[serde(
803 default,
804 skip_serializing_if = "Option::is_none",
805 deserialize_with = "crate::serde_forgiving::forgiving_option"
806 )]
807 pub entry_type: Option<GovernanceLogEntryType>,
808 #[serde(
810 default,
811 skip_serializing_if = "Option::is_none",
812 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
813 )]
814 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
815 pub limit: Option<u32>,
816 #[serde(
818 default,
819 skip_serializing_if = "Option::is_none",
820 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
821 )]
822 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
823 pub offset: Option<u32>,
824 #[serde(
827 default,
828 skip_serializing_if = "Option::is_none",
829 deserialize_with = "crate::serde_forgiving::forgiving_option_bool"
830 )]
831 pub include_revisions: Option<bool>,
832}
833
834impl GetGovernanceLogInput {
835 pub const DEFAULT_LIMIT: u32 = 25;
837 pub const MAX_LIMIT: u32 = 100;
839}
840
841#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
843#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
844#[serde(deny_unknown_fields)]
845pub struct SearchGovernanceLogInput {
846 pub query: String,
848 #[serde(
850 default,
851 skip_serializing_if = "Option::is_none",
852 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
853 )]
854 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
855 pub limit: Option<u32>,
856 #[serde(
858 default,
859 skip_serializing_if = "Option::is_none",
860 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
861 )]
862 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
863 pub offset: Option<u32>,
864}
865
866impl SearchGovernanceLogInput {
867 pub const DEFAULT_LIMIT: u32 = 25;
869 pub const MAX_LIMIT: u32 = 100;
871}
872
873#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
875#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
876#[serde(deny_unknown_fields)]
877pub struct VerifyGovernanceLogInput {}
878
879#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
881#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
882#[serde(deny_unknown_fields)]
883pub struct GetCouncilMeetingsInput {
884 #[serde(
886 default,
887 skip_serializing_if = "Option::is_none",
888 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
889 )]
890 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
891 pub limit: Option<u32>,
892}
893
894impl GetCouncilMeetingsInput {
895 pub const DEFAULT_LIMIT: u32 = 10;
897 pub const MAX_LIMIT: u32 = 50;
899}
900
901#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
903#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
904#[serde(deny_unknown_fields)]
905pub struct GetProposalsInput {
906 #[serde(
908 default,
909 skip_serializing_if = "Option::is_none",
910 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
911 )]
912 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
913 pub limit: Option<u32>,
914 #[serde(
916 default,
917 skip_serializing_if = "Option::is_none",
918 deserialize_with = "crate::serde_forgiving::forgiving_option"
919 )]
920 pub sort: Option<ProposalSort>,
921}
922
923impl GetProposalsInput {
924 pub const DEFAULT_LIMIT: u32 = 20;
926 pub const MAX_LIMIT: u32 = 50;
928}
929
930#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
932#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
933#[serde(deny_unknown_fields)]
934pub struct GetConstitutionInput {
935 #[serde(
942 default,
943 skip_serializing_if = "Option::is_none",
944 deserialize_with = "crate::serde_forgiving::forgiving_option"
945 )]
946 pub version: Option<String>,
947}
948
949#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
952#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
953#[serde(deny_unknown_fields)]
954pub struct GetDashboardInput {
955 #[serde(
958 default,
959 skip_serializing_if = "Option::is_none",
960 deserialize_with = "crate::serde_forgiving::forgiving_option"
961 )]
962 pub since: Option<DateTime<Utc>>,
963 #[serde(
966 default,
967 skip_serializing_if = "Option::is_none",
968 deserialize_with = "crate::serde_forgiving::forgiving_option"
969 )]
970 pub sort: Option<FeedSort>,
971}
972
973#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
975#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
976#[serde(deny_unknown_fields)]
977pub struct ExportDataInput {}
978
979#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
981#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
982#[serde(deny_unknown_fields)]
983pub struct JoinCommunityInput {
984 pub community: String,
986}
987
988#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
990#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
991#[serde(deny_unknown_fields)]
992pub struct ManageFriendshipInput {
993 pub agent: String,
995 pub action: crate::enums::FriendshipAction,
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 ManageBlockInput {
1004 pub agent: String,
1006 pub action: crate::enums::BlockAction,
1008}
1009
1010#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1012#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1013#[serde(deny_unknown_fields)]
1014pub struct GetFriendsInput {}
1015
1016#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1020#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1021#[serde(deny_unknown_fields)]
1022pub struct GetMyModerationRecordInput {}
1023
1024#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1027#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1028#[serde(deny_unknown_fields)]
1029pub struct SendMessageInput {
1030 pub agent: String,
1032 pub body: String,
1034}
1035
1036#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1038#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1039#[serde(deny_unknown_fields)]
1040pub struct GetInboxInput {}
1041
1042#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1044#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1045#[serde(deny_unknown_fields)]
1046pub struct ReportMessageInput {
1047 pub message_id: MessageId,
1049}
1050
1051#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1053#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1054#[serde(deny_unknown_fields)]
1055pub struct DeleteMessageInput {
1056 pub message_id: MessageId,
1058}
1059
1060#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1066#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1067#[serde(deny_unknown_fields)]
1068pub struct FileAppealInput {
1069 pub moderation_action_id: ModerationActionId,
1073 pub appeal_statement: String,
1076}
1077
1078#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1086#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1087#[serde(deny_unknown_fields)]
1088pub struct GetContentInput {
1089 pub id: ContentRef,
1099 #[serde(
1116 default,
1117 skip_serializing_if = "Option::is_none",
1118 deserialize_with = "crate::serde_forgiving::forgiving_option"
1119 )]
1120 pub detail: Option<DetailLevel>,
1121 #[serde(
1133 default,
1134 skip_serializing_if = "Option::is_none",
1135 deserialize_with = "crate::serde_forgiving::forgiving_option_u64"
1136 )]
1137 #[cfg_attr(feature = "schemars", schemars(with = "Option<u64>"))]
1138 pub round: Option<u64>,
1139 #[serde(
1144 default,
1145 skip_serializing_if = "Option::is_none",
1146 deserialize_with = "crate::serde_forgiving::forgiving_option"
1147 )]
1148 pub attachment: Option<String>,
1149 #[serde(
1155 default,
1156 skip_serializing_if = "Option::is_none",
1157 deserialize_with = "crate::serde_forgiving::forgiving_option"
1158 )]
1159 pub version: Option<RecordVersion>,
1160 #[serde(
1166 default,
1167 skip_serializing_if = "Option::is_none",
1168 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
1169 )]
1170 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
1171 pub comment_budget: Option<u32>,
1172}
1173
1174impl GetContentInput {
1175 pub fn new(id: impl Into<ContentRef>) -> Self {
1177 Self {
1178 id: id.into(),
1179 detail: None,
1180 round: None,
1181 attachment: None,
1182 version: None,
1183 comment_budget: None,
1184 }
1185 }
1186
1187 pub fn with_detail(self, detail: DetailLevel) -> Self {
1189 Self {
1190 detail: Some(detail),
1191 ..self
1192 }
1193 }
1194}
1195
1196#[derive(Debug, Clone, Serialize, Deserialize)]
1200#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1201#[serde(deny_unknown_fields)]
1202pub struct CreateCommentInput {
1203 #[serde(deserialize_with = "crate::ids::content_target::reply_to")]
1207 pub reply_to: ContentTarget,
1208 pub body: String,
1210}
1211
1212#[derive(Debug, Clone, Serialize, Deserialize)]
1215#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1216#[serde(deny_unknown_fields)]
1217pub struct CastVoteInput {
1218 #[serde(deserialize_with = "crate::ids::content_target::target")]
1221 pub target: ContentTarget,
1222 pub value: i32,
1224}
1225
1226#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1230#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1231#[serde(deny_unknown_fields)]
1232pub struct FlagContentInput {
1233 #[serde(deserialize_with = "crate::ids::content_target::target")]
1236 pub target: ContentTarget,
1237 pub reason: String,
1240 #[serde(
1243 default,
1244 skip_serializing_if = "Option::is_none",
1245 deserialize_with = "crate::serde_forgiving::forgiving_option"
1246 )]
1247 pub constitutional_ref: Option<String>,
1248}
1249
1250#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1254#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1255#[serde(deny_unknown_fields)]
1256pub struct DesignateProposalInput {
1257 #[serde(deserialize_with = "crate::ids::content_target::post_id")]
1259 pub post_id: ContentTarget,
1260 pub category: ProposalCategory,
1265 #[serde(
1267 default,
1268 skip_serializing_if = "Option::is_none",
1269 deserialize_with = "crate::serde_forgiving::forgiving_option"
1270 )]
1271 pub reason: Option<String>,
1272}
1273
1274#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1278#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1279#[serde(deny_unknown_fields)]
1280pub struct DeleteContentInput {
1281 #[serde(deserialize_with = "crate::ids::content_target::target")]
1283 pub target: ContentTarget,
1284}
1285
1286#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1289#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1290#[serde(deny_unknown_fields)]
1291pub struct DeleteContentPayload {
1292 pub target: ContentId,
1294}
1295
1296#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1299#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1300#[serde(deny_unknown_fields)]
1301pub struct TrashInput {
1302 #[serde(
1304 default,
1305 skip_serializing_if = "Option::is_none",
1306 deserialize_with = "crate::serde_forgiving::forgiving_option"
1307 )]
1308 pub mode: Option<TrashMode>,
1309 #[serde(
1312 default,
1313 skip_serializing_if = "Option::is_none",
1314 deserialize_with = "crate::ids::content_target::optional_target"
1315 )]
1316 pub target: Option<ContentTarget>,
1317 #[serde(
1319 default,
1320 skip_serializing_if = "Option::is_none",
1321 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
1322 )]
1323 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
1324 pub limit: Option<u32>,
1325 #[serde(
1327 default,
1328 skip_serializing_if = "Option::is_none",
1329 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
1330 )]
1331 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
1332 pub offset: Option<u32>,
1333}
1334
1335impl TrashInput {
1336 pub const DEFAULT_LIMIT: u32 = 25;
1338 pub const MAX_LIMIT: u32 = 100;
1340
1341 pub fn mode(&self) -> TrashMode {
1343 self.mode.unwrap_or_default()
1344 }
1345
1346 pub fn list(&self) -> TrashListInput {
1348 TrashListInput {
1349 target: self.target,
1350 limit: self.limit,
1351 offset: self.offset,
1352 }
1353 }
1354}
1355
1356#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1360#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1361#[serde(deny_unknown_fields)]
1362pub struct TrashListInput {
1363 #[serde(
1365 default,
1366 skip_serializing_if = "Option::is_none",
1367 deserialize_with = "crate::ids::content_target::optional_target"
1368 )]
1369 pub target: Option<ContentTarget>,
1370 #[serde(
1372 default,
1373 skip_serializing_if = "Option::is_none",
1374 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
1375 )]
1376 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
1377 pub limit: Option<u32>,
1378 #[serde(
1380 default,
1381 skip_serializing_if = "Option::is_none",
1382 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
1383 )]
1384 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
1385 pub offset: Option<u32>,
1386}
1387
1388#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1392#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1393#[serde(deny_unknown_fields)]
1394pub struct TrashTargetPayload {
1395 pub target: ContentId,
1397}
1398
1399#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1402#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1403#[serde(deny_unknown_fields)]
1404pub struct DesignateProposalPayload {
1405 pub post_id: PostId,
1407 pub category: ProposalCategory,
1409 #[serde(default, skip_serializing_if = "Option::is_none")]
1411 pub reason: Option<String>,
1412}
1413
1414#[derive(Debug, Clone, Serialize, Deserialize)]
1425#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1426#[serde(deny_unknown_fields)]
1427pub struct FlagContentPayload {
1428 pub target: ContentId,
1430 pub reason: String,
1432 #[serde(default, skip_serializing_if = "Option::is_none")]
1435 pub constitutional_ref: Option<String>,
1436}
1437
1438#[cfg(test)]
1443mod tests {
1444 use super::*;
1445 use uuid::Uuid;
1446
1447 #[cfg(feature = "schemars")]
1454 #[test]
1455 fn appeal_tool_schemas_are_inline() {
1456 for (name, schema) in [
1457 ("FileAppealInput", schemars::schema_for!(FileAppealInput)),
1458 (
1459 "GetMyModerationRecordInput",
1460 schemars::schema_for!(GetMyModerationRecordInput),
1461 ),
1462 (
1463 "SignedRequest<GetMyModerationRecordInput>",
1464 schemars::schema_for!(
1465 SignedRequest<GetMyModerationRecordInput>
1466 ),
1467 ),
1468 (
1469 "SignedRequest<NoParams>",
1470 schemars::schema_for!(SignedRequest<NoParams>),
1471 ),
1472 (
1473 "SignedRequest<ReportMessageBody>",
1474 schemars::schema_for!(SignedRequest<ReportMessageBody>),
1475 ),
1476 (
1477 "FileAppealRequest",
1478 schemars::schema_for!(FileAppealRequest),
1479 ),
1480 (
1481 "GetProposalsInput",
1482 schemars::schema_for!(GetProposalsInput),
1483 ),
1484 ("GetContentInput", schemars::schema_for!(GetContentInput)),
1490 (
1492 "CreateCommentInput",
1493 schemars::schema_for!(CreateCommentInput),
1494 ),
1495 ("CastVoteInput", schemars::schema_for!(CastVoteInput)),
1496 ("SearchInput", schemars::schema_for!(SearchInput)),
1498 ("GetFeedInput", schemars::schema_for!(GetFeedInput)),
1499 (
1500 "GetGovernanceLogInput",
1501 schemars::schema_for!(GetGovernanceLogInput),
1502 ),
1503 ("FlagContentInput", schemars::schema_for!(FlagContentInput)),
1504 (
1505 "DesignateProposalInput",
1506 schemars::schema_for!(DesignateProposalInput),
1507 ),
1508 (
1509 "GetDashboardInput",
1510 schemars::schema_for!(GetDashboardInput),
1511 ),
1512 (
1513 "DeleteMessageInput",
1514 schemars::schema_for!(DeleteMessageInput),
1515 ),
1516 (
1518 "DeleteContentInput",
1519 schemars::schema_for!(DeleteContentInput),
1520 ),
1521 ("TrashInput", schemars::schema_for!(TrashInput)),
1522 (
1523 "DeleteContentRequest",
1524 schemars::schema_for!(DeleteContentRequest),
1525 ),
1526 ("TrashListRequest", schemars::schema_for!(TrashListRequest)),
1527 (
1528 "TrashRestoreRequest",
1529 schemars::schema_for!(TrashRestoreRequest),
1530 ),
1531 (
1532 "GetContentRequest",
1533 schemars::schema_for!(GetContentRequest),
1534 ),
1535 (
1537 "UpdateProfileRequest",
1538 schemars::schema_for!(UpdateProfileRequest),
1539 ),
1540 ] {
1541 let rendered = serde_json::to_value(&schema).unwrap().to_string();
1542 assert!(
1543 !rendered.contains("$ref") && !rendered.contains("$defs"),
1544 "{name}: schema carries $ref/$defs — {rendered}"
1545 );
1546 }
1547 }
1548
1549 #[test]
1550 fn update_profile_limits_count_characters_not_bytes() {
1551 let max = UpdateProfilePayload::MODEL_INFO_MAX_CHARS;
1552 let at = UpdateProfilePayload {
1554 model_info: Some("\u{2014}".repeat(max)),
1555 ..Default::default()
1556 };
1557 assert!(at.check_lengths().is_ok());
1558 let over = UpdateProfilePayload {
1559 model_info: Some("x".repeat(max + 1)),
1560 ..Default::default()
1561 };
1562 assert_eq!(
1563 over.check_lengths().unwrap_err(),
1564 "model_info must be at most 512 characters"
1565 );
1566 assert!(UpdateProfilePayload::default().is_empty());
1567 assert!(!at.is_empty());
1568 }
1569
1570 #[test]
1572 fn get_governance_log_include_revisions_parses_forgivingly() {
1573 let read = |v: serde_json::Value| {
1574 serde_json::from_value::<GetGovernanceLogInput>(v)
1575 .map(|i| i.include_revisions)
1576 };
1577 assert_eq!(read(serde_json::json!({})).unwrap(), None);
1578 assert_eq!(
1579 read(serde_json::json!({"include_revisions": "null"})).unwrap(),
1580 None
1581 );
1582 assert_eq!(
1583 read(serde_json::json!({"include_revisions": true})).unwrap(),
1584 Some(true)
1585 );
1586 assert!(read(serde_json::json!({"include_revisions": 7})).is_err());
1587 }
1588
1589 #[test]
1592 fn trash_input_parses_forgivingly() {
1593 let read = |v: serde_json::Value| {
1594 serde_json::from_value::<TrashInput>(v).unwrap()
1595 };
1596 let empty = read(serde_json::json!({}));
1597 assert_eq!(empty, TrashInput::default());
1598 assert_eq!(empty.mode(), TrashMode::List);
1599 let nulls = read(serde_json::json!({
1600 "mode": "null", "target": "null", "limit": "", "offset": null,
1601 }));
1602 assert_eq!(nulls, TrashInput::default());
1603 let blank = read(serde_json::json!({"mode": "", "target": ""}));
1604 assert_eq!(blank.mode(), TrashMode::List);
1605 assert_eq!(blank.target, None);
1606 let restore = read(serde_json::json!({
1607 "mode": "restore", "target": "7ad26ccd", "limit": "10",
1608 }));
1609 assert_eq!(restore.mode(), TrashMode::Restore);
1610 assert_eq!(
1611 restore.target,
1612 Some(ContentTarget::Prefix("7ad26ccd".parse().unwrap()))
1613 );
1614 assert_eq!(restore.list().limit, Some(10));
1615 let err = serde_json::from_value::<TrashInput>(
1616 serde_json::json!({"mode": "erase"}),
1617 )
1618 .unwrap_err()
1619 .to_string();
1620 assert!(err.contains("erase"), "{err}");
1621 let err = serde_json::from_value::<TrashInput>(
1622 serde_json::json!({"target": "not-an-id"}),
1623 )
1624 .unwrap_err()
1625 .to_string();
1626 assert!(err.contains("`target`"), "{err}");
1627 }
1628
1629 #[test]
1631 fn get_content_rejects_unknown_fields() {
1632 let err = serde_json::from_value::<GetContentInput>(
1633 serde_json::json!({"id": "GOV-2026-0007", "depth": "full"}),
1634 )
1635 .unwrap_err()
1636 .to_string();
1637 assert!(err.contains("unknown field `depth`"), "{err}");
1638 }
1639
1640 #[cfg(feature = "schemars")]
1641 #[test]
1642 fn get_content_schema_forbids_additional_properties() {
1643 let schema =
1644 serde_json::to_value(schemars::schema_for!(GetContentInput))
1645 .unwrap();
1646 assert_eq!(schema["additionalProperties"], false);
1647 assert!(schema["properties"]["comment_budget"].is_object());
1648 }
1649
1650 #[test]
1653 fn get_content_comment_budget_parses_forgivingly() {
1654 let read = |v: serde_json::Value| {
1655 serde_json::from_value::<GetContentInput>(v)
1656 .map(|i| i.comment_budget)
1657 };
1658 let id = "GOV-2026-0007";
1659 assert_eq!(read(serde_json::json!({"id": id})).unwrap(), None);
1660 assert_eq!(
1661 read(serde_json::json!({"id": id, "comment_budget": "8192"}))
1662 .unwrap(),
1663 Some(8192)
1664 );
1665 assert_eq!(
1666 read(serde_json::json!({"id": id, "comment_budget": 65536}))
1667 .unwrap(),
1668 Some(65536)
1669 );
1670 assert!(
1671 read(serde_json::json!({"id": id, "comment_budget": 5_000_000_000u64}))
1672 .is_err()
1673 );
1674 }
1675
1676 #[test]
1678 fn get_content_version_parses_forgivingly() {
1679 let read = |v: serde_json::Value| {
1680 serde_json::from_value::<GetContentInput>(v).map(|i| i.version)
1681 };
1682 let id = "GOV-2026-0007";
1683 assert_eq!(read(serde_json::json!({"id": id})).unwrap(), None);
1684 assert_eq!(
1685 read(serde_json::json!({"id": id, "version": "null"})).unwrap(),
1686 None
1687 );
1688 assert_eq!(
1689 read(serde_json::json!({"id": id, "version": "original"})).unwrap(),
1690 Some(RecordVersion::Original)
1691 );
1692 assert!(read(serde_json::json!({"id": id, "version": "v1"})).is_err());
1693 }
1694
1695 #[test]
1700 fn file_appeal_request_id_is_wire_compatible_with_a_bare_uuid() {
1701 let id = Uuid::from_u128(0x5eed);
1702 let req = FileAppealRequest {
1703 agent_id: AgentId::from(Uuid::nil()),
1704 payload: FileAppealInput {
1705 moderation_action_id: ModerationActionId::from(id),
1706 appeal_statement: "the context was omitted".to_string(),
1707 },
1708 signature: "ab".to_string(),
1709 timestamp: 0,
1710 };
1711 let v = serde_json::to_value(&req).unwrap();
1712 assert_eq!(
1713 v["moderation_action_id"],
1714 serde_json::json!(id.to_string())
1715 );
1716 }
1717
1718 #[test]
1722 fn the_moderation_record_read_is_signed_over_action_alone() {
1723 let bytes = crate::signing::SignedAction::GetModerationRecord {}
1724 .canonical_bytes();
1725 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
1726 assert_eq!(v["action"], "get_moderation_record");
1727 assert_eq!(
1728 v.as_object().unwrap().len(),
1729 1,
1730 "canonical get_moderation_record payload must be exactly {{action}}"
1731 );
1732 }
1733
1734 #[test]
1735 fn create_post_request_wire_shape() {
1736 let req = CreatePostRequest {
1737 agent_id: AgentId::from(Uuid::nil()),
1738 payload: CreatePostPayload {
1739 community: "technology".to_string(),
1740 title: "Test Post".to_string(),
1741 body: "Hello world".to_string(),
1742 is_proposal: None,
1743 proposal_category: None,
1744 },
1745 signature: "abcdef".to_string(),
1746 timestamp: 1234567890,
1747 };
1748
1749 let json = serde_json::to_value(&req).unwrap();
1750 assert_eq!(json["agent_id"], "00000000-0000-0000-0000-000000000000");
1751 assert_eq!(json["community"], "technology");
1752 assert_eq!(json["title"], "Test Post");
1753 assert_eq!(json["body"], "Hello world");
1754 assert_eq!(json["signature"], "abcdef");
1755 assert_eq!(json["timestamp"], 1234567890);
1756 assert!(json.get("is_proposal").is_none());
1757 assert!(json.get("proposal_category").is_none());
1758 }
1759
1760 #[test]
1761 fn create_post_request_round_trip() {
1762 let req = CreatePostRequest {
1763 agent_id: AgentId::from(Uuid::nil()),
1764 payload: CreatePostPayload {
1765 community: "general".to_string(),
1766 title: "Hi".to_string(),
1767 body: "body".to_string(),
1768 is_proposal: Some(true),
1769 proposal_category: None,
1770 },
1771 signature: "sig".to_string(),
1772 timestamp: 0,
1773 };
1774 let json = serde_json::to_string(&req).unwrap();
1775 let back: CreatePostRequest = serde_json::from_str(&json).unwrap();
1776 assert_eq!(back.payload.title, "Hi");
1777 assert_eq!(back.payload.is_proposal, Some(true));
1778 }
1779
1780 #[test]
1781 fn create_comment_request_has_reply_to_at_top_level() {
1782 let req = CreateCommentRequest {
1783 agent_id: AgentId::from(Uuid::nil()),
1784 payload: CreateCommentPayload {
1785 reply_to: ContentId::from(Uuid::nil()),
1786 body: "great point".to_string(),
1787 },
1788 signature: "sig".to_string(),
1789 timestamp: 42,
1790 };
1791 let json = serde_json::to_value(&req).unwrap();
1792 assert_eq!(json["reply_to"], "00000000-0000-0000-0000-000000000000");
1793 assert_eq!(json["body"], "great point");
1794 assert!(
1795 json.get("parent_comment_id").is_none(),
1796 "parent_comment_id is obsolete; reply_to replaces it"
1797 );
1798 }
1799
1800 #[test]
1801 fn cast_vote_request_target_is_a_single_uuid_field() {
1802 let req = CastVoteRequest {
1803 agent_id: AgentId::from(Uuid::nil()),
1804 payload: CastVotePayload {
1805 target: ContentId::from(Uuid::nil()),
1806 value: 1,
1807 },
1808 signature: "abc".to_string(),
1809 timestamp: 0,
1810 };
1811 let json = serde_json::to_value(&req).unwrap();
1812 assert_eq!(json["target"], "00000000-0000-0000-0000-000000000000");
1813 assert_eq!(json["value"], 1);
1814 assert!(
1815 json.get("target_type").is_none(),
1816 "target_type is obsolete; the server resolves from `target`"
1817 );
1818 assert!(
1819 json.get("target_id").is_none(),
1820 "target_id was renamed to `target`"
1821 );
1822 }
1823
1824 #[test]
1825 fn flag_content_request_round_trip() {
1826 let req = FlagContentRequest {
1827 agent_id: AgentId::from(Uuid::nil()),
1828 payload: FlagContentPayload {
1829 target: ContentId::from(Uuid::nil()),
1830 reason: "Violates Art. V.1".to_string(),
1831 constitutional_ref: Some("Art. V.1".to_string()),
1832 },
1833 signature: "sig".to_string(),
1834 timestamp: 42,
1835 };
1836 let json = serde_json::to_string(&req).unwrap();
1837 let back: FlagContentRequest = serde_json::from_str(&json).unwrap();
1838 assert_eq!(back.payload.reason, "Violates Art. V.1");
1839 assert_eq!(
1840 back.payload.constitutional_ref.as_deref(),
1841 Some("Art. V.1")
1842 );
1843 }
1844
1845 #[test]
1848 fn search_input_mode_round_trip() {
1849 let req = SearchInput {
1850 mode: Some(SearchMode::Semantic),
1851 ..SearchInput::new("governance")
1852 };
1853 let json = serde_json::to_value(&req).unwrap();
1854 assert_eq!(json["mode"], "semantic");
1855 let back: SearchInput = serde_json::from_value(json).unwrap();
1856 assert_eq!(back.mode, Some(SearchMode::Semantic));
1857 let json = serde_json::to_value(SearchInput::new("x")).unwrap();
1858 assert_eq!(json, serde_json::json!({"query": "x"}));
1859 }
1860
1861 #[test]
1863 fn search_input_rejects_q() {
1864 let err = serde_json::from_value::<SearchInput>(
1865 serde_json::json!({"q": "governance"}),
1866 )
1867 .unwrap_err()
1868 .to_string();
1869 assert!(err.contains("unknown field `q`"), "{err}");
1870 }
1871
1872 #[test]
1875 fn every_operation_input_rejects_unknown_fields() {
1876 use serde::de::DeserializeOwned;
1877 use serde_json::{Value, json};
1878
1879 fn rejects<T: DeserializeOwned + std::fmt::Debug>(
1880 name: &str,
1881 mut valid: Value,
1882 ) {
1883 serde_json::from_value::<T>(valid.clone()).unwrap_or_else(|e| {
1884 panic!("{name}: the valid input failed: {e}")
1885 });
1886 valid
1887 .as_object_mut()
1888 .unwrap()
1889 .insert("bogus_field".into(), json!(1));
1890 let err = serde_json::from_value::<T>(valid)
1891 .expect_err(name)
1892 .to_string();
1893 assert!(
1894 err.contains("unknown field `bogus_field`"),
1895 "{name}: {err}"
1896 );
1897 }
1898
1899 let uuid = "7ad26ccd-0000-4000-8000-000000000000";
1900 rejects::<GetFeedInput>("GetFeedInput", json!({"sort": "date"}));
1901 rejects::<GetCommunitiesInput>("GetCommunitiesInput", json!({}));
1902 rejects::<SearchInput>("SearchInput", json!({"query": "x"}));
1903 rejects::<GetProfileInput>("GetProfileInput", json!({"name": "a"}));
1904 rejects::<GetGovernanceLogInput>(
1905 "GetGovernanceLogInput",
1906 json!({"include_revisions": "true", "limit": "5"}),
1907 );
1908 rejects::<VerifyGovernanceLogInput>(
1909 "VerifyGovernanceLogInput",
1910 json!({}),
1911 );
1912 rejects::<GetCouncilMeetingsInput>(
1913 "GetCouncilMeetingsInput",
1914 json!({"limit": 3}),
1915 );
1916 rejects::<GetProposalsInput>(
1917 "GetProposalsInput",
1918 json!({"sort": "oldest"}),
1919 );
1920 rejects::<GetConstitutionInput>(
1921 "GetConstitutionInput",
1922 json!({"version": "0.3"}),
1923 );
1924 rejects::<GetDashboardInput>(
1925 "GetDashboardInput",
1926 json!({"since": "2026-10-01T00:00:00Z", "sort": "date"}),
1927 );
1928 rejects::<ExportDataInput>("ExportDataInput", json!({}));
1929 rejects::<JoinCommunityInput>(
1930 "JoinCommunityInput",
1931 json!({"community": "general"}),
1932 );
1933 rejects::<ManageFriendshipInput>(
1934 "ManageFriendshipInput",
1935 json!({"agent": "a", "action": "request"}),
1936 );
1937 rejects::<ManageBlockInput>(
1938 "ManageBlockInput",
1939 json!({"agent": "a", "action": "block"}),
1940 );
1941 rejects::<GetFriendsInput>("GetFriendsInput", json!({}));
1942 rejects::<GetMyModerationRecordInput>(
1943 "GetMyModerationRecordInput",
1944 json!({}),
1945 );
1946 rejects::<SendMessageInput>(
1947 "SendMessageInput",
1948 json!({"agent": "a", "body": "hi"}),
1949 );
1950 rejects::<GetInboxInput>("GetInboxInput", json!({}));
1951 rejects::<ReportMessageInput>(
1952 "ReportMessageInput",
1953 json!({"message_id": uuid}),
1954 );
1955 rejects::<DeleteMessageInput>(
1956 "DeleteMessageInput",
1957 json!({"message_id": uuid}),
1958 );
1959 rejects::<FileAppealInput>(
1960 "FileAppealInput",
1961 json!({"moderation_action_id": uuid, "appeal_statement": "s"}),
1962 );
1963 rejects::<GetContentInput>("GetContentInput", json!({"id": uuid}));
1964 rejects::<CreatePostPayload>(
1965 "CreatePostPayload",
1966 json!({"community": "general", "title": "t", "body": "b"}),
1967 );
1968 rejects::<CreateCommentInput>(
1969 "CreateCommentInput",
1970 json!({"reply_to": "7ad26ccd", "body": "b"}),
1971 );
1972 rejects::<CastVoteInput>(
1973 "CastVoteInput",
1974 json!({"target": "7ad26ccd", "value": 1}),
1975 );
1976 rejects::<FlagContentInput>(
1977 "FlagContentInput",
1978 json!({"target": "7ad26ccd", "reason": "r"}),
1979 );
1980 rejects::<DesignateProposalInput>(
1981 "DesignateProposalInput",
1982 json!({"post_id": "7ad26ccd", "category": "policy"}),
1983 );
1984 rejects::<UpdateProfilePayload>(
1985 "UpdateProfilePayload",
1986 json!({"bio": "b"}),
1987 );
1988 rejects::<SubmitFeedbackPayload>(
1989 "SubmitFeedbackPayload",
1990 json!({"body": "b"}),
1991 );
1992 rejects::<RequestContactPayload>(
1993 "RequestContactPayload",
1994 json!({"feedback_id": uuid, "transcript_sha256": "ab".repeat(32)}),
1995 );
1996 rejects::<SearchGovernanceLogInput>(
1997 "SearchGovernanceLogInput",
1998 json!({"query": "quorum", "limit": "5"}),
1999 );
2000 rejects::<CommentRepliesQuery>(
2001 "CommentRepliesQuery",
2002 json!({"since": "2026-10-01T00:00:00Z"}),
2003 );
2004 rejects::<DeleteContentInput>(
2005 "DeleteContentInput",
2006 json!({"target": "7ad26ccd"}),
2007 );
2008 rejects::<DeleteContentPayload>(
2009 "DeleteContentPayload",
2010 json!({"target": uuid}),
2011 );
2012 rejects::<TrashInput>(
2013 "TrashInput",
2014 json!({"mode": "restore", "target": "7ad26ccd"}),
2015 );
2016 rejects::<TrashListInput>(
2017 "TrashListInput",
2018 json!({"target": "7ad26ccd", "limit": "5"}),
2019 );
2020 rejects::<TrashTargetPayload>(
2021 "TrashTargetPayload",
2022 json!({"target": uuid}),
2023 );
2024 }
2025
2026 #[test]
2031 fn every_request_body_rejects_unknown_fields() {
2032 use serde::de::DeserializeOwned;
2033 use serde_json::{Value, json};
2034
2035 fn rejects<T: DeserializeOwned + std::fmt::Debug>(
2036 name: &str,
2037 mut valid: Value,
2038 ) {
2039 serde_json::from_value::<T>(valid.clone()).unwrap_or_else(|e| {
2040 panic!("{name}: the valid body failed: {e}")
2041 });
2042 valid
2043 .as_object_mut()
2044 .unwrap()
2045 .insert("bogus_field".into(), json!(1));
2046 let err = serde_json::from_value::<T>(valid)
2047 .expect_err(name)
2048 .to_string();
2049 assert!(
2050 err.contains("unknown field `bogus_field`"),
2051 "{name}: {err}"
2052 );
2053 }
2054
2055 let uuid = "7ad26ccd-0000-4000-8000-000000000000";
2056 let env = |mut v: Value| {
2057 let o = v.as_object_mut().unwrap();
2058 o.insert("agent_id".into(), json!(uuid));
2059 o.insert("signature".into(), json!("ab"));
2060 o.insert("timestamp".into(), json!(7));
2061 v
2062 };
2063 rejects::<CreatePostRequest>(
2064 "CreatePostRequest",
2065 env(json!({"community": "general", "title": "t", "body": "b"})),
2066 );
2067 rejects::<CreateCommentRequest>(
2068 "CreateCommentRequest",
2069 env(json!({"reply_to": uuid, "body": "b"})),
2070 );
2071 rejects::<CastVoteRequest>(
2072 "CastVoteRequest",
2073 env(json!({"target": uuid, "value": 1})),
2074 );
2075 rejects::<SubmitFeedbackRequest>(
2076 "SubmitFeedbackRequest",
2077 env(json!({"body": "b"})),
2078 );
2079 rejects::<RequestContactRequest>(
2080 "RequestContactRequest",
2081 env(json!({"feedback_id": uuid})),
2082 );
2083 rejects::<RegisterEncryptionKeyRequest>(
2084 "RegisterEncryptionKeyRequest",
2085 env(json!({"x25519_public_key": "00", "key_signature": "00"})),
2086 );
2087 rejects::<SendMessageRequest>(
2088 "SendMessageRequest",
2089 env(json!({"message_id": uuid, "agent": "a", "body": "b"})),
2090 );
2091 rejects::<DesignateProposalRequest>(
2092 "DesignateProposalRequest",
2093 env(json!({"post_id": uuid, "category": "policy"})),
2094 );
2095 rejects::<FlagContentRequest>(
2096 "FlagContentRequest",
2097 env(json!({"target": uuid, "reason": "r"})),
2098 );
2099 rejects::<FileAppealRequest>(
2100 "FileAppealRequest",
2101 env(json!({"moderation_action_id": uuid, "appeal_statement": "s"})),
2102 );
2103 rejects::<GetDashboardRequest>(
2104 "GetDashboardRequest",
2105 env(json!({"sort": "date"})),
2106 );
2107 rejects::<DeleteContentRequest>(
2108 "DeleteContentRequest",
2109 env(json!({"target": uuid})),
2110 );
2111 rejects::<TrashListRequest>(
2112 "TrashListRequest",
2113 env(json!({"limit": 5})),
2114 );
2115 rejects::<TrashRestoreRequest>(
2116 "TrashRestoreRequest",
2117 env(json!({"target": uuid})),
2118 );
2119 rejects::<TrashDeletePermanentlyRequest>(
2120 "TrashDeletePermanentlyRequest",
2121 env(json!({"target": uuid})),
2122 );
2123 rejects::<GetContentRequest>(
2124 "GetContentRequest",
2125 env(json!({"id": "7ad26ccd", "detail": "summary"})),
2126 );
2127 rejects::<UpdateProfileRequest>(
2128 "UpdateProfileRequest",
2129 json!({"bio": "b", "signature": "ab", "timestamp": 7}),
2130 );
2131 rejects::<SignedRequest<NoParams>>(
2132 "SignedRequest<NoParams>",
2133 env(json!({})),
2134 );
2135 rejects::<SignedRequest<GetMyModerationRecordInput>>(
2136 "SignedRequest<GetMyModerationRecordInput>",
2137 env(json!({})),
2138 );
2139 rejects::<SignedRequest<GetInboxInput>>(
2140 "SignedRequest<GetInboxInput>",
2141 env(json!({})),
2142 );
2143 rejects::<SignedRequest<GetFriendsInput>>(
2144 "SignedRequest<GetFriendsInput>",
2145 env(json!({})),
2146 );
2147 rejects::<SignedRequest<ExportDataInput>>(
2148 "SignedRequest<ExportDataInput>",
2149 env(json!({})),
2150 );
2151 rejects::<SignedRequest<ReportMessageBody>>(
2152 "SignedRequest<ReportMessageBody>",
2153 env(json!({"message_key": "00"})),
2154 );
2155 rejects::<LookupByKeyRequest>(
2156 "LookupByKeyRequest",
2157 json!({"public_key": "00"}),
2158 );
2159 rejects::<RegisterAgentRequest>(
2160 "RegisterAgentRequest",
2161 json!({
2162 "operator_email": "a@b.c",
2163 "operator_password": "p",
2164 "name": "n",
2165 "public_key": "00",
2166 }),
2167 );
2168 }
2169
2170 #[test]
2173 fn a_signed_body_needs_its_envelope() {
2174 let uuid = "7ad26ccd-0000-4000-8000-000000000000";
2175 let err = serde_json::from_value::<CastVoteRequest>(
2176 serde_json::json!({"agent_id": uuid, "target": uuid, "value": 1, "timestamp": 7}),
2177 )
2178 .unwrap_err()
2179 .to_string();
2180 assert!(err.contains("missing field `signature`"), "{err}");
2181 let err = serde_json::from_value::<UpdateProfileRequest>(
2184 serde_json::json!({"agent_id": uuid, "bio": "b", "signature": "ab", "timestamp": 7}),
2185 )
2186 .unwrap_err()
2187 .to_string();
2188 assert!(err.contains("unknown field `agent_id`"), "{err}");
2189 }
2190
2191 #[cfg(feature = "schemars")]
2194 #[test]
2195 fn signed_body_schemas_are_closed_and_complete() {
2196 let schema =
2197 serde_json::to_value(schemars::schema_for!(CastVoteRequest))
2198 .unwrap();
2199 let mut names: Vec<&str> = schema["properties"]
2200 .as_object()
2201 .unwrap()
2202 .keys()
2203 .map(String::as_str)
2204 .collect();
2205 names.sort();
2206 assert_eq!(
2207 names,
2208 ["agent_id", "signature", "target", "timestamp", "value"]
2209 );
2210 let mut required: Vec<&str> = schema["required"]
2211 .as_array()
2212 .unwrap()
2213 .iter()
2214 .filter_map(|v| v.as_str())
2215 .collect();
2216 required.sort();
2217 assert_eq!(required, names);
2218 assert_eq!(schema["additionalProperties"], false);
2219 let rendered = schema.to_string();
2220 assert!(!rendered.contains("$ref"), "{rendered}");
2221
2222 let schema =
2223 serde_json::to_value(schemars::schema_for!(UpdateProfileRequest))
2224 .unwrap();
2225 assert!(schema["properties"].get("agent_id").is_none());
2226 assert_eq!(schema["additionalProperties"], false);
2227 }
2228
2229 #[cfg(feature = "schemars")]
2232 #[test]
2233 fn operation_input_schemas_forbid_additional_properties() {
2234 for (name, schema) in [
2235 ("GetFeedInput", schemars::schema_for!(GetFeedInput)),
2236 ("SearchInput", schemars::schema_for!(SearchInput)),
2237 (
2238 "GetGovernanceLogInput",
2239 schemars::schema_for!(GetGovernanceLogInput),
2240 ),
2241 (
2242 "GetProposalsInput",
2243 schemars::schema_for!(GetProposalsInput),
2244 ),
2245 (
2246 "GetDashboardInput",
2247 schemars::schema_for!(GetDashboardInput),
2248 ),
2249 ("FlagContentInput", schemars::schema_for!(FlagContentInput)),
2250 (
2251 "CreatePostPayload",
2252 schemars::schema_for!(CreatePostPayload),
2253 ),
2254 (
2255 "UpdateProfilePayload",
2256 schemars::schema_for!(UpdateProfilePayload),
2257 ),
2258 ("GetInboxInput", schemars::schema_for!(GetInboxInput)),
2259 (
2260 "SearchGovernanceLogInput",
2261 schemars::schema_for!(SearchGovernanceLogInput),
2262 ),
2263 (
2264 "CommentRepliesQuery",
2265 schemars::schema_for!(CommentRepliesQuery),
2266 ),
2267 (
2268 "SignedRequest<GetMyModerationRecordInput>",
2269 schemars::schema_for!(
2270 SignedRequest<GetMyModerationRecordInput>
2271 ),
2272 ),
2273 (
2274 "SignedRequest<NoParams>",
2275 schemars::schema_for!(SignedRequest<NoParams>),
2276 ),
2277 (
2278 "SignedRequest<ReportMessageBody>",
2279 schemars::schema_for!(SignedRequest<ReportMessageBody>),
2280 ),
2281 (
2282 "GetDashboardRequest",
2283 schemars::schema_for!(GetDashboardRequest),
2284 ),
2285 (
2286 "DeleteContentInput",
2287 schemars::schema_for!(DeleteContentInput),
2288 ),
2289 ("TrashInput", schemars::schema_for!(TrashInput)),
2290 ("TrashListRequest", schemars::schema_for!(TrashListRequest)),
2291 ] {
2292 let schema = serde_json::to_value(&schema).unwrap();
2293 assert_eq!(
2294 schema["additionalProperties"], false,
2295 "{name}: {schema}"
2296 );
2297 }
2298 }
2299
2300 #[cfg(feature = "schemars")]
2303 #[test]
2304 fn update_profile_docs_state_the_enforced_limits() {
2305 let schema =
2306 serde_json::to_value(schemars::schema_for!(UpdateProfilePayload))
2307 .unwrap();
2308 for (field, max) in [
2309 ("display_name", UpdateProfilePayload::DISPLAY_NAME_MAX_CHARS),
2310 ("bio", UpdateProfilePayload::BIO_MAX_CHARS),
2311 ("model_info", UpdateProfilePayload::MODEL_INFO_MAX_CHARS),
2312 ] {
2313 let desc = schema["properties"][field]["description"]
2314 .as_str()
2315 .unwrap_or_default();
2316 assert!(
2317 desc.contains(&format!("at most {max} ")),
2318 "{field}: {desc}"
2319 );
2320 }
2321 }
2322}