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
8 changes: 6 additions & 2 deletions crates/buzz-relay/src/handlers/ingest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1970,14 +1970,18 @@ 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) — a valid terminal transition from archived
// state; validate_admin_event still enforces kind:9008 authorization
// (owner, or the owning human of an owner-role agent).
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_delete = kind_u32 == KIND_NIP29_DELETE_GROUP;

if !is_unarchive {
if !is_unarchive && !is_delete {
if let Some(channel) = &channel_row {
if channel.archived_at.is_some() {
return Err(IngestError::Rejected("invalid: channel is archived".into()));
Expand Down
16 changes: 10 additions & 6 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 Down Expand Up @@ -320,18 +320,22 @@ 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.
// (unarchive), which must be allowed through so the channel can be restored,
// and kind:9008 (delete), a valid terminal transition from archived state;
// its match arm below still enforces kind:9008 authorization (owner, or
// the owning human of an owner-role agent).
let channel = state
.db
.get_channel(tenant.community(), channel_id)
.await
.map_err(|_| anyhow::anyhow!("channel not found"))?;
let is_unarchive_request = kind == 9002
let is_unarchive_request = kind == KIND_NIP29_EDIT_METADATA
&& 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 {
let is_delete_request = kind == KIND_NIP29_DELETE_GROUP;
if channel.archived_at.is_some() && !is_unarchive_request && !is_delete_request {
return Err(anyhow::anyhow!("channel is archived"));
}

Expand Down
131 changes: 131 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,137 @@ async fn test_unarchive_emits_member_added_notification() {
ws.disconnect().await.expect("disconnect");
}

/// Regression for #2954: an owner must be able to delete an archived channel.
/// Both archived-channel guards used to exempt only kind:9002 unarchive, so an
/// owner's kind:9008 DELETE_GROUP bounced with "channel is archived" — the
/// user-visible case being auto-archived huddle channels. Kind:9008 now passes
/// the archived gates while its own owner-only validation still rejects
/// everyone else.
#[tokio::test]
#[ignore]
async fn test_owner_can_delete_archived_channel() {
let url = relay_url();

let owner_keys = Keys::generate();
let outsider_keys = Keys::generate();

// Creating the channel makes the owner its sole member.
let channel_id = create_test_channel(&owner_keys).await;

let mut ws = BuzzTestClient::connect(&url, &owner_keys)
.await
.expect("connect as owner");

// Archive → unarchive → re-archive via kind:9002 edit-metadata. The
// middle step proves the 9002 archived=false exemption still passes the
// archived guards alongside the new 9008 exemption. Each event carries a
// unique nonce tag: created_at has second resolution, so back-to-back
// toggles with identical tags produce identical event IDs and the relay
// silently drops the repeat as a duplicate (accepted=true, side effects
// skipped) — leaving the channel NOT archived and this test vacuous.
for (i, archived) in ["true", "false", "true"].into_iter().enumerate() {
let event = EventBuilder::new(Kind::Custom(9002), "")
.tags([
Tag::parse(["h", &channel_id]).unwrap(),
Tag::parse(["archived", archived]).unwrap(),
Tag::parse(["nonce", &i.to_string()]).unwrap(),
])
.sign_with_keys(&owner_keys)
.unwrap();
let ok = ws.send_event(event).await.expect("send kind 9002");
assert!(
ok.accepted,
"edit-metadata archived={archived} rejected: {}",
ok.message
);
assert!(
!ok.message.starts_with("duplicate"),
"edit-metadata archived={archived} deduplicated — side effects skipped: {}",
ok.message
);
}

// Prove the channel is genuinely archived and the guard is armed before
// exercising the delete: a plain metadata edit must bounce off it.
let name_edit = EventBuilder::new(Kind::Custom(9002), "")
.tags([
Tag::parse(["h", &channel_id]).unwrap(),
Tag::parse(["name", "renamed-while-archived"]).unwrap(),
])
.sign_with_keys(&owner_keys)
.unwrap();
let guarded = ws.send_event(name_edit).await.expect("send kind 9002");
assert!(
!guarded.accepted,
"metadata edit on archived channel should be rejected"
);
assert!(
guarded.message.contains("channel is archived"),
"metadata edit should hit the archived guard, got: {}",
guarded.message
);

// A non-owner's kind:9008 must still be rejected — by the owner check,
// not swallowed by the archived-channel guard.
let mut outsider_ws = BuzzTestClient::connect(&url, &outsider_keys)
.await
.expect("connect as outsider");
let non_owner_delete = EventBuilder::new(Kind::Custom(9008), "")
.tags([Tag::parse(["h", &channel_id]).unwrap()])
.sign_with_keys(&outsider_keys)
.unwrap();
let rejected = outsider_ws
.send_event(non_owner_delete)
.await
.expect("send non-owner kind 9008");
assert!(
!rejected.accepted,
"non-owner delete of archived channel must be rejected"
);
assert!(
rejected.message.contains("only owner can delete group"),
"non-owner delete should fail the owner check, got: {}",
rejected.message
);

// The owner's kind:9008 must pass the archived gates and soft-delete.
let owner_delete = EventBuilder::new(Kind::Custom(9008), "")
.tags([Tag::parse(["h", &channel_id]).unwrap()])
.sign_with_keys(&owner_keys)
.unwrap();
let ok = ws.send_event(owner_delete).await.expect("send kind 9008");
assert!(
ok.accepted,
"owner delete of archived channel rejected: {}",
ok.message
);

// The channel is soft-deleted: a follow-up admin event no longer finds it.
let unarchive = EventBuilder::new(Kind::Custom(9002), "")
.tags([
Tag::parse(["h", &channel_id]).unwrap(),
Tag::parse(["archived", "false"]).unwrap(),
])
.sign_with_keys(&owner_keys)
.unwrap();
let gone = ws
.send_event(unarchive)
.await
.expect("send post-delete kind 9002");
assert!(
!gone.accepted,
"channel should be deleted, but unarchive was accepted"
);
assert!(
gone.message.contains("channel not found"),
"post-delete admin event should fail with channel not found, got: {}",
gone.message
);

outsider_ws.disconnect().await.expect("disconnect outsider");
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