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: r.extra.clone(),
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 extra: self.extra,
63 }
64 }
65}
66
67#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
70pub struct GrantContent {
71 pub member: String,
72 #[serde(default)]
73 pub role_ids: Vec<String>,
74 #[serde(default, skip_serializing_if = "Option::is_none")]
82 pub control_wrap: Option<String>,
83 #[serde(flatten)]
84 pub extra: serde_json::Map<String, serde_json::Value>,
85}
86
87pub const MAX_CONTROL_WRAP_CHARS: usize = 1024;
90
91impl GrantContent {
92 pub fn from_grant(g: &MemberGrant) -> Self {
93 GrantContent { member: g.member.clone(), role_ids: g.role_ids.clone(), control_wrap: None, extra: serde_json::Map::new() }
94 }
95 pub fn into_grant(self) -> MemberGrant {
96 MemberGrant { member: self.member, role_ids: self.role_ids }
97 }
98}
99
100pub fn role_content_json(r: &Role) -> Result<String, String> {
102 serde_json::to_string(&RoleContent::from_role(r)).map_err(|e| e.to_string())
103}
104
105pub fn parse_role_content(content: &str) -> Option<Role> {
108 serde_json::from_str::<RoleContent>(content).ok().map(RoleContent::into_role)
109}
110
111pub fn grant_content_json(g: &MemberGrant) -> Result<String, String> {
113 serde_json::to_string(&GrantContent::from_grant(g)).map_err(|e| e.to_string())
114}
115
116pub fn grant_content_json_with_wrap(g: &MemberGrant, control_wrap: Option<String>) -> Result<String, String> {
118 let mut content = GrantContent::from_grant(g);
119 content.control_wrap = control_wrap;
120 serde_json::to_string(&content).map_err(|e| e.to_string())
121}
122
123pub fn parse_grant_content(content: &str) -> Option<MemberGrant> {
125 serde_json::from_str::<GrantContent>(content).ok().map(GrantContent::into_grant)
126}
127
128pub fn parse_grant_control_wrap(content: &str) -> Option<String> {
132 serde_json::from_str::<GrantContent>(content)
133 .ok()?
134 .control_wrap
135 .filter(|w| !w.is_empty() && w.len() <= MAX_CONTROL_WRAP_CHARS)
136}
137
138pub fn banlist_content_json(banned: &[String]) -> Result<String, String> {
141 serde_json::to_string(banned).map_err(|e| e.to_string())
142}
143
144pub fn parse_banlist_content(content: &str) -> Option<Vec<String>> {
147 serde_json::from_str::<Vec<String>>(content).ok()
148}
149
150pub fn validate_role(r: &Role) -> Result<(), String> {
153 if r.name.len() > MAX_ROLE_NAME_BYTES {
154 return Err("role name over 64 bytes".to_string());
155 }
156 if r.position == 0 {
158 return Err("position 0 is reserved to the owner".to_string());
159 }
160 Ok(())
161}
162
163pub fn validate_banlist(banned: &[String]) -> Result<(), String> {
166 if banned.len() > MAX_BANLIST {
167 return Err("banlist over the 500-npub ceiling".to_string());
168 }
169 Ok(())
170}
171
172mod perm_decimal_string {
176 use serde::{Deserialize, Deserializer, Serializer};
177
178 pub fn serialize<S: Serializer>(v: &u64, s: S) -> Result<S::Ok, S::Error> {
179 s.serialize_str(&v.to_string())
180 }
181
182 pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<u64, D::Error> {
183 #[derive(Deserialize)]
184 #[serde(untagged)]
185 enum StringOrNumber {
186 S(String),
187 N(u64),
188 }
189 match StringOrNumber::deserialize(d)? {
190 StringOrNumber::N(n) => Ok(n),
191 StringOrNumber::S(s) => {
192 if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) {
193 return Err(serde::de::Error::custom("permissions must be a decimal string"));
194 }
195 s.parse::<u64>().map_err(serde::de::Error::custom)
196 }
197 }
198 }
199}
200
201#[cfg(test)]
202mod tests {
203
204 #[test]
205 fn a_role_republished_here_keeps_what_another_client_wrote() {
206 let wire = r#"{"role_id":"ab","name":"Mods","position":3,"permissions":"24","scope":{"kind":"server"},"color":0,"display":true,"future":{"x":1}}"#;
209 let mut role = parse_role_content(wire).expect("parses");
210 role.name = "Moderators".into();
211 role.color = 0x59fcb3;
212 let back: serde_json::Value = serde_json::from_str(&role_content_json(&role).unwrap()).unwrap();
213 assert_eq!(back["display"], serde_json::json!(true), "the hoist flag survives the edit");
214 assert_eq!(back["future"], serde_json::json!({"x": 1}), "so does anything else unknown");
215 assert_eq!(back["name"], "Moderators");
216 assert_eq!(back["permissions"], "24", "still a decimal string");
217 }
218
219 #[test]
220 fn a_role_with_nothing_extra_serializes_as_before() {
221 let role = parse_role_content(r#"{"role_id":"ab","name":"A","position":1,"permissions":"1","scope":{"kind":"server"},"color":0}"#).unwrap();
222 assert!(role.extra.is_empty());
223 let stored = serde_json::to_string(&role).unwrap();
224 assert!(!stored.contains("extra"), "stored rosters stay byte-identical when nothing is carried");
225 }
226
227 use super::*;
228
229 fn role(perms: u64, pos: u32) -> Role {
230 Role {
231 role_id: "aa".repeat(32),
232 name: "Moderator".into(),
233 position: pos,
234 permissions: Permissions(perms),
235 scope: RoleScope::Server,
236 color: 15158332, extra: Default::default(),
237 }
238 }
239
240 #[test]
241 fn role_permissions_ride_as_a_decimal_string() {
242 let json = role_content_json(&role(40, 2)).unwrap();
244 assert!(json.contains("\"permissions\":\"40\""), "permissions is a decimal string, not a number: {json}");
245 assert!(!json.contains("\"permissions\":40"), "must not emit a bare number");
246 }
247
248 #[test]
249 fn role_round_trips_through_the_wire_form() {
250 let r = role(Permissions::ADMIN_ALL, 1);
251 let back = parse_role_content(&role_content_json(&r).unwrap()).unwrap();
252 assert_eq!(back, r);
253 }
254
255 #[test]
256 fn a_legacy_numeric_permissions_is_still_read() {
257 let legacy = r#"{"role_id":"bb","name":"X","position":3,"permissions":40,"scope":{"kind":"server"},"color":0}"#;
259 assert_eq!(parse_role_content(legacy).unwrap().permissions, Permissions(40));
260 }
261
262 #[test]
263 fn a_non_digit_permissions_string_is_rejected() {
264 for bad in ["\"+40\"", "\"4a\"", "\"\"", "\" 40\""] {
265 let j = format!(r#"{{"role_id":"cc","name":"X","position":2,"permissions":{bad},"scope":{{"kind":"server"}}}}"#);
266 assert!(parse_role_content(&j).is_none(), "rejected non-digit permissions {bad}");
267 }
268 }
269
270 #[test]
271 fn channel_scope_round_trips() {
272 let mut r = role(8, 2);
273 r.scope = RoleScope::Channel("cc".repeat(32));
274 let json = role_content_json(&r).unwrap();
275 assert!(json.contains(r#""scope":{"kind":"channel","channel_id":""#), "{json}");
276 assert_eq!(parse_role_content(&json).unwrap().scope, r.scope);
277 }
278
279 #[test]
280 fn grant_round_trips_and_empty_is_a_revoke() {
281 let g = MemberGrant { member: "dd".repeat(32), role_ids: vec!["aa".repeat(32)] };
282 assert_eq!(parse_grant_content(&grant_content_json(&g).unwrap()).unwrap(), g);
283 let revoke = MemberGrant { member: "ee".repeat(32), role_ids: vec![] };
284 let back = parse_grant_content(&grant_content_json(&revoke).unwrap()).unwrap();
285 assert!(back.role_ids.is_empty());
286 }
287
288 #[test]
289 fn banlist_round_trips() {
290 let banned = vec!["11".repeat(32), "22".repeat(32)];
291 assert_eq!(parse_banlist_content(&banlist_content_json(&banned).unwrap()).unwrap(), banned);
292 assert!(parse_banlist_content("not-an-array").is_none());
293 }
294
295 #[test]
296 fn unknown_role_fields_round_trip() {
297 let j = r#"{"role_id":"ab","name":"X","position":2,"permissions":"8","scope":{"kind":"server"},"color":0,"future":"keep"}"#;
298 let parsed: RoleContent = serde_json::from_str(j).unwrap();
299 let reser = serde_json::to_string(&parsed).unwrap();
300 assert!(reser.contains("\"future\":\"keep\""), "unknown fields survive: {reser}");
301 }
302
303 #[test]
304 fn validate_rejects_pos_zero_and_long_name() {
305 assert!(validate_role(&role(8, 0)).is_err(), "position 0 reserved to owner");
306 let mut long = role(8, 2);
307 long.name = "x".repeat(65);
308 assert!(validate_role(&long).is_err());
309 assert!(validate_banlist(&vec!["z".into(); MAX_BANLIST + 1]).is_err());
310 }
311}