diff --git a/src/nostr/policy/deletion_ops.rs b/src/nostr/policy/deletion_ops.rs index 32becfd..a102944 100644 --- a/src/nostr/policy/deletion_ops.rs +++ b/src/nostr/policy/deletion_ops.rs @@ -5,11 +5,15 @@ use std::collections::HashSet; use std::path::Path; -use nostr_relay_builder::prelude::{Event, EventId, Filter, Kind, PublicKey, ToBech32}; +use nostr_relay_builder::prelude::{ + Alphabet, Event, EventId, Filter, Kind, PublicKey, SingleLetterTag, ToBech32, +}; use super::SharedDatabase; use crate::database::{DeletionMetadata, HoldingDatabase}; use crate::git::archive::{archive_repository, create_archive_metadata}; +use crate::git::authorization::collect_authorized_maintainers; +use crate::nostr::events::RepositoryAnnouncement; /// Determine which events should be deleted using graph-based algorithm /// @@ -187,8 +191,129 @@ pub async fn determine_events_to_delete( traversal_result.delete.len() ); - // Return only events that should be deleted - Ok(traversal_result.delete) + // Step 6: Handle state event cascade deletion + // State events (kind 30618) use 'd' tags, not 'a' tags, so they weren't found by + // the graph-based deletion logic. We need to explicitly check them. + let mut final_deleted_events = traversal_result.delete.clone(); + + // Identify which announcements are being deleted + let deleted_announcement_ids: HashSet = all_events + .iter() + .filter(|e| e.kind == Kind::from(30617) && final_deleted_events.contains(&e.id)) + .map(|e| e.id) + .collect(); + + tracing::debug!( + deleted_announcement_count = deleted_announcement_ids.len(), + "Identified {} announcements being deleted for state event cascade", + deleted_announcement_ids.len() + ); + + // For each identifier being affected, handle state events + for address in &announcement_addresses { + let parts: Vec<&str> = address.split(':').collect(); + if parts.len() != 3 { + continue; // Skip invalid addresses + } + let identifier = parts[2]; + + // Get ALL announcements for this identifier from database + let all_announcements_events = + query_announcements_by_identifier(database, identifier).await?; + + tracing::debug!( + identifier = %identifier, + total_announcements = all_announcements_events.len(), + "Queried announcements for identifier" + ); + + // Convert to RepositoryAnnouncement structs + let all_announcements: Vec = all_announcements_events + .iter() + .filter_map(|e| RepositoryAnnouncement::from_event(e.clone()).ok()) + .collect(); + + // Separate into remaining vs deleted + let remaining_announcements: Vec = all_announcements + .iter() + .filter(|a| !deleted_announcement_ids.contains(&a.event.id)) + .cloned() + .collect(); + + let deleted_owner_pubkeys: HashSet = all_announcements + .iter() + .filter(|a| deleted_announcement_ids.contains(&a.event.id)) + .map(|a| a.event.pubkey.to_hex()) + .collect(); + + tracing::debug!( + identifier = %identifier, + remaining_announcements = remaining_announcements.len(), + deleted_owner_pubkeys = deleted_owner_pubkeys.len(), + "Separated announcements into remaining and deleted" + ); + + // Build maintainer mapping from remaining announcements + let maintainer_map = collect_authorized_maintainers(&remaining_announcements); + let all_authorized: HashSet = maintainer_map + .values() + .flat_map(|v| v.iter().cloned()) + .collect(); + + tracing::debug!( + identifier = %identifier, + authorized_maintainers = all_authorized.len(), + "Built maintainer mapping from remaining announcements" + ); + + // Query all state events for this identifier + let state_events = query_state_events_by_identifier(database, identifier).await?; + + tracing::debug!( + identifier = %identifier, + state_events_count = state_events.len(), + "Queried state events for identifier" + ); + + // Delete state events where author is: + // - One of the deleted owner pubkeys (their repo is being deleted), OR + // - Not in any remaining maintainer list + for state_event in state_events { + let author_hex = state_event.pubkey.to_hex(); + + let should_delete = deleted_owner_pubkeys.contains(&author_hex) + || !all_authorized.contains(&author_hex); + + if should_delete { + final_deleted_events.insert(state_event.id); + tracing::debug!( + state_event_id = %state_event.id, + author = %author_hex, + identifier = %identifier, + is_deleted_owner = deleted_owner_pubkeys.contains(&author_hex), + is_unauthorized = !all_authorized.contains(&author_hex), + "State event marked for deletion: author no longer authorized" + ); + } else { + tracing::debug!( + state_event_id = %state_event.id, + author = %author_hex, + identifier = %identifier, + "State event kept: author still authorized" + ); + } + } + } + + tracing::info!( + graph_deleted = traversal_result.delete.len(), + state_events_deleted = final_deleted_events.len() - traversal_result.delete.len(), + total_deleted = final_deleted_events.len(), + "Cascade deletion complete including state events" + ); + + // Return all events that should be deleted (including state events) + Ok(final_deleted_events) } /// Query all events that depend on the given event IDs @@ -408,6 +533,58 @@ fn has_event_tag(event: &Event, event_id: &EventId) -> bool { false } +/// Query all repository announcements (kind 30617) with a specific identifier +/// +/// # Arguments +/// * `database` - Database to query +/// * `identifier` - Repository identifier to search for +/// +/// # Returns +/// Vector of announcement events with matching `d` tag +async fn query_announcements_by_identifier( + database: &SharedDatabase, + identifier: &str, +) -> Result, String> { + let filter = Filter::new() + .kind(Kind::from(30617)) + .custom_tag(SingleLetterTag::lowercase(Alphabet::D), identifier); + + let events: Vec = database + .query(filter) + .await + .map_err(|e| format!("Database query failed: {}", e))? + .into_iter() + .collect(); + + Ok(events) +} + +/// Query all repository state events (kind 30618) with a specific identifier +/// +/// # Arguments +/// * `database` - Database to query +/// * `identifier` - Repository identifier to search for +/// +/// # Returns +/// Vector of state events with matching `d` tag +async fn query_state_events_by_identifier( + database: &SharedDatabase, + identifier: &str, +) -> Result, String> { + let filter = Filter::new() + .kind(Kind::from(30618)) + .custom_tag(SingleLetterTag::lowercase(Alphabet::D), identifier); + + let events: Vec = database + .query(filter) + .await + .map_err(|e| format!("Database query failed: {}", e))? + .into_iter() + .collect(); + + Ok(events) +} + /// Move events from main database to holding database /// /// This is an atomic operation that: