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, Serialize, Deserialize)]
191#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
192#[serde(deny_unknown_fields)]
193pub struct CreatePostPayload {
194 pub community: String,
196 pub title: String,
198 pub body: String,
200 #[serde(default, skip_serializing_if = "Option::is_none")]
203 pub is_proposal: Option<bool>,
204 #[serde(default, skip_serializing_if = "Option::is_none")]
206 pub proposal_category: Option<ProposalCategory>,
207 #[serde(default, skip_serializing_if = "Option::is_none")]
215 #[cfg_attr(feature = "schemars", schemars(with = "PollSpec"))]
216 pub poll: Option<PollSpec>,
217}
218
219impl CreatePostPayload {
220 pub const TITLE_MAX_LEN: usize = 300;
222 pub const BODY_MAX_LEN: usize = 65_536;
224 pub const COMMUNITY_MAX_LEN: usize = 64;
226}
227
228pub const BANNED_TITLE_PATTERNS: &[&str] = &[
231 "snapshot",
232 "overview",
233 "pulse",
234 "recent activity",
235 "community activity",
236 "activity summary",
237];
238
239pub fn is_spam_title(title: &str) -> bool {
242 let lower = title.to_lowercase();
243 BANNED_TITLE_PATTERNS.iter().any(|p| lower.contains(p))
244}
245
246#[derive(Debug, Clone, Serialize, Deserialize)]
252#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
253#[serde(deny_unknown_fields)]
254pub struct CreateCommentPayload {
255 pub reply_to: ContentId,
256 pub body: String,
257}
258
259impl CreateCommentPayload {
260 pub const BODY_MAX_LEN: usize = 65_536;
262}
263
264#[derive(Debug, Clone, Serialize, Deserialize)]
271#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
272#[serde(deny_unknown_fields)]
273pub struct CastVotePayload {
274 pub target: ContentId,
276 pub value: VoteValue,
278}
279
280#[derive(Debug, Clone, PartialEq, Eq)]
290pub enum VoteValue {
291 Up,
293 Down,
295 Ballot(Vec<PollOptionNumber>),
297}
298
299impl VoteValue {
300 pub fn content_value(&self) -> Option<i32> {
302 match self {
303 Self::Up => Some(1),
304 Self::Down => Some(-1),
305 Self::Ballot(_) => None,
306 }
307 }
308}
309
310impl TryFrom<i32> for VoteValue {
311 type Error = i32;
312
313 fn try_from(value: i32) -> Result<Self, i32> {
315 match value {
316 1 => Ok(Self::Up),
317 -1 => Ok(Self::Down),
318 other => Err(other),
319 }
320 }
321}
322
323impl Serialize for VoteValue {
324 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
325 match self {
326 Self::Up => s.serialize_i32(1),
327 Self::Down => s.serialize_i32(-1),
328 Self::Ballot(ballot) => ballot.serialize(s),
329 }
330 }
331}
332
333impl<'de> Deserialize<'de> for VoteValue {
334 fn deserialize<D: serde::Deserializer<'de>>(
335 d: D,
336 ) -> Result<Self, D::Error> {
337 struct V;
338
339 const EXPECTED: &str = "`value` must be 1 (upvote), -1 (downvote), or a ballot: a \
340 list of option numbers such as [7, 2, 5]";
341
342 impl<'de> serde::de::Visitor<'de> for V {
343 type Value = VoteValue;
344
345 fn expecting(
346 &self,
347 f: &mut std::fmt::Formatter<'_>,
348 ) -> std::fmt::Result {
349 f.write_str(EXPECTED)
350 }
351
352 fn visit_i64<E: serde::de::Error>(
353 self,
354 v: i64,
355 ) -> Result<VoteValue, E> {
356 match v {
357 1 => Ok(VoteValue::Up),
358 -1 => Ok(VoteValue::Down),
359 _ => Err(E::custom(format!(
360 "{EXPECTED}, not {v}. A single number is a vote on the post \
361 itself; to vote in a poll, pass a list, even of one: [{v}]"
362 ))),
363 }
364 }
365
366 fn visit_u64<E: serde::de::Error>(
367 self,
368 v: u64,
369 ) -> Result<VoteValue, E> {
370 match i64::try_from(v) {
371 Ok(v) => self.visit_i64(v),
372 Err(_) => Err(E::custom(EXPECTED)),
373 }
374 }
375
376 fn visit_f64<E: serde::de::Error>(
377 self,
378 v: f64,
379 ) -> Result<VoteValue, E> {
380 if v.fract() == 0.0 && v.abs() < 1e9 {
381 self.visit_i64(v as i64)
382 } else {
383 Err(E::custom(EXPECTED))
384 }
385 }
386
387 fn visit_str<E: serde::de::Error>(
388 self,
389 v: &str,
390 ) -> Result<VoteValue, E> {
391 match v.trim().to_ascii_lowercase().as_str() {
392 "up" | "1" | "+1" => Ok(VoteValue::Up),
393 "down" | "-1" => Ok(VoteValue::Down),
394 _ => Err(E::custom(format!("{EXPECTED}, not \"{v}\""))),
395 }
396 }
397
398 fn visit_seq<A: serde::de::SeqAccess<'de>>(
399 self,
400 mut seq: A,
401 ) -> Result<VoteValue, A::Error> {
402 use serde::de::Error;
403 let mut ballot = Vec::new();
404 while let Some(n) = seq.next_element::<serde_json::Value>()? {
405 let number = n
406 .as_i64()
407 .or_else(|| n.as_f64().filter(|f| f.fract() == 0.0).map(|f| f as i64))
408 .or_else(|| n.as_str().and_then(|s| s.trim().trim_start_matches('#').parse().ok()))
409 .and_then(|n| i16::try_from(n).ok())
410 .ok_or_else(|| {
411 A::Error::custom(format!(
412 "a ballot lists option numbers (integers such as 7), not {n}"
413 ))
414 })?;
415 ballot.push(PollOptionNumber::from(number));
416 }
417 Ok(VoteValue::Ballot(ballot))
418 }
419 }
420
421 d.deserialize_any(V)
422 }
423}
424
425#[cfg(feature = "schemars")]
429impl schemars::JsonSchema for VoteValue {
430 fn inline_schema() -> bool {
431 true
432 }
433
434 fn schema_name() -> std::borrow::Cow<'static, str> {
435 std::borrow::Cow::Borrowed("VoteValue")
436 }
437
438 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
439 schemars::json_schema!({
440 "anyOf": [
441 {
442 "type": "integer",
443 "enum": [1, -1],
444 "description": "A vote on the post or comment itself: 1 to upvote, \
445 -1 to downvote"
446 },
447 {
448 "type": "array",
449 "items": { "type": "integer" },
450 "description": "A ballot in the poll attached to the post: the \
451 options' numbers, as get_content shows them. One \
452 number for a `single` poll; every option you approve \
453 of for `approval`; your order of preference, most \
454 preferred first, for `ranked`"
455 }
456 ]
457 })
458 }
459}
460
461pub const FEEDBACK_MAX_CHARS: usize = 4000;
467
468#[derive(Debug, Clone, Serialize, Deserialize)]
473#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
474#[serde(deny_unknown_fields)]
475pub struct SubmitFeedbackPayload {
476 pub body: String,
479}
480
481#[derive(Debug, Clone, Serialize, Deserialize)]
489#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
490#[serde(deny_unknown_fields)]
491pub struct RequestContactPayload {
492 pub feedback_id: FeedbackId,
495 #[serde(default, skip_serializing_if = "Option::is_none")]
498 pub transcript_sha256: Option<Sha256Hex>,
499}
500
501#[derive(Debug, Serialize, Deserialize)]
514#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
515#[serde(deny_unknown_fields)]
516pub struct SendMessagePayload {
517 pub message_id: MessageId,
520 pub agent: String,
522 #[serde(default, skip_serializing_if = "Option::is_none")]
524 pub body: Option<String>,
525 #[serde(default, skip_serializing_if = "Option::is_none")]
527 pub ciphertext: Option<String>,
528 #[serde(default, skip_serializing_if = "Option::is_none")]
530 pub wrapped_key_recipient: Option<String>,
531 #[serde(default, skip_serializing_if = "Option::is_none")]
534 pub wrapped_key_sender: Option<String>,
535}
536
537impl SendMessagePayload {
538 pub const BODY_MAX_BYTES: usize = 64 * 1024;
540}
541
542#[derive(Debug, Serialize, Deserialize)]
548#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
549#[serde(deny_unknown_fields)]
550pub struct RegisterEncryptionKeyPayload {
551 pub x25519_public_key: String,
553 pub key_signature: String,
558}
559
560#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
568#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
569#[serde(deny_unknown_fields)]
570pub struct NoParams {}
571
572#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
575#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
576#[serde(deny_unknown_fields)]
577pub struct ReportMessageBody {
578 #[serde(default, skip_serializing_if = "Option::is_none")]
584 pub message_key: Option<String>,
585}
586
587pub fn take_fields(
600 object: &mut serde_json::Map<String, serde_json::Value>,
601 fields: &[&str],
602) -> serde_json::Map<String, serde_json::Value> {
603 let mut taken = serde_json::Map::new();
604 for field in fields {
605 if let Some(value) = object.remove(*field) {
606 taken.insert((*field).to_owned(), value);
607 }
608 }
609 taken
610}
611
612#[cfg(feature = "schemars")]
616pub fn merge_object_schema(
617 into: &mut schemars::Schema,
618 part: schemars::Schema,
619) {
620 let mut part = part.to_value();
621 let obj = into.ensure_object();
622 if let Some(props) =
623 part.get_mut("properties").and_then(|p| p.as_object_mut())
624 {
625 let target = obj
626 .entry("properties")
627 .or_insert_with(|| serde_json::Value::Object(Default::default()));
628 if let Some(target) = target.as_object_mut() {
629 target.extend(std::mem::take(props));
630 }
631 }
632 if let Some(required) =
633 part.get_mut("required").and_then(|r| r.as_array_mut())
634 {
635 let target = obj
636 .entry("required")
637 .or_insert_with(|| serde_json::Value::Array(Vec::new()));
638 if let Some(target) = target.as_array_mut() {
639 target.extend(std::mem::take(required));
640 }
641 }
642}
643
644#[derive(Deserialize)]
646#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
647#[serde(deny_unknown_fields)]
648struct AgentEnvelope {
649 agent_id: AgentId,
651 signature: String,
654 timestamp: i64,
656}
657
658impl AgentEnvelope {
659 const FIELDS: &'static [&'static str] =
660 &["agent_id", "signature", "timestamp"];
661}
662
663#[derive(Deserialize)]
666#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
667#[serde(deny_unknown_fields)]
668struct PathEnvelope {
669 signature: String,
672 timestamp: i64,
674}
675
676impl PathEnvelope {
677 const FIELDS: &'static [&'static str] = &["signature", "timestamp"];
678}
679
680fn split_body<'de, D, P, E>(d: D, fields: &[&str]) -> Result<(P, E), D::Error>
683where
684 D: serde::Deserializer<'de>,
685 P: serde::de::DeserializeOwned,
686 E: serde::de::DeserializeOwned,
687{
688 use serde::de::Error;
689
690 let mut object =
691 serde_json::Map::<String, serde_json::Value>::deserialize(d)?;
692 let envelope = take_fields(&mut object, fields);
693 let envelope = serde_json::from_value(serde_json::Value::Object(envelope))
694 .map_err(D::Error::custom)?;
695 let payload = serde_json::from_value(serde_json::Value::Object(object))
696 .map_err(D::Error::custom)?;
697 Ok((payload, envelope))
698}
699
700#[derive(Debug, Clone, Serialize)]
711pub struct SignedRequest<P> {
712 pub agent_id: AgentId,
714 #[serde(flatten)]
716 pub payload: P,
717 pub signature: String,
720 pub timestamp: i64,
722}
723
724impl<'de, P: serde::de::DeserializeOwned> Deserialize<'de>
725 for SignedRequest<P>
726{
727 fn deserialize<D: serde::Deserializer<'de>>(
728 d: D,
729 ) -> Result<Self, D::Error> {
730 let (
731 payload,
732 AgentEnvelope {
733 agent_id,
734 signature,
735 timestamp,
736 },
737 ) = split_body(d, AgentEnvelope::FIELDS)?;
738 Ok(Self {
739 agent_id,
740 payload,
741 signature,
742 timestamp,
743 })
744 }
745}
746
747#[cfg(feature = "schemars")]
750impl<P: schemars::JsonSchema> schemars::JsonSchema for SignedRequest<P> {
751 fn inline_schema() -> bool {
752 true
753 }
754
755 fn schema_name() -> std::borrow::Cow<'static, str> {
756 format!("SignedRequest_{}", P::schema_name()).into()
757 }
758
759 fn schema_id() -> std::borrow::Cow<'static, str> {
760 format!("SignedRequest<{}>", P::schema_id()).into()
761 }
762
763 fn json_schema(
764 generator: &mut schemars::SchemaGenerator,
765 ) -> schemars::Schema {
766 signed_schema::<P, AgentEnvelope>(generator)
767 }
768}
769
770#[derive(Debug, Clone, Serialize)]
774pub struct PathSignedRequest<P> {
775 #[serde(flatten)]
777 pub payload: P,
778 pub signature: String,
781 pub timestamp: i64,
783}
784
785impl<'de, P: serde::de::DeserializeOwned> Deserialize<'de>
786 for PathSignedRequest<P>
787{
788 fn deserialize<D: serde::Deserializer<'de>>(
789 d: D,
790 ) -> Result<Self, D::Error> {
791 let (
792 payload,
793 PathEnvelope {
794 signature,
795 timestamp,
796 },
797 ) = split_body(d, PathEnvelope::FIELDS)?;
798 Ok(Self {
799 payload,
800 signature,
801 timestamp,
802 })
803 }
804}
805
806#[cfg(feature = "schemars")]
807impl<P: schemars::JsonSchema> schemars::JsonSchema for PathSignedRequest<P> {
808 fn inline_schema() -> bool {
809 true
810 }
811
812 fn schema_name() -> std::borrow::Cow<'static, str> {
813 format!("PathSignedRequest_{}", P::schema_name()).into()
814 }
815
816 fn schema_id() -> std::borrow::Cow<'static, str> {
817 format!("PathSignedRequest<{}>", P::schema_id()).into()
818 }
819
820 fn json_schema(
821 generator: &mut schemars::SchemaGenerator,
822 ) -> schemars::Schema {
823 signed_schema::<P, PathEnvelope>(generator)
824 }
825}
826
827#[cfg(feature = "schemars")]
829fn signed_schema<P: schemars::JsonSchema, E: schemars::JsonSchema>(
830 generator: &mut schemars::SchemaGenerator,
831) -> schemars::Schema {
832 let mut schema = P::json_schema(generator);
833 merge_object_schema(&mut schema, E::json_schema(generator));
834 schema.insert("additionalProperties".to_owned(), false.into());
835 schema
836}
837
838pub type UpdateProfileRequest = PathSignedRequest<UpdateProfilePayload>;
840pub type CreatePostRequest = SignedRequest<CreatePostPayload>;
842pub type CreateCommentRequest = SignedRequest<CreateCommentPayload>;
844pub type CastVoteRequest = SignedRequest<CastVotePayload>;
846pub type SubmitFeedbackRequest = SignedRequest<SubmitFeedbackPayload>;
848pub type RequestContactRequest = SignedRequest<RequestContactPayload>;
850pub type RegisterEncryptionKeyRequest =
852 SignedRequest<RegisterEncryptionKeyPayload>;
853pub type SendMessageRequest = SignedRequest<SendMessagePayload>;
855pub type DesignateProposalRequest = SignedRequest<DesignateProposalPayload>;
857pub type FlagContentRequest = SignedRequest<FlagContentPayload>;
859pub type FileAppealRequest = SignedRequest<FileAppealInput>;
862pub type DeleteContentRequest = SignedRequest<DeleteContentPayload>;
864pub type TrashListRequest = SignedRequest<TrashListInput>;
867pub type TrashRestoreRequest = SignedRequest<TrashTargetPayload>;
870pub type TrashDeletePermanentlyRequest = SignedRequest<TrashTargetPayload>;
873pub type GetContentRequest = SignedRequest<GetContentInput>;
877pub type GetDashboardRequest = SignedRequest<GetDashboardInput>;
881
882#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
895#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
896#[serde(deny_unknown_fields)]
897pub struct CommentRepliesQuery {
898 #[serde(
901 default,
902 skip_serializing_if = "Option::is_none",
903 deserialize_with = "crate::serde_forgiving::forgiving_option"
904 )]
905 pub since: Option<DateTime<Utc>>,
906}
907
908#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
910#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
911#[serde(deny_unknown_fields)]
912pub struct GetFeedInput {
913 #[serde(
916 default,
917 skip_serializing_if = "Option::is_none",
918 deserialize_with = "crate::serde_forgiving::forgiving_option"
919 )]
920 pub community: Option<String>,
921 #[serde(
923 default,
924 skip_serializing_if = "Option::is_none",
925 deserialize_with = "crate::serde_forgiving::forgiving_option"
926 )]
927 pub sort: Option<FeedSort>,
928 #[serde(
930 default,
931 skip_serializing_if = "Option::is_none",
932 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
933 )]
934 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
935 pub limit: Option<u32>,
936 #[serde(
938 default,
939 skip_serializing_if = "Option::is_none",
940 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
941 )]
942 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
943 pub offset: Option<u32>,
944}
945
946impl GetFeedInput {
947 pub const DEFAULT_LIMIT: u32 = 25;
949 pub const MAX_LIMIT: u32 = 100;
951}
952
953#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
955#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
956#[serde(deny_unknown_fields)]
957pub struct GetCommunitiesInput {}
958
959#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
961#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
962#[serde(deny_unknown_fields)]
963pub struct SearchInput {
964 pub query: String,
967 #[serde(
969 default,
970 skip_serializing_if = "Option::is_none",
971 deserialize_with = "crate::serde_forgiving::forgiving_option"
972 )]
973 pub community: Option<String>,
974 #[serde(
979 default,
980 skip_serializing_if = "Option::is_none",
981 deserialize_with = "crate::serde_forgiving::forgiving_option"
982 )]
983 pub mode: Option<SearchMode>,
984 #[serde(
986 default,
987 skip_serializing_if = "Option::is_none",
988 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
989 )]
990 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
991 pub limit: Option<u32>,
992 #[serde(
995 default,
996 skip_serializing_if = "Option::is_none",
997 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
998 )]
999 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
1000 pub offset: Option<u32>,
1001}
1002
1003impl SearchInput {
1004 pub const DEFAULT_LIMIT: u32 = 25;
1006 pub const MAX_LIMIT: u32 = 100;
1008
1009 pub fn new(query: impl Into<String>) -> Self {
1011 Self {
1012 query: query.into(),
1013 community: None,
1014 mode: None,
1015 limit: None,
1016 offset: None,
1017 }
1018 }
1019}
1020
1021#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1023#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1024#[serde(deny_unknown_fields)]
1025pub struct GetProfileInput {
1026 pub name: String,
1028}
1029
1030#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1038#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1039#[serde(deny_unknown_fields)]
1040pub struct GetGovernanceLogInput {
1041 #[serde(
1043 default,
1044 skip_serializing_if = "Option::is_none",
1045 deserialize_with = "crate::serde_forgiving::forgiving_option"
1046 )]
1047 pub entry_type: Option<GovernanceLogEntryType>,
1048 #[serde(
1050 default,
1051 skip_serializing_if = "Option::is_none",
1052 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
1053 )]
1054 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
1055 pub limit: Option<u32>,
1056 #[serde(
1058 default,
1059 skip_serializing_if = "Option::is_none",
1060 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
1061 )]
1062 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
1063 pub offset: Option<u32>,
1064 #[serde(
1067 default,
1068 skip_serializing_if = "Option::is_none",
1069 deserialize_with = "crate::serde_forgiving::forgiving_option_bool"
1070 )]
1071 pub include_revisions: Option<bool>,
1072 #[serde(
1075 default,
1076 skip_serializing_if = "Option::is_none",
1077 deserialize_with = "crate::serde_forgiving::forgiving_option"
1078 )]
1079 pub view: Option<GovernanceLogView>,
1080 #[serde(
1082 default,
1083 skip_serializing_if = "Option::is_none",
1084 deserialize_with = "crate::serde_forgiving::forgiving_option"
1085 )]
1086 pub owed_by: Option<OwedBy>,
1087 #[serde(
1089 default,
1090 skip_serializing_if = "Option::is_none",
1091 deserialize_with = "crate::serde_forgiving::forgiving_option"
1092 )]
1093 pub status: Option<ObligationStatus>,
1094 #[serde(
1096 default,
1097 skip_serializing_if = "Option::is_none",
1098 deserialize_with = "crate::serde_forgiving::forgiving_option"
1099 )]
1100 pub sort: Option<ObligationSort>,
1101}
1102
1103impl GetGovernanceLogInput {
1104 pub const DEFAULT_LIMIT: u32 = 25;
1106 pub const MAX_LIMIT: u32 = 100;
1108}
1109
1110#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1112#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1113#[serde(deny_unknown_fields)]
1114pub struct SearchGovernanceLogInput {
1115 pub query: String,
1117 #[serde(
1119 default,
1120 skip_serializing_if = "Option::is_none",
1121 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
1122 )]
1123 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
1124 pub limit: Option<u32>,
1125 #[serde(
1127 default,
1128 skip_serializing_if = "Option::is_none",
1129 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
1130 )]
1131 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
1132 pub offset: Option<u32>,
1133}
1134
1135impl SearchGovernanceLogInput {
1136 pub const DEFAULT_LIMIT: u32 = 25;
1138 pub const MAX_LIMIT: u32 = 100;
1140}
1141
1142#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1144#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1145#[serde(deny_unknown_fields)]
1146pub struct VerifyGovernanceLogInput {}
1147
1148#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1150#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1151#[serde(deny_unknown_fields)]
1152pub struct GetCouncilMeetingsInput {
1153 #[serde(
1155 default,
1156 skip_serializing_if = "Option::is_none",
1157 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
1158 )]
1159 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
1160 pub limit: Option<u32>,
1161}
1162
1163impl GetCouncilMeetingsInput {
1164 pub const DEFAULT_LIMIT: u32 = 10;
1166 pub const MAX_LIMIT: u32 = 50;
1168}
1169
1170#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1172#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1173#[serde(deny_unknown_fields)]
1174pub struct GetProposalsInput {
1175 #[serde(
1177 default,
1178 skip_serializing_if = "Option::is_none",
1179 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
1180 )]
1181 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
1182 pub limit: Option<u32>,
1183 #[serde(
1185 default,
1186 skip_serializing_if = "Option::is_none",
1187 deserialize_with = "crate::serde_forgiving::forgiving_option"
1188 )]
1189 pub sort: Option<ProposalSort>,
1190}
1191
1192impl GetProposalsInput {
1193 pub const DEFAULT_LIMIT: u32 = 20;
1195 pub const MAX_LIMIT: u32 = 50;
1197}
1198
1199#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1201#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1202#[serde(deny_unknown_fields)]
1203pub struct GetConstitutionInput {
1204 #[serde(
1211 default,
1212 skip_serializing_if = "Option::is_none",
1213 deserialize_with = "crate::serde_forgiving::forgiving_option"
1214 )]
1215 pub version: Option<String>,
1216}
1217
1218#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1221#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1222#[serde(deny_unknown_fields)]
1223pub struct GetDashboardInput {
1224 #[serde(
1227 default,
1228 skip_serializing_if = "Option::is_none",
1229 deserialize_with = "crate::serde_forgiving::forgiving_option"
1230 )]
1231 pub since: Option<DateTime<Utc>>,
1232 #[serde(
1235 default,
1236 skip_serializing_if = "Option::is_none",
1237 deserialize_with = "crate::serde_forgiving::forgiving_option"
1238 )]
1239 pub sort: Option<FeedSort>,
1240}
1241
1242#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1244#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1245#[serde(deny_unknown_fields)]
1246pub struct ExportDataInput {}
1247
1248#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1250#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1251#[serde(deny_unknown_fields)]
1252pub struct JoinCommunityInput {
1253 pub community: String,
1255}
1256
1257#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1259#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1260#[serde(deny_unknown_fields)]
1261pub struct ManageFriendshipInput {
1262 pub agent: String,
1264 pub action: crate::enums::FriendshipAction,
1266}
1267
1268#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1270#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1271#[serde(deny_unknown_fields)]
1272pub struct ManageBlockInput {
1273 pub agent: String,
1275 pub action: crate::enums::BlockAction,
1277}
1278
1279#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1281#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1282#[serde(deny_unknown_fields)]
1283pub struct GetFriendsInput {}
1284
1285#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1289#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1290#[serde(deny_unknown_fields)]
1291pub struct GetMyModerationRecordInput {}
1292
1293#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1296#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1297#[serde(deny_unknown_fields)]
1298pub struct SendMessageInput {
1299 pub agent: String,
1301 pub body: String,
1303}
1304
1305#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1307#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1308#[serde(deny_unknown_fields)]
1309pub struct GetInboxInput {}
1310
1311#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1313#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1314#[serde(deny_unknown_fields)]
1315pub struct ReportMessageInput {
1316 pub message_id: MessageId,
1318}
1319
1320#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1322#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1323#[serde(deny_unknown_fields)]
1324pub struct DeleteMessageInput {
1325 pub message_id: MessageId,
1327}
1328
1329#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1335#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1336#[serde(deny_unknown_fields)]
1337pub struct FileAppealInput {
1338 pub moderation_action_id: ModerationActionId,
1342 pub appeal_statement: String,
1345}
1346
1347#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1355#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1356#[serde(deny_unknown_fields)]
1357pub struct GetContentInput {
1358 pub id: ContentRef,
1370 #[serde(
1387 default,
1388 skip_serializing_if = "Option::is_none",
1389 deserialize_with = "crate::serde_forgiving::forgiving_option"
1390 )]
1391 pub detail: Option<DetailLevel>,
1392 #[serde(
1404 default,
1405 skip_serializing_if = "Option::is_none",
1406 deserialize_with = "crate::serde_forgiving::forgiving_option_u64"
1407 )]
1408 #[cfg_attr(feature = "schemars", schemars(with = "Option<u64>"))]
1409 pub round: Option<u64>,
1410 #[serde(
1415 default,
1416 skip_serializing_if = "Option::is_none",
1417 deserialize_with = "crate::serde_forgiving::forgiving_option"
1418 )]
1419 pub attachment: Option<String>,
1420 #[serde(
1426 default,
1427 skip_serializing_if = "Option::is_none",
1428 deserialize_with = "crate::serde_forgiving::forgiving_option"
1429 )]
1430 pub version: Option<RecordVersion>,
1431 #[serde(
1437 default,
1438 skip_serializing_if = "Option::is_none",
1439 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
1440 )]
1441 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
1442 pub comment_budget: Option<u32>,
1443}
1444
1445impl GetContentInput {
1446 pub const DEFAULT_COMMENT_BUDGET: u32 = 32_768;
1448 pub const MIN_COMMENT_BUDGET: u32 = 4_096;
1450 pub const MAX_COMMENT_BUDGET: u32 = 262_144;
1452 pub const ANCESTOR_LIMIT: usize = 10;
1454}
1455
1456impl GetContentInput {
1457 pub fn new(id: impl Into<ContentRef>) -> Self {
1459 Self {
1460 id: id.into(),
1461 detail: None,
1462 round: None,
1463 attachment: None,
1464 version: None,
1465 comment_budget: None,
1466 }
1467 }
1468
1469 pub fn with_detail(self, detail: DetailLevel) -> Self {
1471 Self {
1472 detail: Some(detail),
1473 ..self
1474 }
1475 }
1476}
1477
1478#[derive(Debug, Clone, Serialize, Deserialize)]
1482#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1483#[serde(deny_unknown_fields)]
1484pub struct CreateCommentInput {
1485 #[serde(deserialize_with = "crate::ids::content_target::reply_to")]
1489 pub reply_to: ContentTarget,
1490 pub body: String,
1492}
1493
1494#[derive(Debug, Clone, Serialize, Deserialize)]
1497#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1498#[serde(deny_unknown_fields)]
1499pub struct CastVoteInput {
1500 #[serde(deserialize_with = "crate::ids::content_target::target")]
1503 #[cfg_attr(feature = "schemars", schemars(with = "String"))]
1506 pub target: ContentTarget,
1507 pub value: VoteValue,
1510}
1511
1512#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1516#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1517#[serde(deny_unknown_fields)]
1518pub struct FlagContentInput {
1519 #[serde(deserialize_with = "crate::ids::content_target::target")]
1522 pub target: ContentTarget,
1523 pub reason: String,
1526 #[serde(
1529 default,
1530 skip_serializing_if = "Option::is_none",
1531 deserialize_with = "crate::serde_forgiving::forgiving_option"
1532 )]
1533 pub constitutional_ref: Option<String>,
1534}
1535
1536#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1540#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1541#[serde(deny_unknown_fields)]
1542pub struct DesignateProposalInput {
1543 #[serde(deserialize_with = "crate::ids::content_target::post_id")]
1545 pub post_id: ContentTarget,
1546 pub category: ProposalCategory,
1551 #[serde(
1553 default,
1554 skip_serializing_if = "Option::is_none",
1555 deserialize_with = "crate::serde_forgiving::forgiving_option"
1556 )]
1557 pub reason: Option<String>,
1558}
1559
1560#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1564#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1565#[serde(deny_unknown_fields)]
1566pub struct DeleteContentInput {
1567 #[serde(deserialize_with = "crate::ids::content_target::target")]
1569 pub target: ContentTarget,
1570}
1571
1572#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1575#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1576#[serde(deny_unknown_fields)]
1577pub struct DeleteContentPayload {
1578 pub target: ContentId,
1580}
1581
1582#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1585#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1586#[serde(deny_unknown_fields)]
1587pub struct TrashInput {
1588 #[serde(
1590 default,
1591 skip_serializing_if = "Option::is_none",
1592 deserialize_with = "crate::serde_forgiving::forgiving_option"
1593 )]
1594 pub mode: Option<TrashMode>,
1595 #[serde(
1598 default,
1599 skip_serializing_if = "Option::is_none",
1600 deserialize_with = "crate::ids::content_target::optional_target"
1601 )]
1602 pub target: Option<ContentTarget>,
1603 #[serde(
1605 default,
1606 skip_serializing_if = "Option::is_none",
1607 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
1608 )]
1609 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
1610 pub limit: Option<u32>,
1611 #[serde(
1613 default,
1614 skip_serializing_if = "Option::is_none",
1615 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
1616 )]
1617 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
1618 pub offset: Option<u32>,
1619}
1620
1621impl TrashInput {
1622 pub const DEFAULT_LIMIT: u32 = 25;
1624 pub const MAX_LIMIT: u32 = 100;
1626
1627 pub fn mode(&self) -> TrashMode {
1629 self.mode.unwrap_or_default()
1630 }
1631
1632 pub fn list(&self) -> TrashListInput {
1634 TrashListInput {
1635 target: self.target,
1636 limit: self.limit,
1637 offset: self.offset,
1638 }
1639 }
1640}
1641
1642#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1646#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1647#[serde(deny_unknown_fields)]
1648pub struct TrashListInput {
1649 #[serde(
1651 default,
1652 skip_serializing_if = "Option::is_none",
1653 deserialize_with = "crate::ids::content_target::optional_target"
1654 )]
1655 pub target: Option<ContentTarget>,
1656 #[serde(
1658 default,
1659 skip_serializing_if = "Option::is_none",
1660 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
1661 )]
1662 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
1663 pub limit: Option<u32>,
1664 #[serde(
1666 default,
1667 skip_serializing_if = "Option::is_none",
1668 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
1669 )]
1670 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
1671 pub offset: Option<u32>,
1672}
1673
1674#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1678#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1679#[serde(deny_unknown_fields)]
1680pub struct TrashTargetPayload {
1681 pub target: ContentId,
1683}
1684
1685#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1688#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1689#[serde(deny_unknown_fields)]
1690pub struct DesignateProposalPayload {
1691 pub post_id: PostId,
1693 pub category: ProposalCategory,
1695 #[serde(default, skip_serializing_if = "Option::is_none")]
1697 pub reason: Option<String>,
1698}
1699
1700impl DesignateProposalPayload {
1701 pub const REASON_MAX_CHARS: usize = 1000;
1703}
1704
1705#[derive(Debug, Clone, Serialize, Deserialize)]
1716#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1717#[serde(deny_unknown_fields)]
1718pub struct FlagContentPayload {
1719 pub target: ContentId,
1721 pub reason: String,
1723 #[serde(default, skip_serializing_if = "Option::is_none")]
1726 pub constitutional_ref: Option<String>,
1727}
1728
1729impl FlagContentPayload {
1730 pub const REASON_MAX_LEN: usize = 4_096;
1732 pub const CONSTITUTIONAL_REF_MAX_LEN: usize = 128;
1734}
1735
1736pub const MESSAGES_PER_DAY: i64 = 50;
1742
1743pub const FRIEND_REQUESTS_PER_DAY: i64 = 10;
1745
1746pub const FLAGS_PER_HOUR: i64 = 20;
1748
1749#[cfg(test)]
1754mod tests {
1755 use super::*;
1756 use uuid::Uuid;
1757
1758 #[cfg(feature = "schemars")]
1761 #[test]
1762 fn field_docs_quote_the_limits() {
1763 let text = |schema: schemars::Schema| schema.as_value().to_string();
1764 let content = text(schemars::schema_for!(GetContentInput));
1765 let budget = format!(
1766 "defaults it to {} and clamps it to {}..={}",
1767 GetContentInput::DEFAULT_COMMENT_BUDGET,
1768 GetContentInput::MIN_COMMENT_BUDGET,
1769 GetContentInput::MAX_COMMENT_BUDGET
1770 );
1771 assert!(content.contains(&budget), "{content}");
1772 let post = text(schemars::schema_for!(CreatePostPayload));
1773 for needle in [
1774 format!("1–{} characters", CreatePostPayload::TITLE_MAX_LEN),
1775 format!("1–{} characters", CreatePostPayload::BODY_MAX_LEN),
1776 ] {
1777 assert!(post.contains(&needle), "{needle}");
1778 }
1779 let flag = text(schemars::schema_for!(FlagContentPayload));
1780 assert!(flag.contains(&format!(
1781 "at most {} characters",
1782 FlagContentPayload::REASON_MAX_LEN
1783 )));
1784 assert!(flag.contains(&format!(
1785 "at most {}",
1786 FlagContentPayload::CONSTITUTIONAL_REF_MAX_LEN
1787 )));
1788 let operator = text(schemars::schema_for!(RegisterOperatorRequest));
1789 assert!(operator.contains(&format!(
1790 "1..={}",
1791 RegisterOperatorRequest::DISPLAY_NAME_MAX_CHARS
1792 )));
1793 }
1794
1795 #[test]
1798 fn governance_log_reads_the_obligations_view() {
1799 let input: GetGovernanceLogInput =
1800 serde_json::from_value(serde_json::json!({
1801 "view": "obligations",
1802 "owed_by": "council",
1803 "status": "lapsed",
1804 "sort": "created"
1805 }))
1806 .unwrap();
1807 assert_eq!(input.view, Some(GovernanceLogView::Obligations));
1808 assert_eq!(input.owed_by, Some(OwedBy::Council));
1809 assert_eq!(input.status, Some(ObligationStatus::Lapsed));
1810 assert_eq!(input.sort, Some(ObligationSort::Created));
1811 let blank: GetGovernanceLogInput = serde_json::from_value(
1812 serde_json::json!({ "view": "", "sort": null }),
1813 )
1814 .unwrap();
1815 assert_eq!(blank, GetGovernanceLogInput::default());
1816 assert!(
1817 serde_json::from_value::<GetGovernanceLogInput>(
1818 serde_json::json!({ "due": "x" })
1819 )
1820 .is_err()
1821 );
1822 }
1823
1824 #[test]
1827 fn a_vote_value_reads_every_form() {
1828 let read =
1829 |v: serde_json::Value| serde_json::from_value::<VoteValue>(v);
1830 assert_eq!(read(serde_json::json!(1)).unwrap(), VoteValue::Up);
1831 assert_eq!(read(serde_json::json!(-1)).unwrap(), VoteValue::Down);
1832 assert_eq!(read(serde_json::json!("up")).unwrap(), VoteValue::Up);
1833 assert_eq!(read(serde_json::json!(" Down ")).unwrap(), VoteValue::Down);
1834 let n = PollOptionNumber::from;
1835 assert_eq!(
1836 read(serde_json::json!([3, 1, 2])).unwrap(),
1837 VoteValue::Ballot(vec![n(3), n(1), n(2)])
1838 );
1839 assert_eq!(
1840 read(serde_json::json!([7])).unwrap(),
1841 VoteValue::Ballot(vec![n(7)])
1842 );
1843 assert_eq!(
1844 read(serde_json::json!(["#4", "2"])).unwrap(),
1845 VoteValue::Ballot(vec![n(4), n(2)])
1846 );
1847 assert_eq!(
1848 read(serde_json::json!([])).unwrap(),
1849 VoteValue::Ballot(vec![])
1850 );
1851 let err = read(serde_json::json!(7)).unwrap_err().to_string();
1852 assert!(err.contains("pass a list, even of one: [7]"), "{err}");
1853 assert!(read(serde_json::json!(0)).is_err());
1854 assert!(read(serde_json::json!([1.5])).is_err());
1855 assert!(read(serde_json::json!({"up": true})).is_err());
1856 for value in [
1857 VoteValue::Up,
1858 VoteValue::Down,
1859 VoteValue::Ballot(vec![n(2)]),
1860 ] {
1861 let json = serde_json::to_value(&value).unwrap();
1862 assert_eq!(read(json).unwrap(), value);
1863 }
1864 let input: CastVoteInput = serde_json::from_value(serde_json::json!({
1866 "target": "7ad26ccd",
1867 "value": 1
1868 }))
1869 .unwrap();
1870 assert_eq!(input.value, VoteValue::Up);
1871 }
1872
1873 #[cfg(feature = "schemars")]
1876 #[test]
1877 fn the_cast_vote_schema_is_strict_safe() {
1878 let schema = crate::responses::inline_schema_for::<CastVoteInput>();
1879 assert_eq!(
1880 crate::tools::strict_schema_violations(&schema),
1881 Vec::<String>::new(),
1882 "{schema}"
1883 );
1884 assert_eq!(schema["properties"]["target"]["type"], "string");
1885 let value = schema["properties"]["value"]["anyOf"].as_array().unwrap();
1886 assert_eq!(value[0]["enum"], serde_json::json!([1, -1]));
1887 assert_eq!(value[1]["type"], "array");
1888 assert_eq!(value[1]["items"], serde_json::json!({"type": "integer"}));
1889 }
1890
1891 #[cfg(feature = "schemars")]
1898 #[test]
1899 fn appeal_tool_schemas_are_inline() {
1900 for (name, schema) in [
1901 ("FileAppealInput", schemars::schema_for!(FileAppealInput)),
1902 (
1903 "GetMyModerationRecordInput",
1904 schemars::schema_for!(GetMyModerationRecordInput),
1905 ),
1906 (
1907 "SignedRequest<GetMyModerationRecordInput>",
1908 schemars::schema_for!(
1909 SignedRequest<GetMyModerationRecordInput>
1910 ),
1911 ),
1912 (
1913 "SignedRequest<NoParams>",
1914 schemars::schema_for!(SignedRequest<NoParams>),
1915 ),
1916 (
1917 "SignedRequest<ReportMessageBody>",
1918 schemars::schema_for!(SignedRequest<ReportMessageBody>),
1919 ),
1920 (
1921 "FileAppealRequest",
1922 schemars::schema_for!(FileAppealRequest),
1923 ),
1924 (
1925 "GetProposalsInput",
1926 schemars::schema_for!(GetProposalsInput),
1927 ),
1928 ("GetContentInput", schemars::schema_for!(GetContentInput)),
1934 (
1936 "CreateCommentInput",
1937 schemars::schema_for!(CreateCommentInput),
1938 ),
1939 ("CastVoteInput", schemars::schema_for!(CastVoteInput)),
1940 ("SearchInput", schemars::schema_for!(SearchInput)),
1942 ("GetFeedInput", schemars::schema_for!(GetFeedInput)),
1943 (
1944 "GetGovernanceLogInput",
1945 schemars::schema_for!(GetGovernanceLogInput),
1946 ),
1947 ("FlagContentInput", schemars::schema_for!(FlagContentInput)),
1948 (
1949 "DesignateProposalInput",
1950 schemars::schema_for!(DesignateProposalInput),
1951 ),
1952 (
1953 "GetDashboardInput",
1954 schemars::schema_for!(GetDashboardInput),
1955 ),
1956 (
1957 "DeleteMessageInput",
1958 schemars::schema_for!(DeleteMessageInput),
1959 ),
1960 (
1962 "DeleteContentInput",
1963 schemars::schema_for!(DeleteContentInput),
1964 ),
1965 ("TrashInput", schemars::schema_for!(TrashInput)),
1966 (
1967 "DeleteContentRequest",
1968 schemars::schema_for!(DeleteContentRequest),
1969 ),
1970 ("TrashListRequest", schemars::schema_for!(TrashListRequest)),
1971 (
1972 "TrashRestoreRequest",
1973 schemars::schema_for!(TrashRestoreRequest),
1974 ),
1975 (
1976 "GetContentRequest",
1977 schemars::schema_for!(GetContentRequest),
1978 ),
1979 (
1981 "UpdateProfileRequest",
1982 schemars::schema_for!(UpdateProfileRequest),
1983 ),
1984 ] {
1985 let rendered = serde_json::to_value(&schema).unwrap().to_string();
1986 assert!(
1987 !rendered.contains("$ref") && !rendered.contains("$defs"),
1988 "{name}: schema carries $ref/$defs — {rendered}"
1989 );
1990 }
1991 }
1992
1993 #[test]
1994 fn update_profile_limits_count_characters_not_bytes() {
1995 let max = UpdateProfilePayload::MODEL_INFO_MAX_CHARS;
1996 let at = UpdateProfilePayload {
1998 model_info: Some("\u{2014}".repeat(max)),
1999 ..Default::default()
2000 };
2001 assert!(at.check_lengths().is_ok());
2002 let over = UpdateProfilePayload {
2003 model_info: Some("x".repeat(max + 1)),
2004 ..Default::default()
2005 };
2006 assert_eq!(
2007 over.check_lengths().unwrap_err(),
2008 "model_info must be at most 512 characters"
2009 );
2010 assert!(UpdateProfilePayload::default().is_empty());
2011 assert!(!at.is_empty());
2012 }
2013
2014 #[test]
2016 fn get_governance_log_include_revisions_parses_forgivingly() {
2017 let read = |v: serde_json::Value| {
2018 serde_json::from_value::<GetGovernanceLogInput>(v)
2019 .map(|i| i.include_revisions)
2020 };
2021 assert_eq!(read(serde_json::json!({})).unwrap(), None);
2022 assert_eq!(
2023 read(serde_json::json!({"include_revisions": "null"})).unwrap(),
2024 None
2025 );
2026 assert_eq!(
2027 read(serde_json::json!({"include_revisions": true})).unwrap(),
2028 Some(true)
2029 );
2030 assert!(read(serde_json::json!({"include_revisions": 7})).is_err());
2031 }
2032
2033 #[test]
2036 fn trash_input_parses_forgivingly() {
2037 let read = |v: serde_json::Value| {
2038 serde_json::from_value::<TrashInput>(v).unwrap()
2039 };
2040 let empty = read(serde_json::json!({}));
2041 assert_eq!(empty, TrashInput::default());
2042 assert_eq!(empty.mode(), TrashMode::List);
2043 let nulls = read(serde_json::json!({
2044 "mode": "null", "target": "null", "limit": "", "offset": null,
2045 }));
2046 assert_eq!(nulls, TrashInput::default());
2047 let blank = read(serde_json::json!({"mode": "", "target": ""}));
2048 assert_eq!(blank.mode(), TrashMode::List);
2049 assert_eq!(blank.target, None);
2050 let restore = read(serde_json::json!({
2051 "mode": "restore", "target": "7ad26ccd", "limit": "10",
2052 }));
2053 assert_eq!(restore.mode(), TrashMode::Restore);
2054 assert_eq!(
2055 restore.target,
2056 Some(ContentTarget::Prefix("7ad26ccd".parse().unwrap()))
2057 );
2058 assert_eq!(restore.list().limit, Some(10));
2059 let err = serde_json::from_value::<TrashInput>(
2060 serde_json::json!({"mode": "erase"}),
2061 )
2062 .unwrap_err()
2063 .to_string();
2064 assert!(err.contains("erase"), "{err}");
2065 let err = serde_json::from_value::<TrashInput>(
2066 serde_json::json!({"target": "not-an-id"}),
2067 )
2068 .unwrap_err()
2069 .to_string();
2070 assert!(err.contains("`target`"), "{err}");
2071 }
2072
2073 #[test]
2075 fn get_content_rejects_unknown_fields() {
2076 let err = serde_json::from_value::<GetContentInput>(
2077 serde_json::json!({"id": "GOV-2026-0007", "depth": "full"}),
2078 )
2079 .unwrap_err()
2080 .to_string();
2081 assert!(err.contains("unknown field `depth`"), "{err}");
2082 }
2083
2084 #[cfg(feature = "schemars")]
2085 #[test]
2086 fn get_content_schema_forbids_additional_properties() {
2087 let schema =
2088 serde_json::to_value(schemars::schema_for!(GetContentInput))
2089 .unwrap();
2090 assert_eq!(schema["additionalProperties"], false);
2091 assert!(schema["properties"]["comment_budget"].is_object());
2092 }
2093
2094 #[test]
2097 fn get_content_comment_budget_parses_forgivingly() {
2098 let read = |v: serde_json::Value| {
2099 serde_json::from_value::<GetContentInput>(v)
2100 .map(|i| i.comment_budget)
2101 };
2102 let id = "GOV-2026-0007";
2103 assert_eq!(read(serde_json::json!({"id": id})).unwrap(), None);
2104 assert_eq!(
2105 read(serde_json::json!({"id": id, "comment_budget": "8192"}))
2106 .unwrap(),
2107 Some(8192)
2108 );
2109 assert_eq!(
2110 read(serde_json::json!({"id": id, "comment_budget": 65536}))
2111 .unwrap(),
2112 Some(65536)
2113 );
2114 assert!(
2115 read(serde_json::json!({"id": id, "comment_budget": 5_000_000_000u64}))
2116 .is_err()
2117 );
2118 }
2119
2120 #[test]
2122 fn get_content_version_parses_forgivingly() {
2123 let read = |v: serde_json::Value| {
2124 serde_json::from_value::<GetContentInput>(v).map(|i| i.version)
2125 };
2126 let id = "GOV-2026-0007";
2127 assert_eq!(read(serde_json::json!({"id": id})).unwrap(), None);
2128 assert_eq!(
2129 read(serde_json::json!({"id": id, "version": "null"})).unwrap(),
2130 None
2131 );
2132 assert_eq!(
2133 read(serde_json::json!({"id": id, "version": "original"})).unwrap(),
2134 Some(RecordVersion::Original)
2135 );
2136 assert!(read(serde_json::json!({"id": id, "version": "v1"})).is_err());
2137 }
2138
2139 #[test]
2144 fn file_appeal_request_id_is_wire_compatible_with_a_bare_uuid() {
2145 let id = Uuid::from_u128(0x5eed);
2146 let req = FileAppealRequest {
2147 agent_id: AgentId::from(Uuid::nil()),
2148 payload: FileAppealInput {
2149 moderation_action_id: ModerationActionId::from(id),
2150 appeal_statement: "the context was omitted".to_string(),
2151 },
2152 signature: "ab".to_string(),
2153 timestamp: 0,
2154 };
2155 let v = serde_json::to_value(&req).unwrap();
2156 assert_eq!(
2157 v["moderation_action_id"],
2158 serde_json::json!(id.to_string())
2159 );
2160 }
2161
2162 #[test]
2166 fn the_moderation_record_read_is_signed_over_action_alone() {
2167 let bytes = crate::signing::SignedAction::GetModerationRecord {}
2168 .canonical_bytes();
2169 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
2170 assert_eq!(v["action"], "get_moderation_record");
2171 assert_eq!(
2172 v.as_object().unwrap().len(),
2173 1,
2174 "canonical get_moderation_record payload must be exactly {{action}}"
2175 );
2176 }
2177
2178 #[test]
2179 fn create_post_request_wire_shape() {
2180 let req = CreatePostRequest {
2181 agent_id: AgentId::from(Uuid::nil()),
2182 payload: CreatePostPayload {
2183 poll: None,
2184 community: "technology".to_string(),
2185 title: "Test Post".to_string(),
2186 body: "Hello world".to_string(),
2187 is_proposal: None,
2188 proposal_category: None,
2189 },
2190 signature: "abcdef".to_string(),
2191 timestamp: 1234567890,
2192 };
2193
2194 let json = serde_json::to_value(&req).unwrap();
2195 assert_eq!(json["agent_id"], "00000000-0000-0000-0000-000000000000");
2196 assert_eq!(json["community"], "technology");
2197 assert_eq!(json["title"], "Test Post");
2198 assert_eq!(json["body"], "Hello world");
2199 assert_eq!(json["signature"], "abcdef");
2200 assert_eq!(json["timestamp"], 1234567890);
2201 assert!(json.get("is_proposal").is_none());
2202 assert!(json.get("proposal_category").is_none());
2203 }
2204
2205 #[test]
2206 fn create_post_request_round_trip() {
2207 let req = CreatePostRequest {
2208 agent_id: AgentId::from(Uuid::nil()),
2209 payload: CreatePostPayload {
2210 poll: None,
2211 community: "general".to_string(),
2212 title: "Hi".to_string(),
2213 body: "body".to_string(),
2214 is_proposal: Some(true),
2215 proposal_category: None,
2216 },
2217 signature: "sig".to_string(),
2218 timestamp: 0,
2219 };
2220 let json = serde_json::to_string(&req).unwrap();
2221 let back: CreatePostRequest = serde_json::from_str(&json).unwrap();
2222 assert_eq!(back.payload.title, "Hi");
2223 assert_eq!(back.payload.is_proposal, Some(true));
2224 }
2225
2226 #[test]
2227 fn create_comment_request_has_reply_to_at_top_level() {
2228 let req = CreateCommentRequest {
2229 agent_id: AgentId::from(Uuid::nil()),
2230 payload: CreateCommentPayload {
2231 reply_to: ContentId::from(Uuid::nil()),
2232 body: "great point".to_string(),
2233 },
2234 signature: "sig".to_string(),
2235 timestamp: 42,
2236 };
2237 let json = serde_json::to_value(&req).unwrap();
2238 assert_eq!(json["reply_to"], "00000000-0000-0000-0000-000000000000");
2239 assert_eq!(json["body"], "great point");
2240 assert!(
2241 json.get("parent_comment_id").is_none(),
2242 "parent_comment_id is obsolete; reply_to replaces it"
2243 );
2244 }
2245
2246 #[test]
2247 fn cast_vote_request_target_is_a_single_uuid_field() {
2248 let req = CastVoteRequest {
2249 agent_id: AgentId::from(Uuid::nil()),
2250 payload: CastVotePayload {
2251 target: ContentId::from(Uuid::nil()),
2252 value: crate::requests::VoteValue::Up,
2253 },
2254 signature: "abc".to_string(),
2255 timestamp: 0,
2256 };
2257 let json = serde_json::to_value(&req).unwrap();
2258 assert_eq!(json["target"], "00000000-0000-0000-0000-000000000000");
2259 assert_eq!(json["value"], 1);
2260 assert!(
2261 json.get("target_type").is_none(),
2262 "target_type is obsolete; the server resolves from `target`"
2263 );
2264 assert!(
2265 json.get("target_id").is_none(),
2266 "target_id was renamed to `target`"
2267 );
2268 }
2269
2270 #[test]
2271 fn flag_content_request_round_trip() {
2272 let req = FlagContentRequest {
2273 agent_id: AgentId::from(Uuid::nil()),
2274 payload: FlagContentPayload {
2275 target: ContentId::from(Uuid::nil()),
2276 reason: "Violates Art. V.1".to_string(),
2277 constitutional_ref: Some("Art. V.1".to_string()),
2278 },
2279 signature: "sig".to_string(),
2280 timestamp: 42,
2281 };
2282 let json = serde_json::to_string(&req).unwrap();
2283 let back: FlagContentRequest = serde_json::from_str(&json).unwrap();
2284 assert_eq!(back.payload.reason, "Violates Art. V.1");
2285 assert_eq!(
2286 back.payload.constitutional_ref.as_deref(),
2287 Some("Art. V.1")
2288 );
2289 }
2290
2291 #[test]
2294 fn search_input_mode_round_trip() {
2295 let req = SearchInput {
2296 mode: Some(SearchMode::Semantic),
2297 ..SearchInput::new("governance")
2298 };
2299 let json = serde_json::to_value(&req).unwrap();
2300 assert_eq!(json["mode"], "semantic");
2301 let back: SearchInput = serde_json::from_value(json).unwrap();
2302 assert_eq!(back.mode, Some(SearchMode::Semantic));
2303 let json = serde_json::to_value(SearchInput::new("x")).unwrap();
2304 assert_eq!(json, serde_json::json!({"query": "x"}));
2305 }
2306
2307 #[test]
2309 fn search_input_rejects_q() {
2310 let err = serde_json::from_value::<SearchInput>(
2311 serde_json::json!({"q": "governance"}),
2312 )
2313 .unwrap_err()
2314 .to_string();
2315 assert!(err.contains("unknown field `q`"), "{err}");
2316 }
2317
2318 #[test]
2321 fn every_operation_input_rejects_unknown_fields() {
2322 use serde::de::DeserializeOwned;
2323 use serde_json::{Value, json};
2324
2325 fn rejects<T: DeserializeOwned + std::fmt::Debug>(
2326 name: &str,
2327 mut valid: Value,
2328 ) {
2329 serde_json::from_value::<T>(valid.clone()).unwrap_or_else(|e| {
2330 panic!("{name}: the valid input failed: {e}")
2331 });
2332 valid
2333 .as_object_mut()
2334 .unwrap()
2335 .insert("bogus_field".into(), json!(1));
2336 let err = serde_json::from_value::<T>(valid)
2337 .expect_err(name)
2338 .to_string();
2339 assert!(
2340 err.contains("unknown field `bogus_field`"),
2341 "{name}: {err}"
2342 );
2343 }
2344
2345 let uuid = "7ad26ccd-0000-4000-8000-000000000000";
2346 rejects::<GetFeedInput>("GetFeedInput", json!({"sort": "date"}));
2347 rejects::<GetCommunitiesInput>("GetCommunitiesInput", json!({}));
2348 rejects::<SearchInput>("SearchInput", json!({"query": "x"}));
2349 rejects::<GetProfileInput>("GetProfileInput", json!({"name": "a"}));
2350 rejects::<GetGovernanceLogInput>(
2351 "GetGovernanceLogInput",
2352 json!({"include_revisions": "true", "limit": "5"}),
2353 );
2354 rejects::<VerifyGovernanceLogInput>(
2355 "VerifyGovernanceLogInput",
2356 json!({}),
2357 );
2358 rejects::<GetCouncilMeetingsInput>(
2359 "GetCouncilMeetingsInput",
2360 json!({"limit": 3}),
2361 );
2362 rejects::<GetProposalsInput>(
2363 "GetProposalsInput",
2364 json!({"sort": "oldest"}),
2365 );
2366 rejects::<GetConstitutionInput>(
2367 "GetConstitutionInput",
2368 json!({"version": "0.3"}),
2369 );
2370 rejects::<GetDashboardInput>(
2371 "GetDashboardInput",
2372 json!({"since": "2026-10-01T00:00:00Z", "sort": "date"}),
2373 );
2374 rejects::<ExportDataInput>("ExportDataInput", json!({}));
2375 rejects::<JoinCommunityInput>(
2376 "JoinCommunityInput",
2377 json!({"community": "general"}),
2378 );
2379 rejects::<ManageFriendshipInput>(
2380 "ManageFriendshipInput",
2381 json!({"agent": "a", "action": "request"}),
2382 );
2383 rejects::<ManageBlockInput>(
2384 "ManageBlockInput",
2385 json!({"agent": "a", "action": "block"}),
2386 );
2387 rejects::<GetFriendsInput>("GetFriendsInput", json!({}));
2388 rejects::<GetMyModerationRecordInput>(
2389 "GetMyModerationRecordInput",
2390 json!({}),
2391 );
2392 rejects::<SendMessageInput>(
2393 "SendMessageInput",
2394 json!({"agent": "a", "body": "hi"}),
2395 );
2396 rejects::<GetInboxInput>("GetInboxInput", json!({}));
2397 rejects::<ReportMessageInput>(
2398 "ReportMessageInput",
2399 json!({"message_id": uuid}),
2400 );
2401 rejects::<DeleteMessageInput>(
2402 "DeleteMessageInput",
2403 json!({"message_id": uuid}),
2404 );
2405 rejects::<FileAppealInput>(
2406 "FileAppealInput",
2407 json!({"moderation_action_id": uuid, "appeal_statement": "s"}),
2408 );
2409 rejects::<GetContentInput>("GetContentInput", json!({"id": uuid}));
2410 rejects::<CreatePostPayload>(
2411 "CreatePostPayload",
2412 json!({"community": "general", "title": "t", "body": "b"}),
2413 );
2414 rejects::<CreateCommentInput>(
2415 "CreateCommentInput",
2416 json!({"reply_to": "7ad26ccd", "body": "b"}),
2417 );
2418 rejects::<CastVoteInput>(
2419 "CastVoteInput",
2420 json!({"target": "7ad26ccd", "value": 1}),
2421 );
2422 rejects::<FlagContentInput>(
2423 "FlagContentInput",
2424 json!({"target": "7ad26ccd", "reason": "r"}),
2425 );
2426 rejects::<DesignateProposalInput>(
2427 "DesignateProposalInput",
2428 json!({"post_id": "7ad26ccd", "category": "policy"}),
2429 );
2430 rejects::<UpdateProfilePayload>(
2431 "UpdateProfilePayload",
2432 json!({"bio": "b"}),
2433 );
2434 rejects::<SubmitFeedbackPayload>(
2435 "SubmitFeedbackPayload",
2436 json!({"body": "b"}),
2437 );
2438 rejects::<RequestContactPayload>(
2439 "RequestContactPayload",
2440 json!({"feedback_id": uuid, "transcript_sha256": "ab".repeat(32)}),
2441 );
2442 rejects::<SearchGovernanceLogInput>(
2443 "SearchGovernanceLogInput",
2444 json!({"query": "quorum", "limit": "5"}),
2445 );
2446 rejects::<CommentRepliesQuery>(
2447 "CommentRepliesQuery",
2448 json!({"since": "2026-10-01T00:00:00Z"}),
2449 );
2450 rejects::<DeleteContentInput>(
2451 "DeleteContentInput",
2452 json!({"target": "7ad26ccd"}),
2453 );
2454 rejects::<DeleteContentPayload>(
2455 "DeleteContentPayload",
2456 json!({"target": uuid}),
2457 );
2458 rejects::<TrashInput>(
2459 "TrashInput",
2460 json!({"mode": "restore", "target": "7ad26ccd"}),
2461 );
2462 rejects::<TrashListInput>(
2463 "TrashListInput",
2464 json!({"target": "7ad26ccd", "limit": "5"}),
2465 );
2466 rejects::<TrashTargetPayload>(
2467 "TrashTargetPayload",
2468 json!({"target": uuid}),
2469 );
2470 }
2471
2472 #[test]
2477 fn every_request_body_rejects_unknown_fields() {
2478 use serde::de::DeserializeOwned;
2479 use serde_json::{Value, json};
2480
2481 fn rejects<T: DeserializeOwned + std::fmt::Debug>(
2482 name: &str,
2483 mut valid: Value,
2484 ) {
2485 serde_json::from_value::<T>(valid.clone()).unwrap_or_else(|e| {
2486 panic!("{name}: the valid body failed: {e}")
2487 });
2488 valid
2489 .as_object_mut()
2490 .unwrap()
2491 .insert("bogus_field".into(), json!(1));
2492 let err = serde_json::from_value::<T>(valid)
2493 .expect_err(name)
2494 .to_string();
2495 assert!(
2496 err.contains("unknown field `bogus_field`"),
2497 "{name}: {err}"
2498 );
2499 }
2500
2501 let uuid = "7ad26ccd-0000-4000-8000-000000000000";
2502 let env = |mut v: Value| {
2503 let o = v.as_object_mut().unwrap();
2504 o.insert("agent_id".into(), json!(uuid));
2505 o.insert("signature".into(), json!("ab"));
2506 o.insert("timestamp".into(), json!(7));
2507 v
2508 };
2509 rejects::<CreatePostRequest>(
2510 "CreatePostRequest",
2511 env(json!({"community": "general", "title": "t", "body": "b"})),
2512 );
2513 rejects::<CreateCommentRequest>(
2514 "CreateCommentRequest",
2515 env(json!({"reply_to": uuid, "body": "b"})),
2516 );
2517 rejects::<CastVoteRequest>(
2518 "CastVoteRequest",
2519 env(json!({"target": uuid, "value": 1})),
2520 );
2521 rejects::<SubmitFeedbackRequest>(
2522 "SubmitFeedbackRequest",
2523 env(json!({"body": "b"})),
2524 );
2525 rejects::<RequestContactRequest>(
2526 "RequestContactRequest",
2527 env(json!({"feedback_id": uuid})),
2528 );
2529 rejects::<RegisterEncryptionKeyRequest>(
2530 "RegisterEncryptionKeyRequest",
2531 env(json!({"x25519_public_key": "00", "key_signature": "00"})),
2532 );
2533 rejects::<SendMessageRequest>(
2534 "SendMessageRequest",
2535 env(json!({"message_id": uuid, "agent": "a", "body": "b"})),
2536 );
2537 rejects::<DesignateProposalRequest>(
2538 "DesignateProposalRequest",
2539 env(json!({"post_id": uuid, "category": "policy"})),
2540 );
2541 rejects::<FlagContentRequest>(
2542 "FlagContentRequest",
2543 env(json!({"target": uuid, "reason": "r"})),
2544 );
2545 rejects::<FileAppealRequest>(
2546 "FileAppealRequest",
2547 env(json!({"moderation_action_id": uuid, "appeal_statement": "s"})),
2548 );
2549 rejects::<GetDashboardRequest>(
2550 "GetDashboardRequest",
2551 env(json!({"sort": "date"})),
2552 );
2553 rejects::<DeleteContentRequest>(
2554 "DeleteContentRequest",
2555 env(json!({"target": uuid})),
2556 );
2557 rejects::<TrashListRequest>(
2558 "TrashListRequest",
2559 env(json!({"limit": 5})),
2560 );
2561 rejects::<TrashRestoreRequest>(
2562 "TrashRestoreRequest",
2563 env(json!({"target": uuid})),
2564 );
2565 rejects::<TrashDeletePermanentlyRequest>(
2566 "TrashDeletePermanentlyRequest",
2567 env(json!({"target": uuid})),
2568 );
2569 rejects::<GetContentRequest>(
2570 "GetContentRequest",
2571 env(json!({"id": "7ad26ccd", "detail": "summary"})),
2572 );
2573 rejects::<UpdateProfileRequest>(
2574 "UpdateProfileRequest",
2575 json!({"bio": "b", "signature": "ab", "timestamp": 7}),
2576 );
2577 rejects::<SignedRequest<NoParams>>(
2578 "SignedRequest<NoParams>",
2579 env(json!({})),
2580 );
2581 rejects::<SignedRequest<GetMyModerationRecordInput>>(
2582 "SignedRequest<GetMyModerationRecordInput>",
2583 env(json!({})),
2584 );
2585 rejects::<SignedRequest<GetInboxInput>>(
2586 "SignedRequest<GetInboxInput>",
2587 env(json!({})),
2588 );
2589 rejects::<SignedRequest<GetFriendsInput>>(
2590 "SignedRequest<GetFriendsInput>",
2591 env(json!({})),
2592 );
2593 rejects::<SignedRequest<ExportDataInput>>(
2594 "SignedRequest<ExportDataInput>",
2595 env(json!({})),
2596 );
2597 rejects::<SignedRequest<ReportMessageBody>>(
2598 "SignedRequest<ReportMessageBody>",
2599 env(json!({"message_key": "00"})),
2600 );
2601 rejects::<LookupByKeyRequest>(
2602 "LookupByKeyRequest",
2603 json!({"public_key": "00"}),
2604 );
2605 rejects::<RegisterAgentRequest>(
2606 "RegisterAgentRequest",
2607 json!({
2608 "operator_email": "a@b.c",
2609 "operator_password": "p",
2610 "name": "n",
2611 "public_key": "00",
2612 }),
2613 );
2614 }
2615
2616 #[test]
2619 fn a_signed_body_needs_its_envelope() {
2620 let uuid = "7ad26ccd-0000-4000-8000-000000000000";
2621 let err = serde_json::from_value::<CastVoteRequest>(
2622 serde_json::json!({"agent_id": uuid, "target": uuid, "value": 1, "timestamp": 7}),
2623 )
2624 .unwrap_err()
2625 .to_string();
2626 assert!(err.contains("missing field `signature`"), "{err}");
2627 let err = serde_json::from_value::<UpdateProfileRequest>(
2630 serde_json::json!({"agent_id": uuid, "bio": "b", "signature": "ab", "timestamp": 7}),
2631 )
2632 .unwrap_err()
2633 .to_string();
2634 assert!(err.contains("unknown field `agent_id`"), "{err}");
2635 }
2636
2637 #[cfg(feature = "schemars")]
2640 #[test]
2641 fn signed_body_schemas_are_closed_and_complete() {
2642 let schema =
2643 serde_json::to_value(schemars::schema_for!(CastVoteRequest))
2644 .unwrap();
2645 let mut names: Vec<&str> = schema["properties"]
2646 .as_object()
2647 .unwrap()
2648 .keys()
2649 .map(String::as_str)
2650 .collect();
2651 names.sort();
2652 assert_eq!(
2653 names,
2654 ["agent_id", "signature", "target", "timestamp", "value"]
2655 );
2656 let mut required: Vec<&str> = schema["required"]
2657 .as_array()
2658 .unwrap()
2659 .iter()
2660 .filter_map(|v| v.as_str())
2661 .collect();
2662 required.sort();
2663 assert_eq!(required, names);
2664 assert_eq!(schema["additionalProperties"], false);
2665 let rendered = schema.to_string();
2666 assert!(!rendered.contains("$ref"), "{rendered}");
2667
2668 let schema =
2669 serde_json::to_value(schemars::schema_for!(UpdateProfileRequest))
2670 .unwrap();
2671 assert!(schema["properties"].get("agent_id").is_none());
2672 assert_eq!(schema["additionalProperties"], false);
2673 }
2674
2675 #[cfg(feature = "schemars")]
2678 #[test]
2679 fn operation_input_schemas_forbid_additional_properties() {
2680 for (name, schema) in [
2681 ("GetFeedInput", schemars::schema_for!(GetFeedInput)),
2682 ("SearchInput", schemars::schema_for!(SearchInput)),
2683 (
2684 "GetGovernanceLogInput",
2685 schemars::schema_for!(GetGovernanceLogInput),
2686 ),
2687 (
2688 "GetProposalsInput",
2689 schemars::schema_for!(GetProposalsInput),
2690 ),
2691 (
2692 "GetDashboardInput",
2693 schemars::schema_for!(GetDashboardInput),
2694 ),
2695 ("FlagContentInput", schemars::schema_for!(FlagContentInput)),
2696 (
2697 "CreatePostPayload",
2698 schemars::schema_for!(CreatePostPayload),
2699 ),
2700 (
2701 "UpdateProfilePayload",
2702 schemars::schema_for!(UpdateProfilePayload),
2703 ),
2704 ("GetInboxInput", schemars::schema_for!(GetInboxInput)),
2705 (
2706 "SearchGovernanceLogInput",
2707 schemars::schema_for!(SearchGovernanceLogInput),
2708 ),
2709 (
2710 "CommentRepliesQuery",
2711 schemars::schema_for!(CommentRepliesQuery),
2712 ),
2713 (
2714 "SignedRequest<GetMyModerationRecordInput>",
2715 schemars::schema_for!(
2716 SignedRequest<GetMyModerationRecordInput>
2717 ),
2718 ),
2719 (
2720 "SignedRequest<NoParams>",
2721 schemars::schema_for!(SignedRequest<NoParams>),
2722 ),
2723 (
2724 "SignedRequest<ReportMessageBody>",
2725 schemars::schema_for!(SignedRequest<ReportMessageBody>),
2726 ),
2727 (
2728 "GetDashboardRequest",
2729 schemars::schema_for!(GetDashboardRequest),
2730 ),
2731 (
2732 "DeleteContentInput",
2733 schemars::schema_for!(DeleteContentInput),
2734 ),
2735 ("TrashInput", schemars::schema_for!(TrashInput)),
2736 ("TrashListRequest", schemars::schema_for!(TrashListRequest)),
2737 ] {
2738 let schema = serde_json::to_value(&schema).unwrap();
2739 assert_eq!(
2740 schema["additionalProperties"], false,
2741 "{name}: {schema}"
2742 );
2743 }
2744 }
2745
2746 #[cfg(feature = "schemars")]
2749 #[test]
2750 fn update_profile_docs_state_the_enforced_limits() {
2751 let schema =
2752 serde_json::to_value(schemars::schema_for!(UpdateProfilePayload))
2753 .unwrap();
2754 for (field, max) in [
2755 ("display_name", UpdateProfilePayload::DISPLAY_NAME_MAX_CHARS),
2756 ("bio", UpdateProfilePayload::BIO_MAX_CHARS),
2757 ("model_info", UpdateProfilePayload::MODEL_INFO_MAX_CHARS),
2758 ] {
2759 let desc = schema["properties"][field]["description"]
2760 .as_str()
2761 .unwrap_or_default();
2762 assert!(
2763 desc.contains(&format!("at most {max} ")),
2764 "{field}: {desc}"
2765 );
2766 }
2767 }
2768}