diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index a9cdffcdec..5821113393 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -600,6 +600,37 @@ async fn enqueue_event_created_audit( } } +/// Write an `EventDeleted` audit entry after a successful soft-delete. +/// +/// Follows the same pattern as [`enqueue_event_created_audit`]: bounded channel, +/// backpressure-propagating send, fire-and-log on error. Called from every path +/// that removes an event from the live set (NIP-09 e-tag, NIP-09 a-tag, NIP-29 +/// kind:9005 admin delete). +/// +/// `pub(super)` so `side_effects.rs` sibling handlers can call it. +pub(super) async fn enqueue_event_deleted_audit( + tenant: &TenantContext, + state: &Arc, + actor_pubkey_hex: &str, + event_id_hex: &str, + detail: serde_json::Value, +) { + let Some(audit_tx) = &state.audit_tx else { + return; + }; + let audit_entry = buzz_audit::NewAuditEntry { + community_id: tenant.community(), + action: buzz_audit::AuditAction::EventDeleted, + actor_pubkey: hex::decode(actor_pubkey_hex).ok(), + object_id: Some(event_id_hex.to_owned()), + detail, + }; + if let Err(e) = audit_tx.send(audit_entry).await { + error!(event_id = %event_id_hex, "Audit channel closed — entry lost: {e}"); + metrics::counter!("buzz_audit_send_errors_total").increment(1); + } +} + /// Handle an EVENT message from a WebSocket connection. /// /// Extracts auth from the WS connection, dispatches ephemeral events locally, diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index 660a55fef3..cc4607f30c 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -1731,13 +1731,27 @@ async fn handle_delete_event_side_effect( return Ok(()); // No-op: skip system message to avoid false audit records. } + // Audit the deletion — mirrors enqueue_event_created_audit on the create + // path. The actor is the admin who sent the kind:9005 request. + let actor_hex = hex::encode(event.pubkey.to_bytes()); + super::event::enqueue_event_deleted_audit( + tenant, + state, + &actor_hex, + &hex::encode(&target_id), + serde_json::json!({ + "delete_kind": "nip29_admin", + "channel_id": channel_id, + }), + ) + .await; + // Thread counters were decremented in the same transaction — push a fresh // relay-signed 39005 so live badge counts also count *down*. if let Some(root_id) = root_id { emit_live_thread_summary(tenant, state, channel_id, root_id); } - let actor_hex = hex::encode(event.pubkey.to_bytes()); let mut tombstone = serde_json::json!({ "type": "message_deleted", "actor": actor_hex, @@ -2191,6 +2205,20 @@ async fn handle_a_tag_deletion( d_tag = d_tag, "NIP-09 a-tag deletion: soft-deleted addressable event by coordinate" ); + // Audit the deletion. + let a_tag_actor_hex = hex::encode(&actor_bytes); + super::event::enqueue_event_deleted_audit( + tenant, + state, + &a_tag_actor_hex, + &a_value, + serde_json::json!({ + "delete_kind": "nip09_a_tag", + "event_kind": k, + "coordinate": a_value, + }), + ) + .await; } else { tracing::debug!( kind = k, @@ -2262,6 +2290,21 @@ async fn handle_standard_deletion_event( continue; } + // Audit the deletion. + let nip09_actor_hex = hex::encode(event.pubkey.to_bytes()); + super::event::enqueue_event_deleted_audit( + tenant, + state, + &nip09_actor_hex, + &hex::encode(&target_id), + serde_json::json!({ + "delete_kind": "nip09_e_tag", + "event_kind": u32::from(target_event.event.kind.as_u16()), + "channel_id": target_event.channel_id, + }), + ) + .await; + // Thread counters were decremented in the same transaction — push a // fresh relay-signed 39005 so live badge counts also count *down*. if let (Some(root_id), Some(channel_id)) = (root_id, target_event.channel_id) {