mirror of
https://relay.ngit.dev/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit-grasp.git
synced 2026-10-06 15:38:25 +00:00
feat(nip34): consolidate duplicate role-tag histories instead of rejecting
The NIP allows a pubkey one tag of each role letter; a second tag under the same letter is out of spec. Rejecting such announcements dropped otherwise-valid membership data over a formatting slip, and clients that merge histories tag-by-tag can plausibly emit them. Tolerate them instead: histories for a pubkey are consolidated, and since this service only evaluates current activity - never time-scoped historic authority - consolidation reduces to "a maintainer while any `M`/`m` entry is active", exactly the rule already applied across letters for role transitions. Duplicate-rejection tests become consolidation tests: an ended entry plus an active one keeps the pubkey a maintainer (including the author's own self-entries), all-ended duplicates end it, and duplicate moderator tags still grant no maintainership. Validation: nostr::events and git::authorization unit tests and the state_authorization suite pass.
This commit is contained in:
@@ -291,9 +291,10 @@ Announcements carry maintainer listings in two formats:
|
||||
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
|
||||
may appear in multiple role tags: one of each letter records a role
|
||||
transition, and out-of-spec duplicates under the same letter are
|
||||
tolerated. Histories are consolidated - the pubkey is a maintainer while
|
||||
any `M`/`m` entry is active. 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.
|
||||
|
||||
@@ -249,9 +249,12 @@ NIP-34 maintainers model refined in nips commit `781590b`).
|
||||
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.
|
||||
- A pubkey may appear in multiple role tags: one of each letter records a
|
||||
role transition per the NIP, and out-of-spec duplicates under the same
|
||||
letter are consolidated rather than rejected - rejecting them would drop
|
||||
otherwise-valid membership data over a formatting slip. Since only
|
||||
current activity matters here, consolidation reduces to: a pubkey is a
|
||||
maintainer while any of its `M`/`m` entries is active.
|
||||
- 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
|
||||
@@ -273,8 +276,8 @@ The tag still participates in announcement parsing (per nips commit
|
||||
`986edd1`): its presence suppresses the deprecated `maintainers` fallback,
|
||||
and a self-`o` entry is a self-role, so a moderator-only author is not
|
||||
implicitly a maintainer and their state events are not authorized. Like
|
||||
`M`/`m`, a pubkey may appear in at most one `o` tag; a duplicate rejects
|
||||
the announcement.
|
||||
`M`/`m`, duplicate `o` tags for one pubkey are consolidated rather than
|
||||
rejected.
|
||||
|
||||
Deliberately deferred: announcements from moderators are not walked for
|
||||
the `M`/`m` assignments they might carry (the NIP says role combinations
|
||||
|
||||
+55
-22
@@ -129,10 +129,11 @@ impl RepositoryAnnouncement {
|
||||
// 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 tag of each letter to
|
||||
// record transitions between the roles and is a maintainer while
|
||||
// either its `M` or `m` entry is active; a second tag under the same
|
||||
// letter is malformed and rejects the announcement.
|
||||
// ignored entirely. A pubkey may appear in multiple role tags: one
|
||||
// tag of each letter records transitions between roles, and
|
||||
// out-of-spec duplicates under the same letter are tolerated. The
|
||||
// histories are consolidated - the pubkey is a maintainer while any
|
||||
// of its `M`/`m` entries is active.
|
||||
//
|
||||
// The moderator role (`o`) grants no maintainership: moderators are
|
||||
// only empowered to have status events (kinds 1630-1633) treated as
|
||||
@@ -147,7 +148,6 @@ impl RepositoryAnnouncement {
|
||||
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 };
|
||||
@@ -158,11 +158,6 @@ impl RepositoryAnnouncement {
|
||||
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;
|
||||
}
|
||||
@@ -1128,12 +1123,14 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_duplicate_moderator_tag_rejects_announcement() {
|
||||
fn test_duplicate_moderator_tags_are_consolidated() {
|
||||
use nostr_sdk::prelude::Tag;
|
||||
|
||||
let keys = create_test_keys();
|
||||
let moderator = create_test_keys().public_key().to_hex();
|
||||
|
||||
// Out-of-spec duplicate `o` tags do not reject the announcement, and
|
||||
// moderator entries still grant no maintainership.
|
||||
let event = EventBuilder::new(Kind::GitRepoAnnouncement, "Test repository")
|
||||
.tags(vec![
|
||||
Tag::custom("d", vec!["test-repo".to_string()]),
|
||||
@@ -1141,37 +1138,73 @@ mod tests {
|
||||
"o",
|
||||
vec![moderator.clone(), "0".to_string(), "100".to_string()],
|
||||
),
|
||||
Tag::custom("o", vec![moderator]),
|
||||
Tag::custom("o", vec![moderator.clone()]),
|
||||
])
|
||||
.finalize(&keys)
|
||||
.unwrap();
|
||||
|
||||
let result = RepositoryAnnouncement::from_event(event);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("duplicate"));
|
||||
let announcement = RepositoryAnnouncement::from_event(event).unwrap();
|
||||
assert!(!announcement.listed_maintainers().contains(&moderator));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_duplicate_same_letter_role_tag_rejects_announcement() {
|
||||
fn test_duplicate_same_letter_role_tags_are_consolidated() {
|
||||
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 returned = create_test_keys().public_key().to_hex();
|
||||
let gone = create_test_keys().public_key().to_hex();
|
||||
|
||||
// Out-of-spec duplicate tags under the same letter are consolidated
|
||||
// instead of rejecting the announcement: a pubkey is a maintainer
|
||||
// while any of its entries is active.
|
||||
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]),
|
||||
Tag::custom(
|
||||
"m",
|
||||
vec![returned.clone(), "0".to_string(), "100".to_string()],
|
||||
),
|
||||
Tag::custom("m", vec![returned.clone(), "200".to_string()]),
|
||||
Tag::custom("m", vec![gone.clone(), "0".to_string(), "100".to_string()]),
|
||||
Tag::custom(
|
||||
"m",
|
||||
vec![gone.clone(), "200".to_string(), "300".to_string()],
|
||||
),
|
||||
])
|
||||
.finalize(&keys)
|
||||
.unwrap();
|
||||
|
||||
let result = RepositoryAnnouncement::from_event(event);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("duplicate"));
|
||||
let announcement = RepositoryAnnouncement::from_event(event).unwrap();
|
||||
let listed = announcement.listed_maintainers();
|
||||
assert!(listed.contains(&returned));
|
||||
assert!(!listed.contains(&gone));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_author_duplicate_entries_consolidate_to_active() {
|
||||
use nostr_sdk::prelude::Tag;
|
||||
|
||||
let keys = create_test_keys();
|
||||
let author = 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.clone(), "0".to_string(), "100".to_string()],
|
||||
),
|
||||
Tag::custom("m", vec![author]),
|
||||
])
|
||||
.finalize(&keys)
|
||||
.unwrap();
|
||||
|
||||
let announcement = RepositoryAnnouncement::from_event(event).unwrap();
|
||||
assert!(announcement.author_role_active());
|
||||
assert!(!announcement.author_has_left());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user