diff --git a/NOSTR.md b/NOSTR.md index 6ec8856a4a..5537e72e15 100644 --- a/NOSTR.md +++ b/NOSTR.md @@ -72,7 +72,7 @@ PGPASSWORD=buzz_dev psql -h localhost -U buzz -d buzz -c \ | **NIP-10 threads** | ✅ | WS-submitted replies with `["e","","","reply"]` tags create `thread_metadata` atomically. Visible in REST thread queries. Unknown parents rejected. | | **NIP-17 DMs (gift wrap)** | ✅ | kind:1059 accepted with ephemeral signing keys. Stored community-globally (`channel_id=None` inside the connected community). Delivered via `#p`-filtered subscriptions. Not indexed in search. | | **DM discovery** | ✅ | DM creation emits kind:39000 (with `hidden` tag) + kind:44100 membership notifications. NIP-29 clients discover DMs via standard group discovery flow. | -| **Join request (kind:9021)** | ✅ | Open channels only. Adds the joiner with the channel's `join_role` (`member` by default), emits system message + group discovery events + kind:44100 membership notification. Private channels rejected at ingest. | +| **Join request (kind:9021)** | ✅ | Open channels only. Adds the joiner with the channel's `join_role` (`member` by default), emits system message + group discovery events + kind:44100 membership notification. Private channels rejected at ingest. The same body joins new community members to [default channels](#default-channels). | | **Edits (kind:40003)** | ⚠️ | Works on the wire but Buzz-only — no standard NIP-29 client renders these | | **Rich content (kind:40002)** | ⚠️ | Works on the wire but Buzz-only — no standard NIP-29 client renders these | @@ -216,6 +216,16 @@ one the relay joins every new community member into. The setting is Every change is announced by one kind:40099 `auto_join_changed` system message in the channel. +When someone becomes a community member (an invite claim or a kind:9030 add) +the relay joins them to every effective default channel as a self-join: the +body of kind:9021, so the channel's `join_role` applies and the channel gets +its `member_joined` kind:40099, kind:39002 and kind:44100. It does this once, +at that moment, and never again on login or reconnect, so a later kind:9022 +leave stands. A channel that fails to join is logged and counted +(`buzz_auto_join_failures_total`), not retried: the kind:39000 tag is the +record a client reconciles against. `buzz-admin add-member` writes the +roster row only and does not join default channels. + ### Group Discovery The relay emits NIP-29 group state events when channels are created, updated, or membership changes. diff --git a/crates/buzz-admin/src/main.rs b/crates/buzz-admin/src/main.rs index 13e7f6df82..b286037ac1 100644 --- a/crates/buzz-admin/src/main.rs +++ b/crates/buzz-admin/src/main.rs @@ -56,7 +56,9 @@ enum Command { /// /// 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. + /// the updated list immediately. Does not join the member to the + /// community's default (`auto_join`) channels: the relay does that on an + /// invite claim or a kind:9030 add, which this command bypasses. AddMember { /// Nostr public key — bech32 npub or 64-char hex. #[arg(long)] diff --git a/crates/buzz-relay/src/api/invites.rs b/crates/buzz-relay/src/api/invites.rs index e184a35c41..dc3e48b3b6 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::{fan_out_nip43_membership_list, publish_nip43_member_added}; +use crate::handlers::side_effects::{ + auto_join_default_channels, fan_out_nip43_membership_list, publish_nip43_member_added, +}; use crate::nip_fi_http::admit_nip_fi_http_on_state; use buzz_core::invite::{ hash_v2_code, validate_v2_code, DEFAULT_INVITE_TTL_SECS, MAX_INVITE_TTL_SECS, MAX_INVITE_USES, @@ -496,6 +498,7 @@ pub async fn claim_invite( "failed to publish NIP-43 member-added delta after v2 claim: {e}" ); } + auto_join_default_channels(&tenant, &state, &claimer_hex).await; Ok(Json(serde_json::json!({ "status": "joined", "community_id": tenant.community().to_string(), @@ -589,6 +592,7 @@ pub async fn claim_invite( 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}"); } + auto_join_default_channels(&tenant, &state, &claimer_hex).await; } Ok(Json(serde_json::json!({ @@ -1205,6 +1209,257 @@ mod postgres_tests { ); } + /// How many kind:44100 notifications name `member_hex` across the + /// community, one per channel the relay joined them to. + async fn added_notification_count( + state: &AppState, + community: buzz_core::CommunityId, + member_hex: &str, + ) -> i64 { + state + .db + .count_events(&buzz_db::EventQuery { + kinds: Some(vec![buzz_core::kind::KIND_MEMBER_ADDED_NOTIFICATION as i32]), + p_tag_hex: Some(member_hex.to_string()), + global_only: true, + ..buzz_db::EventQuery::for_community(community) + }) + .await + .expect("count 44100") + } + + /// How many `member_joined` kind:40099 notices in `channel_id` target + /// `member_hex`. + async fn member_joined_notice_count( + state: &AppState, + community: buzz_core::CommunityId, + channel_id: Uuid, + member_hex: &str, + ) -> usize { + state + .db + .query_events(&buzz_db::EventQuery { + kinds: Some(vec![40099]), + channel_id: Some(channel_id), + ..buzz_db::EventQuery::for_community(community) + }) + .await + .expect("query 40099") + .iter() + .filter_map(|stored| serde_json::from_str::(&stored.event.content).ok()) + .filter(|content| content["type"] == "member_joined" && content["target"] == member_hex) + .count() + } + + /// The channel roles the relay holds for `keys` in `channels`, in order. + async fn channel_roles( + state: &AppState, + community: buzz_core::CommunityId, + channels: &[Uuid], + keys: &Keys, + ) -> Vec> { + let mut roles = Vec::new(); + for channel_id in channels { + roles.push( + state + .db + .get_member_role(community, *channel_id, &keys.public_key().to_bytes()) + .await + .expect("member role"), + ); + } + roles + } + + /// Claiming an invite joins the new member to the community's effective + /// default channels, once. Four channels flagged by the relay owner: + /// one open, one archived after flagging, one made private after + /// flagging, plus an unflagged control. A v2 claim joins the open one + /// only, with its kind:39002 roster, one kind:44100 and one + /// `member_joined` notice; a kind:9022 leave followed by a re-claim is + /// `already_member` and does not re-add. The v1 token path joins the same + /// way. The archived guard the shared join body owns is bound directly. + /// Mutation: delete `auto_join_default_channels` at either claim site → + /// RED. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn claim_auto_joins_default_channels_once() { + use crate::handlers::group_state_postgres_tests::{ + create_channel, edit_channel, group_state_tags, http_auth, + }; + use crate::handlers::ingest::ingest_event; + use crate::handlers::side_effects::join_channel_as_self; + + let host = format!("invites-auto-join-{}.example", Uuid::new_v4().simple()); + let owner = Keys::generate(); + let state = invite_test_state(&host) + .await + .expect("requires reachable Postgres and relay test state"); + let community = state + .db + .lookup_community_by_host(&host) + .await + .expect("lookup") + .expect("community exists") + .id; + let tenant = buzz_core::TenantContext::resolved(community, &host); + state + .db + .add_relay_member( + community, + &owner.public_key().to_hex(), + "owner", + None, + &Keys::generate(), + ) + .await + .map(|write| write.outcome) + .expect("seed owner"); + + let flagged = create_channel(&state, &tenant, &owner, &[]).await; + let archived = create_channel(&state, &tenant, &owner, &[]).await; + let private = create_channel(&state, &tenant, &owner, &[]).await; + let control = create_channel(&state, &tenant, &owner, &[]).await; + for channel_id in [flagged, archived, private] { + edit_channel( + &state, + &tenant, + &owner, + channel_id, + &[&["auto_join", "true"]], + ) + .await + .expect("relay owner flags channel"); + } + edit_channel(&state, &tenant, &owner, archived, &[&["archived", "true"]]) + .await + .expect("archive after flagging"); + edit_channel( + &state, + &tenant, + &owner, + private, + &[&["visibility", "private"]], + ) + .await + .expect("make private after flagging"); + let channels = [flagged, archived, private, control]; + + let joiner = Keys::generate(); + let joiner_hex = joiner.public_key().to_hex(); + let archived_refusal = + join_channel_as_self(&tenant, &state, archived, &joiner.public_key().to_bytes()) + .await + .expect_err("self-join of an archived channel"); + assert!( + archived_refusal.to_string().contains("archived"), + "{archived_refusal}" + ); + assert_eq!( + added_notification_count(&state, community, &joiner_hex).await, + 0 + ); + + let code = mint_code( + state.clone(), + &host, + &owner, + serde_json::json!({ "max_uses": 1 }), + ) + .await; + let claim_body = serde_json::json!({ "code": code }).to_string(); + let response = post_json( + state.clone(), + &host, + "/api/invites/claim", + &joiner, + claim_body.clone(), + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(read_json(response).await["status"], "joined"); + assert_eq!( + channel_roles(&state, community, &channels, &joiner).await, + [Some("member".to_string()), None, None, None], + "joined to the effective default channel only" + ); + assert!( + group_state_tags(&state, &tenant, flagged, 39002) + .await + .iter() + .any(|tag| tag[0] == "p" && tag[1] == joiner_hex), + "kind:39002 of the default channel lists the new member" + ); + assert_eq!( + added_notification_count(&state, community, &joiner_hex).await, + 1 + ); + assert_eq!( + member_joined_notice_count(&state, community, flagged, &joiner_hex).await, + 1 + ); + + // Leaving is respected: a re-claim is a no-op, not a re-add. + let leave = EventBuilder::new(Kind::Custom(9022), "") + .tags([Tag::parse(["h", &flagged.to_string()]).unwrap()]) + .sign_with_keys(&joiner) + .expect("sign 9022"); + let left = ingest_event(&state, &tenant, leave, http_auth(&joiner)) + .await + .expect("ingest 9022"); + assert!(left.accepted, "{:?}", left.message); + assert_eq!( + channel_roles(&state, community, &[flagged], &joiner).await, + [None] + ); + let response = post_json( + state.clone(), + &host, + "/api/invites/claim", + &joiner, + claim_body, + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(read_json(response).await["status"], "already_member"); + assert_eq!( + channel_roles(&state, community, &channels, &joiner).await, + [None, None, None, None], + "a re-claim must not resurrect a kind:9022 leave" + ); + assert_eq!( + added_notification_count(&state, community, &joiner_hex).await, + 1 + ); + assert_eq!( + member_joined_notice_count(&state, community, flagged, &joiner_hex).await, + 1 + ); + + // The v1 HMAC token path joins the same way. + let v1_joiner = Keys::generate(); + let v1_joiner_hex = v1_joiner.public_key().to_hex(); + let v1_code = forge_v1_invite_code(&state, community, unix_now() + 3600); + let response = post_json( + state.clone(), + &host, + "/api/invites/claim", + &v1_joiner, + serde_json::json!({ "code": v1_code }).to_string(), + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(read_json(response).await["status"], "joined"); + assert_eq!( + channel_roles(&state, community, &channels, &v1_joiner).await, + [Some("member".to_string()), None, None, None] + ); + assert_eq!( + added_notification_count(&state, community, &v1_joiner_hex).await, + 1 + ); + } + #[tokio::test] #[ignore = "requires Postgres"] async fn owner_mints_and_new_pubkey_claims() { @@ -1618,24 +1873,27 @@ mod postgres_tests { assert_eq!(response.status(), StatusCode::FORBIDDEN); } - /// Forge an already-expired invite payload signed with the relay's derived - /// invite key. `mint_invite` clamps ttl to 60s minimum, so the only way to - /// produce an expired code is to build the payload by hand at the token - /// layer. - fn forge_expired_invite_code( - state: &AppState, - community: buzz_core::CommunityId, - seconds_ago: u64, - ) -> String { - let key = derive_invite_key(&state.relay_keypair); - let now = std::time::SystemTime::now() + fn unix_now() -> u64 { + std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .expect("time") - .as_secs(); + .as_secs() + } + + /// Forge a v1 HMAC invite payload expiring at `expires_at`, signed with + /// the relay's derived invite key. `mint_invite` only mints v2 codes now + /// and clamps ttl to 60s minimum, so v1 and expired codes are built by + /// hand at the token layer. + fn forge_v1_invite_code( + state: &AppState, + community: buzz_core::CommunityId, + expires_at: u64, + ) -> String { + let key = derive_invite_key(&state.relay_keypair); let payload = InvitePayload { c: community.as_uuid().to_string(), r: "member".to_string(), - e: now.saturating_sub(seconds_ago), + e: expires_at, n: "test-nonce".to_string(), }; let payload_bytes = serde_json::to_vec(&payload).expect("payload serializes"); @@ -1667,7 +1925,7 @@ mod postgres_tests { .await .expect("lookup") .expect("community exists"); - let code = forge_expired_invite_code(&state, community.id, 10); + let code = forge_v1_invite_code(&state, community.id, unix_now() - 10); let body = serde_json::json!({ "code": code }).to_string(); let response = post_json(state.clone(), &host, "/api/invites/claim", &joiner, body).await; diff --git a/crates/buzz-relay/src/handlers/relay_admin.rs b/crates/buzz-relay/src/handlers/relay_admin.rs index 16648595f8..9856e3fd92 100644 --- a/crates/buzz-relay/src/handlers/relay_admin.rs +++ b/crates/buzz-relay/src/handlers/relay_admin.rs @@ -25,7 +25,8 @@ use buzz_core::tenant::TenantContext; use buzz_db::relay_members::RemoveResult; use crate::handlers::side_effects::{ - fan_out_nip43_membership_list, publish_nip43_member_added, publish_nip43_member_removed, + auto_join_default_channels, fan_out_nip43_membership_list, publish_nip43_member_added, + publish_nip43_member_removed, }; use crate::state::AppState; @@ -362,6 +363,7 @@ async fn execute_relay_admin_command( if let Err(e) = publish_nip43_member_added(tenant, state, &target_hex).await { warn!(error = %e, "failed to publish NIP-43 member added event"); } + auto_join_default_channels(tenant, state, &target_hex).await; } fan_out_nip43_membership_list(tenant, state, write.snapshot).await; } @@ -988,6 +990,108 @@ mod postgres_tests { assert!(!bystander_socket.cancellation_token().is_cancelled()); } + /// A kind:9030 add joins the new relay member to the community's + /// effective default channels, once: the flagged open channel gains them + /// as a member with one kind:44100; the unflagged channel does not. A + /// kind:9022 leave followed by a repeat kind:9030 (an idempotent no-op on + /// the roster) does not re-add them. Mutation: delete the + /// `auto_join_default_channels` call → RED. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn adding_a_member_auto_joins_default_channels() { + use crate::handlers::group_state_postgres_tests::{ + create_channel, edit_channel, http_auth, + }; + use crate::handlers::ingest::ingest_event; + + let host = format!("add-auto-join-{}.example", uuid::Uuid::new_v4().simple()); + let (state, tenant) = workspace_profile_test_state(&host, true).await; + let (owner, target) = (Keys::generate(), Keys::generate()); + state + .db + .add_relay_member( + tenant.community(), + &owner.public_key().to_hex(), + "owner", + None, + &nostr::Keys::generate(), + ) + .await + .map(|write| write.outcome) + .expect("seed owner"); + let flagged = create_channel(&state, &tenant, &owner, &[]).await; + let control = create_channel(&state, &tenant, &owner, &[]).await; + edit_channel(&state, &tenant, &owner, flagged, &[&["auto_join", "true"]]) + .await + .expect("relay owner flags channel"); + + let roles = |keys: &Keys| { + let state = state.clone(); + let community = tenant.community(); + let pubkey = keys.public_key().to_bytes(); + async move { + let mut roles = Vec::new(); + for channel_id in [flagged, control] { + roles.push( + state + .db + .get_member_role(community, channel_id, &pubkey) + .await + .expect("member role"), + ); + } + roles + } + }; + let added_notifications = || { + let state = state.clone(); + let community = tenant.community(); + let target_hex = target.public_key().to_hex(); + async move { + state + .db + .count_events(&buzz_db::EventQuery { + kinds: Some(vec![buzz_core::kind::KIND_MEMBER_ADDED_NOTIFICATION as i32]), + p_tag_hex: Some(target_hex), + global_only: true, + ..buzz_db::EventQuery::for_community(community) + }) + .await + .expect("count 44100") + } + }; + let add = || { + EventBuilder::new(Kind::Custom(RELAY_ADMIN_ADD_MEMBER as u16), "") + .tags(vec![Tag::public_key(target.public_key())]) + .sign_with_keys(&owner) + .expect("sign 9030") + }; + + handle_relay_admin_event(&tenant, &state, &add()) + .await + .expect("owner adds member"); + assert_eq!(roles(&target).await, [Some("member".to_string()), None]); + assert_eq!(added_notifications().await, 1); + + let leave = EventBuilder::new(Kind::Custom(9022), "") + .tags([Tag::parse(["h", &flagged.to_string()]).expect("h tag")]) + .sign_with_keys(&target) + .expect("sign 9022"); + let left = ingest_event(&state, &tenant, leave, http_auth(&target)) + .await + .expect("ingest 9022"); + assert!(left.accepted, "{:?}", left.message); + handle_relay_admin_event(&tenant, &state, &add()) + .await + .expect("repeat add is a roster no-op"); + assert_eq!( + roles(&target).await, + [None, None], + "a repeat kind:9030 must not resurrect a kind:9022 leave" + ); + assert_eq!(added_notifications().await, 1); + } + /// A pod-local root socket registered and bound to `keys`, as root AUTH /// leaves it. fn bound_root_socket( diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index 8737a70fd3..ef797505f6 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -2679,15 +2679,25 @@ async fn handle_delete_group( Ok(()) } -async fn handle_join_request( - tenant: &TenantContext, - event: &Event, - state: &Arc, -) -> anyhow::Result<()> { - let channel_id = - extract_h_tag_channel(event).ok_or_else(|| anyhow::anyhow!("missing h tag"))?; - let actor_bytes = event.pubkey.to_bytes().to_vec(); +/// What [`join_channel_as_self`] did for the member. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum SelfJoinOutcome { + /// The member was added and every join side effect was emitted. + Joined, + /// The member was already active in the channel; nothing changed. + AlreadyMember, +} +/// Join `pubkey` to an open, unarchived channel as a self-join: no inviter, +/// the channel's `join_role`, and the kind:40099, 39000/39002 and 44100 +/// side effects of a kind:9021. Shared by the kind:9021 handler and the +/// default-channel auto-join on new community memberships. +pub(crate) async fn join_channel_as_self( + tenant: &TenantContext, + state: &Arc, + channel_id: Uuid, + pubkey: &[u8], +) -> anyhow::Result { // Only open channels allow self-join via kind:9021. let channel = state .db @@ -2699,15 +2709,17 @@ async fn handle_join_request( "channel is private — request an invitation" )); } + if channel.archived_at.is_some() { + return Err(anyhow::anyhow!("channel is archived")); + } // Skip if already an active member — prevents duplicate join notifications. // Fail closed on DB errors rather than falling through to add_member. if state - .is_member_cached(tenant.community(), channel_id, &actor_bytes) + .is_member_cached(tenant.community(), channel_id, pubkey) .await? { - info!(channel = %channel_id, "kind:9021 join — already a member, skipping"); - return Ok(()); + return Ok(SelfJoinOutcome::AlreadyMember); } // A self-join gets the channel's `join_role`, read by `put_member` under @@ -2715,11 +2727,11 @@ async fn handle_join_request( // channel, where a member must then make them a member. state .db - .put_member(tenant.community(), channel_id, &actor_bytes, None, None) + .put_member(tenant.community(), channel_id, pubkey, None, None) .await?; - state.invalidate_membership(tenant, channel_id, &actor_bytes); + state.invalidate_membership(tenant, channel_id, pubkey); - let actor_hex = hex::encode(&actor_bytes); + let actor_hex = hex::encode(pubkey); emit_system_message( tenant, state, @@ -2741,8 +2753,8 @@ async fn handle_join_request( tenant, state, channel_id, - &actor_bytes, - &actor_bytes, + pubkey, + pubkey, buzz_core::kind::KIND_MEMBER_ADDED_NOTIFICATION, ) .await @@ -2750,7 +2762,64 @@ async fn handle_join_request( warn!("membership notification for join failed: {e}"); } - info!(channel = %channel_id, "kind:9021 join processed"); + Ok(SelfJoinOutcome::Joined) +} + +/// Join a new community member to every effective `auto_join` channel, as +/// kind:9021 self-joins. Runs inline at the membership write sites (invite +/// claim, kind:9030) and nowhere else, so a later kind:9022 leave stands. +/// +/// Best effort: the relay membership has already committed, so a channel +/// that fails is logged and counted, never retried, and never fails the +/// caller. The `auto_join` tag on kind:39000 is the durable record a client +/// reconciles against. +pub(crate) async fn auto_join_default_channels( + tenant: &TenantContext, + state: &Arc, + pubkey_hex: &str, +) { + let Ok(pubkey) = hex::decode(pubkey_hex) else { + warn!(member = %pubkey_hex, "auto-join skipped: member pubkey is not hex"); + return; + }; + let channels = match state.db.list_auto_join_channels(tenant.community()).await { + Ok(channels) => channels, + Err(e) => { + warn!(member = %pubkey_hex, error = %e, "auto-join skipped: listing default channels failed"); + metrics::counter!("buzz_auto_join_failures_total").increment(1); + return; + } + }; + for channel in channels { + match join_channel_as_self(tenant, state, channel.id, &pubkey).await { + Ok(SelfJoinOutcome::Joined) => { + metrics::counter!("buzz_auto_join_channels_total").increment(1); + info!(channel = %channel.id, member = %pubkey_hex, "auto-joined default channel"); + } + Ok(SelfJoinOutcome::AlreadyMember) => {} + Err(e) => { + metrics::counter!("buzz_auto_join_failures_total").increment(1); + warn!(channel = %channel.id, member = %pubkey_hex, error = %e, "auto-join of default channel failed"); + } + } + } +} + +async fn handle_join_request( + tenant: &TenantContext, + event: &Event, + state: &Arc, +) -> anyhow::Result<()> { + let channel_id = + extract_h_tag_channel(event).ok_or_else(|| anyhow::anyhow!("missing h tag"))?; + match join_channel_as_self(tenant, state, channel_id, &event.pubkey.to_bytes()).await? { + SelfJoinOutcome::AlreadyMember => { + info!(channel = %channel_id, "kind:9021 join — already a member, skipping"); + } + SelfJoinOutcome::Joined => { + info!(channel = %channel_id, "kind:9021 join processed"); + } + } Ok(()) } diff --git a/crates/buzz-test-client/tests/e2e_relay.rs b/crates/buzz-test-client/tests/e2e_relay.rs index ab1c4772af..39d7d74a6d 100644 --- a/crates/buzz-test-client/tests/e2e_relay.rs +++ b/crates/buzz-test-client/tests/e2e_relay.rs @@ -302,6 +302,102 @@ async fn test_invite_mint_and_claim_admits_new_pubkey() { ); } +/// Claiming an invite joins the new member to the community's default +/// channels: the relay owner flags one, the claimer receives its kind:44100 +/// and is on its kind:39002 roster, and a repeat claim is a no-op. +#[tokio::test] +#[ignore] +async fn test_invite_claim_auto_joins_default_channel() { + let url = relay_url(); + let relay_owner = Keys::generate(); + let joiner = Keys::generate(); + let joiner_hex = joiner.public_key().to_hex(); + seed_relay_owner(&relay_owner).await; + let channel_id = create_test_channel(&relay_owner).await; + assert_eq!( + set_metadata_ws(&url, &channel_id, &relay_owner, "auto_join", "true").await, + (true, String::new()) + ); + + let mut joiner_client = BuzzTestClient::connect(&url, &joiner) + .await + .expect("connect joiner"); + let sid = sub_id("auto-join-notif"); + let filter = Filter::new() + .kind(Kind::Custom(44100)) + .custom_tag(SingleLetterTag::lowercase(Alphabet::P), joiner_hex.as_str()) + .since(nostr::Timestamp::now() - 5u64); + joiner_client + .subscribe(&sid, vec![filter]) + .await + .expect("subscribe to membership notifications"); + joiner_client + .collect_until_eose(&sid, Duration::from_secs(5)) + .await + .expect("EOSE for membership sub"); + + let mint_response = invite_post(&relay_owner, "/api/invites", "{}").await; + assert_eq!(mint_response.status(), reqwest::StatusCode::OK); + let mint_json: serde_json::Value = mint_response.json().await.expect("mint JSON"); + let code = mint_json + .get("code") + .and_then(serde_json::Value::as_str) + .expect("mint response includes code"); + let claim_body = serde_json::json!({ "code": code }).to_string(); + let claim_response = invite_post(&joiner, "/api/invites/claim", &claim_body).await; + assert_eq!(claim_response.status(), reqwest::StatusCode::OK); + let claim_json: serde_json::Value = claim_response.json().await.expect("claim JSON"); + assert_eq!( + claim_json.get("status").and_then(serde_json::Value::as_str), + Some("joined") + ); + + // Other tests may leave their own default channels flagged on this + // shared relay; wait for the notification about ours. + let deadline = tokio::time::Instant::now() + Duration::from_secs(5); + loop { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + let msg = joiner_client + .recv_event(remaining) + .await + .expect("recv kind:44100 notification for the default channel"); + if let RelayMessage::Event { event, .. } = msg { + let channel = event + .tags + .iter() + .find(|tag| tag.as_slice().first().map(String::as_str) == Some("h")) + .and_then(|tag| tag.as_slice().get(1).cloned()); + if channel.as_deref() == Some(channel_id.as_str()) { + break; + } + } + } + joiner_client.disconnect().await.ok(); + assert_eq!( + member_role(&url, &relay_owner, &channel_id, &joiner_hex) + .await + .as_deref(), + Some("member"), + "the relay-signed kind:39002 roster lists the claimer" + ); + + let repeat_response = invite_post(&joiner, "/api/invites/claim", &claim_body).await; + assert_eq!(repeat_response.status(), reqwest::StatusCode::OK); + let repeat_json: serde_json::Value = repeat_response.json().await.expect("repeat claim JSON"); + assert_eq!( + repeat_json + .get("status") + .and_then(serde_json::Value::as_str), + Some("already_member") + ); + + // Leave the shared relay as found. + assert_eq!( + set_metadata_ws(&url, &channel_id, &relay_owner, "auto_join", "false").await, + (true, String::new()) + ); +} + #[tokio::test] #[ignore] async fn test_invite_claim_rejects_invalid_code() {