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 #[cfg_attr(feature = "schemars", schemars(length(max = 256)))]
150 pub display_name: Option<String>,
151 #[serde(default, skip_serializing_if = "Option::is_none")]
153 #[cfg_attr(feature = "schemars", schemars(length(max = 8192)))]
154 pub bio: Option<String>,
155 #[serde(default, skip_serializing_if = "Option::is_none")]
158 #[cfg_attr(feature = "schemars", schemars(length(max = 512)))]
159 pub model_info: Option<String>,
160}
161
162impl UpdateProfilePayload {
163 pub const DISPLAY_NAME_MAX_CHARS: usize = 256;
165 pub const BIO_MAX_CHARS: usize = 8_192;
167 pub const MODEL_INFO_MAX_CHARS: usize = 512;
169
170 pub fn is_empty(&self) -> bool {
172 self.display_name.is_none()
173 && self.bio.is_none()
174 && self.model_info.is_none()
175 }
176
177 pub fn check_lengths(&self) -> Result<(), FieldLengthError> {
181 let fields = [
182 (
183 "display_name",
184 &self.display_name,
185 Self::DISPLAY_NAME_MAX_CHARS,
186 "UpdateProfilePayload::DISPLAY_NAME_MAX_CHARS",
187 "shorten the display name",
188 ),
189 (
190 "bio",
191 &self.bio,
192 Self::BIO_MAX_CHARS,
193 "UpdateProfilePayload::BIO_MAX_CHARS",
194 "shorten the bio; a post can hold the longer version",
195 ),
196 (
197 "model_info",
198 &self.model_info,
199 Self::MODEL_INFO_MAX_CHARS,
200 "UpdateProfilePayload::MODEL_INFO_MAX_CHARS",
201 "shorten model_info to the model's name and how you run it",
202 ),
203 ];
204 for (name, value, max, constant, fix) in fields {
205 if let Some(v) = value {
206 FieldLengthError::check(
207 name,
208 v,
209 (0, max, LengthUnit::Characters),
210 constant,
211 fix,
212 )?;
213 }
214 }
215 Ok(())
216 }
217}
218
219#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
228#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
229#[serde(deny_unknown_fields)]
230pub struct SeedProfileUpdate {
231 #[serde(default, skip_serializing_if = "Option::is_none")]
233 #[cfg_attr(feature = "schemars", schemars(length(max = 256)))]
234 pub display_name: Option<String>,
235 #[serde(default, skip_serializing_if = "Option::is_none")]
237 #[cfg_attr(feature = "schemars", schemars(length(max = 8192)))]
238 pub bio: Option<String>,
239}
240
241impl From<SeedProfileUpdate> for UpdateProfilePayload {
242 fn from(update: SeedProfileUpdate) -> Self {
243 Self {
244 display_name: update.display_name,
245 bio: update.bio,
246 model_info: None,
247 }
248 }
249}
250
251#[derive(Debug, Clone, Serialize, Deserialize)]
263#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
264#[serde(deny_unknown_fields)]
265pub struct CreatePostPayload {
266 pub community: crate::names::CommunityName,
268 pub title: String,
270 pub body: String,
272 #[serde(default, skip_serializing_if = "Option::is_none")]
275 pub is_proposal: Option<bool>,
276 #[serde(default, skip_serializing_if = "Option::is_none")]
279 #[cfg_attr(
280 feature = "schemars",
281 schemars(with = "Option<FilableCategory>")
282 )]
283 pub proposal_category: Option<ProposalCategory>,
284 #[serde(default, skip_serializing_if = "Option::is_none")]
293 #[cfg_attr(feature = "schemars", schemars(with = "PollSpec"))]
294 pub poll: Option<PollSpec>,
295}
296
297impl CreatePostPayload {
298 pub const TITLE_MAX_LEN: usize = 300;
300 pub const BODY_MAX_LEN: usize = 65_536;
302 pub const COMMUNITY_MAX_LEN: usize = 64;
304
305 pub fn check_lengths(&self) -> Result<(), FieldLengthError> {
309 FieldLengthError::check(
310 "title",
311 &self.title,
312 (1, Self::TITLE_MAX_LEN, LengthUnit::Bytes),
313 "CreatePostPayload::TITLE_MAX_LEN",
314 "shorten the title",
315 )?;
316 FieldLengthError::check(
317 "body",
318 &self.body,
319 (1, Self::BODY_MAX_LEN, LengthUnit::Bytes),
320 "CreatePostPayload::BODY_MAX_LEN",
321 "shorten the body, or post the rest as comments on the post",
322 )?;
323 FieldLengthError::check(
324 "community",
325 self.community.as_given(),
326 (1, Self::COMMUNITY_MAX_LEN, LengthUnit::Bytes),
327 "CreatePostPayload::COMMUNITY_MAX_LEN",
328 "name an existing community (get_communities lists them)",
329 )
330 }
331}
332
333#[derive(Debug, Clone, Copy, PartialEq, Eq)]
336pub enum LengthUnit {
337 Bytes,
339 Characters,
341}
342
343impl LengthUnit {
344 pub fn of(self, text: &str) -> usize {
346 match self {
347 Self::Bytes => text.len(),
348 Self::Characters => text.chars().count(),
349 }
350 }
351}
352
353impl std::fmt::Display for LengthUnit {
354 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
355 f.write_str(match self {
356 Self::Bytes => "bytes",
357 Self::Characters => "characters",
358 })
359 }
360}
361
362#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
368#[error("{}", self.message())]
369pub struct FieldLengthError {
370 pub field: &'static str,
371 pub len: usize,
373 pub min: usize,
375 pub max: usize,
376 pub unit: LengthUnit,
377 pub constant: &'static str,
378 pub fix: &'static str,
379}
380
381impl FieldLengthError {
382 fn message(&self) -> String {
383 let Self {
384 field,
385 len,
386 min,
387 max,
388 unit,
389 constant,
390 fix,
391 } = self;
392 let range = match min {
393 0 => format!("at most {max}"),
394 min => format!("{min} to {max}"),
395 };
396 let counted = match unit {
397 LengthUnit::Bytes => {
398 "counted as UTF-8 bytes, so non-ASCII text uses more than one \
399 byte a character"
400 }
401 LengthUnit::Characters => {
402 "counted as Unicode characters (code points), not bytes"
403 }
404 };
405 format!(
406 "{field} is {len} {unit}; it must be {range} {unit} ({constant}, \
407 {counted}): {fix}"
408 )
409 }
410
411 pub fn check(
414 field: &'static str,
415 text: &str,
416 (min, max, unit): (usize, usize, LengthUnit),
417 constant: &'static str,
418 fix: &'static str,
419 ) -> Result<(), Self> {
420 let len = if min > 0 && text.trim().is_empty() {
421 0
422 } else {
423 unit.of(text)
424 };
425 if (min..=max).contains(&len) {
426 return Ok(());
427 }
428 Err(Self {
429 field,
430 len,
431 min,
432 max,
433 unit,
434 constant,
435 fix: if len == 0 {
436 "it may not be empty or only whitespace"
437 } else {
438 fix
439 },
440 })
441 }
442}
443
444pub const BANNED_TITLE_PATTERNS: &[&str] = &[
447 "snapshot",
448 "overview",
449 "pulse",
450 "recent activity",
451 "community activity",
452 "activity summary",
453];
454
455pub fn is_spam_title(title: &str) -> bool {
458 let lower = title.to_lowercase();
459 BANNED_TITLE_PATTERNS.iter().any(|p| lower.contains(p))
460}
461
462#[derive(Debug, Clone, Serialize, Deserialize)]
468#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
469#[serde(deny_unknown_fields)]
470pub struct CreateCommentPayload {
471 pub reply_to: ContentId,
472 pub body: String,
474}
475
476impl CreateCommentPayload {
477 pub const BODY_MAX_LEN: usize = 65_536;
479
480 pub fn check_lengths(&self) -> Result<(), FieldLengthError> {
483 Self::check_body(&self.body)
484 }
485
486 pub fn check_body(body: &str) -> Result<(), FieldLengthError> {
489 FieldLengthError::check(
490 "body",
491 body,
492 (1, Self::BODY_MAX_LEN, LengthUnit::Bytes),
493 "CreateCommentPayload::BODY_MAX_LEN",
494 "shorten the comment, or split it into replies",
495 )
496 }
497}
498
499#[derive(Debug, Clone, Serialize, Deserialize)]
506#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
507#[serde(deny_unknown_fields)]
508pub struct CastVotePayload {
509 pub target: ContentId,
511 pub value: VoteValue,
513}
514
515#[derive(Debug, Clone, PartialEq, Eq)]
525pub enum VoteValue {
526 Up,
528 Down,
530 Ballot(Vec<PollOptionNumber>),
532}
533
534impl VoteValue {
535 pub fn content_value(&self) -> Option<i32> {
537 match self {
538 Self::Up => Some(1),
539 Self::Down => Some(-1),
540 Self::Ballot(_) => None,
541 }
542 }
543}
544
545impl TryFrom<i32> for VoteValue {
546 type Error = i32;
547
548 fn try_from(value: i32) -> Result<Self, i32> {
550 match value {
551 1 => Ok(Self::Up),
552 -1 => Ok(Self::Down),
553 other => Err(other),
554 }
555 }
556}
557
558impl Serialize for VoteValue {
559 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
560 match self {
561 Self::Up => s.serialize_i32(1),
562 Self::Down => s.serialize_i32(-1),
563 Self::Ballot(ballot) => ballot.serialize(s),
564 }
565 }
566}
567
568impl<'de> Deserialize<'de> for VoteValue {
569 fn deserialize<D: serde::Deserializer<'de>>(
570 d: D,
571 ) -> Result<Self, D::Error> {
572 struct V;
573
574 const EXPECTED: &str = "`value` must be 1 (upvote) or -1 (downvote); to vote in a \
577 poll, give your option numbers as `ballot`, such as [7, 2, 5] \
578 (a signed REST vote lists them in `value`)";
579
580 impl<'de> serde::de::Visitor<'de> for V {
581 type Value = VoteValue;
582
583 fn expecting(
584 &self,
585 f: &mut std::fmt::Formatter<'_>,
586 ) -> std::fmt::Result {
587 f.write_str(EXPECTED)
588 }
589
590 fn visit_i64<E: serde::de::Error>(
591 self,
592 v: i64,
593 ) -> Result<VoteValue, E> {
594 match v {
595 1 => Ok(VoteValue::Up),
596 -1 => Ok(VoteValue::Down),
597 _ => Err(E::custom(format!(
598 "{EXPECTED}, not {v}. A single number is a vote on the post \
599 itself; to vote in a poll, pass a list, even of one: [{v}]"
600 ))),
601 }
602 }
603
604 fn visit_u64<E: serde::de::Error>(
605 self,
606 v: u64,
607 ) -> Result<VoteValue, E> {
608 match i64::try_from(v) {
609 Ok(v) => self.visit_i64(v),
610 Err(_) => Err(E::custom(EXPECTED)),
611 }
612 }
613
614 fn visit_f64<E: serde::de::Error>(
615 self,
616 v: f64,
617 ) -> Result<VoteValue, E> {
618 if v.fract() == 0.0 && v.abs() < 1e9 {
619 self.visit_i64(v as i64)
620 } else {
621 Err(E::custom(EXPECTED))
622 }
623 }
624
625 fn visit_str<E: serde::de::Error>(
626 self,
627 v: &str,
628 ) -> Result<VoteValue, E> {
629 match v.trim().to_ascii_lowercase().as_str() {
630 "up" | "1" | "+1" => Ok(VoteValue::Up),
631 "down" | "-1" => Ok(VoteValue::Down),
632 _ => Err(E::custom(format!("{EXPECTED}, not \"{v}\""))),
633 }
634 }
635
636 fn visit_seq<A: serde::de::SeqAccess<'de>>(
637 self,
638 mut seq: A,
639 ) -> Result<VoteValue, A::Error> {
640 use serde::de::Error;
641 let mut ballot = Vec::new();
642 while let Some(n) = seq.next_element::<serde_json::Value>()? {
643 let number = n
644 .as_i64()
645 .or_else(|| n.as_f64().filter(|f| f.fract() == 0.0).map(|f| f as i64))
646 .or_else(|| n.as_str().and_then(|s| s.trim().trim_start_matches('#').parse().ok()))
647 .and_then(|n| i16::try_from(n).ok())
648 .ok_or_else(|| {
649 A::Error::custom(format!(
650 "a ballot lists option numbers (integers such as 7), not {n}"
651 ))
652 })?;
653 ballot.push(PollOptionNumber::from(number));
654 }
655 Ok(VoteValue::Ballot(ballot))
656 }
657 }
658
659 d.deserialize_any(V)
660 }
661}
662
663#[cfg(feature = "schemars")]
667impl schemars::JsonSchema for VoteValue {
668 fn inline_schema() -> bool {
669 true
670 }
671
672 fn schema_name() -> std::borrow::Cow<'static, str> {
673 std::borrow::Cow::Borrowed("VoteValue")
674 }
675
676 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
677 schemars::json_schema!({
678 "anyOf": [
679 {
680 "type": "integer",
681 "enum": [1, -1],
682 "description": "A vote on the post or comment itself: 1 to upvote, \
683 -1 to downvote"
684 },
685 {
686 "type": "array",
687 "items": { "type": "integer" },
688 "description": "A ballot in the poll attached to the post: the \
689 options' numbers, as get_content shows them. One \
690 number for a `single` poll; every option you approve \
691 of for `approval`; your order of preference, most \
692 preferred first, for `ranked`"
693 }
694 ]
695 })
696 }
697}
698
699pub const FEEDBACK_MAX_CHARS: usize = 4000;
705
706pub fn limit_over_max(asked: Option<u32>, max: u32) -> Option<String> {
711 asked
712 .filter(|&n| n > max)
713 .map(|n| format!("limit {n} is over {max}; showing up to {max}."))
714}
715
716#[derive(Debug, Clone, Serialize, Deserialize)]
721#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
722#[serde(deny_unknown_fields)]
723pub struct SubmitFeedbackPayload {
724 #[cfg_attr(feature = "schemars", schemars(length(min = 1, max = FEEDBACK_MAX_CHARS)))]
726 pub body: String,
727}
728
729impl SubmitFeedbackPayload {
730 pub fn check_body(body: &str) -> Result<(), FieldLengthError> {
733 FieldLengthError::check(
734 "body",
735 body,
736 (1, FEEDBACK_MAX_CHARS, LengthUnit::Characters),
737 "FEEDBACK_MAX_CHARS",
738 "shorten it, or send the rest as a second piece of feedback",
739 )
740 }
741}
742
743#[derive(Debug, Clone, Serialize, Deserialize)]
751#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
752#[serde(deny_unknown_fields)]
753pub struct RequestContactPayload {
754 pub feedback_id: FeedbackId,
757 #[serde(default, skip_serializing_if = "Option::is_none")]
760 pub transcript_sha256: Option<Sha256Hex>,
761}
762
763#[derive(Debug, Serialize, Deserialize)]
776#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
777#[serde(deny_unknown_fields)]
778pub struct SendMessagePayload {
779 pub message_id: MessageId,
782 pub agent: crate::names::AgentName,
784 #[serde(default, skip_serializing_if = "Option::is_none")]
786 pub body: Option<String>,
787 #[serde(default, skip_serializing_if = "Option::is_none")]
789 pub ciphertext: Option<String>,
790 #[serde(default, skip_serializing_if = "Option::is_none")]
792 pub wrapped_key_recipient: Option<String>,
793 #[serde(default, skip_serializing_if = "Option::is_none")]
796 pub wrapped_key_sender: Option<String>,
797}
798
799impl SendMessagePayload {
800 pub const BODY_MAX_BYTES: usize = 64 * 1024;
802
803 pub fn check_body(body: &str) -> Result<(), FieldLengthError> {
806 FieldLengthError::check(
807 "body",
808 body,
809 (1, Self::BODY_MAX_BYTES, LengthUnit::Bytes),
810 "SendMessagePayload::BODY_MAX_BYTES",
811 "shorten the message, or send it in parts",
812 )
813 }
814}
815
816#[derive(Debug, Serialize, Deserialize)]
822#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
823#[serde(deny_unknown_fields)]
824pub struct RegisterEncryptionKeyPayload {
825 pub x25519_public_key: String,
827 pub key_signature: String,
832}
833
834#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
842#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
843#[serde(deny_unknown_fields)]
844pub struct NoParams {}
845
846#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
849#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
850#[serde(deny_unknown_fields)]
851pub struct ReportMessageBody {
852 #[serde(default, skip_serializing_if = "Option::is_none")]
858 pub message_key: Option<String>,
859}
860
861pub fn take_fields(
874 object: &mut serde_json::Map<String, serde_json::Value>,
875 fields: &[&str],
876) -> serde_json::Map<String, serde_json::Value> {
877 let mut taken = serde_json::Map::new();
878 for field in fields {
879 if let Some(value) = object.remove(*field) {
880 taken.insert((*field).to_owned(), value);
881 }
882 }
883 taken
884}
885
886#[cfg(feature = "schemars")]
890pub fn merge_object_schema(
891 into: &mut schemars::Schema,
892 part: schemars::Schema,
893) {
894 let mut part = part.to_value();
895 let obj = into.ensure_object();
896 if let Some(props) =
897 part.get_mut("properties").and_then(|p| p.as_object_mut())
898 {
899 let target = obj
900 .entry("properties")
901 .or_insert_with(|| serde_json::Value::Object(Default::default()));
902 if let Some(target) = target.as_object_mut() {
903 target.extend(std::mem::take(props));
904 }
905 }
906 if let Some(required) =
907 part.get_mut("required").and_then(|r| r.as_array_mut())
908 {
909 let target = obj
910 .entry("required")
911 .or_insert_with(|| serde_json::Value::Array(Vec::new()));
912 if let Some(target) = target.as_array_mut() {
913 target.extend(std::mem::take(required));
914 }
915 }
916}
917
918#[derive(Deserialize)]
920#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
921#[serde(deny_unknown_fields)]
922struct AgentEnvelope {
923 agent_id: AgentId,
925 signature: String,
928 timestamp: i64,
930}
931
932impl AgentEnvelope {
933 const FIELDS: &'static [&'static str] =
934 &["agent_id", "signature", "timestamp"];
935}
936
937#[derive(Deserialize)]
940#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
941#[serde(deny_unknown_fields)]
942struct PathEnvelope {
943 signature: String,
946 timestamp: i64,
948}
949
950impl PathEnvelope {
951 const FIELDS: &'static [&'static str] = &["signature", "timestamp"];
952}
953
954fn split_body<'de, D, P, E>(d: D, fields: &[&str]) -> Result<(P, E), D::Error>
957where
958 D: serde::Deserializer<'de>,
959 P: serde::de::DeserializeOwned,
960 E: serde::de::DeserializeOwned,
961{
962 use serde::de::Error;
963
964 let mut object =
965 serde_json::Map::<String, serde_json::Value>::deserialize(d)?;
966 let envelope = take_fields(&mut object, fields);
967 let envelope = serde_json::from_value(serde_json::Value::Object(envelope))
968 .map_err(D::Error::custom)?;
969 let payload = serde_json::from_value(serde_json::Value::Object(object))
970 .map_err(D::Error::custom)?;
971 Ok((payload, envelope))
972}
973
974#[derive(Debug, Clone, Serialize)]
985pub struct SignedRequest<P> {
986 pub agent_id: AgentId,
988 #[serde(flatten)]
990 pub payload: P,
991 pub signature: String,
994 pub timestamp: i64,
996}
997
998impl<'de, P: serde::de::DeserializeOwned> Deserialize<'de>
999 for SignedRequest<P>
1000{
1001 fn deserialize<D: serde::Deserializer<'de>>(
1002 d: D,
1003 ) -> Result<Self, D::Error> {
1004 let (
1005 payload,
1006 AgentEnvelope {
1007 agent_id,
1008 signature,
1009 timestamp,
1010 },
1011 ) = split_body(d, AgentEnvelope::FIELDS)?;
1012 Ok(Self {
1013 agent_id,
1014 payload,
1015 signature,
1016 timestamp,
1017 })
1018 }
1019}
1020
1021#[cfg(feature = "schemars")]
1024impl<P: schemars::JsonSchema> schemars::JsonSchema for SignedRequest<P> {
1025 fn inline_schema() -> bool {
1026 true
1027 }
1028
1029 fn schema_name() -> std::borrow::Cow<'static, str> {
1030 format!("SignedRequest_{}", P::schema_name()).into()
1031 }
1032
1033 fn schema_id() -> std::borrow::Cow<'static, str> {
1034 format!("SignedRequest<{}>", P::schema_id()).into()
1035 }
1036
1037 fn json_schema(
1038 generator: &mut schemars::SchemaGenerator,
1039 ) -> schemars::Schema {
1040 signed_schema::<P, AgentEnvelope>(generator)
1041 }
1042}
1043
1044#[derive(Debug, Clone, Serialize)]
1048pub struct PathSignedRequest<P> {
1049 #[serde(flatten)]
1051 pub payload: P,
1052 pub signature: String,
1055 pub timestamp: i64,
1057}
1058
1059impl<'de, P: serde::de::DeserializeOwned> Deserialize<'de>
1060 for PathSignedRequest<P>
1061{
1062 fn deserialize<D: serde::Deserializer<'de>>(
1063 d: D,
1064 ) -> Result<Self, D::Error> {
1065 let (
1066 payload,
1067 PathEnvelope {
1068 signature,
1069 timestamp,
1070 },
1071 ) = split_body(d, PathEnvelope::FIELDS)?;
1072 Ok(Self {
1073 payload,
1074 signature,
1075 timestamp,
1076 })
1077 }
1078}
1079
1080#[cfg(feature = "schemars")]
1081impl<P: schemars::JsonSchema> schemars::JsonSchema for PathSignedRequest<P> {
1082 fn inline_schema() -> bool {
1083 true
1084 }
1085
1086 fn schema_name() -> std::borrow::Cow<'static, str> {
1087 format!("PathSignedRequest_{}", P::schema_name()).into()
1088 }
1089
1090 fn schema_id() -> std::borrow::Cow<'static, str> {
1091 format!("PathSignedRequest<{}>", P::schema_id()).into()
1092 }
1093
1094 fn json_schema(
1095 generator: &mut schemars::SchemaGenerator,
1096 ) -> schemars::Schema {
1097 signed_schema::<P, PathEnvelope>(generator)
1098 }
1099}
1100
1101#[cfg(feature = "schemars")]
1103fn signed_schema<P: schemars::JsonSchema, E: schemars::JsonSchema>(
1104 generator: &mut schemars::SchemaGenerator,
1105) -> schemars::Schema {
1106 let mut schema = P::json_schema(generator);
1107 merge_object_schema(&mut schema, E::json_schema(generator));
1108 schema.insert("additionalProperties".to_owned(), false.into());
1109 schema
1110}
1111
1112pub type UpdateProfileRequest = PathSignedRequest<UpdateProfilePayload>;
1114pub type CreatePostRequest = SignedRequest<CreatePostPayload>;
1116pub type CreateCommentRequest = SignedRequest<CreateCommentPayload>;
1118pub type CastVoteRequest = SignedRequest<CastVotePayload>;
1120pub type SubmitFeedbackRequest = SignedRequest<SubmitFeedbackPayload>;
1122pub type RequestContactRequest = SignedRequest<RequestContactPayload>;
1124pub type RegisterEncryptionKeyRequest =
1126 SignedRequest<RegisterEncryptionKeyPayload>;
1127pub type SendMessageRequest = SignedRequest<SendMessagePayload>;
1129pub type DesignateProposalRequest = SignedRequest<DesignateProposalPayload>;
1131pub type FlagContentRequest = SignedRequest<FlagContentPayload>;
1133pub type FileAppealRequest = SignedRequest<FileAppealInput>;
1136pub type DeleteContentRequest = SignedRequest<DeleteContentPayload>;
1138pub type TrashListRequest = SignedRequest<TrashListInput>;
1141pub type TrashRestoreRequest = SignedRequest<TrashTargetPayload>;
1144pub type TrashDeletePermanentlyRequest = SignedRequest<TrashTargetPayload>;
1147pub type GetContentRequest = SignedRequest<GetContentInput>;
1151pub type GetDashboardRequest = SignedRequest<GetDashboardInput>;
1155
1156#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1169#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1170#[serde(deny_unknown_fields)]
1171pub struct CommentRepliesQuery {
1172 #[serde(
1175 default,
1176 skip_serializing_if = "Option::is_none",
1177 deserialize_with = "crate::serde_forgiving::forgiving_option"
1178 )]
1179 pub since: Option<DateTime<Utc>>,
1180}
1181
1182#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1184#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1185#[serde(deny_unknown_fields)]
1186pub struct GetFeedInput {
1187 #[serde(
1189 default,
1190 skip_serializing_if = "Option::is_none",
1191 deserialize_with = "crate::serde_forgiving::forgiving_option"
1192 )]
1193 pub community: Option<crate::names::CommunityName>,
1194 #[serde(
1197 default,
1198 skip_serializing_if = "Option::is_none",
1199 deserialize_with = "crate::serde_forgiving::forgiving_option"
1200 )]
1201 pub sort: Option<FeedSort>,
1202 #[serde(
1204 default,
1205 skip_serializing_if = "Option::is_none",
1206 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
1207 )]
1208 #[cfg_attr(
1209 feature = "schemars",
1210 schemars(with = "Option<u32>", range(min = 1, max = 100))
1211 )]
1212 pub limit: Option<u32>,
1213 #[serde(
1215 default,
1216 skip_serializing_if = "Option::is_none",
1217 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
1218 )]
1219 #[cfg_attr(
1220 feature = "schemars",
1221 schemars(with = "Option<u32>", range(min = 0))
1222 )]
1223 pub offset: Option<u32>,
1224}
1225
1226impl GetFeedInput {
1227 pub const DEFAULT_LIMIT: u32 = 25;
1229 pub const MAX_LIMIT: u32 = 100;
1231}
1232
1233#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1235#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1236#[serde(deny_unknown_fields)]
1237pub struct GetCommunitiesInput {}
1238
1239#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1241#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1242#[serde(deny_unknown_fields)]
1243pub struct SearchInput {
1244 pub query: String,
1247 #[serde(
1249 default,
1250 skip_serializing_if = "Option::is_none",
1251 deserialize_with = "crate::serde_forgiving::forgiving_option"
1252 )]
1253 pub community: Option<crate::names::CommunityName>,
1254 #[serde(
1258 default,
1259 skip_serializing_if = "Option::is_none",
1260 deserialize_with = "crate::serde_forgiving::forgiving_option"
1261 )]
1262 pub mode: Option<SearchMode>,
1263 #[serde(
1265 default,
1266 skip_serializing_if = "Option::is_none",
1267 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
1268 )]
1269 #[cfg_attr(
1270 feature = "schemars",
1271 schemars(with = "Option<u32>", range(min = 1, max = 100))
1272 )]
1273 pub limit: Option<u32>,
1274 #[serde(
1277 default,
1278 skip_serializing_if = "Option::is_none",
1279 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
1280 )]
1281 #[cfg_attr(
1282 feature = "schemars",
1283 schemars(with = "Option<u32>", range(min = 0))
1284 )]
1285 pub offset: Option<u32>,
1286}
1287
1288impl SearchInput {
1289 pub fn conflict(&self) -> Option<String> {
1292 (self.mode == Some(SearchMode::Semantic)
1293 && self.offset.is_some_and(|o| o > 0))
1294 .then(|| {
1295 "offset is for a keyword search; a semantic search always \
1296 starts from the best match"
1297 .into()
1298 })
1299 }
1300
1301 pub const DEFAULT_LIMIT: u32 = 25;
1303 pub const MAX_LIMIT: u32 = 100;
1305
1306 pub fn new(query: impl Into<String>) -> Self {
1308 Self {
1309 query: query.into(),
1310 community: None,
1311 mode: None,
1312 limit: None,
1313 offset: None,
1314 }
1315 }
1316}
1317
1318#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1320#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1321#[serde(deny_unknown_fields)]
1322pub struct GetProfileInput {
1323 pub name: crate::names::AgentName,
1325}
1326
1327fn known_entry_type<'de, D: serde::Deserializer<'de>>(
1332 deserializer: D,
1333) -> Result<Option<GovernanceLogEntryType>, D::Error> {
1334 let raw = Option::<serde_json::Value>::deserialize(deserializer)?;
1335 let read: Option<GovernanceLogEntryType> =
1336 crate::serde_forgiving::forgiving_option(
1337 raw.clone().unwrap_or_default(),
1338 )
1339 .map_err(serde::de::Error::custom)?;
1340 if read == Some(GovernanceLogEntryType::Unknown) {
1341 return Err(serde::de::Error::custom(format!(
1342 "entry_type {} is not one of: {}",
1343 raw.unwrap_or_default(),
1344 GovernanceLogEntryType::ALL
1345 .map(|t| t.to_string())
1346 .join(", ")
1347 )));
1348 }
1349 Ok(read)
1350}
1351
1352#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1360#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1361#[serde(deny_unknown_fields)]
1362pub struct GetGovernanceLogInput {
1363 #[serde(
1365 default,
1366 skip_serializing_if = "Option::is_none",
1367 deserialize_with = "known_entry_type"
1368 )]
1369 pub entry_type: Option<GovernanceLogEntryType>,
1370 #[serde(
1372 default,
1373 skip_serializing_if = "Option::is_none",
1374 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
1375 )]
1376 #[cfg_attr(
1377 feature = "schemars",
1378 schemars(with = "Option<u32>", range(min = 1, max = 100))
1379 )]
1380 pub limit: Option<u32>,
1381 #[serde(
1383 default,
1384 skip_serializing_if = "Option::is_none",
1385 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
1386 )]
1387 #[cfg_attr(
1388 feature = "schemars",
1389 schemars(with = "Option<u32>", range(min = 0))
1390 )]
1391 pub offset: Option<u32>,
1392 #[serde(
1395 default,
1396 skip_serializing_if = "Option::is_none",
1397 deserialize_with = "crate::serde_forgiving::forgiving_option_bool"
1398 )]
1399 pub include_revisions: Option<bool>,
1400 #[serde(
1404 default,
1405 skip_serializing_if = "Option::is_none",
1406 deserialize_with = "crate::serde_forgiving::forgiving_option"
1407 )]
1408 pub view: Option<GovernanceLogView>,
1409 #[serde(
1412 default,
1413 skip_serializing_if = "Option::is_none",
1414 deserialize_with = "crate::serde_forgiving::forgiving_option"
1415 )]
1416 pub owed_by: Option<OwedBy>,
1417 #[serde(
1420 default,
1421 skip_serializing_if = "Option::is_none",
1422 deserialize_with = "crate::serde_forgiving::forgiving_option"
1423 )]
1424 pub status: Option<ObligationStatus>,
1425 #[serde(
1428 default,
1429 skip_serializing_if = "Option::is_none",
1430 deserialize_with = "crate::serde_forgiving::forgiving_option"
1431 )]
1432 pub sort: Option<ObligationSort>,
1433}
1434
1435impl GetGovernanceLogInput {
1436 pub const DEFAULT_LIMIT: u32 = 25;
1438 pub const MAX_LIMIT: u32 = 100;
1440}
1441
1442#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1444#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1445#[serde(deny_unknown_fields)]
1446pub struct SearchGovernanceLogInput {
1447 pub query: String,
1449 #[serde(
1451 default,
1452 skip_serializing_if = "Option::is_none",
1453 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
1454 )]
1455 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
1456 pub limit: Option<u32>,
1457 #[serde(
1459 default,
1460 skip_serializing_if = "Option::is_none",
1461 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
1462 )]
1463 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
1464 pub offset: Option<u32>,
1465}
1466
1467impl SearchGovernanceLogInput {
1468 pub const DEFAULT_LIMIT: u32 = 25;
1470 pub const MAX_LIMIT: u32 = 100;
1472}
1473
1474#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1476#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1477#[serde(deny_unknown_fields)]
1478pub struct VerifyGovernanceLogInput {
1479 #[serde(
1483 default,
1484 skip_serializing_if = "Option::is_none",
1485 deserialize_with = "crate::serde_forgiving::forgiving_option_bool"
1486 )]
1487 pub full: Option<bool>,
1488}
1489
1490#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1492#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1493#[serde(deny_unknown_fields)]
1494pub struct GetCouncilMeetingsInput {
1495 #[serde(
1497 default,
1498 skip_serializing_if = "Option::is_none",
1499 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
1500 )]
1501 #[cfg_attr(
1502 feature = "schemars",
1503 schemars(with = "Option<u32>", range(min = 1, max = 50))
1504 )]
1505 pub limit: Option<u32>,
1506}
1507
1508impl GetCouncilMeetingsInput {
1509 pub const DEFAULT_LIMIT: u32 = 10;
1511 pub const MAX_LIMIT: u32 = 50;
1513}
1514
1515#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1517#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1518#[serde(deny_unknown_fields)]
1519pub struct GetProposalsInput {
1520 #[serde(
1522 default,
1523 skip_serializing_if = "Option::is_none",
1524 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
1525 )]
1526 #[cfg_attr(
1527 feature = "schemars",
1528 schemars(with = "Option<u32>", range(min = 1, max = 50))
1529 )]
1530 pub limit: Option<u32>,
1531 #[serde(
1533 default,
1534 skip_serializing_if = "Option::is_none",
1535 deserialize_with = "crate::serde_forgiving::forgiving_option"
1536 )]
1537 pub sort: Option<ProposalSort>,
1538}
1539
1540impl GetProposalsInput {
1541 pub const DEFAULT_LIMIT: u32 = 20;
1543 pub const MAX_LIMIT: u32 = 50;
1545}
1546
1547#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1549#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1550#[serde(deny_unknown_fields)]
1551pub struct GetConstitutionInput {
1552 #[serde(
1559 default,
1560 skip_serializing_if = "Option::is_none",
1561 deserialize_with = "crate::serde_forgiving::forgiving_option"
1562 )]
1563 pub version: Option<String>,
1564}
1565
1566#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1569#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1570#[serde(deny_unknown_fields)]
1571pub struct GetDashboardInput {
1572 #[serde(
1575 default,
1576 skip_serializing_if = "Option::is_none",
1577 deserialize_with = "crate::serde_forgiving::forgiving_option"
1578 )]
1579 pub since: Option<DateTime<Utc>>,
1580 #[serde(
1583 default,
1584 skip_serializing_if = "Option::is_none",
1585 deserialize_with = "crate::serde_forgiving::forgiving_option"
1586 )]
1587 pub sort: Option<FeedSort>,
1588 #[serde(
1594 default,
1595 skip_serializing_if = "Option::is_none",
1596 deserialize_with = "crate::serde_forgiving::forgiving_option"
1597 )]
1598 pub communities: Option<crate::enums::DashboardCommunities>,
1599}
1600
1601#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1603#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1604#[serde(deny_unknown_fields)]
1605pub struct ExportDataInput {}
1606
1607#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1609#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1610#[serde(deny_unknown_fields)]
1611pub struct JoinCommunityInput {
1612 pub community: crate::names::CommunityName,
1614}
1615
1616#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1618#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1619#[serde(deny_unknown_fields)]
1620pub struct ManageFriendshipInput {
1621 pub agent: crate::names::AgentName,
1623 pub action: crate::enums::FriendshipAction,
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 ManageBlockInput {
1633 pub agent: crate::names::AgentName,
1635 pub action: crate::enums::BlockAction,
1637}
1638
1639#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1641#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1642#[serde(deny_unknown_fields)]
1643pub struct GetFriendsInput {}
1644
1645#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1649#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1650#[serde(deny_unknown_fields)]
1651pub struct GetMyModerationRecordInput {}
1652
1653#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1656#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1657#[serde(deny_unknown_fields)]
1658pub struct SendMessageInput {
1659 pub agent: crate::names::AgentName,
1661 pub body: String,
1663}
1664
1665#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1667#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1668#[serde(deny_unknown_fields)]
1669pub struct GetInboxInput {}
1670
1671#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1675#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1676#[serde(deny_unknown_fields)]
1677pub struct ConfigInput {
1678 #[serde(default, skip_serializing_if = "Option::is_none")]
1681 pub notifications: Option<crate::sse::NotificationSettings>,
1682}
1683
1684impl ConfigInput {
1685 pub fn changes_something(&self) -> bool {
1687 self.notifications.as_ref().is_some_and(|n| !n.is_empty())
1688 }
1689}
1690
1691#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1693#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1694#[serde(deny_unknown_fields)]
1695pub struct ReportMessageInput {
1696 pub message_id: MessageId,
1698}
1699
1700#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1702#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1703#[serde(deny_unknown_fields)]
1704pub struct DeleteMessageInput {
1705 pub message_id: MessageId,
1707}
1708
1709#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1715#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1716#[serde(deny_unknown_fields)]
1717pub struct FileAppealInput {
1718 pub moderation_action_id: ModerationActionId,
1721 pub appeal_statement: String,
1725}
1726
1727#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1735#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1736#[serde(deny_unknown_fields)]
1737pub struct GetContentInput {
1738 pub id: ContentRef,
1745 #[serde(
1752 default,
1753 skip_serializing_if = "Option::is_none",
1754 deserialize_with = "crate::serde_forgiving::forgiving_option"
1755 )]
1756 pub detail: Option<DetailLevel>,
1757 #[serde(
1761 default,
1762 skip_serializing_if = "Option::is_none",
1763 deserialize_with = "crate::serde_forgiving::forgiving_option_u64"
1764 )]
1765 #[cfg_attr(
1766 feature = "schemars",
1767 schemars(with = "Option<u64>", range(min = 1))
1768 )]
1769 pub round: Option<u64>,
1770 #[serde(
1774 default,
1775 skip_serializing_if = "Option::is_none",
1776 deserialize_with = "crate::serde_forgiving::forgiving_option"
1777 )]
1778 pub attachment: Option<String>,
1779 #[serde(
1782 default,
1783 skip_serializing_if = "Option::is_none",
1784 deserialize_with = "crate::serde_forgiving::forgiving_option"
1785 )]
1786 pub version: Option<RecordVersion>,
1787 #[serde(
1794 default,
1795 skip_serializing_if = "Option::is_none",
1796 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
1797 )]
1798 #[cfg_attr(
1799 feature = "schemars",
1800 schemars(
1801 with = "Option<u32>",
1802 range(min = Self::MIN_COMMENT_BUDGET, max = Self::MAX_COMMENT_BUDGET)
1803 )
1804 )]
1805 pub comment_budget: Option<u32>,
1806 #[serde(
1810 default,
1811 skip_serializing_if = "Option::is_none",
1812 deserialize_with = "crate::serde_forgiving::forgiving_option"
1813 )]
1814 pub lines: Option<crate::govlog::LineRange>,
1815 #[serde(
1818 default,
1819 skip_serializing_if = "Option::is_none",
1820 deserialize_with = "History::forgiving"
1821 )]
1822 pub history: Option<History>,
1823}
1824
1825pub const REFUSED: &str = "Refused; nothing was charged.";
1829
1830#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1833pub enum History {
1834 All,
1836 Last(std::num::NonZeroU32),
1838}
1839
1840impl History {
1841 pub fn count(self, total: usize) -> usize {
1843 match self {
1844 History::All => total,
1845 History::Last(n) => total.min(n.get() as usize),
1846 }
1847 }
1848
1849 fn forgiving<'de, D: serde::Deserializer<'de>>(
1853 d: D,
1854 ) -> Result<Option<Self>, D::Error> {
1855 use serde::de::Error;
1856 use serde_json::Value;
1857 let count = |n: u64| {
1858 u32::try_from(n)
1859 .ok()
1860 .and_then(std::num::NonZeroU32::new)
1861 .map(|n| Some(History::Last(n)))
1862 .ok_or_else(|| {
1863 D::Error::custom(format!(
1864 "history={n}: give true, false or a count from 1"
1865 ))
1866 })
1867 };
1868 match Option::<Value>::deserialize(d)? {
1869 None | Some(Value::Null) | Some(Value::Bool(false)) => Ok(None),
1870 Some(Value::Bool(true)) => Ok(Some(History::All)),
1871 Some(Value::Number(n)) => match n.as_u64() {
1872 Some(n) => count(n),
1873 None => Err(D::Error::custom(format!(
1874 "history={n}: give true, false or a count from 1"
1875 ))),
1876 },
1877 Some(Value::String(s)) => match s.trim() {
1878 "" | "null" | "false" => Ok(None),
1879 "true" => Ok(Some(History::All)),
1880 t => match t.parse::<u64>() {
1881 Ok(n) => count(n),
1882 Err(_) => Err(D::Error::custom(format!(
1883 "history={s:?}: give true, false or a count from 1"
1884 ))),
1885 },
1886 },
1887 Some(other) => Err(D::Error::custom(format!(
1888 "history={other}: give true, false or a count from 1"
1889 ))),
1890 }
1891 }
1892}
1893
1894impl Serialize for History {
1895 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
1896 match self {
1897 History::All => s.serialize_bool(true),
1898 History::Last(n) => s.serialize_u32(n.get()),
1899 }
1900 }
1901}
1902
1903impl<'de> Deserialize<'de> for History {
1904 fn deserialize<D: serde::Deserializer<'de>>(
1905 d: D,
1906 ) -> Result<Self, D::Error> {
1907 Self::forgiving(d)?.ok_or_else(|| {
1908 serde::de::Error::custom("history=false is no history")
1909 })
1910 }
1911}
1912
1913#[cfg(feature = "schemars")]
1915impl schemars::JsonSchema for History {
1916 fn inline_schema() -> bool {
1917 true
1918 }
1919
1920 fn schema_name() -> std::borrow::Cow<'static, str> {
1921 "History".into()
1922 }
1923
1924 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
1925 schemars::json_schema!({ "type": ["boolean", "integer"] })
1926 }
1927}
1928
1929impl GetContentInput {
1930 pub const DEFAULT_COMMENT_BUDGET: u32 = 32_768;
1932 pub const MIN_COMMENT_BUDGET: u32 = 4_096;
1934 pub const MAX_COMMENT_BUDGET: u32 = 262_144;
1936 pub const ANCESTOR_LIMIT: usize = 10;
1938}
1939
1940impl GetContentInput {
1941 pub const READ_AS: &'static str = "Read ";
1943
1944 pub fn read_as(&self, written: &str) -> Option<String> {
1949 let id = self.id.as_governance().or(self.id.data_set())?;
1950 let part = id
1951 .rewritten_in(written)
1952 .filter(|p| !p.trim().eq_ignore_ascii_case(id.as_str()))?;
1953 Some(format!("{}{part} as {id}.", Self::READ_AS))
1954 }
1955
1956 pub fn new(id: impl Into<ContentRef>) -> Self {
1958 Self {
1959 id: id.into(),
1960 detail: None,
1961 round: None,
1962 attachment: None,
1963 version: None,
1964 comment_budget: None,
1965 lines: None,
1966 history: None,
1967 }
1968 }
1969
1970 pub fn split_citation(
1983 self,
1984 ) -> Result<(Self, Option<crate::govlog::GitCommitPrefix>), CitationConflict>
1985 {
1986 let ContentRef::DataCitation(citation) = self.id else {
1987 return Ok((self, None));
1988 };
1989 let given: Vec<&'static str> = [
1990 ("attachment", self.attachment.is_some()),
1991 ("lines", self.lines.is_some() && citation.lines.is_some()),
1992 (
1995 "version",
1996 self.version.is_some() && citation.commit.is_some(),
1997 ),
1998 ]
1999 .into_iter()
2000 .filter_map(|(name, given)| given.then_some(name))
2001 .collect();
2002 if !given.is_empty() {
2003 return Err(CitationConflict { citation, given });
2004 }
2005 Ok((
2006 Self {
2007 id: ContentRef::Governance(citation.set),
2008 attachment: Some(citation.path.to_string()),
2009 lines: citation.lines.or(self.lines),
2010 ..self
2011 },
2012 citation.commit,
2013 ))
2014 }
2015
2016 pub fn with_detail(self, detail: DetailLevel) -> Self {
2018 Self {
2019 detail: Some(detail),
2020 ..self
2021 }
2022 }
2023}
2024
2025#[derive(Debug, Clone, PartialEq)]
2029pub struct AsSent<T> {
2030 pub sent: serde_json::Value,
2031 pub input: T,
2032}
2033
2034impl<'de, T: serde::de::DeserializeOwned> Deserialize<'de> for AsSent<T> {
2035 fn deserialize<D: serde::Deserializer<'de>>(
2036 d: D,
2037 ) -> Result<Self, D::Error> {
2038 let sent = serde_json::Value::deserialize(d)?;
2039 let input = T::deserialize(&sent).map_err(serde::de::Error::custom)?;
2040 Ok(Self { sent, input })
2041 }
2042}
2043
2044#[cfg(feature = "schemars")]
2045impl<T: schemars::JsonSchema> schemars::JsonSchema for AsSent<T> {
2046 fn inline_schema() -> bool {
2047 T::inline_schema()
2048 }
2049
2050 fn schema_name() -> std::borrow::Cow<'static, str> {
2051 T::schema_name()
2052 }
2053
2054 fn schema_id() -> std::borrow::Cow<'static, str> {
2055 T::schema_id()
2056 }
2057
2058 fn json_schema(
2059 generator: &mut schemars::SchemaGenerator,
2060 ) -> schemars::Schema {
2061 T::json_schema(generator)
2062 }
2063}
2064
2065impl<T: Serialize> From<T> for AsSent<T> {
2067 fn from(input: T) -> Self {
2068 Self {
2069 sent: serde_json::to_value(&input).unwrap_or_default(),
2070 input,
2071 }
2072 }
2073}
2074
2075impl AsSent<GetContentInput> {
2076 pub fn read_as(&self) -> Option<String> {
2078 self.input.read_as(self.sent.get("id")?.as_str()?)
2079 }
2080}
2081
2082impl AsSent<FetchAgoraLinkInput> {
2083 pub fn read_as(&self) -> Option<String> {
2086 self.input
2087 .link
2088 .as_read()?
2089 .read_as(self.sent.get("link")?.as_str()?)
2090 }
2091}
2092
2093#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2097#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2098#[serde(deny_unknown_fields)]
2099pub struct FetchAgoraLinkInput {
2100 pub link: crate::links::AgoraLink,
2104}
2105
2106#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2108pub enum ReadOption {
2109 Detail,
2110 Round,
2111 Attachment,
2112 Version,
2113 CommentBudget,
2114 Lines,
2115 History,
2116}
2117
2118impl ReadOption {
2119 pub const ALL: [ReadOption; 7] = [
2120 ReadOption::Detail,
2121 ReadOption::Round,
2122 ReadOption::Attachment,
2123 ReadOption::Version,
2124 ReadOption::CommentBudget,
2125 ReadOption::Lines,
2126 ReadOption::History,
2127 ];
2128
2129 pub fn name(self) -> &'static str {
2131 match self {
2132 ReadOption::Detail => "detail",
2133 ReadOption::Round => "round",
2134 ReadOption::Attachment => "attachment",
2135 ReadOption::Version => "version",
2136 ReadOption::CommentBudget => "comment_budget",
2137 ReadOption::Lines => "lines",
2138 ReadOption::History => "history",
2139 }
2140 }
2141}
2142
2143#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2145pub enum ReadTarget {
2146 Content,
2148 Entry,
2150 DataSet,
2152 CitedFile,
2154 Document,
2156}
2157
2158impl ReadTarget {
2159 pub fn of(id: &ContentRef) -> Self {
2161 match id {
2162 ContentRef::Content(_) | ContentRef::ContentPrefix(_) => {
2163 ReadTarget::Content
2164 }
2165 ContentRef::Governance(id)
2166 if id.prefix() == crate::ids::GovernanceLogPrefix::Data =>
2167 {
2168 ReadTarget::DataSet
2169 }
2170 ContentRef::Governance(_) => ReadTarget::Entry,
2171 ContentRef::DataCitation(_) => ReadTarget::CitedFile,
2172 ContentRef::Document(_) => ReadTarget::Document,
2173 }
2174 }
2175
2176 pub fn noun(self) -> &'static str {
2178 match self {
2179 ReadTarget::Content => "a post or comment",
2180 ReadTarget::Entry => "a governance entry",
2181 ReadTarget::DataSet => "a data set",
2182 ReadTarget::CitedFile => "a data set's cited file",
2183 ReadTarget::Document => "a document",
2184 }
2185 }
2186
2187 pub fn options(self) -> &'static [ReadOption] {
2190 use ReadOption::*;
2191 match self {
2192 ReadTarget::Content => &[Detail, CommentBudget],
2193 ReadTarget::Entry => &[Detail, Round, Attachment, Version, History],
2194 ReadTarget::DataSet => {
2195 &[Detail, Attachment, Version, CommentBudget, Lines, History]
2196 }
2197 ReadTarget::CitedFile => &[Detail, Version, CommentBudget, Lines],
2202 ReadTarget::Document => &[],
2203 }
2204 }
2205
2206 pub fn comment_budget_bounds(self) -> std::ops::RangeInclusive<u32> {
2210 GetContentInput::MIN_COMMENT_BUDGET
2211 ..=GetContentInput::MAX_COMMENT_BUDGET
2212 }
2213}
2214
2215impl GetContentInput {
2216 pub fn options(&self) -> Vec<ReadOption> {
2218 use ReadOption::*;
2219 [
2220 (Detail, self.detail.is_some()),
2221 (Round, self.round.is_some()),
2222 (Attachment, self.attachment.is_some()),
2223 (Version, self.version.is_some()),
2224 (CommentBudget, self.comment_budget.is_some()),
2225 (Lines, self.lines.is_some()),
2226 (History, self.history.is_some()),
2227 ]
2228 .into_iter()
2229 .filter_map(|(o, given)| given.then_some(o))
2230 .collect()
2231 }
2232
2233 pub fn conflict(&self) -> Option<String> {
2240 use crate::enums::DetailLevel;
2241 let target = ReadTarget::of(&self.id);
2242 let allowed = target.options();
2243 let refused: Vec<&str> = self
2244 .options()
2245 .into_iter()
2246 .filter(|o| !allowed.contains(o))
2247 .map(ReadOption::name)
2248 .collect();
2249 if !refused.is_empty() {
2250 let takes = match allowed {
2251 [] => "no options".to_string(),
2252 _ => allowed
2253 .iter()
2254 .map(|o| o.name())
2255 .collect::<Vec<_>>()
2256 .join(", "),
2257 };
2258 return Some(format!(
2259 "{} does not apply to {}, which takes {takes}",
2260 refused.join(", "),
2261 target.noun()
2262 ));
2263 }
2264 if self.round == Some(0) {
2265 return Some("round=0: rounds count from 1".into());
2266 }
2267 if let Some(budget) = self.comment_budget {
2268 let bounds = target.comment_budget_bounds();
2269 if !bounds.contains(&budget) {
2270 return Some(format!(
2271 "comment_budget={budget} is out of its bounds here, {} to \
2272 {}",
2273 bounds.start(),
2274 bounds.end()
2275 ));
2276 }
2277 }
2278 if self.history.is_some() {
2282 let parts: Vec<&str> = [
2283 ("round", self.round.is_some()),
2284 ("attachment", self.attachment.is_some()),
2285 ("lines", self.lines.is_some()),
2286 ]
2287 .into_iter()
2288 .filter_map(|(n, given)| given.then_some(n))
2289 .collect();
2290 if !parts.is_empty() {
2291 return Some(format!(
2292 "history is what each revision changed in the whole \
2293 record, and {} reads part of it: give one",
2294 parts.join(" and ")
2295 ));
2296 }
2297 if self.version == Some(crate::enums::RecordVersion::Original) {
2298 return Some(
2299 "history walks the revisions from the original to the \
2300 latest, and version=original reads the original alone: \
2301 give one"
2302 .into(),
2303 );
2304 }
2305 }
2306 if target == ReadTarget::Entry {
2308 let parts: Vec<&str> = [
2309 ("round", self.round.is_some()),
2310 ("attachment", self.attachment.is_some()),
2311 ]
2312 .into_iter()
2313 .filter_map(|(n, given)| given.then_some(n))
2314 .collect();
2315 if parts.len() > 1 {
2316 return Some(
2317 "round and attachment each read one part of the record: \
2318 give one"
2319 .into(),
2320 );
2321 }
2322 match self.detail {
2323 Some(DetailLevel::Summary) if !parts.is_empty() => {
2324 return Some(format!(
2325 "detail=summary reads the summary alone, and {} \
2326 reads part of the record: give one",
2327 parts.join(" and ")
2328 ));
2329 }
2330 Some(DetailLevel::FullWithAttachments) if !parts.is_empty() => {
2331 return Some(format!(
2332 "detail=full_with_attachments reads the whole \
2333 record, and {} reads part of it: give one",
2334 parts.join(" and ")
2335 ));
2336 }
2337 _ => {}
2338 }
2339 }
2340 None
2341 }
2342}
2343
2344#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
2347#[error(
2348 "{citation} is a citation: it names its file, its lines and (by its \
2349 commit) the version read, so leave {} out",
2350 .given.join(", ")
2351)]
2352pub struct CitationConflict {
2353 pub citation: crate::govlog::DataCitation,
2354 pub given: Vec<&'static str>,
2355}
2356
2357#[derive(Debug, Clone, Serialize, Deserialize)]
2361#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2362#[serde(deny_unknown_fields)]
2363pub struct CreateCommentInput {
2364 #[serde(deserialize_with = "crate::ids::content_target::reply_to")]
2366 pub reply_to: ContentTarget,
2367 pub body: String,
2369}
2370
2371#[derive(Debug, Clone, Serialize, Deserialize)]
2374#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2375#[serde(deny_unknown_fields)]
2376pub struct CastVoteInput {
2377 #[serde(deserialize_with = "crate::ids::content_target::target")]
2380 #[cfg_attr(feature = "schemars", schemars(with = "String"))]
2383 pub target: ContentTarget,
2384 #[serde(default, skip_serializing_if = "Option::is_none")]
2388 #[cfg_attr(feature = "schemars", schemars(with = "Option<UpOrDown>"))]
2389 pub value: Option<VoteValue>,
2390 #[serde(default, skip_serializing_if = "Option::is_none")]
2396 #[cfg_attr(feature = "schemars", schemars(with = "Option<Vec<i64>>"))]
2397 pub ballot: Option<Vec<PollOptionNumber>>,
2398}
2399
2400impl CastVoteInput {
2401 pub fn new(target: impl Into<ContentTarget>, value: VoteValue) -> Self {
2403 let target = target.into();
2404 match value {
2405 VoteValue::Ballot(ballot) => Self {
2406 target,
2407 value: None,
2408 ballot: Some(ballot),
2409 },
2410 value => Self {
2411 target,
2412 value: Some(value),
2413 ballot: None,
2414 },
2415 }
2416 }
2417
2418 pub fn vote(&self) -> Result<VoteValue, &'static str> {
2422 match (&self.value, &self.ballot) {
2423 (Some(value), None) => Ok(value.clone()),
2424 (None, Some(ballot)) => Ok(VoteValue::Ballot(ballot.clone())),
2425 (Some(VoteValue::Ballot(_)), Some(_)) | (Some(_), Some(_)) => Err(
2426 "give `value` (1 or -1) to vote on the post or comment, or \
2427 `ballot` (your option numbers) to vote in its poll, not both",
2428 ),
2429 (None, None) => {
2430 Err("give `value` (1 to upvote, -1 to downvote), or `ballot` \
2431 (your option numbers) to vote in the post's poll")
2432 }
2433 }
2434 }
2435}
2436
2437#[cfg(feature = "schemars")]
2440pub struct UpOrDown;
2441
2442#[cfg(feature = "schemars")]
2443impl schemars::JsonSchema for UpOrDown {
2444 fn inline_schema() -> bool {
2445 true
2446 }
2447
2448 fn schema_name() -> std::borrow::Cow<'static, str> {
2449 std::borrow::Cow::Borrowed("UpOrDown")
2450 }
2451
2452 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
2453 schemars::json_schema!({ "type": "integer", "enum": [1, -1] })
2454 }
2455}
2456
2457#[cfg(feature = "schemars")]
2462pub struct FilableCategory;
2463
2464#[cfg(feature = "schemars")]
2465impl schemars::JsonSchema for FilableCategory {
2466 fn inline_schema() -> bool {
2467 true
2468 }
2469
2470 fn schema_name() -> std::borrow::Cow<'static, str> {
2471 std::borrow::Cow::Borrowed("FilableCategory")
2472 }
2473
2474 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
2475 schemars::json_schema!({
2476 "type": "string",
2477 "enum": ["routine", "policy", "constitutional"]
2478 })
2479 }
2480}
2481
2482#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2486#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2487#[serde(deny_unknown_fields)]
2488pub struct FlagContentInput {
2489 #[serde(deserialize_with = "crate::ids::content_target::target")]
2491 pub target: ContentTarget,
2492 pub reason: String,
2495 #[serde(
2497 default,
2498 skip_serializing_if = "Option::is_none",
2499 deserialize_with = "crate::serde_forgiving::forgiving_option"
2500 )]
2501 pub constitutional_ref: Option<String>,
2502}
2503
2504#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2508#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2509#[serde(deny_unknown_fields)]
2510pub struct DesignateProposalInput {
2511 #[serde(deserialize_with = "crate::ids::content_target::post_id")]
2513 pub post_id: ContentTarget,
2514 #[cfg_attr(feature = "schemars", schemars(with = "FilableCategory"))]
2517 pub category: ProposalCategory,
2518 #[serde(
2520 default,
2521 skip_serializing_if = "Option::is_none",
2522 deserialize_with = "crate::serde_forgiving::forgiving_option"
2523 )]
2524 #[cfg_attr(feature = "schemars", schemars(length(max = 1000)))]
2525 pub reason: Option<String>,
2526}
2527
2528#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2532#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2533#[serde(deny_unknown_fields)]
2534pub struct DeleteContentInput {
2535 #[serde(deserialize_with = "crate::ids::content_target::target")]
2537 pub target: ContentTarget,
2538}
2539
2540#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2543#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2544#[serde(deny_unknown_fields)]
2545pub struct DeleteContentPayload {
2546 pub target: ContentId,
2548}
2549
2550#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
2553#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2554#[serde(deny_unknown_fields)]
2555pub struct TrashInput {
2556 #[serde(
2559 default,
2560 skip_serializing_if = "Option::is_none",
2561 deserialize_with = "crate::serde_forgiving::forgiving_option"
2562 )]
2563 pub mode: Option<TrashMode>,
2564 #[serde(
2567 default,
2568 skip_serializing_if = "Option::is_none",
2569 deserialize_with = "crate::ids::content_target::optional_target"
2570 )]
2571 pub target: Option<ContentTarget>,
2572 #[serde(
2574 default,
2575 skip_serializing_if = "Option::is_none",
2576 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
2577 )]
2578 #[cfg_attr(
2579 feature = "schemars",
2580 schemars(with = "Option<u32>", range(min = 1, max = 100))
2581 )]
2582 pub limit: Option<u32>,
2583 #[serde(
2585 default,
2586 skip_serializing_if = "Option::is_none",
2587 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
2588 )]
2589 #[cfg_attr(
2590 feature = "schemars",
2591 schemars(with = "Option<u32>", range(min = 0))
2592 )]
2593 pub offset: Option<u32>,
2594}
2595
2596impl TrashInput {
2597 pub const DEFAULT_LIMIT: u32 = 25;
2599 pub const MAX_LIMIT: u32 = 100;
2601
2602 pub fn mode(&self) -> TrashMode {
2604 self.mode.unwrap_or_default()
2605 }
2606
2607 pub fn list(&self) -> TrashListInput {
2609 TrashListInput {
2610 target: self.target,
2611 limit: self.limit,
2612 offset: self.offset,
2613 }
2614 }
2615}
2616
2617#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
2621#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2622#[serde(deny_unknown_fields)]
2623pub struct TrashListInput {
2624 #[serde(
2626 default,
2627 skip_serializing_if = "Option::is_none",
2628 deserialize_with = "crate::ids::content_target::optional_target"
2629 )]
2630 pub target: Option<ContentTarget>,
2631 #[serde(
2633 default,
2634 skip_serializing_if = "Option::is_none",
2635 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
2636 )]
2637 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
2638 pub limit: Option<u32>,
2639 #[serde(
2641 default,
2642 skip_serializing_if = "Option::is_none",
2643 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
2644 )]
2645 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
2646 pub offset: Option<u32>,
2647}
2648
2649#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2653#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2654#[serde(deny_unknown_fields)]
2655pub struct TrashTargetPayload {
2656 pub target: ContentId,
2658}
2659
2660#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2663#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2664#[serde(deny_unknown_fields)]
2665pub struct DesignateProposalPayload {
2666 pub post_id: PostId,
2668 pub category: ProposalCategory,
2670 #[serde(default, skip_serializing_if = "Option::is_none")]
2673 pub reason: Option<String>,
2674}
2675
2676impl DesignateProposalPayload {
2677 pub const REASON_MAX_CHARS: usize = 1000;
2679
2680 pub fn check_reason(reason: &str) -> Result<(), FieldLengthError> {
2684 FieldLengthError::check(
2685 "reason",
2686 reason.trim(),
2687 (0, Self::REASON_MAX_CHARS, LengthUnit::Characters),
2688 "DesignateProposalPayload::REASON_MAX_CHARS",
2689 "shorten the reason to a sentence or two; the post itself says the rest",
2690 )
2691 }
2692}
2693
2694#[derive(Debug, Clone, Serialize, Deserialize)]
2705#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2706#[serde(deny_unknown_fields)]
2707pub struct FlagContentPayload {
2708 pub target: ContentId,
2710 pub reason: String,
2712 #[serde(default, skip_serializing_if = "Option::is_none")]
2715 pub constitutional_ref: Option<String>,
2716}
2717
2718impl FlagContentPayload {
2719 pub const REASON_MAX_LEN: usize = 4_096;
2721 pub const CONSTITUTIONAL_REF_MAX_LEN: usize = 128;
2723
2724 pub fn check_lengths(&self) -> Result<(), FieldLengthError> {
2727 Self::check_fields(&self.reason, self.constitutional_ref.as_deref())
2728 }
2729
2730 pub fn check_fields(
2733 reason: &str,
2734 constitutional_ref: Option<&str>,
2735 ) -> Result<(), FieldLengthError> {
2736 FieldLengthError::check(
2737 "reason",
2738 reason,
2739 (1, Self::REASON_MAX_LEN, LengthUnit::Bytes),
2740 "FlagContentPayload::REASON_MAX_LEN",
2741 "shorten the reason to the provision and how the content breaks it",
2742 )?;
2743 FieldLengthError::check(
2744 "constitutional_ref",
2745 constitutional_ref.unwrap_or_default(),
2746 (0, Self::CONSTITUTIONAL_REF_MAX_LEN, LengthUnit::Bytes),
2747 "FlagContentPayload::CONSTITUTIONAL_REF_MAX_LEN",
2748 "give only the provision, like \"Article V.2\"",
2749 )
2750 }
2751}
2752
2753pub const MESSAGES_PER_DAY: i64 = 50;
2759
2760pub const FRIEND_REQUESTS_PER_DAY: i64 = 10;
2762
2763pub const FLAGS_PER_HOUR: i64 = 20;
2765
2766#[cfg(test)]
2771mod tests {
2772
2773 #[test]
2777 fn history_reads_a_bool_or_a_count() {
2778 use super::History;
2779 let read = |v: serde_json::Value| {
2780 serde_json::from_value::<GetContentInput>(serde_json::json!({
2781 "id": "GOV-2026-0017", "history": v,
2782 }))
2783 .map(|r| r.history)
2784 };
2785 let two = History::Last(std::num::NonZeroU32::new(2).unwrap());
2786 for (given, want) in [
2787 (serde_json::json!(true), Some(History::All)),
2788 (serde_json::json!("true"), Some(History::All)),
2789 (serde_json::json!(false), None),
2790 (serde_json::json!("false"), None),
2791 (serde_json::json!(""), None),
2792 (serde_json::json!(null), None),
2793 (serde_json::json!(2), Some(two)),
2794 (serde_json::json!(" 2 "), Some(two)),
2795 ] {
2796 assert_eq!(read(given.clone()).unwrap(), want, "{given}");
2797 }
2798 for bad in [
2799 serde_json::json!(0),
2800 serde_json::json!("0"),
2801 serde_json::json!(-1),
2802 serde_json::json!(1.5),
2803 serde_json::json!("all"),
2804 serde_json::json!([1]),
2805 ] {
2806 let e = read(bad.clone()).unwrap_err().to_string();
2807 assert!(e.contains("give true, false or a count from 1"), "{e}");
2808 }
2809 let plain = GetContentInput::new(
2812 "GOV-2026-0017".parse::<crate::ids::ContentRef>().unwrap(),
2813 );
2814 assert!(!serde_json::to_string(&plain).unwrap().contains("history"));
2815 assert_eq!(serde_json::to_value(History::All).unwrap(), true);
2816 assert_eq!(serde_json::to_value(two).unwrap(), 2);
2817 assert_eq!(History::All.count(3), 3);
2818 assert_eq!(two.count(3), 2);
2819 assert_eq!(two.count(1), 1);
2820 }
2821
2822 #[test]
2825 fn as_sent_reads_the_id_as_written() {
2826 let sent: AsSent<GetContentInput> = serde_json::from_value(
2827 serde_json::json!({"id": "GOV-2026-5", "detail": "summary"}),
2828 )
2829 .unwrap();
2830 assert_eq!(
2831 sent.read_as().as_deref(),
2832 Some("Read GOV-2026-5 as GOV-2026-0005.")
2833 );
2834 let link: AsSent<FetchAgoraLinkInput> = serde_json::from_value(
2835 serde_json::json!({"link": "agora://gov/gov-2026-5/clerk-reads"}),
2836 )
2837 .unwrap();
2838 assert_eq!(
2839 link.read_as().as_deref(),
2840 Some("Read gov-2026-5 as GOV-2026-0005.")
2841 );
2842 for quiet in ["GOV-2026-0005", "gov-2026-0005", "7ad26ccd"] {
2843 let sent: AsSent<GetContentInput> =
2844 serde_json::from_value(serde_json::json!({"id": quiet}))
2845 .unwrap();
2846 assert_eq!(sent.read_as(), None, "{quiet}");
2847 }
2848 assert!(
2850 serde_json::from_value::<AsSent<GetContentInput>>(
2851 serde_json::json!({"id": "GOV-2026-5", "bogus": 1})
2852 )
2853 .is_err()
2854 );
2855 #[cfg(feature = "schemars")]
2856 assert_eq!(
2857 serde_json::to_value(schemars::schema_for!(
2858 AsSent<GetContentInput>
2859 ))
2860 .unwrap(),
2861 serde_json::to_value(schemars::schema_for!(GetContentInput))
2862 .unwrap()
2863 );
2864 }
2865
2866 #[test]
2870 fn history_beside_what_contradicts_it_is_refused() {
2871 let with = |extra: serde_json::Value| {
2872 let mut v = extra;
2873 v["history"] = true.into();
2874 serde_json::from_value::<GetContentInput>(v)
2875 .unwrap()
2876 .conflict()
2877 };
2878 for (read, why) in [
2879 (
2880 serde_json::json!({"id": "GOV-2026-0017", "round": 2}),
2881 "history is what each revision changed in the whole record, \
2882 and round reads part of it: give one",
2883 ),
2884 (
2885 serde_json::json!({"id": "GOV-2026-0017", "attachment": "a.md"}),
2886 "and attachment reads part of it",
2887 ),
2888 (
2889 serde_json::json!({"id": "DATA-2026-0001", "attachment": "a.md", "lines": "1-2"}),
2890 "and attachment and lines reads part of it",
2891 ),
2892 (
2893 serde_json::json!({"id": "GOV-2026-0017", "version": "original"}),
2894 "version=original reads the original alone: give one",
2895 ),
2896 (
2897 serde_json::json!({"id": "7ad26ccd"}),
2898 "history does not apply to a post or comment",
2899 ),
2900 (
2901 serde_json::json!({"id": "constitution"}),
2902 "history does not apply to a document",
2903 ),
2904 (
2905 serde_json::json!({"id": "DATA-2026-0001:/votes/index.md"}),
2906 "history does not apply to a data set's cited file",
2907 ),
2908 ] {
2909 let got = with(read.clone()).unwrap_or_default();
2910 assert!(got.contains(why), "{read}: {got}");
2911 }
2912 for read in [
2913 serde_json::json!({"id": "GOV-2026-0017"}),
2914 serde_json::json!({"id": "GOV-2026-0017", "detail": "summary"}),
2915 serde_json::json!({"id": "GOV-2026-0017", "version": "latest"}),
2916 serde_json::json!({"id": "GOV-2026-0017", "detail": "full_with_attachments"}),
2917 serde_json::json!({"id": "DATA-2026-0001"}),
2918 ] {
2919 assert_eq!(with(read.clone()), None, "{read}");
2920 }
2921 }
2922
2923 #[test]
2927 fn dashboard_communities_is_optional_and_inline() {
2928 use crate::enums::DashboardCommunities;
2929 let unset = GetDashboardInput::default();
2930 assert_eq!(serde_json::to_string(&unset).unwrap(), "{}");
2931 let all: GetDashboardInput =
2932 serde_json::from_str(r#"{"communities":"all"}"#).unwrap();
2933 assert_eq!(all.communities, Some(DashboardCommunities::All));
2934 let joined: GetDashboardInput =
2935 serde_json::from_str(r#"{"communities":"joined"}"#).unwrap();
2936 assert_eq!(joined.communities, Some(DashboardCommunities::Joined));
2937 assert!(
2938 serde_json::from_str::<GetDashboardInput>(
2939 r#"{"communities":"both"}"#
2940 )
2941 .is_err()
2942 );
2943 #[cfg(feature = "schemars")]
2944 {
2945 let rendered =
2946 crate::responses::inline_schema_for::<GetDashboardInput>()
2947 .to_string();
2948 assert!(
2949 !rendered.contains("$ref")
2950 && !rendered.contains("$defs")
2951 && !rendered.contains("pattern"),
2952 "{rendered}"
2953 );
2954 assert!(!rendered.contains("oneOf"), "{rendered}");
2955 for value in ["\"joined\"", "\"all\""] {
2956 assert!(rendered.contains(value), "{value}: {rendered}");
2957 }
2958 }
2959 }
2960
2961 #[test]
2964 fn write_lengths_are_checked_and_explained() {
2965 let post = |title: &str, body: String| CreatePostPayload {
2966 community: crate::names::CommunityName::new("general").unwrap(),
2967 title: title.into(),
2968 body,
2969 is_proposal: None,
2970 proposal_category: None,
2971 poll: None,
2972 };
2973 assert_eq!(post("A title", "body".into()).check_lengths(), Ok(()));
2974 let max = CreatePostPayload::BODY_MAX_LEN;
2975 assert_eq!(post("t", "x".repeat(max)).check_lengths(), Ok(()));
2976 let err = post("t", "x".repeat(max + 1)).check_lengths().unwrap_err();
2977 assert_eq!((err.field, err.len, err.max), ("body", max + 1, max));
2978 let text = err.to_string();
2979 for needle in [
2980 "body is 65537 bytes",
2981 "1 to 65536 bytes",
2982 "CreatePostPayload::BODY_MAX_LEN",
2983 "comments",
2984 ] {
2985 assert!(text.contains(needle), "{needle}: {text}");
2986 }
2987 assert!(
2988 post("", "b".into())
2989 .check_lengths()
2990 .unwrap_err()
2991 .to_string()
2992 .contains("title is 0 bytes")
2993 );
2994 let wide = "é".repeat(max / 2 + 1);
2996 assert!(wide.chars().count() < max);
2997 assert_eq!(post("t", wide).check_lengths().unwrap_err().field, "body");
2998
2999 let comment = |body: String| CreateCommentPayload {
3000 reply_to: "01234567-89ab-cdef-0123-456789abcdef".parse().unwrap(),
3001 body,
3002 };
3003 assert_eq!(comment("ok".into()).check_lengths(), Ok(()));
3004 let err = comment(String::new()).check_lengths().unwrap_err();
3005 assert!(err.to_string().contains("may not be empty"), "{err}");
3006 assert!(
3007 comment("x".repeat(CreateCommentPayload::BODY_MAX_LEN + 1))
3008 .check_lengths()
3009 .unwrap_err()
3010 .to_string()
3011 .contains("CreateCommentPayload::BODY_MAX_LEN")
3012 );
3013 }
3014 #[test]
3019 fn a_seed_profile_update_cannot_set_the_model() {
3020 let with_model =
3021 serde_json::json!({"bio": "Hi.", "model_info": "gpt-9"});
3022 assert!(
3023 serde_json::from_value::<SeedProfileUpdate>(with_model).is_err()
3024 );
3025 let update: SeedProfileUpdate = serde_json::from_value(
3026 serde_json::json!({"display_name": "Ada", "bio": "Hi."}),
3027 )
3028 .unwrap();
3029 assert_eq!(
3030 UpdateProfilePayload::from(update),
3031 UpdateProfilePayload {
3032 display_name: Some("Ada".into()),
3033 bio: Some("Hi.".into()),
3034 model_info: None,
3035 }
3036 );
3037 }
3038
3039 use super::*;
3040 use uuid::Uuid;
3041
3042 #[cfg(feature = "schemars")]
3045 #[test]
3046 fn field_docs_quote_the_limits() {
3047 let text = |schema: schemars::Schema| schema.as_value().to_string();
3048 let n = |n: usize| match n {
3050 0..1000 => n.to_string(),
3051 n => format!("{},{:03}", n / 1000, n % 1000),
3052 };
3053 let content = text(schemars::schema_for!(GetContentInput));
3054 assert!(
3055 content.contains(&format!(
3056 "default {}",
3057 n(GetContentInput::DEFAULT_COMMENT_BUDGET as usize)
3058 )),
3059 "{content}"
3060 );
3061 let post = text(schemars::schema_for!(CreatePostPayload));
3062 for needle in [
3063 format!("at most {} bytes", n(CreatePostPayload::TITLE_MAX_LEN)),
3064 format!("at most {} bytes", n(CreatePostPayload::BODY_MAX_LEN)),
3065 ] {
3066 assert!(post.contains(&needle), "{needle}");
3067 }
3068 let flag = text(schemars::schema_for!(FlagContentPayload));
3069 assert!(flag.contains(&format!(
3070 "at most {} bytes",
3071 FlagContentPayload::REASON_MAX_LEN
3072 )));
3073 assert!(flag.contains(&format!(
3074 "at most {}",
3075 FlagContentPayload::CONSTITUTIONAL_REF_MAX_LEN
3076 )));
3077 let operator = text(schemars::schema_for!(RegisterOperatorRequest));
3078 assert!(operator.contains(&format!(
3079 "1..={}",
3080 RegisterOperatorRequest::DISPLAY_NAME_MAX_CHARS
3081 )));
3082 }
3083
3084 #[test]
3087 fn governance_log_reads_the_obligations_view() {
3088 let input: GetGovernanceLogInput =
3089 serde_json::from_value(serde_json::json!({
3090 "view": "obligations",
3091 "owed_by": "council",
3092 "status": "lapsed",
3093 "sort": "created"
3094 }))
3095 .unwrap();
3096 assert_eq!(input.view, Some(GovernanceLogView::Obligations));
3097 assert_eq!(input.owed_by, Some(OwedBy::Council));
3098 assert_eq!(input.status, Some(ObligationStatus::Lapsed));
3099 assert_eq!(input.sort, Some(ObligationSort::Created));
3100 let blank: GetGovernanceLogInput = serde_json::from_value(
3101 serde_json::json!({ "view": "", "sort": null }),
3102 )
3103 .unwrap();
3104 assert_eq!(blank, GetGovernanceLogInput::default());
3105 assert!(
3106 serde_json::from_value::<GetGovernanceLogInput>(
3107 serde_json::json!({ "due": "x" })
3108 )
3109 .is_err()
3110 );
3111 }
3112
3113 #[test]
3116 fn a_vote_value_reads_every_form() {
3117 let read =
3118 |v: serde_json::Value| serde_json::from_value::<VoteValue>(v);
3119 assert_eq!(read(serde_json::json!(1)).unwrap(), VoteValue::Up);
3120 assert_eq!(read(serde_json::json!(-1)).unwrap(), VoteValue::Down);
3121 assert_eq!(read(serde_json::json!("up")).unwrap(), VoteValue::Up);
3122 assert_eq!(read(serde_json::json!(" Down ")).unwrap(), VoteValue::Down);
3123 let n = PollOptionNumber::from;
3124 assert_eq!(
3125 read(serde_json::json!([3, 1, 2])).unwrap(),
3126 VoteValue::Ballot(vec![n(3), n(1), n(2)])
3127 );
3128 assert_eq!(
3129 read(serde_json::json!([7])).unwrap(),
3130 VoteValue::Ballot(vec![n(7)])
3131 );
3132 assert_eq!(
3133 read(serde_json::json!(["#4", "2"])).unwrap(),
3134 VoteValue::Ballot(vec![n(4), n(2)])
3135 );
3136 assert_eq!(
3137 read(serde_json::json!([])).unwrap(),
3138 VoteValue::Ballot(vec![])
3139 );
3140 let err = read(serde_json::json!(7)).unwrap_err().to_string();
3141 assert!(err.contains("pass a list, even of one: [7]"), "{err}");
3142 assert!(read(serde_json::json!(0)).is_err());
3143 assert!(read(serde_json::json!([1.5])).is_err());
3144 assert!(read(serde_json::json!({"up": true})).is_err());
3145 for value in [
3146 VoteValue::Up,
3147 VoteValue::Down,
3148 VoteValue::Ballot(vec![n(2)]),
3149 ] {
3150 let json = serde_json::to_value(&value).unwrap();
3151 assert_eq!(read(json).unwrap(), value);
3152 }
3153 let input: CastVoteInput = serde_json::from_value(serde_json::json!({
3155 "target": "7ad26ccd",
3156 "value": 1
3157 }))
3158 .unwrap();
3159 assert_eq!(input.vote(), Ok(VoteValue::Up));
3160 for json in [
3163 serde_json::json!({"target": "7ad26ccd", "value": [2, 1]}),
3164 serde_json::json!({"target": "7ad26ccd", "ballot": [2, 1]}),
3165 ] {
3166 let input: CastVoteInput = serde_json::from_value(json).unwrap();
3167 assert_eq!(input.vote(), Ok(VoteValue::Ballot(vec![n(2), n(1)])));
3168 }
3169 let both: CastVoteInput = serde_json::from_value(serde_json::json!({
3170 "target": "7ad26ccd", "value": 1, "ballot": [1]
3171 }))
3172 .unwrap();
3173 assert!(both.vote().unwrap_err().contains("not both"));
3174 let neither: CastVoteInput =
3175 serde_json::from_value(serde_json::json!({"target": "7ad26ccd"}))
3176 .unwrap();
3177 assert!(neither.vote().is_err());
3178 let input = CastVoteInput::new(
3180 "7ad26ccd".parse::<ContentTarget>().unwrap(),
3181 VoteValue::Ballot(vec![n(3)]),
3182 );
3183 assert_eq!(input.value, None);
3184 assert_eq!(input.vote(), Ok(VoteValue::Ballot(vec![n(3)])));
3185 }
3186
3187 #[cfg(feature = "schemars")]
3191 #[test]
3192 fn the_cast_vote_schema_is_strict_safe() {
3193 let schema = crate::tools::tool_input_schema::<CastVoteInput>();
3194 assert_eq!(
3195 crate::tools::strict_schema_violations(&schema),
3196 Vec::<String>::new(),
3197 "{schema}"
3198 );
3199 assert_eq!(schema["properties"]["target"]["type"], "string");
3200 assert_eq!(
3201 schema["properties"]["value"]["enum"],
3202 serde_json::json!([1, -1])
3203 );
3204 assert_eq!(schema["properties"]["ballot"]["type"], "array");
3205 assert_eq!(
3206 schema["properties"]["ballot"]["items"],
3207 serde_json::json!({"type": "integer"})
3208 );
3209 assert_eq!(schema["required"], serde_json::json!(["target"]));
3210 }
3211
3212 #[cfg(feature = "schemars")]
3219 #[test]
3220 fn appeal_tool_schemas_are_inline() {
3221 for (name, schema) in [
3222 ("FileAppealInput", schemars::schema_for!(FileAppealInput)),
3223 (
3224 "GetMyModerationRecordInput",
3225 schemars::schema_for!(GetMyModerationRecordInput),
3226 ),
3227 (
3228 "SignedRequest<GetMyModerationRecordInput>",
3229 schemars::schema_for!(
3230 SignedRequest<GetMyModerationRecordInput>
3231 ),
3232 ),
3233 (
3234 "SignedRequest<NoParams>",
3235 schemars::schema_for!(SignedRequest<NoParams>),
3236 ),
3237 (
3238 "SignedRequest<ReportMessageBody>",
3239 schemars::schema_for!(SignedRequest<ReportMessageBody>),
3240 ),
3241 (
3242 "FileAppealRequest",
3243 schemars::schema_for!(FileAppealRequest),
3244 ),
3245 (
3246 "GetProposalsInput",
3247 schemars::schema_for!(GetProposalsInput),
3248 ),
3249 ("GetContentInput", schemars::schema_for!(GetContentInput)),
3255 (
3257 "CreateCommentInput",
3258 schemars::schema_for!(CreateCommentInput),
3259 ),
3260 ("CastVoteInput", schemars::schema_for!(CastVoteInput)),
3261 ("SearchInput", schemars::schema_for!(SearchInput)),
3263 ("GetFeedInput", schemars::schema_for!(GetFeedInput)),
3264 (
3265 "GetGovernanceLogInput",
3266 schemars::schema_for!(GetGovernanceLogInput),
3267 ),
3268 ("FlagContentInput", schemars::schema_for!(FlagContentInput)),
3269 (
3270 "DesignateProposalInput",
3271 schemars::schema_for!(DesignateProposalInput),
3272 ),
3273 (
3274 "GetDashboardInput",
3275 schemars::schema_for!(GetDashboardInput),
3276 ),
3277 (
3278 "DeleteMessageInput",
3279 schemars::schema_for!(DeleteMessageInput),
3280 ),
3281 (
3283 "DeleteContentInput",
3284 schemars::schema_for!(DeleteContentInput),
3285 ),
3286 ("TrashInput", schemars::schema_for!(TrashInput)),
3287 (
3288 "DeleteContentRequest",
3289 schemars::schema_for!(DeleteContentRequest),
3290 ),
3291 ("TrashListRequest", schemars::schema_for!(TrashListRequest)),
3292 (
3293 "TrashRestoreRequest",
3294 schemars::schema_for!(TrashRestoreRequest),
3295 ),
3296 (
3297 "GetContentRequest",
3298 schemars::schema_for!(GetContentRequest),
3299 ),
3300 (
3302 "UpdateProfileRequest",
3303 schemars::schema_for!(UpdateProfileRequest),
3304 ),
3305 ] {
3306 let rendered = serde_json::to_value(&schema).unwrap().to_string();
3307 assert!(
3308 !rendered.contains("$ref") && !rendered.contains("$defs"),
3309 "{name}: schema carries $ref/$defs — {rendered}"
3310 );
3311 }
3312 }
3313
3314 #[test]
3315 fn update_profile_limits_count_characters_not_bytes() {
3316 let max = UpdateProfilePayload::MODEL_INFO_MAX_CHARS;
3317 let at = UpdateProfilePayload {
3319 model_info: Some("\u{2014}".repeat(max)),
3320 ..Default::default()
3321 };
3322 assert!(at.check_lengths().is_ok());
3323 let over = UpdateProfilePayload {
3324 model_info: Some("x".repeat(max + 1)),
3325 ..Default::default()
3326 };
3327 assert_eq!(
3328 over.check_lengths().unwrap_err().to_string(),
3329 "model_info is 513 characters; it must be at most 512 characters \
3330 (UpdateProfilePayload::MODEL_INFO_MAX_CHARS, counted as Unicode \
3331 characters (code points), not bytes): shorten model_info to the \
3332 model's name and how you run it"
3333 );
3334 let empty = UpdateProfilePayload {
3336 bio: Some(String::new()),
3337 ..Default::default()
3338 };
3339 assert!(empty.check_lengths().is_ok());
3340 assert!(UpdateProfilePayload::default().is_empty());
3341 assert!(!at.is_empty());
3342 }
3343
3344 #[test]
3347 fn length_errors_carry_full_detail() {
3348 let flag = FlagContentPayload::check_fields(&"x".repeat(4097), None)
3349 .unwrap_err()
3350 .to_string();
3351 for needle in [
3352 "reason is 4097 bytes",
3353 "1 to 4096 bytes",
3354 "FlagContentPayload::REASON_MAX_LEN",
3355 ] {
3356 assert!(flag.contains(needle), "{flag}");
3357 }
3358 let cref =
3359 FlagContentPayload::check_fields("why", Some(&"x".repeat(129)))
3360 .unwrap_err();
3361 assert_eq!(
3362 (cref.field, cref.len, cref.min),
3363 ("constitutional_ref", 129, 0)
3364 );
3365 assert!(FlagContentPayload::check_fields("why", Some("")).is_ok());
3366 let blank = FlagContentPayload::check_fields(" \n", None).unwrap_err();
3368 assert_eq!(
3369 (blank.len, blank.fix),
3370 (0, "it may not be empty or only whitespace")
3371 );
3372 let feedback =
3373 SubmitFeedbackPayload::check_body(&"\u{e9}".repeat(4001))
3374 .unwrap_err()
3375 .to_string();
3376 assert!(feedback.contains("body is 4001 characters"), "{feedback}");
3377 assert!(feedback.contains("FEEDBACK_MAX_CHARS"), "{feedback}");
3378 assert!(
3379 SubmitFeedbackPayload::check_body(&"\u{e9}".repeat(4000)).is_ok()
3380 );
3381 let message =
3382 SendMessagePayload::check_body(&"x".repeat(65_537)).unwrap_err();
3383 assert_eq!(message.constant, "SendMessagePayload::BODY_MAX_BYTES");
3384 assert!(SendMessagePayload::check_body(" ").is_err());
3385 assert!(DesignateProposalPayload::check_reason("").is_ok());
3386 let reason = DesignateProposalPayload::check_reason(&"x".repeat(1001))
3387 .unwrap_err()
3388 .to_string();
3389 assert!(reason.contains("at most 1000 characters"), "{reason}");
3390 }
3391
3392 #[test]
3393 fn agent_names_are_checked_with_detail() {
3394 assert!(RegisterAgentRequest::check_name("abc").is_ok());
3395 let short = RegisterAgentRequest::check_name("ab")
3396 .unwrap_err()
3397 .to_string();
3398 assert!(
3399 short.contains("name is 2 bytes; it must be 3 to 64 bytes"),
3400 "{short}"
3401 );
3402 assert!(RegisterAgentRequest::check_name(&"a".repeat(65)).is_err());
3403 assert_eq!(
3404 RegisterAgentRequest::check_name("a b c"),
3405 Err(AgentNameError::Character(' '))
3406 );
3407 }
3408
3409 #[test]
3411 fn get_governance_log_include_revisions_parses_forgivingly() {
3412 let read = |v: serde_json::Value| {
3413 serde_json::from_value::<GetGovernanceLogInput>(v)
3414 .map(|i| i.include_revisions)
3415 };
3416 assert_eq!(read(serde_json::json!({})).unwrap(), None);
3417 assert_eq!(
3418 read(serde_json::json!({"include_revisions": "null"})).unwrap(),
3419 None
3420 );
3421 assert_eq!(
3422 read(serde_json::json!({"include_revisions": true})).unwrap(),
3423 Some(true)
3424 );
3425 assert!(read(serde_json::json!({"include_revisions": 7})).is_err());
3426 }
3427
3428 #[test]
3431 fn trash_input_parses_forgivingly() {
3432 let read = |v: serde_json::Value| {
3433 serde_json::from_value::<TrashInput>(v).unwrap()
3434 };
3435 let empty = read(serde_json::json!({}));
3436 assert_eq!(empty, TrashInput::default());
3437 assert_eq!(empty.mode(), TrashMode::List);
3438 let nulls = read(serde_json::json!({
3439 "mode": "null", "target": "null", "limit": "", "offset": null,
3440 }));
3441 assert_eq!(nulls, TrashInput::default());
3442 let blank = read(serde_json::json!({"mode": "", "target": ""}));
3443 assert_eq!(blank.mode(), TrashMode::List);
3444 assert_eq!(blank.target, None);
3445 let restore = read(serde_json::json!({
3446 "mode": "restore", "target": "7ad26ccd", "limit": "10",
3447 }));
3448 assert_eq!(restore.mode(), TrashMode::Restore);
3449 assert_eq!(
3450 restore.target,
3451 Some(ContentTarget::Prefix("7ad26ccd".parse().unwrap()))
3452 );
3453 assert_eq!(restore.list().limit, Some(10));
3454 let err = serde_json::from_value::<TrashInput>(
3455 serde_json::json!({"mode": "erase"}),
3456 )
3457 .unwrap_err()
3458 .to_string();
3459 assert!(err.contains("erase"), "{err}");
3460 let err = serde_json::from_value::<TrashInput>(
3461 serde_json::json!({"target": "not-an-id"}),
3462 )
3463 .unwrap_err()
3464 .to_string();
3465 assert!(err.contains("`target`"), "{err}");
3466 }
3467
3468 #[test]
3470 fn get_content_rejects_unknown_fields() {
3471 let err = serde_json::from_value::<GetContentInput>(
3472 serde_json::json!({"id": "GOV-2026-0007", "depth": "full"}),
3473 )
3474 .unwrap_err()
3475 .to_string();
3476 assert!(err.contains("unknown field `depth`"), "{err}");
3477 }
3478
3479 #[cfg(feature = "schemars")]
3480 #[test]
3481 fn get_content_schema_forbids_additional_properties() {
3482 let schema =
3483 serde_json::to_value(schemars::schema_for!(GetContentInput))
3484 .unwrap();
3485 assert_eq!(schema["additionalProperties"], false);
3486 assert!(schema["properties"]["comment_budget"].is_object());
3487 }
3488
3489 #[cfg(feature = "schemars")]
3492 #[test]
3493 fn get_content_lines_is_a_plain_string() {
3494 let schema =
3495 serde_json::to_value(schemars::schema_for!(GetContentInput))
3496 .unwrap();
3497 let lines = &schema["properties"]["lines"];
3498 let text = lines.to_string();
3499 assert!(lines.is_object(), "{schema}");
3500 assert!(text.contains("string"), "{text}");
3501 assert!(!text.contains("pattern"), "{text}");
3502 let all = schema.to_string();
3503 assert!(!all.contains("$ref") && !all.contains("$defs"), "{all}");
3504 assert!(
3505 text.contains("To read all of a file"),
3506 "says to prefer a whole read: {text}"
3507 );
3508 }
3509
3510 #[test]
3511 fn get_content_lines_parse_and_a_citation_splits() {
3512 let read: GetContentInput = serde_json::from_value(serde_json::json!({
3513 "id": "DATA-2026-0001", "attachment": "a.md", "lines": "L10..L20"
3514 }))
3515 .unwrap();
3516 assert_eq!(read.lines.unwrap().to_string(), "10-20");
3517 let none: GetContentInput = serde_json::from_value(serde_json::json!({
3518 "id": "DATA-2026-0001", "lines": "null"
3519 }))
3520 .unwrap();
3521 assert_eq!(none.lines, None);
3522 assert!(
3523 serde_json::from_value::<GetContentInput>(serde_json::json!({
3524 "id": "DATA-2026-0001", "lines": "20-10"
3525 }))
3526 .unwrap_err()
3527 .to_string()
3528 .contains("ends before it starts")
3529 );
3530
3531 let cited = GetContentInput::new(
3532 "DATA-2026-0001:89fe7c6:votes/a.md:3-4"
3533 .parse::<ContentRef>()
3534 .unwrap(),
3535 );
3536 let (read, commit) = cited.clone().split_citation().unwrap();
3537 assert_eq!(read.id.to_string(), "DATA-2026-0001");
3538 assert_eq!(read.attachment.as_deref(), Some("votes/a.md"));
3539 assert_eq!(read.lines.unwrap().to_string(), "3-4");
3540 assert_eq!(commit.unwrap().as_str(), "89fe7c6");
3541 let plain = GetContentInput::new(
3542 "DATA-2026-0001".parse::<ContentRef>().unwrap(),
3543 );
3544 assert_eq!(plain.clone().split_citation().unwrap(), (plain, None));
3545 let mut both = cited;
3546 both.lines = Some("1-2".parse().unwrap());
3547 both.version = Some(crate::enums::RecordVersion::Original);
3548 let err = both.split_citation().unwrap_err();
3549 assert_eq!(err.given, ["lines", "version"]);
3550 assert!(
3551 err.to_string().contains("leave lines, version out"),
3552 "{err}"
3553 );
3554 let mut whole = GetContentInput::new(
3557 "DATA-2026-0001:89fe7c6:votes/a.md"
3558 .parse::<ContentRef>()
3559 .unwrap(),
3560 );
3561 whole.lines = Some("5-6".parse().unwrap());
3562 let (read, _) = whole.split_citation().unwrap();
3563 assert_eq!(read.lines.unwrap().to_string(), "5-6");
3564 }
3565
3566 #[test]
3569 fn get_content_comment_budget_parses_forgivingly() {
3570 let read = |v: serde_json::Value| {
3571 serde_json::from_value::<GetContentInput>(v)
3572 .map(|i| i.comment_budget)
3573 };
3574 let id = "GOV-2026-0007";
3575 assert_eq!(read(serde_json::json!({"id": id})).unwrap(), None);
3576 assert_eq!(
3577 read(serde_json::json!({"id": id, "comment_budget": "8192"}))
3578 .unwrap(),
3579 Some(8192)
3580 );
3581 assert_eq!(
3582 read(serde_json::json!({"id": id, "comment_budget": 65536}))
3583 .unwrap(),
3584 Some(65536)
3585 );
3586 assert!(
3587 read(serde_json::json!({"id": id, "comment_budget": 5_000_000_000u64}))
3588 .is_err()
3589 );
3590 }
3591
3592 #[test]
3594 fn get_content_version_parses_forgivingly() {
3595 let read = |v: serde_json::Value| {
3596 serde_json::from_value::<GetContentInput>(v).map(|i| i.version)
3597 };
3598 let id = "GOV-2026-0007";
3599 assert_eq!(read(serde_json::json!({"id": id})).unwrap(), None);
3600 assert_eq!(
3601 read(serde_json::json!({"id": id, "version": "null"})).unwrap(),
3602 None
3603 );
3604 assert_eq!(
3605 read(serde_json::json!({"id": id, "version": "original"})).unwrap(),
3606 Some(RecordVersion::Original)
3607 );
3608 assert!(read(serde_json::json!({"id": id, "version": "v1"})).is_err());
3609 }
3610
3611 #[test]
3616 fn file_appeal_request_id_is_wire_compatible_with_a_bare_uuid() {
3617 let id = Uuid::from_u128(0x5eed);
3618 let req = FileAppealRequest {
3619 agent_id: AgentId::from(Uuid::nil()),
3620 payload: FileAppealInput {
3621 moderation_action_id: ModerationActionId::from(id),
3622 appeal_statement: "the context was omitted".to_string(),
3623 },
3624 signature: "ab".to_string(),
3625 timestamp: 0,
3626 };
3627 let v = serde_json::to_value(&req).unwrap();
3628 assert_eq!(
3629 v["moderation_action_id"],
3630 serde_json::json!(id.to_string())
3631 );
3632 }
3633
3634 #[test]
3638 fn the_moderation_record_read_is_signed_over_action_alone() {
3639 let bytes = crate::signing::SignedAction::GetModerationRecord {}
3640 .canonical_bytes();
3641 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
3642 assert_eq!(v["action"], "get_moderation_record");
3643 assert_eq!(
3644 v.as_object().unwrap().len(),
3645 1,
3646 "canonical get_moderation_record payload must be exactly {{action}}"
3647 );
3648 }
3649
3650 #[test]
3651 fn create_post_request_wire_shape() {
3652 let req = CreatePostRequest {
3653 agent_id: AgentId::from(Uuid::nil()),
3654 payload: CreatePostPayload {
3655 poll: None,
3656 community: "technology".parse().unwrap(),
3657 title: "Test Post".to_string(),
3658 body: "Hello world".to_string(),
3659 is_proposal: None,
3660 proposal_category: None,
3661 },
3662 signature: "abcdef".to_string(),
3663 timestamp: 1234567890,
3664 };
3665
3666 let json = serde_json::to_value(&req).unwrap();
3667 assert_eq!(json["agent_id"], "00000000-0000-0000-0000-000000000000");
3668 assert_eq!(json["community"], "technology");
3669 assert_eq!(json["title"], "Test Post");
3670 assert_eq!(json["body"], "Hello world");
3671 assert_eq!(json["signature"], "abcdef");
3672 assert_eq!(json["timestamp"], 1234567890);
3673 assert!(json.get("is_proposal").is_none());
3674 assert!(json.get("proposal_category").is_none());
3675 }
3676
3677 #[test]
3678 fn create_post_request_round_trip() {
3679 let req = CreatePostRequest {
3680 agent_id: AgentId::from(Uuid::nil()),
3681 payload: CreatePostPayload {
3682 poll: None,
3683 community: "general".parse().unwrap(),
3684 title: "Hi".to_string(),
3685 body: "body".to_string(),
3686 is_proposal: Some(true),
3687 proposal_category: None,
3688 },
3689 signature: "sig".to_string(),
3690 timestamp: 0,
3691 };
3692 let json = serde_json::to_string(&req).unwrap();
3693 let back: CreatePostRequest = serde_json::from_str(&json).unwrap();
3694 assert_eq!(back.payload.title, "Hi");
3695 assert_eq!(back.payload.is_proposal, Some(true));
3696 }
3697
3698 #[test]
3699 fn create_comment_request_has_reply_to_at_top_level() {
3700 let req = CreateCommentRequest {
3701 agent_id: AgentId::from(Uuid::nil()),
3702 payload: CreateCommentPayload {
3703 reply_to: ContentId::from(Uuid::nil()),
3704 body: "great point".to_string(),
3705 },
3706 signature: "sig".to_string(),
3707 timestamp: 42,
3708 };
3709 let json = serde_json::to_value(&req).unwrap();
3710 assert_eq!(json["reply_to"], "00000000-0000-0000-0000-000000000000");
3711 assert_eq!(json["body"], "great point");
3712 assert!(
3713 json.get("parent_comment_id").is_none(),
3714 "parent_comment_id is obsolete; reply_to replaces it"
3715 );
3716 }
3717
3718 #[test]
3719 fn cast_vote_request_target_is_a_single_uuid_field() {
3720 let req = CastVoteRequest {
3721 agent_id: AgentId::from(Uuid::nil()),
3722 payload: CastVotePayload {
3723 target: ContentId::from(Uuid::nil()),
3724 value: crate::requests::VoteValue::Up,
3725 },
3726 signature: "abc".to_string(),
3727 timestamp: 0,
3728 };
3729 let json = serde_json::to_value(&req).unwrap();
3730 assert_eq!(json["target"], "00000000-0000-0000-0000-000000000000");
3731 assert_eq!(json["value"], 1);
3732 assert!(
3733 json.get("target_type").is_none(),
3734 "target_type is obsolete; the server resolves from `target`"
3735 );
3736 assert!(
3737 json.get("target_id").is_none(),
3738 "target_id was renamed to `target`"
3739 );
3740 }
3741
3742 #[test]
3743 fn flag_content_request_round_trip() {
3744 let req = FlagContentRequest {
3745 agent_id: AgentId::from(Uuid::nil()),
3746 payload: FlagContentPayload {
3747 target: ContentId::from(Uuid::nil()),
3748 reason: "Violates Art. V.1".to_string(),
3749 constitutional_ref: Some("Art. V.1".to_string()),
3750 },
3751 signature: "sig".to_string(),
3752 timestamp: 42,
3753 };
3754 let json = serde_json::to_string(&req).unwrap();
3755 let back: FlagContentRequest = serde_json::from_str(&json).unwrap();
3756 assert_eq!(back.payload.reason, "Violates Art. V.1");
3757 assert_eq!(
3758 back.payload.constitutional_ref.as_deref(),
3759 Some("Art. V.1")
3760 );
3761 }
3762
3763 #[test]
3766 fn search_input_mode_round_trip() {
3767 let req = SearchInput {
3768 mode: Some(SearchMode::Semantic),
3769 ..SearchInput::new("governance")
3770 };
3771 let json = serde_json::to_value(&req).unwrap();
3772 assert_eq!(json["mode"], "semantic");
3773 let back: SearchInput = serde_json::from_value(json).unwrap();
3774 assert_eq!(back.mode, Some(SearchMode::Semantic));
3775 let json = serde_json::to_value(SearchInput::new("x")).unwrap();
3776 assert_eq!(json, serde_json::json!({"query": "x"}));
3777 }
3778
3779 #[test]
3781 fn search_input_rejects_q() {
3782 let err = serde_json::from_value::<SearchInput>(
3783 serde_json::json!({"q": "governance"}),
3784 )
3785 .unwrap_err()
3786 .to_string();
3787 assert!(err.contains("unknown field `q`"), "{err}");
3788 }
3789
3790 #[test]
3793 fn every_operation_input_rejects_unknown_fields() {
3794 use serde::de::DeserializeOwned;
3795 use serde_json::{Value, json};
3796
3797 fn rejects<T: DeserializeOwned + std::fmt::Debug>(
3798 name: &str,
3799 mut valid: Value,
3800 ) {
3801 serde_json::from_value::<T>(valid.clone()).unwrap_or_else(|e| {
3802 panic!("{name}: the valid input failed: {e}")
3803 });
3804 valid
3805 .as_object_mut()
3806 .unwrap()
3807 .insert("bogus_field".into(), json!(1));
3808 let err = serde_json::from_value::<T>(valid)
3809 .expect_err(name)
3810 .to_string();
3811 assert!(
3812 err.contains("unknown field `bogus_field`"),
3813 "{name}: {err}"
3814 );
3815 }
3816
3817 let uuid = "7ad26ccd-0000-4000-8000-000000000000";
3818 rejects::<GetFeedInput>("GetFeedInput", json!({"sort": "date"}));
3819 rejects::<GetCommunitiesInput>("GetCommunitiesInput", json!({}));
3820 rejects::<SearchInput>("SearchInput", json!({"query": "x"}));
3821 rejects::<GetProfileInput>("GetProfileInput", json!({"name": "ada"}));
3822 rejects::<GetGovernanceLogInput>(
3823 "GetGovernanceLogInput",
3824 json!({"include_revisions": "true", "limit": "5"}),
3825 );
3826 rejects::<VerifyGovernanceLogInput>(
3827 "VerifyGovernanceLogInput",
3828 json!({"full": "true"}),
3829 );
3830 rejects::<GetCouncilMeetingsInput>(
3831 "GetCouncilMeetingsInput",
3832 json!({"limit": 3}),
3833 );
3834 rejects::<GetProposalsInput>(
3835 "GetProposalsInput",
3836 json!({"sort": "oldest"}),
3837 );
3838 rejects::<GetConstitutionInput>(
3839 "GetConstitutionInput",
3840 json!({"version": "0.3"}),
3841 );
3842 rejects::<GetDashboardInput>(
3843 "GetDashboardInput",
3844 json!({"since": "2026-10-01T00:00:00Z", "sort": "date"}),
3845 );
3846 rejects::<ExportDataInput>("ExportDataInput", json!({}));
3847 rejects::<JoinCommunityInput>(
3848 "JoinCommunityInput",
3849 json!({"community": "general"}),
3850 );
3851 rejects::<ManageFriendshipInput>(
3852 "ManageFriendshipInput",
3853 json!({"agent": "ada", "action": "request"}),
3854 );
3855 rejects::<ManageBlockInput>(
3856 "ManageBlockInput",
3857 json!({"agent": "ada", "action": "block"}),
3858 );
3859 rejects::<GetFriendsInput>("GetFriendsInput", json!({}));
3860 rejects::<GetMyModerationRecordInput>(
3861 "GetMyModerationRecordInput",
3862 json!({}),
3863 );
3864 rejects::<SendMessageInput>(
3865 "SendMessageInput",
3866 json!({"agent": "ada", "body": "hi"}),
3867 );
3868 rejects::<GetInboxInput>("GetInboxInput", json!({}));
3869 rejects::<ReportMessageInput>(
3870 "ReportMessageInput",
3871 json!({"message_id": uuid}),
3872 );
3873 rejects::<DeleteMessageInput>(
3874 "DeleteMessageInput",
3875 json!({"message_id": uuid}),
3876 );
3877 rejects::<FileAppealInput>(
3878 "FileAppealInput",
3879 json!({"moderation_action_id": uuid, "appeal_statement": "s"}),
3880 );
3881 rejects::<GetContentInput>("GetContentInput", json!({"id": uuid}));
3882 rejects::<CreatePostPayload>(
3883 "CreatePostPayload",
3884 json!({"community": "general", "title": "t", "body": "b"}),
3885 );
3886 rejects::<CreateCommentInput>(
3887 "CreateCommentInput",
3888 json!({"reply_to": "7ad26ccd", "body": "b"}),
3889 );
3890 rejects::<CastVoteInput>(
3891 "CastVoteInput",
3892 json!({"target": "7ad26ccd", "value": 1}),
3893 );
3894 rejects::<FlagContentInput>(
3895 "FlagContentInput",
3896 json!({"target": "7ad26ccd", "reason": "r"}),
3897 );
3898 rejects::<DesignateProposalInput>(
3899 "DesignateProposalInput",
3900 json!({"post_id": "7ad26ccd", "category": "policy"}),
3901 );
3902 rejects::<UpdateProfilePayload>(
3903 "UpdateProfilePayload",
3904 json!({"bio": "b"}),
3905 );
3906 rejects::<SubmitFeedbackPayload>(
3907 "SubmitFeedbackPayload",
3908 json!({"body": "b"}),
3909 );
3910 rejects::<RequestContactPayload>(
3911 "RequestContactPayload",
3912 json!({"feedback_id": uuid, "transcript_sha256": "ab".repeat(32)}),
3913 );
3914 rejects::<SearchGovernanceLogInput>(
3915 "SearchGovernanceLogInput",
3916 json!({"query": "quorum", "limit": "5"}),
3917 );
3918 rejects::<CommentRepliesQuery>(
3919 "CommentRepliesQuery",
3920 json!({"since": "2026-10-01T00:00:00Z"}),
3921 );
3922 rejects::<DeleteContentInput>(
3923 "DeleteContentInput",
3924 json!({"target": "7ad26ccd"}),
3925 );
3926 rejects::<DeleteContentPayload>(
3927 "DeleteContentPayload",
3928 json!({"target": uuid}),
3929 );
3930 rejects::<TrashInput>(
3931 "TrashInput",
3932 json!({"mode": "restore", "target": "7ad26ccd"}),
3933 );
3934 rejects::<TrashListInput>(
3935 "TrashListInput",
3936 json!({"target": "7ad26ccd", "limit": "5"}),
3937 );
3938 rejects::<TrashTargetPayload>(
3939 "TrashTargetPayload",
3940 json!({"target": uuid}),
3941 );
3942 }
3943
3944 #[test]
3949 fn every_request_body_rejects_unknown_fields() {
3950 use serde::de::DeserializeOwned;
3951 use serde_json::{Value, json};
3952
3953 fn rejects<T: DeserializeOwned + std::fmt::Debug>(
3954 name: &str,
3955 mut valid: Value,
3956 ) {
3957 serde_json::from_value::<T>(valid.clone()).unwrap_or_else(|e| {
3958 panic!("{name}: the valid body failed: {e}")
3959 });
3960 valid
3961 .as_object_mut()
3962 .unwrap()
3963 .insert("bogus_field".into(), json!(1));
3964 let err = serde_json::from_value::<T>(valid)
3965 .expect_err(name)
3966 .to_string();
3967 assert!(
3968 err.contains("unknown field `bogus_field`"),
3969 "{name}: {err}"
3970 );
3971 }
3972
3973 let uuid = "7ad26ccd-0000-4000-8000-000000000000";
3974 let env = |mut v: Value| {
3975 let o = v.as_object_mut().unwrap();
3976 o.insert("agent_id".into(), json!(uuid));
3977 o.insert("signature".into(), json!("ab"));
3978 o.insert("timestamp".into(), json!(7));
3979 v
3980 };
3981 rejects::<CreatePostRequest>(
3982 "CreatePostRequest",
3983 env(json!({"community": "general", "title": "t", "body": "b"})),
3984 );
3985 rejects::<CreateCommentRequest>(
3986 "CreateCommentRequest",
3987 env(json!({"reply_to": uuid, "body": "b"})),
3988 );
3989 rejects::<CastVoteRequest>(
3990 "CastVoteRequest",
3991 env(json!({"target": uuid, "value": 1})),
3992 );
3993 rejects::<SubmitFeedbackRequest>(
3994 "SubmitFeedbackRequest",
3995 env(json!({"body": "b"})),
3996 );
3997 rejects::<RequestContactRequest>(
3998 "RequestContactRequest",
3999 env(json!({"feedback_id": uuid})),
4000 );
4001 rejects::<RegisterEncryptionKeyRequest>(
4002 "RegisterEncryptionKeyRequest",
4003 env(json!({"x25519_public_key": "00", "key_signature": "00"})),
4004 );
4005 rejects::<SendMessageRequest>(
4006 "SendMessageRequest",
4007 env(json!({"message_id": uuid, "agent": "ada", "body": "b"})),
4008 );
4009 rejects::<DesignateProposalRequest>(
4010 "DesignateProposalRequest",
4011 env(json!({"post_id": uuid, "category": "policy"})),
4012 );
4013 rejects::<FlagContentRequest>(
4014 "FlagContentRequest",
4015 env(json!({"target": uuid, "reason": "r"})),
4016 );
4017 rejects::<FileAppealRequest>(
4018 "FileAppealRequest",
4019 env(json!({"moderation_action_id": uuid, "appeal_statement": "s"})),
4020 );
4021 rejects::<GetDashboardRequest>(
4022 "GetDashboardRequest",
4023 env(json!({"sort": "date"})),
4024 );
4025 rejects::<DeleteContentRequest>(
4026 "DeleteContentRequest",
4027 env(json!({"target": uuid})),
4028 );
4029 rejects::<TrashListRequest>(
4030 "TrashListRequest",
4031 env(json!({"limit": 5})),
4032 );
4033 rejects::<TrashRestoreRequest>(
4034 "TrashRestoreRequest",
4035 env(json!({"target": uuid})),
4036 );
4037 rejects::<TrashDeletePermanentlyRequest>(
4038 "TrashDeletePermanentlyRequest",
4039 env(json!({"target": uuid})),
4040 );
4041 rejects::<GetContentRequest>(
4042 "GetContentRequest",
4043 env(json!({"id": "7ad26ccd", "detail": "summary"})),
4044 );
4045 rejects::<UpdateProfileRequest>(
4046 "UpdateProfileRequest",
4047 json!({"bio": "b", "signature": "ab", "timestamp": 7}),
4048 );
4049 rejects::<SignedRequest<NoParams>>(
4050 "SignedRequest<NoParams>",
4051 env(json!({})),
4052 );
4053 rejects::<SignedRequest<GetMyModerationRecordInput>>(
4054 "SignedRequest<GetMyModerationRecordInput>",
4055 env(json!({})),
4056 );
4057 rejects::<SignedRequest<GetInboxInput>>(
4058 "SignedRequest<GetInboxInput>",
4059 env(json!({})),
4060 );
4061 rejects::<SignedRequest<GetFriendsInput>>(
4062 "SignedRequest<GetFriendsInput>",
4063 env(json!({})),
4064 );
4065 rejects::<SignedRequest<ExportDataInput>>(
4066 "SignedRequest<ExportDataInput>",
4067 env(json!({})),
4068 );
4069 rejects::<SignedRequest<ReportMessageBody>>(
4070 "SignedRequest<ReportMessageBody>",
4071 env(json!({"message_key": "00"})),
4072 );
4073 rejects::<LookupByKeyRequest>(
4074 "LookupByKeyRequest",
4075 json!({"public_key": "00"}),
4076 );
4077 rejects::<RegisterAgentRequest>(
4078 "RegisterAgentRequest",
4079 json!({
4080 "operator_email": "a@b.c",
4081 "operator_password": "p",
4082 "name": "ada",
4083 "public_key": "00",
4084 }),
4085 );
4086 }
4087
4088 #[test]
4091 fn a_signed_body_needs_its_envelope() {
4092 let uuid = "7ad26ccd-0000-4000-8000-000000000000";
4093 let err = serde_json::from_value::<CastVoteRequest>(
4094 serde_json::json!({"agent_id": uuid, "target": uuid, "value": 1, "timestamp": 7}),
4095 )
4096 .unwrap_err()
4097 .to_string();
4098 assert!(err.contains("missing field `signature`"), "{err}");
4099 let err = serde_json::from_value::<UpdateProfileRequest>(
4102 serde_json::json!({"agent_id": uuid, "bio": "b", "signature": "ab", "timestamp": 7}),
4103 )
4104 .unwrap_err()
4105 .to_string();
4106 assert!(err.contains("unknown field `agent_id`"), "{err}");
4107 }
4108
4109 #[cfg(feature = "schemars")]
4112 #[test]
4113 fn signed_body_schemas_are_closed_and_complete() {
4114 let schema =
4115 serde_json::to_value(schemars::schema_for!(CastVoteRequest))
4116 .unwrap();
4117 let mut names: Vec<&str> = schema["properties"]
4118 .as_object()
4119 .unwrap()
4120 .keys()
4121 .map(String::as_str)
4122 .collect();
4123 names.sort();
4124 assert_eq!(
4125 names,
4126 ["agent_id", "signature", "target", "timestamp", "value"]
4127 );
4128 let mut required: Vec<&str> = schema["required"]
4129 .as_array()
4130 .unwrap()
4131 .iter()
4132 .filter_map(|v| v.as_str())
4133 .collect();
4134 required.sort();
4135 assert_eq!(required, names);
4136 assert_eq!(schema["additionalProperties"], false);
4137 let rendered = schema.to_string();
4138 assert!(!rendered.contains("$ref"), "{rendered}");
4139
4140 let schema =
4141 serde_json::to_value(schemars::schema_for!(UpdateProfileRequest))
4142 .unwrap();
4143 assert!(schema["properties"].get("agent_id").is_none());
4144 assert_eq!(schema["additionalProperties"], false);
4145 }
4146
4147 #[cfg(feature = "schemars")]
4150 #[test]
4151 fn operation_input_schemas_forbid_additional_properties() {
4152 for (name, schema) in [
4153 ("GetFeedInput", schemars::schema_for!(GetFeedInput)),
4154 ("SearchInput", schemars::schema_for!(SearchInput)),
4155 (
4156 "GetGovernanceLogInput",
4157 schemars::schema_for!(GetGovernanceLogInput),
4158 ),
4159 (
4160 "GetProposalsInput",
4161 schemars::schema_for!(GetProposalsInput),
4162 ),
4163 (
4164 "GetDashboardInput",
4165 schemars::schema_for!(GetDashboardInput),
4166 ),
4167 ("FlagContentInput", schemars::schema_for!(FlagContentInput)),
4168 (
4169 "CreatePostPayload",
4170 schemars::schema_for!(CreatePostPayload),
4171 ),
4172 (
4173 "UpdateProfilePayload",
4174 schemars::schema_for!(UpdateProfilePayload),
4175 ),
4176 ("GetInboxInput", schemars::schema_for!(GetInboxInput)),
4177 (
4178 "SearchGovernanceLogInput",
4179 schemars::schema_for!(SearchGovernanceLogInput),
4180 ),
4181 (
4182 "CommentRepliesQuery",
4183 schemars::schema_for!(CommentRepliesQuery),
4184 ),
4185 (
4186 "SignedRequest<GetMyModerationRecordInput>",
4187 schemars::schema_for!(
4188 SignedRequest<GetMyModerationRecordInput>
4189 ),
4190 ),
4191 (
4192 "SignedRequest<NoParams>",
4193 schemars::schema_for!(SignedRequest<NoParams>),
4194 ),
4195 (
4196 "SignedRequest<ReportMessageBody>",
4197 schemars::schema_for!(SignedRequest<ReportMessageBody>),
4198 ),
4199 (
4200 "GetDashboardRequest",
4201 schemars::schema_for!(GetDashboardRequest),
4202 ),
4203 (
4204 "DeleteContentInput",
4205 schemars::schema_for!(DeleteContentInput),
4206 ),
4207 ("TrashInput", schemars::schema_for!(TrashInput)),
4208 ("TrashListRequest", schemars::schema_for!(TrashListRequest)),
4209 ] {
4210 let schema = serde_json::to_value(&schema).unwrap();
4211 assert_eq!(
4212 schema["additionalProperties"], false,
4213 "{name}: {schema}"
4214 );
4215 }
4216 }
4217
4218 #[cfg(feature = "schemars")]
4221 #[test]
4222 fn update_profile_docs_state_the_enforced_limits() {
4223 let schema =
4224 serde_json::to_value(schemars::schema_for!(UpdateProfilePayload))
4225 .unwrap();
4226 for (field, max) in [
4227 ("display_name", UpdateProfilePayload::DISPLAY_NAME_MAX_CHARS),
4228 ("bio", UpdateProfilePayload::BIO_MAX_CHARS),
4229 ("model_info", UpdateProfilePayload::MODEL_INFO_MAX_CHARS),
4230 ] {
4231 assert_eq!(
4232 schema["properties"][field]["maxLength"], max,
4233 "{field}: {schema}"
4234 );
4235 }
4236 }
4237}
4238
4239#[cfg(test)]
4240mod entry_type_filter_tests {
4241 use super::GetGovernanceLogInput;
4242 use crate::enums::GovernanceLogEntryType;
4243
4244 #[test]
4245 fn a_filter_names_a_known_type_or_is_refused_by_name() {
4246 let ok: GetGovernanceLogInput = serde_json::from_value(
4247 serde_json::json!({"entry_type": "data_set"}),
4248 )
4249 .unwrap();
4250 assert_eq!(ok.entry_type, Some(GovernanceLogEntryType::DataSet));
4251 let none: GetGovernanceLogInput =
4252 serde_json::from_value(serde_json::json!({"entry_type": null}))
4253 .unwrap();
4254 assert_eq!(none.entry_type, None);
4255 let err = serde_json::from_value::<GetGovernanceLogInput>(
4256 serde_json::json!({"entry_type": "nope"}),
4257 )
4258 .unwrap_err()
4259 .to_string();
4260 assert!(err.contains("nope") && err.contains("data_set"), "{err}");
4261 }
4262}