From eb7a71785f2ebd54acbb4329d6b57e2ecbc73404 Mon Sep 17 00:00:00 2001 From: Hyein Cho Date: Thu, 10 Sep 2026 22:02:05 +0100 Subject: [PATCH] fix(relay): allow deleting archived channels Archive is a write-lock, not a tombstone. Owners could unarchive (kind:9002 archived=false) but not delete (kind:9008), so ephemeral huddle backing channels archived on hang-up or by the TTL reaper could not be removed. Signed-off-by: Hyein Cho --- crates/buzz-relay/src/handlers/ingest.rs | 12 +- .../buzz-relay/src/handlers/side_effects.rs | 106 +++++++++++++++++- crates/buzz-test-client/tests/e2e_relay.rs | 38 +++++++ 3 files changed, 141 insertions(+), 15 deletions(-) diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index 1b162cc3293..8b526338dde 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -3031,15 +3031,9 @@ async fn ingest_event_inner( } if channel_id.is_some() { - // kind:9002 is not checked here: its atomic write decides the - // archive state under the channel row lock, after its duplicate - // check, so a resend that races the first commit is a duplicate. - let is_edit_metadata = kind_u32 == KIND_NIP29_EDIT_METADATA; - - // A member may still leave an archived channel by themselves. - let is_self_departure = crate::handlers::side_effects::is_self_departure(&event); - - if !is_edit_metadata && !is_self_departure { + // Metadata edits defer archive-state validation to the atomic write; + // delete-group and self-departure may pass the archived-channel gate. + if !crate::handlers::side_effects::allow_event_on_archived_channel(kind_u32, &event) { if let Some(channel) = &channel_row { if channel.archived_at.is_some() { return Err(match target_denial { diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index c5814055917..8a20314acbe 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -28,6 +28,17 @@ pub fn is_admin_kind(kind: u32) -> bool { matches!(kind, 9000..=9022) } +/// Whether an event may pass the archived-channel pre-storage gates. +/// +/// Metadata edits (`kind:9002`) defer archive-state validation to their atomic +/// write, under the channel row lock and after the duplicate check. Deleting +/// the channel (`kind:9008`) and self-departure are also allowed through, still +/// subject to their owner and last-owner checks. Ephemeral huddle backing +/// channels are archived on hang-up and by the TTL reaper, not deleted. +pub(crate) fn allow_event_on_archived_channel(kind: u32, event: &Event) -> bool { + matches!(kind, 9002 | 9008) || is_self_departure(event) +} + /// Check if a kind triggers side effects after storage. /// /// NOTE: kind:7 (reaction) is intentionally excluded — dedup and DB writes are @@ -579,17 +590,16 @@ pub async fn validate_admin_event( let actor_bytes = event.pubkey.to_bytes().to_vec(); - // Reject mutations on archived channels, except a member leaving by - // themselves (still subject to the last-owner rule). kind:9002 is not - // checked here: its atomic write decides the archive state under the - // channel row lock, after its duplicate check, so a resend that races - // the first commit is a duplicate and not a refusal. + // Reject archived-channel mutations except delete-group and self-departure + // (still subject to owner and last-owner checks). Metadata edits (9002) + // defer archive-state validation to the atomic write under the channel row + // lock, after its duplicate check, so a racing resend is a duplicate. let channel = state .db .get_channel_for_event_write(tenant.community(), channel_id) .await .map_err(|_| anyhow::anyhow!("channel not found"))?; - if kind != 9002 && channel.archived_at.is_some() && !is_self_departure(event) { + if channel.archived_at.is_some() && !allow_event_on_archived_channel(kind, event) { return Err(anyhow::anyhow!("channel is archived")); } @@ -4117,4 +4127,88 @@ mod tests { assert!(actor_is_channel_owner_or_admin(&members, &actor)); } + + fn signed_event(kind: u16, tags: Vec) -> Event { + EventBuilder::new(Kind::Custom(kind), "") + .tags(tags) + .sign_with_keys(&nostr::Keys::generate()) + .expect("sign") + } + + fn h_tag(channel: &str) -> Tag { + Tag::parse(["h", channel]).expect("h tag") + } + + #[test] + fn archived_channel_gate_allows_delete_group() { + let channel = Uuid::new_v4().to_string(); + let pubkey = "a".repeat(64); + let event_id = "b".repeat(64); + let keys = nostr::Keys::generate(); + let self_departure = EventBuilder::new(Kind::Custom(9001), "") + .tags([ + h_tag(&channel), + Tag::parse(["p", &keys.public_key().to_hex()]).expect("self p tag"), + ]) + .allow_self_tagging() + .sign_with_keys(&keys) + .expect("sign self-departure"); + + let allowed = [ + signed_event(9008, vec![h_tag(&channel)]), + signed_event( + 9002, + vec![ + h_tag(&channel), + Tag::parse(["archived", "false"]).expect("tag"), + ], + ), + signed_event( + 9002, + vec![ + h_tag(&channel), + Tag::parse(["archived", "true"]).expect("tag"), + ], + ), + signed_event( + 9002, + vec![ + h_tag(&channel), + Tag::parse(["name", "still-locked"]).expect("tag"), + ], + ), + signed_event(9022, vec![h_tag(&channel)]), + self_departure, + ]; + for event in &allowed { + assert!( + allow_event_on_archived_channel(event_kind_u32(event), event), + "expected kind {} to pass", + event_kind_u32(event) + ); + } + + let rejected = [ + signed_event( + 9000, + vec![h_tag(&channel), Tag::parse(["p", &pubkey]).expect("tag")], + ), + signed_event( + 9001, + vec![h_tag(&channel), Tag::parse(["p", &pubkey]).expect("tag")], + ), + signed_event( + 9005, + vec![h_tag(&channel), Tag::parse(["e", &event_id]).expect("tag")], + ), + signed_event(1, vec![h_tag(&channel)]), + ]; + for event in &rejected { + assert!( + !allow_event_on_archived_channel(event_kind_u32(event), event), + "expected kind {} to stay rejected on an archived channel", + event_kind_u32(event) + ); + } + } } diff --git a/crates/buzz-test-client/tests/e2e_relay.rs b/crates/buzz-test-client/tests/e2e_relay.rs index 7f39eb7040f..d3a8953d71e 100644 --- a/crates/buzz-test-client/tests/e2e_relay.rs +++ b/crates/buzz-test-client/tests/e2e_relay.rs @@ -1324,6 +1324,44 @@ async fn test_unarchive_emits_member_added_notification() { ws.disconnect().await.expect("disconnect"); } +/// Deleting an archived channel must succeed without an unarchive first. +/// Archive is a write-lock, not a tombstone — kind:9008 is an allowed exception, +/// matching the huddle/ephemeral-reaper path that archives backing channels. +#[tokio::test] +#[ignore] +async fn test_owner_can_delete_archived_channel() { + let url = relay_url(); + let owner_keys = Keys::generate(); + let channel_id = create_test_channel(&owner_keys).await; + + let mut ws = BuzzTestClient::connect(&url, &owner_keys) + .await + .expect("connect as owner"); + + let archive = EventBuilder::new(Kind::Custom(9002), "") + .tags([ + Tag::parse(["h", &channel_id]).unwrap(), + Tag::parse(["archived", "true"]).unwrap(), + ]) + .sign_with_keys(&owner_keys) + .unwrap(); + let ok = ws.send_event(archive).await.expect("send archive"); + assert!(ok.accepted, "archive rejected: {}", ok.message); + + let delete = EventBuilder::new(Kind::Custom(9008), "") + .tags([Tag::parse(["h", &channel_id]).unwrap()]) + .sign_with_keys(&owner_keys) + .unwrap(); + let ok = ws.send_event(delete).await.expect("send delete-group"); + assert!( + ok.accepted, + "delete of archived channel rejected: {}", + ok.message + ); + + ws.disconnect().await.expect("disconnect"); +} + /// NIP-29 kind 9000 (PUT_USER): "nobody" policy blocks a third party from adding the agent. #[tokio::test] #[ignore]