From 819866330c7e2f535a155d1d7efaf2e12dc15dc2 Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Thu, 4 Dec 2025 15:42:00 +0000 Subject: 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 --- src/nostr/builder.rs | 1334 +++++++------------------------------------------- 1 file changed, 165 insertions(+), 1169 deletions(-) (limited to 'src/nostr/builder.rs') 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 @@ /// Nostr Relay Builder Configuration /// /// This module integrates nostr-relay-builder with NIP-34 validation logic -/// preserved from the original implementation. +/// using modular sub-policies for each event type. use std::net::SocketAddr; -use std::path::{Path, PathBuf}; +use std::path::Path; use std::sync::Arc; use nostr::nips::nip19::ToBech32; -use nostr::prelude::{Alphabet, SingleLetterTag}; -use nostr::{EventId, Filter, Kind, PublicKey}; use nostr_lmdb::NostrLMDB; use nostr_relay_builder::prelude::*; use crate::config::{Config, DatabaseBackend}; -use crate::git; use crate::nostr::events::{ - validate_announcement, validate_state, RepositoryAnnouncement, RepositoryState, KIND_PR, - KIND_PR_UPDATE, KIND_REPOSITORY_ANNOUNCEMENT, KIND_REPOSITORY_STATE, + RepositoryAnnouncement, RepositoryState, KIND_PR, KIND_PR_UPDATE, KIND_REPOSITORY_ANNOUNCEMENT, + KIND_REPOSITORY_STATE, +}; +use crate::nostr::policy::{ + AnnouncementPolicy, AnnouncementResult, PolicyContext, PrEventPolicy, RelatedEventPolicy, + ReferenceResult, StatePolicy, StateResult, }; /// Type alias for the shared database used by the relay pub type SharedDatabase = Arc; -/// Result of aligning a repository with authorized state -#[derive(Debug, Default)] -struct AlignmentResult { - /// Number of refs created - refs_created: usize, - /// Number of refs updated - refs_updated: usize, - /// Number of refs deleted - refs_deleted: usize, - /// Whether HEAD was set - head_set: bool, -} - /// NIP-34 Write Policy with Full GRASP-01 Event Validation /// -/// Validates all events according to GRASP-01 specification: -/// - Repository announcements must list service in clone and relays tags -/// EXCEPTION: Recursive maintainer announcements are accepted even without -/// listing the service, to enable maintainer chain discovery and GRASP-02 sync -/// - Repository state announcements must have valid structure -/// - Other events must reference accepted repositories or events -/// - Forward references are supported (events referenced by accepted events) -/// - Orphan events with no valid references are rejected +/// Validates all events according to GRASP-01 specification using modular sub-policies: +/// - `AnnouncementPolicy` - Repository announcement validation +/// - `StatePolicy` - State event validation + ref alignment +/// - `PrEventPolicy` - PR/PR Update validation +/// - `RelatedEventPolicy` - Forward/backward reference checking /// /// Uses stateful database queries to check event relationships. #[derive(Clone)] pub struct Nip34WritePolicy { - domain: String, - database: SharedDatabase, - git_data_path: PathBuf, + ctx: PolicyContext, + announcement_policy: AnnouncementPolicy, + state_policy: StatePolicy, + pr_event_policy: PrEventPolicy, + related_event_policy: RelatedEventPolicy, } impl std::fmt::Debug for Nip34WritePolicy { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("Nip34WritePolicy") - .field("domain", &self.domain) - .field("git_data_path", &self.git_data_path) + .field("domain", &self.ctx.domain) + .field("git_data_path", &self.ctx.git_data_path) .field("database", &"") .finish() } @@ -68,837 +55,200 @@ impl Nip34WritePolicy { pub fn new( domain: impl Into, database: SharedDatabase, - git_data_path: impl Into, + git_data_path: impl Into, ) -> Self { + let ctx = PolicyContext::new(domain, database, git_data_path); Self { - domain: domain.into(), - database, - git_data_path: git_data_path.into(), + announcement_policy: AnnouncementPolicy::new(ctx.clone()), + state_policy: StatePolicy::new(ctx.clone()), + pr_event_policy: PrEventPolicy::new(ctx.clone()), + related_event_policy: RelatedEventPolicy::new(ctx.clone()), + ctx, } } - /// Create a bare git repository if it doesn't exist - /// Path format: //.git - fn ensure_bare_repository(&self, announcement: &RepositoryAnnouncement) -> Result<(), String> { - let repo_path = self.git_data_path.join(announcement.repo_path()); - - // Check if repository already exists - if repo_path.exists() { - tracing::debug!("Repository already exists at {}", repo_path.display()); - return Ok(()); - } - - // Create parent directory (npub directory) - let parent = repo_path - .parent() - .ok_or_else(|| format!("Invalid repository path: {}", repo_path.display()))?; - - std::fs::create_dir_all(parent) - .map_err(|e| format!("Failed to create directory {}: {}", parent.display(), e))?; - - // Initialize bare repository using git command - let output = std::process::Command::new("git") - .args(["init", "--bare", repo_path.to_str().unwrap()]) - .output() - .map_err(|e| format!("Failed to execute git init: {}", e))?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(format!("git init failed: {}", stderr)); - } - - tracing::info!("Created bare repository at {}", repo_path.display()); - Ok(()) - } - - /// Check if this state event is the latest for its identifier among authorized authors - /// - /// A state is considered "latest" if no other state event in the database - /// from an authorized author has a newer timestamp. This handles out-of-order - /// delivery where an older event arrives after a newer one. - /// - /// The authorized_pubkeys should be the owner and maintainers of a specific - /// announcement, so different owners with the same identifier don't interfere. - async fn is_latest_state_for_identifier( - database: &SharedDatabase, - state: &RepositoryState, - authorized_pubkeys: &[PublicKey], - ) -> Result { - let filter = Filter::new() - .kind(Kind::from(KIND_REPOSITORY_STATE)) - .custom_tag( - SingleLetterTag::lowercase(Alphabet::D), - state.identifier.clone(), - ); + /// Handle repository announcement event + async fn handle_announcement(&self, event: &Event) -> PolicyResult { + let event_id_str = event.id.to_bech32().unwrap_or_else(|_| event.id.to_hex()); + + match self.announcement_policy.validate(event).await { + AnnouncementResult::Accept => { + // Parse announcement to get repository details + match RepositoryAnnouncement::from_event(event.clone()) { + Ok(announcement) => { + // Try to create bare repository if it doesn't exist + if let Err(e) = self.announcement_policy.ensure_bare_repository(&announcement) + { + tracing::warn!( + "Failed to create bare repository for {}: {}", + event_id_str, + e + ); + // Note: We still accept the event even if repo creation fails + } - match database.query(filter).await { - Ok(events) => { - for event in events { - // Skip comparing to self (same event ID) - if event.id == state.event.id { - continue; + tracing::debug!("Accepted repository announcement: {}", event_id_str); + PolicyResult::Accept } - // Only consider events from authorized authors for this announcement - if !authorized_pubkeys.contains(&event.pubkey) { - continue; - } - // If any existing event from an authorized author is newer, this is not the latest - if event.created_at > state.event.created_at { - tracing::debug!( - "State {} is not latest: found newer state {} from {} (ts {} > {})", - state.event.id.to_hex(), - event.id.to_hex(), - event.pubkey.to_hex(), - event.created_at.as_secs(), - state.event.created_at.as_secs() + Err(e) => { + tracing::warn!( + "Failed to parse repository announcement {}: {}", + event_id_str, + e ); - return Ok(false); + PolicyResult::Reject(format!("Failed to parse announcement: {}", e)) } } - Ok(true) } - Err(e) => Err(format!("Database query failed: {}", e)), - } - } - - /// Find all repository announcements where the given pubkey is authorized - /// - /// A pubkey is authorized for an announcement if: - /// - They are the owner (pubkey of the announcement event), OR - /// - They are listed in the "maintainers" tag - /// - /// This is needed because a maintainer can publish a state event that - /// should update HEAD in the repository of the announcement owner, - /// not in the maintainer's own (possibly non-existent) repository. - async fn find_authorized_announcements( - database: &SharedDatabase, - identifier: &str, - state_author: &PublicKey, - ) -> Result, String> { - let filter = Filter::new() - .kind(Kind::from(KIND_REPOSITORY_ANNOUNCEMENT)) - .custom_tag( - SingleLetterTag::lowercase(Alphabet::D), - identifier.to_string(), - ); - - match database.query(filter).await { - Ok(events) => { - let mut authorized = Vec::new(); - let state_author_hex = state_author.to_hex(); - - for event in events { - if let Ok(announcement) = RepositoryAnnouncement::from_event(event.clone()) { - // Check if state author is authorized for this announcement - let is_owner = event.pubkey == *state_author; - let is_maintainer = announcement.maintainers.contains(&state_author_hex); - - if is_owner || is_maintainer { - tracing::debug!( - "Found authorized announcement for {}: owner={}, maintainer={}", - identifier, - if is_owner { - event.pubkey.to_hex() - } else { - "n/a".to_string() - }, - is_maintainer - ); - authorized.push(announcement); - } + AnnouncementResult::AcceptMaintainer => { + // Parse announcement to get details for logging + match RepositoryAnnouncement::from_event(event.clone()) { + Ok(announcement) => { + tracing::info!( + "Accepted maintainer announcement {} (author {} is listed as maintainer for {})", + event_id_str, + event.pubkey.to_hex(), + announcement.identifier + ); + // Don't create bare repository for external announcements + PolicyResult::Accept + } + Err(e) => { + tracing::warn!( + "Failed to parse maintainer announcement {}: {}", + event_id_str, + e + ); + PolicyResult::Reject(format!("Failed to parse announcement: {}", e)) } - } - Ok(authorized) - } - Err(e) => Err(format!("Database query failed: {}", e)), - } - } - - /// Identify all owner repositories for which this state event is the latest authorized state - /// - /// Returns a list of (announcement, repo_path) pairs where: - /// - The state author is authorized (owner or maintainer) - /// - This state event is the latest for the identifier in that context - async fn identify_owner_repositories( - &self, - database: &SharedDatabase, - state: &RepositoryState, - ) -> Result, String> { - // Find all announcements where state author is authorized - let announcements = - Self::find_authorized_announcements(database, &state.identifier, &state.event.pubkey) - .await?; - - if announcements.is_empty() { - tracing::debug!( - "No authorized announcements found for state {} by {}", - state.identifier, - state.event.pubkey.to_hex() - ); - return Ok(Vec::new()); - } - - let mut owner_repos = Vec::new(); - - for announcement in announcements { - // Build the list of authorized pubkeys for this specific announcement - // (owner + maintainers) - let mut authorized_pubkeys = vec![announcement.event.pubkey]; - for maintainer_hex in &announcement.maintainers { - if let Ok(pk) = PublicKey::from_hex(maintainer_hex) { - authorized_pubkeys.push(pk); } } - - // Check if this is the latest state event for THIS announcement's context - if !Self::is_latest_state_for_identifier(database, state, &authorized_pubkeys).await? { - tracing::debug!( - "Skipping {} in {}'s repo - not the latest state event for this context", - state.identifier, - announcement.event.pubkey.to_hex() + AnnouncementResult::Reject(reason) => { + tracing::warn!( + "Rejected repository announcement {}: {}", + event_id_str, + reason ); - continue; + PolicyResult::Reject(reason) } - - // Build repository path: //.git - let repo_path = self.git_data_path.join(announcement.repo_path().clone()); - owner_repos.push((announcement, repo_path)); } - - Ok(owner_repos) } - /// Align an owner repository's refs with the authorized state - /// - /// This function: - /// 1. Deletes refs that are in the repo but not in the state (for refs/heads/ and refs/tags/) - /// 2. Updates refs that exist in state if we have the commit (for refs/heads/ and refs/tags/) - /// 3. Sets HEAD if the HEAD branch's commit is available - /// - /// Per GRASP-01: "MUST set repository HEAD per repository state announcement - /// as soon as the git data related to that branch has been received." - /// - /// Returns a summary of actions taken. - fn align_owner_repository_with_state( - &self, - repo_path: &std::path::Path, - state: &RepositoryState, - ) -> AlignmentResult { - let mut result = AlignmentResult::default(); - - // Check if repository exists - if !repo_path.exists() { - tracing::debug!( - "Repository not found at {}, cannot align with state", - repo_path.display() - ); - return result; - } - - // Get current refs from the repository - let current_refs = match git::list_refs(repo_path) { - Ok(refs) => refs, - Err(e) => { - tracing::warn!("Failed to list refs in {}: {}", repo_path.display(), e); - return result; - } - }; - - // Build expected refs from state - let mut expected_refs: std::collections::HashMap = - std::collections::HashMap::new(); - - for branch in &state.branches { - let ref_name = format!("refs/heads/{}", branch.name); - expected_refs.insert(ref_name, branch.commit.clone()); - } - - for tag in &state.tags { - let ref_name = format!("refs/tags/{}", tag.name); - expected_refs.insert(ref_name, tag.commit.clone()); - } - - // Process current refs: update or delete as needed - for (ref_name, current_commit) in ¤t_refs { - // Only process refs/heads/ and refs/tags/ - if !ref_name.starts_with("refs/heads/") && !ref_name.starts_with("refs/tags/") { - continue; - } - - match expected_refs.get(ref_name) { - Some(expected_commit) => { - // Ref should exist - check if commit matches - if current_commit != expected_commit { - // Check if we have the expected commit - if git::commit_exists(repo_path, expected_commit) { - // Update the ref - match git::update_ref(repo_path, ref_name, expected_commit) { - Ok(()) => { - tracing::info!( - "Updated {} from {} to {} in {}", - ref_name, - current_commit, - expected_commit, - repo_path.display() - ); - result.refs_updated += 1; - } - Err(e) => { - tracing::warn!( - "Failed to update {} in {}: {}", - ref_name, - repo_path.display(), - e - ); - } - } - } else { - tracing::debug!( - "Commit {} not available for {} in {}", - expected_commit, - ref_name, - repo_path.display() - ); - } - } - } - None => { - // Ref should not exist - delete it - match git::delete_ref(repo_path, ref_name) { - Ok(()) => { - tracing::info!( - "Deleted {} (not in state) from {}", - ref_name, - repo_path.display() - ); - result.refs_deleted += 1; - } - Err(e) => { + /// Handle repository state event + async fn handle_state(&self, event: &Event) -> PolicyResult { + let event_id_str = event.id.to_bech32().unwrap_or_else(|_| event.id.to_hex()); + + match self.state_policy.validate(event) { + StateResult::Accept => { + // Parse state to get HEAD and branch info + match RepositoryState::from_event(event.clone()) { + Ok(_state) => { + // Process state alignment asynchronously + if let Err(e) = self.state_policy.process_state_event(event).await { tracing::warn!( - "Failed to delete {} from {}: {}", - ref_name, - repo_path.display(), + "Failed to process state event {}: {}", + event_id_str, e ); } - } - } - } - } - // Add refs that exist in state but not in repo (if we have the commit) - for (ref_name, expected_commit) in &expected_refs { - let exists = current_refs.iter().any(|(r, _)| r == ref_name); - if !exists && git::commit_exists(repo_path, expected_commit) { - match git::update_ref(repo_path, ref_name, expected_commit) { - Ok(()) => { - tracing::info!( - "Created {} at {} in {}", - ref_name, - expected_commit, - repo_path.display() - ); - result.refs_created += 1; + tracing::debug!("Accepted repository state: {}", event_id_str); + PolicyResult::Accept } Err(e) => { tracing::warn!( - "Failed to create {} in {}: {}", - ref_name, - repo_path.display(), + "Failed to parse repository state {}: {}", + event_id_str, e ); + // Still accept the event even if we can't parse it + // The validation passed, so it's structurally valid + PolicyResult::Accept } } } - } - - // Set HEAD if specified in state - if let Some(head_ref) = &state.head { - if let Some(branch_name) = state.get_head_branch() { - if let Some(head_commit) = state.get_branch_commit(branch_name) { - match git::try_set_head_if_available(repo_path, head_ref, head_commit) { - Ok(true) => { - tracing::info!( - "Set HEAD to {} in {} (from state by {})", - head_ref, - repo_path.display(), - state.event.pubkey.to_hex() - ); - result.head_set = true; - } - Ok(false) => { - tracing::debug!( - "HEAD commit {} not available yet in {}", - head_commit, - repo_path.display() - ); - } - Err(e) => { - tracing::warn!("Failed to set HEAD in {}: {}", repo_path.display(), e); - } - } - } + StateResult::Reject(reason) => { + tracing::warn!("Rejected repository state {}: {}", event_id_str, reason); + PolicyResult::Reject(reason) } } - - result } - /// Check if a pubkey is listed as a maintainer in any announcement for this identifier - /// - /// A pubkey is considered a maintainer if: - /// 1. They are the owner (pubkey) of an accepted announcement with this identifier, OR - /// 2. They are listed in the maintainers tag of ANY announcement with this identifier - /// - /// This enables accepting announcements from maintainers even when they don't list - /// this GRASP server, for maintainer chain discovery and GRASP-02 sync. - async fn is_maintainer_in_any_announcement( - database: &SharedDatabase, - identifier: &str, - author: &PublicKey, - ) -> Result { - // Query all announcements with this identifier that are already in the database - let filter = Filter::new() - .kind(Kind::from(KIND_REPOSITORY_ANNOUNCEMENT)) - .custom_tag( - SingleLetterTag::lowercase(Alphabet::D), - identifier.to_string(), + /// Handle PR or PR Update event + async fn handle_pr_event(&self, event: &Event) -> PolicyResult { + let event_id_str = event.id.to_bech32().unwrap_or_else(|_| event.id.to_hex()); + + // Validate refs/nostr refs for this PR event + // This deletes any refs/nostr/ that points to wrong commit + if let Err(e) = self.pr_event_policy.validate_nostr_ref(event).await { + tracing::warn!( + "Failed to validate refs/nostr for PR event {}: {}", + event_id_str, + e ); - - let announcements: Vec = match database.query(filter).await { - Ok(events) => events.into_iter().collect(), - Err(e) => return Err(format!("Database query failed: {}", e)), - }; - - if announcements.is_empty() { - // No existing announcements for this identifier - author cannot be a maintainer - return Ok(false); - } - - let author_hex = author.to_hex(); - - // Check each announcement to see if author is listed as a maintainer - for event in &announcements { - // Check if author is the owner of this announcement - if event.pubkey == *author { - return Ok(true); - } - - // Check if author is listed in the maintainers tag - if let Ok(announcement) = RepositoryAnnouncement::from_event(event.clone()) { - if announcement.maintainers.contains(&author_hex) { - return Ok(true); - } - } - } - - Ok(false) - } - - /// Extract all reference tags from an event (a, A, q, e, E) - /// Returns (addressable_refs, event_refs) - fn extract_reference_tags(event: &Event) -> (Vec, Vec) { - let mut addressable_refs = Vec::new(); - let mut event_refs = Vec::new(); - - for tag in event.tags.iter() { - let tag_vec = tag.clone().to_vec(); - if tag_vec.is_empty() { - continue; - } - - match tag_vec[0].as_str() { - // Addressable event references (a, A, q with kind:pubkey:identifier format) - "a" | "A" | "q" if tag_vec.len() > 1 && tag_vec[1].contains(':') => { - addressable_refs.push(tag_vec[1].clone()); - } - // Event ID references (e, E, q with event ID format) - "e" | "E" if tag_vec.len() > 1 => { - if let Ok(event_id) = EventId::from_hex(&tag_vec[1]) { - event_refs.push(event_id); - } - } - "q" if tag_vec.len() > 1 && !tag_vec[1].contains(':') => { - if let Ok(event_id) = EventId::from_hex(&tag_vec[1]) { - event_refs.push(event_id); - } - } - _ => {} - } + // Don't reject - just log the error and proceed with normal validation } - (addressable_refs, event_refs) + // Continue with reference checking (same as related events) + self.handle_related_event(event, "PR").await } - /// Validate refs/nostr/ ref against a PR or PR Update event's `c` tag - /// - /// When a PR event (kind 1618) or PR Update event (kind 1619) is received, - /// this checks if a corresponding refs/nostr/ ref exists in the - /// repository and validates that it points to the correct commit (from the - /// `c` tag). If the ref exists but points to a different commit, the ref is - /// deleted. - /// - /// PR and PR Update events can have multiple `a` tags to update multiple - /// repositories simultaneously. - /// - /// This is part of GRASP-01 compliance: ensuring refs/nostr refs are consistent - /// with their corresponding events. - /// - /// # Arguments - /// * `database` - Database for looking up repository announcements - /// * `event` - The PR event (kind 1618) or PR Update event (kind 1619) - /// - /// # Returns - /// Ok(Some(n)) if n refs were deleted, Ok(None) if no action taken, Err on failure - async fn validate_pr_nostr_ref( - &self, - database: &SharedDatabase, - event: &Event, - ) -> Result, String> { - let event_id = event.id.to_hex(); - - // Extract the `c` tag (commit hash) from the PR event - let expected_commit = event.tags.iter().find_map(|tag| { - let tag_vec = tag.clone().to_vec(); - if tag_vec.len() >= 2 && tag_vec[0] == "c" { - Some(tag_vec[1].clone()) - } else { - None - } - }); + /// Handle events that must reference accepted repositories or events + async fn handle_related_event(&self, event: &Event, event_type: &str) -> PolicyResult { + let event_id_str = event.id.to_bech32().unwrap_or_else(|_| event.id.to_hex()); - let expected_commit = match expected_commit { - Some(c) => c, - None => { + match self.related_event_policy.check_references(event).await { + Ok(ReferenceResult::ReferencesRepository(addr_ref)) => { tracing::debug!( - "PR event {} has no 'c' tag, skipping ref validation", - event_id + "Accepted {} event {}: references accepted repository {}", + event_type, + event_id_str, + addr_ref ); - return Ok(None); + PolicyResult::Accept } - }; - - // Extract ALL `a` tags (repository references) from the PR event - // PR events can reference multiple repositories - // Format: 30617:: - let repo_refs: Vec = event - .tags - .iter() - .filter_map(|tag| { - let tag_vec = tag.clone().to_vec(); - if tag_vec.len() >= 2 && tag_vec[0] == "a" && tag_vec[1].starts_with("30617:") { - Some(tag_vec[1].clone()) - } else { - None - } - }) - .collect(); - - if repo_refs.is_empty() { - tracing::debug!( - "PR event {} has no repo 'a' tags, skipping ref validation", - event_id - ); - return Ok(None); - } - - let mut deleted_count = 0; - - // Process each repository reference - for repo_ref in repo_refs { - // Parse the repo reference: 30617:: - let parts: Vec<&str> = repo_ref.split(':').collect(); - if parts.len() < 3 { + Ok(ReferenceResult::ReferencesEvent(event_ref)) => { tracing::debug!( - "PR event {} has invalid 'a' tag format: {}", - event_id, - repo_ref + "Accepted {} event {}: references accepted event {}", + event_type, + event_id_str, + event_ref ); - continue; + PolicyResult::Accept } - - let repo_pubkey = match PublicKey::from_hex(parts[1]) { - Ok(pk) => pk, - Err(_) => { - tracing::debug!( - "PR event {} has invalid pubkey in 'a' tag: {}", - event_id, - parts[1] - ); - continue; - } - }; - let identifier = parts[2]; - - // Look up repository announcement to get the npub for path - let filter = Filter::new() - .kind(Kind::from(KIND_REPOSITORY_ANNOUNCEMENT)) - .author(repo_pubkey) - .custom_tag( - SingleLetterTag::lowercase(Alphabet::D), - identifier.to_string(), - ); - - let announcements: Vec = match database.query(filter).await { - Ok(events) => events.into_iter().collect(), - Err(e) => { - tracing::warn!( - "Failed to query for repository announcement for PR {}: {}", - event_id, - e - ); - continue; - } - }; - - if announcements.is_empty() { + Ok(ReferenceResult::ReferencedByAccepted) => { tracing::debug!( - "No repository announcement found for PR event {} (repo {}:{})", - event_id, - repo_pubkey.to_hex(), - identifier + "Accepted {} event {}: referenced by accepted event", + event_type, + event_id_str ); - continue; - } - - // Process each matching announcement (there could be multiple) - for announcement_event in announcements { - let announcement = match RepositoryAnnouncement::from_event(announcement_event) { - Ok(a) => a, - Err(e) => { - tracing::warn!( - "Failed to parse announcement for PR {} validation: {}", - event_id, - e - ); - continue; - } - }; - - // Build repository path - let repo_path = self.git_data_path.join(announcement.repo_path()); - - // Validate the ref - match git::validate_nostr_ref(&repo_path, &event_id, &expected_commit) { - Ok(true) => { - tracing::info!( - "Deleted mismatched refs/nostr/{} in {} (expected commit {})", - event_id, - repo_path.display(), - expected_commit - ); - deleted_count += 1; - } - Ok(false) => { - tracing::debug!( - "refs/nostr/{} in {} is valid or doesn't exist", - event_id, - repo_path.display() - ); - } - Err(e) => { - tracing::warn!( - "Failed to validate refs/nostr/{} in {}: {}", - event_id, - repo_path.display(), - e - ); - } - } - } - } - - if deleted_count > 0 { - Ok(Some(deleted_count)) - } else { - Ok(None) - } - } - - /// Check if any addressable events (repositories) exist in database - /// Returns the first matching addressable reference found, or None if none match - async fn find_accepted_repository( - database: &SharedDatabase, - addressables: &[String], - ) -> Result, String> { - if addressables.is_empty() { - return Ok(None); - } - - // Parse all addressable references - let mut parsed_refs = Vec::new(); - for addr in addressables { - let parts: Vec<&str> = addr.split(':').collect(); - if parts.len() < 3 { - continue; // Skip invalid format - } - - let kind = match parts[0].parse::() { - Ok(k) => k, - Err(_) => continue, // Skip invalid kind - }; - let pubkey = match PublicKey::from_hex(parts[1]) { - Ok(pk) => pk, - Err(_) => continue, // Skip invalid pubkey - }; - let identifier = parts[2].to_string(); - - parsed_refs.push((addr.clone(), kind, pubkey, identifier)); - } - - if parsed_refs.is_empty() { - return Ok(None); - } - - // Group by kind to reduce queries - use std::collections::HashMap; - let mut by_kind: HashMap> = HashMap::new(); - for (addr, kind, pubkey, identifier) in parsed_refs { - by_kind - .entry(kind) - .or_default() - .push((addr, pubkey, identifier)); - } - - // Query each kind group - for (kind, refs) in by_kind { - let authors: Vec = refs.iter().map(|(_, pk, _)| *pk).collect(); - - let filter = Filter::new().kind(Kind::from(kind)).authors(authors); - - match database.query(filter).await { - Ok(events) => { - // Check if any event matches our identifier requirements - for event in events { - for (addr, _pubkey, identifier) in &refs { - // Match identifier tag - if event.tags.iter().any(|tag| { - let tag_vec = tag.clone().to_vec(); - tag_vec.len() >= 2 && tag_vec[0] == "d" && tag_vec[1] == *identifier - }) { - return Ok(Some(addr.clone())); - } - } - } - } - Err(e) => return Err(format!("Database query failed: {}", e)), + PolicyResult::Accept } - } - - Ok(None) - } - - /// Check if any events exist in database - /// Returns the first matching event ID found, or None if none match - async fn find_accepted_event( - database: &SharedDatabase, - event_ids: &[EventId], - ) -> Result, String> { - if event_ids.is_empty() { - return Ok(None); - } - - // Single query for all event IDs - let filter = Filter::new().ids(event_ids.iter().copied()); - - match database.query(filter).await { - Ok(events) => { - // Get first event from the iterator - Ok(events.into_iter().next().map(|e| e.id)) - } - Err(e) => Err(format!("Database query failed: {}", e)), - } - } - - /// Check if any accepted event references this event (forward reference) - /// - /// For regular replaceable events (10000-19999): Checks addressable tags with kind:pubkey format - /// For parameterized replaceable (30000-39999): Checks addressable tags with kind:pubkey:d-identifier format - /// For regular events: Only checks event ID reference tags (e, E, q) - /// - /// This optimization recognizes that replaceable events are referenced by coordinate address, - /// while regular events are referenced by event ID. - async fn is_referenced_by_accepted( - database: &SharedDatabase, - event: &Event, - ) -> Result { - let kind_u16 = event.kind.as_u16(); - - // Check if this is any kind of replaceable event - let is_regular_replaceable = (10000..20000).contains(&kind_u16); - let is_parameterized_replaceable = (30000..40000).contains(&kind_u16); - - if is_regular_replaceable || is_parameterized_replaceable { - // Build the appropriate address format based on event type - let address = if is_parameterized_replaceable { - // For parameterized replaceable: kind:pubkey:d-identifier format (2 colons) - let identifier = event - .tags - .iter() - .find_map(|tag| { - let tag_vec = tag.clone().to_vec(); - if tag_vec.len() >= 2 && tag_vec[0] == "d" { - Some(tag_vec[1].clone()) - } else { - None - } - }) - .unwrap_or_default(); // Empty string if no 'd' tag - format!( - "{}:{}:{}", - event.kind.as_u16(), - event.pubkey.to_hex(), - identifier - ) - } else { - // For regular replaceable: kind:pubkey format (1 colon) - format!("{}:{}", event.kind.as_u16(), event.pubkey.to_hex()) - }; - - // Check addressable reference tags: a, A, q (with address format) - let addressable_tags = [ - SingleLetterTag::lowercase(Alphabet::A), // 'a' - addressable event reference - SingleLetterTag::uppercase(Alphabet::A), // 'A' - uppercase addressable reference - SingleLetterTag::lowercase(Alphabet::Q), // 'q' - quote (can be address or ID) - ]; - - for tag_type in &addressable_tags { - let filter = Filter::new().custom_tag(*tag_type, address.clone()); - - match database.query(filter).await { - Ok(events) => { - if !events.is_empty() { - return Ok(true); - } - } - Err(e) => return Err(format!("Database query failed: {}", e)), - } + Ok(ReferenceResult::Orphan) => { + let (addressable_refs, event_refs) = + RelatedEventPolicy::extract_reference_tags(event); + tracing::info!( + "Rejected orphan {} event {}: no references to accepted repos or events (checked {} addressable, {} event refs)", + event_type, + event_id_str, + addressable_refs.len(), + event_refs.len() + ); + PolicyResult::Reject(format!( + "{} event must reference an accepted repository or accepted event", + event_type + )) } - } else { - // For regular events, check event ID reference tags: e, E, q (with hex ID) - let event_id_hex = event.id.to_hex(); - - let event_id_tags = [ - SingleLetterTag::lowercase(Alphabet::E), // 'e' - standard event reference - SingleLetterTag::uppercase(Alphabet::E), // 'E' - NIP-22 root event reference - SingleLetterTag::lowercase(Alphabet::Q), // 'q' - quote reference - ]; - - for tag_type in &event_id_tags { - let filter = Filter::new().custom_tag(*tag_type, event_id_hex.clone()); - - match database.query(filter).await { - Ok(events) => { - if !events.is_empty() { - return Ok(true); - } - } - Err(e) => return Err(format!("Database query failed: {}", e)), - } + Err(e) => { + tracing::warn!( + "Database query failed for {} {}, rejecting (fail-secure): {}", + event_type, + event_id_str, + e + ); + PolicyResult::Reject(format!("Database query failed: {}", e)) } } - - Ok(false) } } @@ -908,366 +258,12 @@ impl WritePolicy for Nip34WritePolicy { event: &'a nostr_relay_builder::prelude::Event, _addr: &'a SocketAddr, ) -> BoxedFuture<'a, PolicyResult> { - let database = self.database.clone(); - let domain = self.domain.clone(); - Box::pin(async move { - let event_id_str = event.id.to_bech32().unwrap_or_else(|_| event.id.to_hex()); - match event.kind.as_u16() { - KIND_REPOSITORY_ANNOUNCEMENT => { - // First, try normal validation (announcement lists service) - match validate_announcement(event, &domain) { - Ok(_) => { - // Parse announcement to get repository details - match RepositoryAnnouncement::from_event(event.clone()) { - Ok(announcement) => { - // Try to create bare repository if it doesn't exist - if let Err(e) = self.ensure_bare_repository(&announcement) { - tracing::warn!( - "Failed to create bare repository for {}: {}", - event_id_str, - e - ); - // Note: We still accept the event even if repo creation fails - // The git operation failure shouldn't prevent event acceptance - } - - tracing::debug!( - "Accepted repository announcement: {}", - event_id_str - ); - PolicyResult::Accept - } - Err(e) => { - tracing::warn!( - "Failed to parse repository announcement {}: {}", - event_id_str, - e - ); - PolicyResult::Reject(format!( - "Failed to parse announcement: {}", - e - )) - } - } - } - Err(validation_err) => { - // Validation failed - check if this is a recursive maintainer announcement - // GRASP-01 Exception: Accept announcements from recursive maintainers - // even without listing the service, for chain discovery and GRASP-02 sync - - // Try to parse the announcement to get identifier - match RepositoryAnnouncement::from_event(event.clone()) { - Ok(announcement) => { - // Check if author is listed as maintainer in any existing announcement - match Self::is_maintainer_in_any_announcement( - &database, - &announcement.identifier, - &event.pubkey, - ) - .await - { - Ok(true) => { - tracing::info!( - "Accepted maintainer announcement {} (author {} is listed as maintainer for {})", - event_id_str, - event.pubkey.to_hex(), - announcement.identifier - ); - // Don't create bare repository for external announcements - // (they point to other servers) - PolicyResult::Accept - } - Ok(false) => { - tracing::warn!( - "Rejected repository announcement {}: {} (not a maintainer)", - event_id_str, - validation_err - ); - PolicyResult::Reject(validation_err.to_string()) - } - Err(e) => { - tracing::warn!( - "Failed to check maintainer status for {}: {}", - event_id_str, - e - ); - // Fail-secure: reject on database errors - PolicyResult::Reject(validation_err.to_string()) - } - } - } - Err(parse_err) => { - tracing::warn!( - "Rejected repository announcement {}: {} (parse error: {})", - event_id_str, - validation_err, - parse_err - ); - PolicyResult::Reject(validation_err.to_string()) - } - } - } - } - } - KIND_REPOSITORY_STATE => match validate_state(event) { - Ok(_) => { - // Parse state to get HEAD and branch info - match RepositoryState::from_event(event.clone()) { - Ok(state) => { - // Identify owner repositories for which this is the latest authorized state - match self.identify_owner_repositories(&database, &state).await { - Ok(owner_repos) => { - let repo_count = owner_repos.len(); - let mut total_aligned = 0; - - // Align each owner repository with the authorized state - for (_announcement, repo_path) in owner_repos { - let result = self.align_owner_repository_with_state( - &repo_path, &state, - ); - - if result.refs_created > 0 - || result.refs_updated > 0 - || result.refs_deleted > 0 - || result.head_set - { - tracing::info!( - "Aligned {} with state {}: created={}, updated={}, deleted={}, head_set={}", - repo_path.display(), - event_id_str, - result.refs_created, - result.refs_updated, - result.refs_deleted, - result.head_set - ); - total_aligned += 1; - } - } - - if repo_count > 0 { - tracing::info!( - "Processed state event {} for {} repo(s) ({} aligned) with identifier {}", - event_id_str, - repo_count, - total_aligned, - state.identifier - ); - } else { - tracing::debug!( - "No owner repos to align for state {} - git data not available yet or not latest", - event_id_str - ); - } - } - Err(e) => { - tracing::warn!( - "Failed to identify owner repositories for state {}: {}", - event_id_str, - e - ); - } - } - - tracing::debug!("Accepted repository state: {}", event_id_str); - PolicyResult::Accept - } - Err(e) => { - tracing::warn!( - "Failed to parse repository state {}: {}", - event_id_str, - e - ); - // Still accept the event even if we can't parse it - // The validation passed, so it's structurally valid - PolicyResult::Accept - } - } - } - Err(e) => { - tracing::warn!("Rejected repository state {}: {}", event_id_str, e); - PolicyResult::Reject(e.to_string()) - } - }, - // KIND_PR (1618) and KIND_PR_UPDATE (1619): Validate refs/nostr/ refs before acceptance - KIND_PR | KIND_PR_UPDATE => { - // Validate refs/nostr refs for this PR event - // This deletes any refs/nostr/ that points to wrong commit - if let Err(e) = self.validate_pr_nostr_ref(&database, event).await { - tracing::warn!( - "Failed to validate refs/nostr for PR event {}: {}", - event_id_str, - e - ); - // Don't reject - just log the error and proceed with normal validation - } - - // Continue with standard reference checking (same as default case) - let (addressable_refs, event_refs) = Self::extract_reference_tags(event); - - // Check 1: Does this event reference an accepted repository? - match Self::find_accepted_repository(&database, &addressable_refs).await { - Ok(Some(addr_ref)) => { - tracing::debug!( - "Accepted PR event {}: references accepted repository {}", - event_id_str, - addr_ref - ); - return PolicyResult::Accept; - } - Ok(None) => { - // No matching repositories, continue to next check - } - Err(e) => { - tracing::warn!( - "Database query failed for PR {}, rejecting (fail-secure): {}", - event_id_str, - e - ); - return PolicyResult::Reject(format!("Database query failed: {}", e)); - } - } - - // Check 2: Does this event reference an accepted event? - match Self::find_accepted_event(&database, &event_refs).await { - Ok(Some(event_ref)) => { - tracing::debug!( - "Accepted PR event {}: references accepted event {}", - event_id_str, - event_ref - ); - return PolicyResult::Accept; - } - Ok(None) => { - // No matching events, continue to next check - } - Err(e) => { - tracing::warn!( - "Database query failed for PR {}, rejecting (fail-secure): {}", - event_id_str, - e - ); - return PolicyResult::Reject(format!("Database query failed: {}", e)); - } - } - - // Check 3: Is this event referenced by an accepted event? - match Self::is_referenced_by_accepted(&database, event).await { - Ok(true) => { - tracing::debug!( - "Accepted PR event {}: referenced by accepted event", - event_id_str - ); - return PolicyResult::Accept; - } - Ok(false) => { - // No forward references found, continue to rejection - } - Err(e) => { - tracing::warn!( - "Database query failed for PR {}, rejecting (fail-secure): {}", - event_id_str, - e - ); - return PolicyResult::Reject(format!("Database query failed: {}", e)); - } - } - - // No valid references found - reject as orphan event - tracing::info!( - "Rejected orphan PR event {}: no references to accepted repos or events", - event_id_str - ); - PolicyResult::Reject( - "PR event must reference an accepted repository or accepted event" - .to_string(), - ) - } - // GRASP-01: Check if event references accepted repositories or events - _ => { - // Extract all reference tags from event - let (addressable_refs, event_refs) = Self::extract_reference_tags(event); - - // Check 1: Does this event reference an accepted repository? (batched) - match Self::find_accepted_repository(&database, &addressable_refs).await { - Ok(Some(addr_ref)) => { - tracing::debug!( - "Accepted event {}: references accepted repository {}", - event_id_str, - addr_ref - ); - return PolicyResult::Accept; - } - Ok(None) => { - // No matching repositories, continue to next check - } - Err(e) => { - tracing::warn!( - "Database query failed for event {}, rejecting (fail-secure): {}", - event_id_str, - e - ); - return PolicyResult::Reject(format!("Database query failed: {}", e)); - } - } - - // Check 2: Does this event reference an accepted event? (batched, transitive) - match Self::find_accepted_event(&database, &event_refs).await { - Ok(Some(event_ref)) => { - tracing::debug!( - "Accepted event {}: references accepted event {}", - event_id_str, - event_ref - ); - return PolicyResult::Accept; - } - Ok(None) => { - // No matching events, continue to next check - } - Err(e) => { - tracing::warn!( - "Database query failed for event {}, rejecting (fail-secure): {}", - event_id_str, - e - ); - return PolicyResult::Reject(format!("Database query failed: {}", e)); - } - } - - // Check 3: Is this event referenced by an accepted event? (forward reference) - match Self::is_referenced_by_accepted(&database, event).await { - Ok(true) => { - tracing::debug!( - "Accepted event {}: referenced by accepted event", - event_id_str - ); - return PolicyResult::Accept; - } - Ok(false) => { - // No forward references found, continue to rejection - } - Err(e) => { - tracing::warn!( - "Database query failed for event {}, rejecting (fail-secure): {}", - event_id_str, - e - ); - return PolicyResult::Reject(format!("Database query failed: {}", e)); - } - } - - // No valid references found - reject as orphan event - tracing::info!( - "Rejected orphan event {}: no references to accepted repos or events (checked {} addressable, {} event refs)", - event_id_str, - addressable_refs.len(), - event_refs.len() - ); - PolicyResult::Reject( - "Event must reference an accepted repository or accepted event".to_string(), - ) - } + KIND_REPOSITORY_ANNOUNCEMENT => self.handle_announcement(event).await, + KIND_REPOSITORY_STATE => self.handle_state(event).await, + KIND_PR | KIND_PR_UPDATE => self.handle_pr_event(event).await, + _ => self.handle_related_event(event, "Event").await, } }) } @@ -1351,4 +347,4 @@ pub fn create_relay(config: &Config) -> Result { relay: LocalRelay::new(builder), database, }) -} +} \ No newline at end of file -- cgit v1.2.3