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:
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)