Skip to main content

vector_core/community/v2/
roles.rs

1//! CORD-04 Roles: v2 wire content for the Role (vsk 1), Grant (vsk 3), and Banlist
2//! (vsk 4) control entities.
3//!
4//! The authority ALGEBRA is shared, not copied: these types convert to/from
5//! [`crate::community::roles`]'s `Role`/`MemberGrant`/`Permissions`, and the fold
6//! feeds the shared `authorize_delegation` + `is_authorized`. Only the SERIALIZATION
7//! is v2-native, because CORD-04 §3 rides `permissions` as a decimal STRING (the
8//! shared `Permissions` serializes as a bare `u64` for v1's storage), and a reader
9//! MUST accept either form (a number from an older edition, a string henceforth) and
10//! always write the string.
11
12use serde::{Deserialize, Serialize};
13
14use crate::community::roles::{MemberGrant, Permissions, Role, RoleScope};
15
16/// A member holds at most this many Roles (CORD-04 §2); a Community folds at most
17/// [`MAX_ROLES_PER_COMMUNITY`] Roles (the lowest role_ids win, the same deterministic
18/// cap the member list uses). A Banlist edition holds at most [`MAX_BANLIST`] npubs
19/// (the practical NIP-44-envelope ceiling, CORD-04 §4).
20pub const MAX_ROLES_PER_MEMBER: usize = 64;
21pub const MAX_ROLES_PER_COMMUNITY: usize = 100;
22pub const MAX_BANLIST: usize = 500;
23/// A role `name` shares the protocol-wide 64-byte cap (CORD-04 §2/§3).
24pub const MAX_ROLE_NAME_BYTES: usize = super::control::MAX_NAME_BYTES;
25
26/// CORD-04 §2 Role content (vsk 1), `eid == role_id`. `permissions` rides as a
27/// decimal string (§3); unknown fields round-trip (CORD-02 §6).
28#[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/// CORD-04 §2 Grant content (vsk 3), `eid == grant_locator(community_id, member)`.
68/// Empty `role_ids` is a revoke.
69#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
70pub struct GrantContent {
71    pub member: String,
72    #[serde(default)]
73    pub role_ids: Vec<String>,
74    /// The staff write key riding a staff-making Grant (CORD-04 §3): the
75    /// current `control_root` NIP-44-encrypted under the granter↔member
76    /// pairwise conversation key, base64 — delivery, never authority. Its
77    /// plaintext is fixed-width `epoch_be[8] ‖ control_root[32]`, and the
78    /// recipient adopts the secret only if it derives to exactly the
79    /// `control_pk` they hold for the named epoch. Opaque pairwise ciphertext
80    /// to every other reader.
81    #[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
87/// Sanity bound on a carried `control_wrap` (a NIP-44 wrap of 40 bytes is
88/// ~130 chars); anything over is dropped on read, never carried.
89pub 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
100/// Serialize a Role's content to its CORD-04 §2 wire JSON (permissions as a string).
101pub 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
105/// Parse a vsk-1 edition's content into a shared `Role`. Accepts permissions as a
106/// string or a legacy number.
107pub fn parse_role_content(content: &str) -> Option<Role> {
108    serde_json::from_str::<RoleContent>(content).ok().map(RoleContent::into_role)
109}
110
111/// Serialize a Grant's content to its CORD-04 §2 wire JSON.
112pub 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
116/// [`grant_content_json`] carrying the staff write key (CORD-04 §3).
117pub 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
123/// Parse a vsk-3 edition's content into a shared `MemberGrant`.
124pub fn parse_grant_content(content: &str) -> Option<MemberGrant> {
125    serde_json::from_str::<GrantContent>(content).ok().map(GrantContent::into_grant)
126}
127
128/// Parse a vsk-3 edition's `control_wrap`, if one rides within bounds
129/// (CORD-04 §3). Only the grant's own member can open it — every other
130/// reader treats it as opaque bytes.
131pub 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
138/// Serialize a banlist to its CORD-04 §4 wire JSON (a flat array of lowercase-hex
139/// npubs, replaced entire on every edit).
140pub fn banlist_content_json(banned: &[String]) -> Result<String, String> {
141    serde_json::to_string(banned).map_err(|e| e.to_string())
142}
143
144/// Parse a vsk-4 edition's content into the banned set (lowercase hex). Non-array or
145/// malformed content yields `None` (dropped, never a partial ban).
146pub fn parse_banlist_content(content: &str) -> Option<Vec<String>> {
147    serde_json::from_str::<Vec<String>>(content).ok()
148}
149
150/// A Role's content byte-fits its cap discipline: name ≤ 64 bytes. (The 100-per-
151/// community and 64-per-member caps are fold-side, applied on read.)
152pub 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    // Position 0 is the owner's alone; no Role may claim it (CORD-04 §3).
157    if r.position == 0 {
158        return Err("position 0 is reserved to the owner".to_string());
159    }
160    Ok(())
161}
162
163/// A Banlist edition fits its ceiling (CORD-04 §4): refuse an over-cap edit rather
164/// than publish one a strict reader drops.
165pub 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
172/// CORD-04 §3 permissions: a decimal string on the wire, accepting either a string or
173/// a legacy bare number on read, and always writing the string. Digit-only (a leading
174/// `+`/`-` or whitespace is rejected, matching the strict `ms`/`ev` discipline).
175mod 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        // Armada's hoist flag and a field nobody has invented yet: neither is modelled
207        // here, and an edit must not erase either for every member (CORD-02 §6).
208        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        // CORD-04 §3 canonical example: 1<<3 KICK | 1<<5 MANAGE_MESSAGES = 40.
243        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        // A reader MUST accept a bare number from an older edition (§3).
258        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}