From 1d09e4bdea7e328cf2740818df9df660c5532a99 Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Fri, 13 Feb 2026 13:24:46 +0000 Subject: feat: implement announcement purgatory core (breaks archive sync test) Route new announcements to purgatory instead of accepting immediately. Announcements are promoted to the database when git data arrives, ensuring we only serve announcements for repos with actual content. Implemented: - AnnouncementPurgatoryEntry type and DashMap store - Route new announcements to purgatory (replacement announcements skip) - Promote announcements on git data arrival (process_purgatory_announcements) - Authorization checks purgatory announcements (fetch_repository_data_with_purgatory) - State policy uses purgatory announcements for maintainer validation - Cleanup task handles announcement expiry - Updated count()/cleanup() to 3-tuples Known broken: - test_archive_read_only_creates_bare_repo fails: sync module does not treat purgatory announcements as confirmed repos, so per-repo sync (state events, PRs) is never triggered for purgatory announcements - Announcement persistence (save/restore) not implemented - SyncLevel (StateOnly vs Full) not implemented - Soft expiry two-phase not implemented - Expiry extension on state event / git auth not wired up --- src/nostr/policy/announcement.rs | 117 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 111 insertions(+), 6 deletions(-) (limited to 'src/nostr/policy/announcement.rs') diff --git a/src/nostr/policy/announcement.rs b/src/nostr/policy/announcement.rs index 15a6e58..1118497 100644 --- a/src/nostr/policy/announcement.rs +++ b/src/nostr/policy/announcement.rs @@ -3,6 +3,7 @@ /// Handles validation of NIP-34 repository announcements (kind 30617) /// according to GRASP-01 specification. use nostr_relay_builder::prelude::{Alphabet, Event, Filter, Kind, PublicKey, SingleLetterTag}; +use std::collections::HashSet; use super::PolicyContext; use crate::config::Config; @@ -11,12 +12,14 @@ use crate::nostr::events::{validate_announcement, RepositoryAnnouncement}; /// Result of announcement policy evaluation #[derive(Debug, Clone, PartialEq)] pub enum AnnouncementResult { - /// Accept: Event lists our service (GRASP-01 compliant) + /// Accept: Event lists our service (GRASP-01 compliant) - replacement announcement Accept, /// Accept as maintainer: Event accepted via maintainer exception (multi-maintainer) AcceptMaintainer, /// Accept as archive: Event accepted via GRASP-05 archive whitelist (read-only) AcceptArchive, + /// Accept to purgatory: New announcement, waiting for git data + AcceptPurgatory, /// Reject: Event fails validation with reason Reject(String), } @@ -35,10 +38,12 @@ impl AnnouncementPolicy { /// Validate a repository announcement event /// - /// Returns `Accept` if the announcement lists the service properly, - /// `AcceptMaintainer` if accepted via maintainer exception, - /// `AcceptArchive` if accepted via GRASP-05 archive config, - /// or `Reject` with reason. + /// Returns: + /// - `Accept` if this is a replacement announcement (active announcement exists) + /// - `AcceptPurgatory` if this is a new announcement (no active announcement exists) + /// - `AcceptMaintainer` if accepted via maintainer exception + /// - `AcceptArchive` if accepted via GRASP-05 archive config + /// - `Reject` with reason if validation fails pub async fn validate(&self, event: &Event) -> AnnouncementResult { // First, try validation (GRASP-01 + GRASP-05) let validation_result = validate_announcement(event, &self.config); @@ -67,11 +72,111 @@ impl AnnouncementPolicy { Err(_) => AnnouncementResult::Reject(reason), } } - // Accept, AcceptArchive, or AcceptMaintainer - return as-is + AnnouncementResult::Accept | AnnouncementResult::AcceptArchive => { + // Parse announcement to check for existing active announcement + match RepositoryAnnouncement::from_event(event.clone()) { + Ok(announcement) => { + // Check if there's already an active announcement for this (pubkey, identifier) + match self + .has_active_announcement(&event.pubkey, &announcement.identifier) + .await + { + Ok(true) => { + // Replacement announcement - accept immediately + tracing::debug!( + identifier = %announcement.identifier, + "Replacement announcement - accepting immediately" + ); + validation_result + } + Ok(false) => { + // New announcement - route to purgatory + tracing::debug!( + identifier = %announcement.identifier, + "New announcement - routing to purgatory" + ); + AnnouncementResult::AcceptPurgatory + } + Err(e) => { + tracing::warn!( + error = %e, + "Failed to check for existing announcement - rejecting" + ); + AnnouncementResult::Reject(format!( + "Database error checking existing announcement: {}", + e + )) + } + } + } + Err(e) => AnnouncementResult::Reject(format!( + "Failed to parse announcement: {}", + e + )), + } + } + // AcceptPurgatory shouldn't come from validate_announcement, but handle it result => result, } } + /// Check if there's an active announcement in the database for this (pubkey, identifier) + async fn has_active_announcement( + &self, + pubkey: &PublicKey, + identifier: &str, + ) -> Result { + let filter = Filter::new() + .kind(Kind::GitRepoAnnouncement) + .author(*pubkey) + .custom_tag( + SingleLetterTag::lowercase(Alphabet::D), + identifier.to_string(), + ); + + let events: Vec = match self.ctx.database.query(filter).await { + Ok(events) => events.into_iter().collect(), + Err(e) => return Err(format!("Database query failed: {}", e)), + }; + + Ok(!events.is_empty()) + } + + /// Add an announcement to purgatory + /// + /// Creates the bare repository and stores the announcement in purgatory + /// until git data arrives. + pub fn add_to_purgatory(&self, event: &Event) -> Result<(), String> { + let announcement = RepositoryAnnouncement::from_event(event.clone()) + .map_err(|e| format!("Failed to parse announcement: {}", e))?; + + // Create bare repository + self.ensure_bare_repository(&announcement)?; + + // Build repo path + let repo_path = self.ctx.git_data_path.join(announcement.repo_path()); + + // Extract relays from announcement + let relays: HashSet = announcement.relays.iter().cloned().collect(); + + // Add to purgatory + self.ctx.purgatory.add_announcement( + event.clone(), + announcement.identifier.clone(), + event.pubkey, + repo_path, + relays, + ); + + tracing::info!( + identifier = %announcement.identifier, + event_id = %event.id, + "Added announcement to purgatory" + ); + + Ok(()) + } + /// Create a bare git repository if it doesn't exist /// Path format: //.git pub fn ensure_bare_repository( -- cgit v1.2.3 From 467690f33bbbfd442852e61de221e4e5e161b878 Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Wed, 18 Feb 2026 08:59:52 +0000 Subject: fix: check purgatory in maintainer announcement lookup is_maintainer_in_any_announcement only queried the database, missing announcements still in purgatory. A maintainer's announcement (which lists the recursive maintainer) may arrive and enter purgatory before the recursive maintainer's announcement does, causing the maintainer exception check to return false and reject the recursive maintainer's announcement. --- src/nostr/policy/announcement.rs | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) (limited to 'src/nostr/policy/announcement.rs') diff --git a/src/nostr/policy/announcement.rs b/src/nostr/policy/announcement.rs index 1118497..abe9651 100644 --- a/src/nostr/policy/announcement.rs +++ b/src/nostr/policy/announcement.rs @@ -222,6 +222,11 @@ impl AnnouncementPolicy { /// /// This enables accepting announcements from maintainers even when they don't list /// this GRASP server, for maintainer chain discovery and GRASP-02 sync. + /// + /// Checks both the database (promoted announcements) and purgatory (announcements + /// waiting for git data). This is necessary because a maintainer's announcement + /// (which lists the recursive maintainer) may still be in purgatory when the + /// recursive maintainer's announcement arrives. async fn is_maintainer_in_any_announcement( &self, identifier: &str, @@ -233,12 +238,26 @@ impl AnnouncementPolicy { identifier.to_string(), ); - let announcements: Vec = match self.ctx.database.query(filter).await { + let db_announcements: Vec = match self.ctx.database.query(filter).await { Ok(events) => events.into_iter().collect(), Err(e) => return Err(format!("Database query failed: {}", e)), }; - if announcements.is_empty() { + // Also collect purgatory announcements for this identifier + let purgatory_announcements: Vec = self + .ctx + .purgatory + .get_announcements_by_identifier(identifier) + .into_iter() + .map(|entry| entry.event) + .collect(); + + let all_announcements: Vec<&Event> = db_announcements + .iter() + .chain(purgatory_announcements.iter()) + .collect(); + + if all_announcements.is_empty() { // No existing announcements for this identifier - author cannot be a maintainer return Ok(false); } @@ -246,14 +265,14 @@ impl AnnouncementPolicy { let author_hex = author.to_hex(); // Check each announcement to see if author is listed as a maintainer - for event in &announcements { + for event in &all_announcements { // Check if author is the owner of this announcement if event.pubkey == *author { return Ok(true); } // Check if author is listed in the maintainers tag - if let Ok(announcement) = RepositoryAnnouncement::from_event(event.clone()) { + if let Ok(announcement) = RepositoryAnnouncement::from_event((*event).clone()) { if announcement.maintainers.contains(&author_hex) { return Ok(true); } -- cgit v1.2.3 From 0c01797812bb77fc81d0efe58f0e7858f2b7af66 Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Wed, 18 Feb 2026 09:24:01 +0000 Subject: fix: handle announcement replacement when original is still in purgatory Previously, has_active_announcement() only queried the database, so when a newer announcement arrived for the same (pubkey, identifier) while the original was still in purgatory, it was incorrectly routed as a brand-new announcement (AcceptPurgatory) rather than replacing the existing entry. This change splits the logic into two cases: - If the existing entry is in the database: return Accept (replacement) as before - If the existing entry is only in purgatory: replace the purgatory entry via add_announcement() (which overwrites by key) and extend expiries for both the announcement and any waiting state events, then return Accept - If the owner sends a Reject-classified announcement (service removed) but has a purgatory entry: clear the purgatory entry, delete the bare repo, and remove any waiting state events before rejecting Also add an explicit comment to find_accepted_repository() in related.rs clarifying that it intentionally only checks the database. Related events should only be accepted after the repository announcement has been promoted (validated via git data) - this is correct behaviour, not a missing check. --- src/nostr/policy/announcement.rs | 161 +++++++++++++++++++++++++++++++++------ src/nostr/policy/related.rs | 5 ++ 2 files changed, 141 insertions(+), 25 deletions(-) (limited to 'src/nostr/policy/announcement.rs') diff --git a/src/nostr/policy/announcement.rs b/src/nostr/policy/announcement.rs index abe9651..a90ec94 100644 --- a/src/nostr/policy/announcement.rs +++ b/src/nostr/policy/announcement.rs @@ -4,6 +4,7 @@ /// according to GRASP-01 specification. use nostr_relay_builder::prelude::{Alphabet, Event, Filter, Kind, PublicKey, SingleLetterTag}; use std::collections::HashSet; +use std::time::Duration; use super::PolicyContext; use crate::config::Config; @@ -39,7 +40,8 @@ impl AnnouncementPolicy { /// Validate a repository announcement event /// /// Returns: - /// - `Accept` if this is a replacement announcement (active announcement exists) + /// - `Accept` if this is a replacement announcement (active announcement exists in DB or + /// purgatory) /// - `AcceptPurgatory` if this is a new announcement (no active announcement exists) /// - `AcceptMaintainer` if accepted via maintainer exception /// - `AcceptArchive` if accepted via GRASP-05 archive config @@ -54,6 +56,17 @@ impl AnnouncementPolicy { // GRASP-01 Exception: Accept announcements from recursive maintainers match RepositoryAnnouncement::from_event(event.clone()) { Ok(announcement) => { + // If this pubkey+identifier had a purgatory entry, the owner may be + // sending a new announcement that removes our service. Clear the + // purgatory entry and its bare repo so we don't hold stale data. + if self + .ctx + .purgatory + .has_purgatory_announcement(&event.pubkey, &announcement.identifier) + { + self.remove_purgatory_announcement(&event.pubkey, &announcement.identifier); + } + match self .is_maintainer_in_any_announcement( &announcement.identifier, @@ -76,38 +89,55 @@ impl AnnouncementPolicy { // Parse announcement to check for existing active announcement match RepositoryAnnouncement::from_event(event.clone()) { Ok(announcement) => { - // Check if there's already an active announcement for this (pubkey, identifier) - match self - .has_active_announcement(&event.pubkey, &announcement.identifier) + let in_db = match self + .has_db_announcement(&event.pubkey, &announcement.identifier) .await { - Ok(true) => { - // Replacement announcement - accept immediately - tracing::debug!( - identifier = %announcement.identifier, - "Replacement announcement - accepting immediately" - ); - validation_result - } - Ok(false) => { - // New announcement - route to purgatory - tracing::debug!( - identifier = %announcement.identifier, - "New announcement - routing to purgatory" - ); - AnnouncementResult::AcceptPurgatory - } + Ok(v) => v, Err(e) => { tracing::warn!( error = %e, - "Failed to check for existing announcement - rejecting" + "Failed to check for existing DB announcement - rejecting" ); - AnnouncementResult::Reject(format!( + return AnnouncementResult::Reject(format!( "Database error checking existing announcement: {}", e - )) + )); } + }; + + if in_db { + // Replacement announcement with DB entry - accept immediately + tracing::debug!( + identifier = %announcement.identifier, + "Replacement announcement (DB) - accepting immediately" + ); + return validation_result; } + + let in_purgatory = self + .ctx + .purgatory + .has_purgatory_announcement(&event.pubkey, &announcement.identifier); + + if in_purgatory { + // Replacement announcement with purgatory entry - replace it and + // extend expiry so the new announcement gets a fresh 30-minute window. + tracing::debug!( + identifier = %announcement.identifier, + "Replacement announcement (purgatory) - replacing purgatory entry" + ); + self.replace_purgatory_announcement(event, &announcement); + // Return Accept (not AcceptPurgatory) - this is a replacement, not new + return validation_result; + } + + // No existing announcement - route to purgatory + tracing::debug!( + identifier = %announcement.identifier, + "New announcement - routing to purgatory" + ); + AnnouncementResult::AcceptPurgatory } Err(e) => AnnouncementResult::Reject(format!( "Failed to parse announcement: {}", @@ -120,8 +150,89 @@ impl AnnouncementPolicy { } } - /// Check if there's an active announcement in the database for this (pubkey, identifier) - async fn has_active_announcement( + /// Replace a purgatory announcement entry with a newer event. + /// + /// Called when a replacement announcement arrives for a (pubkey, identifier) pair + /// that is currently in purgatory. Updates the purgatory entry and extends the + /// expiry so the new announcement has a fresh waiting window. + fn replace_purgatory_announcement( + &self, + event: &Event, + announcement: &RepositoryAnnouncement, + ) { + let repo_path = self.ctx.git_data_path.join(announcement.repo_path()); + let relays: HashSet = announcement.relays.iter().cloned().collect(); + + // add_announcement uses the (owner, identifier) key so it overwrites the old entry + self.ctx.purgatory.add_announcement( + event.clone(), + announcement.identifier.clone(), + event.pubkey, + repo_path, + relays, + ); + + // Extend the announcement's expiry (reset to full 30 min window) + self.ctx.purgatory.extend_announcement_expiry( + &event.pubkey, + &announcement.identifier, + Duration::from_secs(1800), + ); + + // Also extend any state events waiting for this identifier + let state_entries = self.ctx.purgatory.find_state(&announcement.identifier); + if !state_entries.is_empty() { + let state_ids: Vec<_> = state_entries.iter().map(|e| e.event.id).collect(); + self.ctx.purgatory.extend_expiry( + &announcement.identifier, + &state_ids, + Duration::from_secs(1800), + ); + } + } + + /// Remove a purgatory announcement and clean up associated resources. + /// + /// Called when a replacement announcement is rejected (owner removed our service). + /// Deletes the bare repository from disk and removes any state events waiting for + /// this identifier. + fn remove_purgatory_announcement(&self, pubkey: &PublicKey, identifier: &str) { + // Get the repo path before removing from purgatory + if let Some(entry) = self.ctx.purgatory.find_announcement(pubkey, identifier) { + // Delete the bare repository from disk + if entry.repo_path.exists() { + if let Err(e) = std::fs::remove_dir_all(&entry.repo_path) { + tracing::warn!( + path = %entry.repo_path.display(), + error = %e, + "Failed to delete bare repository during purgatory cleanup" + ); + } else { + tracing::info!( + path = %entry.repo_path.display(), + "Deleted bare repository for rejected purgatory announcement" + ); + } + } + } + + // Remove the announcement from purgatory + self.ctx.purgatory.remove_announcement(pubkey, identifier); + + // Remove any state events waiting for this identifier + self.ctx.purgatory.remove_state(identifier); + + tracing::info!( + identifier = %identifier, + "Cleared purgatory entry: owner removed our service from announcement" + ); + } + + /// Check if there's an announcement in the database for this (pubkey, identifier). + /// + /// Only checks the database (promoted announcements). For purgatory checks use + /// `purgatory.has_purgatory_announcement()` directly. + async fn has_db_announcement( &self, pubkey: &PublicKey, identifier: &str, diff --git a/src/nostr/policy/related.rs b/src/nostr/policy/related.rs index 7ce87db..cfe04a7 100644 --- a/src/nostr/policy/related.rs +++ b/src/nostr/policy/related.rs @@ -139,6 +139,11 @@ impl RelatedEventPolicy { .push((addr, pubkey, identifier)); } + // NOTE: Intentionally only checks the database (promoted announcements), not purgatory. + // Related events should only be accepted once the repository announcement has been + // validated (promoted via git data). Events referencing purgatory-only repositories + // are correctly rejected as orphans and can be re-submitted after promotion. + // Query each kind group for (kind, refs) in by_kind { let authors: Vec = refs.iter().map(|(_, pk, _)| *pk).collect(); -- cgit v1.2.3 From 28aa19bc5b196f2259ab8ff0ac8534afe886529f Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Wed, 18 Feb 2026 09:43:34 +0000 Subject: fix: only evict purgatory entry when incoming rejected announcement is newer An older rejected announcement (e.g. a relay replay of a superseded event) was incorrectly evicting a newer purgatory entry for the same pubkey+identifier. Now only evict when the incoming event's created_at is strictly greater than the stored entry's created_at. --- src/nostr/policy/announcement.rs | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) (limited to 'src/nostr/policy/announcement.rs') diff --git a/src/nostr/policy/announcement.rs b/src/nostr/policy/announcement.rs index a90ec94..9b92aeb 100644 --- a/src/nostr/policy/announcement.rs +++ b/src/nostr/policy/announcement.rs @@ -56,14 +56,20 @@ impl AnnouncementPolicy { // GRASP-01 Exception: Accept announcements from recursive maintainers match RepositoryAnnouncement::from_event(event.clone()) { Ok(announcement) => { - // If this pubkey+identifier had a purgatory entry, the owner may be - // sending a new announcement that removes our service. Clear the - // purgatory entry and its bare repo so we don't hold stale data. - if self + // If this pubkey+identifier has a purgatory entry AND the incoming + // event is strictly newer, the owner is sending a replacement that + // removes our service. Clear the purgatory entry and its bare repo. + // + // If the incoming event is older than the purgatory entry (e.g. a + // relay replay of a superseded announcement), ignore it — the newer + // purgatory entry takes precedence and must not be evicted. + let should_evict = self .ctx .purgatory - .has_purgatory_announcement(&event.pubkey, &announcement.identifier) - { + .find_announcement(&event.pubkey, &announcement.identifier) + .is_some_and(|entry| event.created_at > entry.event.created_at); + + if should_evict { self.remove_purgatory_announcement(&event.pubkey, &announcement.identifier); } -- cgit v1.2.3 From 2f365fc3b209f6d377d59a6ab8a6891b0350fee6 Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Wed, 18 Feb 2026 09:58:41 +0000 Subject: fix: preserve state events when another owner's announcement remains in purgatory remove_purgatory_announcement() was unconditionally wiping all state events for an identifier when one owner's announcement was evicted. State events are keyed by identifier alone, so this incorrectly discarded state events belonging to a different owner's repository sharing the same identifier string. Now only removes state events if no other owner's announcement remains in purgatory for that identifier. --- src/nostr/policy/announcement.rs | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) (limited to 'src/nostr/policy/announcement.rs') diff --git a/src/nostr/policy/announcement.rs b/src/nostr/policy/announcement.rs index 9b92aeb..b366f0b 100644 --- a/src/nostr/policy/announcement.rs +++ b/src/nostr/policy/announcement.rs @@ -225,11 +225,23 @@ impl AnnouncementPolicy { // Remove the announcement from purgatory self.ctx.purgatory.remove_announcement(pubkey, identifier); - // Remove any state events waiting for this identifier - self.ctx.purgatory.remove_state(identifier); + // Only remove state events if no other owner still has an announcement in purgatory + // for this identifier. State events are keyed by identifier alone, so blindly removing + // them would also discard state events legitimately belonging to a different owner's + // repository that happens to share the same identifier string. + let other_owners_remain = !self + .ctx + .purgatory + .get_announcements_by_identifier(identifier) + .is_empty(); + + if !other_owners_remain { + self.ctx.purgatory.remove_state(identifier); + } tracing::info!( identifier = %identifier, + other_owners_remain = %other_owners_remain, "Cleared purgatory entry: owner removed our service from announcement" ); } -- cgit v1.2.3