mirror of
https://relay.ngit.dev/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit-grasp.git
synced 2026-10-05 15:08:24 +00:00
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.
This commit is contained in:
@@ -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
|
||||
↓
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
+94
-15
@@ -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<Event> {
|
||||
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<String> {
|
||||
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();
|
||||
|
||||
@@ -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))?;
|
||||
|
||||
|
||||
@@ -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://")
|
||||
|
||||
+207
-380
@@ -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<String>,
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
/// 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<String> = 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<String> = 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<String, Vec<String>> {
|
||||
@@ -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<String> = 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<String>,
|
||||
) {
|
||||
// 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<String>,
|
||||
) -> 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<String>,
|
||||
) -> 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<String>) -> 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<String>) -> 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<Event>,
|
||||
}
|
||||
|
||||
impl AuthorizationContext {
|
||||
/// Create a new authorization context from fetched events
|
||||
pub fn new(events: Vec<Event>) -> 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<AuthorizationResult> {
|
||||
// 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<RepositoryState> = 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<String> {
|
||||
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]
|
||||
|
||||
+21
-3
@@ -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!(
|
||||
|
||||
@@ -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<String> {
|
||||
let author = self.event.pubkey.to_hex();
|
||||
let mut listed: Vec<String> = 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
|
||||
|
||||
@@ -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<WritePolicyResult> {
|
||||
// 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"));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user