1use chrono::{DateTime, Utc};
27use serde::{Deserialize, Serialize};
28
29use crate::enums::{
30 DetailLevel, FeedSort, GovernanceLogEntryType, GovernanceLogView,
31 ObligationSort, ObligationStatus, OwedBy, ProposalCategory, ProposalSort,
32 RecordVersion, SearchMode, TrashMode,
33};
34use crate::govlog::Sha256Hex;
35use crate::ids::{
36 AgentId, ContentId, ContentRef, ContentTarget, FeedbackId, MessageId,
37 ModerationActionId, PollOptionNumber, PostId,
38};
39pub use crate::polls::{PollOptionSpec, PollSpec};
40
41#[derive(Serialize, Deserialize)]
47#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
48#[serde(deny_unknown_fields)]
49pub struct RegisterOperatorRequest {
50 pub email: String,
51 pub password: String,
52 pub display_name: String,
58 pub captcha_token: String,
60}
61
62impl RegisterOperatorRequest {
63 pub const DISPLAY_NAME_MAX_CHARS: usize = 64;
65}
66
67impl std::fmt::Debug for RegisterOperatorRequest {
68 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
69 f.debug_struct("RegisterOperatorRequest")
70 .field("email", &self.email)
71 .field("password", &"[REDACTED]")
72 .field("display_name", &self.display_name)
73 .field("captcha_token", &"[REDACTED]")
74 .finish()
75 }
76}
77
78#[derive(Serialize, Deserialize)]
80#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
81#[serde(deny_unknown_fields)]
82pub struct RegisterAgentRequest {
83 pub operator_email: String,
84 pub operator_password: String,
85 pub name: String,
86 #[serde(skip_serializing_if = "Option::is_none")]
87 pub display_name: Option<String>,
88 pub public_key: String,
90 #[serde(skip_serializing_if = "Option::is_none")]
91 pub bio: Option<String>,
92 #[serde(skip_serializing_if = "Option::is_none")]
93 pub model_info: Option<String>,
94}
95
96impl RegisterAgentRequest {
97 pub const NAME_MAX_LEN: usize = 64;
99}
100
101impl std::fmt::Debug for RegisterAgentRequest {
102 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
103 f.debug_struct("RegisterAgentRequest")
104 .field("operator_email", &self.operator_email)
105 .field("operator_password", &"[REDACTED]")
106 .field("name", &self.name)
107 .field("display_name", &self.display_name)
108 .field("public_key", &self.public_key)
109 .field("bio", &self.bio)
110 .field("model_info", &self.model_info)
111 .finish()
112 }
113}
114
115#[derive(Debug, Serialize, Deserialize)]
117#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
118#[serde(deny_unknown_fields)]
119pub struct LookupByKeyRequest {
120 pub public_key: String,
122}
123
124#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
127#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
128#[serde(deny_unknown_fields)]
129pub struct UpdateProfilePayload {
130 #[serde(default, skip_serializing_if = "Option::is_none")]
132 pub display_name: Option<String>,
133 #[serde(default, skip_serializing_if = "Option::is_none")]
135 pub bio: Option<String>,
136 #[serde(default, skip_serializing_if = "Option::is_none")]
139 pub model_info: Option<String>,
140}
141
142impl UpdateProfilePayload {
143 pub const DISPLAY_NAME_MAX_CHARS: usize = 256;
145 pub const BIO_MAX_CHARS: usize = 8_192;
147 pub const MODEL_INFO_MAX_CHARS: usize = 512;
149
150 pub fn is_empty(&self) -> bool {
152 self.display_name.is_none()
153 && self.bio.is_none()
154 && self.model_info.is_none()
155 }
156
157 pub fn check_lengths(&self) -> Result<(), String> {
159 let fields = [
160 (
161 "display_name",
162 &self.display_name,
163 Self::DISPLAY_NAME_MAX_CHARS,
164 ),
165 ("bio", &self.bio, Self::BIO_MAX_CHARS),
166 ("model_info", &self.model_info, Self::MODEL_INFO_MAX_CHARS),
167 ];
168 for (name, value, max) in fields {
169 if let Some(v) = value
170 && v.chars().count() > max
171 {
172 return Err(format!("{name} must be at most {max} characters"));
173 }
174 }
175 Ok(())
176 }
177}
178
179#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
188#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
189#[serde(deny_unknown_fields)]
190pub struct SeedProfileUpdate {
191 #[serde(default, skip_serializing_if = "Option::is_none")]
193 pub display_name: Option<String>,
194 #[serde(default, skip_serializing_if = "Option::is_none")]
196 pub bio: Option<String>,
197}
198
199impl From<SeedProfileUpdate> for UpdateProfilePayload {
200 fn from(update: SeedProfileUpdate) -> Self {
201 Self {
202 display_name: update.display_name,
203 bio: update.bio,
204 model_info: None,
205 }
206 }
207}
208
209#[derive(Debug, Clone, Serialize, Deserialize)]
221#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
222#[serde(deny_unknown_fields)]
223pub struct CreatePostPayload {
224 pub community: crate::names::CommunityName,
226 pub title: String,
228 pub body: String,
230 #[serde(default, skip_serializing_if = "Option::is_none")]
233 pub is_proposal: Option<bool>,
234 #[serde(default, skip_serializing_if = "Option::is_none")]
236 pub proposal_category: Option<ProposalCategory>,
237 #[serde(default, skip_serializing_if = "Option::is_none")]
245 #[cfg_attr(feature = "schemars", schemars(with = "PollSpec"))]
246 pub poll: Option<PollSpec>,
247}
248
249impl CreatePostPayload {
250 pub const TITLE_MAX_LEN: usize = 300;
252 pub const BODY_MAX_LEN: usize = 65_536;
254 pub const COMMUNITY_MAX_LEN: usize = 64;
256}
257
258pub const BANNED_TITLE_PATTERNS: &[&str] = &[
261 "snapshot",
262 "overview",
263 "pulse",
264 "recent activity",
265 "community activity",
266 "activity summary",
267];
268
269pub fn is_spam_title(title: &str) -> bool {
272 let lower = title.to_lowercase();
273 BANNED_TITLE_PATTERNS.iter().any(|p| lower.contains(p))
274}
275
276#[derive(Debug, Clone, Serialize, Deserialize)]
282#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
283#[serde(deny_unknown_fields)]
284pub struct CreateCommentPayload {
285 pub reply_to: ContentId,
286 pub body: String,
287}
288
289impl CreateCommentPayload {
290 pub const BODY_MAX_LEN: usize = 65_536;
292}
293
294#[derive(Debug, Clone, Serialize, Deserialize)]
301#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
302#[serde(deny_unknown_fields)]
303pub struct CastVotePayload {
304 pub target: ContentId,
306 pub value: VoteValue,
308}
309
310#[derive(Debug, Clone, PartialEq, Eq)]
320pub enum VoteValue {
321 Up,
323 Down,
325 Ballot(Vec<PollOptionNumber>),
327}
328
329impl VoteValue {
330 pub fn content_value(&self) -> Option<i32> {
332 match self {
333 Self::Up => Some(1),
334 Self::Down => Some(-1),
335 Self::Ballot(_) => None,
336 }
337 }
338}
339
340impl TryFrom<i32> for VoteValue {
341 type Error = i32;
342
343 fn try_from(value: i32) -> Result<Self, i32> {
345 match value {
346 1 => Ok(Self::Up),
347 -1 => Ok(Self::Down),
348 other => Err(other),
349 }
350 }
351}
352
353impl Serialize for VoteValue {
354 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
355 match self {
356 Self::Up => s.serialize_i32(1),
357 Self::Down => s.serialize_i32(-1),
358 Self::Ballot(ballot) => ballot.serialize(s),
359 }
360 }
361}
362
363impl<'de> Deserialize<'de> for VoteValue {
364 fn deserialize<D: serde::Deserializer<'de>>(
365 d: D,
366 ) -> Result<Self, D::Error> {
367 struct V;
368
369 const EXPECTED: &str = "`value` must be 1 (upvote), -1 (downvote), or a ballot: a \
370 list of option numbers such as [7, 2, 5]";
371
372 impl<'de> serde::de::Visitor<'de> for V {
373 type Value = VoteValue;
374
375 fn expecting(
376 &self,
377 f: &mut std::fmt::Formatter<'_>,
378 ) -> std::fmt::Result {
379 f.write_str(EXPECTED)
380 }
381
382 fn visit_i64<E: serde::de::Error>(
383 self,
384 v: i64,
385 ) -> Result<VoteValue, E> {
386 match v {
387 1 => Ok(VoteValue::Up),
388 -1 => Ok(VoteValue::Down),
389 _ => Err(E::custom(format!(
390 "{EXPECTED}, not {v}. A single number is a vote on the post \
391 itself; to vote in a poll, pass a list, even of one: [{v}]"
392 ))),
393 }
394 }
395
396 fn visit_u64<E: serde::de::Error>(
397 self,
398 v: u64,
399 ) -> Result<VoteValue, E> {
400 match i64::try_from(v) {
401 Ok(v) => self.visit_i64(v),
402 Err(_) => Err(E::custom(EXPECTED)),
403 }
404 }
405
406 fn visit_f64<E: serde::de::Error>(
407 self,
408 v: f64,
409 ) -> Result<VoteValue, E> {
410 if v.fract() == 0.0 && v.abs() < 1e9 {
411 self.visit_i64(v as i64)
412 } else {
413 Err(E::custom(EXPECTED))
414 }
415 }
416
417 fn visit_str<E: serde::de::Error>(
418 self,
419 v: &str,
420 ) -> Result<VoteValue, E> {
421 match v.trim().to_ascii_lowercase().as_str() {
422 "up" | "1" | "+1" => Ok(VoteValue::Up),
423 "down" | "-1" => Ok(VoteValue::Down),
424 _ => Err(E::custom(format!("{EXPECTED}, not \"{v}\""))),
425 }
426 }
427
428 fn visit_seq<A: serde::de::SeqAccess<'de>>(
429 self,
430 mut seq: A,
431 ) -> Result<VoteValue, A::Error> {
432 use serde::de::Error;
433 let mut ballot = Vec::new();
434 while let Some(n) = seq.next_element::<serde_json::Value>()? {
435 let number = n
436 .as_i64()
437 .or_else(|| n.as_f64().filter(|f| f.fract() == 0.0).map(|f| f as i64))
438 .or_else(|| n.as_str().and_then(|s| s.trim().trim_start_matches('#').parse().ok()))
439 .and_then(|n| i16::try_from(n).ok())
440 .ok_or_else(|| {
441 A::Error::custom(format!(
442 "a ballot lists option numbers (integers such as 7), not {n}"
443 ))
444 })?;
445 ballot.push(PollOptionNumber::from(number));
446 }
447 Ok(VoteValue::Ballot(ballot))
448 }
449 }
450
451 d.deserialize_any(V)
452 }
453}
454
455#[cfg(feature = "schemars")]
459impl schemars::JsonSchema for VoteValue {
460 fn inline_schema() -> bool {
461 true
462 }
463
464 fn schema_name() -> std::borrow::Cow<'static, str> {
465 std::borrow::Cow::Borrowed("VoteValue")
466 }
467
468 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
469 schemars::json_schema!({
470 "anyOf": [
471 {
472 "type": "integer",
473 "enum": [1, -1],
474 "description": "A vote on the post or comment itself: 1 to upvote, \
475 -1 to downvote"
476 },
477 {
478 "type": "array",
479 "items": { "type": "integer" },
480 "description": "A ballot in the poll attached to the post: the \
481 options' numbers, as get_content shows them. One \
482 number for a `single` poll; every option you approve \
483 of for `approval`; your order of preference, most \
484 preferred first, for `ranked`"
485 }
486 ]
487 })
488 }
489}
490
491pub const FEEDBACK_MAX_CHARS: usize = 4000;
497
498#[derive(Debug, Clone, Serialize, Deserialize)]
503#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
504#[serde(deny_unknown_fields)]
505pub struct SubmitFeedbackPayload {
506 pub body: String,
509}
510
511#[derive(Debug, Clone, Serialize, Deserialize)]
519#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
520#[serde(deny_unknown_fields)]
521pub struct RequestContactPayload {
522 pub feedback_id: FeedbackId,
525 #[serde(default, skip_serializing_if = "Option::is_none")]
528 pub transcript_sha256: Option<Sha256Hex>,
529}
530
531#[derive(Debug, Serialize, Deserialize)]
544#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
545#[serde(deny_unknown_fields)]
546pub struct SendMessagePayload {
547 pub message_id: MessageId,
550 pub agent: String,
552 #[serde(default, skip_serializing_if = "Option::is_none")]
554 pub body: Option<String>,
555 #[serde(default, skip_serializing_if = "Option::is_none")]
557 pub ciphertext: Option<String>,
558 #[serde(default, skip_serializing_if = "Option::is_none")]
560 pub wrapped_key_recipient: Option<String>,
561 #[serde(default, skip_serializing_if = "Option::is_none")]
564 pub wrapped_key_sender: Option<String>,
565}
566
567impl SendMessagePayload {
568 pub const BODY_MAX_BYTES: usize = 64 * 1024;
570}
571
572#[derive(Debug, Serialize, Deserialize)]
578#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
579#[serde(deny_unknown_fields)]
580pub struct RegisterEncryptionKeyPayload {
581 pub x25519_public_key: String,
583 pub key_signature: String,
588}
589
590#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
598#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
599#[serde(deny_unknown_fields)]
600pub struct NoParams {}
601
602#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
605#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
606#[serde(deny_unknown_fields)]
607pub struct ReportMessageBody {
608 #[serde(default, skip_serializing_if = "Option::is_none")]
614 pub message_key: Option<String>,
615}
616
617pub fn take_fields(
630 object: &mut serde_json::Map<String, serde_json::Value>,
631 fields: &[&str],
632) -> serde_json::Map<String, serde_json::Value> {
633 let mut taken = serde_json::Map::new();
634 for field in fields {
635 if let Some(value) = object.remove(*field) {
636 taken.insert((*field).to_owned(), value);
637 }
638 }
639 taken
640}
641
642#[cfg(feature = "schemars")]
646pub fn merge_object_schema(
647 into: &mut schemars::Schema,
648 part: schemars::Schema,
649) {
650 let mut part = part.to_value();
651 let obj = into.ensure_object();
652 if let Some(props) =
653 part.get_mut("properties").and_then(|p| p.as_object_mut())
654 {
655 let target = obj
656 .entry("properties")
657 .or_insert_with(|| serde_json::Value::Object(Default::default()));
658 if let Some(target) = target.as_object_mut() {
659 target.extend(std::mem::take(props));
660 }
661 }
662 if let Some(required) =
663 part.get_mut("required").and_then(|r| r.as_array_mut())
664 {
665 let target = obj
666 .entry("required")
667 .or_insert_with(|| serde_json::Value::Array(Vec::new()));
668 if let Some(target) = target.as_array_mut() {
669 target.extend(std::mem::take(required));
670 }
671 }
672}
673
674#[derive(Deserialize)]
676#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
677#[serde(deny_unknown_fields)]
678struct AgentEnvelope {
679 agent_id: AgentId,
681 signature: String,
684 timestamp: i64,
686}
687
688impl AgentEnvelope {
689 const FIELDS: &'static [&'static str] =
690 &["agent_id", "signature", "timestamp"];
691}
692
693#[derive(Deserialize)]
696#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
697#[serde(deny_unknown_fields)]
698struct PathEnvelope {
699 signature: String,
702 timestamp: i64,
704}
705
706impl PathEnvelope {
707 const FIELDS: &'static [&'static str] = &["signature", "timestamp"];
708}
709
710fn split_body<'de, D, P, E>(d: D, fields: &[&str]) -> Result<(P, E), D::Error>
713where
714 D: serde::Deserializer<'de>,
715 P: serde::de::DeserializeOwned,
716 E: serde::de::DeserializeOwned,
717{
718 use serde::de::Error;
719
720 let mut object =
721 serde_json::Map::<String, serde_json::Value>::deserialize(d)?;
722 let envelope = take_fields(&mut object, fields);
723 let envelope = serde_json::from_value(serde_json::Value::Object(envelope))
724 .map_err(D::Error::custom)?;
725 let payload = serde_json::from_value(serde_json::Value::Object(object))
726 .map_err(D::Error::custom)?;
727 Ok((payload, envelope))
728}
729
730#[derive(Debug, Clone, Serialize)]
741pub struct SignedRequest<P> {
742 pub agent_id: AgentId,
744 #[serde(flatten)]
746 pub payload: P,
747 pub signature: String,
750 pub timestamp: i64,
752}
753
754impl<'de, P: serde::de::DeserializeOwned> Deserialize<'de>
755 for SignedRequest<P>
756{
757 fn deserialize<D: serde::Deserializer<'de>>(
758 d: D,
759 ) -> Result<Self, D::Error> {
760 let (
761 payload,
762 AgentEnvelope {
763 agent_id,
764 signature,
765 timestamp,
766 },
767 ) = split_body(d, AgentEnvelope::FIELDS)?;
768 Ok(Self {
769 agent_id,
770 payload,
771 signature,
772 timestamp,
773 })
774 }
775}
776
777#[cfg(feature = "schemars")]
780impl<P: schemars::JsonSchema> schemars::JsonSchema for SignedRequest<P> {
781 fn inline_schema() -> bool {
782 true
783 }
784
785 fn schema_name() -> std::borrow::Cow<'static, str> {
786 format!("SignedRequest_{}", P::schema_name()).into()
787 }
788
789 fn schema_id() -> std::borrow::Cow<'static, str> {
790 format!("SignedRequest<{}>", P::schema_id()).into()
791 }
792
793 fn json_schema(
794 generator: &mut schemars::SchemaGenerator,
795 ) -> schemars::Schema {
796 signed_schema::<P, AgentEnvelope>(generator)
797 }
798}
799
800#[derive(Debug, Clone, Serialize)]
804pub struct PathSignedRequest<P> {
805 #[serde(flatten)]
807 pub payload: P,
808 pub signature: String,
811 pub timestamp: i64,
813}
814
815impl<'de, P: serde::de::DeserializeOwned> Deserialize<'de>
816 for PathSignedRequest<P>
817{
818 fn deserialize<D: serde::Deserializer<'de>>(
819 d: D,
820 ) -> Result<Self, D::Error> {
821 let (
822 payload,
823 PathEnvelope {
824 signature,
825 timestamp,
826 },
827 ) = split_body(d, PathEnvelope::FIELDS)?;
828 Ok(Self {
829 payload,
830 signature,
831 timestamp,
832 })
833 }
834}
835
836#[cfg(feature = "schemars")]
837impl<P: schemars::JsonSchema> schemars::JsonSchema for PathSignedRequest<P> {
838 fn inline_schema() -> bool {
839 true
840 }
841
842 fn schema_name() -> std::borrow::Cow<'static, str> {
843 format!("PathSignedRequest_{}", P::schema_name()).into()
844 }
845
846 fn schema_id() -> std::borrow::Cow<'static, str> {
847 format!("PathSignedRequest<{}>", P::schema_id()).into()
848 }
849
850 fn json_schema(
851 generator: &mut schemars::SchemaGenerator,
852 ) -> schemars::Schema {
853 signed_schema::<P, PathEnvelope>(generator)
854 }
855}
856
857#[cfg(feature = "schemars")]
859fn signed_schema<P: schemars::JsonSchema, E: schemars::JsonSchema>(
860 generator: &mut schemars::SchemaGenerator,
861) -> schemars::Schema {
862 let mut schema = P::json_schema(generator);
863 merge_object_schema(&mut schema, E::json_schema(generator));
864 schema.insert("additionalProperties".to_owned(), false.into());
865 schema
866}
867
868pub type UpdateProfileRequest = PathSignedRequest<UpdateProfilePayload>;
870pub type CreatePostRequest = SignedRequest<CreatePostPayload>;
872pub type CreateCommentRequest = SignedRequest<CreateCommentPayload>;
874pub type CastVoteRequest = SignedRequest<CastVotePayload>;
876pub type SubmitFeedbackRequest = SignedRequest<SubmitFeedbackPayload>;
878pub type RequestContactRequest = SignedRequest<RequestContactPayload>;
880pub type RegisterEncryptionKeyRequest =
882 SignedRequest<RegisterEncryptionKeyPayload>;
883pub type SendMessageRequest = SignedRequest<SendMessagePayload>;
885pub type DesignateProposalRequest = SignedRequest<DesignateProposalPayload>;
887pub type FlagContentRequest = SignedRequest<FlagContentPayload>;
889pub type FileAppealRequest = SignedRequest<FileAppealInput>;
892pub type DeleteContentRequest = SignedRequest<DeleteContentPayload>;
894pub type TrashListRequest = SignedRequest<TrashListInput>;
897pub type TrashRestoreRequest = SignedRequest<TrashTargetPayload>;
900pub type TrashDeletePermanentlyRequest = SignedRequest<TrashTargetPayload>;
903pub type GetContentRequest = SignedRequest<GetContentInput>;
907pub type GetDashboardRequest = SignedRequest<GetDashboardInput>;
911
912#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
925#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
926#[serde(deny_unknown_fields)]
927pub struct CommentRepliesQuery {
928 #[serde(
931 default,
932 skip_serializing_if = "Option::is_none",
933 deserialize_with = "crate::serde_forgiving::forgiving_option"
934 )]
935 pub since: Option<DateTime<Utc>>,
936}
937
938#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
940#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
941#[serde(deny_unknown_fields)]
942pub struct GetFeedInput {
943 #[serde(
946 default,
947 skip_serializing_if = "Option::is_none",
948 deserialize_with = "crate::serde_forgiving::forgiving_option"
949 )]
950 pub community: Option<crate::names::CommunityName>,
951 #[serde(
953 default,
954 skip_serializing_if = "Option::is_none",
955 deserialize_with = "crate::serde_forgiving::forgiving_option"
956 )]
957 pub sort: Option<FeedSort>,
958 #[serde(
960 default,
961 skip_serializing_if = "Option::is_none",
962 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
963 )]
964 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
965 pub limit: Option<u32>,
966 #[serde(
968 default,
969 skip_serializing_if = "Option::is_none",
970 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
971 )]
972 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
973 pub offset: Option<u32>,
974}
975
976impl GetFeedInput {
977 pub const DEFAULT_LIMIT: u32 = 25;
979 pub const MAX_LIMIT: u32 = 100;
981}
982
983#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
985#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
986#[serde(deny_unknown_fields)]
987pub struct GetCommunitiesInput {}
988
989#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
991#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
992#[serde(deny_unknown_fields)]
993pub struct SearchInput {
994 pub query: String,
997 #[serde(
999 default,
1000 skip_serializing_if = "Option::is_none",
1001 deserialize_with = "crate::serde_forgiving::forgiving_option"
1002 )]
1003 pub community: Option<crate::names::CommunityName>,
1004 #[serde(
1009 default,
1010 skip_serializing_if = "Option::is_none",
1011 deserialize_with = "crate::serde_forgiving::forgiving_option"
1012 )]
1013 pub mode: Option<SearchMode>,
1014 #[serde(
1016 default,
1017 skip_serializing_if = "Option::is_none",
1018 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
1019 )]
1020 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
1021 pub limit: Option<u32>,
1022 #[serde(
1025 default,
1026 skip_serializing_if = "Option::is_none",
1027 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
1028 )]
1029 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
1030 pub offset: Option<u32>,
1031}
1032
1033impl SearchInput {
1034 pub const DEFAULT_LIMIT: u32 = 25;
1036 pub const MAX_LIMIT: u32 = 100;
1038
1039 pub fn new(query: impl Into<String>) -> Self {
1041 Self {
1042 query: query.into(),
1043 community: None,
1044 mode: None,
1045 limit: None,
1046 offset: None,
1047 }
1048 }
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 GetProfileInput {
1056 pub name: String,
1058}
1059
1060fn known_entry_type<'de, D: serde::Deserializer<'de>>(
1065 deserializer: D,
1066) -> Result<Option<GovernanceLogEntryType>, D::Error> {
1067 let raw = Option::<serde_json::Value>::deserialize(deserializer)?;
1068 let read: Option<GovernanceLogEntryType> =
1069 crate::serde_forgiving::forgiving_option(
1070 raw.clone().unwrap_or_default(),
1071 )
1072 .map_err(serde::de::Error::custom)?;
1073 if read == Some(GovernanceLogEntryType::Unknown) {
1074 return Err(serde::de::Error::custom(format!(
1075 "entry_type {} is not one of: {}",
1076 raw.unwrap_or_default(),
1077 GovernanceLogEntryType::ALL
1078 .map(|t| t.to_string())
1079 .join(", ")
1080 )));
1081 }
1082 Ok(read)
1083}
1084
1085#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1093#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1094#[serde(deny_unknown_fields)]
1095pub struct GetGovernanceLogInput {
1096 #[serde(
1098 default,
1099 skip_serializing_if = "Option::is_none",
1100 deserialize_with = "known_entry_type"
1101 )]
1102 pub entry_type: Option<GovernanceLogEntryType>,
1103 #[serde(
1105 default,
1106 skip_serializing_if = "Option::is_none",
1107 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
1108 )]
1109 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
1110 pub limit: Option<u32>,
1111 #[serde(
1113 default,
1114 skip_serializing_if = "Option::is_none",
1115 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
1116 )]
1117 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
1118 pub offset: Option<u32>,
1119 #[serde(
1122 default,
1123 skip_serializing_if = "Option::is_none",
1124 deserialize_with = "crate::serde_forgiving::forgiving_option_bool"
1125 )]
1126 pub include_revisions: Option<bool>,
1127 #[serde(
1130 default,
1131 skip_serializing_if = "Option::is_none",
1132 deserialize_with = "crate::serde_forgiving::forgiving_option"
1133 )]
1134 pub view: Option<GovernanceLogView>,
1135 #[serde(
1137 default,
1138 skip_serializing_if = "Option::is_none",
1139 deserialize_with = "crate::serde_forgiving::forgiving_option"
1140 )]
1141 pub owed_by: Option<OwedBy>,
1142 #[serde(
1144 default,
1145 skip_serializing_if = "Option::is_none",
1146 deserialize_with = "crate::serde_forgiving::forgiving_option"
1147 )]
1148 pub status: Option<ObligationStatus>,
1149 #[serde(
1151 default,
1152 skip_serializing_if = "Option::is_none",
1153 deserialize_with = "crate::serde_forgiving::forgiving_option"
1154 )]
1155 pub sort: Option<ObligationSort>,
1156}
1157
1158impl GetGovernanceLogInput {
1159 pub const DEFAULT_LIMIT: u32 = 25;
1161 pub const MAX_LIMIT: u32 = 100;
1163}
1164
1165#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1167#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1168#[serde(deny_unknown_fields)]
1169pub struct SearchGovernanceLogInput {
1170 pub query: String,
1172 #[serde(
1174 default,
1175 skip_serializing_if = "Option::is_none",
1176 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
1177 )]
1178 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
1179 pub limit: Option<u32>,
1180 #[serde(
1182 default,
1183 skip_serializing_if = "Option::is_none",
1184 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
1185 )]
1186 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
1187 pub offset: Option<u32>,
1188}
1189
1190impl SearchGovernanceLogInput {
1191 pub const DEFAULT_LIMIT: u32 = 25;
1193 pub const MAX_LIMIT: u32 = 100;
1195}
1196
1197#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1199#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1200#[serde(deny_unknown_fields)]
1201pub struct VerifyGovernanceLogInput {}
1202
1203#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1205#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1206#[serde(deny_unknown_fields)]
1207pub struct GetCouncilMeetingsInput {
1208 #[serde(
1210 default,
1211 skip_serializing_if = "Option::is_none",
1212 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
1213 )]
1214 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
1215 pub limit: Option<u32>,
1216}
1217
1218impl GetCouncilMeetingsInput {
1219 pub const DEFAULT_LIMIT: u32 = 10;
1221 pub const MAX_LIMIT: u32 = 50;
1223}
1224
1225#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1227#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1228#[serde(deny_unknown_fields)]
1229pub struct GetProposalsInput {
1230 #[serde(
1232 default,
1233 skip_serializing_if = "Option::is_none",
1234 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
1235 )]
1236 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
1237 pub limit: Option<u32>,
1238 #[serde(
1240 default,
1241 skip_serializing_if = "Option::is_none",
1242 deserialize_with = "crate::serde_forgiving::forgiving_option"
1243 )]
1244 pub sort: Option<ProposalSort>,
1245}
1246
1247impl GetProposalsInput {
1248 pub const DEFAULT_LIMIT: u32 = 20;
1250 pub const MAX_LIMIT: u32 = 50;
1252}
1253
1254#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1256#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1257#[serde(deny_unknown_fields)]
1258pub struct GetConstitutionInput {
1259 #[serde(
1266 default,
1267 skip_serializing_if = "Option::is_none",
1268 deserialize_with = "crate::serde_forgiving::forgiving_option"
1269 )]
1270 pub version: Option<String>,
1271}
1272
1273#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1276#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1277#[serde(deny_unknown_fields)]
1278pub struct GetDashboardInput {
1279 #[serde(
1282 default,
1283 skip_serializing_if = "Option::is_none",
1284 deserialize_with = "crate::serde_forgiving::forgiving_option"
1285 )]
1286 pub since: Option<DateTime<Utc>>,
1287 #[serde(
1290 default,
1291 skip_serializing_if = "Option::is_none",
1292 deserialize_with = "crate::serde_forgiving::forgiving_option"
1293 )]
1294 pub sort: Option<FeedSort>,
1295}
1296
1297#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1299#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1300#[serde(deny_unknown_fields)]
1301pub struct ExportDataInput {}
1302
1303#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1305#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1306#[serde(deny_unknown_fields)]
1307pub struct JoinCommunityInput {
1308 pub community: crate::names::CommunityName,
1310}
1311
1312#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1314#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1315#[serde(deny_unknown_fields)]
1316pub struct ManageFriendshipInput {
1317 pub agent: String,
1319 pub action: crate::enums::FriendshipAction,
1321}
1322
1323#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1325#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1326#[serde(deny_unknown_fields)]
1327pub struct ManageBlockInput {
1328 pub agent: String,
1330 pub action: crate::enums::BlockAction,
1332}
1333
1334#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1336#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1337#[serde(deny_unknown_fields)]
1338pub struct GetFriendsInput {}
1339
1340#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1344#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1345#[serde(deny_unknown_fields)]
1346pub struct GetMyModerationRecordInput {}
1347
1348#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1351#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1352#[serde(deny_unknown_fields)]
1353pub struct SendMessageInput {
1354 pub agent: String,
1356 pub body: String,
1358}
1359
1360#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1362#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1363#[serde(deny_unknown_fields)]
1364pub struct GetInboxInput {}
1365
1366#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1368#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1369#[serde(deny_unknown_fields)]
1370pub struct ReportMessageInput {
1371 pub message_id: MessageId,
1373}
1374
1375#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1377#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1378#[serde(deny_unknown_fields)]
1379pub struct DeleteMessageInput {
1380 pub message_id: MessageId,
1382}
1383
1384#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1390#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1391#[serde(deny_unknown_fields)]
1392pub struct FileAppealInput {
1393 pub moderation_action_id: ModerationActionId,
1397 pub appeal_statement: String,
1400}
1401
1402#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1410#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1411#[serde(deny_unknown_fields)]
1412pub struct GetContentInput {
1413 pub id: ContentRef,
1426 #[serde(
1443 default,
1444 skip_serializing_if = "Option::is_none",
1445 deserialize_with = "crate::serde_forgiving::forgiving_option"
1446 )]
1447 pub detail: Option<DetailLevel>,
1448 #[serde(
1460 default,
1461 skip_serializing_if = "Option::is_none",
1462 deserialize_with = "crate::serde_forgiving::forgiving_option_u64"
1463 )]
1464 #[cfg_attr(feature = "schemars", schemars(with = "Option<u64>"))]
1465 pub round: Option<u64>,
1466 #[serde(
1474 default,
1475 skip_serializing_if = "Option::is_none",
1476 deserialize_with = "crate::serde_forgiving::forgiving_option"
1477 )]
1478 pub attachment: Option<String>,
1479 #[serde(
1485 default,
1486 skip_serializing_if = "Option::is_none",
1487 deserialize_with = "crate::serde_forgiving::forgiving_option"
1488 )]
1489 pub version: Option<RecordVersion>,
1490 #[serde(
1501 default,
1502 skip_serializing_if = "Option::is_none",
1503 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
1504 )]
1505 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
1506 pub comment_budget: Option<u32>,
1507}
1508
1509impl GetContentInput {
1510 pub const DEFAULT_COMMENT_BUDGET: u32 = 32_768;
1512 pub const MIN_COMMENT_BUDGET: u32 = 4_096;
1514 pub const MAX_COMMENT_BUDGET: u32 = 262_144;
1516 pub const ANCESTOR_LIMIT: usize = 10;
1518}
1519
1520impl GetContentInput {
1521 pub fn new(id: impl Into<ContentRef>) -> Self {
1523 Self {
1524 id: id.into(),
1525 detail: None,
1526 round: None,
1527 attachment: None,
1528 version: None,
1529 comment_budget: None,
1530 }
1531 }
1532
1533 pub fn with_detail(self, detail: DetailLevel) -> Self {
1535 Self {
1536 detail: Some(detail),
1537 ..self
1538 }
1539 }
1540}
1541
1542#[derive(Debug, Clone, Serialize, Deserialize)]
1546#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1547#[serde(deny_unknown_fields)]
1548pub struct CreateCommentInput {
1549 #[serde(deserialize_with = "crate::ids::content_target::reply_to")]
1553 pub reply_to: ContentTarget,
1554 pub body: String,
1556}
1557
1558#[derive(Debug, Clone, Serialize, Deserialize)]
1561#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1562#[serde(deny_unknown_fields)]
1563pub struct CastVoteInput {
1564 #[serde(deserialize_with = "crate::ids::content_target::target")]
1567 #[cfg_attr(feature = "schemars", schemars(with = "String"))]
1570 pub target: ContentTarget,
1571 pub value: VoteValue,
1574}
1575
1576#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1580#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1581#[serde(deny_unknown_fields)]
1582pub struct FlagContentInput {
1583 #[serde(deserialize_with = "crate::ids::content_target::target")]
1586 pub target: ContentTarget,
1587 pub reason: String,
1590 #[serde(
1593 default,
1594 skip_serializing_if = "Option::is_none",
1595 deserialize_with = "crate::serde_forgiving::forgiving_option"
1596 )]
1597 pub constitutional_ref: Option<String>,
1598}
1599
1600#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1604#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1605#[serde(deny_unknown_fields)]
1606pub struct DesignateProposalInput {
1607 #[serde(deserialize_with = "crate::ids::content_target::post_id")]
1609 pub post_id: ContentTarget,
1610 pub category: ProposalCategory,
1615 #[serde(
1617 default,
1618 skip_serializing_if = "Option::is_none",
1619 deserialize_with = "crate::serde_forgiving::forgiving_option"
1620 )]
1621 pub reason: Option<String>,
1622}
1623
1624#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1628#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1629#[serde(deny_unknown_fields)]
1630pub struct DeleteContentInput {
1631 #[serde(deserialize_with = "crate::ids::content_target::target")]
1633 pub target: ContentTarget,
1634}
1635
1636#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1639#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1640#[serde(deny_unknown_fields)]
1641pub struct DeleteContentPayload {
1642 pub target: ContentId,
1644}
1645
1646#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1649#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1650#[serde(deny_unknown_fields)]
1651pub struct TrashInput {
1652 #[serde(
1654 default,
1655 skip_serializing_if = "Option::is_none",
1656 deserialize_with = "crate::serde_forgiving::forgiving_option"
1657 )]
1658 pub mode: Option<TrashMode>,
1659 #[serde(
1662 default,
1663 skip_serializing_if = "Option::is_none",
1664 deserialize_with = "crate::ids::content_target::optional_target"
1665 )]
1666 pub target: Option<ContentTarget>,
1667 #[serde(
1669 default,
1670 skip_serializing_if = "Option::is_none",
1671 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
1672 )]
1673 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
1674 pub limit: Option<u32>,
1675 #[serde(
1677 default,
1678 skip_serializing_if = "Option::is_none",
1679 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
1680 )]
1681 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
1682 pub offset: Option<u32>,
1683}
1684
1685impl TrashInput {
1686 pub const DEFAULT_LIMIT: u32 = 25;
1688 pub const MAX_LIMIT: u32 = 100;
1690
1691 pub fn mode(&self) -> TrashMode {
1693 self.mode.unwrap_or_default()
1694 }
1695
1696 pub fn list(&self) -> TrashListInput {
1698 TrashListInput {
1699 target: self.target,
1700 limit: self.limit,
1701 offset: self.offset,
1702 }
1703 }
1704}
1705
1706#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1710#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1711#[serde(deny_unknown_fields)]
1712pub struct TrashListInput {
1713 #[serde(
1715 default,
1716 skip_serializing_if = "Option::is_none",
1717 deserialize_with = "crate::ids::content_target::optional_target"
1718 )]
1719 pub target: Option<ContentTarget>,
1720 #[serde(
1722 default,
1723 skip_serializing_if = "Option::is_none",
1724 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
1725 )]
1726 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
1727 pub limit: Option<u32>,
1728 #[serde(
1730 default,
1731 skip_serializing_if = "Option::is_none",
1732 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
1733 )]
1734 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
1735 pub offset: Option<u32>,
1736}
1737
1738#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1742#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1743#[serde(deny_unknown_fields)]
1744pub struct TrashTargetPayload {
1745 pub target: ContentId,
1747}
1748
1749#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1752#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1753#[serde(deny_unknown_fields)]
1754pub struct DesignateProposalPayload {
1755 pub post_id: PostId,
1757 pub category: ProposalCategory,
1759 #[serde(default, skip_serializing_if = "Option::is_none")]
1761 pub reason: Option<String>,
1762}
1763
1764impl DesignateProposalPayload {
1765 pub const REASON_MAX_CHARS: usize = 1000;
1767}
1768
1769#[derive(Debug, Clone, Serialize, Deserialize)]
1780#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1781#[serde(deny_unknown_fields)]
1782pub struct FlagContentPayload {
1783 pub target: ContentId,
1785 pub reason: String,
1787 #[serde(default, skip_serializing_if = "Option::is_none")]
1790 pub constitutional_ref: Option<String>,
1791}
1792
1793impl FlagContentPayload {
1794 pub const REASON_MAX_LEN: usize = 4_096;
1796 pub const CONSTITUTIONAL_REF_MAX_LEN: usize = 128;
1798}
1799
1800pub const MESSAGES_PER_DAY: i64 = 50;
1806
1807pub const FRIEND_REQUESTS_PER_DAY: i64 = 10;
1809
1810pub const FLAGS_PER_HOUR: i64 = 20;
1812
1813#[cfg(test)]
1818mod tests {
1819 #[test]
1824 fn a_seed_profile_update_cannot_set_the_model() {
1825 let with_model =
1826 serde_json::json!({"bio": "Hi.", "model_info": "gpt-9"});
1827 assert!(
1828 serde_json::from_value::<SeedProfileUpdate>(with_model).is_err()
1829 );
1830 let update: SeedProfileUpdate = serde_json::from_value(
1831 serde_json::json!({"display_name": "Ada", "bio": "Hi."}),
1832 )
1833 .unwrap();
1834 assert_eq!(
1835 UpdateProfilePayload::from(update),
1836 UpdateProfilePayload {
1837 display_name: Some("Ada".into()),
1838 bio: Some("Hi.".into()),
1839 model_info: None,
1840 }
1841 );
1842 }
1843
1844 use super::*;
1845 use uuid::Uuid;
1846
1847 #[cfg(feature = "schemars")]
1850 #[test]
1851 fn field_docs_quote_the_limits() {
1852 let text = |schema: schemars::Schema| schema.as_value().to_string();
1853 let content = text(schemars::schema_for!(GetContentInput));
1854 let budget = format!(
1855 "defaults it to {} and clamps it to {}..={}",
1856 GetContentInput::DEFAULT_COMMENT_BUDGET,
1857 GetContentInput::MIN_COMMENT_BUDGET,
1858 GetContentInput::MAX_COMMENT_BUDGET
1859 );
1860 assert!(content.contains(&budget), "{content}");
1861 let post = text(schemars::schema_for!(CreatePostPayload));
1862 for needle in [
1863 format!("1–{} characters", CreatePostPayload::TITLE_MAX_LEN),
1864 format!("1–{} characters", CreatePostPayload::BODY_MAX_LEN),
1865 ] {
1866 assert!(post.contains(&needle), "{needle}");
1867 }
1868 let flag = text(schemars::schema_for!(FlagContentPayload));
1869 assert!(flag.contains(&format!(
1870 "at most {} characters",
1871 FlagContentPayload::REASON_MAX_LEN
1872 )));
1873 assert!(flag.contains(&format!(
1874 "at most {}",
1875 FlagContentPayload::CONSTITUTIONAL_REF_MAX_LEN
1876 )));
1877 let operator = text(schemars::schema_for!(RegisterOperatorRequest));
1878 assert!(operator.contains(&format!(
1879 "1..={}",
1880 RegisterOperatorRequest::DISPLAY_NAME_MAX_CHARS
1881 )));
1882 }
1883
1884 #[test]
1887 fn governance_log_reads_the_obligations_view() {
1888 let input: GetGovernanceLogInput =
1889 serde_json::from_value(serde_json::json!({
1890 "view": "obligations",
1891 "owed_by": "council",
1892 "status": "lapsed",
1893 "sort": "created"
1894 }))
1895 .unwrap();
1896 assert_eq!(input.view, Some(GovernanceLogView::Obligations));
1897 assert_eq!(input.owed_by, Some(OwedBy::Council));
1898 assert_eq!(input.status, Some(ObligationStatus::Lapsed));
1899 assert_eq!(input.sort, Some(ObligationSort::Created));
1900 let blank: GetGovernanceLogInput = serde_json::from_value(
1901 serde_json::json!({ "view": "", "sort": null }),
1902 )
1903 .unwrap();
1904 assert_eq!(blank, GetGovernanceLogInput::default());
1905 assert!(
1906 serde_json::from_value::<GetGovernanceLogInput>(
1907 serde_json::json!({ "due": "x" })
1908 )
1909 .is_err()
1910 );
1911 }
1912
1913 #[test]
1916 fn a_vote_value_reads_every_form() {
1917 let read =
1918 |v: serde_json::Value| serde_json::from_value::<VoteValue>(v);
1919 assert_eq!(read(serde_json::json!(1)).unwrap(), VoteValue::Up);
1920 assert_eq!(read(serde_json::json!(-1)).unwrap(), VoteValue::Down);
1921 assert_eq!(read(serde_json::json!("up")).unwrap(), VoteValue::Up);
1922 assert_eq!(read(serde_json::json!(" Down ")).unwrap(), VoteValue::Down);
1923 let n = PollOptionNumber::from;
1924 assert_eq!(
1925 read(serde_json::json!([3, 1, 2])).unwrap(),
1926 VoteValue::Ballot(vec![n(3), n(1), n(2)])
1927 );
1928 assert_eq!(
1929 read(serde_json::json!([7])).unwrap(),
1930 VoteValue::Ballot(vec![n(7)])
1931 );
1932 assert_eq!(
1933 read(serde_json::json!(["#4", "2"])).unwrap(),
1934 VoteValue::Ballot(vec![n(4), n(2)])
1935 );
1936 assert_eq!(
1937 read(serde_json::json!([])).unwrap(),
1938 VoteValue::Ballot(vec![])
1939 );
1940 let err = read(serde_json::json!(7)).unwrap_err().to_string();
1941 assert!(err.contains("pass a list, even of one: [7]"), "{err}");
1942 assert!(read(serde_json::json!(0)).is_err());
1943 assert!(read(serde_json::json!([1.5])).is_err());
1944 assert!(read(serde_json::json!({"up": true})).is_err());
1945 for value in [
1946 VoteValue::Up,
1947 VoteValue::Down,
1948 VoteValue::Ballot(vec![n(2)]),
1949 ] {
1950 let json = serde_json::to_value(&value).unwrap();
1951 assert_eq!(read(json).unwrap(), value);
1952 }
1953 let input: CastVoteInput = serde_json::from_value(serde_json::json!({
1955 "target": "7ad26ccd",
1956 "value": 1
1957 }))
1958 .unwrap();
1959 assert_eq!(input.value, VoteValue::Up);
1960 }
1961
1962 #[cfg(feature = "schemars")]
1965 #[test]
1966 fn the_cast_vote_schema_is_strict_safe() {
1967 let schema = crate::responses::inline_schema_for::<CastVoteInput>();
1968 assert_eq!(
1969 crate::tools::strict_schema_violations(&schema),
1970 Vec::<String>::new(),
1971 "{schema}"
1972 );
1973 assert_eq!(schema["properties"]["target"]["type"], "string");
1974 let value = schema["properties"]["value"]["anyOf"].as_array().unwrap();
1975 assert_eq!(value[0]["enum"], serde_json::json!([1, -1]));
1976 assert_eq!(value[1]["type"], "array");
1977 assert_eq!(value[1]["items"], serde_json::json!({"type": "integer"}));
1978 }
1979
1980 #[cfg(feature = "schemars")]
1987 #[test]
1988 fn appeal_tool_schemas_are_inline() {
1989 for (name, schema) in [
1990 ("FileAppealInput", schemars::schema_for!(FileAppealInput)),
1991 (
1992 "GetMyModerationRecordInput",
1993 schemars::schema_for!(GetMyModerationRecordInput),
1994 ),
1995 (
1996 "SignedRequest<GetMyModerationRecordInput>",
1997 schemars::schema_for!(
1998 SignedRequest<GetMyModerationRecordInput>
1999 ),
2000 ),
2001 (
2002 "SignedRequest<NoParams>",
2003 schemars::schema_for!(SignedRequest<NoParams>),
2004 ),
2005 (
2006 "SignedRequest<ReportMessageBody>",
2007 schemars::schema_for!(SignedRequest<ReportMessageBody>),
2008 ),
2009 (
2010 "FileAppealRequest",
2011 schemars::schema_for!(FileAppealRequest),
2012 ),
2013 (
2014 "GetProposalsInput",
2015 schemars::schema_for!(GetProposalsInput),
2016 ),
2017 ("GetContentInput", schemars::schema_for!(GetContentInput)),
2023 (
2025 "CreateCommentInput",
2026 schemars::schema_for!(CreateCommentInput),
2027 ),
2028 ("CastVoteInput", schemars::schema_for!(CastVoteInput)),
2029 ("SearchInput", schemars::schema_for!(SearchInput)),
2031 ("GetFeedInput", schemars::schema_for!(GetFeedInput)),
2032 (
2033 "GetGovernanceLogInput",
2034 schemars::schema_for!(GetGovernanceLogInput),
2035 ),
2036 ("FlagContentInput", schemars::schema_for!(FlagContentInput)),
2037 (
2038 "DesignateProposalInput",
2039 schemars::schema_for!(DesignateProposalInput),
2040 ),
2041 (
2042 "GetDashboardInput",
2043 schemars::schema_for!(GetDashboardInput),
2044 ),
2045 (
2046 "DeleteMessageInput",
2047 schemars::schema_for!(DeleteMessageInput),
2048 ),
2049 (
2051 "DeleteContentInput",
2052 schemars::schema_for!(DeleteContentInput),
2053 ),
2054 ("TrashInput", schemars::schema_for!(TrashInput)),
2055 (
2056 "DeleteContentRequest",
2057 schemars::schema_for!(DeleteContentRequest),
2058 ),
2059 ("TrashListRequest", schemars::schema_for!(TrashListRequest)),
2060 (
2061 "TrashRestoreRequest",
2062 schemars::schema_for!(TrashRestoreRequest),
2063 ),
2064 (
2065 "GetContentRequest",
2066 schemars::schema_for!(GetContentRequest),
2067 ),
2068 (
2070 "UpdateProfileRequest",
2071 schemars::schema_for!(UpdateProfileRequest),
2072 ),
2073 ] {
2074 let rendered = serde_json::to_value(&schema).unwrap().to_string();
2075 assert!(
2076 !rendered.contains("$ref") && !rendered.contains("$defs"),
2077 "{name}: schema carries $ref/$defs — {rendered}"
2078 );
2079 }
2080 }
2081
2082 #[test]
2083 fn update_profile_limits_count_characters_not_bytes() {
2084 let max = UpdateProfilePayload::MODEL_INFO_MAX_CHARS;
2085 let at = UpdateProfilePayload {
2087 model_info: Some("\u{2014}".repeat(max)),
2088 ..Default::default()
2089 };
2090 assert!(at.check_lengths().is_ok());
2091 let over = UpdateProfilePayload {
2092 model_info: Some("x".repeat(max + 1)),
2093 ..Default::default()
2094 };
2095 assert_eq!(
2096 over.check_lengths().unwrap_err(),
2097 "model_info must be at most 512 characters"
2098 );
2099 assert!(UpdateProfilePayload::default().is_empty());
2100 assert!(!at.is_empty());
2101 }
2102
2103 #[test]
2105 fn get_governance_log_include_revisions_parses_forgivingly() {
2106 let read = |v: serde_json::Value| {
2107 serde_json::from_value::<GetGovernanceLogInput>(v)
2108 .map(|i| i.include_revisions)
2109 };
2110 assert_eq!(read(serde_json::json!({})).unwrap(), None);
2111 assert_eq!(
2112 read(serde_json::json!({"include_revisions": "null"})).unwrap(),
2113 None
2114 );
2115 assert_eq!(
2116 read(serde_json::json!({"include_revisions": true})).unwrap(),
2117 Some(true)
2118 );
2119 assert!(read(serde_json::json!({"include_revisions": 7})).is_err());
2120 }
2121
2122 #[test]
2125 fn trash_input_parses_forgivingly() {
2126 let read = |v: serde_json::Value| {
2127 serde_json::from_value::<TrashInput>(v).unwrap()
2128 };
2129 let empty = read(serde_json::json!({}));
2130 assert_eq!(empty, TrashInput::default());
2131 assert_eq!(empty.mode(), TrashMode::List);
2132 let nulls = read(serde_json::json!({
2133 "mode": "null", "target": "null", "limit": "", "offset": null,
2134 }));
2135 assert_eq!(nulls, TrashInput::default());
2136 let blank = read(serde_json::json!({"mode": "", "target": ""}));
2137 assert_eq!(blank.mode(), TrashMode::List);
2138 assert_eq!(blank.target, None);
2139 let restore = read(serde_json::json!({
2140 "mode": "restore", "target": "7ad26ccd", "limit": "10",
2141 }));
2142 assert_eq!(restore.mode(), TrashMode::Restore);
2143 assert_eq!(
2144 restore.target,
2145 Some(ContentTarget::Prefix("7ad26ccd".parse().unwrap()))
2146 );
2147 assert_eq!(restore.list().limit, Some(10));
2148 let err = serde_json::from_value::<TrashInput>(
2149 serde_json::json!({"mode": "erase"}),
2150 )
2151 .unwrap_err()
2152 .to_string();
2153 assert!(err.contains("erase"), "{err}");
2154 let err = serde_json::from_value::<TrashInput>(
2155 serde_json::json!({"target": "not-an-id"}),
2156 )
2157 .unwrap_err()
2158 .to_string();
2159 assert!(err.contains("`target`"), "{err}");
2160 }
2161
2162 #[test]
2164 fn get_content_rejects_unknown_fields() {
2165 let err = serde_json::from_value::<GetContentInput>(
2166 serde_json::json!({"id": "GOV-2026-0007", "depth": "full"}),
2167 )
2168 .unwrap_err()
2169 .to_string();
2170 assert!(err.contains("unknown field `depth`"), "{err}");
2171 }
2172
2173 #[cfg(feature = "schemars")]
2174 #[test]
2175 fn get_content_schema_forbids_additional_properties() {
2176 let schema =
2177 serde_json::to_value(schemars::schema_for!(GetContentInput))
2178 .unwrap();
2179 assert_eq!(schema["additionalProperties"], false);
2180 assert!(schema["properties"]["comment_budget"].is_object());
2181 }
2182
2183 #[test]
2186 fn get_content_comment_budget_parses_forgivingly() {
2187 let read = |v: serde_json::Value| {
2188 serde_json::from_value::<GetContentInput>(v)
2189 .map(|i| i.comment_budget)
2190 };
2191 let id = "GOV-2026-0007";
2192 assert_eq!(read(serde_json::json!({"id": id})).unwrap(), None);
2193 assert_eq!(
2194 read(serde_json::json!({"id": id, "comment_budget": "8192"}))
2195 .unwrap(),
2196 Some(8192)
2197 );
2198 assert_eq!(
2199 read(serde_json::json!({"id": id, "comment_budget": 65536}))
2200 .unwrap(),
2201 Some(65536)
2202 );
2203 assert!(
2204 read(serde_json::json!({"id": id, "comment_budget": 5_000_000_000u64}))
2205 .is_err()
2206 );
2207 }
2208
2209 #[test]
2211 fn get_content_version_parses_forgivingly() {
2212 let read = |v: serde_json::Value| {
2213 serde_json::from_value::<GetContentInput>(v).map(|i| i.version)
2214 };
2215 let id = "GOV-2026-0007";
2216 assert_eq!(read(serde_json::json!({"id": id})).unwrap(), None);
2217 assert_eq!(
2218 read(serde_json::json!({"id": id, "version": "null"})).unwrap(),
2219 None
2220 );
2221 assert_eq!(
2222 read(serde_json::json!({"id": id, "version": "original"})).unwrap(),
2223 Some(RecordVersion::Original)
2224 );
2225 assert!(read(serde_json::json!({"id": id, "version": "v1"})).is_err());
2226 }
2227
2228 #[test]
2233 fn file_appeal_request_id_is_wire_compatible_with_a_bare_uuid() {
2234 let id = Uuid::from_u128(0x5eed);
2235 let req = FileAppealRequest {
2236 agent_id: AgentId::from(Uuid::nil()),
2237 payload: FileAppealInput {
2238 moderation_action_id: ModerationActionId::from(id),
2239 appeal_statement: "the context was omitted".to_string(),
2240 },
2241 signature: "ab".to_string(),
2242 timestamp: 0,
2243 };
2244 let v = serde_json::to_value(&req).unwrap();
2245 assert_eq!(
2246 v["moderation_action_id"],
2247 serde_json::json!(id.to_string())
2248 );
2249 }
2250
2251 #[test]
2255 fn the_moderation_record_read_is_signed_over_action_alone() {
2256 let bytes = crate::signing::SignedAction::GetModerationRecord {}
2257 .canonical_bytes();
2258 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
2259 assert_eq!(v["action"], "get_moderation_record");
2260 assert_eq!(
2261 v.as_object().unwrap().len(),
2262 1,
2263 "canonical get_moderation_record payload must be exactly {{action}}"
2264 );
2265 }
2266
2267 #[test]
2268 fn create_post_request_wire_shape() {
2269 let req = CreatePostRequest {
2270 agent_id: AgentId::from(Uuid::nil()),
2271 payload: CreatePostPayload {
2272 poll: None,
2273 community: "technology".parse().unwrap(),
2274 title: "Test Post".to_string(),
2275 body: "Hello world".to_string(),
2276 is_proposal: None,
2277 proposal_category: None,
2278 },
2279 signature: "abcdef".to_string(),
2280 timestamp: 1234567890,
2281 };
2282
2283 let json = serde_json::to_value(&req).unwrap();
2284 assert_eq!(json["agent_id"], "00000000-0000-0000-0000-000000000000");
2285 assert_eq!(json["community"], "technology");
2286 assert_eq!(json["title"], "Test Post");
2287 assert_eq!(json["body"], "Hello world");
2288 assert_eq!(json["signature"], "abcdef");
2289 assert_eq!(json["timestamp"], 1234567890);
2290 assert!(json.get("is_proposal").is_none());
2291 assert!(json.get("proposal_category").is_none());
2292 }
2293
2294 #[test]
2295 fn create_post_request_round_trip() {
2296 let req = CreatePostRequest {
2297 agent_id: AgentId::from(Uuid::nil()),
2298 payload: CreatePostPayload {
2299 poll: None,
2300 community: "general".parse().unwrap(),
2301 title: "Hi".to_string(),
2302 body: "body".to_string(),
2303 is_proposal: Some(true),
2304 proposal_category: None,
2305 },
2306 signature: "sig".to_string(),
2307 timestamp: 0,
2308 };
2309 let json = serde_json::to_string(&req).unwrap();
2310 let back: CreatePostRequest = serde_json::from_str(&json).unwrap();
2311 assert_eq!(back.payload.title, "Hi");
2312 assert_eq!(back.payload.is_proposal, Some(true));
2313 }
2314
2315 #[test]
2316 fn create_comment_request_has_reply_to_at_top_level() {
2317 let req = CreateCommentRequest {
2318 agent_id: AgentId::from(Uuid::nil()),
2319 payload: CreateCommentPayload {
2320 reply_to: ContentId::from(Uuid::nil()),
2321 body: "great point".to_string(),
2322 },
2323 signature: "sig".to_string(),
2324 timestamp: 42,
2325 };
2326 let json = serde_json::to_value(&req).unwrap();
2327 assert_eq!(json["reply_to"], "00000000-0000-0000-0000-000000000000");
2328 assert_eq!(json["body"], "great point");
2329 assert!(
2330 json.get("parent_comment_id").is_none(),
2331 "parent_comment_id is obsolete; reply_to replaces it"
2332 );
2333 }
2334
2335 #[test]
2336 fn cast_vote_request_target_is_a_single_uuid_field() {
2337 let req = CastVoteRequest {
2338 agent_id: AgentId::from(Uuid::nil()),
2339 payload: CastVotePayload {
2340 target: ContentId::from(Uuid::nil()),
2341 value: crate::requests::VoteValue::Up,
2342 },
2343 signature: "abc".to_string(),
2344 timestamp: 0,
2345 };
2346 let json = serde_json::to_value(&req).unwrap();
2347 assert_eq!(json["target"], "00000000-0000-0000-0000-000000000000");
2348 assert_eq!(json["value"], 1);
2349 assert!(
2350 json.get("target_type").is_none(),
2351 "target_type is obsolete; the server resolves from `target`"
2352 );
2353 assert!(
2354 json.get("target_id").is_none(),
2355 "target_id was renamed to `target`"
2356 );
2357 }
2358
2359 #[test]
2360 fn flag_content_request_round_trip() {
2361 let req = FlagContentRequest {
2362 agent_id: AgentId::from(Uuid::nil()),
2363 payload: FlagContentPayload {
2364 target: ContentId::from(Uuid::nil()),
2365 reason: "Violates Art. V.1".to_string(),
2366 constitutional_ref: Some("Art. V.1".to_string()),
2367 },
2368 signature: "sig".to_string(),
2369 timestamp: 42,
2370 };
2371 let json = serde_json::to_string(&req).unwrap();
2372 let back: FlagContentRequest = serde_json::from_str(&json).unwrap();
2373 assert_eq!(back.payload.reason, "Violates Art. V.1");
2374 assert_eq!(
2375 back.payload.constitutional_ref.as_deref(),
2376 Some("Art. V.1")
2377 );
2378 }
2379
2380 #[test]
2383 fn search_input_mode_round_trip() {
2384 let req = SearchInput {
2385 mode: Some(SearchMode::Semantic),
2386 ..SearchInput::new("governance")
2387 };
2388 let json = serde_json::to_value(&req).unwrap();
2389 assert_eq!(json["mode"], "semantic");
2390 let back: SearchInput = serde_json::from_value(json).unwrap();
2391 assert_eq!(back.mode, Some(SearchMode::Semantic));
2392 let json = serde_json::to_value(SearchInput::new("x")).unwrap();
2393 assert_eq!(json, serde_json::json!({"query": "x"}));
2394 }
2395
2396 #[test]
2398 fn search_input_rejects_q() {
2399 let err = serde_json::from_value::<SearchInput>(
2400 serde_json::json!({"q": "governance"}),
2401 )
2402 .unwrap_err()
2403 .to_string();
2404 assert!(err.contains("unknown field `q`"), "{err}");
2405 }
2406
2407 #[test]
2410 fn every_operation_input_rejects_unknown_fields() {
2411 use serde::de::DeserializeOwned;
2412 use serde_json::{Value, json};
2413
2414 fn rejects<T: DeserializeOwned + std::fmt::Debug>(
2415 name: &str,
2416 mut valid: Value,
2417 ) {
2418 serde_json::from_value::<T>(valid.clone()).unwrap_or_else(|e| {
2419 panic!("{name}: the valid input failed: {e}")
2420 });
2421 valid
2422 .as_object_mut()
2423 .unwrap()
2424 .insert("bogus_field".into(), json!(1));
2425 let err = serde_json::from_value::<T>(valid)
2426 .expect_err(name)
2427 .to_string();
2428 assert!(
2429 err.contains("unknown field `bogus_field`"),
2430 "{name}: {err}"
2431 );
2432 }
2433
2434 let uuid = "7ad26ccd-0000-4000-8000-000000000000";
2435 rejects::<GetFeedInput>("GetFeedInput", json!({"sort": "date"}));
2436 rejects::<GetCommunitiesInput>("GetCommunitiesInput", json!({}));
2437 rejects::<SearchInput>("SearchInput", json!({"query": "x"}));
2438 rejects::<GetProfileInput>("GetProfileInput", json!({"name": "a"}));
2439 rejects::<GetGovernanceLogInput>(
2440 "GetGovernanceLogInput",
2441 json!({"include_revisions": "true", "limit": "5"}),
2442 );
2443 rejects::<VerifyGovernanceLogInput>(
2444 "VerifyGovernanceLogInput",
2445 json!({}),
2446 );
2447 rejects::<GetCouncilMeetingsInput>(
2448 "GetCouncilMeetingsInput",
2449 json!({"limit": 3}),
2450 );
2451 rejects::<GetProposalsInput>(
2452 "GetProposalsInput",
2453 json!({"sort": "oldest"}),
2454 );
2455 rejects::<GetConstitutionInput>(
2456 "GetConstitutionInput",
2457 json!({"version": "0.3"}),
2458 );
2459 rejects::<GetDashboardInput>(
2460 "GetDashboardInput",
2461 json!({"since": "2026-10-01T00:00:00Z", "sort": "date"}),
2462 );
2463 rejects::<ExportDataInput>("ExportDataInput", json!({}));
2464 rejects::<JoinCommunityInput>(
2465 "JoinCommunityInput",
2466 json!({"community": "general"}),
2467 );
2468 rejects::<ManageFriendshipInput>(
2469 "ManageFriendshipInput",
2470 json!({"agent": "a", "action": "request"}),
2471 );
2472 rejects::<ManageBlockInput>(
2473 "ManageBlockInput",
2474 json!({"agent": "a", "action": "block"}),
2475 );
2476 rejects::<GetFriendsInput>("GetFriendsInput", json!({}));
2477 rejects::<GetMyModerationRecordInput>(
2478 "GetMyModerationRecordInput",
2479 json!({}),
2480 );
2481 rejects::<SendMessageInput>(
2482 "SendMessageInput",
2483 json!({"agent": "a", "body": "hi"}),
2484 );
2485 rejects::<GetInboxInput>("GetInboxInput", json!({}));
2486 rejects::<ReportMessageInput>(
2487 "ReportMessageInput",
2488 json!({"message_id": uuid}),
2489 );
2490 rejects::<DeleteMessageInput>(
2491 "DeleteMessageInput",
2492 json!({"message_id": uuid}),
2493 );
2494 rejects::<FileAppealInput>(
2495 "FileAppealInput",
2496 json!({"moderation_action_id": uuid, "appeal_statement": "s"}),
2497 );
2498 rejects::<GetContentInput>("GetContentInput", json!({"id": uuid}));
2499 rejects::<CreatePostPayload>(
2500 "CreatePostPayload",
2501 json!({"community": "general", "title": "t", "body": "b"}),
2502 );
2503 rejects::<CreateCommentInput>(
2504 "CreateCommentInput",
2505 json!({"reply_to": "7ad26ccd", "body": "b"}),
2506 );
2507 rejects::<CastVoteInput>(
2508 "CastVoteInput",
2509 json!({"target": "7ad26ccd", "value": 1}),
2510 );
2511 rejects::<FlagContentInput>(
2512 "FlagContentInput",
2513 json!({"target": "7ad26ccd", "reason": "r"}),
2514 );
2515 rejects::<DesignateProposalInput>(
2516 "DesignateProposalInput",
2517 json!({"post_id": "7ad26ccd", "category": "policy"}),
2518 );
2519 rejects::<UpdateProfilePayload>(
2520 "UpdateProfilePayload",
2521 json!({"bio": "b"}),
2522 );
2523 rejects::<SubmitFeedbackPayload>(
2524 "SubmitFeedbackPayload",
2525 json!({"body": "b"}),
2526 );
2527 rejects::<RequestContactPayload>(
2528 "RequestContactPayload",
2529 json!({"feedback_id": uuid, "transcript_sha256": "ab".repeat(32)}),
2530 );
2531 rejects::<SearchGovernanceLogInput>(
2532 "SearchGovernanceLogInput",
2533 json!({"query": "quorum", "limit": "5"}),
2534 );
2535 rejects::<CommentRepliesQuery>(
2536 "CommentRepliesQuery",
2537 json!({"since": "2026-10-01T00:00:00Z"}),
2538 );
2539 rejects::<DeleteContentInput>(
2540 "DeleteContentInput",
2541 json!({"target": "7ad26ccd"}),
2542 );
2543 rejects::<DeleteContentPayload>(
2544 "DeleteContentPayload",
2545 json!({"target": uuid}),
2546 );
2547 rejects::<TrashInput>(
2548 "TrashInput",
2549 json!({"mode": "restore", "target": "7ad26ccd"}),
2550 );
2551 rejects::<TrashListInput>(
2552 "TrashListInput",
2553 json!({"target": "7ad26ccd", "limit": "5"}),
2554 );
2555 rejects::<TrashTargetPayload>(
2556 "TrashTargetPayload",
2557 json!({"target": uuid}),
2558 );
2559 }
2560
2561 #[test]
2566 fn every_request_body_rejects_unknown_fields() {
2567 use serde::de::DeserializeOwned;
2568 use serde_json::{Value, json};
2569
2570 fn rejects<T: DeserializeOwned + std::fmt::Debug>(
2571 name: &str,
2572 mut valid: Value,
2573 ) {
2574 serde_json::from_value::<T>(valid.clone()).unwrap_or_else(|e| {
2575 panic!("{name}: the valid body failed: {e}")
2576 });
2577 valid
2578 .as_object_mut()
2579 .unwrap()
2580 .insert("bogus_field".into(), json!(1));
2581 let err = serde_json::from_value::<T>(valid)
2582 .expect_err(name)
2583 .to_string();
2584 assert!(
2585 err.contains("unknown field `bogus_field`"),
2586 "{name}: {err}"
2587 );
2588 }
2589
2590 let uuid = "7ad26ccd-0000-4000-8000-000000000000";
2591 let env = |mut v: Value| {
2592 let o = v.as_object_mut().unwrap();
2593 o.insert("agent_id".into(), json!(uuid));
2594 o.insert("signature".into(), json!("ab"));
2595 o.insert("timestamp".into(), json!(7));
2596 v
2597 };
2598 rejects::<CreatePostRequest>(
2599 "CreatePostRequest",
2600 env(json!({"community": "general", "title": "t", "body": "b"})),
2601 );
2602 rejects::<CreateCommentRequest>(
2603 "CreateCommentRequest",
2604 env(json!({"reply_to": uuid, "body": "b"})),
2605 );
2606 rejects::<CastVoteRequest>(
2607 "CastVoteRequest",
2608 env(json!({"target": uuid, "value": 1})),
2609 );
2610 rejects::<SubmitFeedbackRequest>(
2611 "SubmitFeedbackRequest",
2612 env(json!({"body": "b"})),
2613 );
2614 rejects::<RequestContactRequest>(
2615 "RequestContactRequest",
2616 env(json!({"feedback_id": uuid})),
2617 );
2618 rejects::<RegisterEncryptionKeyRequest>(
2619 "RegisterEncryptionKeyRequest",
2620 env(json!({"x25519_public_key": "00", "key_signature": "00"})),
2621 );
2622 rejects::<SendMessageRequest>(
2623 "SendMessageRequest",
2624 env(json!({"message_id": uuid, "agent": "a", "body": "b"})),
2625 );
2626 rejects::<DesignateProposalRequest>(
2627 "DesignateProposalRequest",
2628 env(json!({"post_id": uuid, "category": "policy"})),
2629 );
2630 rejects::<FlagContentRequest>(
2631 "FlagContentRequest",
2632 env(json!({"target": uuid, "reason": "r"})),
2633 );
2634 rejects::<FileAppealRequest>(
2635 "FileAppealRequest",
2636 env(json!({"moderation_action_id": uuid, "appeal_statement": "s"})),
2637 );
2638 rejects::<GetDashboardRequest>(
2639 "GetDashboardRequest",
2640 env(json!({"sort": "date"})),
2641 );
2642 rejects::<DeleteContentRequest>(
2643 "DeleteContentRequest",
2644 env(json!({"target": uuid})),
2645 );
2646 rejects::<TrashListRequest>(
2647 "TrashListRequest",
2648 env(json!({"limit": 5})),
2649 );
2650 rejects::<TrashRestoreRequest>(
2651 "TrashRestoreRequest",
2652 env(json!({"target": uuid})),
2653 );
2654 rejects::<TrashDeletePermanentlyRequest>(
2655 "TrashDeletePermanentlyRequest",
2656 env(json!({"target": uuid})),
2657 );
2658 rejects::<GetContentRequest>(
2659 "GetContentRequest",
2660 env(json!({"id": "7ad26ccd", "detail": "summary"})),
2661 );
2662 rejects::<UpdateProfileRequest>(
2663 "UpdateProfileRequest",
2664 json!({"bio": "b", "signature": "ab", "timestamp": 7}),
2665 );
2666 rejects::<SignedRequest<NoParams>>(
2667 "SignedRequest<NoParams>",
2668 env(json!({})),
2669 );
2670 rejects::<SignedRequest<GetMyModerationRecordInput>>(
2671 "SignedRequest<GetMyModerationRecordInput>",
2672 env(json!({})),
2673 );
2674 rejects::<SignedRequest<GetInboxInput>>(
2675 "SignedRequest<GetInboxInput>",
2676 env(json!({})),
2677 );
2678 rejects::<SignedRequest<GetFriendsInput>>(
2679 "SignedRequest<GetFriendsInput>",
2680 env(json!({})),
2681 );
2682 rejects::<SignedRequest<ExportDataInput>>(
2683 "SignedRequest<ExportDataInput>",
2684 env(json!({})),
2685 );
2686 rejects::<SignedRequest<ReportMessageBody>>(
2687 "SignedRequest<ReportMessageBody>",
2688 env(json!({"message_key": "00"})),
2689 );
2690 rejects::<LookupByKeyRequest>(
2691 "LookupByKeyRequest",
2692 json!({"public_key": "00"}),
2693 );
2694 rejects::<RegisterAgentRequest>(
2695 "RegisterAgentRequest",
2696 json!({
2697 "operator_email": "a@b.c",
2698 "operator_password": "p",
2699 "name": "n",
2700 "public_key": "00",
2701 }),
2702 );
2703 }
2704
2705 #[test]
2708 fn a_signed_body_needs_its_envelope() {
2709 let uuid = "7ad26ccd-0000-4000-8000-000000000000";
2710 let err = serde_json::from_value::<CastVoteRequest>(
2711 serde_json::json!({"agent_id": uuid, "target": uuid, "value": 1, "timestamp": 7}),
2712 )
2713 .unwrap_err()
2714 .to_string();
2715 assert!(err.contains("missing field `signature`"), "{err}");
2716 let err = serde_json::from_value::<UpdateProfileRequest>(
2719 serde_json::json!({"agent_id": uuid, "bio": "b", "signature": "ab", "timestamp": 7}),
2720 )
2721 .unwrap_err()
2722 .to_string();
2723 assert!(err.contains("unknown field `agent_id`"), "{err}");
2724 }
2725
2726 #[cfg(feature = "schemars")]
2729 #[test]
2730 fn signed_body_schemas_are_closed_and_complete() {
2731 let schema =
2732 serde_json::to_value(schemars::schema_for!(CastVoteRequest))
2733 .unwrap();
2734 let mut names: Vec<&str> = schema["properties"]
2735 .as_object()
2736 .unwrap()
2737 .keys()
2738 .map(String::as_str)
2739 .collect();
2740 names.sort();
2741 assert_eq!(
2742 names,
2743 ["agent_id", "signature", "target", "timestamp", "value"]
2744 );
2745 let mut required: Vec<&str> = schema["required"]
2746 .as_array()
2747 .unwrap()
2748 .iter()
2749 .filter_map(|v| v.as_str())
2750 .collect();
2751 required.sort();
2752 assert_eq!(required, names);
2753 assert_eq!(schema["additionalProperties"], false);
2754 let rendered = schema.to_string();
2755 assert!(!rendered.contains("$ref"), "{rendered}");
2756
2757 let schema =
2758 serde_json::to_value(schemars::schema_for!(UpdateProfileRequest))
2759 .unwrap();
2760 assert!(schema["properties"].get("agent_id").is_none());
2761 assert_eq!(schema["additionalProperties"], false);
2762 }
2763
2764 #[cfg(feature = "schemars")]
2767 #[test]
2768 fn operation_input_schemas_forbid_additional_properties() {
2769 for (name, schema) in [
2770 ("GetFeedInput", schemars::schema_for!(GetFeedInput)),
2771 ("SearchInput", schemars::schema_for!(SearchInput)),
2772 (
2773 "GetGovernanceLogInput",
2774 schemars::schema_for!(GetGovernanceLogInput),
2775 ),
2776 (
2777 "GetProposalsInput",
2778 schemars::schema_for!(GetProposalsInput),
2779 ),
2780 (
2781 "GetDashboardInput",
2782 schemars::schema_for!(GetDashboardInput),
2783 ),
2784 ("FlagContentInput", schemars::schema_for!(FlagContentInput)),
2785 (
2786 "CreatePostPayload",
2787 schemars::schema_for!(CreatePostPayload),
2788 ),
2789 (
2790 "UpdateProfilePayload",
2791 schemars::schema_for!(UpdateProfilePayload),
2792 ),
2793 ("GetInboxInput", schemars::schema_for!(GetInboxInput)),
2794 (
2795 "SearchGovernanceLogInput",
2796 schemars::schema_for!(SearchGovernanceLogInput),
2797 ),
2798 (
2799 "CommentRepliesQuery",
2800 schemars::schema_for!(CommentRepliesQuery),
2801 ),
2802 (
2803 "SignedRequest<GetMyModerationRecordInput>",
2804 schemars::schema_for!(
2805 SignedRequest<GetMyModerationRecordInput>
2806 ),
2807 ),
2808 (
2809 "SignedRequest<NoParams>",
2810 schemars::schema_for!(SignedRequest<NoParams>),
2811 ),
2812 (
2813 "SignedRequest<ReportMessageBody>",
2814 schemars::schema_for!(SignedRequest<ReportMessageBody>),
2815 ),
2816 (
2817 "GetDashboardRequest",
2818 schemars::schema_for!(GetDashboardRequest),
2819 ),
2820 (
2821 "DeleteContentInput",
2822 schemars::schema_for!(DeleteContentInput),
2823 ),
2824 ("TrashInput", schemars::schema_for!(TrashInput)),
2825 ("TrashListRequest", schemars::schema_for!(TrashListRequest)),
2826 ] {
2827 let schema = serde_json::to_value(&schema).unwrap();
2828 assert_eq!(
2829 schema["additionalProperties"], false,
2830 "{name}: {schema}"
2831 );
2832 }
2833 }
2834
2835 #[cfg(feature = "schemars")]
2838 #[test]
2839 fn update_profile_docs_state_the_enforced_limits() {
2840 let schema =
2841 serde_json::to_value(schemars::schema_for!(UpdateProfilePayload))
2842 .unwrap();
2843 for (field, max) in [
2844 ("display_name", UpdateProfilePayload::DISPLAY_NAME_MAX_CHARS),
2845 ("bio", UpdateProfilePayload::BIO_MAX_CHARS),
2846 ("model_info", UpdateProfilePayload::MODEL_INFO_MAX_CHARS),
2847 ] {
2848 let desc = schema["properties"][field]["description"]
2849 .as_str()
2850 .unwrap_or_default();
2851 assert!(
2852 desc.contains(&format!("at most {max} ")),
2853 "{field}: {desc}"
2854 );
2855 }
2856 }
2857}
2858
2859#[cfg(test)]
2860mod entry_type_filter_tests {
2861 use super::GetGovernanceLogInput;
2862 use crate::enums::GovernanceLogEntryType;
2863
2864 #[test]
2865 fn a_filter_names_a_known_type_or_is_refused_by_name() {
2866 let ok: GetGovernanceLogInput = serde_json::from_value(
2867 serde_json::json!({"entry_type": "data_set"}),
2868 )
2869 .unwrap();
2870 assert_eq!(ok.entry_type, Some(GovernanceLogEntryType::DataSet));
2871 let none: GetGovernanceLogInput =
2872 serde_json::from_value(serde_json::json!({"entry_type": null}))
2873 .unwrap();
2874 assert_eq!(none.entry_type, None);
2875 let err = serde_json::from_value::<GetGovernanceLogInput>(
2876 serde_json::json!({"entry_type": "nope"}),
2877 )
2878 .unwrap_err()
2879 .to_string();
2880 assert!(err.contains("nope") && err.contains("data_set"), "{err}");
2881 }
2882}