commit 3333bc18b3ccd380348d215ca26f23abf471f8e1
parent 96256fdb4def721f05eaf66aade786c16dfd5b38
Author: triesap <tyson@radroots.org>
Date: Sun, 14 Jun 2026 04:29:41 -0700
groups: enforce public join policy
- Deny 9021 join requests by default through the strict group policy unless public_join is explicitly enabled.
- Keep duplicate joins and member leaves independent of public join while preserving non-enumerating join denial messages.
- Update runtime and acceptance tests so successful join flows opt into public_join=true and strict defaults reject joins.
- 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, 183 insertions(+), 24 deletions(-)
diff --git a/crates/tangle_groups/src/policy.rs b/crates/tangle_groups/src/policy.rs
@@ -233,7 +233,7 @@ impl<'a> GroupWritePolicy<'a> {
"group member already exists",
));
}
- if group.metadata().closed() {
+ if group.metadata().closed() || !self.policy.public_join() {
return Err(non_enumerating_group_error());
}
Ok(GroupWriteDecision::Accept)
@@ -616,10 +616,30 @@ mod tests {
);
put_member(&mut projection, "Farm", member.clone(), []);
let authority = GroupAuthority::new([owner], Vec::<PublicKeyHex>::new());
- let policy = GroupWritePolicy::new(&projection, &authority, GroupPolicyConfig::strict());
+ let strict_policy =
+ GroupWritePolicy::new(&projection, &authority, GroupPolicyConfig::strict());
+ let public_join_error = strict_policy
+ .check_event(
+ &event(KIND_GROUP_JOIN_REQUEST, joiner.clone(), vec![h("Farm")]),
+ &GroupEventClass::Normal {
+ group_id: group("Farm"),
+ },
+ &GroupAuthContext::new([joiner.clone()]),
+ )
+ .expect_err("public join");
+ assert_eq!(public_join_error.kind(), GroupErrorKind::GroupUnavailable);
assert_eq!(
- policy
+ public_join_error.prefixed_message(),
+ "restricted: group is unavailable"
+ );
+ let public_policy = GroupWritePolicy::new(
+ &projection,
+ &authority,
+ GroupPolicyConfig::new(true, false).expect("policy"),
+ );
+ assert_eq!(
+ public_policy
.check_event(
&event(KIND_GROUP_JOIN_REQUEST, joiner.clone(), vec![h("Farm")]),
&GroupEventClass::Normal {
@@ -627,11 +647,11 @@ mod tests {
},
&GroupAuthContext::new([joiner])
)
- .expect("join"),
+ .expect("public join"),
GroupWriteDecision::Accept
);
assert_eq!(
- policy
+ strict_policy
.check_event(
&event(KIND_GROUP_JOIN_REQUEST, member.clone(), vec![h("Farm")]),
&GroupEventClass::Normal {
@@ -644,7 +664,7 @@ mod tests {
GroupErrorKind::DuplicateMember
);
assert_eq!(
- policy
+ strict_policy
.check_event(
&event(KIND_GROUP_LEAVE_REQUEST, member.clone(), vec![h("Farm")]),
&GroupEventClass::Normal {
@@ -667,7 +687,11 @@ mod tests {
owner.clone(),
);
let authority = GroupAuthority::new([owner], Vec::<PublicKeyHex>::new());
- let policy = GroupWritePolicy::new(&projection, &authority, GroupPolicyConfig::strict());
+ let policy = GroupWritePolicy::new(
+ &projection,
+ &authority,
+ GroupPolicyConfig::new(true, false).expect("policy"),
+ );
assert_eq!(
policy
diff --git a/crates/tangle_runtime/src/relay/core.rs b/crates/tangle_runtime/src/relay/core.rs
@@ -935,12 +935,15 @@ mod tests {
let mut relay = test_relay_with_groups(
"base-relay-group-join",
4,
- &enabled_groups_for_owner(&owner),
+ &enabled_groups_for_owner_with_public_join(&owner),
);
let create = signed_group_create_event(7, "Farm");
- relay
- .handle_event_with_auth(create, &authenticated_state(7))
- .expect("create");
+ assert_accepted(
+ relay
+ .handle_event_with_auth(create.clone(), &authenticated_state(7))
+ .expect("create"),
+ &create,
+ );
let join = signed_event_at(
8,
KIND_GROUP_JOIN_REQUEST.into(),
@@ -973,6 +976,37 @@ mod tests {
}
#[test]
+ fn group_join_requires_public_join_policy() {
+ let owner = signer(7).public_key().clone();
+ let mut relay = test_relay_with_groups(
+ "base-relay-group-join-default-deny",
+ 4,
+ &enabled_groups_for_owner(&owner),
+ );
+ let create = signed_group_create_event(7, "Farm");
+ relay
+ .handle_event_with_auth(create, &authenticated_state(7))
+ .expect("create");
+ let join = signed_event_at(
+ 8,
+ KIND_GROUP_JOIN_REQUEST.into(),
+ vec![Tag::from_parts("h", &["Farm"]).expect("h")],
+ "",
+ 1_714_124_434,
+ );
+
+ assert_eq!(
+ rejected_message(
+ relay
+ .handle_event_with_auth(join, &authenticated_state(8))
+ .expect("join")
+ ),
+ "restricted: group is unavailable"
+ );
+ assert_eq!(count_kind(&relay, KIND_GROUP_PUT_USER), 0);
+ }
+
+ #[test]
fn group_metadata_edit_replaces_generated_metadata_snapshot() {
let owner = signer(7).public_key().clone();
let mut relay = test_relay_with_groups(
@@ -1028,7 +1062,7 @@ mod tests {
let mut relay = test_relay_with_groups(
"base-relay-group-member-flow",
4,
- &enabled_groups_for_owner(&owner),
+ &enabled_groups_for_owner_with_public_join(&owner),
);
let owner_auth = authenticated_state(7);
let member_auth = authenticated_state(8);
@@ -1642,6 +1676,23 @@ mod tests {
.expect("groups")
}
+ fn enabled_groups_for_owner_with_public_join(
+ owner: &PublicKeyHex,
+ ) -> tangle_groups::GroupRuntimeConfig {
+ parse_group_runtime_config_json(&format!(
+ r#"{{
+ "enabled": true,
+ "canonical_relay_url": "wss://relay.radroots.test",
+ "relay_secret": "{}",
+ "owner_pubkeys": ["{}"],
+ "policy": {{"public_join": true, "invites_enabled": false}}
+ }}"#,
+ "7".repeat(64),
+ owner.as_str()
+ ))
+ .expect("groups")
+ }
+
fn disabled_groups() -> tangle_groups::GroupRuntimeConfig {
parse_group_runtime_config_json(r#"{"enabled": false}"#).expect("groups")
}
diff --git a/crates/tangle_runtime/tests/base_relay_v2.rs b/crates/tangle_runtime/tests/base_relay_v2.rs
@@ -4,7 +4,7 @@ use std::{fs, panic, path::PathBuf};
use tangle_crypto::{event_id_matches, verify_event_signature};
use tangle_groups::{
GroupId, GroupRuntimeConfig, KIND_GROUP_ADMINS, KIND_GROUP_DELETE_GROUP, KIND_GROUP_MEMBERS,
- KIND_GROUP_METADATA, MemberStatus,
+ KIND_GROUP_METADATA, KIND_GROUP_PUT_USER, MemberStatus, parse_group_runtime_config_json,
};
use tangle_protocol::{
Event, Filter, RawEventJson, RelayMessage, SubscriptionId, Tag, UnixTimestamp,
@@ -16,10 +16,11 @@ use tangle_runtime::{
};
use tangle_store_pocket::{PocketStoreConfig, PocketSyncPolicy};
use tangle_test_support::{
- FixtureKey, TANGLE_V2_RELAY_URL, tangle_v2_auth_event, tangle_v2_delete_group_event,
- tangle_v2_event, tangle_v2_group_config, tangle_v2_group_create_event, tangle_v2_group_event,
- tangle_v2_group_metadata_event, tangle_v2_join_event, tangle_v2_leave_event,
- tangle_v2_put_user_event, tangle_v2_remove_user_event,
+ FixtureKey, TANGLE_V2_RELAY_SECRET_HEX, TANGLE_V2_RELAY_URL, tangle_v2_auth_event,
+ tangle_v2_delete_group_event, tangle_v2_event, tangle_v2_group_config,
+ tangle_v2_group_create_event, tangle_v2_group_event, tangle_v2_group_metadata_event,
+ tangle_v2_join_event, tangle_v2_leave_event, tangle_v2_put_user_event,
+ tangle_v2_remove_user_event,
};
#[test]
@@ -146,7 +147,8 @@ fn auth_integration_covers_challenge_edges() {
#[test]
fn group_auth_lifecycle_membership_and_flag_flows_pass_in_process() {
let config = test_store_config("group-flows");
- let mut relay = BaseRelay::open_with_groups(&config, 8, &group_config()).expect("relay");
+ let groups = group_config_with_public_join();
+ let mut relay = BaseRelay::open_with_groups(&config, 8, &groups).expect("relay");
let owner_auth = authenticated(FixtureKey::Owner);
let admin_auth = authenticated(FixtureKey::Admin);
let member_auth = authenticated(FixtureKey::Member);
@@ -268,6 +270,39 @@ fn group_auth_lifecycle_membership_and_flag_flows_pass_in_process() {
}
#[test]
+fn group_join_requests_are_denied_by_default() {
+ let config = test_store_config("group-public-join-default");
+ let mut relay = BaseRelay::open_with_groups(&config, 8, &group_config()).expect("relay");
+ let owner_auth = authenticated(FixtureKey::Owner);
+ let outsider_auth = authenticated(FixtureKey::Outsider);
+ let create = tangle_v2_group_create_event(FixtureKey::Owner, "Farm", 1, &[]).expect("create");
+ assert_accepted(
+ relay
+ .handle_event_with_auth(create.clone(), &owner_auth)
+ .expect("create"),
+ &create,
+ );
+ let join = tangle_v2_join_event(FixtureKey::Outsider, "Farm", 2).expect("join");
+
+ assert_eq!(
+ rejected_message(
+ relay
+ .handle_event_with_auth(join, &outsider_auth)
+ .expect("join")
+ ),
+ "restricted: group is unavailable"
+ );
+ assert!(
+ relay
+ .group_projection()
+ .expect("projection")
+ .member(&group("Farm"), &FixtureKey::Outsider.public_key())
+ .is_none()
+ );
+ assert_eq!(count_kind(&relay, KIND_GROUP_PUT_USER), 0);
+}
+
+#[test]
fn metadata_flags_and_read_privacy_cover_req_count_and_fanout() {
let config = test_store_config("privacy-flags");
let mut relay = BaseRelay::open_with_groups(&config, 8, &group_config()).expect("relay");
@@ -693,6 +728,22 @@ fn group_config() -> GroupRuntimeConfig {
tangle_v2_group_config(FixtureKey::Owner, &[FixtureKey::Admin]).expect("groups")
}
+fn group_config_with_public_join() -> GroupRuntimeConfig {
+ parse_group_runtime_config_json(&format!(
+ r#"{{
+ "enabled": true,
+ "canonical_relay_url": "{TANGLE_V2_RELAY_URL}",
+ "relay_secret": "{TANGLE_V2_RELAY_SECRET_HEX}",
+ "owner_pubkeys": ["{}"],
+ "admin_pubkeys": ["{}"],
+ "policy": {{"public_join": true, "invites_enabled": false}}
+ }}"#,
+ FixtureKey::Owner.public_key().as_str(),
+ FixtureKey::Admin.public_key().as_str()
+ ))
+ .expect("groups")
+}
+
fn authenticated(key: FixtureKey) -> BaseAuthState {
let auth = BaseAuthState::new(TANGLE_V2_RELAY_URL, 60, 600).expect("auth");
let mut auth = issue_challenge(auth, "challenge-a", 100);
diff --git a/crates/tangle_runtime/tests/phase2_acceptance_targets.rs b/crates/tangle_runtime/tests/phase2_acceptance_targets.rs
@@ -10,9 +10,10 @@ use std::{
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
};
use tangle_groups::{
- GroupAuthority, GroupMetadata, GroupMetadataFlags, GroupMetadataText, GroupProjection,
- GroupReadDecision, GroupReadGate, GroupState, KIND_GROUP_ADMINS, KIND_GROUP_MEMBERS,
- KIND_GROUP_METADATA, ProjectionOrderTuple, StoreOffset, SupportedKinds,
+ 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,
};
use tangle_protocol::{
Event, EventId, Kind, PublicKeyHex, RelayMessage, SignatureHex, Tag, UnixTimestamp,
@@ -323,11 +324,29 @@ fn private_but_not_hidden_group_metadata_remains_visible() {
}
#[test]
-#[ignore = "phase2 target: public join policy"]
fn public_join_defaults_false() {
- pending(
- "group join requests must be denied by default unless public join or invite flow allows them",
+ let owner = phase2_pubkey("1");
+ let joiner = phase2_pubkey("2");
+ let projection = phase2_projection_with_group(
+ "Farm",
+ phase2_metadata(false, false, false, false),
+ owner.clone(),
);
+ let authority = GroupAuthority::new([owner], Vec::<PublicKeyHex>::new());
+ let policy = GroupWritePolicy::new(&projection, &authority, GroupPolicyConfig::strict());
+ let join = phase2_group_event(KIND_GROUP_JOIN_REQUEST, "Farm", joiner.clone());
+ let error = policy
+ .check_event(
+ &join,
+ &GroupEventClass::Normal {
+ group_id: GroupId::new("Farm").expect("group"),
+ },
+ &GroupAuthContext::new([joiner]),
+ )
+ .expect_err("join");
+
+ assert_eq!(error.kind(), GroupErrorKind::GroupUnavailable);
+ assert_eq!(error.prefixed_message(), "restricted: group is unavailable");
}
#[test]
@@ -588,6 +607,20 @@ fn phase2_snapshot_event(kind: u32, group_id: &str) -> Event {
)
}
+fn phase2_group_event(kind: u32, group_id: &str, author: PublicKeyHex) -> Event {
+ Event::new(
+ phase2_event_id("02"),
+ UnsignedEvent::new(
+ author,
+ UnixTimestamp::new(2),
+ Kind::new(kind.into()).expect("kind"),
+ vec![Tag::from_parts("h", &[group_id]).expect("h")],
+ "",
+ ),
+ SignatureHex::new(&"3".repeat(128)).expect("sig"),
+ )
+}
+
fn phase2_pubkey(suffix: &str) -> PublicKeyHex {
PublicKeyHex::new(&suffix.repeat(64)).expect("pubkey")
}