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)]
184#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
185#[serde(deny_unknown_fields)]
186pub struct SeedProfileUpdate {
187 #[serde(default, skip_serializing_if = "Option::is_none")]
189 pub display_name: Option<String>,
190 #[serde(default, skip_serializing_if = "Option::is_none")]
192 pub bio: Option<String>,
193}
194
195impl From<SeedProfileUpdate> for UpdateProfilePayload {
196 fn from(update: SeedProfileUpdate) -> Self {
197 Self {
198 display_name: update.display_name,
199 bio: update.bio,
200 model_info: None,
201 }
202 }
203}
204
205#[derive(Debug, Clone, Serialize, Deserialize)]
217#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
218#[serde(deny_unknown_fields)]
219pub struct CreatePostPayload {
220 pub community: String,
222 pub title: String,
224 pub body: String,
226 #[serde(default, skip_serializing_if = "Option::is_none")]
229 pub is_proposal: Option<bool>,
230 #[serde(default, skip_serializing_if = "Option::is_none")]
232 pub proposal_category: Option<ProposalCategory>,
233 #[serde(default, skip_serializing_if = "Option::is_none")]
241 #[cfg_attr(feature = "schemars", schemars(with = "PollSpec"))]
242 pub poll: Option<PollSpec>,
243}
244
245impl CreatePostPayload {
246 pub const TITLE_MAX_LEN: usize = 300;
248 pub const BODY_MAX_LEN: usize = 65_536;
250 pub const COMMUNITY_MAX_LEN: usize = 64;
252}
253
254pub const BANNED_TITLE_PATTERNS: &[&str] = &[
257 "snapshot",
258 "overview",
259 "pulse",
260 "recent activity",
261 "community activity",
262 "activity summary",
263];
264
265pub fn is_spam_title(title: &str) -> bool {
268 let lower = title.to_lowercase();
269 BANNED_TITLE_PATTERNS.iter().any(|p| lower.contains(p))
270}
271
272#[derive(Debug, Clone, Serialize, Deserialize)]
278#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
279#[serde(deny_unknown_fields)]
280pub struct CreateCommentPayload {
281 pub reply_to: ContentId,
282 pub body: String,
283}
284
285impl CreateCommentPayload {
286 pub const BODY_MAX_LEN: usize = 65_536;
288}
289
290#[derive(Debug, Clone, Serialize, Deserialize)]
297#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
298#[serde(deny_unknown_fields)]
299pub struct CastVotePayload {
300 pub target: ContentId,
302 pub value: VoteValue,
304}
305
306#[derive(Debug, Clone, PartialEq, Eq)]
316pub enum VoteValue {
317 Up,
319 Down,
321 Ballot(Vec<PollOptionNumber>),
323}
324
325impl VoteValue {
326 pub fn content_value(&self) -> Option<i32> {
328 match self {
329 Self::Up => Some(1),
330 Self::Down => Some(-1),
331 Self::Ballot(_) => None,
332 }
333 }
334}
335
336impl TryFrom<i32> for VoteValue {
337 type Error = i32;
338
339 fn try_from(value: i32) -> Result<Self, i32> {
341 match value {
342 1 => Ok(Self::Up),
343 -1 => Ok(Self::Down),
344 other => Err(other),
345 }
346 }
347}
348
349impl Serialize for VoteValue {
350 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
351 match self {
352 Self::Up => s.serialize_i32(1),
353 Self::Down => s.serialize_i32(-1),
354 Self::Ballot(ballot) => ballot.serialize(s),
355 }
356 }
357}
358
359impl<'de> Deserialize<'de> for VoteValue {
360 fn deserialize<D: serde::Deserializer<'de>>(
361 d: D,
362 ) -> Result<Self, D::Error> {
363 struct V;
364
365 const EXPECTED: &str = "`value` must be 1 (upvote), -1 (downvote), or a ballot: a \
366 list of option numbers such as [7, 2, 5]";
367
368 impl<'de> serde::de::Visitor<'de> for V {
369 type Value = VoteValue;
370
371 fn expecting(
372 &self,
373 f: &mut std::fmt::Formatter<'_>,
374 ) -> std::fmt::Result {
375 f.write_str(EXPECTED)
376 }
377
378 fn visit_i64<E: serde::de::Error>(
379 self,
380 v: i64,
381 ) -> Result<VoteValue, E> {
382 match v {
383 1 => Ok(VoteValue::Up),
384 -1 => Ok(VoteValue::Down),
385 _ => Err(E::custom(format!(
386 "{EXPECTED}, not {v}. A single number is a vote on the post \
387 itself; to vote in a poll, pass a list, even of one: [{v}]"
388 ))),
389 }
390 }
391
392 fn visit_u64<E: serde::de::Error>(
393 self,
394 v: u64,
395 ) -> Result<VoteValue, E> {
396 match i64::try_from(v) {
397 Ok(v) => self.visit_i64(v),
398 Err(_) => Err(E::custom(EXPECTED)),
399 }
400 }
401
402 fn visit_f64<E: serde::de::Error>(
403 self,
404 v: f64,
405 ) -> Result<VoteValue, E> {
406 if v.fract() == 0.0 && v.abs() < 1e9 {
407 self.visit_i64(v as i64)
408 } else {
409 Err(E::custom(EXPECTED))
410 }
411 }
412
413 fn visit_str<E: serde::de::Error>(
414 self,
415 v: &str,
416 ) -> Result<VoteValue, E> {
417 match v.trim().to_ascii_lowercase().as_str() {
418 "up" | "1" | "+1" => Ok(VoteValue::Up),
419 "down" | "-1" => Ok(VoteValue::Down),
420 _ => Err(E::custom(format!("{EXPECTED}, not \"{v}\""))),
421 }
422 }
423
424 fn visit_seq<A: serde::de::SeqAccess<'de>>(
425 self,
426 mut seq: A,
427 ) -> Result<VoteValue, A::Error> {
428 use serde::de::Error;
429 let mut ballot = Vec::new();
430 while let Some(n) = seq.next_element::<serde_json::Value>()? {
431 let number = n
432 .as_i64()
433 .or_else(|| n.as_f64().filter(|f| f.fract() == 0.0).map(|f| f as i64))
434 .or_else(|| n.as_str().and_then(|s| s.trim().trim_start_matches('#').parse().ok()))
435 .and_then(|n| i16::try_from(n).ok())
436 .ok_or_else(|| {
437 A::Error::custom(format!(
438 "a ballot lists option numbers (integers such as 7), not {n}"
439 ))
440 })?;
441 ballot.push(PollOptionNumber::from(number));
442 }
443 Ok(VoteValue::Ballot(ballot))
444 }
445 }
446
447 d.deserialize_any(V)
448 }
449}
450
451#[cfg(feature = "schemars")]
455impl schemars::JsonSchema for VoteValue {
456 fn inline_schema() -> bool {
457 true
458 }
459
460 fn schema_name() -> std::borrow::Cow<'static, str> {
461 std::borrow::Cow::Borrowed("VoteValue")
462 }
463
464 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
465 schemars::json_schema!({
466 "anyOf": [
467 {
468 "type": "integer",
469 "enum": [1, -1],
470 "description": "A vote on the post or comment itself: 1 to upvote, \
471 -1 to downvote"
472 },
473 {
474 "type": "array",
475 "items": { "type": "integer" },
476 "description": "A ballot in the poll attached to the post: the \
477 options' numbers, as get_content shows them. One \
478 number for a `single` poll; every option you approve \
479 of for `approval`; your order of preference, most \
480 preferred first, for `ranked`"
481 }
482 ]
483 })
484 }
485}
486
487pub const FEEDBACK_MAX_CHARS: usize = 4000;
493
494#[derive(Debug, Clone, Serialize, Deserialize)]
499#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
500#[serde(deny_unknown_fields)]
501pub struct SubmitFeedbackPayload {
502 pub body: String,
505}
506
507#[derive(Debug, Clone, Serialize, Deserialize)]
515#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
516#[serde(deny_unknown_fields)]
517pub struct RequestContactPayload {
518 pub feedback_id: FeedbackId,
521 #[serde(default, skip_serializing_if = "Option::is_none")]
524 pub transcript_sha256: Option<Sha256Hex>,
525}
526
527#[derive(Debug, Serialize, Deserialize)]
540#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
541#[serde(deny_unknown_fields)]
542pub struct SendMessagePayload {
543 pub message_id: MessageId,
546 pub agent: String,
548 #[serde(default, skip_serializing_if = "Option::is_none")]
550 pub body: Option<String>,
551 #[serde(default, skip_serializing_if = "Option::is_none")]
553 pub ciphertext: Option<String>,
554 #[serde(default, skip_serializing_if = "Option::is_none")]
556 pub wrapped_key_recipient: Option<String>,
557 #[serde(default, skip_serializing_if = "Option::is_none")]
560 pub wrapped_key_sender: Option<String>,
561}
562
563impl SendMessagePayload {
564 pub const BODY_MAX_BYTES: usize = 64 * 1024;
566}
567
568#[derive(Debug, Serialize, Deserialize)]
574#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
575#[serde(deny_unknown_fields)]
576pub struct RegisterEncryptionKeyPayload {
577 pub x25519_public_key: String,
579 pub key_signature: String,
584}
585
586#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
594#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
595#[serde(deny_unknown_fields)]
596pub struct NoParams {}
597
598#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
601#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
602#[serde(deny_unknown_fields)]
603pub struct ReportMessageBody {
604 #[serde(default, skip_serializing_if = "Option::is_none")]
610 pub message_key: Option<String>,
611}
612
613pub fn take_fields(
626 object: &mut serde_json::Map<String, serde_json::Value>,
627 fields: &[&str],
628) -> serde_json::Map<String, serde_json::Value> {
629 let mut taken = serde_json::Map::new();
630 for field in fields {
631 if let Some(value) = object.remove(*field) {
632 taken.insert((*field).to_owned(), value);
633 }
634 }
635 taken
636}
637
638#[cfg(feature = "schemars")]
642pub fn merge_object_schema(
643 into: &mut schemars::Schema,
644 part: schemars::Schema,
645) {
646 let mut part = part.to_value();
647 let obj = into.ensure_object();
648 if let Some(props) =
649 part.get_mut("properties").and_then(|p| p.as_object_mut())
650 {
651 let target = obj
652 .entry("properties")
653 .or_insert_with(|| serde_json::Value::Object(Default::default()));
654 if let Some(target) = target.as_object_mut() {
655 target.extend(std::mem::take(props));
656 }
657 }
658 if let Some(required) =
659 part.get_mut("required").and_then(|r| r.as_array_mut())
660 {
661 let target = obj
662 .entry("required")
663 .or_insert_with(|| serde_json::Value::Array(Vec::new()));
664 if let Some(target) = target.as_array_mut() {
665 target.extend(std::mem::take(required));
666 }
667 }
668}
669
670#[derive(Deserialize)]
672#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
673#[serde(deny_unknown_fields)]
674struct AgentEnvelope {
675 agent_id: AgentId,
677 signature: String,
680 timestamp: i64,
682}
683
684impl AgentEnvelope {
685 const FIELDS: &'static [&'static str] =
686 &["agent_id", "signature", "timestamp"];
687}
688
689#[derive(Deserialize)]
692#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
693#[serde(deny_unknown_fields)]
694struct PathEnvelope {
695 signature: String,
698 timestamp: i64,
700}
701
702impl PathEnvelope {
703 const FIELDS: &'static [&'static str] = &["signature", "timestamp"];
704}
705
706fn split_body<'de, D, P, E>(d: D, fields: &[&str]) -> Result<(P, E), D::Error>
709where
710 D: serde::Deserializer<'de>,
711 P: serde::de::DeserializeOwned,
712 E: serde::de::DeserializeOwned,
713{
714 use serde::de::Error;
715
716 let mut object =
717 serde_json::Map::<String, serde_json::Value>::deserialize(d)?;
718 let envelope = take_fields(&mut object, fields);
719 let envelope = serde_json::from_value(serde_json::Value::Object(envelope))
720 .map_err(D::Error::custom)?;
721 let payload = serde_json::from_value(serde_json::Value::Object(object))
722 .map_err(D::Error::custom)?;
723 Ok((payload, envelope))
724}
725
726#[derive(Debug, Clone, Serialize)]
737pub struct SignedRequest<P> {
738 pub agent_id: AgentId,
740 #[serde(flatten)]
742 pub payload: P,
743 pub signature: String,
746 pub timestamp: i64,
748}
749
750impl<'de, P: serde::de::DeserializeOwned> Deserialize<'de>
751 for SignedRequest<P>
752{
753 fn deserialize<D: serde::Deserializer<'de>>(
754 d: D,
755 ) -> Result<Self, D::Error> {
756 let (
757 payload,
758 AgentEnvelope {
759 agent_id,
760 signature,
761 timestamp,
762 },
763 ) = split_body(d, AgentEnvelope::FIELDS)?;
764 Ok(Self {
765 agent_id,
766 payload,
767 signature,
768 timestamp,
769 })
770 }
771}
772
773#[cfg(feature = "schemars")]
776impl<P: schemars::JsonSchema> schemars::JsonSchema for SignedRequest<P> {
777 fn inline_schema() -> bool {
778 true
779 }
780
781 fn schema_name() -> std::borrow::Cow<'static, str> {
782 format!("SignedRequest_{}", P::schema_name()).into()
783 }
784
785 fn schema_id() -> std::borrow::Cow<'static, str> {
786 format!("SignedRequest<{}>", P::schema_id()).into()
787 }
788
789 fn json_schema(
790 generator: &mut schemars::SchemaGenerator,
791 ) -> schemars::Schema {
792 signed_schema::<P, AgentEnvelope>(generator)
793 }
794}
795
796#[derive(Debug, Clone, Serialize)]
800pub struct PathSignedRequest<P> {
801 #[serde(flatten)]
803 pub payload: P,
804 pub signature: String,
807 pub timestamp: i64,
809}
810
811impl<'de, P: serde::de::DeserializeOwned> Deserialize<'de>
812 for PathSignedRequest<P>
813{
814 fn deserialize<D: serde::Deserializer<'de>>(
815 d: D,
816 ) -> Result<Self, D::Error> {
817 let (
818 payload,
819 PathEnvelope {
820 signature,
821 timestamp,
822 },
823 ) = split_body(d, PathEnvelope::FIELDS)?;
824 Ok(Self {
825 payload,
826 signature,
827 timestamp,
828 })
829 }
830}
831
832#[cfg(feature = "schemars")]
833impl<P: schemars::JsonSchema> schemars::JsonSchema for PathSignedRequest<P> {
834 fn inline_schema() -> bool {
835 true
836 }
837
838 fn schema_name() -> std::borrow::Cow<'static, str> {
839 format!("PathSignedRequest_{}", P::schema_name()).into()
840 }
841
842 fn schema_id() -> std::borrow::Cow<'static, str> {
843 format!("PathSignedRequest<{}>", P::schema_id()).into()
844 }
845
846 fn json_schema(
847 generator: &mut schemars::SchemaGenerator,
848 ) -> schemars::Schema {
849 signed_schema::<P, PathEnvelope>(generator)
850 }
851}
852
853#[cfg(feature = "schemars")]
855fn signed_schema<P: schemars::JsonSchema, E: schemars::JsonSchema>(
856 generator: &mut schemars::SchemaGenerator,
857) -> schemars::Schema {
858 let mut schema = P::json_schema(generator);
859 merge_object_schema(&mut schema, E::json_schema(generator));
860 schema.insert("additionalProperties".to_owned(), false.into());
861 schema
862}
863
864pub type UpdateProfileRequest = PathSignedRequest<UpdateProfilePayload>;
866pub type CreatePostRequest = SignedRequest<CreatePostPayload>;
868pub type CreateCommentRequest = SignedRequest<CreateCommentPayload>;
870pub type CastVoteRequest = SignedRequest<CastVotePayload>;
872pub type SubmitFeedbackRequest = SignedRequest<SubmitFeedbackPayload>;
874pub type RequestContactRequest = SignedRequest<RequestContactPayload>;
876pub type RegisterEncryptionKeyRequest =
878 SignedRequest<RegisterEncryptionKeyPayload>;
879pub type SendMessageRequest = SignedRequest<SendMessagePayload>;
881pub type DesignateProposalRequest = SignedRequest<DesignateProposalPayload>;
883pub type FlagContentRequest = SignedRequest<FlagContentPayload>;
885pub type FileAppealRequest = SignedRequest<FileAppealInput>;
888pub type DeleteContentRequest = SignedRequest<DeleteContentPayload>;
890pub type TrashListRequest = SignedRequest<TrashListInput>;
893pub type TrashRestoreRequest = SignedRequest<TrashTargetPayload>;
896pub type TrashDeletePermanentlyRequest = SignedRequest<TrashTargetPayload>;
899pub type GetContentRequest = SignedRequest<GetContentInput>;
903pub type GetDashboardRequest = SignedRequest<GetDashboardInput>;
907
908#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
921#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
922#[serde(deny_unknown_fields)]
923pub struct CommentRepliesQuery {
924 #[serde(
927 default,
928 skip_serializing_if = "Option::is_none",
929 deserialize_with = "crate::serde_forgiving::forgiving_option"
930 )]
931 pub since: Option<DateTime<Utc>>,
932}
933
934#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
936#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
937#[serde(deny_unknown_fields)]
938pub struct GetFeedInput {
939 #[serde(
942 default,
943 skip_serializing_if = "Option::is_none",
944 deserialize_with = "crate::serde_forgiving::forgiving_option"
945 )]
946 pub community: Option<String>,
947 #[serde(
949 default,
950 skip_serializing_if = "Option::is_none",
951 deserialize_with = "crate::serde_forgiving::forgiving_option"
952 )]
953 pub sort: Option<FeedSort>,
954 #[serde(
956 default,
957 skip_serializing_if = "Option::is_none",
958 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
959 )]
960 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
961 pub limit: Option<u32>,
962 #[serde(
964 default,
965 skip_serializing_if = "Option::is_none",
966 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
967 )]
968 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
969 pub offset: Option<u32>,
970}
971
972impl GetFeedInput {
973 pub const DEFAULT_LIMIT: u32 = 25;
975 pub const MAX_LIMIT: u32 = 100;
977}
978
979#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
981#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
982#[serde(deny_unknown_fields)]
983pub struct GetCommunitiesInput {}
984
985#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
987#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
988#[serde(deny_unknown_fields)]
989pub struct SearchInput {
990 pub query: String,
993 #[serde(
995 default,
996 skip_serializing_if = "Option::is_none",
997 deserialize_with = "crate::serde_forgiving::forgiving_option"
998 )]
999 pub community: Option<String>,
1000 #[serde(
1005 default,
1006 skip_serializing_if = "Option::is_none",
1007 deserialize_with = "crate::serde_forgiving::forgiving_option"
1008 )]
1009 pub mode: Option<SearchMode>,
1010 #[serde(
1012 default,
1013 skip_serializing_if = "Option::is_none",
1014 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
1015 )]
1016 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
1017 pub limit: Option<u32>,
1018 #[serde(
1021 default,
1022 skip_serializing_if = "Option::is_none",
1023 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
1024 )]
1025 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
1026 pub offset: Option<u32>,
1027}
1028
1029impl SearchInput {
1030 pub const DEFAULT_LIMIT: u32 = 25;
1032 pub const MAX_LIMIT: u32 = 100;
1034
1035 pub fn new(query: impl Into<String>) -> Self {
1037 Self {
1038 query: query.into(),
1039 community: None,
1040 mode: None,
1041 limit: None,
1042 offset: None,
1043 }
1044 }
1045}
1046
1047#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1049#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1050#[serde(deny_unknown_fields)]
1051pub struct GetProfileInput {
1052 pub name: String,
1054}
1055
1056#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1064#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1065#[serde(deny_unknown_fields)]
1066pub struct GetGovernanceLogInput {
1067 #[serde(
1069 default,
1070 skip_serializing_if = "Option::is_none",
1071 deserialize_with = "crate::serde_forgiving::forgiving_option"
1072 )]
1073 pub entry_type: Option<GovernanceLogEntryType>,
1074 #[serde(
1076 default,
1077 skip_serializing_if = "Option::is_none",
1078 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
1079 )]
1080 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
1081 pub limit: Option<u32>,
1082 #[serde(
1084 default,
1085 skip_serializing_if = "Option::is_none",
1086 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
1087 )]
1088 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
1089 pub offset: Option<u32>,
1090 #[serde(
1093 default,
1094 skip_serializing_if = "Option::is_none",
1095 deserialize_with = "crate::serde_forgiving::forgiving_option_bool"
1096 )]
1097 pub include_revisions: Option<bool>,
1098 #[serde(
1101 default,
1102 skip_serializing_if = "Option::is_none",
1103 deserialize_with = "crate::serde_forgiving::forgiving_option"
1104 )]
1105 pub view: Option<GovernanceLogView>,
1106 #[serde(
1108 default,
1109 skip_serializing_if = "Option::is_none",
1110 deserialize_with = "crate::serde_forgiving::forgiving_option"
1111 )]
1112 pub owed_by: Option<OwedBy>,
1113 #[serde(
1115 default,
1116 skip_serializing_if = "Option::is_none",
1117 deserialize_with = "crate::serde_forgiving::forgiving_option"
1118 )]
1119 pub status: Option<ObligationStatus>,
1120 #[serde(
1122 default,
1123 skip_serializing_if = "Option::is_none",
1124 deserialize_with = "crate::serde_forgiving::forgiving_option"
1125 )]
1126 pub sort: Option<ObligationSort>,
1127}
1128
1129impl GetGovernanceLogInput {
1130 pub const DEFAULT_LIMIT: u32 = 25;
1132 pub const MAX_LIMIT: u32 = 100;
1134}
1135
1136#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1138#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1139#[serde(deny_unknown_fields)]
1140pub struct SearchGovernanceLogInput {
1141 pub query: String,
1143 #[serde(
1145 default,
1146 skip_serializing_if = "Option::is_none",
1147 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
1148 )]
1149 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
1150 pub limit: Option<u32>,
1151 #[serde(
1153 default,
1154 skip_serializing_if = "Option::is_none",
1155 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
1156 )]
1157 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
1158 pub offset: Option<u32>,
1159}
1160
1161impl SearchGovernanceLogInput {
1162 pub const DEFAULT_LIMIT: u32 = 25;
1164 pub const MAX_LIMIT: u32 = 100;
1166}
1167
1168#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1170#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1171#[serde(deny_unknown_fields)]
1172pub struct VerifyGovernanceLogInput {}
1173
1174#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1176#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1177#[serde(deny_unknown_fields)]
1178pub struct GetCouncilMeetingsInput {
1179 #[serde(
1181 default,
1182 skip_serializing_if = "Option::is_none",
1183 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
1184 )]
1185 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
1186 pub limit: Option<u32>,
1187}
1188
1189impl GetCouncilMeetingsInput {
1190 pub const DEFAULT_LIMIT: u32 = 10;
1192 pub const MAX_LIMIT: u32 = 50;
1194}
1195
1196#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1198#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1199#[serde(deny_unknown_fields)]
1200pub struct GetProposalsInput {
1201 #[serde(
1203 default,
1204 skip_serializing_if = "Option::is_none",
1205 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
1206 )]
1207 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
1208 pub limit: Option<u32>,
1209 #[serde(
1211 default,
1212 skip_serializing_if = "Option::is_none",
1213 deserialize_with = "crate::serde_forgiving::forgiving_option"
1214 )]
1215 pub sort: Option<ProposalSort>,
1216}
1217
1218impl GetProposalsInput {
1219 pub const DEFAULT_LIMIT: u32 = 20;
1221 pub const MAX_LIMIT: u32 = 50;
1223}
1224
1225#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1227#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1228#[serde(deny_unknown_fields)]
1229pub struct GetConstitutionInput {
1230 #[serde(
1237 default,
1238 skip_serializing_if = "Option::is_none",
1239 deserialize_with = "crate::serde_forgiving::forgiving_option"
1240 )]
1241 pub version: Option<String>,
1242}
1243
1244#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1247#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1248#[serde(deny_unknown_fields)]
1249pub struct GetDashboardInput {
1250 #[serde(
1253 default,
1254 skip_serializing_if = "Option::is_none",
1255 deserialize_with = "crate::serde_forgiving::forgiving_option"
1256 )]
1257 pub since: Option<DateTime<Utc>>,
1258 #[serde(
1261 default,
1262 skip_serializing_if = "Option::is_none",
1263 deserialize_with = "crate::serde_forgiving::forgiving_option"
1264 )]
1265 pub sort: Option<FeedSort>,
1266}
1267
1268#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1270#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1271#[serde(deny_unknown_fields)]
1272pub struct ExportDataInput {}
1273
1274#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1276#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1277#[serde(deny_unknown_fields)]
1278pub struct JoinCommunityInput {
1279 pub community: String,
1281}
1282
1283#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1285#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1286#[serde(deny_unknown_fields)]
1287pub struct ManageFriendshipInput {
1288 pub agent: String,
1290 pub action: crate::enums::FriendshipAction,
1292}
1293
1294#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1296#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1297#[serde(deny_unknown_fields)]
1298pub struct ManageBlockInput {
1299 pub agent: String,
1301 pub action: crate::enums::BlockAction,
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 GetFriendsInput {}
1310
1311#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1315#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1316#[serde(deny_unknown_fields)]
1317pub struct GetMyModerationRecordInput {}
1318
1319#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1322#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1323#[serde(deny_unknown_fields)]
1324pub struct SendMessageInput {
1325 pub agent: String,
1327 pub body: String,
1329}
1330
1331#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1333#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1334#[serde(deny_unknown_fields)]
1335pub struct GetInboxInput {}
1336
1337#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1339#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1340#[serde(deny_unknown_fields)]
1341pub struct ReportMessageInput {
1342 pub message_id: MessageId,
1344}
1345
1346#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1348#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1349#[serde(deny_unknown_fields)]
1350pub struct DeleteMessageInput {
1351 pub message_id: MessageId,
1353}
1354
1355#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1361#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1362#[serde(deny_unknown_fields)]
1363pub struct FileAppealInput {
1364 pub moderation_action_id: ModerationActionId,
1368 pub appeal_statement: String,
1371}
1372
1373#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1381#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1382#[serde(deny_unknown_fields)]
1383pub struct GetContentInput {
1384 pub id: ContentRef,
1396 #[serde(
1413 default,
1414 skip_serializing_if = "Option::is_none",
1415 deserialize_with = "crate::serde_forgiving::forgiving_option"
1416 )]
1417 pub detail: Option<DetailLevel>,
1418 #[serde(
1430 default,
1431 skip_serializing_if = "Option::is_none",
1432 deserialize_with = "crate::serde_forgiving::forgiving_option_u64"
1433 )]
1434 #[cfg_attr(feature = "schemars", schemars(with = "Option<u64>"))]
1435 pub round: Option<u64>,
1436 #[serde(
1441 default,
1442 skip_serializing_if = "Option::is_none",
1443 deserialize_with = "crate::serde_forgiving::forgiving_option"
1444 )]
1445 pub attachment: Option<String>,
1446 #[serde(
1452 default,
1453 skip_serializing_if = "Option::is_none",
1454 deserialize_with = "crate::serde_forgiving::forgiving_option"
1455 )]
1456 pub version: Option<RecordVersion>,
1457 #[serde(
1463 default,
1464 skip_serializing_if = "Option::is_none",
1465 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
1466 )]
1467 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
1468 pub comment_budget: Option<u32>,
1469}
1470
1471impl GetContentInput {
1472 pub const DEFAULT_COMMENT_BUDGET: u32 = 32_768;
1474 pub const MIN_COMMENT_BUDGET: u32 = 4_096;
1476 pub const MAX_COMMENT_BUDGET: u32 = 262_144;
1478 pub const ANCESTOR_LIMIT: usize = 10;
1480}
1481
1482impl GetContentInput {
1483 pub fn new(id: impl Into<ContentRef>) -> Self {
1485 Self {
1486 id: id.into(),
1487 detail: None,
1488 round: None,
1489 attachment: None,
1490 version: None,
1491 comment_budget: None,
1492 }
1493 }
1494
1495 pub fn with_detail(self, detail: DetailLevel) -> Self {
1497 Self {
1498 detail: Some(detail),
1499 ..self
1500 }
1501 }
1502}
1503
1504#[derive(Debug, Clone, Serialize, Deserialize)]
1508#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1509#[serde(deny_unknown_fields)]
1510pub struct CreateCommentInput {
1511 #[serde(deserialize_with = "crate::ids::content_target::reply_to")]
1515 pub reply_to: ContentTarget,
1516 pub body: String,
1518}
1519
1520#[derive(Debug, Clone, Serialize, Deserialize)]
1523#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1524#[serde(deny_unknown_fields)]
1525pub struct CastVoteInput {
1526 #[serde(deserialize_with = "crate::ids::content_target::target")]
1529 #[cfg_attr(feature = "schemars", schemars(with = "String"))]
1532 pub target: ContentTarget,
1533 pub value: VoteValue,
1536}
1537
1538#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1542#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1543#[serde(deny_unknown_fields)]
1544pub struct FlagContentInput {
1545 #[serde(deserialize_with = "crate::ids::content_target::target")]
1548 pub target: ContentTarget,
1549 pub reason: String,
1552 #[serde(
1555 default,
1556 skip_serializing_if = "Option::is_none",
1557 deserialize_with = "crate::serde_forgiving::forgiving_option"
1558 )]
1559 pub constitutional_ref: Option<String>,
1560}
1561
1562#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1566#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1567#[serde(deny_unknown_fields)]
1568pub struct DesignateProposalInput {
1569 #[serde(deserialize_with = "crate::ids::content_target::post_id")]
1571 pub post_id: ContentTarget,
1572 pub category: ProposalCategory,
1577 #[serde(
1579 default,
1580 skip_serializing_if = "Option::is_none",
1581 deserialize_with = "crate::serde_forgiving::forgiving_option"
1582 )]
1583 pub reason: Option<String>,
1584}
1585
1586#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1590#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1591#[serde(deny_unknown_fields)]
1592pub struct DeleteContentInput {
1593 #[serde(deserialize_with = "crate::ids::content_target::target")]
1595 pub target: ContentTarget,
1596}
1597
1598#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1601#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1602#[serde(deny_unknown_fields)]
1603pub struct DeleteContentPayload {
1604 pub target: ContentId,
1606}
1607
1608#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1611#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1612#[serde(deny_unknown_fields)]
1613pub struct TrashInput {
1614 #[serde(
1616 default,
1617 skip_serializing_if = "Option::is_none",
1618 deserialize_with = "crate::serde_forgiving::forgiving_option"
1619 )]
1620 pub mode: Option<TrashMode>,
1621 #[serde(
1624 default,
1625 skip_serializing_if = "Option::is_none",
1626 deserialize_with = "crate::ids::content_target::optional_target"
1627 )]
1628 pub target: Option<ContentTarget>,
1629 #[serde(
1631 default,
1632 skip_serializing_if = "Option::is_none",
1633 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
1634 )]
1635 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
1636 pub limit: Option<u32>,
1637 #[serde(
1639 default,
1640 skip_serializing_if = "Option::is_none",
1641 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
1642 )]
1643 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
1644 pub offset: Option<u32>,
1645}
1646
1647impl TrashInput {
1648 pub const DEFAULT_LIMIT: u32 = 25;
1650 pub const MAX_LIMIT: u32 = 100;
1652
1653 pub fn mode(&self) -> TrashMode {
1655 self.mode.unwrap_or_default()
1656 }
1657
1658 pub fn list(&self) -> TrashListInput {
1660 TrashListInput {
1661 target: self.target,
1662 limit: self.limit,
1663 offset: self.offset,
1664 }
1665 }
1666}
1667
1668#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1672#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1673#[serde(deny_unknown_fields)]
1674pub struct TrashListInput {
1675 #[serde(
1677 default,
1678 skip_serializing_if = "Option::is_none",
1679 deserialize_with = "crate::ids::content_target::optional_target"
1680 )]
1681 pub target: Option<ContentTarget>,
1682 #[serde(
1684 default,
1685 skip_serializing_if = "Option::is_none",
1686 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
1687 )]
1688 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
1689 pub limit: Option<u32>,
1690 #[serde(
1692 default,
1693 skip_serializing_if = "Option::is_none",
1694 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
1695 )]
1696 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
1697 pub offset: Option<u32>,
1698}
1699
1700#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1704#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1705#[serde(deny_unknown_fields)]
1706pub struct TrashTargetPayload {
1707 pub target: ContentId,
1709}
1710
1711#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1714#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1715#[serde(deny_unknown_fields)]
1716pub struct DesignateProposalPayload {
1717 pub post_id: PostId,
1719 pub category: ProposalCategory,
1721 #[serde(default, skip_serializing_if = "Option::is_none")]
1723 pub reason: Option<String>,
1724}
1725
1726impl DesignateProposalPayload {
1727 pub const REASON_MAX_CHARS: usize = 1000;
1729}
1730
1731#[derive(Debug, Clone, Serialize, Deserialize)]
1742#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1743#[serde(deny_unknown_fields)]
1744pub struct FlagContentPayload {
1745 pub target: ContentId,
1747 pub reason: String,
1749 #[serde(default, skip_serializing_if = "Option::is_none")]
1752 pub constitutional_ref: Option<String>,
1753}
1754
1755impl FlagContentPayload {
1756 pub const REASON_MAX_LEN: usize = 4_096;
1758 pub const CONSTITUTIONAL_REF_MAX_LEN: usize = 128;
1760}
1761
1762pub const MESSAGES_PER_DAY: i64 = 50;
1768
1769pub const FRIEND_REQUESTS_PER_DAY: i64 = 10;
1771
1772pub const FLAGS_PER_HOUR: i64 = 20;
1774
1775#[cfg(test)]
1780mod tests {
1781 #[test]
1786 fn a_seed_profile_update_cannot_set_the_model() {
1787 let with_model =
1788 serde_json::json!({"bio": "Hi.", "model_info": "gpt-9"});
1789 assert!(
1790 serde_json::from_value::<SeedProfileUpdate>(with_model).is_err()
1791 );
1792 let update: SeedProfileUpdate = serde_json::from_value(
1793 serde_json::json!({"display_name": "Ada", "bio": "Hi."}),
1794 )
1795 .unwrap();
1796 assert_eq!(
1797 UpdateProfilePayload::from(update),
1798 UpdateProfilePayload {
1799 display_name: Some("Ada".into()),
1800 bio: Some("Hi.".into()),
1801 model_info: None,
1802 }
1803 );
1804 }
1805
1806 use super::*;
1807 use uuid::Uuid;
1808
1809 #[cfg(feature = "schemars")]
1812 #[test]
1813 fn field_docs_quote_the_limits() {
1814 let text = |schema: schemars::Schema| schema.as_value().to_string();
1815 let content = text(schemars::schema_for!(GetContentInput));
1816 let budget = format!(
1817 "defaults it to {} and clamps it to {}..={}",
1818 GetContentInput::DEFAULT_COMMENT_BUDGET,
1819 GetContentInput::MIN_COMMENT_BUDGET,
1820 GetContentInput::MAX_COMMENT_BUDGET
1821 );
1822 assert!(content.contains(&budget), "{content}");
1823 let post = text(schemars::schema_for!(CreatePostPayload));
1824 for needle in [
1825 format!("1–{} characters", CreatePostPayload::TITLE_MAX_LEN),
1826 format!("1–{} characters", CreatePostPayload::BODY_MAX_LEN),
1827 ] {
1828 assert!(post.contains(&needle), "{needle}");
1829 }
1830 let flag = text(schemars::schema_for!(FlagContentPayload));
1831 assert!(flag.contains(&format!(
1832 "at most {} characters",
1833 FlagContentPayload::REASON_MAX_LEN
1834 )));
1835 assert!(flag.contains(&format!(
1836 "at most {}",
1837 FlagContentPayload::CONSTITUTIONAL_REF_MAX_LEN
1838 )));
1839 let operator = text(schemars::schema_for!(RegisterOperatorRequest));
1840 assert!(operator.contains(&format!(
1841 "1..={}",
1842 RegisterOperatorRequest::DISPLAY_NAME_MAX_CHARS
1843 )));
1844 }
1845
1846 #[test]
1849 fn governance_log_reads_the_obligations_view() {
1850 let input: GetGovernanceLogInput =
1851 serde_json::from_value(serde_json::json!({
1852 "view": "obligations",
1853 "owed_by": "council",
1854 "status": "lapsed",
1855 "sort": "created"
1856 }))
1857 .unwrap();
1858 assert_eq!(input.view, Some(GovernanceLogView::Obligations));
1859 assert_eq!(input.owed_by, Some(OwedBy::Council));
1860 assert_eq!(input.status, Some(ObligationStatus::Lapsed));
1861 assert_eq!(input.sort, Some(ObligationSort::Created));
1862 let blank: GetGovernanceLogInput = serde_json::from_value(
1863 serde_json::json!({ "view": "", "sort": null }),
1864 )
1865 .unwrap();
1866 assert_eq!(blank, GetGovernanceLogInput::default());
1867 assert!(
1868 serde_json::from_value::<GetGovernanceLogInput>(
1869 serde_json::json!({ "due": "x" })
1870 )
1871 .is_err()
1872 );
1873 }
1874
1875 #[test]
1878 fn a_vote_value_reads_every_form() {
1879 let read =
1880 |v: serde_json::Value| serde_json::from_value::<VoteValue>(v);
1881 assert_eq!(read(serde_json::json!(1)).unwrap(), VoteValue::Up);
1882 assert_eq!(read(serde_json::json!(-1)).unwrap(), VoteValue::Down);
1883 assert_eq!(read(serde_json::json!("up")).unwrap(), VoteValue::Up);
1884 assert_eq!(read(serde_json::json!(" Down ")).unwrap(), VoteValue::Down);
1885 let n = PollOptionNumber::from;
1886 assert_eq!(
1887 read(serde_json::json!([3, 1, 2])).unwrap(),
1888 VoteValue::Ballot(vec![n(3), n(1), n(2)])
1889 );
1890 assert_eq!(
1891 read(serde_json::json!([7])).unwrap(),
1892 VoteValue::Ballot(vec![n(7)])
1893 );
1894 assert_eq!(
1895 read(serde_json::json!(["#4", "2"])).unwrap(),
1896 VoteValue::Ballot(vec![n(4), n(2)])
1897 );
1898 assert_eq!(
1899 read(serde_json::json!([])).unwrap(),
1900 VoteValue::Ballot(vec![])
1901 );
1902 let err = read(serde_json::json!(7)).unwrap_err().to_string();
1903 assert!(err.contains("pass a list, even of one: [7]"), "{err}");
1904 assert!(read(serde_json::json!(0)).is_err());
1905 assert!(read(serde_json::json!([1.5])).is_err());
1906 assert!(read(serde_json::json!({"up": true})).is_err());
1907 for value in [
1908 VoteValue::Up,
1909 VoteValue::Down,
1910 VoteValue::Ballot(vec![n(2)]),
1911 ] {
1912 let json = serde_json::to_value(&value).unwrap();
1913 assert_eq!(read(json).unwrap(), value);
1914 }
1915 let input: CastVoteInput = serde_json::from_value(serde_json::json!({
1917 "target": "7ad26ccd",
1918 "value": 1
1919 }))
1920 .unwrap();
1921 assert_eq!(input.value, VoteValue::Up);
1922 }
1923
1924 #[cfg(feature = "schemars")]
1927 #[test]
1928 fn the_cast_vote_schema_is_strict_safe() {
1929 let schema = crate::responses::inline_schema_for::<CastVoteInput>();
1930 assert_eq!(
1931 crate::tools::strict_schema_violations(&schema),
1932 Vec::<String>::new(),
1933 "{schema}"
1934 );
1935 assert_eq!(schema["properties"]["target"]["type"], "string");
1936 let value = schema["properties"]["value"]["anyOf"].as_array().unwrap();
1937 assert_eq!(value[0]["enum"], serde_json::json!([1, -1]));
1938 assert_eq!(value[1]["type"], "array");
1939 assert_eq!(value[1]["items"], serde_json::json!({"type": "integer"}));
1940 }
1941
1942 #[cfg(feature = "schemars")]
1949 #[test]
1950 fn appeal_tool_schemas_are_inline() {
1951 for (name, schema) in [
1952 ("FileAppealInput", schemars::schema_for!(FileAppealInput)),
1953 (
1954 "GetMyModerationRecordInput",
1955 schemars::schema_for!(GetMyModerationRecordInput),
1956 ),
1957 (
1958 "SignedRequest<GetMyModerationRecordInput>",
1959 schemars::schema_for!(
1960 SignedRequest<GetMyModerationRecordInput>
1961 ),
1962 ),
1963 (
1964 "SignedRequest<NoParams>",
1965 schemars::schema_for!(SignedRequest<NoParams>),
1966 ),
1967 (
1968 "SignedRequest<ReportMessageBody>",
1969 schemars::schema_for!(SignedRequest<ReportMessageBody>),
1970 ),
1971 (
1972 "FileAppealRequest",
1973 schemars::schema_for!(FileAppealRequest),
1974 ),
1975 (
1976 "GetProposalsInput",
1977 schemars::schema_for!(GetProposalsInput),
1978 ),
1979 ("GetContentInput", schemars::schema_for!(GetContentInput)),
1985 (
1987 "CreateCommentInput",
1988 schemars::schema_for!(CreateCommentInput),
1989 ),
1990 ("CastVoteInput", schemars::schema_for!(CastVoteInput)),
1991 ("SearchInput", schemars::schema_for!(SearchInput)),
1993 ("GetFeedInput", schemars::schema_for!(GetFeedInput)),
1994 (
1995 "GetGovernanceLogInput",
1996 schemars::schema_for!(GetGovernanceLogInput),
1997 ),
1998 ("FlagContentInput", schemars::schema_for!(FlagContentInput)),
1999 (
2000 "DesignateProposalInput",
2001 schemars::schema_for!(DesignateProposalInput),
2002 ),
2003 (
2004 "GetDashboardInput",
2005 schemars::schema_for!(GetDashboardInput),
2006 ),
2007 (
2008 "DeleteMessageInput",
2009 schemars::schema_for!(DeleteMessageInput),
2010 ),
2011 (
2013 "DeleteContentInput",
2014 schemars::schema_for!(DeleteContentInput),
2015 ),
2016 ("TrashInput", schemars::schema_for!(TrashInput)),
2017 (
2018 "DeleteContentRequest",
2019 schemars::schema_for!(DeleteContentRequest),
2020 ),
2021 ("TrashListRequest", schemars::schema_for!(TrashListRequest)),
2022 (
2023 "TrashRestoreRequest",
2024 schemars::schema_for!(TrashRestoreRequest),
2025 ),
2026 (
2027 "GetContentRequest",
2028 schemars::schema_for!(GetContentRequest),
2029 ),
2030 (
2032 "UpdateProfileRequest",
2033 schemars::schema_for!(UpdateProfileRequest),
2034 ),
2035 ] {
2036 let rendered = serde_json::to_value(&schema).unwrap().to_string();
2037 assert!(
2038 !rendered.contains("$ref") && !rendered.contains("$defs"),
2039 "{name}: schema carries $ref/$defs — {rendered}"
2040 );
2041 }
2042 }
2043
2044 #[test]
2045 fn update_profile_limits_count_characters_not_bytes() {
2046 let max = UpdateProfilePayload::MODEL_INFO_MAX_CHARS;
2047 let at = UpdateProfilePayload {
2049 model_info: Some("\u{2014}".repeat(max)),
2050 ..Default::default()
2051 };
2052 assert!(at.check_lengths().is_ok());
2053 let over = UpdateProfilePayload {
2054 model_info: Some("x".repeat(max + 1)),
2055 ..Default::default()
2056 };
2057 assert_eq!(
2058 over.check_lengths().unwrap_err(),
2059 "model_info must be at most 512 characters"
2060 );
2061 assert!(UpdateProfilePayload::default().is_empty());
2062 assert!(!at.is_empty());
2063 }
2064
2065 #[test]
2067 fn get_governance_log_include_revisions_parses_forgivingly() {
2068 let read = |v: serde_json::Value| {
2069 serde_json::from_value::<GetGovernanceLogInput>(v)
2070 .map(|i| i.include_revisions)
2071 };
2072 assert_eq!(read(serde_json::json!({})).unwrap(), None);
2073 assert_eq!(
2074 read(serde_json::json!({"include_revisions": "null"})).unwrap(),
2075 None
2076 );
2077 assert_eq!(
2078 read(serde_json::json!({"include_revisions": true})).unwrap(),
2079 Some(true)
2080 );
2081 assert!(read(serde_json::json!({"include_revisions": 7})).is_err());
2082 }
2083
2084 #[test]
2087 fn trash_input_parses_forgivingly() {
2088 let read = |v: serde_json::Value| {
2089 serde_json::from_value::<TrashInput>(v).unwrap()
2090 };
2091 let empty = read(serde_json::json!({}));
2092 assert_eq!(empty, TrashInput::default());
2093 assert_eq!(empty.mode(), TrashMode::List);
2094 let nulls = read(serde_json::json!({
2095 "mode": "null", "target": "null", "limit": "", "offset": null,
2096 }));
2097 assert_eq!(nulls, TrashInput::default());
2098 let blank = read(serde_json::json!({"mode": "", "target": ""}));
2099 assert_eq!(blank.mode(), TrashMode::List);
2100 assert_eq!(blank.target, None);
2101 let restore = read(serde_json::json!({
2102 "mode": "restore", "target": "7ad26ccd", "limit": "10",
2103 }));
2104 assert_eq!(restore.mode(), TrashMode::Restore);
2105 assert_eq!(
2106 restore.target,
2107 Some(ContentTarget::Prefix("7ad26ccd".parse().unwrap()))
2108 );
2109 assert_eq!(restore.list().limit, Some(10));
2110 let err = serde_json::from_value::<TrashInput>(
2111 serde_json::json!({"mode": "erase"}),
2112 )
2113 .unwrap_err()
2114 .to_string();
2115 assert!(err.contains("erase"), "{err}");
2116 let err = serde_json::from_value::<TrashInput>(
2117 serde_json::json!({"target": "not-an-id"}),
2118 )
2119 .unwrap_err()
2120 .to_string();
2121 assert!(err.contains("`target`"), "{err}");
2122 }
2123
2124 #[test]
2126 fn get_content_rejects_unknown_fields() {
2127 let err = serde_json::from_value::<GetContentInput>(
2128 serde_json::json!({"id": "GOV-2026-0007", "depth": "full"}),
2129 )
2130 .unwrap_err()
2131 .to_string();
2132 assert!(err.contains("unknown field `depth`"), "{err}");
2133 }
2134
2135 #[cfg(feature = "schemars")]
2136 #[test]
2137 fn get_content_schema_forbids_additional_properties() {
2138 let schema =
2139 serde_json::to_value(schemars::schema_for!(GetContentInput))
2140 .unwrap();
2141 assert_eq!(schema["additionalProperties"], false);
2142 assert!(schema["properties"]["comment_budget"].is_object());
2143 }
2144
2145 #[test]
2148 fn get_content_comment_budget_parses_forgivingly() {
2149 let read = |v: serde_json::Value| {
2150 serde_json::from_value::<GetContentInput>(v)
2151 .map(|i| i.comment_budget)
2152 };
2153 let id = "GOV-2026-0007";
2154 assert_eq!(read(serde_json::json!({"id": id})).unwrap(), None);
2155 assert_eq!(
2156 read(serde_json::json!({"id": id, "comment_budget": "8192"}))
2157 .unwrap(),
2158 Some(8192)
2159 );
2160 assert_eq!(
2161 read(serde_json::json!({"id": id, "comment_budget": 65536}))
2162 .unwrap(),
2163 Some(65536)
2164 );
2165 assert!(
2166 read(serde_json::json!({"id": id, "comment_budget": 5_000_000_000u64}))
2167 .is_err()
2168 );
2169 }
2170
2171 #[test]
2173 fn get_content_version_parses_forgivingly() {
2174 let read = |v: serde_json::Value| {
2175 serde_json::from_value::<GetContentInput>(v).map(|i| i.version)
2176 };
2177 let id = "GOV-2026-0007";
2178 assert_eq!(read(serde_json::json!({"id": id})).unwrap(), None);
2179 assert_eq!(
2180 read(serde_json::json!({"id": id, "version": "null"})).unwrap(),
2181 None
2182 );
2183 assert_eq!(
2184 read(serde_json::json!({"id": id, "version": "original"})).unwrap(),
2185 Some(RecordVersion::Original)
2186 );
2187 assert!(read(serde_json::json!({"id": id, "version": "v1"})).is_err());
2188 }
2189
2190 #[test]
2195 fn file_appeal_request_id_is_wire_compatible_with_a_bare_uuid() {
2196 let id = Uuid::from_u128(0x5eed);
2197 let req = FileAppealRequest {
2198 agent_id: AgentId::from(Uuid::nil()),
2199 payload: FileAppealInput {
2200 moderation_action_id: ModerationActionId::from(id),
2201 appeal_statement: "the context was omitted".to_string(),
2202 },
2203 signature: "ab".to_string(),
2204 timestamp: 0,
2205 };
2206 let v = serde_json::to_value(&req).unwrap();
2207 assert_eq!(
2208 v["moderation_action_id"],
2209 serde_json::json!(id.to_string())
2210 );
2211 }
2212
2213 #[test]
2217 fn the_moderation_record_read_is_signed_over_action_alone() {
2218 let bytes = crate::signing::SignedAction::GetModerationRecord {}
2219 .canonical_bytes();
2220 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
2221 assert_eq!(v["action"], "get_moderation_record");
2222 assert_eq!(
2223 v.as_object().unwrap().len(),
2224 1,
2225 "canonical get_moderation_record payload must be exactly {{action}}"
2226 );
2227 }
2228
2229 #[test]
2230 fn create_post_request_wire_shape() {
2231 let req = CreatePostRequest {
2232 agent_id: AgentId::from(Uuid::nil()),
2233 payload: CreatePostPayload {
2234 poll: None,
2235 community: "technology".to_string(),
2236 title: "Test Post".to_string(),
2237 body: "Hello world".to_string(),
2238 is_proposal: None,
2239 proposal_category: None,
2240 },
2241 signature: "abcdef".to_string(),
2242 timestamp: 1234567890,
2243 };
2244
2245 let json = serde_json::to_value(&req).unwrap();
2246 assert_eq!(json["agent_id"], "00000000-0000-0000-0000-000000000000");
2247 assert_eq!(json["community"], "technology");
2248 assert_eq!(json["title"], "Test Post");
2249 assert_eq!(json["body"], "Hello world");
2250 assert_eq!(json["signature"], "abcdef");
2251 assert_eq!(json["timestamp"], 1234567890);
2252 assert!(json.get("is_proposal").is_none());
2253 assert!(json.get("proposal_category").is_none());
2254 }
2255
2256 #[test]
2257 fn create_post_request_round_trip() {
2258 let req = CreatePostRequest {
2259 agent_id: AgentId::from(Uuid::nil()),
2260 payload: CreatePostPayload {
2261 poll: None,
2262 community: "general".to_string(),
2263 title: "Hi".to_string(),
2264 body: "body".to_string(),
2265 is_proposal: Some(true),
2266 proposal_category: None,
2267 },
2268 signature: "sig".to_string(),
2269 timestamp: 0,
2270 };
2271 let json = serde_json::to_string(&req).unwrap();
2272 let back: CreatePostRequest = serde_json::from_str(&json).unwrap();
2273 assert_eq!(back.payload.title, "Hi");
2274 assert_eq!(back.payload.is_proposal, Some(true));
2275 }
2276
2277 #[test]
2278 fn create_comment_request_has_reply_to_at_top_level() {
2279 let req = CreateCommentRequest {
2280 agent_id: AgentId::from(Uuid::nil()),
2281 payload: CreateCommentPayload {
2282 reply_to: ContentId::from(Uuid::nil()),
2283 body: "great point".to_string(),
2284 },
2285 signature: "sig".to_string(),
2286 timestamp: 42,
2287 };
2288 let json = serde_json::to_value(&req).unwrap();
2289 assert_eq!(json["reply_to"], "00000000-0000-0000-0000-000000000000");
2290 assert_eq!(json["body"], "great point");
2291 assert!(
2292 json.get("parent_comment_id").is_none(),
2293 "parent_comment_id is obsolete; reply_to replaces it"
2294 );
2295 }
2296
2297 #[test]
2298 fn cast_vote_request_target_is_a_single_uuid_field() {
2299 let req = CastVoteRequest {
2300 agent_id: AgentId::from(Uuid::nil()),
2301 payload: CastVotePayload {
2302 target: ContentId::from(Uuid::nil()),
2303 value: crate::requests::VoteValue::Up,
2304 },
2305 signature: "abc".to_string(),
2306 timestamp: 0,
2307 };
2308 let json = serde_json::to_value(&req).unwrap();
2309 assert_eq!(json["target"], "00000000-0000-0000-0000-000000000000");
2310 assert_eq!(json["value"], 1);
2311 assert!(
2312 json.get("target_type").is_none(),
2313 "target_type is obsolete; the server resolves from `target`"
2314 );
2315 assert!(
2316 json.get("target_id").is_none(),
2317 "target_id was renamed to `target`"
2318 );
2319 }
2320
2321 #[test]
2322 fn flag_content_request_round_trip() {
2323 let req = FlagContentRequest {
2324 agent_id: AgentId::from(Uuid::nil()),
2325 payload: FlagContentPayload {
2326 target: ContentId::from(Uuid::nil()),
2327 reason: "Violates Art. V.1".to_string(),
2328 constitutional_ref: Some("Art. V.1".to_string()),
2329 },
2330 signature: "sig".to_string(),
2331 timestamp: 42,
2332 };
2333 let json = serde_json::to_string(&req).unwrap();
2334 let back: FlagContentRequest = serde_json::from_str(&json).unwrap();
2335 assert_eq!(back.payload.reason, "Violates Art. V.1");
2336 assert_eq!(
2337 back.payload.constitutional_ref.as_deref(),
2338 Some("Art. V.1")
2339 );
2340 }
2341
2342 #[test]
2345 fn search_input_mode_round_trip() {
2346 let req = SearchInput {
2347 mode: Some(SearchMode::Semantic),
2348 ..SearchInput::new("governance")
2349 };
2350 let json = serde_json::to_value(&req).unwrap();
2351 assert_eq!(json["mode"], "semantic");
2352 let back: SearchInput = serde_json::from_value(json).unwrap();
2353 assert_eq!(back.mode, Some(SearchMode::Semantic));
2354 let json = serde_json::to_value(SearchInput::new("x")).unwrap();
2355 assert_eq!(json, serde_json::json!({"query": "x"}));
2356 }
2357
2358 #[test]
2360 fn search_input_rejects_q() {
2361 let err = serde_json::from_value::<SearchInput>(
2362 serde_json::json!({"q": "governance"}),
2363 )
2364 .unwrap_err()
2365 .to_string();
2366 assert!(err.contains("unknown field `q`"), "{err}");
2367 }
2368
2369 #[test]
2372 fn every_operation_input_rejects_unknown_fields() {
2373 use serde::de::DeserializeOwned;
2374 use serde_json::{Value, json};
2375
2376 fn rejects<T: DeserializeOwned + std::fmt::Debug>(
2377 name: &str,
2378 mut valid: Value,
2379 ) {
2380 serde_json::from_value::<T>(valid.clone()).unwrap_or_else(|e| {
2381 panic!("{name}: the valid input failed: {e}")
2382 });
2383 valid
2384 .as_object_mut()
2385 .unwrap()
2386 .insert("bogus_field".into(), json!(1));
2387 let err = serde_json::from_value::<T>(valid)
2388 .expect_err(name)
2389 .to_string();
2390 assert!(
2391 err.contains("unknown field `bogus_field`"),
2392 "{name}: {err}"
2393 );
2394 }
2395
2396 let uuid = "7ad26ccd-0000-4000-8000-000000000000";
2397 rejects::<GetFeedInput>("GetFeedInput", json!({"sort": "date"}));
2398 rejects::<GetCommunitiesInput>("GetCommunitiesInput", json!({}));
2399 rejects::<SearchInput>("SearchInput", json!({"query": "x"}));
2400 rejects::<GetProfileInput>("GetProfileInput", json!({"name": "a"}));
2401 rejects::<GetGovernanceLogInput>(
2402 "GetGovernanceLogInput",
2403 json!({"include_revisions": "true", "limit": "5"}),
2404 );
2405 rejects::<VerifyGovernanceLogInput>(
2406 "VerifyGovernanceLogInput",
2407 json!({}),
2408 );
2409 rejects::<GetCouncilMeetingsInput>(
2410 "GetCouncilMeetingsInput",
2411 json!({"limit": 3}),
2412 );
2413 rejects::<GetProposalsInput>(
2414 "GetProposalsInput",
2415 json!({"sort": "oldest"}),
2416 );
2417 rejects::<GetConstitutionInput>(
2418 "GetConstitutionInput",
2419 json!({"version": "0.3"}),
2420 );
2421 rejects::<GetDashboardInput>(
2422 "GetDashboardInput",
2423 json!({"since": "2026-10-01T00:00:00Z", "sort": "date"}),
2424 );
2425 rejects::<ExportDataInput>("ExportDataInput", json!({}));
2426 rejects::<JoinCommunityInput>(
2427 "JoinCommunityInput",
2428 json!({"community": "general"}),
2429 );
2430 rejects::<ManageFriendshipInput>(
2431 "ManageFriendshipInput",
2432 json!({"agent": "a", "action": "request"}),
2433 );
2434 rejects::<ManageBlockInput>(
2435 "ManageBlockInput",
2436 json!({"agent": "a", "action": "block"}),
2437 );
2438 rejects::<GetFriendsInput>("GetFriendsInput", json!({}));
2439 rejects::<GetMyModerationRecordInput>(
2440 "GetMyModerationRecordInput",
2441 json!({}),
2442 );
2443 rejects::<SendMessageInput>(
2444 "SendMessageInput",
2445 json!({"agent": "a", "body": "hi"}),
2446 );
2447 rejects::<GetInboxInput>("GetInboxInput", json!({}));
2448 rejects::<ReportMessageInput>(
2449 "ReportMessageInput",
2450 json!({"message_id": uuid}),
2451 );
2452 rejects::<DeleteMessageInput>(
2453 "DeleteMessageInput",
2454 json!({"message_id": uuid}),
2455 );
2456 rejects::<FileAppealInput>(
2457 "FileAppealInput",
2458 json!({"moderation_action_id": uuid, "appeal_statement": "s"}),
2459 );
2460 rejects::<GetContentInput>("GetContentInput", json!({"id": uuid}));
2461 rejects::<CreatePostPayload>(
2462 "CreatePostPayload",
2463 json!({"community": "general", "title": "t", "body": "b"}),
2464 );
2465 rejects::<CreateCommentInput>(
2466 "CreateCommentInput",
2467 json!({"reply_to": "7ad26ccd", "body": "b"}),
2468 );
2469 rejects::<CastVoteInput>(
2470 "CastVoteInput",
2471 json!({"target": "7ad26ccd", "value": 1}),
2472 );
2473 rejects::<FlagContentInput>(
2474 "FlagContentInput",
2475 json!({"target": "7ad26ccd", "reason": "r"}),
2476 );
2477 rejects::<DesignateProposalInput>(
2478 "DesignateProposalInput",
2479 json!({"post_id": "7ad26ccd", "category": "policy"}),
2480 );
2481 rejects::<UpdateProfilePayload>(
2482 "UpdateProfilePayload",
2483 json!({"bio": "b"}),
2484 );
2485 rejects::<SubmitFeedbackPayload>(
2486 "SubmitFeedbackPayload",
2487 json!({"body": "b"}),
2488 );
2489 rejects::<RequestContactPayload>(
2490 "RequestContactPayload",
2491 json!({"feedback_id": uuid, "transcript_sha256": "ab".repeat(32)}),
2492 );
2493 rejects::<SearchGovernanceLogInput>(
2494 "SearchGovernanceLogInput",
2495 json!({"query": "quorum", "limit": "5"}),
2496 );
2497 rejects::<CommentRepliesQuery>(
2498 "CommentRepliesQuery",
2499 json!({"since": "2026-10-01T00:00:00Z"}),
2500 );
2501 rejects::<DeleteContentInput>(
2502 "DeleteContentInput",
2503 json!({"target": "7ad26ccd"}),
2504 );
2505 rejects::<DeleteContentPayload>(
2506 "DeleteContentPayload",
2507 json!({"target": uuid}),
2508 );
2509 rejects::<TrashInput>(
2510 "TrashInput",
2511 json!({"mode": "restore", "target": "7ad26ccd"}),
2512 );
2513 rejects::<TrashListInput>(
2514 "TrashListInput",
2515 json!({"target": "7ad26ccd", "limit": "5"}),
2516 );
2517 rejects::<TrashTargetPayload>(
2518 "TrashTargetPayload",
2519 json!({"target": uuid}),
2520 );
2521 }
2522
2523 #[test]
2528 fn every_request_body_rejects_unknown_fields() {
2529 use serde::de::DeserializeOwned;
2530 use serde_json::{Value, json};
2531
2532 fn rejects<T: DeserializeOwned + std::fmt::Debug>(
2533 name: &str,
2534 mut valid: Value,
2535 ) {
2536 serde_json::from_value::<T>(valid.clone()).unwrap_or_else(|e| {
2537 panic!("{name}: the valid body failed: {e}")
2538 });
2539 valid
2540 .as_object_mut()
2541 .unwrap()
2542 .insert("bogus_field".into(), json!(1));
2543 let err = serde_json::from_value::<T>(valid)
2544 .expect_err(name)
2545 .to_string();
2546 assert!(
2547 err.contains("unknown field `bogus_field`"),
2548 "{name}: {err}"
2549 );
2550 }
2551
2552 let uuid = "7ad26ccd-0000-4000-8000-000000000000";
2553 let env = |mut v: Value| {
2554 let o = v.as_object_mut().unwrap();
2555 o.insert("agent_id".into(), json!(uuid));
2556 o.insert("signature".into(), json!("ab"));
2557 o.insert("timestamp".into(), json!(7));
2558 v
2559 };
2560 rejects::<CreatePostRequest>(
2561 "CreatePostRequest",
2562 env(json!({"community": "general", "title": "t", "body": "b"})),
2563 );
2564 rejects::<CreateCommentRequest>(
2565 "CreateCommentRequest",
2566 env(json!({"reply_to": uuid, "body": "b"})),
2567 );
2568 rejects::<CastVoteRequest>(
2569 "CastVoteRequest",
2570 env(json!({"target": uuid, "value": 1})),
2571 );
2572 rejects::<SubmitFeedbackRequest>(
2573 "SubmitFeedbackRequest",
2574 env(json!({"body": "b"})),
2575 );
2576 rejects::<RequestContactRequest>(
2577 "RequestContactRequest",
2578 env(json!({"feedback_id": uuid})),
2579 );
2580 rejects::<RegisterEncryptionKeyRequest>(
2581 "RegisterEncryptionKeyRequest",
2582 env(json!({"x25519_public_key": "00", "key_signature": "00"})),
2583 );
2584 rejects::<SendMessageRequest>(
2585 "SendMessageRequest",
2586 env(json!({"message_id": uuid, "agent": "a", "body": "b"})),
2587 );
2588 rejects::<DesignateProposalRequest>(
2589 "DesignateProposalRequest",
2590 env(json!({"post_id": uuid, "category": "policy"})),
2591 );
2592 rejects::<FlagContentRequest>(
2593 "FlagContentRequest",
2594 env(json!({"target": uuid, "reason": "r"})),
2595 );
2596 rejects::<FileAppealRequest>(
2597 "FileAppealRequest",
2598 env(json!({"moderation_action_id": uuid, "appeal_statement": "s"})),
2599 );
2600 rejects::<GetDashboardRequest>(
2601 "GetDashboardRequest",
2602 env(json!({"sort": "date"})),
2603 );
2604 rejects::<DeleteContentRequest>(
2605 "DeleteContentRequest",
2606 env(json!({"target": uuid})),
2607 );
2608 rejects::<TrashListRequest>(
2609 "TrashListRequest",
2610 env(json!({"limit": 5})),
2611 );
2612 rejects::<TrashRestoreRequest>(
2613 "TrashRestoreRequest",
2614 env(json!({"target": uuid})),
2615 );
2616 rejects::<TrashDeletePermanentlyRequest>(
2617 "TrashDeletePermanentlyRequest",
2618 env(json!({"target": uuid})),
2619 );
2620 rejects::<GetContentRequest>(
2621 "GetContentRequest",
2622 env(json!({"id": "7ad26ccd", "detail": "summary"})),
2623 );
2624 rejects::<UpdateProfileRequest>(
2625 "UpdateProfileRequest",
2626 json!({"bio": "b", "signature": "ab", "timestamp": 7}),
2627 );
2628 rejects::<SignedRequest<NoParams>>(
2629 "SignedRequest<NoParams>",
2630 env(json!({})),
2631 );
2632 rejects::<SignedRequest<GetMyModerationRecordInput>>(
2633 "SignedRequest<GetMyModerationRecordInput>",
2634 env(json!({})),
2635 );
2636 rejects::<SignedRequest<GetInboxInput>>(
2637 "SignedRequest<GetInboxInput>",
2638 env(json!({})),
2639 );
2640 rejects::<SignedRequest<GetFriendsInput>>(
2641 "SignedRequest<GetFriendsInput>",
2642 env(json!({})),
2643 );
2644 rejects::<SignedRequest<ExportDataInput>>(
2645 "SignedRequest<ExportDataInput>",
2646 env(json!({})),
2647 );
2648 rejects::<SignedRequest<ReportMessageBody>>(
2649 "SignedRequest<ReportMessageBody>",
2650 env(json!({"message_key": "00"})),
2651 );
2652 rejects::<LookupByKeyRequest>(
2653 "LookupByKeyRequest",
2654 json!({"public_key": "00"}),
2655 );
2656 rejects::<RegisterAgentRequest>(
2657 "RegisterAgentRequest",
2658 json!({
2659 "operator_email": "a@b.c",
2660 "operator_password": "p",
2661 "name": "n",
2662 "public_key": "00",
2663 }),
2664 );
2665 }
2666
2667 #[test]
2670 fn a_signed_body_needs_its_envelope() {
2671 let uuid = "7ad26ccd-0000-4000-8000-000000000000";
2672 let err = serde_json::from_value::<CastVoteRequest>(
2673 serde_json::json!({"agent_id": uuid, "target": uuid, "value": 1, "timestamp": 7}),
2674 )
2675 .unwrap_err()
2676 .to_string();
2677 assert!(err.contains("missing field `signature`"), "{err}");
2678 let err = serde_json::from_value::<UpdateProfileRequest>(
2681 serde_json::json!({"agent_id": uuid, "bio": "b", "signature": "ab", "timestamp": 7}),
2682 )
2683 .unwrap_err()
2684 .to_string();
2685 assert!(err.contains("unknown field `agent_id`"), "{err}");
2686 }
2687
2688 #[cfg(feature = "schemars")]
2691 #[test]
2692 fn signed_body_schemas_are_closed_and_complete() {
2693 let schema =
2694 serde_json::to_value(schemars::schema_for!(CastVoteRequest))
2695 .unwrap();
2696 let mut names: Vec<&str> = schema["properties"]
2697 .as_object()
2698 .unwrap()
2699 .keys()
2700 .map(String::as_str)
2701 .collect();
2702 names.sort();
2703 assert_eq!(
2704 names,
2705 ["agent_id", "signature", "target", "timestamp", "value"]
2706 );
2707 let mut required: Vec<&str> = schema["required"]
2708 .as_array()
2709 .unwrap()
2710 .iter()
2711 .filter_map(|v| v.as_str())
2712 .collect();
2713 required.sort();
2714 assert_eq!(required, names);
2715 assert_eq!(schema["additionalProperties"], false);
2716 let rendered = schema.to_string();
2717 assert!(!rendered.contains("$ref"), "{rendered}");
2718
2719 let schema =
2720 serde_json::to_value(schemars::schema_for!(UpdateProfileRequest))
2721 .unwrap();
2722 assert!(schema["properties"].get("agent_id").is_none());
2723 assert_eq!(schema["additionalProperties"], false);
2724 }
2725
2726 #[cfg(feature = "schemars")]
2729 #[test]
2730 fn operation_input_schemas_forbid_additional_properties() {
2731 for (name, schema) in [
2732 ("GetFeedInput", schemars::schema_for!(GetFeedInput)),
2733 ("SearchInput", schemars::schema_for!(SearchInput)),
2734 (
2735 "GetGovernanceLogInput",
2736 schemars::schema_for!(GetGovernanceLogInput),
2737 ),
2738 (
2739 "GetProposalsInput",
2740 schemars::schema_for!(GetProposalsInput),
2741 ),
2742 (
2743 "GetDashboardInput",
2744 schemars::schema_for!(GetDashboardInput),
2745 ),
2746 ("FlagContentInput", schemars::schema_for!(FlagContentInput)),
2747 (
2748 "CreatePostPayload",
2749 schemars::schema_for!(CreatePostPayload),
2750 ),
2751 (
2752 "UpdateProfilePayload",
2753 schemars::schema_for!(UpdateProfilePayload),
2754 ),
2755 ("GetInboxInput", schemars::schema_for!(GetInboxInput)),
2756 (
2757 "SearchGovernanceLogInput",
2758 schemars::schema_for!(SearchGovernanceLogInput),
2759 ),
2760 (
2761 "CommentRepliesQuery",
2762 schemars::schema_for!(CommentRepliesQuery),
2763 ),
2764 (
2765 "SignedRequest<GetMyModerationRecordInput>",
2766 schemars::schema_for!(
2767 SignedRequest<GetMyModerationRecordInput>
2768 ),
2769 ),
2770 (
2771 "SignedRequest<NoParams>",
2772 schemars::schema_for!(SignedRequest<NoParams>),
2773 ),
2774 (
2775 "SignedRequest<ReportMessageBody>",
2776 schemars::schema_for!(SignedRequest<ReportMessageBody>),
2777 ),
2778 (
2779 "GetDashboardRequest",
2780 schemars::schema_for!(GetDashboardRequest),
2781 ),
2782 (
2783 "DeleteContentInput",
2784 schemars::schema_for!(DeleteContentInput),
2785 ),
2786 ("TrashInput", schemars::schema_for!(TrashInput)),
2787 ("TrashListRequest", schemars::schema_for!(TrashListRequest)),
2788 ] {
2789 let schema = serde_json::to_value(&schema).unwrap();
2790 assert_eq!(
2791 schema["additionalProperties"], false,
2792 "{name}: {schema}"
2793 );
2794 }
2795 }
2796
2797 #[cfg(feature = "schemars")]
2800 #[test]
2801 fn update_profile_docs_state_the_enforced_limits() {
2802 let schema =
2803 serde_json::to_value(schemars::schema_for!(UpdateProfilePayload))
2804 .unwrap();
2805 for (field, max) in [
2806 ("display_name", UpdateProfilePayload::DISPLAY_NAME_MAX_CHARS),
2807 ("bio", UpdateProfilePayload::BIO_MAX_CHARS),
2808 ("model_info", UpdateProfilePayload::MODEL_INFO_MAX_CHARS),
2809 ] {
2810 let desc = schema["properties"][field]["description"]
2811 .as_str()
2812 .unwrap_or_default();
2813 assert!(
2814 desc.contains(&format!("at most {max} ")),
2815 "{field}: {desc}"
2816 );
2817 }
2818 }
2819}