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,
|
||||
|
||||
Reference in New Issue
Block a user