feat(relay): auto-join new community members to default channels

Join every new community member into the channels a relay owner or admin
flagged with `channels.auto_join` (R1, d65bf983a), at the moment they
become a member. This is the behaviour half (R2) of default channels:
clients see the flagged channels in their sidebar, unread state and push
with no client work, because the relay runs the kind:9021 body for them.

- `join_channel_as_self` is kind:9021's body extracted from
  `handle_join_request` and returns `Joined | AlreadyMember`: open and
  unarchived checks, skip-if-member, then `put_member(.., None, None)` so
  the channel's `join_role` applies, cache invalidation, the
  `member_joined` kind:40099, 39000/39002 and the kind:44100. The handler
  keeps its exact external behaviour; the archived guard is new in the
  shared body because only the 9021 validator held it before.
- `auto_join_default_channels` iterates `Db::list_auto_join_channels`
  (effective channels only) and calls the body per channel, best effort:
  a failure is logged and counted (`buzz_auto_join_failures_total`), a
  join counted (`buzz_auto_join_channels_total`), and the caller never
  fails, because the relay membership has already committed. No retry
  after that commit: re-running at any later point would resurrect
  kind:9022 leaves, and the kind:39000 `auto_join` tag is the record a
  client reconciles against. This is an accepted Review-Proven Rule 1
  deviation, documented in NOSTR.md.
- Called inline right after `publish_nip43_member_added` at the three
  user-facing join sites: the v2 invite claim `Joined` arm, the v1 claim
  `was_inserted` branch, and kind:9030 when the roster row was inserted.
  Never at login or reconnect. Inline means the kind:39002 roster already
  lists the member when the claim response returns.
- `buzz-admin add-member` is unchanged except for help text noting it
  writes the roster row only; it has no `AppState` to sign 39002/44100.

Tests: `claim_auto_joins_default_channels_once` (Postgres lane) covers a
v2 claim against a flagged, an archived-after-flagging, a
private-after-flagging and an unflagged channel: membership and 39002 in
the flagged one only, one 44100 and one `member_joined` 40099, then a
kind:9022 leave and re-claim that stays `already_member` without re-adding;
the v1 token path; and the shared body's archived refusal directly.
`adding_a_member_auto_joins_default_channels` is the first Postgres test
for kind:9030 add, with the same leave-then-repeat step. Deleting the call
at any of the three sites fails the matching test (verified by mutation).
`test_invite_claim_auto_joins_default_channel` is the e2e smoke, run
against a local relay: the claimer receives the kind:44100 and is on the
kind:39002 roster.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
This commit is contained in:
Matt Toohey
2026-10-26 12:50:42 +11:00
parent da502cd222
commit 9e446e3017
6 changed files with 574 additions and 35 deletions

View File

@@ -72,7 +72,7 @@ PGPASSWORD=buzz_dev psql -h localhost -U buzz -d buzz -c \
| **NIP-10 threads** | ✅ | WS-submitted replies with `["e","<root>","","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.

View File

@@ -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)]

View File

@@ -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::<Value>(&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<Option<String>> {
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;

View File

@@ -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(

View File

@@ -2679,15 +2679,25 @@ async fn handle_delete_group(
Ok(())
}
async fn handle_join_request(
tenant: &TenantContext,
event: &Event,
state: &Arc<AppState>,
) -> 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<AppState>,
channel_id: Uuid,
pubkey: &[u8],
) -> anyhow::Result<SelfJoinOutcome> {
// 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<AppState>,
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<AppState>,
) -> 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(())
}

View File

@@ -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() {