upleb.uk

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

summaryrefslogtreecommitdiff
path: root/src/nostr
diff options
context:
space:
mode:
authorDanConwayDev <DanConwayDev@protonmail.com>2025-12-24 08:02:12 +0000
committerDanConwayDev <DanConwayDev@protonmail.com>2025-12-24 11:54:18 +0000
commit70d0197e85ae4ef85202781f6d2dc9e76bd508b3 (patch)
tree45efb6565e81ba755acc5955e68d5b7119d1e122 /src/nostr
parentf8c3e3920ed2a1bdaab30be912276993449a5476 (diff)
feat(purgatory): add broken purgatory implementation
Diffstat (limited to 'src/nostr')
-rw-r--r--src/nostr/builder.rs116
-rw-r--r--src/nostr/policy/mod.rs5
-rw-r--r--src/nostr/policy/pr_event.rs149
-rw-r--r--src/nostr/policy/state.rs55
4 files changed, 310 insertions, 15 deletions
diff --git a/src/nostr/builder.rs b/src/nostr/builder.rs
index 8dd6291..2b4d524 100644
--- a/src/nostr/builder.rs
+++ b/src/nostr/builder.rs
@@ -13,7 +13,7 @@ use nostr_relay_builder::prelude::*;
13 13
14use crate::config::{Config, DatabaseBackend}; 14use crate::config::{Config, DatabaseBackend};
15use crate::nostr::events::{ 15use crate::nostr::events::{
16 RepositoryAnnouncement, RepositoryState, KIND_PR, KIND_PR_UPDATE, KIND_REPOSITORY_ANNOUNCEMENT, 16 RepositoryAnnouncement, KIND_PR, KIND_PR_UPDATE, KIND_REPOSITORY_ANNOUNCEMENT,
17 KIND_REPOSITORY_STATE, KIND_USER_GRASP_LIST, 17 KIND_REPOSITORY_STATE, KIND_USER_GRASP_LIST,
18}; 18};
19use crate::nostr::policy::{ 19use crate::nostr::policy::{
@@ -57,8 +57,9 @@ impl Nip34WritePolicy {
57 domain: impl Into<String>, 57 domain: impl Into<String>,
58 database: SharedDatabase, 58 database: SharedDatabase,
59 git_data_path: impl Into<std::path::PathBuf>, 59 git_data_path: impl Into<std::path::PathBuf>,
60 purgatory: std::sync::Arc<crate::purgatory::Purgatory>,
60 ) -> Self { 61 ) -> Self {
61 let ctx = PolicyContext::new(domain, database, git_data_path); 62 let ctx = PolicyContext::new(domain, database, git_data_path, purgatory);
62 Self { 63 Self {
63 announcement_policy: AnnouncementPolicy::new(ctx.clone()), 64 announcement_policy: AnnouncementPolicy::new(ctx.clone()),
64 state_policy: StatePolicy::new(ctx.clone()), 65 state_policy: StatePolicy::new(ctx.clone()),
@@ -143,21 +144,50 @@ impl Nip34WritePolicy {
143 144
144 match self.state_policy.validate(event) { 145 match self.state_policy.validate(event) {
145 StateResult::Accept => { 146 StateResult::Accept => {
146 // Parse state to get HEAD and branch info 147 // Parse state to get identifier for purgatory message
147 match RepositoryState::from_event(event.clone()) { 148 let identifier = event
148 Ok(_state) => { 149 .tags
149 // Process state alignment asynchronously 150 .iter()
150 if let Err(e) = self.state_policy.process_state_event(event).await { 151 .find_map(|tag| {
151 tracing::warn!("Failed to process state event {}: {}", event_id_str, e); 152 let tag_vec = tag.clone().to_vec();
153 if tag_vec.len() >= 2 && tag_vec[0] == "d" {
154 Some(tag_vec[1].clone())
155 } else {
156 None
152 } 157 }
158 })
159 .unwrap_or_else(|| "unknown".to_string());
153 160
154 tracing::debug!("Accepted repository state: {}", event_id_str); 161 // Process state alignment asynchronously
162 match self.state_policy.process_state_event(event).await {
163 Ok(0) => {
164 // No repos aligned - event was added to purgatory
165 tracing::info!(
166 "State event {} added to purgatory: waiting for git data for identifier {}",
167 event_id_str,
168 identifier
169 );
170 WritePolicyResult::Reject {
171 status: true, // Client sees OK
172 message: format!(
173 "purgatory: state event stored, waiting for git push for {}",
174 identifier
175 )
176 .into(),
177 }
178 }
179 Ok(count) => {
180 // Successfully aligned repos
181 tracing::debug!(
182 "Accepted repository state {}: aligned {} repo(s)",
183 event_id_str,
184 count
185 );
155 WritePolicyResult::Accept 186 WritePolicyResult::Accept
156 } 187 }
157 Err(e) => { 188 Err(e) => {
158 tracing::warn!("Failed to parse repository state {}: {}", event_id_str, e); 189 tracing::warn!("Failed to process state event {}: {}", event_id_str, e);
159 // Still accept the event even if we can't parse it 190 // Still accept the event even if processing failed
160 // The validation passed, so it's structurally valid
161 WritePolicyResult::Accept 191 WritePolicyResult::Accept
162 } 192 }
163 } 193 }
@@ -173,6 +203,58 @@ impl Nip34WritePolicy {
173 async fn handle_pr_event(&self, event: &Event) -> WritePolicyResult { 203 async fn handle_pr_event(&self, event: &Event) -> WritePolicyResult {
174 let event_id_str = event.id.to_bech32().unwrap_or_else(|_| event.id.to_hex()); 204 let event_id_str = event.id.to_bech32().unwrap_or_else(|_| event.id.to_hex());
175 205
206 // Check if git data exists (checks placeholders and commit existence)
207 match self.pr_event_policy.check_git_data_exists(event).await {
208 Ok(false) => {
209 // No git data exists - add to purgatory
210 let commit = event
211 .tags
212 .iter()
213 .find_map(|tag| {
214 let tag_vec = tag.clone().to_vec();
215 if tag_vec.len() >= 2 && tag_vec[0] == "c" {
216 Some(tag_vec[1].clone())
217 } else {
218 None
219 }
220 })
221 .unwrap_or_else(|| "unknown".to_string());
222
223 tracing::info!(
224 "PR event {} added to purgatory: waiting for git push with commit {}",
225 event_id_str,
226 commit
227 );
228
229 // Add to purgatory
230 self.ctx
231 .purgatory
232 .add_pr(event.clone(), event.id.to_hex(), commit.clone());
233
234 return WritePolicyResult::Reject {
235 status: true, // Client sees OK
236 message: format!(
237 "purgatory: PR event stored, waiting for git push with commit {}",
238 commit
239 )
240 .into(),
241 };
242 }
243 Ok(true) => {
244 // Git data exists - proceed with normal validation
245 tracing::debug!("Git data exists for PR event {}", event_id_str);
246 }
247 Err(e) => {
248 // Error checking git data - reject event
249 tracing::warn!(
250 "Failed to check git data for PR event {}: {}",
251 event_id_str,
252 e
253 );
254 return WritePolicyResult::reject(format!("Failed to check git data: {}", e));
255 }
256 }
257
176 // Validate refs/nostr refs for this PR event 258 // Validate refs/nostr refs for this PR event
177 // This deletes any refs/nostr/<event-id> that points to wrong commit 259 // This deletes any refs/nostr/<event-id> that points to wrong commit
178 if let Err(e) = self.pr_event_policy.validate_nostr_ref(event).await { 260 if let Err(e) = self.pr_event_policy.validate_nostr_ref(event).await {
@@ -289,7 +371,10 @@ pub struct RelayWithDatabase {
289/// Returns a `RelayWithDatabase` struct containing: 371/// Returns a `RelayWithDatabase` struct containing:
290/// - The `LocalRelay` for handling WebSocket connections 372/// - The `LocalRelay` for handling WebSocket connections
291/// - The `SharedDatabase` for direct database queries (e.g., push authorization) 373/// - The `SharedDatabase` for direct database queries (e.g., push authorization)
292pub async fn create_relay(config: &Config) -> Result<RelayWithDatabase> { 374pub async fn create_relay(
375 config: &Config,
376 purgatory: Arc<crate::purgatory::Purgatory>,
377) -> Result<RelayWithDatabase> {
293 tracing::info!("Configuring nostr relay with GRASP-01 validation..."); 378 tracing::info!("Configuring nostr relay with GRASP-01 validation...");
294 379
295 // Determine database path 380 // Determine database path
@@ -337,7 +422,10 @@ pub async fn create_relay(config: &Config) -> Result<RelayWithDatabase> {
337 // Build relay with GRASP-01 validation 422 // Build relay with GRASP-01 validation
338 // Clone Arc for the write policy so both relay and policy can access the database 423 // Clone Arc for the write policy so both relay and policy can access the database
339 let git_data_path = config.effective_git_data_path(); 424 let git_data_path = config.effective_git_data_path();
340 let write_policy = Nip34WritePolicy::new(&config.domain, database.clone(), &git_data_path); 425
426 // Create write policy with purgatory integration
427 let write_policy =
428 Nip34WritePolicy::new(&config.domain, database.clone(), &git_data_path, purgatory);
341 429
342 let relay = LocalRelayBuilder::default() 430 let relay = LocalRelayBuilder::default()
343 .database(database.clone()) 431 .database(database.clone())
diff --git a/src/nostr/policy/mod.rs b/src/nostr/policy/mod.rs
index 19db5f6..2a446fe 100644
--- a/src/nostr/policy/mod.rs
+++ b/src/nostr/policy/mod.rs
@@ -16,6 +16,8 @@ pub use related::{ReferenceResult, RelatedEventPolicy};
16pub use state::{AlignmentResult, StatePolicy, StateResult}; 16pub use state::{AlignmentResult, StatePolicy, StateResult};
17 17
18use super::SharedDatabase; 18use super::SharedDatabase;
19use crate::purgatory::Purgatory;
20use std::sync::Arc;
19 21
20/// Shared context for all sub-policies 22/// Shared context for all sub-policies
21#[derive(Clone)] 23#[derive(Clone)]
@@ -23,6 +25,7 @@ pub struct PolicyContext {
23 pub domain: String, 25 pub domain: String,
24 pub database: SharedDatabase, 26 pub database: SharedDatabase,
25 pub git_data_path: std::path::PathBuf, 27 pub git_data_path: std::path::PathBuf,
28 pub purgatory: Arc<Purgatory>,
26} 29}
27 30
28impl PolicyContext { 31impl PolicyContext {
@@ -30,11 +33,13 @@ impl PolicyContext {
30 domain: impl Into<String>, 33 domain: impl Into<String>,
31 database: SharedDatabase, 34 database: SharedDatabase,
32 git_data_path: impl Into<std::path::PathBuf>, 35 git_data_path: impl Into<std::path::PathBuf>,
36 purgatory: Arc<Purgatory>,
33 ) -> Self { 37 ) -> Self {
34 Self { 38 Self {
35 domain: domain.into(), 39 domain: domain.into(),
36 database, 40 database,
37 git_data_path: git_data_path.into(), 41 git_data_path: git_data_path.into(),
42 purgatory,
38 } 43 }
39 } 44 }
40} 45}
diff --git a/src/nostr/policy/pr_event.rs b/src/nostr/policy/pr_event.rs
index 53da369..c7602b0 100644
--- a/src/nostr/policy/pr_event.rs
+++ b/src/nostr/policy/pr_event.rs
@@ -19,6 +19,155 @@ impl PrEventPolicy {
19 Self { ctx } 19 Self { ctx }
20 } 20 }
21 21
22 /// Check if git data exists for a PR event
23 ///
24 /// This checks:
25 /// 1. If a placeholder exists (git-data-first scenario)
26 /// 2. If the commit exists in any relevant repository
27 ///
28 /// # Returns
29 /// - `Ok(true)` if git data ready (either placeholder found or commit exists)
30 /// - `Ok(false)` if git data missing (should add to purgatory)
31 /// - `Err(msg)` on errors
32 pub async fn check_git_data_exists(&self, event: &Event) -> Result<bool, String> {
33 let event_id = event.id.to_hex();
34
35 // Extract the `c` tag (commit hash) from the PR event
36 let commit = event.tags.iter().find_map(|tag| {
37 let tag_vec = tag.clone().to_vec();
38 if tag_vec.len() >= 2 && tag_vec[0] == "c" {
39 Some(tag_vec[1].clone())
40 } else {
41 None
42 }
43 });
44
45 let commit = match commit {
46 Some(c) => c,
47 None => {
48 return Err(format!("PR event {} has no 'c' tag", event_id));
49 }
50 };
51
52 // Check for placeholder first (git-data-first scenario)
53 if let Some(placeholder_commit) = self.ctx.purgatory.find_pr_placeholder(&event_id) {
54 if placeholder_commit == commit {
55 // Perfect match - git data arrived first with matching commit
56 tracing::debug!(
57 "Found matching placeholder for PR event {} with commit {}",
58 event_id,
59 commit
60 );
61 // Remove placeholder - event processing will continue normally
62 self.ctx.purgatory.remove_pr(&event_id);
63 return Ok(true);
64 } else {
65 // Placeholder has different commit - incoming event supersedes
66 tracing::info!(
67 "PR event {} supersedes placeholder: event expects commit {}, placeholder has {}",
68 event_id,
69 commit,
70 placeholder_commit
71 );
72 // Remove placeholder with old commit data
73 self.ctx.purgatory.remove_pr(&event_id);
74 // TODO: Also remove git data (refs/nostr/<event-id>) - Phase 5
75 // Fall through to check if new commit exists
76 }
77 }
78
79 // Check if commit exists in any repository referenced by this PR
80 // Extract ALL `a` tags (repository references) from the PR event
81 let repo_refs: Vec<String> = event
82 .tags
83 .iter()
84 .filter_map(|tag| {
85 let tag_vec = tag.clone().to_vec();
86 if tag_vec.len() >= 2 && tag_vec[0] == "a" && tag_vec[1].starts_with("30617:") {
87 Some(tag_vec[1].clone())
88 } else {
89 None
90 }
91 })
92 .collect();
93
94 if repo_refs.is_empty() {
95 // No repo references - cannot check git data
96 // This is unusual but let it through (other validation will catch issues)
97 return Ok(true);
98 }
99
100 // Check each repository to see if commit exists
101 for repo_ref in repo_refs {
102 // Parse the repo reference: 30617:<pubkey>:<identifier>
103 let parts: Vec<&str> = repo_ref.split(':').collect();
104 if parts.len() < 3 {
105 continue;
106 }
107
108 let repo_pubkey = match PublicKey::from_hex(parts[1]) {
109 Ok(pk) => pk,
110 Err(_) => continue,
111 };
112 let identifier = parts[2];
113
114 // Look up repository announcement to get the npub for path
115 let filter = Filter::new()
116 .kind(Kind::from(KIND_REPOSITORY_ANNOUNCEMENT))
117 .author(repo_pubkey)
118 .custom_tag(
119 SingleLetterTag::lowercase(Alphabet::D),
120 identifier.to_string(),
121 );
122
123 let announcements: Vec<Event> = match self.ctx.database.query(filter).await {
124 Ok(events) => events.into_iter().collect(),
125 Err(e) => {
126 tracing::warn!(
127 "Failed to query for repository announcement for PR {}: {}",
128 event_id,
129 e
130 );
131 continue;
132 }
133 };
134
135 if announcements.is_empty() {
136 continue;
137 }
138
139 // Check each matching announcement
140 for announcement_event in announcements {
141 let announcement = match RepositoryAnnouncement::from_event(announcement_event) {
142 Ok(a) => a,
143 Err(_) => continue,
144 };
145
146 // Build repository path
147 let repo_path = self.ctx.git_data_path.join(announcement.repo_path());
148
149 // Check if commit exists
150 if git::commit_exists(&repo_path, &commit) {
151 tracing::debug!(
152 "Found commit {} for PR event {} in repository {}",
153 commit,
154 event_id,
155 repo_path.display()
156 );
157 return Ok(true);
158 }
159 }
160 }
161
162 // No git data found - should add to purgatory
163 tracing::debug!(
164 "No git data found for PR event {} with commit {}",
165 event_id,
166 commit
167 );
168 Ok(false)
169 }
170
22 /// Validate refs/nostr/<event-id> ref against a PR or PR Update event's `c` tag 171 /// Validate refs/nostr/<event-id> ref against a PR or PR Update event's `c` tag
23 /// 172 ///
24 /// When a PR event (kind 1618) or PR Update event (kind 1619) is received, 173 /// When a PR event (kind 1618) or PR Update event (kind 1619) is received,
diff --git a/src/nostr/policy/state.rs b/src/nostr/policy/state.rs
index 43349e2..5e749ed 100644
--- a/src/nostr/policy/state.rs
+++ b/src/nostr/policy/state.rs
@@ -66,6 +66,24 @@ impl StatePolicy {
66 let state = RepositoryState::from_event(event.clone()) 66 let state = RepositoryState::from_event(event.clone())
67 .map_err(|e| format!("Failed to parse state: {}", e))?; 67 .map_err(|e| format!("Failed to parse state: {}", e))?;
68 68
69 // Check if ANY git repositories exist for this identifier (regardless of authorization)
70 // This helps us distinguish "no git data yet" from "not authorized" or "not latest"
71 let has_any_git_data = self.has_git_data_for_identifier(&state.identifier);
72
73 if !has_any_git_data {
74 // No git data exists yet - add to purgatory
75 tracing::debug!(
76 "No git data found for identifier {}, adding state event {} to purgatory",
77 state.identifier,
78 event.id.to_hex()
79 );
80 self.ctx
81 .purgatory
82 .add_state(event.clone(), state.identifier.clone(), event.pubkey);
83 // Return 0 repos aligned, but this is not an error
84 return Ok(0);
85 }
86
69 // Identify owner repositories for which this is the latest authorized state 87 // Identify owner repositories for which this is the latest authorized state
70 let owner_repos = self.identify_owner_repositories(&state).await?; 88 let owner_repos = self.identify_owner_repositories(&state).await?;
71 let repo_count = owner_repos.len(); 89 let repo_count = owner_repos.len();
@@ -97,13 +115,48 @@ impl StatePolicy {
97 ); 115 );
98 } else { 116 } else {
99 tracing::debug!( 117 tracing::debug!(
100 "No owner repos to align for state - git data not available yet or not latest" 118 "No owner repos to align for state - git data exists but author not authorized or not latest"
101 ); 119 );
102 } 120 }
103 121
104 Ok(total_aligned) 122 Ok(total_aligned)
105 } 123 }
106 124
125 /// Check if any git repositories exist for the given identifier
126 ///
127 /// Scans the git_data_path for any directories matching the pattern:
128 /// `<any-npub>/<identifier>.git`
129 ///
130 /// This is used to distinguish "no git data yet" from "not authorized".
131 fn has_git_data_for_identifier(&self, identifier: &str) -> bool {
132 let git_data_path = &self.ctx.git_data_path;
133
134 // Check if git_data_path exists
135 if !git_data_path.exists() {
136 return false;
137 }
138
139 // Scan for any npub directories
140 let read_dir = match std::fs::read_dir(git_data_path) {
141 Ok(dir) => dir,
142 Err(_) => return false,
143 };
144
145 for entry in read_dir.flatten() {
146 if let Ok(file_type) = entry.file_type() {
147 if file_type.is_dir() {
148 // Check if <npub>/<identifier>.git exists
149 let repo_path = entry.path().join(format!("{}.git", identifier));
150 if repo_path.exists() {
151 return true;
152 }
153 }
154 }
155 }
156
157 false
158 }
159
107 /// Check if this state event is the latest for its identifier among authorized authors 160 /// Check if this state event is the latest for its identifier among authorized authors
108 /// 161 ///
109 /// A state is considered "latest" if no other state event in the database 162 /// A state is considered "latest" if no other state event in the database