upleb.uk

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

summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDanConwayDev <DanConwayDev@protonmail.com>2025-12-03 17:06:59 +0000
committerDanConwayDev <DanConwayDev@protonmail.com>2025-12-03 17:06:59 +0000
commitde683147779eaf57376a90e73bbdd123846a01e3 (patch)
tree3f4ecaf6e49bdb3d7b338df669ecd8cead98e2e6
parentdd1b44132199aa72c2b699e1160fbe6b885f0ef6 (diff)
feat: accept maintainer announcements without service listing
-rw-r--r--grasp-audit/src/specs/grasp01/event_acceptance_policy.rs149
-rw-r--r--src/nostr/builder.rs168
-rw-r--r--tests/nip34_announcements.rs1
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 @@
5//! This file validates that a GRASP-01 compliant relay: 5//! This file validates that a GRASP-01 compliant relay:
6//! - Accepts valid NIP-34 repository announcements listing the service 6//! - Accepts valid NIP-34 repository announcements listing the service
7//! - Rejects announcements that don't list the service in clone and relays tags 7//! - Rejects announcements that don't list the service in clone and relays tags
8//! EXCEPTION: maintainer announcements (from authors in the maintainer chain)
9//! MUST be accepted even without listing the service - this enables recursive maintainer
10//! chain discovery and more reliable GRASP-02 sync capabilities
8//! - Accepts repository state announcements 11//! - Accepts repository state announcements
9//! - Accepts events that TAG accepted repositories 12//! - Accepts events that TAG accepted repositories
10//! - Accepts events that ARE TAGGED BY accepted events (transitive) 13//! - Accepts events that ARE TAGGED BY accepted events (transitive)
@@ -90,7 +93,7 @@
90 93
91use crate::fixtures::{send_and_verify_accepted, send_and_verify_rejected}; 94use crate::fixtures::{send_and_verify_accepted, send_and_verify_rejected};
92use crate::{AuditClient, AuditResult, FixtureKind, TestContext, TestResult}; 95use crate::{AuditClient, AuditResult, FixtureKind, TestContext, TestResult};
93use nostr_sdk::{Event, Filter, Kind, Tag, TagKind, Timestamp}; 96use nostr_sdk::{Event, Filter, Kind, Tag, TagKind, Timestamp, ToBech32};
94use std::time::Duration; 97use std::time::Duration;
95 98
96/// Test suite for GRASP-01 event acceptance policy 99/// Test suite for GRASP-01 event acceptance policy
@@ -105,6 +108,7 @@ impl EventAcceptancePolicyTests {
105 results.add(Self::test_accept_valid_repo_announcement(client).await); 108 results.add(Self::test_accept_valid_repo_announcement(client).await);
106 results.add(Self::test_reject_repo_announcement_missing_clone_tag(client).await); 109 results.add(Self::test_reject_repo_announcement_missing_clone_tag(client).await);
107 results.add(Self::test_reject_repo_announcement_missing_relays_tag(client).await); 110 results.add(Self::test_reject_repo_announcement_missing_relays_tag(client).await);
111 results.add(Self::test_accept_maintainer_announcement_without_service_listed(client).await);
108 112
109 // Repository State Announcement Tests 113 // Repository State Announcement Tests
110 results.add(Self::test_accept_valid_repo_state_announcement(client).await); 114 results.add(Self::test_accept_valid_repo_state_announcement(client).await);
@@ -404,6 +408,134 @@ impl EventAcceptancePolicyTests {
404 .await 408 .await
405 } 409 }
406 410
411 /// Test: Accept recursive maintainer announcement without service in clone tag
412 ///
413 /// Spec: Line 9 of ../grasp/01.md (EXCEPTION to rejection rule)
414 /// Requirement: MUST accept recursive maintainer announcements for chain discovery
415 ///
416 /// GRASP-01: "respecting the recursive maintainer set"
417 ///
418 /// When a recursive maintainer is listed in a maintainer's announcement, they may
419 /// publish their own announcement for the same repo (with their own maintainers).
420 /// The relay MUST accept this recursive maintainer's announcement even if it doesn't
421 /// list this GRASP server in its clone tag - because the relay needs it to discover
422 /// the full recursive maintainer chain.
423 ///
424 /// This also enables GRASP-02 to sync state events and git data when authoritative
425 /// users publish them to other relays/git servers, keeping repos up-to-date.
426 pub async fn test_accept_maintainer_announcement_without_service_listed(
427 client: &AuditClient,
428 ) -> TestResult {
429 TestResult::new(
430 "accept_recursive_maintainer_announcement_without_service",
431 "GRASP-01:nostr-relay:9",
432 "Accept recursive maintainer announcement for chain discovery (even without GRASP server in clone)",
433 )
434 .run(|| async {
435 // Create TestContext for mode-aware fixture management
436 let ctx = TestContext::new(client);
437
438 // Step 1: Get RecursiveMaintainerStateDataPushed fixture
439 // This establishes: Owner -> Maintainer -> RecursiveMaintainer chain
440 // with all git data pushed. The recursive maintainer is already listed
441 // in maintainer's announcement (and maintainer in owner's announcement).
442 let recursive_state = ctx
443 .get_fixture(FixtureKind::RecursiveMaintainerStateDataPushed)
444 .await
445 .map_err(|e| {
446 format!(
447 "Test setup failed: could not get RecursiveMaintainerStateDataPushed fixture: {}",
448 e
449 )
450 })?;
451
452 // Extract repo_id from the recursive maintainer's state event
453 let repo_id = recursive_state
454 .tags
455 .iter()
456 .find(|t| t.kind() == TagKind::d())
457 .and_then(|t| t.content())
458 .ok_or("Missing d tag in recursive maintainer state")?
459 .to_string();
460
461 // Step 2: Build a recursive maintainer announcement that DOES NOT include
462 // this GRASP server in its clone tag - simulating an announcement pointing
463 // to a different server (e.g., another GRASP server)
464 let recursive_maintainer_npub = client
465 .recursive_maintainer_keys()
466 .public_key()
467 .to_bech32()
468 .map_err(|e| format!("Failed to convert recursive maintainer pubkey: {}", e))?;
469
470 // Create announcement with external clone URL (not this server)
471 let recursive_maintainer_announcement = client
472 .event_builder(
473 Kind::GitRepoAnnouncement,
474 format!(
475 "Recursive maintainer announcement for {} (external clone)",
476 repo_id
477 ),
478 )
479 .tag(Tag::identifier(&repo_id))
480 .tag(Tag::custom(
481 TagKind::custom("name"),
482 vec![format!("{} (recursive maintainer view)", repo_id)],
483 ))
484 // Clone points to another server, NOT the GRASP server
485 .tag(Tag::custom(
486 TagKind::custom("clone"),
487 vec![format!(
488 "https://another-grasp-server.com/{}/{}.git",
489 recursive_maintainer_npub, repo_id
490 )],
491 ))
492 // Relays also points elsewhere (not this server)
493 .tag(Tag::custom(
494 TagKind::custom("relays"),
495 vec!["wss://relay.damus.io"],
496 ))
497 .build(client.recursive_maintainer_keys())
498 .map_err(|e| format!("Failed to build recursive maintainer announcement: {}", e))?;
499
500 let event_id = recursive_maintainer_announcement.id;
501
502 // Step 3: Send the recursive maintainer announcement
503 client
504 .send_event(recursive_maintainer_announcement)
505 .await
506 .map_err(|e| format!("Failed to send recursive maintainer announcement: {}", e))?;
507
508 // Wait for propagation
509 tokio::time::sleep(Duration::from_millis(200)).await;
510
511 // Step 4: Query to verify it was accepted
512 let filter = Filter::new()
513 .kind(Kind::GitRepoAnnouncement)
514 .author(client.recursive_maintainer_keys().public_key())
515 .identifier(&repo_id);
516
517 let events = client
518 .query(filter)
519 .await
520 .map_err(|e| format!("Failed to query events: {}", e))?;
521
522 // Verify the recursive maintainer's announcement was stored
523 if !events.iter().any(|e| e.id == event_id) {
524 return Err(format!(
525 "Recursive maintainer announcement was NOT accepted by relay. \
526 The recursive maintainer (listed in maintainer's announcement, which is \
527 listed in owner's announcement) published their own announcement for \
528 repo {} with an external clone URL. The relay should accept this to \
529 enable full recursive maintainer chain discovery. Event ID: {}",
530 repo_id, event_id
531 ));
532 }
533
534 Ok(())
535 })
536 .await
537 }
538
407 // ============================================================ 539 // ============================================================
408 // Repository State Announcement Tests 540 // Repository State Announcement Tests
409 // ============================================================ 541 // ============================================================
@@ -432,12 +564,15 @@ impl EventAcceptancePolicyTests {
432 // 2. Pushes git data with the deterministic commit 564 // 2. Pushes git data with the deterministic commit
433 // 3. Sends the state announcement 565 // 3. Sends the state announcement
434 // This ensures the state event references a commit that actually exists 566 // This ensures the state event references a commit that actually exists
435 let state_event = ctx.get_fixture(FixtureKind::OwnerStateDataPushed).await.map_err(|e| { 567 let state_event = ctx
436 format!( 568 .get_fixture(FixtureKind::OwnerStateDataPushed)
437 "Test setup failed: could not get repository state fixture: {}", 569 .await
438 e 570 .map_err(|e| {
439 ) 571 format!(
440 })?; 572 "Test setup failed: could not get repository state fixture: {}",
573 e
574 )
575 })?;
441 576
442 // Extract repo_id from the state event 577 // Extract repo_id from the state event
443 let repo_id = state_event 578 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 {
39/// 39///
40/// Validates all events according to GRASP-01 specification: 40/// Validates all events according to GRASP-01 specification:
41/// - Repository announcements must list service in clone and relays tags 41/// - Repository announcements must list service in clone and relays tags
42/// EXCEPTION: Recursive maintainer announcements are accepted even without
43/// listing the service, to enable maintainer chain discovery and GRASP-02 sync
42/// - Repository state announcements must have valid structure 44/// - Repository state announcements must have valid structure
43/// - Other events must reference accepted repositories or events 45/// - Other events must reference accepted repositories or events
44/// - Forward references are supported (events referenced by accepted events) 46/// - Forward references are supported (events referenced by accepted events)
@@ -442,6 +444,57 @@ impl Nip34WritePolicy {
442 result 444 result
443 } 445 }
444 446
447 /// Check if a pubkey is listed as a maintainer in any announcement for this identifier
448 ///
449 /// A pubkey is considered a maintainer if:
450 /// 1. They are the owner (pubkey) of an accepted announcement with this identifier, OR
451 /// 2. They are listed in the maintainers tag of ANY announcement with this identifier
452 ///
453 /// This enables accepting announcements from maintainers even when they don't list
454 /// this GRASP server, for maintainer chain discovery and GRASP-02 sync.
455 async fn is_maintainer_in_any_announcement(
456 database: &SharedDatabase,
457 identifier: &str,
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 );
467
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
445 /// Extract all reference tags from an event (a, A, q, e, E) 498 /// Extract all reference tags from an event (a, A, q, e, E)
446 /// Returns (addressable_refs, event_refs) 499 /// Returns (addressable_refs, event_refs)
447 fn extract_reference_tags(event: &Event) -> (Vec<String>, Vec<EventId>) { 500 fn extract_reference_tags(event: &Event) -> (Vec<String>, Vec<EventId>) {
@@ -862,43 +915,102 @@ impl WritePolicy for Nip34WritePolicy {
862 let event_id_str = event.id.to_bech32().unwrap_or_else(|_| event.id.to_hex()); 915 let event_id_str = event.id.to_bech32().unwrap_or_else(|_| event.id.to_hex());
863 916
864 match event.kind.as_u16() { 917 match event.kind.as_u16() {
865 KIND_REPOSITORY_ANNOUNCEMENT => match validate_announcement(event, &domain) { 918 KIND_REPOSITORY_ANNOUNCEMENT => {
866 Ok(_) => { 919 // First, try normal validation (announcement lists service)
867 // Parse announcement to get repository details 920 match validate_announcement(event, &domain) {
868 match RepositoryAnnouncement::from_event(event.clone()) { 921 Ok(_) => {
869 Ok(announcement) => { 922 // Parse announcement to get repository details
870 // Try to create bare repository if it doesn't exist 923 match RepositoryAnnouncement::from_event(event.clone()) {
871 if let Err(e) = self.ensure_bare_repository(&announcement) { 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) => {
872 tracing::warn!( 943 tracing::warn!(
873 "Failed to create bare repository for {}: {}", 944 "Failed to parse repository announcement {}: {}",
874 event_id_str, 945 event_id_str,
875 e 946 e
876 ); 947 );
877 // Note: We still accept the event even if repo creation fails 948 PolicyResult::Reject(format!(
878 // The git operation failure shouldn't prevent event acceptance 949 "Failed to parse announcement: {}",
950 e
951 ))
879 } 952 }
880
881 tracing::debug!(
882 "Accepted repository announcement: {}",
883 event_id_str
884 );
885 PolicyResult::Accept
886 } 953 }
887 Err(e) => { 954 }
888 tracing::warn!( 955 Err(validation_err) => {
889 "Failed to parse repository announcement {}: {}", 956 // Validation failed - check if this is a recursive maintainer announcement
890 event_id_str, 957 // GRASP-01 Exception: Accept announcements from recursive maintainers
891 e 958 // even without listing the service, for chain discovery and GRASP-02 sync
892 ); 959
893 PolicyResult::Reject(format!("Failed to parse announcement: {}", e)) 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 }
894 } 1010 }
895 } 1011 }
896 } 1012 }
897 Err(e) => { 1013 }
898 tracing::warn!("Rejected repository announcement {}: {}", event_id_str, e);
899 PolicyResult::Reject(e.to_string())
900 }
901 },
902 KIND_REPOSITORY_STATE => match validate_state(event) { 1014 KIND_REPOSITORY_STATE => match validate_state(event) {
903 Ok(_) => { 1015 Ok(_) => {
904 // Parse state to get HEAD and branch info 1016 // 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 {
59isolated_test!(test_accept_valid_repo_announcement); 59isolated_test!(test_accept_valid_repo_announcement);
60isolated_test!(test_reject_repo_announcement_missing_clone_tag); 60isolated_test!(test_reject_repo_announcement_missing_clone_tag);
61isolated_test!(test_reject_repo_announcement_missing_relays_tag); 61isolated_test!(test_reject_repo_announcement_missing_relays_tag);
62isolated_test!(test_accept_maintainer_announcement_without_service_listed);
62isolated_test!(test_accept_valid_repo_state_announcement); 63isolated_test!(test_accept_valid_repo_state_announcement);
63isolated_test!(test_accept_issue_via_a_tag); 64isolated_test!(test_accept_issue_via_a_tag);
64isolated_test!(test_accept_comment_via_capital_a_tag); 65isolated_test!(test_accept_comment_via_capital_a_tag);