diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index fcd0d70728..6a4c8f6712 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -2309,21 +2309,13 @@ async fn ingest_event_inner( .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; } - if channel_id.is_some() { - // Allow kind:9002 with archived=false (unarchive operation) - 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" - }); - - if !is_unarchive { - if let Some(channel) = &channel_row { - if channel.archived_at.is_some() { - return Err(IngestError::Rejected("invalid: channel is archived".into())); - } - } - } + if channel_id.is_some() + && !crate::handlers::side_effects::archived_channel_allows_event(kind_u32, &event) + && channel_row + .as_ref() + .is_some_and(|channel| channel.archived_at.is_some()) + { + return Err(IngestError::Rejected("invalid: channel is archived".into())); } // NIP-09: kind:5 may reference targets via `e` tag (regular events) OR diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index 660a55fef3..d4d0ffc0b4 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -9,9 +9,9 @@ use uuid::Uuid; use buzz_core::kind::{ event_kind_u32, is_parameterized_replaceable, KIND_AGENT_PROFILE, KIND_DM_VISIBILITY, KIND_GIT_REPO_ANNOUNCEMENT, KIND_IA_ARCHIVED, KIND_IA_ARCHIVED_LIST, KIND_IA_UNARCHIVED, - KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, KIND_NIP29_GROUP_ADMINS, - KIND_NIP29_GROUP_MEMBERS, KIND_NIP29_GROUP_METADATA, KIND_NIP43_MEMBERSHIP_LIST, KIND_REACTION, - KIND_THREAD_SUMMARY, + KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, KIND_NIP29_DELETE_GROUP, + KIND_NIP29_EDIT_METADATA, KIND_NIP29_GROUP_ADMINS, KIND_NIP29_GROUP_MEMBERS, + KIND_NIP29_GROUP_METADATA, KIND_NIP43_MEMBERSHIP_LIST, KIND_REACTION, KIND_THREAD_SUMMARY, }; use buzz_core::StoredEvent; use buzz_db::channel::{MemberRecord, MemberRole}; @@ -36,6 +36,17 @@ pub fn is_side_effect_kind(kind: u32) -> bool { matches!(kind, 0 | 5 | 9000..=9022 | KIND_GIT_REPO_ANNOUNCEMENT | KIND_AGENT_PROFILE | 41001..=41003 | 40099) } +/// Whether an event is one of the explicit lifecycle changes allowed for an +/// archived channel: restoring it or deleting it permanently. +pub(crate) fn archived_channel_allows_event(kind: u32, event: &Event) -> bool { + kind == KIND_NIP29_DELETE_GROUP + || (kind == KIND_NIP29_EDIT_METADATA + && event.tags.iter().any(|tag| { + let parts = tag.as_slice(); + parts.len() >= 2 && parts[0] == "archived" && parts[1] == "false" + })) +} + async fn evict_live_channel_subscriptions( tenant: &TenantContext, state: &Arc, @@ -319,19 +330,14 @@ 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. + // Archived channels are read-only except for restoring or permanently + // deleting them. let channel = state .db .get_channel(tenant.community(), channel_id) .await .map_err(|_| anyhow::anyhow!("channel not found"))?; - let is_unarchive_request = kind == 9002 - && event.tags.iter().any(|t| { - 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() && !archived_channel_allows_event(kind, event) { return Err(anyhow::anyhow!("channel is archived")); } @@ -3373,6 +3379,51 @@ fn topic_for_subscription(channel_id: Option) -> EventTopic { mod tests { use super::*; + fn admin_event(kind: u32, tags: impl IntoIterator) -> Event { + EventBuilder::new(Kind::Custom(kind as u16), "") + .tags(tags) + .sign_with_keys(&nostr::Keys::generate()) + .expect("sign admin event") + } + + #[test] + fn archived_channel_allows_permanent_deletion() { + let event = admin_event(KIND_NIP29_DELETE_GROUP, []); + + assert!(archived_channel_allows_event( + KIND_NIP29_DELETE_GROUP, + &event + )); + } + + #[test] + fn archived_channel_allows_unarchive_but_not_archive() { + let unarchive = admin_event( + KIND_NIP29_EDIT_METADATA, + [Tag::parse(["archived", "false"]).expect("unarchive tag")], + ); + let archive = admin_event( + KIND_NIP29_EDIT_METADATA, + [Tag::parse(["archived", "true"]).expect("archive tag")], + ); + + assert!(archived_channel_allows_event( + KIND_NIP29_EDIT_METADATA, + &unarchive + )); + assert!(!archived_channel_allows_event( + KIND_NIP29_EDIT_METADATA, + &archive + )); + } + + #[test] + fn archived_channel_rejects_other_admin_mutations() { + let event = admin_event(9000, []); + + assert!(!archived_channel_allows_event(9000, &event)); + } + #[test] fn delete_tombstone_omits_absent_moderation_metadata() { let content = diff --git a/crates/buzz-test-client/tests/e2e_relay.rs b/crates/buzz-test-client/tests/e2e_relay.rs index 6f59299ed2..8dfec3c0f6 100644 --- a/crates/buzz-test-client/tests/e2e_relay.rs +++ b/crates/buzz-test-client/tests/e2e_relay.rs @@ -1228,6 +1228,47 @@ async fn test_unarchive_emits_member_added_notification() { ws.disconnect().await.expect("disconnect"); } +/// An owner must be able to permanently delete an archived channel. Archived +/// channels reject ordinary mutations, but deletion is a terminal state change +/// and is exposed directly by every client. +#[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("archive channel"); + 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("delete archived channel"); + assert!( + ok.accepted, + "archived channel deletion 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]