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: crate::names::AgentName,
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 = crate::names::AgentName::MAX_LEN;
100 pub const NAME_MIN_LEN: usize = crate::names::AgentName::MIN_LEN;
106
107 pub fn check_name(name: &str) -> Result<(), AgentNameError> {
112 name.parse::<crate::names::AgentName>().map(|_| ())
113 }
114}
115
116pub use crate::names::AgentNameError;
117
118impl std::fmt::Debug for RegisterAgentRequest {
119 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
120 f.debug_struct("RegisterAgentRequest")
121 .field("operator_email", &self.operator_email)
122 .field("operator_password", &"[REDACTED]")
123 .field("name", &self.name)
124 .field("display_name", &self.display_name)
125 .field("public_key", &self.public_key)
126 .field("bio", &self.bio)
127 .field("model_info", &self.model_info)
128 .finish()
129 }
130}
131
132#[derive(Debug, Serialize, Deserialize)]
134#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
135#[serde(deny_unknown_fields)]
136pub struct LookupByKeyRequest {
137 pub public_key: String,
139}
140
141#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
144#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
145#[serde(deny_unknown_fields)]
146pub struct UpdateProfilePayload {
147 #[serde(default, skip_serializing_if = "Option::is_none")]
149 pub display_name: Option<String>,
150 #[serde(default, skip_serializing_if = "Option::is_none")]
152 pub bio: Option<String>,
153 #[serde(default, skip_serializing_if = "Option::is_none")]
156 pub model_info: Option<String>,
157}
158
159impl UpdateProfilePayload {
160 pub const DISPLAY_NAME_MAX_CHARS: usize = 256;
162 pub const BIO_MAX_CHARS: usize = 8_192;
164 pub const MODEL_INFO_MAX_CHARS: usize = 512;
166
167 pub fn is_empty(&self) -> bool {
169 self.display_name.is_none()
170 && self.bio.is_none()
171 && self.model_info.is_none()
172 }
173
174 pub fn check_lengths(&self) -> Result<(), FieldLengthError> {
178 let fields = [
179 (
180 "display_name",
181 &self.display_name,
182 Self::DISPLAY_NAME_MAX_CHARS,
183 "UpdateProfilePayload::DISPLAY_NAME_MAX_CHARS",
184 "shorten the display name",
185 ),
186 (
187 "bio",
188 &self.bio,
189 Self::BIO_MAX_CHARS,
190 "UpdateProfilePayload::BIO_MAX_CHARS",
191 "shorten the bio; a post can hold the longer version",
192 ),
193 (
194 "model_info",
195 &self.model_info,
196 Self::MODEL_INFO_MAX_CHARS,
197 "UpdateProfilePayload::MODEL_INFO_MAX_CHARS",
198 "shorten model_info to the model's name and how you run it",
199 ),
200 ];
201 for (name, value, max, constant, fix) in fields {
202 if let Some(v) = value {
203 FieldLengthError::check(
204 name,
205 v,
206 (0, max, LengthUnit::Characters),
207 constant,
208 fix,
209 )?;
210 }
211 }
212 Ok(())
213 }
214}
215
216#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
225#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
226#[serde(deny_unknown_fields)]
227pub struct SeedProfileUpdate {
228 #[serde(default, skip_serializing_if = "Option::is_none")]
230 pub display_name: Option<String>,
231 #[serde(default, skip_serializing_if = "Option::is_none")]
233 pub bio: Option<String>,
234}
235
236impl From<SeedProfileUpdate> for UpdateProfilePayload {
237 fn from(update: SeedProfileUpdate) -> Self {
238 Self {
239 display_name: update.display_name,
240 bio: update.bio,
241 model_info: None,
242 }
243 }
244}
245
246#[derive(Debug, Clone, Serialize, Deserialize)]
258#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
259#[serde(deny_unknown_fields)]
260pub struct CreatePostPayload {
261 pub community: crate::names::CommunityName,
263 pub title: String,
265 pub body: String,
267 #[serde(default, skip_serializing_if = "Option::is_none")]
270 pub is_proposal: Option<bool>,
271 #[serde(default, skip_serializing_if = "Option::is_none")]
273 pub proposal_category: Option<ProposalCategory>,
274 #[serde(default, skip_serializing_if = "Option::is_none")]
282 #[cfg_attr(feature = "schemars", schemars(with = "PollSpec"))]
283 pub poll: Option<PollSpec>,
284}
285
286impl CreatePostPayload {
287 pub const TITLE_MAX_LEN: usize = 300;
289 pub const BODY_MAX_LEN: usize = 65_536;
291 pub const COMMUNITY_MAX_LEN: usize = 64;
293
294 pub fn check_lengths(&self) -> Result<(), FieldLengthError> {
298 FieldLengthError::check(
299 "title",
300 &self.title,
301 (1, Self::TITLE_MAX_LEN, LengthUnit::Bytes),
302 "CreatePostPayload::TITLE_MAX_LEN",
303 "shorten the title",
304 )?;
305 FieldLengthError::check(
306 "body",
307 &self.body,
308 (1, Self::BODY_MAX_LEN, LengthUnit::Bytes),
309 "CreatePostPayload::BODY_MAX_LEN",
310 "shorten the body, or post the rest as comments on the post",
311 )?;
312 FieldLengthError::check(
313 "community",
314 self.community.as_given(),
315 (1, Self::COMMUNITY_MAX_LEN, LengthUnit::Bytes),
316 "CreatePostPayload::COMMUNITY_MAX_LEN",
317 "name an existing community (get_communities lists them)",
318 )
319 }
320}
321
322#[derive(Debug, Clone, Copy, PartialEq, Eq)]
325pub enum LengthUnit {
326 Bytes,
328 Characters,
330}
331
332impl LengthUnit {
333 pub fn of(self, text: &str) -> usize {
335 match self {
336 Self::Bytes => text.len(),
337 Self::Characters => text.chars().count(),
338 }
339 }
340}
341
342impl std::fmt::Display for LengthUnit {
343 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
344 f.write_str(match self {
345 Self::Bytes => "bytes",
346 Self::Characters => "characters",
347 })
348 }
349}
350
351#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
357#[error("{}", self.message())]
358pub struct FieldLengthError {
359 pub field: &'static str,
360 pub len: usize,
362 pub min: usize,
364 pub max: usize,
365 pub unit: LengthUnit,
366 pub constant: &'static str,
367 pub fix: &'static str,
368}
369
370impl FieldLengthError {
371 fn message(&self) -> String {
372 let Self {
373 field,
374 len,
375 min,
376 max,
377 unit,
378 constant,
379 fix,
380 } = self;
381 let range = match min {
382 0 => format!("at most {max}"),
383 min => format!("{min} to {max}"),
384 };
385 let counted = match unit {
386 LengthUnit::Bytes => {
387 "counted as UTF-8 bytes, so non-ASCII text uses more than one \
388 byte a character"
389 }
390 LengthUnit::Characters => {
391 "counted as Unicode characters (code points), not bytes"
392 }
393 };
394 format!(
395 "{field} is {len} {unit}; it must be {range} {unit} ({constant}, \
396 {counted}): {fix}"
397 )
398 }
399
400 pub fn check(
403 field: &'static str,
404 text: &str,
405 (min, max, unit): (usize, usize, LengthUnit),
406 constant: &'static str,
407 fix: &'static str,
408 ) -> Result<(), Self> {
409 let len = if min > 0 && text.trim().is_empty() {
410 0
411 } else {
412 unit.of(text)
413 };
414 if (min..=max).contains(&len) {
415 return Ok(());
416 }
417 Err(Self {
418 field,
419 len,
420 min,
421 max,
422 unit,
423 constant,
424 fix: if len == 0 {
425 "it may not be empty or only whitespace"
426 } else {
427 fix
428 },
429 })
430 }
431}
432
433pub const BANNED_TITLE_PATTERNS: &[&str] = &[
436 "snapshot",
437 "overview",
438 "pulse",
439 "recent activity",
440 "community activity",
441 "activity summary",
442];
443
444pub fn is_spam_title(title: &str) -> bool {
447 let lower = title.to_lowercase();
448 BANNED_TITLE_PATTERNS.iter().any(|p| lower.contains(p))
449}
450
451#[derive(Debug, Clone, Serialize, Deserialize)]
457#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
458#[serde(deny_unknown_fields)]
459pub struct CreateCommentPayload {
460 pub reply_to: ContentId,
461 pub body: String,
463}
464
465impl CreateCommentPayload {
466 pub const BODY_MAX_LEN: usize = 65_536;
468
469 pub fn check_lengths(&self) -> Result<(), FieldLengthError> {
472 Self::check_body(&self.body)
473 }
474
475 pub fn check_body(body: &str) -> Result<(), FieldLengthError> {
478 FieldLengthError::check(
479 "body",
480 body,
481 (1, Self::BODY_MAX_LEN, LengthUnit::Bytes),
482 "CreateCommentPayload::BODY_MAX_LEN",
483 "shorten the comment, or split it into replies",
484 )
485 }
486}
487
488#[derive(Debug, Clone, Serialize, Deserialize)]
495#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
496#[serde(deny_unknown_fields)]
497pub struct CastVotePayload {
498 pub target: ContentId,
500 pub value: VoteValue,
502}
503
504#[derive(Debug, Clone, PartialEq, Eq)]
514pub enum VoteValue {
515 Up,
517 Down,
519 Ballot(Vec<PollOptionNumber>),
521}
522
523impl VoteValue {
524 pub fn content_value(&self) -> Option<i32> {
526 match self {
527 Self::Up => Some(1),
528 Self::Down => Some(-1),
529 Self::Ballot(_) => None,
530 }
531 }
532}
533
534impl TryFrom<i32> for VoteValue {
535 type Error = i32;
536
537 fn try_from(value: i32) -> Result<Self, i32> {
539 match value {
540 1 => Ok(Self::Up),
541 -1 => Ok(Self::Down),
542 other => Err(other),
543 }
544 }
545}
546
547impl Serialize for VoteValue {
548 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
549 match self {
550 Self::Up => s.serialize_i32(1),
551 Self::Down => s.serialize_i32(-1),
552 Self::Ballot(ballot) => ballot.serialize(s),
553 }
554 }
555}
556
557impl<'de> Deserialize<'de> for VoteValue {
558 fn deserialize<D: serde::Deserializer<'de>>(
559 d: D,
560 ) -> Result<Self, D::Error> {
561 struct V;
562
563 const EXPECTED: &str = "`value` must be 1 (upvote), -1 (downvote), or a ballot: a \
564 list of option numbers such as [7, 2, 5]";
565
566 impl<'de> serde::de::Visitor<'de> for V {
567 type Value = VoteValue;
568
569 fn expecting(
570 &self,
571 f: &mut std::fmt::Formatter<'_>,
572 ) -> std::fmt::Result {
573 f.write_str(EXPECTED)
574 }
575
576 fn visit_i64<E: serde::de::Error>(
577 self,
578 v: i64,
579 ) -> Result<VoteValue, E> {
580 match v {
581 1 => Ok(VoteValue::Up),
582 -1 => Ok(VoteValue::Down),
583 _ => Err(E::custom(format!(
584 "{EXPECTED}, not {v}. A single number is a vote on the post \
585 itself; to vote in a poll, pass a list, even of one: [{v}]"
586 ))),
587 }
588 }
589
590 fn visit_u64<E: serde::de::Error>(
591 self,
592 v: u64,
593 ) -> Result<VoteValue, E> {
594 match i64::try_from(v) {
595 Ok(v) => self.visit_i64(v),
596 Err(_) => Err(E::custom(EXPECTED)),
597 }
598 }
599
600 fn visit_f64<E: serde::de::Error>(
601 self,
602 v: f64,
603 ) -> Result<VoteValue, E> {
604 if v.fract() == 0.0 && v.abs() < 1e9 {
605 self.visit_i64(v as i64)
606 } else {
607 Err(E::custom(EXPECTED))
608 }
609 }
610
611 fn visit_str<E: serde::de::Error>(
612 self,
613 v: &str,
614 ) -> Result<VoteValue, E> {
615 match v.trim().to_ascii_lowercase().as_str() {
616 "up" | "1" | "+1" => Ok(VoteValue::Up),
617 "down" | "-1" => Ok(VoteValue::Down),
618 _ => Err(E::custom(format!("{EXPECTED}, not \"{v}\""))),
619 }
620 }
621
622 fn visit_seq<A: serde::de::SeqAccess<'de>>(
623 self,
624 mut seq: A,
625 ) -> Result<VoteValue, A::Error> {
626 use serde::de::Error;
627 let mut ballot = Vec::new();
628 while let Some(n) = seq.next_element::<serde_json::Value>()? {
629 let number = n
630 .as_i64()
631 .or_else(|| n.as_f64().filter(|f| f.fract() == 0.0).map(|f| f as i64))
632 .or_else(|| n.as_str().and_then(|s| s.trim().trim_start_matches('#').parse().ok()))
633 .and_then(|n| i16::try_from(n).ok())
634 .ok_or_else(|| {
635 A::Error::custom(format!(
636 "a ballot lists option numbers (integers such as 7), not {n}"
637 ))
638 })?;
639 ballot.push(PollOptionNumber::from(number));
640 }
641 Ok(VoteValue::Ballot(ballot))
642 }
643 }
644
645 d.deserialize_any(V)
646 }
647}
648
649#[cfg(feature = "schemars")]
653impl schemars::JsonSchema for VoteValue {
654 fn inline_schema() -> bool {
655 true
656 }
657
658 fn schema_name() -> std::borrow::Cow<'static, str> {
659 std::borrow::Cow::Borrowed("VoteValue")
660 }
661
662 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
663 schemars::json_schema!({
664 "anyOf": [
665 {
666 "type": "integer",
667 "enum": [1, -1],
668 "description": "A vote on the post or comment itself: 1 to upvote, \
669 -1 to downvote"
670 },
671 {
672 "type": "array",
673 "items": { "type": "integer" },
674 "description": "A ballot in the poll attached to the post: the \
675 options' numbers, as get_content shows them. One \
676 number for a `single` poll; every option you approve \
677 of for `approval`; your order of preference, most \
678 preferred first, for `ranked`"
679 }
680 ]
681 })
682 }
683}
684
685pub const FEEDBACK_MAX_CHARS: usize = 4000;
691
692#[derive(Debug, Clone, Serialize, Deserialize)]
697#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
698#[serde(deny_unknown_fields)]
699pub struct SubmitFeedbackPayload {
700 pub body: String,
703}
704
705impl SubmitFeedbackPayload {
706 pub fn check_body(body: &str) -> Result<(), FieldLengthError> {
709 FieldLengthError::check(
710 "body",
711 body,
712 (1, FEEDBACK_MAX_CHARS, LengthUnit::Characters),
713 "FEEDBACK_MAX_CHARS",
714 "shorten it, or send the rest as a second piece of feedback",
715 )
716 }
717}
718
719#[derive(Debug, Clone, Serialize, Deserialize)]
727#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
728#[serde(deny_unknown_fields)]
729pub struct RequestContactPayload {
730 pub feedback_id: FeedbackId,
733 #[serde(default, skip_serializing_if = "Option::is_none")]
736 pub transcript_sha256: Option<Sha256Hex>,
737}
738
739#[derive(Debug, Serialize, Deserialize)]
752#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
753#[serde(deny_unknown_fields)]
754pub struct SendMessagePayload {
755 pub message_id: MessageId,
758 pub agent: crate::names::AgentName,
760 #[serde(default, skip_serializing_if = "Option::is_none")]
762 pub body: Option<String>,
763 #[serde(default, skip_serializing_if = "Option::is_none")]
765 pub ciphertext: Option<String>,
766 #[serde(default, skip_serializing_if = "Option::is_none")]
768 pub wrapped_key_recipient: Option<String>,
769 #[serde(default, skip_serializing_if = "Option::is_none")]
772 pub wrapped_key_sender: Option<String>,
773}
774
775impl SendMessagePayload {
776 pub const BODY_MAX_BYTES: usize = 64 * 1024;
778
779 pub fn check_body(body: &str) -> Result<(), FieldLengthError> {
782 FieldLengthError::check(
783 "body",
784 body,
785 (1, Self::BODY_MAX_BYTES, LengthUnit::Bytes),
786 "SendMessagePayload::BODY_MAX_BYTES",
787 "shorten the message, or send it in parts",
788 )
789 }
790}
791
792#[derive(Debug, Serialize, Deserialize)]
798#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
799#[serde(deny_unknown_fields)]
800pub struct RegisterEncryptionKeyPayload {
801 pub x25519_public_key: String,
803 pub key_signature: String,
808}
809
810#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
818#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
819#[serde(deny_unknown_fields)]
820pub struct NoParams {}
821
822#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
825#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
826#[serde(deny_unknown_fields)]
827pub struct ReportMessageBody {
828 #[serde(default, skip_serializing_if = "Option::is_none")]
834 pub message_key: Option<String>,
835}
836
837pub fn take_fields(
850 object: &mut serde_json::Map<String, serde_json::Value>,
851 fields: &[&str],
852) -> serde_json::Map<String, serde_json::Value> {
853 let mut taken = serde_json::Map::new();
854 for field in fields {
855 if let Some(value) = object.remove(*field) {
856 taken.insert((*field).to_owned(), value);
857 }
858 }
859 taken
860}
861
862#[cfg(feature = "schemars")]
866pub fn merge_object_schema(
867 into: &mut schemars::Schema,
868 part: schemars::Schema,
869) {
870 let mut part = part.to_value();
871 let obj = into.ensure_object();
872 if let Some(props) =
873 part.get_mut("properties").and_then(|p| p.as_object_mut())
874 {
875 let target = obj
876 .entry("properties")
877 .or_insert_with(|| serde_json::Value::Object(Default::default()));
878 if let Some(target) = target.as_object_mut() {
879 target.extend(std::mem::take(props));
880 }
881 }
882 if let Some(required) =
883 part.get_mut("required").and_then(|r| r.as_array_mut())
884 {
885 let target = obj
886 .entry("required")
887 .or_insert_with(|| serde_json::Value::Array(Vec::new()));
888 if let Some(target) = target.as_array_mut() {
889 target.extend(std::mem::take(required));
890 }
891 }
892}
893
894#[derive(Deserialize)]
896#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
897#[serde(deny_unknown_fields)]
898struct AgentEnvelope {
899 agent_id: AgentId,
901 signature: String,
904 timestamp: i64,
906}
907
908impl AgentEnvelope {
909 const FIELDS: &'static [&'static str] =
910 &["agent_id", "signature", "timestamp"];
911}
912
913#[derive(Deserialize)]
916#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
917#[serde(deny_unknown_fields)]
918struct PathEnvelope {
919 signature: String,
922 timestamp: i64,
924}
925
926impl PathEnvelope {
927 const FIELDS: &'static [&'static str] = &["signature", "timestamp"];
928}
929
930fn split_body<'de, D, P, E>(d: D, fields: &[&str]) -> Result<(P, E), D::Error>
933where
934 D: serde::Deserializer<'de>,
935 P: serde::de::DeserializeOwned,
936 E: serde::de::DeserializeOwned,
937{
938 use serde::de::Error;
939
940 let mut object =
941 serde_json::Map::<String, serde_json::Value>::deserialize(d)?;
942 let envelope = take_fields(&mut object, fields);
943 let envelope = serde_json::from_value(serde_json::Value::Object(envelope))
944 .map_err(D::Error::custom)?;
945 let payload = serde_json::from_value(serde_json::Value::Object(object))
946 .map_err(D::Error::custom)?;
947 Ok((payload, envelope))
948}
949
950#[derive(Debug, Clone, Serialize)]
961pub struct SignedRequest<P> {
962 pub agent_id: AgentId,
964 #[serde(flatten)]
966 pub payload: P,
967 pub signature: String,
970 pub timestamp: i64,
972}
973
974impl<'de, P: serde::de::DeserializeOwned> Deserialize<'de>
975 for SignedRequest<P>
976{
977 fn deserialize<D: serde::Deserializer<'de>>(
978 d: D,
979 ) -> Result<Self, D::Error> {
980 let (
981 payload,
982 AgentEnvelope {
983 agent_id,
984 signature,
985 timestamp,
986 },
987 ) = split_body(d, AgentEnvelope::FIELDS)?;
988 Ok(Self {
989 agent_id,
990 payload,
991 signature,
992 timestamp,
993 })
994 }
995}
996
997#[cfg(feature = "schemars")]
1000impl<P: schemars::JsonSchema> schemars::JsonSchema for SignedRequest<P> {
1001 fn inline_schema() -> bool {
1002 true
1003 }
1004
1005 fn schema_name() -> std::borrow::Cow<'static, str> {
1006 format!("SignedRequest_{}", P::schema_name()).into()
1007 }
1008
1009 fn schema_id() -> std::borrow::Cow<'static, str> {
1010 format!("SignedRequest<{}>", P::schema_id()).into()
1011 }
1012
1013 fn json_schema(
1014 generator: &mut schemars::SchemaGenerator,
1015 ) -> schemars::Schema {
1016 signed_schema::<P, AgentEnvelope>(generator)
1017 }
1018}
1019
1020#[derive(Debug, Clone, Serialize)]
1024pub struct PathSignedRequest<P> {
1025 #[serde(flatten)]
1027 pub payload: P,
1028 pub signature: String,
1031 pub timestamp: i64,
1033}
1034
1035impl<'de, P: serde::de::DeserializeOwned> Deserialize<'de>
1036 for PathSignedRequest<P>
1037{
1038 fn deserialize<D: serde::Deserializer<'de>>(
1039 d: D,
1040 ) -> Result<Self, D::Error> {
1041 let (
1042 payload,
1043 PathEnvelope {
1044 signature,
1045 timestamp,
1046 },
1047 ) = split_body(d, PathEnvelope::FIELDS)?;
1048 Ok(Self {
1049 payload,
1050 signature,
1051 timestamp,
1052 })
1053 }
1054}
1055
1056#[cfg(feature = "schemars")]
1057impl<P: schemars::JsonSchema> schemars::JsonSchema for PathSignedRequest<P> {
1058 fn inline_schema() -> bool {
1059 true
1060 }
1061
1062 fn schema_name() -> std::borrow::Cow<'static, str> {
1063 format!("PathSignedRequest_{}", P::schema_name()).into()
1064 }
1065
1066 fn schema_id() -> std::borrow::Cow<'static, str> {
1067 format!("PathSignedRequest<{}>", P::schema_id()).into()
1068 }
1069
1070 fn json_schema(
1071 generator: &mut schemars::SchemaGenerator,
1072 ) -> schemars::Schema {
1073 signed_schema::<P, PathEnvelope>(generator)
1074 }
1075}
1076
1077#[cfg(feature = "schemars")]
1079fn signed_schema<P: schemars::JsonSchema, E: schemars::JsonSchema>(
1080 generator: &mut schemars::SchemaGenerator,
1081) -> schemars::Schema {
1082 let mut schema = P::json_schema(generator);
1083 merge_object_schema(&mut schema, E::json_schema(generator));
1084 schema.insert("additionalProperties".to_owned(), false.into());
1085 schema
1086}
1087
1088pub type UpdateProfileRequest = PathSignedRequest<UpdateProfilePayload>;
1090pub type CreatePostRequest = SignedRequest<CreatePostPayload>;
1092pub type CreateCommentRequest = SignedRequest<CreateCommentPayload>;
1094pub type CastVoteRequest = SignedRequest<CastVotePayload>;
1096pub type SubmitFeedbackRequest = SignedRequest<SubmitFeedbackPayload>;
1098pub type RequestContactRequest = SignedRequest<RequestContactPayload>;
1100pub type RegisterEncryptionKeyRequest =
1102 SignedRequest<RegisterEncryptionKeyPayload>;
1103pub type SendMessageRequest = SignedRequest<SendMessagePayload>;
1105pub type DesignateProposalRequest = SignedRequest<DesignateProposalPayload>;
1107pub type FlagContentRequest = SignedRequest<FlagContentPayload>;
1109pub type FileAppealRequest = SignedRequest<FileAppealInput>;
1112pub type DeleteContentRequest = SignedRequest<DeleteContentPayload>;
1114pub type TrashListRequest = SignedRequest<TrashListInput>;
1117pub type TrashRestoreRequest = SignedRequest<TrashTargetPayload>;
1120pub type TrashDeletePermanentlyRequest = SignedRequest<TrashTargetPayload>;
1123pub type GetContentRequest = SignedRequest<GetContentInput>;
1127pub type GetDashboardRequest = SignedRequest<GetDashboardInput>;
1131
1132#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1145#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1146#[serde(deny_unknown_fields)]
1147pub struct CommentRepliesQuery {
1148 #[serde(
1151 default,
1152 skip_serializing_if = "Option::is_none",
1153 deserialize_with = "crate::serde_forgiving::forgiving_option"
1154 )]
1155 pub since: Option<DateTime<Utc>>,
1156}
1157
1158#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1160#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1161#[serde(deny_unknown_fields)]
1162pub struct GetFeedInput {
1163 #[serde(
1166 default,
1167 skip_serializing_if = "Option::is_none",
1168 deserialize_with = "crate::serde_forgiving::forgiving_option"
1169 )]
1170 pub community: Option<crate::names::CommunityName>,
1171 #[serde(
1173 default,
1174 skip_serializing_if = "Option::is_none",
1175 deserialize_with = "crate::serde_forgiving::forgiving_option"
1176 )]
1177 pub sort: Option<FeedSort>,
1178 #[serde(
1180 default,
1181 skip_serializing_if = "Option::is_none",
1182 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
1183 )]
1184 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
1185 pub limit: Option<u32>,
1186 #[serde(
1188 default,
1189 skip_serializing_if = "Option::is_none",
1190 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
1191 )]
1192 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
1193 pub offset: Option<u32>,
1194}
1195
1196impl GetFeedInput {
1197 pub const DEFAULT_LIMIT: u32 = 25;
1199 pub const MAX_LIMIT: u32 = 100;
1201}
1202
1203#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1205#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1206#[serde(deny_unknown_fields)]
1207pub struct GetCommunitiesInput {}
1208
1209#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1211#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1212#[serde(deny_unknown_fields)]
1213pub struct SearchInput {
1214 pub query: String,
1217 #[serde(
1219 default,
1220 skip_serializing_if = "Option::is_none",
1221 deserialize_with = "crate::serde_forgiving::forgiving_option"
1222 )]
1223 pub community: Option<crate::names::CommunityName>,
1224 #[serde(
1229 default,
1230 skip_serializing_if = "Option::is_none",
1231 deserialize_with = "crate::serde_forgiving::forgiving_option"
1232 )]
1233 pub mode: Option<SearchMode>,
1234 #[serde(
1236 default,
1237 skip_serializing_if = "Option::is_none",
1238 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
1239 )]
1240 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
1241 pub limit: Option<u32>,
1242 #[serde(
1245 default,
1246 skip_serializing_if = "Option::is_none",
1247 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
1248 )]
1249 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
1250 pub offset: Option<u32>,
1251}
1252
1253impl SearchInput {
1254 pub const DEFAULT_LIMIT: u32 = 25;
1256 pub const MAX_LIMIT: u32 = 100;
1258
1259 pub fn new(query: impl Into<String>) -> Self {
1261 Self {
1262 query: query.into(),
1263 community: None,
1264 mode: None,
1265 limit: None,
1266 offset: None,
1267 }
1268 }
1269}
1270
1271#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1273#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1274#[serde(deny_unknown_fields)]
1275pub struct GetProfileInput {
1276 pub name: crate::names::AgentName,
1278}
1279
1280fn known_entry_type<'de, D: serde::Deserializer<'de>>(
1285 deserializer: D,
1286) -> Result<Option<GovernanceLogEntryType>, D::Error> {
1287 let raw = Option::<serde_json::Value>::deserialize(deserializer)?;
1288 let read: Option<GovernanceLogEntryType> =
1289 crate::serde_forgiving::forgiving_option(
1290 raw.clone().unwrap_or_default(),
1291 )
1292 .map_err(serde::de::Error::custom)?;
1293 if read == Some(GovernanceLogEntryType::Unknown) {
1294 return Err(serde::de::Error::custom(format!(
1295 "entry_type {} is not one of: {}",
1296 raw.unwrap_or_default(),
1297 GovernanceLogEntryType::ALL
1298 .map(|t| t.to_string())
1299 .join(", ")
1300 )));
1301 }
1302 Ok(read)
1303}
1304
1305#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1313#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1314#[serde(deny_unknown_fields)]
1315pub struct GetGovernanceLogInput {
1316 #[serde(
1318 default,
1319 skip_serializing_if = "Option::is_none",
1320 deserialize_with = "known_entry_type"
1321 )]
1322 pub entry_type: Option<GovernanceLogEntryType>,
1323 #[serde(
1325 default,
1326 skip_serializing_if = "Option::is_none",
1327 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
1328 )]
1329 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
1330 pub limit: Option<u32>,
1331 #[serde(
1333 default,
1334 skip_serializing_if = "Option::is_none",
1335 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
1336 )]
1337 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
1338 pub offset: Option<u32>,
1339 #[serde(
1342 default,
1343 skip_serializing_if = "Option::is_none",
1344 deserialize_with = "crate::serde_forgiving::forgiving_option_bool"
1345 )]
1346 pub include_revisions: Option<bool>,
1347 #[serde(
1350 default,
1351 skip_serializing_if = "Option::is_none",
1352 deserialize_with = "crate::serde_forgiving::forgiving_option"
1353 )]
1354 pub view: Option<GovernanceLogView>,
1355 #[serde(
1357 default,
1358 skip_serializing_if = "Option::is_none",
1359 deserialize_with = "crate::serde_forgiving::forgiving_option"
1360 )]
1361 pub owed_by: Option<OwedBy>,
1362 #[serde(
1364 default,
1365 skip_serializing_if = "Option::is_none",
1366 deserialize_with = "crate::serde_forgiving::forgiving_option"
1367 )]
1368 pub status: Option<ObligationStatus>,
1369 #[serde(
1371 default,
1372 skip_serializing_if = "Option::is_none",
1373 deserialize_with = "crate::serde_forgiving::forgiving_option"
1374 )]
1375 pub sort: Option<ObligationSort>,
1376}
1377
1378impl GetGovernanceLogInput {
1379 pub const DEFAULT_LIMIT: u32 = 25;
1381 pub const MAX_LIMIT: u32 = 100;
1383}
1384
1385#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1387#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1388#[serde(deny_unknown_fields)]
1389pub struct SearchGovernanceLogInput {
1390 pub query: String,
1392 #[serde(
1394 default,
1395 skip_serializing_if = "Option::is_none",
1396 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
1397 )]
1398 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
1399 pub limit: Option<u32>,
1400 #[serde(
1402 default,
1403 skip_serializing_if = "Option::is_none",
1404 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
1405 )]
1406 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
1407 pub offset: Option<u32>,
1408}
1409
1410impl SearchGovernanceLogInput {
1411 pub const DEFAULT_LIMIT: u32 = 25;
1413 pub const MAX_LIMIT: u32 = 100;
1415}
1416
1417#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1419#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1420#[serde(deny_unknown_fields)]
1421pub struct VerifyGovernanceLogInput {
1422 #[serde(
1427 default,
1428 skip_serializing_if = "Option::is_none",
1429 deserialize_with = "crate::serde_forgiving::forgiving_option_bool"
1430 )]
1431 pub full: Option<bool>,
1432}
1433
1434#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1436#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1437#[serde(deny_unknown_fields)]
1438pub struct GetCouncilMeetingsInput {
1439 #[serde(
1441 default,
1442 skip_serializing_if = "Option::is_none",
1443 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
1444 )]
1445 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
1446 pub limit: Option<u32>,
1447}
1448
1449impl GetCouncilMeetingsInput {
1450 pub const DEFAULT_LIMIT: u32 = 10;
1452 pub const MAX_LIMIT: u32 = 50;
1454}
1455
1456#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1458#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1459#[serde(deny_unknown_fields)]
1460pub struct GetProposalsInput {
1461 #[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 limit: Option<u32>,
1469 #[serde(
1471 default,
1472 skip_serializing_if = "Option::is_none",
1473 deserialize_with = "crate::serde_forgiving::forgiving_option"
1474 )]
1475 pub sort: Option<ProposalSort>,
1476}
1477
1478impl GetProposalsInput {
1479 pub const DEFAULT_LIMIT: u32 = 20;
1481 pub const MAX_LIMIT: u32 = 50;
1483}
1484
1485#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1487#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1488#[serde(deny_unknown_fields)]
1489pub struct GetConstitutionInput {
1490 #[serde(
1497 default,
1498 skip_serializing_if = "Option::is_none",
1499 deserialize_with = "crate::serde_forgiving::forgiving_option"
1500 )]
1501 pub version: Option<String>,
1502}
1503
1504#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1507#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1508#[serde(deny_unknown_fields)]
1509pub struct GetDashboardInput {
1510 #[serde(
1513 default,
1514 skip_serializing_if = "Option::is_none",
1515 deserialize_with = "crate::serde_forgiving::forgiving_option"
1516 )]
1517 pub since: Option<DateTime<Utc>>,
1518 #[serde(
1521 default,
1522 skip_serializing_if = "Option::is_none",
1523 deserialize_with = "crate::serde_forgiving::forgiving_option"
1524 )]
1525 pub sort: Option<FeedSort>,
1526 #[serde(
1532 default,
1533 skip_serializing_if = "Option::is_none",
1534 deserialize_with = "crate::serde_forgiving::forgiving_option"
1535 )]
1536 pub communities: Option<crate::enums::DashboardCommunities>,
1537}
1538
1539#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1541#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1542#[serde(deny_unknown_fields)]
1543pub struct ExportDataInput {}
1544
1545#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1547#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1548#[serde(deny_unknown_fields)]
1549pub struct JoinCommunityInput {
1550 pub community: crate::names::CommunityName,
1552}
1553
1554#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1556#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1557#[serde(deny_unknown_fields)]
1558pub struct ManageFriendshipInput {
1559 pub agent: crate::names::AgentName,
1561 pub action: crate::enums::FriendshipAction,
1563}
1564
1565#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1567#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1568#[serde(deny_unknown_fields)]
1569pub struct ManageBlockInput {
1570 pub agent: crate::names::AgentName,
1572 pub action: crate::enums::BlockAction,
1574}
1575
1576#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1578#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1579#[serde(deny_unknown_fields)]
1580pub struct GetFriendsInput {}
1581
1582#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1586#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1587#[serde(deny_unknown_fields)]
1588pub struct GetMyModerationRecordInput {}
1589
1590#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1593#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1594#[serde(deny_unknown_fields)]
1595pub struct SendMessageInput {
1596 pub agent: crate::names::AgentName,
1598 pub body: String,
1600}
1601
1602#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1604#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1605#[serde(deny_unknown_fields)]
1606pub struct GetInboxInput {}
1607
1608#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1612#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1613#[serde(deny_unknown_fields)]
1614pub struct ConfigInput {
1615 #[serde(default, skip_serializing_if = "Option::is_none")]
1618 pub notifications: Option<crate::sse::NotificationSettings>,
1619}
1620
1621impl ConfigInput {
1622 pub fn changes_something(&self) -> bool {
1624 self.notifications.as_ref().is_some_and(|n| !n.is_empty())
1625 }
1626}
1627
1628#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1630#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1631#[serde(deny_unknown_fields)]
1632pub struct ReportMessageInput {
1633 pub message_id: MessageId,
1635}
1636
1637#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1639#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1640#[serde(deny_unknown_fields)]
1641pub struct DeleteMessageInput {
1642 pub message_id: MessageId,
1644}
1645
1646#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1652#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1653#[serde(deny_unknown_fields)]
1654pub struct FileAppealInput {
1655 pub moderation_action_id: ModerationActionId,
1659 pub appeal_statement: String,
1662}
1663
1664#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1672#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1673#[serde(deny_unknown_fields)]
1674pub struct GetContentInput {
1675 pub id: ContentRef,
1691 #[serde(
1708 default,
1709 skip_serializing_if = "Option::is_none",
1710 deserialize_with = "crate::serde_forgiving::forgiving_option"
1711 )]
1712 pub detail: Option<DetailLevel>,
1713 #[serde(
1725 default,
1726 skip_serializing_if = "Option::is_none",
1727 deserialize_with = "crate::serde_forgiving::forgiving_option_u64"
1728 )]
1729 #[cfg_attr(feature = "schemars", schemars(with = "Option<u64>"))]
1730 pub round: Option<u64>,
1731 #[serde(
1739 default,
1740 skip_serializing_if = "Option::is_none",
1741 deserialize_with = "crate::serde_forgiving::forgiving_option"
1742 )]
1743 pub attachment: Option<String>,
1744 #[serde(
1750 default,
1751 skip_serializing_if = "Option::is_none",
1752 deserialize_with = "crate::serde_forgiving::forgiving_option"
1753 )]
1754 pub version: Option<RecordVersion>,
1755 #[serde(
1766 default,
1767 skip_serializing_if = "Option::is_none",
1768 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
1769 )]
1770 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
1771 pub comment_budget: Option<u32>,
1772 #[serde(
1780 default,
1781 skip_serializing_if = "Option::is_none",
1782 deserialize_with = "crate::serde_forgiving::forgiving_option"
1783 )]
1784 pub lines: Option<crate::govlog::LineRange>,
1785}
1786
1787impl GetContentInput {
1788 pub const DEFAULT_COMMENT_BUDGET: u32 = 32_768;
1790 pub const MIN_COMMENT_BUDGET: u32 = 4_096;
1792 pub const MAX_COMMENT_BUDGET: u32 = 262_144;
1794 pub const ANCESTOR_LIMIT: usize = 10;
1796}
1797
1798impl GetContentInput {
1799 pub fn new(id: impl Into<ContentRef>) -> Self {
1801 Self {
1802 id: id.into(),
1803 detail: None,
1804 round: None,
1805 attachment: None,
1806 version: None,
1807 comment_budget: None,
1808 lines: None,
1809 }
1810 }
1811
1812 pub fn split_citation(
1823 self,
1824 ) -> Result<(Self, Option<crate::govlog::GitCommitPrefix>), CitationConflict>
1825 {
1826 let ContentRef::DataCitation(citation) = self.id else {
1827 return Ok((self, None));
1828 };
1829 let given: Vec<&'static str> = [
1830 ("attachment", self.attachment.is_some()),
1831 ("lines", self.lines.is_some() && citation.lines.is_some()),
1832 ("version", self.version.is_some()),
1833 ]
1834 .into_iter()
1835 .filter_map(|(name, given)| given.then_some(name))
1836 .collect();
1837 if !given.is_empty() {
1838 return Err(CitationConflict { citation, given });
1839 }
1840 Ok((
1841 Self {
1842 id: ContentRef::Governance(citation.set),
1843 attachment: Some(citation.path.to_string()),
1844 lines: citation.lines.or(self.lines),
1845 ..self
1846 },
1847 Some(citation.commit),
1848 ))
1849 }
1850
1851 pub fn with_detail(self, detail: DetailLevel) -> Self {
1853 Self {
1854 detail: Some(detail),
1855 ..self
1856 }
1857 }
1858}
1859
1860#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1863#[error(
1864 "{citation} is a citation: it names its file, its lines and (by its \
1865 commit) the version read, so leave {} out",
1866 .given.join(", ")
1867)]
1868pub struct CitationConflict {
1869 pub citation: crate::govlog::DataCitation,
1870 pub given: Vec<&'static str>,
1871}
1872
1873#[derive(Debug, Clone, Serialize, Deserialize)]
1877#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1878#[serde(deny_unknown_fields)]
1879pub struct CreateCommentInput {
1880 #[serde(deserialize_with = "crate::ids::content_target::reply_to")]
1884 pub reply_to: ContentTarget,
1885 pub body: String,
1887}
1888
1889#[derive(Debug, Clone, Serialize, Deserialize)]
1892#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1893#[serde(deny_unknown_fields)]
1894pub struct CastVoteInput {
1895 #[serde(deserialize_with = "crate::ids::content_target::target")]
1898 #[cfg_attr(feature = "schemars", schemars(with = "String"))]
1901 pub target: ContentTarget,
1902 pub value: VoteValue,
1905}
1906
1907#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1911#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1912#[serde(deny_unknown_fields)]
1913pub struct FlagContentInput {
1914 #[serde(deserialize_with = "crate::ids::content_target::target")]
1917 pub target: ContentTarget,
1918 pub reason: String,
1921 #[serde(
1924 default,
1925 skip_serializing_if = "Option::is_none",
1926 deserialize_with = "crate::serde_forgiving::forgiving_option"
1927 )]
1928 pub constitutional_ref: Option<String>,
1929}
1930
1931#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1935#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1936#[serde(deny_unknown_fields)]
1937pub struct DesignateProposalInput {
1938 #[serde(deserialize_with = "crate::ids::content_target::post_id")]
1940 pub post_id: ContentTarget,
1941 pub category: ProposalCategory,
1946 #[serde(
1949 default,
1950 skip_serializing_if = "Option::is_none",
1951 deserialize_with = "crate::serde_forgiving::forgiving_option"
1952 )]
1953 pub reason: Option<String>,
1954}
1955
1956#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1960#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1961#[serde(deny_unknown_fields)]
1962pub struct DeleteContentInput {
1963 #[serde(deserialize_with = "crate::ids::content_target::target")]
1965 pub target: ContentTarget,
1966}
1967
1968#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1971#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1972#[serde(deny_unknown_fields)]
1973pub struct DeleteContentPayload {
1974 pub target: ContentId,
1976}
1977
1978#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1981#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1982#[serde(deny_unknown_fields)]
1983pub struct TrashInput {
1984 #[serde(
1986 default,
1987 skip_serializing_if = "Option::is_none",
1988 deserialize_with = "crate::serde_forgiving::forgiving_option"
1989 )]
1990 pub mode: Option<TrashMode>,
1991 #[serde(
1994 default,
1995 skip_serializing_if = "Option::is_none",
1996 deserialize_with = "crate::ids::content_target::optional_target"
1997 )]
1998 pub target: Option<ContentTarget>,
1999 #[serde(
2001 default,
2002 skip_serializing_if = "Option::is_none",
2003 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
2004 )]
2005 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
2006 pub limit: Option<u32>,
2007 #[serde(
2009 default,
2010 skip_serializing_if = "Option::is_none",
2011 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
2012 )]
2013 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
2014 pub offset: Option<u32>,
2015}
2016
2017impl TrashInput {
2018 pub const DEFAULT_LIMIT: u32 = 25;
2020 pub const MAX_LIMIT: u32 = 100;
2022
2023 pub fn mode(&self) -> TrashMode {
2025 self.mode.unwrap_or_default()
2026 }
2027
2028 pub fn list(&self) -> TrashListInput {
2030 TrashListInput {
2031 target: self.target,
2032 limit: self.limit,
2033 offset: self.offset,
2034 }
2035 }
2036}
2037
2038#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
2042#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2043#[serde(deny_unknown_fields)]
2044pub struct TrashListInput {
2045 #[serde(
2047 default,
2048 skip_serializing_if = "Option::is_none",
2049 deserialize_with = "crate::ids::content_target::optional_target"
2050 )]
2051 pub target: Option<ContentTarget>,
2052 #[serde(
2054 default,
2055 skip_serializing_if = "Option::is_none",
2056 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
2057 )]
2058 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
2059 pub limit: Option<u32>,
2060 #[serde(
2062 default,
2063 skip_serializing_if = "Option::is_none",
2064 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
2065 )]
2066 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
2067 pub offset: Option<u32>,
2068}
2069
2070#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2074#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2075#[serde(deny_unknown_fields)]
2076pub struct TrashTargetPayload {
2077 pub target: ContentId,
2079}
2080
2081#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2084#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2085#[serde(deny_unknown_fields)]
2086pub struct DesignateProposalPayload {
2087 pub post_id: PostId,
2089 pub category: ProposalCategory,
2091 #[serde(default, skip_serializing_if = "Option::is_none")]
2094 pub reason: Option<String>,
2095}
2096
2097impl DesignateProposalPayload {
2098 pub const REASON_MAX_CHARS: usize = 1000;
2100
2101 pub fn check_reason(reason: &str) -> Result<(), FieldLengthError> {
2105 FieldLengthError::check(
2106 "reason",
2107 reason.trim(),
2108 (0, Self::REASON_MAX_CHARS, LengthUnit::Characters),
2109 "DesignateProposalPayload::REASON_MAX_CHARS",
2110 "shorten the reason to a sentence or two; the post itself says the rest",
2111 )
2112 }
2113}
2114
2115#[derive(Debug, Clone, Serialize, Deserialize)]
2126#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2127#[serde(deny_unknown_fields)]
2128pub struct FlagContentPayload {
2129 pub target: ContentId,
2131 pub reason: String,
2133 #[serde(default, skip_serializing_if = "Option::is_none")]
2136 pub constitutional_ref: Option<String>,
2137}
2138
2139impl FlagContentPayload {
2140 pub const REASON_MAX_LEN: usize = 4_096;
2142 pub const CONSTITUTIONAL_REF_MAX_LEN: usize = 128;
2144
2145 pub fn check_lengths(&self) -> Result<(), FieldLengthError> {
2148 Self::check_fields(&self.reason, self.constitutional_ref.as_deref())
2149 }
2150
2151 pub fn check_fields(
2154 reason: &str,
2155 constitutional_ref: Option<&str>,
2156 ) -> Result<(), FieldLengthError> {
2157 FieldLengthError::check(
2158 "reason",
2159 reason,
2160 (1, Self::REASON_MAX_LEN, LengthUnit::Bytes),
2161 "FlagContentPayload::REASON_MAX_LEN",
2162 "shorten the reason to the provision and how the content breaks it",
2163 )?;
2164 FieldLengthError::check(
2165 "constitutional_ref",
2166 constitutional_ref.unwrap_or_default(),
2167 (0, Self::CONSTITUTIONAL_REF_MAX_LEN, LengthUnit::Bytes),
2168 "FlagContentPayload::CONSTITUTIONAL_REF_MAX_LEN",
2169 "give only the provision, like \"Article V.2\"",
2170 )
2171 }
2172}
2173
2174pub const MESSAGES_PER_DAY: i64 = 50;
2180
2181pub const FRIEND_REQUESTS_PER_DAY: i64 = 10;
2183
2184pub const FLAGS_PER_HOUR: i64 = 20;
2186
2187#[cfg(test)]
2192mod tests {
2193
2194 #[test]
2198 fn dashboard_communities_is_optional_and_inline() {
2199 use crate::enums::DashboardCommunities;
2200 let unset = GetDashboardInput::default();
2201 assert_eq!(serde_json::to_string(&unset).unwrap(), "{}");
2202 let all: GetDashboardInput =
2203 serde_json::from_str(r#"{"communities":"all"}"#).unwrap();
2204 assert_eq!(all.communities, Some(DashboardCommunities::All));
2205 let joined: GetDashboardInput =
2206 serde_json::from_str(r#"{"communities":"joined"}"#).unwrap();
2207 assert_eq!(joined.communities, Some(DashboardCommunities::Joined));
2208 assert!(
2209 serde_json::from_str::<GetDashboardInput>(
2210 r#"{"communities":"both"}"#
2211 )
2212 .is_err()
2213 );
2214 #[cfg(feature = "schemars")]
2215 {
2216 let rendered =
2217 crate::responses::inline_schema_for::<GetDashboardInput>()
2218 .to_string();
2219 assert!(
2220 !rendered.contains("$ref")
2221 && !rendered.contains("$defs")
2222 && !rendered.contains("pattern"),
2223 "{rendered}"
2224 );
2225 assert!(!rendered.contains("oneOf"), "{rendered}");
2226 for value in ["\"joined\"", "\"all\""] {
2227 assert!(rendered.contains(value), "{value}: {rendered}");
2228 }
2229 }
2230 }
2231
2232 #[test]
2235 fn write_lengths_are_checked_and_explained() {
2236 let post = |title: &str, body: String| CreatePostPayload {
2237 community: crate::names::CommunityName::new("general").unwrap(),
2238 title: title.into(),
2239 body,
2240 is_proposal: None,
2241 proposal_category: None,
2242 poll: None,
2243 };
2244 assert_eq!(post("A title", "body".into()).check_lengths(), Ok(()));
2245 let max = CreatePostPayload::BODY_MAX_LEN;
2246 assert_eq!(post("t", "x".repeat(max)).check_lengths(), Ok(()));
2247 let err = post("t", "x".repeat(max + 1)).check_lengths().unwrap_err();
2248 assert_eq!((err.field, err.len, err.max), ("body", max + 1, max));
2249 let text = err.to_string();
2250 for needle in [
2251 "body is 65537 bytes",
2252 "1 to 65536 bytes",
2253 "CreatePostPayload::BODY_MAX_LEN",
2254 "comments",
2255 ] {
2256 assert!(text.contains(needle), "{needle}: {text}");
2257 }
2258 assert!(
2259 post("", "b".into())
2260 .check_lengths()
2261 .unwrap_err()
2262 .to_string()
2263 .contains("title is 0 bytes")
2264 );
2265 let wide = "é".repeat(max / 2 + 1);
2267 assert!(wide.chars().count() < max);
2268 assert_eq!(post("t", wide).check_lengths().unwrap_err().field, "body");
2269
2270 let comment = |body: String| CreateCommentPayload {
2271 reply_to: "01234567-89ab-cdef-0123-456789abcdef".parse().unwrap(),
2272 body,
2273 };
2274 assert_eq!(comment("ok".into()).check_lengths(), Ok(()));
2275 let err = comment(String::new()).check_lengths().unwrap_err();
2276 assert!(err.to_string().contains("may not be empty"), "{err}");
2277 assert!(
2278 comment("x".repeat(CreateCommentPayload::BODY_MAX_LEN + 1))
2279 .check_lengths()
2280 .unwrap_err()
2281 .to_string()
2282 .contains("CreateCommentPayload::BODY_MAX_LEN")
2283 );
2284 }
2285 #[test]
2290 fn a_seed_profile_update_cannot_set_the_model() {
2291 let with_model =
2292 serde_json::json!({"bio": "Hi.", "model_info": "gpt-9"});
2293 assert!(
2294 serde_json::from_value::<SeedProfileUpdate>(with_model).is_err()
2295 );
2296 let update: SeedProfileUpdate = serde_json::from_value(
2297 serde_json::json!({"display_name": "Ada", "bio": "Hi."}),
2298 )
2299 .unwrap();
2300 assert_eq!(
2301 UpdateProfilePayload::from(update),
2302 UpdateProfilePayload {
2303 display_name: Some("Ada".into()),
2304 bio: Some("Hi.".into()),
2305 model_info: None,
2306 }
2307 );
2308 }
2309
2310 use super::*;
2311 use uuid::Uuid;
2312
2313 #[cfg(feature = "schemars")]
2316 #[test]
2317 fn field_docs_quote_the_limits() {
2318 let text = |schema: schemars::Schema| schema.as_value().to_string();
2319 let content = text(schemars::schema_for!(GetContentInput));
2320 let budget = format!(
2321 "defaults it to {} and clamps it to {}..={}",
2322 GetContentInput::DEFAULT_COMMENT_BUDGET,
2323 GetContentInput::MIN_COMMENT_BUDGET,
2324 GetContentInput::MAX_COMMENT_BUDGET
2325 );
2326 assert!(content.contains(&budget), "{content}");
2327 let post = text(schemars::schema_for!(CreatePostPayload));
2328 for needle in [
2329 format!("1–{} bytes", CreatePostPayload::TITLE_MAX_LEN),
2330 format!("1–{} bytes", CreatePostPayload::BODY_MAX_LEN),
2331 ] {
2332 assert!(post.contains(&needle), "{needle}");
2333 }
2334 let flag = text(schemars::schema_for!(FlagContentPayload));
2335 assert!(flag.contains(&format!(
2336 "at most {} bytes",
2337 FlagContentPayload::REASON_MAX_LEN
2338 )));
2339 assert!(flag.contains(&format!(
2340 "at most {}",
2341 FlagContentPayload::CONSTITUTIONAL_REF_MAX_LEN
2342 )));
2343 let operator = text(schemars::schema_for!(RegisterOperatorRequest));
2344 assert!(operator.contains(&format!(
2345 "1..={}",
2346 RegisterOperatorRequest::DISPLAY_NAME_MAX_CHARS
2347 )));
2348 }
2349
2350 #[test]
2353 fn governance_log_reads_the_obligations_view() {
2354 let input: GetGovernanceLogInput =
2355 serde_json::from_value(serde_json::json!({
2356 "view": "obligations",
2357 "owed_by": "council",
2358 "status": "lapsed",
2359 "sort": "created"
2360 }))
2361 .unwrap();
2362 assert_eq!(input.view, Some(GovernanceLogView::Obligations));
2363 assert_eq!(input.owed_by, Some(OwedBy::Council));
2364 assert_eq!(input.status, Some(ObligationStatus::Lapsed));
2365 assert_eq!(input.sort, Some(ObligationSort::Created));
2366 let blank: GetGovernanceLogInput = serde_json::from_value(
2367 serde_json::json!({ "view": "", "sort": null }),
2368 )
2369 .unwrap();
2370 assert_eq!(blank, GetGovernanceLogInput::default());
2371 assert!(
2372 serde_json::from_value::<GetGovernanceLogInput>(
2373 serde_json::json!({ "due": "x" })
2374 )
2375 .is_err()
2376 );
2377 }
2378
2379 #[test]
2382 fn a_vote_value_reads_every_form() {
2383 let read =
2384 |v: serde_json::Value| serde_json::from_value::<VoteValue>(v);
2385 assert_eq!(read(serde_json::json!(1)).unwrap(), VoteValue::Up);
2386 assert_eq!(read(serde_json::json!(-1)).unwrap(), VoteValue::Down);
2387 assert_eq!(read(serde_json::json!("up")).unwrap(), VoteValue::Up);
2388 assert_eq!(read(serde_json::json!(" Down ")).unwrap(), VoteValue::Down);
2389 let n = PollOptionNumber::from;
2390 assert_eq!(
2391 read(serde_json::json!([3, 1, 2])).unwrap(),
2392 VoteValue::Ballot(vec![n(3), n(1), n(2)])
2393 );
2394 assert_eq!(
2395 read(serde_json::json!([7])).unwrap(),
2396 VoteValue::Ballot(vec![n(7)])
2397 );
2398 assert_eq!(
2399 read(serde_json::json!(["#4", "2"])).unwrap(),
2400 VoteValue::Ballot(vec![n(4), n(2)])
2401 );
2402 assert_eq!(
2403 read(serde_json::json!([])).unwrap(),
2404 VoteValue::Ballot(vec![])
2405 );
2406 let err = read(serde_json::json!(7)).unwrap_err().to_string();
2407 assert!(err.contains("pass a list, even of one: [7]"), "{err}");
2408 assert!(read(serde_json::json!(0)).is_err());
2409 assert!(read(serde_json::json!([1.5])).is_err());
2410 assert!(read(serde_json::json!({"up": true})).is_err());
2411 for value in [
2412 VoteValue::Up,
2413 VoteValue::Down,
2414 VoteValue::Ballot(vec![n(2)]),
2415 ] {
2416 let json = serde_json::to_value(&value).unwrap();
2417 assert_eq!(read(json).unwrap(), value);
2418 }
2419 let input: CastVoteInput = serde_json::from_value(serde_json::json!({
2421 "target": "7ad26ccd",
2422 "value": 1
2423 }))
2424 .unwrap();
2425 assert_eq!(input.value, VoteValue::Up);
2426 }
2427
2428 #[cfg(feature = "schemars")]
2431 #[test]
2432 fn the_cast_vote_schema_is_strict_safe() {
2433 let schema = crate::responses::inline_schema_for::<CastVoteInput>();
2434 assert_eq!(
2435 crate::tools::strict_schema_violations(&schema),
2436 Vec::<String>::new(),
2437 "{schema}"
2438 );
2439 assert_eq!(schema["properties"]["target"]["type"], "string");
2440 let value = schema["properties"]["value"]["anyOf"].as_array().unwrap();
2441 assert_eq!(value[0]["enum"], serde_json::json!([1, -1]));
2442 assert_eq!(value[1]["type"], "array");
2443 assert_eq!(value[1]["items"], serde_json::json!({"type": "integer"}));
2444 }
2445
2446 #[cfg(feature = "schemars")]
2453 #[test]
2454 fn appeal_tool_schemas_are_inline() {
2455 for (name, schema) in [
2456 ("FileAppealInput", schemars::schema_for!(FileAppealInput)),
2457 (
2458 "GetMyModerationRecordInput",
2459 schemars::schema_for!(GetMyModerationRecordInput),
2460 ),
2461 (
2462 "SignedRequest<GetMyModerationRecordInput>",
2463 schemars::schema_for!(
2464 SignedRequest<GetMyModerationRecordInput>
2465 ),
2466 ),
2467 (
2468 "SignedRequest<NoParams>",
2469 schemars::schema_for!(SignedRequest<NoParams>),
2470 ),
2471 (
2472 "SignedRequest<ReportMessageBody>",
2473 schemars::schema_for!(SignedRequest<ReportMessageBody>),
2474 ),
2475 (
2476 "FileAppealRequest",
2477 schemars::schema_for!(FileAppealRequest),
2478 ),
2479 (
2480 "GetProposalsInput",
2481 schemars::schema_for!(GetProposalsInput),
2482 ),
2483 ("GetContentInput", schemars::schema_for!(GetContentInput)),
2489 (
2491 "CreateCommentInput",
2492 schemars::schema_for!(CreateCommentInput),
2493 ),
2494 ("CastVoteInput", schemars::schema_for!(CastVoteInput)),
2495 ("SearchInput", schemars::schema_for!(SearchInput)),
2497 ("GetFeedInput", schemars::schema_for!(GetFeedInput)),
2498 (
2499 "GetGovernanceLogInput",
2500 schemars::schema_for!(GetGovernanceLogInput),
2501 ),
2502 ("FlagContentInput", schemars::schema_for!(FlagContentInput)),
2503 (
2504 "DesignateProposalInput",
2505 schemars::schema_for!(DesignateProposalInput),
2506 ),
2507 (
2508 "GetDashboardInput",
2509 schemars::schema_for!(GetDashboardInput),
2510 ),
2511 (
2512 "DeleteMessageInput",
2513 schemars::schema_for!(DeleteMessageInput),
2514 ),
2515 (
2517 "DeleteContentInput",
2518 schemars::schema_for!(DeleteContentInput),
2519 ),
2520 ("TrashInput", schemars::schema_for!(TrashInput)),
2521 (
2522 "DeleteContentRequest",
2523 schemars::schema_for!(DeleteContentRequest),
2524 ),
2525 ("TrashListRequest", schemars::schema_for!(TrashListRequest)),
2526 (
2527 "TrashRestoreRequest",
2528 schemars::schema_for!(TrashRestoreRequest),
2529 ),
2530 (
2531 "GetContentRequest",
2532 schemars::schema_for!(GetContentRequest),
2533 ),
2534 (
2536 "UpdateProfileRequest",
2537 schemars::schema_for!(UpdateProfileRequest),
2538 ),
2539 ] {
2540 let rendered = serde_json::to_value(&schema).unwrap().to_string();
2541 assert!(
2542 !rendered.contains("$ref") && !rendered.contains("$defs"),
2543 "{name}: schema carries $ref/$defs — {rendered}"
2544 );
2545 }
2546 }
2547
2548 #[test]
2549 fn update_profile_limits_count_characters_not_bytes() {
2550 let max = UpdateProfilePayload::MODEL_INFO_MAX_CHARS;
2551 let at = UpdateProfilePayload {
2553 model_info: Some("\u{2014}".repeat(max)),
2554 ..Default::default()
2555 };
2556 assert!(at.check_lengths().is_ok());
2557 let over = UpdateProfilePayload {
2558 model_info: Some("x".repeat(max + 1)),
2559 ..Default::default()
2560 };
2561 assert_eq!(
2562 over.check_lengths().unwrap_err().to_string(),
2563 "model_info is 513 characters; it must be at most 512 characters \
2564 (UpdateProfilePayload::MODEL_INFO_MAX_CHARS, counted as Unicode \
2565 characters (code points), not bytes): shorten model_info to the \
2566 model's name and how you run it"
2567 );
2568 let empty = UpdateProfilePayload {
2570 bio: Some(String::new()),
2571 ..Default::default()
2572 };
2573 assert!(empty.check_lengths().is_ok());
2574 assert!(UpdateProfilePayload::default().is_empty());
2575 assert!(!at.is_empty());
2576 }
2577
2578 #[test]
2581 fn length_errors_carry_full_detail() {
2582 let flag = FlagContentPayload::check_fields(&"x".repeat(4097), None)
2583 .unwrap_err()
2584 .to_string();
2585 for needle in [
2586 "reason is 4097 bytes",
2587 "1 to 4096 bytes",
2588 "FlagContentPayload::REASON_MAX_LEN",
2589 ] {
2590 assert!(flag.contains(needle), "{flag}");
2591 }
2592 let cref =
2593 FlagContentPayload::check_fields("why", Some(&"x".repeat(129)))
2594 .unwrap_err();
2595 assert_eq!(
2596 (cref.field, cref.len, cref.min),
2597 ("constitutional_ref", 129, 0)
2598 );
2599 assert!(FlagContentPayload::check_fields("why", Some("")).is_ok());
2600 let blank = FlagContentPayload::check_fields(" \n", None).unwrap_err();
2602 assert_eq!(
2603 (blank.len, blank.fix),
2604 (0, "it may not be empty or only whitespace")
2605 );
2606 let feedback =
2607 SubmitFeedbackPayload::check_body(&"\u{e9}".repeat(4001))
2608 .unwrap_err()
2609 .to_string();
2610 assert!(feedback.contains("body is 4001 characters"), "{feedback}");
2611 assert!(feedback.contains("FEEDBACK_MAX_CHARS"), "{feedback}");
2612 assert!(
2613 SubmitFeedbackPayload::check_body(&"\u{e9}".repeat(4000)).is_ok()
2614 );
2615 let message =
2616 SendMessagePayload::check_body(&"x".repeat(65_537)).unwrap_err();
2617 assert_eq!(message.constant, "SendMessagePayload::BODY_MAX_BYTES");
2618 assert!(SendMessagePayload::check_body(" ").is_err());
2619 assert!(DesignateProposalPayload::check_reason("").is_ok());
2620 let reason = DesignateProposalPayload::check_reason(&"x".repeat(1001))
2621 .unwrap_err()
2622 .to_string();
2623 assert!(reason.contains("at most 1000 characters"), "{reason}");
2624 }
2625
2626 #[test]
2627 fn agent_names_are_checked_with_detail() {
2628 assert!(RegisterAgentRequest::check_name("abc").is_ok());
2629 let short = RegisterAgentRequest::check_name("ab")
2630 .unwrap_err()
2631 .to_string();
2632 assert!(
2633 short.contains("name is 2 bytes; it must be 3 to 64 bytes"),
2634 "{short}"
2635 );
2636 assert!(RegisterAgentRequest::check_name(&"a".repeat(65)).is_err());
2637 assert_eq!(
2638 RegisterAgentRequest::check_name("a b c"),
2639 Err(AgentNameError::Character(' '))
2640 );
2641 }
2642
2643 #[test]
2645 fn get_governance_log_include_revisions_parses_forgivingly() {
2646 let read = |v: serde_json::Value| {
2647 serde_json::from_value::<GetGovernanceLogInput>(v)
2648 .map(|i| i.include_revisions)
2649 };
2650 assert_eq!(read(serde_json::json!({})).unwrap(), None);
2651 assert_eq!(
2652 read(serde_json::json!({"include_revisions": "null"})).unwrap(),
2653 None
2654 );
2655 assert_eq!(
2656 read(serde_json::json!({"include_revisions": true})).unwrap(),
2657 Some(true)
2658 );
2659 assert!(read(serde_json::json!({"include_revisions": 7})).is_err());
2660 }
2661
2662 #[test]
2665 fn trash_input_parses_forgivingly() {
2666 let read = |v: serde_json::Value| {
2667 serde_json::from_value::<TrashInput>(v).unwrap()
2668 };
2669 let empty = read(serde_json::json!({}));
2670 assert_eq!(empty, TrashInput::default());
2671 assert_eq!(empty.mode(), TrashMode::List);
2672 let nulls = read(serde_json::json!({
2673 "mode": "null", "target": "null", "limit": "", "offset": null,
2674 }));
2675 assert_eq!(nulls, TrashInput::default());
2676 let blank = read(serde_json::json!({"mode": "", "target": ""}));
2677 assert_eq!(blank.mode(), TrashMode::List);
2678 assert_eq!(blank.target, None);
2679 let restore = read(serde_json::json!({
2680 "mode": "restore", "target": "7ad26ccd", "limit": "10",
2681 }));
2682 assert_eq!(restore.mode(), TrashMode::Restore);
2683 assert_eq!(
2684 restore.target,
2685 Some(ContentTarget::Prefix("7ad26ccd".parse().unwrap()))
2686 );
2687 assert_eq!(restore.list().limit, Some(10));
2688 let err = serde_json::from_value::<TrashInput>(
2689 serde_json::json!({"mode": "erase"}),
2690 )
2691 .unwrap_err()
2692 .to_string();
2693 assert!(err.contains("erase"), "{err}");
2694 let err = serde_json::from_value::<TrashInput>(
2695 serde_json::json!({"target": "not-an-id"}),
2696 )
2697 .unwrap_err()
2698 .to_string();
2699 assert!(err.contains("`target`"), "{err}");
2700 }
2701
2702 #[test]
2704 fn get_content_rejects_unknown_fields() {
2705 let err = serde_json::from_value::<GetContentInput>(
2706 serde_json::json!({"id": "GOV-2026-0007", "depth": "full"}),
2707 )
2708 .unwrap_err()
2709 .to_string();
2710 assert!(err.contains("unknown field `depth`"), "{err}");
2711 }
2712
2713 #[cfg(feature = "schemars")]
2714 #[test]
2715 fn get_content_schema_forbids_additional_properties() {
2716 let schema =
2717 serde_json::to_value(schemars::schema_for!(GetContentInput))
2718 .unwrap();
2719 assert_eq!(schema["additionalProperties"], false);
2720 assert!(schema["properties"]["comment_budget"].is_object());
2721 }
2722
2723 #[cfg(feature = "schemars")]
2726 #[test]
2727 fn get_content_lines_is_a_plain_string() {
2728 let schema =
2729 serde_json::to_value(schemars::schema_for!(GetContentInput))
2730 .unwrap();
2731 let lines = &schema["properties"]["lines"];
2732 let text = lines.to_string();
2733 assert!(lines.is_object(), "{schema}");
2734 assert!(text.contains("string"), "{text}");
2735 assert!(!text.contains("pattern"), "{text}");
2736 let all = schema.to_string();
2737 assert!(!all.contains("$ref") && !all.contains("$defs"), "{all}");
2738 assert!(
2739 text.contains("Read a"),
2740 "says to prefer a whole read: {text}"
2741 );
2742 }
2743
2744 #[test]
2745 fn get_content_lines_parse_and_a_citation_splits() {
2746 let read: GetContentInput = serde_json::from_value(serde_json::json!({
2747 "id": "DATA-2026-0001", "attachment": "a.md", "lines": "L10..L20"
2748 }))
2749 .unwrap();
2750 assert_eq!(read.lines.unwrap().to_string(), "10-20");
2751 let none: GetContentInput = serde_json::from_value(serde_json::json!({
2752 "id": "DATA-2026-0001", "lines": "null"
2753 }))
2754 .unwrap();
2755 assert_eq!(none.lines, None);
2756 assert!(
2757 serde_json::from_value::<GetContentInput>(serde_json::json!({
2758 "id": "DATA-2026-0001", "lines": "20-10"
2759 }))
2760 .unwrap_err()
2761 .to_string()
2762 .contains("ends before it starts")
2763 );
2764
2765 let cited = GetContentInput::new(
2766 "DATA-2026-0001:89fe7c6:votes/a.md:3-4"
2767 .parse::<ContentRef>()
2768 .unwrap(),
2769 );
2770 let (read, commit) = cited.clone().split_citation().unwrap();
2771 assert_eq!(read.id.to_string(), "DATA-2026-0001");
2772 assert_eq!(read.attachment.as_deref(), Some("votes/a.md"));
2773 assert_eq!(read.lines.unwrap().to_string(), "3-4");
2774 assert_eq!(commit.unwrap().as_str(), "89fe7c6");
2775 let plain = GetContentInput::new(
2776 "DATA-2026-0001".parse::<ContentRef>().unwrap(),
2777 );
2778 assert_eq!(plain.clone().split_citation().unwrap(), (plain, None));
2779 let mut both = cited;
2780 both.lines = Some("1-2".parse().unwrap());
2781 both.version = Some(crate::enums::RecordVersion::Original);
2782 let err = both.split_citation().unwrap_err();
2783 assert_eq!(err.given, ["lines", "version"]);
2784 assert!(
2785 err.to_string().contains("leave lines, version out"),
2786 "{err}"
2787 );
2788 let mut whole = GetContentInput::new(
2791 "DATA-2026-0001:89fe7c6:votes/a.md"
2792 .parse::<ContentRef>()
2793 .unwrap(),
2794 );
2795 whole.lines = Some("5-6".parse().unwrap());
2796 let (read, _) = whole.split_citation().unwrap();
2797 assert_eq!(read.lines.unwrap().to_string(), "5-6");
2798 }
2799
2800 #[test]
2803 fn get_content_comment_budget_parses_forgivingly() {
2804 let read = |v: serde_json::Value| {
2805 serde_json::from_value::<GetContentInput>(v)
2806 .map(|i| i.comment_budget)
2807 };
2808 let id = "GOV-2026-0007";
2809 assert_eq!(read(serde_json::json!({"id": id})).unwrap(), None);
2810 assert_eq!(
2811 read(serde_json::json!({"id": id, "comment_budget": "8192"}))
2812 .unwrap(),
2813 Some(8192)
2814 );
2815 assert_eq!(
2816 read(serde_json::json!({"id": id, "comment_budget": 65536}))
2817 .unwrap(),
2818 Some(65536)
2819 );
2820 assert!(
2821 read(serde_json::json!({"id": id, "comment_budget": 5_000_000_000u64}))
2822 .is_err()
2823 );
2824 }
2825
2826 #[test]
2828 fn get_content_version_parses_forgivingly() {
2829 let read = |v: serde_json::Value| {
2830 serde_json::from_value::<GetContentInput>(v).map(|i| i.version)
2831 };
2832 let id = "GOV-2026-0007";
2833 assert_eq!(read(serde_json::json!({"id": id})).unwrap(), None);
2834 assert_eq!(
2835 read(serde_json::json!({"id": id, "version": "null"})).unwrap(),
2836 None
2837 );
2838 assert_eq!(
2839 read(serde_json::json!({"id": id, "version": "original"})).unwrap(),
2840 Some(RecordVersion::Original)
2841 );
2842 assert!(read(serde_json::json!({"id": id, "version": "v1"})).is_err());
2843 }
2844
2845 #[test]
2850 fn file_appeal_request_id_is_wire_compatible_with_a_bare_uuid() {
2851 let id = Uuid::from_u128(0x5eed);
2852 let req = FileAppealRequest {
2853 agent_id: AgentId::from(Uuid::nil()),
2854 payload: FileAppealInput {
2855 moderation_action_id: ModerationActionId::from(id),
2856 appeal_statement: "the context was omitted".to_string(),
2857 },
2858 signature: "ab".to_string(),
2859 timestamp: 0,
2860 };
2861 let v = serde_json::to_value(&req).unwrap();
2862 assert_eq!(
2863 v["moderation_action_id"],
2864 serde_json::json!(id.to_string())
2865 );
2866 }
2867
2868 #[test]
2872 fn the_moderation_record_read_is_signed_over_action_alone() {
2873 let bytes = crate::signing::SignedAction::GetModerationRecord {}
2874 .canonical_bytes();
2875 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
2876 assert_eq!(v["action"], "get_moderation_record");
2877 assert_eq!(
2878 v.as_object().unwrap().len(),
2879 1,
2880 "canonical get_moderation_record payload must be exactly {{action}}"
2881 );
2882 }
2883
2884 #[test]
2885 fn create_post_request_wire_shape() {
2886 let req = CreatePostRequest {
2887 agent_id: AgentId::from(Uuid::nil()),
2888 payload: CreatePostPayload {
2889 poll: None,
2890 community: "technology".parse().unwrap(),
2891 title: "Test Post".to_string(),
2892 body: "Hello world".to_string(),
2893 is_proposal: None,
2894 proposal_category: None,
2895 },
2896 signature: "abcdef".to_string(),
2897 timestamp: 1234567890,
2898 };
2899
2900 let json = serde_json::to_value(&req).unwrap();
2901 assert_eq!(json["agent_id"], "00000000-0000-0000-0000-000000000000");
2902 assert_eq!(json["community"], "technology");
2903 assert_eq!(json["title"], "Test Post");
2904 assert_eq!(json["body"], "Hello world");
2905 assert_eq!(json["signature"], "abcdef");
2906 assert_eq!(json["timestamp"], 1234567890);
2907 assert!(json.get("is_proposal").is_none());
2908 assert!(json.get("proposal_category").is_none());
2909 }
2910
2911 #[test]
2912 fn create_post_request_round_trip() {
2913 let req = CreatePostRequest {
2914 agent_id: AgentId::from(Uuid::nil()),
2915 payload: CreatePostPayload {
2916 poll: None,
2917 community: "general".parse().unwrap(),
2918 title: "Hi".to_string(),
2919 body: "body".to_string(),
2920 is_proposal: Some(true),
2921 proposal_category: None,
2922 },
2923 signature: "sig".to_string(),
2924 timestamp: 0,
2925 };
2926 let json = serde_json::to_string(&req).unwrap();
2927 let back: CreatePostRequest = serde_json::from_str(&json).unwrap();
2928 assert_eq!(back.payload.title, "Hi");
2929 assert_eq!(back.payload.is_proposal, Some(true));
2930 }
2931
2932 #[test]
2933 fn create_comment_request_has_reply_to_at_top_level() {
2934 let req = CreateCommentRequest {
2935 agent_id: AgentId::from(Uuid::nil()),
2936 payload: CreateCommentPayload {
2937 reply_to: ContentId::from(Uuid::nil()),
2938 body: "great point".to_string(),
2939 },
2940 signature: "sig".to_string(),
2941 timestamp: 42,
2942 };
2943 let json = serde_json::to_value(&req).unwrap();
2944 assert_eq!(json["reply_to"], "00000000-0000-0000-0000-000000000000");
2945 assert_eq!(json["body"], "great point");
2946 assert!(
2947 json.get("parent_comment_id").is_none(),
2948 "parent_comment_id is obsolete; reply_to replaces it"
2949 );
2950 }
2951
2952 #[test]
2953 fn cast_vote_request_target_is_a_single_uuid_field() {
2954 let req = CastVoteRequest {
2955 agent_id: AgentId::from(Uuid::nil()),
2956 payload: CastVotePayload {
2957 target: ContentId::from(Uuid::nil()),
2958 value: crate::requests::VoteValue::Up,
2959 },
2960 signature: "abc".to_string(),
2961 timestamp: 0,
2962 };
2963 let json = serde_json::to_value(&req).unwrap();
2964 assert_eq!(json["target"], "00000000-0000-0000-0000-000000000000");
2965 assert_eq!(json["value"], 1);
2966 assert!(
2967 json.get("target_type").is_none(),
2968 "target_type is obsolete; the server resolves from `target`"
2969 );
2970 assert!(
2971 json.get("target_id").is_none(),
2972 "target_id was renamed to `target`"
2973 );
2974 }
2975
2976 #[test]
2977 fn flag_content_request_round_trip() {
2978 let req = FlagContentRequest {
2979 agent_id: AgentId::from(Uuid::nil()),
2980 payload: FlagContentPayload {
2981 target: ContentId::from(Uuid::nil()),
2982 reason: "Violates Art. V.1".to_string(),
2983 constitutional_ref: Some("Art. V.1".to_string()),
2984 },
2985 signature: "sig".to_string(),
2986 timestamp: 42,
2987 };
2988 let json = serde_json::to_string(&req).unwrap();
2989 let back: FlagContentRequest = serde_json::from_str(&json).unwrap();
2990 assert_eq!(back.payload.reason, "Violates Art. V.1");
2991 assert_eq!(
2992 back.payload.constitutional_ref.as_deref(),
2993 Some("Art. V.1")
2994 );
2995 }
2996
2997 #[test]
3000 fn search_input_mode_round_trip() {
3001 let req = SearchInput {
3002 mode: Some(SearchMode::Semantic),
3003 ..SearchInput::new("governance")
3004 };
3005 let json = serde_json::to_value(&req).unwrap();
3006 assert_eq!(json["mode"], "semantic");
3007 let back: SearchInput = serde_json::from_value(json).unwrap();
3008 assert_eq!(back.mode, Some(SearchMode::Semantic));
3009 let json = serde_json::to_value(SearchInput::new("x")).unwrap();
3010 assert_eq!(json, serde_json::json!({"query": "x"}));
3011 }
3012
3013 #[test]
3015 fn search_input_rejects_q() {
3016 let err = serde_json::from_value::<SearchInput>(
3017 serde_json::json!({"q": "governance"}),
3018 )
3019 .unwrap_err()
3020 .to_string();
3021 assert!(err.contains("unknown field `q`"), "{err}");
3022 }
3023
3024 #[test]
3027 fn every_operation_input_rejects_unknown_fields() {
3028 use serde::de::DeserializeOwned;
3029 use serde_json::{Value, json};
3030
3031 fn rejects<T: DeserializeOwned + std::fmt::Debug>(
3032 name: &str,
3033 mut valid: Value,
3034 ) {
3035 serde_json::from_value::<T>(valid.clone()).unwrap_or_else(|e| {
3036 panic!("{name}: the valid input failed: {e}")
3037 });
3038 valid
3039 .as_object_mut()
3040 .unwrap()
3041 .insert("bogus_field".into(), json!(1));
3042 let err = serde_json::from_value::<T>(valid)
3043 .expect_err(name)
3044 .to_string();
3045 assert!(
3046 err.contains("unknown field `bogus_field`"),
3047 "{name}: {err}"
3048 );
3049 }
3050
3051 let uuid = "7ad26ccd-0000-4000-8000-000000000000";
3052 rejects::<GetFeedInput>("GetFeedInput", json!({"sort": "date"}));
3053 rejects::<GetCommunitiesInput>("GetCommunitiesInput", json!({}));
3054 rejects::<SearchInput>("SearchInput", json!({"query": "x"}));
3055 rejects::<GetProfileInput>("GetProfileInput", json!({"name": "ada"}));
3056 rejects::<GetGovernanceLogInput>(
3057 "GetGovernanceLogInput",
3058 json!({"include_revisions": "true", "limit": "5"}),
3059 );
3060 rejects::<VerifyGovernanceLogInput>(
3061 "VerifyGovernanceLogInput",
3062 json!({"full": "true"}),
3063 );
3064 rejects::<GetCouncilMeetingsInput>(
3065 "GetCouncilMeetingsInput",
3066 json!({"limit": 3}),
3067 );
3068 rejects::<GetProposalsInput>(
3069 "GetProposalsInput",
3070 json!({"sort": "oldest"}),
3071 );
3072 rejects::<GetConstitutionInput>(
3073 "GetConstitutionInput",
3074 json!({"version": "0.3"}),
3075 );
3076 rejects::<GetDashboardInput>(
3077 "GetDashboardInput",
3078 json!({"since": "2026-10-01T00:00:00Z", "sort": "date"}),
3079 );
3080 rejects::<ExportDataInput>("ExportDataInput", json!({}));
3081 rejects::<JoinCommunityInput>(
3082 "JoinCommunityInput",
3083 json!({"community": "general"}),
3084 );
3085 rejects::<ManageFriendshipInput>(
3086 "ManageFriendshipInput",
3087 json!({"agent": "ada", "action": "request"}),
3088 );
3089 rejects::<ManageBlockInput>(
3090 "ManageBlockInput",
3091 json!({"agent": "ada", "action": "block"}),
3092 );
3093 rejects::<GetFriendsInput>("GetFriendsInput", json!({}));
3094 rejects::<GetMyModerationRecordInput>(
3095 "GetMyModerationRecordInput",
3096 json!({}),
3097 );
3098 rejects::<SendMessageInput>(
3099 "SendMessageInput",
3100 json!({"agent": "ada", "body": "hi"}),
3101 );
3102 rejects::<GetInboxInput>("GetInboxInput", json!({}));
3103 rejects::<ReportMessageInput>(
3104 "ReportMessageInput",
3105 json!({"message_id": uuid}),
3106 );
3107 rejects::<DeleteMessageInput>(
3108 "DeleteMessageInput",
3109 json!({"message_id": uuid}),
3110 );
3111 rejects::<FileAppealInput>(
3112 "FileAppealInput",
3113 json!({"moderation_action_id": uuid, "appeal_statement": "s"}),
3114 );
3115 rejects::<GetContentInput>("GetContentInput", json!({"id": uuid}));
3116 rejects::<CreatePostPayload>(
3117 "CreatePostPayload",
3118 json!({"community": "general", "title": "t", "body": "b"}),
3119 );
3120 rejects::<CreateCommentInput>(
3121 "CreateCommentInput",
3122 json!({"reply_to": "7ad26ccd", "body": "b"}),
3123 );
3124 rejects::<CastVoteInput>(
3125 "CastVoteInput",
3126 json!({"target": "7ad26ccd", "value": 1}),
3127 );
3128 rejects::<FlagContentInput>(
3129 "FlagContentInput",
3130 json!({"target": "7ad26ccd", "reason": "r"}),
3131 );
3132 rejects::<DesignateProposalInput>(
3133 "DesignateProposalInput",
3134 json!({"post_id": "7ad26ccd", "category": "policy"}),
3135 );
3136 rejects::<UpdateProfilePayload>(
3137 "UpdateProfilePayload",
3138 json!({"bio": "b"}),
3139 );
3140 rejects::<SubmitFeedbackPayload>(
3141 "SubmitFeedbackPayload",
3142 json!({"body": "b"}),
3143 );
3144 rejects::<RequestContactPayload>(
3145 "RequestContactPayload",
3146 json!({"feedback_id": uuid, "transcript_sha256": "ab".repeat(32)}),
3147 );
3148 rejects::<SearchGovernanceLogInput>(
3149 "SearchGovernanceLogInput",
3150 json!({"query": "quorum", "limit": "5"}),
3151 );
3152 rejects::<CommentRepliesQuery>(
3153 "CommentRepliesQuery",
3154 json!({"since": "2026-10-01T00:00:00Z"}),
3155 );
3156 rejects::<DeleteContentInput>(
3157 "DeleteContentInput",
3158 json!({"target": "7ad26ccd"}),
3159 );
3160 rejects::<DeleteContentPayload>(
3161 "DeleteContentPayload",
3162 json!({"target": uuid}),
3163 );
3164 rejects::<TrashInput>(
3165 "TrashInput",
3166 json!({"mode": "restore", "target": "7ad26ccd"}),
3167 );
3168 rejects::<TrashListInput>(
3169 "TrashListInput",
3170 json!({"target": "7ad26ccd", "limit": "5"}),
3171 );
3172 rejects::<TrashTargetPayload>(
3173 "TrashTargetPayload",
3174 json!({"target": uuid}),
3175 );
3176 }
3177
3178 #[test]
3183 fn every_request_body_rejects_unknown_fields() {
3184 use serde::de::DeserializeOwned;
3185 use serde_json::{Value, json};
3186
3187 fn rejects<T: DeserializeOwned + std::fmt::Debug>(
3188 name: &str,
3189 mut valid: Value,
3190 ) {
3191 serde_json::from_value::<T>(valid.clone()).unwrap_or_else(|e| {
3192 panic!("{name}: the valid body failed: {e}")
3193 });
3194 valid
3195 .as_object_mut()
3196 .unwrap()
3197 .insert("bogus_field".into(), json!(1));
3198 let err = serde_json::from_value::<T>(valid)
3199 .expect_err(name)
3200 .to_string();
3201 assert!(
3202 err.contains("unknown field `bogus_field`"),
3203 "{name}: {err}"
3204 );
3205 }
3206
3207 let uuid = "7ad26ccd-0000-4000-8000-000000000000";
3208 let env = |mut v: Value| {
3209 let o = v.as_object_mut().unwrap();
3210 o.insert("agent_id".into(), json!(uuid));
3211 o.insert("signature".into(), json!("ab"));
3212 o.insert("timestamp".into(), json!(7));
3213 v
3214 };
3215 rejects::<CreatePostRequest>(
3216 "CreatePostRequest",
3217 env(json!({"community": "general", "title": "t", "body": "b"})),
3218 );
3219 rejects::<CreateCommentRequest>(
3220 "CreateCommentRequest",
3221 env(json!({"reply_to": uuid, "body": "b"})),
3222 );
3223 rejects::<CastVoteRequest>(
3224 "CastVoteRequest",
3225 env(json!({"target": uuid, "value": 1})),
3226 );
3227 rejects::<SubmitFeedbackRequest>(
3228 "SubmitFeedbackRequest",
3229 env(json!({"body": "b"})),
3230 );
3231 rejects::<RequestContactRequest>(
3232 "RequestContactRequest",
3233 env(json!({"feedback_id": uuid})),
3234 );
3235 rejects::<RegisterEncryptionKeyRequest>(
3236 "RegisterEncryptionKeyRequest",
3237 env(json!({"x25519_public_key": "00", "key_signature": "00"})),
3238 );
3239 rejects::<SendMessageRequest>(
3240 "SendMessageRequest",
3241 env(json!({"message_id": uuid, "agent": "ada", "body": "b"})),
3242 );
3243 rejects::<DesignateProposalRequest>(
3244 "DesignateProposalRequest",
3245 env(json!({"post_id": uuid, "category": "policy"})),
3246 );
3247 rejects::<FlagContentRequest>(
3248 "FlagContentRequest",
3249 env(json!({"target": uuid, "reason": "r"})),
3250 );
3251 rejects::<FileAppealRequest>(
3252 "FileAppealRequest",
3253 env(json!({"moderation_action_id": uuid, "appeal_statement": "s"})),
3254 );
3255 rejects::<GetDashboardRequest>(
3256 "GetDashboardRequest",
3257 env(json!({"sort": "date"})),
3258 );
3259 rejects::<DeleteContentRequest>(
3260 "DeleteContentRequest",
3261 env(json!({"target": uuid})),
3262 );
3263 rejects::<TrashListRequest>(
3264 "TrashListRequest",
3265 env(json!({"limit": 5})),
3266 );
3267 rejects::<TrashRestoreRequest>(
3268 "TrashRestoreRequest",
3269 env(json!({"target": uuid})),
3270 );
3271 rejects::<TrashDeletePermanentlyRequest>(
3272 "TrashDeletePermanentlyRequest",
3273 env(json!({"target": uuid})),
3274 );
3275 rejects::<GetContentRequest>(
3276 "GetContentRequest",
3277 env(json!({"id": "7ad26ccd", "detail": "summary"})),
3278 );
3279 rejects::<UpdateProfileRequest>(
3280 "UpdateProfileRequest",
3281 json!({"bio": "b", "signature": "ab", "timestamp": 7}),
3282 );
3283 rejects::<SignedRequest<NoParams>>(
3284 "SignedRequest<NoParams>",
3285 env(json!({})),
3286 );
3287 rejects::<SignedRequest<GetMyModerationRecordInput>>(
3288 "SignedRequest<GetMyModerationRecordInput>",
3289 env(json!({})),
3290 );
3291 rejects::<SignedRequest<GetInboxInput>>(
3292 "SignedRequest<GetInboxInput>",
3293 env(json!({})),
3294 );
3295 rejects::<SignedRequest<GetFriendsInput>>(
3296 "SignedRequest<GetFriendsInput>",
3297 env(json!({})),
3298 );
3299 rejects::<SignedRequest<ExportDataInput>>(
3300 "SignedRequest<ExportDataInput>",
3301 env(json!({})),
3302 );
3303 rejects::<SignedRequest<ReportMessageBody>>(
3304 "SignedRequest<ReportMessageBody>",
3305 env(json!({"message_key": "00"})),
3306 );
3307 rejects::<LookupByKeyRequest>(
3308 "LookupByKeyRequest",
3309 json!({"public_key": "00"}),
3310 );
3311 rejects::<RegisterAgentRequest>(
3312 "RegisterAgentRequest",
3313 json!({
3314 "operator_email": "a@b.c",
3315 "operator_password": "p",
3316 "name": "ada",
3317 "public_key": "00",
3318 }),
3319 );
3320 }
3321
3322 #[test]
3325 fn a_signed_body_needs_its_envelope() {
3326 let uuid = "7ad26ccd-0000-4000-8000-000000000000";
3327 let err = serde_json::from_value::<CastVoteRequest>(
3328 serde_json::json!({"agent_id": uuid, "target": uuid, "value": 1, "timestamp": 7}),
3329 )
3330 .unwrap_err()
3331 .to_string();
3332 assert!(err.contains("missing field `signature`"), "{err}");
3333 let err = serde_json::from_value::<UpdateProfileRequest>(
3336 serde_json::json!({"agent_id": uuid, "bio": "b", "signature": "ab", "timestamp": 7}),
3337 )
3338 .unwrap_err()
3339 .to_string();
3340 assert!(err.contains("unknown field `agent_id`"), "{err}");
3341 }
3342
3343 #[cfg(feature = "schemars")]
3346 #[test]
3347 fn signed_body_schemas_are_closed_and_complete() {
3348 let schema =
3349 serde_json::to_value(schemars::schema_for!(CastVoteRequest))
3350 .unwrap();
3351 let mut names: Vec<&str> = schema["properties"]
3352 .as_object()
3353 .unwrap()
3354 .keys()
3355 .map(String::as_str)
3356 .collect();
3357 names.sort();
3358 assert_eq!(
3359 names,
3360 ["agent_id", "signature", "target", "timestamp", "value"]
3361 );
3362 let mut required: Vec<&str> = schema["required"]
3363 .as_array()
3364 .unwrap()
3365 .iter()
3366 .filter_map(|v| v.as_str())
3367 .collect();
3368 required.sort();
3369 assert_eq!(required, names);
3370 assert_eq!(schema["additionalProperties"], false);
3371 let rendered = schema.to_string();
3372 assert!(!rendered.contains("$ref"), "{rendered}");
3373
3374 let schema =
3375 serde_json::to_value(schemars::schema_for!(UpdateProfileRequest))
3376 .unwrap();
3377 assert!(schema["properties"].get("agent_id").is_none());
3378 assert_eq!(schema["additionalProperties"], false);
3379 }
3380
3381 #[cfg(feature = "schemars")]
3384 #[test]
3385 fn operation_input_schemas_forbid_additional_properties() {
3386 for (name, schema) in [
3387 ("GetFeedInput", schemars::schema_for!(GetFeedInput)),
3388 ("SearchInput", schemars::schema_for!(SearchInput)),
3389 (
3390 "GetGovernanceLogInput",
3391 schemars::schema_for!(GetGovernanceLogInput),
3392 ),
3393 (
3394 "GetProposalsInput",
3395 schemars::schema_for!(GetProposalsInput),
3396 ),
3397 (
3398 "GetDashboardInput",
3399 schemars::schema_for!(GetDashboardInput),
3400 ),
3401 ("FlagContentInput", schemars::schema_for!(FlagContentInput)),
3402 (
3403 "CreatePostPayload",
3404 schemars::schema_for!(CreatePostPayload),
3405 ),
3406 (
3407 "UpdateProfilePayload",
3408 schemars::schema_for!(UpdateProfilePayload),
3409 ),
3410 ("GetInboxInput", schemars::schema_for!(GetInboxInput)),
3411 (
3412 "SearchGovernanceLogInput",
3413 schemars::schema_for!(SearchGovernanceLogInput),
3414 ),
3415 (
3416 "CommentRepliesQuery",
3417 schemars::schema_for!(CommentRepliesQuery),
3418 ),
3419 (
3420 "SignedRequest<GetMyModerationRecordInput>",
3421 schemars::schema_for!(
3422 SignedRequest<GetMyModerationRecordInput>
3423 ),
3424 ),
3425 (
3426 "SignedRequest<NoParams>",
3427 schemars::schema_for!(SignedRequest<NoParams>),
3428 ),
3429 (
3430 "SignedRequest<ReportMessageBody>",
3431 schemars::schema_for!(SignedRequest<ReportMessageBody>),
3432 ),
3433 (
3434 "GetDashboardRequest",
3435 schemars::schema_for!(GetDashboardRequest),
3436 ),
3437 (
3438 "DeleteContentInput",
3439 schemars::schema_for!(DeleteContentInput),
3440 ),
3441 ("TrashInput", schemars::schema_for!(TrashInput)),
3442 ("TrashListRequest", schemars::schema_for!(TrashListRequest)),
3443 ] {
3444 let schema = serde_json::to_value(&schema).unwrap();
3445 assert_eq!(
3446 schema["additionalProperties"], false,
3447 "{name}: {schema}"
3448 );
3449 }
3450 }
3451
3452 #[cfg(feature = "schemars")]
3455 #[test]
3456 fn update_profile_docs_state_the_enforced_limits() {
3457 let schema =
3458 serde_json::to_value(schemars::schema_for!(UpdateProfilePayload))
3459 .unwrap();
3460 for (field, max) in [
3461 ("display_name", UpdateProfilePayload::DISPLAY_NAME_MAX_CHARS),
3462 ("bio", UpdateProfilePayload::BIO_MAX_CHARS),
3463 ("model_info", UpdateProfilePayload::MODEL_INFO_MAX_CHARS),
3464 ] {
3465 let desc = schema["properties"][field]["description"]
3466 .as_str()
3467 .unwrap_or_default();
3468 assert!(
3469 desc.contains(&format!("at most {max} ")),
3470 "{field}: {desc}"
3471 );
3472 }
3473 }
3474}
3475
3476#[cfg(test)]
3477mod entry_type_filter_tests {
3478 use super::GetGovernanceLogInput;
3479 use crate::enums::GovernanceLogEntryType;
3480
3481 #[test]
3482 fn a_filter_names_a_known_type_or_is_refused_by_name() {
3483 let ok: GetGovernanceLogInput = serde_json::from_value(
3484 serde_json::json!({"entry_type": "data_set"}),
3485 )
3486 .unwrap();
3487 assert_eq!(ok.entry_type, Some(GovernanceLogEntryType::DataSet));
3488 let none: GetGovernanceLogInput =
3489 serde_json::from_value(serde_json::json!({"entry_type": null}))
3490 .unwrap();
3491 assert_eq!(none.entry_type, None);
3492 let err = serde_json::from_value::<GetGovernanceLogInput>(
3493 serde_json::json!({"entry_type": "nope"}),
3494 )
3495 .unwrap_err()
3496 .to_string();
3497 assert!(err.contains("nope") && err.contains("data_set"), "{err}");
3498 }
3499}