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-04 15:42:00 +0000
committerDanConwayDev <DanConwayDev@protonmail.com>2025-12-04 15:42:00 +0000
commit819866330c7e2f535a155d1d7efaf2e12dc15dc2 (patch)
treed84c8361811544aad9cad089c0358b9028c8fb80 /src/nostr
parentfd0c87c787d0626b3546fa571541c9c809711821 (diff)
refactor: split Nip34WritePolicy into focused sub-policies
Split the ~900 line Nip34WritePolicy into focused sub-policies for improved testability and maintainability: - AnnouncementPolicy - Repository announcement validation - StatePolicy - State event validation + ref alignment - PrEventPolicy - PR/PR Update validation - RelatedEventPolicy - Forward/backward reference checking The main Nip34WritePolicy now delegates to these sub-policies via a shared PolicyContext that provides domain, database, and git_data_path. Also updates: - README.md: Accurate project structure reflecting actual implementation - docs/learnings: Marks this technical debt item as complete
Diffstat (limited to 'src/nostr')
-rw-r--r--src/nostr/builder.rs1334
-rw-r--r--src/nostr/mod.rs4
-rw-r--r--src/nostr/policy/announcement.rs157
-rw-r--r--src/nostr/policy/mod.rs41
-rw-r--r--src/nostr/policy/pr_event.rs198
-rw-r--r--src/nostr/policy/related.rs276
-rw-r--r--src/nostr/policy/state.rs419
7 files changed, 1260 insertions, 1169 deletions
diff --git a/src/nostr/builder.rs b/src/nostr/builder.rs
index 00e5969..15ff083 100644
--- a/src/nostr/builder.rs
+++ b/src/nostr/builder.rs
@@ -1,64 +1,51 @@
1/// Nostr Relay Builder Configuration 1/// Nostr Relay Builder Configuration
2/// 2///
3/// This module integrates nostr-relay-builder with NIP-34 validation logic 3/// This module integrates nostr-relay-builder with NIP-34 validation logic
4/// preserved from the original implementation. 4/// using modular sub-policies for each event type.
5use std::net::SocketAddr; 5use std::net::SocketAddr;
6use std::path::{Path, PathBuf}; 6use std::path::Path;
7use std::sync::Arc; 7use std::sync::Arc;
8 8
9use nostr::nips::nip19::ToBech32; 9use nostr::nips::nip19::ToBech32;
10use nostr::prelude::{Alphabet, SingleLetterTag};
11use nostr::{EventId, Filter, Kind, PublicKey};
12use nostr_lmdb::NostrLMDB; 10use nostr_lmdb::NostrLMDB;
13use nostr_relay_builder::prelude::*; 11use nostr_relay_builder::prelude::*;
14 12
15use crate::config::{Config, DatabaseBackend}; 13use crate::config::{Config, DatabaseBackend};
16use crate::git;
17use crate::nostr::events::{ 14use crate::nostr::events::{
18 validate_announcement, validate_state, RepositoryAnnouncement, RepositoryState, KIND_PR, 15 RepositoryAnnouncement, RepositoryState, KIND_PR, KIND_PR_UPDATE, KIND_REPOSITORY_ANNOUNCEMENT,
19 KIND_PR_UPDATE, KIND_REPOSITORY_ANNOUNCEMENT, KIND_REPOSITORY_STATE, 16 KIND_REPOSITORY_STATE,
17};
18use crate::nostr::policy::{
19 AnnouncementPolicy, AnnouncementResult, PolicyContext, PrEventPolicy, RelatedEventPolicy,
20 ReferenceResult, StatePolicy, StateResult,
20}; 21};
21 22
22/// Type alias for the shared database used by the relay 23/// Type alias for the shared database used by the relay
23pub type SharedDatabase = Arc<dyn NostrDatabase>; 24pub type SharedDatabase = Arc<dyn NostrDatabase>;
24 25
25/// Result of aligning a repository with authorized state
26#[derive(Debug, Default)]
27struct AlignmentResult {
28 /// Number of refs created
29 refs_created: usize,
30 /// Number of refs updated
31 refs_updated: usize,
32 /// Number of refs deleted
33 refs_deleted: usize,
34 /// Whether HEAD was set
35 head_set: bool,
36}
37
38/// NIP-34 Write Policy with Full GRASP-01 Event Validation 26/// NIP-34 Write Policy with Full GRASP-01 Event Validation
39/// 27///
40/// Validates all events according to GRASP-01 specification: 28/// Validates all events according to GRASP-01 specification using modular sub-policies:
41/// - Repository announcements must list service in clone and relays tags 29/// - `AnnouncementPolicy` - Repository announcement validation
42/// EXCEPTION: Recursive maintainer announcements are accepted even without 30/// - `StatePolicy` - State event validation + ref alignment
43/// listing the service, to enable maintainer chain discovery and GRASP-02 sync 31/// - `PrEventPolicy` - PR/PR Update validation
44/// - Repository state announcements must have valid structure 32/// - `RelatedEventPolicy` - Forward/backward reference checking
45/// - Other events must reference accepted repositories or events
46/// - Forward references are supported (events referenced by accepted events)
47/// - Orphan events with no valid references are rejected
48/// 33///
49/// Uses stateful database queries to check event relationships. 34/// Uses stateful database queries to check event relationships.
50#[derive(Clone)] 35#[derive(Clone)]
51pub struct Nip34WritePolicy { 36pub struct Nip34WritePolicy {
52 domain: String, 37 ctx: PolicyContext,
53 database: SharedDatabase, 38 announcement_policy: AnnouncementPolicy,
54 git_data_path: PathBuf, 39 state_policy: StatePolicy,
40 pr_event_policy: PrEventPolicy,
41 related_event_policy: RelatedEventPolicy,
55} 42}
56 43
57impl std::fmt::Debug for Nip34WritePolicy { 44impl std::fmt::Debug for Nip34WritePolicy {
58 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 45 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
59 f.debug_struct("Nip34WritePolicy") 46 f.debug_struct("Nip34WritePolicy")
60 .field("domain", &self.domain) 47 .field("domain", &self.ctx.domain)
61 .field("git_data_path", &self.git_data_path) 48 .field("git_data_path", &self.ctx.git_data_path)
62 .field("database", &"<database>") 49 .field("database", &"<database>")
63 .finish() 50 .finish()
64 } 51 }
@@ -68,837 +55,200 @@ impl Nip34WritePolicy {
68 pub fn new( 55 pub fn new(
69 domain: impl Into<String>, 56 domain: impl Into<String>,
70 database: SharedDatabase, 57 database: SharedDatabase,
71 git_data_path: impl Into<PathBuf>, 58 git_data_path: impl Into<std::path::PathBuf>,
72 ) -> Self { 59 ) -> Self {
60 let ctx = PolicyContext::new(domain, database, git_data_path);
73 Self { 61 Self {
74 domain: domain.into(), 62 announcement_policy: AnnouncementPolicy::new(ctx.clone()),
75 database, 63 state_policy: StatePolicy::new(ctx.clone()),
76 git_data_path: git_data_path.into(), 64 pr_event_policy: PrEventPolicy::new(ctx.clone()),
65 related_event_policy: RelatedEventPolicy::new(ctx.clone()),
66 ctx,
77 } 67 }
78 } 68 }
79 69
80 /// Create a bare git repository if it doesn't exist 70 /// Handle repository announcement event
81 /// Path format: <git_data_path>/<npub>/<identifier>.git 71 async fn handle_announcement(&self, event: &Event) -> PolicyResult {
82 fn ensure_bare_repository(&self, announcement: &RepositoryAnnouncement) -> Result<(), String> { 72 let event_id_str = event.id.to_bech32().unwrap_or_else(|_| event.id.to_hex());
83 let repo_path = self.git_data_path.join(announcement.repo_path()); 73
84 74 match self.announcement_policy.validate(event).await {
85 // Check if repository already exists 75 AnnouncementResult::Accept => {
86 if repo_path.exists() { 76 // Parse announcement to get repository details
87 tracing::debug!("Repository already exists at {}", repo_path.display()); 77 match RepositoryAnnouncement::from_event(event.clone()) {
88 return Ok(()); 78 Ok(announcement) => {
89 } 79 // Try to create bare repository if it doesn't exist
90 80 if let Err(e) = self.announcement_policy.ensure_bare_repository(&announcement)
91 // Create parent directory (npub directory) 81 {
92 let parent = repo_path 82 tracing::warn!(
93 .parent() 83 "Failed to create bare repository for {}: {}",
94 .ok_or_else(|| format!("Invalid repository path: {}", repo_path.display()))?; 84 event_id_str,
95 85 e
96 std::fs::create_dir_all(parent) 86 );
97 .map_err(|e| format!("Failed to create directory {}: {}", parent.display(), e))?; 87 // Note: We still accept the event even if repo creation fails
98 88 }
99 // Initialize bare repository using git command
100 let output = std::process::Command::new("git")
101 .args(["init", "--bare", repo_path.to_str().unwrap()])
102 .output()
103 .map_err(|e| format!("Failed to execute git init: {}", e))?;
104
105 if !output.status.success() {
106 let stderr = String::from_utf8_lossy(&output.stderr);
107 return Err(format!("git init failed: {}", stderr));
108 }
109
110 tracing::info!("Created bare repository at {}", repo_path.display());
111 Ok(())
112 }
113
114 /// Check if this state event is the latest for its identifier among authorized authors
115 ///
116 /// A state is considered "latest" if no other state event in the database
117 /// from an authorized author has a newer timestamp. This handles out-of-order
118 /// delivery where an older event arrives after a newer one.
119 ///
120 /// The authorized_pubkeys should be the owner and maintainers of a specific
121 /// announcement, so different owners with the same identifier don't interfere.
122 async fn is_latest_state_for_identifier(
123 database: &SharedDatabase,
124 state: &RepositoryState,
125 authorized_pubkeys: &[PublicKey],
126 ) -> Result<bool, String> {
127 let filter = Filter::new()
128 .kind(Kind::from(KIND_REPOSITORY_STATE))
129 .custom_tag(
130 SingleLetterTag::lowercase(Alphabet::D),
131 state.identifier.clone(),
132 );
133 89
134 match database.query(filter).await { 90 tracing::debug!("Accepted repository announcement: {}", event_id_str);
135 Ok(events) => { 91 PolicyResult::Accept
136 for event in events {
137 // Skip comparing to self (same event ID)
138 if event.id == state.event.id {
139 continue;
140 } 92 }
141 // Only consider events from authorized authors for this announcement 93 Err(e) => {
142 if !authorized_pubkeys.contains(&event.pubkey) { 94 tracing::warn!(
143 continue; 95 "Failed to parse repository announcement {}: {}",
144 } 96 event_id_str,
145 // If any existing event from an authorized author is newer, this is not the latest 97 e
146 if event.created_at > state.event.created_at {
147 tracing::debug!(
148 "State {} is not latest: found newer state {} from {} (ts {} > {})",
149 state.event.id.to_hex(),
150 event.id.to_hex(),
151 event.pubkey.to_hex(),
152 event.created_at.as_secs(),
153 state.event.created_at.as_secs()
154 ); 98 );
155 return Ok(false); 99 PolicyResult::Reject(format!("Failed to parse announcement: {}", e))
156 } 100 }
157 } 101 }
158 Ok(true)
159 } 102 }
160 Err(e) => Err(format!("Database query failed: {}", e)), 103 AnnouncementResult::AcceptMaintainer => {
161 } 104 // Parse announcement to get details for logging
162 } 105 match RepositoryAnnouncement::from_event(event.clone()) {
163 106 Ok(announcement) => {
164 /// Find all repository announcements where the given pubkey is authorized 107 tracing::info!(
165 /// 108 "Accepted maintainer announcement {} (author {} is listed as maintainer for {})",
166 /// A pubkey is authorized for an announcement if: 109 event_id_str,
167 /// - They are the owner (pubkey of the announcement event), OR 110 event.pubkey.to_hex(),
168 /// - They are listed in the "maintainers" tag 111 announcement.identifier
169 /// 112 );
170 /// This is needed because a maintainer can publish a state event that 113 // Don't create bare repository for external announcements
171 /// should update HEAD in the repository of the announcement owner, 114 PolicyResult::Accept
172 /// not in the maintainer's own (possibly non-existent) repository. 115 }
173 async fn find_authorized_announcements( 116 Err(e) => {
174 database: &SharedDatabase, 117 tracing::warn!(
175 identifier: &str, 118 "Failed to parse maintainer announcement {}: {}",
176 state_author: &PublicKey, 119 event_id_str,
177 ) -> Result<Vec<RepositoryAnnouncement>, String> { 120 e
178 let filter = Filter::new() 121 );
179 .kind(Kind::from(KIND_REPOSITORY_ANNOUNCEMENT)) 122 PolicyResult::Reject(format!("Failed to parse announcement: {}", e))
180 .custom_tag(
181 SingleLetterTag::lowercase(Alphabet::D),
182 identifier.to_string(),
183 );
184
185 match database.query(filter).await {
186 Ok(events) => {
187 let mut authorized = Vec::new();
188 let state_author_hex = state_author.to_hex();
189
190 for event in events {
191 if let Ok(announcement) = RepositoryAnnouncement::from_event(event.clone()) {
192 // Check if state author is authorized for this announcement
193 let is_owner = event.pubkey == *state_author;
194 let is_maintainer = announcement.maintainers.contains(&state_author_hex);
195
196 if is_owner || is_maintainer {
197 tracing::debug!(
198 "Found authorized announcement for {}: owner={}, maintainer={}",
199 identifier,
200 if is_owner {
201 event.pubkey.to_hex()
202 } else {
203 "n/a".to_string()
204 },
205 is_maintainer
206 );
207 authorized.push(announcement);
208 }
209 } 123 }
210 }
211 Ok(authorized)
212 }
213 Err(e) => Err(format!("Database query failed: {}", e)),
214 }
215 }
216
217 /// Identify all owner repositories for which this state event is the latest authorized state
218 ///
219 /// Returns a list of (announcement, repo_path) pairs where:
220 /// - The state author is authorized (owner or maintainer)
221 /// - This state event is the latest for the identifier in that context
222 async fn identify_owner_repositories(
223 &self,
224 database: &SharedDatabase,
225 state: &RepositoryState,
226 ) -> Result<Vec<(RepositoryAnnouncement, std::path::PathBuf)>, String> {
227 // Find all announcements where state author is authorized
228 let announcements =
229 Self::find_authorized_announcements(database, &state.identifier, &state.event.pubkey)
230 .await?;
231
232 if announcements.is_empty() {
233 tracing::debug!(
234 "No authorized announcements found for state {} by {}",
235 state.identifier,
236 state.event.pubkey.to_hex()
237 );
238 return Ok(Vec::new());
239 }
240
241 let mut owner_repos = Vec::new();
242
243 for announcement in announcements {
244 // Build the list of authorized pubkeys for this specific announcement
245 // (owner + maintainers)
246 let mut authorized_pubkeys = vec![announcement.event.pubkey];
247 for maintainer_hex in &announcement.maintainers {
248 if let Ok(pk) = PublicKey::from_hex(maintainer_hex) {
249 authorized_pubkeys.push(pk);
250 } 124 }
251 } 125 }
252 126 AnnouncementResult::Reject(reason) => {
253 // Check if this is the latest state event for THIS announcement's context 127 tracing::warn!(
254 if !Self::is_latest_state_for_identifier(database, state, &authorized_pubkeys).await? { 128 "Rejected repository announcement {}: {}",
255 tracing::debug!( 129 event_id_str,
256 "Skipping {} in {}'s repo - not the latest state event for this context", 130 reason
257 state.identifier,
258 announcement.event.pubkey.to_hex()
259 ); 131 );
260 continue; 132 PolicyResult::Reject(reason)
261 } 133 }
262
263 // Build repository path: <git_data_path>/<owner_npub>/<identifier>.git
264 let repo_path = self.git_data_path.join(announcement.repo_path().clone());
265 owner_repos.push((announcement, repo_path));
266 } 134 }
267
268 Ok(owner_repos)
269 } 135 }
270 136
271 /// Align an owner repository's refs with the authorized state 137 /// Handle repository state event
272 /// 138 async fn handle_state(&self, event: &Event) -> PolicyResult {
273 /// This function: 139 let event_id_str = event.id.to_bech32().unwrap_or_else(|_| event.id.to_hex());
274 /// 1. Deletes refs that are in the repo but not in the state (for refs/heads/ and refs/tags/) 140
275 /// 2. Updates refs that exist in state if we have the commit (for refs/heads/ and refs/tags/) 141 match self.state_policy.validate(event) {
276 /// 3. Sets HEAD if the HEAD branch's commit is available 142 StateResult::Accept => {
277 /// 143 // Parse state to get HEAD and branch info
278 /// Per GRASP-01: "MUST set repository HEAD per repository state announcement 144 match RepositoryState::from_event(event.clone()) {
279 /// as soon as the git data related to that branch has been received." 145 Ok(_state) => {
280 /// 146 // Process state alignment asynchronously
281 /// Returns a summary of actions taken. 147 if let Err(e) = self.state_policy.process_state_event(event).await {
282 fn align_owner_repository_with_state(
283 &self,
284 repo_path: &std::path::Path,
285 state: &RepositoryState,
286 ) -> AlignmentResult {
287 let mut result = AlignmentResult::default();
288
289 // Check if repository exists
290 if !repo_path.exists() {
291 tracing::debug!(
292 "Repository not found at {}, cannot align with state",
293 repo_path.display()
294 );
295 return result;
296 }
297
298 // Get current refs from the repository
299 let current_refs = match git::list_refs(repo_path) {
300 Ok(refs) => refs,
301 Err(e) => {
302 tracing::warn!("Failed to list refs in {}: {}", repo_path.display(), e);
303 return result;
304 }
305 };
306
307 // Build expected refs from state
308 let mut expected_refs: std::collections::HashMap<String, String> =
309 std::collections::HashMap::new();
310
311 for branch in &state.branches {
312 let ref_name = format!("refs/heads/{}", branch.name);
313 expected_refs.insert(ref_name, branch.commit.clone());
314 }
315
316 for tag in &state.tags {
317 let ref_name = format!("refs/tags/{}", tag.name);
318 expected_refs.insert(ref_name, tag.commit.clone());
319 }
320
321 // Process current refs: update or delete as needed
322 for (ref_name, current_commit) in &current_refs {
323 // Only process refs/heads/ and refs/tags/
324 if !ref_name.starts_with("refs/heads/") && !ref_name.starts_with("refs/tags/") {
325 continue;
326 }
327
328 match expected_refs.get(ref_name) {
329 Some(expected_commit) => {
330 // Ref should exist - check if commit matches
331 if current_commit != expected_commit {
332 // Check if we have the expected commit
333 if git::commit_exists(repo_path, expected_commit) {
334 // Update the ref
335 match git::update_ref(repo_path, ref_name, expected_commit) {
336 Ok(()) => {
337 tracing::info!(
338 "Updated {} from {} to {} in {}",
339 ref_name,
340 current_commit,
341 expected_commit,
342 repo_path.display()
343 );
344 result.refs_updated += 1;
345 }
346 Err(e) => {
347 tracing::warn!(
348 "Failed to update {} in {}: {}",
349 ref_name,
350 repo_path.display(),
351 e
352 );
353 }
354 }
355 } else {
356 tracing::debug!(
357 "Commit {} not available for {} in {}",
358 expected_commit,
359 ref_name,
360 repo_path.display()
361 );
362 }
363 }
364 }
365 None => {
366 // Ref should not exist - delete it
367 match git::delete_ref(repo_path, ref_name) {
368 Ok(()) => {
369 tracing::info!(
370 "Deleted {} (not in state) from {}",
371 ref_name,
372 repo_path.display()
373 );
374 result.refs_deleted += 1;
375 }
376 Err(e) => {
377 tracing::warn!( 148 tracing::warn!(
378 "Failed to delete {} from {}: {}", 149 "Failed to process state event {}: {}",
379 ref_name, 150 event_id_str,
380 repo_path.display(),
381 e 151 e
382 ); 152 );
383 } 153 }
384 }
385 }
386 }
387 }
388 154
389 // Add refs that exist in state but not in repo (if we have the commit) 155 tracing::debug!("Accepted repository state: {}", event_id_str);
390 for (ref_name, expected_commit) in &expected_refs { 156 PolicyResult::Accept
391 let exists = current_refs.iter().any(|(r, _)| r == ref_name);
392 if !exists && git::commit_exists(repo_path, expected_commit) {
393 match git::update_ref(repo_path, ref_name, expected_commit) {
394 Ok(()) => {
395 tracing::info!(
396 "Created {} at {} in {}",
397 ref_name,
398 expected_commit,
399 repo_path.display()
400 );
401 result.refs_created += 1;
402 } 157 }
403 Err(e) => { 158 Err(e) => {
404 tracing::warn!( 159 tracing::warn!(
405 "Failed to create {} in {}: {}", 160 "Failed to parse repository state {}: {}",
406 ref_name, 161 event_id_str,
407 repo_path.display(),
408 e 162 e
409 ); 163 );
164 // Still accept the event even if we can't parse it
165 // The validation passed, so it's structurally valid
166 PolicyResult::Accept
410 } 167 }
411 } 168 }
412 } 169 }
413 } 170 StateResult::Reject(reason) => {
414 171 tracing::warn!("Rejected repository state {}: {}", event_id_str, reason);
415 // Set HEAD if specified in state 172 PolicyResult::Reject(reason)
416 if let Some(head_ref) = &state.head {
417 if let Some(branch_name) = state.get_head_branch() {
418 if let Some(head_commit) = state.get_branch_commit(branch_name) {
419 match git::try_set_head_if_available(repo_path, head_ref, head_commit) {
420 Ok(true) => {
421 tracing::info!(
422 "Set HEAD to {} in {} (from state by {})",
423 head_ref,
424 repo_path.display(),
425 state.event.pubkey.to_hex()
426 );
427 result.head_set = true;
428 }
429 Ok(false) => {
430 tracing::debug!(
431 "HEAD commit {} not available yet in {}",
432 head_commit,
433 repo_path.display()
434 );
435 }
436 Err(e) => {
437 tracing::warn!("Failed to set HEAD in {}: {}", repo_path.display(), e);
438 }
439 }
440 }
441 } 173 }
442 } 174 }
443
444 result
445 } 175 }
446 176
447 /// Check if a pubkey is listed as a maintainer in any announcement for this identifier 177 /// Handle PR or PR Update event
448 /// 178 async fn handle_pr_event(&self, event: &Event) -> PolicyResult {
449 /// A pubkey is considered a maintainer if: 179 let event_id_str = event.id.to_bech32().unwrap_or_else(|_| event.id.to_hex());
450 /// 1. They are the owner (pubkey) of an accepted announcement with this identifier, OR 180
451 /// 2. They are listed in the maintainers tag of ANY announcement with this identifier 181 // Validate refs/nostr refs for this PR event
452 /// 182 // This deletes any refs/nostr/<event-id> that points to wrong commit
453 /// This enables accepting announcements from maintainers even when they don't list 183 if let Err(e) = self.pr_event_policy.validate_nostr_ref(event).await {
454 /// this GRASP server, for maintainer chain discovery and GRASP-02 sync. 184 tracing::warn!(
455 async fn is_maintainer_in_any_announcement( 185 "Failed to validate refs/nostr for PR event {}: {}",
456 database: &SharedDatabase, 186 event_id_str,
457 identifier: &str, 187 e
458 author: &PublicKey,
459 ) -> Result<bool, String> {
460 // Query all announcements with this identifier that are already in the database
461 let filter = Filter::new()
462 .kind(Kind::from(KIND_REPOSITORY_ANNOUNCEMENT))
463 .custom_tag(
464 SingleLetterTag::lowercase(Alphabet::D),
465 identifier.to_string(),
466 ); 188 );
467 189 // Don't reject - just log the error and proceed with normal validation
468 let announcements: Vec<Event> = match database.query(filter).await {
469 Ok(events) => events.into_iter().collect(),
470 Err(e) => return Err(format!("Database query failed: {}", e)),
471 };
472
473 if announcements.is_empty() {
474 // No existing announcements for this identifier - author cannot be a maintainer
475 return Ok(false);
476 }
477
478 let author_hex = author.to_hex();
479
480 // Check each announcement to see if author is listed as a maintainer
481 for event in &announcements {
482 // Check if author is the owner of this announcement
483 if event.pubkey == *author {
484 return Ok(true);
485 }
486
487 // Check if author is listed in the maintainers tag
488 if let Ok(announcement) = RepositoryAnnouncement::from_event(event.clone()) {
489 if announcement.maintainers.contains(&author_hex) {
490 return Ok(true);
491 }
492 }
493 }
494
495 Ok(false)
496 }
497
498 /// Extract all reference tags from an event (a, A, q, e, E)
499 /// Returns (addressable_refs, event_refs)
500 fn extract_reference_tags(event: &Event) -> (Vec<String>, Vec<EventId>) {
501 let mut addressable_refs = Vec::new();
502 let mut event_refs = Vec::new();
503
504 for tag in event.tags.iter() {
505 let tag_vec = tag.clone().to_vec();
506 if tag_vec.is_empty() {
507 continue;
508 }
509
510 match tag_vec[0].as_str() {
511 // Addressable event references (a, A, q with kind:pubkey:identifier format)
512 "a" | "A" | "q" if tag_vec.len() > 1 && tag_vec[1].contains(':') => {
513 addressable_refs.push(tag_vec[1].clone());
514 }
515 // Event ID references (e, E, q with event ID format)
516 "e" | "E" if tag_vec.len() > 1 => {
517 if let Ok(event_id) = EventId::from_hex(&tag_vec[1]) {
518 event_refs.push(event_id);
519 }
520 }
521 "q" if tag_vec.len() > 1 && !tag_vec[1].contains(':') => {
522 if let Ok(event_id) = EventId::from_hex(&tag_vec[1]) {
523 event_refs.push(event_id);
524 }
525 }
526 _ => {}
527 }
528 } 190 }
529 191
530 (addressable_refs, event_refs) 192 // Continue with reference checking (same as related events)
193 self.handle_related_event(event, "PR").await
531 } 194 }
532 195
533 /// Validate refs/nostr/<event-id> ref against a PR or PR Update event's `c` tag 196 /// Handle events that must reference accepted repositories or events
534 /// 197 async fn handle_related_event(&self, event: &Event, event_type: &str) -> PolicyResult {
535 /// When a PR event (kind 1618) or PR Update event (kind 1619) is received, 198 let event_id_str = event.id.to_bech32().unwrap_or_else(|_| event.id.to_hex());
536 /// this checks if a corresponding refs/nostr/<event-id> ref exists in the
537 /// repository and validates that it points to the correct commit (from the
538 /// `c` tag). If the ref exists but points to a different commit, the ref is
539 /// deleted.
540 ///
541 /// PR and PR Update events can have multiple `a` tags to update multiple
542 /// repositories simultaneously.
543 ///
544 /// This is part of GRASP-01 compliance: ensuring refs/nostr refs are consistent
545 /// with their corresponding events.
546 ///
547 /// # Arguments
548 /// * `database` - Database for looking up repository announcements
549 /// * `event` - The PR event (kind 1618) or PR Update event (kind 1619)
550 ///
551 /// # Returns
552 /// Ok(Some(n)) if n refs were deleted, Ok(None) if no action taken, Err on failure
553 async fn validate_pr_nostr_ref(
554 &self,
555 database: &SharedDatabase,
556 event: &Event,
557 ) -> Result<Option<usize>, String> {
558 let event_id = event.id.to_hex();
559
560 // Extract the `c` tag (commit hash) from the PR event
561 let expected_commit = event.tags.iter().find_map(|tag| {
562 let tag_vec = tag.clone().to_vec();
563 if tag_vec.len() >= 2 && tag_vec[0] == "c" {
564 Some(tag_vec[1].clone())
565 } else {
566 None
567 }
568 });
569 199
570 let expected_commit = match expected_commit { 200 match self.related_event_policy.check_references(event).await {
571 Some(c) => c, 201 Ok(ReferenceResult::ReferencesRepository(addr_ref)) => {
572 None => {
573 tracing::debug!( 202 tracing::debug!(
574 "PR event {} has no 'c' tag, skipping ref validation", 203 "Accepted {} event {}: references accepted repository {}",
575 event_id 204 event_type,
205 event_id_str,
206 addr_ref
576 ); 207 );
577 return Ok(None); 208 PolicyResult::Accept
578 } 209 }
579 }; 210 Ok(ReferenceResult::ReferencesEvent(event_ref)) => {
580
581 // Extract ALL `a` tags (repository references) from the PR event
582 // PR events can reference multiple repositories
583 // Format: 30617:<pubkey>:<identifier>
584 let repo_refs: Vec<String> = event
585 .tags
586 .iter()
587 .filter_map(|tag| {
588 let tag_vec = tag.clone().to_vec();
589 if tag_vec.len() >= 2 && tag_vec[0] == "a" && tag_vec[1].starts_with("30617:") {
590 Some(tag_vec[1].clone())
591 } else {
592 None
593 }
594 })
595 .collect();
596
597 if repo_refs.is_empty() {
598 tracing::debug!(
599 "PR event {} has no repo 'a' tags, skipping ref validation",
600 event_id
601 );
602 return Ok(None);
603 }
604
605 let mut deleted_count = 0;
606
607 // Process each repository reference
608 for repo_ref in repo_refs {
609 // Parse the repo reference: 30617:<pubkey>:<identifier>
610 let parts: Vec<&str> = repo_ref.split(':').collect();
611 if parts.len() < 3 {
612 tracing::debug!( 211 tracing::debug!(
613 "PR event {} has invalid 'a' tag format: {}", 212 "Accepted {} event {}: references accepted event {}",
614 event_id, 213 event_type,
615 repo_ref 214 event_id_str,
215 event_ref
616 ); 216 );
617 continue; 217 PolicyResult::Accept
618 } 218 }
619 219 Ok(ReferenceResult::ReferencedByAccepted) => {
620 let repo_pubkey = match PublicKey::from_hex(parts[1]) {
621 Ok(pk) => pk,
622 Err(_) => {
623 tracing::debug!(
624 "PR event {} has invalid pubkey in 'a' tag: {}",
625 event_id,
626 parts[1]
627 );
628 continue;
629 }
630 };
631 let identifier = parts[2];
632
633 // Look up repository announcement to get the npub for path
634 let filter = Filter::new()
635 .kind(Kind::from(KIND_REPOSITORY_ANNOUNCEMENT))
636 .author(repo_pubkey)
637 .custom_tag(
638 SingleLetterTag::lowercase(Alphabet::D),
639 identifier.to_string(),
640 );
641
642 let announcements: Vec<Event> = match database.query(filter).await {
643 Ok(events) => events.into_iter().collect(),
644 Err(e) => {
645 tracing::warn!(
646 "Failed to query for repository announcement for PR {}: {}",
647 event_id,
648 e
649 );
650 continue;
651 }
652 };
653
654 if announcements.is_empty() {
655 tracing::debug!( 220 tracing::debug!(
656 "No repository announcement found for PR event {} (repo {}:{})", 221 "Accepted {} event {}: referenced by accepted event",
657 event_id, 222 event_type,
658 repo_pubkey.to_hex(), 223 event_id_str
659 identifier
660 ); 224 );
661 continue; 225 PolicyResult::Accept
662 }
663
664 // Process each matching announcement (there could be multiple)
665 for announcement_event in announcements {
666 let announcement = match RepositoryAnnouncement::from_event(announcement_event) {
667 Ok(a) => a,
668 Err(e) => {
669 tracing::warn!(
670 "Failed to parse announcement for PR {} validation: {}",
671 event_id,
672 e
673 );
674 continue;
675 }
676 };
677
678 // Build repository path
679 let repo_path = self.git_data_path.join(announcement.repo_path());
680
681 // Validate the ref
682 match git::validate_nostr_ref(&repo_path, &event_id, &expected_commit) {
683 Ok(true) => {
684 tracing::info!(
685 "Deleted mismatched refs/nostr/{} in {} (expected commit {})",
686 event_id,
687 repo_path.display(),
688 expected_commit
689 );
690 deleted_count += 1;
691 }
692 Ok(false) => {
693 tracing::debug!(
694 "refs/nostr/{} in {} is valid or doesn't exist",
695 event_id,
696 repo_path.display()
697 );
698 }
699 Err(e) => {
700 tracing::warn!(
701 "Failed to validate refs/nostr/{} in {}: {}",
702 event_id,
703 repo_path.display(),
704 e
705 );
706 }
707 }
708 }
709 }
710
711 if deleted_count > 0 {
712 Ok(Some(deleted_count))
713 } else {
714 Ok(None)
715 }
716 }
717
718 /// Check if any addressable events (repositories) exist in database
719 /// Returns the first matching addressable reference found, or None if none match
720 async fn find_accepted_repository(
721 database: &SharedDatabase,
722 addressables: &[String],
723 ) -> Result<Option<String>, String> {
724 if addressables.is_empty() {
725 return Ok(None);
726 }
727
728 // Parse all addressable references
729 let mut parsed_refs = Vec::new();
730 for addr in addressables {
731 let parts: Vec<&str> = addr.split(':').collect();
732 if parts.len() < 3 {
733 continue; // Skip invalid format
734 }
735
736 let kind = match parts[0].parse::<u16>() {
737 Ok(k) => k,
738 Err(_) => continue, // Skip invalid kind
739 };
740 let pubkey = match PublicKey::from_hex(parts[1]) {
741 Ok(pk) => pk,
742 Err(_) => continue, // Skip invalid pubkey
743 };
744 let identifier = parts[2].to_string();
745
746 parsed_refs.push((addr.clone(), kind, pubkey, identifier));
747 }
748
749 if parsed_refs.is_empty() {
750 return Ok(None);
751 }
752
753 // Group by kind to reduce queries
754 use std::collections::HashMap;
755 let mut by_kind: HashMap<u16, Vec<_>> = HashMap::new();
756 for (addr, kind, pubkey, identifier) in parsed_refs {
757 by_kind
758 .entry(kind)
759 .or_default()
760 .push((addr, pubkey, identifier));
761 }
762
763 // Query each kind group
764 for (kind, refs) in by_kind {
765 let authors: Vec<PublicKey> = refs.iter().map(|(_, pk, _)| *pk).collect();
766
767 let filter = Filter::new().kind(Kind::from(kind)).authors(authors);
768
769 match database.query(filter).await {
770 Ok(events) => {
771 // Check if any event matches our identifier requirements
772 for event in events {
773 for (addr, _pubkey, identifier) in &refs {
774 // Match identifier tag
775 if event.tags.iter().any(|tag| {
776 let tag_vec = tag.clone().to_vec();
777 tag_vec.len() >= 2 && tag_vec[0] == "d" && tag_vec[1] == *identifier
778 }) {
779 return Ok(Some(addr.clone()));
780 }
781 }
782 }
783 }
784 Err(e) => return Err(format!("Database query failed: {}", e)),
785 } 226 }
786 } 227 Ok(ReferenceResult::Orphan) => {
787 228 let (addressable_refs, event_refs) =
788 Ok(None) 229 RelatedEventPolicy::extract_reference_tags(event);
789 } 230 tracing::info!(
790 231 "Rejected orphan {} event {}: no references to accepted repos or events (checked {} addressable, {} event refs)",
791 /// Check if any events exist in database 232 event_type,
792 /// Returns the first matching event ID found, or None if none match 233 event_id_str,
793 async fn find_accepted_event( 234 addressable_refs.len(),
794 database: &SharedDatabase, 235 event_refs.len()
795 event_ids: &[EventId], 236 );
796 ) -> Result<Option<EventId>, String> { 237 PolicyResult::Reject(format!(
797 if event_ids.is_empty() { 238 "{} event must reference an accepted repository or accepted event",
798 return Ok(None); 239 event_type
799 } 240 ))
800
801 // Single query for all event IDs
802 let filter = Filter::new().ids(event_ids.iter().copied());
803
804 match database.query(filter).await {
805 Ok(events) => {
806 // Get first event from the iterator
807 Ok(events.into_iter().next().map(|e| e.id))
808 }
809 Err(e) => Err(format!("Database query failed: {}", e)),
810 }
811 }
812
813 /// Check if any accepted event references this event (forward reference)
814 ///
815 /// For regular replaceable events (10000-19999): Checks addressable tags with kind:pubkey format
816 /// For parameterized replaceable (30000-39999): Checks addressable tags with kind:pubkey:d-identifier format
817 /// For regular events: Only checks event ID reference tags (e, E, q)
818 ///
819 /// This optimization recognizes that replaceable events are referenced by coordinate address,
820 /// while regular events are referenced by event ID.
821 async fn is_referenced_by_accepted(
822 database: &SharedDatabase,
823 event: &Event,
824 ) -> Result<bool, String> {
825 let kind_u16 = event.kind.as_u16();
826
827 // Check if this is any kind of replaceable event
828 let is_regular_replaceable = (10000..20000).contains(&kind_u16);
829 let is_parameterized_replaceable = (30000..40000).contains(&kind_u16);
830
831 if is_regular_replaceable || is_parameterized_replaceable {
832 // Build the appropriate address format based on event type
833 let address = if is_parameterized_replaceable {
834 // For parameterized replaceable: kind:pubkey:d-identifier format (2 colons)
835 let identifier = event
836 .tags
837 .iter()
838 .find_map(|tag| {
839 let tag_vec = tag.clone().to_vec();
840 if tag_vec.len() >= 2 && tag_vec[0] == "d" {
841 Some(tag_vec[1].clone())
842 } else {
843 None
844 }
845 })
846 .unwrap_or_default(); // Empty string if no 'd' tag
847 format!(
848 "{}:{}:{}",
849 event.kind.as_u16(),
850 event.pubkey.to_hex(),
851 identifier
852 )
853 } else {
854 // For regular replaceable: kind:pubkey format (1 colon)
855 format!("{}:{}", event.kind.as_u16(), event.pubkey.to_hex())
856 };
857
858 // Check addressable reference tags: a, A, q (with address format)
859 let addressable_tags = [
860 SingleLetterTag::lowercase(Alphabet::A), // 'a' - addressable event reference
861 SingleLetterTag::uppercase(Alphabet::A), // 'A' - uppercase addressable reference
862 SingleLetterTag::lowercase(Alphabet::Q), // 'q' - quote (can be address or ID)
863 ];
864
865 for tag_type in &addressable_tags {
866 let filter = Filter::new().custom_tag(*tag_type, address.clone());
867
868 match database.query(filter).await {
869 Ok(events) => {
870 if !events.is_empty() {
871 return Ok(true);
872 }
873 }
874 Err(e) => return Err(format!("Database query failed: {}", e)),
875 }
876 } 241 }
877 } else { 242 Err(e) => {
878 // For regular events, check event ID reference tags: e, E, q (with hex ID) 243 tracing::warn!(
879 let event_id_hex = event.id.to_hex(); 244 "Database query failed for {} {}, rejecting (fail-secure): {}",
880 245 event_type,
881 let event_id_tags = [ 246 event_id_str,
882 SingleLetterTag::lowercase(Alphabet::E), // 'e' - standard event reference 247 e
883 SingleLetterTag::uppercase(Alphabet::E), // 'E' - NIP-22 root event reference 248 );
884 SingleLetterTag::lowercase(Alphabet::Q), // 'q' - quote reference 249 PolicyResult::Reject(format!("Database query failed: {}", e))
885 ];
886
887 for tag_type in &event_id_tags {
888 let filter = Filter::new().custom_tag(*tag_type, event_id_hex.clone());
889
890 match database.query(filter).await {
891 Ok(events) => {
892 if !events.is_empty() {
893 return Ok(true);
894 }
895 }
896 Err(e) => return Err(format!("Database query failed: {}", e)),
897 }
898 } 250 }
899 } 251 }
900
901 Ok(false)
902 } 252 }
903} 253}
904 254
@@ -908,366 +258,12 @@ impl WritePolicy for Nip34WritePolicy {
908 event: &'a nostr_relay_builder::prelude::Event, 258 event: &'a nostr_relay_builder::prelude::Event,
909 _addr: &'a SocketAddr, 259 _addr: &'a SocketAddr,
910 ) -> BoxedFuture<'a, PolicyResult> { 260 ) -> BoxedFuture<'a, PolicyResult> {
911 let database = self.database.clone();
912 let domain = self.domain.clone();
913
914 Box::pin(async move { 261 Box::pin(async move {
915 let event_id_str = event.id.to_bech32().unwrap_or_else(|_| event.id.to_hex());
916
917 match event.kind.as_u16() { 262 match event.kind.as_u16() {
918 KIND_REPOSITORY_ANNOUNCEMENT => { 263 KIND_REPOSITORY_ANNOUNCEMENT => self.handle_announcement(event).await,
919 // First, try normal validation (announcement lists service) 264 KIND_REPOSITORY_STATE => self.handle_state(event).await,
920 match validate_announcement(event, &domain) { 265 KIND_PR | KIND_PR_UPDATE => self.handle_pr_event(event).await,
921 Ok(_) => { 266 _ => self.handle_related_event(event, "Event").await,
922 // Parse announcement to get repository details
923 match RepositoryAnnouncement::from_event(event.clone()) {
924 Ok(announcement) => {
925 // Try to create bare repository if it doesn't exist
926 if let Err(e) = self.ensure_bare_repository(&announcement) {
927 tracing::warn!(
928 "Failed to create bare repository for {}: {}",
929 event_id_str,
930 e
931 );
932 // Note: We still accept the event even if repo creation fails
933 // The git operation failure shouldn't prevent event acceptance
934 }
935
936 tracing::debug!(
937 "Accepted repository announcement: {}",
938 event_id_str
939 );
940 PolicyResult::Accept
941 }
942 Err(e) => {
943 tracing::warn!(
944 "Failed to parse repository announcement {}: {}",
945 event_id_str,
946 e
947 );
948 PolicyResult::Reject(format!(
949 "Failed to parse announcement: {}",
950 e
951 ))
952 }
953 }
954 }
955 Err(validation_err) => {
956 // Validation failed - check if this is a recursive maintainer announcement
957 // GRASP-01 Exception: Accept announcements from recursive maintainers
958 // even without listing the service, for chain discovery and GRASP-02 sync
959
960 // Try to parse the announcement to get identifier
961 match RepositoryAnnouncement::from_event(event.clone()) {
962 Ok(announcement) => {
963 // Check if author is listed as maintainer in any existing announcement
964 match Self::is_maintainer_in_any_announcement(
965 &database,
966 &announcement.identifier,
967 &event.pubkey,
968 )
969 .await
970 {
971 Ok(true) => {
972 tracing::info!(
973 "Accepted maintainer announcement {} (author {} is listed as maintainer for {})",
974 event_id_str,
975 event.pubkey.to_hex(),
976 announcement.identifier
977 );
978 // Don't create bare repository for external announcements
979 // (they point to other servers)
980 PolicyResult::Accept
981 }
982 Ok(false) => {
983 tracing::warn!(
984 "Rejected repository announcement {}: {} (not a maintainer)",
985 event_id_str,
986 validation_err
987 );
988 PolicyResult::Reject(validation_err.to_string())
989 }
990 Err(e) => {
991 tracing::warn!(
992 "Failed to check maintainer status for {}: {}",
993 event_id_str,
994 e
995 );
996 // Fail-secure: reject on database errors
997 PolicyResult::Reject(validation_err.to_string())
998 }
999 }
1000 }
1001 Err(parse_err) => {
1002 tracing::warn!(
1003 "Rejected repository announcement {}: {} (parse error: {})",
1004 event_id_str,
1005 validation_err,
1006 parse_err
1007 );
1008 PolicyResult::Reject(validation_err.to_string())
1009 }
1010 }
1011 }
1012 }
1013 }
1014 KIND_REPOSITORY_STATE => match validate_state(event) {
1015 Ok(_) => {
1016 // Parse state to get HEAD and branch info
1017 match RepositoryState::from_event(event.clone()) {
1018 Ok(state) => {
1019 // Identify owner repositories for which this is the latest authorized state
1020 match self.identify_owner_repositories(&database, &state).await {
1021 Ok(owner_repos) => {
1022 let repo_count = owner_repos.len();
1023 let mut total_aligned = 0;
1024
1025 // Align each owner repository with the authorized state
1026 for (_announcement, repo_path) in owner_repos {
1027 let result = self.align_owner_repository_with_state(
1028 &repo_path, &state,
1029 );
1030
1031 if result.refs_created > 0
1032 || result.refs_updated > 0
1033 || result.refs_deleted > 0
1034 || result.head_set
1035 {
1036 tracing::info!(
1037 "Aligned {} with state {}: created={}, updated={}, deleted={}, head_set={}",
1038 repo_path.display(),
1039 event_id_str,
1040 result.refs_created,
1041 result.refs_updated,
1042 result.refs_deleted,
1043 result.head_set
1044 );
1045 total_aligned += 1;
1046 }
1047 }
1048
1049 if repo_count > 0 {
1050 tracing::info!(
1051 "Processed state event {} for {} repo(s) ({} aligned) with identifier {}",
1052 event_id_str,
1053 repo_count,
1054 total_aligned,
1055 state.identifier
1056 );
1057 } else {
1058 tracing::debug!(
1059 "No owner repos to align for state {} - git data not available yet or not latest",
1060 event_id_str
1061 );
1062 }
1063 }
1064 Err(e) => {
1065 tracing::warn!(
1066 "Failed to identify owner repositories for state {}: {}",
1067 event_id_str,
1068 e
1069 );
1070 }
1071 }
1072
1073 tracing::debug!("Accepted repository state: {}", event_id_str);
1074 PolicyResult::Accept
1075 }
1076 Err(e) => {
1077 tracing::warn!(
1078 "Failed to parse repository state {}: {}",
1079 event_id_str,
1080 e
1081 );
1082 // Still accept the event even if we can't parse it
1083 // The validation passed, so it's structurally valid
1084 PolicyResult::Accept
1085 }
1086 }
1087 }
1088 Err(e) => {
1089 tracing::warn!("Rejected repository state {}: {}", event_id_str, e);
1090 PolicyResult::Reject(e.to_string())
1091 }
1092 },
1093 // KIND_PR (1618) and KIND_PR_UPDATE (1619): Validate refs/nostr/<event-id> refs before acceptance
1094 KIND_PR | KIND_PR_UPDATE => {
1095 // Validate refs/nostr refs for this PR event
1096 // This deletes any refs/nostr/<event-id> that points to wrong commit
1097 if let Err(e) = self.validate_pr_nostr_ref(&database, event).await {
1098 tracing::warn!(
1099 "Failed to validate refs/nostr for PR event {}: {}",
1100 event_id_str,
1101 e
1102 );
1103 // Don't reject - just log the error and proceed with normal validation
1104 }
1105
1106 // Continue with standard reference checking (same as default case)
1107 let (addressable_refs, event_refs) = Self::extract_reference_tags(event);
1108
1109 // Check 1: Does this event reference an accepted repository?
1110 match Self::find_accepted_repository(&database, &addressable_refs).await {
1111 Ok(Some(addr_ref)) => {
1112 tracing::debug!(
1113 "Accepted PR event {}: references accepted repository {}",
1114 event_id_str,
1115 addr_ref
1116 );
1117 return PolicyResult::Accept;
1118 }
1119 Ok(None) => {
1120 // No matching repositories, continue to next check
1121 }
1122 Err(e) => {
1123 tracing::warn!(
1124 "Database query failed for PR {}, rejecting (fail-secure): {}",
1125 event_id_str,
1126 e
1127 );
1128 return PolicyResult::Reject(format!("Database query failed: {}", e));
1129 }
1130 }
1131
1132 // Check 2: Does this event reference an accepted event?
1133 match Self::find_accepted_event(&database, &event_refs).await {
1134 Ok(Some(event_ref)) => {
1135 tracing::debug!(
1136 "Accepted PR event {}: references accepted event {}",
1137 event_id_str,
1138 event_ref
1139 );
1140 return PolicyResult::Accept;
1141 }
1142 Ok(None) => {
1143 // No matching events, continue to next check
1144 }
1145 Err(e) => {
1146 tracing::warn!(
1147 "Database query failed for PR {}, rejecting (fail-secure): {}",
1148 event_id_str,
1149 e
1150 );
1151 return PolicyResult::Reject(format!("Database query failed: {}", e));
1152 }
1153 }
1154
1155 // Check 3: Is this event referenced by an accepted event?
1156 match Self::is_referenced_by_accepted(&database, event).await {
1157 Ok(true) => {
1158 tracing::debug!(
1159 "Accepted PR event {}: referenced by accepted event",
1160 event_id_str
1161 );
1162 return PolicyResult::Accept;
1163 }
1164 Ok(false) => {
1165 // No forward references found, continue to rejection
1166 }
1167 Err(e) => {
1168 tracing::warn!(
1169 "Database query failed for PR {}, rejecting (fail-secure): {}",
1170 event_id_str,
1171 e
1172 );
1173 return PolicyResult::Reject(format!("Database query failed: {}", e));
1174 }
1175 }
1176
1177 // No valid references found - reject as orphan event
1178 tracing::info!(
1179 "Rejected orphan PR event {}: no references to accepted repos or events",
1180 event_id_str
1181 );
1182 PolicyResult::Reject(
1183 "PR event must reference an accepted repository or accepted event"
1184 .to_string(),
1185 )
1186 }
1187 // GRASP-01: Check if event references accepted repositories or events
1188 _ => {
1189 // Extract all reference tags from event
1190 let (addressable_refs, event_refs) = Self::extract_reference_tags(event);
1191
1192 // Check 1: Does this event reference an accepted repository? (batched)
1193 match Self::find_accepted_repository(&database, &addressable_refs).await {
1194 Ok(Some(addr_ref)) => {
1195 tracing::debug!(
1196 "Accepted event {}: references accepted repository {}",
1197 event_id_str,
1198 addr_ref
1199 );
1200 return PolicyResult::Accept;
1201 }
1202 Ok(None) => {
1203 // No matching repositories, continue to next check
1204 }
1205 Err(e) => {
1206 tracing::warn!(
1207 "Database query failed for event {}, rejecting (fail-secure): {}",
1208 event_id_str,
1209 e
1210 );
1211 return PolicyResult::Reject(format!("Database query failed: {}", e));
1212 }
1213 }
1214
1215 // Check 2: Does this event reference an accepted event? (batched, transitive)
1216 match Self::find_accepted_event(&database, &event_refs).await {
1217 Ok(Some(event_ref)) => {
1218 tracing::debug!(
1219 "Accepted event {}: references accepted event {}",
1220 event_id_str,
1221 event_ref
1222 );
1223 return PolicyResult::Accept;
1224 }
1225 Ok(None) => {
1226 // No matching events, continue to next check
1227 }
1228 Err(e) => {
1229 tracing::warn!(
1230 "Database query failed for event {}, rejecting (fail-secure): {}",
1231 event_id_str,
1232 e
1233 );
1234 return PolicyResult::Reject(format!("Database query failed: {}", e));
1235 }
1236 }
1237
1238 // Check 3: Is this event referenced by an accepted event? (forward reference)
1239 match Self::is_referenced_by_accepted(&database, event).await {
1240 Ok(true) => {
1241 tracing::debug!(
1242 "Accepted event {}: referenced by accepted event",
1243 event_id_str
1244 );
1245 return PolicyResult::Accept;
1246 }
1247 Ok(false) => {
1248 // No forward references found, continue to rejection
1249 }
1250 Err(e) => {
1251 tracing::warn!(
1252 "Database query failed for event {}, rejecting (fail-secure): {}",
1253 event_id_str,
1254 e
1255 );
1256 return PolicyResult::Reject(format!("Database query failed: {}", e));
1257 }
1258 }
1259
1260 // No valid references found - reject as orphan event
1261 tracing::info!(
1262 "Rejected orphan event {}: no references to accepted repos or events (checked {} addressable, {} event refs)",
1263 event_id_str,
1264 addressable_refs.len(),
1265 event_refs.len()
1266 );
1267 PolicyResult::Reject(
1268 "Event must reference an accepted repository or accepted event".to_string(),
1269 )
1270 }
1271 } 267 }
1272 }) 268 })
1273 } 269 }
@@ -1351,4 +347,4 @@ pub fn create_relay(config: &Config) -> Result<RelayWithDatabase> {
1351 relay: LocalRelay::new(builder), 347 relay: LocalRelay::new(builder),
1352 database, 348 database,
1353 }) 349 })
1354} 350} \ No newline at end of file
diff --git a/src/nostr/mod.rs b/src/nostr/mod.rs
index 2bf0346..a2820fc 100644
--- a/src/nostr/mod.rs
+++ b/src/nostr/mod.rs
@@ -1,2 +1,6 @@
1pub mod builder; 1pub mod builder;
2pub mod events; 2pub mod events;
3pub mod policy;
4
5/// Re-export SharedDatabase for use by policy modules
6pub use builder::SharedDatabase;
diff --git a/src/nostr/policy/announcement.rs b/src/nostr/policy/announcement.rs
new file mode 100644
index 0000000..8d30baf
--- /dev/null
+++ b/src/nostr/policy/announcement.rs
@@ -0,0 +1,157 @@
1/// Announcement Policy - Repository announcement validation
2///
3/// Handles validation of NIP-34 repository announcements (kind 30617)
4/// according to GRASP-01 specification.
5use nostr_relay_builder::prelude::{Alphabet, Event, Filter, Kind, PublicKey, SingleLetterTag};
6
7use super::PolicyContext;
8use crate::nostr::events::{
9 validate_announcement, RepositoryAnnouncement, KIND_REPOSITORY_ANNOUNCEMENT,
10};
11
12/// Result of announcement policy evaluation
13#[derive(Debug)]
14pub enum AnnouncementResult {
15 /// Accept: Event passes validation
16 Accept,
17 /// Accept as maintainer: Event accepted via maintainer exception
18 AcceptMaintainer,
19 /// Reject: Event fails validation with reason
20 Reject(String),
21}
22
23/// Policy for validating repository announcements
24#[derive(Clone)]
25pub struct AnnouncementPolicy {
26 ctx: PolicyContext,
27}
28
29impl AnnouncementPolicy {
30 pub fn new(ctx: PolicyContext) -> Self {
31 Self { ctx }
32 }
33
34 /// Validate a repository announcement event
35 ///
36 /// Returns `Accept` if the announcement lists the service properly,
37 /// `AcceptMaintainer` if accepted via maintainer exception,
38 /// or `Reject` with reason.
39 pub async fn validate(&self, event: &Event) -> AnnouncementResult {
40 // First, try normal validation (announcement lists service)
41 match validate_announcement(event, &self.ctx.domain) {
42 Ok(_) => AnnouncementResult::Accept,
43 Err(validation_err) => {
44 // Validation failed - check if this is a recursive maintainer announcement
45 // GRASP-01 Exception: Accept announcements from recursive maintainers
46 // even without listing the service, for chain discovery and GRASP-02 sync
47
48 // Try to parse the announcement to get identifier
49 match RepositoryAnnouncement::from_event(event.clone()) {
50 Ok(announcement) => {
51 // Check if author is listed as maintainer in any existing announcement
52 match self
53 .is_maintainer_in_any_announcement(
54 &announcement.identifier,
55 &event.pubkey,
56 )
57 .await
58 {
59 Ok(true) => AnnouncementResult::AcceptMaintainer,
60 Ok(false) => AnnouncementResult::Reject(validation_err.to_string()),
61 Err(_) => {
62 // Fail-secure: reject on database errors
63 AnnouncementResult::Reject(validation_err.to_string())
64 }
65 }
66 }
67 Err(_) => AnnouncementResult::Reject(validation_err.to_string()),
68 }
69 }
70 }
71 }
72
73 /// Create a bare git repository if it doesn't exist
74 /// Path format: <git_data_path>/<npub>/<identifier>.git
75 pub fn ensure_bare_repository(&self, announcement: &RepositoryAnnouncement) -> Result<(), String> {
76 let repo_path = self.ctx.git_data_path.join(announcement.repo_path());
77
78 // Check if repository already exists
79 if repo_path.exists() {
80 tracing::debug!("Repository already exists at {}", repo_path.display());
81 return Ok(());
82 }
83
84 // Create parent directory (npub directory)
85 let parent = repo_path
86 .parent()
87 .ok_or_else(|| format!("Invalid repository path: {}", repo_path.display()))?;
88
89 std::fs::create_dir_all(parent)
90 .map_err(|e| format!("Failed to create directory {}: {}", parent.display(), e))?;
91
92 // Initialize bare repository using git command
93 let output = std::process::Command::new("git")
94 .args(["init", "--bare", repo_path.to_str().unwrap()])
95 .output()
96 .map_err(|e| format!("Failed to execute git init: {}", e))?;
97
98 if !output.status.success() {
99 let stderr = String::from_utf8_lossy(&output.stderr);
100 return Err(format!("git init failed: {}", stderr));
101 }
102
103 tracing::info!("Created bare repository at {}", repo_path.display());
104 Ok(())
105 }
106
107 /// Check if a pubkey is listed as a maintainer in any announcement for this identifier
108 ///
109 /// A pubkey is considered a maintainer if:
110 /// 1. They are the owner (pubkey) of an accepted announcement with this identifier, OR
111 /// 2. They are listed in the maintainers tag of ANY announcement with this identifier
112 ///
113 /// This enables accepting announcements from maintainers even when they don't list
114 /// this GRASP server, for maintainer chain discovery and GRASP-02 sync.
115 async fn is_maintainer_in_any_announcement(
116 &self,
117 identifier: &str,
118 author: &PublicKey,
119 ) -> Result<bool, String> {
120 // Query all announcements with this identifier that are already in the database
121 let filter = Filter::new()
122 .kind(Kind::from(KIND_REPOSITORY_ANNOUNCEMENT))
123 .custom_tag(
124 SingleLetterTag::lowercase(Alphabet::D),
125 identifier.to_string(),
126 );
127
128 let announcements: Vec<Event> = match self.ctx.database.query(filter).await {
129 Ok(events) => events.into_iter().collect(),
130 Err(e) => return Err(format!("Database query failed: {}", e)),
131 };
132
133 if announcements.is_empty() {
134 // No existing announcements for this identifier - author cannot be a maintainer
135 return Ok(false);
136 }
137
138 let author_hex = author.to_hex();
139
140 // Check each announcement to see if author is listed as a maintainer
141 for event in &announcements {
142 // Check if author is the owner of this announcement
143 if event.pubkey == *author {
144 return Ok(true);
145 }
146
147 // Check if author is listed in the maintainers tag
148 if let Ok(announcement) = RepositoryAnnouncement::from_event(event.clone()) {
149 if announcement.maintainers.contains(&author_hex) {
150 return Ok(true);
151 }
152 }
153 }
154
155 Ok(false)
156 }
157} \ No newline at end of file
diff --git a/src/nostr/policy/mod.rs b/src/nostr/policy/mod.rs
new file mode 100644
index 0000000..6d67394
--- /dev/null
+++ b/src/nostr/policy/mod.rs
@@ -0,0 +1,41 @@
1/// Policy module for NIP-34 write policies
2///
3/// This module splits the large Nip34WritePolicy into focused sub-policies:
4/// - `AnnouncementPolicy` - Repository announcement validation
5/// - `StatePolicy` - State event validation + ref alignment
6/// - `PrEventPolicy` - PR/PR Update validation
7/// - `RelatedEventPolicy` - Forward/backward reference checking
8
9mod announcement;
10mod pr_event;
11mod related;
12mod state;
13
14pub use announcement::{AnnouncementPolicy, AnnouncementResult};
15pub use pr_event::PrEventPolicy;
16pub use related::{ReferenceResult, RelatedEventPolicy};
17pub use state::{AlignmentResult, StatePolicy, StateResult};
18
19use super::SharedDatabase;
20
21/// Shared context for all sub-policies
22#[derive(Clone)]
23pub struct PolicyContext {
24 pub domain: String,
25 pub database: SharedDatabase,
26 pub git_data_path: std::path::PathBuf,
27}
28
29impl PolicyContext {
30 pub fn new(
31 domain: impl Into<String>,
32 database: SharedDatabase,
33 git_data_path: impl Into<std::path::PathBuf>,
34 ) -> Self {
35 Self {
36 domain: domain.into(),
37 database,
38 git_data_path: git_data_path.into(),
39 }
40 }
41} \ No newline at end of file
diff --git a/src/nostr/policy/pr_event.rs b/src/nostr/policy/pr_event.rs
new file mode 100644
index 0000000..fee9a2a
--- /dev/null
+++ b/src/nostr/policy/pr_event.rs
@@ -0,0 +1,198 @@
1/// PR Event Policy - PR/PR Update validation
2///
3/// Handles validation of NIP-34 PR events (kind 1618) and PR Update events (kind 1619)
4/// according to GRASP-01 specification.
5use nostr_relay_builder::prelude::{Alphabet, Event, Filter, Kind, PublicKey, SingleLetterTag};
6
7use super::PolicyContext;
8use crate::git;
9use crate::nostr::events::{RepositoryAnnouncement, KIND_REPOSITORY_ANNOUNCEMENT};
10
11/// Policy for validating PR and PR Update events
12#[derive(Clone)]
13pub struct PrEventPolicy {
14 ctx: PolicyContext,
15}
16
17impl PrEventPolicy {
18 pub fn new(ctx: PolicyContext) -> Self {
19 Self { ctx }
20 }
21
22 /// Validate refs/nostr/<event-id> ref against a PR or PR Update event's `c` tag
23 ///
24 /// When a PR event (kind 1618) or PR Update event (kind 1619) is received,
25 /// this checks if a corresponding refs/nostr/<event-id> ref exists in the
26 /// repository and validates that it points to the correct commit (from the
27 /// `c` tag). If the ref exists but points to a different commit, the ref is
28 /// deleted.
29 ///
30 /// PR and PR Update events can have multiple `a` tags to update multiple
31 /// repositories simultaneously.
32 ///
33 /// This is part of GRASP-01 compliance: ensuring refs/nostr refs are consistent
34 /// with their corresponding events.
35 ///
36 /// # Returns
37 /// Ok(Some(n)) if n refs were deleted, Ok(None) if no action taken, Err on failure
38 pub async fn validate_nostr_ref(&self, event: &Event) -> Result<Option<usize>, String> {
39 let event_id = event.id.to_hex();
40
41 // Extract the `c` tag (commit hash) from the PR event
42 let expected_commit = event.tags.iter().find_map(|tag| {
43 let tag_vec = tag.clone().to_vec();
44 if tag_vec.len() >= 2 && tag_vec[0] == "c" {
45 Some(tag_vec[1].clone())
46 } else {
47 None
48 }
49 });
50
51 let expected_commit = match expected_commit {
52 Some(c) => c,
53 None => {
54 tracing::debug!(
55 "PR event {} has no 'c' tag, skipping ref validation",
56 event_id
57 );
58 return Ok(None);
59 }
60 };
61
62 // Extract ALL `a` tags (repository references) from the PR event
63 // PR events can reference multiple repositories
64 // Format: 30617:<pubkey>:<identifier>
65 let repo_refs: Vec<String> = event
66 .tags
67 .iter()
68 .filter_map(|tag| {
69 let tag_vec = tag.clone().to_vec();
70 if tag_vec.len() >= 2 && tag_vec[0] == "a" && tag_vec[1].starts_with("30617:") {
71 Some(tag_vec[1].clone())
72 } else {
73 None
74 }
75 })
76 .collect();
77
78 if repo_refs.is_empty() {
79 tracing::debug!(
80 "PR event {} has no repo 'a' tags, skipping ref validation",
81 event_id
82 );
83 return Ok(None);
84 }
85
86 let mut deleted_count = 0;
87
88 // Process each repository reference
89 for repo_ref in repo_refs {
90 // Parse the repo reference: 30617:<pubkey>:<identifier>
91 let parts: Vec<&str> = repo_ref.split(':').collect();
92 if parts.len() < 3 {
93 tracing::debug!(
94 "PR event {} has invalid 'a' tag format: {}",
95 event_id,
96 repo_ref
97 );
98 continue;
99 }
100
101 let repo_pubkey = match PublicKey::from_hex(parts[1]) {
102 Ok(pk) => pk,
103 Err(_) => {
104 tracing::debug!(
105 "PR event {} has invalid pubkey in 'a' tag: {}",
106 event_id,
107 parts[1]
108 );
109 continue;
110 }
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 tracing::debug!(
137 "No repository announcement found for PR event {} (repo {}:{})",
138 event_id,
139 repo_pubkey.to_hex(),
140 identifier
141 );
142 continue;
143 }
144
145 // Process each matching announcement (there could be multiple)
146 for announcement_event in announcements {
147 let announcement = match RepositoryAnnouncement::from_event(announcement_event) {
148 Ok(a) => a,
149 Err(e) => {
150 tracing::warn!(
151 "Failed to parse announcement for PR {} validation: {}",
152 event_id,
153 e
154 );
155 continue;
156 }
157 };
158
159 // Build repository path
160 let repo_path = self.ctx.git_data_path.join(announcement.repo_path());
161
162 // Validate the ref
163 match git::validate_nostr_ref(&repo_path, &event_id, &expected_commit) {
164 Ok(true) => {
165 tracing::info!(
166 "Deleted mismatched refs/nostr/{} in {} (expected commit {})",
167 event_id,
168 repo_path.display(),
169 expected_commit
170 );
171 deleted_count += 1;
172 }
173 Ok(false) => {
174 tracing::debug!(
175 "refs/nostr/{} in {} is valid or doesn't exist",
176 event_id,
177 repo_path.display()
178 );
179 }
180 Err(e) => {
181 tracing::warn!(
182 "Failed to validate refs/nostr/{} in {}: {}",
183 event_id,
184 repo_path.display(),
185 e
186 );
187 }
188 }
189 }
190 }
191
192 if deleted_count > 0 {
193 Ok(Some(deleted_count))
194 } else {
195 Ok(None)
196 }
197 }
198} \ No newline at end of file
diff --git a/src/nostr/policy/related.rs b/src/nostr/policy/related.rs
new file mode 100644
index 0000000..1937ca7
--- /dev/null
+++ b/src/nostr/policy/related.rs
@@ -0,0 +1,276 @@
1/// Related Event Policy - Forward/backward reference checking
2///
3/// Handles validation of events that reference accepted repositories or events
4/// (backward references) and events that are referenced by accepted events
5/// (forward references).
6use nostr_relay_builder::prelude::{
7 Alphabet, Event, EventId, Filter, Kind, PublicKey, SingleLetterTag,
8};
9
10use super::PolicyContext;
11
12/// Result of reference checking
13#[derive(Debug)]
14pub enum ReferenceResult {
15 /// Event references an accepted repository (addressable ref found)
16 ReferencesRepository(String),
17 /// Event references an accepted event (event ID found)
18 ReferencesEvent(EventId),
19 /// Event is referenced by an accepted event (forward reference)
20 ReferencedByAccepted,
21 /// No valid references found - event is an orphan
22 Orphan,
23}
24
25/// Policy for checking event references (backward and forward)
26#[derive(Clone)]
27pub struct RelatedEventPolicy {
28 ctx: PolicyContext,
29}
30
31impl RelatedEventPolicy {
32 pub fn new(ctx: PolicyContext) -> Self {
33 Self { ctx }
34 }
35
36 /// Check all reference types for an event
37 ///
38 /// Returns the first valid reference found, or `Orphan` if none found.
39 pub async fn check_references(&self, event: &Event) -> Result<ReferenceResult, String> {
40 // Extract all reference tags from event
41 let (addressable_refs, event_refs) = Self::extract_reference_tags(event);
42
43 // Check 1: Does this event reference an accepted repository?
44 if let Some(addr_ref) = self.find_accepted_repository(&addressable_refs).await? {
45 return Ok(ReferenceResult::ReferencesRepository(addr_ref));
46 }
47
48 // Check 2: Does this event reference an accepted event?
49 if let Some(event_ref) = self.find_accepted_event(&event_refs).await? {
50 return Ok(ReferenceResult::ReferencesEvent(event_ref));
51 }
52
53 // Check 3: Is this event referenced by an accepted event?
54 if self.is_referenced_by_accepted(event).await? {
55 return Ok(ReferenceResult::ReferencedByAccepted);
56 }
57
58 // No valid references found
59 Ok(ReferenceResult::Orphan)
60 }
61
62 /// Extract all reference tags from an event (a, A, q, e, E)
63 /// Returns (addressable_refs, event_refs)
64 pub fn extract_reference_tags(event: &Event) -> (Vec<String>, Vec<EventId>) {
65 let mut addressable_refs = Vec::new();
66 let mut event_refs = Vec::new();
67
68 for tag in event.tags.iter() {
69 let tag_vec = tag.clone().to_vec();
70 if tag_vec.is_empty() {
71 continue;
72 }
73
74 match tag_vec[0].as_str() {
75 // Addressable event references (a, A, q with kind:pubkey:identifier format)
76 "a" | "A" | "q" if tag_vec.len() > 1 && tag_vec[1].contains(':') => {
77 addressable_refs.push(tag_vec[1].clone());
78 }
79 // Event ID references (e, E, q with event ID format)
80 "e" | "E" if tag_vec.len() > 1 => {
81 if let Ok(event_id) = EventId::from_hex(&tag_vec[1]) {
82 event_refs.push(event_id);
83 }
84 }
85 "q" if tag_vec.len() > 1 && !tag_vec[1].contains(':') => {
86 if let Ok(event_id) = EventId::from_hex(&tag_vec[1]) {
87 event_refs.push(event_id);
88 }
89 }
90 _ => {}
91 }
92 }
93
94 (addressable_refs, event_refs)
95 }
96
97 /// Check if any addressable events (repositories) exist in database
98 /// Returns the first matching addressable reference found, or None if none match
99 async fn find_accepted_repository(
100 &self,
101 addressables: &[String],
102 ) -> Result<Option<String>, String> {
103 if addressables.is_empty() {
104 return Ok(None);
105 }
106
107 // Parse all addressable references
108 let mut parsed_refs = Vec::new();
109 for addr in addressables {
110 let parts: Vec<&str> = addr.split(':').collect();
111 if parts.len() < 3 {
112 continue; // Skip invalid format
113 }
114
115 let kind = match parts[0].parse::<u16>() {
116 Ok(k) => k,
117 Err(_) => continue, // Skip invalid kind
118 };
119 let pubkey = match PublicKey::from_hex(parts[1]) {
120 Ok(pk) => pk,
121 Err(_) => continue, // Skip invalid pubkey
122 };
123 let identifier = parts[2].to_string();
124
125 parsed_refs.push((addr.clone(), kind, pubkey, identifier));
126 }
127
128 if parsed_refs.is_empty() {
129 return Ok(None);
130 }
131
132 // Group by kind to reduce queries
133 use std::collections::HashMap;
134 let mut by_kind: HashMap<u16, Vec<_>> = HashMap::new();
135 for (addr, kind, pubkey, identifier) in parsed_refs {
136 by_kind
137 .entry(kind)
138 .or_default()
139 .push((addr, pubkey, identifier));
140 }
141
142 // Query each kind group
143 for (kind, refs) in by_kind {
144 let authors: Vec<PublicKey> = refs.iter().map(|(_, pk, _)| *pk).collect();
145
146 let filter = Filter::new().kind(Kind::from(kind)).authors(authors);
147
148 match self.ctx.database.query(filter).await {
149 Ok(events) => {
150 // Check if any event matches our identifier requirements
151 for event in events {
152 for (addr, _pubkey, identifier) in &refs {
153 // Match identifier tag
154 if event.tags.iter().any(|tag| {
155 let tag_vec = tag.clone().to_vec();
156 tag_vec.len() >= 2 && tag_vec[0] == "d" && tag_vec[1] == *identifier
157 }) {
158 return Ok(Some(addr.clone()));
159 }
160 }
161 }
162 }
163 Err(e) => return Err(format!("Database query failed: {}", e)),
164 }
165 }
166
167 Ok(None)
168 }
169
170 /// Check if any events exist in database
171 /// Returns the first matching event ID found, or None if none match
172 async fn find_accepted_event(
173 &self,
174 event_ids: &[EventId],
175 ) -> Result<Option<EventId>, String> {
176 if event_ids.is_empty() {
177 return Ok(None);
178 }
179
180 // Single query for all event IDs
181 let filter = Filter::new().ids(event_ids.iter().copied());
182
183 match self.ctx.database.query(filter).await {
184 Ok(events) => {
185 // Get first event from the iterator
186 Ok(events.into_iter().next().map(|e| e.id))
187 }
188 Err(e) => Err(format!("Database query failed: {}", e)),
189 }
190 }
191
192 /// Check if any accepted event references this event (forward reference)
193 ///
194 /// For regular replaceable events (10000-19999): Checks addressable tags with kind:pubkey format
195 /// For parameterized replaceable (30000-39999): Checks addressable tags with kind:pubkey:d-identifier format
196 /// For regular events: Only checks event ID reference tags (e, E, q)
197 async fn is_referenced_by_accepted(&self, event: &Event) -> Result<bool, String> {
198 let kind_u16 = event.kind.as_u16();
199
200 // Check if this is any kind of replaceable event
201 let is_regular_replaceable = (10000..20000).contains(&kind_u16);
202 let is_parameterized_replaceable = (30000..40000).contains(&kind_u16);
203
204 if is_regular_replaceable || is_parameterized_replaceable {
205 // Build the appropriate address format based on event type
206 let address = if is_parameterized_replaceable {
207 // For parameterized replaceable: kind:pubkey:d-identifier format (2 colons)
208 let identifier = event
209 .tags
210 .iter()
211 .find_map(|tag| {
212 let tag_vec = tag.clone().to_vec();
213 if tag_vec.len() >= 2 && tag_vec[0] == "d" {
214 Some(tag_vec[1].clone())
215 } else {
216 None
217 }
218 })
219 .unwrap_or_default(); // Empty string if no 'd' tag
220 format!(
221 "{}:{}:{}",
222 event.kind.as_u16(),
223 event.pubkey.to_hex(),
224 identifier
225 )
226 } else {
227 // For regular replaceable: kind:pubkey format (1 colon)
228 format!("{}:{}", event.kind.as_u16(), event.pubkey.to_hex())
229 };
230
231 // Check addressable reference tags: a, A, q (with address format)
232 let addressable_tags = [
233 SingleLetterTag::lowercase(Alphabet::A), // 'a' - addressable event reference
234 SingleLetterTag::uppercase(Alphabet::A), // 'A' - uppercase addressable reference
235 SingleLetterTag::lowercase(Alphabet::Q), // 'q' - quote (can be address or ID)
236 ];
237
238 for tag_type in &addressable_tags {
239 let filter = Filter::new().custom_tag(*tag_type, address.clone());
240
241 match self.ctx.database.query(filter).await {
242 Ok(events) => {
243 if !events.is_empty() {
244 return Ok(true);
245 }
246 }
247 Err(e) => return Err(format!("Database query failed: {}", e)),
248 }
249 }
250 } else {
251 // For regular events, check event ID reference tags: e, E, q (with hex ID)
252 let event_id_hex = event.id.to_hex();
253
254 let event_id_tags = [
255 SingleLetterTag::lowercase(Alphabet::E), // 'e' - standard event reference
256 SingleLetterTag::uppercase(Alphabet::E), // 'E' - NIP-22 root event reference
257 SingleLetterTag::lowercase(Alphabet::Q), // 'q' - quote reference
258 ];
259
260 for tag_type in &event_id_tags {
261 let filter = Filter::new().custom_tag(*tag_type, event_id_hex.clone());
262
263 match self.ctx.database.query(filter).await {
264 Ok(events) => {
265 if !events.is_empty() {
266 return Ok(true);
267 }
268 }
269 Err(e) => return Err(format!("Database query failed: {}", e)),
270 }
271 }
272 }
273
274 Ok(false)
275 }
276} \ No newline at end of file
diff --git a/src/nostr/policy/state.rs b/src/nostr/policy/state.rs
new file mode 100644
index 0000000..5692bd8
--- /dev/null
+++ b/src/nostr/policy/state.rs
@@ -0,0 +1,419 @@
1/// State Policy - State event validation + ref alignment
2///
3/// Handles validation of NIP-34 repository state events (kind 30618)
4/// and aligns git refs with authorized state according to GRASP-01.
5use nostr_relay_builder::prelude::{Alphabet, Event, Filter, Kind, PublicKey, SingleLetterTag};
6
7use super::PolicyContext;
8use crate::git;
9use crate::nostr::events::{
10 validate_state, RepositoryAnnouncement, RepositoryState, KIND_REPOSITORY_ANNOUNCEMENT,
11 KIND_REPOSITORY_STATE,
12};
13
14/// Result of aligning a repository with authorized state
15#[derive(Debug, Default)]
16pub struct AlignmentResult {
17 /// Number of refs created
18 pub refs_created: usize,
19 /// Number of refs updated
20 pub refs_updated: usize,
21 /// Number of refs deleted
22 pub refs_deleted: usize,
23 /// Whether HEAD was set
24 pub head_set: bool,
25}
26
27impl AlignmentResult {
28 pub fn has_changes(&self) -> bool {
29 self.refs_created > 0 || self.refs_updated > 0 || self.refs_deleted > 0 || self.head_set
30 }
31}
32
33/// Result of state policy evaluation
34#[derive(Debug)]
35pub enum StateResult {
36 /// Accept: Event passes validation
37 Accept,
38 /// Reject: Event fails validation with reason
39 Reject(String),
40}
41
42/// Policy for validating repository state events and aligning refs
43#[derive(Clone)]
44pub struct StatePolicy {
45 ctx: PolicyContext,
46}
47
48impl StatePolicy {
49 pub fn new(ctx: PolicyContext) -> Self {
50 Self { ctx }
51 }
52
53 /// Validate a repository state event
54 pub fn validate(&self, event: &Event) -> StateResult {
55 match validate_state(event) {
56 Ok(_) => StateResult::Accept,
57 Err(e) => StateResult::Reject(e.to_string()),
58 }
59 }
60
61 /// Process a state event: validate and align owner repositories
62 ///
63 /// Returns the number of repositories aligned if successful.
64 pub async fn process_state_event(&self, event: &Event) -> Result<usize, String> {
65 // Parse state to get HEAD and branch info
66 let state = RepositoryState::from_event(event.clone())
67 .map_err(|e| format!("Failed to parse state: {}", e))?;
68
69 // Identify owner repositories for which this is the latest authorized state
70 let owner_repos = self.identify_owner_repositories(&state).await?;
71 let repo_count = owner_repos.len();
72 let mut total_aligned = 0;
73
74 // Align each owner repository with the authorized state
75 for (_announcement, repo_path) in owner_repos {
76 let result = self.align_repository_with_state(&repo_path, &state);
77
78 if result.has_changes() {
79 tracing::info!(
80 "Aligned {} with state: created={}, updated={}, deleted={}, head_set={}",
81 repo_path.display(),
82 result.refs_created,
83 result.refs_updated,
84 result.refs_deleted,
85 result.head_set
86 );
87 total_aligned += 1;
88 }
89 }
90
91 if repo_count > 0 {
92 tracing::info!(
93 "Processed state event for {} repo(s) ({} aligned) with identifier {}",
94 repo_count,
95 total_aligned,
96 state.identifier
97 );
98 } else {
99 tracing::debug!(
100 "No owner repos to align for state - git data not available yet or not latest"
101 );
102 }
103
104 Ok(total_aligned)
105 }
106
107 /// Check if this state event is the latest for its identifier among authorized authors
108 ///
109 /// A state is considered "latest" if no other state event in the database
110 /// from an authorized author has a newer timestamp.
111 async fn is_latest_state_for_identifier(
112 &self,
113 state: &RepositoryState,
114 authorized_pubkeys: &[PublicKey],
115 ) -> Result<bool, String> {
116 let filter = Filter::new()
117 .kind(Kind::from(KIND_REPOSITORY_STATE))
118 .custom_tag(
119 SingleLetterTag::lowercase(Alphabet::D),
120 state.identifier.clone(),
121 );
122
123 match self.ctx.database.query(filter).await {
124 Ok(events) => {
125 for event in events {
126 // Skip comparing to self (same event ID)
127 if event.id == state.event.id {
128 continue;
129 }
130 // Only consider events from authorized authors for this announcement
131 if !authorized_pubkeys.contains(&event.pubkey) {
132 continue;
133 }
134 // If any existing event from an authorized author is newer, this is not the latest
135 if event.created_at > state.event.created_at {
136 tracing::debug!(
137 "State {} is not latest: found newer state {} from {} (ts {} > {})",
138 state.event.id.to_hex(),
139 event.id.to_hex(),
140 event.pubkey.to_hex(),
141 event.created_at.as_secs(),
142 state.event.created_at.as_secs()
143 );
144 return Ok(false);
145 }
146 }
147 Ok(true)
148 }
149 Err(e) => Err(format!("Database query failed: {}", e)),
150 }
151 }
152
153 /// Find all repository announcements where the given pubkey is authorized
154 async fn find_authorized_announcements(
155 &self,
156 identifier: &str,
157 state_author: &PublicKey,
158 ) -> Result<Vec<RepositoryAnnouncement>, String> {
159 let filter = Filter::new()
160 .kind(Kind::from(KIND_REPOSITORY_ANNOUNCEMENT))
161 .custom_tag(
162 SingleLetterTag::lowercase(Alphabet::D),
163 identifier.to_string(),
164 );
165
166 match self.ctx.database.query(filter).await {
167 Ok(events) => {
168 let mut authorized = Vec::new();
169 let state_author_hex = state_author.to_hex();
170
171 for event in events {
172 if let Ok(announcement) = RepositoryAnnouncement::from_event(event.clone()) {
173 // Check if state author is authorized for this announcement
174 let is_owner = event.pubkey == *state_author;
175 let is_maintainer = announcement.maintainers.contains(&state_author_hex);
176
177 if is_owner || is_maintainer {
178 tracing::debug!(
179 "Found authorized announcement for {}: owner={}, maintainer={}",
180 identifier,
181 if is_owner {
182 event.pubkey.to_hex()
183 } else {
184 "n/a".to_string()
185 },
186 is_maintainer
187 );
188 authorized.push(announcement);
189 }
190 }
191 }
192 Ok(authorized)
193 }
194 Err(e) => Err(format!("Database query failed: {}", e)),
195 }
196 }
197
198 /// Identify all owner repositories for which this state event is the latest authorized state
199 async fn identify_owner_repositories(
200 &self,
201 state: &RepositoryState,
202 ) -> Result<Vec<(RepositoryAnnouncement, std::path::PathBuf)>, String> {
203 // Find all announcements where state author is authorized
204 let announcements = self
205 .find_authorized_announcements(&state.identifier, &state.event.pubkey)
206 .await?;
207
208 if announcements.is_empty() {
209 tracing::debug!(
210 "No authorized announcements found for state {} by {}",
211 state.identifier,
212 state.event.pubkey.to_hex()
213 );
214 return Ok(Vec::new());
215 }
216
217 let mut owner_repos = Vec::new();
218
219 for announcement in announcements {
220 // Build the list of authorized pubkeys for this specific announcement
221 let mut authorized_pubkeys = vec![announcement.event.pubkey];
222 for maintainer_hex in &announcement.maintainers {
223 if let Ok(pk) = PublicKey::from_hex(maintainer_hex) {
224 authorized_pubkeys.push(pk);
225 }
226 }
227
228 // Check if this is the latest state event for THIS announcement's context
229 if !self
230 .is_latest_state_for_identifier(state, &authorized_pubkeys)
231 .await?
232 {
233 tracing::debug!(
234 "Skipping {} in {}'s repo - not the latest state event for this context",
235 state.identifier,
236 announcement.event.pubkey.to_hex()
237 );
238 continue;
239 }
240
241 // Build repository path: <git_data_path>/<owner_npub>/<identifier>.git
242 let repo_path = self.ctx.git_data_path.join(announcement.repo_path().clone());
243 owner_repos.push((announcement, repo_path));
244 }
245
246 Ok(owner_repos)
247 }
248
249 /// Align a repository's refs with the authorized state
250 ///
251 /// This function:
252 /// 1. Deletes refs that are in the repo but not in the state (for refs/heads/ and refs/tags/)
253 /// 2. Updates refs that exist in state if we have the commit
254 /// 3. Sets HEAD if the HEAD branch's commit is available
255 pub fn align_repository_with_state(
256 &self,
257 repo_path: &std::path::Path,
258 state: &RepositoryState,
259 ) -> AlignmentResult {
260 let mut result = AlignmentResult::default();
261
262 // Check if repository exists
263 if !repo_path.exists() {
264 tracing::debug!(
265 "Repository not found at {}, cannot align with state",
266 repo_path.display()
267 );
268 return result;
269 }
270
271 // Get current refs from the repository
272 let current_refs = match git::list_refs(repo_path) {
273 Ok(refs) => refs,
274 Err(e) => {
275 tracing::warn!("Failed to list refs in {}: {}", repo_path.display(), e);
276 return result;
277 }
278 };
279
280 // Build expected refs from state
281 let mut expected_refs: std::collections::HashMap<String, String> =
282 std::collections::HashMap::new();
283
284 for branch in &state.branches {
285 let ref_name = format!("refs/heads/{}", branch.name);
286 expected_refs.insert(ref_name, branch.commit.clone());
287 }
288
289 for tag in &state.tags {
290 let ref_name = format!("refs/tags/{}", tag.name);
291 expected_refs.insert(ref_name, tag.commit.clone());
292 }
293
294 // Process current refs: update or delete as needed
295 for (ref_name, current_commit) in &current_refs {
296 // Only process refs/heads/ and refs/tags/
297 if !ref_name.starts_with("refs/heads/") && !ref_name.starts_with("refs/tags/") {
298 continue;
299 }
300
301 match expected_refs.get(ref_name) {
302 Some(expected_commit) => {
303 // Ref should exist - check if commit matches
304 if current_commit != expected_commit {
305 // Check if we have the expected commit
306 if git::commit_exists(repo_path, expected_commit) {
307 // Update the ref
308 match git::update_ref(repo_path, ref_name, expected_commit) {
309 Ok(()) => {
310 tracing::info!(
311 "Updated {} from {} to {} in {}",
312 ref_name,
313 current_commit,
314 expected_commit,
315 repo_path.display()
316 );
317 result.refs_updated += 1;
318 }
319 Err(e) => {
320 tracing::warn!(
321 "Failed to update {} in {}: {}",
322 ref_name,
323 repo_path.display(),
324 e
325 );
326 }
327 }
328 } else {
329 tracing::debug!(
330 "Commit {} not available for {} in {}",
331 expected_commit,
332 ref_name,
333 repo_path.display()
334 );
335 }
336 }
337 }
338 None => {
339 // Ref should not exist - delete it
340 match git::delete_ref(repo_path, ref_name) {
341 Ok(()) => {
342 tracing::info!(
343 "Deleted {} (not in state) from {}",
344 ref_name,
345 repo_path.display()
346 );
347 result.refs_deleted += 1;
348 }
349 Err(e) => {
350 tracing::warn!(
351 "Failed to delete {} from {}: {}",
352 ref_name,
353 repo_path.display(),
354 e
355 );
356 }
357 }
358 }
359 }
360 }
361
362 // Add refs that exist in state but not in repo (if we have the commit)
363 for (ref_name, expected_commit) in &expected_refs {
364 let exists = current_refs.iter().any(|(r, _)| r == ref_name);
365 if !exists && git::commit_exists(repo_path, expected_commit) {
366 match git::update_ref(repo_path, ref_name, expected_commit) {
367 Ok(()) => {
368 tracing::info!(
369 "Created {} at {} in {}",
370 ref_name,
371 expected_commit,
372 repo_path.display()
373 );
374 result.refs_created += 1;
375 }
376 Err(e) => {
377 tracing::warn!(
378 "Failed to create {} in {}: {}",
379 ref_name,
380 repo_path.display(),
381 e
382 );
383 }
384 }
385 }
386 }
387
388 // Set HEAD if specified in state
389 if let Some(head_ref) = &state.head {
390 if let Some(branch_name) = state.get_head_branch() {
391 if let Some(head_commit) = state.get_branch_commit(branch_name) {
392 match git::try_set_head_if_available(repo_path, head_ref, head_commit) {
393 Ok(true) => {
394 tracing::info!(
395 "Set HEAD to {} in {} (from state by {})",
396 head_ref,
397 repo_path.display(),
398 state.event.pubkey.to_hex()
399 );
400 result.head_set = true;
401 }
402 Ok(false) => {
403 tracing::debug!(
404 "HEAD commit {} not available yet in {}",
405 head_commit,
406 repo_path.display()
407 );
408 }
409 Err(e) => {
410 tracing::warn!("Failed to set HEAD in {}: {}", repo_path.display(), e);
411 }
412 }
413 }
414 }
415 }
416
417 result
418 }
419} \ No newline at end of file