tangle


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

commit f50fb5f4ae566dcdc1903386e946f47a176d2bbe
parent cbdf1221f373efe8af72f2ae98d2bf1cb0fe2882
Author: triesap <tyson@radroots.org>
Date:   Sat, 13 Jun 2026 18:37:24 -0700

feat: complete group flow gates

- persist event deletion projection state

- hide moderated targets and keep tombstone markers gated

- validate delete-event targets against stored group events

- reject disabled invite creation

- cover end-to-end group moderation flows

Diffstat:
Mcrates/tangle_groups/src/lib.rs | 6+++---
Mcrates/tangle_groups/src/policy.rs | 40+++++++++++++++++++++++++++++++++++++---
Mcrates/tangle_groups/src/projection.rs | 198++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mcrates/tangle_groups/src/read_gate.rs | 116++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mcrates/tangle_runtime/src/base_relay.rs | 691++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
5 files changed, 1012 insertions(+), 39 deletions(-)

diff --git a/crates/tangle_groups/src/lib.rs b/crates/tangle_groups/src/lib.rs @@ -39,12 +39,12 @@ pub use policy::{ GroupAuthority, GroupWriteDecision, GroupWritePolicy, non_enumerating_group_error, }; pub use projection::{ - CanonicalGroupEvent, GROUP_POLICY_VERSION, GROUP_PROJECTION_SCHEMA_VERSION, + CanonicalGroupEvent, GROUP_POLICY_VERSION, GROUP_PROJECTION_SCHEMA_VERSION, GroupEventDeletion, GroupLifecycleState, GroupProjection, GroupRecoveryReadiness, GroupSnapshotIds, GroupState, GroupTombstone, MemberState, MemberStatus, ProjectedRoleDefinition, ProjectionApplyOutcome, ProjectionCheckpoint, ProjectionOrderTuple, ProjectionRebuildReport, StoreOffset, - group_current_key, member_current_key, projection_checkpoint_key, rebuild_group_projection, - role_current_key, tombstone_key, + event_deletion_key, group_current_key, member_current_key, projection_checkpoint_key, + rebuild_group_projection, role_current_key, tombstone_key, }; pub use read_gate::{GroupReadDecision, GroupReadGate}; pub use roles::{ diff --git a/crates/tangle_groups/src/policy.rs b/crates/tangle_groups/src/policy.rs @@ -123,6 +123,12 @@ impl<'a> GroupWritePolicy<'a> { return self.check_create_group(event, group_id); } let group = self.require_active_group(group_id)?; + if kind == KIND_GROUP_CREATE_INVITE { + return Err(GroupError::restricted( + GroupErrorKind::MissingCapability, + "invites not enabled", + )); + } let required = required_capability(kind, event.unsigned().tags())?; if let Some(required) = required { self.require_capability(group_id, event.unsigned().pubkey(), required)?; @@ -369,9 +375,10 @@ mod tests { use crate::{ Capability, CapabilitySet, GroupAuthContext, GroupErrorKind, GroupEventClass, GroupId, GroupMetadata, GroupProjection, GroupState, KIND_GROUP_CREATE_GROUP, - KIND_GROUP_DELETE_GROUP, KIND_GROUP_JOIN_REQUEST, KIND_GROUP_LEAVE_REQUEST, - KIND_GROUP_REMOVE_USER, MemberState, MemberStatus, ProjectedRoleDefinition, - ProjectionOrderTuple, RoleDefinition, RoleName, StoreOffset, SupportedKinds, + KIND_GROUP_CREATE_INVITE, KIND_GROUP_DELETE_GROUP, KIND_GROUP_JOIN_REQUEST, + KIND_GROUP_LEAVE_REQUEST, KIND_GROUP_REMOVE_USER, MemberState, MemberStatus, + ProjectedRoleDefinition, ProjectionOrderTuple, RoleDefinition, RoleName, StoreOffset, + SupportedKinds, }; use tangle_protocol::{ Event, EventId, Kind, PublicKeyHex, SignatureHex, Tag, UnixTimestamp, UnsignedEvent, @@ -656,6 +663,33 @@ mod tests { ); } + #[test] + fn invite_creation_is_rejected_while_invites_are_disabled() { + let owner = pubkey("1"); + let projection = projection_with_group( + "Farm", + metadata(false, false, false, false, SupportedKinds::UnspecifiedAll), + owner.clone(), + ); + let authority = GroupAuthority::new([owner.clone()], Vec::<PublicKeyHex>::new()); + let policy = GroupWritePolicy::new(&projection, &authority); + let invite = event(KIND_GROUP_CREATE_INVITE, owner.clone(), vec![h("Farm")]); + + let error = policy + .check_event( + &invite, + &GroupEventClass::Moderation { + kind: kind(KIND_GROUP_CREATE_INVITE), + group_id: group("Farm"), + }, + &GroupAuthContext::new([owner]), + ) + .expect_err("invite"); + + assert_eq!(error.kind(), GroupErrorKind::MissingCapability); + assert_eq!(error.prefixed_message(), "restricted: invites not enabled"); + } + fn projection_with_group( group_id: &str, metadata: GroupMetadata, diff --git a/crates/tangle_groups/src/projection.rs b/crates/tangle_groups/src/projection.rs @@ -409,6 +409,74 @@ impl GroupTombstone { } #[derive(Debug, Clone, PartialEq, Eq)] +pub struct GroupEventDeletion { + group_id: GroupId, + target_event_id: EventId, + delete_event_id: EventId, + deleted_at: UnixTimestamp, + deleted_by: PublicKeyHex, + last_tuple: ProjectionOrderTuple, +} + +impl GroupEventDeletion { + pub fn new( + group_id: GroupId, + target_event_id: EventId, + delete_event_id: EventId, + deleted_at: UnixTimestamp, + deleted_by: PublicKeyHex, + last_tuple: ProjectionOrderTuple, + ) -> Self { + Self { + group_id, + target_event_id, + delete_event_id, + deleted_at, + deleted_by, + last_tuple, + } + } + + pub fn group_id(&self) -> &GroupId { + &self.group_id + } + + pub fn target_event_id(&self) -> &EventId { + &self.target_event_id + } + + pub fn delete_event_id(&self) -> &EventId { + &self.delete_event_id + } + + pub fn deleted_at(&self) -> UnixTimestamp { + self.deleted_at + } + + pub fn deleted_by(&self) -> &PublicKeyHex { + &self.deleted_by + } + + pub fn last_tuple(&self) -> &ProjectionOrderTuple { + &self.last_tuple + } + + pub fn to_json_bytes(&self) -> Result<Vec<u8>, GroupError> { + serde_json::to_vec(&GroupEventDeletionDocument::from_deletion(self)).map_err(|error| { + GroupError::internal(format!("event deletion JSON encode failed: {error}")) + }) + } + + pub fn from_json_bytes(raw: &[u8]) -> Result<Self, GroupError> { + let document = + serde_json::from_slice::<GroupEventDeletionDocument>(raw).map_err(|error| { + GroupError::internal(format!("event deletion JSON decode failed: {error}")) + })?; + document.into_deletion() + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] pub struct ProjectionCheckpoint { projection_version: u32, policy_version: u32, @@ -482,6 +550,7 @@ pub struct GroupProjection { members: BTreeMap<(GroupId, PublicKeyHex), MemberState>, roles: BTreeMap<(GroupId, RoleName), ProjectedRoleDefinition>, tombstones: BTreeMap<GroupId, GroupTombstone>, + event_deletions: BTreeMap<EventId, GroupEventDeletion>, checkpoint: Option<ProjectionCheckpoint>, } @@ -510,6 +579,10 @@ impl GroupProjection { self.tombstones.get(group_id) } + pub fn event_deletion(&self, event_id: &EventId) -> Option<&GroupEventDeletion> { + self.event_deletions.get(event_id) + } + pub fn groups(&self) -> &BTreeMap<GroupId, GroupState> { &self.groups } @@ -526,6 +599,10 @@ impl GroupProjection { &self.tombstones } + pub fn event_deletions(&self) -> &BTreeMap<EventId, GroupEventDeletion> { + &self.event_deletions + } + pub fn checkpoint(&self) -> Option<&ProjectionCheckpoint> { self.checkpoint.as_ref() } @@ -571,6 +648,17 @@ impl GroupProjection { } } + pub fn put_event_deletion(&mut self, deletion: GroupEventDeletion) { + if self + .event_deletions + .get(deletion.target_event_id()) + .is_none_or(|current| deletion.last_tuple() >= current.last_tuple()) + { + self.event_deletions + .insert(deletion.target_event_id().clone(), deletion); + } + } + pub fn apply_canonical_event( &mut self, event: &Event, @@ -633,6 +721,25 @@ impl GroupProjection { crate::KIND_GROUP_REMOVE_USER => { self.apply_member_status(group_id, event, tuple, MemberStatus::Removed) } + crate::KIND_GROUP_DELETE_EVENT => { + let target_event_id = EventId::new(first_tag_value(event.unsigned().tags(), "e")?) + .map_err(|reason| { + GroupError::invalid( + GroupErrorKind::MalformedTargetTag, + format!("malformed e target tag: {reason}"), + ) + })?; + let deletion = GroupEventDeletion::new( + group_id, + target_event_id, + event.id().clone(), + event.unsigned().created_at(), + event.unsigned().pubkey().clone(), + tuple, + ); + self.put_event_deletion(deletion); + Ok(ProjectionApplyOutcome::Applied) + } crate::KIND_GROUP_DELETE_GROUP => { let tombstone = GroupTombstone::new( group_id.clone(), @@ -846,6 +953,10 @@ pub fn tombstone_key(group_id: &GroupId) -> Vec<u8> { prefixed_key("tombstone", group_id.as_str(), None) } +pub fn event_deletion_key(event_id: &EventId) -> Vec<u8> { + prefixed_key("event_deletion", event_id.as_str(), None) +} + pub fn projection_checkpoint_key() -> Vec<u8> { b"checkpoint\0groups".to_vec() } @@ -1193,6 +1304,40 @@ impl GroupTombstoneDocument { } #[derive(Debug, Clone, Serialize, Deserialize)] +struct GroupEventDeletionDocument { + group_id: String, + target_event_id: String, + delete_event_id: String, + deleted_at: u64, + deleted_by: String, + last_tuple: TupleDocument, +} + +impl GroupEventDeletionDocument { + fn from_deletion(deletion: &GroupEventDeletion) -> Self { + Self { + group_id: deletion.group_id().as_str().to_owned(), + target_event_id: deletion.target_event_id().as_str().to_owned(), + delete_event_id: deletion.delete_event_id().as_str().to_owned(), + deleted_at: deletion.deleted_at().as_u64(), + deleted_by: deletion.deleted_by().as_str().to_owned(), + last_tuple: TupleDocument::from_tuple(deletion.last_tuple()), + } + } + + fn into_deletion(self) -> Result<GroupEventDeletion, GroupError> { + Ok(GroupEventDeletion::new( + GroupId::new(&self.group_id)?, + EventId::new(&self.target_event_id).map_err(GroupError::internal)?, + EventId::new(&self.delete_event_id).map_err(GroupError::internal)?, + UnixTimestamp::new(self.deleted_at), + PublicKeyHex::new(&self.deleted_by).map_err(GroupError::internal)?, + self.last_tuple.into_tuple()?, + )) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] struct ProjectionCheckpointDocument { projection_version: u32, policy_version: u32, @@ -1267,15 +1412,16 @@ fn parse_optional_event_id(value: Option<String>) -> Result<Option<EventId>, Gro #[cfg(test)] mod tests { use super::{ - CanonicalGroupEvent, GroupLifecycleState, GroupProjection, GroupTombstone, MemberStatus, - ProjectedRoleDefinition, ProjectionCheckpoint, ProjectionOrderTuple, StoreOffset, - group_current_key, member_current_key, projection_checkpoint_key, rebuild_group_projection, - role_current_key, tombstone_key, + CanonicalGroupEvent, GroupEventDeletion, GroupLifecycleState, GroupProjection, + GroupTombstone, MemberStatus, ProjectedRoleDefinition, ProjectionCheckpoint, + ProjectionOrderTuple, StoreOffset, event_deletion_key, group_current_key, + member_current_key, projection_checkpoint_key, rebuild_group_projection, role_current_key, + tombstone_key, }; use crate::{ Capability, CapabilitySet, GroupId, GroupLimitsConfig, KIND_GROUP_CREATE_GROUP, - KIND_GROUP_DELETE_GROUP, KIND_GROUP_EDIT_METADATA, KIND_GROUP_METADATA, - KIND_GROUP_PUT_USER, RoleDefinition, RoleName, SupportedKinds, + KIND_GROUP_DELETE_EVENT, KIND_GROUP_DELETE_GROUP, KIND_GROUP_EDIT_METADATA, + KIND_GROUP_METADATA, KIND_GROUP_PUT_USER, RoleDefinition, RoleName, SupportedKinds, }; use tangle_protocol::{ Event, EventId, Kind, PublicKeyHex, SignatureHex, Tag, UnixTimestamp, UnsignedEvent, @@ -1365,12 +1511,27 @@ mod tests { projection .apply_canonical_event( &event( + KIND_GROUP_DELETE_EVENT, + "45", + 45, + vec![ + Tag::from_parts("h", &["Farm"]).expect("h"), + Tag::from_parts("e", &[id("30")]).expect("e"), + ], + ), + StoreOffset::new(5), + GroupLimitsConfig::default(), + ) + .expect("event deletion"); + projection + .apply_canonical_event( + &event( KIND_GROUP_DELETE_GROUP, "50", 50, vec![Tag::from_parts("h", &["Farm"]).expect("h")], ), - StoreOffset::new(5), + StoreOffset::new(6), GroupLimitsConfig::default(), ) .expect("delete"); @@ -1401,6 +1562,11 @@ mod tests { .tombstone(&GroupId::new("Farm").expect("group")) .is_some() ); + assert!( + projection + .event_deletion(&EventId::new(id("30")).expect("event")) + .is_some() + ); } #[test] @@ -1507,6 +1673,14 @@ mod tests { PublicKeyHex::new(&"3".repeat(64)).expect("pubkey"), tuple(40, "40", 4), ); + let deletion = GroupEventDeletion::new( + GroupId::new("Farm").expect("group"), + EventId::new(id("30")).expect("target"), + EventId::new(id("45")).expect("delete"), + UnixTimestamp::new(45), + PublicKeyHex::new(&"3".repeat(64)).expect("pubkey"), + tuple(45, "45", 5), + ); let checkpoint = ProjectionCheckpoint::current(Some(StoreOffset::new(25)), UnixTimestamp::new(99)); @@ -1531,6 +1705,11 @@ mod tests { tombstone ); assert_eq!( + GroupEventDeletion::from_json_bytes(&deletion.to_json_bytes().expect("bytes")) + .expect("deletion"), + deletion + ); + assert_eq!( ProjectionCheckpoint::from_json_bytes(&checkpoint.to_json_bytes().expect("bytes")) .expect("checkpoint"), checkpoint @@ -1552,6 +1731,10 @@ mod tests { b"role\0Farm\0moderator".to_vec() ); assert_eq!(tombstone_key(&group), b"tombstone\0Farm".to_vec()); + assert_eq!( + event_deletion_key(&EventId::new(id("30")).expect("event")), + format!("event_deletion\0{}", id("30")).into_bytes() + ); assert_eq!(projection_checkpoint_key(), b"checkpoint\0groups".to_vec()); } @@ -1583,6 +1766,7 @@ mod tests { "20" => "0000000000000000000000000000000000000000000000000000000000000020", "30" => "0000000000000000000000000000000000000000000000000000000000000030", "40" => "0000000000000000000000000000000000000000000000000000000000000040", + "45" => "0000000000000000000000000000000000000000000000000000000000000045", "50" => "0000000000000000000000000000000000000000000000000000000000000050", "a" => "000000000000000000000000000000000000000000000000000000000000000a", "b" => "000000000000000000000000000000000000000000000000000000000000000b", diff --git a/crates/tangle_groups/src/read_gate.rs b/crates/tangle_groups/src/read_gate.rs @@ -1,6 +1,6 @@ use crate::{ GroupAuthority, GroupError, GroupEventClass, GroupId, GroupLimitsConfig, GroupProjection, - MemberStatus, classify_group_event, non_enumerating_group_error, + KIND_GROUP_DELETE_GROUP, MemberStatus, classify_group_event, non_enumerating_group_error, }; use tangle_protocol::{Event, PublicKeyHex}; @@ -30,11 +30,18 @@ impl<'a> GroupReadGate<'a> { reader: Option<&PublicKeyHex>, limits: GroupLimitsConfig, ) -> Result<GroupReadDecision, GroupError> { + if self.projection.event_deletion(event.id()).is_some() { + return Ok(GroupReadDecision::Hidden); + } match classify_group_event(event, limits)? { GroupEventClass::NonGroup => Ok(GroupReadDecision::Visible), GroupEventClass::Normal { group_id } => self.screen_normal_event(&group_id, reader), GroupEventClass::Moderation { group_id, .. } => { - self.screen_normal_event(&group_id, reader) + if event.unsigned().kind().as_u32() == KIND_GROUP_DELETE_GROUP { + self.screen_delete_group_marker(event, &group_id, reader) + } else { + self.screen_normal_event(&group_id, reader) + } } GroupEventClass::RelayGeneratedSnapshot { kind, group_id } => { self.screen_snapshot_event(kind.as_u32(), &group_id, reader) @@ -75,6 +82,30 @@ impl<'a> GroupReadGate<'a> { Ok(GroupReadDecision::Visible) } + fn screen_delete_group_marker( + &self, + event: &Event, + group_id: &GroupId, + reader: Option<&PublicKeyHex>, + ) -> Result<GroupReadDecision, GroupError> { + let Some(group) = self.projection.group(group_id) else { + return Ok(GroupReadDecision::Hidden); + }; + if !self + .projection + .tombstone(group_id) + .is_some_and(|tombstone| tombstone.delete_event_id() == event.id()) + { + return self.screen_normal_event(group_id, reader); + } + if (group.metadata().hidden() || group.metadata().private()) + && !self.can_read_group(group_id, reader) + { + return Ok(GroupReadDecision::Hidden); + } + Ok(GroupReadDecision::Visible) + } + fn screen_normal_event( &self, group_id: &GroupId, @@ -111,8 +142,9 @@ impl<'a> GroupReadGate<'a> { mod tests { use super::{GroupReadDecision, GroupReadGate}; use crate::{ - GroupAuthority, GroupId, GroupMetadata, GroupProjection, GroupState, KIND_GROUP_METADATA, - MemberState, MemberStatus, ProjectionOrderTuple, StoreOffset, SupportedKinds, + GroupAuthority, GroupEventDeletion, GroupId, GroupMetadata, GroupProjection, GroupState, + GroupTombstone, KIND_GROUP_DELETE_GROUP, KIND_GROUP_METADATA, MemberState, MemberStatus, + ProjectionOrderTuple, StoreOffset, SupportedKinds, }; use tangle_protocol::{ Event, EventId, Kind, PublicKeyHex, SignatureHex, Tag, UnixTimestamp, UnsignedEvent, @@ -266,6 +298,82 @@ mod tests { ); } + #[test] + fn read_gate_hides_deleted_target_events() { + let owner = pubkey("1"); + let mut projection = projection_with_group( + "Farm", + GroupMetadata::new( + None, + None, + None, + false, + false, + false, + false, + SupportedKinds::UnspecifiedAll, + ), + owner, + ); + let target = event(1, vec![h("Farm")]); + projection.put_event_deletion(GroupEventDeletion::new( + GroupId::new("Farm").expect("group"), + target.id().clone(), + event_id("20"), + UnixTimestamp::new(20), + pubkey("2"), + tuple(20, "20", 2), + )); + let authority = GroupAuthority::empty(); + let gate = GroupReadGate::new(&projection, &authority); + + assert_eq!( + gate.screen_event(&target, None, Default::default()) + .expect("deleted"), + GroupReadDecision::Hidden + ); + } + + #[test] + fn read_gate_keeps_group_delete_marker_under_group_visibility_policy() { + let owner = pubkey("1"); + let mut projection = projection_with_group( + "Farm", + GroupMetadata::new( + None, + None, + None, + false, + false, + false, + false, + SupportedKinds::UnspecifiedAll, + ), + owner, + ); + let marker = event(KIND_GROUP_DELETE_GROUP, vec![h("Farm")]); + projection.put_tombstone(GroupTombstone::new( + GroupId::new("Farm").expect("group"), + marker.id().clone(), + marker.unsigned().created_at(), + marker.unsigned().pubkey().clone(), + tuple(30, "01", 3), + )); + let authority = GroupAuthority::empty(); + let gate = GroupReadGate::new(&projection, &authority); + + assert_eq!( + gate.screen_event(&marker, None, Default::default()) + .expect("marker"), + GroupReadDecision::Visible + ); + assert_eq!( + gate.screen_event(&event(1, vec![h("Farm")]), None, Default::default()) + .expect("normal"), + GroupReadDecision::Hidden + ); + } + fn projection_with_group( group_id: &str, metadata: GroupMetadata, diff --git a/crates/tangle_runtime/src/base_relay.rs b/crates/tangle_runtime/src/base_relay.rs @@ -10,14 +10,16 @@ use serde::{Deserialize, Serialize}; use std::{collections::BTreeMap, collections::BTreeSet, str}; use tangle_crypto::{RelaySigner, verify_event_signature}; use tangle_groups::{ - GroupAuthContext, GroupAuthority, GroupError, GroupEventClass, GroupGeneratedEventBuilder, - GroupId, GroupLimitsConfig, GroupOutbox, GroupOutboxEffect, GroupOutboxKey, GroupOutboxPayload, - GroupOutboxRecord, GroupProjection, GroupReadDecision, GroupReadGate, GroupRuntimeConfig, - GroupState, GroupTombstone, KIND_GROUP_CREATE_GROUP, KIND_GROUP_EDIT_METADATA, + GroupAuthContext, GroupAuthority, GroupError, GroupErrorKind, GroupEventClass, + GroupEventDeletion, GroupGeneratedEventBuilder, GroupId, GroupLimitsConfig, GroupOutbox, + GroupOutboxEffect, GroupOutboxKey, GroupOutboxPayload, GroupOutboxRecord, GroupProjection, + GroupReadDecision, GroupReadGate, GroupRuntimeConfig, GroupState, GroupTombstone, + KIND_GROUP_CREATE_GROUP, KIND_GROUP_DELETE_EVENT, KIND_GROUP_EDIT_METADATA, KIND_GROUP_JOIN_REQUEST, KIND_GROUP_LEAVE_REQUEST, KIND_GROUP_MEMBERS, KIND_GROUP_PUT_USER, KIND_GROUP_REMOVE_USER, MemberState, ProjectedRoleDefinition, ProjectionCheckpoint, - StoreOffset, group_current_key, member_current_key, projection_checkpoint_key, - role_current_key, tombstone_key, validate_client_group_event_structure, + StoreOffset, event_deletion_key, group_current_key, member_current_key, + projection_checkpoint_key, role_current_key, tombstone_key, + validate_client_group_event_structure, }; use tangle_nips::parse_relay_auth_event; use tangle_protocol::{ @@ -388,7 +390,7 @@ impl BaseRelay { "blocked: NIP-29 group events are not accepted before group service".to_owned(), )); }; - if let Err(error) = groups.check_event(&event, &class, auth) { + if let Err(error) = groups.check_event(&self.store, &event, &class, auth) { return Ok(ok_rejected(event_id, error.prefixed_message())); } } @@ -591,13 +593,60 @@ impl GroupService { fn check_event( &self, + store: &PocketStoreHandle, event: &Event, class: &GroupEventClass, auth: &GroupAuthContext, ) -> Result<(), GroupError> { tangle_groups::GroupWritePolicy::new(&self.projection, &self.authority) .check_event(event, class, auth) - .map(|_| ()) + .map(|_| ())?; + self.check_runtime_write_constraints(store, event, class) + } + + fn check_runtime_write_constraints( + &self, + store: &PocketStoreHandle, + event: &Event, + class: &GroupEventClass, + ) -> Result<(), GroupError> { + if let GroupEventClass::Moderation { kind, group_id } = class + && kind.as_u32() == KIND_GROUP_DELETE_EVENT + { + self.check_delete_event_target(store, event, group_id)?; + } + Ok(()) + } + + fn check_delete_event_target( + &self, + store: &PocketStoreHandle, + event: &Event, + group_id: &GroupId, + ) -> Result<(), GroupError> { + let target_id = delete_target_event_id(event)?; + let Some(target) = store + .event_by_id( + pocket_event_id(&target_id) + .map_err(|error| GroupError::internal(error.prefixed_message()))?, + ) + .map_err(|error| GroupError::internal(error.to_string()))? + else { + return Err(GroupError::invalid( + GroupErrorKind::MalformedTargetTag, + "delete target event is unavailable", + )); + }; + let target = pocket_event_to_tangle(&target) + .map_err(|error| GroupError::internal(error.prefixed_message()))?; + let target_class = tangle_groups::classify_group_event(&target, self.limits)?; + if target_class.group_id() != Some(group_id) { + return Err(GroupError::invalid( + GroupErrorKind::MalformedTargetTag, + "delete target event is not in group", + )); + } + Ok(()) } fn event_visible_to_auth( @@ -862,6 +911,15 @@ impl GroupService { &tombstone.to_json_bytes()?, )?; } + for (target_event_id, deletion) in self.projection.event_deletions() { + if deletion.group_id() == group_id { + store.put_extra_record( + TANGLE_GROUP_PROJECTION_TABLE, + &event_deletion_key(target_event_id), + &deletion.to_json_bytes()?, + )?; + } + } Ok(()) } @@ -886,6 +944,9 @@ fn load_group_projection(store: &PocketStoreHandle) -> Result<GroupProjection, B ProjectedRoleDefinition::from_json_bytes(&value)?, ), ["tombstone", _] => projection.put_tombstone(GroupTombstone::from_json_bytes(&value)?), + ["event_deletion", _] => { + projection.put_event_deletion(GroupEventDeletion::from_json_bytes(&value)?) + } _ => {} } } @@ -931,6 +992,30 @@ fn class_group_id(class: &GroupEventClass) -> Option<&GroupId> { } } +fn delete_target_event_id(event: &Event) -> Result<EventId, GroupError> { + for tag in event.unsigned().tags() { + if !tag.values().first().is_some_and(|name| name == "e") { + continue; + } + let Some((_, value)) = tag.indexed_pair() else { + return Err(GroupError::invalid( + GroupErrorKind::MalformedTargetTag, + "malformed e target tag", + )); + }; + return EventId::new(value).map_err(|reason| { + GroupError::invalid( + GroupErrorKind::MalformedTargetTag, + format!("malformed e target tag: {reason}"), + ) + }); + } + Err(GroupError::invalid( + GroupErrorKind::MissingTargetTag, + "missing e target tag", + )) +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct LiveSubscriptionSet { subscriptions: BTreeMap<SubscriptionId, LiveSubscription>, @@ -1176,12 +1261,14 @@ mod tests { use http::{Request, StatusCode, header}; use tangle_crypto::RelaySigner; use tangle_groups::{ - GroupId, KIND_GROUP_ADMINS, KIND_GROUP_CREATE_GROUP, KIND_GROUP_JOIN_REQUEST, - KIND_GROUP_METADATA, KIND_GROUP_PUT_USER, MemberStatus, parse_group_runtime_config_json, + GroupId, KIND_GROUP_ADMINS, KIND_GROUP_CREATE_GROUP, KIND_GROUP_CREATE_INVITE, + KIND_GROUP_DELETE_EVENT, KIND_GROUP_DELETE_GROUP, KIND_GROUP_EDIT_METADATA, + KIND_GROUP_JOIN_REQUEST, KIND_GROUP_LEAVE_REQUEST, KIND_GROUP_MEMBERS, KIND_GROUP_METADATA, + KIND_GROUP_PUT_USER, KIND_GROUP_REMOVE_USER, MemberStatus, parse_group_runtime_config_json, }; use tangle_protocol::{ - ClientMessage, Event, Filter, Kind, PublicKeyHex, RelayMessage, SubscriptionId, Tag, - UnixTimestamp, UnsignedEvent, filter_from_value, + ClientMessage, Event, EventId, Filter, Kind, PublicKeyHex, RelayMessage, SubscriptionId, + Tag, UnixTimestamp, UnsignedEvent, filter_from_value, }; use tangle_store_pocket::{PocketStoreConfig, PocketSyncPolicy}; use tower::ServiceExt; @@ -1508,6 +1595,423 @@ mod tests { } #[test] + fn group_metadata_edit_replaces_generated_metadata_snapshot() { + let owner = signer(7).public_key().clone(); + let mut relay = test_relay_with_groups( + "base-relay-group-metadata-edit", + 4, + &enabled_groups_for_owner(&owner), + ); + let auth = authenticated_state(7); + let create = signed_group_create_event(7, "Farm"); + assert_accepted( + relay + .handle_event_with_auth(create.clone(), &auth) + .expect("create"), + &create, + ); + let edit = signed_event_at( + 7, + KIND_GROUP_EDIT_METADATA.into(), + vec![h("Farm"), name("Market")], + "", + 1_714_124_436, + ); + assert_accepted( + relay + .handle_event_with_auth(edit.clone(), &auth) + .expect("edit"), + &edit, + ); + + let group_id = GroupId::new("Farm").expect("group"); + let group = relay + .group_projection() + .expect("projection") + .group(&group_id) + .expect("group"); + assert_eq!(group.metadata().name(), Some("Market")); + let metadata = query_filter( + &mut relay, + "metadata-edit", + filter_group_tag(KIND_GROUP_METADATA, "d", "Farm"), + ); + assert_eq!(metadata.len(), 1); + assert!(has_tag(&metadata[0], "d", &["Farm"])); + assert!(has_tag(&metadata[0], "name", &["Market"])); + assert_eq!(count_kind(&relay, KIND_GROUP_METADATA), 1); + } + + #[test] + fn group_member_moderation_join_leave_and_snapshots_flow() { + let owner = signer(7).public_key().clone(); + let member = signer(8).public_key().clone(); + let target = signer(9).public_key().clone(); + let mut relay = test_relay_with_groups( + "base-relay-group-member-flow", + 4, + &enabled_groups_for_owner(&owner), + ); + let owner_auth = authenticated_state(7); + let member_auth = authenticated_state(8); + let target_auth = authenticated_state(9); + relay + .handle_event_with_auth(signed_group_create_event(7, "Farm"), &owner_auth) + .expect("create"); + let rejected_add = signed_event_at( + 9, + KIND_GROUP_PUT_USER.into(), + vec![h("Farm"), p(&target)], + "", + 1_714_124_434, + ); + assert_eq!( + rejected_message( + relay + .handle_event_with_auth(rejected_add.clone(), &target_auth) + .expect("rejected add") + ), + "restricted: missing group capability manage_members" + ); + let add = signed_event_at( + 7, + KIND_GROUP_PUT_USER.into(), + vec![h("Farm"), p(&member)], + "", + 1_714_124_435, + ); + assert_accepted( + relay + .handle_event_with_auth(add.clone(), &owner_auth) + .expect("add"), + &add, + ); + assert_member_status(&relay, "Farm", &member, MemberStatus::Member); + assert_eq!(count_kind(&relay, KIND_GROUP_MEMBERS), 1); + + let remove = signed_event_at( + 7, + KIND_GROUP_REMOVE_USER.into(), + vec![h("Farm"), p(&member)], + "", + 1_714_124_436, + ); + assert_accepted( + relay + .handle_event_with_auth(remove.clone(), &owner_auth) + .expect("remove"), + &remove, + ); + assert_member_status(&relay, "Farm", &member, MemberStatus::Removed); + assert_eq!(count_kind(&relay, KIND_GROUP_MEMBERS), 1); + + let join = signed_event_at( + 8, + KIND_GROUP_JOIN_REQUEST.into(), + vec![h("Farm")], + "", + 1_714_124_437, + ); + assert_accepted( + relay + .handle_event_with_auth(join.clone(), &member_auth) + .expect("join"), + &join, + ); + assert_member_status(&relay, "Farm", &member, MemberStatus::Member); + let duplicate_join = signed_event_at( + 8, + KIND_GROUP_JOIN_REQUEST.into(), + vec![h("Farm")], + "", + 1_714_124_438, + ); + assert_eq!( + rejected_message( + relay + .handle_event_with_auth(duplicate_join, &member_auth) + .expect("duplicate join") + ), + "invalid: group member already exists" + ); + + let leave = signed_event_at( + 8, + KIND_GROUP_LEAVE_REQUEST.into(), + vec![h("Farm")], + "", + 1_714_124_439, + ); + assert_accepted( + relay + .handle_event_with_auth(leave.clone(), &member_auth) + .expect("leave"), + &leave, + ); + assert_member_status(&relay, "Farm", &member, MemberStatus::Removed); + assert_eq!(count_kind(&relay, KIND_GROUP_REMOVE_USER), 2); + let duplicate_leave = signed_event_at( + 8, + KIND_GROUP_LEAVE_REQUEST.into(), + vec![h("Farm")], + "", + 1_714_124_440, + ); + assert_eq!( + rejected_message( + relay + .handle_event_with_auth(duplicate_leave, &member_auth) + .expect("duplicate leave") + ), + "invalid: group member does not exist" + ); + } + + #[test] + fn group_delete_event_moderation_hides_target_and_validates_group() { + let owner = signer(7).public_key().clone(); + let outsider_auth = authenticated_state(8); + let owner_auth = authenticated_state(7); + let mut relay = test_relay_with_groups( + "base-relay-group-delete-event", + 4, + &enabled_groups_for_owner(&owner), + ); + relay + .handle_event_with_auth(signed_group_create_event(7, "Farm"), &owner_auth) + .expect("create farm"); + relay + .handle_event_with_auth(signed_group_create_event(7, "Other"), &owner_auth) + .expect("create other"); + let target = signed_event_at(7, 1, vec![h("Farm")], "harvest", 1_714_124_434); + let other = signed_event_at(7, 1, vec![h("Other")], "other", 1_714_124_435); + relay + .handle_event_with_auth(target.clone(), &owner_auth) + .expect("target"); + relay + .handle_event_with_auth(other.clone(), &owner_auth) + .expect("other"); + + let wrong_group = signed_event_at( + 7, + KIND_GROUP_DELETE_EVENT.into(), + vec![h("Farm"), e(other.id())], + "", + 1_714_124_436, + ); + assert_eq!( + rejected_message( + relay + .handle_event_with_auth(wrong_group, &owner_auth) + .expect("wrong group") + ), + "invalid: delete target event is not in group" + ); + let unauthorized = signed_event_at( + 8, + KIND_GROUP_DELETE_EVENT.into(), + vec![h("Farm"), e(target.id())], + "", + 1_714_124_437, + ); + assert_eq!( + rejected_message( + relay + .handle_event_with_auth(unauthorized, &outsider_auth) + .expect("unauthorized") + ), + "restricted: missing group capability delete_events" + ); + assert_eq!( + count_filter( + &relay, + "target-before-delete", + filter_group_tag(1, "h", "Farm") + ), + 1 + ); + + let delete = signed_event_at( + 7, + KIND_GROUP_DELETE_EVENT.into(), + vec![h("Farm"), e(target.id())], + "", + 1_714_124_438, + ); + assert_accepted( + relay + .handle_event_with_auth(delete.clone(), &owner_auth) + .expect("delete"), + &delete, + ); + + assert_eq!( + count_filter( + &relay, + "target-after-delete", + filter_group_tag(1, "h", "Farm") + ), + 0 + ); + assert_eq!( + count_filter( + &relay, + "delete-event-marker", + filter_group_tag(KIND_GROUP_DELETE_EVENT, "h", "Farm") + ), + 1 + ); + } + + #[test] + fn group_delete_tombstone_hides_events_and_rejects_future_writes() { + let owner = signer(7).public_key().clone(); + let auth = authenticated_state(7); + let mut relay = test_relay_with_groups( + "base-relay-group-delete-tombstone", + 4, + &enabled_groups_for_owner(&owner), + ); + relay + .handle_event_with_auth(signed_group_create_event(7, "Farm"), &auth) + .expect("create"); + let normal = signed_event_at(7, 1, vec![h("Farm")], "harvest", 1_714_124_434); + relay.handle_event_with_auth(normal, &auth).expect("normal"); + let delete_group = signed_event_at( + 7, + KIND_GROUP_DELETE_GROUP.into(), + vec![h("Farm")], + "", + 1_714_124_435, + ); + assert_accepted( + relay + .handle_event_with_auth(delete_group.clone(), &auth) + .expect("delete group"), + &delete_group, + ); + + let future = signed_event_at(7, 1, vec![h("Farm")], "future", 1_714_124_436); + assert_eq!( + rejected_message(relay.handle_event_with_auth(future, &auth).expect("future")), + "blocked: group is deleted" + ); + assert_eq!( + count_filter( + &relay, + "deleted-group-normal", + filter_group_tag(1, "h", "Farm") + ), + 0 + ); + assert_eq!( + count_filter( + &relay, + "deleted-group-marker", + filter_group_tag(KIND_GROUP_DELETE_GROUP, "h", "Farm") + ), + 1 + ); + } + + #[test] + fn strict_closed_restricted_hidden_and_disabled_invite_flows() { + let owner = signer(7).public_key().clone(); + let outsider_auth = authenticated_state(8); + let owner_auth = authenticated_state(7); + let mut relay = test_relay_with_groups( + "base-relay-group-strict-policy-flow", + 4, + &enabled_groups_for_owner(&owner), + ); + relay + .handle_event_with_auth( + signed_group_create_event_with_tags(7, "Restricted", vec![restricted()], 1), + &owner_auth, + ) + .expect("restricted create"); + let restricted_write = + signed_event_at(8, 1, vec![h("Restricted")], "restricted", 1_714_124_434); + assert_eq!( + rejected_message( + relay + .handle_event_with_auth(restricted_write, &outsider_auth) + .expect("restricted write") + ), + "restricted: group is unavailable" + ); + + relay + .handle_event_with_auth( + signed_group_create_event_with_tags(7, "Closed", vec![closed()], 2), + &owner_auth, + ) + .expect("closed create"); + let closed_join = signed_event_at( + 8, + KIND_GROUP_JOIN_REQUEST.into(), + vec![h("Closed")], + "", + 1_714_124_435, + ); + assert_eq!( + rejected_message( + relay + .handle_event_with_auth(closed_join, &outsider_auth) + .expect("closed join") + ), + "restricted: group is unavailable" + ); + let closed_normal = signed_event_at(8, 1, vec![h("Closed")], "open", 1_714_124_436); + assert_accepted( + relay + .handle_event_with_auth(closed_normal.clone(), &outsider_auth) + .expect("closed normal"), + &closed_normal, + ); + + relay + .handle_event_with_auth( + signed_group_create_event_with_tags(7, "Hidden", vec![hidden()], 3), + &owner_auth, + ) + .expect("hidden create"); + assert_eq!( + count_filter( + &relay, + "hidden-unauth", + filter_group_tag(KIND_GROUP_METADATA, "d", "Hidden") + ), + 0 + ); + assert_eq!( + count_filter_with_auth( + &relay, + "hidden-owner", + filter_group_tag(KIND_GROUP_METADATA, "d", "Hidden"), + &owner_auth + ), + 1 + ); + + let invite = signed_event_at( + 7, + KIND_GROUP_CREATE_INVITE.into(), + vec![h("Closed")], + "", + 1_714_124_437, + ); + assert_eq!( + rejected_message( + relay + .handle_event_with_auth(invite, &owner_auth) + .expect("invite") + ), + "restricted: invites not enabled" + ); + } + + #[test] fn private_group_req_and_count_use_reader_auth() { let owner = signer(7).public_key().clone(); let auth = authenticated_state(7); @@ -1723,15 +2227,23 @@ mod tests { } fn signed_group_create_event(secret_byte: u8, group_id: &str) -> Event { + signed_group_create_event_with_tags(secret_byte, group_id, Vec::new(), 1_714_124_433) + } + + fn signed_group_create_event_with_tags( + secret_byte: u8, + group_id: &str, + mut extra_tags: Vec<Tag>, + created_at: u64, + ) -> Event { + let mut tags = vec![h(group_id), name(group_id)]; + tags.append(&mut extra_tags); signed_event_at( secret_byte, KIND_GROUP_CREATE_GROUP.into(), - vec![ - Tag::from_parts("h", &[group_id]).expect("h"), - Tag::from_parts("name", &[group_id]).expect("name"), - ], + tags, "", - 1_714_124_433, + created_at, ) } @@ -1739,11 +2251,7 @@ mod tests { signed_event_at( secret_byte, KIND_GROUP_CREATE_GROUP.into(), - vec![ - Tag::from_parts("h", &[group_id]).expect("h"), - Tag::from_parts("name", &[group_id]).expect("name"), - Tag::from_parts("private", &[]).expect("private"), - ], + vec![h(group_id), name(group_id), private()], "", 1_714_124_433, ) @@ -1801,10 +2309,149 @@ mod tests { } } + fn count_filter(relay: &BaseRelay, subscription_id: &str, filter: Filter) -> u64 { + match relay + .handle_count( + SubscriptionId::new(subscription_id).expect("sub"), + vec![filter], + ) + .expect("count") + { + RelayMessage::Count { count, .. } => count, + _ => panic!("count response expected"), + } + } + + fn count_filter_with_auth( + relay: &BaseRelay, + subscription_id: &str, + filter: Filter, + auth: &BaseAuthState, + ) -> u64 { + match relay + .handle_count_with_auth( + SubscriptionId::new(subscription_id).expect("sub"), + vec![filter], + auth, + ) + .expect("count") + { + RelayMessage::Count { count, .. } => count, + _ => panic!("count response expected"), + } + } + + fn query_filter(relay: &mut BaseRelay, subscription_id: &str, filter: Filter) -> Vec<Event> { + relay + .handle_req( + SubscriptionId::new(subscription_id).expect("sub"), + vec![filter], + ) + .expect("query") + .into_iter() + .filter_map(|message| match message { + RelayMessage::Event { event, .. } => Some(event), + _ => None, + }) + .collect() + } + fn filter_kind(kind: u32) -> Filter { filter_from_value(&serde_json::json!({"kinds":[kind]})).expect("filter") } + fn filter_group_tag(kind: u32, tag: &str, group_id: &str) -> Filter { + let mut value = serde_json::json!({"kinds":[kind]}); + value + .as_object_mut() + .expect("object") + .insert(format!("#{tag}"), serde_json::json!([group_id])); + filter_from_value(&value).expect("filter") + } + + fn assert_accepted(message: RelayMessage, event: &Event) { + assert_eq!( + message, + RelayMessage::Ok { + event_id: event.id().clone(), + accepted: true, + message: String::new() + } + ); + } + + fn rejected_message(message: RelayMessage) -> String { + match message { + RelayMessage::Ok { + accepted: false, + message, + .. + } => message, + _ => panic!("rejected OK expected"), + } + } + + fn assert_member_status( + relay: &BaseRelay, + group_id: &str, + pubkey: &PublicKeyHex, + status: MemberStatus, + ) { + assert_eq!( + relay + .group_projection() + .expect("projection") + .member(&GroupId::new(group_id).expect("group"), pubkey) + .expect("member") + .status(), + status + ); + } + + fn has_tag(event: &Event, name: &str, values: &[&str]) -> bool { + event.unsigned().tags().iter().any(|tag| { + tag.values().first().is_some_and(|value| value == name) + && tag.values().len() == values.len() + 1 + && values.iter().enumerate().all(|(index, expected)| { + tag.values() + .get(index + 1) + .is_some_and(|value| value == expected) + }) + }) + } + + fn h(group_id: &str) -> Tag { + Tag::from_parts("h", &[group_id]).expect("h") + } + + fn p(pubkey: &PublicKeyHex) -> Tag { + Tag::from_parts("p", &[pubkey.as_str()]).expect("p") + } + + fn e(event_id: &EventId) -> Tag { + Tag::from_parts("e", &[event_id.as_str()]).expect("e") + } + + fn name(value: &str) -> Tag { + Tag::from_parts("name", &[value]).expect("name") + } + + fn private() -> Tag { + Tag::from_parts("private", &[]).expect("private") + } + + fn restricted() -> Tag { + Tag::from_parts("restricted", &[]).expect("restricted") + } + + fn hidden() -> Tag { + Tag::from_parts("hidden", &[]).expect("hidden") + } + + fn closed() -> Tag { + Tag::from_parts("closed", &[]).expect("closed") + } + fn signer(secret_byte: u8) -> RelaySigner { RelaySigner::from_secret_hex(&format!("{:02x}", secret_byte).repeat(32)).expect("signer") }