From de683147779eaf57376a90e73bbdd123846a01e3 Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Wed, 3 Dec 2025 17:06:59 +0000 Subject: feat: accept maintainer announcements without service listing --- .../src/specs/grasp01/event_acceptance_policy.rs | 149 +++++++++++++++++- src/nostr/builder.rs | 168 +++++++++++++++++---- tests/nip34_announcements.rs | 1 + 3 files changed, 283 insertions(+), 35 deletions(-) diff --git a/grasp-audit/src/specs/grasp01/event_acceptance_policy.rs b/grasp-audit/src/specs/grasp01/event_acceptance_policy.rs index 6474399..c34fe66 100644 --- a/grasp-audit/src/specs/grasp01/event_acceptance_policy.rs +++ b/grasp-audit/src/specs/grasp01/event_acceptance_policy.rs @@ -5,6 +5,9 @@ //! This file validates that a GRASP-01 compliant relay: //! - Accepts valid NIP-34 repository announcements listing the service //! - Rejects announcements that don't list the service in clone and relays tags +//! EXCEPTION: maintainer announcements (from authors in the maintainer chain) +//! MUST be accepted even without listing the service - this enables recursive maintainer +//! chain discovery and more reliable GRASP-02 sync capabilities //! - Accepts repository state announcements //! - Accepts events that TAG accepted repositories //! - Accepts events that ARE TAGGED BY accepted events (transitive) @@ -90,7 +93,7 @@ use crate::fixtures::{send_and_verify_accepted, send_and_verify_rejected}; use crate::{AuditClient, AuditResult, FixtureKind, TestContext, TestResult}; -use nostr_sdk::{Event, Filter, Kind, Tag, TagKind, Timestamp}; +use nostr_sdk::{Event, Filter, Kind, Tag, TagKind, Timestamp, ToBech32}; use std::time::Duration; /// Test suite for GRASP-01 event acceptance policy @@ -105,6 +108,7 @@ impl EventAcceptancePolicyTests { results.add(Self::test_accept_valid_repo_announcement(client).await); results.add(Self::test_reject_repo_announcement_missing_clone_tag(client).await); results.add(Self::test_reject_repo_announcement_missing_relays_tag(client).await); + results.add(Self::test_accept_maintainer_announcement_without_service_listed(client).await); // Repository State Announcement Tests results.add(Self::test_accept_valid_repo_state_announcement(client).await); @@ -404,6 +408,134 @@ impl EventAcceptancePolicyTests { .await } + /// Test: Accept recursive maintainer announcement without service in clone tag + /// + /// Spec: Line 9 of ../grasp/01.md (EXCEPTION to rejection rule) + /// Requirement: MUST accept recursive maintainer announcements for chain discovery + /// + /// GRASP-01: "respecting the recursive maintainer set" + /// + /// When a recursive maintainer is listed in a maintainer's announcement, they may + /// publish their own announcement for the same repo (with their own maintainers). + /// The relay MUST accept this recursive maintainer's announcement even if it doesn't + /// list this GRASP server in its clone tag - because the relay needs it to discover + /// the full recursive maintainer chain. + /// + /// This also enables GRASP-02 to sync state events and git data when authoritative + /// users publish them to other relays/git servers, keeping repos up-to-date. + pub async fn test_accept_maintainer_announcement_without_service_listed( + client: &AuditClient, + ) -> TestResult { + TestResult::new( + "accept_recursive_maintainer_announcement_without_service", + "GRASP-01:nostr-relay:9", + "Accept recursive maintainer announcement for chain discovery (even without GRASP server in clone)", + ) + .run(|| async { + // Create TestContext for mode-aware fixture management + let ctx = TestContext::new(client); + + // Step 1: Get RecursiveMaintainerStateDataPushed fixture + // This establishes: Owner -> Maintainer -> RecursiveMaintainer chain + // with all git data pushed. The recursive maintainer is already listed + // in maintainer's announcement (and maintainer in owner's announcement). + let recursive_state = ctx + .get_fixture(FixtureKind::RecursiveMaintainerStateDataPushed) + .await + .map_err(|e| { + format!( + "Test setup failed: could not get RecursiveMaintainerStateDataPushed fixture: {}", + e + ) + })?; + + // Extract repo_id from the recursive maintainer's state event + let repo_id = recursive_state + .tags + .iter() + .find(|t| t.kind() == TagKind::d()) + .and_then(|t| t.content()) + .ok_or("Missing d tag in recursive maintainer state")? + .to_string(); + + // Step 2: Build a recursive maintainer announcement that DOES NOT include + // this GRASP server in its clone tag - simulating an announcement pointing + // to a different server (e.g., another GRASP server) + let recursive_maintainer_npub = client + .recursive_maintainer_keys() + .public_key() + .to_bech32() + .map_err(|e| format!("Failed to convert recursive maintainer pubkey: {}", e))?; + + // Create announcement with external clone URL (not this server) + let recursive_maintainer_announcement = client + .event_builder( + Kind::GitRepoAnnouncement, + format!( + "Recursive maintainer announcement for {} (external clone)", + repo_id + ), + ) + .tag(Tag::identifier(&repo_id)) + .tag(Tag::custom( + TagKind::custom("name"), + vec![format!("{} (recursive maintainer view)", repo_id)], + )) + // Clone points to another server, NOT the GRASP server + .tag(Tag::custom( + TagKind::custom("clone"), + vec![format!( + "https://another-grasp-server.com/{}/{}.git", + recursive_maintainer_npub, repo_id + )], + )) + // Relays also points elsewhere (not this server) + .tag(Tag::custom( + TagKind::custom("relays"), + vec!["wss://relay.damus.io"], + )) + .build(client.recursive_maintainer_keys()) + .map_err(|e| format!("Failed to build recursive maintainer announcement: {}", e))?; + + let event_id = recursive_maintainer_announcement.id; + + // Step 3: Send the recursive maintainer announcement + client + .send_event(recursive_maintainer_announcement) + .await + .map_err(|e| format!("Failed to send recursive maintainer announcement: {}", e))?; + + // Wait for propagation + tokio::time::sleep(Duration::from_millis(200)).await; + + // Step 4: Query to verify it was accepted + let filter = Filter::new() + .kind(Kind::GitRepoAnnouncement) + .author(client.recursive_maintainer_keys().public_key()) + .identifier(&repo_id); + + let events = client + .query(filter) + .await + .map_err(|e| format!("Failed to query events: {}", e))?; + + // Verify the recursive maintainer's announcement was stored + if !events.iter().any(|e| e.id == event_id) { + return Err(format!( + "Recursive maintainer announcement was NOT accepted by relay. \ + The recursive maintainer (listed in maintainer's announcement, which is \ + listed in owner's announcement) published their own announcement for \ + repo {} with an external clone URL. The relay should accept this to \ + enable full recursive maintainer chain discovery. Event ID: {}", + repo_id, event_id + )); + } + + Ok(()) + }) + .await + } + // ============================================================ // Repository State Announcement Tests // ============================================================ @@ -432,12 +564,15 @@ impl EventAcceptancePolicyTests { // 2. Pushes git data with the deterministic commit // 3. Sends the state announcement // This ensures the state event references a commit that actually exists - let state_event = ctx.get_fixture(FixtureKind::OwnerStateDataPushed).await.map_err(|e| { - format!( - "Test setup failed: could not get repository state fixture: {}", - e - ) - })?; + let state_event = ctx + .get_fixture(FixtureKind::OwnerStateDataPushed) + .await + .map_err(|e| { + format!( + "Test setup failed: could not get repository state fixture: {}", + e + ) + })?; // Extract repo_id from the state event let repo_id = state_event diff --git a/src/nostr/builder.rs b/src/nostr/builder.rs index 904cba4..00e5969 100644 --- a/src/nostr/builder.rs +++ b/src/nostr/builder.rs @@ -39,6 +39,8 @@ struct AlignmentResult { /// /// 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) @@ -442,6 +444,57 @@ impl Nip34WritePolicy { 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(), + ); + + 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) { @@ -862,43 +915,102 @@ impl WritePolicy for Nip34WritePolicy { let event_id_str = event.id.to_bech32().unwrap_or_else(|_| event.id.to_hex()); match event.kind.as_u16() { - KIND_REPOSITORY_ANNOUNCEMENT => 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) { + 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 create bare repository for {}: {}", + "Failed to parse repository announcement {}: {}", event_id_str, e ); - // Note: We still accept the event even if repo creation fails - // The git operation failure shouldn't prevent event acceptance + PolicyResult::Reject(format!( + "Failed to parse announcement: {}", + e + )) } - - 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()) + } } } } - Err(e) => { - tracing::warn!("Rejected repository announcement {}: {}", event_id_str, e); - PolicyResult::Reject(e.to_string()) - } - }, + } KIND_REPOSITORY_STATE => match validate_state(event) { Ok(_) => { // Parse state to get HEAD and branch info diff --git a/tests/nip34_announcements.rs b/tests/nip34_announcements.rs index aa623d3..fc68bac 100644 --- a/tests/nip34_announcements.rs +++ b/tests/nip34_announcements.rs @@ -59,6 +59,7 @@ macro_rules! isolated_test { isolated_test!(test_accept_valid_repo_announcement); isolated_test!(test_reject_repo_announcement_missing_clone_tag); isolated_test!(test_reject_repo_announcement_missing_relays_tag); +isolated_test!(test_accept_maintainer_announcement_without_service_listed); isolated_test!(test_accept_valid_repo_state_announcement); isolated_test!(test_accept_issue_via_a_tag); isolated_test!(test_accept_comment_via_capital_a_tag); -- cgit v1.2.3