# 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 ` 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":[""]}` → 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","","","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=`, `name`, `t=`, `P=`, `created_at=`, `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=`, `t=`, `P=`, `p` tags with role label (`owner`, `admin`) | Admin list | | **39002** | `d=`, `t=`, `P=`, `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", ]`: 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",