diff options
| author | DanConwayDev <DanConwayDev@protonmail.com> | 2026-02-23 13:29:47 +0000 |
|---|---|---|
| committer | DanConwayDev <DanConwayDev@protonmail.com> | 2026-02-23 13:29:47 +0000 |
| commit | 65ac6ef83205c41653e6ffe2acd664f968926fb2 (patch) | |
| tree | c31301c599dfaffd75e61af3f6004d1b95373a72 | |
| parent | c368f9132a16d45a17ad55943e4b68ba85a6835b (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.rs | 199 | ||||
| -rw-r--r-- | src/nostr/builder.rs | 8 | ||||
| -rw-r--r-- | src/nostr/policy/deletion.rs | 438 | ||||
| -rw-r--r-- | src/nostr/policy/mod.rs | 2 | ||||
| -rw-r--r-- | tests/purgatory.rs | 7 |
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::*; | |||
| 14 | use crate::config::{Config, DatabaseBackend}; | 14 | use crate::config::{Config, DatabaseBackend}; |
| 15 | use crate::nostr::events::RepositoryAnnouncement; | 15 | use crate::nostr::events::RepositoryAnnouncement; |
| 16 | use crate::nostr::policy::{ | 16 | use 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 | ||
| 43 | impl std::fmt::Debug for Nip34WritePolicy { | 45 | impl 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. | ||
| 19 | use nostr_relay_builder::prelude::{Event, WritePolicyResult}; | ||
| 20 | |||
| 21 | use super::PolicyContext; | ||
| 22 | |||
| 23 | /// Policy for handling NIP-09 event deletion requests | ||
| 24 | #[derive(Clone)] | ||
| 25 | pub struct DeletionPolicy { | ||
| 26 | ctx: PolicyContext, | ||
| 27 | } | ||
| 28 | |||
| 29 | impl 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)] | ||
| 226 | mod 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 |
| 8 | mod announcement; | 8 | mod announcement; |
| 9 | mod deletion; | ||
| 9 | mod pr_event; | 10 | mod pr_event; |
| 10 | mod related; | 11 | mod related; |
| 11 | mod state; | 12 | mod state; |
| 12 | 13 | ||
| 13 | pub use announcement::{AnnouncementPolicy, AnnouncementResult}; | 14 | pub use announcement::{AnnouncementPolicy, AnnouncementResult}; |
| 15 | pub use deletion::DeletionPolicy; | ||
| 14 | pub use pr_event::PrEventPolicy; | 16 | pub use pr_event::PrEventPolicy; |
| 15 | pub use related::{ReferenceResult, RelatedEventPolicy}; | 17 | pub use related::{ReferenceResult, RelatedEventPolicy}; |
| 16 | pub use state::{StatePolicy, StateResult}; | 18 | 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 | |||
| @@ -67,6 +67,13 @@ isolated_purgatory_test!(test_bare_repo_exists_for_purgatory_announcement); | |||
| 67 | isolated_purgatory_test!(test_state_event_accepted_for_purgatory_announcement); | 67 | isolated_purgatory_test!(test_state_event_accepted_for_purgatory_announcement); |
| 68 | 68 | ||
| 69 | // ============================================================ | 69 | // ============================================================ |
| 70 | // Deletion Event Tests (NIP-09) | ||
| 71 | // ============================================================ | ||
| 72 | |||
| 73 | isolated_purgatory_test!(test_deletion_by_event_id_removes_purgatory_announcement); | ||
| 74 | isolated_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 | ||