Skip to content

Deleting an auto-archived huddle channel returns 400 "channel is archived" #2954

Open

Description

@lnbc1QWFyb24

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

  1. Create a channel as its owner.
  2. Archive it with kind 9002 and archived=true; ending a huddle so its ephemeral channel auto-archives also reproduces this.
  3. As the same owner, submit a kind 9008 delete-group event for that channel. In Desktop, right-click the retained huddle channel, select Delete channel, and confirm.
  4. 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 upstream main at c2a4ee711e481bb427d6cf8cd08b2c7329d1508c has the same guards

Logs / additional context

Relevant code paths:

  • validate_admin_event rejects archived channels before its kind 9008 owner check:
    // 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:
    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 9008 already has owner-only authorization:
    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:
    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

  1. added 3 commits that reference this issue on Jul 26, 2026
    ad2c31d
    6c5470e
    16e9d23
  2. willi-werner commented on Sep 13, 2026

    @willi-werner

    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_huddle archives it immediately.

    The TTL reaper (reap_expired_ephemeral_channels in crates/buzz-db/src/store/channel.rs) only sets archived_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 in channels forever, 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_at older 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

    1. Start a huddle in any channel, end it.
    2. Wait longer than the TTL (1 h).
    3. Observe the archived huddle-<id> channel still present. Try to delete it from the desktop app: invalid: channel is archived.

    Suggestion

    A purge_archived_ephemeral_channels step 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.

  3. BMGY commented on Oct 13, 2026

    @BMGY

    Reproduced again with a newer Desktop/self-hosted Relay combination, and the guard is still present on current main.

    Environment

    • Buzz Desktop: 0.5.28, macOS
    • Self-hosted relay image: block/buzz@sha256:e1865b3f89194d7d6068629d7ff62d3993140c4b920ee3a3f5f81564256a038d
    • Relay image revision: 5fdb2e53659ee29002545f1022c138fe0b8282f9
    • Current upstream main checked while reproducing: 1d9f2c81a5583f12be1239a088e2f0b379292dc3

    Observed state

    An ephemeral huddle backing channel ended up as:

    channel_type = stream
    visibility = private
    ttl_seconds = 3600
    archived_at = set
    deleted_at = NULL
    owner = creator identity
    

    The newest kind 39000 metadata for that channel still had d/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:

    HTTP 400
    reason: invalid: channel is archived
    

    Current main source

    Both 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 kind 9002 with archived=false: current main lines 468-482.
    • crates/buzz-relay/src/handlers/ingest.rs independently rejects every event targeting an archived channel except that same kind 9002 unarchive shape: current main lines 2903-2915.

    Kind 9008 never reaches its later owner-only authorization or handle_delete_group/soft_delete_channel path.

    Verified workaround

    Using the channel owner identity from the CLI, this sequence works:

    buzz channels unarchive --channel <huddle-channel-id>
    buzz channels delete   --channel <huddle-channel-id>

    I used this to clean four accumulated archived huddle channels. After the two events, channels.deleted_at was set for all four and channels list no longer returned them. No SQL mutation was needed.

    Suggested regression

    Please cover both gates, not just one:

    1. owner archives a channel;
    2. owner submits kind 9008;
    3. delete is accepted and the channel is soft-deleted;
    4. non-owner kind 9008 is still rejected by owner authorization;
    5. other archived-channel mutations (message, membership change, rename/archive) remain rejected.

    The existing fix direction in #7569 (shared helper allowing 9002 archived=false and 9008 through both gates) matches the behavior observed here. The stale kind 39000/ghost-row half is tracked separately in #4879 and #4918.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions