upleb.uk

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

summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--grasp-audit/src/specs/grasp01/purgatory.rs329
-rw-r--r--src/nostr/policy/deletion.rs138
-rw-r--r--tests/purgatory.rs4
3 files changed, 307 insertions, 164 deletions
diff --git a/grasp-audit/src/specs/grasp01/purgatory.rs b/grasp-audit/src/specs/grasp01/purgatory.rs
index 9d97d3b..29eabad 100644
--- a/grasp-audit/src/specs/grasp01/purgatory.rs
+++ b/grasp-audit/src/specs/grasp01/purgatory.rs
@@ -27,9 +27,11 @@
27//! - `test_pr_event_in_purgatory_git_push_accepted` - Git push to refs/nostr/<event-id> succeeds 27//! - `test_pr_event_in_purgatory_git_push_accepted` - Git push to refs/nostr/<event-id> succeeds
28//! - `test_pr_event_served_after_git_push` - Event becomes queryable after git data 28//! - `test_pr_event_served_after_git_push` - Event becomes queryable after git data
29 29
30use crate::fixtures::{clone_repo, create_commit, try_push};
30use crate::specs::grasp01::SpecRef; 31use crate::specs::grasp01::SpecRef;
31use crate::{AuditClient, AuditResult, FixtureKind, TestContext, TestResult}; 32use crate::{AuditClient, AuditResult, FixtureKind, TestContext, TestResult};
32use nostr_sdk::prelude::*; 33use nostr_sdk::prelude::*;
34use std::fs;
33use std::time::Duration; 35use std::time::Duration;
34 36
35/// Test suite for GRASP-01 purgatory behavior 37/// Test suite for GRASP-01 purgatory behavior
@@ -47,9 +49,9 @@ impl PurgatoryTests {
47 results.add(Self::test_state_event_accepted_for_purgatory_announcement(client).await); 49 results.add(Self::test_state_event_accepted_for_purgatory_announcement(client).await);
48 50
49 // Deletion event tests (NIP-09) 51 // Deletion event tests (NIP-09)
50 results.add(Self::test_deletion_by_event_id_removes_purgatory_announcement(client).await); 52 results.add(Self::test_deletion_by_event_id_removes_purgatory_state_event(client).await);
51 results.add( 53 results.add(
52 Self::test_deletion_by_coordinate_removes_purgatory_announcement(client).await, 54 Self::test_deletion_by_coordinate_removes_purgatory_state_event(client).await,
53 ); 55 );
54 56
55 // State event purgatory tests (already implemented) 57 // State event purgatory tests (already implemented)
@@ -656,192 +658,293 @@ impl PurgatoryTests {
656 // Deletion Event Tests (NIP-09) 658 // Deletion Event Tests (NIP-09)
657 // ============================================================ 659 // ============================================================
658 660
659 /// Test: Kind 5 deletion event by event ID removes purgatory announcement 661 /// Test: Kind 5 deletion event by event ID removes a purgatory state event
660 /// 662 ///
661 /// Spec: NIP-09 663 /// Spec: NIP-09
662 /// "A special event with kind 5... having a list of one or more `e` or `a` tags, 664 /// "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." 665 /// each referencing an event the author is requesting to be deleted."
664 /// 666 ///
665 /// This test verifies: 667 /// This test verifies:
666 /// 1. Send a valid repository announcement (enters purgatory) 668 /// 1. Get a promoted repo (OwnerStateDataPushed) so git pushes are possible
667 /// 2. Send a kind 5 deletion event referencing the announcement by event ID 669 /// 2. Clone the repo and create a unique commit (not yet pushed)
668 /// 3. The announcement is no longer in purgatory (git push would fail) 670 /// 3. Submit a state event pointing to that unique commit (enters purgatory)
669 /// 4. The deletion event itself is accepted by the relay 671 /// 4. Send a kind 5 deletion event referencing the state event by event ID
670 pub async fn test_deletion_by_event_id_removes_purgatory_announcement( 672 /// 5. Attempt to push the unique commit — MUST be rejected (no authorized state event)
673 pub async fn test_deletion_by_event_id_removes_purgatory_state_event(
671 client: &AuditClient, 674 client: &AuditClient,
672 ) -> TestResult { 675 ) -> TestResult {
673 TestResult::new( 676 TestResult::new(
674 "deletion_by_event_id_removes_purgatory_announcement", 677 "deletion_by_event_id_removes_purgatory_state_event",
675 SpecRef::PurgatoryAcceptUntilGitData, 678 SpecRef::PurgatoryAcceptUntilGitData,
676 "Kind 5 deletion by event ID SHOULD remove a purgatory announcement", 679 "Kind 5 deletion by event ID SHOULD remove a purgatory state event, causing push rejection",
677 ) 680 )
678 .run(|| async { 681 .run(|| async {
679 let ctx = TestContext::new(client); 682 let ctx = TestContext::new(client);
680 683
681 // Send announcement to purgatory 684 // Stage 1: get a promoted repo with git data already on the relay
682 let repo = ctx 685 let existing_state = ctx
683 .get_fixture(FixtureKind::ValidRepoSent) 686 .get_fixture(FixtureKind::OwnerStateDataPushed)
684 .await 687 .await
685 .map_err(|e| format!("Failed to create repo announcement: {}", e))?; 688 .map_err(|e| format!("Failed to get promoted repo: {}", e))?;
686 689
687 let repo_id = repo 690 let repo_id = existing_state
688 .tags 691 .tags
689 .iter() 692 .iter()
690 .find(|t| t.kind() == TagKind::d()) 693 .find(|t| t.kind() == TagKind::d())
691 .and_then(|t| t.content()) 694 .and_then(|t| t.content())
692 .ok_or("Missing d tag in repo announcement")? 695 .ok_or("Missing d tag in state event")?
693 .to_string(); 696 .to_string();
694 697
695 // Verify it's in purgatory (not served) 698 let relay_domain = client
696 tokio::time::sleep(Duration::from_millis(300)).await; 699 .relay_url()
697 if client.is_event_on_relay(repo.id).await.map_err(|e| e.to_string())? { 700 .await
698 return Err( 701 .map_err(|e| e.to_string())?
699 "Announcement was served immediately - purgatory not working".to_string(), 702 .trim_start_matches("ws://")
700 ); 703 .trim_start_matches("wss://")
704 .to_string();
705
706 let npub = client
707 .public_key()
708 .to_bech32()
709 .map_err(|e| e.to_string())?;
710
711 // Stage 2: clone the repo and create a unique commit (not pushed yet)
712 let clone_path = clone_repo(&relay_domain, &npub, &repo_id)
713 .map_err(|e| format!("Failed to clone repo: {}", e))?;
714
715 let cleanup = || { let _ = fs::remove_dir_all(&clone_path); };
716
717 let unique_commit = match create_commit(&clone_path, "deletion test unique commit") {
718 Ok(h) => h,
719 Err(e) => { cleanup(); return Err(format!("Failed to create commit: {}", e)); }
720 };
721
722 // Stage 3: submit a state event pointing to the unique commit (enters purgatory)
723 let state_event = client
724 .event_builder(Kind::RepoState, "")
725 .tag(Tag::identifier(&repo_id))
726 .tag(Tag::custom(
727 TagKind::custom("refs/heads/main"),
728 vec![unique_commit.clone()],
729 ))
730 .tag(Tag::custom(
731 TagKind::custom("HEAD"),
732 vec!["ref: refs/heads/main".to_string()],
733 ))
734 .build(client.keys())
735 .map_err(|e| { cleanup(); format!("Failed to build state event: {}", e) })?;
736
737 let (_, in_purgatory) = client
738 .send_event_and_note_purgatory(state_event.clone())
739 .await
740 .map_err(|e| { cleanup(); format!("Failed to send state event: {}", e) })?;
741
742 if !in_purgatory {
743 cleanup();
744 return Err(format!(
745 "State event was served immediately (not in purgatory). \
746 Commit {} may already exist on relay.",
747 unique_commit
748 ));
701 } 749 }
702 750
703 // Build and send kind 5 deletion event referencing the announcement by event ID 751 // Stage 4: send kind 5 deletion event referencing the state event by event ID
704 let deletion = client 752 let deletion = client
705 .event_builder(Kind::EventDeletion, "") 753 .event_builder(Kind::EventDeletion, "")
706 .tag(Tag::event(repo.id)) 754 .tag(Tag::event(state_event.id))
707 .tag(Tag::custom( 755 .tag(Tag::custom(TagKind::custom("k"), vec!["30618"]))
708 TagKind::custom("k"),
709 vec!["30617"],
710 ))
711 .build(client.keys()) 756 .build(client.keys())
712 .map_err(|e| format!("Failed to build deletion event: {}", e))?; 757 .map_err(|e| { cleanup(); format!("Failed to build deletion event: {}", e) })?;
713 758
714 client 759 client
715 .send_event(deletion) 760 .send_event(deletion)
716 .await 761 .await
717 .map_err(|e| format!("Relay rejected deletion event: {}", e))?; 762 .map_err(|e| { cleanup(); format!("Relay rejected deletion event: {}", e) })?;
718 763
719 tokio::time::sleep(Duration::from_millis(300)).await; 764 tokio::time::sleep(Duration::from_millis(300)).await;
720 765
721 // Verify the announcement can no longer be promoted by attempting a git push. 766 // Stage 5: attempt to push the unique commit — must be rejected
722 // We check this indirectly: if the purgatory entry was removed, a subsequent 767 let push_result = try_push(&clone_path);
723 // git push to the repo path should fail (no bare repo). 768 cleanup();
724 // For the integration test we verify the announcement is still not served 769
725 // (it was never promoted) and that the deletion event was accepted. 770 match push_result {
726 // The bare-repo deletion is verified by attempting a git clone. 771 Ok(false) => Ok(()), // push rejected as expected
727 let http_url = AuditClient::ws_to_http_url(&client.relay_url().await.map_err(|e| e.to_string())?) 772 Ok(true) => Err(format!(
728 .map_err(|e| e.to_string())?; 773 "Push was accepted but should have been rejected. \
729 let clone_url = format!( 774 The state event (id={}) was deleted, so commit {} \
730 "{}/{}/{}.git", 775 should not be authorized.",
731 http_url, 776 state_event.id, unique_commit
732 client.public_key().to_bech32().map_err(|e| e.to_string())?, 777 )),
733 repo_id 778 Err(e) => Err(format!("Git push error: {}", e)),
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 } 779 }
749
750 Ok(())
751 }) 780 })
752 .await 781 .await
753 } 782 }
754 783
755 /// Test: Kind 5 deletion event by `a` tag coordinate removes purgatory announcement 784 /// Test: Kind 5 deletion event by `a` tag coordinate removes a purgatory state event
756 /// 785 ///
757 /// Spec: NIP-09 786 /// Spec: NIP-09
758 /// "When an `a` tag is used, relays SHOULD delete all versions of the replaceable 787 /// "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." 788 /// event up to the `created_at` timestamp of the deletion request event."
760 /// 789 ///
761 /// This test verifies: 790 /// This test verifies:
762 /// 1. Send a valid repository announcement (enters purgatory) 791 /// 1. Get a promoted repo (OwnerStateDataPushed) so git pushes are possible
763 /// 2. Send a kind 5 deletion event referencing the announcement by coordinate 792 /// 2. Generate a fresh keypair for a new maintainer
764 /// (`30617:<pubkey>:<identifier>`) 793 /// 3. Send a replacement owner announcement adding the new maintainer (goes to DB)
765 /// 3. The announcement is no longer in purgatory 794 /// 4. Send a state event signed by the new maintainer pointing to a unique commit
766 pub async fn test_deletion_by_coordinate_removes_purgatory_announcement( 795 /// (enters purgatory — maintainer is authorized but commit doesn't exist yet)
796 /// 5. Delete by coordinate `30618:<new_maintainer_pubkey>:<identifier>`
797 /// 6. Clone repo, create that unique commit, attempt to push — MUST be rejected
798 /// (the state event was deleted, so the commit is no longer authorized)
799 pub async fn test_deletion_by_coordinate_removes_purgatory_state_event(
767 client: &AuditClient, 800 client: &AuditClient,
768 ) -> TestResult { 801 ) -> TestResult {
769 TestResult::new( 802 TestResult::new(
770 "deletion_by_coordinate_removes_purgatory_announcement", 803 "deletion_by_coordinate_removes_purgatory_state_event",
771 SpecRef::PurgatoryAcceptUntilGitData, 804 SpecRef::PurgatoryAcceptUntilGitData,
772 "Kind 5 deletion by `a` coordinate SHOULD remove a purgatory announcement", 805 "Kind 5 deletion by `a` coordinate SHOULD remove a purgatory state event, causing push rejection",
773 ) 806 )
774 .run(|| async { 807 .run(|| async {
775 let ctx = TestContext::new(client); 808 let ctx = TestContext::new(client);
776 809
777 // Send announcement to purgatory 810 // Stage 1: get a promoted repo with git data already on the relay
778 let repo = ctx 811 let existing_state = ctx
779 .get_fixture(FixtureKind::ValidRepoSent) 812 .get_fixture(FixtureKind::OwnerStateDataPushed)
780 .await 813 .await
781 .map_err(|e| format!("Failed to create repo announcement: {}", e))?; 814 .map_err(|e| format!("Failed to get promoted repo: {}", e))?;
782 815
783 let repo_id = repo 816 let repo_id = existing_state
784 .tags 817 .tags
785 .iter() 818 .iter()
786 .find(|t| t.kind() == TagKind::d()) 819 .find(|t| t.kind() == TagKind::d())
787 .and_then(|t| t.content()) 820 .and_then(|t| t.content())
788 .ok_or("Missing d tag in repo announcement")? 821 .ok_or("Missing d tag in state event")?
789 .to_string(); 822 .to_string();
790 823
791 // Verify it's in purgatory (not served) 824 // Stage 2: generate a fresh keypair for a new maintainer
792 tokio::time::sleep(Duration::from_millis(300)).await; 825 let new_maintainer_keys = Keys::generate();
793 if client.is_event_on_relay(repo.id).await.map_err(|e| e.to_string())? { 826 let new_maintainer_hex = new_maintainer_keys.public_key().to_hex();
794 return Err(
795 "Announcement was served immediately - purgatory not working".to_string(),
796 );
797 }
798 827
799 // Build coordinate: `30617:<pubkey_hex>:<identifier>` 828 // Stage 3: send a replacement owner announcement that adds the new maintainer.
800 let coord = format!( 829 // This is a replacement (same pubkey + identifier already in DB) so it goes
801 "30617:{}:{}", 830 // straight to the database without entering purgatory.
802 client.public_key().to_hex(), 831 let relay_url = client
803 repo_id 832 .relay_url()
804 ); 833 .await
834 .map_err(|e| e.to_string())?;
835 let http_url = relay_url
836 .replace("ws://", "http://")
837 .replace("wss://", "https://");
838 let npub = client
839 .public_key()
840 .to_bech32()
841 .map_err(|e| e.to_string())?;
805 842
806 // Build and send kind 5 deletion event referencing by coordinate 843 let replacement_announcement = client
807 let deletion = client 844 .event_builder(Kind::GitRepoAnnouncement, "")
808 .event_builder(Kind::EventDeletion, "") 845 .tag(Tag::identifier(&repo_id))
809 .tag(Tag::custom(TagKind::custom("a"), vec![coord])) 846 .tag(Tag::custom(
810 .tag(Tag::custom(TagKind::custom("k"), vec!["30617"])) 847 TagKind::custom("clone"),
848 vec![format!("{}/{}/{}.git", http_url, npub, repo_id)],
849 ))
850 .tag(Tag::custom(
851 TagKind::custom("relays"),
852 vec![relay_url.clone()],
853 ))
854 .tag(Tag::custom(
855 TagKind::custom("maintainers"),
856 vec![new_maintainer_hex.clone()],
857 ))
811 .build(client.keys()) 858 .build(client.keys())
812 .map_err(|e| format!("Failed to build deletion event: {}", e))?; 859 .map_err(|e| format!("Failed to build replacement announcement: {}", e))?;
813 860
814 client 861 client
815 .send_event(deletion) 862 .send_event(replacement_announcement)
816 .await 863 .await
817 .map_err(|e| format!("Relay rejected deletion event: {}", e))?; 864 .map_err(|e| format!("Relay rejected replacement announcement: {}", e))?;
818 865
819 tokio::time::sleep(Duration::from_millis(300)).await; 866 tokio::time::sleep(Duration::from_millis(200)).await;
820 867
821 // Verify bare repo was deleted 868 // Stage 4: clone the repo and create a unique commit (not pushed yet)
822 let http_url = AuditClient::ws_to_http_url(&client.relay_url().await.map_err(|e| e.to_string())?) 869 let relay_domain = relay_url
823 .map_err(|e| e.to_string())?; 870 .trim_start_matches("ws://")
824 let clone_url = format!( 871 .trim_start_matches("wss://")
825 "{}/{}/{}.git", 872 .to_string();
826 http_url, 873
827 client.public_key().to_bech32().map_err(|e| e.to_string())?, 874 let clone_path = clone_repo(&relay_domain, &npub, &repo_id)
828 repo_id 875 .map_err(|e| format!("Failed to clone repo: {}", e))?;
829 ); 876
877 let cleanup = || { let _ = fs::remove_dir_all(&clone_path); };
830 878
831 let output = std::process::Command::new("git") 879 let unique_commit = match create_commit(&clone_path, "deletion coordinate test unique commit") {
832 .args(["ls-remote", &clone_url]) 880 Ok(h) => h,
833 .output() 881 Err(e) => { cleanup(); return Err(format!("Failed to create commit: {}", e)); }
834 .map_err(|e| format!("Failed to run git ls-remote: {}", e))?; 882 };
835 883
836 if output.status.success() { 884 // Stage 5: submit a state event signed by the new maintainer pointing to the
885 // unique commit. The new maintainer is now authorized (listed in the replacement
886 // announcement), so the state event should enter purgatory (commit doesn't exist).
887 let state_event = client
888 .event_builder(Kind::RepoState, "")
889 .tag(Tag::identifier(&repo_id))
890 .tag(Tag::custom(
891 TagKind::custom("refs/heads/main"),
892 vec![unique_commit.clone()],
893 ))
894 .tag(Tag::custom(
895 TagKind::custom("HEAD"),
896 vec!["ref: refs/heads/main".to_string()],
897 ))
898 .build(&new_maintainer_keys)
899 .map_err(|e| { cleanup(); format!("Failed to build state event: {}", e) })?;
900
901 let (_, in_purgatory) = client
902 .send_event_and_note_purgatory(state_event.clone())
903 .await
904 .map_err(|e| { cleanup(); format!("Failed to send state event: {}", e) })?;
905
906 if !in_purgatory {
907 cleanup();
837 return Err(format!( 908 return Err(format!(
838 "Bare repo still exists after deletion event. \ 909 "State event was served immediately (not in purgatory). \
839 Expected git ls-remote to fail for {}", 910 Commit {} may already exist on relay.",
840 clone_url 911 unique_commit
841 )); 912 ));
842 } 913 }
843 914
844 Ok(()) 915 // Stage 6: send kind 5 deletion event signed by the new maintainer,
916 // referencing their state event by coordinate `30618:<pubkey>:<identifier>`
917 let coord = format!("30618:{}:{}", new_maintainer_hex, repo_id);
918
919 let deletion = client
920 .event_builder(Kind::EventDeletion, "")
921 .tag(Tag::custom(TagKind::custom("a"), vec![coord]))
922 .tag(Tag::custom(TagKind::custom("k"), vec!["30618"]))
923 .build(&new_maintainer_keys)
924 .map_err(|e| { cleanup(); format!("Failed to build deletion event: {}", e) })?;
925
926 client
927 .send_event(deletion)
928 .await
929 .map_err(|e| { cleanup(); format!("Relay rejected deletion event: {}", e) })?;
930
931 tokio::time::sleep(Duration::from_millis(300)).await;
932
933 // Stage 7: attempt to push the unique commit — must be rejected because
934 // the new maintainer's state event was deleted from purgatory
935 let push_result = try_push(&clone_path);
936 cleanup();
937
938 match push_result {
939 Ok(false) => Ok(()), // push rejected as expected
940 Ok(true) => Err(format!(
941 "Push was accepted but should have been rejected. \
942 The new maintainer's state event (id={}) was deleted by coordinate, \
943 so commit {} should not be authorized.",
944 state_event.id, unique_commit
945 )),
946 Err(e) => Err(format!("Git push error: {}", e)),
947 }
845 }) 948 })
846 .await 949 .await
847 } 950 }
diff --git a/src/nostr/policy/deletion.rs b/src/nostr/policy/deletion.rs
index 69a5758..01241c9 100644
--- a/src/nostr/policy/deletion.rs
+++ b/src/nostr/policy/deletion.rs
@@ -1,7 +1,7 @@
1/// Deletion Policy - NIP-09 event deletion request handling 1/// Deletion Policy - NIP-09 event deletion request handling
2/// 2///
3/// Handles kind 5 (EventDeletion) events that request removal of repository 3/// Handles kind 5 (EventDeletion) events that request removal of purgatory entries
4/// announcements (kind 30617) from purgatory. 4/// for repository announcements (kind 30617) and state events (kind 30618).
5/// 5///
6/// ## NIP-09 Rules Enforced 6/// ## NIP-09 Rules Enforced
7/// 7///
@@ -13,9 +13,9 @@
13/// 13///
14/// ## Purgatory Interaction 14/// ## Purgatory Interaction
15/// 15///
16/// When a valid deletion request targets a kind 30617 announcement that is currently 16/// - Kind 30617 (announcement) in purgatory: entry removed, bare repo deleted from disk
17/// in purgatory (not yet promoted to the database), the purgatory entry is removed 17/// - Kind 30618 (state event) in purgatory: matching state event(s) removed by event ID
18/// and the bare repository is deleted from disk. 18/// or by (author, identifier) coordinate
19use nostr_relay_builder::prelude::{Event, WritePolicyResult}; 19use nostr_relay_builder::prelude::{Event, WritePolicyResult};
20 20
21use super::PolicyContext; 21use super::PolicyContext;
@@ -48,13 +48,13 @@ impl DeletionPolicy {
48 WritePolicyResult::Accept 48 WritePolicyResult::Accept
49 } 49 }
50 50
51 /// Remove any purgatory announcements targeted by this deletion event. 51 /// Remove any purgatory entries targeted by this deletion event.
52 /// 52 ///
53 /// Handles both reference styles from NIP-09: 53 /// Handles both reference styles from NIP-09:
54 /// - `e` tags: event ID references — match against purgatory entry event IDs 54 /// - `e` tags: event ID references — match against announcement or state event IDs
55 /// - `a` tags: addressable coordinate references — `30617:<pubkey>:<identifier>` 55 /// - `a` tags: addressable coordinate references — `30617:…` or `30618:…`
56 /// 56 ///
57 /// Only removes entries where the purgatory entry's owner matches the deletion 57 /// Only removes entries where the purgatory entry's author matches the deletion
58 /// event's pubkey (enforces author-only deletion). 58 /// event's pubkey (enforces author-only deletion).
59 fn remove_purgatory_targets(&self, event: &Event) { 59 fn remove_purgatory_targets(&self, event: &Event) {
60 let author = &event.pubkey; 60 let author = &event.pubkey;
@@ -81,17 +81,19 @@ impl DeletionPolicy {
81 } 81 }
82 } 82 }
83 83
84 /// Remove a purgatory announcement matched by event ID. 84 /// Remove a purgatory entry (announcement or state event) matched by event ID.
85 /// 85 ///
86 /// Scans all purgatory announcements owned by `author` and removes the one 86 /// Checks announcements first (kind 30617), then state events (kind 30618).
87 /// whose event ID hex matches `target_id_hex`. 87 /// Only removes entries whose author matches `author`.
88 fn remove_by_event_id(&self, author: &nostr_relay_builder::prelude::PublicKey, target_id_hex: &str, _deletion_created_at: u64) { 88 fn remove_by_event_id(
89 // Scan announcements owned by this author for a matching event ID 89 &self,
90 // We use get_announcements_by_identifier would require knowing the identifier, 90 author: &nostr_relay_builder::prelude::PublicKey,
91 // so instead we iterate via find_announcement after collecting all entries. 91 target_id_hex: &str,
92 _deletion_created_at: u64,
93 ) {
94 // --- Check announcements (kind 30617) ---
92 // The DashMap doesn't expose a direct "find by event ID" method, so we use 95 // 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 96 // the announcements_for_sync snapshot to enumerate all (repo_id, _) pairs.
94 // look up each one.
95 let all = self.ctx.purgatory.announcements_for_sync(); 97 let all = self.ctx.purgatory.announcements_for_sync();
96 for (repo_id, _) in all { 98 for (repo_id, _) in all {
97 // repo_id format: "30617:{pubkey_hex}:{identifier}" 99 // repo_id format: "30617:{pubkey_hex}:{identifier}"
@@ -102,7 +104,6 @@ impl DeletionPolicy {
102 let entry_pubkey_hex = parts[1]; 104 let entry_pubkey_hex = parts[1];
103 let identifier = parts[2]; 105 let identifier = parts[2];
104 106
105 // Only check entries owned by the deletion event author
106 if entry_pubkey_hex != author.to_hex() { 107 if entry_pubkey_hex != author.to_hex() {
107 continue; 108 continue;
108 } 109 }
@@ -116,18 +117,37 @@ impl DeletionPolicy {
116 "Deletion request: removing purgatory announcement by event ID" 117 "Deletion request: removing purgatory announcement by event ID"
117 ); 118 );
118 self.evict_purgatory_entry(author, identifier); 119 self.evict_purgatory_entry(author, identifier);
119 return; // event IDs are unique, no need to continue 120 return; // event IDs are unique
121 }
122 }
123 }
124
125 // --- Check state events (kind 30618) ---
126 // State events are keyed by identifier; scan all identifiers for a match.
127 let state_identifiers = self.ctx.purgatory.get_all_identifiers();
128 for identifier in state_identifiers {
129 let entries = self.ctx.purgatory.find_state(&identifier);
130 for entry in entries {
131 if entry.author == *author && entry.event.id.to_hex() == target_id_hex {
132 tracing::info!(
133 event_id = %target_id_hex,
134 identifier = %identifier,
135 author = %author.to_hex(),
136 "Deletion request: removing purgatory state event by event ID"
137 );
138 self.ctx.purgatory.remove_state_event(&identifier, &entry.event.id);
139 return; // event IDs are unique
120 } 140 }
121 } 141 }
122 } 142 }
123 } 143 }
124 144
125 /// Remove a purgatory announcement matched by addressable coordinate. 145 /// Remove a purgatory entry matched by addressable coordinate.
146 ///
147 /// The coordinate format is `<kind>:<pubkey>:<d-identifier>`.
148 /// Handles kind 30617 (announcements) and kind 30618 (state events).
126 /// 149 ///
127 /// The coordinate format is `<kind>:<pubkey>:<d-identifier>`. Only kind 30617 150 /// Per NIP-09, all versions up to `deletion_created_at` are considered deleted.
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( 151 fn remove_by_coordinate(
132 &self, 152 &self,
133 author: &nostr_relay_builder::prelude::PublicKey, 153 author: &nostr_relay_builder::prelude::PublicKey,
@@ -144,11 +164,6 @@ impl DeletionPolicy {
144 let coord_pubkey_hex = parts[1]; 164 let coord_pubkey_hex = parts[1];
145 let identifier = parts[2]; 165 let identifier = parts[2];
146 166
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 167 // The coordinate pubkey must match the deletion event author
153 if coord_pubkey_hex != author.to_hex() { 168 if coord_pubkey_hex != author.to_hex() {
154 tracing::debug!( 169 tracing::debug!(
@@ -159,25 +174,50 @@ impl DeletionPolicy {
159 return; 174 return;
160 } 175 }
161 176
162 if let Some(entry) = self.ctx.purgatory.find_announcement(author, identifier) { 177 match kind_str {
163 // Per NIP-09: delete all versions up to deletion_created_at 178 "30617" => {
164 if entry.event.created_at.as_secs() <= deletion_created_at { 179 // Announcement purgatory entry
165 tracing::info!( 180 if let Some(entry) = self.ctx.purgatory.find_announcement(author, identifier) {
166 identifier = %identifier, 181 if entry.event.created_at.as_secs() <= deletion_created_at {
167 author = %author.to_hex(), 182 tracing::info!(
168 entry_created_at = entry.event.created_at.as_secs(), 183 identifier = %identifier,
169 deletion_created_at = %deletion_created_at, 184 author = %author.to_hex(),
170 "Deletion request: removing purgatory announcement by coordinate" 185 "Deletion request: removing purgatory announcement by coordinate"
171 ); 186 );
172 self.evict_purgatory_entry(author, identifier); 187 self.evict_purgatory_entry(author, identifier);
173 } else { 188 } else {
174 tracing::debug!( 189 tracing::debug!(
175 identifier = %identifier, 190 identifier = %identifier,
176 author = %author.to_hex(), 191 author = %author.to_hex(),
177 entry_created_at = entry.event.created_at.as_secs(), 192 "Ignoring deletion: purgatory announcement is newer than deletion request"
178 deletion_created_at = %deletion_created_at, 193 );
179 "Ignoring deletion: purgatory entry is newer than deletion request" 194 }
180 ); 195 }
196 }
197 "30618" => {
198 // State event purgatory entries for this (author, identifier).
199 // Remove all entries authored by `author` with created_at ≤ deletion_created_at.
200 let entries = self.ctx.purgatory.find_state(identifier);
201 let mut removed = 0usize;
202 for entry in entries {
203 if entry.author == *author
204 && entry.event.created_at.as_secs() <= deletion_created_at
205 {
206 self.ctx.purgatory.remove_state_event(identifier, &entry.event.id);
207 removed += 1;
208 }
209 }
210 if removed > 0 {
211 tracing::info!(
212 identifier = %identifier,
213 author = %author.to_hex(),
214 removed = %removed,
215 "Deletion request: removed purgatory state event(s) by coordinate"
216 );
217 }
218 }
219 _ => {
220 // Other kinds not handled
181 } 221 }
182 } 222 }
183 } 223 }
diff --git a/tests/purgatory.rs b/tests/purgatory.rs
index 553271f..73f85ca 100644
--- a/tests/purgatory.rs
+++ b/tests/purgatory.rs
@@ -70,8 +70,8 @@ isolated_purgatory_test!(test_state_event_accepted_for_purgatory_announcement);
70// Deletion Event Tests (NIP-09) 70// Deletion Event Tests (NIP-09)
71// ============================================================ 71// ============================================================
72 72
73isolated_purgatory_test!(test_deletion_by_event_id_removes_purgatory_announcement); 73isolated_purgatory_test!(test_deletion_by_event_id_removes_purgatory_state_event);
74isolated_purgatory_test!(test_deletion_by_coordinate_removes_purgatory_announcement); 74isolated_purgatory_test!(test_deletion_by_coordinate_removes_purgatory_state_event);
75 75
76// ============================================================ 76// ============================================================
77// State Event Purgatory Tests (already implemented) 77// State Event Purgatory Tests (already implemented)