feat(relay): add buzz-admin member management CLI with NIP-43 roster publish (#1265)

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Will Pfleger
2026-06-24 23:13:31 -04:00
committed by GitHub
parent 958ac7aaaa
commit 0cee0435f7
7 changed files with 458 additions and 34 deletions

View File

@@ -718,11 +718,14 @@ Subcommands:
| Subcommand | Purpose |
|------------|---------|
| `add-member` | Add a pubkey to the relay membership list (`--pubkey`, `--role`) |
| `add-member` | Add a pubkey to the relay membership list (`--pubkey`, `--role`); accepts npub or hex; publishes kind:13534 roster |
| `remove-member` | Remove a pubkey from the relay membership list (`--pubkey`, optional `--role` guard); publishes kind:13534 roster |
| `list-members` | List all relay members |
| `generate-key` | Generate a new Nostr keypair (for bootstrapping) |
| `reconcile-channels` | Emit kind:39000/39002 discovery events for channels missing them (idempotent) |
The `buzz-admin` binary is shipped in the relay Docker image (`/usr/local/bin/buzz-admin`) and is the recommended way to manage relay membership in production. Use `./run.sh add-member`, `./run.sh remove-member`, and `./run.sh list-members` in Docker Compose deployments.
---
### buzz-test-client — Integration Test Harness

9
Cargo.lock generated
View File

@@ -738,14 +738,23 @@ name = "buzz-admin"
version = "0.1.0"
dependencies = [
"anyhow",
"buzz-audit",
"buzz-auth",
"buzz-core",
"buzz-db",
"buzz-media",
"buzz-pubsub",
"buzz-search",
"buzz-workflow",
"clap",
"deadpool-redis",
"hex",
"nostr",
"serde_json",
"sqlx",
"tokio",
"tracing",
"uuid",
]
[[package]]

View File

@@ -44,7 +44,9 @@ COPY --from=planner /build/recipe.json recipe.json
RUN cargo chef cook --release --recipe-path recipe.json
COPY . .
RUN cargo build --release --locked -p buzz-relay --bin buzz-relay \
&& strip target/release/buzz-relay
-p buzz-admin --bin buzz-admin \
&& strip target/release/buzz-relay \
&& strip target/release/buzz-admin
# ─── Stage 4: web bundle (pnpm + vite) ──────────────────────────────────────
# Independent of the Rust layers so a CSS change doesn't bust Rust cache and
@@ -82,6 +84,7 @@ RUN apt-get update \
--create-home --shell /usr/sbin/nologin buzz
COPY --from=builder /build/target/release/buzz-relay /usr/local/bin/buzz-relay
COPY --from=builder /build/target/release/buzz-admin /usr/local/bin/buzz-admin
COPY --from=web-builder /build/web/dist /srv/buzz/web
ENV BUZZ_WEB_DIR=/srv/buzz/web

111
NOSTR.md
View File

@@ -471,6 +471,117 @@ is dual-sourced: local snapshot metadata plus upstream edit events (kind:40003
---
## Relay Membership (NIP-43)
When `BUZZ_REQUIRE_RELAY_MEMBERSHIP=true`, every authenticated connection is checked against the
`relay_members` table. Only pubkeys with a row in that table may use the relay. 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
```
**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"]` |
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
```
### 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 |

View File

@@ -15,9 +15,18 @@ path = "src/main.rs"
buzz-db = { workspace = true }
buzz-core = { workspace = true }
buzz-auth = { workspace = true }
buzz-pubsub = { workspace = true }
buzz-search = { workspace = true }
buzz-audit = { workspace = true }
buzz-workflow = { workspace = true }
buzz-media = { workspace = true }
nostr = { workspace = true }
tokio = { workspace = true }
serde_json = { workspace = true }
anyhow = { workspace = true }
hex = { workspace = true }
deadpool-redis = { workspace = true }
tracing = { workspace = true }
sqlx = { workspace = true }
uuid = { workspace = true }
clap = { version = "4", features = ["derive"] }

View File

@@ -2,14 +2,33 @@
//! Buzz instance administration CLI.
//!
//! In the pure Nostr architecture, API tokens no longer exist.
//! Admin operations are performed via signed Nostr events (NIP-43 relay admin commands).
//! This binary is retained as a placeholder for future admin tooling.
//! # Member management (NIP-43)
//!
//! ## Why only kind:13534 (membership list), not kind:8000/8001 (deltas)
//!
//! 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.
//!
//! ## Same-second domination guard
//!
//! 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`).
use std::sync::Arc;
use anyhow::Result;
use buzz_core::kind::KIND_NIP43_MEMBERSHIP_LIST;
use buzz_db::{Db, DbConfig};
use buzz_pubsub::PubSubManager;
use clap::{Parser, Subcommand};
use nostr::Keys;
use nostr::{EventBuilder, Keys, Kind, Tag};
use tracing::warn;
#[derive(Parser)]
#[command(name = "buzz-admin", about = "Buzz instance administration")]
@@ -21,15 +40,35 @@ struct Cli {
#[derive(Subcommand)]
enum Command {
/// Add a pubkey to the relay membership list.
///
/// Accepts a bech32 npub or 64-char hex pubkey. After inserting the DB row,
/// publishes a kind:13534 membership roster via Redis so live clients see
/// the updated list immediately.
AddMember {
/// Nostr public key (hex) to add.
/// Nostr public key — bech32 npub or 64-char hex.
#[arg(long)]
pubkey: String,
/// Role: "admin" or "member" (default: member).
/// Role: "admin" or "member" (default: member). Cannot be "owner" —
/// use RELAY_OWNER_PUBKEY config to set the relay owner.
#[arg(long, default_value = "member")]
role: String,
},
/// Remove a pubkey from the relay membership list.
///
/// Accepts a bech32 npub or 64-char hex pubkey. After removing the DB row,
/// publishes a kind:13534 membership roster via Redis. Cannot remove the
/// relay owner — change RELAY_OWNER_PUBKEY config instead.
RemoveMember {
/// Nostr public key — bech32 npub or 64-char hex.
#[arg(long)]
pubkey: String,
/// Only remove if the member's current role matches this value.
/// Omit to remove regardless of role.
#[arg(long)]
role: Option<String>,
},
/// List all relay members.
ListMembers,
/// Generate a new Nostr keypair (for bootstrapping).
@@ -51,53 +90,285 @@ enum Command {
}
#[tokio::main]
async fn main() -> Result<()> {
async fn main() {
let cli = Cli::parse();
let code = match run(cli).await {
Ok(code) => code,
Err(e) => {
eprintln!("error: {e}");
5
}
};
std::process::exit(code);
}
async fn run(cli: Cli) -> Result<i32> {
match cli.command {
Command::GenerateKey => {
let keys = Keys::generate();
println!("Public key: {}", keys.public_key().to_hex());
println!("Secret key: {}", keys.secret_key().display_secret());
println!("\nSet BUZZ_PRIVATE_KEY to the secret key to use this identity.");
Ok(0)
}
Command::Migrate => {
let db = connect_db().await?;
db.migrate().await?;
println!("Database migrations complete.");
Ok(0)
}
Command::AddMember { pubkey, role } => {
let db = connect_db().await?;
let pk_bytes = hex::decode(&pubkey)?;
if pk_bytes.len() != 32 {
anyhow::bail!("pubkey must be 32 bytes (64 hex chars)");
}
db.ensure_user(&pk_bytes).await?;
// Add to relay members via DB (admin bootstrap — normally done via kind:9030)
db.add_relay_member(&pubkey, &role, None).await?;
println!("Added {} as {} to relay membership list.", pubkey, role);
}
Command::ListMembers => {
let db = connect_db().await?;
let members = db.list_relay_members().await?;
if members.is_empty() {
println!("No relay members found.");
} else {
println!("{:<66} {:<10}", "Pubkey", "Role");
println!("{}", "-".repeat(78));
for m in &members {
println!("{:<66} {:<10}", hex::encode(&m.pubkey), m.role);
}
}
}
Command::AddMember { pubkey, role } => cmd_add_member(pubkey, role).await,
Command::RemoveMember { pubkey, role } => cmd_remove_member(pubkey, role).await,
Command::ListMembers => cmd_list_members().await,
Command::ReconcileChannels { relay_key } => {
reconcile_channels(relay_key).await?;
Ok(0)
}
}
}
// ── Member management subcommands ─────────────────────────────────────────────
async fn cmd_add_member(pubkey_arg: String, role: String) -> Result<i32> {
if let Err(msg) = validate_role(&role) {
eprintln!("error: {msg}");
return Ok(1);
}
let pubkey_hex = match parse_pubkey_hex(&pubkey_arg) {
Ok(h) => h,
Err(msg) => {
eprintln!("error: {msg}");
return Ok(1);
}
};
let (db, pubsub, relay_keypair) = connect_member_services().await?;
match db.add_relay_member(&pubkey_hex, &role, None).await {
Ok(true) => println!("added {pubkey_hex} as {role}"),
Ok(false) => println!("already a member: {pubkey_hex} (no change)"),
Err(e) => {
eprintln!("error: DB write failed: {e}");
return Ok(5);
}
}
if let Err(e) = publish_membership_list_with_bump(&db, &pubsub, &relay_keypair).await {
eprintln!("warning: member added to DB but list publish failed: {e}");
}
Ok(0)
}
async fn cmd_remove_member(pubkey_arg: String, role_filter: Option<String>) -> Result<i32> {
if let Some(ref role) = role_filter {
if let Err(msg) = validate_role(role) {
eprintln!("error: {msg}");
return Ok(1);
}
}
let pubkey_hex = match parse_pubkey_hex(&pubkey_arg) {
Ok(h) => h,
Err(msg) => {
eprintln!("error: {msg}");
return Ok(1);
}
};
let (db, pubsub, relay_keypair) = connect_member_services().await?;
use buzz_db::relay_members::RemoveResult;
let result = if let Some(ref role) = role_filter {
db.remove_relay_member_if_role(&pubkey_hex, role).await
} else {
db.remove_relay_member(&pubkey_hex).await
};
match result {
Ok(RemoveResult::Removed) => println!("removed {pubkey_hex}"),
Ok(RemoveResult::NotFound) => {
eprintln!("error: member not found: {pubkey_hex}");
return Ok(2);
}
Ok(RemoveResult::IsOwner) => {
eprintln!(
"error: cannot remove relay owner: {pubkey_hex}\n\
To change the owner, update RELAY_OWNER_PUBKEY and restart."
);
return Ok(3);
}
Ok(RemoveResult::RoleMismatch) => {
let role_str = role_filter.as_deref().unwrap_or("(unknown)");
eprintln!("error: role mismatch — {pubkey_hex} is not currently '{role_str}'");
return Ok(4);
}
Err(e) => {
eprintln!("error: DB write failed: {e}");
return Ok(5);
}
}
if let Err(e) = publish_membership_list_with_bump(&db, &pubsub, &relay_keypair).await {
eprintln!("warning: member removed from DB but list publish failed: {e}");
}
Ok(0)
}
async fn cmd_list_members() -> Result<i32> {
let db = connect_db().await?;
let members = db.list_relay_members().await?;
if members.is_empty() {
println!("(no relay members)");
return Ok(0);
}
println!(
"{:<66} {:<8} {:<66} created_at",
"pubkey", "role", "added_by"
);
println!("{}", "-".repeat(160));
for m in &members {
let added_by = m.added_by.as_deref().unwrap_or("-");
println!(
"{:<66} {:<8} {:<66} {}",
m.pubkey,
m.role,
added_by,
m.created_at.format("%Y-%m-%dT%H:%M:%SZ")
);
}
Ok(0)
}
// ── Helpers ───────────────────────────────────────────────────────────────────
/// Validate that `role` is `"member"` or `"admin"`. Rejects `"owner"`.
fn validate_role(role: &str) -> std::result::Result<(), String> {
match role {
"member" | "admin" => Ok(()),
"owner" => {
Err("role 'owner' cannot be set via CLI — use RELAY_OWNER_PUBKEY config".to_string())
}
other => Err(format!(
"invalid role '{other}': must be 'member' or 'admin'"
)),
}
}
/// Parse a bech32 npub or 64-char hex pubkey into lowercase hex.
fn parse_pubkey_hex(input: &str) -> std::result::Result<String, String> {
nostr::PublicKey::parse(input)
.map(|pk| pk.to_hex())
.map_err(|e| format!("invalid pubkey '{input}': {e}"))
}
/// Publish kind:13534 with `custom_created_at = max(now, newest_existing + 1s)`.
///
/// Guarantees the new event is not dominated by a same-second prior invocation,
/// so `replace_addressable_event` always inserts and dispatches to Redis.
///
/// See module-level doc for the TOCTOU caveat on concurrent CLI processes.
async fn publish_membership_list_with_bump(
db: &Db,
pubsub: &Arc<PubSubManager>,
relay_keypair: &Keys,
) -> Result<()> {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let relay_pubkey = relay_keypair.public_key();
let relay_pubkey_bytes = relay_pubkey.to_bytes();
// Query the newest existing kind:13534 for this relay's pubkey (channel_id=None).
let newest_ts = db
.get_latest_global_replaceable(KIND_NIP43_MEMBERSHIP_LIST as i32, &relay_pubkey_bytes)
.await?
.map(|e| e.event.created_at.as_secs());
// custom_created_at = max(now, existing + 1s) — defeats same-second domination.
let ts = match newest_ts {
Some(existing) => (existing + 1).max(now),
None => now,
};
let members = db.list_relay_members().await?;
let mut tags: Vec<Tag> = Vec::with_capacity(members.len() + 1);
// NIP-70 protected-event marker — prevents re-broadcasting by third parties.
tags.push(Tag::parse(["-"]).map_err(|e| anyhow::anyhow!("failed to build '-' tag: {e}"))?);
for member in &members {
tags.push(
Tag::parse(["member", &member.pubkey, &member.role])
.map_err(|e| anyhow::anyhow!("failed to build member tag: {e}"))?,
);
}
let event = EventBuilder::new(Kind::Custom(KIND_NIP43_MEMBERSHIP_LIST as u16), "")
.tags(tags)
.custom_created_at(nostr::Timestamp::from(ts))
.sign_with_keys(relay_keypair)
.map_err(|e| anyhow::anyhow!("failed to sign kind:13534: {e}"))?;
let (stored, was_inserted) = db.replace_addressable_event(&event, None).await?;
if was_inserted {
// Publish to Redis so live clients receive the updated roster.
// Uses channel_id=Nil (global scope) matching the relay's own publish path.
let pubsub_channel = uuid::Uuid::nil();
if let Err(e) = pubsub.publish_event(pubsub_channel, &stored.event).await {
warn!("Redis publish of kind:13534 failed: {e}");
}
}
tracing::info!(
member_count = members.len(),
ts,
"NIP-43 membership list published by buzz-admin"
);
Ok(())
}
/// Connect to DB, Redis pub/sub, and load the relay keypair.
///
/// `BUZZ_RELAY_PRIVATE_KEY` is required — the CLI signs kind:13534 events.
async fn connect_member_services() -> Result<(Db, Arc<PubSubManager>, Keys)> {
let db = connect_db().await?;
let relay_keypair = {
let hex = std::env::var("BUZZ_RELAY_PRIVATE_KEY").map_err(|_| {
anyhow::anyhow!(
"BUZZ_RELAY_PRIVATE_KEY is required for add-member/remove-member.\n\
The relay must have a stable signing key to publish kind:13534 events."
)
})?;
Keys::parse(&hex).map_err(|e| anyhow::anyhow!("invalid BUZZ_RELAY_PRIVATE_KEY: {e}"))?
};
let redis_url =
std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://localhost:6379".to_string());
let redis_pool = {
let cfg = deadpool_redis::Config::from_url(&redis_url);
cfg.create_pool(Some(deadpool_redis::Runtime::Tokio1))
.map_err(|e| anyhow::anyhow!("Redis pool creation failed: {e}"))?
};
let pubsub = Arc::new(
PubSubManager::new(&redis_url, redis_pool)
.await
.map_err(|e| anyhow::anyhow!("PubSub init failed: {e}"))?,
);
Ok((db, pubsub, relay_keypair))
}
async fn connect_db() -> Result<Db> {
let db_url = std::env::var("DATABASE_URL")
.unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string());
@@ -112,7 +383,6 @@ async fn connect_db() -> Result<Db> {
async fn reconcile_channels(relay_key_arg: Option<String>) -> Result<()> {
use buzz_core::kind::KIND_NIP29_GROUP_ADMINS;
use buzz_db::event::EventQuery;
use nostr::{EventBuilder, Kind, Tag};
let db = connect_db().await?;

View File

@@ -86,6 +86,15 @@ case "${1:-help}" in
backup-hint)
backup_hint
;;
add-member)
docker compose exec relay /usr/local/bin/buzz-admin add-member --pubkey "${2:?Usage: ./run.sh add-member <npub-or-hex> [--role member|admin]}" "${@:3}"
;;
remove-member)
docker compose exec relay /usr/local/bin/buzz-admin remove-member --pubkey "${2:?Usage: ./run.sh remove-member <npub-or-hex> [--role member|admin]}" "${@:3}"
;;
list-members)
docker compose exec relay /usr/local/bin/buzz-admin list-members
;;
help|-h|--help)
cat <<'MSG'
Usage: ./run.sh <command>
@@ -101,6 +110,16 @@ Commands:
config Render merged compose config
backup-hint Print the production backup checklist
add-member <npub-or-hex> [--role member|admin]
Add a relay member (default role: member)
remove-member <npub-or-hex> [--role member|admin]
Remove a relay member
list-members List all relay members
Note: when adding multiple members in a loop, add `sleep 1` between
invocations to avoid same-second timestamp collisions in the kind:13534
roster event. Do not use parallel adds (e.g. xargs -P).
Environment switches:
BUZZ_COMPOSE_TLS=true Include compose.caddy.yml for automatic HTTPS
BUZZ_COMPOSE_DEV=true Include compose.dev.yml for local admin ports/tools