mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
## Summary The relay-signed NIP-43 member list (kind:13534) was published after each membership change had already committed. If that publish failed, or if a change never republished at all (startup `bootstrap_owner`), the list stayed wrong until the background reconcile sweep found and fixed it. #8467 moved that sweep off the startup path. This PR removes the reason the sweep exists. Now every `buzz-db` function that changes `relay_members` writes the new list **in the same transaction**, as its last statement before commit: - It takes the per-community list lock last, reads the members, signs the list, retires the old one and inserts the new one. - If the list write fails, the membership change rolls back and the caller gets an error. - A call that changes nothing (already a member, role mismatch, host exists, limit reached) writes no list. The functions return `MembershipWrite<T> { outcome, snapshot }`. Callers push `snapshot` to live subscribers after commit. That push is the only thing left outside the transaction; if it fails, live clients see the change on their next fetch, and the stored list is already correct. Write paths covered (RPR #2, every write path): `add_relay_member`, `claim_relay_membership`, `claim_relay_invite`, `remove_relay_member`, `remove_relay_member_if_role`, `update_relay_member_role`, `bootstrap_owner` / `provision_owner`, `transfer_ownership`, `backfill_from_allowlist`, and the owner insert in `create_community_with_owner[_and_nip_fi_issuers]`. Community deletion removes the members together with the community, so it writes no list. The relay keypair is passed in explicitly instead of being stored on `Db`, so the compiler finds every caller. ### Behavior changes - **Open relays get an accurate list too.** Provisioning and ownership transfer used to publish only when `require_relay_membership` was on. The write is now unconditional. - **Same-second changes can't reuse an id.** The list's `created_at` is one second past the newest earlier list for that community, including retired ones, or the current time if that is later. Before, adding and then removing a member in the same second could produce an event with the same id as an earlier list. The insert then failed, and the stale list stayed live. A burst of changes can push `created_at` ahead of the clock by up to one second per change; it returns to the clock once the burst ends. - **`buzz-admin add-member` / `remove-member`** no longer build their own list (`publish_membership_list_with_bump` is deleted). They push the list the database wrote. Concurrent CLI runs now serialize on the database lock rather than depending on `run.sh` running one at a time. - **Owner mutations lock the community row `FOR NO KEY UPDATE`.** `transfer_ownership` and `bootstrap_owner` used `FOR UPDATE`. The list insert takes `FOR KEY SHARE` on that row through `events.community_id`'s foreign key, so a repair holding the list lock, or a removal or role update holding a member row, deadlocked with a concurrent owner change. `FOR NO KEY UPDATE` still conflicts with archive, unarchive and deletion admission, so the lifecycle ordering is unchanged. - **Startup delivers its list to other replicas.** When backfill or `bootstrap_owner` changes members at startup, the relay fans the newest list out through pub/sub once its state is ready, so clients connected to other replicas during a rolling deploy see an owner rotation without a refetch. - **New `buzz-admin republish-members`.** Rewrites the community's list from the stored members in one community-write transaction and pushes it live. This replaces the sweep as the repair for a list that is missing or stale: a `relay_members` change made directly in SQL, a restore, or a deployment that turns on `BUZZ_REQUIRE_RELAY_MEMBERSHIP` after running without it. ## Rollout Nothing rewrites a list automatically once the sweep is gone. A community whose list drifted before this deploys stays wrong until its next membership change or until an operator runs `buzz-admin republish-members`. Either deploy #8467 and let its sweep complete one pass first, or run `republish-members` for each community after this deploys. The command repairs only the community that `RELAY_URL` maps to, so on a relay hosting many communities, run it once per community host. ## What got simpler Deleted: - `reconcile_nip43_membership_snapshots`, `spawn_nip43_membership_reconciler`, its leader lock key and #8467's standby retry - the three `nip43_membership_snapshot_needs_reconciliation*` drift reads (one already deprecated) - `publish_nip43_membership_list` / `publish_nip43_membership_locked`, the separate after-commit republish at every call site, and `buzz-admin`'s duplicate publisher - the reconcile metrics `buzz_nip43_membership_reconciliations_total` and `buzz_nip43_membership_reconciliation_failures_total` - the after-commit publish metrics `buzz_nip43_membership_publications_total` and `buzz_nip43_membership_publication_seconds`, and the `publish_nip43_membership_locked` transaction metric label. A failed list write is now a failed membership write and shows up in the caller's existing error handling and the transaction metrics of its write path. - the `BUZZ_NIP43_RECONCILE_INTERVAL_SECS` setting There is now one way the list gets written, and it can't disagree with `relay_members` after commit. **Unused deploy config (outside this repo):** hosted deployments that set `BUZZ_NIP43_RECONCILE_INTERVAL_SECS` can drop it. It is now ignored. Dashboards or alerts on the four deleted metrics will stop receiving data. ## Testing New Postgres tests (`buzz-db`): - `relay_members::every_membership_write_commits_its_list`: for each write path, the returned list is the single live kind:13534 and names exactly the committed members. No-op calls return no list and leave the live list alone. - `relay_members::a_failed_list_write_rolls_back_the_membership_change`: holds the list lock, waits for the join to block on it, cancels that statement, and checks that the member was not added and the previous list is still live. - `relay_members::concurrent_joins_all_land_in_the_list`: 8 concurrent joins; the final list names all 8. - `relay_members::same_second_return_to_an_earlier_membership_writes_a_new_list`: add, remove, add, remove within a second; every list is newer than the last and no id repeats. - `relay_members::owner_transfer_and_list_repair_both_commit` and `relay_members::owner_transfer_and_member_removal_both_commit`: hold a repair (list lock) or removal (member row) open, wait until a transfer blocks behind it, then finish it. Both transactions commit; with `FOR UPDATE` back in place, one of them fails. - `community::create_community_with_owner_is_atomic_and_create_only` and `relay_invite::bounded_claim_exhausts_and_existing_member_retry_does_not_consume` now also check the list. Each new test was checked to fail against a mutation of the new code that brings back the old behavior (details in the thread). Stacked on #8467. Merge order: #8467, then #8468, then this. --------- Signed-off-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
432 lines
23 KiB
Markdown
432 lines
23 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` 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. 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 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):
|
||
```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;
|
||
```
|
||
|
||
### 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`, `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`).
|
||
|
||
**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 skip system channels. 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.
|