From fddd0f2d0aa155703b9e5917e1cb266afb8b48c3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 12 Aug 2026 14:48:23 -0700 Subject: [PATCH 1/8] Build(deps): bump quinn-proto from 0.11.14 to 0.11.16 (#190) Bumps [quinn-proto](https://github.com/quinn-rs/quinn) from 0.11.14 to 0.11.16. - [Release notes](https://github.com/quinn-rs/quinn/releases) - [Commits](https://github.com/quinn-rs/quinn/compare/quinn-proto-0.11.14...quinn-proto-0.11.16) --- updated-dependencies: - dependency-name: quinn-proto dependency-version: 0.11.16 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Jazz Turner-Baggs <473256+jazzz@users.noreply.github.com> --- Cargo.lock | 36 +++++++++++++++++++++++++++++------- 1 file changed, 29 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f17ad3e..081efb6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1294,6 +1294,17 @@ dependencies = [ "cpufeatures 0.2.17", ] +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + [[package]] name = "chacha20poly1305" version = "0.10.1" @@ -1301,7 +1312,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" dependencies = [ "aead", - "chacha20", + "chacha20 0.9.1", "cipher", "poly1305", "zeroize", @@ -2439,11 +2450,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", - "js-sys", "libc", "r-efi 5.3.0", "wasip2", - "wasm-bindgen", ] [[package]] @@ -2453,11 +2462,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", "rand_core 0.10.1", "wasip2", "wasip3", + "wasm-bindgen", ] [[package]] @@ -4510,15 +4521,16 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.14" +version = "0.11.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" dependencies = [ "aws-lc-rs", "bytes", - "getrandom 0.3.4", + "getrandom 0.4.2", "lru-slab", - "rand 0.9.4", + "rand 0.10.1", + "rand_pcg", "ring", "rustc-hash", "rustls", @@ -4600,6 +4612,7 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" dependencies = [ + "chacha20 0.10.1", "getrandom 0.4.2", "rand_core 0.10.1", ] @@ -4659,6 +4672,15 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "rand_xorshift" version = "0.4.0" From cbfd8174d1c9bc8766f63340a048770c830c4817 Mon Sep 17 00:00:00 2001 From: kaichao Date: Sat, 15 Aug 2026 00:40:12 +0800 Subject: [PATCH 2/8] feat: produce message acked events (#204) * feat: adopt causal history for group v2 * feat: message seen * chore: clear duplicate tests * chore: refactor the event convertion * chore: refactor on imports, functional sequential processing etc * chore: renaming to DeliveryAck * chore: merge tests into group v2 integration tests * chore: fix ack related namings --- bin/chat-cli/src/app.rs | 102 +++++++-- bin/chat-cli/src/ui.rs | 12 ++ core/conversations/src/causal_history.rs | 143 +++++++++++- core/conversations/src/conversation.rs | 13 +- .../src/conversation/direct_v1.rs | 4 +- .../src/conversation/group_v1.rs | 9 +- .../src/conversation/group_v2.rs | 63 ++++-- core/conversations/src/core.rs | 18 +- core/conversations/src/lib.rs | 4 +- .../integration_tests_core/src/test_client.rs | 7 + .../tests/test_group_v2.rs | 143 ++++++++++++ crates/generic-chat/src/client.rs | 203 ++++++++++++++++-- crates/generic-chat/src/event.rs | 30 +++ crates/generic-chat/src/lib.rs | 3 +- crates/generic-chat/tests/group_v2.rs | 60 ++++++ crates/generic-chat/tests/saro_and_raya.rs | 23 +- 16 files changed, 764 insertions(+), 73 deletions(-) diff --git a/bin/chat-cli/src/app.rs b/bin/chat-cli/src/app.rs index 30b9e24..7499181 100644 --- a/bin/chat-cli/src/app.rs +++ b/bin/chat-cli/src/app.rs @@ -15,6 +15,21 @@ pub struct DisplayMessage { pub from_self: bool, pub content: String, pub timestamp: u64, + pub message_id: Option, + #[serde(default)] + pub delivered_to: Vec, +} + +impl DisplayMessage { + fn new(from_self: bool, content: String) -> Self { + Self { + from_self, + content, + timestamp: now(), + message_id: None, + delivered_to: Vec::new(), + } + } } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -189,11 +204,58 @@ where let Some(session) = self.state.chats.get_mut(&chat_id) else { return; }; - session.messages.push(DisplayMessage { - from_self: false, - content: String::from_utf8_lossy(&content).into_owned(), - timestamp: now(), - }); + session.messages.push(DisplayMessage::new( + false, + String::from_utf8_lossy(&content).into_owned(), + )); + } + Event::MessageAcked { + convo_id, + message_id, + acked_by, + } => { + let Some(session) = self.state.chats.get_mut(convo_id.as_ref()) else { + return; + }; + let Some(message) = session + .messages + .iter_mut() + .find(|m| m.message_id.as_deref() == Some(message_id.as_str())) + else { + return; // sent before this session, or not ours + }; + let peer = acked_by.map_or_else( + || "a member".to_string(), + |s| { + let id = s.account.unwrap_or(s.local_identity); + format!("{}…", &id.as_str()[..8.min(id.as_str().len())]) + }, + ); + if !message.delivered_to.contains(&peer) { + message.delivered_to.push(peer); + } + } + Event::MessageMissing { + convo_id, + sender_hint, + .. + } => { + let Some(session) = self.state.chats.get(convo_id.as_ref()) else { + return; + }; + // The hint is not authenticated (see `Event::MessageMissing`), + // so name the author loosely rather than as an established fact. + let author = sender_hint.map_or_else( + || "a member".to_string(), + |s| { + let id = s.account.unwrap_or(s.local_identity); + format!("{}…", &id.as_str()[..8.min(id.as_str().len())]) + }, + ); + self.status = format!( + "A message from {author} never arrived in '{}'.", + session.display_name() + ); } Event::InboundError { message } => { self.status = format!("Could not process incoming message: {message}"); @@ -209,16 +271,16 @@ where .clone() .ok_or_else(|| anyhow::anyhow!("No active chat. Use /connect or /switch first."))?; - self.client + let message_id = self + .client .send_message(&chat_id, content.as_bytes()) .map_err(|e| anyhow::anyhow!("{e:?}"))?; if let Some(session) = self.state.chats.get_mut(&chat_id) { - session.messages.push(DisplayMessage { - from_self: true, - content: content.to_string(), - timestamp: now(), - }); + let mut message = DisplayMessage::new(true, content.to_string()); + // Kept so `MessageAcked` can find this message again. + message.message_id = Some(message_id); + session.messages.push(message); } self.save_state()?; @@ -226,11 +288,8 @@ where } fn add_system_message(&mut self, content: &str) { - self.command_output.push(DisplayMessage { - from_self: true, - content: content.to_string(), - timestamp: now(), - }); + self.command_output + .push(DisplayMessage::new(true, content.to_string())); } pub fn handle_command(&mut self, cmd: &str) -> Result> { @@ -273,7 +332,8 @@ where .client .create_direct_conversation(args) .map_err(|e| anyhow::anyhow!("{e:?}"))?; - self.client + let message_id = self + .client .send_message(&chat_id, initial.as_bytes()) .map_err(|e| anyhow::anyhow!("{e:?}"))?; @@ -283,11 +343,9 @@ where nickname: None, messages: Vec::new(), }; - session.messages.push(DisplayMessage { - from_self: true, - content: initial, - timestamp: now(), - }); + let mut message = DisplayMessage::new(true, initial); + message.message_id = Some(message_id); + session.messages.push(message); self.state.chats.insert(chat_id.clone(), session); self.set_active_chat(Some(chat_id)); self.save_state()?; diff --git a/bin/chat-cli/src/ui.rs b/bin/chat-cli/src/ui.rs index 595c48e..4914dc6 100644 --- a/bin/chat-cli/src/ui.rs +++ b/bin/chat-cli/src/ui.rs @@ -156,6 +156,18 @@ where remaining = tail; } + // Delivery receipts for our own sends: the peers whose later + // messages showed they hold this one. + if !msg.delivered_to.is_empty() { + items.push(ListItem::new(Line::from(vec![ + Span::raw(indent.clone()), + Span::styled( + format!("↳ delivered to {}", msg.delivered_to.join(", ")), + Style::default().fg(Color::DarkGray), + ), + ]))); + } + items }) .collect(); diff --git a/core/conversations/src/causal_history.rs b/core/conversations/src/causal_history.rs index 7afc7b1..ebdf1e2 100644 --- a/core/conversations/src/causal_history.rs +++ b/core/conversations/src/causal_history.rs @@ -9,12 +9,19 @@ //! - assign a deterministic message ID + Lamport timestamp to outbound msgs //! - attach a bounded causal-history frontier to each outbound message //! - on receive, detect referenced-but-unseen message IDs (gaps) +//! - on receive, detect references to *our own* messages (acknowledgements) //! //! Out of scope here: bloom-filter acknowledgements, //! resend / outgoing buffer, incoming reorder buffer, Store-based recovery. //! This is detection only — an out-of-order message is still delivered to //! the application, but the gap it implies is reported. //! +//! The same references also show who received our messages: a peer that names +//! one of ours must have had it. Nothing is sent back — the acknowledgement +//! rides on whatever the peer says next — so a silent peer never acknowledges, +//! and neither does one that speaks after our message has dropped out of its +//! [`CAUSAL_HISTORY_LEN`]-entry frontier. +//! //! State is in-memory and session-scoped, matching the crate's current //! in-memory MLS state. @@ -72,6 +79,21 @@ pub struct MissingMessage { pub frontier: Frontier, } +/// A peer acknowledging one of our messages: it named that message in the +/// causal history of a message it sent, so it held ours at the time. +/// +/// Evidence of *delivery to a peer's client*, not of a human reading it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DeliveryAck { + pub conversation_id: String, + /// The message of ours the peer acknowledged. + pub message_id: String, + /// The acknowledging peer's `sender_id`, verbatim off the wire — + /// self-asserted like [`Frontier::sender_id`], not bound to the MLS + /// identity that sent the payload. + pub acked_by: String, +} + /// Per-conversation causal state. #[derive(Debug, Default)] struct ConvoState { @@ -84,6 +106,11 @@ struct ConvoState { frontiers: VecDeque, /// Missing IDs already reported, so a gap is surfaced exactly once. reported_missing: HashSet, + /// IDs of messages we authored: a reference to one is an acknowledgement. + own: HashSet, + /// Which peers have acknowledged each of our messages, so each is + /// surfaced exactly once. + acked_by: HashMap>, } impl ConvoState { @@ -102,6 +129,9 @@ struct Inner { convos: HashMap, /// Detected gaps, drained by the client (future #97 event bus). missing: Vec, + /// Detected acknowledgements of our own messages, drained alongside + /// `missing`. + acks: Vec, } /// Session-scoped causal-history store shared by every `GroupV1Convo` @@ -143,7 +173,9 @@ impl CausalHistoryStore { .collect(); // Our own message joins the seen-set so it appears in our future - // causal history (and, later, so we can ack peers' references to it). + // causal history, and the own-set so a peer referencing it back is + // recognised as acknowledging this send. + state.own.insert(message_id.clone()); state.record_seen(frontier); ReliablePayload { @@ -166,7 +198,11 @@ impl CausalHistoryStore { payload: &ReliablePayload, ) -> Vec { let mut inner = self.inner.borrow_mut(); - let Inner { convos, missing } = &mut *inner; + let Inner { + convos, + missing, + acks, + } = &mut *inner; let state = convos.entry(conversation_id.to_owned()).or_default(); // Lamport merge: the next local send will be strictly greater than @@ -175,6 +211,23 @@ impl CausalHistoryStore { let mut detected = Vec::new(); for entry in &payload.causal_history { + // The sender named one of ours, so it has it. Reported once per + // peer per message, and never for the message's own author. + if state.own.contains(&entry.message_id) + && payload.sender_id != entry.sender_id + && state + .acked_by + .entry(entry.message_id.clone()) + .or_default() + .insert(payload.sender_id.clone()) + { + acks.push(DeliveryAck { + conversation_id: conversation_id.to_owned(), + message_id: entry.message_id.clone(), + acked_by: payload.sender_id.clone(), + }); + } + let frontier = Frontier::new(entry.sender_id.clone(), entry.message_id.clone()); if !state.seen.contains(&frontier) && state.reported_missing.insert(frontier.clone()) { let m = MissingMessage { @@ -201,6 +254,11 @@ impl CausalHistoryStore { pub fn take_missing(&self) -> Vec { std::mem::take(&mut self.inner.borrow_mut().missing) } + + /// Drain all acknowledgements of our own messages detected so far. + pub fn take_acks(&self) -> Vec { + std::mem::take(&mut self.inner.borrow_mut().acks) + } } /// Deterministic, collision-resistant message ID. @@ -293,6 +351,87 @@ mod tests { assert_eq!(missing[0].frontier.sender_id(), "alice"); } + /// Bob replies after receiving Alice's message, so his causal history + /// names it — that reference is the acknowledgement. + #[test] + fn a_peer_referencing_our_message_acknowledges_it() { + let alice = CausalHistoryStore::new(); + let bob = CausalHistoryStore::new(); + + let a1 = payload(&alice, "c", "alice", b"hello"); + bob.on_receive("c", &a1); + let b1 = payload(&bob, "c", "bob", b"hi back"); + alice.on_receive("c", &b1); + + assert_eq!( + alice.take_acks(), + vec![DeliveryAck { + conversation_id: "c".to_owned(), + message_id: a1.message_id.clone(), + acked_by: "bob".to_owned(), + }] + ); + // Draining clears the report. + assert!(alice.take_acks().is_empty()); + } + + /// Every member that replies acknowledges separately, which is what lets an + /// application list the peers that hold a message. + #[test] + fn each_peer_acknowledges_separately() { + let alice = CausalHistoryStore::new(); + let bob = CausalHistoryStore::new(); + let carol = CausalHistoryStore::new(); + + let a1 = payload(&alice, "c", "alice", b"hello all"); + bob.on_receive("c", &a1); + carol.on_receive("c", &a1); + alice.on_receive("c", &payload(&bob, "c", "bob", b"bob here")); + alice.on_receive("c", &payload(&carol, "c", "carol", b"carol here")); + + let holders: Vec = alice + .take_acks() + .into_iter() + .filter(|a| a.message_id == a1.message_id) + .map(|a| a.acked_by) + .collect(); + assert_eq!(holders, vec!["bob".to_owned(), "carol".to_owned()]); + } + + /// Bob keeps naming the message in later sends; the application is told + /// once. + #[test] + fn a_peer_acknowledges_a_message_only_once() { + let alice = CausalHistoryStore::new(); + let bob = CausalHistoryStore::new(); + + let a1 = payload(&alice, "c", "alice", b"hello"); + bob.on_receive("c", &a1); + alice.on_receive("c", &payload(&bob, "c", "bob", b"first reply")); + alice.take_acks(); + alice.on_receive("c", &payload(&bob, "c", "bob", b"second reply")); + + assert!( + alice.take_acks().is_empty(), + "a peer's acknowledgement of one message is reported once" + ); + } + + /// Carol's reply names Bob's message, not ours — nothing for us to report. + #[test] + fn a_reference_to_someone_elses_message_is_not_our_acknowledgement() { + let alice = CausalHistoryStore::new(); + let bob = CausalHistoryStore::new(); + let carol = CausalHistoryStore::new(); + + let b1 = payload(&bob, "c", "bob", b"bob speaks"); + carol.on_receive("c", &b1); + // Alice observes Carol's reply, which references Bob's message only. + alice.on_receive("c", &payload(&carol, "c", "carol", b"carol replies")); + + assert!(alice.take_acks().is_empty()); + } + #[test] fn a_gap_is_reported_only_once() { let sender = CausalHistoryStore::new(); diff --git a/core/conversations/src/conversation.rs b/core/conversations/src/conversation.rs index 2d016f3..7a9f1a1 100644 --- a/core/conversations/src/conversation.rs +++ b/core/conversations/src/conversation.rs @@ -16,10 +16,19 @@ use shared_traits::IdentIdRef; pub type ConversationId = String; pub type ConversationIdRef<'a> = &'a str; +/// Identifies one message within a conversation, as carried by the +/// causal-history envelope. Handed back by a send so a caller can match later +/// observations — acknowledgements, gaps — to the message that produced them. +pub type MessageId = String; + /// Behaviour shared by every conversation kind. pub(crate) trait Convo: Identified + Send { - fn send_content(&mut self, cx: &mut ServiceContext, content: &[u8]) - -> Result<(), ChatError>; + /// Encrypt and publish `content`, returning the id assigned to it. + fn send_content( + &mut self, + cx: &mut ServiceContext, + content: &[u8], + ) -> Result; /// Decrypts and processes an incoming encrypted frame. /// diff --git a/core/conversations/src/conversation/direct_v1.rs b/core/conversations/src/conversation/direct_v1.rs index b1cf04a..d9d191c 100644 --- a/core/conversations/src/conversation/direct_v1.rs +++ b/core/conversations/src/conversation/direct_v1.rs @@ -2,7 +2,7 @@ use chat_proto::logoschat::encryption::EncryptedPayload; use shared_traits::IdentIdRef; use crate::{ - ChatError, ExternalServices, + ChatError, ExternalServices, MessageId, conversation::{ConversationIdRef, Convo, GroupConvo, GroupV1Convo, Identified}, service_context::ServiceContext, }; @@ -43,7 +43,7 @@ where &mut self, cx: &mut ServiceContext, content: &[u8], - ) -> Result<(), super::ChatError> { + ) -> Result { self.inner_group.send_content(cx, content) } diff --git a/core/conversations/src/conversation/group_v1.rs b/core/conversations/src/conversation/group_v1.rs index 665a9f4..6c8fd22 100644 --- a/core/conversations/src/conversation/group_v1.rs +++ b/core/conversations/src/conversation/group_v1.rs @@ -12,7 +12,7 @@ use shared_traits::IdentIdRef; use std::collections::VecDeque; use tracing::debug; -use crate::conversation::ConversationIdRef; +use crate::conversation::{ConversationIdRef, MessageId}; use crate::inbox_v2::MlsProvider; use crate::service_context::{ExternalServices, ServiceContext}; @@ -177,7 +177,7 @@ impl GroupV1Convo { &mut self, content: &[u8], cx: &mut ServiceContext, - ) -> Result<(), ChatError> { + ) -> Result { let sender_id = cx.mls_identity.id().as_str(); let reliable = cx.causal.on_send(&self.convo_id, sender_id, content); let wire = reliable.encode_to_vec(); @@ -188,7 +188,8 @@ impl GroupV1Convo { .unwrap(); let msg_bytes = mls_message_out.to_bytes().unwrap(); - self.send_payload(cx, msg_bytes) + self.send_payload(cx, msg_bytes)?; + Ok(reliable.message_id) } // Publish outboubound payloads to the DeliveryService @@ -233,7 +234,7 @@ impl Convo for GroupV1Convo { &mut self, cx: &mut ServiceContext, content: &[u8], - ) -> Result<(), ChatError> { + ) -> Result { self.send_message(content, cx) } diff --git a/core/conversations/src/conversation/group_v2.rs b/core/conversations/src/conversation/group_v2.rs index 5a4172e..d37b2be 100644 --- a/core/conversations/src/conversation/group_v2.rs +++ b/core/conversations/src/conversation/group_v2.rs @@ -10,6 +10,7 @@ use crate::{Content, WakeupService}; use alloy::signers::local::PrivateKeySigner; use blake2::{Blake2b, Digest, digest::consts::U6}; use chat_proto::logoschat::encryption::{EncryptedPayload, Plaintext, encrypted_payload}; +use chat_proto::logoschat::reliability::ReliablePayload; use de_mls::protos::de_mls::messages::v1::{ AppMessage as AppMessageProto, MemberWelcome, app_message, }; @@ -31,7 +32,7 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; use tracing::{info, instrument}; use crate::IdentityProvider; -use crate::conversation::{ConversationIdRef, ExternalServices, ServiceContext}; +use crate::conversation::{ConversationIdRef, ExternalServices, MessageId, ServiceContext}; use crate::{ ConvoOutcome, DeliveryService, RegistrationService, conversation::{ChatError, Convo, GroupConvo, Identified}, @@ -304,14 +305,20 @@ where &mut self, service_ctx: &mut super::ServiceContext, content: &[u8], - ) -> Result<(), ChatError> { + ) -> Result { + let reliable = service_ctx.causal.on_send( + &self.convo_id, + service_ctx.mls_identity.id().as_str(), + content, + ); + self.conversation.send_message( &service_ctx.mls_provider, &service_ctx.mls_identity, - content.to_vec(), + reliable.encode_to_vec(), )?; self.after_op(service_ctx)?; - Ok(()) + Ok(reliable.message_id) } #[instrument(name = "groupv2.handle_frame", skip_all, fields(user_id = %service_ctx.mls_identity.display_name()))] @@ -341,7 +348,7 @@ where self.conversation .poll(&service_ctx.mls_provider, &service_ctx.mls_identity); let events = self.after_op(service_ctx)?; // route + publish + re-arm, returns events - Ok(self.outcome_from_events(&events)) + self.outcome_from_events(service_ctx, &events) } #[instrument(name = "groupv2.wakeup", skip_all, fields(user_id = %ctx.mls_identity.display_name()))] @@ -355,7 +362,7 @@ where tracing::warn!(convo = %self.convo_id, "conversation requested teardown"); } let events = self.after_op(ctx)?; // publish what poll produced + re-arm alarm - Ok(self.outcome_from_events(&events)) + self.outcome_from_events(ctx, &events) } fn members(&self) -> Result>, ChatError> { @@ -491,27 +498,47 @@ impl GroupV2Convo { Ok(events) } - fn outcome_from_events(&self, events: &[ConversationEvent]) -> ConvoOutcome { - let content = events.iter().find_map(|evt| match evt { - ConversationEvent::ConversationMessage(AppMessageProto { - payload: Some(app_message::Payload::ConversationMessage(cm)), - }) => Some(Content { - bytes: cm.message.clone(), - encoded_credential: cm.sender.clone(), - }), - _ => None, - }); + /// Turn drained de-mls events into a [`ConvoOutcome`], unwrapping the + /// message from its causal-history envelope. + /// + /// An outcome holds one message and de-mls emits at most one per frame, so + /// the first wins. A second would be dropped without being recorded as + /// seen, leaving a later reference to report it missing. + fn outcome_from_events( + &self, + service_ctx: &ServiceContext, + events: &[ConversationEvent], + ) -> Result { + let content = events + .iter() + .find_map(|evt| match evt { + ConversationEvent::ConversationMessage(AppMessageProto { + payload: Some(app_message::Payload::ConversationMessage(cm)), + }) => Some(cm), + _ => None, + }) + .map(|cm| -> Result { + let reliable = + ReliablePayload::decode(cm.message.as_slice()).map_err(ChatError::generic)?; + service_ctx.causal.on_receive(&self.convo_id, &reliable); + Ok(Content { + bytes: reliable.content.to_vec(), + encoded_credential: cm.sender.clone(), + }) + }) + .transpose()?; + let members_changed = events.iter().any(|evt| { matches!( evt, ConversationEvent::CommitApplied(_) | ConversationEvent::WelcomeReady { .. } ) }); - ConvoOutcome { + Ok(ConvoOutcome { convo_id: self.convo_id.clone(), content, members_changed, - } + }) } } diff --git a/core/conversations/src/core.rs b/core/conversations/src/core.rs index b696594..f6003f1 100644 --- a/core/conversations/src/core.rs +++ b/core/conversations/src/core.rs @@ -1,6 +1,6 @@ -use crate::causal_history::{CausalHistoryStore, MissingMessage}; +use crate::causal_history::{CausalHistoryStore, DeliveryAck, MissingMessage}; use crate::conversation::{ - ConversationIdRef, DirectV1Convo, GroupV1Convo, GroupV2Convo, Identified, + ConversationIdRef, DirectV1Convo, GroupV1Convo, GroupV2Convo, Identified, MessageId, }; use crate::service_context::{ExternalServices, ServiceContext}; use crate::types::ConvoMetadata; @@ -329,8 +329,16 @@ impl<'a, S: ExternalServices + 'static> Core { self.services.causal.take_missing() } - /// Encrypt and publish `content` to an existing conversation. - pub fn send_content(&mut self, convo_id: &str, content: &[u8]) -> Result<(), ChatError> { + /// Drain the acknowledgements observed since the last call: peers that + /// referenced one of our messages, and so demonstrably hold it. + pub fn take_acks(&self) -> Vec { + self.services.causal.take_acks() + } + + /// Encrypt and publish `content` to an existing conversation, returning the + /// id assigned to the message so later acknowledgements can be matched to + /// it. + pub fn send_content(&mut self, convo_id: &str, content: &[u8]) -> Result { if self.cached_convos.contains_key(convo_id) { let convo = self .cached_convos @@ -508,7 +516,7 @@ impl Convo for ConvoTypeOwned { &mut self, cx: &mut ServiceContext, content: &[u8], - ) -> Result<(), ChatError> { + ) -> Result { match self { ConvoTypeOwned::Group(group_convo) => group_convo.send_content(cx, content), ConvoTypeOwned::Direct(convo) => convo.send_content(cx, content), diff --git a/core/conversations/src/lib.rs b/core/conversations/src/lib.rs index 4dadf8d..ca319fb 100644 --- a/core/conversations/src/lib.rs +++ b/core/conversations/src/lib.rs @@ -10,10 +10,10 @@ mod service_traits; mod types; mod utils; -pub use causal_history::{Frontier, MissingMessage}; +pub use causal_history::{DeliveryAck, Frontier, MissingMessage}; pub use chat_sqlite::ChatStorage; pub use chat_sqlite::StorageConfig; -pub use conversation::GroupV2Clock; +pub use conversation::{GroupV2Clock, MessageId}; pub use core::{ConversationId, Core}; /// Timing/policy for GroupV2 conversations (de-mls's per-conversation config). /// Defaults to the de-mls library defaults; inject via diff --git a/core/integration_tests_core/src/test_client.rs b/core/integration_tests_core/src/test_client.rs index bf95b15..2c5a0e1 100644 --- a/core/integration_tests_core/src/test_client.rs +++ b/core/integration_tests_core/src/test_client.rs @@ -84,6 +84,13 @@ impl TestClient { outcomes } + /// Poll and discard every payload waiting for this client — simulates + /// frames the transport never delivered. + pub fn drop_pending_payloads(&mut self) { + let ds = self.inner.ds(); + while ds.poll().is_some() {} + } + pub fn received_messages(&self) -> &[ReceivedMessage>] { &self.received_messages } diff --git a/core/integration_tests_core/tests/test_group_v2.rs b/core/integration_tests_core/tests/test_group_v2.rs index acfe88a..ef672d3 100644 --- a/core/integration_tests_core/tests/test_group_v2.rs +++ b/core/integration_tests_core/tests/test_group_v2.rs @@ -1,4 +1,5 @@ use integration_tests_core::TestHarness; +use libchat::{DeliveryAck, MissingMessage}; use tracing::info; #[test] @@ -310,3 +311,145 @@ fn direct_v1_then_group_v2_reuses_key_package() { "raya did not join the group after a direct chat" ); } + +/// End-to-end causal-history gap detection on GroupV2. +/// +/// Saro and Raya share a GroupV2 conversation. Saro sends three messages; the +/// second never reaches Raya. The third carries the second in its causal +/// history, so Raya must detect and report the gap. +#[test] +fn missing_group_v2_message_is_detected() { + let mut harness = TestHarness::<2>::new(|_, _| {}); + + let participants = &[&harness.raya().addr()]; + let convo_id = harness + .saro() + .create_group_convo_v2(participants, "", "") + .expect("saro create group"); + + // Carry the invite through (commit, WelcomeReady, inbox routing, welcome + // accept) until Raya has joined. + harness.process_until_label("raya joins", |h| h.raya().convo_count() == 1); + + // M1 is delivered normally. + harness + .saro() + .send_content(&convo_id, b"first") + .expect("saro send m1"); + harness.process_until_label("raya gets m1", |h| h.raya().check(&convo_id, b"first")); + assert!( + harness.raya().take_missing_messages().is_empty(), + "no gap expected while every message is delivered" + ); + + // M2 is published but never reaches Raya. The settle above drained Raya's + // queue, and `send_content` publishes synchronously, so discarding what is + // pending now drops that message and nothing of the group protocol. + harness + .saro() + .send_content(&convo_id, b"second") + .expect("saro send m2"); + harness.raya().drop_pending_payloads(); + + // M3 is delivered; its causal history references the dropped M2. + harness + .saro() + .send_content(&convo_id, b"third") + .expect("saro send m3"); + harness.process_until_label("raya gets m3", |h| h.raya().check(&convo_id, b"third")); + + let missing: Vec = harness.raya().take_missing_messages(); + assert_eq!(missing.len(), 1, "exactly one message should be missing"); + assert_eq!(missing[0].conversation_id, convo_id); + assert!( + !missing[0].frontier.message_id().is_empty(), + "the missing message must be identified" + ); + // The causal sender hint carries the MLS identity id ("saro") — the same + // value de-mls stamps as the message's authenticated member id — not the + // signer id the inbox and registry key on. + assert_eq!( + missing[0].frontier.sender_id(), + "saro", + "missing-message sender hint should attribute to Saro" + ); + + // Draining clears the report; a reported gap is not surfaced again. + assert!(harness.raya().take_missing_messages().is_empty()); +} + +/// End-to-end acknowledgement detection on GroupV2. +/// +/// Saro sends a message; Raya and Pax reply. Each reply carries Saro's message +/// in its causal history, so Saro learns both peers hold it — without either +/// sending anything back on purpose. +#[test] +fn replies_acknowledge_the_message_they_were_sent_after() { + let mut harness = TestHarness::<3>::new(|_, _| {}); + + let participants = &[&harness.raya().addr(), &harness.pax().addr()]; + let convo_id = harness + .saro() + .create_group_convo_v2(participants, "", "") + .expect("saro create group"); + + harness.process_until_label("peers join", |h| { + h.raya().convo_count() == 1 && h.pax().convo_count() == 1 + }); + + let message_id = harness + .saro() + .send_content(&convo_id, b"anyone there?") + .expect("saro send"); + harness.process_until_label("peers get the message", |h| { + h.raya().check(&convo_id, b"anyone there?") && h.pax().check(&convo_id, b"anyone there?") + }); + assert!( + harness.saro().take_acks().is_empty(), + "holding a message is only observable once the peer sends" + ); + + // Each reply names Saro's message in its causal history. + harness + .raya() + .send_content(&convo_id, b"raya here") + .expect("raya reply"); + harness + .pax() + .send_content(&convo_id, b"pax here") + .expect("pax reply"); + harness.process_until_label("saro gets both replies", |h| { + h.saro().check(&convo_id, b"raya here") && h.saro().check(&convo_id, b"pax here") + }); + + let acks: Vec = harness.saro().take_acks(); + let mut holders: Vec<&str> = acks + .iter() + .filter(|a| a.conversation_id == convo_id && a.message_id == message_id) + .map(|a| a.acked_by.as_str()) + .collect(); + holders.sort_unstable(); + assert_eq!( + holders, + vec!["pax", "raya"], + "both peers that replied should be reported as holding the message" + ); + + // Draining clears the reports, and neither peer acknowledges twice. + assert!(harness.saro().take_acks().is_empty()); + harness + .raya() + .send_content(&convo_id, b"raya again") + .expect("raya second reply"); + harness.process_until_label("saro gets the second reply", |h| { + h.saro().check(&convo_id, b"raya again") + }); + assert!( + harness + .saro() + .take_acks() + .iter() + .all(|a| a.message_id != message_id), + "a peer acknowledges one message only once" + ); +} diff --git a/crates/generic-chat/src/client.rs b/crates/generic-chat/src/client.rs index f4e0df4..27006ad 100644 --- a/crates/generic-chat/src/client.rs +++ b/crates/generic-chat/src/client.rs @@ -6,8 +6,9 @@ use components::{ThreadedWakeupService, WakeupEvent}; use crossbeam_channel::{Receiver, Sender, select}; use crypto::Ed25519VerifyingKey; use libchat::{ - ConversationId, ConvoMetadata, ConvoOutcome, Core, DeliveryService, GroupV2Config, IdentId, - IdentIdRef, InboxOutcome, PayloadOutcome, RegistrationService, + ConversationId, ConvoMetadata, ConvoOutcome, Core, DeliveryAck, DeliveryService, GroupV2Config, + IdentId, IdentIdRef, InboxOutcome, MessageId, MissingMessage, PayloadOutcome, + RegistrationService, }; use logos_account::{AccountDirectory, resolve_device_ids}; use parking_lot::Mutex; @@ -275,7 +276,14 @@ where /// Encrypt and send `content` to an existing conversation. The core /// publishes the outbound envelope. - pub fn send_message(&mut self, convo_id: &str, content: &[u8]) -> Result<(), ClientError> { + /// + /// Returns the message's id, which later [`Event::MessageAcked`] events + /// carry — hold onto it to show which peers have the message. + pub fn send_message( + &mut self, + convo_id: &str, + content: &[u8], + ) -> Result { self.core .lock() .send_content(convo_id, content) @@ -347,7 +355,7 @@ fn worker_loop( }; let events = { let mut core = core.lock(); - match core.handle_payload(&bytes) { + let mut events = match core.handle_payload(&bytes) { Ok(outcome) => events_from_inbound(outcome, &directory), Err(e) => { tracing::warn!("inbound handle_payload failed: {e:?}"); @@ -355,7 +363,10 @@ fn worker_loop( message: e.to_string(), }] } - } + }; + events.extend(delivery_ack_events(core.take_acks(), &directory)); + events.extend(missing_events(core.take_missing_messages(), &directory)); + events }; for event in events { if event_tx.send(event).is_err() { @@ -368,12 +379,18 @@ fn worker_loop( return; // wakeup service's sender dropped }; // A wakeup can drive the steward's own commit, so it yields events too. - let events = match core.lock().wakeup(&convo_id) { - Ok(outcome) => events_from_inbound(outcome, &directory), - Err(e) => { - tracing::warn!("wakeup failed: {e:?}"); - Vec::new() - } + let events = { + let mut core = core.lock(); + let mut events = match core.wakeup(&convo_id) { + Ok(outcome) => events_from_inbound(outcome, &directory), + Err(e) => { + tracing::warn!("wakeup failed: {e:?}"); + Vec::new() + } + }; + events.extend(delivery_ack_events(core.take_acks(), &directory)); + events.extend(missing_events(core.take_missing_messages(), &directory)); + events }; for event in events { if event_tx.send(event).is_err() { @@ -398,6 +415,57 @@ fn events_from_inbound(result: PayloadOutcome, directory: &impl AccountDirectory } } +/// Map the acknowledgements the core observed while processing one payload onto +/// [`Event::MessageAcked`], one per peer per message. +/// +/// Drained from the same place as [`missing_events`]: the causal history of the +/// message just processed is what carried the acknowledgement. +fn delivery_ack_events(acks: Vec, directory: &impl AccountDirectory) -> Vec { + acks.into_iter() + .map(|a| Event::MessageAcked { + convo_id: Arc::from(a.conversation_id), + message_id: a.message_id, + acked_by: sender_hint(directory, &a.acked_by), + }) + .collect() +} + +/// Map the causal-history gaps the core detected while processing one payload +/// onto [`Event::MessageMissing`]. +/// +/// Drained right after each drive of the core, so a gap arrives with the batch +/// of events for the message that revealed it — and after them, so a gap on a +/// conversation this payload just started still follows its +/// [`Event::ConversationStarted`]. +fn missing_events(missing: Vec, directory: &impl AccountDirectory) -> Vec { + missing + .into_iter() + .map(|m| Event::MessageMissing { + convo_id: Arc::from(m.conversation_id), + message_id: m.frontier.message_id().to_owned(), + sender_hint: sender_hint(directory, m.frontier.sender_id()), + }) + .collect() +} + +/// Resolve a participant a causal-history observation named — the author of a +/// message we never saw, or the peer acknowledging one of ours. +/// +/// Same credential decoding as a delivered message's sender, but the claim is +/// self-asserted rather than authenticated, so an unconfirmable account yields +/// the device alone rather than dropping the observation. `None` when the value +/// is not a credential at all. +fn sender_hint(directory: &impl AccountDirectory, encoded: &str) -> Option { + let (device, claim) = parse_credential(directory, encoded.as_bytes()).ok()?; + Some(MessageSender { + account: match claim { + AccountClaim::Verified(account) => Some(account), + AccountClaim::None | AccountClaim::Unverified(_) => None, + }, + local_identity: device, + }) +} + /// Interpret a hex account address as an Ed25519 account verifying key. fn account_key_from_hex(addr: &str) -> Option { let bytes: [u8; 32] = hex::decode(addr).ok()?.try_into().ok()?; @@ -601,10 +669,11 @@ mod sender_check_tests { use logos_account::{DeviceSet, SignedDeviceBundle}; use super::{ - GroupMember, MessageSender, SenderError, decode_sender, dedup_members, member_key, - roster_member, + Event, GroupMember, MessageSender, SenderError, decode_sender, dedup_members, + delivery_ack_events, member_key, missing_events, roster_member, }; use crate::delegate::DelegateCredential; + use libchat::{DeliveryAck, Frontier, MissingMessage}; /// In-test account → device directory. Holds device id sets keyed by the hex /// account key, and can be made to fail to simulate a directory outage. @@ -913,4 +982,112 @@ mod sender_check_tests { vec![committed] ); } + + /// A gap reported by the causal history, as the core hands it over: the + /// sender hint travels in the same encoding a message's credential does. + fn gap(sender_hint: &str) -> MissingMessage { + MissingMessage { + conversation_id: "convo".to_owned(), + frontier: Frontier::new(sender_hint.to_owned(), "msg-id".to_owned()), + } + } + + fn hex_cred(cred: DelegateCredential) -> String { + hex::encode(cred.serialize()) + } + + /// Unwrap the single `MessageMissing` a one-gap batch produces. + fn only_missing(events: Vec) -> (String, Option) { + match <[Event; 1]>::try_from(events) + .expect("one gap produces one event") + .into_iter() + .next() + .unwrap() + { + Event::MessageMissing { + convo_id, + message_id, + sender_hint, + } => { + assert_eq!(&*convo_id, "convo"); + (message_id, sender_hint) + } + other => panic!("expected MessageMissing, got {other:?}"), + } + } + + /// An account claim the directory contradicts drops a *delivered* message, + /// but a gap is still worth reporting: the hint keeps the device and + /// forgoes the account, since nothing about an unseen message is verifiable + /// anyway. + #[test] + fn missing_message_hint_keeps_the_device_when_the_account_claim_fails() { + let account = key(); + let endorsed = key(); + let spoofer = key(); + let dir = FakeDir::with_devices(&account, &[&endorsed]); + let cred = DelegateCredential::associated(&spoofer, &hex::encode(account.as_ref())); + + let (_, sender) = only_missing(missing_events(vec![gap(&hex_cred(cred))], &dir)); + assert_eq!( + sender, + Some(MessageSender { + account: None, + local_identity: local_id(&spoofer), + }) + ); + } + + /// A hint that is not a credential at all still reports the gap — the + /// message id is the part the application needs. + #[test] + fn missing_message_without_a_resolvable_hint_is_still_reported() { + let (message_id, sender) = + only_missing(missing_events(vec![gap("saro")], &FakeDir::default())); + assert_eq!(message_id, "msg-id"); + assert_eq!(sender, None); + } + + /// One acknowledgement per peer per message, each naming the peer an + /// application would list against the message. + #[test] + fn acks_name_the_peers_that_hold_the_message() { + let account = key(); + let device = key(); + let dir = FakeDir::with_devices(&account, &[&device]); + let peer = DelegateCredential::associated(&device, &hex::encode(account.as_ref())); + + let events = delivery_ack_events( + vec![DeliveryAck { + conversation_id: "convo".to_owned(), + message_id: "msg-id".to_owned(), + acked_by: hex_cred(peer), + }], + &dir, + ); + + match <[Event; 1]>::try_from(events) + .expect("one ack produces one event") + .into_iter() + .next() + .unwrap() + { + Event::MessageAcked { + convo_id, + message_id, + acked_by, + } => { + assert_eq!(&*convo_id, "convo"); + assert_eq!(message_id, "msg-id"); + assert_eq!( + acked_by, + Some(MessageSender { + account: Some(local_id(&account)), + local_identity: local_id(&device), + }) + ); + } + other => panic!("expected MessageAcked, got {other:?}"), + } + } } diff --git a/crates/generic-chat/src/event.rs b/crates/generic-chat/src/event.rs index 4618e02..5e2e4a8 100644 --- a/crates/generic-chat/src/event.rs +++ b/crates/generic-chat/src/event.rs @@ -38,6 +38,36 @@ pub enum Event { content: Vec, sender: MessageSender, }, + /// A peer acknowledged a message this client sent: it referenced that + /// message in the causal history of a message of its own, so it held ours + /// when it sent. `message_id` is the id the send returned. + /// + /// Evidence of delivery to the peer's client, not of a human reading it. + /// The acknowledgement is passive — nothing is sent back on purpose — so a + /// peer that never sends never acknowledges, and an application should + /// treat the absence of one as "not confirmed" rather than "not delivered". + /// + /// `acked_by` is resolved from the peer's self-asserted `sender_id` and is + /// **not authenticated**; see [`Self::MessageMissing`]'s `sender_hint`. + /// `None` when it could not be resolved to a device. + MessageAcked { + convo_id: Arc, + message_id: String, + acked_by: Option, + }, + /// A message this client never received, revealed by the causal history of + /// one that did arrive. Detection only — nothing is fetched or replayed, + /// and the gap is reported once. + /// + /// `sender_hint` is the author the *referencing* peer named, resolved the + /// same way as [`Self::MessageReceived`]'s sender but **not authenticated**: + /// nothing about a message we never saw can be verified, so treat it as a + /// display hint. `None` when the hint could not be resolved to a device. + MessageMissing { + convo_id: Arc, + message_id: String, + sender_hint: Option, + }, /// A commit changed a conversation's membership. ConversationMembersChanged { convo_id: Arc, diff --git a/crates/generic-chat/src/lib.rs b/crates/generic-chat/src/lib.rs index 91d1318..7156ca7 100644 --- a/crates/generic-chat/src/lib.rs +++ b/crates/generic-chat/src/lib.rs @@ -15,7 +15,8 @@ pub use event::{Event, MessageSender}; // Re-export types callers need to interact with ChatClient. pub use libchat::{ AddressedEnvelope, ChatStore, ConversationClass, ConversationId, ConvoMetadata, - DeliveryService, GroupV2Config, IdentityProvider, RegistrationService, StorageConfig, + DeliveryService, GroupV2Config, IdentityProvider, MessageId, RegistrationService, + StorageConfig, }; // The directory trait bounds ChatClient's registry parameter, so callers // writing code generic over ChatClient need it too. diff --git a/crates/generic-chat/tests/group_v2.rs b/crates/generic-chat/tests/group_v2.rs index 9ace11e..447f70a 100644 --- a/crates/generic-chat/tests/group_v2.rs +++ b/crates/generic-chat/tests/group_v2.rs @@ -479,3 +479,63 @@ fn group_metadata_defaults_to_empty() { assert_eq!(meta.name, ""); assert_eq!(meta.desc, ""); } + +/// The peers that hold a sent message surface as `MessageAcked` events keyed by +/// the id the send returned — what an application needs to list the peers that +/// hold a message. The acknowledgement is passive: Raya and Pax only send +/// ordinary replies, never a receipt. +#[test] +fn a_sent_message_is_acknowledged_by_the_peers_that_reply() { + let bus = MessageBus::default(); + let reg = EphemeralRegistry::new(); + + let (mut saro, saro_events, saro_addr) = create_test_client(bus.clone(), reg.clone()); + let (mut raya, raya_events, raya_addr) = create_test_client(bus.clone(), reg.clone()); + let (mut pax, pax_events, pax_addr) = create_test_client(bus.clone(), reg.clone()); + + let convo_id = saro + .create_group_conversation(&[&raya_addr, &pax_addr], unnamed_group()) + .expect("saro create group"); + wait_for_group_started(&raya_events, "raya ConversationStarted"); + wait_for_group_started(&pax_events, "pax ConversationStarted"); + wait_for_members(&mut saro, &convo_id, &[&saro_addr, &raya_addr, &pax_addr]); + + let message_id = saro + .send_message(&convo_id, b"anyone there?") + .expect("saro send"); + wait_for_message(&raya_events, b"anyone there?"); + wait_for_message(&pax_events, b"anyone there?"); + + // Ordinary replies; their causal history carries the acknowledgement. + raya.send_message(&convo_id, b"raya here") + .expect("raya reply"); + pax.send_message(&convo_id, b"pax here").expect("pax reply"); + + let mut holders = Vec::new(); + while holders.len() < 2 { + let peer = wait_for_event( + &saro_events, + "saro MessageAcked", + Duration::from_secs(10), + |e| match e { + Event::MessageAcked { + convo_id: id, + message_id: acked, + acked_by, + } if **id == *convo_id && *acked == message_id => Some( + acked_by + .as_ref() + .and_then(|a| a.account.as_ref()) + .map(|a| a.as_str().to_string()), + ), + _ => None, + }, + ); + holders.push(peer.expect("the acknowledging peer's account should be directory-verified")); + } + holders.sort(); + + let mut expected = vec![raya_addr.clone(), pax_addr.clone()]; + expected.sort(); + assert_eq!(holders, expected, "both replying peers should be listed"); +} diff --git a/crates/generic-chat/tests/saro_and_raya.rs b/crates/generic-chat/tests/saro_and_raya.rs index 4c7f0d5..09f3ae7 100644 --- a/crates/generic-chat/tests/saro_and_raya.rs +++ b/crates/generic-chat/tests/saro_and_raya.rs @@ -54,6 +54,25 @@ where f(event).unwrap_or_else(|other| panic!("expected {label}, got {other:?}")) } +/// [`expect_event`] for a back-and-forth exchange, skipping acknowledgements. +/// +/// Each reply acknowledges the message it was sent after, so `MessageAcked` +/// lands at points a test driving one direction at a time does not control. +fn expect_event_ignoring_acks(events: &Receiver, label: &str, mut f: F) -> T +where + F: FnMut(Event) -> Result, +{ + loop { + let event = events + .recv_timeout(Duration::from_secs(5)) + .unwrap_or_else(|_| panic!("timed out waiting for {label}")); + if matches!(event, Event::MessageAcked { .. }) { + continue; + } + return f(event).unwrap_or_else(|other| panic!("expected {label}, got {other:?}")); + } +} + #[test] fn direct_v1_integration() { let bus = MessageBus::default(); @@ -255,7 +274,7 @@ fn saro_raya_message_exchange() { for i in 0u8..5 { let msg = format!("msg {i}"); saro.send_message(&saro_convo_id, msg.as_bytes()).unwrap(); - expect_event( + expect_event_ignoring_acks( &raya_events, &format!("MessageReceived(msg {i})"), |e| match e { @@ -269,7 +288,7 @@ fn saro_raya_message_exchange() { let reply = format!("reply {i}"); raya.send_message(&raya_convo_id, reply.as_bytes()).unwrap(); - expect_event( + expect_event_ignoring_acks( &saro_events, &format!("MessageReceived(reply {i})"), |e| match e { From fbc8b7741f62b72864ab404711d3468759e6311c Mon Sep 17 00:00:00 2001 From: Mojtaba Chenani Date: Tue, 18 Aug 2026 07:08:45 +0200 Subject: [PATCH 3/8] feat: rename chat-cli's /intro command to /account (#205) --- bin/chat-cli/README.md | 8 ++++---- bin/chat-cli/src/app.rs | 12 ++++++------ core/conversations/src/conversation/group_v1.rs | 4 ++-- core/conversations/src/inbox_v2/identity.rs | 6 +++--- 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/bin/chat-cli/README.md b/bin/chat-cli/README.md index 4d88ec5..b3800d0 100644 --- a/bin/chat-cli/README.md +++ b/bin/chat-cli/README.md @@ -54,8 +54,8 @@ cargo run -p chat-cli -- --name bob --transport file ### Establishing a connection -1. In Alice's terminal, type `/intro` — the bundle is copied to your clipboard automatically. -2. In Bob's terminal, type `/connect `. +1. In Alice's terminal, type `/account` — her address is copied to the clipboard automatically. +2. In Bob's terminal, type `/connect `. 3. Bob's "Hello!" message appears in Alice's terminal. Both can now chat. ### Optional: KeyPackage registry @@ -97,8 +97,8 @@ The registry is a throwaway testnet helper; v0.3 replaces it with a | Command | Description | |---------|-------------| | `/help` | Show available commands | -| `/intro` | Generate your introduction bundle (copies to clipboard) | -| `/connect ` | Connect to a user using their introduction bundle | +| `/account` | Show your account address (copies to clipboard) | +| `/connect
` | Connect to a user using their address | | `/chats` | List all established chats | | `/switch ` | Switch active chat | | `/delete ` | Delete a chat session | diff --git a/bin/chat-cli/src/app.rs b/bin/chat-cli/src/app.rs index 7499181..570e35b 100644 --- a/bin/chat-cli/src/app.rs +++ b/bin/chat-cli/src/app.rs @@ -300,7 +300,7 @@ where match command { "/help" => { self.add_system_message("── Commands ──"); - self.add_system_message("/intro - Show your address"); + self.add_system_message("/account - Show your account address"); self.add_system_message("/connect
- Connect using an address"); self.add_system_message("/nickname - Name the active chat"); self.add_system_message("/chats - List all chats"); @@ -311,17 +311,17 @@ where self.add_system_message("/quit or Esc or Ctrl+C - Exit"); Ok(Some("Help displayed".to_string())) } - "/intro" => { + "/account" => { let address = self.client.addr().to_string(); - self.add_system_message("── Your Address ──"); + self.add_system_message("── Your Account Address ──"); self.add_system_message(&address); let clipboard_msg = match Clipboard::new().and_then(|mut cb| cb.set_text(&address)) { - Ok(()) => "Address copied to clipboard! Share it, then /connect their address.", - Err(_) => "Share this address with others to connect!", + Ok(()) => "Address copied to clipboard. Share it so others can reach you.", + Err(_) => "Share this address so others can reach you.", }; self.add_system_message(clipboard_msg); - Ok(Some("Address shown".to_string())) + Ok(Some("Account address shown".to_string())) } "/connect" => { if args.is_empty() { diff --git a/core/conversations/src/conversation/group_v1.rs b/core/conversations/src/conversation/group_v1.rs index 6c8fd22..2162b85 100644 --- a/core/conversations/src/conversation/group_v1.rs +++ b/core/conversations/src/conversation/group_v1.rs @@ -192,7 +192,7 @@ impl GroupV1Convo { Ok(reliable.message_id) } - // Publish outboubound payloads to the DeliveryService + // Publish outbound payloads to the DeliveryService fn send_payload( &mut self, cx: &mut ServiceContext, @@ -330,7 +330,7 @@ impl GroupConvo for GroupV1Convo { members: &[IdentIdRef], ) -> Result<(), ChatError> { if members.len() > 50 { - // This is a temporary limit that originates from the the De-MLS epoch time. + // This is a temporary limit that originates from the De-MLS epoch time. return Err(ChatError::Protocol( "Cannot add more than 50 Members at a time".into(), )); diff --git a/core/conversations/src/inbox_v2/identity.rs b/core/conversations/src/inbox_v2/identity.rs index 7691cc4..0964c26 100644 --- a/core/conversations/src/inbox_v2/identity.rs +++ b/core/conversations/src/inbox_v2/identity.rs @@ -11,8 +11,8 @@ use crate::IdentityProvider; /// A Wrapper for an IdentityProvider which provides MLS specific functionality /// -/// This type stops OpenMLS internal from leaking outside of the crate. -/// Developers provider a simple IdentitityProvider, and Signer and Credential generation +/// This type stops OpenMLS internal from leaking outside the crate. +/// Developers provider a simple IdentityProvider, and Signer and Credential generation /// is provided pub struct MlsIdentityProvider(T); @@ -55,7 +55,7 @@ impl IdentityProvider for MlsIdentityProvider { } } -// Implement Signer directly for MlsIdentityProvider, so that openmls Signer contstraint +// Implement Signer directly for MlsIdentityProvider, so that openmls Signer constraint // does not leave the module. impl Signer for MlsIdentityProvider { fn sign(&self, payload: &[u8]) -> Result, SignerError> { From fa38c472815fcf6ff48db8da3c58ddc996bfbb5b Mon Sep 17 00:00:00 2001 From: Mojtaba Chenani Date: Tue, 18 Aug 2026 19:06:22 +0200 Subject: [PATCH 4/8] chore: add cargo-deny for supply-chain, license, and advisory linting (#215) --- .github/workflows/ci.yml | 10 ++ Cargo.lock | 99 ++++++++++++++++--- Cargo.toml | 3 + bin/chat-cli/Cargo.toml | 1 + core/account/Cargo.toml | 1 + core/conversations/Cargo.toml | 1 + core/crypto/Cargo.toml | 1 + core/double-ratchets/Cargo.toml | 1 + core/integration_tests_core/Cargo.toml | 1 + core/shared-traits/Cargo.toml | 1 + core/sqlite/Cargo.toml | 1 + core/storage/Cargo.toml | 1 + crates/generic-chat/Cargo.toml | 1 + crates/logos-chat/Cargo.toml | 1 + deny.toml | 61 ++++++++++++ extensions/components/Cargo.toml | 1 + extensions/embedded-logos-delivery/Cargo.toml | 1 + extensions/logos-delivery-rust/Cargo.toml | 1 + 18 files changed, 175 insertions(+), 12 deletions(-) create mode 100644 deny.toml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 47e2704..e30708a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,6 +47,16 @@ jobs: - run: rustup component add rustfmt - run: cargo fmt --all -- --check + cargo-deny: + name: Cargo Deny + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + # Reads the dependency graph only, so no protoc/liblogosdelivery needed. + - uses: EmbarkStudios/cargo-deny-action@v2 + with: + command: check + smoketest: name: Smoketest environment: ${{ github.ref == 'refs/heads/main' && 'public-cache' || '' }} diff --git a/Cargo.lock b/Cargo.lock index 081efb6..c93bad7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -754,9 +754,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "arboard" @@ -836,6 +836,23 @@ dependencies = [ "zeroize", ] +[[package]] +name = "ark-ff" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7a806ac6c8307b929df4645776290a50ee2aac754ad09d8bdf73391309e43af" +dependencies = [ + "ark-ff-asm 0.6.0", + "ark-ff-macros 0.6.0", + "ark-serialize 0.6.0", + "ark-std 0.6.0", + "digest 0.10.7", + "educe", + "num-bigint", + "num-traits", + "zeroize", +] + [[package]] name = "ark-ff-asm" version = "0.3.0" @@ -866,6 +883,16 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "ark-ff-asm" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1479009684adc073dff49a1025d3a7065b317a9ead25aaaca38cdc70058ba8a2" +dependencies = [ + "quote", + "syn 2.0.117", +] + [[package]] name = "ark-ff-macros" version = "0.3.0" @@ -904,6 +931,19 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "ark-ff-macros" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a0691ed21ef00ef89c1e9bda832eba493dda3ec2f8d892fb25b705f73f06bb8" +dependencies = [ + "num-bigint", + "num-traits", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "ark-serialize" version = "0.3.0" @@ -937,6 +977,30 @@ dependencies = [ "num-bigint", ] +[[package]] +name = "ark-serialize" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a74dd304fd536fb95d0a328e72be759209cc496a9da094c5bc56e5fea4f9e86b" +dependencies = [ + "ark-serialize-derive", + "ark-std 0.6.0", + "digest 0.10.7", + "num-bigint", + "serde_with", +] + +[[package]] +name = "ark-serialize-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f153690697a2b91e5e1251ff98411ee5371500a111a0fd317a70e588eb300f9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "ark-std" version = "0.3.0" @@ -967,6 +1031,16 @@ dependencies = [ "rand 0.8.6", ] +[[package]] +name = "ark-std" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "367c9c827ed431bff6868b7aa926e05b16eb46603cc8b6e768e4a5553fa1d155" +dependencies = [ + "num-traits", + "rand 0.8.6", +] + [[package]] name = "arrayvec" version = "0.7.6" @@ -1632,9 +1706,9 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.18" +version = "0.9.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" dependencies = [ "crossbeam-utils", ] @@ -2132,7 +2206,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -3760,7 +3834,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4958,14 +5032,15 @@ dependencies = [ [[package]] name = "ruint" -version = "1.18.0" +version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0298da754d1395046b0afdc2f20ee76d29a8ae310cd30ffa84ed42acba9cb12a" +checksum = "f5e99bff0393163bb25029a6af25d3d8d202ba5b5438a74d1bd8789f5c822970" dependencies = [ "alloy-rlp", "ark-ff 0.3.0", "ark-ff 0.4.2", "ark-ff 0.5.0", + "ark-ff 0.6.0", "bytes", "fastrlp 0.3.1", "fastrlp 0.4.0", @@ -5057,7 +5132,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -5115,7 +5190,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -5725,7 +5800,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -6423,7 +6498,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 47d84ea..5a5510e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,9 @@ default-members = [ "crates/generic-chat", ] +[workspace.package] +license = "MIT OR Apache-2.0" + [workspace.dependencies] # Internal Workspace dependency declarations (sorted) logos-account = { path = "core/account" } diff --git a/bin/chat-cli/Cargo.toml b/bin/chat-cli/Cargo.toml index 71da08b..3e162a5 100644 --- a/bin/chat-cli/Cargo.toml +++ b/bin/chat-cli/Cargo.toml @@ -2,6 +2,7 @@ name = "chat-cli" version = "0.1.0" edition = "2024" +license.workspace = true [[bin]] name = "chat-cli" diff --git a/core/account/Cargo.toml b/core/account/Cargo.toml index 17db547..cde8530 100644 --- a/core/account/Cargo.toml +++ b/core/account/Cargo.toml @@ -2,6 +2,7 @@ name = "logos-account" version = "0.1.0" edition = "2024" +license.workspace = true [features] dev = [] diff --git a/core/conversations/Cargo.toml b/core/conversations/Cargo.toml index 13754a9..61742b5 100644 --- a/core/conversations/Cargo.toml +++ b/core/conversations/Cargo.toml @@ -2,6 +2,7 @@ name = "libchat" version = "0.1.0" edition = "2024" +license.workspace = true [lib] crate-type = ["rlib"] diff --git a/core/crypto/Cargo.toml b/core/crypto/Cargo.toml index e5a0755..7fca144 100644 --- a/core/crypto/Cargo.toml +++ b/core/crypto/Cargo.toml @@ -2,6 +2,7 @@ name = "crypto" version = "0.1.0" edition = "2024" +license.workspace = true [dependencies] # External dependencies (sorted) diff --git a/core/double-ratchets/Cargo.toml b/core/double-ratchets/Cargo.toml index 3e3091d..8acf802 100644 --- a/core/double-ratchets/Cargo.toml +++ b/core/double-ratchets/Cargo.toml @@ -2,6 +2,7 @@ name = "double-ratchets" version = "0.0.1" edition = "2024" +license.workspace = true [lib] crate-type = ["rlib"] diff --git a/core/integration_tests_core/Cargo.toml b/core/integration_tests_core/Cargo.toml index 462b701..a9d1409 100644 --- a/core/integration_tests_core/Cargo.toml +++ b/core/integration_tests_core/Cargo.toml @@ -2,6 +2,7 @@ name = "integration_tests_core" version = "0.1.0" edition = "2024" +license.workspace = true # [[test]] # name = "integration_tests_core" diff --git a/core/shared-traits/Cargo.toml b/core/shared-traits/Cargo.toml index ada17dc..174e534 100644 --- a/core/shared-traits/Cargo.toml +++ b/core/shared-traits/Cargo.toml @@ -3,6 +3,7 @@ name = "shared-traits" description = "Shared traits for the Logos Ecosystem" version = "0.1.0" edition = "2024" +license.workspace = true [dependencies] crypto = { workspace = true } diff --git a/core/sqlite/Cargo.toml b/core/sqlite/Cargo.toml index 4fabf33..684ab54 100644 --- a/core/sqlite/Cargo.toml +++ b/core/sqlite/Cargo.toml @@ -2,6 +2,7 @@ name = "chat-sqlite" version = "0.1.0" edition = "2024" +license.workspace = true description = "SQLite storage implementation for libchat" [dependencies] diff --git a/core/storage/Cargo.toml b/core/storage/Cargo.toml index 0c0775c..613b397 100644 --- a/core/storage/Cargo.toml +++ b/core/storage/Cargo.toml @@ -2,6 +2,7 @@ name = "storage" version = "0.1.0" edition = "2024" +license.workspace = true description = "Shared storage layer for libchat" [dependencies] diff --git a/crates/generic-chat/Cargo.toml b/crates/generic-chat/Cargo.toml index beb802d..bfd3ede 100644 --- a/crates/generic-chat/Cargo.toml +++ b/crates/generic-chat/Cargo.toml @@ -2,6 +2,7 @@ name = "logos-generic-chat" version = "0.1.0" edition = "2024" +license.workspace = true [lib] crate-type = ["rlib"] diff --git a/crates/logos-chat/Cargo.toml b/crates/logos-chat/Cargo.toml index 63a8af6..6c0430d 100644 --- a/crates/logos-chat/Cargo.toml +++ b/crates/logos-chat/Cargo.toml @@ -2,6 +2,7 @@ name = "logos-chat" version = "0.1.0" edition = "2024" +license.workspace = true [lib] crate-type = ["rlib"] diff --git a/deny.toml b/deny.toml new file mode 100644 index 0000000..f6af2b1 --- /dev/null +++ b/deny.toml @@ -0,0 +1,61 @@ +# cargo-deny configuration — run with: cargo deny check + +[graph] +all-features = true + +[advisories] +version = 2 +# libcrux crypto crates, exact-pinned (0.0.x) via openmls_libcrux_crypto 0.3.1. +# The fix only exists on the openmls 0.9 prerelease line, which de-mls does not +# support yet — revisit once openmls 0.9 is stable. +ignore = [ + "RUSTSEC-2026-0073", # libcrux-poly1305 + "RUSTSEC-2026-0075", # libcrux-ed25519 + "RUSTSEC-2026-0124", # libcrux-chacha20poly1305 + "RUSTSEC-2026-0207", # libcrux-sha3 + "RUSTSEC-2026-0208", # libcrux-sha3 + "RUSTSEC-2026-0212", # libcrux-secrets + "RUSTSEC-2026-0209", # libcrux-aesgcm — AES-GCM, unused by our ciphersuite + "RUSTSEC-2026-0211", # libcrux-aesgcm — AES-GCM, unused by our ciphersuite + "RUSTSEC-2026-0210", # libcrux-aesgcm renamed + "RUSTSEC-2024-0436", # paste, unmaintained + "RUSTSEC-2026-0173", # proc-macro-error2, unmaintained +] + +[licenses] +version = 2 +allow = [ + "MIT", + "Apache-2.0", + "Apache-2.0 WITH LLVM-exception", + "BSD-2-Clause", + "BSD-3-Clause", + "ISC", + "Zlib", + "Unicode-3.0", + "Unicode-DFS-2016", + "CC0-1.0", + "Unlicense", + "BSL-1.0", + "MPL-2.0", + "CDLA-Permissive-2.0", +] +confidence-threshold = 0.8 + +# chat-proto ships no license field yet; treat it as the workspace license. +[[licenses.clarify]] +crate = "chat-proto" +expression = "MIT OR Apache-2.0" +license-files = [] + +[bans] +multiple-versions = "warn" +wildcards = "allow" + +[sources] +unknown-registry = "deny" +unknown-git = "deny" +allow-git = [ + "https://github.com/logos-messaging/chat_proto", + "https://github.com/vacp2p/de-mls", +] diff --git a/extensions/components/Cargo.toml b/extensions/components/Cargo.toml index c4d1dd5..f08a12e 100644 --- a/extensions/components/Cargo.toml +++ b/extensions/components/Cargo.toml @@ -2,6 +2,7 @@ name = "components" version = "0.1.0" edition = "2024" +license.workspace = true [dependencies] # Workspace dependencies (sorted) diff --git a/extensions/embedded-logos-delivery/Cargo.toml b/extensions/embedded-logos-delivery/Cargo.toml index 65a9c71..953e0dc 100644 --- a/extensions/embedded-logos-delivery/Cargo.toml +++ b/extensions/embedded-logos-delivery/Cargo.toml @@ -2,6 +2,7 @@ name = "embedded-logos-delivery" version = "0.1.0" edition = "2024" +license.workspace = true [dependencies] # Workspace dependencies (sorted) diff --git a/extensions/logos-delivery-rust/Cargo.toml b/extensions/logos-delivery-rust/Cargo.toml index bae3f20..ac2f5f6 100644 --- a/extensions/logos-delivery-rust/Cargo.toml +++ b/extensions/logos-delivery-rust/Cargo.toml @@ -2,6 +2,7 @@ name = "logos-delivery" version = "0.1.0" edition = "2024" +license.workspace = true links = "logosdelivery" [dependencies] From 982a536ea5103794003400ba3909de01b63ebd39 Mon Sep 17 00:00:00 2001 From: Mojtaba Chenani Date: Wed, 19 Aug 2026 11:44:05 +0200 Subject: [PATCH 5/8] =?UTF-8?q?feat:=20expose=20ConversationClass=20(serde?= =?UTF-8?q?=20derive=20+=20rename=20Private=E2=86=92Dm)=20(#214)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.lock | 1 + core/conversations/Cargo.toml | 1 + core/conversations/src/inbox_v2.rs | 4 ++-- core/conversations/src/outcomes.rs | 5 +++-- crates/generic-chat/tests/saro_and_raya.rs | 2 +- 5 files changed, 8 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c93bad7..0ed89c2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3359,6 +3359,7 @@ dependencies = [ "prost", "rand 0.9.4", "rand_core 0.6.4", + "serde", "shared-traits", "storage", "tempfile", diff --git a/core/conversations/Cargo.toml b/core/conversations/Cargo.toml index 61742b5..0742a7a 100644 --- a/core/conversations/Cargo.toml +++ b/core/conversations/Cargo.toml @@ -30,6 +30,7 @@ openmls_traits = "0.5.0" prost = "0.14.1" rand = "0.9" rand_core = { version = "0.6" } +serde = { version = "1.0", features = ["derive"] } thiserror = "2.0.17" tracing = "0.1.44" x25519-dalek = { version = "2.0.1", features = [ diff --git a/core/conversations/src/inbox_v2.rs b/core/conversations/src/inbox_v2.rs index ead4575..46ec59a 100644 --- a/core/conversations/src/inbox_v2.rs +++ b/core/conversations/src/inbox_v2.rs @@ -122,7 +122,7 @@ impl InboxV2 { /// The convo built from an invite, paired with the display class its invite /// type implies: `InviteType::GroupV1` carries the pairwise DirectV1 welcome, - /// so it is `Private`; `InviteType::GroupV2` is a real group. + /// so it is `Dm`; `InviteType::GroupV2` is a real group. #[instrument(name = "inboxV2.handle_frame", skip_all, fields(user_id = %service_ctx.mls_identity.display_name()))] pub fn handle_frame( &self, @@ -143,7 +143,7 @@ impl InboxV2 { match payload { InviteType::GroupV1(inv) => { let convo = self.handle_heavy_invite(service_ctx, inv)?; - Ok(Some((Box::new(convo), ConversationClass::Private))) + Ok(Some((Box::new(convo), ConversationClass::Dm))) } InviteType::GroupV2(welcome_bytes) => { info!("Process WelcomeMessage"); diff --git a/core/conversations/src/outcomes.rs b/core/conversations/src/outcomes.rs index 18877e7..78221fb 100644 --- a/core/conversations/src/outcomes.rs +++ b/core/conversations/src/outcomes.rs @@ -6,6 +6,7 @@ //! initial [`ConvoOutcome`]. //! - [`PayloadOutcome`] — the union of the above, plus `Empty`. +use serde::{Deserialize, Serialize}; use storage::ConversationKind; use crate::conversation::ConversationId; @@ -68,9 +69,9 @@ impl From for PayloadOutcome { } /// Stable across protocol versions of the same conversation shape. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum ConversationClass { - Private, + Dm, Group, } diff --git a/crates/generic-chat/tests/saro_and_raya.rs b/crates/generic-chat/tests/saro_and_raya.rs index 09f3ae7..e45f660 100644 --- a/crates/generic-chat/tests/saro_and_raya.rs +++ b/crates/generic-chat/tests/saro_and_raya.rs @@ -189,7 +189,7 @@ fn direct_v1_by_account_address() { // though its welcome rides the InboxV2 (GroupV1 invite) path. let raya_convo_id = expect_event(&raya_events, "ConversationStarted", |e| match e { Event::ConversationStarted { convo_id, class } => { - assert_eq!(class, ConversationClass::Private); + assert_eq!(class, ConversationClass::Dm); Ok(convo_id) } other => Err(other), From 409f0765f4972143b6347b6c549f5cf2ff5cd54c Mon Sep 17 00:00:00 2001 From: Mojtaba Chenani Date: Wed, 19 Aug 2026 12:03:50 +0200 Subject: [PATCH 6/8] feat: replace chat-cli /connect with /dm and /new (#206) --- bin/chat-cli/README.md | 35 ++++++++---- bin/chat-cli/src/app.rs | 114 ++++++++++++++++++++++++++-------------- bin/chat-cli/src/ui.rs | 2 +- 3 files changed, 100 insertions(+), 51 deletions(-) diff --git a/bin/chat-cli/README.md b/bin/chat-cli/README.md index b3800d0..3908879 100644 --- a/bin/chat-cli/README.md +++ b/bin/chat-cli/README.md @@ -36,27 +36,39 @@ Run two instances in separate terminals: ```bash # Terminal 1 -cargo run -p chat-cli -- --name alice --port 60001 +cargo run -p chat-cli -- --name saro --port 60001 # Terminal 2 -cargo run -p chat-cli -- --name bob --port 60002 +cargo run -p chat-cli -- --name raya --port 60002 ``` For local-only testing without any network dependency, use the file transport: ```bash # Terminal 1 -cargo run -p chat-cli -- --name alice --transport file +cargo run -p chat-cli -- --name saro --transport file # Terminal 2 -cargo run -p chat-cli -- --name bob --transport file +cargo run -p chat-cli -- --name raya --transport file ``` -### Establishing a connection +### Starting a conversation -1. In Alice's terminal, type `/account` — her address is copied to the clipboard automatically. -2. In Bob's terminal, type `/connect `. -3. Bob's "Hello!" message appears in Alice's terminal. Both can now chat. +Every conversation is an MLS group. A **DM** is a 1:1; a **group** is a named +conversation. First share your address: type `/account` — it prints your address +and copies it to the clipboard. + +**Direct message (1:1):** + +1. Raya runs `/account` and shares her address. +2. Saro types `/dm `. +3. The chat opens on both sides; either can message. + +**Group:** + +1. Saro types `/new weekend ` to create a group named "weekend" + and invite Raya. A name is required; more addresses (e.g. Pax's) can follow. +2. Once the invite commits, everyone can chat. ### Optional: KeyPackage registry @@ -71,9 +83,9 @@ process. cargo run -- --bind 127.0.0.1:18080 # Terminal 2 / 3 — chat clients pointing at it -cargo run -p chat-cli -- --name alice --transport file \ +cargo run -p chat-cli -- --name saro --transport file \ --registry-url http://127.0.0.1:18080 -cargo run -p chat-cli -- --name bob --transport file \ +cargo run -p chat-cli -- --name raya --transport file \ --registry-url http://127.0.0.1:18080 ``` @@ -98,7 +110,8 @@ The registry is a throwaway testnet helper; v0.3 replaces it with a |---------|-------------| | `/help` | Show available commands | | `/account` | Show your account address (copies to clipboard) | -| `/connect
` | Connect to a user using their address | +| `/dm
` | Start a direct (1:1) chat | +| `/new [address...]` | Create a named group chat (optionally inviting members) | | `/chats` | List all established chats | | `/switch ` | Switch active chat | | `/delete ` | Delete a chat session | diff --git a/bin/chat-cli/src/app.rs b/bin/chat-cli/src/app.rs index 570e35b..7fadba3 100644 --- a/bin/chat-cli/src/app.rs +++ b/bin/chat-cli/src/app.rs @@ -5,7 +5,10 @@ use std::path::{Path, PathBuf}; use anyhow::Result; use arboard::Clipboard; use crossbeam_channel::Receiver; -use logos_chat::{AccountDirectory, ChatClient, ChatStore, Event, RegistrationService, Transport}; +use logos_chat::{ + AccountDirectory, ChatClient, ChatStore, ConversationClass, Event, GroupMetadata, + RegistrationService, Transport, +}; use serde::{Deserialize, Serialize}; use crate::utils::now; @@ -36,6 +39,7 @@ impl DisplayMessage { pub struct ChatSession { pub chat_id: String, pub nickname: Option, + pub kind: ConversationClass, pub messages: Vec, } @@ -147,6 +151,25 @@ where self.command_output.clear(); } + /// Insert a freshly created conversation and make it active. + fn start_session( + &mut self, + chat_id: String, + kind: ConversationClass, + nickname: Option, + ) { + self.state.chats.insert( + chat_id.clone(), + ChatSession { + chat_id: chat_id.clone(), + nickname, + kind, + messages: Vec::new(), + }, + ); + self.set_active_chat(Some(chat_id)); + } + /// Find a chat_id by nickname (exact) or chat_id prefix. fn resolve_chat_id(&self, query: &str) -> Option<&str> { // Exact nickname match first. @@ -180,22 +203,14 @@ where fn handle_event(&mut self, event: Event) { match event { - Event::ConversationStarted { convo_id, .. } => { + Event::ConversationStarted { convo_id, class } => { let chat_id = convo_id.to_string(); if self.state.chats.contains_key(&chat_id) { return; } - self.state.chats.insert( - chat_id.clone(), - ChatSession { - chat_id: chat_id.clone(), - nickname: None, - messages: Vec::new(), - }, - ); - let label = &chat_id[..8.min(chat_id.len())]; - self.status = format!("New chat ({label})! Use /nickname to name it."); - self.set_active_chat(Some(chat_id)); + let label = chat_id[..8.min(chat_id.len())].to_string(); + self.status = format!("New {class:?} ({label})! Use /nickname to name it."); + self.start_session(chat_id, class, None); } Event::MessageReceived { convo_id, content, .. @@ -269,7 +284,7 @@ where .state .active_chat .clone() - .ok_or_else(|| anyhow::anyhow!("No active chat. Use /connect or /switch first."))?; + .ok_or_else(|| anyhow::anyhow!("No active chat. Use /dm or /new first."))?; let message_id = self .client @@ -301,7 +316,8 @@ where "/help" => { self.add_system_message("── Commands ──"); self.add_system_message("/account - Show your account address"); - self.add_system_message("/connect
- Connect using an address"); + self.add_system_message("/dm
- Start a direct (1:1) chat"); + self.add_system_message("/new [address...] - Create a group chat"); self.add_system_message("/nickname - Name the active chat"); self.add_system_message("/chats - List all chats"); self.add_system_message("/switch - Switch active chat"); @@ -323,34 +339,51 @@ where self.add_system_message(clipboard_msg); Ok(Some("Account address shown".to_string())) } - "/connect" => { - if args.is_empty() { - return Ok(Some("Usage: /connect
".to_string())); + "/dm" => { + let address = args.trim(); + if address.is_empty() { + return Ok(Some("Usage: /dm
".to_string())); } - let initial = format!("Hello from {}!", self.user_name); let chat_id = self .client - .create_direct_conversation(args) + .create_direct_conversation(address) .map_err(|e| anyhow::anyhow!("{e:?}"))?; - let message_id = self - .client - .send_message(&chat_id, initial.as_bytes()) - .map_err(|e| anyhow::anyhow!("{e:?}"))?; - let label = chat_id[..8.min(chat_id.len())].to_string(); - let mut session = ChatSession { - chat_id: chat_id.clone(), - nickname: None, - messages: Vec::new(), - }; - let mut message = DisplayMessage::new(true, initial); - message.message_id = Some(message_id); - session.messages.push(message); - self.state.chats.insert(chat_id.clone(), session); - self.set_active_chat(Some(chat_id)); + self.start_session(chat_id, ConversationClass::Dm, None); self.save_state()?; - self.status = format!("Connected ({label})! Use /nickname to name this chat."); - Ok(Some(format!("Connected ({label})"))) + self.status = format!("Direct chat started ({label}). Say hello!"); + Ok(Some(format!("DM started ({label})"))) + } + "/new" => { + // First token is the group name (required); any remaining tokens + // are addresses to invite at creation. + let mut tokens = args.split_whitespace(); + let Some(name) = tokens.next().map(str::to_string) else { + return Ok(Some("Usage: /new [address...]".to_string())); + }; + // The creator is already a member; drop self and any repeats so we + // don't propose a duplicate signature key (which MLS rejects). + let my_addr = self.client.addr().to_string(); + let mut members: Vec<&str> = tokens.filter(|a| *a != my_addr).collect(); + members.sort_unstable(); + members.dedup(); + let chat_id = self + .client + .create_group_conversation(&members, GroupMetadata::new(name.clone(), "")) + .map_err(|e| anyhow::anyhow!("{e:?}"))?; + let label = chat_id[..8.min(chat_id.len())].to_string(); + self.start_session(chat_id, ConversationClass::Group, Some(name)); + self.save_state()?; + let msg = if members.is_empty() { + format!("Group created ({label}).") + } else { + format!( + "Group created ({label}); {} invite(s) pending.", + members.len() + ) + }; + self.status = msg.clone(); + Ok(Some(msg)) } "/nickname" => { if args.is_empty() { @@ -374,7 +407,9 @@ where "/chats" => { let sessions: Vec<_> = self.state.chats.values().cloned().collect(); if sessions.is_empty() { - Ok(Some("No chats yet. Use /connect to start one.".to_string())) + Ok(Some( + "No chats yet. Use /dm or /new to start one.".to_string(), + )) } else { self.add_system_message(&format!("── Your Chats ({}) ──", sessions.len())); for s in &sessions { @@ -384,7 +419,8 @@ where "" }; let label = format!( - " • {} ({}){marker}", + " • [{:?}] {} ({}){marker}", + s.kind, s.display_name(), &s.chat_id[..8.min(s.chat_id.len())] ); diff --git a/bin/chat-cli/src/ui.rs b/bin/chat-cli/src/ui.rs index 4914dc6..2dcd30c 100644 --- a/bin/chat-cli/src/ui.rs +++ b/bin/chat-cli/src/ui.rs @@ -279,7 +279,7 @@ where app.status = format!("Send error: {}", e); } } else { - app.status = "No active chat. Use /connect first.".to_string(); + app.status = "No active chat. Use /dm or /new first.".to_string(); } } KeyCode::Char(c) => { From ee2572a1419af5a957e7325753e04e7bf0438f9e Mon Sep 17 00:00:00 2001 From: Mojtaba Chenani Date: Wed, 19 Aug 2026 15:37:46 +0200 Subject: [PATCH 7/8] feat: add chat-cli /add (#208) --- bin/chat-cli/README.md | 9 +++++--- bin/chat-cli/src/app.rs | 51 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/bin/chat-cli/README.md b/bin/chat-cli/README.md index 3908879..7c8827d 100644 --- a/bin/chat-cli/README.md +++ b/bin/chat-cli/README.md @@ -66,9 +66,11 @@ and copies it to the clipboard. **Group:** -1. Saro types `/new weekend ` to create a group named "weekend" - and invite Raya. A name is required; more addresses (e.g. Pax's) can follow. -2. Once the invite commits, everyone can chat. +1. Saro types `/new weekend` to create a group named "weekend". A name is + required; addresses can follow (e.g. Pax's) to invite people at creation. +2. Saro types `/add ` to invite Raya; the invite stays pending + until the group commits it. +3. Once the invite commits, everyone can chat. ### Optional: KeyPackage registry @@ -112,6 +114,7 @@ The registry is a throwaway testnet helper; v0.3 replaces it with a | `/account` | Show your account address (copies to clipboard) | | `/dm
` | Start a direct (1:1) chat | | `/new [address...]` | Create a named group chat (optionally inviting members) | +| `/add
` | Add someone to the active group | | `/chats` | List all established chats | | `/switch ` | Switch active chat | | `/delete ` | Delete a chat session | diff --git a/bin/chat-cli/src/app.rs b/bin/chat-cli/src/app.rs index 7fadba3..60adb1e 100644 --- a/bin/chat-cli/src/app.rs +++ b/bin/chat-cli/src/app.rs @@ -272,6 +272,12 @@ where session.display_name() ); } + Event::ConversationMembersChanged { convo_id } => { + let chat_id = convo_id.to_string(); + if let Some(session) = self.state.chats.get(&chat_id) { + self.status = format!("Membership changed in {}.", session.display_name()); + } + } Event::InboundError { message } => { self.status = format!("Could not process incoming message: {message}"); } @@ -318,6 +324,7 @@ where self.add_system_message("/account - Show your account address"); self.add_system_message("/dm
- Start a direct (1:1) chat"); self.add_system_message("/new [address...] - Create a group chat"); + self.add_system_message("/add
- Add someone to the active group"); self.add_system_message("/nickname - Name the active chat"); self.add_system_message("/chats - List all chats"); self.add_system_message("/switch - Switch active chat"); @@ -385,6 +392,50 @@ where self.status = msg.clone(); Ok(Some(msg)) } + "/add" => { + let address = args.trim(); + if address.is_empty() { + return Ok(Some("Usage: /add
".to_string())); + } + let chat_id = self.state.active_chat.as_deref().ok_or_else(|| { + anyhow::anyhow!("No active conversation. Use /new to create a group.") + })?; + // DMs are 1:1 and reject adds at the protocol level; refuse early + // with a friendly hint rather than surfacing UnsupportedFunction. + if self.state.chats.get(chat_id).map(|s| s.kind) == Some(ConversationClass::Dm) { + return Ok(Some( + "DMs are 1:1 — start a group with /new to add people.".to_string(), + )); + } + // Adding a signature key already in the group (yourself, or a + // member/pending invite) makes MLS reject the commit with + // DuplicateSignatureKey. Catch it here as a friendly no-op. + if address == self.client.addr() { + return Ok(Some( + "That's your own address — you're already in the group.".to_string(), + )); + } + let already_present = self + .client + .group_members(chat_id) + .map(|members| { + members + .iter() + .any(|m| m.account.as_ref().map(|a| a.as_str()) == Some(address)) + }) + .unwrap_or(false); + if already_present { + return Ok(Some( + "That account is already in the group (or its invite is pending)." + .to_string(), + )); + } + self.client + .add_group_members(chat_id, &[address]) + .map_err(|e| anyhow::anyhow!("{e:?}"))?; + self.status = "Invite pending — the group will commit it shortly.".to_string(); + Ok(Some("Invite pending".to_string())) + } "/nickname" => { if args.is_empty() { return Ok(Some("Usage: /nickname ".to_string())); From 74265e6ed752bdd70fb0208104f743622754e4a8 Mon Sep 17 00:00:00 2001 From: Mojtaba Chenani Date: Wed, 19 Aug 2026 16:06:06 +0200 Subject: [PATCH 8/8] feat: implement chat-cli /members to list group members (#209) --- bin/chat-cli/README.md | 4 +++- bin/chat-cli/src/app.rs | 31 +++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/bin/chat-cli/README.md b/bin/chat-cli/README.md index 7c8827d..9848b4c 100644 --- a/bin/chat-cli/README.md +++ b/bin/chat-cli/README.md @@ -70,7 +70,8 @@ and copies it to the clipboard. required; addresses can follow (e.g. Pax's) to invite people at creation. 2. Saro types `/add ` to invite Raya; the invite stays pending until the group commits it. -3. Once the invite commits, everyone can chat. +3. `/members` lists the roster — Raya shows `(pending)` until the commit lands, + then appears without it. Once committed, both can chat. ### Optional: KeyPackage registry @@ -115,6 +116,7 @@ The registry is a throwaway testnet helper; v0.3 replaces it with a | `/dm
` | Start a direct (1:1) chat | | `/new [address...]` | Create a named group chat (optionally inviting members) | | `/add
` | Add someone to the active group | +| `/members` | List members of the active conversation | | `/chats` | List all established chats | | `/switch ` | Switch active chat | | `/delete ` | Delete a chat session | diff --git a/bin/chat-cli/src/app.rs b/bin/chat-cli/src/app.rs index 60adb1e..cebdefd 100644 --- a/bin/chat-cli/src/app.rs +++ b/bin/chat-cli/src/app.rs @@ -325,6 +325,7 @@ where self.add_system_message("/dm
- Start a direct (1:1) chat"); self.add_system_message("/new [address...] - Create a group chat"); self.add_system_message("/add
- Add someone to the active group"); + self.add_system_message("/members - List members of the active conversation"); self.add_system_message("/nickname - Name the active chat"); self.add_system_message("/chats - List all chats"); self.add_system_message("/switch - Switch active chat"); @@ -436,6 +437,36 @@ where self.status = "Invite pending — the group will commit it shortly.".to_string(); Ok(Some("Invite pending".to_string())) } + "/members" => { + let chat_id = self + .state + .active_chat + .clone() + .ok_or_else(|| anyhow::anyhow!("No active conversation."))?; + let members = self + .client + .group_members(&chat_id) + .map_err(|e| anyhow::anyhow!("{e:?}"))?; + let my_addr = self.client.addr().to_string(); + self.add_system_message(&format!("── Members ({}) ──", members.len())); + for m in &members { + let id = m + .account + .as_ref() + .map(|a| a.as_str()) + .unwrap_or_else(|| m.local_identity.as_str()); + let short = &id[..16.min(id.len())]; + let mut tags = String::new(); + if m.account.as_ref().map(|a| a.as_str()) == Some(my_addr.as_str()) { + tags.push_str(" (you)"); + } + if m.pending { + tags.push_str(" (pending)"); + } + self.add_system_message(&format!(" • {short}…{tags}")); + } + Ok(Some(format!("{} member(s)", members.len()))) + } "/nickname" => { if args.is_empty() { return Ok(Some("Usage: /nickname ".to_string()));