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