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

@@ -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()))
}