diff options
| author | DanConwayDev <DanConwayDev@protonmail.com> | 2025-12-03 17:06:59 +0000 |
|---|---|---|
| committer | DanConwayDev <DanConwayDev@protonmail.com> | 2025-12-03 17:06:59 +0000 |
| commit | de683147779eaf57376a90e73bbdd123846a01e3 (patch) | |
| tree | 3f4ecaf6e49bdb3d7b338df669ecd8cead98e2e6 | |
| parent | dd1b44132199aa72c2b699e1160fbe6b885f0ef6 (diff) | |
feat: accept maintainer announcements without service listing
| -rw-r--r-- | grasp-audit/src/specs/grasp01/event_acceptance_policy.rs | 149 | ||||
| -rw-r--r-- | src/nostr/builder.rs | 168 | ||||
| -rw-r--r-- | 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 @@ | |||
| 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 | ||
| 91 | use crate::fixtures::{send_and_verify_accepted, send_and_verify_rejected}; | 94 | use crate::fixtures::{send_and_verify_accepted, send_and_verify_rejected}; |
| 92 | use crate::{AuditClient, AuditResult, FixtureKind, TestContext, TestResult}; | 95 | use crate::{AuditClient, AuditResult, FixtureKind, TestContext, TestResult}; |
| 93 | use nostr_sdk::{Event, Filter, Kind, Tag, TagKind, Timestamp}; | 96 | use nostr_sdk::{Event, Filter, Kind, Tag, TagKind, Timestamp, ToBech32}; |
| 94 | use std::time::Duration; | 97 | use 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 { | |||
| 59 | isolated_test!(test_accept_valid_repo_announcement); | 59 | isolated_test!(test_accept_valid_repo_announcement); |
| 60 | isolated_test!(test_reject_repo_announcement_missing_clone_tag); | 60 | isolated_test!(test_reject_repo_announcement_missing_clone_tag); |
| 61 | isolated_test!(test_reject_repo_announcement_missing_relays_tag); | 61 | isolated_test!(test_reject_repo_announcement_missing_relays_tag); |
| 62 | isolated_test!(test_accept_maintainer_announcement_without_service_listed); | ||
| 62 | isolated_test!(test_accept_valid_repo_state_announcement); | 63 | isolated_test!(test_accept_valid_repo_state_announcement); |
| 63 | isolated_test!(test_accept_issue_via_a_tag); | 64 | isolated_test!(test_accept_issue_via_a_tag); |
| 64 | isolated_test!(test_accept_comment_via_capital_a_tag); | 65 | isolated_test!(test_accept_comment_via_capital_a_tag); |