mirror of
https://relay.ngit.dev/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit-grasp.git
synced 2026-10-05 23:18:24 +00:00
feat: accept maintainer announcements without service listing
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user