Follow-up to #8575. ## Problem Today a kind:9000 is stored first and the membership write runs afterwards, on its own. If that write fails, the relay logs it and still returns an accepted OK. A resend of the same event then returns `duplicate:`, so the person is never added. This is the catch-log-and-accept failure that Review-Proven Rules #1 and #5 describe. ## Change kind:9000 now uses the same atomic path as kind:9002 (`persisted_atomically` in ingest). - **`buzz-db`:** new `put_members_with_event`, which runs one transaction: 1. Take the channel's membership lock, before the event insert, the TTL lock and the channel row. This matches the order the other membership writers use, so it adds no new lock cycle. 2. Store the event. If it's already stored, roll back and return `Duplicate`. 3. Run the existing `put_members` decisions and upsert. These now live in a shared `put_members_on` step on the transaction. 4. Commit. - **Failure:** nothing is stored and the client gets an `error:` rejection, so it can resend the same event and have it applied. - **Everyone refused:** if the decisions under the lock refuse every person, nothing is stored and the event is rejected with the same text validation gives. Before, a single-person add refused at that point was accepted with an empty message. - **After commit:** the live effects stay as they were. They're best-effort and read the committed state: cache invalidation, `member_joined` notices, member-added notifications and 39002 discovery. - **`NOSTR.md`:** now says a failed kind:9000 can be resent as is. ## What got simpler - kind:9000 no longer has its own store-then-apply path in ingest. It shares the atomic branch with 9002 and workflow deletion. - `apply_put_user`'s "write failed, report it as a per-person rejection inside an accepted OK" branch is gone. - The `Db::put_members` wrapper is deleted. Its only caller was the kind:9000 path. ## Identified debt (not in this PR) kind:9001 (remove), 9021 (join) and 9022 (leave) have the same gap. Each stores the event, then runs the membership write in `handle_side_effects`, and a failure is only logged. kind:9008 and 9005 follow the same store-then-apply pattern. These can move onto the same atomic shape in a follow-up. ## Tests - `failed_put_user_stores_nothing` (relay, Postgres): a test trigger fails the second person's row in a two-person add, after the event row and the first person were already written in the same transaction. It checks: - Both the send and a resend are rejected with `error:`. - No kind:9000 is stored, nobody is added, and no `member_joined` notice is posted. - With the trigger dropped, the same signed event adds both people. - One more resend returns `duplicate:` and posts no second notice. - `put_members_with_event_stores_the_event_with_its_writes` (buzz-db): checks the three outcomes: - Refused: nothing is stored. - Applied: the event is stored and the person is written. - Duplicate: the roster is left alone. - `put_user_refused_under_the_lock_stores_nothing` (relay, Postgres): a test trigger removes the sender from a private channel inside the write transaction, so validation passes and the decision under the lock refuses. For a single add and a batch, it checks the `invalid:` rejection, that nothing is stored and no notice is posted, and that the same event applies once the trigger is gone. - Mutation checks, run in Blox gates: - At `d18e88fd`: committing the refused event fails the buzz-db test; applying a duplicate fails the first two tests; storing the event in its own transaction fails the first two tests. - At `3b106623`: turning the refusal under the lock into `error:` fails the new refusal test, and so does committing the refused event. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Luke Tornquist <tornquist@squareup.com> Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
30 KiB
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
# 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: {…}. The event and its membership changes are stored together, so an error: rejection stores nothing and the same event can be resent; a resent stored event returns duplicate: without results. 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. |
| 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):
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 noroletag. 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_roleor 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 (
rolemember). 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 toguest, and existing guests stay read-only after a change tomember. A stored value the relay does not know is treated asguest.
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#Por#t:["system"]), COUNT, NIP-50 search, artifact queries and the channel list. A filter that names it by#h, or by#don 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). - Joining (kind:9021) and posting work as on any open channel;
join_rolestill 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-29hidden: the metadata stays readable by id. - Relays that support it advertise
buzz-unlisted-channelsin NIP-11supported_extensions. An older relay applies["visibility", "open"]and ignoresunlisted, 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 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). |
| 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#don 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#Palso 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:
# 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
#pfilter where all#pvalues match the authenticated pubkey. The relay rejects subscriptions that omit#por include other pubkeys (prevents eavesdropping on others' membership changes and DMs). Error:restricted: p-gated events require #p matching your pubkey.
nak req -k 44100 -k 44101 --tag "p=<your-hex-pubkey>" \
--auth --sec <privkey> ws://localhost:3000
Sending Messages
# 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
#etarget (client#his 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.#hmatching works whether or not the signed reaction carries anhtag: explicithtags 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:
# 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:
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:
# 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:
# 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.
Known Limitations
-
CLI intentionally does not emit kind 8000/8001 deltas —
publish_nip43_deltais 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 deployedcompose execpath. -
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.shserialization is the guard against parallel adds (e.g.xargs -P). When adding multiple members in a loop, addsleep 1between 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
#htag. Messages without a channel-scoped#htag are rejected. - kind:7 derives channel from target. Reactions look up the target event's channel via
#e— client-supplied#htags are ignored. Reactions to unknown events are rejected (fail-closed). - kind:5 uses
#hif present, but doesn't require it. Deletions validate author-match against target events via#etags. 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 — the upstream NIP specifications (NIP-01, NIP-29, NIP-42, and the other NIPs referenced throughout this guide).
docs/nips/— Buzz's own NIP extension documents.ARCHITECTURE.md— event kinds, wire protocol, and relay internals.