Files
buzz/crates/buzz-test-client/tests
Logan Johnson d97f6b4c6e feat(relay): add announce channels with a join_role setting (#8143)
🤖
## 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 <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
2026-10-21 16:01:11 -04:00
..