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:
208
crates/quicproquo-server/src/domain/groups.rs
Normal file
208
crates/quicproquo-server/src/domain/groups.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
162
crates/quicproquo-server/src/v2_handlers/group.rs
Normal file
162
crates/quicproquo-server/src/v2_handlers/group.rs
Normal 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()))
|
||||
}
|
||||
Reference in New Issue
Block a user