diff options
| author | DanConwayDev <DanConwayDev@protonmail.com> | 2026-02-13 13:24:46 +0000 |
|---|---|---|
| committer | DanConwayDev <DanConwayDev@protonmail.com> | 2026-02-13 17:29:23 +0000 |
| commit | 1d09e4bdea7e328cf2740818df9df660c5532a99 (patch) | |
| tree | dcb758a70a2e9b84709df247cc685a2f6423094e /src/nostr | |
| parent | a2a99d5a4137b57e4141cf2840f2f51b38035cfa (diff) | |
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
Diffstat (limited to 'src/nostr')
| -rw-r--r-- | src/nostr/builder.rs | 23 | ||||
| -rw-r--r-- | src/nostr/policy/announcement.rs | 117 | ||||
| -rw-r--r-- | src/nostr/policy/state.rs | 10 |
3 files changed, 142 insertions, 8 deletions
diff --git a/src/nostr/builder.rs b/src/nostr/builder.rs index 34014db..aff12a6 100644 --- a/src/nostr/builder.rs +++ b/src/nostr/builder.rs | |||
| @@ -138,6 +138,29 @@ impl Nip34WritePolicy { | |||
| 138 | } | 138 | } |
| 139 | } | 139 | } |
| 140 | } | 140 | } |
| 141 | AnnouncementResult::AcceptPurgatory => { | ||
| 142 | // New announcement - add to purgatory | ||
| 143 | match self.announcement_policy.add_to_purgatory(event) { | ||
| 144 | Ok(()) => { | ||
| 145 | tracing::info!( | ||
| 146 | "Accepted announcement to purgatory: {} (waiting for git data)", | ||
| 147 | event_id_str | ||
| 148 | ); | ||
| 149 | WritePolicyResult::Reject { | ||
| 150 | status: true, // Client sees OK | ||
| 151 | message: "purgatory: won't be served until git data arrives".into(), | ||
| 152 | } | ||
| 153 | } | ||
| 154 | Err(e) => { | ||
| 155 | tracing::warn!( | ||
| 156 | "Failed to add announcement to purgatory {}: {}", | ||
| 157 | event_id_str, | ||
| 158 | e | ||
| 159 | ); | ||
| 160 | WritePolicyResult::reject(e) | ||
| 161 | } | ||
| 162 | } | ||
| 163 | } | ||
| 141 | AnnouncementResult::AcceptMaintainer => { | 164 | AnnouncementResult::AcceptMaintainer => { |
| 142 | // Parse announcement to get details for logging | 165 | // Parse announcement to get details for logging |
| 143 | match RepositoryAnnouncement::from_event(event.clone()) { | 166 | match RepositoryAnnouncement::from_event(event.clone()) { |
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 @@ | |||
| 3 | /// Handles validation of NIP-34 repository announcements (kind 30617) | 3 | /// Handles validation of NIP-34 repository announcements (kind 30617) |
| 4 | /// according to GRASP-01 specification. | 4 | /// according to GRASP-01 specification. |
| 5 | use nostr_relay_builder::prelude::{Alphabet, Event, Filter, Kind, PublicKey, SingleLetterTag}; | 5 | use nostr_relay_builder::prelude::{Alphabet, Event, Filter, Kind, PublicKey, SingleLetterTag}; |
| 6 | use std::collections::HashSet; | ||
| 6 | 7 | ||
| 7 | use super::PolicyContext; | 8 | use super::PolicyContext; |
| 8 | use crate::config::Config; | 9 | use crate::config::Config; |
| @@ -11,12 +12,14 @@ use crate::nostr::events::{validate_announcement, RepositoryAnnouncement}; | |||
| 11 | /// Result of announcement policy evaluation | 12 | /// Result of announcement policy evaluation |
| 12 | #[derive(Debug, Clone, PartialEq)] | 13 | #[derive(Debug, Clone, PartialEq)] |
| 13 | pub enum AnnouncementResult { | 14 | pub enum AnnouncementResult { |
| 14 | /// Accept: Event lists our service (GRASP-01 compliant) | 15 | /// Accept: Event lists our service (GRASP-01 compliant) - replacement announcement |
| 15 | Accept, | 16 | Accept, |
| 16 | /// Accept as maintainer: Event accepted via maintainer exception (multi-maintainer) | 17 | /// Accept as maintainer: Event accepted via maintainer exception (multi-maintainer) |
| 17 | AcceptMaintainer, | 18 | AcceptMaintainer, |
| 18 | /// Accept as archive: Event accepted via GRASP-05 archive whitelist (read-only) | 19 | /// Accept as archive: Event accepted via GRASP-05 archive whitelist (read-only) |
| 19 | AcceptArchive, | 20 | AcceptArchive, |
| 21 | /// Accept to purgatory: New announcement, waiting for git data | ||
| 22 | AcceptPurgatory, | ||
| 20 | /// Reject: Event fails validation with reason | 23 | /// Reject: Event fails validation with reason |
| 21 | Reject(String), | 24 | Reject(String), |
| 22 | } | 25 | } |
| @@ -35,10 +38,12 @@ impl AnnouncementPolicy { | |||
| 35 | 38 | ||
| 36 | /// Validate a repository announcement event | 39 | /// Validate a repository announcement event |
| 37 | /// | 40 | /// |
| 38 | /// Returns `Accept` if the announcement lists the service properly, | 41 | /// Returns: |
| 39 | /// `AcceptMaintainer` if accepted via maintainer exception, | 42 | /// - `Accept` if this is a replacement announcement (active announcement exists) |
| 40 | /// `AcceptArchive` if accepted via GRASP-05 archive config, | 43 | /// - `AcceptPurgatory` if this is a new announcement (no active announcement exists) |
| 41 | /// or `Reject` with reason. | 44 | /// - `AcceptMaintainer` if accepted via maintainer exception |
| 45 | /// - `AcceptArchive` if accepted via GRASP-05 archive config | ||
| 46 | /// - `Reject` with reason if validation fails | ||
| 42 | pub async fn validate(&self, event: &Event) -> AnnouncementResult { | 47 | pub async fn validate(&self, event: &Event) -> AnnouncementResult { |
| 43 | // First, try validation (GRASP-01 + GRASP-05) | 48 | // First, try validation (GRASP-01 + GRASP-05) |
| 44 | let validation_result = validate_announcement(event, &self.config); | 49 | let validation_result = validate_announcement(event, &self.config); |
| @@ -67,11 +72,111 @@ impl AnnouncementPolicy { | |||
| 67 | Err(_) => AnnouncementResult::Reject(reason), | 72 | Err(_) => AnnouncementResult::Reject(reason), |
| 68 | } | 73 | } |
| 69 | } | 74 | } |
| 70 | // Accept, AcceptArchive, or AcceptMaintainer - return as-is | 75 | AnnouncementResult::Accept | AnnouncementResult::AcceptArchive => { |
| 76 | // Parse announcement to check for existing active announcement | ||
| 77 | match RepositoryAnnouncement::from_event(event.clone()) { | ||
| 78 | Ok(announcement) => { | ||
| 79 | // Check if there's already an active announcement for this (pubkey, identifier) | ||
| 80 | match self | ||
| 81 | .has_active_announcement(&event.pubkey, &announcement.identifier) | ||
| 82 | .await | ||
| 83 | { | ||
| 84 | Ok(true) => { | ||
| 85 | // Replacement announcement - accept immediately | ||
| 86 | tracing::debug!( | ||
| 87 | identifier = %announcement.identifier, | ||
| 88 | "Replacement announcement - accepting immediately" | ||
| 89 | ); | ||
| 90 | validation_result | ||
| 91 | } | ||
| 92 | Ok(false) => { | ||
| 93 | // New announcement - route to purgatory | ||
| 94 | tracing::debug!( | ||
| 95 | identifier = %announcement.identifier, | ||
| 96 | "New announcement - routing to purgatory" | ||
| 97 | ); | ||
| 98 | AnnouncementResult::AcceptPurgatory | ||
| 99 | } | ||
| 100 | Err(e) => { | ||
| 101 | tracing::warn!( | ||
| 102 | error = %e, | ||
| 103 | "Failed to check for existing announcement - rejecting" | ||
| 104 | ); | ||
| 105 | AnnouncementResult::Reject(format!( | ||
| 106 | "Database error checking existing announcement: {}", | ||
| 107 | e | ||
| 108 | )) | ||
| 109 | } | ||
| 110 | } | ||
| 111 | } | ||
| 112 | Err(e) => AnnouncementResult::Reject(format!( | ||
| 113 | "Failed to parse announcement: {}", | ||
| 114 | e | ||
| 115 | )), | ||
| 116 | } | ||
| 117 | } | ||
| 118 | // AcceptPurgatory shouldn't come from validate_announcement, but handle it | ||
| 71 | result => result, | 119 | result => result, |
| 72 | } | 120 | } |
| 73 | } | 121 | } |
| 74 | 122 | ||
| 123 | /// Check if there's an active announcement in the database for this (pubkey, identifier) | ||
| 124 | async fn has_active_announcement( | ||
| 125 | &self, | ||
| 126 | pubkey: &PublicKey, | ||
| 127 | identifier: &str, | ||
| 128 | ) -> Result<bool, String> { | ||
| 129 | let filter = Filter::new() | ||
| 130 | .kind(Kind::GitRepoAnnouncement) | ||
| 131 | .author(*pubkey) | ||
| 132 | .custom_tag( | ||
| 133 | SingleLetterTag::lowercase(Alphabet::D), | ||
| 134 | identifier.to_string(), | ||
| 135 | ); | ||
| 136 | |||
| 137 | let events: Vec<Event> = match self.ctx.database.query(filter).await { | ||
| 138 | Ok(events) => events.into_iter().collect(), | ||
| 139 | Err(e) => return Err(format!("Database query failed: {}", e)), | ||
| 140 | }; | ||
| 141 | |||
| 142 | Ok(!events.is_empty()) | ||
| 143 | } | ||
| 144 | |||
| 145 | /// Add an announcement to purgatory | ||
| 146 | /// | ||
| 147 | /// Creates the bare repository and stores the announcement in purgatory | ||
| 148 | /// until git data arrives. | ||
| 149 | pub fn add_to_purgatory(&self, event: &Event) -> Result<(), String> { | ||
| 150 | let announcement = RepositoryAnnouncement::from_event(event.clone()) | ||
| 151 | .map_err(|e| format!("Failed to parse announcement: {}", e))?; | ||
| 152 | |||
| 153 | // Create bare repository | ||
| 154 | self.ensure_bare_repository(&announcement)?; | ||
| 155 | |||
| 156 | // Build repo path | ||
| 157 | let repo_path = self.ctx.git_data_path.join(announcement.repo_path()); | ||
| 158 | |||
| 159 | // Extract relays from announcement | ||
| 160 | let relays: HashSet<String> = announcement.relays.iter().cloned().collect(); | ||
| 161 | |||
| 162 | // Add to purgatory | ||
| 163 | self.ctx.purgatory.add_announcement( | ||
| 164 | event.clone(), | ||
| 165 | announcement.identifier.clone(), | ||
| 166 | event.pubkey, | ||
| 167 | repo_path, | ||
| 168 | relays, | ||
| 169 | ); | ||
| 170 | |||
| 171 | tracing::info!( | ||
| 172 | identifier = %announcement.identifier, | ||
| 173 | event_id = %event.id, | ||
| 174 | "Added announcement to purgatory" | ||
| 175 | ); | ||
| 176 | |||
| 177 | Ok(()) | ||
| 178 | } | ||
| 179 | |||
| 75 | /// Create a bare git repository if it doesn't exist | 180 | /// Create a bare git repository if it doesn't exist |
| 76 | /// Path format: <git_data_path>/<npub>/<identifier>.git | 181 | /// Path format: <git_data_path>/<npub>/<identifier>.git |
| 77 | pub fn ensure_bare_repository( | 182 | pub fn ensure_bare_repository( |
diff --git a/src/nostr/policy/state.rs b/src/nostr/policy/state.rs index f94f004..4bfb513 100644 --- a/src/nostr/policy/state.rs +++ b/src/nostr/policy/state.rs | |||
| @@ -10,7 +10,7 @@ use nostr_relay_builder::prelude::Event; | |||
| 10 | 10 | ||
| 11 | use super::PolicyContext; | 11 | use super::PolicyContext; |
| 12 | use crate::git; | 12 | use crate::git; |
| 13 | use crate::git::authorization::fetch_repository_data; | 13 | use crate::git::authorization::fetch_repository_data_with_purgatory; |
| 14 | use crate::nostr::events::{validate_state, RepositoryAnnouncement, RepositoryState}; | 14 | use crate::nostr::events::{validate_state, RepositoryAnnouncement, RepositoryState}; |
| 15 | 15 | ||
| 16 | /// Result of state policy evaluation | 16 | /// Result of state policy evaluation |
| @@ -76,7 +76,13 @@ impl StatePolicy { | |||
| 76 | } | 76 | } |
| 77 | 77 | ||
| 78 | // Get all repositories and state events from db with identifier | 78 | // Get all repositories and state events from db with identifier |
| 79 | let db_repo_data = fetch_repository_data(&self.ctx.database, &state.identifier).await?; | 79 | // Include purgatory announcements for authorization |
| 80 | let db_repo_data = fetch_repository_data_with_purgatory( | ||
| 81 | &self.ctx.database, | ||
| 82 | &self.ctx.purgatory, | ||
| 83 | &state.identifier, | ||
| 84 | ) | ||
| 85 | .await?; | ||
| 80 | 86 | ||
| 81 | // CRITICAL: Check if author is authorized via maintainer set | 87 | // CRITICAL: Check if author is authorized via maintainer set |
| 82 | // State events MUST be rejected if author is not in maintainer set of any accepted announcement | 88 | // State events MUST be rejected if author is not in maintainer set of any accepted announcement |