diff --git a/crates/buzz-relay/src/api/invites.rs b/crates/buzz-relay/src/api/invites.rs index 6104171cca..d7942080c5 100644 --- a/crates/buzz-relay/src/api/invites.rs +++ b/crates/buzz-relay/src/api/invites.rs @@ -24,7 +24,9 @@ use axum::{ use serde::Deserialize; use serde_json::Value; -use crate::handlers::side_effects::{publish_nip43_member_added, publish_nip43_membership_list}; +use crate::handlers::side_effects::{ + emit_group_discovery_events, publish_nip43_member_added, publish_nip43_membership_list, +}; use buzz_core::invite::{ hash_v2_code, validate_v2_code, DEFAULT_INVITE_TTL_SECS, MAX_INVITE_TTL_SECS, MAX_INVITE_USES, MIN_INVITE_TTL_SECS, V2_PREFIX, @@ -406,6 +408,7 @@ pub async fn claim_invite( member = %claimer_hex, "relay member added via v2 invite" ); + join_active_open_channels(&tenant, &state, &claimer_hex).await; // NIP-43 side effects only on Joined, never on other outcomes. if let Err(e) = publish_nip43_member_added(&tenant, &state, &claimer_hex).await { tracing::warn!( @@ -484,6 +487,7 @@ pub async fn claim_invite( member = %claimer_hex, "relay member added via invite" ); + join_active_open_channels(&tenant, &state, &claimer_hex).await; if let Err(e) = publish_nip43_member_added(&tenant, &state, &claimer_hex).await { tracing::warn!("failed to publish NIP-43 member-added delta after claim: {e}"); } @@ -526,6 +530,93 @@ fn claim_key_rate_limited( counter.fetch_add(1, Ordering::Relaxed) >= CLAIM_RATE_LIMIT } +/// Add a newly joined relay member to every active open channel and republish +/// each channel's NIP-29 discovery state. Relay membership has already been +/// committed when this runs, so failures are logged rather than changing the +/// successful claim response. +async fn join_active_open_channels( + tenant: &buzz_core::tenant::TenantContext, + state: &Arc, + claimer_hex: &str, +) { + let claimer_bytes = match hex::decode(claimer_hex) { + Ok(bytes) if bytes.len() == 32 => bytes, + Ok(bytes) => { + tracing::warn!( + community = %tenant.community(), + member = %claimer_hex, + decoded_len = bytes.len(), + "cannot auto-join invite claimer to open channels: decoded pubkey is not 32 bytes" + ); + return; + } + Err(error) => { + tracing::warn!( + community = %tenant.community(), + member = %claimer_hex, + error = %error, + "cannot auto-join invite claimer to open channels: invalid hex pubkey" + ); + return; + } + }; + + let channels = match state + .db + .list_channels(tenant.community(), Some("open")) + .await + { + Ok(channels) => channels, + Err(error) => { + tracing::warn!( + community = %tenant.community(), + member = %claimer_hex, + error = %error, + "failed to list open channels after invite claim" + ); + return; + } + }; + + for channel in channels { + if channel.archived_at.is_some() || channel.channel_type == "dm" { + continue; + } + + if let Err(error) = state + .db + .add_member( + tenant.community(), + channel.id, + &claimer_bytes, + buzz_db::channel::MemberRole::Member, + None, + ) + .await + { + tracing::warn!( + community = %tenant.community(), + channel = %channel.id, + member = %claimer_hex, + error = %error, + "failed to auto-join invite claimer to open channel" + ); + continue; + } + + state.invalidate_membership(tenant, channel.id, &claimer_bytes); + if let Err(error) = emit_group_discovery_events(tenant, state, channel.id).await { + tracing::warn!( + community = %tenant.community(), + channel = %channel.id, + member = %claimer_hex, + error = %error, + "failed to publish open-channel discovery after invite claim" + ); + } + } +} + #[cfg(test)] mod tests { use std::sync::Arc; @@ -655,7 +746,8 @@ mod tests { .or_else(|_| std::env::var("DATABASE_URL")) .unwrap_or_else(|_| TEST_DB_URL.to_string()); config.database_url = database_url.clone(); - config.redis_url = "redis://127.0.0.1:1".to_string(); + config.redis_url = std::env::var("BUZZ_TEST_REDIS_URL") + .unwrap_or_else(|_| "redis://127.0.0.1:1".to_string()); config.relay_url = format!("wss://{host}"); // The claim route must work on relays where membership is enforced — // that is the entire point of an invite. @@ -751,6 +843,44 @@ mod tests { .expect("count side-effect events") } + async fn has_channel_member( + state: &AppState, + community: buzz_core::CommunityId, + channel: Uuid, + member: &Keys, + ) -> bool { + state + .db + .is_member(community, channel, &member.public_key().to_bytes()) + .await + .expect("channel membership lookup") + } + + async fn group_members_event_mentions( + state: &AppState, + community: buzz_core::CommunityId, + channel: Uuid, + member: &Keys, + ) -> bool { + let events = state + .db + .query_events(&buzz_db::EventQuery { + channel_id: Some(channel), + kinds: Some(vec![buzz_core::kind::KIND_NIP29_GROUP_MEMBERS as i32]), + limit: Some(1), + ..buzz_db::EventQuery::for_community(community) + }) + .await + .expect("query group members event"); + let member_hex = member.public_key().to_hex(); + events.first().is_some_and(|stored| { + stored.event.tags.iter().any(|tag| { + let parts = tag.as_slice(); + parts.len() >= 2 && parts[0] == "p" && parts[1] == member_hex + }) + }) + } + #[test] fn mint_request_deserialization_is_strict() { for valid in [ @@ -954,6 +1084,51 @@ mod tests { .add_relay_member(community.id, &owner.public_key().to_hex(), "owner", None) .await .expect("seed owner"); + let owner_bytes = owner.public_key().to_bytes(); + let open_channel = state + .db + .create_channel( + community.id, + "open-invites", + buzz_db::channel::ChannelType::Stream, + buzz_db::channel::ChannelVisibility::Open, + None, + &owner_bytes, + None, + ) + .await + .expect("create open channel"); + let private_channel = state + .db + .create_channel( + community.id, + "private-invites", + buzz_db::channel::ChannelType::Stream, + buzz_db::channel::ChannelVisibility::Private, + None, + &owner_bytes, + None, + ) + .await + .expect("create private channel"); + let archived_channel = state + .db + .create_channel( + community.id, + "archived-invites", + buzz_db::channel::ChannelType::Stream, + buzz_db::channel::ChannelVisibility::Open, + None, + &owner_bytes, + None, + ) + .await + .expect("create archived channel"); + state + .db + .archive_channel(community.id, archived_channel.id) + .await + .expect("archive channel"); let before_delta_count = event_count( &state, community.id, @@ -1005,6 +1180,35 @@ mod tests { .await; assert_eq!(delta_count, before_delta_count + 1); assert_eq!(list_count, before_list_count + 1); + assert!( + has_channel_member(&state, community.id, open_channel.id, &first).await, + "new claimer must join active open channels" + ); + assert!( + group_members_event_mentions(&state, community.id, open_channel.id, &first).await, + "kind:39002 must advertise the new open-channel membership" + ); + assert!( + !has_channel_member(&state, community.id, private_channel.id, &first).await, + "new claimer must not join private channels" + ); + assert!( + !has_channel_member(&state, community.id, archived_channel.id, &first).await, + "new claimer must not join archived open channels" + ); + let late_open_channel = state + .db + .create_channel( + community.id, + "late-open-invites", + buzz_db::channel::ChannelType::Stream, + buzz_db::channel::ChannelVisibility::Open, + None, + &owner_bytes, + None, + ) + .await + .expect("create open channel after the genuine claim"); let response = post_json( state.clone(), @@ -1040,6 +1244,10 @@ mod tests { .await, list_count ); + assert!( + !has_channel_member(&state, community.id, late_open_channel.id, &first).await, + "AlreadyMember re-claim must not add channel memberships" + ); let response = post_json( state.clone(),