NIP-29 member/admin lists silently omit the relay's own pubkey, making channels permanently unjoinable
- Dominant language
- Rust
- Stars
- 32.7k
- Forks
- 4.3k
- Avg merge
- 1d 13h
- Merged PRs (30d)
- 253
Description
## Summary
If `BUZZ_RELAY_PRIVATE_KEY` is set to the key of an identity that is *also* a community member, that member is silently excluded from every kind:39002 (members) and kind:39001 (admins) event the relay emits — even though the row exists and is correct in `channel_members`.
Clients that derive "am I in this channel?" from kind:39002 therefore render a **Join** button for a user who is already a member (in our case the community owner). Clicking Join can never succeed (second bug, below), so the channel is permanently unjoinable from the UI with no error shown.
## Environment
- buzz relay 0.2.0, `ghcr.io/block/buzz:main`, revision `5e0efb0bb95182f588390b55cc5affa09114c87e`
- Postgres 17, self-hosted `deploy/compose`
- `BUZZ_REQUIRE_RELAY_MEMBERSHIP=true`, NIP-42 auth required
## Impact
Community owner could not join or appear in any channel. Every kind:39001 admin list was **empty** despite an active `owner` row existing for each channel.
## Reproduction
1. Set `BUZZ_RELAY_PRIVATE_KEY` to the private key of identity **X**.
2. Create a community where **X** is also `owner` in `channel_members`.
3. Trigger `emit_group_discovery_events` (add a member, or delete the kind:39000 and start with `BUZZ_RECONCILE_CHANNELS=true`).
4. Inspect the emitted kind:39002 / kind:39001.
**Expected:** X present with role `owner`.
**Actual:** X absent from 39002; 39001 empty.
## Evidence that the exclusion is identity-specific
I inserted a throwaway member into `channel_members` with role `owner` and a `joined_at` *earlier* than X's row, then regenerated:
```
39001 → [["d",…],["p","deadbeef…deadbeef","owner"]]
39002 → [["d",…],["p","deadbeef…deadbeef","","owner"],
["p","","","member"],
["p","","","bot"]]
```
The throwaway `owner` appears, and appears *first*. X still does not. This rules out:
- **an owner-role filter** — the throwaway is `owner` and appears; kind:39002 has no role filter at all, yet X is missing from it too
- **an off-by-one / skip-first-row bug** — the throwaway sorts first by `joined_at` and appears
The only distinguishing property of X is that it is the relay's signing key.
Also ruled out: the DB itself (running the exact `get_members` query from `buzz-db/src/channel.rs:689` returns X with role `owner`), a stale membership cache, row-level security, a duplicate schema/database, a dirty working tree, and an image/source mismatch (the image's `org.opencontainers.image.created` is ~90s after the commit it claims).
**I could not locate the code performing the exclusion.** `emit_group_discovery_events` (`buzz-relay/src/handlers/side_effects.rs:1045`) contains no such filter — `relay_pubkey_hex` (line 1053) is only passed through as the signing author, and the kind:39002 loop (line 1141) iterates all members unconditionally. `replace_addressable_event` persists `serde_json::to_value(&event.tags)` verbatim. So the behaviour is empirically reproducible but the mechanism is unidentified; a pointer from someone who knows the code would be very welcome.
## Resolution on our side
Generating a dedicated relay keypair distinct from any human identity, then regenerating group state, fixed it completely — X now appears as `owner` in every list.
## Suggested fixes
1. **Validate at startup:** fail, or at minimum warn loudly, when `relay_keypair.public_key() == relay_owner_pubkey`. Today this can't happen structurally: `bootstrap_owner` runs at `main.rs:321`, roughly 100 lines before the keypair is parsed at `main.rs:419`. The misconfiguration is easy to make and fails silently and confusingly.
2. Document that `BUZZ_RELAY_PRIVATE_KEY` must never be a human's key.
---
# Second, compounding bug: kind:9021 join on an existing member is a silent no-op
`handle_join_request` (`side_effects.rs:1930`):
```rust
if state.is_member_cached(tenant.community(), channel_id, &actor_bytes).await? {
info!(channel = %channel_id, "kind:9021 join — already a member, skipping");
return Ok(());
}
```
This returns before `emit_group_discovery_events` (line 1988). The event was already stored, so the client still receives `["OK", , true, ""]`. Side-effect errors are only logged, never surfaced (`ingest.rs:2829`).
Consequence: whenever a member list is stale or wrong for *any* reason, Join becomes a **dead button** — no state change, no re-emit, no error, indefinitely. Our owner clicked it 7 times; all 7 requests are in the DB, all silently no-oped.
This is compounded by `reconcile_channel_events` (`side_effects.rs:3054`), which only re-emits when a kind:39000 is *entirely absent* (`if existing.is_empty()`, line 3090). A channel with a valid 39000 but a stale 39002 is never repaired — and reconcile only runs under `BUZZ_RECONCILE_CHANNELS` at all.
**Suggested fix:** on the already-a-member path, re-emit the discovery events (or at least kind:39002) before returning. It's idempotent and makes Join self-healing. Optionally, have reconcile compare 39002 `p` tags against `channel_members` instead of only checking whether a 39000 exists.
Contributor guide
Assessment
This issue has not been assessed yet.