mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
🤖 ## Summary Sharing a Solo chat with a large channel today means adding every person as a member, one at a time, and the app blocks the share past 100 people. The plan is to share it the way Google Docs does with "anyone at the company with the link". Buzz's `open` visibility already gives that kind of access: anyone in the community can read and join an open channel. The problem is that an open channel also shows up in everyone's channel list and search, and a private chat shouldn't appear there just because its link was posted somewhere. This PR adds a channel setting, `unlisted`. An **unlisted open channel** can be read and joined by anyone who names it by id (from a shared link). For non-members, it is left out of every read that does not name it. Members see it as before. This is the relay side only; the buzz-app change comes later. #8575 (batch adds) remains the path for small audiences. Unlisted is not an access control. Anyone who has the id can read the channel, and that is intended. ## Solo flows this supports Each user action is one event. The buzz-app side comes later; the relay supports all of these now. | Flow | Event | Result | | --- | --- | --- | | New Solo chat | kind:9007 `visibility private` + `["unlisted", "true"]` | The chat starts unlisted, so it stays unlisted when any client later opens it. | | Share | One kind:9002: `visibility open` + `unlisted true` + `join_role guest\|member`. Then post the link. | Anyone with the link can read the chat by id, both history and live. Nobody is added as a member, so the chat isn't in anyone's channel list. A non-member can't post; `join_role` decides whether someone who joins can. | | View | A REQ that names the chat by `#h`, or by `#d` for its metadata | A non-member can read it. It is never listed for them. | | Reply | kind:9021 join (the app sends it before a viewer's first reply) | Joining makes them a member with the role from `join_role`, so with `member` they can post. A kind:9022 leave makes them a viewer again; the chat stays readable by id. | | Unshare | kind:9002 `visibility private` | The existing open→private eviction closes non-members' live subscriptions. New reads by non-members are refused. | | Convert to a channel | One kind:9002: `["t", ""]` + `unlisted false`, plus `visibility` | Clears the Solo label. Non-members can now find it in discovery. | **Sidebar pins are not relay state.** Pin and unpin live in the app's existing kind:30078 sidebar preferences, which sync across devices and don't touch membership. Don't map them to join/leave: someone a chat was shared with by member add is already a member, so a leave would remove their access. Your own Solo chats keep showing up, because you're their owner member, and members see unlisted channels everywhere. Existing Solo chats aren't backfilled. They get the flag when they're shared, because the share event sets it. **Open question, not built here:** someone who only views a shared chat isn't a member. A mention of them doesn't reach their inbox (`#p:[me]` without `#h`) and sends them no push. ## Behavior | Reader | Reads that don't name the channel (kind:39000 without `#d`, `#P` creator queries, messages, COUNT, NIP-50 search, artifact queries, channel list) | Reads that name it (`#h`, or `#d` on 39000–39003) | kind:9021 join | | --- | --- | --- | --- | | Member | Listed | Readable | n/a | | Non-member, unlisted open channel | **Not listed** | Readable | Works; `join_role` still decides the role | | Non-member, private channel (unlisted or not) | Not listed | Not readable | Refused | - **Setting it:** kind:9002 `["unlisted", "true"]` / `["unlisted", "false"]`. Owner or admin only, like `visibility`. A missing tag means no change. It can be sent in the same event as `["visibility", "open"]`, so a private chat becomes open and unlisted in one step. Any other value is refused before the event is stored. The relay posts an `unlisted_changed` system message and clears the accessible-channels cache across nodes. Nobody loses access when a channel is unlisted, so no subscriptions are evicted. - **At creation:** kind:9007 also takes `["unlisted", "true"|"false"]`. A missing tag means listed. Any other value is refused before the event is stored, as on kind:9002. It is stored with the channel row in the create's transaction, on both the client-UUID path and the relay-chosen-UUID path. - **kind:39000** carries a bare `["unlisted"]` flag while the setting is on. It does not add NIP-29 `hidden`, so the metadata stays readable by id. - **NIP-11** advertises `buzz-unlisted-channels` in `supported_extensions`. A relay without this PR would apply `["visibility", "open"]` and ignore `unlisted`, which would list the chat publicly. So buzz-app must check for the extension before it sends that pair. - **CLI:** `buzz channels update --channel <id> --unlisted true|false` and `buzz channels create ... --unlisted`. `buzz_sdk::build_update_channel` takes a new `unlisted: Option<bool>` argument, and `buzz_sdk::build_create_channel` takes `unlisted: bool`, which emits the tag only when true. ### Open question: does `unlisted` only mean something when the channel is open? **Decision: store it on its own; it only has an effect while the channel is open.** Private channels are already invisible to non-members, so on a private channel the flag does nothing. It is still stored and published, for three reasons: - a client can mark a Solo chat unlisted before opening it, or in the same event that opens it, so the chat is never listed even for a moment; - switching private → open → private → open keeps the setting; - there is no cross-field rule in kind:9002 to validate or keep in sync. kind:39000 publishes the flag as stored, whatever the visibility, so clients can show the setting. ## How The relay already has a "readable only when named" path for system channels (NIP-CL). Unlisted open channels reuse it: - **`get_accessible_channel_ids`**: the open half (the implicit scope of a REQ that doesn't name a channel) adds `AND NOT unlisted`. The member half is unchanged, so members still have the channel. - **`get_readable_named_only_channels`**: also returns unlisted open channels of the legacy types. Every reader of that set (REQ, COUNT, NIP-50 search, HTTP `/query` and `/count`, thread windows, workflow run reads) adds a named-only channel only to a filter that names it, through `filter_channel_scope`. An unlisted channel's type is `stream`/`forum`, so the `#t:["system"]`/`#P` system opt-in never adds it. - **`get_accessible_channels`** (the channel list): open channels are listed to non-members only when not unlisted. - **`query_artifacts`**: an artifact in an unlisted open channel reaches a non-member only when the query's `#h` names that channel. - **Live fan-out:** no change was needed. A global subscription never receives channel-scoped events, kind:39000 included (`fan_out_scoped`). A channel-scoped subscription has to name the channel, and that goes through the same named-only authorization. **Left alone on purpose:** access checks that read a named channel or event keep treating an unlisted channel as open. These are `handle_join_request` (kind:9021), the ingest write gate, operator-listener mention delivery (`load_deliverable_event`), personal read-state writes, and push matching. Push matching still requires membership, so non-members get no pushes from an unlisted channel. **Named reads checked:** these callers use the implicit channel set (`get_accessible_channel_ids`, its cache, `get_accessible_channels`). Every one that reads a channel the request names also adds the named-only set, so a non-member can still read an unlisted open chat by id. That covers WS REQ (history, live registration, NIP-50 search), WS COUNT, HTTP `/query` and `/count`, thread windows and workflow run reads. Other named reads don't use the implicit set: they check visibility or membership, so an unlisted open chat counts as open. Those are live fan-out, the ingest write gate, typing, kind:9021 join, huddle audio join, operator-listener delivery, the workflow sink and read-state writes. Feed queries (mentions, needs-action, activity) and the `/buzz/v1/me/sidebar` accessory API are lists, so leaving out chats you only view is intended. Media reads don't check channels. `buzz-auth`'s `ChannelAccessChecker` has no production implementation. ## Database (docs/database.md checklist) - **Phase:** expand only. The migration adds a column with a constant default, and the same release ships the code that reads and writes it. No backfill or contract step follows. - No trigger, function, procedure, rule or foreign key is added. - No migration changes existing rows. `ADD COLUMN ... BOOLEAN NOT NULL DEFAULT FALSE` stores the constant default in the catalog, so the table is not rewritten. - Every location in Rule 4 is updated: `migrations/0069_channel_unlisted.sql`, `schema/tables/public/channels.sql`, and the migration count. No seeds, types or reconcile changes are needed. `admin_schema_parity_between_desired_state_and_migrations` now compares `channels` columns between the pgschema desired state and the migrated database. I failed it once by removing the column from `channels.sql`. Only columns are compared for `channels`: on current `main`, pgschema already re-emits `idx_channels_id_live` without its `INCLUDE (community_id)`, an index drift that predates this PR and is noted in the test. - **Currently deployed relay against the new schema:** it never selects or writes `unlisted`, and every insert gets the default. All channel-record queries name their columns. Nobody can set the flag until this relay ships, because the old relay refuses a kind:9002 whose only tag is `unlisted`. - The migration starts with `SET LOCAL lock_timeout = '5s'`. No index is added: the new predicates ride the existing `(community_id, visibility)` index. ## Rollout 1. Deploy. Every channel stays listed, so nothing changes. 2. **Don't unlist any channel while an old relay is still running.** An old relay ignores the column and lists the channel to everyone. The NIP-11 extension lets clients gate on this. Unlisted isn't an access boundary, so a mixed fleet only exposes the channel in listings for a while. 3. **Rollback:** an older relay ignores the column, so unlisted channels show up as ordinary open channels again. Nothing breaks. ## Tests Postgres (`buzz-db`, discovered by the Postgres lane): - `unlisted_open_channel_is_named_only_for_non_members`: sets the flag through `update_channel`. Then checks that a non-member doesn't get it from `get_accessible_channel_ids` or `get_accessible_channels`, and does get it from `get_readable_named_only_channels`; that a member still lists it; that `put_member` (the kind:9021 path) lets the non-member join, after which they list it; and that listing it again shows it to a new reader. - `unlisted_private_channel_stays_private_and_opens_unlisted`: a private unlisted channel is not readable by name, and opening it keeps the flag. - `group_metadata_publishes_the_stored_unlisted_flag`, plus the unit test `publishes_the_unlisted_flag_as_stored`. - `unlisted_channel_artifacts_are_read_by_name_for_non_members`. - `channel_created_unlisted_is_never_listed`: creating through `create_channel_with_event` (the kind:9007 path) and `create_labeled_channel` stores the flag, and the channel is never listed to a non-member. - Mutation-checked: each of `NOT unlisted` in the implicit scope, `NOT c.unlisted` in the channel list, the unlisted branch of the named-only query, its `visibility = 'open'` guard, and the artifact condition was removed one at a time, and each removal failed at least one of these tests. Relay E2E (`test_nip29_unlisted_channel_wire`, added to the CI relay E2E step), against a local relay built from this branch: - A non-member or an unknown value is refused. - kind:39000 by `#d` carries `unlisted` and `public`, and not `hidden`. - kind:39000 discovery without `#d`: non-members don't see the channel, members do. - History and NIP-50 search: unnamed, a non-member doesn't see the channel's messages; with `#h`, they do; a member sees them unnamed. - A kind:9021 join works, and the joiner then lists the channel. - Listing it again shows it to everyone. - A reader whose channel set was cached before each change sees the change at once. I ran this against a relay with the cache invalidation removed, and it failed. Relay E2E (`test_nip29_solo_share_and_convert_wire`, added to the CI relay E2E step), each flow as a single event: - kind:9007 with an unknown `unlisted` value is refused. A chat created with `unlisted true` publishes the flag, and stays unlisted after a plain `visibility open`. - Share (open + unlisted + `join_role guest` in one kind:9002): a non-member doesn't list the chat. They read it by `#h`, both history and a live message, and can't post. A kind:9021 join lists it for them as a read-only guest. A kind:9022 leave unlists it again, and it stays readable by `#h`. - Unshare (`visibility private`): the viewer's live subscription gets CLOSED, and a new `#h` read is refused. - Convert (`["t", ""]` + `unlisted false`): the Solo label is cleared, and a non-member finds the chat in discovery. - I ran this against a relay that dropped the kind:9007 flag, and it failed. Also run: `cargo test` for buzz-db, buzz-sdk, buzz-cli, buzz-core, buzz-relay and buzz-test-client; the Postgres lane for buzz-db and buzz-relay (1045 tests); and the existing unlisted, announce, departure, guest, role-change and NIP-43 snapshot wire tests. In the lane, two tests failed on pool timeouts (`resend_validated_after_the_first_commit_is_a_duplicate`, `edit_replaces_and_clears_labels`) and passed when rerun alone. An earlier run had the same kind of flake: `routed_fallback_spends_one_acquire_budget_when_aurora_cache_is_cold` (a timing budget) and `resent_edit_is_a_duplicate` (a pool timeout). In a full local `e2e_relay` run, three tests outside CI's list also failed (`test_invite_*` twice, `test_unarchive_emits_member_added_notification`). They fail the same way on a relay built from `main`. ## Not in this PR - The buzz-app follow-up: support open Solo chats, the join-from-card view, the large-audience prompt, and gating on `buzz-unlisted-channels`. - A NIP-CL update. The behavior is documented in NOSTR.md ("Unlisted channels"). buzz-review-completed --------- Signed-off-by: Bradley Axen <baxen@squareup.com> Signed-off-by: Luke Tornquist <tornquist@squareup.com> Co-authored-by: ART <ea39c1c8acf57180161ef4ba4ed68abf8589f5291956b909a5aa991fc66664ca@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: mbot <3754f8729004d95654c46dbab3129e4ab9ef05cc2534e2a3fbfc155983bd637b@buzz.block.builderlab.xyz> Co-authored-by: Luke Tornquist <tornquist@squareup.com> Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
525 lines
30 KiB
Markdown
525 lines
30 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`, label `t` and `unlisted` 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. One event may name up to 100 people: one `p` tag each, with an optional role as the `p` tag's third element (NIP-29), falling back to the event's `role` tag. Each person is checked and added on their own. When the event names several people, the OK message is `members: ` followed by JSON listing the non-empty `added`, `updated` (role changed), `already_member` and `rejected` (`[{"reason", "pubkeys"}]`) results. The event is refused only when it is malformed or nobody it names may be added; then one person keeps the single-add error, and several get `invalid: members: {…}`. A resent event returns `duplicate:` without results; to retry an `error:` rejection, send a new event. Re-adding someone at their current role posts no second `member_joined` notice. |
|
||
| **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`/`unlisted` 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; `unlisted` when the channel is unlisted; `about` only if description non-empty; `hidden` for DM channels and private system channels |
|
||
| **Unlisted channels** | ✅ | kind:9002 `["unlisted", "true"\|"false"]`, owner/admin, or at creation on kind:9007. An unlisted open channel is left out of every read that does not name it, for non-members: channel lists, kind:39000 without `#d`, messages, COUNT, NIP-50 search and artifact queries. Naming it by `#h` (or `#d` on 39000–39003) reads it, and kind:9021 joins it, as for any open channel. See [Unlisted channels](#unlisted-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`, `unlisted` | 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 | ✓ | | ✓ | ✓ |
|
||
|
||
### Unlisted channels
|
||
|
||
An **unlisted** open channel works like a link-shared document: anyone in
|
||
the community who has its id (for example from a shared link) can read it
|
||
and join it, but it does not show up for people who are not members.
|
||
|
||
- Owners and admins set it with kind:9002 `["unlisted", "true"]` and clear
|
||
it with `["unlisted", "false"]`. A missing tag means "no change". It can be
|
||
sent in the same event as `["visibility", "open"]`, so a private channel
|
||
becomes open and unlisted in one step.
|
||
- kind:9007 also takes `["unlisted", "true"|"false"]`, so a channel can
|
||
start unlisted; a missing tag means listed. Any other value is refused
|
||
before the event is stored, as on kind:9002.
|
||
- For a non-member, an unlisted open channel, of any type, is left out of
|
||
every read that does not name it: a filter without `#h` (including
|
||
kind:39000 discovery without `#d`, and the system-channel opt-in by `#P`
|
||
or `#t:["system"]`), COUNT, NIP-50 search, artifact queries and the
|
||
channel list. A filter that names it by `#h`, or by `#d`
|
||
on a filter whose kinds are all 39000–39003, reads it under the ordinary
|
||
open-channel rules. This is the same "named-only" rule that applies to
|
||
system channels ([NIP-CL](docs/nips/NIP-CL.md)).
|
||
- Joining (kind:9021) and posting work as on any open channel; `join_role`
|
||
still decides what a joiner gets.
|
||
- Members see it everywhere, as before.
|
||
- The flag is stored on its own and matters only while the channel is open.
|
||
Private channels are already invisible to non-members. A private channel
|
||
can be marked unlisted ahead of time, so opening it later never lists it.
|
||
- kind:39000 carries a bare `["unlisted"]` flag whenever the setting is on,
|
||
whatever the visibility. It does not add NIP-29 `hidden`: the metadata
|
||
stays readable by id.
|
||
- Relays that support it advertise `buzz-unlisted-channels` in NIP-11
|
||
`supported_extensions`. An older relay applies `["visibility", "open"]`
|
||
and ignores `unlisted`, so a client must check for it before opening a
|
||
channel as unlisted.
|
||
- Unlisted is not an access control. Anyone with the id can read the channel.
|
||
|
||
### 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); `unlisted` (unlisted channels); `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.
|