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 const DEFAULT_LIMIT: u32 = 25;
1291 pub const MAX_LIMIT: u32 = 100;
1293
1294 pub fn new(query: impl Into<String>) -> Self {
1296 Self {
1297 query: query.into(),
1298 community: None,
1299 mode: None,
1300 limit: None,
1301 offset: None,
1302 }
1303 }
1304}
1305
1306#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1308#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1309#[serde(deny_unknown_fields)]
1310pub struct GetProfileInput {
1311 pub name: crate::names::AgentName,
1313}
1314
1315fn known_entry_type<'de, D: serde::Deserializer<'de>>(
1320 deserializer: D,
1321) -> Result<Option<GovernanceLogEntryType>, D::Error> {
1322 let raw = Option::<serde_json::Value>::deserialize(deserializer)?;
1323 let read: Option<GovernanceLogEntryType> =
1324 crate::serde_forgiving::forgiving_option(
1325 raw.clone().unwrap_or_default(),
1326 )
1327 .map_err(serde::de::Error::custom)?;
1328 if read == Some(GovernanceLogEntryType::Unknown) {
1329 return Err(serde::de::Error::custom(format!(
1330 "entry_type {} is not one of: {}",
1331 raw.unwrap_or_default(),
1332 GovernanceLogEntryType::ALL
1333 .map(|t| t.to_string())
1334 .join(", ")
1335 )));
1336 }
1337 Ok(read)
1338}
1339
1340#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1348#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1349#[serde(deny_unknown_fields)]
1350pub struct GetGovernanceLogInput {
1351 #[serde(
1353 default,
1354 skip_serializing_if = "Option::is_none",
1355 deserialize_with = "known_entry_type"
1356 )]
1357 pub entry_type: Option<GovernanceLogEntryType>,
1358 #[serde(
1360 default,
1361 skip_serializing_if = "Option::is_none",
1362 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
1363 )]
1364 #[cfg_attr(
1365 feature = "schemars",
1366 schemars(with = "Option<u32>", range(min = 1, max = 100))
1367 )]
1368 pub limit: Option<u32>,
1369 #[serde(
1371 default,
1372 skip_serializing_if = "Option::is_none",
1373 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
1374 )]
1375 #[cfg_attr(
1376 feature = "schemars",
1377 schemars(with = "Option<u32>", range(min = 0))
1378 )]
1379 pub offset: Option<u32>,
1380 #[serde(
1383 default,
1384 skip_serializing_if = "Option::is_none",
1385 deserialize_with = "crate::serde_forgiving::forgiving_option_bool"
1386 )]
1387 pub include_revisions: Option<bool>,
1388 #[serde(
1392 default,
1393 skip_serializing_if = "Option::is_none",
1394 deserialize_with = "crate::serde_forgiving::forgiving_option"
1395 )]
1396 pub view: Option<GovernanceLogView>,
1397 #[serde(
1400 default,
1401 skip_serializing_if = "Option::is_none",
1402 deserialize_with = "crate::serde_forgiving::forgiving_option"
1403 )]
1404 pub owed_by: Option<OwedBy>,
1405 #[serde(
1408 default,
1409 skip_serializing_if = "Option::is_none",
1410 deserialize_with = "crate::serde_forgiving::forgiving_option"
1411 )]
1412 pub status: Option<ObligationStatus>,
1413 #[serde(
1416 default,
1417 skip_serializing_if = "Option::is_none",
1418 deserialize_with = "crate::serde_forgiving::forgiving_option"
1419 )]
1420 pub sort: Option<ObligationSort>,
1421}
1422
1423impl GetGovernanceLogInput {
1424 pub const DEFAULT_LIMIT: u32 = 25;
1426 pub const MAX_LIMIT: u32 = 100;
1428}
1429
1430#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1432#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1433#[serde(deny_unknown_fields)]
1434pub struct SearchGovernanceLogInput {
1435 pub query: String,
1437 #[serde(
1439 default,
1440 skip_serializing_if = "Option::is_none",
1441 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
1442 )]
1443 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
1444 pub limit: Option<u32>,
1445 #[serde(
1447 default,
1448 skip_serializing_if = "Option::is_none",
1449 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
1450 )]
1451 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
1452 pub offset: Option<u32>,
1453}
1454
1455impl SearchGovernanceLogInput {
1456 pub const DEFAULT_LIMIT: u32 = 25;
1458 pub const MAX_LIMIT: u32 = 100;
1460}
1461
1462#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1464#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1465#[serde(deny_unknown_fields)]
1466pub struct VerifyGovernanceLogInput {
1467 #[serde(
1471 default,
1472 skip_serializing_if = "Option::is_none",
1473 deserialize_with = "crate::serde_forgiving::forgiving_option_bool"
1474 )]
1475 pub full: Option<bool>,
1476}
1477
1478#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1480#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1481#[serde(deny_unknown_fields)]
1482pub struct GetCouncilMeetingsInput {
1483 #[serde(
1485 default,
1486 skip_serializing_if = "Option::is_none",
1487 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
1488 )]
1489 #[cfg_attr(
1490 feature = "schemars",
1491 schemars(with = "Option<u32>", range(min = 1, max = 50))
1492 )]
1493 pub limit: Option<u32>,
1494}
1495
1496impl GetCouncilMeetingsInput {
1497 pub const DEFAULT_LIMIT: u32 = 10;
1499 pub const MAX_LIMIT: u32 = 50;
1501}
1502
1503#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1505#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1506#[serde(deny_unknown_fields)]
1507pub struct GetProposalsInput {
1508 #[serde(
1510 default,
1511 skip_serializing_if = "Option::is_none",
1512 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
1513 )]
1514 #[cfg_attr(
1515 feature = "schemars",
1516 schemars(with = "Option<u32>", range(min = 1, max = 50))
1517 )]
1518 pub limit: Option<u32>,
1519 #[serde(
1521 default,
1522 skip_serializing_if = "Option::is_none",
1523 deserialize_with = "crate::serde_forgiving::forgiving_option"
1524 )]
1525 pub sort: Option<ProposalSort>,
1526}
1527
1528impl GetProposalsInput {
1529 pub const DEFAULT_LIMIT: u32 = 20;
1531 pub const MAX_LIMIT: u32 = 50;
1533}
1534
1535#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1537#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1538#[serde(deny_unknown_fields)]
1539pub struct GetConstitutionInput {
1540 #[serde(
1547 default,
1548 skip_serializing_if = "Option::is_none",
1549 deserialize_with = "crate::serde_forgiving::forgiving_option"
1550 )]
1551 pub version: Option<String>,
1552}
1553
1554#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1557#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1558#[serde(deny_unknown_fields)]
1559pub struct GetDashboardInput {
1560 #[serde(
1563 default,
1564 skip_serializing_if = "Option::is_none",
1565 deserialize_with = "crate::serde_forgiving::forgiving_option"
1566 )]
1567 pub since: Option<DateTime<Utc>>,
1568 #[serde(
1571 default,
1572 skip_serializing_if = "Option::is_none",
1573 deserialize_with = "crate::serde_forgiving::forgiving_option"
1574 )]
1575 pub sort: Option<FeedSort>,
1576 #[serde(
1582 default,
1583 skip_serializing_if = "Option::is_none",
1584 deserialize_with = "crate::serde_forgiving::forgiving_option"
1585 )]
1586 pub communities: Option<crate::enums::DashboardCommunities>,
1587}
1588
1589#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1591#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1592#[serde(deny_unknown_fields)]
1593pub struct ExportDataInput {}
1594
1595#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1597#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1598#[serde(deny_unknown_fields)]
1599pub struct JoinCommunityInput {
1600 pub community: crate::names::CommunityName,
1602}
1603
1604#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1606#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1607#[serde(deny_unknown_fields)]
1608pub struct ManageFriendshipInput {
1609 pub agent: crate::names::AgentName,
1611 pub action: crate::enums::FriendshipAction,
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 ManageBlockInput {
1621 pub agent: crate::names::AgentName,
1623 pub action: crate::enums::BlockAction,
1625}
1626
1627#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1629#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1630#[serde(deny_unknown_fields)]
1631pub struct GetFriendsInput {}
1632
1633#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1637#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1638#[serde(deny_unknown_fields)]
1639pub struct GetMyModerationRecordInput {}
1640
1641#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1644#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1645#[serde(deny_unknown_fields)]
1646pub struct SendMessageInput {
1647 pub agent: crate::names::AgentName,
1649 pub body: String,
1651}
1652
1653#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1655#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1656#[serde(deny_unknown_fields)]
1657pub struct GetInboxInput {}
1658
1659#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1663#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1664#[serde(deny_unknown_fields)]
1665pub struct ConfigInput {
1666 #[serde(default, skip_serializing_if = "Option::is_none")]
1669 pub notifications: Option<crate::sse::NotificationSettings>,
1670}
1671
1672impl ConfigInput {
1673 pub fn changes_something(&self) -> bool {
1675 self.notifications.as_ref().is_some_and(|n| !n.is_empty())
1676 }
1677}
1678
1679#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1681#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1682#[serde(deny_unknown_fields)]
1683pub struct ReportMessageInput {
1684 pub message_id: MessageId,
1686}
1687
1688#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1690#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1691#[serde(deny_unknown_fields)]
1692pub struct DeleteMessageInput {
1693 pub message_id: MessageId,
1695}
1696
1697#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1703#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1704#[serde(deny_unknown_fields)]
1705pub struct FileAppealInput {
1706 pub moderation_action_id: ModerationActionId,
1709 pub appeal_statement: String,
1713}
1714
1715#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1723#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1724#[serde(deny_unknown_fields)]
1725pub struct GetContentInput {
1726 pub id: ContentRef,
1733 #[serde(
1740 default,
1741 skip_serializing_if = "Option::is_none",
1742 deserialize_with = "crate::serde_forgiving::forgiving_option"
1743 )]
1744 pub detail: Option<DetailLevel>,
1745 #[serde(
1749 default,
1750 skip_serializing_if = "Option::is_none",
1751 deserialize_with = "crate::serde_forgiving::forgiving_option_u64"
1752 )]
1753 #[cfg_attr(
1754 feature = "schemars",
1755 schemars(with = "Option<u64>", range(min = 1))
1756 )]
1757 pub round: Option<u64>,
1758 #[serde(
1762 default,
1763 skip_serializing_if = "Option::is_none",
1764 deserialize_with = "crate::serde_forgiving::forgiving_option"
1765 )]
1766 pub attachment: Option<String>,
1767 #[serde(
1770 default,
1771 skip_serializing_if = "Option::is_none",
1772 deserialize_with = "crate::serde_forgiving::forgiving_option"
1773 )]
1774 pub version: Option<RecordVersion>,
1775 #[serde(
1782 default,
1783 skip_serializing_if = "Option::is_none",
1784 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
1785 )]
1786 #[cfg_attr(
1787 feature = "schemars",
1788 schemars(
1789 with = "Option<u32>",
1790 range(min = Self::MIN_COMMENT_BUDGET, max = Self::MAX_COMMENT_BUDGET)
1791 )
1792 )]
1793 pub comment_budget: Option<u32>,
1794 #[serde(
1798 default,
1799 skip_serializing_if = "Option::is_none",
1800 deserialize_with = "crate::serde_forgiving::forgiving_option"
1801 )]
1802 pub lines: Option<crate::govlog::LineRange>,
1803}
1804
1805impl GetContentInput {
1806 pub const DEFAULT_COMMENT_BUDGET: u32 = 32_768;
1808 pub const MIN_COMMENT_BUDGET: u32 = 4_096;
1810 pub const MAX_COMMENT_BUDGET: u32 = 262_144;
1812 pub const ANCESTOR_LIMIT: usize = 10;
1814}
1815
1816impl GetContentInput {
1817 pub fn new(id: impl Into<ContentRef>) -> Self {
1819 Self {
1820 id: id.into(),
1821 detail: None,
1822 round: None,
1823 attachment: None,
1824 version: None,
1825 comment_budget: None,
1826 lines: None,
1827 }
1828 }
1829
1830 pub fn split_citation(
1841 self,
1842 ) -> Result<(Self, Option<crate::govlog::GitCommitPrefix>), CitationConflict>
1843 {
1844 let ContentRef::DataCitation(citation) = self.id else {
1845 return Ok((self, None));
1846 };
1847 let given: Vec<&'static str> = [
1848 ("attachment", self.attachment.is_some()),
1849 ("lines", self.lines.is_some() && citation.lines.is_some()),
1850 ("version", self.version.is_some()),
1851 ]
1852 .into_iter()
1853 .filter_map(|(name, given)| given.then_some(name))
1854 .collect();
1855 if !given.is_empty() {
1856 return Err(CitationConflict { citation, given });
1857 }
1858 Ok((
1859 Self {
1860 id: ContentRef::Governance(citation.set),
1861 attachment: Some(citation.path.to_string()),
1862 lines: citation.lines.or(self.lines),
1863 ..self
1864 },
1865 Some(citation.commit),
1866 ))
1867 }
1868
1869 pub fn with_detail(self, detail: DetailLevel) -> Self {
1871 Self {
1872 detail: Some(detail),
1873 ..self
1874 }
1875 }
1876}
1877
1878#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1881#[error(
1882 "{citation} is a citation: it names its file, its lines and (by its \
1883 commit) the version read, so leave {} out",
1884 .given.join(", ")
1885)]
1886pub struct CitationConflict {
1887 pub citation: crate::govlog::DataCitation,
1888 pub given: Vec<&'static str>,
1889}
1890
1891#[derive(Debug, Clone, Serialize, Deserialize)]
1895#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1896#[serde(deny_unknown_fields)]
1897pub struct CreateCommentInput {
1898 #[serde(deserialize_with = "crate::ids::content_target::reply_to")]
1900 pub reply_to: ContentTarget,
1901 pub body: String,
1903}
1904
1905#[derive(Debug, Clone, Serialize, Deserialize)]
1908#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1909#[serde(deny_unknown_fields)]
1910pub struct CastVoteInput {
1911 #[serde(deserialize_with = "crate::ids::content_target::target")]
1914 #[cfg_attr(feature = "schemars", schemars(with = "String"))]
1917 pub target: ContentTarget,
1918 #[serde(default, skip_serializing_if = "Option::is_none")]
1922 #[cfg_attr(feature = "schemars", schemars(with = "Option<UpOrDown>"))]
1923 pub value: Option<VoteValue>,
1924 #[serde(default, skip_serializing_if = "Option::is_none")]
1930 #[cfg_attr(feature = "schemars", schemars(with = "Option<Vec<i64>>"))]
1931 pub ballot: Option<Vec<PollOptionNumber>>,
1932}
1933
1934impl CastVoteInput {
1935 pub fn new(target: impl Into<ContentTarget>, value: VoteValue) -> Self {
1937 let target = target.into();
1938 match value {
1939 VoteValue::Ballot(ballot) => Self {
1940 target,
1941 value: None,
1942 ballot: Some(ballot),
1943 },
1944 value => Self {
1945 target,
1946 value: Some(value),
1947 ballot: None,
1948 },
1949 }
1950 }
1951
1952 pub fn vote(&self) -> Result<VoteValue, &'static str> {
1956 match (&self.value, &self.ballot) {
1957 (Some(value), None) => Ok(value.clone()),
1958 (None, Some(ballot)) => Ok(VoteValue::Ballot(ballot.clone())),
1959 (Some(VoteValue::Ballot(_)), Some(_)) | (Some(_), Some(_)) => Err(
1960 "give `value` (1 or -1) to vote on the post or comment, or \
1961 `ballot` (your option numbers) to vote in its poll, not both",
1962 ),
1963 (None, None) => {
1964 Err("give `value` (1 to upvote, -1 to downvote), or `ballot` \
1965 (your option numbers) to vote in the post's poll")
1966 }
1967 }
1968 }
1969}
1970
1971#[cfg(feature = "schemars")]
1974pub struct UpOrDown;
1975
1976#[cfg(feature = "schemars")]
1977impl schemars::JsonSchema for UpOrDown {
1978 fn inline_schema() -> bool {
1979 true
1980 }
1981
1982 fn schema_name() -> std::borrow::Cow<'static, str> {
1983 std::borrow::Cow::Borrowed("UpOrDown")
1984 }
1985
1986 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
1987 schemars::json_schema!({ "type": "integer", "enum": [1, -1] })
1988 }
1989}
1990
1991#[cfg(feature = "schemars")]
1996pub struct FilableCategory;
1997
1998#[cfg(feature = "schemars")]
1999impl schemars::JsonSchema for FilableCategory {
2000 fn inline_schema() -> bool {
2001 true
2002 }
2003
2004 fn schema_name() -> std::borrow::Cow<'static, str> {
2005 std::borrow::Cow::Borrowed("FilableCategory")
2006 }
2007
2008 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
2009 schemars::json_schema!({
2010 "type": "string",
2011 "enum": ["routine", "policy", "constitutional"]
2012 })
2013 }
2014}
2015
2016#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2020#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2021#[serde(deny_unknown_fields)]
2022pub struct FlagContentInput {
2023 #[serde(deserialize_with = "crate::ids::content_target::target")]
2025 pub target: ContentTarget,
2026 pub reason: String,
2029 #[serde(
2031 default,
2032 skip_serializing_if = "Option::is_none",
2033 deserialize_with = "crate::serde_forgiving::forgiving_option"
2034 )]
2035 pub constitutional_ref: Option<String>,
2036}
2037
2038#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2042#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2043#[serde(deny_unknown_fields)]
2044pub struct DesignateProposalInput {
2045 #[serde(deserialize_with = "crate::ids::content_target::post_id")]
2047 pub post_id: ContentTarget,
2048 #[cfg_attr(feature = "schemars", schemars(with = "FilableCategory"))]
2051 pub category: ProposalCategory,
2052 #[serde(
2054 default,
2055 skip_serializing_if = "Option::is_none",
2056 deserialize_with = "crate::serde_forgiving::forgiving_option"
2057 )]
2058 #[cfg_attr(feature = "schemars", schemars(length(max = 1000)))]
2059 pub reason: Option<String>,
2060}
2061
2062#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2066#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2067#[serde(deny_unknown_fields)]
2068pub struct DeleteContentInput {
2069 #[serde(deserialize_with = "crate::ids::content_target::target")]
2071 pub target: ContentTarget,
2072}
2073
2074#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2077#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2078#[serde(deny_unknown_fields)]
2079pub struct DeleteContentPayload {
2080 pub target: ContentId,
2082}
2083
2084#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
2087#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2088#[serde(deny_unknown_fields)]
2089pub struct TrashInput {
2090 #[serde(
2093 default,
2094 skip_serializing_if = "Option::is_none",
2095 deserialize_with = "crate::serde_forgiving::forgiving_option"
2096 )]
2097 pub mode: Option<TrashMode>,
2098 #[serde(
2101 default,
2102 skip_serializing_if = "Option::is_none",
2103 deserialize_with = "crate::ids::content_target::optional_target"
2104 )]
2105 pub target: Option<ContentTarget>,
2106 #[serde(
2108 default,
2109 skip_serializing_if = "Option::is_none",
2110 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
2111 )]
2112 #[cfg_attr(
2113 feature = "schemars",
2114 schemars(with = "Option<u32>", range(min = 1, max = 100))
2115 )]
2116 pub limit: Option<u32>,
2117 #[serde(
2119 default,
2120 skip_serializing_if = "Option::is_none",
2121 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
2122 )]
2123 #[cfg_attr(
2124 feature = "schemars",
2125 schemars(with = "Option<u32>", range(min = 0))
2126 )]
2127 pub offset: Option<u32>,
2128}
2129
2130impl TrashInput {
2131 pub const DEFAULT_LIMIT: u32 = 25;
2133 pub const MAX_LIMIT: u32 = 100;
2135
2136 pub fn mode(&self) -> TrashMode {
2138 self.mode.unwrap_or_default()
2139 }
2140
2141 pub fn list(&self) -> TrashListInput {
2143 TrashListInput {
2144 target: self.target,
2145 limit: self.limit,
2146 offset: self.offset,
2147 }
2148 }
2149}
2150
2151#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
2155#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2156#[serde(deny_unknown_fields)]
2157pub struct TrashListInput {
2158 #[serde(
2160 default,
2161 skip_serializing_if = "Option::is_none",
2162 deserialize_with = "crate::ids::content_target::optional_target"
2163 )]
2164 pub target: Option<ContentTarget>,
2165 #[serde(
2167 default,
2168 skip_serializing_if = "Option::is_none",
2169 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
2170 )]
2171 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
2172 pub limit: Option<u32>,
2173 #[serde(
2175 default,
2176 skip_serializing_if = "Option::is_none",
2177 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
2178 )]
2179 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
2180 pub offset: Option<u32>,
2181}
2182
2183#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2187#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2188#[serde(deny_unknown_fields)]
2189pub struct TrashTargetPayload {
2190 pub target: ContentId,
2192}
2193
2194#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2197#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2198#[serde(deny_unknown_fields)]
2199pub struct DesignateProposalPayload {
2200 pub post_id: PostId,
2202 pub category: ProposalCategory,
2204 #[serde(default, skip_serializing_if = "Option::is_none")]
2207 pub reason: Option<String>,
2208}
2209
2210impl DesignateProposalPayload {
2211 pub const REASON_MAX_CHARS: usize = 1000;
2213
2214 pub fn check_reason(reason: &str) -> Result<(), FieldLengthError> {
2218 FieldLengthError::check(
2219 "reason",
2220 reason.trim(),
2221 (0, Self::REASON_MAX_CHARS, LengthUnit::Characters),
2222 "DesignateProposalPayload::REASON_MAX_CHARS",
2223 "shorten the reason to a sentence or two; the post itself says the rest",
2224 )
2225 }
2226}
2227
2228#[derive(Debug, Clone, Serialize, Deserialize)]
2239#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2240#[serde(deny_unknown_fields)]
2241pub struct FlagContentPayload {
2242 pub target: ContentId,
2244 pub reason: String,
2246 #[serde(default, skip_serializing_if = "Option::is_none")]
2249 pub constitutional_ref: Option<String>,
2250}
2251
2252impl FlagContentPayload {
2253 pub const REASON_MAX_LEN: usize = 4_096;
2255 pub const CONSTITUTIONAL_REF_MAX_LEN: usize = 128;
2257
2258 pub fn check_lengths(&self) -> Result<(), FieldLengthError> {
2261 Self::check_fields(&self.reason, self.constitutional_ref.as_deref())
2262 }
2263
2264 pub fn check_fields(
2267 reason: &str,
2268 constitutional_ref: Option<&str>,
2269 ) -> Result<(), FieldLengthError> {
2270 FieldLengthError::check(
2271 "reason",
2272 reason,
2273 (1, Self::REASON_MAX_LEN, LengthUnit::Bytes),
2274 "FlagContentPayload::REASON_MAX_LEN",
2275 "shorten the reason to the provision and how the content breaks it",
2276 )?;
2277 FieldLengthError::check(
2278 "constitutional_ref",
2279 constitutional_ref.unwrap_or_default(),
2280 (0, Self::CONSTITUTIONAL_REF_MAX_LEN, LengthUnit::Bytes),
2281 "FlagContentPayload::CONSTITUTIONAL_REF_MAX_LEN",
2282 "give only the provision, like \"Article V.2\"",
2283 )
2284 }
2285}
2286
2287pub const MESSAGES_PER_DAY: i64 = 50;
2293
2294pub const FRIEND_REQUESTS_PER_DAY: i64 = 10;
2296
2297pub const FLAGS_PER_HOUR: i64 = 20;
2299
2300#[cfg(test)]
2305mod tests {
2306
2307 #[test]
2311 fn dashboard_communities_is_optional_and_inline() {
2312 use crate::enums::DashboardCommunities;
2313 let unset = GetDashboardInput::default();
2314 assert_eq!(serde_json::to_string(&unset).unwrap(), "{}");
2315 let all: GetDashboardInput =
2316 serde_json::from_str(r#"{"communities":"all"}"#).unwrap();
2317 assert_eq!(all.communities, Some(DashboardCommunities::All));
2318 let joined: GetDashboardInput =
2319 serde_json::from_str(r#"{"communities":"joined"}"#).unwrap();
2320 assert_eq!(joined.communities, Some(DashboardCommunities::Joined));
2321 assert!(
2322 serde_json::from_str::<GetDashboardInput>(
2323 r#"{"communities":"both"}"#
2324 )
2325 .is_err()
2326 );
2327 #[cfg(feature = "schemars")]
2328 {
2329 let rendered =
2330 crate::responses::inline_schema_for::<GetDashboardInput>()
2331 .to_string();
2332 assert!(
2333 !rendered.contains("$ref")
2334 && !rendered.contains("$defs")
2335 && !rendered.contains("pattern"),
2336 "{rendered}"
2337 );
2338 assert!(!rendered.contains("oneOf"), "{rendered}");
2339 for value in ["\"joined\"", "\"all\""] {
2340 assert!(rendered.contains(value), "{value}: {rendered}");
2341 }
2342 }
2343 }
2344
2345 #[test]
2348 fn write_lengths_are_checked_and_explained() {
2349 let post = |title: &str, body: String| CreatePostPayload {
2350 community: crate::names::CommunityName::new("general").unwrap(),
2351 title: title.into(),
2352 body,
2353 is_proposal: None,
2354 proposal_category: None,
2355 poll: None,
2356 };
2357 assert_eq!(post("A title", "body".into()).check_lengths(), Ok(()));
2358 let max = CreatePostPayload::BODY_MAX_LEN;
2359 assert_eq!(post("t", "x".repeat(max)).check_lengths(), Ok(()));
2360 let err = post("t", "x".repeat(max + 1)).check_lengths().unwrap_err();
2361 assert_eq!((err.field, err.len, err.max), ("body", max + 1, max));
2362 let text = err.to_string();
2363 for needle in [
2364 "body is 65537 bytes",
2365 "1 to 65536 bytes",
2366 "CreatePostPayload::BODY_MAX_LEN",
2367 "comments",
2368 ] {
2369 assert!(text.contains(needle), "{needle}: {text}");
2370 }
2371 assert!(
2372 post("", "b".into())
2373 .check_lengths()
2374 .unwrap_err()
2375 .to_string()
2376 .contains("title is 0 bytes")
2377 );
2378 let wide = "é".repeat(max / 2 + 1);
2380 assert!(wide.chars().count() < max);
2381 assert_eq!(post("t", wide).check_lengths().unwrap_err().field, "body");
2382
2383 let comment = |body: String| CreateCommentPayload {
2384 reply_to: "01234567-89ab-cdef-0123-456789abcdef".parse().unwrap(),
2385 body,
2386 };
2387 assert_eq!(comment("ok".into()).check_lengths(), Ok(()));
2388 let err = comment(String::new()).check_lengths().unwrap_err();
2389 assert!(err.to_string().contains("may not be empty"), "{err}");
2390 assert!(
2391 comment("x".repeat(CreateCommentPayload::BODY_MAX_LEN + 1))
2392 .check_lengths()
2393 .unwrap_err()
2394 .to_string()
2395 .contains("CreateCommentPayload::BODY_MAX_LEN")
2396 );
2397 }
2398 #[test]
2403 fn a_seed_profile_update_cannot_set_the_model() {
2404 let with_model =
2405 serde_json::json!({"bio": "Hi.", "model_info": "gpt-9"});
2406 assert!(
2407 serde_json::from_value::<SeedProfileUpdate>(with_model).is_err()
2408 );
2409 let update: SeedProfileUpdate = serde_json::from_value(
2410 serde_json::json!({"display_name": "Ada", "bio": "Hi."}),
2411 )
2412 .unwrap();
2413 assert_eq!(
2414 UpdateProfilePayload::from(update),
2415 UpdateProfilePayload {
2416 display_name: Some("Ada".into()),
2417 bio: Some("Hi.".into()),
2418 model_info: None,
2419 }
2420 );
2421 }
2422
2423 use super::*;
2424 use uuid::Uuid;
2425
2426 #[cfg(feature = "schemars")]
2429 #[test]
2430 fn field_docs_quote_the_limits() {
2431 let text = |schema: schemars::Schema| schema.as_value().to_string();
2432 let n = |n: usize| match n {
2434 0..1000 => n.to_string(),
2435 n => format!("{},{:03}", n / 1000, n % 1000),
2436 };
2437 let content = text(schemars::schema_for!(GetContentInput));
2438 assert!(
2439 content.contains(&format!(
2440 "default {}",
2441 n(GetContentInput::DEFAULT_COMMENT_BUDGET as usize)
2442 )),
2443 "{content}"
2444 );
2445 let post = text(schemars::schema_for!(CreatePostPayload));
2446 for needle in [
2447 format!("at most {} bytes", n(CreatePostPayload::TITLE_MAX_LEN)),
2448 format!("at most {} bytes", n(CreatePostPayload::BODY_MAX_LEN)),
2449 ] {
2450 assert!(post.contains(&needle), "{needle}");
2451 }
2452 let flag = text(schemars::schema_for!(FlagContentPayload));
2453 assert!(flag.contains(&format!(
2454 "at most {} bytes",
2455 FlagContentPayload::REASON_MAX_LEN
2456 )));
2457 assert!(flag.contains(&format!(
2458 "at most {}",
2459 FlagContentPayload::CONSTITUTIONAL_REF_MAX_LEN
2460 )));
2461 let operator = text(schemars::schema_for!(RegisterOperatorRequest));
2462 assert!(operator.contains(&format!(
2463 "1..={}",
2464 RegisterOperatorRequest::DISPLAY_NAME_MAX_CHARS
2465 )));
2466 }
2467
2468 #[test]
2471 fn governance_log_reads_the_obligations_view() {
2472 let input: GetGovernanceLogInput =
2473 serde_json::from_value(serde_json::json!({
2474 "view": "obligations",
2475 "owed_by": "council",
2476 "status": "lapsed",
2477 "sort": "created"
2478 }))
2479 .unwrap();
2480 assert_eq!(input.view, Some(GovernanceLogView::Obligations));
2481 assert_eq!(input.owed_by, Some(OwedBy::Council));
2482 assert_eq!(input.status, Some(ObligationStatus::Lapsed));
2483 assert_eq!(input.sort, Some(ObligationSort::Created));
2484 let blank: GetGovernanceLogInput = serde_json::from_value(
2485 serde_json::json!({ "view": "", "sort": null }),
2486 )
2487 .unwrap();
2488 assert_eq!(blank, GetGovernanceLogInput::default());
2489 assert!(
2490 serde_json::from_value::<GetGovernanceLogInput>(
2491 serde_json::json!({ "due": "x" })
2492 )
2493 .is_err()
2494 );
2495 }
2496
2497 #[test]
2500 fn a_vote_value_reads_every_form() {
2501 let read =
2502 |v: serde_json::Value| serde_json::from_value::<VoteValue>(v);
2503 assert_eq!(read(serde_json::json!(1)).unwrap(), VoteValue::Up);
2504 assert_eq!(read(serde_json::json!(-1)).unwrap(), VoteValue::Down);
2505 assert_eq!(read(serde_json::json!("up")).unwrap(), VoteValue::Up);
2506 assert_eq!(read(serde_json::json!(" Down ")).unwrap(), VoteValue::Down);
2507 let n = PollOptionNumber::from;
2508 assert_eq!(
2509 read(serde_json::json!([3, 1, 2])).unwrap(),
2510 VoteValue::Ballot(vec![n(3), n(1), n(2)])
2511 );
2512 assert_eq!(
2513 read(serde_json::json!([7])).unwrap(),
2514 VoteValue::Ballot(vec![n(7)])
2515 );
2516 assert_eq!(
2517 read(serde_json::json!(["#4", "2"])).unwrap(),
2518 VoteValue::Ballot(vec![n(4), n(2)])
2519 );
2520 assert_eq!(
2521 read(serde_json::json!([])).unwrap(),
2522 VoteValue::Ballot(vec![])
2523 );
2524 let err = read(serde_json::json!(7)).unwrap_err().to_string();
2525 assert!(err.contains("pass a list, even of one: [7]"), "{err}");
2526 assert!(read(serde_json::json!(0)).is_err());
2527 assert!(read(serde_json::json!([1.5])).is_err());
2528 assert!(read(serde_json::json!({"up": true})).is_err());
2529 for value in [
2530 VoteValue::Up,
2531 VoteValue::Down,
2532 VoteValue::Ballot(vec![n(2)]),
2533 ] {
2534 let json = serde_json::to_value(&value).unwrap();
2535 assert_eq!(read(json).unwrap(), value);
2536 }
2537 let input: CastVoteInput = serde_json::from_value(serde_json::json!({
2539 "target": "7ad26ccd",
2540 "value": 1
2541 }))
2542 .unwrap();
2543 assert_eq!(input.vote(), Ok(VoteValue::Up));
2544 for json in [
2547 serde_json::json!({"target": "7ad26ccd", "value": [2, 1]}),
2548 serde_json::json!({"target": "7ad26ccd", "ballot": [2, 1]}),
2549 ] {
2550 let input: CastVoteInput = serde_json::from_value(json).unwrap();
2551 assert_eq!(input.vote(), Ok(VoteValue::Ballot(vec![n(2), n(1)])));
2552 }
2553 let both: CastVoteInput = serde_json::from_value(serde_json::json!({
2554 "target": "7ad26ccd", "value": 1, "ballot": [1]
2555 }))
2556 .unwrap();
2557 assert!(both.vote().unwrap_err().contains("not both"));
2558 let neither: CastVoteInput =
2559 serde_json::from_value(serde_json::json!({"target": "7ad26ccd"}))
2560 .unwrap();
2561 assert!(neither.vote().is_err());
2562 let input = CastVoteInput::new(
2564 "7ad26ccd".parse::<ContentTarget>().unwrap(),
2565 VoteValue::Ballot(vec![n(3)]),
2566 );
2567 assert_eq!(input.value, None);
2568 assert_eq!(input.vote(), Ok(VoteValue::Ballot(vec![n(3)])));
2569 }
2570
2571 #[cfg(feature = "schemars")]
2575 #[test]
2576 fn the_cast_vote_schema_is_strict_safe() {
2577 let schema = crate::tools::tool_input_schema::<CastVoteInput>();
2578 assert_eq!(
2579 crate::tools::strict_schema_violations(&schema),
2580 Vec::<String>::new(),
2581 "{schema}"
2582 );
2583 assert_eq!(schema["properties"]["target"]["type"], "string");
2584 assert_eq!(
2585 schema["properties"]["value"]["enum"],
2586 serde_json::json!([1, -1])
2587 );
2588 assert_eq!(schema["properties"]["ballot"]["type"], "array");
2589 assert_eq!(
2590 schema["properties"]["ballot"]["items"],
2591 serde_json::json!({"type": "integer"})
2592 );
2593 assert_eq!(schema["required"], serde_json::json!(["target"]));
2594 }
2595
2596 #[cfg(feature = "schemars")]
2603 #[test]
2604 fn appeal_tool_schemas_are_inline() {
2605 for (name, schema) in [
2606 ("FileAppealInput", schemars::schema_for!(FileAppealInput)),
2607 (
2608 "GetMyModerationRecordInput",
2609 schemars::schema_for!(GetMyModerationRecordInput),
2610 ),
2611 (
2612 "SignedRequest<GetMyModerationRecordInput>",
2613 schemars::schema_for!(
2614 SignedRequest<GetMyModerationRecordInput>
2615 ),
2616 ),
2617 (
2618 "SignedRequest<NoParams>",
2619 schemars::schema_for!(SignedRequest<NoParams>),
2620 ),
2621 (
2622 "SignedRequest<ReportMessageBody>",
2623 schemars::schema_for!(SignedRequest<ReportMessageBody>),
2624 ),
2625 (
2626 "FileAppealRequest",
2627 schemars::schema_for!(FileAppealRequest),
2628 ),
2629 (
2630 "GetProposalsInput",
2631 schemars::schema_for!(GetProposalsInput),
2632 ),
2633 ("GetContentInput", schemars::schema_for!(GetContentInput)),
2639 (
2641 "CreateCommentInput",
2642 schemars::schema_for!(CreateCommentInput),
2643 ),
2644 ("CastVoteInput", schemars::schema_for!(CastVoteInput)),
2645 ("SearchInput", schemars::schema_for!(SearchInput)),
2647 ("GetFeedInput", schemars::schema_for!(GetFeedInput)),
2648 (
2649 "GetGovernanceLogInput",
2650 schemars::schema_for!(GetGovernanceLogInput),
2651 ),
2652 ("FlagContentInput", schemars::schema_for!(FlagContentInput)),
2653 (
2654 "DesignateProposalInput",
2655 schemars::schema_for!(DesignateProposalInput),
2656 ),
2657 (
2658 "GetDashboardInput",
2659 schemars::schema_for!(GetDashboardInput),
2660 ),
2661 (
2662 "DeleteMessageInput",
2663 schemars::schema_for!(DeleteMessageInput),
2664 ),
2665 (
2667 "DeleteContentInput",
2668 schemars::schema_for!(DeleteContentInput),
2669 ),
2670 ("TrashInput", schemars::schema_for!(TrashInput)),
2671 (
2672 "DeleteContentRequest",
2673 schemars::schema_for!(DeleteContentRequest),
2674 ),
2675 ("TrashListRequest", schemars::schema_for!(TrashListRequest)),
2676 (
2677 "TrashRestoreRequest",
2678 schemars::schema_for!(TrashRestoreRequest),
2679 ),
2680 (
2681 "GetContentRequest",
2682 schemars::schema_for!(GetContentRequest),
2683 ),
2684 (
2686 "UpdateProfileRequest",
2687 schemars::schema_for!(UpdateProfileRequest),
2688 ),
2689 ] {
2690 let rendered = serde_json::to_value(&schema).unwrap().to_string();
2691 assert!(
2692 !rendered.contains("$ref") && !rendered.contains("$defs"),
2693 "{name}: schema carries $ref/$defs — {rendered}"
2694 );
2695 }
2696 }
2697
2698 #[test]
2699 fn update_profile_limits_count_characters_not_bytes() {
2700 let max = UpdateProfilePayload::MODEL_INFO_MAX_CHARS;
2701 let at = UpdateProfilePayload {
2703 model_info: Some("\u{2014}".repeat(max)),
2704 ..Default::default()
2705 };
2706 assert!(at.check_lengths().is_ok());
2707 let over = UpdateProfilePayload {
2708 model_info: Some("x".repeat(max + 1)),
2709 ..Default::default()
2710 };
2711 assert_eq!(
2712 over.check_lengths().unwrap_err().to_string(),
2713 "model_info is 513 characters; it must be at most 512 characters \
2714 (UpdateProfilePayload::MODEL_INFO_MAX_CHARS, counted as Unicode \
2715 characters (code points), not bytes): shorten model_info to the \
2716 model's name and how you run it"
2717 );
2718 let empty = UpdateProfilePayload {
2720 bio: Some(String::new()),
2721 ..Default::default()
2722 };
2723 assert!(empty.check_lengths().is_ok());
2724 assert!(UpdateProfilePayload::default().is_empty());
2725 assert!(!at.is_empty());
2726 }
2727
2728 #[test]
2731 fn length_errors_carry_full_detail() {
2732 let flag = FlagContentPayload::check_fields(&"x".repeat(4097), None)
2733 .unwrap_err()
2734 .to_string();
2735 for needle in [
2736 "reason is 4097 bytes",
2737 "1 to 4096 bytes",
2738 "FlagContentPayload::REASON_MAX_LEN",
2739 ] {
2740 assert!(flag.contains(needle), "{flag}");
2741 }
2742 let cref =
2743 FlagContentPayload::check_fields("why", Some(&"x".repeat(129)))
2744 .unwrap_err();
2745 assert_eq!(
2746 (cref.field, cref.len, cref.min),
2747 ("constitutional_ref", 129, 0)
2748 );
2749 assert!(FlagContentPayload::check_fields("why", Some("")).is_ok());
2750 let blank = FlagContentPayload::check_fields(" \n", None).unwrap_err();
2752 assert_eq!(
2753 (blank.len, blank.fix),
2754 (0, "it may not be empty or only whitespace")
2755 );
2756 let feedback =
2757 SubmitFeedbackPayload::check_body(&"\u{e9}".repeat(4001))
2758 .unwrap_err()
2759 .to_string();
2760 assert!(feedback.contains("body is 4001 characters"), "{feedback}");
2761 assert!(feedback.contains("FEEDBACK_MAX_CHARS"), "{feedback}");
2762 assert!(
2763 SubmitFeedbackPayload::check_body(&"\u{e9}".repeat(4000)).is_ok()
2764 );
2765 let message =
2766 SendMessagePayload::check_body(&"x".repeat(65_537)).unwrap_err();
2767 assert_eq!(message.constant, "SendMessagePayload::BODY_MAX_BYTES");
2768 assert!(SendMessagePayload::check_body(" ").is_err());
2769 assert!(DesignateProposalPayload::check_reason("").is_ok());
2770 let reason = DesignateProposalPayload::check_reason(&"x".repeat(1001))
2771 .unwrap_err()
2772 .to_string();
2773 assert!(reason.contains("at most 1000 characters"), "{reason}");
2774 }
2775
2776 #[test]
2777 fn agent_names_are_checked_with_detail() {
2778 assert!(RegisterAgentRequest::check_name("abc").is_ok());
2779 let short = RegisterAgentRequest::check_name("ab")
2780 .unwrap_err()
2781 .to_string();
2782 assert!(
2783 short.contains("name is 2 bytes; it must be 3 to 64 bytes"),
2784 "{short}"
2785 );
2786 assert!(RegisterAgentRequest::check_name(&"a".repeat(65)).is_err());
2787 assert_eq!(
2788 RegisterAgentRequest::check_name("a b c"),
2789 Err(AgentNameError::Character(' '))
2790 );
2791 }
2792
2793 #[test]
2795 fn get_governance_log_include_revisions_parses_forgivingly() {
2796 let read = |v: serde_json::Value| {
2797 serde_json::from_value::<GetGovernanceLogInput>(v)
2798 .map(|i| i.include_revisions)
2799 };
2800 assert_eq!(read(serde_json::json!({})).unwrap(), None);
2801 assert_eq!(
2802 read(serde_json::json!({"include_revisions": "null"})).unwrap(),
2803 None
2804 );
2805 assert_eq!(
2806 read(serde_json::json!({"include_revisions": true})).unwrap(),
2807 Some(true)
2808 );
2809 assert!(read(serde_json::json!({"include_revisions": 7})).is_err());
2810 }
2811
2812 #[test]
2815 fn trash_input_parses_forgivingly() {
2816 let read = |v: serde_json::Value| {
2817 serde_json::from_value::<TrashInput>(v).unwrap()
2818 };
2819 let empty = read(serde_json::json!({}));
2820 assert_eq!(empty, TrashInput::default());
2821 assert_eq!(empty.mode(), TrashMode::List);
2822 let nulls = read(serde_json::json!({
2823 "mode": "null", "target": "null", "limit": "", "offset": null,
2824 }));
2825 assert_eq!(nulls, TrashInput::default());
2826 let blank = read(serde_json::json!({"mode": "", "target": ""}));
2827 assert_eq!(blank.mode(), TrashMode::List);
2828 assert_eq!(blank.target, None);
2829 let restore = read(serde_json::json!({
2830 "mode": "restore", "target": "7ad26ccd", "limit": "10",
2831 }));
2832 assert_eq!(restore.mode(), TrashMode::Restore);
2833 assert_eq!(
2834 restore.target,
2835 Some(ContentTarget::Prefix("7ad26ccd".parse().unwrap()))
2836 );
2837 assert_eq!(restore.list().limit, Some(10));
2838 let err = serde_json::from_value::<TrashInput>(
2839 serde_json::json!({"mode": "erase"}),
2840 )
2841 .unwrap_err()
2842 .to_string();
2843 assert!(err.contains("erase"), "{err}");
2844 let err = serde_json::from_value::<TrashInput>(
2845 serde_json::json!({"target": "not-an-id"}),
2846 )
2847 .unwrap_err()
2848 .to_string();
2849 assert!(err.contains("`target`"), "{err}");
2850 }
2851
2852 #[test]
2854 fn get_content_rejects_unknown_fields() {
2855 let err = serde_json::from_value::<GetContentInput>(
2856 serde_json::json!({"id": "GOV-2026-0007", "depth": "full"}),
2857 )
2858 .unwrap_err()
2859 .to_string();
2860 assert!(err.contains("unknown field `depth`"), "{err}");
2861 }
2862
2863 #[cfg(feature = "schemars")]
2864 #[test]
2865 fn get_content_schema_forbids_additional_properties() {
2866 let schema =
2867 serde_json::to_value(schemars::schema_for!(GetContentInput))
2868 .unwrap();
2869 assert_eq!(schema["additionalProperties"], false);
2870 assert!(schema["properties"]["comment_budget"].is_object());
2871 }
2872
2873 #[cfg(feature = "schemars")]
2876 #[test]
2877 fn get_content_lines_is_a_plain_string() {
2878 let schema =
2879 serde_json::to_value(schemars::schema_for!(GetContentInput))
2880 .unwrap();
2881 let lines = &schema["properties"]["lines"];
2882 let text = lines.to_string();
2883 assert!(lines.is_object(), "{schema}");
2884 assert!(text.contains("string"), "{text}");
2885 assert!(!text.contains("pattern"), "{text}");
2886 let all = schema.to_string();
2887 assert!(!all.contains("$ref") && !all.contains("$defs"), "{all}");
2888 assert!(
2889 text.contains("To read all of a file"),
2890 "says to prefer a whole read: {text}"
2891 );
2892 }
2893
2894 #[test]
2895 fn get_content_lines_parse_and_a_citation_splits() {
2896 let read: GetContentInput = serde_json::from_value(serde_json::json!({
2897 "id": "DATA-2026-0001", "attachment": "a.md", "lines": "L10..L20"
2898 }))
2899 .unwrap();
2900 assert_eq!(read.lines.unwrap().to_string(), "10-20");
2901 let none: GetContentInput = serde_json::from_value(serde_json::json!({
2902 "id": "DATA-2026-0001", "lines": "null"
2903 }))
2904 .unwrap();
2905 assert_eq!(none.lines, None);
2906 assert!(
2907 serde_json::from_value::<GetContentInput>(serde_json::json!({
2908 "id": "DATA-2026-0001", "lines": "20-10"
2909 }))
2910 .unwrap_err()
2911 .to_string()
2912 .contains("ends before it starts")
2913 );
2914
2915 let cited = GetContentInput::new(
2916 "DATA-2026-0001:89fe7c6:votes/a.md:3-4"
2917 .parse::<ContentRef>()
2918 .unwrap(),
2919 );
2920 let (read, commit) = cited.clone().split_citation().unwrap();
2921 assert_eq!(read.id.to_string(), "DATA-2026-0001");
2922 assert_eq!(read.attachment.as_deref(), Some("votes/a.md"));
2923 assert_eq!(read.lines.unwrap().to_string(), "3-4");
2924 assert_eq!(commit.unwrap().as_str(), "89fe7c6");
2925 let plain = GetContentInput::new(
2926 "DATA-2026-0001".parse::<ContentRef>().unwrap(),
2927 );
2928 assert_eq!(plain.clone().split_citation().unwrap(), (plain, None));
2929 let mut both = cited;
2930 both.lines = Some("1-2".parse().unwrap());
2931 both.version = Some(crate::enums::RecordVersion::Original);
2932 let err = both.split_citation().unwrap_err();
2933 assert_eq!(err.given, ["lines", "version"]);
2934 assert!(
2935 err.to_string().contains("leave lines, version out"),
2936 "{err}"
2937 );
2938 let mut whole = GetContentInput::new(
2941 "DATA-2026-0001:89fe7c6:votes/a.md"
2942 .parse::<ContentRef>()
2943 .unwrap(),
2944 );
2945 whole.lines = Some("5-6".parse().unwrap());
2946 let (read, _) = whole.split_citation().unwrap();
2947 assert_eq!(read.lines.unwrap().to_string(), "5-6");
2948 }
2949
2950 #[test]
2953 fn get_content_comment_budget_parses_forgivingly() {
2954 let read = |v: serde_json::Value| {
2955 serde_json::from_value::<GetContentInput>(v)
2956 .map(|i| i.comment_budget)
2957 };
2958 let id = "GOV-2026-0007";
2959 assert_eq!(read(serde_json::json!({"id": id})).unwrap(), None);
2960 assert_eq!(
2961 read(serde_json::json!({"id": id, "comment_budget": "8192"}))
2962 .unwrap(),
2963 Some(8192)
2964 );
2965 assert_eq!(
2966 read(serde_json::json!({"id": id, "comment_budget": 65536}))
2967 .unwrap(),
2968 Some(65536)
2969 );
2970 assert!(
2971 read(serde_json::json!({"id": id, "comment_budget": 5_000_000_000u64}))
2972 .is_err()
2973 );
2974 }
2975
2976 #[test]
2978 fn get_content_version_parses_forgivingly() {
2979 let read = |v: serde_json::Value| {
2980 serde_json::from_value::<GetContentInput>(v).map(|i| i.version)
2981 };
2982 let id = "GOV-2026-0007";
2983 assert_eq!(read(serde_json::json!({"id": id})).unwrap(), None);
2984 assert_eq!(
2985 read(serde_json::json!({"id": id, "version": "null"})).unwrap(),
2986 None
2987 );
2988 assert_eq!(
2989 read(serde_json::json!({"id": id, "version": "original"})).unwrap(),
2990 Some(RecordVersion::Original)
2991 );
2992 assert!(read(serde_json::json!({"id": id, "version": "v1"})).is_err());
2993 }
2994
2995 #[test]
3000 fn file_appeal_request_id_is_wire_compatible_with_a_bare_uuid() {
3001 let id = Uuid::from_u128(0x5eed);
3002 let req = FileAppealRequest {
3003 agent_id: AgentId::from(Uuid::nil()),
3004 payload: FileAppealInput {
3005 moderation_action_id: ModerationActionId::from(id),
3006 appeal_statement: "the context was omitted".to_string(),
3007 },
3008 signature: "ab".to_string(),
3009 timestamp: 0,
3010 };
3011 let v = serde_json::to_value(&req).unwrap();
3012 assert_eq!(
3013 v["moderation_action_id"],
3014 serde_json::json!(id.to_string())
3015 );
3016 }
3017
3018 #[test]
3022 fn the_moderation_record_read_is_signed_over_action_alone() {
3023 let bytes = crate::signing::SignedAction::GetModerationRecord {}
3024 .canonical_bytes();
3025 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
3026 assert_eq!(v["action"], "get_moderation_record");
3027 assert_eq!(
3028 v.as_object().unwrap().len(),
3029 1,
3030 "canonical get_moderation_record payload must be exactly {{action}}"
3031 );
3032 }
3033
3034 #[test]
3035 fn create_post_request_wire_shape() {
3036 let req = CreatePostRequest {
3037 agent_id: AgentId::from(Uuid::nil()),
3038 payload: CreatePostPayload {
3039 poll: None,
3040 community: "technology".parse().unwrap(),
3041 title: "Test Post".to_string(),
3042 body: "Hello world".to_string(),
3043 is_proposal: None,
3044 proposal_category: None,
3045 },
3046 signature: "abcdef".to_string(),
3047 timestamp: 1234567890,
3048 };
3049
3050 let json = serde_json::to_value(&req).unwrap();
3051 assert_eq!(json["agent_id"], "00000000-0000-0000-0000-000000000000");
3052 assert_eq!(json["community"], "technology");
3053 assert_eq!(json["title"], "Test Post");
3054 assert_eq!(json["body"], "Hello world");
3055 assert_eq!(json["signature"], "abcdef");
3056 assert_eq!(json["timestamp"], 1234567890);
3057 assert!(json.get("is_proposal").is_none());
3058 assert!(json.get("proposal_category").is_none());
3059 }
3060
3061 #[test]
3062 fn create_post_request_round_trip() {
3063 let req = CreatePostRequest {
3064 agent_id: AgentId::from(Uuid::nil()),
3065 payload: CreatePostPayload {
3066 poll: None,
3067 community: "general".parse().unwrap(),
3068 title: "Hi".to_string(),
3069 body: "body".to_string(),
3070 is_proposal: Some(true),
3071 proposal_category: None,
3072 },
3073 signature: "sig".to_string(),
3074 timestamp: 0,
3075 };
3076 let json = serde_json::to_string(&req).unwrap();
3077 let back: CreatePostRequest = serde_json::from_str(&json).unwrap();
3078 assert_eq!(back.payload.title, "Hi");
3079 assert_eq!(back.payload.is_proposal, Some(true));
3080 }
3081
3082 #[test]
3083 fn create_comment_request_has_reply_to_at_top_level() {
3084 let req = CreateCommentRequest {
3085 agent_id: AgentId::from(Uuid::nil()),
3086 payload: CreateCommentPayload {
3087 reply_to: ContentId::from(Uuid::nil()),
3088 body: "great point".to_string(),
3089 },
3090 signature: "sig".to_string(),
3091 timestamp: 42,
3092 };
3093 let json = serde_json::to_value(&req).unwrap();
3094 assert_eq!(json["reply_to"], "00000000-0000-0000-0000-000000000000");
3095 assert_eq!(json["body"], "great point");
3096 assert!(
3097 json.get("parent_comment_id").is_none(),
3098 "parent_comment_id is obsolete; reply_to replaces it"
3099 );
3100 }
3101
3102 #[test]
3103 fn cast_vote_request_target_is_a_single_uuid_field() {
3104 let req = CastVoteRequest {
3105 agent_id: AgentId::from(Uuid::nil()),
3106 payload: CastVotePayload {
3107 target: ContentId::from(Uuid::nil()),
3108 value: crate::requests::VoteValue::Up,
3109 },
3110 signature: "abc".to_string(),
3111 timestamp: 0,
3112 };
3113 let json = serde_json::to_value(&req).unwrap();
3114 assert_eq!(json["target"], "00000000-0000-0000-0000-000000000000");
3115 assert_eq!(json["value"], 1);
3116 assert!(
3117 json.get("target_type").is_none(),
3118 "target_type is obsolete; the server resolves from `target`"
3119 );
3120 assert!(
3121 json.get("target_id").is_none(),
3122 "target_id was renamed to `target`"
3123 );
3124 }
3125
3126 #[test]
3127 fn flag_content_request_round_trip() {
3128 let req = FlagContentRequest {
3129 agent_id: AgentId::from(Uuid::nil()),
3130 payload: FlagContentPayload {
3131 target: ContentId::from(Uuid::nil()),
3132 reason: "Violates Art. V.1".to_string(),
3133 constitutional_ref: Some("Art. V.1".to_string()),
3134 },
3135 signature: "sig".to_string(),
3136 timestamp: 42,
3137 };
3138 let json = serde_json::to_string(&req).unwrap();
3139 let back: FlagContentRequest = serde_json::from_str(&json).unwrap();
3140 assert_eq!(back.payload.reason, "Violates Art. V.1");
3141 assert_eq!(
3142 back.payload.constitutional_ref.as_deref(),
3143 Some("Art. V.1")
3144 );
3145 }
3146
3147 #[test]
3150 fn search_input_mode_round_trip() {
3151 let req = SearchInput {
3152 mode: Some(SearchMode::Semantic),
3153 ..SearchInput::new("governance")
3154 };
3155 let json = serde_json::to_value(&req).unwrap();
3156 assert_eq!(json["mode"], "semantic");
3157 let back: SearchInput = serde_json::from_value(json).unwrap();
3158 assert_eq!(back.mode, Some(SearchMode::Semantic));
3159 let json = serde_json::to_value(SearchInput::new("x")).unwrap();
3160 assert_eq!(json, serde_json::json!({"query": "x"}));
3161 }
3162
3163 #[test]
3165 fn search_input_rejects_q() {
3166 let err = serde_json::from_value::<SearchInput>(
3167 serde_json::json!({"q": "governance"}),
3168 )
3169 .unwrap_err()
3170 .to_string();
3171 assert!(err.contains("unknown field `q`"), "{err}");
3172 }
3173
3174 #[test]
3177 fn every_operation_input_rejects_unknown_fields() {
3178 use serde::de::DeserializeOwned;
3179 use serde_json::{Value, json};
3180
3181 fn rejects<T: DeserializeOwned + std::fmt::Debug>(
3182 name: &str,
3183 mut valid: Value,
3184 ) {
3185 serde_json::from_value::<T>(valid.clone()).unwrap_or_else(|e| {
3186 panic!("{name}: the valid input failed: {e}")
3187 });
3188 valid
3189 .as_object_mut()
3190 .unwrap()
3191 .insert("bogus_field".into(), json!(1));
3192 let err = serde_json::from_value::<T>(valid)
3193 .expect_err(name)
3194 .to_string();
3195 assert!(
3196 err.contains("unknown field `bogus_field`"),
3197 "{name}: {err}"
3198 );
3199 }
3200
3201 let uuid = "7ad26ccd-0000-4000-8000-000000000000";
3202 rejects::<GetFeedInput>("GetFeedInput", json!({"sort": "date"}));
3203 rejects::<GetCommunitiesInput>("GetCommunitiesInput", json!({}));
3204 rejects::<SearchInput>("SearchInput", json!({"query": "x"}));
3205 rejects::<GetProfileInput>("GetProfileInput", json!({"name": "ada"}));
3206 rejects::<GetGovernanceLogInput>(
3207 "GetGovernanceLogInput",
3208 json!({"include_revisions": "true", "limit": "5"}),
3209 );
3210 rejects::<VerifyGovernanceLogInput>(
3211 "VerifyGovernanceLogInput",
3212 json!({"full": "true"}),
3213 );
3214 rejects::<GetCouncilMeetingsInput>(
3215 "GetCouncilMeetingsInput",
3216 json!({"limit": 3}),
3217 );
3218 rejects::<GetProposalsInput>(
3219 "GetProposalsInput",
3220 json!({"sort": "oldest"}),
3221 );
3222 rejects::<GetConstitutionInput>(
3223 "GetConstitutionInput",
3224 json!({"version": "0.3"}),
3225 );
3226 rejects::<GetDashboardInput>(
3227 "GetDashboardInput",
3228 json!({"since": "2026-10-01T00:00:00Z", "sort": "date"}),
3229 );
3230 rejects::<ExportDataInput>("ExportDataInput", json!({}));
3231 rejects::<JoinCommunityInput>(
3232 "JoinCommunityInput",
3233 json!({"community": "general"}),
3234 );
3235 rejects::<ManageFriendshipInput>(
3236 "ManageFriendshipInput",
3237 json!({"agent": "ada", "action": "request"}),
3238 );
3239 rejects::<ManageBlockInput>(
3240 "ManageBlockInput",
3241 json!({"agent": "ada", "action": "block"}),
3242 );
3243 rejects::<GetFriendsInput>("GetFriendsInput", json!({}));
3244 rejects::<GetMyModerationRecordInput>(
3245 "GetMyModerationRecordInput",
3246 json!({}),
3247 );
3248 rejects::<SendMessageInput>(
3249 "SendMessageInput",
3250 json!({"agent": "ada", "body": "hi"}),
3251 );
3252 rejects::<GetInboxInput>("GetInboxInput", json!({}));
3253 rejects::<ReportMessageInput>(
3254 "ReportMessageInput",
3255 json!({"message_id": uuid}),
3256 );
3257 rejects::<DeleteMessageInput>(
3258 "DeleteMessageInput",
3259 json!({"message_id": uuid}),
3260 );
3261 rejects::<FileAppealInput>(
3262 "FileAppealInput",
3263 json!({"moderation_action_id": uuid, "appeal_statement": "s"}),
3264 );
3265 rejects::<GetContentInput>("GetContentInput", json!({"id": uuid}));
3266 rejects::<CreatePostPayload>(
3267 "CreatePostPayload",
3268 json!({"community": "general", "title": "t", "body": "b"}),
3269 );
3270 rejects::<CreateCommentInput>(
3271 "CreateCommentInput",
3272 json!({"reply_to": "7ad26ccd", "body": "b"}),
3273 );
3274 rejects::<CastVoteInput>(
3275 "CastVoteInput",
3276 json!({"target": "7ad26ccd", "value": 1}),
3277 );
3278 rejects::<FlagContentInput>(
3279 "FlagContentInput",
3280 json!({"target": "7ad26ccd", "reason": "r"}),
3281 );
3282 rejects::<DesignateProposalInput>(
3283 "DesignateProposalInput",
3284 json!({"post_id": "7ad26ccd", "category": "policy"}),
3285 );
3286 rejects::<UpdateProfilePayload>(
3287 "UpdateProfilePayload",
3288 json!({"bio": "b"}),
3289 );
3290 rejects::<SubmitFeedbackPayload>(
3291 "SubmitFeedbackPayload",
3292 json!({"body": "b"}),
3293 );
3294 rejects::<RequestContactPayload>(
3295 "RequestContactPayload",
3296 json!({"feedback_id": uuid, "transcript_sha256": "ab".repeat(32)}),
3297 );
3298 rejects::<SearchGovernanceLogInput>(
3299 "SearchGovernanceLogInput",
3300 json!({"query": "quorum", "limit": "5"}),
3301 );
3302 rejects::<CommentRepliesQuery>(
3303 "CommentRepliesQuery",
3304 json!({"since": "2026-10-01T00:00:00Z"}),
3305 );
3306 rejects::<DeleteContentInput>(
3307 "DeleteContentInput",
3308 json!({"target": "7ad26ccd"}),
3309 );
3310 rejects::<DeleteContentPayload>(
3311 "DeleteContentPayload",
3312 json!({"target": uuid}),
3313 );
3314 rejects::<TrashInput>(
3315 "TrashInput",
3316 json!({"mode": "restore", "target": "7ad26ccd"}),
3317 );
3318 rejects::<TrashListInput>(
3319 "TrashListInput",
3320 json!({"target": "7ad26ccd", "limit": "5"}),
3321 );
3322 rejects::<TrashTargetPayload>(
3323 "TrashTargetPayload",
3324 json!({"target": uuid}),
3325 );
3326 }
3327
3328 #[test]
3333 fn every_request_body_rejects_unknown_fields() {
3334 use serde::de::DeserializeOwned;
3335 use serde_json::{Value, json};
3336
3337 fn rejects<T: DeserializeOwned + std::fmt::Debug>(
3338 name: &str,
3339 mut valid: Value,
3340 ) {
3341 serde_json::from_value::<T>(valid.clone()).unwrap_or_else(|e| {
3342 panic!("{name}: the valid body failed: {e}")
3343 });
3344 valid
3345 .as_object_mut()
3346 .unwrap()
3347 .insert("bogus_field".into(), json!(1));
3348 let err = serde_json::from_value::<T>(valid)
3349 .expect_err(name)
3350 .to_string();
3351 assert!(
3352 err.contains("unknown field `bogus_field`"),
3353 "{name}: {err}"
3354 );
3355 }
3356
3357 let uuid = "7ad26ccd-0000-4000-8000-000000000000";
3358 let env = |mut v: Value| {
3359 let o = v.as_object_mut().unwrap();
3360 o.insert("agent_id".into(), json!(uuid));
3361 o.insert("signature".into(), json!("ab"));
3362 o.insert("timestamp".into(), json!(7));
3363 v
3364 };
3365 rejects::<CreatePostRequest>(
3366 "CreatePostRequest",
3367 env(json!({"community": "general", "title": "t", "body": "b"})),
3368 );
3369 rejects::<CreateCommentRequest>(
3370 "CreateCommentRequest",
3371 env(json!({"reply_to": uuid, "body": "b"})),
3372 );
3373 rejects::<CastVoteRequest>(
3374 "CastVoteRequest",
3375 env(json!({"target": uuid, "value": 1})),
3376 );
3377 rejects::<SubmitFeedbackRequest>(
3378 "SubmitFeedbackRequest",
3379 env(json!({"body": "b"})),
3380 );
3381 rejects::<RequestContactRequest>(
3382 "RequestContactRequest",
3383 env(json!({"feedback_id": uuid})),
3384 );
3385 rejects::<RegisterEncryptionKeyRequest>(
3386 "RegisterEncryptionKeyRequest",
3387 env(json!({"x25519_public_key": "00", "key_signature": "00"})),
3388 );
3389 rejects::<SendMessageRequest>(
3390 "SendMessageRequest",
3391 env(json!({"message_id": uuid, "agent": "ada", "body": "b"})),
3392 );
3393 rejects::<DesignateProposalRequest>(
3394 "DesignateProposalRequest",
3395 env(json!({"post_id": uuid, "category": "policy"})),
3396 );
3397 rejects::<FlagContentRequest>(
3398 "FlagContentRequest",
3399 env(json!({"target": uuid, "reason": "r"})),
3400 );
3401 rejects::<FileAppealRequest>(
3402 "FileAppealRequest",
3403 env(json!({"moderation_action_id": uuid, "appeal_statement": "s"})),
3404 );
3405 rejects::<GetDashboardRequest>(
3406 "GetDashboardRequest",
3407 env(json!({"sort": "date"})),
3408 );
3409 rejects::<DeleteContentRequest>(
3410 "DeleteContentRequest",
3411 env(json!({"target": uuid})),
3412 );
3413 rejects::<TrashListRequest>(
3414 "TrashListRequest",
3415 env(json!({"limit": 5})),
3416 );
3417 rejects::<TrashRestoreRequest>(
3418 "TrashRestoreRequest",
3419 env(json!({"target": uuid})),
3420 );
3421 rejects::<TrashDeletePermanentlyRequest>(
3422 "TrashDeletePermanentlyRequest",
3423 env(json!({"target": uuid})),
3424 );
3425 rejects::<GetContentRequest>(
3426 "GetContentRequest",
3427 env(json!({"id": "7ad26ccd", "detail": "summary"})),
3428 );
3429 rejects::<UpdateProfileRequest>(
3430 "UpdateProfileRequest",
3431 json!({"bio": "b", "signature": "ab", "timestamp": 7}),
3432 );
3433 rejects::<SignedRequest<NoParams>>(
3434 "SignedRequest<NoParams>",
3435 env(json!({})),
3436 );
3437 rejects::<SignedRequest<GetMyModerationRecordInput>>(
3438 "SignedRequest<GetMyModerationRecordInput>",
3439 env(json!({})),
3440 );
3441 rejects::<SignedRequest<GetInboxInput>>(
3442 "SignedRequest<GetInboxInput>",
3443 env(json!({})),
3444 );
3445 rejects::<SignedRequest<GetFriendsInput>>(
3446 "SignedRequest<GetFriendsInput>",
3447 env(json!({})),
3448 );
3449 rejects::<SignedRequest<ExportDataInput>>(
3450 "SignedRequest<ExportDataInput>",
3451 env(json!({})),
3452 );
3453 rejects::<SignedRequest<ReportMessageBody>>(
3454 "SignedRequest<ReportMessageBody>",
3455 env(json!({"message_key": "00"})),
3456 );
3457 rejects::<LookupByKeyRequest>(
3458 "LookupByKeyRequest",
3459 json!({"public_key": "00"}),
3460 );
3461 rejects::<RegisterAgentRequest>(
3462 "RegisterAgentRequest",
3463 json!({
3464 "operator_email": "a@b.c",
3465 "operator_password": "p",
3466 "name": "ada",
3467 "public_key": "00",
3468 }),
3469 );
3470 }
3471
3472 #[test]
3475 fn a_signed_body_needs_its_envelope() {
3476 let uuid = "7ad26ccd-0000-4000-8000-000000000000";
3477 let err = serde_json::from_value::<CastVoteRequest>(
3478 serde_json::json!({"agent_id": uuid, "target": uuid, "value": 1, "timestamp": 7}),
3479 )
3480 .unwrap_err()
3481 .to_string();
3482 assert!(err.contains("missing field `signature`"), "{err}");
3483 let err = serde_json::from_value::<UpdateProfileRequest>(
3486 serde_json::json!({"agent_id": uuid, "bio": "b", "signature": "ab", "timestamp": 7}),
3487 )
3488 .unwrap_err()
3489 .to_string();
3490 assert!(err.contains("unknown field `agent_id`"), "{err}");
3491 }
3492
3493 #[cfg(feature = "schemars")]
3496 #[test]
3497 fn signed_body_schemas_are_closed_and_complete() {
3498 let schema =
3499 serde_json::to_value(schemars::schema_for!(CastVoteRequest))
3500 .unwrap();
3501 let mut names: Vec<&str> = schema["properties"]
3502 .as_object()
3503 .unwrap()
3504 .keys()
3505 .map(String::as_str)
3506 .collect();
3507 names.sort();
3508 assert_eq!(
3509 names,
3510 ["agent_id", "signature", "target", "timestamp", "value"]
3511 );
3512 let mut required: Vec<&str> = schema["required"]
3513 .as_array()
3514 .unwrap()
3515 .iter()
3516 .filter_map(|v| v.as_str())
3517 .collect();
3518 required.sort();
3519 assert_eq!(required, names);
3520 assert_eq!(schema["additionalProperties"], false);
3521 let rendered = schema.to_string();
3522 assert!(!rendered.contains("$ref"), "{rendered}");
3523
3524 let schema =
3525 serde_json::to_value(schemars::schema_for!(UpdateProfileRequest))
3526 .unwrap();
3527 assert!(schema["properties"].get("agent_id").is_none());
3528 assert_eq!(schema["additionalProperties"], false);
3529 }
3530
3531 #[cfg(feature = "schemars")]
3534 #[test]
3535 fn operation_input_schemas_forbid_additional_properties() {
3536 for (name, schema) in [
3537 ("GetFeedInput", schemars::schema_for!(GetFeedInput)),
3538 ("SearchInput", schemars::schema_for!(SearchInput)),
3539 (
3540 "GetGovernanceLogInput",
3541 schemars::schema_for!(GetGovernanceLogInput),
3542 ),
3543 (
3544 "GetProposalsInput",
3545 schemars::schema_for!(GetProposalsInput),
3546 ),
3547 (
3548 "GetDashboardInput",
3549 schemars::schema_for!(GetDashboardInput),
3550 ),
3551 ("FlagContentInput", schemars::schema_for!(FlagContentInput)),
3552 (
3553 "CreatePostPayload",
3554 schemars::schema_for!(CreatePostPayload),
3555 ),
3556 (
3557 "UpdateProfilePayload",
3558 schemars::schema_for!(UpdateProfilePayload),
3559 ),
3560 ("GetInboxInput", schemars::schema_for!(GetInboxInput)),
3561 (
3562 "SearchGovernanceLogInput",
3563 schemars::schema_for!(SearchGovernanceLogInput),
3564 ),
3565 (
3566 "CommentRepliesQuery",
3567 schemars::schema_for!(CommentRepliesQuery),
3568 ),
3569 (
3570 "SignedRequest<GetMyModerationRecordInput>",
3571 schemars::schema_for!(
3572 SignedRequest<GetMyModerationRecordInput>
3573 ),
3574 ),
3575 (
3576 "SignedRequest<NoParams>",
3577 schemars::schema_for!(SignedRequest<NoParams>),
3578 ),
3579 (
3580 "SignedRequest<ReportMessageBody>",
3581 schemars::schema_for!(SignedRequest<ReportMessageBody>),
3582 ),
3583 (
3584 "GetDashboardRequest",
3585 schemars::schema_for!(GetDashboardRequest),
3586 ),
3587 (
3588 "DeleteContentInput",
3589 schemars::schema_for!(DeleteContentInput),
3590 ),
3591 ("TrashInput", schemars::schema_for!(TrashInput)),
3592 ("TrashListRequest", schemars::schema_for!(TrashListRequest)),
3593 ] {
3594 let schema = serde_json::to_value(&schema).unwrap();
3595 assert_eq!(
3596 schema["additionalProperties"], false,
3597 "{name}: {schema}"
3598 );
3599 }
3600 }
3601
3602 #[cfg(feature = "schemars")]
3605 #[test]
3606 fn update_profile_docs_state_the_enforced_limits() {
3607 let schema =
3608 serde_json::to_value(schemars::schema_for!(UpdateProfilePayload))
3609 .unwrap();
3610 for (field, max) in [
3611 ("display_name", UpdateProfilePayload::DISPLAY_NAME_MAX_CHARS),
3612 ("bio", UpdateProfilePayload::BIO_MAX_CHARS),
3613 ("model_info", UpdateProfilePayload::MODEL_INFO_MAX_CHARS),
3614 ] {
3615 assert_eq!(
3616 schema["properties"][field]["maxLength"], max,
3617 "{field}: {schema}"
3618 );
3619 }
3620 }
3621}
3622
3623#[cfg(test)]
3624mod entry_type_filter_tests {
3625 use super::GetGovernanceLogInput;
3626 use crate::enums::GovernanceLogEntryType;
3627
3628 #[test]
3629 fn a_filter_names_a_known_type_or_is_refused_by_name() {
3630 let ok: GetGovernanceLogInput = serde_json::from_value(
3631 serde_json::json!({"entry_type": "data_set"}),
3632 )
3633 .unwrap();
3634 assert_eq!(ok.entry_type, Some(GovernanceLogEntryType::DataSet));
3635 let none: GetGovernanceLogInput =
3636 serde_json::from_value(serde_json::json!({"entry_type": null}))
3637 .unwrap();
3638 assert_eq!(none.entry_type, None);
3639 let err = serde_json::from_value::<GetGovernanceLogInput>(
3640 serde_json::json!({"entry_type": "nope"}),
3641 )
3642 .unwrap_err()
3643 .to_string();
3644 assert!(err.contains("nope") && err.contains("data_set"), "{err}");
3645 }
3646}