feat(nip34): parse indexed M/m role tags as the primary maintainer listing

Follow the indexed repository roles format from NIP-34 (nips 986edd1):
`M` (lead) and `m` (co-maintainer) tags are now the primary maintainer
listing, and their presence means the deprecated `maintainers` tag is
ignored entirely. The lead / co-maintainer distinction carries no meaning
for this service, so both collapse into one maintainer set.

Role tags may record history as alternating start/end timestamps; a tag
is currently active when it has fewer than four elements or an odd number
of elements. Ended entries are ignored entirely: role history is only
consulted to conclude that a pubkey is no longer a maintainer, never to
grant time-scoped retroactive authority over historic events. A pubkey
may appear in one `M` and one `m` tag to record a role transition and
remains a maintainer while either entry is active; a second tag under
the same letter is malformed and rejects the announcement.

RepositoryAnnouncement::listed_maintainers() - already the single source
for the listed maintainer set since the reciprocal-membership commit -
now prefers active role-tag entries over the deprecated tag, so state
authorization, replacement detection, the maintainer exception, sync
discovery and the dependency walkers all pick up the new format through
the sites switched to it here. Two refinements to membership follow from
the format:

- An announcement using role tags acknowledges its author via an active
  self-entry, or implicitly: per NIP-34 an author who appears in no role
  tag is a maintainer for the repository's entire history. Only an ended
  self-entry means the member left, which takes precedence over
  assignments in other announcements and is distinct from merely being
  invited (author_has_left).
- A `u` (subordinate fork) tag has no effect on maintainership: the
  author of a role-less announcement asserts maintainership with or
  without it.

Correctness assumption: authorization remains namespace-scoped, so the
owner of a repository namespace stays authorized for it regardless of
their own role history; role history only ends the authority of listed
maintainers. Conflicting listings across
announcements resolve as the union of confirmed members' active
listings, matching the NIP's current-role rule; the NIP's owner-first
precedence applies only to conflicting records of past roles, which
this service never evaluates.

Scope deliberately excluded: the moderator role tag (`o`) is handled in
a follow-up commit.

Validation: nostr::events and git::authorization unit tests,
state_authorization suite (including new role-tag acceptance and
ended-role rejection tests) and the sync invitation tests all pass.
This commit is contained in:
DanConwayDev
2026-08-19 11:29:30 +00:00
parent 96cb60501a
commit d0a23caf5c
9 changed files with 677 additions and 39 deletions
+20
View File
@@ -283,6 +283,22 @@ pub struct RepositoryState { ... }
#### Maintainer Membership Model
Announcements carry maintainer listings in two formats:
- **Indexed role tags** (`M` lead, `m` co-maintainer). A tag may record
history as alternating start/end timestamps; it is currently active when
it has fewer than four elements or an odd number of elements. Ended
entries are ignored entirely - the history is only used to conclude a
role has *ended*, never to grant authority for a past period. The lead /
co-maintainer distinction carries no meaning for this service. A pubkey
may appear in one `M` and one `m` tag to record a role transition and is
a maintainer while either entry is active; a second tag under the same
letter rejects the announcement. An author who appears in no role tag is
implicitly a maintainer for the repository's entire history.
- **Deprecated `maintainers` tag** (fallback). Ignored when `M`/`m` tags
are present. Without any listing tags the author is the sole maintainer.
A `u` (subordinate fork) tag has no effect on maintainership.
Authorization follows a reciprocal membership rule, computed per owner by
[`compute_membership`](src/git/authorization.rs):
@@ -293,6 +309,10 @@ Authorization follows a reciprocal membership rule, computed per owner by
authoritative.
- Confirmation is a fixpoint, so maintainers listed by other confirmed
maintainers are reached recursively.
- The acknowledging announcement must assert an active role for its own
author: an ended `M`/`m` self-entry means the pubkey has left, which
takes precedence over assignments in other announcements. An author who
appears in no role tag implicitly asserts maintainership.
- The owner is always a confirmed maintainer of their own repository:
announcing a repository in their namespace is what creates it on this
service.
+25
View File
@@ -235,3 +235,28 @@ NIP-34 maintainers model refined in nips commit `781590b`).
(`reapply_stored`) so a newly confirmed maintainer's latest state
re-points the owner's repository without another push.
### Indexed role tags (`M`/`m`)
- `M` (lead) and `m` (co-maintainer) tags are the primary maintainer
listing (per the model clarified in nips commit `986edd1`); when present
the deprecated `maintainers` tag is ignored per NIP-34 (and parsed as
empty). The role distinction carries no meaning for this service: both
collapse into one maintainer set.
- Role tags may record history as alternating start/end timestamps; a tag
is currently active when it has fewer than four elements or an odd number
of elements. Ended entries are ignored entirely: role history is only
used to conclude that a pubkey is *no longer* a maintainer, never to
grant time-scoped retroactive authority over historic events. The
NIP's owner-first precedence for conflicting past-role records is
therefore unused.
- A pubkey may appear in one `M` and one `m` tag to record a role
transition and is a maintainer while either entry is active. A second
tag under the same letter is malformed and rejects the announcement.
- An announcement using role tags acknowledges its author via an active
self-entry, or implicitly: an author who appears in no role tag is a
maintainer for the repository's entire history. Only an ended self-entry
means the member left, which takes precedence over assignments in other
announcements.
- A `u` (subordinate fork) tag has no effect on maintainership: the author
of a role-less announcement asserts maintainership with or without it.
+188 -1
View File
@@ -331,7 +331,11 @@ pub struct RepoMembership {
/// 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.
/// maintainers are reached recursively. The acknowledging announcement must
/// also assert an active role for its own author: an ended `M`/`m`
/// self-entry means the pubkey has left, which takes precedence over
/// assignments in other announcements. An author who appears in no role tag
/// implicitly asserts maintainership for the repository's entire history.
///
/// The owner is always a confirmed maintainer of their own repository:
/// announcing a repository in their namespace is what creates it on this
@@ -369,6 +373,9 @@ pub fn compute_membership(
let Some(candidate_announcement) = find(candidate) else {
continue; // invited: no announcement of their own yet
};
if !candidate_announcement.author_role_active() {
continue; // left: an ended self-role takes precedence
}
let lists_back = candidate_announcement
.listed_maintainers()
.iter()
@@ -383,6 +390,9 @@ pub fn compute_membership(
let invited = listed
.into_iter()
.filter(|pubkey| !confirmed.contains(pubkey))
// A pubkey whose own announcement shows it left is not
// invited.
.filter(|pubkey| !find(pubkey).is_some_and(|a| a.author_has_left()))
.collect();
return RepoMembership {
state_maintainers: confirmed,
@@ -1401,6 +1411,183 @@ mod tests {
assert!(membership.invited.contains(&hex(&bob)));
}
/// Build an announcement using NIP-34 indexed role tags.
/// Each entry is (tag name, values) where values start with the pubkey
/// followed by optional history timestamps.
fn create_role_announcement(
keys: &Keys,
identifier: &str,
roles: &[(&str, Vec<String>)],
) -> Event {
let mut tags = vec![Tag::custom("d", vec![identifier.to_string()])];
for (name, values) in roles {
tags.push(Tag::custom(*name, values.clone()));
}
tags.push(Tag::custom(
"clone",
vec!["https://example.com/test.git".to_string()],
));
tags.push(Tag::custom("relays", vec!["wss://example.com".to_string()]));
EventBuilder::new(Kind::GitRepoAnnouncement, "Test repo")
.tags(tags)
.finalize(keys)
.unwrap()
}
#[test]
fn test_role_tag_maintainer_confirmed_when_reciprocal() {
let alice = create_test_keys();
let bob = create_test_keys();
let identifier = "test-repo";
let announcements = vec![
parse(create_role_announcement(
&alice,
identifier,
&[("M", vec![hex(&alice)]), ("m", vec![hex(&bob)])],
)),
parse(create_role_announcement(
&bob,
identifier,
&[("M", vec![hex(&alice)]), ("m", vec![hex(&bob)])],
)),
];
let membership = compute_membership(&announcements, &hex(&alice), identifier);
assert!(membership.state_maintainers.contains(&hex(&bob)));
}
#[test]
fn test_ended_role_is_no_longer_maintainer() {
let alice = create_test_keys();
let bob = create_test_keys();
let identifier = "test-repo";
// Alice's announcement records Bob's co-maintainer role as ended
let alice_announcement = create_role_announcement(
&alice,
identifier,
&[
("M", vec![hex(&alice)]),
(
"m",
vec![hex(&bob), "0".to_string(), "1700000000".to_string()],
),
],
);
// Bob still lists himself and Alice as active
let bob_announcement = create_role_announcement(
&bob,
identifier,
&[("M", vec![hex(&alice)]), ("m", vec![hex(&bob)])],
);
let announcements = vec![parse(alice_announcement), parse(bob_announcement)];
let membership = compute_membership(&announcements, &hex(&alice), identifier);
assert!(!membership.state_maintainers.contains(&hex(&bob)));
assert!(
!membership.invited.contains(&hex(&bob)),
"an ended role must not be treated as an open invitation"
);
}
#[test]
fn test_returning_maintainer_history_is_active() {
let alice = create_test_keys();
let bob = create_test_keys();
let identifier = "test-repo";
// Odd element count: added, removed, then added again
let alice_announcement = create_role_announcement(
&alice,
identifier,
&[
("M", vec![hex(&alice)]),
(
"m",
vec![
hex(&bob),
"100".to_string(),
"200".to_string(),
"300".to_string(),
],
),
],
);
let bob_announcement = create_role_announcement(
&bob,
identifier,
&[("M", vec![hex(&alice)]), ("m", vec![hex(&bob)])],
);
let announcements = vec![parse(alice_announcement), parse(bob_announcement)];
let membership = compute_membership(&announcements, &hex(&alice), identifier);
assert!(membership.state_maintainers.contains(&hex(&bob)));
}
#[test]
fn test_self_leave_takes_precedence() {
let alice = create_test_keys();
let bob = create_test_keys();
let identifier = "test-repo";
// Alice still lists Bob as active
let alice_announcement = create_role_announcement(
&alice,
identifier,
&[("M", vec![hex(&alice)]), ("m", vec![hex(&bob)])],
);
// Bob ended his own role: he has left
let bob_announcement = create_role_announcement(
&bob,
identifier,
&[
("M", vec![hex(&alice)]),
(
"m",
vec![hex(&bob), "0".to_string(), "1700000000".to_string()],
),
],
);
let announcements = vec![parse(alice_announcement), parse(bob_announcement)];
let membership = compute_membership(&announcements, &hex(&alice), identifier);
assert!(!membership.state_maintainers.contains(&hex(&bob)));
assert!(!membership.invited.contains(&hex(&bob)));
}
#[test]
fn test_subordinate_fork_marker_does_not_block_acceptance() {
let alice = create_test_keys();
let bob = create_test_keys();
let identifier = "test-repo";
// Alice invites Bob; Bob's announcement carries a `u` (subordinate
// fork) tag. Per NIP-34 `u` has no effect on maintainership, so
// Bob's listing of Alice still acknowledges the invitation.
let alice_announcement = create_announcement_event(&alice, identifier, &[&bob]);
let mut tags = vec![
Tag::custom("d", vec![identifier.to_string()]),
Tag::custom("maintainers", vec![hex(&alice)]),
Tag::custom("u", vec![format!("30617:{}:{}", hex(&alice), identifier)]),
];
tags.push(Tag::custom(
"clone",
vec!["https://example.com/test.git".to_string()],
));
tags.push(Tag::custom("relays", vec!["wss://example.com".to_string()]));
let bob_announcement = EventBuilder::new(Kind::GitRepoAnnouncement, "Test repo")
.tags(tags)
.finalize(&bob)
.unwrap();
let announcements = vec![parse(alice_announcement), parse(bob_announcement)];
let membership = compute_membership(&announcements, &hex(&alice), identifier);
assert!(membership.state_maintainers.contains(&hex(&bob)));
assert!(!membership.invited.contains(&hex(&bob)));
}
#[test]
fn test_validate_push_refs_success() {
let alice = create_test_keys();
+300 -13
View File
@@ -26,7 +26,22 @@ pub struct RepositoryAnnouncement {
pub clone_urls: Vec<String>,
pub relays: Vec<String>,
pub web_urls: Vec<String>,
/// Pubkeys from the deprecated `maintainers` tag. Empty when the
/// announcement uses `M`/`m` role tags, which take precedence per NIP-34.
pub maintainers: Vec<String>,
/// Currently-active maintainer pubkeys from NIP-34 `M`/`m` role tags;
/// `None` when the announcement contains no `M`/`m` tags.
///
/// The lead (`M`) / co-maintainer (`m`) distinction carries no meaning
/// for this service, so both collapse into one maintainer set. Entries
/// whose role history shows the role has ended are excluded entirely: an
/// ended role means the pubkey is no longer a maintainer, and historic
/// listing never grants authority for a past period.
pub role_maintainers: Option<Vec<String>>,
/// Whether any role tag names the author, active or ended. Per NIP-34 an
/// author without a self-entry is implicitly a maintainer for the
/// repository's entire history.
author_has_role_entry: bool,
}
impl RepositoryAnnouncement {
@@ -107,19 +122,63 @@ impl RepositoryAnnouncement {
})
.collect();
// Extract maintainers from "maintainers" tag per NIP-34
// Extract maintainer pubkeys from NIP-34 `M`/`m` role tags
// Format: ["M"|"m", "<pubkey-hex>", "<start>", "<end>", ...]
// A role tag lists a pubkey followed by optional alternating start/end
// history timestamps; it is currently active when it has fewer than
// four elements or an odd number of elements. Ended entries are
// ignored entirely. A pubkey may appear in one `M` and one `m` tag to
// record a transition between the roles and remains a maintainer
// while either entry is active; a second tag under the same letter is
// malformed and rejects the announcement.
let author_hex = event.pubkey.to_hex();
let mut role_tags_present = false;
let mut author_has_role_entry = false;
let mut role_active: Vec<String> = Vec::new();
let mut seen_role_entries: Vec<(String, String)> = Vec::new();
for tag in event.tags.iter() {
let slice = tag.as_slice();
let Some(kind) = slice.first() else { continue };
if kind != "M" && kind != "m" {
continue;
}
role_tags_present = true;
let Some(pubkey) = slice.get(1).filter(|value| !value.is_empty()) else {
continue;
};
let entry = (kind.clone(), pubkey.clone());
if seen_role_entries.contains(&entry) {
return Err(anyhow!("duplicate `{kind}` role tag for pubkey {pubkey}"));
}
seen_role_entries.push(entry);
if *pubkey == author_hex {
author_has_role_entry = true;
}
let active = slice.len() < 4 || slice.len() % 2 == 1;
if active && !role_active.contains(pubkey) {
role_active.push(pubkey.clone());
}
}
let role_maintainers = role_tags_present.then_some(role_active);
// Extract maintainers from the deprecated "maintainers" tag per NIP-34
// Format: ["maintainers", "<pubkey1-hex>", "<pubkey2-hex>", ...]
let maintainers = event
.tags
.iter()
.find(|tag| tag.as_slice().first().map(|s| s.as_str()) == Some("maintainers"))
.map(|tag| {
tag.as_slice()[1..] // Skip the "maintainers" tag name
.iter()
.map(|s| s.to_string())
.collect()
})
.unwrap_or_default();
// Ignored entirely when `M` or `m` role tags are present.
let maintainers = if role_tags_present {
Vec::new()
} else {
event
.tags
.iter()
.find(|tag| tag.as_slice().first().map(|s| s.as_str()) == Some("maintainers"))
.map(|tag| {
tag.as_slice()[1..] // Skip the "maintainers" tag name
.iter()
.map(|s| s.to_string())
.collect()
})
.unwrap_or_default()
};
Ok(RepositoryAnnouncement {
event,
@@ -130,6 +189,8 @@ impl RepositoryAnnouncement {
relays,
web_urls,
maintainers,
role_maintainers,
author_has_role_entry,
})
}
@@ -142,8 +203,12 @@ impl RepositoryAnnouncement {
/// listing alone makes none of their events authoritative.
pub fn listed_maintainers(&self) -> Vec<String> {
let author = self.event.pubkey.to_hex();
let source = self
.role_maintainers
.as_deref()
.unwrap_or(&self.maintainers);
let mut listed: Vec<String> = Vec::new();
for pubkey in &self.maintainers {
for pubkey in source {
if *pubkey != author && !listed.contains(pubkey) {
listed.push(pubkey.clone());
}
@@ -151,6 +216,34 @@ impl RepositoryAnnouncement {
listed
}
/// Whether this announcement asserts an active maintainer role for its
/// own author.
///
/// With `M`/`m` role tags the author is active via an active self-entry,
/// or implicitly: per NIP-34 an author who appears in no role tag is a
/// maintainer for the repository's entire history. Only an ended
/// self-entry means the author has left, which takes precedence over
/// assignments in other announcements. In the deprecated format the
/// author always asserts maintainership; a `u` (subordinate fork) tag
/// has no effect on maintainership.
pub fn author_role_active(&self) -> bool {
match &self.role_maintainers {
Some(active) => {
!self.author_has_role_entry || active.contains(&self.event.pubkey.to_hex())
}
None => true,
}
}
/// Whether this announcement shows its author has left: a role tag names
/// the author but none of their entries is active.
///
/// An author absent from all role tags has *not* left - they are
/// implicitly a maintainer for the repository's entire history.
pub fn author_has_left(&self) -> bool {
self.role_maintainers.is_some() && !self.author_role_active()
}
/// Check if this announcement lists the given domain in clone URLs
///
/// Compares parsed host and port semantics, not substrings, so a URL such
@@ -808,6 +901,200 @@ mod tests {
);
}
fn role_announcement(keys: &Keys, tags: Vec<(&str, Vec<String>)>) -> RepositoryAnnouncement {
use nostr_sdk::prelude::Tag;
let mut event_tags = vec![Tag::custom("d", vec!["test-repo".to_string()])];
for (name, values) in tags {
event_tags.push(Tag::custom(name, values));
}
let event = EventBuilder::new(Kind::GitRepoAnnouncement, "Test repository")
.tags(event_tags)
.finalize(keys)
.unwrap();
RepositoryAnnouncement::from_event(event).unwrap()
}
#[test]
fn test_role_tag_activeness_by_element_count() {
let keys = create_test_keys();
let author = keys.public_key().to_hex();
let other = create_test_keys().public_key().to_hex();
// (history values after the pubkey, expected active)
// Tag element counts: 2 -> active, 3 -> active, 4 -> ended,
// 5 -> active, 6 -> ended.
let cases: Vec<(Vec<&str>, bool)> = vec![
(vec![], true),
(vec!["100"], true),
(vec!["100", "200"], false),
(vec!["100", "200", "300"], true),
(vec!["100", "200", "300", "400"], false),
];
for (history, expected_active) in cases {
let mut values = vec![other.clone()];
values.extend(history.iter().map(|s| s.to_string()));
let announcement =
role_announcement(&keys, vec![("M", vec![author.clone()]), ("m", values)]);
assert_eq!(
announcement.listed_maintainers().contains(&other),
expected_active,
"history {history:?} expected active={expected_active}"
);
}
}
#[test]
fn test_lead_and_co_maintainer_roles_are_equivalent() {
let keys = create_test_keys();
let author = keys.public_key().to_hex();
let listed_as_lead = create_test_keys().public_key().to_hex();
let listed_as_co = create_test_keys().public_key().to_hex();
let announcement = role_announcement(
&keys,
vec![
("M", vec![author.clone()]),
("M", vec![listed_as_lead.clone()]),
("m", vec![listed_as_co.clone()]),
],
);
let listed = announcement.listed_maintainers();
assert!(listed.contains(&listed_as_lead));
assert!(listed.contains(&listed_as_co));
assert!(
!listed.contains(&author),
"author is excluded from the listed set"
);
assert!(announcement.author_role_active());
}
#[test]
fn test_maintainers_tag_ignored_when_role_tags_present() {
let keys = create_test_keys();
let author = keys.public_key().to_hex();
let legacy_listed = create_test_keys().public_key().to_hex();
let announcement = role_announcement(
&keys,
vec![
("M", vec![author]),
("maintainers", vec![legacy_listed.clone()]),
],
);
assert!(announcement.maintainers.is_empty());
assert!(!announcement.listed_maintainers().contains(&legacy_listed));
}
#[test]
fn test_author_leave_via_ended_self_role() {
let keys = create_test_keys();
let author = keys.public_key().to_hex();
let lead = create_test_keys().public_key().to_hex();
let announcement = role_announcement(
&keys,
vec![
("M", vec![lead.clone()]),
("m", vec![author, "0".to_string(), "1700000000".to_string()]),
],
);
assert!(!announcement.author_role_active());
assert!(announcement.listed_maintainers().contains(&lead));
}
#[test]
fn test_u_tag_has_no_effect_on_maintainership() {
let keys = create_test_keys();
let upstream = create_test_keys().public_key().to_hex();
// Deprecated format: the author implicitly asserts maintainership,
// with or without a `u` (subordinate fork) tag.
let plain = role_announcement(&keys, vec![("maintainers", vec![upstream.clone()])]);
assert!(plain.author_role_active());
let fork = role_announcement(
&keys,
vec![
("maintainers", vec![upstream.clone()]),
("u", vec![format!("30617:{upstream}:test-repo")]),
],
);
assert!(fork.author_role_active());
assert!(!fork.author_has_left());
}
#[test]
fn test_author_without_self_role_entry_is_implicitly_active() {
let keys = create_test_keys();
let other = create_test_keys().public_key().to_hex();
// Role tags that do not name the author: the author is implicitly a
// maintainer for the repository's entire history.
let announcement = role_announcement(&keys, vec![("m", vec![other])]);
assert!(announcement.author_role_active());
assert!(!announcement.author_has_left());
}
#[test]
fn test_role_transition_across_letters_is_active_while_either_entry_active() {
let keys = create_test_keys();
let author = keys.public_key().to_hex();
let demoted = create_test_keys().public_key().to_hex();
let gone = create_test_keys().public_key().to_hex();
// One `M` and one `m` tag for the same pubkey record a transition
// between the roles: the pubkey is a maintainer while either entry
// is active, and no longer one when both have ended.
let announcement = role_announcement(
&keys,
vec![
("M", vec![author.clone()]),
(
"M",
vec![demoted.clone(), "0".to_string(), "100".to_string()],
),
("m", vec![demoted.clone(), "100".to_string()]),
("M", vec![gone.clone(), "0".to_string(), "100".to_string()]),
(
"m",
vec![gone.clone(), "100".to_string(), "200".to_string()],
),
],
);
let listed = announcement.listed_maintainers();
assert!(listed.contains(&demoted));
assert!(!listed.contains(&gone));
}
#[test]
fn test_duplicate_same_letter_role_tag_rejects_announcement() {
use nostr_sdk::prelude::Tag;
let keys = create_test_keys();
let author = keys.public_key().to_hex();
let other = create_test_keys().public_key().to_hex();
let event = EventBuilder::new(Kind::GitRepoAnnouncement, "Test repository")
.tags(vec![
Tag::custom("d", vec!["test-repo".to_string()]),
Tag::custom("M", vec![author]),
Tag::custom("m", vec![other.clone(), "0".to_string(), "100".to_string()]),
Tag::custom("m", vec![other]),
])
.finalize(&keys)
.unwrap();
let result = RepositoryAnnouncement::from_event(event);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("duplicate"));
}
#[test]
fn test_state_with_tags() {
use nostr_sdk::prelude::Tag;
+15 -13
View File
@@ -175,13 +175,10 @@ impl AnnouncementPolicy {
))
}
};
let current_maintainers: HashSet<&str> =
current.maintainers.iter().map(String::as_str).collect();
let incoming_maintainers: HashSet<&str> = announcement
.maintainers
.iter()
.map(String::as_str)
.collect();
let current_maintainers: HashSet<String> =
current.listed_maintainers().into_iter().collect();
let incoming_maintainers: HashSet<String> =
announcement.listed_maintainers().into_iter().collect();
if current_maintainers != incoming_maintainers {
tracing::debug!(
identifier = %announcement.identifier,
@@ -423,14 +420,19 @@ impl AnnouncementPolicy {
Ok(())
}
/// Check if a pubkey is listed as a maintainer in any announcement for this identifier
/// Check if a pubkey is currently listed as a maintainer in any announcement
/// for this identifier
///
/// A pubkey is considered a maintainer if:
/// A pubkey qualifies if:
/// 1. They are the owner (pubkey) of an accepted announcement with this identifier, OR
/// 2. They are listed in the maintainers tag of ANY announcement with this identifier
/// 2. ANY announcement with this identifier currently lists them as a maintainer
/// (active `M`/`m` role tags or the deprecated `maintainers` tag)
///
/// This enables accepting announcements from maintainers even when they don't list
/// this GRASP server, for maintainer chain discovery and GRASP-02 sync.
/// this GRASP server, for maintainer chain discovery and GRASP-02 sync. It
/// deliberately includes *invited* pubkeys - their announcement is exactly how we
/// learn they accepted the role - but excludes pubkeys whose role history shows
/// the role has ended.
///
/// Checks both the database (promoted announcements) and purgatory (announcements
/// waiting for git data). This is necessary because a maintainer's announcement
@@ -479,9 +481,9 @@ impl AnnouncementPolicy {
return Ok(true);
}
// Check if author is listed in the maintainers tag
// Check if the announcement currently lists the author as a maintainer
if let Ok(announcement) = RepositoryAnnouncement::from_event((*event).clone()) {
if announcement.maintainers.contains(&author_hex) {
if announcement.listed_maintainers().contains(&author_hex) {
return Ok(true);
}
}
+5 -2
View File
@@ -150,14 +150,17 @@ impl PurgatoryPromotionHooks for NostrPurgatoryPromotionHooks {
return;
};
// Every currently-listed maintainer, including invited ones whose
// announcements we still need to notice acceptance.
let listed_maintainers = announcement.listed_maintainers();
debug!(
identifier = %announcement.identifier,
event_id = %event.id,
maintainer_count = announcement.maintainers.len(),
maintainer_count = listed_maintainers.len(),
"Owner announcement promoted from purgatory, checking hot cache for rejected maintainer announcements"
);
for maintainer_hex in &announcement.maintainers {
for maintainer_hex in &listed_maintainers {
match PublicKey::from_hex(maintainer_hex) {
Ok(maintainer_pubkey) => {
let (event_ids, hot_events) = rejected_events_index.dependency_candidates(
+8 -5
View File
@@ -160,13 +160,16 @@ pub fn accepted_repository_authors<'a>(
continue;
}
authors.insert(event.pubkey);
for maintainer in event
.tags
.iter()
.filter(|tag| tag.as_slice().first().map(String::as_str) == Some("maintainers"))
// Include every currently-listed maintainer (active `M`/`m` role tags
// or the deprecated maintainers fallback). Invited pubkeys are
// included deliberately: their announcements must be fetched so we
// notice when they accept the role. Ended roles are excluded.
if let Ok(announcement) =
crate::nostr::events::RepositoryAnnouncement::from_event(event.clone())
{
authors.extend(
maintainer.as_slice()[1..]
announcement
.listed_maintainers()
.iter()
.filter_map(|value| PublicKey::from_hex(value).ok()),
);
+10 -5
View File
@@ -6635,8 +6635,10 @@ impl SyncManager {
let relay_url = format!("ws://{}", self.service_domain);
let mut dependency_refetch_ids = HashSet::new();
let mut hot_dependency_events = Vec::new();
// Walk every currently-listed maintainer, including invited ones:
// their announcements must be recovered so acceptance is noticed.
let mut maintainer_queue: VecDeque<String> =
announcement.maintainers.iter().cloned().collect();
announcement.listed_maintainers().into_iter().collect();
let mut visited_maintainers = HashSet::new();
let mut dependency_relay_urls: HashSet<String> =
announcement.relays.iter().cloned().collect();
@@ -6703,8 +6705,8 @@ impl SyncManager {
if let Ok(accepted_announcement) =
crate::nostr::events::RepositoryAnnouncement::from_event(accepted_event)
{
maintainer_queue.extend(accepted_announcement.listed_maintainers());
dependency_relay_urls.extend(accepted_announcement.relays);
maintainer_queue.extend(accepted_announcement.maintainers);
}
}
}
@@ -7409,16 +7411,19 @@ impl SyncManager {
match RepositoryAnnouncement::from_event(event.clone()) {
Ok(announcement) => {
// Re-process rejected maintainer announcements
if !announcement.maintainers.is_empty() {
// Re-process rejected announcements from every
// currently-listed maintainer (invited included)
let listed_maintainers = announcement.listed_maintainers();
if !listed_maintainers.is_empty() {
tracing::debug!(
event_id = %event.id,
identifier = %announcement.identifier,
maintainer_count = announcement.maintainers.len(),
maintainer_count = listed_maintainers.len(),
"Owner announcement accepted, checking for rejected maintainer announcements"
);
// For each maintainer, invalidate and get their events
for maintainer_hex in &announcement.maintainers {
for maintainer_hex in &listed_maintainers {
// Parse maintainer public key
match PublicKey::from_hex(maintainer_hex) {
Ok(maintainer_pubkey) => {
+106
View File
@@ -330,3 +330,109 @@ async fn test_reject_state_from_invited_maintainer() {
relay.stop().await;
}
#[tokio::test]
async fn test_accept_state_from_role_tag_maintainer() {
let relay = TestRelay::start().await;
let owner_keys = Keys::generate();
let maintainer_keys = Keys::generate();
// Owner uses NIP-34 indexed role tags: M for themselves, m for the
// co-maintainer.
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("M", [owner_keys.public_key().to_hex()]),
Tag::custom("m", [maintainer_keys.public_key().to_hex()]),
])
.finalize(&owner_keys)
.unwrap();
// Reciprocal announcement acknowledging the role and listing the lead.
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("M", [owner_keys.public_key().to_hex()]),
Tag::custom("m", [maintainer_keys.public_key().to_hex()]),
])
.finalize(&maintainer_keys)
.unwrap();
let client = Client::default();
client.add_relay(relay.url()).await.unwrap();
client.connect().await;
client.send_event(&announcement).await.unwrap();
client.send_event(&reciprocal_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 a confirmed role-tag co-maintainer must not be rejected as unauthorized"
);
relay.stop().await;
}
#[tokio::test]
async fn test_reject_state_from_removed_maintainer() {
let relay = TestRelay::start().await;
let owner_keys = Keys::generate();
let maintainer_keys = Keys::generate();
// The owner's announcement records the co-maintainer role as ended
// (four elements: pubkey plus start/end boundary timestamps).
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("M", [owner_keys.public_key().to_hex()]),
Tag::custom(
"m",
[
maintainer_keys.public_key().to_hex(),
"0".to_string(),
"1700000000".to_string(),
],
),
])
.finalize(&owner_keys)
.unwrap();
// Even a reciprocal announcement cannot restore an ended role. It points
// at another host so it does not create the maintainer's own hosted repo
// here; with the role ended it also no longer qualifies for the
// maintainer exception, so the relay may reject it outright.
let reciprocal_announcement = EventBuilder::new(Kind::GitRepoAnnouncement, "")
.tags([
Tag::custom("d", ["test-repo"]),
Tag::custom("clone", ["https://elsewhere.example/test.git".to_string()]),
Tag::custom("relays", ["wss://elsewhere.example".to_string()]),
Tag::custom("M", [owner_keys.public_key().to_hex()]),
Tag::custom("m", [maintainer_keys.public_key().to_hex()]),
])
.finalize(&maintainer_keys)
.unwrap();
let client = Client::default();
client.add_relay(relay.url()).await.unwrap();
client.connect().await;
client.send_event(&announcement).await.unwrap();
let _ = client.send_event(&reciprocal_announcement).await;
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
assert!(
state_event_rejected_as_unauthorized(&client, &maintainer_keys).await,
"state event from a maintainer whose role has ended must be rejected as unauthorized"
);
relay.stop().await;
}