diff --git a/crates/buzz-relay/src/api/operator.rs b/crates/buzz-relay/src/api/operator.rs index d2ef6ade0..1ed509260 100644 --- a/crates/buzz-relay/src/api/operator.rs +++ b/crates/buzz-relay/src/api/operator.rs @@ -394,7 +394,7 @@ pub async fn transfer_community( // Best-effort NIP-43 membership snapshot publication — same pattern as // provision_community. The DB mutation is already committed; a publication // failure must not turn a success into an HTTP error. - if state.config.require_relay_membership { + if state.config.can_publish_membership_metadata() { if let Some(host) = state .db .lookup_community_host(community) diff --git a/crates/buzz-relay/src/config.rs b/crates/buzz-relay/src/config.rs index 020d865b0..0f8ea2588 100644 --- a/crates/buzz-relay/src/config.rs +++ b/crates/buzz-relay/src/config.rs @@ -269,6 +269,14 @@ fn ensure_git_repo_path( } impl Config { + /// Whether this relay can publish durable, relay-signed membership metadata. + /// + /// Open relays use the same metadata to expose owner/admin capabilities to + /// clients without enforcing membership for reads or writes. + pub fn can_publish_membership_metadata(&self) -> bool { + self.relay_private_key.is_some() + } + /// Loads configuration from environment variables, falling back to development defaults. pub fn from_env() -> Result { let bind_addr_raw = @@ -683,6 +691,21 @@ mod tests { // value set by `invalid_bind_addr_returns_error`, causing a flaky failure. static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(()); + #[test] + fn stable_signing_key_enables_membership_metadata_on_open_relay() { + let _guard = ENV_MUTEX.lock().unwrap(); + std::env::set_var( + "BUZZ_RELAY_PRIVATE_KEY", + "0000000000000000000000000000000000000000000000000000000000000001", + ); + std::env::remove_var("BUZZ_REQUIRE_RELAY_MEMBERSHIP"); + let config = Config::from_env().expect("config"); + std::env::remove_var("BUZZ_RELAY_PRIVATE_KEY"); + + assert!(!config.require_relay_membership); + assert!(config.can_publish_membership_metadata()); + } + #[test] fn defaults_are_valid() { let _guard = ENV_MUTEX.lock().unwrap(); diff --git a/crates/buzz-relay/src/handlers/community_provisioning.rs b/crates/buzz-relay/src/handlers/community_provisioning.rs index 3185af8be..b724bed96 100644 --- a/crates/buzz-relay/src/handlers/community_provisioning.rs +++ b/crates/buzz-relay/src/handlers/community_provisioning.rs @@ -212,7 +212,7 @@ async fn publish_membership_snapshot_if_required( community: buzz_core::CommunityId, host: &str, ) { - if !state.config.require_relay_membership { + if !state.config.can_publish_membership_metadata() { return; } diff --git a/crates/buzz-relay/src/main.rs b/crates/buzz-relay/src/main.rs index de7f0c172..d4d8acf90 100644 --- a/crates/buzz-relay/src/main.rs +++ b/crates/buzz-relay/src/main.rs @@ -452,9 +452,12 @@ async fn main() -> anyhow::Result<()> { ); } - // NIP-43: publish the initial membership list on startup so clients can - // REQ kind:13534 immediately without waiting for the next membership change. - if config.require_relay_membership { + // NIP-43 membership metadata: publish the initial role snapshot so clients + // can discover owner/admin capabilities immediately. Closed relays require + // this for NIP-43 enforcement; open relays with a stable signing key publish + // the same metadata so owner-only settings remain discoverable without + // turning membership enforcement on. + if config.require_relay_membership || config.can_publish_membership_metadata() { let startup_state = Arc::clone(&state); tokio::spawn(async move { // Resolve the deployment's community from the configured relay URL