upleb.uk

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

summaryrefslogtreecommitdiff
path: root/src/nostr/policy
diff options
context:
space:
mode:
authorDanConwayDev <DanConwayDev@protonmail.com>2026-02-13 13:24:46 +0000
committerDanConwayDev <DanConwayDev@protonmail.com>2026-02-13 17:29:23 +0000
commit1d09e4bdea7e328cf2740818df9df660c5532a99 (patch)
treedcb758a70a2e9b84709df247cc685a2f6423094e /src/nostr/policy
parenta2a99d5a4137b57e4141cf2840f2f51b38035cfa (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/policy')
-rw-r--r--src/nostr/policy/announcement.rs117
-rw-r--r--src/nostr/policy/state.rs10
2 files changed, 119 insertions, 8 deletions
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.
5use nostr_relay_builder::prelude::{Alphabet, Event, Filter, Kind, PublicKey, SingleLetterTag}; 5use nostr_relay_builder::prelude::{Alphabet, Event, Filter, Kind, PublicKey, SingleLetterTag};
6use std::collections::HashSet;
6 7
7use super::PolicyContext; 8use super::PolicyContext;
8use crate::config::Config; 9use 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)]
13pub enum AnnouncementResult { 14pub 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
11use super::PolicyContext; 11use super::PolicyContext;
12use crate::git; 12use crate::git;
13use crate::git::authorization::fetch_repository_data; 13use crate::git::authorization::fetch_repository_data_with_purgatory;
14use crate::nostr::events::{validate_state, RepositoryAnnouncement, RepositoryState}; 14use 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