tangle


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

commit 3ac859cd9d435903105d0128edc5c4743df21b3b
parent c7196fb89707c4b6163c6c498f23874fe302a764
Author: triesap <tyson@radroots.org>
Date:   Sun, 14 Jun 2026 04:11:50 -0700

groups: split private and hidden snapshot visibility

- Allow private-but-not-hidden group metadata and admin snapshots to remain visible to unauthenticated readers.
- Keep hidden snapshots and private member snapshots behind the group read gate.
- Update relay and Phase 2 acceptance tests so private normal events stay protected while public metadata remains discoverable.
- 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/read_gate.rs | 63+++++++++++++++++++++++++++++++++++++++++++++++++++------------
Mcrates/tangle_runtime/src/relay/core.rs | 5++++-
Mcrates/tangle_runtime/tests/base_relay_v2.rs | 21+++++++++++++++++++++
Mcrates/tangle_runtime/tests/phase2_acceptance_targets.rs | 114++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
4 files changed, 187 insertions(+), 16 deletions(-)

diff --git a/crates/tangle_groups/src/read_gate.rs b/crates/tangle_groups/src/read_gate.rs @@ -1,7 +1,7 @@ use crate::{ GroupAuthority, GroupError, GroupEventClass, GroupId, GroupLimitsConfig, GroupProjection, - KIND_GROUP_DELETE_GROUP, MemberStatus, classify_group_event, event_view::GroupEventView, - non_enumerating_group_error, + KIND_GROUP_ADMINS, KIND_GROUP_DELETE_GROUP, KIND_GROUP_METADATA, MemberStatus, + classify_group_event, event_view::GroupEventView, non_enumerating_group_error, }; use tangle_protocol::{EventId, PublicKeyHex}; @@ -65,7 +65,7 @@ impl<'a> GroupReadGate<'a> { fn screen_snapshot_event( &self, - _kind: u32, + kind: u32, group_id: &GroupId, reader: Option<&PublicKeyHex>, ) -> Result<GroupReadDecision, GroupError> { @@ -78,7 +78,10 @@ impl<'a> GroupReadGate<'a> { if group.metadata().hidden() && !self.can_read_group(group_id, reader) { return Ok(GroupReadDecision::Hidden); } - if group.metadata().private() && !self.can_read_group(group_id, reader) { + if group.metadata().private() + && !is_public_when_private_snapshot(kind) + && !self.can_read_group(group_id, reader) + { return Ok(GroupReadDecision::Hidden); } Ok(GroupReadDecision::Visible) @@ -140,14 +143,18 @@ impl<'a> GroupReadGate<'a> { } } +fn is_public_when_private_snapshot(kind: u32) -> bool { + matches!(kind, KIND_GROUP_METADATA | KIND_GROUP_ADMINS) +} + #[cfg(test)] mod tests { use super::{GroupReadDecision, GroupReadGate}; use crate::{ GroupAuthority, GroupEventDeletion, GroupId, GroupMetadata, GroupMetadataFlags, - GroupMetadataText, GroupProjection, GroupState, GroupTombstone, KIND_GROUP_DELETE_GROUP, - KIND_GROUP_METADATA, MemberState, MemberStatus, ProjectionOrderTuple, StoreOffset, - SupportedKinds, + GroupMetadataText, GroupProjection, GroupState, GroupTombstone, KIND_GROUP_ADMINS, + KIND_GROUP_DELETE_GROUP, KIND_GROUP_MEMBERS, KIND_GROUP_METADATA, MemberState, + MemberStatus, ProjectionOrderTuple, StoreOffset, SupportedKinds, }; use pocket_types::Event as PocketEvent; use tangle_protocol::{ @@ -227,10 +234,10 @@ mod tests { } #[test] - fn read_gate_hides_hidden_and_private_snapshots_from_non_members() { + fn read_gate_splits_private_and_hidden_snapshot_visibility() { let owner = pubkey("1"); let projection = - projection_with_group("Farm", metadata(true, false, true, false), owner.clone()); + projection_with_group("Farm", metadata(true, false, false, false), owner.clone()); let authority = GroupAuthority::new([owner.clone()], Vec::<PublicKeyHex>::new()); let gate = GroupReadGate::new(&projection, &authority); @@ -240,18 +247,50 @@ mod tests { None, Default::default() ) - .expect("hidden"), + .expect("private metadata"), + GroupReadDecision::Visible + ); + assert_eq!( + gate.screen_event( + &event(KIND_GROUP_ADMINS, vec![d("Farm")]), + None, + Default::default() + ) + .expect("private admins"), + GroupReadDecision::Visible + ); + assert_eq!( + gate.screen_event( + &event(KIND_GROUP_MEMBERS, vec![d("Farm")]), + None, + Default::default() + ) + .expect("private members"), GroupReadDecision::Hidden ); assert_eq!( gate.screen_event( - &event(KIND_GROUP_METADATA, vec![d("Farm")]), + &event(KIND_GROUP_MEMBERS, vec![d("Farm")]), Some(&owner), Default::default() ) - .expect("owner"), + .expect("owner members"), GroupReadDecision::Visible ); + + let hidden_projection = + projection_with_group("Hidden", metadata(false, false, true, false), owner.clone()); + let hidden_gate = GroupReadGate::new(&hidden_projection, &authority); + assert_eq!( + hidden_gate + .screen_event( + &event(KIND_GROUP_METADATA, vec![d("Hidden")]), + None, + Default::default() + ) + .expect("hidden metadata"), + GroupReadDecision::Hidden + ); } #[test] diff --git a/crates/tangle_runtime/src/relay/core.rs b/crates/tangle_runtime/src/relay/core.rs @@ -1430,8 +1430,11 @@ mod tests { )); assert_eq!(count_kind(&relay, 1), 0); assert_eq!(count_kind_with_auth(&relay, 1, &auth), 1); - assert_eq!(count_kind(&relay, KIND_GROUP_METADATA), 0); + assert_eq!(count_kind(&relay, KIND_GROUP_METADATA), 1); + assert_eq!(count_kind(&relay, KIND_GROUP_ADMINS), 1); + assert_eq!(count_kind(&relay, KIND_GROUP_MEMBERS), 0); assert_eq!(count_kind_with_auth(&relay, KIND_GROUP_METADATA, &auth), 1); + assert_eq!(count_kind_with_auth(&relay, KIND_GROUP_ADMINS, &auth), 1); } #[test] diff --git a/crates/tangle_runtime/tests/base_relay_v2.rs b/crates/tangle_runtime/tests/base_relay_v2.rs @@ -302,6 +302,27 @@ fn metadata_flags_and_read_privacy_cover_req_count_and_fanout() { ), 0, ); + assert_count( + relay.handle_count( + subscription("private-metadata-unauth"), + vec![filter_group_tag(KIND_GROUP_METADATA, "d", "PrivateFarm")], + ), + 1, + ); + assert_count( + relay.handle_count( + subscription("private-admins-unauth"), + vec![filter_group_tag(KIND_GROUP_ADMINS, "d", "PrivateFarm")], + ), + 1, + ); + assert_count( + relay.handle_count( + subscription("private-members-unauth"), + vec![filter_kind(KIND_GROUP_MEMBERS)], + ), + 0, + ); let owner_query_id = subscription("private-owner"); assert_event_query( relay diff --git a/crates/tangle_runtime/tests/phase2_acceptance_targets.rs b/crates/tangle_runtime/tests/phase2_acceptance_targets.rs @@ -9,7 +9,15 @@ use std::{ path::{Path, PathBuf}, time::{Duration, Instant, SystemTime, UNIX_EPOCH}, }; -use tangle_protocol::{Event, RelayMessage, UnixTimestamp, event_to_value}; +use tangle_groups::{ + GroupAuthority, GroupMetadata, GroupMetadataFlags, GroupMetadataText, GroupProjection, + GroupReadDecision, GroupReadGate, GroupState, KIND_GROUP_ADMINS, KIND_GROUP_MEMBERS, + KIND_GROUP_METADATA, ProjectionOrderTuple, StoreOffset, SupportedKinds, +}; +use tangle_protocol::{ + Event, EventId, Kind, PublicKeyHex, RelayMessage, SignatureHex, Tag, UnixTimestamp, + UnsignedEvent, event_to_value, +}; use tangle_runtime::{ config::{BaseRelayRuntimeConfig, parse_base_relay_runtime_config_json}, relay::auth::BaseAuthState, @@ -261,9 +269,57 @@ fn protected_events_require_author_auth_before_nip70_is_advertised() { } #[test] -#[ignore = "phase2 target: private hidden semantics"] fn private_but_not_hidden_group_metadata_remains_visible() { - pending("private-but-not-hidden group metadata and admins must remain visible to non-members"); + let owner = phase2_pubkey("1"); + let projection = phase2_projection_with_group( + "Farm", + phase2_metadata(true, false, false, false), + owner.clone(), + ); + let authority = GroupAuthority::new([owner.clone()], Vec::<PublicKeyHex>::new()); + let gate = GroupReadGate::new(&projection, &authority); + + assert_eq!( + gate.screen_event( + &phase2_snapshot_event(KIND_GROUP_METADATA, "Farm"), + None, + Default::default() + ) + .expect("metadata"), + GroupReadDecision::Visible + ); + assert_eq!( + gate.screen_event( + &phase2_snapshot_event(KIND_GROUP_ADMINS, "Farm"), + None, + Default::default() + ) + .expect("admins"), + GroupReadDecision::Visible + ); + assert_eq!( + gate.screen_event( + &phase2_snapshot_event(KIND_GROUP_MEMBERS, "Farm"), + None, + Default::default() + ) + .expect("members"), + GroupReadDecision::Hidden + ); + + let hidden_projection = + phase2_projection_with_group("Hidden", phase2_metadata(false, false, true, false), owner); + let hidden_gate = GroupReadGate::new(&hidden_projection, &authority); + assert_eq!( + hidden_gate + .screen_event( + &phase2_snapshot_event(KIND_GROUP_METADATA, "Hidden"), + None, + Default::default() + ) + .expect("hidden metadata"), + GroupReadDecision::Hidden + ); } #[test] @@ -490,6 +546,58 @@ fn assert_live_event(value: Value, subscription_id: &str, event: &Event) { assert_eq!(value[2]["id"], event.id().as_str()); } +fn phase2_projection_with_group( + group_id: &str, + metadata: GroupMetadata, + author: PublicKeyHex, +) -> GroupProjection { + let mut projection = GroupProjection::new(); + projection.put_group(GroupState::new( + tangle_groups::GroupId::new(group_id).expect("group"), + metadata, + author, + phase2_event_id("10"), + ProjectionOrderTuple::new( + UnixTimestamp::new(10), + phase2_event_id("10"), + StoreOffset::new(1), + ), + )); + projection +} + +fn phase2_metadata(private: bool, restricted: bool, hidden: bool, closed: bool) -> GroupMetadata { + GroupMetadata::from_parts( + GroupMetadataText::empty(), + GroupMetadataFlags::new(private, restricted, hidden, closed), + SupportedKinds::UnspecifiedAll, + ) +} + +fn phase2_snapshot_event(kind: u32, group_id: &str) -> Event { + Event::new( + phase2_event_id("01"), + UnsignedEvent::new( + phase2_pubkey("9"), + UnixTimestamp::new(1), + Kind::new(kind.into()).expect("kind"), + vec![Tag::from_parts("d", &[group_id]).expect("d")], + "", + ), + SignatureHex::new(&"2".repeat(128)).expect("sig"), + ) +} + +fn phase2_pubkey(suffix: &str) -> PublicKeyHex { + PublicKeyHex::new(&suffix.repeat(64)).expect("pubkey") +} + +fn phase2_event_id(suffix: &str) -> EventId { + let mut value = "0".repeat(64 - suffix.len()); + value.push_str(suffix); + EventId::new(&value).expect("id") +} + fn current_unix_timestamp() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH)