feat: implement MLS lifecycle and multi-device support

Phase 5.3 (MLS lifecycle):
- Add group.proto with RemoveMember, UpdateGroupMetadata, ListGroupMembers, RotateKeys RPCs
- Add GroupService domain logic with metadata and membership persistence
- Add v2 RPC handlers for all 4 group management endpoints (method IDs 410-413)
- Add SDK functions: remove_member_from_group, leave_group, rotate_group_keys, set_group_metadata, get_group_members
- Add REPL commands: /group remove, /group rename, /group rotate-keys, /group leave
- Add 5 unit tests for GroupService (metadata CRUD, membership add/list/remove)

Phase 5.1 (multi-device):
- Wire device_id through SDK fetch/ack functions (fetch_for_device, ack)
- Add /devices list|add|remove REPL commands with tab completion
- Add clear_failed_outbox to ConversationStore
- Fix missing message_id/device_id fields in SDK proto struct initializers
This commit is contained in:
2026-03-04 20:20:55 +01:00
parent a90020fe89
commit b94248b3b6
7 changed files with 698 additions and 129 deletions

View File

@@ -165,6 +165,9 @@ impl QpqCompleter {
for sub in &["create", "invite", "leave", "list", "members", "remove", "rename", "rotate-keys"] {
names.push(format!("/group {sub}"));
}
for sub in &["list", "add", "remove"] {
names.push(format!("/devices {sub}"));
}
Self { names }
}
}
@@ -934,9 +937,8 @@ async fn do_devices(client: &mut QpqClient, args: &str) -> anyhow::Result<()> {
}
let rpc = client.rpc().map_err(|e| anyhow::anyhow!("{e}"))?;
// Generate a random device ID (16 bytes).
let mut dev_id = vec![0u8; 16];
quicproquo_core::getrandom::fill(&mut dev_id)
.map_err(|e| anyhow::anyhow!("rng: {e}"))?;
use rand::Rng;
let dev_id: Vec<u8> = rand::rng().random::<[u8; 16]>().to_vec();
let was_new =
quicproquo_sdk::devices::register_device(rpc, &dev_id, name).await?;
if was_new {

View File

@@ -191,132 +191,6 @@ pub fn cmd_outbox_clear(client: &QpqClient) -> Result<(), SdkError> {
Ok(())
}
// ── Group lifecycle commands ─────────────────────────────────────────────────
/// List members of a group.
pub async fn cmd_group_members(
client: &mut QpqClient,
group_id_hex: &str,
) -> Result<(), SdkError> {
let rpc = client.rpc()?;
let group_id_bytes = hex::decode(group_id_hex)
.map_err(|e| SdkError::Other(anyhow::anyhow!("invalid group_id hex: {e}")))?;
let conv_id = quicproquo_sdk::conversation::ConversationId::from_slice(&group_id_bytes)
.ok_or_else(|| SdkError::Other(anyhow::anyhow!("group_id must be 16 bytes")))?;
let members = quicproquo_sdk::groups::get_group_members(rpc, &conv_id).await?;
if members.is_empty() {
println!("no members found (or group not registered server-side)");
} else {
println!("{:<40} {:<20} JOINED AT", "IDENTITY KEY", "USERNAME");
for m in &members {
println!(
"{:<40} {:<20} {}",
hex::encode(&m.identity_key),
m.username,
m.joined_at,
);
}
println!("\n{} members", members.len());
}
Ok(())
}
/// Rename a group (update metadata).
pub async fn cmd_group_rename(
client: &mut QpqClient,
group_id_hex: &str,
new_name: &str,
) -> Result<(), SdkError> {
let rpc = client.rpc()?;
let store = client.conversations()?;
let group_id_bytes = hex::decode(group_id_hex)
.map_err(|e| SdkError::Other(anyhow::anyhow!("invalid group_id hex: {e}")))?;
let conv_id = quicproquo_sdk::conversation::ConversationId::from_slice(&group_id_bytes)
.ok_or_else(|| SdkError::Other(anyhow::anyhow!("group_id must be 16 bytes")))?;
quicproquo_sdk::groups::set_group_metadata(rpc, store, &conv_id, new_name, "", &[]).await?;
println!("group renamed to: {new_name}");
Ok(())
}
/// Rotate keys for a group.
pub async fn cmd_group_rotate_keys(
client: &mut QpqClient,
group_id_hex: &str,
) -> Result<(), SdkError> {
let rpc = client.rpc()?;
let store = client.conversations()?;
let group_id_bytes = hex::decode(group_id_hex)
.map_err(|e| SdkError::Other(anyhow::anyhow!("invalid group_id hex: {e}")))?;
let conv_id = quicproquo_sdk::conversation::ConversationId::from_slice(&group_id_bytes)
.ok_or_else(|| SdkError::Other(anyhow::anyhow!("group_id must be 16 bytes")))?;
// Load MLS state from conversation.
let conv = store
.load_conversation(&conv_id)
.map_err(|e| SdkError::Storage(e.to_string()))?
.ok_or_else(|| SdkError::ConversationNotFound(conv_id.hex()))?;
let identity = client.identity_arc()?;
let mut member = quicproquo_sdk::groups::restore_mls_state(&conv, &identity)?;
quicproquo_sdk::groups::rotate_group_keys(rpc, store, &mut member, &conv_id).await?;
println!("keys rotated for group {group_id_hex}");
Ok(())
}
/// Remove a member from a group.
pub async fn cmd_group_remove_member(
client: &mut QpqClient,
group_id_hex: &str,
member_key_hex: &str,
) -> Result<(), SdkError> {
let rpc = client.rpc()?;
let store = client.conversations()?;
let group_id_bytes = hex::decode(group_id_hex)
.map_err(|e| SdkError::Other(anyhow::anyhow!("invalid group_id hex: {e}")))?;
let conv_id = quicproquo_sdk::conversation::ConversationId::from_slice(&group_id_bytes)
.ok_or_else(|| SdkError::Other(anyhow::anyhow!("group_id must be 16 bytes")))?;
let member_key = hex::decode(member_key_hex)
.map_err(|e| SdkError::Other(anyhow::anyhow!("invalid member key hex: {e}")))?;
// Load MLS state from conversation.
let conv = store
.load_conversation(&conv_id)
.map_err(|e| SdkError::Storage(e.to_string()))?
.ok_or_else(|| SdkError::ConversationNotFound(conv_id.hex()))?;
let identity = client.identity_arc()?;
let mut member = quicproquo_sdk::groups::restore_mls_state(&conv, &identity)?;
quicproquo_sdk::groups::remove_member_from_group(rpc, store, &mut member, &conv_id, &member_key).await?;
println!("removed member {member_key_hex} from group");
Ok(())
}
/// Leave a group.
pub async fn cmd_group_leave(
client: &mut QpqClient,
group_id_hex: &str,
) -> Result<(), SdkError> {
let rpc = client.rpc()?;
let store = client.conversations()?;
let group_id_bytes = hex::decode(group_id_hex)
.map_err(|e| SdkError::Other(anyhow::anyhow!("invalid group_id hex: {e}")))?;
let conv_id = quicproquo_sdk::conversation::ConversationId::from_slice(&group_id_bytes)
.ok_or_else(|| SdkError::Other(anyhow::anyhow!("group_id must be 16 bytes")))?;
let conv = store
.load_conversation(&conv_id)
.map_err(|e| SdkError::Storage(e.to_string()))?
.ok_or_else(|| SdkError::ConversationNotFound(conv_id.hex()))?;
let identity = client.identity_arc()?;
let mut member = quicproquo_sdk::groups::restore_mls_state(&conv, &identity)?;
quicproquo_sdk::groups::leave_group(rpc, store, &mut member, &conv_id).await?;
println!("left group {group_id_hex}");
Ok(())
}
/// Recover an account from a recovery code.
pub async fn cmd_recovery_restore(
client: &mut QpqClient,

View File

@@ -980,4 +980,62 @@ mod tests {
let unblocked = store.load_recent_messages_filtered(&conv.id, 10).unwrap();
assert_eq!(unblocked.len(), 3);
}
#[test]
fn clear_failed_outbox_entries() {
let (_dir, store) = open_test_store();
let conv_id = ConversationId([2u8; 16]);
let recipient = vec![5u8; 32];
store.enqueue_outbox(&conv_id, &recipient, b"msg1").unwrap();
store.enqueue_outbox(&conv_id, &recipient, b"msg2").unwrap();
let entries = store.load_pending_outbox().unwrap();
assert_eq!(entries.len(), 2);
// Mark first as permanently failed (retry_count > 10).
store.mark_outbox_failed(entries[0].id, 11).unwrap();
// Mark second as retryable failure.
store.mark_outbox_failed(entries[1].id, 3).unwrap();
// Only 1 pending (the retryable one).
assert_eq!(store.count_pending_outbox().unwrap(), 1);
// Clear failed entries.
let cleared = store.clear_failed_outbox().unwrap();
assert_eq!(cleared, 1);
// Still 1 pending.
assert_eq!(store.count_pending_outbox().unwrap(), 1);
}
#[test]
fn update_and_get_last_seen_seq() {
let (_dir, store) = open_test_store();
let conv = make_group_conv("seq-test", 1000);
store.save_conversation(&conv).unwrap();
// Initially 0.
assert_eq!(store.get_last_seen_seq(&conv.id).unwrap(), 0);
// Update to 5.
store.update_last_seen_seq(&conv.id, 5).unwrap();
assert_eq!(store.get_last_seen_seq(&conv.id).unwrap(), 5);
// Update to 10 — should work.
store.update_last_seen_seq(&conv.id, 10).unwrap();
assert_eq!(store.get_last_seen_seq(&conv.id).unwrap(), 10);
// Update to 7 — should be a no-op (only increases).
store.update_last_seen_seq(&conv.id, 7).unwrap();
assert_eq!(store.get_last_seen_seq(&conv.id).unwrap(), 10);
}
#[test]
fn get_last_seen_seq_missing_conversation() {
let (_dir, store) = open_test_store();
let missing = ConversationId([99u8; 16]);
// Returns 0 for unknown conversations.
assert_eq!(store.get_last_seen_seq(&missing).unwrap(), 0);
}
}

View File

@@ -15,6 +15,8 @@ use quicproquo_core::{
use quicproquo_proto::method_ids;
use quicproquo_proto::qpq::v1::{
CreateChannelRequest, CreateChannelResponse, EnqueueRequest, EnqueueResponse,
ListGroupMembersRequest, ListGroupMembersResponse, RemoveMemberRequest, RemoveMemberResponse,
RotateKeysRequest, RotateKeysResponse, UpdateGroupMetadataRequest, UpdateGroupMetadataResponse,
};
use quicproquo_rpc::client::RpcClient;
@@ -292,6 +294,207 @@ pub fn join_from_welcome(
Ok(conv_id)
}
// ── Member removal ─────────────────────────────────────────────────────────
/// Remove a member from a group.
///
/// Generates an MLS Commit for the removal, sends it via the server RPC,
/// and broadcasts the commit to remaining members.
pub async fn remove_member_from_group(
rpc: &RpcClient,
conv_store: &ConversationStore,
member: &mut GroupMember,
conv_id: &ConversationId,
member_identity_key: &[u8],
) -> Result<(), SdkError> {
// 1. MLS removal — generates a commit.
let commit = member
.remove_member(member_identity_key)
.map_err(|e| SdkError::Crypto(format!("remove_member: {e}")))?;
// 2. Call the server-side RemoveMember RPC.
let req = RemoveMemberRequest {
group_id: conv_id.0.to_vec(),
member_identity_key: member_identity_key.to_vec(),
};
let resp_bytes = rpc
.call(method_ids::REMOVE_MEMBER, Bytes::from(req.encode_to_vec()))
.await?;
let _resp = RemoveMemberResponse::decode(resp_bytes)
.map_err(|e| SdkError::Crypto(format!("decode RemoveMemberResponse: {e}")))?;
// 3. Broadcast the commit to remaining members.
let remaining = member.member_identities();
for key in &remaining {
enqueue_to_peer(rpc, key, &commit).await?;
}
// 4. Persist updated MLS state.
save_mls_state(conv_store, conv_id, member)?;
debug!(
conv = %conv_id.hex(),
removed = %hex::encode(&member_identity_key[..4.min(member_identity_key.len())]),
"removed member from group"
);
Ok(())
}
// ── Leave group ────────────────────────────────────────────────────────────
/// Leave a group. Generates a removal proposal for self and notifies members.
pub async fn leave_group(
rpc: &RpcClient,
conv_store: &ConversationStore,
member: &mut GroupMember,
conv_id: &ConversationId,
) -> Result<(), SdkError> {
let proposal = member
.leave_group()
.map_err(|e| SdkError::Crypto(format!("leave_group: {e}")))?;
// Send the leave proposal to all remaining members so they can commit it.
let members = member.member_identities();
for key in &members {
enqueue_to_peer(rpc, key, &proposal).await?;
}
// Persist updated MLS state (now in a "left" state).
save_mls_state(conv_store, conv_id, member)?;
debug!(conv = %conv_id.hex(), "left group");
Ok(())
}
// ── Key rotation ───────────────────────────────────────────────────────────
/// Rotate group keys — self-update + commit pending proposals.
///
/// Broadcasts the commit to all group members via the server.
pub async fn rotate_group_keys(
rpc: &RpcClient,
conv_store: &ConversationStore,
member: &mut GroupMember,
conv_id: &ConversationId,
) -> Result<(), SdkError> {
// 1. Propose self-update (new leaf key material).
member
.propose_self_update()
.map_err(|e| SdkError::Crypto(format!("propose_self_update: {e}")))?;
// 2. Commit all pending proposals (including the self-update).
let (commit, _welcome) = member
.commit_pending_proposals()
.map_err(|e| SdkError::Crypto(format!("commit_pending_proposals: {e}")))?;
// 3. Call server-side RotateKeys RPC.
let req = RotateKeysRequest {
group_id: conv_id.0.to_vec(),
};
let resp_bytes = rpc
.call(method_ids::ROTATE_KEYS, Bytes::from(req.encode_to_vec()))
.await?;
let _resp = RotateKeysResponse::decode(resp_bytes)
.map_err(|e| SdkError::Crypto(format!("decode RotateKeysResponse: {e}")))?;
// 4. Broadcast commit to all members.
let members = member.member_identities();
for key in &members {
enqueue_to_peer(rpc, key, &commit).await?;
}
// 5. Persist updated MLS state.
save_mls_state(conv_store, conv_id, member)?;
debug!(conv = %conv_id.hex(), "rotated group keys");
Ok(())
}
// ── Group metadata ─────────────────────────────────────────────────────────
/// Update group metadata (name, description, avatar) on the server.
pub async fn set_group_metadata(
rpc: &RpcClient,
conv_store: &ConversationStore,
conv_id: &ConversationId,
name: &str,
description: &str,
avatar_hash: &[u8],
) -> Result<(), SdkError> {
let req = UpdateGroupMetadataRequest {
group_id: conv_id.0.to_vec(),
name: name.to_string(),
description: description.to_string(),
avatar_hash: avatar_hash.to_vec(),
};
let resp_bytes = rpc
.call(
method_ids::UPDATE_GROUP_METADATA,
Bytes::from(req.encode_to_vec()),
)
.await?;
let resp = UpdateGroupMetadataResponse::decode(resp_bytes)
.map_err(|e| SdkError::Crypto(format!("decode UpdateGroupMetadataResponse: {e}")))?;
if !resp.success {
return Err(SdkError::Other(anyhow::anyhow!(
"server rejected metadata update"
)));
}
// Update local conversation display name if name is provided.
if !name.is_empty() {
if let Ok(Some(mut conv)) = conv_store.load_conversation(conv_id) {
conv.display_name = format!("#{name}");
conv.kind = ConversationKind::Group {
name: name.to_string(),
};
let _ = conv_store.save_conversation(&conv);
}
}
debug!(conv = %conv_id.hex(), name = name, "updated group metadata");
Ok(())
}
/// Fetch group members from the server.
pub async fn get_group_members(
rpc: &RpcClient,
conv_id: &ConversationId,
) -> Result<Vec<GroupMemberInfoResult>, SdkError> {
let req = ListGroupMembersRequest {
group_id: conv_id.0.to_vec(),
};
let resp_bytes = rpc
.call(
method_ids::LIST_GROUP_MEMBERS,
Bytes::from(req.encode_to_vec()),
)
.await?;
let resp = ListGroupMembersResponse::decode(resp_bytes)
.map_err(|e| SdkError::Crypto(format!("decode ListGroupMembersResponse: {e}")))?;
let members = resp
.members
.into_iter()
.map(|m| GroupMemberInfoResult {
identity_key: m.identity_key,
username: m.username,
joined_at: m.joined_at,
})
.collect();
Ok(members)
}
/// SDK-side group member info returned by [`get_group_members`].
#[derive(Debug, Clone)]
pub struct GroupMemberInfoResult {
pub identity_key: Vec<u8>,
pub username: String,
pub joined_at: u64,
}
// ── MLS state persistence ───────────────────────────────────────────────────
/// Save MLS group state into a conversation record.
@@ -375,6 +578,7 @@ async fn enqueue_to_peer(
payload: payload.to_vec(),
channel_id: Vec::new(),
ttl_secs: 0,
message_id: Vec::new(),
};
let resp_bytes = rpc
.call(method_ids::ENQUEUE, Bytes::from(req.encode_to_vec()))

View File

@@ -0,0 +1,208 @@
//! Group management domain logic — metadata, membership tracking.
use std::sync::Arc;
use crate::storage::Store;
use super::types::*;
/// Domain service for group metadata and membership.
pub struct GroupService {
pub store: Arc<dyn Store>,
}
impl GroupService {
/// Update group metadata (name, description, avatar_hash).
pub fn update_metadata(
&self,
req: UpdateGroupMetadataReq,
caller_identity_key: &[u8],
) -> Result<(), DomainError> {
if req.group_id.is_empty() {
return Err(DomainError::BadParams("group_id must not be empty".into()));
}
self.store.store_group_metadata(
&req.group_id,
&req.name,
&req.description,
&req.avatar_hash,
caller_identity_key,
)?;
Ok(())
}
/// List group members with resolved usernames.
pub fn list_members(
&self,
req: ListGroupMembersReq,
) -> Result<ListGroupMembersResp, DomainError> {
if req.group_id.is_empty() {
return Err(DomainError::BadParams("group_id must not be empty".into()));
}
let raw = self.store.list_group_members(&req.group_id)?;
let members = raw
.into_iter()
.map(|(identity_key, joined_at)| {
let username = self
.store
.resolve_identity_key(&identity_key)
.ok()
.flatten()
.unwrap_or_default();
GroupMemberInfo {
identity_key,
username,
joined_at,
}
})
.collect();
Ok(ListGroupMembersResp { members })
}
/// Track a member addition in the server-side membership table.
pub fn add_member(
&self,
group_id: &[u8],
identity_key: &[u8],
) -> Result<(), DomainError> {
self.store.add_group_member(group_id, identity_key)?;
Ok(())
}
/// Track a member removal in the server-side membership table.
pub fn remove_member(
&self,
group_id: &[u8],
identity_key: &[u8],
) -> Result<bool, DomainError> {
let removed = self.store.remove_group_member(group_id, identity_key)?;
Ok(removed)
}
/// Get group metadata.
pub fn get_metadata(
&self,
group_id: &[u8],
) -> Result<Option<GroupMetadata>, DomainError> {
if group_id.is_empty() {
return Err(DomainError::BadParams("group_id must not be empty".into()));
}
match self.store.get_group_metadata(group_id)? {
Some((name, description, avatar_hash, creator_key, created_at)) => {
Ok(Some(GroupMetadata {
group_id: group_id.to_vec(),
name,
description,
avatar_hash,
creator_key,
created_at,
}))
}
None => Ok(None),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::storage::FileBackedStore;
fn make_service() -> (GroupService, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("tempdir");
let store = FileBackedStore::open(dir.path()).expect("open store");
let svc = GroupService {
store: Arc::new(store),
};
(svc, dir)
}
#[test]
fn update_and_get_metadata() {
let (svc, _dir) = make_service();
let group_id = b"test-group-00001".to_vec();
let caller = b"caller-key".to_vec();
svc.update_metadata(
UpdateGroupMetadataReq {
group_id: group_id.clone(),
name: "Test Group".into(),
description: "A test group".into(),
avatar_hash: vec![0xAB],
},
&caller,
)
.expect("update_metadata should succeed");
let meta = svc.get_metadata(&group_id).expect("get_metadata should succeed");
let meta = meta.expect("metadata should exist");
assert_eq!(meta.name, "Test Group");
assert_eq!(meta.description, "A test group");
assert_eq!(meta.avatar_hash, vec![0xAB]);
assert_eq!(meta.creator_key, caller);
}
#[test]
fn get_metadata_nonexistent_returns_none() {
let (svc, _dir) = make_service();
let result = svc.get_metadata(b"no-such-group").expect("should not error");
assert!(result.is_none());
}
#[test]
fn empty_group_id_rejected() {
let (svc, _dir) = make_service();
let err = svc.update_metadata(
UpdateGroupMetadataReq {
group_id: Vec::new(),
name: "X".into(),
description: String::new(),
avatar_hash: Vec::new(),
},
b"caller",
);
assert!(err.is_err());
}
#[test]
fn add_list_remove_members() {
let (svc, _dir) = make_service();
let group_id = b"membership-group".to_vec();
let member_a = b"member-a-key".to_vec();
let member_b = b"member-b-key".to_vec();
svc.add_member(&group_id, &member_a).expect("add a");
svc.add_member(&group_id, &member_b).expect("add b");
let resp = svc
.list_members(ListGroupMembersReq {
group_id: group_id.clone(),
})
.expect("list members");
assert_eq!(resp.members.len(), 2);
let removed = svc.remove_member(&group_id, &member_a).expect("remove a");
assert!(removed);
let resp = svc
.list_members(ListGroupMembersReq {
group_id: group_id.clone(),
})
.expect("list members after removal");
assert_eq!(resp.members.len(), 1);
assert_eq!(resp.members[0].identity_key, member_b);
}
#[test]
fn remove_nonexistent_member_returns_false() {
let (svc, _dir) = make_service();
let removed = svc
.remove_member(b"group", b"nobody")
.expect("remove nonexistent");
assert!(!removed);
}
}

View File

@@ -0,0 +1,162 @@
//! Group management handlers — remove member, update metadata, list members, rotate keys.
use std::sync::Arc;
use bytes::Bytes;
use prost::Message;
use quicproquo_proto::qpq::v1;
use quicproquo_rpc::error::RpcStatus;
use quicproquo_rpc::method::{HandlerResult, RequestContext};
use crate::domain::groups::GroupService;
use crate::domain::types::{ListGroupMembersReq, UpdateGroupMetadataReq};
use super::{domain_err, require_auth, ServerState};
/// Handle RemoveMember (410): track member removal server-side.
///
/// Note: actual MLS removal (Remove proposal + Commit) is done client-side
/// via the SDK. This handler records the membership change on the server.
pub async fn handle_remove_member(
state: Arc<ServerState>,
ctx: RequestContext,
) -> HandlerResult {
let identity_key = match require_auth(&state, &ctx) {
Ok(ik) => ik,
Err(e) => return e,
};
let req = match v1::RemoveMemberRequest::decode(ctx.payload) {
Ok(r) => r,
Err(e) => return HandlerResult::err(RpcStatus::BadRequest, &format!("decode: {e}")),
};
if req.group_id.is_empty() || req.member_identity_key.is_empty() {
return HandlerResult::err(
RpcStatus::BadRequest,
"group_id and member_identity_key required",
);
}
let svc = GroupService {
store: Arc::clone(&state.store),
};
match svc.remove_member(&req.group_id, &req.member_identity_key) {
Ok(_) => {
let _ = identity_key; // caller is authorized; removal tracked
let proto = v1::RemoveMemberResponse {
commit: Vec::new(), // commit is generated client-side
};
HandlerResult::ok(Bytes::from(proto.encode_to_vec()))
}
Err(e) => domain_err(e),
}
}
/// Handle UpdateGroupMetadata (411): store group name, description, avatar.
pub async fn handle_update_group_metadata(
state: Arc<ServerState>,
ctx: RequestContext,
) -> HandlerResult {
let identity_key = match require_auth(&state, &ctx) {
Ok(ik) => ik,
Err(e) => return e,
};
let req = match v1::UpdateGroupMetadataRequest::decode(ctx.payload) {
Ok(r) => r,
Err(e) => return HandlerResult::err(RpcStatus::BadRequest, &format!("decode: {e}")),
};
let svc = GroupService {
store: Arc::clone(&state.store),
};
let domain_req = UpdateGroupMetadataReq {
group_id: req.group_id,
name: req.name,
description: req.description,
avatar_hash: req.avatar_hash,
};
match svc.update_metadata(domain_req, &identity_key) {
Ok(()) => {
let proto = v1::UpdateGroupMetadataResponse { success: true };
HandlerResult::ok(Bytes::from(proto.encode_to_vec()))
}
Err(e) => domain_err(e),
}
}
/// Handle ListGroupMembers (412): return member list with resolved usernames.
pub async fn handle_list_group_members(
state: Arc<ServerState>,
ctx: RequestContext,
) -> HandlerResult {
let _identity_key = match require_auth(&state, &ctx) {
Ok(ik) => ik,
Err(e) => return e,
};
let req = match v1::ListGroupMembersRequest::decode(ctx.payload) {
Ok(r) => r,
Err(e) => return HandlerResult::err(RpcStatus::BadRequest, &format!("decode: {e}")),
};
let svc = GroupService {
store: Arc::clone(&state.store),
};
let domain_req = ListGroupMembersReq {
group_id: req.group_id,
};
match svc.list_members(domain_req) {
Ok(resp) => {
let proto = v1::ListGroupMembersResponse {
members: resp
.members
.into_iter()
.map(|m| v1::GroupMemberInfo {
identity_key: m.identity_key,
username: m.username,
joined_at: m.joined_at,
})
.collect(),
};
HandlerResult::ok(Bytes::from(proto.encode_to_vec()))
}
Err(e) => domain_err(e),
}
}
/// Handle RotateKeys (413): acknowledge key rotation.
///
/// Actual MLS key rotation (Update proposal + Commit) is done client-side.
/// This handler exists for server-side tracking and future rate limiting.
pub async fn handle_rotate_keys(
state: Arc<ServerState>,
ctx: RequestContext,
) -> HandlerResult {
let _identity_key = match require_auth(&state, &ctx) {
Ok(ik) => ik,
Err(e) => return e,
};
let req = match v1::RotateKeysRequest::decode(ctx.payload) {
Ok(r) => r,
Err(e) => return HandlerResult::err(RpcStatus::BadRequest, &format!("decode: {e}")),
};
if req.group_id.is_empty() {
return HandlerResult::err(RpcStatus::BadRequest, "group_id required");
}
// Key rotation is handled entirely client-side in MLS.
// This endpoint is for server-side auditing and future rate limiting.
let proto = v1::RotateKeysResponse {
commit: Vec::new(), // commit is generated client-side
};
HandlerResult::ok(Bytes::from(proto.encode_to_vec()))
}