mirror of
https://github.com/block/buzz.git
synced 2026-10-27 20:41:57 +00:00
🤖 ## 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>
488 lines
26 KiB
Markdown
488 lines
26 KiB
Markdown
# Using Third-Party Nostr Clients with Buzz
|
||
|
||
Buzz is a Nostr relay that speaks NIP-29 (relay-based groups) natively. Third-party Nostr clients connect directly to `buzz-relay` using NIP-29 and NIP-42 authentication. The old NIP-28 compatibility proxy has been removed.
|
||
|
||
## Community scope
|
||
|
||
Buzz treats the relay URL/domain as authoritative for the community. Today's
|
||
single-relay deployment has exactly one community behind that URL, so existing
|
||
NIP-29 clients keep using the same WebSocket URL, event kinds, tags, and
|
||
HTTP/media/git paths. In a multi-community deployment, each community is reached
|
||
by its own domain or subdomain; the backend resolves the community from the host
|
||
before handling AUTH, EVENT, REQ, REST, media, git, search, or workflow traffic.
|
||
|
||
The Nostr wire format does not grow a tenant tag. Client-supplied `#h` tags still
|
||
name channels/groups and are checked against the host-derived community. Events
|
||
without `#h` — profiles, gift-wrapped DMs, membership notifications, lists,
|
||
status, long-form notes, workflow/system events, and other "global" streams — are
|
||
global only inside the connected community. A pubkey can join multiple
|
||
communities and repost its profile in each one; DMs and profiles do not inherit
|
||
across community domains.
|
||
|
||
---
|
||
|
||
## NIP-29 Direct
|
||
|
||
Connect any NIP-29 client straight to the relay.
|
||
|
||
### Quick Start
|
||
|
||
```bash
|
||
# 1. (Optional) Enable pubkey allowlist — must be set BEFORE relay startup
|
||
export BUZZ_PUBKEY_ALLOWLIST=true
|
||
|
||
# 2. Start the relay (auto-starts Docker services and runs migrations)
|
||
just relay & # relay on :3000
|
||
|
||
# 3. Add a pubkey to the allowlist (if enabled)
|
||
# Insert directly — there is no CLI command for this yet.
|
||
PGPASSWORD=buzz_dev psql -h localhost -U buzz -d buzz -c \
|
||
"INSERT INTO pubkey_allowlist (pubkey) VALUES (decode('<64-char-hex-pubkey>', 'hex'))"
|
||
|
||
# 4. Connect any NIP-29 + NIP-42 client to ws://localhost:3000
|
||
```
|
||
|
||
### What Works
|
||
|
||
| Feature | Status | Notes |
|
||
|---------|:------:|-------|
|
||
| **Group chat (kind:9)** | ✅ | Send/receive messages with `#h <channel-uuid>` tag |
|
||
| **Reactions (kind:7)** | ✅ | Standard NIP-25; channel derived from target event's `#e` tag (client `#h` ignored) |
|
||
| **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. 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`/`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`, `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) |
|
||
| **Presence (kind:20001)** | ✅ | Ephemeral; arbitrary status string (truncated to 128 chars); writes to Redis (`set_presence`/`clear_presence` on `"offline"`), then fan-out to local subscribers. In multi-community mode presence is scoped to the connected community. |
|
||
| **Agent presence (kind:20003)** | ✅ | Ephemeral; an owner's presence for the agents it owns, named by `p` tags (at most 256); `online`/`away` set and `offline` clears each agent's presence as its own kind:20001 would. Not fanned out. |
|
||
| **Typing indicators (kind:20002)** | ✅ | Ephemeral, not stored; published via Redis pub/sub (multi-node capable unlike presence fan-out) |
|
||
| **NIP-42 authentication** | ✅ | Proactive challenge; optional pubkey allowlist |
|
||
| **NIP-11 relay info** | ✅ | `GET /` with `Accept: application/nostr+json` |
|
||
| **Blossom media** | ✅ | `PUT /media/upload` (BUD-02), `GET /media/{sha256}.{ext}` (BUD-01) |
|
||
| **NIP-50 search** | ✅ | One-shot search REQs: `{"search":"query","kinds":[9],"#h":["<uuid>"]}` → relevance-sorted results → EOSE. Not registered as persistent subscriptions. HTTP `POST /query` also accepts `search_sort: "newest"` (advertised as `buzz-search-sort` in NIP-11 `supported_extensions`) for newest-first results, paged with `page`. |
|
||
| **NIP-10 threads** | ✅ | WS-submitted replies with `["e","<root>","","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 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 |
|
||
|
||
### What Doesn't Work
|
||
|
||
| Feature | Status | Why |
|
||
|---------|:------:|-----|
|
||
| **Create invite (kind:9009)** | ⚠️ | Accepted and stored, but side-effect handler is deferred (no-op with warning log) |
|
||
| **Group roles (kind:39003)** | ❌ | Defined in kind registry but not emitted by the relay |
|
||
| **DMs** | ⚠️ | NIP-17 gift wraps supported; NIP-04/NIP-44 not implemented. kind:10050 (DM relay list) deferred. |
|
||
|
||
### Pubkey Allowlist
|
||
|
||
When `BUZZ_PUBKEY_ALLOWLIST=true`, NIP-42 connections that authenticate with only a pubkey
|
||
(no API token) are checked against the `pubkey_allowlist` table. This lets you open the
|
||
relay to specific external Nostr identities without granting full access.
|
||
|
||
- Users with valid **API tokens** bypass the allowlist.
|
||
- **Fail-closed:** if the DB lookup fails, the connection is denied.
|
||
- Default: `false` (all authenticated pubkeys accepted).
|
||
- Auth failure returns generic `auth-required: verification failed` (no allowlist-specific message).
|
||
- Manage the allowlist via direct SQL (no CLI command yet):
|
||
```sql
|
||
INSERT INTO pubkey_allowlist (pubkey) VALUES (decode('<64-char-hex-pubkey>', 'hex'));
|
||
DELETE FROM pubkey_allowlist WHERE pubkey = decode('<64-char-hex-pubkey>', 'hex');
|
||
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.
|
||
[NIP-CL](docs/nips/NIP-CL.md) defines the channel identity tags on these events and how clients
|
||
use them to find channels.
|
||
All discovery events include a `d` tag set to the channel UUID (NIP-29 addressable event convention):
|
||
|
||
| Kind | Tags | Content |
|
||
|------|------|---------|
|
||
| **39000** | `d=<uuid>`, `name`, `t=<channel_type>`, `P=<creator>`, `created_at=<unix seconds>`, `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=<uuid>`, `t=<channel_type>`, `P=<creator>`, `p` tags with role label (`owner`, `admin`) | Admin list |
|
||
| **39002** | `d=<uuid>`, `t=<channel_type>`, `P=<creator>`, `p` tags for all members | Member list |
|
||
|
||
On all three kinds the channel type `t` tag comes first, then one `t` tag
|
||
per channel label, then `P`. Clients read the first `t` value as the
|
||
channel type. `P` is
|
||
the public key that signed the channel's kind:9007. The relay writes it
|
||
from the database, and it does not change when ownership moves. Uppercase
|
||
`P` follows NIP-22, NIP-34 and NIP-72, where it names the author of the root
|
||
object; lowercase `p` already lists members and DM participants on these
|
||
kinds.
|
||
|
||
kind:39000 also carries `["created_at", <unix seconds>]`: the time the
|
||
channel was created. The event's own `created_at` is the time of the last
|
||
edit, so use the tag to find the older of two channels.
|
||
|
||
**Channel labels.** A label groups channels across channel types, for
|
||
example `["t", "workspace"]`. Set labels with `["t", <label>]` tags on
|
||
kind:9007. On kind:9002 the `t` tags replace the whole set, and
|
||
`["t", ""]` with no other `t` tag clears it; a kind:9002 without `t` tags
|
||
leaves the labels as they are. Only an owner or admin may change labels.
|
||
The relay refuses a label set with more than 8 distinct labels, a label
|
||
that is not 1-64 characters from `a-z`, `0-9`, `.`, `:` and `-`, or a
|
||
label equal to a channel type name (`stream`, `forum`, `dm`, `workflow`,
|
||
`system`).
|
||
|
||
**System channels.** A channel with `channel_type` `system` exists only for
|
||
access control, for example to hold config that only an agent and its owner
|
||
can read. It is an ordinary channel in every other way, but queries leave it
|
||
out unless they ask for it:
|
||
|
||
- A filter that does not name it, for example a REQ with no `#h`, a COUNT,
|
||
a NIP-50 search, or kind:39002 `#p:[me]`, does not return it. Such a
|
||
filter returns only the legacy channel types (`stream`, `forum`, `dm`,
|
||
`workflow`), so a channel type added later is also left out.
|
||
- A filter that names it with `#h`, or with `#d` on kinds 39000–39003, reads
|
||
it under the ordinary rules: the reader is a member, or it is open. A
|
||
channel of a type added later is read the same way.
|
||
- A filter on kinds 39000–39003 with `#t:["system"]` or any `#P` also
|
||
returns the system channels the reader can read, then matches as usual.
|
||
It does not return channels of a type added later.
|
||
For example, `{kinds:[39000], #P:[<creator>], #t:[<label>]}` finds one
|
||
creator's labeled channel, system or not. Values in one tag filter
|
||
combine with OR, so `#t:["system", <label>]` does not mean "a system
|
||
channel with this label".
|
||
|
||
Push notifications use the same rule: a lease filter pushes events from a
|
||
system channel only when its `#h` names that channel. Every kind:44100/44101
|
||
membership notification carries its channel type as `t`, and a filter without
|
||
`#t` sees only the legacy types, so system channel notices reach only a filter
|
||
with `#t:["system"]` (see NIP-CL).
|
||
|
||
Events are stored **channel-scoped** so access control applies — private channel member lists are
|
||
only visible to members. Discover groups via historical REQ:
|
||
|
||
```bash
|
||
# All groups you can see
|
||
nak req -k 39000 --auth --sec <privkey> ws://localhost:3000
|
||
|
||
# Specific group's members (filter by d tag)
|
||
nak req -k 39002 --tag "d=<channel-uuid>" --auth --sec <privkey> ws://localhost:3000
|
||
```
|
||
|
||
> **Note:** Channel-scoped storage means live global subscriptions (`{kinds:[39000]}`) won't
|
||
> receive these via fan-out. Clients discover groups via historical REQ queries. Live push for
|
||
> open-channel discovery is a future enhancement.
|
||
|
||
### Membership Notifications
|
||
|
||
The relay emits relay-signed notifications when members are added or removed:
|
||
|
||
| Kind | Meaning | Tags | Scope |
|
||
|------|---------|------|-------|
|
||
| **44100** | Member added | `p` = target pubkey, `h` = channel UUID | Community-global |
|
||
| **44101** | Member removed | `p` = target pubkey, `h` = channel UUID | Community-global |
|
||
|
||
Each notice also has `t` = the channel type (`stream`, `forum`, `dm`,
|
||
`workflow`, `system`). A filter with `#t` gets only the types it names. A
|
||
filter without `#t` gets only `stream`, `forum`, `dm` and `workflow`, and so
|
||
does an untagged notice stored before the tag existed (NIP-CL).
|
||
|
||
Stored community-globally (`channel_id = None` inside the connected community) so agents and clients can subscribe without knowing channel
|
||
UUIDs in advance. Client-submitted kind:44100/44101 events are rejected — only the relay keypair
|
||
may sign these.
|
||
|
||
> **Subscription constraint:** Global REQs that can match p-gated kinds (44100, 44101, 1059) **must**
|
||
> include a `#p` filter where **all** `#p` values match the authenticated pubkey. The relay rejects
|
||
> subscriptions that omit `#p` or include other pubkeys (prevents eavesdropping on others' membership
|
||
> changes and DMs). Error: `restricted: p-gated events require #p matching your pubkey`.
|
||
|
||
```bash
|
||
nak req -k 44100 -k 44101 --tag "p=<your-hex-pubkey>" \
|
||
--auth --sec <privkey> ws://localhost:3000
|
||
```
|
||
|
||
### Sending Messages
|
||
|
||
```bash
|
||
# Send a kind:9 message
|
||
nak event -k 9 -c "Hello from NIP-29!" --tag "h=<channel-uuid>" \
|
||
--auth --sec <privkey> ws://localhost:3000
|
||
|
||
# Subscribe to channel messages
|
||
nak req -k 9 --tag "h=<channel-uuid>" --stream \
|
||
--auth --sec <privkey> ws://localhost:3000
|
||
|
||
# React to a message (#h optional but recommended; channel derived from #e target)
|
||
nak event -k 7 -c "+" --tag "h=<channel-uuid>" --tag "e=<message-event-id>" \
|
||
--auth --sec <privkey> ws://localhost:3000
|
||
|
||
# Subscribe to reactions to channel messages — include #h for live delivery (see note below)
|
||
nak req -k 7 --tag "h=<channel-uuid>" --stream \
|
||
--auth --sec <privkey> ws://localhost:3000
|
||
|
||
# Delete a message (#h optional; #e required; must be self-authored)
|
||
nak event -k 5 -c "reason" --tag "h=<channel-uuid>" --tag "e=<message-event-id>" \
|
||
--auth --sec <privkey> ws://localhost:3000
|
||
|
||
# Create a group
|
||
nak event -k 9007 --tag "name=my-channel" --tag "visibility=open" \
|
||
--auth --sec <privkey> ws://localhost:3000
|
||
|
||
# Search messages (NIP-50)
|
||
nak req -k 9 --tag "h=<channel-uuid>" --search "search query" -l 20 \
|
||
--auth --sec <privkey> ws://localhost:3000
|
||
|
||
# Reply to a message (NIP-10 threading)
|
||
nak event -k 9 -c "Reply text" --tag "h=<channel-uuid>" \
|
||
--tag "e=<parent-event-id>;;reply" \
|
||
--auth --sec <privkey> ws://localhost:3000
|
||
|
||
# Fetch gift-wrapped DMs (NIP-17)
|
||
nak req -k 1059 --tag "p=<your-hex-pubkey>" \
|
||
--auth --sec <privkey> ws://localhost:3000
|
||
```
|
||
|
||
> **Note:** The relay derives a reaction's channel from its `#e` target (client `#h` is
|
||
> ignored for channel determination). Reactions to channel-scoped events are therefore
|
||
> channel-scoped. Live fan-out keeps channel-scoped and global subscriptions strictly
|
||
> separate, which means a kinds-only subscription (`{"kinds":[7]}`) receives none of
|
||
> those reactions — subscribe with `{"kinds":[7],"#h":["<channel-uuid>"]}` instead.
|
||
> `#h` matching works whether or not the signed reaction carries an `h` tag: explicit
|
||
> `h` tags are matched directly, and tagless reactions match via their stored channel.
|
||
|
||
### Tested Clients (Direct)
|
||
|
||
| Client | Platform | Evidence | Notes |
|
||
|--------|----------|:--------:|-------|
|
||
| **BuzzTestClient** | Rust (repo) | Automated E2E | Full NIP-29 flow: discovery (39000/39001/39002), kind:9 send/receive, reactions, deletions, h-tag enforcement |
|
||
| **E2E nostr interop** | Rust (repo) | Automated E2E | NIP-50 search (3 tests), NIP-10 threads (3 tests), NIP-17 gift wraps (3 tests), DM discovery (1 test) |
|
||
| **nak** | CLI | Manual (verified) | kind:9 send/recv, NIP-50 search, NIP-10 thread replies, group discovery |
|
||
|
||
**Not verified in-repo** (anecdotal / expected based on NIP-29 support):
|
||
- **Chachi** (Web/Mobile) — NDK-based; NIP-29 native
|
||
- **0xchat** (Mobile) — NIP-29 native
|
||
|
||
---
|
||
|
||
## Relay Membership (NIP-43)
|
||
|
||
When `BUZZ_REQUIRE_RELAY_MEMBERSHIP=true`, every authenticated connection is checked against the
|
||
`relay_members` table. In today's single-community deployment this is the relay-wide member list; in multi-community mode the same rule is scoped to the host-derived community. Only pubkeys with a row for that community may use that community. The relay owner
|
||
is bootstrapped automatically from `RELAY_OWNER_PUBKEY` on startup.
|
||
|
||
### CLI: Managing Members
|
||
|
||
Use `buzz-admin` — the operator CLI shipped in the relay image — to manage relay membership.
|
||
In a Docker Compose deployment, use `run.sh`:
|
||
|
||
```bash
|
||
# Add a member (accepts bech32 npub or 64-char hex; default role: member)
|
||
./run.sh add-member npub1abc...
|
||
./run.sh add-member <64-char-hex-pubkey>
|
||
./run.sh add-member npub1abc... --role admin
|
||
|
||
# Remove a member
|
||
./run.sh remove-member npub1abc...
|
||
./run.sh remove-member npub1abc... --role member # only removes if role matches
|
||
|
||
# List all members
|
||
./run.sh list-members
|
||
```
|
||
|
||
Or invoke `buzz-admin` directly inside the container:
|
||
|
||
```bash
|
||
docker compose exec relay buzz-admin add-member --pubkey npub1abc...
|
||
docker compose exec relay buzz-admin add-member --pubkey npub1abc... --role admin
|
||
docker compose exec relay buzz-admin remove-member --pubkey npub1abc...
|
||
docker compose exec relay buzz-admin list-members
|
||
# Rewrite the kind:13534 member list from the stored members (repairs a missing or stale list)
|
||
# Repairs only the community RELAY_URL maps to; on a multi-community relay, run it once per community host
|
||
docker compose exec relay buzz-admin republish-members
|
||
```
|
||
|
||
**Exit codes:**
|
||
|
||
| Code | Meaning |
|
||
|------|---------|
|
||
| 0 | Success |
|
||
| 1 | Validation error (bad pubkey, bad role, usage error) |
|
||
| 2 | Not found (remove: member does not exist) |
|
||
| 3 | Cannot remove relay owner (use `RELAY_OWNER_PUBKEY` to change owner) |
|
||
| 4 | Role mismatch (`--role` check failed) |
|
||
| 5 | DB/Redis/internal error |
|
||
|
||
**Required environment variables for member management:**
|
||
|
||
| Variable | Notes |
|
||
|----------|-------|
|
||
| `DATABASE_URL` | Postgres connection string |
|
||
| `REDIS_URL` | Redis connection string |
|
||
| `BUZZ_RELAY_PRIVATE_KEY` | Hex private key — required to sign kind:13534 events |
|
||
|
||
### NIP-43 Admin Events (WebSocket)
|
||
|
||
Relay membership can also be managed over WebSocket using NIP-43 admin events. These require
|
||
the sender to be authenticated (NIP-42) as the relay owner or an admin.
|
||
|
||
| Kind | Action | Required tags |
|
||
|------|--------|---------------|
|
||
| 9030 | Add member | `["p", "<hex-pubkey>"]`, optional `["role", "member\|admin"]` |
|
||
| 9031 | Remove member | `["p", "<hex-pubkey>"]`, optional `["role", "member\|admin"]` |
|
||
| 9032 | Change role | `["p", "<hex-pubkey>"]`, `["role", "member\|admin"]` |
|
||
| 9033 | Set workspace profile (icon) | `["icon", "<https-url or data:image/* URL>"]` (empty clears) |
|
||
|
||
Example using `nak`:
|
||
|
||
```bash
|
||
# Add a member (owner or admin must sign)
|
||
nak event -k 9030 \
|
||
--tag "p=<target-hex-pubkey>" \
|
||
--tag "role=member" \
|
||
--auth --sec <owner-or-admin-privkey> \
|
||
ws://localhost:3000
|
||
|
||
# Remove a member
|
||
nak event -k 9031 \
|
||
--tag "p=<target-hex-pubkey>" \
|
||
--auth --sec <owner-or-admin-privkey> \
|
||
ws://localhost:3000
|
||
|
||
# Change a member's role to admin
|
||
nak event -k 9032 \
|
||
--tag "p=<target-hex-pubkey>" \
|
||
--tag "role=admin" \
|
||
--auth --sec <owner-or-admin-privkey> \
|
||
ws://localhost:3000
|
||
```
|
||
|
||
After each add/remove/role-change, the relay publishes a kind:13534 membership list event
|
||
(relay-signed, NIP-70 protected) that clients can subscribe to:
|
||
|
||
```bash
|
||
# Subscribe to the live membership roster
|
||
nak req -k 13534 --auth --sec <privkey> ws://localhost:3000
|
||
```
|
||
|
||
A kind:9033 command similarly makes the relay store the workspace icon (per
|
||
community) and serve it in the standard NIP-11 `icon` field of its relay
|
||
information document. Clients render it in the workspace rail/switcher; anyone
|
||
can read it (`curl -H 'Accept: application/nostr+json' http://localhost:3000`),
|
||
but only admins/owners can set it. Full spec:
|
||
[docs/nips/NIP-WP.md](docs/nips/NIP-WP.md).
|
||
|
||
### Known Limitations
|
||
|
||
1. **CLI intentionally does not emit kind 8000/8001 deltas** — `publish_nip43_delta` is
|
||
in-process-only (no Redis hop), so a sidecar call stores but never pushes. The 13534 list
|
||
snapshot is the authoritative roster and rides Redis to live clients. Do not wire a delta call
|
||
that passes in-process tests and silently no-ops in the deployed `compose exec` path.
|
||
|
||
2. **The `custom_created_at = max(now, newest_existing_13534 + 1s)` bump defeats same-second
|
||
domination for serial invocations; it does NOT serialize concurrent CLI processes** — two
|
||
near-simultaneous adds can read the same newest timestamp and collide on the bumped second.
|
||
`run.sh` serialization is the guard against parallel adds (e.g. `xargs -P`). When adding
|
||
multiple members in a loop, add `sleep 1` between invocations.
|
||
|
||
---
|
||
|
||
## Relay Environment Variables (NIP-29 relevant)
|
||
|
||
| Variable | Required | Default | Description |
|
||
|----------|:--------:|---------|-------------|
|
||
| `BUZZ_PUBKEY_ALLOWLIST` | ❌ | `false` | Enable pubkey allowlist for NIP-42 pubkey-only auth |
|
||
| `BUZZ_RELAY_PRIVATE_KEY` | ❌ | random | Hex secret key for relay signing (discovery events, system messages) |
|
||
| `BUZZ_REQUIRE_AUTH_TOKEN` | ❌ | `false` | Require authenticated NIP-42 for all connections |
|
||
|
||
---
|
||
|
||
## Security Notes
|
||
|
||
### Direct Path
|
||
- **Pubkey allowlist is fail-closed.** DB errors deny the connection.
|
||
- **API token users bypass the allowlist.** The allowlist only gates pubkey-only NIP-42.
|
||
- **kind:9 requires `#h` tag.** Messages without a channel-scoped `#h` tag are rejected.
|
||
- **kind:7 derives channel from target.** Reactions look up the target event's channel via `#e` — client-supplied `#h` tags are ignored. Reactions to unknown events are rejected (fail-closed).
|
||
- **kind:5 uses `#h` if present, but doesn't require it.** Deletions validate author-match against target events via `#e` tags. Only self-authored events can be deleted (admin deletions use kind:9005).
|
||
- **Client-submitted kind:44100/44101 rejected.** Membership notifications can only be signed by the relay keypair.
|
||
|
||
---
|
||
|
||
## Troubleshooting
|
||
|
||
### Direct Path
|
||
|
||
| Symptom | Cause | Fix |
|
||
|---------|-------|-----|
|
||
| `auth-required: verification failed` | Pubkey not in allowlist (when enabled), or NIP-42 auth failed | Add pubkey to `pubkey_allowlist` table; verify NIP-42 challenge/response |
|
||
| `invalid: channel-scoped events must include an h tag` | kind:9 sent without `#h` tag | Include `--tag "h=<channel-uuid>"` |
|
||
| `invalid: reaction target event not found` | Reaction references unknown event | Ensure the target event exists in the relay |
|
||
| No discovery events | Channel is private + you're not a member | Join the channel first |
|
||
|
||
---
|
||
|
||
## Further Reading
|
||
|
||
- [nostr-protocol/nips](https://github.com/nostr-protocol/nips) — the upstream NIP specifications (NIP-01, NIP-29, NIP-42, and the other NIPs referenced throughout this guide).
|
||
- [`docs/nips/`](docs/nips/) — Buzz's own NIP extension documents.
|
||
- [`ARCHITECTURE.md`](ARCHITECTURE.md) — event kinds, wire protocol, and relay internals.
|