🤖 ## Summary Buzz uses channel membership to decide who can read what, and every channel a person belongs to shows up in their client as a chat. So there is no way to make a group that exists only for access control, for example "only this agent and its owner can read the agent's config". Such a channel would appear in both people's sidebars, search results and unread counts. This stack adds a **system channel**: an ordinary channel that queries leave out unless they ask for it. This first PR adds the type itself. - `kind:9007` (create channel) with `["channel_type", "system"]` creates a system channel. - It is private unless the creator sends `["visibility", "open"]`. - While it is private, its channel metadata event (kind:39000) carries the NIP-29 `hidden` marker, which means "hide this group's metadata from non-members". Until now the relay set `hidden` only for DMs. On its own this PR does not yet hide system channels from queries. Part 2 does that. Part 1 of 3, stacked on #8176 (the channel labels and creator lookup stack, which this feature uses). ### Related issue None found. ### Details - Migration 0062 adds `system` to the `channel_type` Postgres enum. No new columns. - `buzz_core::channel::ChannelType::System`, and `system` is now a reserved label name (labels cannot equal a channel type name). - `buzz-admin` reconcile writes `hidden` with the same rule as the relay. Usage metrics count system channels. - **Rollback.** A relay older than this change cannot read `channel_type = 'system'`. Do not roll back past it while system channels exist. - **Spec.** NIP-CL gets a "System channels" section. `ARCHITECTURE.md` lists the `System` type. ### Testing - Relay test: a system channel is private and `hidden` by default; opening it removes `hidden`; its first `t` tag is `system`. - Migration embed and usage-metrics tests. ### Stack These are two stacks. Merge them in this order. Client support (creating, labeling and finding these channels) will be in block/buzz-app, not the buzz SDK and CLI. **Stack 1: channel labels and creator lookup (#8174–#8176)** 1. #8174 channel type and creator tags, and the channel creation time 2. #8175 channel labels 3. #8176 database-side label and creator match, and the rule for choosing one channel **Stack 2: system channels (#8180–#8182)**, stacked on stack 1 because it uses labels and the creator tag 4. #8180 the system channel type ← this PR 5. #8181 leave system channels out of reads that do not name them 6. #8182 channel-state filters opt in to system channels --------- Signed-off-by: Logan Johnson <loganj@squareup.com> Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
21 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 and label t tags |
| Add user (kind:9000) | ✅ | Open: any user, subject to target's channel_add_policy (owner_only/nobody can block). Private: owner/admin only. Self-add bypasses agent policy but not private-channel auth. |
| 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 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, ["t", <channel_type>], ["P", <creator>] and ["created_at", <unix seconds>] tags; about only if description non-empty; private if applicable; hidden for DM channels and private system channels |
| Group admins (kind:39001) | ✅ | Relay-signed; d, channel type t and creator P tags + p tags with roles (owner, admin) |
| Group members (kind:39002) | ✅ | Relay-signed; d, channel type t and creator P tags + p tags for all members |
| Membership notifications | ✅ | kind:44100 (added) / kind:44101 (removed); relay-signed, community-global scope (channel_id=None inside the connected community) |
| Presence (kind:20001) | ✅ | Ephemeral; arbitrary status string (truncated to 128 chars); writes to Redis (set_presence/clear_presence on "offline"), then fan-out to local subscribers. In multi-community mode presence is scoped to the connected community. |
| Agent presence (kind:20003) | ✅ | Ephemeral; an owner's presence for the agents it owns, named by p tags (at most 256); online/away set and offline clears each agent's presence as its own kind:20001 would. Not fanned out. |
| Typing indicators (kind:20002) | ✅ | Ephemeral, not stored; published via Redis pub/sub (multi-node capable unlike presence fan-out) |
| NIP-42 authentication | ✅ | Proactive challenge; optional pubkey allowlist |
| NIP-11 relay info | ✅ | GET / with Accept: application/nostr+json |
| Blossom media | ✅ | PUT /media/upload (BUD-02), GET /media/{sha256}.{ext} (BUD-01) |
| NIP-50 search | ✅ | One-shot search REQs: {"search":"query","kinds":[9],"#h":["<uuid>"]} → relevance-sorted results → EOSE. Not registered as persistent subscriptions. |
| 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.
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, closed, t=<channel_type>, P=<creator>, created_at=<unix seconds> (always); about (if description non-empty); private (if applicable); hidden (DM channels and private system channels) |
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>, 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).
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
# 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
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.