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")); }