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(flatten)]
74 pub extra: serde_json::Map<String, serde_json::Value>,
75}
76
77impl GrantContent {
78 pub fn from_grant(g: &MemberGrant) -> Self {
79 GrantContent { member: g.member.clone(), role_ids: g.role_ids.clone(), extra: serde_json::Map::new() }
80 }
81 pub fn into_grant(self) -> MemberGrant {
82 MemberGrant { member: self.member, role_ids: self.role_ids }
83 }
84}
85
86pub fn role_content_json(r: &Role) -> Result<String, String> {
88 serde_json::to_string(&RoleContent::from_role(r)).map_err(|e| e.to_string())
89}
90
91pub fn parse_role_content(content: &str) -> Option<Role> {
94 serde_json::from_str::<RoleContent>(content).ok().map(RoleContent::into_role)
95}
96
97pub fn grant_content_json(g: &MemberGrant) -> Result<String, String> {
99 serde_json::to_string(&GrantContent::from_grant(g)).map_err(|e| e.to_string())
100}
101
102pub fn parse_grant_content(content: &str) -> Option<MemberGrant> {
104 serde_json::from_str::<GrantContent>(content).ok().map(GrantContent::into_grant)
105}
106
107pub fn banlist_content_json(banned: &[String]) -> Result<String, String> {
110 serde_json::to_string(banned).map_err(|e| e.to_string())
111}
112
113pub fn parse_banlist_content(content: &str) -> Option<Vec<String>> {
116 serde_json::from_str::<Vec<String>>(content).ok()
117}
118
119pub fn validate_role(r: &Role) -> Result<(), String> {
122 if r.name.len() > MAX_ROLE_NAME_BYTES {
123 return Err("role name over 64 bytes".to_string());
124 }
125 if r.position == 0 {
127 return Err("position 0 is reserved to the owner".to_string());
128 }
129 Ok(())
130}
131
132pub fn validate_banlist(banned: &[String]) -> Result<(), String> {
135 if banned.len() > MAX_BANLIST {
136 return Err("banlist over the 500-npub ceiling".to_string());
137 }
138 Ok(())
139}
140
141mod perm_decimal_string {
145 use serde::{Deserialize, Deserializer, Serializer};
146
147 pub fn serialize<S: Serializer>(v: &u64, s: S) -> Result<S::Ok, S::Error> {
148 s.serialize_str(&v.to_string())
149 }
150
151 pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<u64, D::Error> {
152 #[derive(Deserialize)]
153 #[serde(untagged)]
154 enum StringOrNumber {
155 S(String),
156 N(u64),
157 }
158 match StringOrNumber::deserialize(d)? {
159 StringOrNumber::N(n) => Ok(n),
160 StringOrNumber::S(s) => {
161 if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) {
162 return Err(serde::de::Error::custom("permissions must be a decimal string"));
163 }
164 s.parse::<u64>().map_err(serde::de::Error::custom)
165 }
166 }
167 }
168}
169
170#[cfg(test)]
171mod tests {
172 use super::*;
173
174 fn role(perms: u64, pos: u32) -> Role {
175 Role {
176 role_id: "aa".repeat(32),
177 name: "Moderator".into(),
178 position: pos,
179 permissions: Permissions(perms),
180 scope: RoleScope::Server,
181 color: 15158332,
182 }
183 }
184
185 #[test]
186 fn role_permissions_ride_as_a_decimal_string() {
187 let json = role_content_json(&role(40, 2)).unwrap();
189 assert!(json.contains("\"permissions\":\"40\""), "permissions is a decimal string, not a number: {json}");
190 assert!(!json.contains("\"permissions\":40"), "must not emit a bare number");
191 }
192
193 #[test]
194 fn role_round_trips_through_the_wire_form() {
195 let r = role(Permissions::ADMIN_ALL, 1);
196 let back = parse_role_content(&role_content_json(&r).unwrap()).unwrap();
197 assert_eq!(back, r);
198 }
199
200 #[test]
201 fn a_legacy_numeric_permissions_is_still_read() {
202 let legacy = r#"{"role_id":"bb","name":"X","position":3,"permissions":40,"scope":{"kind":"server"},"color":0}"#;
204 assert_eq!(parse_role_content(legacy).unwrap().permissions, Permissions(40));
205 }
206
207 #[test]
208 fn a_non_digit_permissions_string_is_rejected() {
209 for bad in ["\"+40\"", "\"4a\"", "\"\"", "\" 40\""] {
210 let j = format!(r#"{{"role_id":"cc","name":"X","position":2,"permissions":{bad},"scope":{{"kind":"server"}}}}"#);
211 assert!(parse_role_content(&j).is_none(), "rejected non-digit permissions {bad}");
212 }
213 }
214
215 #[test]
216 fn channel_scope_round_trips() {
217 let mut r = role(8, 2);
218 r.scope = RoleScope::Channel("cc".repeat(32));
219 let json = role_content_json(&r).unwrap();
220 assert!(json.contains(r#""scope":{"kind":"channel","channel_id":""#), "{json}");
221 assert_eq!(parse_role_content(&json).unwrap().scope, r.scope);
222 }
223
224 #[test]
225 fn grant_round_trips_and_empty_is_a_revoke() {
226 let g = MemberGrant { member: "dd".repeat(32), role_ids: vec!["aa".repeat(32)] };
227 assert_eq!(parse_grant_content(&grant_content_json(&g).unwrap()).unwrap(), g);
228 let revoke = MemberGrant { member: "ee".repeat(32), role_ids: vec![] };
229 let back = parse_grant_content(&grant_content_json(&revoke).unwrap()).unwrap();
230 assert!(back.role_ids.is_empty());
231 }
232
233 #[test]
234 fn banlist_round_trips() {
235 let banned = vec!["11".repeat(32), "22".repeat(32)];
236 assert_eq!(parse_banlist_content(&banlist_content_json(&banned).unwrap()).unwrap(), banned);
237 assert!(parse_banlist_content("not-an-array").is_none());
238 }
239
240 #[test]
241 fn unknown_role_fields_round_trip() {
242 let j = r#"{"role_id":"ab","name":"X","position":2,"permissions":"8","scope":{"kind":"server"},"color":0,"future":"keep"}"#;
243 let parsed: RoleContent = serde_json::from_str(j).unwrap();
244 let reser = serde_json::to_string(&parsed).unwrap();
245 assert!(reser.contains("\"future\":\"keep\""), "unknown fields survive: {reser}");
246 }
247
248 #[test]
249 fn validate_rejects_pos_zero_and_long_name() {
250 assert!(validate_role(&role(8, 0)).is_err(), "position 0 reserved to owner");
251 let mut long = role(8, 2);
252 long.name = "x".repeat(65);
253 assert!(validate_role(&long).is_err());
254 assert!(validate_banlist(&vec!["z".into(); MAX_BANLIST + 1]).is_err());
255 }
256}