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