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,