diff --git a/.github/workflows/_ci-relay.yml b/.github/workflows/_ci-relay.yml index a0fa0a4ae8..c02086dfda 100644 --- a/.github/workflows/_ci-relay.yml +++ b/.github/workflows/_ci-relay.yml @@ -627,6 +627,10 @@ jobs: cargo test -p buzz-test-client --test e2e_relay invite -- --ignored --nocapture cargo test -p buzz-test-client --test e2e_relay nip43_membership_snapshots_are_rejected -- --ignored --nocapture cargo test -p buzz-test-client --test e2e_relay nip29_departure_wire -- --ignored --nocapture + cargo test -p buzz-test-client --test e2e_relay nip29_announce_channel_wire -- --ignored --nocapture + cargo test -p buzz-test-client --test e2e_relay nip29_member_raises_guest_agent -- --ignored --nocapture + cargo test -p buzz-test-client --test e2e_relay nip29_guest_is_read_only_in_normal_channel -- --ignored --nocapture + cargo test -p buzz-test-client --test e2e_relay nip29_relay_rejects_role_change_by_unprivileged_actor -- --ignored --nocapture env: RELAY_URL: ws://localhost:3000 GIT_CREDENTIAL_NOSTR_BIN: ${{ github.workspace }}/target/ci/git-credential-nostr diff --git a/NOSTR.md b/NOSTR.md index 4212907295..543f99b6bc 100644 --- a/NOSTR.md +++ b/NOSTR.md @@ -51,13 +51,13 @@ PGPASSWORD=buzz_dev psql -h localhost -U buzz -d buzz -c \ | **Deletions (kind:5)** | ✅ | Standard NIP-09; self-authored only. `#h` optional, `#e` required | | **User profiles (kind:0)** | ✅ | NIP-01 metadata; synced to users table (display_name, avatar, about, NIP-05). NIP-05 handles must canonicalize to this relay's domain — off-domain or invalid handles are silently cleared. If a NIP-05 handle collides with another user's (UNIQUE constraint), the handle is skipped but other profile fields (display_name, avatar, about) are still synced. | | **Group creation (kind:9007)** | ✅ | NIP-29; include `name` tag, optional `visibility`, `channel_type` and label `t` tags | -| **Add user (kind:9000)** | ✅ | Open: any user, subject to target's `channel_add_policy` (`owner_only`/`nobody` can block). Private: owner/admin only. Self-add bypasses agent policy but not private-channel auth. | +| **Add user (kind:9000)** | ✅ | Open: any user, subject to target's `channel_add_policy` (`owner_only`/`nobody` can block). Private: owner/admin only. Self-add bypasses agent policy but not private-channel auth. A guest cannot add anyone. A new member with no role tag gets the channel's `join_role`; a non-member adding themselves may ask for `join_role` or a lower role. | | **Remove user (kind:9001)** | ✅ | Self-remove allowed (with last-owner guard). Removing others: owner/admin only. | -| **Edit group metadata (kind:9002)** | ✅ | `name`/`about`/label `t` tags: owner/admin. `topic`/`purpose` tags: any member. | +| **Edit group metadata (kind:9002)** | ✅ | `name`/`about`/label `t`/`visibility`/`ttl`/`archived`/`join_role` tags: owner/admin. `topic`/`purpose` tags: any member except a guest. Buzz applies 9002 as a partial update: an absent tag leaves that setting unchanged. | | **Admin delete event (kind:9005)** | ✅ | Event author can always delete own. Otherwise owner/admin required. Target must be in same channel. | | **Group deletion (kind:9008)** | ✅ | Owner only. | | **Leave group (kind:9022)** | ✅ | Any member. Last-owner guard prevents orphaned groups. | -| **Group metadata (kind:39000)** | ✅ | Relay-signed; always `d`, `name`, `closed`, `["t", ]`, `["P", ]` and `["created_at", ]` tags; `about` only if description non-empty; `private` if applicable; `hidden` for DM channels and private system channels | +| **Group metadata (kind:39000)** | ✅ | Relay-signed; always `d`, `name`, `t`, `P`, `created_at`, `join_role`; `public` or `private`; `restricted` and `closed` per the access flags below; `about` only if description non-empty; `hidden` for DM channels and private system channels | | **Group admins (kind:39001)** | ✅ | Relay-signed; `d`, channel type `t` and creator `P` tags + `p` tags with roles (`owner`, `admin`) | | **Group members (kind:39002)** | ✅ | Relay-signed; `d`, channel type `t` and creator `P` tags + `p` tags for all members | | **Membership notifications** | ✅ | kind:44100 (added) / kind:44101 (removed); relay-signed, community-global scope (`channel_id=None` inside the connected community) | @@ -71,7 +71,7 @@ PGPASSWORD=buzz_dev psql -h localhost -U buzz -d buzz -c \ | **NIP-10 threads** | ✅ | WS-submitted replies with `["e","","","reply"]` tags create `thread_metadata` atomically. Visible in REST thread queries. Unknown parents rejected. | | **NIP-17 DMs (gift wrap)** | ✅ | kind:1059 accepted with ephemeral signing keys. Stored community-globally (`channel_id=None` inside the connected community). Delivered via `#p`-filtered subscriptions. Not indexed in search. | | **DM discovery** | ✅ | DM creation emits kind:39000 (with `hidden` tag) + kind:44100 membership notifications. NIP-29 clients discover DMs via standard group discovery flow. | -| **Join request (kind:9021)** | ✅ | Open channels only. Adds member, emits system message + group discovery events + kind:44100 membership notification. Private channels rejected at ingest. | +| **Join request (kind:9021)** | ✅ | Open channels only. Adds the joiner with the channel's `join_role` (`member` by default), emits system message + group discovery events + kind:44100 membership notification. Private channels rejected at ingest. | | **Edits (kind:40003)** | ⚠️ | Works on the wire but Buzz-only — no standard NIP-29 client renders these | | **Rich content (kind:40002)** | ⚠️ | Works on the wire but Buzz-only — no standard NIP-29 client renders these | @@ -100,6 +100,61 @@ relay to specific external Nostr identities without granting full access. SELECT encode(pubkey, 'hex'), added_at, note FROM pubkey_allowlist; ``` +### Announce channels + +Roles have the same permissions in every channel. A channel's settings +decide only who can join and which role a new person gets: + +- `visibility` (`open` | `private`) decides who can find, join and read. +- `join_role` (`member` | `guest`) is the role a person gets when nobody + picks one: a self-join (kind:9021, or a kind:9000 self-add), or a kind:9000 + add with no `role` tag. The default, `member`, keeps today's behavior. + +An open channel with `join_role = guest` is an **announce channel**: anyone +can find, read and join it, but people who join are guests and cannot write. + +What each role can do: + +| Action | Guest | Member or bot | Admin / owner | +|--------|-------|---------------|---------------| +| Read, leave | Yes | Yes | Yes | +| Post, reply, edit, delete own, react, artifacts, canvas, forum, pins, typing, workflow messages, huddles | No | Yes | Yes | +| Topic, purpose (kind:9002) | No | Yes | Yes | +| Add people (kind:9000) | No | Yes, as member, guest or bot | Yes, any role | +| Push to the channel's git repositories | No | Yes | Yes | +| Name, about, visibility, TTL, `join_role` | No | No | Yes | + +A non-member may write only where joining would give a role that can write: +an open channel with `join_role = member`. A refused write gets +`restricted: only members can post in this channel`. + +Other rules: + +- A non-member adding themselves with kind:9000 may ask for `join_role` or a + lower role. A higher role is refused. +- Any member who can write (member, bot, owner or admin) can make a guest a + member with kind:9000 (`role` `member`). This is the same for a person and + an agent. Only owners and admins can make any other role change. +- The human owner of an agent can edit (kind:40003) and delete (kind:9005) + that agent's messages, also as a guest or a non-member. This is existing + behavior. It lets a person correct or remove what their agent posted; it + does not let them post. +- Owners and admins change the setting with kind:9002 + `["join_role", "guest"]` or `["join_role", "member"]`. A missing tag means + "no change". Changing it does not change anyone's role: existing members + can still write after a change to `guest`, and existing guests stay + read-only after a change to `member`. A stored value the relay does not + know is treated as `guest`. + +kind:39000 publishes the NIP-29 access flags from these settings, and an +explicit `["join_role", ...]` tag so clients can tell what joining gives: + +| `visibility` | `join_role` | `private` | `public` | `restricted` | `closed` | +|--------------|-------------|-----------|----------|--------------|----------| +| open | `member` | | ✓ | | | +| open | `guest` | | ✓ | ✓ | | +| private | either | ✓ | | ✓ | ✓ | + ### Group Discovery The relay emits NIP-29 group state events when channels are created, updated, or membership changes. @@ -109,7 +164,7 @@ All discovery events include a `d` tag set to the channel UUID (NIP-29 addressab | Kind | Tags | Content | |------|------|---------| -| **39000** | `d=`, `name`, `closed`, `t=`, `P=`, `created_at=` (always); `about` (if description non-empty); `private` (if applicable); `hidden` (DM channels and private system channels) | Group metadata. **Note:** `closed` is always emitted per NIP-29 convention (Buzz channels require explicit membership), but open channels are still readable/writable by non-members at runtime. The tag reflects the membership model, not access enforcement. | +| **39000** | `d=`, `name`, `t=`, `P=`, `created_at=`, `join_role` (always); `public` or `private`; `restricted` (private channels, and `join_role=guest`); `closed` (private channels only); `about` (if description non-empty); `hidden` (DM channels and private system channels) | Group metadata. The access flags follow the channel settings (see [Announce channels](#announce-channels)). | | **39001** | `d=`, `t=`, `P=`, `p` tags with role label (`owner`, `admin`) | Admin list | | **39002** | `d=`, `t=`, `P=`, `p` tags for all members | Member list | diff --git a/crates/buzz-admin/src/main.rs b/crates/buzz-admin/src/main.rs index 20e4acfa95..4f91369d6d 100644 --- a/crates/buzz-admin/src/main.rs +++ b/crates/buzz-admin/src/main.rs @@ -716,7 +716,7 @@ async fn reconcile_channels( channel_arg: Option, relay_key_arg: Option, ) -> Result<()> { - use buzz_core::channel::{channel_created_at_tag, group_state_identity_tags}; + use buzz_core::channel::group_state_identity_tags; use buzz_core::kind::KIND_NIP29_GROUP_ADMINS; use buzz_db::event::EventQuery; @@ -801,31 +801,10 @@ async fn reconcile_channels( if target_channel.is_none() { // kind:39000 — channel metadata { - let mut tags: Vec = vec![Tag::parse(["d", &channel_id_str])?]; - tags.push(Tag::parse(["name", &channel.name])?); - if let Some(ref desc) = channel.description { - if !desc.is_empty() { - tags.push(Tag::parse(["about", desc])?); - } - } - if channel.visibility == "private" { - tags.push(Tag::parse(["private"])?); - } else { - tags.push(Tag::parse(["public"])?); - } - if channel.channel_type == "dm" - || (channel.channel_type == "system" && channel.visibility == "private") - { - tags.push(Tag::parse(["hidden"])?); - } - tags.push(Tag::parse(["closed"])?); - tags.extend(group_state_identity_tags( - &channel.channel_type, - &channel.labels, - &channel.created_by, - )?); - tags.push(channel_created_at_tag(channel.created_at.timestamp())?); - + let tags = buzz_db::channel::group_metadata_tags(channel, &members) + .into_iter() + .map(Tag::parse) + .collect::, _>>()?; let event = EventBuilder::new(Kind::Custom(39000), "") .tags(tags) .sign_with_keys(&relay_keys) diff --git a/crates/buzz-cli/src/commands/channels.rs b/crates/buzz-cli/src/commands/channels.rs index 1eaccf434d..019a2fca6b 100644 --- a/crates/buzz-cli/src/commands/channels.rs +++ b/crates/buzz-cli/src/commands/channels.rs @@ -1598,16 +1598,23 @@ fn validate_update_channel_fields( description: Option<&str>, visibility: Option<&str>, ttl_change: Option>, + join_role: Option, ) -> Result<(), CliError> { - if name.is_none() && description.is_none() && visibility.is_none() && ttl_change.is_none() { + if name.is_none() + && description.is_none() + && visibility.is_none() + && ttl_change.is_none() + && join_role.is_none() + { return Err(CliError::Usage( - "at least one field required (--name, --description, --visibility, --ttl, --no-ttl)" + "at least one field required (--name, --description, --visibility, --ttl, --no-ttl, --join-role)" .into(), )); } Ok(()) } +#[allow(clippy::too_many_arguments)] pub async fn cmd_update_channel( client: &BuzzClient, channel_id: &str, @@ -1616,6 +1623,7 @@ pub async fn cmd_update_channel( visibility: Option<&str>, ttl: Option, no_ttl: bool, + join_role: Option, ) -> Result<(), CliError> { // Outer Option: None leaves TTL unchanged. Inner: Some(secs) sets it, // None (from --no-ttl) clears it, making the channel permanent. @@ -1625,12 +1633,18 @@ pub async fn cmd_update_channel( (None, false) => None, }; - validate_update_channel_fields(name, description, visibility, ttl_change)?; + validate_update_channel_fields(name, description, visibility, ttl_change, join_role)?; let channel_uuid = parse_uuid(channel_id)?; - let builder = - buzz_sdk::build_update_channel(channel_uuid, name, description, visibility, ttl_change) - .map_err(|e| CliError::Other(format!("build_update_channel failed: {e}")))?; + let builder = buzz_sdk::build_update_channel( + channel_uuid, + name, + description, + visibility, + ttl_change, + join_role, + ) + .map_err(|e| CliError::Other(format!("build_update_channel failed: {e}")))?; let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; @@ -1931,6 +1945,7 @@ pub async fn dispatch( visibility, ttl, no_ttl, + join_role, } => { let visibility = visibility.as_ref().map(|v| v.to_string()); cmd_update_channel( @@ -1941,6 +1956,7 @@ pub async fn dispatch( visibility.as_deref(), ttl, no_ttl, + join_role.map(Into::into), ) .await } @@ -2130,7 +2146,7 @@ mod tests { #[test] fn update_channel_fields_rejects_empty_update() { - let result = validate_update_channel_fields(None, None, None, None); + let result = validate_update_channel_fields(None, None, None, None, None); assert!(matches!(result, Err(CliError::Usage(_)))); let msg = result.unwrap_err().to_string(); assert!(msg.contains("at least one field required")); @@ -2139,7 +2155,7 @@ mod tests { #[test] fn update_channel_fields_accepts_visibility_only_update() { - let result = validate_update_channel_fields(None, None, Some("open"), None); + let result = validate_update_channel_fields(None, None, Some("open"), None, None); assert!(result.is_ok(), "visibility-only update should be accepted"); } diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index 00def0d9b9..c33afa78c2 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -191,6 +191,27 @@ impl std::fmt::Display for ChannelVisibility { } } +/// The role a new person gets in a channel (`join_role`). +#[derive(Clone, Copy, clap::ValueEnum)] +pub enum ChannelJoinRoleArg { + /// New people become members (default). + #[value(name = "member")] + Member, + /// New people become guests, who can read but not write. On an open + /// channel this makes an announce channel. + #[value(name = "guest")] + Guest, +} + +impl From for buzz_sdk::ChannelJoinRole { + fn from(value: ChannelJoinRoleArg) -> Self { + match value { + ChannelJoinRoleArg::Member => Self::Member, + ChannelJoinRoleArg::Guest => Self::Guest, + } + } +} + #[derive(Clone, clap::ValueEnum)] pub enum PresenceStatus { #[value(name = "online")] @@ -671,9 +692,9 @@ pub enum ChannelsCmd { #[arg(long, value_name = "PATH")] templates_file: Option, }, - /// Update channel name, description, visibility, or ephemeral TTL + /// Update channel name, description, visibility, join role, or ephemeral TTL #[command( - after_help = "Examples:\n buzz channels update --channel --name general\n buzz channels update --channel --visibility open\n buzz channels update --channel --visibility private" + after_help = "Examples:\n buzz channels update --channel --name general\n buzz channels update --channel --visibility open\n buzz channels update --channel --visibility private\n buzz channels update --channel --join-role guest" )] Update { /// Channel UUID @@ -695,6 +716,11 @@ pub enum ChannelsCmd { /// Clear an existing TTL, making the channel permanent. #[arg(long)] no_ttl: bool, + /// The role a new person gets when nobody picks one. `guest` on an + /// open channel makes an announce channel: people who join can read + /// but not write. Does not change existing members. Owner/admin only. + #[arg(long, value_enum)] + join_role: Option, }, /// Set the channel topic Topic { diff --git a/crates/buzz-core/src/channel.rs b/crates/buzz-core/src/channel.rs index bf8c3706fb..3e3a7b1090 100644 --- a/crates/buzz-core/src/channel.rs +++ b/crates/buzz-core/src/channel.rs @@ -17,6 +17,24 @@ pub fn canonical_channel_name(name: &str) -> &str { .trim_end() } +/// Maximum length of a canonical channel name, in characters. Matches +/// `channels.name VARCHAR(255)`, so the relay can refuse a longer name before +/// it stores the command event instead of failing when it applies it. +pub const MAX_CHANNEL_NAME_CHARS: usize = 255; + +/// Whether `name` is a storable channel name: nonempty and at most +/// [`MAX_CHANNEL_NAME_CHARS`] characters after [`canonical_channel_name`]. +pub fn channel_name_error(name: &str) -> Option<&'static str> { + let name = canonical_channel_name(name); + if name.is_empty() { + Some("channel name is required") + } else if name.chars().count() > MAX_CHANNEL_NAME_CHARS { + Some("channel name is longer than 255 characters") + } else { + None + } +} + /// Maximum number of labels on one channel. pub const MAX_CHANNEL_LABELS: usize = 8; @@ -171,6 +189,179 @@ impl FromStr for ChannelVisibility { } } +/// The role a person gets in a channel when nobody picks one +/// (`channels.join_role`): a self-join (kind:9021, or a kind:9000 self-add), +/// or a kind:9000 add with no `role` tag. +/// +/// This never changes what a role can do. [`MemberRole`] permissions are the +/// same in every channel; this setting only decides admission. An open channel +/// with `Guest` is an **announce channel**: anyone can follow and read it, but +/// only people a member, admin or owner makes a member can write. In NIP-29 +/// terms, `Guest` on an open channel is the `restricted` flag of kind:39000. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ChannelJoinRole { + /// New people become members. The default, and today's behavior. + Member, + /// New people become guests: they can read and leave, not write. + Guest, +} + +impl ChannelJoinRole { + /// Canonical string representation (matches the DB column and Nostr tags). + pub fn as_str(&self) -> &'static str { + match self { + Self::Member => "member", + Self::Guest => "guest", + } + } + + /// Parse a stored value for an authorization decision. An unknown value + /// is treated as [`ChannelJoinRole::Guest`], so it can never widen access. + pub fn parse_fail_closed(value: &str) -> Self { + value.parse().unwrap_or(Self::Guest) + } + + /// The [`MemberRole`] this setting gives a new person. + pub fn member_role(&self) -> MemberRole { + match self { + Self::Member => MemberRole::Member, + Self::Guest => MemberRole::Guest, + } + } + + /// Whether a non-member who adds themselves may ask for `requested`. + /// A joiner may ask for `join_role` or a lower role, never a higher one. + /// `Bot` has member permissions, so it counts as `Member` here. + pub fn allows_self_join_as(&self, requested: MemberRole) -> bool { + match requested { + MemberRole::Guest => true, + MemberRole::Member | MemberRole::Bot => *self == Self::Member, + MemberRole::Owner | MemberRole::Admin => false, + } + } +} + +impl fmt::Display for ChannelJoinRole { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl FromStr for ChannelJoinRole { + type Err = String; + + fn from_str(s: &str) -> Result { + match s { + "member" => Ok(Self::Member), + "guest" => Ok(Self::Guest), + other => Err(format!("unknown channel join role: {other:?}")), + } + } +} + +/// The facts a kind:9000 add or role change is decided on, for +/// [`decide_member_put`]. Every field must come from one consistent read: the +/// relay validator reads them for a fast, client-visible answer, and +/// `add_member` reads them again under the channel's membership lock, so the +/// stored role is always decided on current state. +#[derive(Debug, Clone, Copy)] +pub struct MemberPut<'a> { + /// Whether the channel is private. + pub private: bool, + /// The channel's `join_role`. + pub join_role: ChannelJoinRole, + /// The actor's active role, or `None` when the actor is not a member. + pub actor_role: Option, + /// Whether the actor is adding themselves. + pub target_is_actor: bool, + /// The target's active stored role, or `None` when the target is not an + /// active member. A soft-removed row is not an active role. + pub target_role: Option<&'a str>, + /// The role the event asks for, or `None` when it has no `role` tag. + pub requested: Option, +} + +/// Why [`decide_member_put`] refused an add or role change. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MemberPutDenial { + /// A non-member tried to add someone to a private channel. + NotAMember, + /// A non-elevated actor asked for owner or admin. + ElevatedRoleGrant, + /// A guest, or a non-member where joining does not give `member`, tried + /// to add someone else. Adding people is a write. + GuestCannotAdd, + /// A non-member adding themselves asked for a role above `join_role`. + SelfJoinAboveJoinRole, + /// The actor may not change the target's active role. + RoleChange, +} + +/// Decide a kind:9000 add or role change, and the role to store. +/// +/// This is the one definition of the admission rules: a guest never adds +/// anyone; a non-member adds others only where joining gives `member`; a +/// joiner gets `join_role` or a lower role it asks for; only owners and admins +/// grant elevated roles; and an active role changes only as +/// [`MemberRole::may_change_role`] allows. With no `role` tag, an active member +/// keeps their role and a new member gets `join_role`. +/// +/// Returns the role to store. The last-owner rule needs the owner count and +/// the target agent's `channel_add_policy` needs its own read; callers check +/// both separately. +pub fn decide_member_put(put: MemberPut<'_>) -> Result { + let actor_elevated = put.actor_role.is_some_and(|role| role.is_elevated()); + if put.private && put.actor_role.is_none() { + return Err(MemberPutDenial::NotAMember); + } + if put.target_is_actor && put.actor_role.is_none() { + // A joiner (open channels only; private ones are refused above). + if put + .requested + .is_some_and(|role| !put.join_role.allows_self_join_as(role)) + { + return Err(MemberPutDenial::SelfJoinAboveJoinRole); + } + } else if put.target_role.is_none() + && put.requested.is_some_and(|role| role.is_elevated()) + && !actor_elevated + { + // Granting an elevated role to a new member. An active member's role + // change is decided by `may_change_role` below. + return Err(MemberPutDenial::ElevatedRoleGrant); + } + if !put.target_is_actor { + let may_add = match put.actor_role { + Some(role) => role.can_post(), + None => put.join_role == ChannelJoinRole::Member, + }; + if !may_add { + return Err(MemberPutDenial::GuestCannotAdd); + } + } + + let Some(current) = put.target_role else { + return Ok(put.requested.unwrap_or(put.join_role.member_role())); + }; + let Some(requested) = put.requested else { + // No role tag: an active member keeps their role. + return current.parse().map_err(|_| MemberPutDenial::RoleChange); + }; + if current == requested.as_str() { + return Ok(requested); + } + // A stored role this build does not know is changed only by an elevated + // actor. + match current.parse::() { + Ok(current) if MemberRole::may_change_role(put.actor_role, current, requested) => { + Ok(requested) + } + Ok(_) => Err(MemberPutDenial::RoleChange), + Err(_) if actor_elevated => Ok(requested), + Err(_) => Err(MemberPutDenial::RoleChange), + } +} + /// The functional type of a channel. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ChannelType { @@ -281,6 +472,36 @@ impl MemberRole { matches!(self, Self::Owner | Self::Admin) } + /// Whether this role may write in a channel: post, react, edit, change + /// topic, add people, push to git. The same in every channel: every role + /// except [`MemberRole::Guest`] may, and a bot counts as a member. + pub fn can_post(&self) -> bool { + !matches!(self, Self::Guest) + } + + /// Whether an actor whose active role is `actor` may change another + /// active member's role from `current` to `requested`. + /// + /// Owners and admins may make any change (the last-owner rule is checked + /// separately). Any other active member who may post may make a guest a + /// member: the same authority they have to add a new member. This holds + /// for agents too; a later restricted-agent level may limit it. Nobody + /// else may change a role; a guest cannot promote anyone, including + /// themselves. + pub fn may_change_role( + actor: Option, + current: MemberRole, + requested: MemberRole, + ) -> bool { + let Some(actor) = actor else { + return false; + }; + actor.is_elevated() + || (actor.can_post() + && current == Self::Guest + && matches!(requested, Self::Member | Self::Bot)) + } + /// Numeric permission level for authorization comparisons. /// /// Higher = more privileged. Bot returns 0 (must use explicit grants). @@ -327,11 +548,22 @@ impl FromStr for MemberRole { #[cfg(test)] mod tests { use super::{ - canonical_channel_name, channel_type_from_group_state_tags, group_state_identity_tags, - parse_channel_labels, ChannelLabelError, ChannelType, LEGACY_CHANNEL_TYPES, - MAX_CHANNEL_LABELS, RESERVED_CHANNEL_LABELS, + canonical_channel_name, channel_name_error, channel_type_from_group_state_tags, + group_state_identity_tags, parse_channel_labels, ChannelJoinRole, ChannelLabelError, + ChannelType, MemberRole, LEGACY_CHANNEL_TYPES, MAX_CHANNEL_LABELS, RESERVED_CHANNEL_LABELS, }; + #[test] + fn channel_name_error_matches_the_column_limit() { + assert_eq!( + channel_name_error(" ## "), + Some("channel name is required") + ); + assert_eq!(channel_name_error(&"é".repeat(255)), None); + assert_eq!(channel_name_error(&format!("# {}", "a".repeat(255))), None); + assert!(channel_name_error(&"a".repeat(256)).is_some()); + } + #[test] fn only_the_types_from_before_nip_cl_are_legacy() { for name in LEGACY_CHANNEL_TYPES { @@ -484,6 +716,94 @@ mod tests { ); } + #[test] + fn join_role_parses_known_values_and_fails_closed() { + assert_eq!("member".parse(), Ok(ChannelJoinRole::Member)); + assert_eq!("guest".parse(), Ok(ChannelJoinRole::Guest)); + assert!("announce".parse::().is_err()); + assert_eq!( + ChannelJoinRole::parse_fail_closed("member"), + ChannelJoinRole::Member + ); + for unknown in ["", "Member", "everyone", "bot", "member "] { + assert_eq!( + ChannelJoinRole::parse_fail_closed(unknown), + ChannelJoinRole::Guest, + "{unknown:?}" + ); + } + assert_eq!(ChannelJoinRole::Member.member_role(), MemberRole::Member); + assert_eq!(ChannelJoinRole::Guest.member_role(), MemberRole::Guest); + } + + #[test] + fn a_joiner_may_ask_for_join_role_or_lower() { + use MemberRole::*; + for (join_role, requested, allowed) in [ + (ChannelJoinRole::Member, Guest, true), + (ChannelJoinRole::Member, Member, true), + (ChannelJoinRole::Member, Bot, true), + (ChannelJoinRole::Member, Admin, false), + (ChannelJoinRole::Member, Owner, false), + (ChannelJoinRole::Guest, Guest, true), + (ChannelJoinRole::Guest, Member, false), + (ChannelJoinRole::Guest, Bot, false), + (ChannelJoinRole::Guest, Admin, false), + (ChannelJoinRole::Guest, Owner, false), + ] { + assert_eq!( + join_role.allows_self_join_as(requested), + allowed, + "{join_role} {requested}" + ); + } + } + + #[test] + fn only_guests_cannot_post() { + for role in [ + MemberRole::Owner, + MemberRole::Admin, + MemberRole::Member, + MemberRole::Bot, + ] { + assert!(role.can_post(), "{role}"); + } + assert!(!MemberRole::Guest.can_post()); + } + + #[test] + fn only_elevated_roles_change_roles_except_promoting_a_guest() { + use MemberRole::*; + const ALL: [MemberRole; 5] = [Owner, Admin, Member, Guest, Bot]; + for actor in [Owner, Admin] { + for current in ALL { + for requested in ALL { + assert!(MemberRole::may_change_role(Some(actor), current, requested)); + } + } + } + for actor in [Member, Bot] { + for current in ALL { + for requested in ALL { + let promotes_guest = current == Guest && matches!(requested, Member | Bot); + assert_eq!( + MemberRole::may_change_role(Some(actor), current, requested), + promotes_guest, + "{actor}: {current} -> {requested}" + ); + } + } + } + for actor in [None, Some(Guest)] { + for current in ALL { + for requested in ALL { + assert!(!MemberRole::may_change_role(actor, current, requested)); + } + } + } + } + #[test] fn channel_names_trim_whitespace_and_drop_all_leading_hashes() { assert_eq!(canonical_channel_name("channel"), "channel"); diff --git a/crates/buzz-db/src/runtime/migration.rs b/crates/buzz-db/src/runtime/migration.rs index 7cb0879ff8..d194f0fa35 100644 --- a/crates/buzz-db/src/runtime/migration.rs +++ b/crates/buzz-db/src/runtime/migration.rs @@ -709,7 +709,7 @@ mod postgres_tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 66); + assert_eq!(migrations.len(), 67); assert_eq!(migrations[64].version, 65); let retire_ttl = strip_sql_comments(migrations[64].sql.as_str()); for statement in [ @@ -761,6 +761,11 @@ mod postgres_tests { .sql .as_str() .contains("ADD COLUMN through_message_id")); + assert_eq!(migrations[66].version, 67); + assert!(migrations[66] + .sql + .as_str() + .contains("ADD COLUMN join_role TEXT NOT NULL DEFAULT 'member'")); assert_eq!(migrations[48].version, 49); assert_eq!(migrations[49].version, 50); assert_eq!(migrations[50].version, 51); diff --git a/crates/buzz-db/src/runtime/tests/thread_window_postgres_tests.rs b/crates/buzz-db/src/runtime/tests/thread_window_postgres_tests.rs index beda4b199f..48846b42cd 100644 --- a/crates/buzz-db/src/runtime/tests/thread_window_postgres_tests.rs +++ b/crates/buzz-db/src/runtime/tests/thread_window_postgres_tests.rs @@ -215,7 +215,26 @@ async fn migration_schema_thread_window_prebuild_does_not_queue_behind_writer() let (pool, name) = create_scratch_db_through(&admin, "tw_prebuild_writer", Some(48)).await; let community = Uuid::new_v4(); let channel = Uuid::new_v4(); - seed_community_channel(&pool, community, channel, &nostr::Keys::generate()).await; + // Plain SQL: the schema stops at 0048, so the current channel store's + // column list (which includes later columns such as `join_role`) does not + // apply yet. + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(community) + .bind(format!("tw-prebuild-{}.example", community.simple())) + .execute(&pool) + .await + .expect("insert community"); + sqlx::query( + "INSERT INTO channels (id, community_id, name, channel_type, visibility, created_by) \ + VALUES ($1, $2, $3, 'stream', 'open', $4)", + ) + .bind(channel) + .bind(community) + .bind(format!("tw-prebuild-{channel}")) + .bind(nostr::Keys::generate().public_key().to_bytes().to_vec()) + .execute(&pool) + .await + .expect("insert channel"); sqlx::query("CREATE INDEX CONCURRENTLY idx_thread_metadata_window ON thread_metadata (community_id,root_event_id,event_created_at DESC,event_id ASC)") .execute(&pool).await.unwrap(); let oid: i64 = sqlx::query_scalar("SELECT 'idx_thread_metadata_window'::regclass::oid::bigint") diff --git a/crates/buzz-db/src/store/channel.rs b/crates/buzz-db/src/store/channel.rs index a4f1148335..a14cb9aa02 100644 --- a/crates/buzz-db/src/store/channel.rs +++ b/crates/buzz-db/src/store/channel.rs @@ -16,7 +16,10 @@ use buzz_datastore_tracing::datastore_span; // Re-export the canonical enum definitions from buzz-core. // These live in core (zero I/O deps) so the SDK can share them // without pulling in sqlx/tokio. -pub use buzz_core::channel::{ChannelType, ChannelVisibility, MemberRole}; +pub use buzz_core::channel::{ + decide_member_put, ChannelJoinRole, ChannelType, ChannelVisibility, MemberPut, MemberPutDenial, + MemberRole, +}; // Keep the established channel module paths compatible while membership SQL // and invariants live in their dedicated store module. @@ -78,6 +81,9 @@ pub struct ChannelRecord { pub ttl_deadline: Option>, /// Labels published as `["t",