Skip to main content

vector_core/community/
roles.rs

1//! Concord role graph (GROUP_PROTOCOL.md).
2//!
3//! The MVP exposes a single auto-generated "Admin" role, but this is the FULL graph:
4//! roles are data (not a hardcoded `is_admin` flag), enforcement is capability-based
5//! (effective-permission bits + position), and the engine reads an arbitrary set of
6//! roles + grants. Mod roles, per-channel mods, and custom roles later are just more
7//! `Role` records flowing through the same code — additive, no enforcement changes.
8//!
9//! WIRE MODEL (per-entity): roles and grants do NOT live in the GroupRoot, and they
10//! are NOT one consolidated blob. Each role is its own addressable RoleMetadata event
11//! (vsk=1, d-tag = role_id) and each member's grants are their own Grant event
12//! (vsk=3, d-tag = an opaque per-member locator). So two managers editing *different*
13//! roles or *different* members never clobber each other — only same-coordinate edits
14//! converge (authority-first). `CommunityRoles` here is the in-memory AGGREGATION a
15//! client builds from those fetched per-entity events, not an on-wire document.
16
17use serde::{Deserialize, Serialize};
18
19/// Management/moderation permission bits. Access (read/post a channel) is NOT
20/// here — that is key possession (the two-mechanism split). Bit positions are part of
21/// the wire format and are FROZEN: append a reserved bit, never renumber or reuse one.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
23#[serde(transparent)]
24pub struct Permissions(pub u64);
25
26impl Permissions {
27    pub const MANAGE_ROLES: u64 = 1 << 0;
28    pub const MANAGE_CHANNELS: u64 = 1 << 1;
29    pub const MANAGE_METADATA: u64 = 1 << 2;
30    pub const KICK: u64 = 1 << 3;
31    pub const BAN: u64 = 1 << 4;
32    pub const MANAGE_MESSAGES: u64 = 1 << 5;
33    pub const CREATE_INVITE: u64 = 1 << 6;
34    pub const VIEW_AUDIT_LOG: u64 = 1 << 8;
35    pub const MENTION_EVERYONE: u64 = 1 << 9;
36    // Reserved / retired (claim the bit so it's never reassigned):
37    // `1 << 7` was MANAGE_INVITES — RETIRED (per-creator ownership: no one can manage another's
38    // links, so there is nothing to grant; `CREATE_INVITE` mints your own, `BAN` owns the revoking rekey).
39    // MANAGE_EMOJI = 1 << 10, PIN_MESSAGES = 1 << 11, MANAGE_EVENTS = 1 << 12.
40
41    /// Every management bit currently defined — what the MVP "Admin" role holds.
42    pub const ADMIN_ALL: u64 = Self::MANAGE_ROLES
43        | Self::MANAGE_CHANNELS
44        | Self::MANAGE_METADATA
45        | Self::KICK
46        | Self::BAN
47        | Self::MANAGE_MESSAGES
48        | Self::CREATE_INVITE
49        | Self::VIEW_AUDIT_LOG
50        | Self::MENTION_EVERYONE;
51
52    /// Control-plane bits: exercising any of these signs a control/metadata edition (keyless model —
53    /// the actor's own npub signature IS the authority, re-verified against the roster). Every
54    /// management bit EXCEPT purely-social ones (`MENTION_EVERYONE` acts at the message layer, not the
55    /// control plane). A role with any of these is a "management role" — its holder shows the admin crown.
56    pub const MANAGEMENT_MASK: u64 = Self::MANAGE_ROLES
57        | Self::MANAGE_CHANNELS
58        | Self::MANAGE_METADATA
59        | Self::KICK
60        | Self::BAN
61        | Self::MANAGE_MESSAGES
62        | Self::CREATE_INVITE
63        | Self::VIEW_AUDIT_LOG;
64
65    pub fn empty() -> Self {
66        Permissions(0)
67    }
68    pub fn admin() -> Self {
69        Permissions(Self::ADMIN_ALL)
70    }
71    /// True iff this role carries any management permission (vs. a purely-social role) — i.e. its
72    /// holder counts as an admin.
73    pub fn is_management(self) -> bool {
74        self.0 & Self::MANAGEMENT_MASK != 0
75    }
76    /// True iff every bit in `bits` is set.
77    pub fn contains(self, bits: u64) -> bool {
78        self.0 & bits == bits
79    }
80    pub fn union(self, other: Permissions) -> Permissions {
81        Permissions(self.0 | other.0)
82    }
83}
84
85/// Discord's "any channel" vs "this channel". Server-scope acts everywhere;
86/// channel-scope is rejected against any other channel.
87#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
88#[serde(rename_all = "snake_case", tag = "kind", content = "channel_id")]
89pub enum RoleScope {
90    Server,
91    /// Channel id, lowercase hex.
92    Channel(String),
93}
94
95/// A role: its own addressable RoleMetadata event (vsk=1). Fully general; the MVP
96/// auto-creates exactly one (`Role::admin`).
97#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
98pub struct Role {
99    /// Random opaque 32-byte hex id, stable across renames; also the event's d-tag.
100    pub role_id: String,
101    pub name: String,
102    /// Lower = higher authority. Owner is the implicit top (position 0, never a Role).
103    /// Authoritative ordering will move to a single RoleOrder entity when
104    /// drag-reorder ships; until then this declared value is the position (no reordering
105    /// to race in the owner-only MVP).
106    pub position: u32,
107    pub permissions: Permissions,
108    pub scope: RoleScope,
109    /// UI badge color (e.g. the Admin crown); 0 = theme default. Cosmetic.
110    #[serde(default)]
111    pub color: u32,
112}
113
114impl Role {
115    /// The MVP's auto-created server-scope Admin role: all management bits, position 1
116    /// (just below the owner). `role_id` is a fresh random opaque id minted at creation.
117    pub fn admin(role_id: String) -> Self {
118        Role {
119            role_id,
120            name: "Admin".to_string(),
121            position: 1,
122            permissions: Permissions::admin(),
123            scope: RoleScope::Server,
124            color: 0,
125        }
126    }
127}
128
129/// One member's role grants (vsk=3) — its own addressable event so granting Alice
130/// never clobbers Bob's grants. `member` is the grantee's pubkey, lowercase hex (same form
131/// as the banlist), and `role_ids` are the roles they hold (a member can have several).
132#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
133pub struct MemberGrant {
134    pub member: String,
135    #[serde(default)]
136    pub role_ids: Vec<String>,
137}
138
139/// The role graph a client AGGREGATES from the fetched per-entity events (RoleMetadata +
140/// per-member Grant). Not an on-wire document — the enforcement engine queries this.
141#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
142pub struct CommunityRoles {
143    #[serde(default)]
144    pub roles: Vec<Role>,
145    #[serde(default)]
146    pub grants: Vec<MemberGrant>,
147}
148
149impl CommunityRoles {
150    /// Look up a role definition by id.
151    pub fn role(&self, role_id: &str) -> Option<&Role> {
152        self.roles.iter().find(|r| r.role_id == role_id)
153    }
154
155    /// The roles granted to `member_hex` (resolved through the grant list).
156    pub fn roles_of<'a>(&'a self, member_hex: &'a str) -> impl Iterator<Item = &'a Role> + 'a {
157        self.grants
158            .iter()
159            .filter(move |g| g.member == member_hex)
160            .flat_map(move |g| g.role_ids.iter())
161            .filter_map(move |rid| self.role(rid))
162    }
163
164    /// Effective permissions for a member = union of their granted roles' bits.
165    pub fn effective_permissions(&self, member_hex: &str) -> Permissions {
166        self.roles_of(member_hex)
167            .fold(Permissions::empty(), |acc, r| acc.union(r.permissions))
168    }
169
170    /// True iff the member's effective permissions include every bit in `bits`.
171    pub fn has_permission(&self, member_hex: &str, bits: u64) -> bool {
172        self.effective_permissions(member_hex).contains(bits)
173    }
174
175    /// Highest authority (lowest position index) among the member's roles; `None` if
176    /// they hold no role. The owner (implicit position 0) is handled by the caller.
177    pub fn highest_position(&self, member_hex: &str) -> Option<u32> {
178        self.roles_of(member_hex).map(|r| r.position).min()
179    }
180
181    /// True iff the member holds at least one role (i.e. is privileged below the owner).
182    pub fn is_privileged(&self, member_hex: &str) -> bool {
183        self.roles_of(member_hex).next().is_some()
184    }
185
186    /// True iff the member holds a role with management permissions — an "admin" (vs. a member who
187    /// holds only a non-management/social role). Drives the member-list crown.
188    pub fn is_admin(&self, member_hex: &str) -> bool {
189        self.roles_of(member_hex).any(|r| r.permissions.is_management())
190    }
191
192    /// Is `actor_hex` authorized for an action requiring `permission`? The **owner** (the
193    /// proven owner npub, if known) is supreme and always authorized; otherwise the actor must hold
194    /// a role granting `permission`. This is the grant-set check the inner-author-proof gates on: a
195    /// demoted member is no longer in the grant set, so their actions stop being honored.
196    pub fn is_authorized(&self, actor_hex: &str, owner_hex: Option<&str>, permission: u64) -> bool {
197        if owner_hex == Some(actor_hex) {
198            return true;
199        }
200        self.has_permission(actor_hex, permission)
201    }
202
203    /// escalation defense — may `actor_hex` manage something sitting at `target_position`
204    /// (grant/revoke/edit/reorder a role)? The actor must **strictly outrank** it (their highest
205    /// authority is a *lower* position number) AND hold `MANAGE_ROLES`. The owner is supreme
206    /// (implicit position 0, above every role) and always may. Equal cannot act on equal: an admin
207    /// can never grant/revoke a peer admin at the same position — only someone strictly above can.
208    /// This is what stops an admin granting the Admin role (`pos == pos`, refused) while still
209    /// letting them manage any role beneath them.
210    pub fn can_manage_position(&self, actor_hex: &str, owner_hex: Option<&str>, target_position: u32) -> bool {
211        self.can_act_on_position(actor_hex, owner_hex, target_position, Permissions::MANAGE_ROLES)
212    }
213
214    /// May `actor_hex` act on MEMBER `target_hex` for a role change (add/remove a role)? Resolves the
215    /// target's highest authority and applies the `MANAGE_ROLES` position rule. The **owner is never a
216    /// valid target** (supreme, unremovable — the sole hardcoded exception).
217    pub fn can_manage_member(&self, actor_hex: &str, owner_hex: Option<&str>, target_hex: &str) -> bool {
218        self.can_act_on_member(actor_hex, owner_hex, target_hex, Permissions::MANAGE_ROLES)
219    }
220
221    /// The pure position test (no permission bit): does `actor_hex` **strictly outrank**
222    /// `target_position`? The owner (implicit position 0) outranks everything; a roleless actor
223    /// outranks nothing. This is the position half of every authority check — callers AND it with the
224    /// specific permission the action needs (`BAN`, `MANAGE_MESSAGES`, `MANAGE_ROLES`, ...).
225    pub fn outranks(&self, actor_hex: &str, owner_hex: Option<&str>, target_position: u32) -> bool {
226        if owner_hex == Some(actor_hex) {
227            return true;
228        }
229        match self.highest_position(actor_hex) {
230            Some(p) => p < target_position,
231            None => false,
232        }
233    }
234
235    /// Generalized authority test: may `actor_hex` perform an action requiring `permission` against a
236    /// target at `target_position`? Owner is supreme; otherwise the actor must hold `permission` AND
237    /// strictly outrank the target. (`can_manage_position` is this with `MANAGE_ROLES`; bans pass
238    /// `BAN`, moderation-hides pass `MANAGE_MESSAGES`.)
239    pub fn can_act_on_position(&self, actor_hex: &str, owner_hex: Option<&str>, target_position: u32, permission: u64) -> bool {
240        if owner_hex == Some(actor_hex) {
241            return true;
242        }
243        self.has_permission(actor_hex, permission) && self.outranks(actor_hex, owner_hex, target_position)
244    }
245
246    /// Generalized member-targeting authority test (ban / kick / hide / role-change). The **owner is
247    /// never a valid target**; a roleless member sits below everyone. The actor needs `permission`
248    /// plus a strict outrank of the target's highest role.
249    pub fn can_act_on_member(&self, actor_hex: &str, owner_hex: Option<&str>, target_hex: &str, permission: u64) -> bool {
250        if owner_hex == Some(target_hex) {
251            return false;
252        }
253        let target_position = self.highest_position(target_hex).unwrap_or(u32::MAX);
254        self.can_act_on_position(actor_hex, owner_hex, target_position, permission)
255    }
256
257    // ── Private-Channel entitlement (CORD-03/04/06) ──────────────────────────
258    //
259    // CORD-03 defines a Private Channel as "readable only by granted
260    // role-holders", its key "delivered on grant and rekeyed on removal"; the
261    // binding that names those role-holders is CORD-04 §2's
262    // `scope: {"kind":"channel","channel_id":...}`. So **the roles scoped to a
263    // channel are its access list**.
264    //
265    // Read access is enforced by key possession alone — nothing here grants it.
266    // This is the routing that decides who a key is delivered TO on grant, and
267    // who a rekey keeps on revoke.
268
269    /// The roles conferring read access to `channel_hex`, in display order
270    /// (highest authority first).
271    ///
272    /// A Private Channel with none is readable by nobody but the owner and
273    /// whoever already holds the key — degenerate rather than "open", so
274    /// clients shouldn't create it, but one arriving that way from elsewhere
275    /// still reads correctly and its key holders keep reading it.
276    pub fn channel_roles(&self, channel_hex: &str) -> Vec<&Role> {
277        let wanted = channel_hex.to_ascii_lowercase();
278        let mut out: Vec<&Role> = self
279            .roles
280            .iter()
281            .filter(|r| matches!(&r.scope, RoleScope::Channel(c) if c.eq_ignore_ascii_case(&wanted)))
282            .collect();
283        out.sort_by_key(|r| r.position);
284        out
285    }
286
287    /// [`channel_roles`], by id.
288    pub fn channel_role_ids(&self, channel_hex: &str) -> Vec<String> {
289        self.channel_roles(channel_hex).into_iter().map(|r| r.role_id.clone()).collect()
290    }
291
292    /// Is `member_hex` entitled to `channel_hex`'s key? The owner always is
293    /// (position 0, supreme and unremovable).
294    ///
295    /// `with`/`without` overlay a Grant that was JUST published, so a caller can
296    /// settle key custody against the change it just made rather than against a
297    /// fold that lags its own publish.
298    pub fn is_entitled(
299        &self,
300        owner_hex: Option<&str>,
301        member_hex: &str,
302        channel_hex: &str,
303        with: &[String],
304        without: &[String],
305    ) -> bool {
306        if owner_hex == Some(member_hex) {
307            return true;
308        }
309        let mut held: std::collections::HashSet<&str> =
310            self.roles_of(member_hex).map(|r| r.role_id.as_str()).collect();
311        for id in with {
312            held.insert(id.as_str());
313        }
314        for id in without {
315            held.remove(id.as_str());
316        }
317        self.channel_role_ids(channel_hex).iter().any(|id| held.contains(id.as_str()))
318    }
319
320    /// Effective permissions for an action TARGETING one channel: server-scope
321    /// roles plus roles scoped to that channel.
322    ///
323    /// ⚠️ **Offer-side only.** The fold stays scope-agnostic in every
324    /// implementation (CORD-04 §3, and [`effective_permissions`] is what judges
325    /// inbound authority) — this narrows only what THIS client offers its own
326    /// user, never what it honors from others. Tightening the honor path would
327    /// retroactively invalidate actions shipped clients already folded.
328    pub fn effective_permissions_in(&self, member_hex: &str, channel_hex: &str) -> Permissions {
329        let wanted = channel_hex.to_ascii_lowercase();
330        self.roles_of(member_hex)
331            .filter(|r| match &r.scope {
332                RoleScope::Server => true,
333                RoleScope::Channel(c) => c.eq_ignore_ascii_case(&wanted),
334            })
335            .fold(Permissions::empty(), |acc, r| acc.union(r.permissions))
336    }
337
338    /// [`is_authorized`](Self::is_authorized), judged against one channel per
339    /// [`effective_permissions_in`](Self::effective_permissions_in). Offer-side only.
340    pub fn is_authorized_in(&self, actor_hex: &str, owner_hex: Option<&str>, channel_hex: &str, permission: u64) -> bool {
341        if owner_hex == Some(actor_hex) {
342            return true;
343        }
344        self.effective_permissions_in(actor_hex, channel_hex).contains(permission)
345    }
346}
347
348#[cfg(test)]
349mod tests {
350    use super::*;
351
352    fn admin_roster(member: &str) -> CommunityRoles {
353        let role = Role::admin("a".repeat(64));
354        CommunityRoles {
355            grants: vec![MemberGrant {
356                member: member.to_string(),
357                role_ids: vec![role.role_id.clone()],
358            }],
359            roles: vec![role],
360        }
361    }
362
363    #[test]
364    fn admin_role_holds_every_management_bit() {
365        let p = Permissions::admin();
366        for bit in [
367            Permissions::MANAGE_ROLES,
368            Permissions::MANAGE_CHANNELS,
369            Permissions::MANAGE_METADATA,
370            Permissions::KICK,
371            Permissions::BAN,
372            Permissions::MANAGE_MESSAGES,
373            Permissions::CREATE_INVITE,
374            Permissions::VIEW_AUDIT_LOG,
375            Permissions::MENTION_EVERYONE,
376        ] {
377            assert!(p.contains(bit), "admin must hold bit {bit}");
378        }
379    }
380
381    #[test]
382    fn social_only_role_is_not_management_nor_admin() {
383        // A role holding ONLY a social bit (MENTION_EVERYONE) is not "management" and its holder is
384        // NOT an admin — so it gets no crown and no management-secret delivery. Guards the
385        // MANAGEMENT_MASK invariant against a future fat-finger that folds a social bit in.
386        let social = Permissions(Permissions::MENTION_EVERYONE);
387        assert!(!social.is_management(), "a purely-social permission is not management");
388        let role = Role {
389            role_id: "c".repeat(64),
390            name: "Hype".into(),
391            position: 5,
392            permissions: social,
393            scope: RoleScope::Server,
394            color: 0,
395        };
396        let alice = "aa".repeat(32);
397        let r = CommunityRoles {
398            grants: vec![MemberGrant { member: alice.clone(), role_ids: vec![role.role_id.clone()] }],
399            roles: vec![role],
400        };
401        assert!(!r.is_admin(&alice), "a social-only role holder is not an admin");
402        assert!(r.is_privileged(&alice), "though they do hold a role");
403    }
404
405    #[test]
406    fn hierarchy_only_strictly_higher_can_manage() {
407        // owner (implicit pos 0) > admin (pos 1) > mod (pos 2); a plain member holds no role.
408        let owner = "00".repeat(32);
409        let admin = "aa".repeat(32);
410        let moderator = "bb".repeat(32);
411        let member = "cc".repeat(32);
412        let admin_role = Role::admin("a".repeat(64));
413        let mod_role = Role {
414            role_id: "b".repeat(64),
415            name: "Mod".into(),
416            position: 2,
417            permissions: Permissions(Permissions::MANAGE_ROLES | Permissions::KICK),
418            scope: RoleScope::Server,
419            color: 0,
420        };
421        let admin_pos = admin_role.position; // 1
422        let mod_pos = mod_role.position; // 2
423        let r = CommunityRoles {
424            grants: vec![
425                MemberGrant { member: admin.clone(), role_ids: vec![admin_role.role_id.clone()] },
426                MemberGrant { member: moderator.clone(), role_ids: vec![mod_role.role_id.clone()] },
427            ],
428            roles: vec![admin_role, mod_role],
429        };
430        let owner_ref = Some(owner.as_str());
431
432        // Owner is supreme — outranks every position and every member, can't be targeted.
433        assert!(r.can_manage_position(&owner, owner_ref, admin_pos));
434        assert!(r.can_manage_member(&owner, owner_ref, &admin));
435        assert!(!r.can_manage_member(&admin, owner_ref, &owner), "owner is never a valid target");
436
437        // Equal cannot act on equal: an admin can NOT manage the Admin position (closes self/peer
438        // escalation — granting the Admin role), but CAN manage everything strictly below.
439        assert!(!r.can_manage_position(&admin, owner_ref, admin_pos), "admin can't grant a peer-rank role");
440        assert!(r.can_manage_position(&admin, owner_ref, mod_pos), "admin outranks Mod");
441        assert!(r.can_manage_member(&admin, owner_ref, &moderator), "admin outranks the mod member");
442        assert!(r.can_manage_member(&admin, owner_ref, &member), "admin outranks a roleless member");
443        assert!(!r.can_manage_member(&admin, owner_ref, &admin), "admin can't manage a peer admin");
444
445        // A mod can't reach up to the Admin position, and a roleless member can manage nothing.
446        assert!(!r.can_manage_position(&moderator, owner_ref, admin_pos));
447        assert!(!r.can_manage_position(&member, owner_ref, mod_pos), "no role, no MANAGE_ROLES → nothing");
448    }
449
450    #[test]
451    fn can_act_on_member_gates_on_permission_and_rank() {
452        // owner (pos 0), a BAN-capable admin (pos 1), a Mod with KICK-only (pos 2, NO ban), a plain
453        // member (no role). Verifies the generalized gate requires BOTH the right permission AND a
454        // strict outrank — the rule ban/hide use, distinct from MANAGE_ROLES.
455        let owner = "00".repeat(32);
456        let admin = "aa".repeat(32);
457        let kicker = "bb".repeat(32);
458        let member = "cc".repeat(32);
459        let admin_role = Role::admin("a".repeat(64)); // pos 1, ADMIN_ALL (incl. BAN + MANAGE_MESSAGES)
460        let kick_role = Role {
461            role_id: "b".repeat(64),
462            name: "Mod".into(),
463            position: 2,
464            permissions: Permissions(Permissions::KICK), // KICK only — NO ban, NO manage-messages
465            scope: RoleScope::Server,
466            color: 0,
467        };
468        let r = CommunityRoles {
469            grants: vec![
470                MemberGrant { member: admin.clone(), role_ids: vec![admin_role.role_id.clone()] },
471                MemberGrant { member: kicker.clone(), role_ids: vec![kick_role.role_id.clone()] },
472            ],
473            roles: vec![admin_role, kick_role],
474        };
475        let o = Some(owner.as_str());
476        use Permissions as P;
477
478        // The BAN-capable admin: bans the Mod and a plain member, but NOT a peer admin, NOT the owner.
479        assert!(r.can_act_on_member(&admin, o, &kicker, P::BAN));
480        assert!(r.can_act_on_member(&admin, o, &member, P::BAN));
481        assert!(!r.can_act_on_member(&admin, o, &admin, P::BAN), "no banning a peer admin (equal rank)");
482        assert!(!r.can_act_on_member(&admin, o, &owner, P::BAN), "the owner is never a valid target");
483        assert!(r.can_act_on_member(&admin, o, &member, P::MANAGE_MESSAGES), "admin can hide a member's msg");
484
485        // The Mod has KICK but NOT BAN/MANAGE_MESSAGES → the permission gate refuses even a target it outranks.
486        assert!(!r.can_act_on_member(&kicker, o, &member, P::BAN), "no BAN permission → can't ban");
487        assert!(!r.can_act_on_member(&kicker, o, &member, P::MANAGE_MESSAGES), "no MANAGE_MESSAGES → can't hide");
488        assert!(r.can_act_on_member(&kicker, o, &member, P::KICK), "but it CAN kick a plain member");
489
490        // The owner is supreme for any permission, against anyone.
491        assert!(r.can_act_on_member(&owner, o, &admin, P::BAN));
492        // A plain member can do nothing.
493        assert!(!r.can_act_on_member(&member, o, &kicker, P::BAN));
494    }
495
496    #[test]
497    fn effective_permissions_union_and_position() {
498        let alice = "aa".repeat(32);
499        let r = admin_roster(&alice);
500        assert!(r.has_permission(&alice, Permissions::BAN));
501        assert!(r.has_permission(&alice, Permissions::MANAGE_ROLES));
502        assert_eq!(r.highest_position(&alice), Some(1));
503        assert!(r.is_privileged(&alice));
504
505        // A member with no grant has no permissions and no position.
506        let bob = "bb".repeat(32);
507        assert!(!r.has_permission(&bob, Permissions::BAN));
508        assert_eq!(r.highest_position(&bob), None);
509        assert!(!r.is_privileged(&bob));
510    }
511
512    #[test]
513    fn multiple_roles_union_perms_and_take_highest_position() {
514        let alice = "aa".repeat(32);
515        let mod_role = Role {
516            role_id: "b".repeat(64),
517            name: "Mod".into(),
518            position: 2,
519            permissions: Permissions(Permissions::MANAGE_MESSAGES | Permissions::KICK),
520            scope: RoleScope::Server,
521            color: 0,
522        };
523        let admin = Role::admin("a".repeat(64));
524        let r = CommunityRoles {
525            grants: vec![MemberGrant {
526                member: alice.clone(),
527                role_ids: vec![admin.role_id.clone(), mod_role.role_id.clone()],
528            }],
529            roles: vec![admin, mod_role],
530        };
531        // Union of both roles' bits, and the *highest* (lowest index) position wins.
532        assert!(r.has_permission(&alice, Permissions::BAN)); // from Admin
533        assert!(r.has_permission(&alice, Permissions::MANAGE_MESSAGES)); // from both
534        assert_eq!(r.highest_position(&alice), Some(1));
535    }
536
537    #[test]
538    fn round_trips_json() {
539        let alice = "aa".repeat(32);
540        let r = admin_roster(&alice);
541        let json = serde_json::to_string(&r).unwrap();
542        let back: CommunityRoles = serde_json::from_str(&json).unwrap();
543        assert_eq!(r, back);
544        assert!(json.contains("\"kind\":\"server\""));
545    }
546
547    #[test]
548    fn is_authorized_owner_supreme_admin_by_permission_demoted_rejected() {
549        let owner = "00".repeat(32);
550        let alice = "aa".repeat(32);
551        let r = admin_roster(&alice); // alice granted Admin (has BAN)
552        // Owner is always authorized, even without a role.
553        assert!(r.is_authorized(&owner, Some(&owner), Permissions::BAN));
554        // Admin Alice is authorized for BAN via her role.
555        assert!(r.is_authorized(&alice, Some(&owner), Permissions::BAN));
556        // A member with no role (Bob) is not — and neither is Alice once demoted (no grant).
557        let bob = "bb".repeat(32);
558        assert!(!r.is_authorized(&bob, Some(&owner), Permissions::BAN));
559        let demoted = CommunityRoles { roles: r.roles.clone(), grants: vec![] };
560        assert!(!demoted.is_authorized(&alice, Some(&owner), Permissions::BAN));
561        // ...but the owner stays authorized even with an empty grant set.
562        assert!(demoted.is_authorized(&owner, Some(&owner), Permissions::BAN));
563    }
564
565    #[test]
566    fn channel_scope_round_trips() {
567        let scope = RoleScope::Channel("cc".repeat(32));
568        let json = serde_json::to_string(&scope).unwrap();
569        let back: RoleScope = serde_json::from_str(&json).unwrap();
570        assert_eq!(scope, back);
571    }
572
573    // ── Private-Channel entitlement ──────────────────────────────────────────
574
575    /// A roster with one channel-scoped role (`pos` 5) over `chan`, granted to
576    /// `member`, plus a server-scope Admin held by `admin`.
577    fn scoped_roster(chan: &str, member: &str, admin: &str) -> CommunityRoles {
578        let scoped = Role {
579            role_id: "11".repeat(32),
580            name: "Testers".into(),
581            position: 5,
582            permissions: Permissions(Permissions::MANAGE_MESSAGES),
583            scope: RoleScope::Channel(chan.to_string()),
584            color: 0,
585        };
586        let server = Role::admin("22".repeat(32));
587        CommunityRoles {
588            grants: vec![
589                MemberGrant { member: member.to_string(), role_ids: vec![scoped.role_id.clone()] },
590                MemberGrant { member: admin.to_string(), role_ids: vec![server.role_id.clone()] },
591            ],
592            roles: vec![scoped, server],
593        }
594    }
595
596    #[test]
597    fn a_channels_scoped_roles_are_its_access_list() {
598        let chan = "cc".repeat(32);
599        let other = "dd".repeat(32);
600        let owner = "00".repeat(32);
601        let member = "aa".repeat(32);
602        let admin = "bb".repeat(32);
603        let r = scoped_roster(&chan, &member, &admin);
604
605        assert_eq!(r.channel_roles(&chan).len(), 1, "the scoped role is the access list");
606        assert!(r.channel_roles(&other).is_empty(), "another channel gets none of it");
607
608        assert!(r.is_entitled(Some(&owner), &member, &chan, &[], &[]), "the granted holder is entitled");
609        assert!(r.is_entitled(Some(&owner), &owner, &chan, &[], &[]), "the owner is always entitled");
610        // A SERVER-scope Admin is not automatically entitled: entitlement is the
611        // channel's scoped roles, never rank (CORD-03 — key possession, not authority).
612        assert!(!r.is_entitled(Some(&owner), &admin, &chan, &[], &[]), "rank alone is not entitlement");
613        assert!(!r.is_entitled(Some(&owner), &member, &other, &[], &[]), "entitlement does not spill to another channel");
614    }
615
616    #[test]
617    fn the_grant_overlay_settles_against_a_publish_the_fold_has_not_caught() {
618        let chan = "cc".repeat(32);
619        let owner = "00".repeat(32);
620        let member = "aa".repeat(32);
621        let stranger = "ee".repeat(32);
622        let r = scoped_roster(&chan, &member, &"bb".repeat(32));
623        let scoped_id = r.channel_role_ids(&chan)[0].clone();
624
625        assert!(
626            r.is_entitled(Some(&owner), &stranger, &chan, std::slice::from_ref(&scoped_id), &[]),
627            "a just-published grant settles as entitled before the fold catches up"
628        );
629        assert!(
630            !r.is_entitled(Some(&owner), &member, &chan, &[], std::slice::from_ref(&scoped_id)),
631            "a just-published revoke settles as unentitled before the fold catches up"
632        );
633    }
634
635    #[test]
636    fn channel_narrowing_is_offer_side_only() {
637        let chan = "cc".repeat(32);
638        let other = "dd".repeat(32);
639        let owner = "00".repeat(32);
640        let member = "aa".repeat(32);
641        let r = scoped_roster(&chan, &member, &"bb".repeat(32));
642
643        // Narrowed: the scoped role's bits apply in ITS channel and nowhere else.
644        assert!(r.is_authorized_in(&member, Some(&owner), &chan, Permissions::MANAGE_MESSAGES));
645        assert!(!r.is_authorized_in(&member, Some(&owner), &other, Permissions::MANAGE_MESSAGES));
646
647        // The HONOR path stays scope-agnostic — every implementation folds the
648        // same union (CORD-04 §3). Tightening this would retroactively invalidate
649        // actions shipped clients already folded.
650        assert!(
651            r.is_authorized(&member, Some(&owner), Permissions::MANAGE_MESSAGES),
652            "the scope-blind honor path is unchanged"
653        );
654    }
655}