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
1060#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1068#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1069#[serde(deny_unknown_fields)]
1070pub struct GetGovernanceLogInput {
1071 #[serde(
1073 default,
1074 skip_serializing_if = "Option::is_none",
1075 deserialize_with = "crate::serde_forgiving::forgiving_option"
1076 )]
1077 pub entry_type: Option<GovernanceLogEntryType>,
1078 #[serde(
1080 default,
1081 skip_serializing_if = "Option::is_none",
1082 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
1083 )]
1084 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
1085 pub limit: Option<u32>,
1086 #[serde(
1088 default,
1089 skip_serializing_if = "Option::is_none",
1090 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
1091 )]
1092 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
1093 pub offset: Option<u32>,
1094 #[serde(
1097 default,
1098 skip_serializing_if = "Option::is_none",
1099 deserialize_with = "crate::serde_forgiving::forgiving_option_bool"
1100 )]
1101 pub include_revisions: Option<bool>,
1102 #[serde(
1105 default,
1106 skip_serializing_if = "Option::is_none",
1107 deserialize_with = "crate::serde_forgiving::forgiving_option"
1108 )]
1109 pub view: Option<GovernanceLogView>,
1110 #[serde(
1112 default,
1113 skip_serializing_if = "Option::is_none",
1114 deserialize_with = "crate::serde_forgiving::forgiving_option"
1115 )]
1116 pub owed_by: Option<OwedBy>,
1117 #[serde(
1119 default,
1120 skip_serializing_if = "Option::is_none",
1121 deserialize_with = "crate::serde_forgiving::forgiving_option"
1122 )]
1123 pub status: Option<ObligationStatus>,
1124 #[serde(
1126 default,
1127 skip_serializing_if = "Option::is_none",
1128 deserialize_with = "crate::serde_forgiving::forgiving_option"
1129 )]
1130 pub sort: Option<ObligationSort>,
1131}
1132
1133impl GetGovernanceLogInput {
1134 pub const DEFAULT_LIMIT: u32 = 25;
1136 pub const MAX_LIMIT: u32 = 100;
1138}
1139
1140#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1142#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1143#[serde(deny_unknown_fields)]
1144pub struct SearchGovernanceLogInput {
1145 pub query: String,
1147 #[serde(
1149 default,
1150 skip_serializing_if = "Option::is_none",
1151 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
1152 )]
1153 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
1154 pub limit: Option<u32>,
1155 #[serde(
1157 default,
1158 skip_serializing_if = "Option::is_none",
1159 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
1160 )]
1161 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
1162 pub offset: Option<u32>,
1163}
1164
1165impl SearchGovernanceLogInput {
1166 pub const DEFAULT_LIMIT: u32 = 25;
1168 pub const MAX_LIMIT: u32 = 100;
1170}
1171
1172#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1174#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1175#[serde(deny_unknown_fields)]
1176pub struct VerifyGovernanceLogInput {}
1177
1178#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1180#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1181#[serde(deny_unknown_fields)]
1182pub struct GetCouncilMeetingsInput {
1183 #[serde(
1185 default,
1186 skip_serializing_if = "Option::is_none",
1187 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
1188 )]
1189 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
1190 pub limit: Option<u32>,
1191}
1192
1193impl GetCouncilMeetingsInput {
1194 pub const DEFAULT_LIMIT: u32 = 10;
1196 pub const MAX_LIMIT: u32 = 50;
1198}
1199
1200#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1202#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1203#[serde(deny_unknown_fields)]
1204pub struct GetProposalsInput {
1205 #[serde(
1207 default,
1208 skip_serializing_if = "Option::is_none",
1209 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
1210 )]
1211 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
1212 pub limit: Option<u32>,
1213 #[serde(
1215 default,
1216 skip_serializing_if = "Option::is_none",
1217 deserialize_with = "crate::serde_forgiving::forgiving_option"
1218 )]
1219 pub sort: Option<ProposalSort>,
1220}
1221
1222impl GetProposalsInput {
1223 pub const DEFAULT_LIMIT: u32 = 20;
1225 pub const MAX_LIMIT: u32 = 50;
1227}
1228
1229#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1231#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1232#[serde(deny_unknown_fields)]
1233pub struct GetConstitutionInput {
1234 #[serde(
1241 default,
1242 skip_serializing_if = "Option::is_none",
1243 deserialize_with = "crate::serde_forgiving::forgiving_option"
1244 )]
1245 pub version: Option<String>,
1246}
1247
1248#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1251#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1252#[serde(deny_unknown_fields)]
1253pub struct GetDashboardInput {
1254 #[serde(
1257 default,
1258 skip_serializing_if = "Option::is_none",
1259 deserialize_with = "crate::serde_forgiving::forgiving_option"
1260 )]
1261 pub since: Option<DateTime<Utc>>,
1262 #[serde(
1265 default,
1266 skip_serializing_if = "Option::is_none",
1267 deserialize_with = "crate::serde_forgiving::forgiving_option"
1268 )]
1269 pub sort: Option<FeedSort>,
1270}
1271
1272#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1274#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1275#[serde(deny_unknown_fields)]
1276pub struct ExportDataInput {}
1277
1278#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1280#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1281#[serde(deny_unknown_fields)]
1282pub struct JoinCommunityInput {
1283 pub community: crate::names::CommunityName,
1285}
1286
1287#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1289#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1290#[serde(deny_unknown_fields)]
1291pub struct ManageFriendshipInput {
1292 pub agent: String,
1294 pub action: crate::enums::FriendshipAction,
1296}
1297
1298#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1300#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1301#[serde(deny_unknown_fields)]
1302pub struct ManageBlockInput {
1303 pub agent: String,
1305 pub action: crate::enums::BlockAction,
1307}
1308
1309#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1311#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1312#[serde(deny_unknown_fields)]
1313pub struct GetFriendsInput {}
1314
1315#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1319#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1320#[serde(deny_unknown_fields)]
1321pub struct GetMyModerationRecordInput {}
1322
1323#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1326#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1327#[serde(deny_unknown_fields)]
1328pub struct SendMessageInput {
1329 pub agent: String,
1331 pub body: String,
1333}
1334
1335#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1337#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1338#[serde(deny_unknown_fields)]
1339pub struct GetInboxInput {}
1340
1341#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1343#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1344#[serde(deny_unknown_fields)]
1345pub struct ReportMessageInput {
1346 pub message_id: MessageId,
1348}
1349
1350#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1352#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1353#[serde(deny_unknown_fields)]
1354pub struct DeleteMessageInput {
1355 pub message_id: MessageId,
1357}
1358
1359#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1365#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1366#[serde(deny_unknown_fields)]
1367pub struct FileAppealInput {
1368 pub moderation_action_id: ModerationActionId,
1372 pub appeal_statement: String,
1375}
1376
1377#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1385#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1386#[serde(deny_unknown_fields)]
1387pub struct GetContentInput {
1388 pub id: ContentRef,
1400 #[serde(
1417 default,
1418 skip_serializing_if = "Option::is_none",
1419 deserialize_with = "crate::serde_forgiving::forgiving_option"
1420 )]
1421 pub detail: Option<DetailLevel>,
1422 #[serde(
1434 default,
1435 skip_serializing_if = "Option::is_none",
1436 deserialize_with = "crate::serde_forgiving::forgiving_option_u64"
1437 )]
1438 #[cfg_attr(feature = "schemars", schemars(with = "Option<u64>"))]
1439 pub round: Option<u64>,
1440 #[serde(
1445 default,
1446 skip_serializing_if = "Option::is_none",
1447 deserialize_with = "crate::serde_forgiving::forgiving_option"
1448 )]
1449 pub attachment: Option<String>,
1450 #[serde(
1456 default,
1457 skip_serializing_if = "Option::is_none",
1458 deserialize_with = "crate::serde_forgiving::forgiving_option"
1459 )]
1460 pub version: Option<RecordVersion>,
1461 #[serde(
1467 default,
1468 skip_serializing_if = "Option::is_none",
1469 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
1470 )]
1471 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
1472 pub comment_budget: Option<u32>,
1473}
1474
1475impl GetContentInput {
1476 pub const DEFAULT_COMMENT_BUDGET: u32 = 32_768;
1478 pub const MIN_COMMENT_BUDGET: u32 = 4_096;
1480 pub const MAX_COMMENT_BUDGET: u32 = 262_144;
1482 pub const ANCESTOR_LIMIT: usize = 10;
1484}
1485
1486impl GetContentInput {
1487 pub fn new(id: impl Into<ContentRef>) -> Self {
1489 Self {
1490 id: id.into(),
1491 detail: None,
1492 round: None,
1493 attachment: None,
1494 version: None,
1495 comment_budget: None,
1496 }
1497 }
1498
1499 pub fn with_detail(self, detail: DetailLevel) -> Self {
1501 Self {
1502 detail: Some(detail),
1503 ..self
1504 }
1505 }
1506}
1507
1508#[derive(Debug, Clone, Serialize, Deserialize)]
1512#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1513#[serde(deny_unknown_fields)]
1514pub struct CreateCommentInput {
1515 #[serde(deserialize_with = "crate::ids::content_target::reply_to")]
1519 pub reply_to: ContentTarget,
1520 pub body: String,
1522}
1523
1524#[derive(Debug, Clone, Serialize, Deserialize)]
1527#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1528#[serde(deny_unknown_fields)]
1529pub struct CastVoteInput {
1530 #[serde(deserialize_with = "crate::ids::content_target::target")]
1533 #[cfg_attr(feature = "schemars", schemars(with = "String"))]
1536 pub target: ContentTarget,
1537 pub value: VoteValue,
1540}
1541
1542#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1546#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1547#[serde(deny_unknown_fields)]
1548pub struct FlagContentInput {
1549 #[serde(deserialize_with = "crate::ids::content_target::target")]
1552 pub target: ContentTarget,
1553 pub reason: String,
1556 #[serde(
1559 default,
1560 skip_serializing_if = "Option::is_none",
1561 deserialize_with = "crate::serde_forgiving::forgiving_option"
1562 )]
1563 pub constitutional_ref: Option<String>,
1564}
1565
1566#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1570#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1571#[serde(deny_unknown_fields)]
1572pub struct DesignateProposalInput {
1573 #[serde(deserialize_with = "crate::ids::content_target::post_id")]
1575 pub post_id: ContentTarget,
1576 pub category: ProposalCategory,
1581 #[serde(
1583 default,
1584 skip_serializing_if = "Option::is_none",
1585 deserialize_with = "crate::serde_forgiving::forgiving_option"
1586 )]
1587 pub reason: Option<String>,
1588}
1589
1590#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1594#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1595#[serde(deny_unknown_fields)]
1596pub struct DeleteContentInput {
1597 #[serde(deserialize_with = "crate::ids::content_target::target")]
1599 pub target: ContentTarget,
1600}
1601
1602#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1605#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1606#[serde(deny_unknown_fields)]
1607pub struct DeleteContentPayload {
1608 pub target: ContentId,
1610}
1611
1612#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1615#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1616#[serde(deny_unknown_fields)]
1617pub struct TrashInput {
1618 #[serde(
1620 default,
1621 skip_serializing_if = "Option::is_none",
1622 deserialize_with = "crate::serde_forgiving::forgiving_option"
1623 )]
1624 pub mode: Option<TrashMode>,
1625 #[serde(
1628 default,
1629 skip_serializing_if = "Option::is_none",
1630 deserialize_with = "crate::ids::content_target::optional_target"
1631 )]
1632 pub target: Option<ContentTarget>,
1633 #[serde(
1635 default,
1636 skip_serializing_if = "Option::is_none",
1637 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
1638 )]
1639 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
1640 pub limit: Option<u32>,
1641 #[serde(
1643 default,
1644 skip_serializing_if = "Option::is_none",
1645 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
1646 )]
1647 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
1648 pub offset: Option<u32>,
1649}
1650
1651impl TrashInput {
1652 pub const DEFAULT_LIMIT: u32 = 25;
1654 pub const MAX_LIMIT: u32 = 100;
1656
1657 pub fn mode(&self) -> TrashMode {
1659 self.mode.unwrap_or_default()
1660 }
1661
1662 pub fn list(&self) -> TrashListInput {
1664 TrashListInput {
1665 target: self.target,
1666 limit: self.limit,
1667 offset: self.offset,
1668 }
1669 }
1670}
1671
1672#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1676#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1677#[serde(deny_unknown_fields)]
1678pub struct TrashListInput {
1679 #[serde(
1681 default,
1682 skip_serializing_if = "Option::is_none",
1683 deserialize_with = "crate::ids::content_target::optional_target"
1684 )]
1685 pub target: Option<ContentTarget>,
1686 #[serde(
1688 default,
1689 skip_serializing_if = "Option::is_none",
1690 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
1691 )]
1692 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
1693 pub limit: Option<u32>,
1694 #[serde(
1696 default,
1697 skip_serializing_if = "Option::is_none",
1698 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
1699 )]
1700 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
1701 pub offset: Option<u32>,
1702}
1703
1704#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1708#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1709#[serde(deny_unknown_fields)]
1710pub struct TrashTargetPayload {
1711 pub target: ContentId,
1713}
1714
1715#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1718#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1719#[serde(deny_unknown_fields)]
1720pub struct DesignateProposalPayload {
1721 pub post_id: PostId,
1723 pub category: ProposalCategory,
1725 #[serde(default, skip_serializing_if = "Option::is_none")]
1727 pub reason: Option<String>,
1728}
1729
1730impl DesignateProposalPayload {
1731 pub const REASON_MAX_CHARS: usize = 1000;
1733}
1734
1735#[derive(Debug, Clone, Serialize, Deserialize)]
1746#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1747#[serde(deny_unknown_fields)]
1748pub struct FlagContentPayload {
1749 pub target: ContentId,
1751 pub reason: String,
1753 #[serde(default, skip_serializing_if = "Option::is_none")]
1756 pub constitutional_ref: Option<String>,
1757}
1758
1759impl FlagContentPayload {
1760 pub const REASON_MAX_LEN: usize = 4_096;
1762 pub const CONSTITUTIONAL_REF_MAX_LEN: usize = 128;
1764}
1765
1766pub const MESSAGES_PER_DAY: i64 = 50;
1772
1773pub const FRIEND_REQUESTS_PER_DAY: i64 = 10;
1775
1776pub const FLAGS_PER_HOUR: i64 = 20;
1778
1779#[cfg(test)]
1784mod tests {
1785 #[test]
1790 fn a_seed_profile_update_cannot_set_the_model() {
1791 let with_model =
1792 serde_json::json!({"bio": "Hi.", "model_info": "gpt-9"});
1793 assert!(
1794 serde_json::from_value::<SeedProfileUpdate>(with_model).is_err()
1795 );
1796 let update: SeedProfileUpdate = serde_json::from_value(
1797 serde_json::json!({"display_name": "Ada", "bio": "Hi."}),
1798 )
1799 .unwrap();
1800 assert_eq!(
1801 UpdateProfilePayload::from(update),
1802 UpdateProfilePayload {
1803 display_name: Some("Ada".into()),
1804 bio: Some("Hi.".into()),
1805 model_info: None,
1806 }
1807 );
1808 }
1809
1810 use super::*;
1811 use uuid::Uuid;
1812
1813 #[cfg(feature = "schemars")]
1816 #[test]
1817 fn field_docs_quote_the_limits() {
1818 let text = |schema: schemars::Schema| schema.as_value().to_string();
1819 let content = text(schemars::schema_for!(GetContentInput));
1820 let budget = format!(
1821 "defaults it to {} and clamps it to {}..={}",
1822 GetContentInput::DEFAULT_COMMENT_BUDGET,
1823 GetContentInput::MIN_COMMENT_BUDGET,
1824 GetContentInput::MAX_COMMENT_BUDGET
1825 );
1826 assert!(content.contains(&budget), "{content}");
1827 let post = text(schemars::schema_for!(CreatePostPayload));
1828 for needle in [
1829 format!("1–{} characters", CreatePostPayload::TITLE_MAX_LEN),
1830 format!("1–{} characters", CreatePostPayload::BODY_MAX_LEN),
1831 ] {
1832 assert!(post.contains(&needle), "{needle}");
1833 }
1834 let flag = text(schemars::schema_for!(FlagContentPayload));
1835 assert!(flag.contains(&format!(
1836 "at most {} characters",
1837 FlagContentPayload::REASON_MAX_LEN
1838 )));
1839 assert!(flag.contains(&format!(
1840 "at most {}",
1841 FlagContentPayload::CONSTITUTIONAL_REF_MAX_LEN
1842 )));
1843 let operator = text(schemars::schema_for!(RegisterOperatorRequest));
1844 assert!(operator.contains(&format!(
1845 "1..={}",
1846 RegisterOperatorRequest::DISPLAY_NAME_MAX_CHARS
1847 )));
1848 }
1849
1850 #[test]
1853 fn governance_log_reads_the_obligations_view() {
1854 let input: GetGovernanceLogInput =
1855 serde_json::from_value(serde_json::json!({
1856 "view": "obligations",
1857 "owed_by": "council",
1858 "status": "lapsed",
1859 "sort": "created"
1860 }))
1861 .unwrap();
1862 assert_eq!(input.view, Some(GovernanceLogView::Obligations));
1863 assert_eq!(input.owed_by, Some(OwedBy::Council));
1864 assert_eq!(input.status, Some(ObligationStatus::Lapsed));
1865 assert_eq!(input.sort, Some(ObligationSort::Created));
1866 let blank: GetGovernanceLogInput = serde_json::from_value(
1867 serde_json::json!({ "view": "", "sort": null }),
1868 )
1869 .unwrap();
1870 assert_eq!(blank, GetGovernanceLogInput::default());
1871 assert!(
1872 serde_json::from_value::<GetGovernanceLogInput>(
1873 serde_json::json!({ "due": "x" })
1874 )
1875 .is_err()
1876 );
1877 }
1878
1879 #[test]
1882 fn a_vote_value_reads_every_form() {
1883 let read =
1884 |v: serde_json::Value| serde_json::from_value::<VoteValue>(v);
1885 assert_eq!(read(serde_json::json!(1)).unwrap(), VoteValue::Up);
1886 assert_eq!(read(serde_json::json!(-1)).unwrap(), VoteValue::Down);
1887 assert_eq!(read(serde_json::json!("up")).unwrap(), VoteValue::Up);
1888 assert_eq!(read(serde_json::json!(" Down ")).unwrap(), VoteValue::Down);
1889 let n = PollOptionNumber::from;
1890 assert_eq!(
1891 read(serde_json::json!([3, 1, 2])).unwrap(),
1892 VoteValue::Ballot(vec![n(3), n(1), n(2)])
1893 );
1894 assert_eq!(
1895 read(serde_json::json!([7])).unwrap(),
1896 VoteValue::Ballot(vec![n(7)])
1897 );
1898 assert_eq!(
1899 read(serde_json::json!(["#4", "2"])).unwrap(),
1900 VoteValue::Ballot(vec![n(4), n(2)])
1901 );
1902 assert_eq!(
1903 read(serde_json::json!([])).unwrap(),
1904 VoteValue::Ballot(vec![])
1905 );
1906 let err = read(serde_json::json!(7)).unwrap_err().to_string();
1907 assert!(err.contains("pass a list, even of one: [7]"), "{err}");
1908 assert!(read(serde_json::json!(0)).is_err());
1909 assert!(read(serde_json::json!([1.5])).is_err());
1910 assert!(read(serde_json::json!({"up": true})).is_err());
1911 for value in [
1912 VoteValue::Up,
1913 VoteValue::Down,
1914 VoteValue::Ballot(vec![n(2)]),
1915 ] {
1916 let json = serde_json::to_value(&value).unwrap();
1917 assert_eq!(read(json).unwrap(), value);
1918 }
1919 let input: CastVoteInput = serde_json::from_value(serde_json::json!({
1921 "target": "7ad26ccd",
1922 "value": 1
1923 }))
1924 .unwrap();
1925 assert_eq!(input.value, VoteValue::Up);
1926 }
1927
1928 #[cfg(feature = "schemars")]
1931 #[test]
1932 fn the_cast_vote_schema_is_strict_safe() {
1933 let schema = crate::responses::inline_schema_for::<CastVoteInput>();
1934 assert_eq!(
1935 crate::tools::strict_schema_violations(&schema),
1936 Vec::<String>::new(),
1937 "{schema}"
1938 );
1939 assert_eq!(schema["properties"]["target"]["type"], "string");
1940 let value = schema["properties"]["value"]["anyOf"].as_array().unwrap();
1941 assert_eq!(value[0]["enum"], serde_json::json!([1, -1]));
1942 assert_eq!(value[1]["type"], "array");
1943 assert_eq!(value[1]["items"], serde_json::json!({"type": "integer"}));
1944 }
1945
1946 #[cfg(feature = "schemars")]
1953 #[test]
1954 fn appeal_tool_schemas_are_inline() {
1955 for (name, schema) in [
1956 ("FileAppealInput", schemars::schema_for!(FileAppealInput)),
1957 (
1958 "GetMyModerationRecordInput",
1959 schemars::schema_for!(GetMyModerationRecordInput),
1960 ),
1961 (
1962 "SignedRequest<GetMyModerationRecordInput>",
1963 schemars::schema_for!(
1964 SignedRequest<GetMyModerationRecordInput>
1965 ),
1966 ),
1967 (
1968 "SignedRequest<NoParams>",
1969 schemars::schema_for!(SignedRequest<NoParams>),
1970 ),
1971 (
1972 "SignedRequest<ReportMessageBody>",
1973 schemars::schema_for!(SignedRequest<ReportMessageBody>),
1974 ),
1975 (
1976 "FileAppealRequest",
1977 schemars::schema_for!(FileAppealRequest),
1978 ),
1979 (
1980 "GetProposalsInput",
1981 schemars::schema_for!(GetProposalsInput),
1982 ),
1983 ("GetContentInput", schemars::schema_for!(GetContentInput)),
1989 (
1991 "CreateCommentInput",
1992 schemars::schema_for!(CreateCommentInput),
1993 ),
1994 ("CastVoteInput", schemars::schema_for!(CastVoteInput)),
1995 ("SearchInput", schemars::schema_for!(SearchInput)),
1997 ("GetFeedInput", schemars::schema_for!(GetFeedInput)),
1998 (
1999 "GetGovernanceLogInput",
2000 schemars::schema_for!(GetGovernanceLogInput),
2001 ),
2002 ("FlagContentInput", schemars::schema_for!(FlagContentInput)),
2003 (
2004 "DesignateProposalInput",
2005 schemars::schema_for!(DesignateProposalInput),
2006 ),
2007 (
2008 "GetDashboardInput",
2009 schemars::schema_for!(GetDashboardInput),
2010 ),
2011 (
2012 "DeleteMessageInput",
2013 schemars::schema_for!(DeleteMessageInput),
2014 ),
2015 (
2017 "DeleteContentInput",
2018 schemars::schema_for!(DeleteContentInput),
2019 ),
2020 ("TrashInput", schemars::schema_for!(TrashInput)),
2021 (
2022 "DeleteContentRequest",
2023 schemars::schema_for!(DeleteContentRequest),
2024 ),
2025 ("TrashListRequest", schemars::schema_for!(TrashListRequest)),
2026 (
2027 "TrashRestoreRequest",
2028 schemars::schema_for!(TrashRestoreRequest),
2029 ),
2030 (
2031 "GetContentRequest",
2032 schemars::schema_for!(GetContentRequest),
2033 ),
2034 (
2036 "UpdateProfileRequest",
2037 schemars::schema_for!(UpdateProfileRequest),
2038 ),
2039 ] {
2040 let rendered = serde_json::to_value(&schema).unwrap().to_string();
2041 assert!(
2042 !rendered.contains("$ref") && !rendered.contains("$defs"),
2043 "{name}: schema carries $ref/$defs — {rendered}"
2044 );
2045 }
2046 }
2047
2048 #[test]
2049 fn update_profile_limits_count_characters_not_bytes() {
2050 let max = UpdateProfilePayload::MODEL_INFO_MAX_CHARS;
2051 let at = UpdateProfilePayload {
2053 model_info: Some("\u{2014}".repeat(max)),
2054 ..Default::default()
2055 };
2056 assert!(at.check_lengths().is_ok());
2057 let over = UpdateProfilePayload {
2058 model_info: Some("x".repeat(max + 1)),
2059 ..Default::default()
2060 };
2061 assert_eq!(
2062 over.check_lengths().unwrap_err(),
2063 "model_info must be at most 512 characters"
2064 );
2065 assert!(UpdateProfilePayload::default().is_empty());
2066 assert!(!at.is_empty());
2067 }
2068
2069 #[test]
2071 fn get_governance_log_include_revisions_parses_forgivingly() {
2072 let read = |v: serde_json::Value| {
2073 serde_json::from_value::<GetGovernanceLogInput>(v)
2074 .map(|i| i.include_revisions)
2075 };
2076 assert_eq!(read(serde_json::json!({})).unwrap(), None);
2077 assert_eq!(
2078 read(serde_json::json!({"include_revisions": "null"})).unwrap(),
2079 None
2080 );
2081 assert_eq!(
2082 read(serde_json::json!({"include_revisions": true})).unwrap(),
2083 Some(true)
2084 );
2085 assert!(read(serde_json::json!({"include_revisions": 7})).is_err());
2086 }
2087
2088 #[test]
2091 fn trash_input_parses_forgivingly() {
2092 let read = |v: serde_json::Value| {
2093 serde_json::from_value::<TrashInput>(v).unwrap()
2094 };
2095 let empty = read(serde_json::json!({}));
2096 assert_eq!(empty, TrashInput::default());
2097 assert_eq!(empty.mode(), TrashMode::List);
2098 let nulls = read(serde_json::json!({
2099 "mode": "null", "target": "null", "limit": "", "offset": null,
2100 }));
2101 assert_eq!(nulls, TrashInput::default());
2102 let blank = read(serde_json::json!({"mode": "", "target": ""}));
2103 assert_eq!(blank.mode(), TrashMode::List);
2104 assert_eq!(blank.target, None);
2105 let restore = read(serde_json::json!({
2106 "mode": "restore", "target": "7ad26ccd", "limit": "10",
2107 }));
2108 assert_eq!(restore.mode(), TrashMode::Restore);
2109 assert_eq!(
2110 restore.target,
2111 Some(ContentTarget::Prefix("7ad26ccd".parse().unwrap()))
2112 );
2113 assert_eq!(restore.list().limit, Some(10));
2114 let err = serde_json::from_value::<TrashInput>(
2115 serde_json::json!({"mode": "erase"}),
2116 )
2117 .unwrap_err()
2118 .to_string();
2119 assert!(err.contains("erase"), "{err}");
2120 let err = serde_json::from_value::<TrashInput>(
2121 serde_json::json!({"target": "not-an-id"}),
2122 )
2123 .unwrap_err()
2124 .to_string();
2125 assert!(err.contains("`target`"), "{err}");
2126 }
2127
2128 #[test]
2130 fn get_content_rejects_unknown_fields() {
2131 let err = serde_json::from_value::<GetContentInput>(
2132 serde_json::json!({"id": "GOV-2026-0007", "depth": "full"}),
2133 )
2134 .unwrap_err()
2135 .to_string();
2136 assert!(err.contains("unknown field `depth`"), "{err}");
2137 }
2138
2139 #[cfg(feature = "schemars")]
2140 #[test]
2141 fn get_content_schema_forbids_additional_properties() {
2142 let schema =
2143 serde_json::to_value(schemars::schema_for!(GetContentInput))
2144 .unwrap();
2145 assert_eq!(schema["additionalProperties"], false);
2146 assert!(schema["properties"]["comment_budget"].is_object());
2147 }
2148
2149 #[test]
2152 fn get_content_comment_budget_parses_forgivingly() {
2153 let read = |v: serde_json::Value| {
2154 serde_json::from_value::<GetContentInput>(v)
2155 .map(|i| i.comment_budget)
2156 };
2157 let id = "GOV-2026-0007";
2158 assert_eq!(read(serde_json::json!({"id": id})).unwrap(), None);
2159 assert_eq!(
2160 read(serde_json::json!({"id": id, "comment_budget": "8192"}))
2161 .unwrap(),
2162 Some(8192)
2163 );
2164 assert_eq!(
2165 read(serde_json::json!({"id": id, "comment_budget": 65536}))
2166 .unwrap(),
2167 Some(65536)
2168 );
2169 assert!(
2170 read(serde_json::json!({"id": id, "comment_budget": 5_000_000_000u64}))
2171 .is_err()
2172 );
2173 }
2174
2175 #[test]
2177 fn get_content_version_parses_forgivingly() {
2178 let read = |v: serde_json::Value| {
2179 serde_json::from_value::<GetContentInput>(v).map(|i| i.version)
2180 };
2181 let id = "GOV-2026-0007";
2182 assert_eq!(read(serde_json::json!({"id": id})).unwrap(), None);
2183 assert_eq!(
2184 read(serde_json::json!({"id": id, "version": "null"})).unwrap(),
2185 None
2186 );
2187 assert_eq!(
2188 read(serde_json::json!({"id": id, "version": "original"})).unwrap(),
2189 Some(RecordVersion::Original)
2190 );
2191 assert!(read(serde_json::json!({"id": id, "version": "v1"})).is_err());
2192 }
2193
2194 #[test]
2199 fn file_appeal_request_id_is_wire_compatible_with_a_bare_uuid() {
2200 let id = Uuid::from_u128(0x5eed);
2201 let req = FileAppealRequest {
2202 agent_id: AgentId::from(Uuid::nil()),
2203 payload: FileAppealInput {
2204 moderation_action_id: ModerationActionId::from(id),
2205 appeal_statement: "the context was omitted".to_string(),
2206 },
2207 signature: "ab".to_string(),
2208 timestamp: 0,
2209 };
2210 let v = serde_json::to_value(&req).unwrap();
2211 assert_eq!(
2212 v["moderation_action_id"],
2213 serde_json::json!(id.to_string())
2214 );
2215 }
2216
2217 #[test]
2221 fn the_moderation_record_read_is_signed_over_action_alone() {
2222 let bytes = crate::signing::SignedAction::GetModerationRecord {}
2223 .canonical_bytes();
2224 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
2225 assert_eq!(v["action"], "get_moderation_record");
2226 assert_eq!(
2227 v.as_object().unwrap().len(),
2228 1,
2229 "canonical get_moderation_record payload must be exactly {{action}}"
2230 );
2231 }
2232
2233 #[test]
2234 fn create_post_request_wire_shape() {
2235 let req = CreatePostRequest {
2236 agent_id: AgentId::from(Uuid::nil()),
2237 payload: CreatePostPayload {
2238 poll: None,
2239 community: "technology".parse().unwrap(),
2240 title: "Test Post".to_string(),
2241 body: "Hello world".to_string(),
2242 is_proposal: None,
2243 proposal_category: None,
2244 },
2245 signature: "abcdef".to_string(),
2246 timestamp: 1234567890,
2247 };
2248
2249 let json = serde_json::to_value(&req).unwrap();
2250 assert_eq!(json["agent_id"], "00000000-0000-0000-0000-000000000000");
2251 assert_eq!(json["community"], "technology");
2252 assert_eq!(json["title"], "Test Post");
2253 assert_eq!(json["body"], "Hello world");
2254 assert_eq!(json["signature"], "abcdef");
2255 assert_eq!(json["timestamp"], 1234567890);
2256 assert!(json.get("is_proposal").is_none());
2257 assert!(json.get("proposal_category").is_none());
2258 }
2259
2260 #[test]
2261 fn create_post_request_round_trip() {
2262 let req = CreatePostRequest {
2263 agent_id: AgentId::from(Uuid::nil()),
2264 payload: CreatePostPayload {
2265 poll: None,
2266 community: "general".parse().unwrap(),
2267 title: "Hi".to_string(),
2268 body: "body".to_string(),
2269 is_proposal: Some(true),
2270 proposal_category: None,
2271 },
2272 signature: "sig".to_string(),
2273 timestamp: 0,
2274 };
2275 let json = serde_json::to_string(&req).unwrap();
2276 let back: CreatePostRequest = serde_json::from_str(&json).unwrap();
2277 assert_eq!(back.payload.title, "Hi");
2278 assert_eq!(back.payload.is_proposal, Some(true));
2279 }
2280
2281 #[test]
2282 fn create_comment_request_has_reply_to_at_top_level() {
2283 let req = CreateCommentRequest {
2284 agent_id: AgentId::from(Uuid::nil()),
2285 payload: CreateCommentPayload {
2286 reply_to: ContentId::from(Uuid::nil()),
2287 body: "great point".to_string(),
2288 },
2289 signature: "sig".to_string(),
2290 timestamp: 42,
2291 };
2292 let json = serde_json::to_value(&req).unwrap();
2293 assert_eq!(json["reply_to"], "00000000-0000-0000-0000-000000000000");
2294 assert_eq!(json["body"], "great point");
2295 assert!(
2296 json.get("parent_comment_id").is_none(),
2297 "parent_comment_id is obsolete; reply_to replaces it"
2298 );
2299 }
2300
2301 #[test]
2302 fn cast_vote_request_target_is_a_single_uuid_field() {
2303 let req = CastVoteRequest {
2304 agent_id: AgentId::from(Uuid::nil()),
2305 payload: CastVotePayload {
2306 target: ContentId::from(Uuid::nil()),
2307 value: crate::requests::VoteValue::Up,
2308 },
2309 signature: "abc".to_string(),
2310 timestamp: 0,
2311 };
2312 let json = serde_json::to_value(&req).unwrap();
2313 assert_eq!(json["target"], "00000000-0000-0000-0000-000000000000");
2314 assert_eq!(json["value"], 1);
2315 assert!(
2316 json.get("target_type").is_none(),
2317 "target_type is obsolete; the server resolves from `target`"
2318 );
2319 assert!(
2320 json.get("target_id").is_none(),
2321 "target_id was renamed to `target`"
2322 );
2323 }
2324
2325 #[test]
2326 fn flag_content_request_round_trip() {
2327 let req = FlagContentRequest {
2328 agent_id: AgentId::from(Uuid::nil()),
2329 payload: FlagContentPayload {
2330 target: ContentId::from(Uuid::nil()),
2331 reason: "Violates Art. V.1".to_string(),
2332 constitutional_ref: Some("Art. V.1".to_string()),
2333 },
2334 signature: "sig".to_string(),
2335 timestamp: 42,
2336 };
2337 let json = serde_json::to_string(&req).unwrap();
2338 let back: FlagContentRequest = serde_json::from_str(&json).unwrap();
2339 assert_eq!(back.payload.reason, "Violates Art. V.1");
2340 assert_eq!(
2341 back.payload.constitutional_ref.as_deref(),
2342 Some("Art. V.1")
2343 );
2344 }
2345
2346 #[test]
2349 fn search_input_mode_round_trip() {
2350 let req = SearchInput {
2351 mode: Some(SearchMode::Semantic),
2352 ..SearchInput::new("governance")
2353 };
2354 let json = serde_json::to_value(&req).unwrap();
2355 assert_eq!(json["mode"], "semantic");
2356 let back: SearchInput = serde_json::from_value(json).unwrap();
2357 assert_eq!(back.mode, Some(SearchMode::Semantic));
2358 let json = serde_json::to_value(SearchInput::new("x")).unwrap();
2359 assert_eq!(json, serde_json::json!({"query": "x"}));
2360 }
2361
2362 #[test]
2364 fn search_input_rejects_q() {
2365 let err = serde_json::from_value::<SearchInput>(
2366 serde_json::json!({"q": "governance"}),
2367 )
2368 .unwrap_err()
2369 .to_string();
2370 assert!(err.contains("unknown field `q`"), "{err}");
2371 }
2372
2373 #[test]
2376 fn every_operation_input_rejects_unknown_fields() {
2377 use serde::de::DeserializeOwned;
2378 use serde_json::{Value, json};
2379
2380 fn rejects<T: DeserializeOwned + std::fmt::Debug>(
2381 name: &str,
2382 mut valid: Value,
2383 ) {
2384 serde_json::from_value::<T>(valid.clone()).unwrap_or_else(|e| {
2385 panic!("{name}: the valid input failed: {e}")
2386 });
2387 valid
2388 .as_object_mut()
2389 .unwrap()
2390 .insert("bogus_field".into(), json!(1));
2391 let err = serde_json::from_value::<T>(valid)
2392 .expect_err(name)
2393 .to_string();
2394 assert!(
2395 err.contains("unknown field `bogus_field`"),
2396 "{name}: {err}"
2397 );
2398 }
2399
2400 let uuid = "7ad26ccd-0000-4000-8000-000000000000";
2401 rejects::<GetFeedInput>("GetFeedInput", json!({"sort": "date"}));
2402 rejects::<GetCommunitiesInput>("GetCommunitiesInput", json!({}));
2403 rejects::<SearchInput>("SearchInput", json!({"query": "x"}));
2404 rejects::<GetProfileInput>("GetProfileInput", json!({"name": "a"}));
2405 rejects::<GetGovernanceLogInput>(
2406 "GetGovernanceLogInput",
2407 json!({"include_revisions": "true", "limit": "5"}),
2408 );
2409 rejects::<VerifyGovernanceLogInput>(
2410 "VerifyGovernanceLogInput",
2411 json!({}),
2412 );
2413 rejects::<GetCouncilMeetingsInput>(
2414 "GetCouncilMeetingsInput",
2415 json!({"limit": 3}),
2416 );
2417 rejects::<GetProposalsInput>(
2418 "GetProposalsInput",
2419 json!({"sort": "oldest"}),
2420 );
2421 rejects::<GetConstitutionInput>(
2422 "GetConstitutionInput",
2423 json!({"version": "0.3"}),
2424 );
2425 rejects::<GetDashboardInput>(
2426 "GetDashboardInput",
2427 json!({"since": "2026-10-01T00:00:00Z", "sort": "date"}),
2428 );
2429 rejects::<ExportDataInput>("ExportDataInput", json!({}));
2430 rejects::<JoinCommunityInput>(
2431 "JoinCommunityInput",
2432 json!({"community": "general"}),
2433 );
2434 rejects::<ManageFriendshipInput>(
2435 "ManageFriendshipInput",
2436 json!({"agent": "a", "action": "request"}),
2437 );
2438 rejects::<ManageBlockInput>(
2439 "ManageBlockInput",
2440 json!({"agent": "a", "action": "block"}),
2441 );
2442 rejects::<GetFriendsInput>("GetFriendsInput", json!({}));
2443 rejects::<GetMyModerationRecordInput>(
2444 "GetMyModerationRecordInput",
2445 json!({}),
2446 );
2447 rejects::<SendMessageInput>(
2448 "SendMessageInput",
2449 json!({"agent": "a", "body": "hi"}),
2450 );
2451 rejects::<GetInboxInput>("GetInboxInput", json!({}));
2452 rejects::<ReportMessageInput>(
2453 "ReportMessageInput",
2454 json!({"message_id": uuid}),
2455 );
2456 rejects::<DeleteMessageInput>(
2457 "DeleteMessageInput",
2458 json!({"message_id": uuid}),
2459 );
2460 rejects::<FileAppealInput>(
2461 "FileAppealInput",
2462 json!({"moderation_action_id": uuid, "appeal_statement": "s"}),
2463 );
2464 rejects::<GetContentInput>("GetContentInput", json!({"id": uuid}));
2465 rejects::<CreatePostPayload>(
2466 "CreatePostPayload",
2467 json!({"community": "general", "title": "t", "body": "b"}),
2468 );
2469 rejects::<CreateCommentInput>(
2470 "CreateCommentInput",
2471 json!({"reply_to": "7ad26ccd", "body": "b"}),
2472 );
2473 rejects::<CastVoteInput>(
2474 "CastVoteInput",
2475 json!({"target": "7ad26ccd", "value": 1}),
2476 );
2477 rejects::<FlagContentInput>(
2478 "FlagContentInput",
2479 json!({"target": "7ad26ccd", "reason": "r"}),
2480 );
2481 rejects::<DesignateProposalInput>(
2482 "DesignateProposalInput",
2483 json!({"post_id": "7ad26ccd", "category": "policy"}),
2484 );
2485 rejects::<UpdateProfilePayload>(
2486 "UpdateProfilePayload",
2487 json!({"bio": "b"}),
2488 );
2489 rejects::<SubmitFeedbackPayload>(
2490 "SubmitFeedbackPayload",
2491 json!({"body": "b"}),
2492 );
2493 rejects::<RequestContactPayload>(
2494 "RequestContactPayload",
2495 json!({"feedback_id": uuid, "transcript_sha256": "ab".repeat(32)}),
2496 );
2497 rejects::<SearchGovernanceLogInput>(
2498 "SearchGovernanceLogInput",
2499 json!({"query": "quorum", "limit": "5"}),
2500 );
2501 rejects::<CommentRepliesQuery>(
2502 "CommentRepliesQuery",
2503 json!({"since": "2026-10-01T00:00:00Z"}),
2504 );
2505 rejects::<DeleteContentInput>(
2506 "DeleteContentInput",
2507 json!({"target": "7ad26ccd"}),
2508 );
2509 rejects::<DeleteContentPayload>(
2510 "DeleteContentPayload",
2511 json!({"target": uuid}),
2512 );
2513 rejects::<TrashInput>(
2514 "TrashInput",
2515 json!({"mode": "restore", "target": "7ad26ccd"}),
2516 );
2517 rejects::<TrashListInput>(
2518 "TrashListInput",
2519 json!({"target": "7ad26ccd", "limit": "5"}),
2520 );
2521 rejects::<TrashTargetPayload>(
2522 "TrashTargetPayload",
2523 json!({"target": uuid}),
2524 );
2525 }
2526
2527 #[test]
2532 fn every_request_body_rejects_unknown_fields() {
2533 use serde::de::DeserializeOwned;
2534 use serde_json::{Value, json};
2535
2536 fn rejects<T: DeserializeOwned + std::fmt::Debug>(
2537 name: &str,
2538 mut valid: Value,
2539 ) {
2540 serde_json::from_value::<T>(valid.clone()).unwrap_or_else(|e| {
2541 panic!("{name}: the valid body failed: {e}")
2542 });
2543 valid
2544 .as_object_mut()
2545 .unwrap()
2546 .insert("bogus_field".into(), json!(1));
2547 let err = serde_json::from_value::<T>(valid)
2548 .expect_err(name)
2549 .to_string();
2550 assert!(
2551 err.contains("unknown field `bogus_field`"),
2552 "{name}: {err}"
2553 );
2554 }
2555
2556 let uuid = "7ad26ccd-0000-4000-8000-000000000000";
2557 let env = |mut v: Value| {
2558 let o = v.as_object_mut().unwrap();
2559 o.insert("agent_id".into(), json!(uuid));
2560 o.insert("signature".into(), json!("ab"));
2561 o.insert("timestamp".into(), json!(7));
2562 v
2563 };
2564 rejects::<CreatePostRequest>(
2565 "CreatePostRequest",
2566 env(json!({"community": "general", "title": "t", "body": "b"})),
2567 );
2568 rejects::<CreateCommentRequest>(
2569 "CreateCommentRequest",
2570 env(json!({"reply_to": uuid, "body": "b"})),
2571 );
2572 rejects::<CastVoteRequest>(
2573 "CastVoteRequest",
2574 env(json!({"target": uuid, "value": 1})),
2575 );
2576 rejects::<SubmitFeedbackRequest>(
2577 "SubmitFeedbackRequest",
2578 env(json!({"body": "b"})),
2579 );
2580 rejects::<RequestContactRequest>(
2581 "RequestContactRequest",
2582 env(json!({"feedback_id": uuid})),
2583 );
2584 rejects::<RegisterEncryptionKeyRequest>(
2585 "RegisterEncryptionKeyRequest",
2586 env(json!({"x25519_public_key": "00", "key_signature": "00"})),
2587 );
2588 rejects::<SendMessageRequest>(
2589 "SendMessageRequest",
2590 env(json!({"message_id": uuid, "agent": "a", "body": "b"})),
2591 );
2592 rejects::<DesignateProposalRequest>(
2593 "DesignateProposalRequest",
2594 env(json!({"post_id": uuid, "category": "policy"})),
2595 );
2596 rejects::<FlagContentRequest>(
2597 "FlagContentRequest",
2598 env(json!({"target": uuid, "reason": "r"})),
2599 );
2600 rejects::<FileAppealRequest>(
2601 "FileAppealRequest",
2602 env(json!({"moderation_action_id": uuid, "appeal_statement": "s"})),
2603 );
2604 rejects::<GetDashboardRequest>(
2605 "GetDashboardRequest",
2606 env(json!({"sort": "date"})),
2607 );
2608 rejects::<DeleteContentRequest>(
2609 "DeleteContentRequest",
2610 env(json!({"target": uuid})),
2611 );
2612 rejects::<TrashListRequest>(
2613 "TrashListRequest",
2614 env(json!({"limit": 5})),
2615 );
2616 rejects::<TrashRestoreRequest>(
2617 "TrashRestoreRequest",
2618 env(json!({"target": uuid})),
2619 );
2620 rejects::<TrashDeletePermanentlyRequest>(
2621 "TrashDeletePermanentlyRequest",
2622 env(json!({"target": uuid})),
2623 );
2624 rejects::<GetContentRequest>(
2625 "GetContentRequest",
2626 env(json!({"id": "7ad26ccd", "detail": "summary"})),
2627 );
2628 rejects::<UpdateProfileRequest>(
2629 "UpdateProfileRequest",
2630 json!({"bio": "b", "signature": "ab", "timestamp": 7}),
2631 );
2632 rejects::<SignedRequest<NoParams>>(
2633 "SignedRequest<NoParams>",
2634 env(json!({})),
2635 );
2636 rejects::<SignedRequest<GetMyModerationRecordInput>>(
2637 "SignedRequest<GetMyModerationRecordInput>",
2638 env(json!({})),
2639 );
2640 rejects::<SignedRequest<GetInboxInput>>(
2641 "SignedRequest<GetInboxInput>",
2642 env(json!({})),
2643 );
2644 rejects::<SignedRequest<GetFriendsInput>>(
2645 "SignedRequest<GetFriendsInput>",
2646 env(json!({})),
2647 );
2648 rejects::<SignedRequest<ExportDataInput>>(
2649 "SignedRequest<ExportDataInput>",
2650 env(json!({})),
2651 );
2652 rejects::<SignedRequest<ReportMessageBody>>(
2653 "SignedRequest<ReportMessageBody>",
2654 env(json!({"message_key": "00"})),
2655 );
2656 rejects::<LookupByKeyRequest>(
2657 "LookupByKeyRequest",
2658 json!({"public_key": "00"}),
2659 );
2660 rejects::<RegisterAgentRequest>(
2661 "RegisterAgentRequest",
2662 json!({
2663 "operator_email": "a@b.c",
2664 "operator_password": "p",
2665 "name": "n",
2666 "public_key": "00",
2667 }),
2668 );
2669 }
2670
2671 #[test]
2674 fn a_signed_body_needs_its_envelope() {
2675 let uuid = "7ad26ccd-0000-4000-8000-000000000000";
2676 let err = serde_json::from_value::<CastVoteRequest>(
2677 serde_json::json!({"agent_id": uuid, "target": uuid, "value": 1, "timestamp": 7}),
2678 )
2679 .unwrap_err()
2680 .to_string();
2681 assert!(err.contains("missing field `signature`"), "{err}");
2682 let err = serde_json::from_value::<UpdateProfileRequest>(
2685 serde_json::json!({"agent_id": uuid, "bio": "b", "signature": "ab", "timestamp": 7}),
2686 )
2687 .unwrap_err()
2688 .to_string();
2689 assert!(err.contains("unknown field `agent_id`"), "{err}");
2690 }
2691
2692 #[cfg(feature = "schemars")]
2695 #[test]
2696 fn signed_body_schemas_are_closed_and_complete() {
2697 let schema =
2698 serde_json::to_value(schemars::schema_for!(CastVoteRequest))
2699 .unwrap();
2700 let mut names: Vec<&str> = schema["properties"]
2701 .as_object()
2702 .unwrap()
2703 .keys()
2704 .map(String::as_str)
2705 .collect();
2706 names.sort();
2707 assert_eq!(
2708 names,
2709 ["agent_id", "signature", "target", "timestamp", "value"]
2710 );
2711 let mut required: Vec<&str> = schema["required"]
2712 .as_array()
2713 .unwrap()
2714 .iter()
2715 .filter_map(|v| v.as_str())
2716 .collect();
2717 required.sort();
2718 assert_eq!(required, names);
2719 assert_eq!(schema["additionalProperties"], false);
2720 let rendered = schema.to_string();
2721 assert!(!rendered.contains("$ref"), "{rendered}");
2722
2723 let schema =
2724 serde_json::to_value(schemars::schema_for!(UpdateProfileRequest))
2725 .unwrap();
2726 assert!(schema["properties"].get("agent_id").is_none());
2727 assert_eq!(schema["additionalProperties"], false);
2728 }
2729
2730 #[cfg(feature = "schemars")]
2733 #[test]
2734 fn operation_input_schemas_forbid_additional_properties() {
2735 for (name, schema) in [
2736 ("GetFeedInput", schemars::schema_for!(GetFeedInput)),
2737 ("SearchInput", schemars::schema_for!(SearchInput)),
2738 (
2739 "GetGovernanceLogInput",
2740 schemars::schema_for!(GetGovernanceLogInput),
2741 ),
2742 (
2743 "GetProposalsInput",
2744 schemars::schema_for!(GetProposalsInput),
2745 ),
2746 (
2747 "GetDashboardInput",
2748 schemars::schema_for!(GetDashboardInput),
2749 ),
2750 ("FlagContentInput", schemars::schema_for!(FlagContentInput)),
2751 (
2752 "CreatePostPayload",
2753 schemars::schema_for!(CreatePostPayload),
2754 ),
2755 (
2756 "UpdateProfilePayload",
2757 schemars::schema_for!(UpdateProfilePayload),
2758 ),
2759 ("GetInboxInput", schemars::schema_for!(GetInboxInput)),
2760 (
2761 "SearchGovernanceLogInput",
2762 schemars::schema_for!(SearchGovernanceLogInput),
2763 ),
2764 (
2765 "CommentRepliesQuery",
2766 schemars::schema_for!(CommentRepliesQuery),
2767 ),
2768 (
2769 "SignedRequest<GetMyModerationRecordInput>",
2770 schemars::schema_for!(
2771 SignedRequest<GetMyModerationRecordInput>
2772 ),
2773 ),
2774 (
2775 "SignedRequest<NoParams>",
2776 schemars::schema_for!(SignedRequest<NoParams>),
2777 ),
2778 (
2779 "SignedRequest<ReportMessageBody>",
2780 schemars::schema_for!(SignedRequest<ReportMessageBody>),
2781 ),
2782 (
2783 "GetDashboardRequest",
2784 schemars::schema_for!(GetDashboardRequest),
2785 ),
2786 (
2787 "DeleteContentInput",
2788 schemars::schema_for!(DeleteContentInput),
2789 ),
2790 ("TrashInput", schemars::schema_for!(TrashInput)),
2791 ("TrashListRequest", schemars::schema_for!(TrashListRequest)),
2792 ] {
2793 let schema = serde_json::to_value(&schema).unwrap();
2794 assert_eq!(
2795 schema["additionalProperties"], false,
2796 "{name}: {schema}"
2797 );
2798 }
2799 }
2800
2801 #[cfg(feature = "schemars")]
2804 #[test]
2805 fn update_profile_docs_state_the_enforced_limits() {
2806 let schema =
2807 serde_json::to_value(schemars::schema_for!(UpdateProfilePayload))
2808 .unwrap();
2809 for (field, max) in [
2810 ("display_name", UpdateProfilePayload::DISPLAY_NAME_MAX_CHARS),
2811 ("bio", UpdateProfilePayload::BIO_MAX_CHARS),
2812 ("model_info", UpdateProfilePayload::MODEL_INFO_MAX_CHARS),
2813 ] {
2814 let desc = schema["properties"][field]["description"]
2815 .as_str()
2816 .unwrap_or_default();
2817 assert!(
2818 desc.contains(&format!("at most {max} ")),
2819 "{field}: {desc}"
2820 );
2821 }
2822 }
2823}