From 96cb60501a544230219fa1078578e6932bc3fde1 Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Wed, 19 Aug 2026 07:00:08 +0000 Subject: [PATCH] feat(nip34): require reciprocal announcements before maintainer state is authorized Follow the reciprocal membership rule from the refined NIP-34 maintainers model (nips 781590b): a pubkey listed as a maintainer is only *invited* until its own announcement for the same identifier lists back an existing confirmed maintainer. State events from invited maintainers are no longer authorized; previously any pubkey reachable through recursive `maintainers` expansion was authorized without ever acknowledging the role. compute_membership replaces get_maintainers_recursive as the single membership computation: a fixpoint over announcements that confirms a listed pubkey once their own announcement lists back a confirmed maintainer. The owner is always a confirmed maintainer of their own repository, since announcing a repository in their namespace is what creates it on this service. collect_authorized_maintainers keeps its signature so all state-authorization call sites pick up the new semantics unchanged. Invited maintainers' announcements are deliberately still fetched, accepted (maintainer exception) and walked by the sync dependency machinery - the reciprocal announcement is exactly how the relay notices an invitation was accepted. To complete that flow, an acceptance stored via the maintainer exception is now pre-saved and stored state events are re-applied (new reapply_stored flag on process_state_event) so the newly confirmed maintainer's latest state re-points the owner's repository without another push; without this the stored state short-circuited as a duplicate and the invitation flow stalled. Remove the dead pre-membership helpers (AuthorizationContext, find_latest_state_for_announcement, find_latest_authorized_state, is_latest_state) that encoded the superseded semantics and had no other callers. Tests updated: invited state is rejected until acceptance (tests/state_authorization.rs), the invitation sync test now asserts the owner's refs are withheld until the invitee accepts, and grasp-audit maintainer fixtures publish reciprocal announcements. Scope deliberately excluded: the indexed `M`/`m` role tags and the moderator role from nips 986edd1 land in follow-up commits; this commit changes membership semantics for the deprecated `maintainers` tag only. Validation: git::authorization unit tests, state_authorization suite, sync invitation tests and grasp-audit lib tests all pass. --- docs/explanation/architecture.md | 26 +- docs/explanation/decisions.md | 34 + grasp-audit/src/fixtures.rs | 109 +++- .../specs/grasp01/event_acceptance_policy.rs | 8 + grasp-audit/src/specs/grasp01/purgatory.rs | 37 ++ src/git/authorization.rs | 587 ++++++------------ src/nostr/builder.rs | 24 +- src/nostr/events.rs | 18 + src/nostr/policy/state.rs | 20 +- tests/state_authorization.rs | 72 ++- tests/sync/maintainer_reprocessing.rs | 62 +- 11 files changed, 578 insertions(+), 419 deletions(-) diff --git a/docs/explanation/architecture.md b/docs/explanation/architecture.md index 63f0ed5..348f652 100644 --- a/docs/explanation/architecture.md +++ b/docs/explanation/architecture.md @@ -281,6 +281,22 @@ pub struct RepositoryAnnouncement { ... } pub struct RepositoryState { ... } ``` +#### Maintainer Membership Model + +Authorization follows a reciprocal membership rule, computed per owner by +[`compute_membership`](src/git/authorization.rs): + +- A pubkey listed as a maintainer is only **invited** until its own + announcement for the same identifier lists back an existing confirmed + maintainer. Invited pubkeys' announcements are still fetched and accepted + (that is how acceptance is noticed), but none of their events are + authoritative. +- Confirmation is a fixpoint, so maintainers listed by other confirmed + maintainers are reached recursively. +- The owner is always a confirmed maintainer of their own repository: + announcing a repository in their namespace is what creates it on this + service. + #### [`policy/state.rs`](src/nostr/policy/state.rs) - State Event Authorization State events undergo authorization checks at multiple points: @@ -288,7 +304,8 @@ State events undergo authorization checks at multiple points: ```rust /// State event authorization checks: /// 1. Announcement must exist for the repository identifier -/// 2. Author must be in maintainer set of accepted announcement +/// 2. Author must be a confirmed maintainer of an accepted announcement - +/// invited pubkeys are rejected /// 3. Validated on arrival, announcement acceptance, and git data arrival ``` @@ -439,7 +456,7 @@ Configuration is loaded via **clap CLI > environment variables > .env > defaults ↓ 5. authorization::get_authorization_for_owner() ├─ Query database for announcements - ├─ Build recursive maintainer set + ├─ Compute confirmed maintainer set (reciprocal membership) └─ Get latest authorized state ↓ 6. authorization::validate_push_refs() @@ -465,7 +482,8 @@ Configuration is loaded via **clap CLI > environment variables > .env > defaults 3. Nip34WritePolicy::admit_event() ├─ Check if instance in clone tags ├─ Check if instance in relays tags - ├─ OR: Check if recursive maintainer + ├─ OR: author is listed as a maintainer in a known announcement + │ (invited maintainers accepted for acceptance discovery) └─ Accept or reject ↓ 4. If ACCEPTED: @@ -498,7 +516,7 @@ Configuration is loaded via **clap CLI > environment variables > .env > defaults 2. Nostr relay receives event ↓ 3. Nip34WritePolicy::admit_event() - ├─ Check author is in maintainer set (DB + purgatory announcements) + ├─ Check author is a confirmed maintainer (DB + purgatory announcements) ├─ Validate state structure └─ Accept or reject ↓ diff --git a/docs/explanation/decisions.md b/docs/explanation/decisions.md index c72112d..d7a3d65 100644 --- a/docs/explanation/decisions.md +++ b/docs/explanation/decisions.md @@ -201,3 +201,37 @@ Implemented according to design specification in [`purgatory-design.md`](purgato - Design: [`purgatory-design.md`](purgatory-design.md) - Architecture: [`architecture.md`](architecture.md#5-purgatory-system-srcpurgatory) - Implementation Plan: [`../purgatory-implementation-plan.md`](../purgatory-implementation-plan.md) + +--- + +## Question: Who may publish authoritative repository state? + +**Decision (2026-08): maintainer membership is reciprocal** (following the +NIP-34 maintainers model refined in nips commit `781590b`). + +### The model + +- A pubkey listed as a maintainer in an announcement is only **invited** + until its own announcement for the same identifier lists back an existing + confirmed maintainer. +- Only confirmed maintainers publish authoritative repository state. +- The owner is always a confirmed maintainer of their own repository: + announcing a repository in their namespace is what creates it on this + service. + +### Implementation choices + +1. **State events from invited maintainers are rejected** as unauthorized + (previously any pubkey listed in a `maintainers` tag was authorized + recursively without reciprocity). The rejected-events index and purgatory + re-evaluation recover them automatically once the acceptance announcement + arrives. +2. **Invited maintainers' announcements are still fetched, accepted and + synced** (maintainer exception, discovery author sets, dependency + walkers): the reciprocal announcement is precisely how the relay learns + an invitation was accepted. +3. **Acceptance triggers reconciliation.** When an acceptance announcement is + stored via the maintainer exception, stored state events are re-applied + (`reapply_stored`) so a newly confirmed maintainer's latest state + re-points the owner's repository without another push. + diff --git a/grasp-audit/src/fixtures.rs b/grasp-audit/src/fixtures.rs index b275788..6cd9a5d 100644 --- a/grasp-audit/src/fixtures.rs +++ b/grasp-audit/src/fixtures.rs @@ -1174,14 +1174,75 @@ impl<'a> TestContext<'a> { vec![format!("{}/{}/{}.git", http_url, maintainer_npub, repo_id)], )) .tag(Tag::custom("relays", vec![relay_url])) + // List the owner back (reciprocal acknowledgment: NIP-34 treats a + // listed pubkey as invited until their own announcement assigns a + // role to an existing member) and assign the recursive maintainer. .tag(Tag::custom( "maintainers", - vec![self.client.recursive_maintainer_pubkey_hex()], + vec![ + self.client.keys().public_key().to_hex(), + self.client.recursive_maintainer_pubkey_hex(), + ], )) .build(self.client.maintainer_keys()) .map_err(|e| anyhow::anyhow!("Failed to build maintainer repo announcement: {}", e)) } + /// Build the recursive maintainer's reciprocal announcement for the given repo_id + /// + /// NIP-34 requires an assigned pubkey to acknowledge the role and assign a + /// role to an existing member before their events become authoritative. + /// The recursive maintainer lists the maintainer (who assigned them) back. + /// + /// The announcement deliberately points at another host: it is accepted via + /// the maintainer exception, confirms membership, and leaves this relay + /// free of a hosted repo view for the recursive maintainer. Later specs + /// replace it with other external announcements, which would be treated as + /// a de-list request if this one listed the service. + async fn build_recursive_maintainer_reciprocal_announcement( + &self, + repo_id: &str, + ) -> Result { + use nostr_sdk::prelude::*; + + let recursive_maintainer_npub = self + .client + .recursive_maintainer_keys() + .public_key() + .to_bech32() + .map_err(|e| anyhow::anyhow!("Failed to convert recursive maintainer pubkey: {}", e))?; + + self.client + .event_builder( + Kind::GitRepoAnnouncement, + format!("Recursive maintainer announcement for {}", repo_id), + ) + .tag(Tag::identifier(repo_id)) + .tag(Tag::custom( + "name", + vec![format!("{} (recursive maintainer)", repo_id)], + )) + .tag(Tag::custom( + "clone", + vec![format!( + "https://another-grasp-server.com/{}/{}.git", + recursive_maintainer_npub, repo_id + )], + )) + .tag(Tag::custom("relays", vec!["wss://relay.damus.io"])) + .tag(Tag::custom( + "maintainers", + vec![self.client.maintainer_pubkey_hex()], + )) + .build(self.client.recursive_maintainer_keys()) + .map_err(|e| { + anyhow::anyhow!( + "Failed to build recursive maintainer reciprocal announcement: {}", + e + ) + }) + } + /// Extract repo_id from a repo announcement event fn extract_repo_id(&self, repo: &Event) -> Result { repo.tags @@ -1471,15 +1532,18 @@ impl<'a> TestContext<'a> { /// Build MaintainerStateDataPushed fixture: full 5-stage fixture for maintainer push authorization /// - /// This tests that a maintainer can authorize pushes with ONLY a state event, - /// without publishing their own repo announcement. + /// This tests that a confirmed maintainer can authorize pushes with a state + /// event. Per NIP-34 a listed pubkey is only *invited* until their own + /// announcement acknowledges the role and lists back an existing member, so + /// the maintainer's reciprocal announcement is published before their state + /// event becomes authoritative. /// /// Depends on OwnerStateDataPushed - the owner's data has already been pushed. /// The maintainer force-pushes their commit on top. /// /// This handles all stages of the fixture: /// 1. **OwnerStateDataPushed dependency**: Owner's repo and state event already on relay, git data pushed - /// 2. **Sent**: Sends maintainer state event to relay (returns OK, accepted but 'purgatory:...' message) + /// 2. **Sent**: Sends maintainer reciprocal announcement + state event to relay /// 3. **Verify Not Served**: Confirms event is not served by relays /// 4. **DataPushed**: Clones repo, creates maintainer deterministic commit, force-pushes to relay /// 5. **Verified**: Confirms event is served by relay @@ -1500,7 +1564,13 @@ impl<'a> TestContext<'a> { // Get the repo (ValidRepoSent, also cached) for the owner's npub let repo = self.get_cached_dependency(FixtureKind::ValidRepoSent)?; - // Build maintainer's state event (state event ONLY - no announcement) + // Publish the maintainer's reciprocal announcement first: without it + // the maintainer is merely invited and their state event would be + // rejected as unauthorized. + let maintainer_announcement = self.build_maintainer_announcement(&repo_id).await?; + self.client.send_event(maintainer_announcement).await?; + + // Build maintainer's state event let base_time = Timestamp::now().as_secs(); let maintainer_timestamp = Timestamp::from(base_time - 5); // 5 seconds ago (more recent than owner's state) @@ -1611,10 +1681,10 @@ impl<'a> TestContext<'a> { if !res { return Err(anyhow::anyhow!( "Push was rejected but should have been accepted. \ - The maintainer published a state event with commit {}, \ - and even without a separate announcement, the relay should \ - authorize pushes matching this state event since the maintainer \ - is listed in the owner's announcement.", + The maintainer published a reciprocal announcement and a state \ + event with commit {}; as a confirmed member of the owner's \ + maintainer set the relay should authorize pushes matching this \ + state event.", MAINTAINER_DETERMINISTIC_COMMIT_HASH )); } @@ -1645,9 +1715,11 @@ impl<'a> TestContext<'a> { /// The recursive maintainer is listed in the maintainer's announcement, not the owner's announcement, /// so this tests the recursive maintainer traversal (Owner -> Maintainer -> RecursiveMaintainer). /// - /// Depends on MaintainerStateDataPushed - the maintainer's data has already been pushed. - /// We then send the MaintainerAnnouncement (which lists the recursive maintainer), and the - /// recursive maintainer force-pushes their commit on top. + /// Depends on MaintainerStateDataPushed - the maintainer's data has already been + /// pushed and the maintainer's announcement (which assigns the recursive + /// maintainer) is on the relay. We then send the recursive maintainer's + /// reciprocal announcement (confirming them as a member), and the recursive + /// maintainer force-pushes their commit on top. /// /// This handles all stages of the fixture: /// 1. **Generated**: (MaintainerStateDataPushed dependency includes ValidRepoSent + OwnerStateDataPushed) @@ -1675,10 +1747,17 @@ impl<'a> TestContext<'a> { let repo = self.get_cached_dependency(FixtureKind::ValidRepoSent)?; // ============================================================ - // Stage 1 (continued): Generate MaintainerAnnouncement and RecursiveMaintainerState + // Stage 1 (continued): Generate the recursive maintainer's reciprocal + // announcement and their state event. The maintainer's announcement + // (which assigns the recursive maintainer) was already published by the + // MaintainerStateDataPushed dependency; the reciprocal announcement + // confirms the recursive maintainer as a member so their state event + // becomes authoritative. // ============================================================ - let maintainer_announcement = self.build_maintainer_announcement(&repo_id).await?; - self.client.send_event(maintainer_announcement).await?; + let reciprocal_announcement = self + .build_recursive_maintainer_reciprocal_announcement(&repo_id) + .await?; + self.client.send_event(reciprocal_announcement).await?; // Build recursive maintainer's state event let base_time = Timestamp::now().as_secs(); diff --git a/grasp-audit/src/specs/grasp01/event_acceptance_policy.rs b/grasp-audit/src/specs/grasp01/event_acceptance_policy.rs index 91d8e72..69be2e1 100644 --- a/grasp-audit/src/specs/grasp01/event_acceptance_policy.rs +++ b/grasp-audit/src/specs/grasp01/event_acceptance_policy.rs @@ -483,6 +483,14 @@ impl EventAcceptancePolicyTests { "relays", vec!["wss://relay.damus.io"], )) + // List the assigning maintainer back: this replaceable event + // supersedes the fixture's reciprocal announcement, and NIP-34 + // demotes a member to invited if their announcement stops + // assigning a role to an existing member. + .tag(Tag::custom( + "maintainers", + vec![client.maintainer_pubkey_hex()], + )) .build(client.recursive_maintainer_keys()) .map_err(|e| format!("Failed to build recursive maintainer announcement: {}", e))?; diff --git a/grasp-audit/src/specs/grasp01/purgatory.rs b/grasp-audit/src/specs/grasp01/purgatory.rs index 36c027e..ff2fbc4 100644 --- a/grasp-audit/src/specs/grasp01/purgatory.rs +++ b/grasp-audit/src/specs/grasp01/purgatory.rs @@ -872,6 +872,43 @@ impl PurgatoryTests { tokio::time::sleep(Duration::from_millis(200)).await; + // Stage 3b: the new maintainer publishes a reciprocal announcement + // listing the owner back. Per NIP-34 they are merely invited (and + // unauthorized) until their own announcement acknowledges the role + // and assigns a role to an existing member. It points at another + // host and is accepted via the maintainer exception. + let new_maintainer_npub = new_maintainer_keys + .public_key() + .to_bech32() + .map_err(|e| e.to_string())?; + let reciprocal_announcement = client + .event_builder(Kind::GitRepoAnnouncement, "") + .tag(Tag::identifier(&repo_id)) + .tag(Tag::custom( + "clone", + vec![format!( + "https://another-grasp-server.com/{}/{}.git", + new_maintainer_npub, repo_id + )], + )) + .tag(Tag::custom( + "relays", + vec!["wss://relay.damus.io".to_string()], + )) + .tag(Tag::custom( + "maintainers", + vec![client.public_key().to_hex()], + )) + .build(&new_maintainer_keys) + .map_err(|e| format!("Failed to build reciprocal announcement: {}", e))?; + + client + .send_event(reciprocal_announcement) + .await + .map_err(|e| format!("Relay rejected reciprocal announcement: {}", e))?; + + tokio::time::sleep(Duration::from_millis(200)).await; + // Stage 4: clone the repo and create a unique commit (not pushed yet) let relay_domain = relay_url .trim_start_matches("ws://") diff --git a/src/git/authorization.rs b/src/git/authorization.rs index dfc1f8a..6897741 100644 --- a/src/git/authorization.rs +++ b/src/git/authorization.rs @@ -10,16 +10,16 @@ //! ## Authorization Flow (Efficient Single-Query Approach) //! //! 1. Fetch announcement and state events for the repository from the relay database -//! 2. Collect all authorized publishers: announcement authors + listed maintainers -//! 3. Find the latest state event authored by any authorized publisher +//! 2. Compute the confirmed maintainer set for the owner's repository +//! 3. Find the latest state event authored by a confirmed maintainer //! 4. Validate that the pushed refs match the state event //! //! ## Authorization Logic //! -//! A pubkey is authorized to publish state events if, for ANY announcement with the -//! same identifier: -//! - They are the author of that announcement, OR -//! - They are listed in the "maintainers" tag of that announcement +//! Maintainership is reciprocal. A pubkey listed as a maintainer in an +//! announcement is only *invited*: none of its events are authoritative until +//! its own announcement for the same identifier lists back an existing +//! confirmed maintainer. See [`compute_membership`]. //! //! ## Shared Helper Functions //! @@ -312,22 +312,94 @@ pub fn pubkey_authorised_for_repo_owners( repo_owners_authorising_pubkey.iter().cloned().collect() } -/// Collect authorized maintainers grouped by owner from a set of announcements +/// Confirmed membership of one owner's repository. +#[derive(Debug, Default)] +pub struct RepoMembership { + /// Pubkeys whose repository state events are authoritative for this + /// owner's repository: the owner plus every confirmed maintainer. + pub state_maintainers: HashSet, + /// Pubkeys currently listed as maintainers whose own announcement does + /// not yet list back a confirmed maintainer. Their announcements must + /// still be fetched and accepted - that is how the relay notices an + /// invitation was accepted - but none of their events are authoritative. + pub invited: HashSet, +} + +/// Compute the confirmed maintainer set for `owner`'s repository. /// -/// For each announcement, returns a map from owner pubkey to authorized maintainers: -/// - The owner is always included in their own list -/// - All pubkeys listed in the "maintainers" tag are also included -/// - **Recursively**: if a maintainer also has an announcement for the same identifier, -/// their maintainers are included too (transitive closure) +/// A pubkey listed as a maintainer is only *invited* until its own +/// announcement for the same identifier lists back a pubkey that is already +/// a confirmed maintainer (reciprocal acknowledgment). Confirmation is +/// evaluated as a fixpoint, so maintainers listed by other confirmed +/// maintainers are reached recursively. /// -/// This allows looking up who can publish state events for a specific owner's -/// version of the repository. +/// The owner is always a confirmed maintainer of their own repository: +/// announcing a repository in their namespace is what creates it on this +/// service. +pub fn compute_membership( + announcements: &[RepositoryAnnouncement], + owner: &str, + identifier: &str, +) -> RepoMembership { + let find = |pubkey: &str| { + announcements + .iter() + .find(|a| a.event.pubkey.to_hex() == pubkey && a.identifier == identifier) + }; + if find(owner).is_none() { + return RepoMembership::default(); + } + + let mut confirmed: HashSet = HashSet::from([owner.to_string()]); + // Fixpoint: keep confirming listed pubkeys whose own announcement lists + // back an already-confirmed maintainer. The confirmed set only grows, so + // the loop terminates. + loop { + let listed: HashSet = confirmed + .iter() + .filter_map(|member| find(member)) + .flat_map(|announcement| announcement.listed_maintainers()) + .collect(); + + let mut progressed = false; + for candidate in &listed { + if confirmed.contains(candidate) { + continue; + } + let Some(candidate_announcement) = find(candidate) else { + continue; // invited: no announcement of their own yet + }; + let lists_back = candidate_announcement + .listed_maintainers() + .iter() + .any(|pubkey| confirmed.contains(pubkey)); + if lists_back { + confirmed.insert(candidate.clone()); + progressed = true; + } + } + + if !progressed { + let invited = listed + .into_iter() + .filter(|pubkey| !confirmed.contains(pubkey)) + .collect(); + return RepoMembership { + state_maintainers: confirmed, + invited, + }; + } + } +} + +/// Collect authorized state publishers grouped by owner from a set of +/// announcements. /// -/// ## Example -/// -/// If Alice's announcement lists Bob as maintainer, and Bob's announcement (for the -/// same identifier) lists Charlie as maintainer, then Alice's authorized set will -/// be {Alice, Bob, Charlie}. +/// For each announcement, returns a map from owner pubkey to the pubkeys +/// whose repository state events are authoritative for that owner's +/// repository: the confirmed maintainer set computed by +/// [`compute_membership`]. Invited pubkeys (listed but without a reciprocal +/// announcement) are never included. pub fn collect_authorized_maintainers( announcements: &[RepositoryAnnouncement], ) -> HashMap> { @@ -335,17 +407,12 @@ pub fn collect_authorized_maintainers( for announcement in announcements { let owner = announcement.event.pubkey.to_hex(); - let identifier = &announcement.identifier; - - // Use recursive helper to get all maintainers - let mut checked: HashSet = HashSet::new(); - get_maintainers_recursive(announcements, &owner, identifier, &mut checked); - - by_owner.insert(owner, checked.into_iter().collect()); + let membership = compute_membership(announcements, &owner, &announcement.identifier); + by_owner.insert(owner, membership.state_maintainers.into_iter().collect()); } debug!( - "Collected maintainers for {} owners from {} announcements (with recursive expansion)", + "Collected confirmed state maintainers for {} owners from {} announcements", by_owner.len(), announcements.len() ); @@ -353,103 +420,6 @@ pub fn collect_authorized_maintainers( by_owner } -/// Recursively find all maintainers starting from a pubkey -/// -/// This follows the pattern from ngit-relay's GetMaintainers function: -/// - If pubkey already checked, return early (cycle prevention) -/// - Mark pubkey as checked -/// - Find the announcement for this pubkey+identifier -/// - Recursively call for each maintainer listed in that announcement -/// - The `checked` set accumulates all visited pubkeys -fn get_maintainers_recursive( - announcements: &[RepositoryAnnouncement], - pubkey: &str, - identifier: &str, - checked: &mut HashSet, -) { - // Check if this pubkey has already been processed - if checked.contains(pubkey) { - return; // Already checked - avoid cycles - } - checked.insert(pubkey.to_string()); // Mark as checked - - // Find the announcement event for this pubkey+identifier - let announcement = announcements - .iter() - .find(|a| a.event.pubkey.to_hex() == pubkey && a.identifier == identifier); - - let Some(announcement) = announcement else { - return; // No announcement found for this pubkey - }; - - // Recursively find maintainers for each listed maintainer - for maintainer_pubkey in &announcement.maintainers { - get_maintainers_recursive(announcements, maintainer_pubkey, identifier, checked); - } -} - -/// Find the latest state event authored by an authorized maintainer -/// -/// Returns the state with the highest created_at timestamp among those -/// authored by pubkeys in the authorized set. -pub fn find_latest_authorized_state<'a>( - states: &'a [RepositoryState], - authorized_pubkeys: &HashSet, -) -> Option<&'a RepositoryState> { - states - .iter() - .filter(|s| { - let pubkey_hex = s.event.pubkey.to_hex(); - authorized_pubkeys.contains(&pubkey_hex) - }) - .max_by_key(|s| s.event.created_at) -} - -/// Find the latest authorized state for a specific announcement context -/// -/// This is similar to `find_latest_authorized_state` but considers only -/// the maintainers authorized for a specific announcement (owner + maintainers), -/// not the global set across all announcements. -pub fn find_latest_state_for_announcement<'a>( - states: &'a [RepositoryState], - announcement: &RepositoryAnnouncement, -) -> Option<&'a RepositoryState> { - // Build the authorized set for this specific announcement - let mut authorized = HashSet::new(); - authorized.insert(announcement.event.pubkey.to_hex()); - for maintainer in &announcement.maintainers { - authorized.insert(maintainer.clone()); - } - - find_latest_authorized_state(states, &authorized) -} - -/// Check if a state event is the latest for its identifier among given authorized authors -/// -/// A state is considered "latest" if no other state in the provided list -/// from an authorized author has a newer timestamp. -pub fn is_latest_state( - state: &RepositoryState, - all_states: &[RepositoryState], - authorized_pubkeys: &HashSet, -) -> bool { - for other in all_states { - // Skip self - if other.event.id == state.event.id { - continue; - } - // Only compare against authorized authors - if !authorized_pubkeys.contains(&other.event.pubkey.to_hex()) { - continue; - } - // If any authorized state is newer, this is not the latest - if other.event.created_at > state.event.created_at { - return false; - } - } - true -} - /// Get the authorization result for a repository scoped to a specific owner /// /// Push authorization checks ONLY purgatory for state events. The database represents @@ -726,17 +696,6 @@ pub struct AuthorizationResult { } impl AuthorizationResult { - /// Create a successful authorization result - pub fn authorized(state: RepositoryState, maintainers: Vec) -> Self { - Self { - authorized: true, - reason: "Push matches latest authorized state".to_string(), - state: Some(state), - maintainers, - purgatory_events: vec![], - } - } - /// Create a denied authorization result pub fn denied(reason: impl Into) -> Self { Self { @@ -749,166 +708,6 @@ impl AuthorizationResult { } } -/// Authorization context for push operations -pub struct AuthorizationContext { - /// Events fetched from the relay (announcements and states) - events: Vec, -} - -impl AuthorizationContext { - /// Create a new authorization context from fetched events - pub fn new(events: Vec) -> Self { - Self { events } - } - - /// Create a filter to fetch announcement and state events for a repository - /// - /// This matches the reference implementation's filter logic - pub fn create_filter(identifier: &str) -> Filter { - Filter::new() - .kinds([Kind::GitRepoAnnouncement, Kind::RepoState]) - .custom_tag(SingleLetterTag::LOWERCASE_D, identifier.to_string()) - } - - /// Get the latest authorized state for a repository - /// - /// This implements the GRASP-01 requirement using an efficient single-query approach: - /// - Collect all authorized publishers from announcements - /// - Find the latest state event from any authorized publisher - /// - /// No owner_pubkey needed - authorization is determined by announcements themselves. - pub fn get_authorized_state(&self, identifier: &str) -> Result { - // Collect all authorized publishers (single pass through announcements) - let authorized_publishers = self.get_authorized_publishers(identifier); - - if authorized_publishers.is_empty() { - return Ok(AuthorizationResult::denied( - "No repository announcement found", - )); - } - - debug!( - "Found {} authorized publishers for repository {}: {:?}", - authorized_publishers.len(), - identifier, - authorized_publishers - ); - - // Find the latest state event from any authorized publisher - let mut latest_state: Option = None; - let mut latest_timestamp = Timestamp::from(0); - - for event in &self.events { - // Check if it's a repository state event - if event.kind != Kind::RepoState { - continue; - } - - // Check if from an authorized publisher - let pubkey_hex = event.pubkey.to_hex(); - if !authorized_publishers.contains(&pubkey_hex) { - debug!( - "Skipping state event from unauthorized publisher: {}", - pubkey_hex - ); - continue; - } - - // Try to parse the state - if let Ok(state) = RepositoryState::from_event(event.clone()) { - // Check identifier matches - if state.identifier != identifier { - continue; - } - - // Check if this is the latest - if event.created_at > latest_timestamp { - latest_timestamp = event.created_at; - latest_state = Some(state); - } - } - } - - match latest_state { - Some(state) => Ok(AuthorizationResult::authorized( - state, - authorized_publishers.into_iter().collect(), - )), - None => Ok(AuthorizationResult::denied( - "No state event found from authorized publishers", - )), - } - } - - /// Get all pubkeys authorized to publish state for an identifier - /// - /// A pubkey is authorized if for ANY announcement with the same identifier: - /// - They are the author of that announcement, OR - /// - They are listed in the "maintainers" tag of that announcement - /// - /// This is a simple O(n) single pass - no recursion needed. - fn get_authorized_publishers(&self, identifier: &str) -> HashSet { - let mut authorized = HashSet::new(); - - for event in &self.events { - // Only look at announcements - if event.kind != Kind::GitRepoAnnouncement { - continue; - } - - // Try to parse and check identifier - if let Ok(announcement) = RepositoryAnnouncement::from_event(event.clone()) { - if announcement.identifier != identifier { - continue; - } - - // Announcement author is authorized - authorized.insert(event.pubkey.to_hex()); - - // All listed maintainers are also authorized - for maintainer in &announcement.maintainers { - authorized.insert(maintainer.clone()); - } - } - } - - authorized - } - - /// Check if a specific pubkey is authorized to publish state for an identifier - /// - /// A pubkey is authorized if for ANY announcement with the same identifier: - /// - They are the author of that announcement, OR - /// - They are listed in the "maintainers" tag of that announcement - #[allow(dead_code)] - pub fn is_state_authorized(&self, state_pubkey: &str, identifier: &str) -> bool { - for event in &self.events { - // Only look at announcements - if event.kind != Kind::GitRepoAnnouncement { - continue; - } - - // Try to parse and check identifier - if let Ok(announcement) = RepositoryAnnouncement::from_event(event.clone()) { - if announcement.identifier != identifier { - continue; - } - - // Check 1: Is state author the announcement author? - if event.pubkey.to_hex() == state_pubkey { - return true; - } - - // Check 2: Is state author in this announcement's maintainers? - if announcement.maintainers.contains(&state_pubkey.to_string()) { - return true; - } - } - } - false - } -} - /// Validate that pushed refs match the authorized state /// /// Takes the refs being pushed (ref name -> commit hash) and validates @@ -1452,126 +1251,154 @@ mod tests { .unwrap() } - #[test] - fn test_authorized_publishers_single_owner() { - let alice = create_test_keys(); - let identifier = "test-repo"; + fn parse(event: Event) -> RepositoryAnnouncement { + RepositoryAnnouncement::from_event(event).unwrap() + } - let announcement = create_announcement_event(&alice, identifier, &[]); - let events = vec![announcement]; - - let ctx = AuthorizationContext::new(events); - - // Alice should be authorized - assert!(ctx.is_state_authorized(&alice.public_key().to_hex(), identifier)); + fn hex(keys: &Keys) -> String { + keys.public_key().to_hex() } #[test] - fn test_authorized_publishers_with_listed_maintainer() { + fn test_owner_is_sole_state_maintainer() { + let alice = create_test_keys(); + let identifier = "test-repo"; + let announcements = vec![parse(create_announcement_event(&alice, identifier, &[]))]; + + let membership = compute_membership(&announcements, &hex(&alice), identifier); + assert_eq!(membership.state_maintainers, HashSet::from([hex(&alice)])); + assert!(membership.invited.is_empty()); + } + + #[test] + fn test_no_owner_announcement_means_no_membership() { let alice = create_test_keys(); let bob = create_test_keys(); let identifier = "test-repo"; - // Alice lists Bob as maintainer - let alice_announcement = create_announcement_event(&alice, identifier, &[&bob]); + // Only Bob has announced; Alice's repository has no membership. + let announcements = vec![parse(create_announcement_event(&bob, identifier, &[]))]; - let events = vec![alice_announcement]; - let ctx = AuthorizationContext::new(events); - - // Both Alice and Bob should be authorized - assert!(ctx.is_state_authorized(&alice.public_key().to_hex(), identifier)); - assert!(ctx.is_state_authorized(&bob.public_key().to_hex(), identifier)); + let membership = compute_membership(&announcements, &hex(&alice), identifier); + assert!(membership.state_maintainers.is_empty()); + assert!(membership.invited.is_empty()); } #[test] - fn test_authorized_publishers_multiple_announcements() { + fn test_listed_maintainer_without_announcement_is_invited() { + let alice = create_test_keys(); + let bob = create_test_keys(); + let identifier = "test-repo"; + + // Alice lists Bob, but Bob has published no announcement + let announcements = vec![parse(create_announcement_event( + &alice, + identifier, + &[&bob], + ))]; + + let membership = compute_membership(&announcements, &hex(&alice), identifier); + assert!(!membership.state_maintainers.contains(&hex(&bob))); + assert!(membership.invited.contains(&hex(&bob))); + + let by_owner = collect_authorized_maintainers(&announcements); + assert!(!by_owner[&hex(&alice)].contains(&hex(&bob))); + } + + #[test] + fn test_reciprocal_maintainer_is_confirmed() { + let alice = create_test_keys(); + let bob = create_test_keys(); + let identifier = "test-repo"; + + let announcements = vec![ + parse(create_announcement_event(&alice, identifier, &[&bob])), + parse(create_announcement_event(&bob, identifier, &[&alice])), + ]; + + let membership = compute_membership(&announcements, &hex(&alice), identifier); + assert!(membership.state_maintainers.contains(&hex(&alice))); + assert!(membership.state_maintainers.contains(&hex(&bob))); + assert!(membership.invited.is_empty()); + } + + #[test] + fn test_non_reciprocal_announcement_stays_invited() { let alice = create_test_keys(); let bob = create_test_keys(); let charlie = create_test_keys(); let identifier = "test-repo"; - // Alice lists Bob, Bob lists Charlie - let alice_announcement = create_announcement_event(&alice, identifier, &[&bob]); - let bob_announcement = create_announcement_event(&bob, identifier, &[&charlie]); + // Bob announced the same identifier but does not list Alice back + let announcements = vec![ + parse(create_announcement_event(&alice, identifier, &[&bob])), + parse(create_announcement_event(&bob, identifier, &[&charlie])), + ]; - let events = vec![alice_announcement, bob_announcement]; - let ctx = AuthorizationContext::new(events); - - // All three should be authorized (Alice, Bob from announcements; Bob, Charlie from maintainers) - assert!(ctx.is_state_authorized(&alice.public_key().to_hex(), identifier)); - assert!(ctx.is_state_authorized(&bob.public_key().to_hex(), identifier)); - assert!(ctx.is_state_authorized(&charlie.public_key().to_hex(), identifier)); + let membership = compute_membership(&announcements, &hex(&alice), identifier); + assert!(!membership.state_maintainers.contains(&hex(&bob))); + assert!(membership.invited.contains(&hex(&bob))); } #[test] - fn test_unauthorized_pubkey() { - let alice = create_test_keys(); - let bob = create_test_keys(); - let eve = create_test_keys(); // Not authorized - let identifier = "test-repo"; - - // Alice lists Bob as maintainer - let alice_announcement = create_announcement_event(&alice, identifier, &[&bob]); - - let events = vec![alice_announcement]; - let ctx = AuthorizationContext::new(events); - - // Eve should NOT be authorized - assert!(!ctx.is_state_authorized(&eve.public_key().to_hex(), identifier)); - } - - #[test] - fn test_get_authorized_state_with_maintainer() { + fn test_reciprocal_announcement_for_other_identifier_stays_invited() { let alice = create_test_keys(); let bob = create_test_keys(); let identifier = "test-repo"; - let announcement = create_announcement_event(&alice, identifier, &[&bob]); + // Bob lists Alice back, but under a different identifier + let announcements = vec![ + parse(create_announcement_event(&alice, identifier, &[&bob])), + parse(create_announcement_event(&bob, "other-repo", &[&alice])), + ]; - // Bob publishes a state event - let state = create_state_event(&bob, identifier, &[("main", "abc123")]); - - let events = vec![announcement, state]; - let ctx = AuthorizationContext::new(events); - - let result = ctx.get_authorized_state(identifier).unwrap(); - - assert!(result.authorized); - assert!(result.state.is_some()); - let state = result.state.unwrap(); - assert_eq!(state.get_branch_commit("main"), Some("abc123")); + let membership = compute_membership(&announcements, &hex(&alice), identifier); + assert!(!membership.state_maintainers.contains(&hex(&bob))); + assert!(membership.invited.contains(&hex(&bob))); } #[test] - fn test_get_authorized_state_no_announcement() { - let identifier = "test-repo"; - - let events = vec![]; - let ctx = AuthorizationContext::new(events); - - let result = ctx.get_authorized_state(identifier).unwrap(); - - assert!(!result.authorized); - assert_eq!(result.reason, "No repository announcement found"); - } - - #[test] - fn test_get_authorized_state_no_state_event() { + fn test_recursive_reciprocal_chain_confirmed() { let alice = create_test_keys(); + let bob = create_test_keys(); + let charlie = create_test_keys(); let identifier = "test-repo"; - let announcement = create_announcement_event(&alice, identifier, &[]); + // Alice <-> Bob, Bob -> Charlie, Charlie -> Bob + let announcements = vec![ + parse(create_announcement_event(&alice, identifier, &[&bob])), + parse(create_announcement_event( + &bob, + identifier, + &[&alice, &charlie], + )), + parse(create_announcement_event(&charlie, identifier, &[&bob])), + ]; - let events = vec![announcement]; - let ctx = AuthorizationContext::new(events); + let membership = compute_membership(&announcements, &hex(&alice), identifier); + assert!(membership.state_maintainers.contains(&hex(&alice))); + assert!(membership.state_maintainers.contains(&hex(&bob))); + assert!(membership.state_maintainers.contains(&hex(&charlie))); + } - let result = ctx.get_authorized_state(identifier).unwrap(); + #[test] + fn test_mutual_listing_without_owner_link_stays_invited() { + let alice = create_test_keys(); + let bob = create_test_keys(); + let charlie = create_test_keys(); + let identifier = "test-repo"; - assert!(!result.authorized); - assert_eq!( - result.reason, - "No state event found from authorized publishers" - ); + // Bob and Charlie list each other but neither lists Alice: a mutual + // clique disconnected from the owner never becomes confirmed. + let announcements = vec![ + parse(create_announcement_event(&alice, identifier, &[&bob])), + parse(create_announcement_event(&bob, identifier, &[&charlie])), + parse(create_announcement_event(&charlie, identifier, &[&bob])), + ]; + + let membership = compute_membership(&announcements, &hex(&alice), identifier); + assert_eq!(membership.state_maintainers, HashSet::from([hex(&alice)])); + assert!(membership.invited.contains(&hex(&bob))); } #[test] diff --git a/src/nostr/builder.rs b/src/nostr/builder.rs index 99fda7a..3586b2e 100644 --- a/src/nostr/builder.rs +++ b/src/nostr/builder.rs @@ -440,10 +440,28 @@ impl Nip34WritePolicy { ); // Don't create bare repository for external announcements + // Persist the announcement before re-evaluating state: + // membership is computed from the database and this may + // be an invited maintainer's acceptance. The relay + // builder's later save is an idempotent duplicate. + if let Err(e) = self.ctx.database.save_event(event).await { + tracing::warn!( + event_id = %event_id_str, + error = %e, + "Failed to pre-save maintainer announcement before reconciliation" + ); + } + // Check purgatory for state events that might now be authorized self.check_purgatory_state_events_for_identifier(&announcement.identifier) .await; + // An acceptance can make already-stored state events + // authoritative for additional owner repositories, so + // reapply stored states with the updated member set. + self.reconcile_stored_state_events_for_identifier(&announcement.identifier) + .await; + WritePolicyResult::Accept } Err(e) => { @@ -491,7 +509,7 @@ impl Nip34WritePolicy { // Process state alignment asynchronously match self .state_policy - .process_state_event(event, is_synced, Some(&promotion_hooks)) + .process_state_event(event, is_synced, false, Some(&promotion_hooks)) .await { Ok(policy_result) => { @@ -711,7 +729,7 @@ impl Nip34WritePolicy { // Re-evaluate authorization with the new announcement match self .state_policy - .process_state_event(&entry.event, false, Some(&promotion_hooks)) + .process_state_event(&entry.event, false, false, Some(&promotion_hooks)) .await { Ok(WritePolicyResult::Accept) => { @@ -787,7 +805,7 @@ impl Nip34WritePolicy { let promotion_hooks = NostrPurgatoryPromotionHooks::recovery_only(self); if let Err(error) = self .state_policy - .process_state_event(&state, true, Some(&promotion_hooks)) + .process_state_event(&state, true, true, Some(&promotion_hooks)) .await { tracing::warn!( diff --git a/src/nostr/events.rs b/src/nostr/events.rs index b34cf53..9137216 100644 --- a/src/nostr/events.rs +++ b/src/nostr/events.rs @@ -133,6 +133,24 @@ impl RepositoryAnnouncement { }) } + /// Pubkeys this announcement currently lists as maintainers, excluding + /// the author (who implicitly asserts maintainership of their own + /// announcement). + /// + /// This is the invitation set for membership computation: listed pubkeys' + /// announcements must be fetched so their acceptance is noticed, but a + /// listing alone makes none of their events authoritative. + pub fn listed_maintainers(&self) -> Vec { + let author = self.event.pubkey.to_hex(); + let mut listed: Vec = Vec::new(); + for pubkey in &self.maintainers { + if *pubkey != author && !listed.contains(pubkey) { + listed.push(pubkey.clone()); + } + } + listed + } + /// Check if this announcement lists the given domain in clone URLs /// /// Compares parsed host and port semantics, not substrings, so a URL such diff --git a/src/nostr/policy/state.rs b/src/nostr/policy/state.rs index 113d3af..f17b1bc 100644 --- a/src/nostr/policy/state.rs +++ b/src/nostr/policy/state.rs @@ -47,12 +47,16 @@ impl StatePolicy { /// # Arguments /// * `event` - The state event to process /// * `is_synced` - True if this event came from proactive sync (vs user-submitted) + /// * `reapply_stored` - True when reconciling after a membership change: + /// a state already in the database is then re-applied to owner + /// repositories instead of short-circuiting as a duplicate /// /// Returns the true if git data already availale or false if added to purgatory pub async fn process_state_event( &self, event: &Event, is_synced: bool, + reapply_stored: bool, promotion_hooks: Option<&dyn PurgatoryPromotionHooks>, ) -> Result { // Parse state to get HEAD and branch info @@ -172,12 +176,14 @@ impl StatePolicy { } } - // A state already stored in the database may still need to be applied to - // a newly-created maintainer repository. This happens when an invitee - // publishes only their reciprocal announcement to a GRASP server that - // already serves the owner's state and Git data. Reconcile that state - // while the authorized invitee announcement is in purgatory instead of - // returning early as an ordinary duplicate. + // A state already stored in the database may still need to be applied + // to a maintainer repository after membership changes. This happens + // when an invitee publishes their reciprocal announcement to a GRASP + // server that already serves the owner's state and Git data: either + // while the invitee's announcement is in purgatory, or — for external + // acceptance announcements stored directly — during the explicit + // reconcile pass (`reapply_stored`). Reconcile instead of returning + // early as an ordinary duplicate. let state_already_in_db = db_repo_data.states.iter().any(|e| e.event.id.eq(&event.id)); let has_authorized_purgatory_announcement = authorized_owners.iter().any(|owner_hex| { nostr_sdk::prelude::PublicKey::from_hex(owner_hex).is_ok_and(|owner| { @@ -187,7 +193,7 @@ impl StatePolicy { }) }); - if state_already_in_db && !has_authorized_purgatory_announcement { + if state_already_in_db && !has_authorized_purgatory_announcement && !reapply_stored { tracing::debug!("processed state event duplicate (in db): {}", event.id); return Ok(duplicate("already have this event")); } diff --git a/tests/state_authorization.rs b/tests/state_authorization.rs index f3e39ae..06ad556 100644 --- a/tests/state_authorization.rs +++ b/tests/state_authorization.rs @@ -216,15 +216,29 @@ async fn test_accept_state_from_maintainer() { .finalize(&owner_keys) .unwrap(); + // The maintainer confirms membership with a reciprocal announcement that + // lists the owner back. Without it they are only invited and their state + // events are not authoritative. + let reciprocal_announcement = EventBuilder::new(Kind::GitRepoAnnouncement, "") + .tags([ + Tag::custom("d", ["test-repo"]), + Tag::custom("clone", [format!("https://{}/test.git", relay.domain())]), + Tag::custom("relays", [relay.url()]), + Tag::custom("maintainers", [owner_keys.public_key().to_hex()]), + ]) + .finalize(&maintainer_keys) + .unwrap(); + // Connect to relay let client = Client::default(); client.add_relay(relay.url()).await.unwrap(); client.connect().await; - // Send announcement + // Send announcements client.send_event(&announcement).await.unwrap(); + client.send_event(&reciprocal_announcement).await.unwrap(); - // Wait for announcement to be processed + // Wait for announcements to be processed tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; // Send state event from maintainer @@ -262,3 +276,57 @@ async fn test_accept_state_from_maintainer() { relay.stop().await; } + +/// Send a state event for `test-repo` signed by `keys` and return whether the +/// relay permanently rejected it as unauthorized. +async fn state_event_rejected_as_unauthorized(client: &Client, keys: &Keys) -> bool { + let state_event = EventBuilder::new(Kind::RepoState, "") + .tags([ + Tag::custom("d", ["test-repo"]), + Tag::custom("refs/heads/main", ["abc123"]), + ]) + .finalize(keys) + .unwrap(); + + match client.send_event(&state_event).await { + Ok(output) => output + .failed + .values() + .any(|err| err.to_string().contains("not authorized")), + Err(e) => e.to_string().contains("not authorized"), + } +} + +#[tokio::test] +async fn test_reject_state_from_invited_maintainer() { + let relay = TestRelay::start().await; + + let owner_keys = Keys::generate(); + let maintainer_keys = Keys::generate(); + + // The owner lists the maintainer, but the maintainer never publishes a + // reciprocal announcement: they remain invited and unauthorized. + let announcement = EventBuilder::new(Kind::GitRepoAnnouncement, "") + .tags([ + Tag::custom("d", ["test-repo"]), + Tag::custom("clone", [format!("https://{}/test.git", relay.domain())]), + Tag::custom("relays", [relay.url()]), + Tag::custom("maintainers", [maintainer_keys.public_key().to_hex()]), + ]) + .finalize(&owner_keys) + .unwrap(); + + let client = Client::default(); + client.add_relay(relay.url()).await.unwrap(); + client.connect().await; + + client.send_event(&announcement).await.unwrap(); + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + + assert!( + state_event_rejected_as_unauthorized(&client, &maintainer_keys).await, + "state event from an invited maintainer must be rejected as unauthorized" + ); + + relay.stop().await; +} diff --git a/tests/sync/maintainer_reprocessing.rs b/tests/sync/maintainer_reprocessing.rs index 1f14277..c1c55f9 100644 --- a/tests/sync/maintainer_reprocessing.rs +++ b/tests/sync/maintainer_reprocessing.rs @@ -699,11 +699,14 @@ async fn unresolved_repositories_share_one_dependency_poll_per_relay() { /// /// The owner first publishes an older state on an owner-only and shared server. /// The invitee later publishes a newer, different state on an invitee-only and -/// shared server. When the owner lists the invitee, GRASP-02 must apply the -/// invitee's newer state to both owner endpoints without changing the invitee's -/// announcement or requiring another Git push. +/// shared server. When the owner lists the invitee, the invitee is merely +/// *invited*: their announcement must still be fetched (that is how acceptance +/// is noticed) but their newer state must NOT become authoritative for the +/// owner. Once the invitee accepts with a reciprocal announcement, GRASP-02 +/// must apply the invitee's newer state to both owner endpoints without +/// requiring another Git push. #[tokio::test] -async fn test_invitation_applies_newer_invitee_state_to_owner_before_acceptance() { +async fn test_invitation_applies_newer_invitee_state_to_owner_after_acceptance() { use crate::common::{create_test_repo_with_commit, CommitVariant}; let owner_relay = TestRelay::start_with_sync(None).await; @@ -861,13 +864,52 @@ async fn test_invitation_applies_newer_invitee_state_to_owner_before_acceptance( for relay in owner_servers { assert_exact_event_served(relay, &invitation, "Owner invitation announcement").await; + // The invited maintainer's announcement must still be fetched and + // served: it is how the relay notices their acceptance. assert_exact_event_served( relay, &invitee_announcement, "Invited maintainer announcement", ) .await; - assert_exact_event_served(relay, &invitee_state, "Newer invited maintainer state").await; + } + + // Deliberate observation window: give a wrongful state replacement time to + // manifest before asserting the owner state is intact. The invitee has not + // published a reciprocal announcement, so they are merely invited and their + // newer state must not become authoritative for the owner. + tokio::time::sleep(Duration::from_millis(500)).await; + for clone_url in &owner_clone_urls { + assert_remote_refs(clone_url, &owner_refs, Duration::from_secs(5)).await; + assert_remote_default_branch( + clone_url, + &format!("refs/heads/{owner_branch}"), + Duration::from_secs(5), + ) + .await; + } + + // The invitee accepts by replacing their announcement with one that lists + // the owner back; only then do they become a confirmed member whose state + // is authoritative for the owner's repositories. + let acceptance = repository_announcement( + &invitee_keys, + &invitee_servers, + &[owner_keys.public_key()], + identifier, + ) + .custom_created_at(Timestamp::from_secs(invitee_created_at.as_secs() + 2)) + .finalize(&invitee_keys) + .expect("Failed to create invitee acceptance"); + for relay in invitee_servers { + send_to_relay(relay, &acceptance) + .await + .expect("Failed to publish invitee acceptance"); + } + + for relay in owner_servers { + assert_exact_event_served(relay, &acceptance, "Invitee acceptance announcement").await; + assert_exact_event_served(relay, &invitee_state, "Newer confirmed maintainer state").await; } for clone_url in &owner_clone_urls { assert_remote_refs(clone_url, &invitee_refs, Duration::from_secs(20)).await; @@ -888,14 +930,14 @@ async fn test_invitation_applies_newer_invitee_state_to_owner_before_acceptance( .await; } - let pushes_after_invitation = [ + let pushes_after_acceptance = [ push_counts(&owner_relay).await, push_counts(&invitee_relay).await, push_counts(&shared_relay).await, ]; assert_eq!( - pushes_after_invitation, pushes_before_invitation, - "One-way maintainer authorization must sync the owner without a client Git push" + pushes_after_acceptance, pushes_before_invitation, + "Invitation acceptance must sync the owner without a client Git push" ); shared_relay.stop().await; @@ -1174,6 +1216,10 @@ async fn test_purgatory_owner_uses_rejected_maintainer_clone_to_sync_git() { Tag::identifier(identifier), Tag::custom("clone", vec![maintainer_clone.clone()]), Tag::custom("relays", vec![source_relay.url().to_string()]), + // List the future owner back: without this reciprocal listing + // the maintainer would remain invited on the target and their + // state could not authorize the owner's repository sync. + Tag::custom("maintainers", vec![owner_keys.public_key().to_hex()]), ]) .finalize(&maintainer_keys) .expect("Failed to create maintainer announcement");