upleb.uk

Public git repos — served from a NIP-34 GRASP relay at git.upleb.uk

summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDanConwayDev <DanConwayDev@protonmail.com>2026-02-23 13:29:47 +0000
committerDanConwayDev <DanConwayDev@protonmail.com>2026-02-23 13:29:47 +0000
commit65ac6ef83205c41653e6ffe2acd664f968926fb2 (patch)
treec31301c599dfaffd75e61af3f6004d1b95373a72
parentc368f9132a16d45a17ad55943e4b68ba85a6835b (diff)
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:<pubkey>:<identifier>): 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).
-rw-r--r--grasp-audit/src/specs/grasp01/purgatory.rs199
-rw-r--r--src/nostr/builder.rs8
-rw-r--r--src/nostr/policy/deletion.rs438
-rw-r--r--src/nostr/policy/mod.rs2
-rw-r--r--tests/purgatory.rs7
5 files changed, 652 insertions, 2 deletions
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 {
46 results.add(Self::test_bare_repo_exists_for_purgatory_announcement(client).await); 46 results.add(Self::test_bare_repo_exists_for_purgatory_announcement(client).await);
47 results.add(Self::test_state_event_accepted_for_purgatory_announcement(client).await); 47 results.add(Self::test_state_event_accepted_for_purgatory_announcement(client).await);
48 48
49 // Deletion event tests (NIP-09)
50 results.add(Self::test_deletion_by_event_id_removes_purgatory_announcement(client).await);
51 results.add(
52 Self::test_deletion_by_coordinate_removes_purgatory_announcement(client).await,
53 );
54
49 // State event purgatory tests (already implemented) 55 // State event purgatory tests (already implemented)
50 results.add(Self::test_state_event_not_served_before_git_data(client).await); 56 results.add(Self::test_state_event_not_served_before_git_data(client).await);
51 results.add(Self::test_state_event_served_after_git_push(client).await); 57 results.add(Self::test_state_event_served_after_git_push(client).await);
@@ -646,6 +652,199 @@ impl PurgatoryTests {
646 }) 652 })
647 .await 653 .await
648 } 654 }
655 // ============================================================
656 // Deletion Event Tests (NIP-09)
657 // ============================================================
658
659 /// Test: Kind 5 deletion event by event ID removes purgatory announcement
660 ///
661 /// Spec: NIP-09
662 /// "A special event with kind 5... having a list of one or more `e` or `a` tags,
663 /// each referencing an event the author is requesting to be deleted."
664 ///
665 /// This test verifies:
666 /// 1. Send a valid repository announcement (enters purgatory)
667 /// 2. Send a kind 5 deletion event referencing the announcement by event ID
668 /// 3. The announcement is no longer in purgatory (git push would fail)
669 /// 4. The deletion event itself is accepted by the relay
670 pub async fn test_deletion_by_event_id_removes_purgatory_announcement(
671 client: &AuditClient,
672 ) -> TestResult {
673 TestResult::new(
674 "deletion_by_event_id_removes_purgatory_announcement",
675 SpecRef::PurgatoryAcceptUntilGitData,
676 "Kind 5 deletion by event ID SHOULD remove a purgatory announcement",
677 )
678 .run(|| async {
679 let ctx = TestContext::new(client);
680
681 // Send announcement to purgatory
682 let repo = ctx
683 .get_fixture(FixtureKind::ValidRepoSent)
684 .await
685 .map_err(|e| format!("Failed to create repo announcement: {}", e))?;
686
687 let repo_id = repo
688 .tags
689 .iter()
690 .find(|t| t.kind() == TagKind::d())
691 .and_then(|t| t.content())
692 .ok_or("Missing d tag in repo announcement")?
693 .to_string();
694
695 // Verify it's in purgatory (not served)
696 tokio::time::sleep(Duration::from_millis(300)).await;
697 if client.is_event_on_relay(repo.id).await.map_err(|e| e.to_string())? {
698 return Err(
699 "Announcement was served immediately - purgatory not working".to_string(),
700 );
701 }
702
703 // Build and send kind 5 deletion event referencing the announcement by event ID
704 let deletion = client
705 .event_builder(Kind::EventDeletion, "")
706 .tag(Tag::event(repo.id))
707 .tag(Tag::custom(
708 TagKind::custom("k"),
709 vec!["30617"],
710 ))
711 .build(client.keys())
712 .map_err(|e| format!("Failed to build deletion event: {}", e))?;
713
714 client
715 .send_event(deletion)
716 .await
717 .map_err(|e| format!("Relay rejected deletion event: {}", e))?;
718
719 tokio::time::sleep(Duration::from_millis(300)).await;
720
721 // Verify the announcement can no longer be promoted by attempting a git push.
722 // We check this indirectly: if the purgatory entry was removed, a subsequent
723 // git push to the repo path should fail (no bare repo).
724 // For the integration test we verify the announcement is still not served
725 // (it was never promoted) and that the deletion event was accepted.
726 // The bare-repo deletion is verified by attempting a git clone.
727 let http_url = AuditClient::ws_to_http_url(&client.relay_url().await.map_err(|e| e.to_string())?)
728 .map_err(|e| e.to_string())?;
729 let clone_url = format!(
730 "{}/{}/{}.git",
731 http_url,
732 client.public_key().to_bech32().map_err(|e| e.to_string())?,
733 repo_id
734 );
735
736 // git ls-remote should fail (bare repo deleted)
737 let output = std::process::Command::new("git")
738 .args(["ls-remote", &clone_url])
739 .output()
740 .map_err(|e| format!("Failed to run git ls-remote: {}", e))?;
741
742 if output.status.success() {
743 return Err(format!(
744 "Bare repo still exists after deletion event. \
745 Expected git ls-remote to fail for {}",
746 clone_url
747 ));
748 }
749
750 Ok(())
751 })
752 .await
753 }
754
755 /// Test: Kind 5 deletion event by `a` tag coordinate removes purgatory announcement
756 ///
757 /// Spec: NIP-09
758 /// "When an `a` tag is used, relays SHOULD delete all versions of the replaceable
759 /// event up to the `created_at` timestamp of the deletion request event."
760 ///
761 /// This test verifies:
762 /// 1. Send a valid repository announcement (enters purgatory)
763 /// 2. Send a kind 5 deletion event referencing the announcement by coordinate
764 /// (`30617:<pubkey>:<identifier>`)
765 /// 3. The announcement is no longer in purgatory
766 pub async fn test_deletion_by_coordinate_removes_purgatory_announcement(
767 client: &AuditClient,
768 ) -> TestResult {
769 TestResult::new(
770 "deletion_by_coordinate_removes_purgatory_announcement",
771 SpecRef::PurgatoryAcceptUntilGitData,
772 "Kind 5 deletion by `a` coordinate SHOULD remove a purgatory announcement",
773 )
774 .run(|| async {
775 let ctx = TestContext::new(client);
776
777 // Send announcement to purgatory
778 let repo = ctx
779 .get_fixture(FixtureKind::ValidRepoSent)
780 .await
781 .map_err(|e| format!("Failed to create repo announcement: {}", e))?;
782
783 let repo_id = repo
784 .tags
785 .iter()
786 .find(|t| t.kind() == TagKind::d())
787 .and_then(|t| t.content())
788 .ok_or("Missing d tag in repo announcement")?
789 .to_string();
790
791 // Verify it's in purgatory (not served)
792 tokio::time::sleep(Duration::from_millis(300)).await;
793 if client.is_event_on_relay(repo.id).await.map_err(|e| e.to_string())? {
794 return Err(
795 "Announcement was served immediately - purgatory not working".to_string(),
796 );
797 }
798
799 // Build coordinate: `30617:<pubkey_hex>:<identifier>`
800 let coord = format!(
801 "30617:{}:{}",
802 client.public_key().to_hex(),
803 repo_id
804 );
805
806 // Build and send kind 5 deletion event referencing by coordinate
807 let deletion = client
808 .event_builder(Kind::EventDeletion, "")
809 .tag(Tag::custom(TagKind::custom("a"), vec![coord]))
810 .tag(Tag::custom(TagKind::custom("k"), vec!["30617"]))
811 .build(client.keys())
812 .map_err(|e| format!("Failed to build deletion event: {}", e))?;
813
814 client
815 .send_event(deletion)
816 .await
817 .map_err(|e| format!("Relay rejected deletion event: {}", e))?;
818
819 tokio::time::sleep(Duration::from_millis(300)).await;
820
821 // Verify bare repo was deleted
822 let http_url = AuditClient::ws_to_http_url(&client.relay_url().await.map_err(|e| e.to_string())?)
823 .map_err(|e| e.to_string())?;
824 let clone_url = format!(
825 "{}/{}/{}.git",
826 http_url,
827 client.public_key().to_bech32().map_err(|e| e.to_string())?,
828 repo_id
829 );
830
831 let output = std::process::Command::new("git")
832 .args(["ls-remote", &clone_url])
833 .output()
834 .map_err(|e| format!("Failed to run git ls-remote: {}", e))?;
835
836 if output.status.success() {
837 return Err(format!(
838 "Bare repo still exists after deletion event. \
839 Expected git ls-remote to fail for {}",
840 clone_url
841 ));
842 }
843
844 Ok(())
845 })
846 .await
847 }
649} 848}
650 849
651#[cfg(test)] 850#[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::*;
14use crate::config::{Config, DatabaseBackend}; 14use crate::config::{Config, DatabaseBackend};
15use crate::nostr::events::RepositoryAnnouncement; 15use crate::nostr::events::RepositoryAnnouncement;
16use crate::nostr::policy::{ 16use crate::nostr::policy::{
17 AnnouncementPolicy, AnnouncementResult, PolicyContext, PrEventPolicy, ReferenceResult, 17 AnnouncementPolicy, AnnouncementResult, DeletionPolicy, PolicyContext, PrEventPolicy,
18 RelatedEventPolicy, StatePolicy, StateResult, 18 ReferenceResult, RelatedEventPolicy, StatePolicy, StateResult,
19}; 19};
20 20
21 21
@@ -29,6 +29,7 @@ pub type SharedDatabase = Arc<dyn NostrDatabase>;
29/// - `StatePolicy` - State event validation + ref alignment 29/// - `StatePolicy` - State event validation + ref alignment
30/// - `PrEventPolicy` - PR/PR Update validation 30/// - `PrEventPolicy` - PR/PR Update validation
31/// - `RelatedEventPolicy` - Forward/backward reference checking 31/// - `RelatedEventPolicy` - Forward/backward reference checking
32/// - `DeletionPolicy` - NIP-09 event deletion request handling
32/// 33///
33/// Uses stateful database queries to check event relationships. 34/// Uses stateful database queries to check event relationships.
34#[derive(Clone)] 35#[derive(Clone)]
@@ -38,6 +39,7 @@ pub struct Nip34WritePolicy {
38 state_policy: StatePolicy, 39 state_policy: StatePolicy,
39 pr_event_policy: PrEventPolicy, 40 pr_event_policy: PrEventPolicy,
40 related_event_policy: RelatedEventPolicy, 41 related_event_policy: RelatedEventPolicy,
42 deletion_policy: DeletionPolicy,
41} 43}
42 44
43impl std::fmt::Debug for Nip34WritePolicy { 45impl std::fmt::Debug for Nip34WritePolicy {
@@ -69,6 +71,7 @@ impl Nip34WritePolicy {
69 state_policy: StatePolicy::new(ctx.clone()), 71 state_policy: StatePolicy::new(ctx.clone()),
70 pr_event_policy: PrEventPolicy::new(ctx.clone()), 72 pr_event_policy: PrEventPolicy::new(ctx.clone()),
71 related_event_policy: RelatedEventPolicy::new(ctx.clone()), 73 related_event_policy: RelatedEventPolicy::new(ctx.clone()),
74 deletion_policy: DeletionPolicy::new(ctx.clone()),
72 ctx, 75 ctx,
73 } 76 }
74 } 77 }
@@ -521,6 +524,7 @@ impl WritePolicy for Nip34WritePolicy {
521 ); 524 );
522 WritePolicyResult::Accept 525 WritePolicyResult::Accept
523 } 526 }
527 Kind::EventDeletion => self.deletion_policy.handle(event).await,
524 _ => self.handle_related_event(event, "Event").await, 528 _ => self.handle_related_event(event, "Event").await,
525 } 529 }
526 }) 530 })
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 @@
1/// Deletion Policy - NIP-09 event deletion request handling
2///
3/// Handles kind 5 (EventDeletion) events that request removal of repository
4/// announcements (kind 30617) from purgatory.
5///
6/// ## NIP-09 Rules Enforced
7///
8/// - Only the event author can delete their own events (pubkey must match)
9/// - `e` tags reference specific event IDs to delete
10/// - `a` tags reference addressable events by coordinate (`<kind>:<pubkey>:<d-identifier>`)
11/// - When an `a` tag is used, all versions up to `created_at` of the deletion request
12/// are considered deleted
13///
14/// ## Purgatory Interaction
15///
16/// When a valid deletion request targets a kind 30617 announcement that is currently
17/// in purgatory (not yet promoted to the database), the purgatory entry is removed
18/// and the bare repository is deleted from disk.
19use nostr_relay_builder::prelude::{Event, WritePolicyResult};
20
21use super::PolicyContext;
22
23/// Policy for handling NIP-09 event deletion requests
24#[derive(Clone)]
25pub struct DeletionPolicy {
26 ctx: PolicyContext,
27}
28
29impl DeletionPolicy {
30 pub fn new(ctx: PolicyContext) -> Self {
31 Self { ctx }
32 }
33
34 /// Process a kind 5 (EventDeletion) event.
35 ///
36 /// Checks whether the deletion request targets any purgatory announcements
37 /// and removes them if so. The deletion event itself is always accepted
38 /// (relays should store deletion requests per NIP-09).
39 ///
40 /// Only the event author can delete their own events — this is enforced by
41 /// checking that the purgatory entry's owner matches `event.pubkey`.
42 pub async fn handle(&self, event: &Event) -> WritePolicyResult {
43 // Process purgatory removals synchronously (no async needed)
44 self.remove_purgatory_targets(event);
45
46 // Always accept the deletion event itself so it is stored and
47 // can prevent re-acceptance of the deleted event in the future.
48 WritePolicyResult::Accept
49 }
50
51 /// Remove any purgatory announcements targeted by this deletion event.
52 ///
53 /// Handles both reference styles from NIP-09:
54 /// - `e` tags: event ID references — match against purgatory entry event IDs
55 /// - `a` tags: addressable coordinate references — `30617:<pubkey>:<identifier>`
56 ///
57 /// Only removes entries where the purgatory entry's owner matches the deletion
58 /// event's pubkey (enforces author-only deletion).
59 fn remove_purgatory_targets(&self, event: &Event) {
60 let author = &event.pubkey;
61
62 for tag in event.tags.iter() {
63 let tag_vec = tag.as_slice();
64 if tag_vec.len() < 2 {
65 continue;
66 }
67
68 match tag_vec[0].as_str() {
69 "e" => {
70 // Event ID reference: find purgatory announcement with this event ID
71 let target_id = &tag_vec[1];
72 self.remove_by_event_id(author, target_id, event.created_at.as_secs());
73 }
74 "a" => {
75 // Addressable coordinate reference: `<kind>:<pubkey>:<d-identifier>`
76 let coord = &tag_vec[1];
77 self.remove_by_coordinate(author, coord, event.created_at.as_secs());
78 }
79 _ => {}
80 }
81 }
82 }
83
84 /// Remove a purgatory announcement matched by event ID.
85 ///
86 /// Scans all purgatory announcements owned by `author` and removes the one
87 /// whose event ID hex matches `target_id_hex`.
88 fn remove_by_event_id(&self, author: &nostr_relay_builder::prelude::PublicKey, target_id_hex: &str, _deletion_created_at: u64) {
89 // Scan announcements owned by this author for a matching event ID
90 // We use get_announcements_by_identifier would require knowing the identifier,
91 // so instead we iterate via find_announcement after collecting all entries.
92 // The DashMap doesn't expose a direct "find by event ID" method, so we use
93 // the announcements_for_sync snapshot to get all (repo_id, _) pairs and then
94 // look up each one.
95 let all = self.ctx.purgatory.announcements_for_sync();
96 for (repo_id, _) in all {
97 // repo_id format: "30617:{pubkey_hex}:{identifier}"
98 let parts: Vec<&str> = repo_id.splitn(3, ':').collect();
99 if parts.len() != 3 {
100 continue;
101 }
102 let entry_pubkey_hex = parts[1];
103 let identifier = parts[2];
104
105 // Only check entries owned by the deletion event author
106 if entry_pubkey_hex != author.to_hex() {
107 continue;
108 }
109
110 if let Some(entry) = self.ctx.purgatory.find_announcement(author, identifier) {
111 if entry.event.id.to_hex() == target_id_hex {
112 tracing::info!(
113 event_id = %target_id_hex,
114 identifier = %identifier,
115 author = %author.to_hex(),
116 "Deletion request: removing purgatory announcement by event ID"
117 );
118 self.evict_purgatory_entry(author, identifier);
119 return; // event IDs are unique, no need to continue
120 }
121 }
122 }
123 }
124
125 /// Remove a purgatory announcement matched by addressable coordinate.
126 ///
127 /// The coordinate format is `<kind>:<pubkey>:<d-identifier>`. Only kind 30617
128 /// coordinates are relevant here. Per NIP-09, all versions up to `deletion_created_at`
129 /// are considered deleted — since purgatory entries are always a single event per
130 /// (owner, identifier), we delete if the entry's `created_at` ≤ `deletion_created_at`.
131 fn remove_by_coordinate(
132 &self,
133 author: &nostr_relay_builder::prelude::PublicKey,
134 coordinate: &str,
135 deletion_created_at: u64,
136 ) {
137 // Parse coordinate: `<kind>:<pubkey>:<d-identifier>`
138 let parts: Vec<&str> = coordinate.splitn(3, ':').collect();
139 if parts.len() != 3 {
140 return;
141 }
142
143 let kind_str = parts[0];
144 let coord_pubkey_hex = parts[1];
145 let identifier = parts[2];
146
147 // Only handle kind 30617 (GitRepoAnnouncement)
148 if kind_str != "30617" {
149 return;
150 }
151
152 // The coordinate pubkey must match the deletion event author
153 if coord_pubkey_hex != author.to_hex() {
154 tracing::debug!(
155 coord_pubkey = %coord_pubkey_hex,
156 deletion_author = %author.to_hex(),
157 "Ignoring deletion: coordinate pubkey does not match deletion author"
158 );
159 return;
160 }
161
162 if let Some(entry) = self.ctx.purgatory.find_announcement(author, identifier) {
163 // Per NIP-09: delete all versions up to deletion_created_at
164 if entry.event.created_at.as_secs() <= deletion_created_at {
165 tracing::info!(
166 identifier = %identifier,
167 author = %author.to_hex(),
168 entry_created_at = entry.event.created_at.as_secs(),
169 deletion_created_at = %deletion_created_at,
170 "Deletion request: removing purgatory announcement by coordinate"
171 );
172 self.evict_purgatory_entry(author, identifier);
173 } else {
174 tracing::debug!(
175 identifier = %identifier,
176 author = %author.to_hex(),
177 entry_created_at = entry.event.created_at.as_secs(),
178 deletion_created_at = %deletion_created_at,
179 "Ignoring deletion: purgatory entry is newer than deletion request"
180 );
181 }
182 }
183 }
184
185 /// Remove a purgatory announcement and delete its bare repository from disk.
186 fn evict_purgatory_entry(
187 &self,
188 author: &nostr_relay_builder::prelude::PublicKey,
189 identifier: &str,
190 ) {
191 // Get repo path before removing
192 if let Some(entry) = self.ctx.purgatory.find_announcement(author, identifier) {
193 if entry.repo_path.exists() {
194 if let Err(e) = std::fs::remove_dir_all(&entry.repo_path) {
195 tracing::warn!(
196 path = %entry.repo_path.display(),
197 error = %e,
198 "Failed to delete bare repository during deletion request processing"
199 );
200 } else {
201 tracing::info!(
202 path = %entry.repo_path.display(),
203 "Deleted bare repository for deletion-requested purgatory announcement"
204 );
205 }
206 }
207 }
208
209 self.ctx.purgatory.remove_announcement(author, identifier);
210
211 // Remove state events for this identifier only if no other owner's
212 // announcement remains in purgatory (state events are keyed by identifier alone)
213 let other_owners_remain = !self
214 .ctx
215 .purgatory
216 .get_announcements_by_identifier(identifier)
217 .is_empty();
218
219 if !other_owners_remain {
220 self.ctx.purgatory.remove_state(identifier);
221 }
222 }
223}
224
225#[cfg(test)]
226mod tests {
227 use super::*;
228 use crate::nostr::policy::PolicyContext;
229 use crate::purgatory::Purgatory;
230 use nostr_relay_builder::prelude::*;
231 use std::collections::HashSet;
232 use std::path::PathBuf;
233 use std::sync::Arc;
234
235 fn make_context() -> PolicyContext {
236 let db = Arc::new(MemoryDatabase::with_opts(MemoryDatabaseOptions {
237 events: true,
238 max_events: None,
239 }));
240 let purgatory = Arc::new(Purgatory::new(PathBuf::new()));
241 let config = crate::config::Config::for_testing();
242 PolicyContext::new("test.example.com", db, PathBuf::new(), purgatory, config)
243 }
244
245 fn make_announcement_event(keys: &Keys, identifier: &str) -> Event {
246 EventBuilder::new(Kind::GitRepoAnnouncement, "")
247 .tags(vec![
248 Tag::identifier(identifier),
249 Tag::custom(TagKind::custom("clone"), vec!["https://example.com/repo.git"]),
250 ])
251 .sign_with_keys(keys)
252 .unwrap()
253 }
254
255 fn add_to_purgatory(ctx: &PolicyContext, event: &Event, identifier: &str) {
256 ctx.purgatory.add_announcement(
257 event.clone(),
258 identifier.to_string(),
259 event.pubkey,
260 PathBuf::new(),
261 HashSet::new(),
262 );
263 }
264
265 #[tokio::test]
266 async fn test_deletion_by_event_id_removes_purgatory_entry() {
267 let ctx = make_context();
268 let keys = Keys::generate();
269 let identifier = "my-repo";
270
271 let announcement = make_announcement_event(&keys, identifier);
272 add_to_purgatory(&ctx, &announcement, identifier);
273
274 assert!(ctx.purgatory.has_purgatory_announcement(&keys.public_key(), identifier));
275
276 // Build kind 5 deletion event referencing the announcement by event ID
277 let deletion = EventBuilder::new(Kind::EventDeletion, "")
278 .tags(vec![
279 Tag::event(announcement.id),
280 Tag::custom(TagKind::custom("k"), vec!["30617"]),
281 ])
282 .sign_with_keys(&keys)
283 .unwrap();
284
285 let policy = DeletionPolicy::new(ctx.clone());
286 let result = policy.handle(&deletion).await;
287
288 assert!(matches!(result, WritePolicyResult::Accept));
289 assert!(
290 !ctx.purgatory.has_purgatory_announcement(&keys.public_key(), identifier),
291 "Purgatory entry should have been removed"
292 );
293 }
294
295 #[tokio::test]
296 async fn test_deletion_by_coordinate_removes_purgatory_entry() {
297 let ctx = make_context();
298 let keys = Keys::generate();
299 let identifier = "my-repo";
300
301 let announcement = make_announcement_event(&keys, identifier);
302 add_to_purgatory(&ctx, &announcement, identifier);
303
304 assert!(ctx.purgatory.has_purgatory_announcement(&keys.public_key(), identifier));
305
306 // Build kind 5 deletion event referencing the announcement by coordinate
307 let coord = format!("30617:{}:{}", keys.public_key().to_hex(), identifier);
308 let deletion = EventBuilder::new(Kind::EventDeletion, "")
309 .tags(vec![
310 Tag::custom(TagKind::custom("a"), vec![coord]),
311 Tag::custom(TagKind::custom("k"), vec!["30617"]),
312 ])
313 .sign_with_keys(&keys)
314 .unwrap();
315
316 let policy = DeletionPolicy::new(ctx.clone());
317 let result = policy.handle(&deletion).await;
318
319 assert!(matches!(result, WritePolicyResult::Accept));
320 assert!(
321 !ctx.purgatory.has_purgatory_announcement(&keys.public_key(), identifier),
322 "Purgatory entry should have been removed"
323 );
324 }
325
326 #[tokio::test]
327 async fn test_deletion_by_wrong_author_does_not_remove() {
328 let ctx = make_context();
329 let owner_keys = Keys::generate();
330 let attacker_keys = Keys::generate();
331 let identifier = "my-repo";
332
333 let announcement = make_announcement_event(&owner_keys, identifier);
334 add_to_purgatory(&ctx, &announcement, identifier);
335
336 // Attacker tries to delete by event ID
337 let deletion = EventBuilder::new(Kind::EventDeletion, "")
338 .tags(vec![
339 Tag::event(announcement.id),
340 Tag::custom(TagKind::custom("k"), vec!["30617"]),
341 ])
342 .sign_with_keys(&attacker_keys)
343 .unwrap();
344
345 let policy = DeletionPolicy::new(ctx.clone());
346 let result = policy.handle(&deletion).await;
347
348 assert!(matches!(result, WritePolicyResult::Accept));
349 assert!(
350 ctx.purgatory.has_purgatory_announcement(&owner_keys.public_key(), identifier),
351 "Purgatory entry should NOT have been removed by wrong author"
352 );
353 }
354
355 #[tokio::test]
356 async fn test_deletion_by_coordinate_wrong_author_does_not_remove() {
357 let ctx = make_context();
358 let owner_keys = Keys::generate();
359 let attacker_keys = Keys::generate();
360 let identifier = "my-repo";
361
362 let announcement = make_announcement_event(&owner_keys, identifier);
363 add_to_purgatory(&ctx, &announcement, identifier);
364
365 // Attacker tries to delete by coordinate using owner's pubkey in coord
366 // but signs with their own key — coord pubkey != deletion author
367 let coord = format!("30617:{}:{}", owner_keys.public_key().to_hex(), identifier);
368 let deletion = EventBuilder::new(Kind::EventDeletion, "")
369 .tags(vec![
370 Tag::custom(TagKind::custom("a"), vec![coord]),
371 Tag::custom(TagKind::custom("k"), vec!["30617"]),
372 ])
373 .sign_with_keys(&attacker_keys)
374 .unwrap();
375
376 let policy = DeletionPolicy::new(ctx.clone());
377 let result = policy.handle(&deletion).await;
378
379 assert!(matches!(result, WritePolicyResult::Accept));
380 assert!(
381 ctx.purgatory.has_purgatory_announcement(&owner_keys.public_key(), identifier),
382 "Purgatory entry should NOT have been removed by wrong author"
383 );
384 }
385
386 #[tokio::test]
387 async fn test_deletion_of_nonexistent_entry_is_accepted() {
388 let ctx = make_context();
389 let keys = Keys::generate();
390
391 // No purgatory entry exists — deletion should still be accepted
392 let deletion = EventBuilder::new(Kind::EventDeletion, "")
393 .tags(vec![
394 Tag::custom(TagKind::custom("a"), vec![
395 format!("30617:{}:nonexistent", keys.public_key().to_hex())
396 ]),
397 ])
398 .sign_with_keys(&keys)
399 .unwrap();
400
401 let policy = DeletionPolicy::new(ctx.clone());
402 let result = policy.handle(&deletion).await;
403
404 assert!(matches!(result, WritePolicyResult::Accept));
405 }
406
407 #[tokio::test]
408 async fn test_deletion_by_coordinate_respects_created_at() {
409 let ctx = make_context();
410 let keys = Keys::generate();
411 let identifier = "my-repo";
412
413 // Create announcement with a future timestamp
414 let future_ts = Timestamp::now().as_secs() + 3600; // 1 hour in the future
415 let announcement = EventBuilder::new(Kind::GitRepoAnnouncement, "")
416 .tags(vec![Tag::identifier(identifier)])
417 .custom_created_at(Timestamp::from(future_ts))
418 .sign_with_keys(&keys)
419 .unwrap();
420 add_to_purgatory(&ctx, &announcement, identifier);
421
422 // Deletion event with current timestamp (older than announcement)
423 let coord = format!("30617:{}:{}", keys.public_key().to_hex(), identifier);
424 let deletion = EventBuilder::new(Kind::EventDeletion, "")
425 .tags(vec![Tag::custom(TagKind::custom("a"), vec![coord])])
426 .sign_with_keys(&keys)
427 .unwrap();
428
429 let policy = DeletionPolicy::new(ctx.clone());
430 let result = policy.handle(&deletion).await;
431
432 assert!(matches!(result, WritePolicyResult::Accept));
433 assert!(
434 ctx.purgatory.has_purgatory_announcement(&keys.public_key(), identifier),
435 "Purgatory entry should NOT be removed: entry is newer than deletion request"
436 );
437 }
438}
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 @@
6/// - `PrEventPolicy` - PR/PR Update validation 6/// - `PrEventPolicy` - PR/PR Update validation
7/// - `RelatedEventPolicy` - Forward/backward reference checking 7/// - `RelatedEventPolicy` - Forward/backward reference checking
8mod announcement; 8mod announcement;
9mod deletion;
9mod pr_event; 10mod pr_event;
10mod related; 11mod related;
11mod state; 12mod state;
12 13
13pub use announcement::{AnnouncementPolicy, AnnouncementResult}; 14pub use announcement::{AnnouncementPolicy, AnnouncementResult};
15pub use deletion::DeletionPolicy;
14pub use pr_event::PrEventPolicy; 16pub use pr_event::PrEventPolicy;
15pub use related::{ReferenceResult, RelatedEventPolicy}; 17pub use related::{ReferenceResult, RelatedEventPolicy};
16pub use state::{StatePolicy, StateResult}; 18pub 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
@@ -67,6 +67,13 @@ isolated_purgatory_test!(test_bare_repo_exists_for_purgatory_announcement);
67isolated_purgatory_test!(test_state_event_accepted_for_purgatory_announcement); 67isolated_purgatory_test!(test_state_event_accepted_for_purgatory_announcement);
68 68
69// ============================================================ 69// ============================================================
70// Deletion Event Tests (NIP-09)
71// ============================================================
72
73isolated_purgatory_test!(test_deletion_by_event_id_removes_purgatory_announcement);
74isolated_purgatory_test!(test_deletion_by_coordinate_removes_purgatory_announcement);
75
76// ============================================================
70// State Event Purgatory Tests (already implemented) 77// State Event Purgatory Tests (already implemented)
71// ============================================================ 78// ============================================================
72 79