diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index a9cdffcdec..e5ae41a3fe 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -600,6 +600,47 @@ async fn enqueue_event_created_audit( } } +/// Enqueue an [`AuditAction::EventDeleted`] entry for a NIP-09 deletion. +/// +/// Mirrors [`enqueue_event_created_audit`] (same bounded channel, same +/// propensity to propagate backpressure rather than silently drop entries, +/// same no-op when `audit_tx` is `None`). The `actor_pubkey_hex` here is the +/// pubkey that *signed* the NIP-09 kind:5 event — i.e. the human/user asking +/// for the delete — not the relay key and not the (now-tombstoned) target +/// event's author. `deletion_path` distinguishes which NIP-09 path fired so +/// the audit log reflects operator intent: `"e_tag"` for the standard +/// `["e", ]` soft-delete, `"a_tag"` for the addressable-coordinate +/// variant. +pub(super) async fn enqueue_event_deleted_audit( + tenant: &TenantContext, + state: &Arc, + actor_pubkey_hex: &str, + target_event_id_hex: &str, + channel_id: Option, + event_kind_u32: Option, + deletion_path: &'static str, +) { + let Some(audit_tx) = &state.audit_tx else { + return; + }; + let detail = serde_json::json!({ + "event_kind": event_kind_u32, + "channel_id": channel_id.map(|c| c.to_string()), + "deletion_path": deletion_path, + }); + 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(target_event_id_hex.to_owned()), + detail, + }; + if let Err(e) = audit_tx.send(audit_entry).await { + error!(target_event_id = %target_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..a8a1bd1ab7 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -16,7 +16,7 @@ use buzz_core::kind::{ use buzz_core::StoredEvent; use buzz_db::channel::{MemberRecord, MemberRole}; -use super::event::dispatch_persistent_event; +use super::event::{dispatch_persistent_event, enqueue_event_deleted_audit}; use crate::protocol::RelayMessage; use crate::state::AppState; use buzz_core::tenant::TenantContext; @@ -1731,6 +1731,19 @@ async fn handle_delete_event_side_effect( return Ok(()); // No-op: skip system message to avoid false audit records. } + // Best-effort domain audit; failure must never break the deletion flow. + // The actor is the NIP-29 kind-9005 moderation signer (`event.pubkey`). + enqueue_event_deleted_audit( + tenant, + state, + &hex::encode(event.pubkey.to_bytes()), + &hex::encode(&target_id), + Some(channel_id), + None, // kind of the deleted event is not known to this handler + "moderation_e_tag", + ) + .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 { @@ -2191,6 +2204,20 @@ async fn handle_a_tag_deletion( d_tag = d_tag, "NIP-09 a-tag deletion: soft-deleted addressable event by coordinate" ); + + // Best-effort domain audit. Actor is the NIP-09 signer. Object id + // is the coordinate (::) — stable across a + // replace and resolvable by the operator to the deleted row. + enqueue_event_deleted_audit( + tenant, + state, + &hex::encode(event.pubkey.to_bytes()), + &format!("{kind_i32}:{pubkey_hex}:{d_tag}"), + None, + Some(k), + "a_tag", + ) + .await; } else { tracing::debug!( kind = k, @@ -2262,6 +2289,20 @@ async fn handle_standard_deletion_event( continue; } + // Best-effort domain audit; failure must never break the deletion flow. + // The actor is the NIP-09 kind:5 signer (`event.pubkey`). The target + // event's own kind/channel travel in the detail for post-hoc queryability. + enqueue_event_deleted_audit( + tenant, + state, + &hex::encode(event.pubkey.to_bytes()), + &hex::encode(&target_id), + target_event.channel_id, + Some(u32::from(target_event.event.kind.as_u16())), + "e_tag", + ) + .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) {