## Problem Three small documentation defects, each verified against the code at06e3d82b: 1. **ARCHITECTURE.md (Event Kinds section)** says `buzz-core` defines "all 81 kinds". The registry has grown: `ALL_KINDS` in `crates/buzz-core/src/kind.rs` now has **127** entries (all unique values). The sentence also says every kind is `pub const KIND_*`, but registry entries such as `RELAY_ADMIN_ADD_MEMBER` do not use that prefix. 2. **NOSTR.md Quick Start** numbers its steps 1, 2, 3, 5 — there is no step 4. PR #797 (2a038515) collapsed the old steps 1-4 (dropping the separate "Start infrastructure" step) into 1-3, but the final "Connect any NIP-29 + NIP-42 client" comment kept its old number 5. 3. **NOSTR.md "Further Reading"** is an empty heading — the section's only content (a link to `crates/buzz-proxy/README.md`) was removed in PR #1321 (14fba21e) along with the proxy crate itself, leaving a dangling header as the last line of the file. ## Fix 1. Reworded the ARCHITECTURE.md sentence to defer to `crates/buzz-core/src/kind.rs` as the source of truth, with the current count (127) as an explicit "at the time of writing" snapshot, so the sentence stays truthful as kinds are added. Also removed the incorrect `KIND_*`-naming claim. 2. Renumbered the final quick-start step 5 → 4. 3. Populated Further Reading with three durable links: the upstream nostr-protocol/nips repo, this repo's `docs/nips/` extension documents, and `ARCHITECTURE.md`. Docs-only; no code changes, no build impact. ## Verification (each claim ~30 seconds) - Kind count: `python3 -c "import re; s=open('crates/buzz-core/src/kind.rs').read(); m=re.search(r'ALL_KINDS: &\[u32\] = &\[(.*?)\];', s, re.S); print(len([e for e in m.group(1).split(',') if e.strip()]))"` → 127. All 127 values are distinct. Non-`KIND_*` entry example: `RELAY_ADMIN_ADD_MEMBER` (kind.rs, in `ALL_KINDS`). - Missing step: `grep -n '^# [0-9]' NOSTR.md` on main shows `# 1.`, `# 2.`, `# 3.`, `# 5.` in the Quick Start block; `git show2a038515-- NOSTR.md` shows the renumbering that orphaned step 5. - Empty section: `tail -1 NOSTR.md` on main is `## Further Reading` with nothing after it; `git log -S'buzz-proxy/README' --oneline -- NOSTR.md` shows the content removal in14fba21e(#1321). ## Links - `crates/buzz-core/src/kind.rs` — `ALL_KINDS` registry (source of truth for the count) - PR #797 /2a038515— introduced the step-numbering gap - PR #1321 /14fba21e— emptied the Further Reading section Signed-off-by: Sean Gearin <sgearin@gmail.com> Co-authored-by: Sean Gearin <sgearin@gmail.com>
18 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 and channel_type |
| 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. |
| Remove user (kind:9001) | ✅ | Self-remove allowed (with last-owner guard). Removing others: owner/admin only. |
| Edit group metadata (kind:9002) | ✅ | name/about tags: owner/admin. topic/purpose tags: any member. |
| Admin delete event (kind:9005) | ✅ | Event author can always delete own. Otherwise owner/admin required. Target must be in same channel. |
| Group deletion (kind:9008) | ✅ | Owner only. |
| Leave group (kind:9022) | ✅ | Any member. Last-owner guard prevents orphaned groups. |
| Group metadata (kind:39000) | ✅ | Relay-signed; always d, name, closed tags; about only if description non-empty; private if applicable; hidden for DM channels |
| Group admins (kind:39001) | ✅ | Relay-signed; d tag + p tags with roles (owner, admin) |
| Group members (kind:39002) | ✅ | Relay-signed; d tag + 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. |
| 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. |
| 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 member, 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;
Group Discovery
The relay emits NIP-29 group state events when channels are created, updated, or membership changes.
All discovery events include a d tag set to the channel UUID (NIP-29 addressable event convention):
| Kind | Tags | Content |
|---|---|---|
| 39000 | d=<uuid>, name, closed (always); about (if description non-empty); private (if applicable); hidden (DM channels only) |
Group metadata. Note: closed is always emitted per NIP-29 convention (Buzz channels require explicit membership), but open channels are still readable/writable by non-members at runtime. The tag reflects the membership model, not access enforcement. |
| 39001 | d=<uuid>, p tags with role label (owner, admin) |
Admin list |
| 39002 | d=<uuid>, p tags for all members |
Member list |
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 |
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
# 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
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
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.