block / block/buzz

NIP-29 member/admin lists silently omit the relay's own pubkey, making channels permanently unjoinable

Open
#4,796 2 comments 0 reactions 0 assignees View on GitHub
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

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.