From 65ac6ef83205c41653e6ffe2acd664f968926fb2 Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Mon, 23 Feb 2026 13:29:47 +0000 Subject: feat: remove purgatory announcements on NIP-09 deletion events Kind 5 deletion events signed by the announcement author now evict the corresponding purgatory entry and delete the bare repository from disk. Both NIP-09 reference styles are supported: - e tag (event ID): matches the purgatory entry whose event ID equals the tag value - a tag (coordinate 30617::): matches by coordinate, only removes entries with created_at <= deletion event created_at per NIP-09 spec Author-only enforcement: coordinate pubkey and e-tag owner must match the deletion event pubkey; third-party deletion attempts are silently ignored. Includes 6 unit tests and 2 integration tests (event ID and coordinate paths). --- grasp-audit/src/specs/grasp01/purgatory.rs | 199 +++++++++++++++++++++++++++++ 1 file changed, 199 insertions(+) (limited to 'grasp-audit/src') diff --git a/grasp-audit/src/specs/grasp01/purgatory.rs b/grasp-audit/src/specs/grasp01/purgatory.rs index 9c4b401..9d97d3b 100644 --- a/grasp-audit/src/specs/grasp01/purgatory.rs +++ b/grasp-audit/src/specs/grasp01/purgatory.rs @@ -46,6 +46,12 @@ impl PurgatoryTests { results.add(Self::test_bare_repo_exists_for_purgatory_announcement(client).await); results.add(Self::test_state_event_accepted_for_purgatory_announcement(client).await); + // Deletion event tests (NIP-09) + results.add(Self::test_deletion_by_event_id_removes_purgatory_announcement(client).await); + results.add( + Self::test_deletion_by_coordinate_removes_purgatory_announcement(client).await, + ); + // State event purgatory tests (already implemented) results.add(Self::test_state_event_not_served_before_git_data(client).await); results.add(Self::test_state_event_served_after_git_push(client).await); @@ -646,6 +652,199 @@ impl PurgatoryTests { }) .await } + // ============================================================ + // Deletion Event Tests (NIP-09) + // ============================================================ + + /// Test: Kind 5 deletion event by event ID removes purgatory announcement + /// + /// Spec: NIP-09 + /// "A special event with kind 5... having a list of one or more `e` or `a` tags, + /// each referencing an event the author is requesting to be deleted." + /// + /// This test verifies: + /// 1. Send a valid repository announcement (enters purgatory) + /// 2. Send a kind 5 deletion event referencing the announcement by event ID + /// 3. The announcement is no longer in purgatory (git push would fail) + /// 4. The deletion event itself is accepted by the relay + pub async fn test_deletion_by_event_id_removes_purgatory_announcement( + client: &AuditClient, + ) -> TestResult { + TestResult::new( + "deletion_by_event_id_removes_purgatory_announcement", + SpecRef::PurgatoryAcceptUntilGitData, + "Kind 5 deletion by event ID SHOULD remove a purgatory announcement", + ) + .run(|| async { + let ctx = TestContext::new(client); + + // Send announcement to purgatory + let repo = ctx + .get_fixture(FixtureKind::ValidRepoSent) + .await + .map_err(|e| format!("Failed to create repo announcement: {}", e))?; + + let repo_id = repo + .tags + .iter() + .find(|t| t.kind() == TagKind::d()) + .and_then(|t| t.content()) + .ok_or("Missing d tag in repo announcement")? + .to_string(); + + // Verify it's in purgatory (not served) + tokio::time::sleep(Duration::from_millis(300)).await; + if client.is_event_on_relay(repo.id).await.map_err(|e| e.to_string())? { + return Err( + "Announcement was served immediately - purgatory not working".to_string(), + ); + } + + // Build and send kind 5 deletion event referencing the announcement by event ID + let deletion = client + .event_builder(Kind::EventDeletion, "") + .tag(Tag::event(repo.id)) + .tag(Tag::custom( + TagKind::custom("k"), + vec!["30617"], + )) + .build(client.keys()) + .map_err(|e| format!("Failed to build deletion event: {}", e))?; + + client + .send_event(deletion) + .await + .map_err(|e| format!("Relay rejected deletion event: {}", e))?; + + tokio::time::sleep(Duration::from_millis(300)).await; + + // Verify the announcement can no longer be promoted by attempting a git push. + // We check this indirectly: if the purgatory entry was removed, a subsequent + // git push to the repo path should fail (no bare repo). + // For the integration test we verify the announcement is still not served + // (it was never promoted) and that the deletion event was accepted. + // The bare-repo deletion is verified by attempting a git clone. + let http_url = AuditClient::ws_to_http_url(&client.relay_url().await.map_err(|e| e.to_string())?) + .map_err(|e| e.to_string())?; + let clone_url = format!( + "{}/{}/{}.git", + http_url, + client.public_key().to_bech32().map_err(|e| e.to_string())?, + repo_id + ); + + // git ls-remote should fail (bare repo deleted) + let output = std::process::Command::new("git") + .args(["ls-remote", &clone_url]) + .output() + .map_err(|e| format!("Failed to run git ls-remote: {}", e))?; + + if output.status.success() { + return Err(format!( + "Bare repo still exists after deletion event. \ + Expected git ls-remote to fail for {}", + clone_url + )); + } + + Ok(()) + }) + .await + } + + /// Test: Kind 5 deletion event by `a` tag coordinate removes purgatory announcement + /// + /// Spec: NIP-09 + /// "When an `a` tag is used, relays SHOULD delete all versions of the replaceable + /// event up to the `created_at` timestamp of the deletion request event." + /// + /// This test verifies: + /// 1. Send a valid repository announcement (enters purgatory) + /// 2. Send a kind 5 deletion event referencing the announcement by coordinate + /// (`30617::`) + /// 3. The announcement is no longer in purgatory + pub async fn test_deletion_by_coordinate_removes_purgatory_announcement( + client: &AuditClient, + ) -> TestResult { + TestResult::new( + "deletion_by_coordinate_removes_purgatory_announcement", + SpecRef::PurgatoryAcceptUntilGitData, + "Kind 5 deletion by `a` coordinate SHOULD remove a purgatory announcement", + ) + .run(|| async { + let ctx = TestContext::new(client); + + // Send announcement to purgatory + let repo = ctx + .get_fixture(FixtureKind::ValidRepoSent) + .await + .map_err(|e| format!("Failed to create repo announcement: {}", e))?; + + let repo_id = repo + .tags + .iter() + .find(|t| t.kind() == TagKind::d()) + .and_then(|t| t.content()) + .ok_or("Missing d tag in repo announcement")? + .to_string(); + + // Verify it's in purgatory (not served) + tokio::time::sleep(Duration::from_millis(300)).await; + if client.is_event_on_relay(repo.id).await.map_err(|e| e.to_string())? { + return Err( + "Announcement was served immediately - purgatory not working".to_string(), + ); + } + + // Build coordinate: `30617::` + let coord = format!( + "30617:{}:{}", + client.public_key().to_hex(), + repo_id + ); + + // Build and send kind 5 deletion event referencing by coordinate + let deletion = client + .event_builder(Kind::EventDeletion, "") + .tag(Tag::custom(TagKind::custom("a"), vec![coord])) + .tag(Tag::custom(TagKind::custom("k"), vec!["30617"])) + .build(client.keys()) + .map_err(|e| format!("Failed to build deletion event: {}", e))?; + + client + .send_event(deletion) + .await + .map_err(|e| format!("Relay rejected deletion event: {}", e))?; + + tokio::time::sleep(Duration::from_millis(300)).await; + + // Verify bare repo was deleted + let http_url = AuditClient::ws_to_http_url(&client.relay_url().await.map_err(|e| e.to_string())?) + .map_err(|e| e.to_string())?; + let clone_url = format!( + "{}/{}/{}.git", + http_url, + client.public_key().to_bech32().map_err(|e| e.to_string())?, + repo_id + ); + + let output = std::process::Command::new("git") + .args(["ls-remote", &clone_url]) + .output() + .map_err(|e| format!("Failed to run git ls-remote: {}", e))?; + + if output.status.success() { + return Err(format!( + "Bare repo still exists after deletion event. \ + Expected git ls-remote to fail for {}", + clone_url + )); + } + + Ok(()) + }) + .await + } } #[cfg(test)] -- cgit v1.2.3