pub mod card;
pub mod diagnostics;
pub mod directory;
pub mod discovery;
pub mod invite;
pub mod kem_envelope;
pub mod member;
pub mod policy;
pub mod public_message;
pub mod request;
pub mod state_commit;
use crate::identity::{AgentId, AgentKeypair};
use crate::mls::SecureGroupPlane;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
pub use self::diagnostics::{
GroupCounters, GroupDiagnostic, GroupsDiagnostics, GroupsDiagnosticsSnapshot,
};
pub use self::directory::GroupCard;
pub use self::discovery::{
may_publish_to_public_shards, name_words, normalize_tag, shard_of, shards_for_public,
topic_for, DigestEntry, DirectoryMessage, DirectoryShardCache, ListedToContactsCard,
ListedToContactsDigest, ListedToContactsPull, ShardKind, SubscriptionRecord, SubscriptionSet,
DEFAULT_MAX_ENTRIES_PER_SHARD, DEFAULT_MAX_SUBSCRIPTIONS, DIRECTORY_TOPIC_PREFIX,
MAX_NAME_WORDS, MAX_TAGS_PER_GROUP, SHARD_COUNT,
};
pub use self::member::{GroupMember, GroupMemberState, GroupRole};
pub use self::policy::{
GroupAdmission, GroupConfidentiality, GroupDiscoverability, GroupPolicy, GroupPolicyPreset,
GroupPolicySummary, GroupReadAccess, GroupWriteAccess,
};
pub use self::public_message::{
public_topic_for, validate_public_message, GroupPublicMessage, GroupPublicMessageKind,
IngestError as PublicMessageIngestError, PublicIngestContext, MAX_PUBLIC_MESSAGE_BYTES,
PUBLIC_GROUP_TOPIC_PREFIX, PUBLIC_MESSAGE_DOMAIN,
};
pub use self::request::{JoinRequest, JoinRequestStatus};
pub use self::state_commit::{
compute_policy_hash, compute_public_meta_hash, compute_roster_root, compute_state_hash,
enforce_last_admin_invariant, roster_projection, roster_root_of_projection, ActionKind,
ApplyContext, ApplyError, GroupGenesis, GroupPublicMeta, GroupStateCommit, RetainedCommit,
RosterMemberSnapshot, CARD_SIGNATURE_DOMAIN, DEFAULT_CARD_TTL_SECS, EVENT_SIGNATURE_DOMAIN,
STATE_COMMIT_DOMAIN,
};
fn now_millis() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IssuedInviteRecord {
#[serde(default)]
pub created_at_secs: u64,
#[serde(default)]
pub expires_at_secs: u64,
#[serde(default = "default_invite_max_role")]
pub max_role: GroupRole,
#[serde(default)]
pub consumed_by: Option<String>,
#[serde(default)]
pub consumed_at_ms: Option<u64>,
}
fn default_invite_max_role() -> GroupRole {
GroupRole::Member
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GroupInfo {
#[serde(default, skip_serializing)]
pub members: BTreeSet<String>,
#[serde(default, skip_serializing)]
pub display_names: HashMap<String, String>,
#[serde(default, skip_serializing)]
pub membership_revision: u64,
pub name: String,
pub description: String,
pub creator: AgentId,
pub created_at: u64,
#[serde(default)]
pub updated_at: u64,
pub mls_group_id: String,
pub metadata_topic: String,
pub chat_topic_prefix: String,
#[serde(default)]
pub policy: GroupPolicy,
#[serde(default)]
pub policy_revision: u64,
#[serde(default)]
pub roster_revision: u64,
#[serde(default)]
pub members_v2: BTreeMap<String, GroupMember>,
#[serde(default)]
pub join_requests: BTreeMap<String, JoinRequest>,
#[serde(default)]
pub discovery_card_topic: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub shared_secret: Option<Vec<u8>>,
#[serde(default)]
pub secret_epoch: u64,
#[serde(default = "secure_plane_legacy_default")]
pub secure_plane: SecureGroupPlane,
#[serde(default)]
pub genesis: Option<state_commit::GroupGenesis>,
#[serde(default)]
pub state_revision: u64,
#[serde(default)]
pub state_hash: String,
#[serde(default)]
pub prev_state_hash: Option<String>,
#[serde(default)]
pub security_binding: Option<String>,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub avatar_url: Option<String>,
#[serde(default)]
pub banner_url: Option<String>,
#[serde(default)]
pub withdrawn: bool,
#[serde(default)]
pub commit_log: Vec<state_commit::RetainedCommit>,
#[serde(default)]
pub issued_invite_secrets: HashSet<String>,
#[serde(default)]
pub issued_invites: HashMap<String, IssuedInviteRecord>,
}
fn secure_plane_legacy_default() -> SecureGroupPlane {
SecureGroupPlane::Gss
}
pub const COMMIT_LOG_CAP: usize = 4096;
pub const LAST_ADMIN_PRECHECK_ERROR: &str =
"a group must always have at least one admin; make another member an admin first";
pub const LAST_ADMIN_SELF_LEAVE_PRECHECK_ERROR: &str =
"a group must always have at least one admin; make another member an admin before leaving";
#[must_use]
pub fn last_admin_precheck_error(
info: &GroupInfo,
apply: impl FnOnce(&mut GroupInfo),
) -> Option<&'static str> {
let mut proposed = info.clone();
apply(&mut proposed);
state_commit::enforce_last_admin_invariant(&proposed.members_v2, proposed.withdrawn)
.err()
.map(|_| LAST_ADMIN_PRECHECK_ERROR)
}
#[must_use]
pub fn last_admin_self_leave_precheck_error(
info: &GroupInfo,
leaver_hex: &str,
) -> Option<&'static str> {
let mut proposed = info.clone();
proposed.remove_member(leaver_hex, None);
state_commit::enforce_last_admin_invariant(&proposed.members_v2, proposed.withdrawn)
.err()
.map(|_| LAST_ADMIN_SELF_LEAVE_PRECHECK_ERROR)
}
impl GroupInfo {
#[must_use]
pub fn new(name: String, description: String, creator: AgentId, mls_group_id: String) -> Self {
Self::with_policy(
name,
description,
creator,
mls_group_id,
GroupPolicy::default(),
)
}
#[must_use]
pub fn with_policy(
name: String,
description: String,
creator: AgentId,
mls_group_id: String,
policy: GroupPolicy,
) -> Self {
let now = now_millis();
let metadata_topic = format!(
"x0x.group.{}.meta",
&mls_group_id[..16.min(mls_group_id.len())]
);
let chat_topic_prefix = format!(
"x0x.group.{}.chat",
&mls_group_id[..16.min(mls_group_id.len())]
);
let discovery_card_topic = if policy.discoverability != GroupDiscoverability::Hidden {
Some(format!(
"x0x.group.{}.card",
&mls_group_id[..16.min(mls_group_id.len())]
))
} else {
None
};
let creator_hex = hex::encode(creator.as_bytes());
let mut members_v2 = BTreeMap::new();
members_v2.insert(
creator_hex.clone(),
GroupMember::new_admin(creator_hex.clone(), None, now),
);
let shared_secret = if policy.confidentiality == GroupConfidentiality::MlsEncrypted {
use rand::RngCore;
let mut secret = vec![0u8; 32];
rand::thread_rng().fill_bytes(&mut secret);
Some(secret)
} else {
None
};
let genesis = state_commit::GroupGenesis::with_existing_id(
mls_group_id.clone(),
creator_hex.clone(),
now,
String::new(),
);
let confidentiality = policy.confidentiality;
let mut info = Self {
members: BTreeSet::new(),
display_names: HashMap::new(),
membership_revision: 0,
name,
description,
creator,
created_at: now,
updated_at: now,
mls_group_id,
metadata_topic,
chat_topic_prefix,
policy,
policy_revision: 0,
roster_revision: 0,
members_v2,
join_requests: BTreeMap::new(),
discovery_card_topic,
commit_log: Vec::new(),
shared_secret,
secret_epoch: 0,
secure_plane: SecureGroupPlane::Gss,
genesis: Some(genesis),
state_revision: 0,
state_hash: String::new(),
prev_state_hash: None,
security_binding: match confidentiality {
GroupConfidentiality::MlsEncrypted => Some("gss:epoch=0".into()),
GroupConfidentiality::SignedPublic => None,
},
tags: Vec::new(),
avatar_url: None,
banner_url: None,
withdrawn: false,
issued_invite_secrets: HashSet::new(),
issued_invites: HashMap::new(),
};
info.recompute_state_hash();
info
}
#[must_use]
pub fn rotate_shared_secret(&mut self) -> (Vec<u8>, u64) {
use rand::RngCore;
let mut new_secret = vec![0u8; 32];
rand::thread_rng().fill_bytes(&mut new_secret);
self.secret_epoch = self.secret_epoch.saturating_add(1);
self.shared_secret = Some(new_secret.clone());
self.security_binding = Some(format!("gss:epoch={}", self.secret_epoch));
(new_secret, self.secret_epoch)
}
#[must_use]
pub fn stable_group_id(&self) -> &str {
self.genesis
.as_ref()
.map(|g| g.group_id.as_str())
.unwrap_or(self.mls_group_id.as_str())
}
#[must_use]
pub fn public_meta(&self) -> state_commit::GroupPublicMeta {
state_commit::GroupPublicMeta {
name: self.name.clone(),
description: self.description.clone(),
tags: self.tags.clone(),
avatar_url: self.avatar_url.clone(),
banner_url: self.banner_url.clone(),
}
}
pub fn recompute_state_hash(&mut self) {
let roster_root = state_commit::compute_roster_root(&self.members_v2);
let policy_hash = state_commit::compute_policy_hash(&self.policy);
let meta_hash = state_commit::compute_public_meta_hash(&self.public_meta());
self.state_hash = state_commit::compute_state_hash(
self.stable_group_id(),
self.state_revision,
self.prev_state_hash.as_deref(),
&roster_root,
&policy_hash,
&meta_hash,
self.security_binding.as_deref(),
self.withdrawn,
);
}
pub fn seal_commit(
&mut self,
keypair: &AgentKeypair,
now_ms: u64,
) -> Result<state_commit::GroupStateCommit, state_commit::ApplyError> {
state_commit::enforce_last_admin_invariant(&self.members_v2, self.withdrawn)?;
if self.withdrawn {
let signer_hex = hex::encode(keypair.agent_id().as_bytes());
if !state_commit::active_signer_is_admin_or_higher(&self.members_v2, &signer_hex) {
return Err(state_commit::ApplyError::Unauthorized {
signer: signer_hex,
action: state_commit::ActionKind::AdminOrHigher.name(),
});
}
}
if self.genesis.is_none() {
let creator_hex = hex::encode(self.creator.as_bytes());
self.genesis = Some(state_commit::GroupGenesis::with_existing_id(
self.mls_group_id.clone(),
creator_hex,
self.created_at,
hex::encode(blake3::hash(self.mls_group_id.as_bytes()).as_bytes()),
));
}
let prev = self.state_hash.clone();
self.state_revision = self.state_revision.saturating_add(1);
self.prev_state_hash = Some(prev.clone());
self.updated_at = now_ms;
self.recompute_state_hash();
let roster_root = state_commit::compute_roster_root(&self.members_v2);
let policy_hash = state_commit::compute_policy_hash(&self.policy);
let meta_hash = state_commit::compute_public_meta_hash(&self.public_meta());
let commit = state_commit::GroupStateCommit::sign(
self.stable_group_id().to_string(),
self.state_revision,
Some(prev),
roster_root,
policy_hash,
meta_hash,
self.security_binding.clone(),
self.withdrawn,
now_ms,
keypair,
)?;
self.retain_commit(&commit);
Ok(commit)
}
fn retain_commit(&mut self, commit: &state_commit::GroupStateCommit) {
self.commit_log.push(state_commit::RetainedCommit::capture(
commit.clone(),
&self.members_v2,
));
if self.commit_log.len() > COMMIT_LOG_CAP {
let overflow = self.commit_log.len() - COMMIT_LOG_CAP;
self.commit_log.drain(0..overflow);
tracing::warn!(
group_id = %self.stable_group_id(),
dropped = overflow,
cap = COMMIT_LOG_CAP,
"commit_log exceeded cap; dropped oldest retained commits",
);
}
}
pub fn seal_withdrawal(
&mut self,
keypair: &AgentKeypair,
now_ms: u64,
) -> Result<state_commit::GroupStateCommit, state_commit::ApplyError> {
let signer_hex = hex::encode(keypair.agent_id().as_bytes());
if !state_commit::active_signer_is_admin_or_higher(&self.members_v2, &signer_hex) {
return Err(state_commit::ApplyError::Unauthorized {
signer: signer_hex,
action: state_commit::ActionKind::AdminOrHigher.name(),
});
}
let original = self.clone();
self.withdrawn = true;
match self.seal_commit(keypair, now_ms) {
Ok(commit) => {
self.shared_secret = None;
Ok(commit)
}
Err(err) => {
*self = original;
Err(err)
}
}
}
pub fn apply_commit(
&mut self,
commit: &state_commit::GroupStateCommit,
action_kind: state_commit::ActionKind,
) -> Result<(), state_commit::ApplyError> {
let ctx = state_commit::ApplyContext {
current_state_hash: &self.state_hash,
current_revision: self.state_revision,
current_withdrawn: self.withdrawn,
members_v2: &self.members_v2,
group_id: self.stable_group_id(),
};
state_commit::validate_apply(&ctx, commit, action_kind)?;
self.finalize_applied_commit(commit)
}
fn finalize_applied_commit_with_terminal_mode(
&mut self,
commit: &state_commit::GroupStateCommit,
allow_live_withdrawal: bool,
) -> Result<(), state_commit::ApplyError> {
if self.withdrawn && !commit.withdrawn {
return Err(state_commit::ApplyError::Withdrawn);
}
let live_to_withdrawn = !self.withdrawn && commit.withdrawn;
if live_to_withdrawn && !allow_live_withdrawal {
return Err(state_commit::ApplyError::Invariant(
"live withdrawal commit requires terminal finalization".to_string(),
));
}
self.state_revision = commit.revision;
self.prev_state_hash = commit.prev_state_hash.clone();
self.withdrawn = commit.withdrawn;
self.recompute_state_hash();
if self.state_hash != commit.state_hash {
return Err(state_commit::ApplyError::StateHashMismatch {
expected: commit.state_hash.clone(),
got: self.state_hash.clone(),
});
}
state_commit::enforce_last_admin_invariant(&self.members_v2, self.withdrawn)?;
if live_to_withdrawn {
self.shared_secret = None;
}
self.retain_commit(commit);
Ok(())
}
pub fn finalize_applied_commit(
&mut self,
commit: &state_commit::GroupStateCommit,
) -> Result<(), state_commit::ApplyError> {
self.finalize_applied_commit_with_terminal_mode(commit, false)
}
pub fn finalize_applied_terminal_commit(
&mut self,
commit: &state_commit::GroupStateCommit,
) -> Result<(), state_commit::ApplyError> {
if !commit.withdrawn {
return Err(state_commit::ApplyError::Invariant(
"terminal finalization requires withdrawn commit".to_string(),
));
}
self.finalize_applied_commit_with_terminal_mode(commit, true)
}
#[must_use]
pub fn secure_message_key(&self) -> Option<Vec<u8>> {
let secret = self.shared_secret.as_ref()?;
Some(Self::derive_message_key(
secret,
self.secret_epoch,
self.stable_group_id(),
))
}
#[must_use]
pub fn derive_message_key(secret: &[u8], epoch: u64, group_id: &str) -> Vec<u8> {
let mut material = Vec::with_capacity(secret.len() + 48);
material.extend_from_slice(b"x0x.group.secure\0");
material.extend_from_slice(secret);
material.extend_from_slice(&epoch.to_le_bytes());
material.extend_from_slice(group_id.as_bytes());
let hash = blake3::hash(&material);
hash.as_bytes()[..32].to_vec()
}
pub fn migrate_from_v1(&mut self) {
if self.members_v2.is_empty() {
let now = now_millis();
let creator_hex = hex::encode(self.creator.as_bytes());
let mut all_ids: BTreeSet<String> = self.members.clone();
all_ids.insert(creator_hex.clone());
for id in self.display_names.keys() {
all_ids.insert(id.clone());
}
for id in all_ids {
let display_name = self.display_names.get(&id).cloned();
let member = if id == creator_hex {
GroupMember::new_admin(id.clone(), display_name, now)
} else {
GroupMember::new_member(
id.clone(),
display_name,
Some(creator_hex.clone()),
now,
)
};
self.members_v2.insert(id, member);
}
if self.roster_revision == 0 {
self.roster_revision = self.membership_revision;
}
if self.updated_at == 0 {
self.updated_at = self.created_at;
}
}
if self.genesis.is_none() {
let creator_hex = hex::encode(self.creator.as_bytes());
let nonce = hex::encode(blake3::hash(self.mls_group_id.as_bytes()).as_bytes());
self.genesis = Some(state_commit::GroupGenesis::with_existing_id(
self.mls_group_id.clone(),
creator_hex,
self.created_at,
nonce,
));
}
if self.security_binding.is_none()
&& self.policy.confidentiality == GroupConfidentiality::MlsEncrypted
{
self.security_binding = Some(format!("gss:epoch={}", self.secret_epoch));
}
if self.state_hash.is_empty() {
self.recompute_state_hash();
}
}
pub fn record_issued_invite(
&mut self,
secret: String,
created_at_secs: u64,
expires_at_secs: u64,
max_role: GroupRole,
) {
self.issued_invite_secrets.insert(secret.clone());
self.issued_invites.insert(
secret,
IssuedInviteRecord {
created_at_secs,
expires_at_secs,
max_role,
consumed_by: None,
consumed_at_ms: None,
},
);
}
pub fn consume_issued_invite(
&mut self,
secret: &str,
member_agent_id: &str,
requested_role: GroupRole,
event_ts_ms: u64,
now_ms: u64,
) -> Result<(), &'static str> {
let Some(record) = self.issued_invites.get_mut(secret) else {
return Err("invite_secret_unknown");
};
if record.consumed_by.is_some() {
return Err("invite_secret_consumed");
}
if !record.max_role.at_least(requested_role) {
return Err("invite_role_exceeds_cap");
}
let event_secs = event_ts_ms / 1_000;
if event_secs.saturating_add(300) < record.created_at_secs {
return Err("invite_event_before_creation");
}
if record.expires_at_secs != 0 {
let now_secs = now_ms / 1_000;
if event_secs > record.expires_at_secs || now_secs > record.expires_at_secs {
return Err("invite_expired");
}
}
record.consumed_by = Some(member_agent_id.to_string());
record.consumed_at_ms = Some(now_ms);
self.issued_invite_secrets.remove(secret);
Ok(())
}
pub fn add_member(
&mut self,
agent_id_hex: String,
role: GroupRole,
added_by: Option<String>,
display_name: Option<String>,
) {
self.add_member_with_kem(agent_id_hex, role, added_by, display_name, None);
}
pub fn add_member_with_kem(
&mut self,
agent_id_hex: String,
role: GroupRole,
added_by: Option<String>,
display_name: Option<String>,
kem_public_key_b64: Option<String>,
) {
let now = now_millis();
self.members_v2
.entry(agent_id_hex.clone())
.and_modify(|m| {
m.role = role;
m.state = GroupMemberState::Active;
m.updated_at = now;
if let Some(dn) = display_name.clone() {
m.display_name = Some(dn);
}
if added_by.is_some() {
m.added_by = added_by.clone();
}
if let Some(ref k) = kem_public_key_b64 {
m.kem_public_key_b64 = Some(k.clone());
}
})
.or_insert_with(|| GroupMember {
agent_id: agent_id_hex,
user_id: None,
role,
state: GroupMemberState::Active,
display_name,
joined_at: now,
updated_at: now,
added_by,
removed_by: None,
kem_public_key_b64,
treekem_key_package_b64: None,
treekem_key_package_hash: None,
});
}
pub fn set_member_kem_public_key(&mut self, agent_id_hex: &str, kem_public_key_b64: String) {
if let Some(m) = self.members_v2.get_mut(agent_id_hex) {
m.kem_public_key_b64 = Some(kem_public_key_b64);
m.updated_at = now_millis();
}
}
pub fn set_member_treekem_key_package(
&mut self,
agent_id_hex: &str,
treekem_key_package_b64: String,
) {
if let Some(m) = self.members_v2.get_mut(agent_id_hex) {
m.treekem_key_package_hash = Some(
blake3::hash(treekem_key_package_b64.as_bytes())
.to_hex()
.to_string(),
);
m.treekem_key_package_b64 = Some(treekem_key_package_b64);
m.updated_at = now_millis();
}
}
pub fn set_member_treekem_key_package_hash(
&mut self,
agent_id_hex: &str,
treekem_key_package_hash: String,
) {
if let Some(member) = self.members_v2.get_mut(agent_id_hex) {
if member.treekem_key_package_hash.as_deref() != Some(&treekem_key_package_hash) {
member.treekem_key_package_b64 = None;
}
member.treekem_key_package_hash = Some(treekem_key_package_hash);
member.updated_at = now_millis();
}
}
pub fn remove_member(&mut self, agent_id_hex: &str, removed_by: Option<String>) {
if let Some(m) = self.members_v2.get_mut(agent_id_hex) {
m.state = GroupMemberState::Removed;
m.updated_at = now_millis();
m.removed_by = removed_by;
}
}
pub fn ban_member(&mut self, agent_id_hex: &str, banned_by: Option<String>) {
let now = now_millis();
self.members_v2
.entry(agent_id_hex.to_string())
.and_modify(|m| {
m.state = GroupMemberState::Banned;
m.updated_at = now;
m.removed_by = banned_by.clone();
})
.or_insert_with(|| GroupMember {
agent_id: agent_id_hex.to_string(),
user_id: None,
role: GroupRole::Guest,
state: GroupMemberState::Banned,
display_name: None,
joined_at: now,
updated_at: now,
added_by: None,
removed_by: banned_by,
kem_public_key_b64: None,
treekem_key_package_b64: None,
treekem_key_package_hash: None,
});
}
pub fn unban_member(&mut self, agent_id_hex: &str) {
if let Some(m) = self.members_v2.get_mut(agent_id_hex) {
if m.state == GroupMemberState::Banned {
m.state = GroupMemberState::Active;
m.updated_at = now_millis();
m.removed_by = None;
}
}
}
pub fn set_member_role(&mut self, agent_id_hex: &str, role: GroupRole) {
if let Some(m) = self.members_v2.get_mut(agent_id_hex) {
m.role = role;
m.updated_at = now_millis();
}
}
#[must_use]
pub fn has_active_member(&self, agent_id_hex: &str) -> bool {
self.members_v2
.get(agent_id_hex)
.is_some_and(GroupMember::is_active)
}
#[must_use]
pub fn has_member(&self, agent_id_hex: &str) -> bool {
self.has_active_member(agent_id_hex)
}
#[must_use]
pub fn caller_role(&self, agent_id_hex: &str) -> Option<GroupRole> {
self.members_v2
.get(agent_id_hex)
.filter(|m| m.is_active())
.map(|m| m.role)
}
#[must_use]
pub fn is_banned(&self, agent_id_hex: &str) -> bool {
self.members_v2
.get(agent_id_hex)
.is_some_and(GroupMember::is_banned)
}
pub fn set_display_name(&mut self, agent_id_hex: &str, name: String) {
if let Some(m) = self.members_v2.get_mut(agent_id_hex) {
m.display_name = Some(name);
m.updated_at = now_millis();
}
}
#[must_use]
pub fn display_name(&self, agent_id_hex: &str) -> String {
if let Some(m) = self.members_v2.get(agent_id_hex) {
if let Some(dn) = &m.display_name {
return dn.clone();
}
}
if agent_id_hex.len() >= 8 {
format!("{}…", &agent_id_hex[..8])
} else {
agent_id_hex.to_string()
}
}
pub fn active_members(&self) -> impl Iterator<Item = &GroupMember> {
self.members_v2.values().filter(|m| m.is_active())
}
#[must_use]
pub fn active_member_count(&self) -> usize {
self.active_members().count()
}
#[must_use]
pub fn active_admin_count(&self) -> usize {
self.members_v2
.values()
.filter(|m| m.is_active() && m.role.at_least(GroupRole::Admin))
.count()
}
#[must_use]
pub fn owner_agent_id(&self) -> Option<String> {
self.members_v2
.values()
.find(|m| m.is_active() && m.role == GroupRole::Owner)
.map(|m| m.agent_id.clone())
}
#[must_use]
pub fn general_chat_topic(&self) -> String {
format!("{}/general", self.chat_topic_prefix)
}
#[must_use]
pub fn to_group_card(&self) -> Option<GroupCard> {
if self.policy.discoverability == GroupDiscoverability::Hidden && !self.withdrawn {
return None;
}
let owner = self
.owner_agent_id()
.unwrap_or_else(|| hex::encode(self.creator.as_bytes()));
let issued_at = now_millis();
let expires_at =
issued_at.saturating_add(state_commit::DEFAULT_CARD_TTL_SECS.saturating_mul(1_000));
Some(GroupCard {
group_id: self.stable_group_id().to_string(),
name: self.name.clone(),
description: self.description.clone(),
avatar_url: self.avatar_url.clone(),
banner_url: self.banner_url.clone(),
tags: self.tags.clone(),
policy_summary: GroupPolicySummary::from(&self.policy),
owner_agent_id: owner,
admin_count: self.active_admin_count() as u32,
member_count: self.active_member_count() as u32,
created_at: self.created_at,
updated_at: self.updated_at,
request_access_enabled: self.policy.admission == GroupAdmission::RequestAccess,
metadata_topic: Some(self.metadata_topic.clone()),
revision: self.state_revision,
state_hash: self.state_hash.clone(),
prev_state_hash: self.prev_state_hash.clone(),
issued_at,
expires_at,
authority_agent_id: String::new(),
authority_public_key: String::new(),
withdrawn: self.withdrawn,
signature: String::new(),
})
}
pub fn to_signed_group_card(
&self,
keypair: &AgentKeypair,
) -> Result<Option<GroupCard>, state_commit::ApplyError> {
let Some(mut card) = self.to_group_card() else {
return Ok(None);
};
card.sign(keypair)?;
Ok(Some(card))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn agent(n: u8) -> AgentId {
AgentId([n; 32])
}
#[test]
fn test_group_info_new_seeds_admin() {
let info = GroupInfo::new(
"Test Group".to_string(),
"A test".to_string(),
agent(1),
"aabb".repeat(8),
);
let creator_hex = hex::encode([1u8; 32]);
let admin = info.members_v2.get(&creator_hex).unwrap();
assert_eq!(admin.role, GroupRole::Admin);
assert!(admin.is_active());
assert_eq!(info.policy, GroupPolicy::default());
assert_eq!(info.active_member_count(), 1);
}
#[test]
fn seal_commit_retains_verifiable_history() {
let mut info = GroupInfo::new("Test".into(), String::new(), agent(1), "aabb".repeat(8));
let kp = crate::identity::AgentKeypair::generate().unwrap();
assert!(
info.commit_log.is_empty(),
"fresh group has no retained commits"
);
let c1 = info.seal_commit(&kp, 1_000).unwrap();
info.add_member(
hex::encode([2u8; 32]),
GroupRole::Member,
Some("alice".into()),
None,
);
let c2 = info.seal_commit(&kp, 2_000).unwrap();
assert_eq!(
info.commit_log.len(),
2,
"each seal retains exactly one entry"
);
assert_eq!(info.commit_log[0].commit.revision, c1.revision);
assert_eq!(info.commit_log[1].commit.revision, c2.revision);
assert!(c2.revision > c1.revision, "revisions are monotonic");
for entry in &info.commit_log {
assert!(
entry.roster_root_consistent(),
"retained entry must re-derive its signed roster_root"
);
}
assert!(
info.commit_log[1]
.roster
.contains_key(&hex::encode([2u8; 32])),
"newly added member must appear in the latest retained projection"
);
}
#[test]
fn seal_withdrawal_rejects_non_admin_without_mutating_local_state() {
let admin = crate::identity::AgentKeypair::generate().unwrap();
let outsider = crate::identity::AgentKeypair::generate().unwrap();
let mut info = GroupInfo::with_policy(
"Test".into(),
String::new(),
admin.agent_id(),
"aabb".repeat(8),
GroupPolicyPreset::PublicRequestSecure.to_policy(),
);
let before = serde_json::to_string(&info).expect("snapshot serializes");
let err = info.seal_withdrawal(&outsider, 1_000).unwrap_err();
assert!(matches!(err, state_commit::ApplyError::Unauthorized { .. }));
assert_eq!(
serde_json::to_string(&info).expect("snapshot serializes"),
before,
"failed withdrawal authorization must leave local group state untouched"
);
assert!(!info.withdrawn);
}
#[test]
fn seal_withdrawal_success_clears_shared_secret() {
let admin = crate::identity::AgentKeypair::generate().unwrap();
let mut info = GroupInfo::with_policy(
"Test".into(),
String::new(),
admin.agent_id(),
"aabb".repeat(8),
GroupPolicyPreset::PublicRequestSecure.to_policy(),
);
assert!(
info.shared_secret.is_some(),
"secure group starts with GSS key material"
);
let commit = info.seal_withdrawal(&admin, 1_000).unwrap();
assert!(commit.withdrawn);
assert!(info.withdrawn);
assert_eq!(info.state_hash, commit.state_hash);
assert!(
info.shared_secret.is_none(),
"successful withdrawal must leave the terminal GroupInfo keyless"
);
assert!(info.secure_message_key().is_none());
}
#[test]
fn test_display_name_fallback() {
let mut info = GroupInfo::new(
"Test".to_string(),
String::new(),
agent(1),
"aabb".repeat(8),
);
let creator_hex = hex::encode([1u8; 32]);
let name = info.display_name(&creator_hex);
assert!(name.ends_with('…'));
info.set_display_name(&creator_hex, "Alice".to_string());
assert_eq!(info.display_name(&creator_hex), "Alice");
}
#[test]
fn test_add_and_remove_member() {
let mut info = GroupInfo::new(
"Test".to_string(),
String::new(),
agent(1),
"aabb".repeat(8),
);
let bob_hex = hex::encode([2u8; 32]);
info.add_member(
bob_hex.clone(),
GroupRole::Member,
Some("alice".into()),
None,
);
assert!(info.has_active_member(&bob_hex));
assert_eq!(info.active_member_count(), 2);
info.remove_member(&bob_hex, Some("alice".into()));
assert!(!info.has_active_member(&bob_hex));
assert_eq!(info.active_member_count(), 1);
}
#[test]
fn test_ban_unban() {
let mut info = GroupInfo::new("T".into(), "".into(), agent(1), "aa".repeat(16));
let hex_b = hex::encode([2u8; 32]);
info.add_member(hex_b.clone(), GroupRole::Member, None, None);
info.ban_member(&hex_b, Some("alice".into()));
assert!(info.is_banned(&hex_b));
assert!(!info.has_active_member(&hex_b));
info.unban_member(&hex_b);
assert!(info.has_active_member(&hex_b));
assert!(!info.is_banned(&hex_b));
}
#[test]
fn test_migrate_from_v1() {
let bob_key = hex::encode([2u8; 32]);
let charlie_key = hex::encode([3u8; 32]);
let creator_bytes: Vec<u8> = vec![1u8; 32];
let v1_json = serde_json::json!({
"members": [bob_key.clone(), charlie_key],
"display_names": { bob_key.clone(): "Bob" },
"membership_revision": 5,
"name": "Old",
"description": "",
"creator": creator_bytes,
"created_at": 1000,
"mls_group_id": "aa".repeat(16),
"metadata_topic": "x0x.group.aa.meta",
"chat_topic_prefix": "x0x.group.aa.chat",
});
let mut info: GroupInfo = serde_json::from_value(v1_json).unwrap();
assert!(info.members_v2.is_empty());
info.migrate_from_v1();
assert_eq!(info.members_v2.len(), 3);
let creator_hex = hex::encode([1u8; 32]);
let bob_hex = hex::encode([2u8; 32]);
assert_eq!(info.members_v2[&creator_hex].role, GroupRole::Admin);
assert_eq!(info.members_v2[&bob_hex].role, GroupRole::Member);
assert_eq!(
info.members_v2[&bob_hex].display_name.as_deref(),
Some("Bob")
);
assert_eq!(info.roster_revision, 5);
let count = info.members_v2.len();
info.migrate_from_v1();
assert_eq!(info.members_v2.len(), count);
}
#[test]
fn legacy_group_without_plane_field_grandfathers_to_gss() {
let json = serde_json::json!({
"name": "Old",
"description": "",
"creator": vec![1u8; 32],
"created_at": 1000,
"mls_group_id": "aa".repeat(16),
"metadata_topic": "x0x.group.aa.meta",
"chat_topic_prefix": "x0x.group.aa.chat",
"shared_secret": vec![7u8; 32],
"secret_epoch": 3,
});
let info: GroupInfo = serde_json::from_value(json).expect("deserialize legacy blob");
assert_eq!(
info.secure_plane,
SecureGroupPlane::Gss,
"a group blob without secure_plane must grandfather to GSS"
);
}
#[test]
fn with_policy_constructor_stays_on_gss_plane() {
let info = GroupInfo::new("T".into(), "".into(), agent(1), "aa".repeat(16));
assert_eq!(info.secure_plane, SecureGroupPlane::Gss);
}
#[test]
fn secure_plane_survives_json_roundtrip() {
let mut info = GroupInfo::new("T".into(), "".into(), agent(1), "aa".repeat(16));
info.secure_plane = SecureGroupPlane::TreeKem;
let json = serde_json::to_string(&info).expect("serialize");
let restored: GroupInfo = serde_json::from_str(&json).expect("deserialize");
assert_eq!(restored.secure_plane, SecureGroupPlane::TreeKem);
}
#[test]
fn test_to_group_card_hidden_returns_none() {
let info = GroupInfo::new("T".into(), "".into(), agent(1), "aa".repeat(16));
assert!(info.to_group_card().is_none());
}
#[test]
fn test_to_group_card_public() {
let info = GroupInfo::with_policy(
"T".into(),
"d".into(),
agent(1),
"aa".repeat(16),
GroupPolicyPreset::PublicRequestSecure.to_policy(),
);
let card = info.to_group_card().unwrap();
assert_eq!(card.name, "T");
assert_eq!(card.member_count, 1);
assert!(card.request_access_enabled);
}
#[test]
fn test_caller_role() {
let info = GroupInfo::new("T".into(), "".into(), agent(1), "aa".repeat(16));
let creator_hex = hex::encode([1u8; 32]);
assert_eq!(info.caller_role(&creator_hex), Some(GroupRole::Admin));
let stranger_hex = hex::encode([9u8; 32]);
assert_eq!(info.caller_role(&stranger_hex), None);
}
#[test]
fn default_invite_max_role_is_member() {
assert_eq!(default_invite_max_role(), GroupRole::Member);
}
}