vector_core/community/v2/
roles.rs1use serde::{Deserialize, Serialize};
13
14use crate::community::roles::{MemberGrant, Permissions, Role, RoleScope};
15
16pub const MAX_ROLES_PER_MEMBER: usize = 64;
21pub const MAX_ROLES_PER_COMMUNITY: usize = 100;
22pub const MAX_BANLIST: usize = 500;
23pub const MAX_ROLE_NAME_BYTES: usize = super::control::MAX_NAME_BYTES;
25
26#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
29pub struct RoleContent {
30 pub role_id: String,
31 pub name: String,
32 pub position: u32,
33 #[serde(with = "perm_decimal_string")]
34 pub permissions: u64,
35 pub scope: RoleScope,
36 #[serde(default)]
37 pub color: u32,
38 #[serde(flatten)]
39 pub extra: serde_json::Map<String, serde_json::Value>,
40}
41
42impl RoleContent {
43 pub fn from_role(r: &Role) -> Self {
44 RoleContent {
45 role_id: r.role_id.clone(),
46 name: r.name.clone(),
47 position: r.position,
48 permissions: r.permissions.0,
49 scope: r.scope.clone(),
50 color: r.color,
51 extra: serde_json::Map::new(),
52 }
53 }
54 pub fn into_role(self) -> Role {
55 Role {
56 role_id: self.role_id,
57 name: self.name,
58 position: self.position,
59 permissions: Permissions(self.permissions),
60 scope: self.scope,
61 color: self.color,
62 }
63 }
64}
65
66#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
69pub struct GrantContent {
70 pub member: String,
71 #[serde(default)]
72 pub role_ids: Vec<String>,
73 #[serde(default, skip_serializing_if = "Option::is_none")]
81 pub control_wrap: Option<String>,
82 #[serde(flatten)]
83 pub extra: serde_json::Map<String, serde_json::Value>,
84}
85
86pub const MAX_CONTROL_WRAP_CHARS: usize = 1024;
89
90impl GrantContent {
91 pub fn from_grant(g: &MemberGrant) -> Self {
92 GrantContent { member: g.member.clone(), role_ids: g.role_ids.clone(), control_wrap: None, extra: serde_json::Map::new() }
93 }
94 pub fn into_grant(self) -> MemberGrant {
95 MemberGrant { member: self.member, role_ids: self.role_ids }
96 }
97}
98
99pub fn role_content_json(r: &Role) -> Result<String, String> {
101 serde_json::to_string(&RoleContent::from_role(r)).map_err(|e| e.to_string())
102}
103
104pub fn parse_role_content(content: &str) -> Option<Role> {
107 serde_json::from_str::<RoleContent>(content).ok().map(RoleContent::into_role)
108}
109
110pub fn grant_content_json(g: &MemberGrant) -> Result<String, String> {
112 serde_json::to_string(&GrantContent::from_grant(g)).map_err(|e| e.to_string())
113}
114
115pub fn grant_content_json_with_wrap(g: &MemberGrant, control_wrap: Option<String>) -> Result<String, String> {
117 let mut content = GrantContent::from_grant(g);
118 content.control_wrap = control_wrap;
119 serde_json::to_string(&content).map_err(|e| e.to_string())
120}
121
122pub fn parse_grant_content(content: &str) -> Option<MemberGrant> {
124 serde_json::from_str::<GrantContent>(content).ok().map(GrantContent::into_grant)
125}
126
127pub fn parse_grant_control_wrap(content: &str) -> Option<String> {
131 serde_json::from_str::<GrantContent>(content)
132 .ok()?
133 .control_wrap
134 .filter(|w| !w.is_empty() && w.len() <= MAX_CONTROL_WRAP_CHARS)
135}
136
137pub fn banlist_content_json(banned: &[String]) -> Result<String, String> {
140 serde_json::to_string(banned).map_err(|e| e.to_string())
141}
142
143pub fn parse_banlist_content(content: &str) -> Option<Vec<String>> {
146 serde_json::from_str::<Vec<String>>(content).ok()
147}
148
149pub fn validate_role(r: &Role) -> Result<(), String> {
152 if r.name.len() > MAX_ROLE_NAME_BYTES {
153 return Err("role name over 64 bytes".to_string());
154 }
155 if r.position == 0 {
157 return Err("position 0 is reserved to the owner".to_string());
158 }
159 Ok(())
160}
161
162pub fn validate_banlist(banned: &[String]) -> Result<(), String> {
165 if banned.len() > MAX_BANLIST {
166 return Err("banlist over the 500-npub ceiling".to_string());
167 }
168 Ok(())
169}
170
171mod perm_decimal_string {
175 use serde::{Deserialize, Deserializer, Serializer};
176
177 pub fn serialize<S: Serializer>(v: &u64, s: S) -> Result<S::Ok, S::Error> {
178 s.serialize_str(&v.to_string())
179 }
180
181 pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<u64, D::Error> {
182 #[derive(Deserialize)]
183 #[serde(untagged)]
184 enum StringOrNumber {
185 S(String),
186 N(u64),
187 }
188 match StringOrNumber::deserialize(d)? {
189 StringOrNumber::N(n) => Ok(n),
190 StringOrNumber::S(s) => {
191 if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) {
192 return Err(serde::de::Error::custom("permissions must be a decimal string"));
193 }
194 s.parse::<u64>().map_err(serde::de::Error::custom)
195 }
196 }
197 }
198}
199
200#[cfg(test)]
201mod tests {
202 use super::*;
203
204 fn role(perms: u64, pos: u32) -> Role {
205 Role {
206 role_id: "aa".repeat(32),
207 name: "Moderator".into(),
208 position: pos,
209 permissions: Permissions(perms),
210 scope: RoleScope::Server,
211 color: 15158332,
212 }
213 }
214
215 #[test]
216 fn role_permissions_ride_as_a_decimal_string() {
217 let json = role_content_json(&role(40, 2)).unwrap();
219 assert!(json.contains("\"permissions\":\"40\""), "permissions is a decimal string, not a number: {json}");
220 assert!(!json.contains("\"permissions\":40"), "must not emit a bare number");
221 }
222
223 #[test]
224 fn role_round_trips_through_the_wire_form() {
225 let r = role(Permissions::ADMIN_ALL, 1);
226 let back = parse_role_content(&role_content_json(&r).unwrap()).unwrap();
227 assert_eq!(back, r);
228 }
229
230 #[test]
231 fn a_legacy_numeric_permissions_is_still_read() {
232 let legacy = r#"{"role_id":"bb","name":"X","position":3,"permissions":40,"scope":{"kind":"server"},"color":0}"#;
234 assert_eq!(parse_role_content(legacy).unwrap().permissions, Permissions(40));
235 }
236
237 #[test]
238 fn a_non_digit_permissions_string_is_rejected() {
239 for bad in ["\"+40\"", "\"4a\"", "\"\"", "\" 40\""] {
240 let j = format!(r#"{{"role_id":"cc","name":"X","position":2,"permissions":{bad},"scope":{{"kind":"server"}}}}"#);
241 assert!(parse_role_content(&j).is_none(), "rejected non-digit permissions {bad}");
242 }
243 }
244
245 #[test]
246 fn channel_scope_round_trips() {
247 let mut r = role(8, 2);
248 r.scope = RoleScope::Channel("cc".repeat(32));
249 let json = role_content_json(&r).unwrap();
250 assert!(json.contains(r#""scope":{"kind":"channel","channel_id":""#), "{json}");
251 assert_eq!(parse_role_content(&json).unwrap().scope, r.scope);
252 }
253
254 #[test]
255 fn grant_round_trips_and_empty_is_a_revoke() {
256 let g = MemberGrant { member: "dd".repeat(32), role_ids: vec!["aa".repeat(32)] };
257 assert_eq!(parse_grant_content(&grant_content_json(&g).unwrap()).unwrap(), g);
258 let revoke = MemberGrant { member: "ee".repeat(32), role_ids: vec![] };
259 let back = parse_grant_content(&grant_content_json(&revoke).unwrap()).unwrap();
260 assert!(back.role_ids.is_empty());
261 }
262
263 #[test]
264 fn banlist_round_trips() {
265 let banned = vec!["11".repeat(32), "22".repeat(32)];
266 assert_eq!(parse_banlist_content(&banlist_content_json(&banned).unwrap()).unwrap(), banned);
267 assert!(parse_banlist_content("not-an-array").is_none());
268 }
269
270 #[test]
271 fn unknown_role_fields_round_trip() {
272 let j = r#"{"role_id":"ab","name":"X","position":2,"permissions":"8","scope":{"kind":"server"},"color":0,"future":"keep"}"#;
273 let parsed: RoleContent = serde_json::from_str(j).unwrap();
274 let reser = serde_json::to_string(&parsed).unwrap();
275 assert!(reser.contains("\"future\":\"keep\""), "unknown fields survive: {reser}");
276 }
277
278 #[test]
279 fn validate_rejects_pos_zero_and_long_name() {
280 assert!(validate_role(&role(8, 0)).is_err(), "position 0 reserved to owner");
281 let mut long = role(8, 2);
282 long.name = "x".repeat(65);
283 assert!(validate_role(&long).is_err());
284 assert!(validate_banlist(&vec!["z".into(); MAX_BANLIST + 1]).is_err());
285 }
286}