commit 96256fdb4def721f05eaf66aade786c16dfd5b38
parent 3ac859cd9d435903105d0128edc5c4743df21b3b
Author: triesap <tyson@radroots.org>
Date: Sun, 14 Jun 2026 04:18:24 -0700
groups: add strict group policy config
- Add GroupPolicyConfig with public_join and invites_enabled fields that default to strict false values.
- Reject enabled invite policy until a complete invite flow exists and reject unknown compatibility policy fields.
- Thread policy config through grouped runtime settings and the group write policy without changing public join enforcement yet.
- 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, 191 insertions(+), 40 deletions(-)
diff --git a/crates/tangle_groups/src/lib.rs b/crates/tangle_groups/src/lib.rs
@@ -153,6 +153,110 @@ impl Default for GroupRedactionConfig {
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct GroupPolicyConfig {
+ #[serde(default)]
+ public_join: bool,
+ #[serde(default)]
+ invites_enabled: bool,
+}
+
+impl GroupPolicyConfig {
+ pub fn strict() -> Self {
+ Self {
+ public_join: false,
+ invites_enabled: false,
+ }
+ }
+
+ pub fn new(public_join: bool, invites_enabled: bool) -> Result<Self, GroupConfigError> {
+ let value = Self {
+ public_join,
+ invites_enabled,
+ };
+ value.validate()?;
+ Ok(value)
+ }
+
+ pub fn validate(&self) -> Result<(), GroupConfigError> {
+ if self.invites_enabled {
+ return Err(GroupConfigError::invalid(
+ "groups.policy.invites_enabled is not supported until invite flow is implemented",
+ ));
+ }
+ Ok(())
+ }
+
+ pub fn public_join(&self) -> bool {
+ self.public_join
+ }
+
+ pub fn invites_enabled(&self) -> bool {
+ self.invites_enabled
+ }
+}
+
+impl Default for GroupPolicyConfig {
+ fn default() -> Self {
+ Self::strict()
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct GroupRuntimeSettingsConfig {
+ policy: GroupPolicyConfig,
+ redaction: GroupRedactionConfig,
+ limits: GroupLimitsConfig,
+}
+
+impl GroupRuntimeSettingsConfig {
+ pub fn strict() -> Self {
+ Self {
+ policy: GroupPolicyConfig::strict(),
+ redaction: GroupRedactionConfig::strict(),
+ limits: GroupLimitsConfig::default(),
+ }
+ }
+
+ pub fn new(
+ policy: GroupPolicyConfig,
+ redaction: GroupRedactionConfig,
+ limits: GroupLimitsConfig,
+ ) -> Result<Self, GroupConfigError> {
+ let value = Self {
+ policy,
+ redaction,
+ limits,
+ };
+ value.validate()?;
+ Ok(value)
+ }
+
+ pub fn validate(&self) -> Result<(), GroupConfigError> {
+ self.policy.validate()?;
+ self.limits.validate()
+ }
+
+ pub fn policy(&self) -> GroupPolicyConfig {
+ self.policy
+ }
+
+ pub fn redaction(&self) -> GroupRedactionConfig {
+ self.redaction
+ }
+
+ pub fn limits(&self) -> GroupLimitsConfig {
+ self.limits
+ }
+}
+
+impl Default for GroupRuntimeSettingsConfig {
+ fn default() -> Self {
+ Self::strict()
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
pub struct GroupLimitsConfig {
#[serde(default = "default_max_group_id_bytes")]
max_group_id_bytes: u16,
@@ -246,8 +350,7 @@ pub struct GroupRuntimeConfig {
relay_secret: Option<RelaySecret>,
owner_pubkeys: Vec<PublicKeyHex>,
admin_pubkeys: Vec<PublicKeyHex>,
- redaction: GroupRedactionConfig,
- limits: GroupLimitsConfig,
+ settings: GroupRuntimeSettingsConfig,
}
impl GroupRuntimeConfig {
@@ -258,8 +361,7 @@ impl GroupRuntimeConfig {
relay_secret: None,
owner_pubkeys: Vec::new(),
admin_pubkeys: Vec::new(),
- redaction: GroupRedactionConfig::default(),
- limits: GroupLimitsConfig::default(),
+ settings: GroupRuntimeSettingsConfig::default(),
}
}
@@ -269,10 +371,9 @@ impl GroupRuntimeConfig {
relay_secret: Option<RelaySecret>,
owner_pubkeys: Vec<PublicKeyHex>,
admin_pubkeys: Vec<PublicKeyHex>,
- redaction: GroupRedactionConfig,
- limits: GroupLimitsConfig,
+ settings: GroupRuntimeSettingsConfig,
) -> Result<Self, GroupConfigError> {
- limits.validate()?;
+ settings.validate()?;
if enabled && canonical_relay_url.is_none() {
return Err(GroupConfigError::invalid(
"groups.canonical_relay_url is required when groups are enabled",
@@ -289,8 +390,7 @@ impl GroupRuntimeConfig {
relay_secret,
owner_pubkeys,
admin_pubkeys,
- redaction,
- limits,
+ settings,
})
}
@@ -314,12 +414,16 @@ impl GroupRuntimeConfig {
&self.admin_pubkeys
}
+ pub fn policy(&self) -> GroupPolicyConfig {
+ self.settings.policy()
+ }
+
pub fn redaction(&self) -> GroupRedactionConfig {
- self.redaction
+ self.settings.redaction()
}
pub fn limits(&self) -> GroupLimitsConfig {
- self.limits
+ self.settings.limits()
}
}
@@ -358,6 +462,8 @@ struct GroupRuntimeConfigDocument {
#[serde(default)]
admin_pubkeys: Vec<String>,
#[serde(default)]
+ policy: GroupPolicyConfig,
+ #[serde(default)]
redaction: GroupRedactionConfig,
#[serde(default)]
limits: GroupLimitsConfig,
@@ -383,8 +489,7 @@ pub fn parse_group_runtime_config_json(raw: &str) -> Result<GroupRuntimeConfig,
relay_secret,
parse_pubkeys("groups.owner_pubkeys", document.owner_pubkeys)?,
parse_pubkeys("groups.admin_pubkeys", document.admin_pubkeys)?,
- document.redaction,
- document.limits,
+ GroupRuntimeSettingsConfig::new(document.policy, document.redaction, document.limits)?,
)
}
@@ -456,7 +561,8 @@ fn default_max_outbox_replay_batch() -> u32 {
#[cfg(test)]
mod tests {
use super::{
- CanonicalRelayUrl, GroupLimitsConfig, RelaySecret, parse_group_runtime_config_json,
+ CanonicalRelayUrl, GroupLimitsConfig, GroupPolicyConfig, RelaySecret,
+ parse_group_runtime_config_json,
};
#[test]
@@ -481,6 +587,7 @@ mod tests {
"relay_secret": "{secret}",
"owner_pubkeys": ["{owner}"],
"admin_pubkeys": ["{admin}"],
+ "policy": {{"public_join": false, "invites_enabled": false}},
"redaction": {{"redact_private_tags": true, "redact_invite_codes": true}},
"limits": {{
"max_group_id_bytes": 64,
@@ -501,6 +608,9 @@ mod tests {
);
assert_eq!(config.owner_pubkeys().len(), 1);
assert_eq!(config.admin_pubkeys().len(), 1);
+ assert_eq!(config.policy(), GroupPolicyConfig::strict());
+ assert!(!config.policy().public_join());
+ assert!(!config.policy().invites_enabled());
assert!(config.redaction().redact_private_tags());
assert!(config.redaction().redact_invite_codes());
assert_eq!(config.limits().max_group_id_bytes(), 64);
@@ -517,6 +627,34 @@ mod tests {
assert!(!config.enabled());
assert!(config.canonical_relay_url().is_none());
assert!(config.relay_secret().is_none());
+ assert_eq!(config.policy(), GroupPolicyConfig::strict());
+ }
+
+ #[test]
+ fn group_policy_rejects_enabled_invites_until_invite_flow_exists() {
+ let error = parse_group_runtime_config_json(
+ r#"{"enabled": false, "policy": {"invites_enabled": true}}"#,
+ )
+ .expect_err("invites");
+
+ assert_eq!(
+ error.message(),
+ "groups.policy.invites_enabled is not supported until invite flow is implemented"
+ );
+ }
+
+ #[test]
+ fn group_policy_rejects_compatibility_fields() {
+ let error = parse_group_runtime_config_json(
+ r#"{"enabled": false, "policy": {"compat_zooid_closed_means_restricted": true}}"#,
+ )
+ .expect_err("compat");
+
+ assert!(
+ error
+ .message()
+ .contains("unknown field `compat_zooid_closed_means_restricted`")
+ );
}
#[test]
diff --git a/crates/tangle_groups/src/policy.rs b/crates/tangle_groups/src/policy.rs
@@ -2,11 +2,11 @@ use std::collections::BTreeSet;
use crate::{
Capability, CapabilitySet, GroupError, GroupErrorKind, GroupEventClass, GroupId,
- GroupLifecycleState, GroupProjection, 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_PUT_USER, KIND_GROUP_REMOVE_USER,
- MemberStatus, RoleDefinition, RoleName, SupportedKinds, event_view::GroupEventView,
- require_group_auth_as_author, resolve_capabilities,
+ GroupLifecycleState, GroupPolicyConfig, GroupProjection, 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_PUT_USER, KIND_GROUP_REMOVE_USER, MemberStatus, RoleDefinition, RoleName,
+ SupportedKinds, event_view::GroupEventView, require_group_auth_as_author, resolve_capabilities,
};
use tangle_protocol::PublicKeyHex;
@@ -62,13 +62,19 @@ pub enum GroupWriteDecision {
pub struct GroupWritePolicy<'a> {
projection: &'a GroupProjection,
authority: &'a GroupAuthority,
+ policy: GroupPolicyConfig,
}
impl<'a> GroupWritePolicy<'a> {
- pub fn new(projection: &'a GroupProjection, authority: &'a GroupAuthority) -> Self {
+ pub fn new(
+ projection: &'a GroupProjection,
+ authority: &'a GroupAuthority,
+ policy: GroupPolicyConfig,
+ ) -> Self {
Self {
projection,
authority,
+ policy,
}
}
@@ -123,7 +129,7 @@ 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 {
+ if kind == KIND_GROUP_CREATE_INVITE && !self.policy.invites_enabled() {
return Err(GroupError::restricted(
GroupErrorKind::MissingCapability,
"invites not enabled",
@@ -389,8 +395,8 @@ mod tests {
use super::{GroupAuthority, GroupWriteDecision, GroupWritePolicy};
use crate::{
Capability, CapabilitySet, GroupAuthContext, GroupErrorKind, GroupEventClass, GroupId,
- GroupMetadata, GroupMetadataFlags, GroupMetadataText, GroupProjection, GroupState,
- KIND_GROUP_CREATE_GROUP, KIND_GROUP_CREATE_INVITE, KIND_GROUP_DELETE_GROUP,
+ GroupMetadata, GroupMetadataFlags, GroupMetadataText, GroupPolicyConfig, GroupProjection,
+ GroupState, KIND_GROUP_CREATE_GROUP, 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,
@@ -405,7 +411,7 @@ mod tests {
let owner = pubkey("1");
let author = pubkey("2");
let authority = GroupAuthority::new([owner.clone()], Vec::<PublicKeyHex>::new());
- let policy = GroupWritePolicy::new(&projection, &authority);
+ let policy = GroupWritePolicy::new(&projection, &authority, GroupPolicyConfig::strict());
let create_by_non_owner = event(KIND_GROUP_CREATE_GROUP, author.clone(), vec![h("Farm")]);
let class = GroupEventClass::Moderation {
kind: create_by_non_owner.unsigned().kind(),
@@ -451,7 +457,7 @@ mod tests {
group_id: group_id.clone(),
};
let authority = GroupAuthority::new([owner.clone()], Vec::<PublicKeyHex>::new());
- let policy = GroupWritePolicy::new(&projection, &authority);
+ let policy = GroupWritePolicy::new(&projection, &authority, GroupPolicyConfig::strict());
let create = event(KIND_GROUP_CREATE_GROUP, owner.clone(), vec![h("Farm")]);
assert_eq!(
@@ -467,7 +473,7 @@ mod tests {
.apply_canonical_event(&delete, StoreOffset::new(2), Default::default())
.expect("delete");
let authority = GroupAuthority::new([owner.clone()], Vec::<PublicKeyHex>::new());
- let policy = GroupWritePolicy::new(&projection, &authority);
+ let policy = GroupWritePolicy::new(&projection, &authority, GroupPolicyConfig::strict());
let normal = event(1, owner.clone(), vec![h("Farm")]);
assert_eq!(
@@ -503,7 +509,7 @@ mod tests {
);
put_member(&mut projection, "Farm", member.clone(), []);
let authority = GroupAuthority::new([owner], Vec::<PublicKeyHex>::new());
- let policy = GroupWritePolicy::new(&projection, &authority);
+ let policy = GroupWritePolicy::new(&projection, &authority, GroupPolicyConfig::strict());
assert_eq!(
policy
@@ -559,7 +565,7 @@ mod tests {
);
put_member(&mut projection, "Farm", moderator.clone(), [moderator_role]);
let authority = GroupAuthority::new([owner], [protected.clone()]);
- let policy = GroupWritePolicy::new(&projection, &authority);
+ let policy = GroupWritePolicy::new(&projection, &authority, GroupPolicyConfig::strict());
assert_eq!(
policy
@@ -610,7 +616,7 @@ mod tests {
);
put_member(&mut projection, "Farm", member.clone(), []);
let authority = GroupAuthority::new([owner], Vec::<PublicKeyHex>::new());
- let policy = GroupWritePolicy::new(&projection, &authority);
+ let policy = GroupWritePolicy::new(&projection, &authority, GroupPolicyConfig::strict());
assert_eq!(
policy
@@ -661,7 +667,7 @@ mod tests {
owner.clone(),
);
let authority = GroupAuthority::new([owner], Vec::<PublicKeyHex>::new());
- let policy = GroupWritePolicy::new(&projection, &authority);
+ let policy = GroupWritePolicy::new(&projection, &authority, GroupPolicyConfig::strict());
assert_eq!(
policy
@@ -687,7 +693,7 @@ mod tests {
owner.clone(),
);
let authority = GroupAuthority::new([owner.clone()], Vec::<PublicKeyHex>::new());
- let policy = GroupWritePolicy::new(&projection, &authority);
+ let policy = GroupWritePolicy::new(&projection, &authority, GroupPolicyConfig::strict());
let invite = event(KIND_GROUP_CREATE_INVITE, owner.clone(), vec![h("Farm")]);
let error = policy
diff --git a/crates/tangle_runtime/src/groups.rs b/crates/tangle_runtime/src/groups.rs
@@ -9,9 +9,9 @@ use tangle_crypto::RelaySigner;
use tangle_groups::{
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,
+ GroupOutboxEffect, GroupOutboxKey, GroupOutboxPayload, GroupOutboxRecord, GroupPolicyConfig,
+ 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, event_deletion_key, event_view::GroupEventView, group_current_key,
@@ -28,6 +28,7 @@ pub(crate) struct GroupService {
authority: GroupAuthority,
projection: GroupProjection,
outbox: GroupOutbox,
+ policy: GroupPolicyConfig,
limits: GroupLimitsConfig,
member_snapshot_cap: u32,
}
@@ -53,6 +54,7 @@ impl GroupService {
),
projection: load_group_projection(store)?,
outbox: load_group_outbox(store)?,
+ policy: config.policy(),
limits: config.limits(),
member_snapshot_cap: config.limits().max_member_list_pubkeys(),
};
@@ -76,7 +78,7 @@ impl GroupService {
class: &GroupEventClass,
auth: &GroupAuthContext,
) -> Result<(), GroupError> {
- tangle_groups::GroupWritePolicy::new(&self.projection, &self.authority)
+ tangle_groups::GroupWritePolicy::new(&self.projection, &self.authority, self.policy)
.check_event(event, class, auth)
.map(|_| ())?;
self.check_runtime_write_constraints(store, event, class)
diff --git a/crates/tangle_test_support/src/lib.rs b/crates/tangle_test_support/src/lib.rs
@@ -6,9 +6,10 @@ use k256::schnorr::{Signature, SigningKey};
use tangle_crypto::{RelaySigner, compute_event_id};
use tangle_groups::{
CanonicalRelayUrl, GroupGeneratedEventBuilder, GroupLimitsConfig, GroupOutboxPayload,
- GroupRedactionConfig, GroupRuntimeConfig, KIND_GROUP_CREATE_GROUP, KIND_GROUP_DELETE_EVENT,
- KIND_GROUP_DELETE_GROUP, KIND_GROUP_EDIT_METADATA, KIND_GROUP_JOIN_REQUEST,
- KIND_GROUP_LEAVE_REQUEST, KIND_GROUP_PUT_USER, KIND_GROUP_REMOVE_USER, RelaySecret,
+ GroupPolicyConfig, GroupRedactionConfig, GroupRuntimeConfig, GroupRuntimeSettingsConfig,
+ KIND_GROUP_CREATE_GROUP, KIND_GROUP_DELETE_EVENT, KIND_GROUP_DELETE_GROUP,
+ KIND_GROUP_EDIT_METADATA, KIND_GROUP_JOIN_REQUEST, KIND_GROUP_LEAVE_REQUEST,
+ KIND_GROUP_PUT_USER, KIND_GROUP_REMOVE_USER, RelaySecret,
};
use tangle_protocol::{
Event, EventId, Kind, PublicKeyHex, SignatureHex, Tag, UnixTimestamp, UnsignedEvent,
@@ -87,8 +88,12 @@ pub fn tangle_v2_group_config(
Some(RelaySecret::from_hex(TANGLE_V2_RELAY_SECRET_HEX).map_err(|error| error.to_string())?),
vec![owner.public_key()],
admins.iter().map(|admin| admin.public_key()).collect(),
- GroupRedactionConfig::strict(),
- GroupLimitsConfig::default(),
+ GroupRuntimeSettingsConfig::new(
+ GroupPolicyConfig::strict(),
+ GroupRedactionConfig::strict(),
+ GroupLimitsConfig::default(),
+ )
+ .map_err(|error| error.to_string())?,
)
.map_err(|error| error.to_string())
}