From 218a5620fb0699b45cc5be137b36f1e30c83d1f1 Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Wed, 24 Jun 2026 22:28:44 -0400 Subject: [PATCH 1/2] feat(relay): add buzz-admin member management CLI with NIP-43 roster publish Relay membership management previously required raw SQL via `docker compose exec postgres psql`. This adds a first-class operator CLI via buzz-admin (the existing dedicated operator binary). buzz-admin add-member / remove-member / list-members: - Accept bech32 npub or 64-char hex via nostr::PublicKey::parse - Reject role 'owner' (use RELAY_OWNER_PUBKEY config instead) - After add/remove: publish kind:13534 membership roster via Redis so live clients see the updated list immediately - custom_created_at = max(now, newest_existing_13534 + 1s) defeats same-second domination for serial invocations - Exit codes: 0=success 1=validation 2=NotFound 3=IsOwner 4=RoleMismatch 5=DB/Redis error Dockerfile: build and ship buzz-admin alongside buzz-relay so `docker compose exec relay buzz-admin` works out of the box. deploy/compose/run.sh: add-member / remove-member / list-members shortcuts delegate to buzz-admin inside the container. NOSTR.md: document the CLI, NIP-43 admin events (kind:9030-9032), kind:13534 roster subscription, and known limitations. ARCHITECTURE.md: add remove-member to the buzz-admin subcommand table; note binary is now shipped in the relay image. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- ARCHITECTURE.md | 5 +- Cargo.lock | 9 + Dockerfile | 5 +- NOSTR.md | 111 +++++++++++ crates/buzz-admin/Cargo.toml | 9 + crates/buzz-admin/src/main.rs | 334 ++++++++++++++++++++++++++++++---- deploy/compose/run.sh | 19 ++ 7 files changed, 458 insertions(+), 34 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 7fff2b6c0..e041df6ba 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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 diff --git a/Cargo.lock b/Cargo.lock index 05ff37be3..e3fbefe55 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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]] diff --git a/Dockerfile b/Dockerfile index aec27dea2..07c0c5bf8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/NOSTR.md b/NOSTR.md index 2bf45ae7a..ddea5c686 100644 --- a/NOSTR.md +++ b/NOSTR.md @@ -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", ""]`, optional `["role", "member\|admin"]` | +| 9031 | Remove member | `["p", ""]`, optional `["role", "member\|admin"]` | +| 9032 | Change role | `["p", ""]`, `["role", "member\|admin"]` | + +Example using `nak`: + +```bash +# Add a member (owner or admin must sign) +nak event -k 9030 \ + --tag "p=" \ + --tag "role=member" \ + --auth --sec \ + ws://localhost:3000 + +# Remove a member +nak event -k 9031 \ + --tag "p=" \ + --auth --sec \ + ws://localhost:3000 + +# Change a member's role to admin +nak event -k 9032 \ + --tag "p=" \ + --tag "role=admin" \ + --auth --sec \ + 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 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 | diff --git a/crates/buzz-admin/Cargo.toml b/crates/buzz-admin/Cargo.toml index e08861696..e2a40979c 100644 --- a/crates/buzz-admin/Cargo.toml +++ b/crates/buzz-admin/Cargo.toml @@ -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"] } diff --git a/crates/buzz-admin/src/main.rs b/crates/buzz-admin/src/main.rs index 73a5f7e9c..feca6574d 100644 --- a/crates/buzz-admin/src/main.rs +++ b/crates/buzz-admin/src/main.rs @@ -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, + }, /// 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 { 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 { + 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) -> Result { + 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 { + 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} {}", + "pubkey", "role", "added_by", "created_at" + ); + 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 { + 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, + 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 = 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, 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 { 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 { async fn reconcile_channels(relay_key_arg: Option) -> Result<()> { use buzz_core::kind::KIND_NIP29_GROUP_ADMINS; use buzz_db::event::EventQuery; - use nostr::{EventBuilder, Kind, Tag}; let db = connect_db().await?; diff --git a/deploy/compose/run.sh b/deploy/compose/run.sh index 0c716679b..9138cd546 100755 --- a/deploy/compose/run.sh +++ b/deploy/compose/run.sh @@ -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 [--role member|admin]}" "${@:3}" + ;; + remove-member) + docker compose exec relay /usr/local/bin/buzz-admin remove-member --pubkey "${2:?Usage: ./run.sh remove-member [--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 @@ -101,6 +110,16 @@ Commands: config Render merged compose config backup-hint Print the production backup checklist + add-member [--role member|admin] + Add a relay member (default role: member) + remove-member [--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 From 2eddb8072104cb1af72f30e26b8550232130087f Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Wed, 24 Jun 2026 22:48:32 -0400 Subject: [PATCH 2/2] fix(admin): inline print_literal in list-members header Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- crates/buzz-admin/src/main.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/buzz-admin/src/main.rs b/crates/buzz-admin/src/main.rs index feca6574d..0bf472e23 100644 --- a/crates/buzz-admin/src/main.rs +++ b/crates/buzz-admin/src/main.rs @@ -228,8 +228,8 @@ async fn cmd_list_members() -> Result { } println!( - "{:<66} {:<8} {:<66} {}", - "pubkey", "role", "added_by", "created_at" + "{:<66} {:<8} {:<66} created_at", + "pubkey", "role", "added_by" ); println!("{}", "-".repeat(160)); for m in &members {