From 65ac6ef83205c41653e6ffe2acd664f968926fb2 Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Mon, 23 Feb 2026 13:29:47 +0000 Subject: feat: remove purgatory announcements on NIP-09 deletion events Kind 5 deletion events signed by the announcement author now evict the corresponding purgatory entry and delete the bare repository from disk. Both NIP-09 reference styles are supported: - e tag (event ID): matches the purgatory entry whose event ID equals the tag value - a tag (coordinate 30617::): matches by coordinate, only removes entries with created_at <= deletion event created_at per NIP-09 spec Author-only enforcement: coordinate pubkey and e-tag owner must match the deletion event pubkey; third-party deletion attempts are silently ignored. Includes 6 unit tests and 2 integration tests (event ID and coordinate paths). --- grasp-audit/src/specs/grasp01/purgatory.rs | 199 +++++++++++++ src/nostr/builder.rs | 8 +- src/nostr/policy/deletion.rs | 438 +++++++++++++++++++++++++++++ src/nostr/policy/mod.rs | 2 + tests/purgatory.rs | 7 + 5 files changed, 652 insertions(+), 2 deletions(-) create mode 100644 src/nostr/policy/deletion.rs diff --git a/grasp-audit/src/specs/grasp01/purgatory.rs b/grasp-audit/src/specs/grasp01/purgatory.rs index 9c4b401..9d97d3b 100644 --- a/grasp-audit/src/specs/grasp01/purgatory.rs +++ b/grasp-audit/src/specs/grasp01/purgatory.rs @@ -46,6 +46,12 @@ impl PurgatoryTests { results.add(Self::test_bare_repo_exists_for_purgatory_announcement(client).await); results.add(Self::test_state_event_accepted_for_purgatory_announcement(client).await); + // Deletion event tests (NIP-09) + results.add(Self::test_deletion_by_event_id_removes_purgatory_announcement(client).await); + results.add( + Self::test_deletion_by_coordinate_removes_purgatory_announcement(client).await, + ); + // State event purgatory tests (already implemented) results.add(Self::test_state_event_not_served_before_git_data(client).await); results.add(Self::test_state_event_served_after_git_push(client).await); @@ -646,6 +652,199 @@ impl PurgatoryTests { }) .await } + // ============================================================ + // Deletion Event Tests (NIP-09) + // ============================================================ + + /// Test: Kind 5 deletion event by event ID removes purgatory announcement + /// + /// Spec: NIP-09 + /// "A special event with kind 5... having a list of one or more `e` or `a` tags, + /// each referencing an event the author is requesting to be deleted." + /// + /// This test verifies: + /// 1. Send a valid repository announcement (enters purgatory) + /// 2. Send a kind 5 deletion event referencing the announcement by event ID + /// 3. The announcement is no longer in purgatory (git push would fail) + /// 4. The deletion event itself is accepted by the relay + pub async fn test_deletion_by_event_id_removes_purgatory_announcement( + client: &AuditClient, + ) -> TestResult { + TestResult::new( + "deletion_by_event_id_removes_purgatory_announcement", + SpecRef::PurgatoryAcceptUntilGitData, + "Kind 5 deletion by event ID SHOULD remove a purgatory announcement", + ) + .run(|| async { + let ctx = TestContext::new(client); + + // Send announcement to purgatory + let repo = ctx + .get_fixture(FixtureKind::ValidRepoSent) + .await + .map_err(|e| format!("Failed to create repo announcement: {}", e))?; + + let repo_id = repo + .tags + .iter() + .find(|t| t.kind() == TagKind::d()) + .and_then(|t| t.content()) + .ok_or("Missing d tag in repo announcement")? + .to_string(); + + // Verify it's in purgatory (not served) + tokio::time::sleep(Duration::from_millis(300)).await; + if client.is_event_on_relay(repo.id).await.map_err(|e| e.to_string())? { + return Err( + "Announcement was served immediately - purgatory not working".to_string(), + ); + } + + // Build and send kind 5 deletion event referencing the announcement by event ID + let deletion = client + .event_builder(Kind::EventDeletion, "") + .tag(Tag::event(repo.id)) + .tag(Tag::custom( + TagKind::custom("k"), + vec!["30617"], + )) + .build(client.keys()) + .map_err(|e| format!("Failed to build deletion event: {}", e))?; + + client + .send_event(deletion) + .await + .map_err(|e| format!("Relay rejected deletion event: {}", e))?; + + tokio::time::sleep(Duration::from_millis(300)).await; + + // Verify the announcement can no longer be promoted by attempting a git push. + // We check this indirectly: if the purgatory entry was removed, a subsequent + // git push to the repo path should fail (no bare repo). + // For the integration test we verify the announcement is still not served + // (it was never promoted) and that the deletion event was accepted. + // The bare-repo deletion is verified by attempting a git clone. + let http_url = AuditClient::ws_to_http_url(&client.relay_url().await.map_err(|e| e.to_string())?) + .map_err(|e| e.to_string())?; + let clone_url = format!( + "{}/{}/{}.git", + http_url, + client.public_key().to_bech32().map_err(|e| e.to_string())?, + repo_id + ); + + // git ls-remote should fail (bare repo deleted) + let output = std::process::Command::new("git") + .args(["ls-remote", &clone_url]) + .output() + .map_err(|e| format!("Failed to run git ls-remote: {}", e))?; + + if output.status.success() { + return Err(format!( + "Bare repo still exists after deletion event. \ + Expected git ls-remote to fail for {}", + clone_url + )); + } + + Ok(()) + }) + .await + } + + /// Test: Kind 5 deletion event by `a` tag coordinate removes purgatory announcement + /// + /// Spec: NIP-09 + /// "When an `a` tag is used, relays SHOULD delete all versions of the replaceable + /// event up to the `created_at` timestamp of the deletion request event." + /// + /// This test verifies: + /// 1. Send a valid repository announcement (enters purgatory) + /// 2. Send a kind 5 deletion event referencing the announcement by coordinate + /// (`30617::`) + /// 3. The announcement is no longer in purgatory + pub async fn test_deletion_by_coordinate_removes_purgatory_announcement( + client: &AuditClient, + ) -> TestResult { + TestResult::new( + "deletion_by_coordinate_removes_purgatory_announcement", + SpecRef::PurgatoryAcceptUntilGitData, + "Kind 5 deletion by `a` coordinate SHOULD remove a purgatory announcement", + ) + .run(|| async { + let ctx = TestContext::new(client); + + // Send announcement to purgatory + let repo = ctx + .get_fixture(FixtureKind::ValidRepoSent) + .await + .map_err(|e| format!("Failed to create repo announcement: {}", e))?; + + let repo_id = repo + .tags + .iter() + .find(|t| t.kind() == TagKind::d()) + .and_then(|t| t.content()) + .ok_or("Missing d tag in repo announcement")? + .to_string(); + + // Verify it's in purgatory (not served) + tokio::time::sleep(Duration::from_millis(300)).await; + if client.is_event_on_relay(repo.id).await.map_err(|e| e.to_string())? { + return Err( + "Announcement was served immediately - purgatory not working".to_string(), + ); + } + + // Build coordinate: `30617::` + let coord = format!( + "30617:{}:{}", + client.public_key().to_hex(), + repo_id + ); + + // Build and send kind 5 deletion event referencing by coordinate + let deletion = client + .event_builder(Kind::EventDeletion, "") + .tag(Tag::custom(TagKind::custom("a"), vec![coord])) + .tag(Tag::custom(TagKind::custom("k"), vec!["30617"])) + .build(client.keys()) + .map_err(|e| format!("Failed to build deletion event: {}", e))?; + + client + .send_event(deletion) + .await + .map_err(|e| format!("Relay rejected deletion event: {}", e))?; + + tokio::time::sleep(Duration::from_millis(300)).await; + + // Verify bare repo was deleted + let http_url = AuditClient::ws_to_http_url(&client.relay_url().await.map_err(|e| e.to_string())?) + .map_err(|e| e.to_string())?; + let clone_url = format!( + "{}/{}/{}.git", + http_url, + client.public_key().to_bech32().map_err(|e| e.to_string())?, + repo_id + ); + + let output = std::process::Command::new("git") + .args(["ls-remote", &clone_url]) + .output() + .map_err(|e| format!("Failed to run git ls-remote: {}", e))?; + + if output.status.success() { + return Err(format!( + "Bare repo still exists after deletion event. \ + Expected git ls-remote to fail for {}", + clone_url + )); + } + + Ok(()) + }) + .await + } } #[cfg(test)] diff --git a/src/nostr/builder.rs b/src/nostr/builder.rs index c2d4939..d056e46 100644 --- a/src/nostr/builder.rs +++ b/src/nostr/builder.rs @@ -14,8 +14,8 @@ use nostr_relay_builder::prelude::*; use crate::config::{Config, DatabaseBackend}; use crate::nostr::events::RepositoryAnnouncement; use crate::nostr::policy::{ - AnnouncementPolicy, AnnouncementResult, PolicyContext, PrEventPolicy, ReferenceResult, - RelatedEventPolicy, StatePolicy, StateResult, + AnnouncementPolicy, AnnouncementResult, DeletionPolicy, PolicyContext, PrEventPolicy, + ReferenceResult, RelatedEventPolicy, StatePolicy, StateResult, }; @@ -29,6 +29,7 @@ pub type SharedDatabase = Arc; /// - `StatePolicy` - State event validation + ref alignment /// - `PrEventPolicy` - PR/PR Update validation /// - `RelatedEventPolicy` - Forward/backward reference checking +/// - `DeletionPolicy` - NIP-09 event deletion request handling /// /// Uses stateful database queries to check event relationships. #[derive(Clone)] @@ -38,6 +39,7 @@ pub struct Nip34WritePolicy { state_policy: StatePolicy, pr_event_policy: PrEventPolicy, related_event_policy: RelatedEventPolicy, + deletion_policy: DeletionPolicy, } impl std::fmt::Debug for Nip34WritePolicy { @@ -69,6 +71,7 @@ impl Nip34WritePolicy { state_policy: StatePolicy::new(ctx.clone()), pr_event_policy: PrEventPolicy::new(ctx.clone()), related_event_policy: RelatedEventPolicy::new(ctx.clone()), + deletion_policy: DeletionPolicy::new(ctx.clone()), ctx, } } @@ -521,6 +524,7 @@ impl WritePolicy for Nip34WritePolicy { ); WritePolicyResult::Accept } + Kind::EventDeletion => self.deletion_policy.handle(event).await, _ => self.handle_related_event(event, "Event").await, } }) diff --git a/src/nostr/policy/deletion.rs b/src/nostr/policy/deletion.rs new file mode 100644 index 0000000..69a5758 --- /dev/null +++ b/src/nostr/policy/deletion.rs @@ -0,0 +1,438 @@ +/// Deletion Policy - NIP-09 event deletion request handling +/// +/// Handles kind 5 (EventDeletion) events that request removal of repository +/// announcements (kind 30617) from purgatory. +/// +/// ## NIP-09 Rules Enforced +/// +/// - Only the event author can delete their own events (pubkey must match) +/// - `e` tags reference specific event IDs to delete +/// - `a` tags reference addressable events by coordinate (`::`) +/// - When an `a` tag is used, all versions up to `created_at` of the deletion request +/// are considered deleted +/// +/// ## Purgatory Interaction +/// +/// When a valid deletion request targets a kind 30617 announcement that is currently +/// in purgatory (not yet promoted to the database), the purgatory entry is removed +/// and the bare repository is deleted from disk. +use nostr_relay_builder::prelude::{Event, WritePolicyResult}; + +use super::PolicyContext; + +/// Policy for handling NIP-09 event deletion requests +#[derive(Clone)] +pub struct DeletionPolicy { + ctx: PolicyContext, +} + +impl DeletionPolicy { + pub fn new(ctx: PolicyContext) -> Self { + Self { ctx } + } + + /// Process a kind 5 (EventDeletion) event. + /// + /// Checks whether the deletion request targets any purgatory announcements + /// and removes them if so. The deletion event itself is always accepted + /// (relays should store deletion requests per NIP-09). + /// + /// Only the event author can delete their own events — this is enforced by + /// checking that the purgatory entry's owner matches `event.pubkey`. + pub async fn handle(&self, event: &Event) -> WritePolicyResult { + // Process purgatory removals synchronously (no async needed) + self.remove_purgatory_targets(event); + + // Always accept the deletion event itself so it is stored and + // can prevent re-acceptance of the deleted event in the future. + WritePolicyResult::Accept + } + + /// Remove any purgatory announcements targeted by this deletion event. + /// + /// Handles both reference styles from NIP-09: + /// - `e` tags: event ID references — match against purgatory entry event IDs + /// - `a` tags: addressable coordinate references — `30617::` + /// + /// Only removes entries where the purgatory entry's owner matches the deletion + /// event's pubkey (enforces author-only deletion). + fn remove_purgatory_targets(&self, event: &Event) { + let author = &event.pubkey; + + for tag in event.tags.iter() { + let tag_vec = tag.as_slice(); + if tag_vec.len() < 2 { + continue; + } + + match tag_vec[0].as_str() { + "e" => { + // Event ID reference: find purgatory announcement with this event ID + let target_id = &tag_vec[1]; + self.remove_by_event_id(author, target_id, event.created_at.as_secs()); + } + "a" => { + // Addressable coordinate reference: `::` + let coord = &tag_vec[1]; + self.remove_by_coordinate(author, coord, event.created_at.as_secs()); + } + _ => {} + } + } + } + + /// Remove a purgatory announcement matched by event ID. + /// + /// Scans all purgatory announcements owned by `author` and removes the one + /// whose event ID hex matches `target_id_hex`. + fn remove_by_event_id(&self, author: &nostr_relay_builder::prelude::PublicKey, target_id_hex: &str, _deletion_created_at: u64) { + // Scan announcements owned by this author for a matching event ID + // We use get_announcements_by_identifier would require knowing the identifier, + // so instead we iterate via find_announcement after collecting all entries. + // The DashMap doesn't expose a direct "find by event ID" method, so we use + // the announcements_for_sync snapshot to get all (repo_id, _) pairs and then + // look up each one. + let all = self.ctx.purgatory.announcements_for_sync(); + for (repo_id, _) in all { + // repo_id format: "30617:{pubkey_hex}:{identifier}" + let parts: Vec<&str> = repo_id.splitn(3, ':').collect(); + if parts.len() != 3 { + continue; + } + let entry_pubkey_hex = parts[1]; + let identifier = parts[2]; + + // Only check entries owned by the deletion event author + if entry_pubkey_hex != author.to_hex() { + continue; + } + + if let Some(entry) = self.ctx.purgatory.find_announcement(author, identifier) { + if entry.event.id.to_hex() == target_id_hex { + tracing::info!( + event_id = %target_id_hex, + identifier = %identifier, + author = %author.to_hex(), + "Deletion request: removing purgatory announcement by event ID" + ); + self.evict_purgatory_entry(author, identifier); + return; // event IDs are unique, no need to continue + } + } + } + } + + /// Remove a purgatory announcement matched by addressable coordinate. + /// + /// The coordinate format is `::`. Only kind 30617 + /// coordinates are relevant here. Per NIP-09, all versions up to `deletion_created_at` + /// are considered deleted — since purgatory entries are always a single event per + /// (owner, identifier), we delete if the entry's `created_at` ≤ `deletion_created_at`. + fn remove_by_coordinate( + &self, + author: &nostr_relay_builder::prelude::PublicKey, + coordinate: &str, + deletion_created_at: u64, + ) { + // Parse coordinate: `::` + let parts: Vec<&str> = coordinate.splitn(3, ':').collect(); + if parts.len() != 3 { + return; + } + + let kind_str = parts[0]; + let coord_pubkey_hex = parts[1]; + let identifier = parts[2]; + + // Only handle kind 30617 (GitRepoAnnouncement) + if kind_str != "30617" { + return; + } + + // The coordinate pubkey must match the deletion event author + if coord_pubkey_hex != author.to_hex() { + tracing::debug!( + coord_pubkey = %coord_pubkey_hex, + deletion_author = %author.to_hex(), + "Ignoring deletion: coordinate pubkey does not match deletion author" + ); + return; + } + + if let Some(entry) = self.ctx.purgatory.find_announcement(author, identifier) { + // Per NIP-09: delete all versions up to deletion_created_at + if entry.event.created_at.as_secs() <= deletion_created_at { + tracing::info!( + identifier = %identifier, + author = %author.to_hex(), + entry_created_at = entry.event.created_at.as_secs(), + deletion_created_at = %deletion_created_at, + "Deletion request: removing purgatory announcement by coordinate" + ); + self.evict_purgatory_entry(author, identifier); + } else { + tracing::debug!( + identifier = %identifier, + author = %author.to_hex(), + entry_created_at = entry.event.created_at.as_secs(), + deletion_created_at = %deletion_created_at, + "Ignoring deletion: purgatory entry is newer than deletion request" + ); + } + } + } + + /// Remove a purgatory announcement and delete its bare repository from disk. + fn evict_purgatory_entry( + &self, + author: &nostr_relay_builder::prelude::PublicKey, + identifier: &str, + ) { + // Get repo path before removing + if let Some(entry) = self.ctx.purgatory.find_announcement(author, identifier) { + if entry.repo_path.exists() { + if let Err(e) = std::fs::remove_dir_all(&entry.repo_path) { + tracing::warn!( + path = %entry.repo_path.display(), + error = %e, + "Failed to delete bare repository during deletion request processing" + ); + } else { + tracing::info!( + path = %entry.repo_path.display(), + "Deleted bare repository for deletion-requested purgatory announcement" + ); + } + } + } + + self.ctx.purgatory.remove_announcement(author, identifier); + + // Remove state events for this identifier only if no other owner's + // announcement remains in purgatory (state events are keyed by identifier alone) + let other_owners_remain = !self + .ctx + .purgatory + .get_announcements_by_identifier(identifier) + .is_empty(); + + if !other_owners_remain { + self.ctx.purgatory.remove_state(identifier); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::nostr::policy::PolicyContext; + use crate::purgatory::Purgatory; + use nostr_relay_builder::prelude::*; + use std::collections::HashSet; + use std::path::PathBuf; + use std::sync::Arc; + + fn make_context() -> PolicyContext { + let db = Arc::new(MemoryDatabase::with_opts(MemoryDatabaseOptions { + events: true, + max_events: None, + })); + let purgatory = Arc::new(Purgatory::new(PathBuf::new())); + let config = crate::config::Config::for_testing(); + PolicyContext::new("test.example.com", db, PathBuf::new(), purgatory, config) + } + + fn make_announcement_event(keys: &Keys, identifier: &str) -> Event { + EventBuilder::new(Kind::GitRepoAnnouncement, "") + .tags(vec![ + Tag::identifier(identifier), + Tag::custom(TagKind::custom("clone"), vec!["https://example.com/repo.git"]), + ]) + .sign_with_keys(keys) + .unwrap() + } + + fn add_to_purgatory(ctx: &PolicyContext, event: &Event, identifier: &str) { + ctx.purgatory.add_announcement( + event.clone(), + identifier.to_string(), + event.pubkey, + PathBuf::new(), + HashSet::new(), + ); + } + + #[tokio::test] + async fn test_deletion_by_event_id_removes_purgatory_entry() { + let ctx = make_context(); + let keys = Keys::generate(); + let identifier = "my-repo"; + + let announcement = make_announcement_event(&keys, identifier); + add_to_purgatory(&ctx, &announcement, identifier); + + assert!(ctx.purgatory.has_purgatory_announcement(&keys.public_key(), identifier)); + + // Build kind 5 deletion event referencing the announcement by event ID + let deletion = EventBuilder::new(Kind::EventDeletion, "") + .tags(vec![ + Tag::event(announcement.id), + Tag::custom(TagKind::custom("k"), vec!["30617"]), + ]) + .sign_with_keys(&keys) + .unwrap(); + + let policy = DeletionPolicy::new(ctx.clone()); + let result = policy.handle(&deletion).await; + + assert!(matches!(result, WritePolicyResult::Accept)); + assert!( + !ctx.purgatory.has_purgatory_announcement(&keys.public_key(), identifier), + "Purgatory entry should have been removed" + ); + } + + #[tokio::test] + async fn test_deletion_by_coordinate_removes_purgatory_entry() { + let ctx = make_context(); + let keys = Keys::generate(); + let identifier = "my-repo"; + + let announcement = make_announcement_event(&keys, identifier); + add_to_purgatory(&ctx, &announcement, identifier); + + assert!(ctx.purgatory.has_purgatory_announcement(&keys.public_key(), identifier)); + + // Build kind 5 deletion event referencing the announcement by coordinate + let coord = format!("30617:{}:{}", keys.public_key().to_hex(), identifier); + let deletion = EventBuilder::new(Kind::EventDeletion, "") + .tags(vec![ + Tag::custom(TagKind::custom("a"), vec![coord]), + Tag::custom(TagKind::custom("k"), vec!["30617"]), + ]) + .sign_with_keys(&keys) + .unwrap(); + + let policy = DeletionPolicy::new(ctx.clone()); + let result = policy.handle(&deletion).await; + + assert!(matches!(result, WritePolicyResult::Accept)); + assert!( + !ctx.purgatory.has_purgatory_announcement(&keys.public_key(), identifier), + "Purgatory entry should have been removed" + ); + } + + #[tokio::test] + async fn test_deletion_by_wrong_author_does_not_remove() { + let ctx = make_context(); + let owner_keys = Keys::generate(); + let attacker_keys = Keys::generate(); + let identifier = "my-repo"; + + let announcement = make_announcement_event(&owner_keys, identifier); + add_to_purgatory(&ctx, &announcement, identifier); + + // Attacker tries to delete by event ID + let deletion = EventBuilder::new(Kind::EventDeletion, "") + .tags(vec![ + Tag::event(announcement.id), + Tag::custom(TagKind::custom("k"), vec!["30617"]), + ]) + .sign_with_keys(&attacker_keys) + .unwrap(); + + let policy = DeletionPolicy::new(ctx.clone()); + let result = policy.handle(&deletion).await; + + assert!(matches!(result, WritePolicyResult::Accept)); + assert!( + ctx.purgatory.has_purgatory_announcement(&owner_keys.public_key(), identifier), + "Purgatory entry should NOT have been removed by wrong author" + ); + } + + #[tokio::test] + async fn test_deletion_by_coordinate_wrong_author_does_not_remove() { + let ctx = make_context(); + let owner_keys = Keys::generate(); + let attacker_keys = Keys::generate(); + let identifier = "my-repo"; + + let announcement = make_announcement_event(&owner_keys, identifier); + add_to_purgatory(&ctx, &announcement, identifier); + + // Attacker tries to delete by coordinate using owner's pubkey in coord + // but signs with their own key — coord pubkey != deletion author + let coord = format!("30617:{}:{}", owner_keys.public_key().to_hex(), identifier); + let deletion = EventBuilder::new(Kind::EventDeletion, "") + .tags(vec![ + Tag::custom(TagKind::custom("a"), vec![coord]), + Tag::custom(TagKind::custom("k"), vec!["30617"]), + ]) + .sign_with_keys(&attacker_keys) + .unwrap(); + + let policy = DeletionPolicy::new(ctx.clone()); + let result = policy.handle(&deletion).await; + + assert!(matches!(result, WritePolicyResult::Accept)); + assert!( + ctx.purgatory.has_purgatory_announcement(&owner_keys.public_key(), identifier), + "Purgatory entry should NOT have been removed by wrong author" + ); + } + + #[tokio::test] + async fn test_deletion_of_nonexistent_entry_is_accepted() { + let ctx = make_context(); + let keys = Keys::generate(); + + // No purgatory entry exists — deletion should still be accepted + let deletion = EventBuilder::new(Kind::EventDeletion, "") + .tags(vec![ + Tag::custom(TagKind::custom("a"), vec![ + format!("30617:{}:nonexistent", keys.public_key().to_hex()) + ]), + ]) + .sign_with_keys(&keys) + .unwrap(); + + let policy = DeletionPolicy::new(ctx.clone()); + let result = policy.handle(&deletion).await; + + assert!(matches!(result, WritePolicyResult::Accept)); + } + + #[tokio::test] + async fn test_deletion_by_coordinate_respects_created_at() { + let ctx = make_context(); + let keys = Keys::generate(); + let identifier = "my-repo"; + + // Create announcement with a future timestamp + let future_ts = Timestamp::now().as_secs() + 3600; // 1 hour in the future + let announcement = EventBuilder::new(Kind::GitRepoAnnouncement, "") + .tags(vec![Tag::identifier(identifier)]) + .custom_created_at(Timestamp::from(future_ts)) + .sign_with_keys(&keys) + .unwrap(); + add_to_purgatory(&ctx, &announcement, identifier); + + // Deletion event with current timestamp (older than announcement) + let coord = format!("30617:{}:{}", keys.public_key().to_hex(), identifier); + let deletion = EventBuilder::new(Kind::EventDeletion, "") + .tags(vec![Tag::custom(TagKind::custom("a"), vec![coord])]) + .sign_with_keys(&keys) + .unwrap(); + + let policy = DeletionPolicy::new(ctx.clone()); + let result = policy.handle(&deletion).await; + + assert!(matches!(result, WritePolicyResult::Accept)); + assert!( + ctx.purgatory.has_purgatory_announcement(&keys.public_key(), identifier), + "Purgatory entry should NOT be removed: entry is newer than deletion request" + ); + } +} diff --git a/src/nostr/policy/mod.rs b/src/nostr/policy/mod.rs index 1566b6c..f5b981a 100644 --- a/src/nostr/policy/mod.rs +++ b/src/nostr/policy/mod.rs @@ -6,11 +6,13 @@ /// - `PrEventPolicy` - PR/PR Update validation /// - `RelatedEventPolicy` - Forward/backward reference checking mod announcement; +mod deletion; mod pr_event; mod related; mod state; pub use announcement::{AnnouncementPolicy, AnnouncementResult}; +pub use deletion::DeletionPolicy; pub use pr_event::PrEventPolicy; pub use related::{ReferenceResult, RelatedEventPolicy}; pub use state::{StatePolicy, StateResult}; diff --git a/tests/purgatory.rs b/tests/purgatory.rs index efc28c9..553271f 100644 --- a/tests/purgatory.rs +++ b/tests/purgatory.rs @@ -66,6 +66,13 @@ isolated_purgatory_test!(test_announcement_served_after_git_push); isolated_purgatory_test!(test_bare_repo_exists_for_purgatory_announcement); isolated_purgatory_test!(test_state_event_accepted_for_purgatory_announcement); +// ============================================================ +// Deletion Event Tests (NIP-09) +// ============================================================ + +isolated_purgatory_test!(test_deletion_by_event_id_removes_purgatory_announcement); +isolated_purgatory_test!(test_deletion_by_coordinate_removes_purgatory_announcement); + // ============================================================ // State Event Purgatory Tests (already implemented) // ============================================================ -- cgit v1.2.3