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:
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()))
|
||||
|
||||
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