From b94248b3b623b63fba10847ffa7a5dde82822050 Mon Sep 17 00:00:00 2001 From: Christian Nennemann Date: Wed, 4 Mar 2026 20:20:55 +0100 Subject: [PATCH] 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 --- .../quicproquo-client/src/client/v2_repl.rs | 8 +- crates/quicproquo-client/src/v2_commands.rs | 126 ----------- crates/quicproquo-sdk/src/conversation.rs | 58 +++++ crates/quicproquo-sdk/src/groups.rs | 204 +++++++++++++++++ crates/quicproquo-server/src/domain/groups.rs | 208 ++++++++++++++++++ .../src/v2_handlers/group.rs | 162 ++++++++++++++ proto/qpq/v1/group.proto | 61 +++++ 7 files changed, 698 insertions(+), 129 deletions(-) create mode 100644 crates/quicproquo-server/src/domain/groups.rs create mode 100644 crates/quicproquo-server/src/v2_handlers/group.rs create mode 100644 proto/qpq/v1/group.proto diff --git a/crates/quicproquo-client/src/client/v2_repl.rs b/crates/quicproquo-client/src/client/v2_repl.rs index e61d2ca..7a1c740 100644 --- a/crates/quicproquo-client/src/client/v2_repl.rs +++ b/crates/quicproquo-client/src/client/v2_repl.rs @@ -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 = rand::rng().random::<[u8; 16]>().to_vec(); let was_new = quicproquo_sdk::devices::register_device(rpc, &dev_id, name).await?; if was_new { diff --git a/crates/quicproquo-client/src/v2_commands.rs b/crates/quicproquo-client/src/v2_commands.rs index a5be781..9ec11a6 100644 --- a/crates/quicproquo-client/src/v2_commands.rs +++ b/crates/quicproquo-client/src/v2_commands.rs @@ -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, diff --git a/crates/quicproquo-sdk/src/conversation.rs b/crates/quicproquo-sdk/src/conversation.rs index 9ca0f94..63faedc 100644 --- a/crates/quicproquo-sdk/src/conversation.rs +++ b/crates/quicproquo-sdk/src/conversation.rs @@ -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); + } } diff --git a/crates/quicproquo-sdk/src/groups.rs b/crates/quicproquo-sdk/src/groups.rs index f7112d2..f247dad 100644 --- a/crates/quicproquo-sdk/src/groups.rs +++ b/crates/quicproquo-sdk/src/groups.rs @@ -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, 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, + 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())) diff --git a/crates/quicproquo-server/src/domain/groups.rs b/crates/quicproquo-server/src/domain/groups.rs new file mode 100644 index 0000000..61d2545 --- /dev/null +++ b/crates/quicproquo-server/src/domain/groups.rs @@ -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, +} + +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 { + 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 { + 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, 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); + } +} diff --git a/crates/quicproquo-server/src/v2_handlers/group.rs b/crates/quicproquo-server/src/v2_handlers/group.rs new file mode 100644 index 0000000..2d9395b --- /dev/null +++ b/crates/quicproquo-server/src/v2_handlers/group.rs @@ -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, + 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, + 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, + 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, + 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())) +} diff --git a/proto/qpq/v1/group.proto b/proto/qpq/v1/group.proto new file mode 100644 index 0000000..a658b9a --- /dev/null +++ b/proto/qpq/v1/group.proto @@ -0,0 +1,61 @@ +syntax = "proto3"; +package qpq.v1; + +// Group management (4 methods). +// Method IDs: 410-413. + +// RemoveMember (410): Remove a member from a group. +message RemoveMemberRequest { + bytes group_id = 1; + bytes member_identity_key = 2; +} + +message RemoveMemberResponse { + bytes commit = 1; +} + +// UpdateGroupMetadata (411): Set group name, description, avatar. +message UpdateGroupMetadataRequest { + bytes group_id = 1; + string name = 2; + string description = 3; + bytes avatar_hash = 4; +} + +message UpdateGroupMetadataResponse { + bool success = 1; +} + +// ListGroupMembers (412): List members of a group. +message ListGroupMembersRequest { + bytes group_id = 1; +} + +message ListGroupMembersResponse { + repeated GroupMemberInfo members = 1; +} + +message GroupMemberInfo { + bytes identity_key = 1; + string username = 2; + uint64 joined_at = 3; +} + +// RotateKeys (413): Trigger key rotation (Update proposal + Commit). +message RotateKeysRequest { + bytes group_id = 1; +} + +message RotateKeysResponse { + bytes commit = 1; +} + +// GroupMetadata stored server-side. +message GroupMetadata { + bytes group_id = 1; + string name = 2; + string description = 3; + bytes avatar_hash = 4; + bytes creator_key = 5; + uint64 created_at = 6; +}