From d97f6b4c6ed616be0c3f1eb0e13d1eaa9e338a66 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Wed, 21 Oct 2026 16:01:11 -0400 Subject: [PATCH] feat(relay): add announce channels with a join_role setting (#8143) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 ## Summary Channels are Buzz's main way to control access, but today they cannot separate reading from writing. In an open channel, anyone who can read can also write. So there is no way to say "many people read, a few people write". Announcements, release feeds, plugin release records and shared configuration all need that. This PR adds **announce channels**: anyone can follow and read them, but only members can post. It does this with one new channel setting and without changing what any role means. - **Roles mean the same thing in every channel.** A *guest* can read and leave, but cannot write. A *member* (or *bot*) can write. *Admins* and *owners* also manage the channel. The relay already had the guest role, but no client used it until now. - **A new setting, `join_role` (`member` | `guest`), picks the role a new person gets** when nobody chooses one: when they join by themselves, or when someone adds them without a role. The default, `member`, keeps today's behavior for every existing channel. - **An open channel with `join_role = guest` is an announce channel.** Anyone can find, read and join it. People who join are guests, so they cannot write until someone makes them a member. The relay enforces these rules, so a client cannot get around them. What each role can do (the same in every channel): | Action | Guest | Member or bot | Admin / owner | | --- | --- | --- | --- | | Read, leave | ✅ | ✅ | ✅ | | Post, reply, edit, delete own, react, artifacts, canvas, forum, pins, topic, typing, workflow messages, save or run workflows, huddles | ❌ | ✅ | ✅ | | Add people | ❌ | ✅ as member, guest or bot | ✅ any role | | Make a guest a member (person or agent) | ❌ | ✅ | ✅ | | Any other role change | ❌ | ❌ | ✅ | | Change name, about, visibility, TTL, `join_role` | ❌ | ❌ | ✅ | Other rules: - **Joining by yourself.** A person who joins (kind:9021), or adds themselves (kind:9000 with their own key), gets the channel's `join_role`. They may ask for `join_role` or a lower role, never a higher one. So in an announce channel nobody can join as a member by themselves. - **Non-members.** A non-member may write only where joining would let them write: an open channel with `join_role = member`. That is today's behavior for open channels. - **Agents.** Agents follow the same rules as people. Any member who can write may make a guest agent a member. Limits that only an agent's owner may lift belong to the later IFC work (its "restricted" agent level), not to this PR. That work only refuses more actions, so it can be added later without breaking anything here. - **Leaving always works.** A member may leave an archived channel too (kind:9022, or kind:9001 naming themselves). The last owner still cannot leave. - **Agent owners keep edit and delete.** Today the human owner of an agent can edit (kind:40003) and delete (kind:9005) that agent's messages without being a member. This PR keeps that existing behavior in announce channels too, and documents and tests it. It does not let the owner post. - **Changing the setting changes no roles.** Existing members can still write after a change to `guest`. Existing guests stay read-only after a change back to `member`. A refused write gets `restricted: only members can post in this channel`. ## Details - **One write rule.** The relay's channel write check decides from the actor's role and the channel's `join_role`. Some event types skip that check and run their own: message edit, deleting your own message (kind:9005), topic and purpose (kind:9002), add-member (kind:9000), workflow messages and joining a huddle. Each of these now uses the same rule. Membership changes (add, remove, leave) do not need write access, so a guest can always leave. If the relay finds a stored `join_role` value it does not know, it treats it as `guest`, so an unknown value can never give more access. - **Admission is decided in the database transaction.** The membership write (`put_member`) makes the whole admission decision inside its transaction, after it takes the channel's membership lock: `join_role` for a missing role tag, the self-join ceiling, guests cannot add people, who may grant admin or owner, and role changes (`MemberRole::may_change_role`). The relay's request check uses the same rules, but the database is the authority, so a write that skips the request check cannot get around them. A change to `visibility` or `join_role` takes the same lock, so an add and a setting change cannot interleave. - **No creator exception.** `put_member` used to give owner to a channel's creator who added themselves to a private channel without an active membership. A removed creator could use that: their join (kind:9021) passes the open-channel check, the channel becomes private before the join takes the membership lock, and the join restored them as owner. Channel creation already makes the creator owner in the same transaction, so the exception is removed. A removed creator is now admitted like anyone else. - **Huddles.** Joining a huddle needs write access in the huddle channel **and** in its parent channel. The relay checks both before the join. When it commits the join, it applies the same rule again while it holds both channels' membership locks: a member needs a role that can post, and a non-member needs an open channel whose `join_role` is `member`. A guest in either channel cannot join. A join is also refused when the joiner loses membership or the channel's `join_role` changes before the commit. - **One lock order.** Each writer that takes more than one of a channel's locks takes them in this order: membership lock, TTL lock, channel row. Settings changes, roster publication, TTL refresh and huddle joins all use this order. Before this change, a `join_role` change on an ephemeral channel at the same time as a roster publication could deadlock. - **Workflows.** Saving a workflow (kind:30620) skips the generic write check, so it now checks the sender's role itself and needs a writing role before anything is stored. Every path that starts a workflow run, including the manual trigger (kind:46020), needs the workflow's owner to hold a writing role now (and an elevated role for an elevated workflow). So a workflow saved while its owner was a member stops running after the owner is made a guest. - **Channel names are checked before storage.** kind:9002 and kind:9007 refuse a name longer than the `channels.name` column (255 characters) before the event is stored. Before this, the relay accepted the event, then the name update failed, and the rest of the update (for example `join_role`) was silently skipped. This PR only adds this check. Storing the event and applying the update in one transaction is a larger change, and it is not in this PR. - **Changing the setting.** Owners and admins send a channel-metadata update (kind:9002) with `["join_role", "guest"]` or `["join_role", "member"]`. Buzz applies these updates as partial updates; a missing tag means "no change". The relay posts a `join_role_changed` system message in the channel. - **What clients see.** The relay signs a channel description event (kind:39000) that clients read. One shared builder now writes it for both the relay and the `buzz-admin` backfill, which had a copy that had drifted from the relay's. It always publishes an explicit `["join_role", …]` tag, plus the standard NIP-29 group access flags: | `visibility` | `join_role` | `public` / `private` | `restricted` | `closed` | | --- | --- | --- | --- | --- | | open | `member` | public | | | | open | `guest` | public | ✓ | | | private | either | private | ✓ | ✓ | This also fixes an old bug: `closed` ("join requests are ignored") was published for every channel, even open channels that accept joins. - **CLI and SDK.** `buzz channels update --join-role guest|member`. `buzz_sdk::build_update_channel` takes a new `join_role` argument and always writes it as an explicit tag. - **Docs.** `NOSTR.md` has an "Announce channels" section with the role table and the flag table. - **Tests.** Relay end-to-end tests, run in the CI relay E2E step: - `test_nip29_announce_channel_wire`: who can set `join_role`, the kind:39000 flags, joining as a guest, refused guest and non-member posts, reactions and adds, member posts, a member making a guest a member who then posts, changing the setting back without changing roles, and leaving. - `test_nip29_member_raises_guest_agent`: a member who is not the agent's owner can make a guest agent a member. - `test_nip29_announce_channel_wire_workflow_send_needs_write_role`: a guest's workflow message is refused; a member's is accepted. - `test_nip29_departure_wire_archived_channel_allows_self_leave`: guests and members can leave an archived channel. - `test_private_channel_any_member_can_invite` now expects a private-channel guest to be refused when it adds someone (it used to expect success). - `test_nip29_announce_channel_wire_workflow_commands_need_write_role`: saving and triggering a workflow, by a member and by a guest, in announce and normal channels; a guest cannot run a workflow saved while it was a member. - `test_nip29_announce_channel_wire_overlong_name_is_refused_before_storage`: a kind:9002 with an overlong name and `join_role` is refused, and kind:39000 still shows the old settings. - `test_nip29_announce_channel_wire_agent_owner_exception`: an agent's owner, as a guest and as a non-member, can edit and delete the agent's messages, while ordinary guests are still refused. - `test_nip29_guest_is_read_only_in_normal_channel`: a guest in an ordinary channel cannot write, and joining an ordinary channel still gives `member`. Unit tables cover the self-join rule for each `join_role`, and every actor, current role and requested role for the role-change rule. Database tests check the admission rules in `put_member` (`put_member_enforces_announce_admission`), that an add which waits on an uncommitted change to `guest` is decided on the new setting (`put_member_waits_for_a_concurrent_join_role_change`), and that a member can make a guest a member but not an admin. Unit tests cover the name limit and the workflow owner rule (`owner_authority_denies_roles_that_cannot_write`). A relay test covers huddle joins with different roles in the huddle and its parent, and a commit after a demotion, a `join_role` change or a lost membership (`huddle_join_needs_a_write_role_in_the_huddle_and_its_parent`). Lock-order tests: `join_role_change_waits_for_a_roster_publication_on_an_ephemeral_channel` reproduced a real deadlock (`40P01`) against the old order. `f2d_join_lock_order_serializes_membership_and_settings_writers` checks that membership and settings writers wait for a join and then succeed. `removed_creator_cannot_rejoin_a_private_channel` checks that `put_member` refuses a removed creator on a private channel, and `removed_creator_join_racing_a_private_switch_is_refused` holds the membership lock so a removed creator's kind:9021 passes the open-channel check, then makes the channel private before the join continues. Each new test was run against the old code and failed. ## Rollout 1. **Before the deploy, count guest rows** (`SELECT count(*) FROM channel_members WHERE role = 'guest' AND removed_at IS NULL`). We expect none. Any guest that exists loses the ability to post, change the topic and add people when this ships. If some exist, decide first whether to make them members. 2. Deploy. Every channel stays `join_role = member`, so nothing else changes. 3. **Do not set any channel to `join_role = guest` until no old relay is running.** Old relays ignore the column, so they would let guests and non-members write in that channel. 4. Rerun the `buzz-admin` channel reconcile so open channels drop the wrong `closed` flag. ## Migration and rollback - Migration `0067` adds `channels.join_role TEXT NOT NULL DEFAULT 'member'` with a CHECK constraint. Postgres stores a constant default in table metadata, so existing rows are not rewritten and there is no backfill. The relay runs the migration at startup. - Existing channels keep the old `closed` flag until their next change, or until the reconcile in Rollout step 4. - **Before rolling back the relay**, set every announce channel back to `join_role = member`. An older relay ignores the column and does not stop guests from writing, so those channels would accept posts from their guests and give `member` to new joiners again. ## Not in this PR Client UI: a Follow button, hiding the composer for guests, and an admin toggle. Clients that ignore the new tag keep working. A guest who tries to post sees the `restricted` error. ## Stack 1. #8143 (this PR): announce channels 2. #8494: store the bot role only for keys with a recorded agent owner --------- Signed-off-by: Logan Johnson Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> --- .github/workflows/_ci-relay.yml | 4 + NOSTR.md | 65 +- crates/buzz-admin/src/main.rs | 31 +- crates/buzz-cli/src/commands/channels.rs | 32 +- crates/buzz-cli/src/lib.rs | 30 +- crates/buzz-core/src/channel.rs | 326 +++++- crates/buzz-db/src/runtime/migration.rs | 7 +- .../tests/thread_window_postgres_tests.rs | 21 +- crates/buzz-db/src/store/channel.rs | 360 ++++++- crates/buzz-db/src/store/channel_members.rs | 768 +++++++++++--- crates/buzz-db/src/store/dm.rs | 7 +- crates/buzz-relay/src/audio/handler.rs | 731 ++++++++++--- .../buzz-relay/src/handlers/channel_authz.rs | 285 +++++- .../src/handlers/command_executor.rs | 15 + crates/buzz-relay/src/handlers/event.rs | 13 +- .../handlers/group_state_postgres_tests.rs | 120 ++- crates/buzz-relay/src/handlers/ingest.rs | 188 +++- crates/buzz-relay/src/handlers/report.rs | 13 +- .../buzz-relay/src/handlers/side_effects.rs | 249 ++--- crates/buzz-relay/src/nip_fi_test_hooks.rs | 11 +- crates/buzz-relay/src/workflow_sink.rs | 15 + crates/buzz-sdk/src/builders.rs | 59 +- crates/buzz-sdk/src/lib.rs | 2 + crates/buzz-test-client/tests/e2e_relay.rs | 956 +++++++++++++++++- crates/buzz-workflow/src/lib.rs | 28 +- migrations/0067_channel_join_role.sql | 13 + schema/tables/public/channels.sql | 2 + 27 files changed, 3781 insertions(+), 570 deletions(-) create mode 100644 migrations/0067_channel_join_role.sql 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",