Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 7 additions & 15 deletions crates/buzz-relay/src/handlers/ingest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
73 changes: 62 additions & 11 deletions crates/buzz-relay/src/handlers/side_effects.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -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<AppState>,
Expand Down Expand Up @@ -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"));
}

Expand Down Expand Up @@ -3373,6 +3379,51 @@ fn topic_for_subscription(channel_id: Option<Uuid>) -> EventTopic {
mod tests {
use super::*;

fn admin_event(kind: u32, tags: impl IntoIterator<Item = Tag>) -> 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 =
Expand Down
41 changes: 41 additions & 0 deletions crates/buzz-test-client/tests/e2e_relay.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down