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:
DanConwayDev
2026-08-19 11:29:30 +00:00
parent d73f2a3276
commit 96cb60501a
11 changed files with 578 additions and 419 deletions
+22 -4
View File
@@ -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
↓
+34
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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!(
+18
View File
@@ -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
+13 -7
View File
@@ -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"));
}
+70 -2
View File
@@ -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;
}
+54 -8
View File
@@ -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");