feat: accept maintainer announcements without service listing

This commit is contained in:
DanConwayDev
2025-12-03 17:06:59 +00:00
parent dd1b441321
commit de68314777
3 changed files with 283 additions and 35 deletions
@@ -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
+140 -28
View File
@@ -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<bool, String> {
// 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<Event> = 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<String>, Vec<EventId>) {
@@ -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
+1
View File
@@ -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);