tangle


git clone https://radroots.dev/git/tangle.git
Log | Files | Refs | README | LICENSE

commit 29b266a30d719c8ff3c4b536ba004a6b5c1fbeb0
parent 3333bc18b3ccd380348d215ca26f23abf471f8e1
Author: triesap <tyson@radroots.org>
Date:   Sun, 14 Jun 2026 04:32:52 -0700

groups: use duplicate prefix for membership conflicts

- Add a duplicate group error constructor backed by the duplicate Nostr reply prefix.
- Return duplicate-prefixed errors for duplicate join requests and missing-member leave requests.
- Update runtime and Phase 2 acceptance tests to assert duplicate membership responses use duplicate: messages.
- Validated with cargo fmt --all -- --check, cargo check --workspace --all-targets, cargo test --workspace, and cargo clippy --workspace --all-targets -- -D warnings.

Diffstat:
Mcrates/tangle_groups/src/errors.rs | 15+++++++++++++++
Mcrates/tangle_groups/src/policy.rs | 27++++++++++++++-------------
Mcrates/tangle_runtime/src/relay/core.rs | 4++--
Mcrates/tangle_runtime/tests/base_relay_v2.rs | 4++--
Mcrates/tangle_runtime/tests/phase2_acceptance_targets.rs | 71+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
5 files changed, 96 insertions(+), 25 deletions(-)

diff --git a/crates/tangle_groups/src/errors.rs b/crates/tangle_groups/src/errors.rs @@ -74,6 +74,10 @@ impl GroupError { Self::new(kind, GroupReplyPrefix::Invalid, message) } + pub fn duplicate(kind: GroupErrorKind, message: impl Into<String>) -> Self { + Self::new(kind, GroupReplyPrefix::Duplicate, message) + } + pub fn blocked(kind: GroupErrorKind, message: impl Into<String>) -> Self { Self::new(kind, GroupReplyPrefix::Blocked, message) } @@ -150,5 +154,16 @@ mod tests { error.prefixed_message(), "restricted: missing group capability manage_members" ); + + let duplicate = GroupError::duplicate( + GroupErrorKind::DuplicateMember, + "group member already exists", + ); + + assert_eq!(duplicate.reply_prefix(), GroupReplyPrefix::Duplicate); + assert_eq!( + duplicate.prefixed_message(), + "duplicate: group member already exists" + ); } } diff --git a/crates/tangle_groups/src/policy.rs b/crates/tangle_groups/src/policy.rs @@ -228,7 +228,7 @@ impl<'a> GroupWritePolicy<'a> { ) -> Result<GroupWriteDecision, GroupError> { let group = self.require_active_group(group_id)?; if self.is_current_member(group_id, &event.pubkey()?) { - return Err(GroupError::invalid( + return Err(GroupError::duplicate( GroupErrorKind::DuplicateMember, "group member already exists", )); @@ -246,7 +246,7 @@ impl<'a> GroupWritePolicy<'a> { ) -> Result<GroupWriteDecision, GroupError> { self.require_active_group(group_id)?; if !self.is_current_member(group_id, &event.pubkey()?) { - return Err(GroupError::invalid( + return Err(GroupError::duplicate( GroupErrorKind::DuplicateMember, "group member does not exist", )); @@ -650,18 +650,19 @@ mod tests { .expect("public join"), GroupWriteDecision::Accept ); + let duplicate_join = strict_policy + .check_event( + &event(KIND_GROUP_JOIN_REQUEST, member.clone(), vec![h("Farm")]), + &GroupEventClass::Normal { + group_id: group("Farm"), + }, + &GroupAuthContext::new([member.clone()]), + ) + .expect_err("duplicate join"); + assert_eq!(duplicate_join.kind(), GroupErrorKind::DuplicateMember); assert_eq!( - strict_policy - .check_event( - &event(KIND_GROUP_JOIN_REQUEST, member.clone(), vec![h("Farm")]), - &GroupEventClass::Normal { - group_id: group("Farm") - }, - &GroupAuthContext::new([member.clone()]) - ) - .expect_err("duplicate join") - .kind(), - GroupErrorKind::DuplicateMember + duplicate_join.prefixed_message(), + "duplicate: group member already exists" ); assert_eq!( strict_policy diff --git a/crates/tangle_runtime/src/relay/core.rs b/crates/tangle_runtime/src/relay/core.rs @@ -1144,7 +1144,7 @@ mod tests { .handle_event_with_auth(duplicate_join, &member_auth) .expect("duplicate join") ), - "invalid: group member already exists" + "duplicate: group member already exists" ); let leave = signed_event_at( @@ -1175,7 +1175,7 @@ mod tests { .handle_event_with_auth(duplicate_leave, &member_auth) .expect("duplicate leave") ), - "invalid: group member does not exist" + "duplicate: group member does not exist" ); } diff --git a/crates/tangle_runtime/tests/base_relay_v2.rs b/crates/tangle_runtime/tests/base_relay_v2.rs @@ -217,7 +217,7 @@ fn group_auth_lifecycle_membership_and_flag_flows_pass_in_process() { ) .expect("duplicate") ), - "invalid: group member already exists" + "duplicate: group member already exists" ); let leave = tangle_v2_leave_event(FixtureKey::Outsider, "Farm", 6).expect("leave"); @@ -236,7 +236,7 @@ fn group_auth_lifecycle_membership_and_flag_flows_pass_in_process() { ) .expect("admin leave") ), - "invalid: group member does not exist" + "duplicate: group member does not exist" ); let protected_remove = diff --git a/crates/tangle_runtime/tests/phase2_acceptance_targets.rs b/crates/tangle_runtime/tests/phase2_acceptance_targets.rs @@ -13,7 +13,8 @@ use tangle_groups::{ GroupAuthContext, GroupAuthority, GroupErrorKind, GroupEventClass, GroupId, GroupMetadata, GroupMetadataFlags, GroupMetadataText, GroupPolicyConfig, GroupProjection, GroupReadDecision, GroupReadGate, GroupState, GroupWritePolicy, KIND_GROUP_ADMINS, KIND_GROUP_JOIN_REQUEST, - KIND_GROUP_MEMBERS, KIND_GROUP_METADATA, ProjectionOrderTuple, StoreOffset, SupportedKinds, + KIND_GROUP_LEAVE_REQUEST, KIND_GROUP_MEMBERS, KIND_GROUP_METADATA, MemberState, MemberStatus, + ProjectionOrderTuple, StoreOffset, SupportedKinds, }; use tangle_protocol::{ Event, EventId, Kind, PublicKeyHex, RelayMessage, SignatureHex, Tag, UnixTimestamp, @@ -350,9 +351,59 @@ fn public_join_defaults_false() { } #[test] -#[ignore = "phase2 target: duplicate membership prefixes"] fn duplicate_join_and_leave_use_duplicate_prefix() { - pending("duplicate join and leave responses must use the duplicate prefix"); + let owner = phase2_pubkey("1"); + let member = phase2_pubkey("2"); + let outsider = phase2_pubkey("3"); + let mut projection = phase2_projection_with_group( + "Farm", + phase2_metadata(false, false, false, false), + owner.clone(), + ); + projection.put_member( + GroupId::new("Farm").expect("group"), + MemberState::new( + member.clone(), + MemberStatus::Member, + Default::default(), + phase2_event_id("20"), + phase2_order_tuple(20, "20", 2), + ), + ); + let authority = GroupAuthority::new([owner], Vec::<PublicKeyHex>::new()); + let policy = GroupWritePolicy::new( + &projection, + &authority, + GroupPolicyConfig::new(true, false).expect("policy"), + ); + + let duplicate_join = policy + .check_event( + &phase2_group_event(KIND_GROUP_JOIN_REQUEST, "Farm", member.clone()), + &GroupEventClass::Normal { + group_id: GroupId::new("Farm").expect("group"), + }, + &GroupAuthContext::new([member]), + ) + .expect_err("duplicate join"); + assert_eq!( + duplicate_join.prefixed_message(), + "duplicate: group member already exists" + ); + + let duplicate_leave = policy + .check_event( + &phase2_group_event(KIND_GROUP_LEAVE_REQUEST, "Farm", outsider.clone()), + &GroupEventClass::Normal { + group_id: GroupId::new("Farm").expect("group"), + }, + &GroupAuthContext::new([outsider]), + ) + .expect_err("duplicate leave"); + assert_eq!( + duplicate_leave.prefixed_message(), + "duplicate: group member does not exist" + ); } #[test] @@ -576,11 +627,7 @@ fn phase2_projection_with_group( metadata, author, phase2_event_id("10"), - ProjectionOrderTuple::new( - UnixTimestamp::new(10), - phase2_event_id("10"), - StoreOffset::new(1), - ), + phase2_order_tuple(10, "10", 1), )); projection } @@ -631,6 +678,14 @@ fn phase2_event_id(suffix: &str) -> EventId { EventId::new(&value).expect("id") } +fn phase2_order_tuple(created_at: u64, suffix: &str, offset: u64) -> ProjectionOrderTuple { + ProjectionOrderTuple::new( + UnixTimestamp::new(created_at), + phase2_event_id(suffix), + StoreOffset::new(offset), + ) +} + fn current_unix_timestamp() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH)