Skip to main content

vector_core/community/
moderation.rs

1//! Moderation authority: who may remove whose message (CORD-04 §3/§5).
2//!
3//! One algebra for both protocols. The wire differs — v1 proves its owner with a
4//! signed attestation, v2's identity self-certifies against the community id — so
5//! the owner lookup branches and everything downstream does not.
6
7use super::roles::{CommunityRoles, Permissions};
8use super::{CommunityId, ConcordProtocol};
9
10/// The community's proven owner as lowercase hex, whichever protocol it speaks.
11///
12/// Resolving this through the v1 loader alone returns `None` for a v2 community
13/// (v2 stores no attestation), and a `None` owner is not a safe default here: it
14/// costs the owner their supremacy AND drops the protection that makes them an
15/// invalid target, so an admin outranks the roleless owner in both directions.
16pub fn owner_hex(community_id: &str) -> Option<String> {
17    let id = CommunityId(crate::simd::hex::hex_to_bytes_32(community_id));
18    match crate::db::community::community_protocol(&id).ok().flatten() {
19        Some(ConcordProtocol::V2) => crate::db::community::load_community_v2(&id)
20            .ok()
21            .flatten()
22            .and_then(|c| c.owner().ok())
23            .map(|pk| pk.to_hex()),
24        _ => crate::db::community::load_community(&id)
25            .ok()
26            .flatten()
27            .and_then(|c| super::service::proven_owner_hex(&c)),
28    }
29}
30
31/// May `actor_hex` remove a message authored by `author_hex`? The actor needs
32/// `MANAGE_MESSAGES` and a strict outrank; the owner is supreme and is never a
33/// valid target, so owner-protection falls out of the algebra with no carve-out.
34///
35/// Identities are normalized first: a message's stored npub is BECH32 while the
36/// owner and the roster grants are keyed by lowercase HEX, and an unnormalized
37/// author matches neither — it skips owner-protection and misses the roster
38/// lookup, defaulting to the lowest rank.
39pub fn can_hide(
40    owner_hex: Option<&str>,
41    roster: &CommunityRoles,
42    actor_hex: &str,
43    author_hex: &str,
44) -> bool {
45    let to_hex = |s: &str| {
46        nostr_sdk::prelude::PublicKey::parse(s)
47            .map(|pk| pk.to_hex())
48            .unwrap_or_else(|_| s.to_string())
49    };
50    roster.can_act_on_member(
51        &to_hex(actor_hex),
52        owner_hex,
53        &to_hex(author_hex),
54        Permissions::MANAGE_MESSAGES,
55    )
56}
57
58/// [`can_hide`] with the owner and roster resolved from the store. Callers
59/// judging a whole page should resolve those once and call [`can_hide`] instead.
60pub fn can_hide_in(community_id: &str, actor_hex: &str, author_hex: &str) -> bool {
61    let roster = crate::db::community::get_community_roles(community_id).unwrap_or_default();
62    can_hide(owner_hex(community_id).as_deref(), &roster, actor_hex, author_hex)
63}
64
65#[cfg(test)]
66mod tests {
67    use super::*;
68    use crate::community::roles::{MemberGrant, Role};
69
70    fn roster(owner: &str, admin_a: &str, admin_b: &str, mod_hex: &str) -> CommunityRoles {
71        let admin = Role::admin("a".repeat(64));
72        let mut moderator = Role::admin("b".repeat(64));
73        moderator.position = admin.position + 1;
74        let grants = vec![
75            MemberGrant { member: owner.to_string(), role_ids: vec![admin.role_id.clone()] },
76            MemberGrant { member: admin_a.to_string(), role_ids: vec![admin.role_id.clone()] },
77            MemberGrant { member: admin_b.to_string(), role_ids: vec![admin.role_id.clone()] },
78            MemberGrant { member: mod_hex.to_string(), role_ids: vec![moderator.role_id.clone()] },
79        ];
80        CommunityRoles { grants, roles: vec![admin, moderator] }
81    }
82
83    #[test]
84    fn the_owner_outranks_an_admin_even_holding_the_same_role() {
85        let (owner, admin_a, admin_b, moderator) =
86            ("11".repeat(32), "22".repeat(32), "33".repeat(32), "44".repeat(32));
87        let r = roster(&owner, &admin_a, &admin_b, &moderator);
88
89        // The owner is supreme: the shared Admin role puts them at the SAME
90        // position as admin_a, and equal-cannot-act-on-equal must not apply.
91        assert!(can_hide(Some(&owner), &r, &owner, &admin_a));
92        assert!(can_hide(Some(&owner), &r, &owner, &moderator));
93        // Peer admins still can't touch each other, and nobody touches the owner.
94        assert!(!can_hide(Some(&owner), &r, &admin_a, &admin_b));
95        assert!(!can_hide(Some(&owner), &r, &admin_a, &owner));
96        assert!(can_hide(Some(&owner), &r, &admin_a, &moderator));
97    }
98
99    #[test]
100    fn an_unresolved_owner_costs_supremacy_and_protection_both_ways() {
101        // Why an unresolvable owner is never a safe default: position 0 is
102        // implicit, so an owner holding no Role ranks LAST once `owner_hex` is
103        // None — unable to moderate anyone, and outranked by their own admins.
104        let (owner, admin, moderator) = ("11".repeat(32), "22".repeat(32), "44".repeat(32));
105        let admin_role = Role::admin("a".repeat(64));
106        let r = CommunityRoles {
107            grants: vec![MemberGrant { member: admin.clone(), role_ids: vec![admin_role.role_id.clone()] }],
108            roles: vec![admin_role],
109        };
110
111        assert!(!can_hide(None, &r, &owner, &admin), "the owner can't moderate anyone");
112        assert!(!can_hide(None, &r, &owner, &moderator));
113        assert!(can_hide(None, &r, &admin, &owner), "and is exposed to their own admins");
114
115        // Resolved, the same roster behaves: supreme in one direction, untouchable in the other.
116        assert!(can_hide(Some(&owner), &r, &owner, &admin));
117        assert!(!can_hide(Some(&owner), &r, &admin, &owner));
118    }
119
120    #[test]
121    fn a_bech32_author_resolves_to_the_same_verdict_as_hex() {
122        use nostr_sdk::prelude::*;
123        let owner_keys = Keys::generate();
124        let admin_keys = Keys::generate();
125        let owner = owner_keys.public_key().to_hex();
126        let admin = admin_keys.public_key().to_hex();
127        let r = roster(&owner, &admin, &"33".repeat(32), &"44".repeat(32));
128        let admin_bech32 = admin_keys.public_key().to_bech32().unwrap();
129        let owner_bech32 = owner_keys.public_key().to_bech32().unwrap();
130
131        assert!(can_hide(Some(&owner), &r, &owner_bech32, &admin_bech32));
132        // Owner-protection survives a bech32 target.
133        assert!(!can_hide(Some(&owner), &r, &admin_bech32, &owner_bech32));
134    }
135}