From 1affa648b9b41f678475154ce7d88c03185da072 Mon Sep 17 00:00:00 2001 From: BachirSaaS <246364794+BachirSaaS@users.noreply.github.com> Date: Wed, 23 Sep 2026 21:17:24 -0400 Subject: [PATCH] fix(relay): allow deleting an archived channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An archived channel (e.g. an ephemeral huddle expired by the reaper) could never be deleted: both the admin-event validator and the ingest archive gate only excepted kind:9002 unarchive, so kind:9008 delete returned 400 'invalid: channel is archived' and the channel was stranded in the owner's list forever (#2954, #7596). 'Archive then delete' is the natural flow — allow kind:9008 through both guards alongside the existing unarchive exception. Desktop's unarchive (kind:9002 with archived=false) already matches the accepted shape; only the delete half was broken. Signed-off-by: BachirSaaS --- crates/buzz-relay/src/handlers/ingest.rs | 7 +++++-- crates/buzz-relay/src/handlers/side_effects.rs | 10 +++++++--- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index 9b95218a026..b95873952bf 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -2781,14 +2781,17 @@ async fn ingest_event_inner( } if channel_id.is_some() { - // Allow kind:9002 with archived=false (unarchive operation) + // Allow kind:9002 with archived=false (unarchive operation) and + // kind:9008 (delete group) — deleting an archived (e.g. reaper-expired + // huddle) channel is the natural escape hatch and must not 400 (#2954). let is_unarchive = kind_u32 == KIND_NIP29_EDIT_METADATA && event.tags.iter().any(|t| { let parts = t.as_slice(); parts.len() >= 2 && parts[0] == "archived" && parts[1] == "false" }); + let is_channel_delete = kind_u32 == KIND_NIP29_DELETE_GROUP; - if !is_unarchive { + if !is_unarchive && !is_channel_delete { if let Some(channel) = &channel_row { if channel.archived_at.is_some() { return Err(IngestError::Rejected("invalid: channel is archived".into())); diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index f63c174d961..7cab28ce07c 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -459,8 +459,12 @@ pub async fn validate_admin_event( let actor_bytes = event.pubkey.to_bytes().to_vec(); - // Reject mutations on archived channels — except kind:9002 with archived=false - // (unarchive), which must be allowed through so the channel can be restored. + // Reject mutations on archived channels — except: + // - kind:9002 with archived=false (unarchive), so the channel can be + // restored; + // - kind:9008 (delete group), so an archived (e.g. reaper-expired + // huddle) channel can still be deleted — "archive then delete" is the + // natural flow and blocking it strands the channel forever (#2954). let channel = state .db .get_channel_for_event_write(tenant.community(), channel_id) @@ -471,7 +475,7 @@ pub async fn validate_admin_event( let parts = t.as_slice(); parts.len() >= 2 && parts[0] == "archived" && parts[1] == "false" }); - if channel.archived_at.is_some() && !is_unarchive_request { + if channel.archived_at.is_some() && !is_unarchive_request && kind != 9008 { return Err(anyhow::anyhow!("channel is archived")); }