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