Deleting an auto-archived huddle channel returns 400 "channel is archived" #2954
Description
Describe the bug
Deleting a channel after it has been archived fails with:
relay returned 400 Bad Request: invalid: channel is archived
The user-visible case is an ephemeral huddle channel. When the last participant leaves, the relay automatically archives the huddle channel. Desktop can keep the channel row visible and offers Delete channel; confirming deletion sends NIP-29 DELETE_GROUP (kind 9008), which the relay rejects before reaching the delete handler. The same context menu can show Archive channel even though the relay already considers the channel archived.
The underlying protocol path is broader than huddles: the relay rejects kind 9008 for any archived channel. Both archived-channel guards exempt only an unarchive event (kind 9002 with archived=false), even though the later kind 9008 validator independently enforces owner authorization and the handler performs a soft delete.
Steps to reproduce
- Create a channel as its owner.
- Archive it with kind
9002andarchived=true; ending a huddle so its ephemeral channel auto-archives also reproduces this. - As the same owner, submit a kind
9008delete-group event for that channel. In Desktop, right-click the retained huddle channel, select Delete channel, and confirm. - Observe the HTTP 400 response:
invalid: channel is archived.
Expected behavior
An authorized owner should be able to permanently delete an archived channel. Kind 9008 should pass the archived-channel gates and continue through its existing owner-only validation before the soft-delete handler runs. Other writes to archived channels should remain rejected.
Desktop should also render lifecycle actions from the authoritative archived state, offering Unarchive channel rather than Archive channel when appropriate while still allowing deletion.
Version and platform
- Buzz version: 0.4.26
- OS: macOS 26.5.2
- Relay: commit
c0d7b52af8d5f3572790be697dd2c4bf3c6a3b8f; current upstreammainatc2a4ee711e481bb427d6cf8cd08b2c7329d1508chas the same guards
Logs / additional context
Relevant code paths:
validate_admin_eventrejects archived channels before its kind9008owner check:buzz/crates/buzz-relay/src/handlers/side_effects.rs
Lines 276 to 290 in c0d7b52
// Reject mutations on archived channels — except kind:9002 with archived=false // (unarchive), which must be allowed through so the channel can be restored. 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 { return Err(anyhow::anyhow!("channel is archived")); } - The general ingest guard independently rejects every archived-channel event except unarchive:
buzz/crates/buzz-relay/src/handlers/ingest.rs
Lines 1953 to 1968 in c0d7b52
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())); } } } } - Kind
9008already has owner-only authorization:buzz/crates/buzz-relay/src/handlers/side_effects.rs
Lines 635 to 654 in c0d7b52
9008 => { // DELETE_GROUP: owner only, or the owning human of the channel's agent-owner. let members = state.db.get_members(tenant.community(), channel_id).await?; let actor_member = members.iter().find(|m| m.pubkey == actor_bytes); match actor_member { Some(m) if m.role == "owner" => Ok(()), _ => { // Allow the owning human of any active owner-role agent in the // channel, even when the human is not a channel member — // diverges from kind:9001 intentionally. if actor_owns_any_owner_agent(state, tenant.community(), &members, &actor_bytes) .await? { return Ok(()); } Err(anyhow::anyhow!("only owner can delete group")) } } } 9022 => { - Huddle teardown auto-archives the ephemeral channel:
buzz/crates/buzz-relay/src/audio/handler.rs
Lines 834 to 842 in c0d7b52
info!(channel_id = %channel_id, "audio room empty — auto-ending huddle"); match state .db .archive_channel(tenant.community(), channel_id) .await { Err(e) => { warn!(channel_id = %channel_id, "auto-archive failed, huddle stays alive: {e}");
A focused regression should archive a channel, submit a correctly signed kind 9008 as its owner, and assert that the channel is soft-deleted; a non-owner kind 9008 must still be rejected.
Duplicate search: no existing open or closed issue or pull request was found for this archive-then-delete failure.
Activity
fix(relay): allow owner to delete archived channels
fix(relay): allow owner to delete archived channels
fix(relay): allow owner to delete archived channels
willi-werner commentedon Sep 13, 2026
Still present, and there is a second half to this that I think belongs in the same issue: the TTL reaper never purges, so every huddle leaves a permanent row even if delete were allowed.
Versions: relay 0.2.1 (deploy/compose, Postgres), desktop 0.5.23 (macOS).
What happens
A huddle creates an ephemeral channel (kind 9007,
ttl_seconds = 3600).end_huddlearchives it immediately.The TTL reaper (
reap_expired_ephemeral_channelsincrates/buzz-db/src/store/channel.rs) only setsarchived_at; it never deletes. For a huddle channel it does not even fire, because the channel is already archived.Deleting the archived channel from the desktop app fails with
relay returned 400 Bad Request: invalid: channel is archived— the same rejection this issue describes. The app offers the delete action anyway.Result: every huddle leaves a
huddle-<id>row inchannelsforever, visible in the channel management and archive views. The only way to remove it is unarchive followed by delete, or a manual SQL cleanup on the relay.Expected
Either the relay purges archived ephemeral channels (TTL passed,
archived_atolder than a retention window, e.g. 24 h) together with their events, mentions, thread metadata and memberships, or delete is allowed on an archived channel, at least for ephemeral ones, so the client can remove them.Steps to reproduce
huddle-<id>channel still present. Try to delete it from the desktop app:invalid: channel is archived.Suggestion
A
purge_archived_ephemeral_channelsstep in the existing reaper loop with a configurable retention (default 24 h), plus either hiding the delete action on archived channels in the desktop app or letting the relay accept it. Keeping archived ephemeral channels indefinitely has no user-facing value.Related: #7596 and #7026 look like duplicates of this one from the client side; #4879 covers the kind:39000 divergence that makes the ghost row visible, which is orthogonal to the purge question above.
BMGY commentedon Oct 13, 2026
Reproduced again with a newer Desktop/self-hosted Relay combination, and the guard is still present on current
main.Environment
0.5.28, macOSblock/buzz@sha256:e1865b3f89194d7d6068629d7ff62d3993140c4b920ee3a3f5f81564256a038d5fdb2e53659ee29002545f1022c138fe0b8282f9mainchecked while reproducing:1d9f2c81a5583f12be1239a088e2f0b379292dc3Observed state
An ephemeral huddle backing channel ended up as:
The newest kind
39000metadata for that channel still hadd/name/private/closed/t/ttl/ttl_deadline, but no["archived","true"], matching the projection divergence in #4879. Desktop therefore continued to expose the channel/actions while the relay treated it as archived.Attempting the channel action produced the same relay rejection:
Current
mainsourceBoth production gates still exempt only unarchive, not delete:
crates/buzz-relay/src/handlers/side_effects.rs(validate_admin_event) rejects every archived-channel mutation except kind9002witharchived=false: currentmainlines 468-482.crates/buzz-relay/src/handlers/ingest.rsindependently rejects every event targeting an archived channel except that same kind9002unarchive shape: currentmainlines 2903-2915.Kind
9008never reaches its later owner-only authorization orhandle_delete_group/soft_delete_channelpath.Verified workaround
Using the channel owner identity from the CLI, this sequence works:
I used this to clean four accumulated archived huddle channels. After the two events,
channels.deleted_atwas set for all four andchannels listno longer returned them. No SQL mutation was needed.Suggested regression
Please cover both gates, not just one:
9008;9008is still rejected by owner authorization;The existing fix direction in #7569 (shared helper allowing
9002 archived=falseand9008through both gates) matches the behavior observed here. The stale kind39000/ghost-row half is tracked separately in #4879 and #4918.