Merge branch 'main' into jazzz/account_service

This commit is contained in:
Jazz Turner-Baggs 2026-07-14 11:05:10 -07:00
commit 9e5abecc80
No known key found for this signature in database
50 changed files with 2126 additions and 441 deletions

View File

@ -38,7 +38,7 @@ class Handler(BaseHTTPRequestHandler):
self._drain()
self._reply(404)
def log_message(self, *args):
def log_message(self, format, *args):
pass

View File

@ -20,12 +20,12 @@ jobs:
- run: rustup update stable && rustup default stable
# hashgraph-like-consensus's build.rs shells out to protoc via prost-build.
- run: sudo apt-get update && sudo apt-get install -y protobuf-compiler
# chat-cli pulls in components' embedded_p2p_delivery feature, whose
# build.rs links liblogosdelivery (built via Nix or LOGOS_DELIVERY_LIB_DIR).
# The smoketest job builds and exercises it under Nix; here we keep the
# toolchain-only job fast by skipping it.
- run: cargo build --verbose --workspace --exclude chat-cli
- run: cargo test --verbose --workspace --exclude chat-cli
# chat-cli and the embedded-logos-delivery crate link liblogosdelivery
# (built via Nix or LOGOS_DELIVERY_LIB_DIR). The smoketest job builds and
# exercises them under Nix; here we keep the toolchain-only job fast by
# skipping them.
- run: cargo build --verbose --workspace --exclude chat-cli --exclude embedded-logos-delivery
- run: cargo test --verbose --workspace --exclude chat-cli --exclude embedded-logos-delivery
clippy:
name: Clippy

47
Cargo.lock generated
View File

@ -1817,7 +1817,7 @@ dependencies = [
[[package]]
name = "de-mls"
version = "4.0.0"
source = "git+https://github.com/vacp2p/de-mls?branch=main#2b3eed17723d4fb12037ef8cfb1f5e8c115b09a1"
source = "git+https://github.com/vacp2p/de-mls?rev=2c7a8669c1492c749c02efd2c5ac45e93e4926a3#2c7a8669c1492c749c02efd2c5ac45e93e4926a3"
dependencies = [
"hashgraph-like-consensus",
"indexmap 2.14.0",
@ -2072,6 +2072,21 @@ dependencies = [
"zeroize",
]
[[package]]
name = "embedded-logos-delivery"
version = "0.1.0"
dependencies = [
"base64",
"crossbeam-channel",
"libchat",
"logos-delivery",
"logos-generic-chat",
"serde",
"serde_json",
"thiserror",
"tracing",
]
[[package]]
name = "enum-ordinalize"
version = "4.3.2"
@ -2529,9 +2544,9 @@ dependencies = [
[[package]]
name = "hashgraph-like-consensus"
version = "0.5.1"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95b6328e1ba3b6ff66e24a018b40c6ea61a3105607755ac378003b57de7bc19d"
checksum = "cba53b85cc33f28262bbd9c258bbbf3f3b66501ef7cc2b7137712215fdf7cbf5"
dependencies = [
"alloy",
"alloy-signer",
@ -3599,6 +3614,32 @@ dependencies = [
[[package]]
name = "logos-chat"
version = "0.1.0"
dependencies = [
"components",
"crossbeam-channel",
"embedded-logos-delivery",
"libchat",
"logos-account",
"logos-generic-chat",
]
[[package]]
name = "logos-delivery"
version = "0.1.0"
dependencies = [
"base64",
"crossbeam-channel",
"libchat",
"logos-generic-chat",
"serde",
"serde_json",
"thiserror",
"tracing",
]
[[package]]
name = "logos-generic-chat"
version = "0.1.0"
dependencies = [
"chat-sqlite",
"components",

View File

@ -12,8 +12,11 @@ members = [
"core/shared-traits",
"core/sqlite",
"core/storage",
"crates/client",
"crates/logos-chat",
"crates/generic-chat",
"extensions/components",
"extensions/embedded-logos-delivery",
"extensions/logos-delivery-rust",
]
default-members = [
@ -25,7 +28,7 @@ default-members = [
"core/shared-traits",
"core/sqlite",
"core/storage",
"crates/client",
"crates/generic-chat",
]
[workspace.dependencies]
@ -34,8 +37,11 @@ logos-account = { path = "core/account" }
chat-sqlite = { path = "core/sqlite" }
components = { path = "extensions/components" }
crypto = { path = "core/crypto" }
embedded-logos-delivery = { path = "extensions/embedded-logos-delivery" }
libchat = { path = "core/conversations" }
logos-chat = { path = "crates/client" }
logos-chat = { path = "crates/logos-chat" }
logos-delivery = { path = "extensions/logos-delivery-rust"}
logos-generic-chat = { path = "crates/generic-chat" }
shared-traits = { path = "core/shared-traits" }
storage = { path = "core/storage" }

View File

@ -11,7 +11,7 @@ path = "src/main.rs"
# Workspace dependencies (sorted)
crossbeam-channel = { workspace = true }
logos-account = { workspace = true, features = ["dev"] }
logos-chat = { workspace = true, features = ["embedded-p2p-delivery"] }
logos-chat = { workspace = true }
# External dependencies (sorted)
anyhow = "1.0"

View File

@ -8,11 +8,9 @@ use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use clap::{Parser, ValueEnum};
use crossbeam_channel::Receiver;
use logos_account::TestLogosAccount;
use logos_chat::{
AccountDirectory, ChatClient, ChatClientBuilder, ChatStore, DelegateSigner, Event,
HttpRegistry, LogosChatClient, NETWORK_PRESET, REGISTRY_ENDPOINT, RegistrationService,
StorageConfig, Transport,
AccountDirectory, ChatClient, ChatStore, Event, LogosConfig, P2pConfig, RegistrationService,
Transport,
};
use app::ChatApp;
@ -49,9 +47,10 @@ struct Cli {
#[arg(long)]
preset: Option<String>,
/// TCP port for the embedded logos-delivery node.
#[arg(long, default_value_t = 60000)]
port: u16,
/// TCP port for the embedded logos-delivery node. When omitted, the
/// preconfigured port is used.
#[arg(long)]
port: Option<u16>,
/// Write logs to a file instead of stderr (keeps TUI output clean).
#[arg(long)]
@ -77,54 +76,46 @@ fn main() -> Result<()> {
let db_str = db_path(&cli)?;
match cli.transport {
// logos-delivery is the transport baked into `LogosChatClient`, so the
// Logos client opens it from config rather than receiving one.
TransportKind::LogosDelivery => {
let preset = cli.preset.as_deref().unwrap_or(NETWORK_PRESET);
println!("Starting logos-delivery node (preset={preset})...");
let mut p2p_config = P2pConfig::default();
if let Some(port) = cli.port {
p2p_config.port = port;
}
if let Some(preset) = cli.preset.as_deref() {
p2p_config.preset = preset.to_string();
}
println!(
"Starting logos-delivery node (preset={})...",
p2p_config.preset
);
println!("This may take a few seconds while connecting to the network.");
let (client, events) = LogosChatClient::open(
db_str,
"chat-cli",
cli.port,
cli.preset.as_deref(),
cli.registry_url.as_deref(),
)
.map_err(|e| anyhow::anyhow!("{e:?}"))
.context("failed to open chat client")?;
let mut config = LogosConfig::new(db_str, "chat-cli");
if let Some(registry_url) = cli.registry_url.as_deref() {
config.set_registry_url(registry_url);
}
config.set_p2p_config(p2p_config);
let (client, events) = logos_chat::open(config)
.map_err(|e| anyhow::anyhow!("{e:?}"))
.context("failed to open chat client")?;
println!("Node connected.");
launch_tui(client, events, &cli)
}
// The file transport is a local-only path: it reuses the Logos service
// stack (delegate identity, HTTP registry, encrypted storage) but swaps
// the transport, so it builds a client directly instead of going through
// `LogosChatClient`.
// the transport in via `open_with_transport`.
TransportKind::File => {
let transport_dir = cli.data.join("transport");
let transport = transport::file::FileTransport::new(&transport_dir)
.context("failed to create file transport")?;
let endpoint = cli.registry_url.as_deref().unwrap_or(REGISTRY_ENDPOINT);
// A fresh dev account endorsing a fresh delegate each launch,
// mirroring `LogosChatClient::open`.
let account = TestLogosAccount::new();
let delegate = DelegateSigner::random();
let mut registry = HttpRegistry::new(endpoint);
account
.add_delegate_signer(&mut registry, delegate.public_key())
.map_err(|e| anyhow::anyhow!("{e:?}"))
.context("failed to publish the device bundle")?;
let (client, events) = ChatClientBuilder::new(account.address())
.ident(delegate)
.transport(transport)
.registration(registry)
.storage_config(StorageConfig::Encrypted {
path: db_str,
key: "chat-cli".to_string(),
})
.build()
let mut config = LogosConfig::new(db_str, "chat-cli");
if let Some(registry_url) = cli.registry_url.as_deref() {
config.set_registry_url(registry_url);
}
let (client, events) = logos_chat::open_with_transport(config, transport)
.map_err(|e| anyhow::anyhow!("{e:?}"))
.context("failed to open chat client")?;

View File

@ -18,9 +18,9 @@ storage = { workspace = true }
alloy = "2.0"
base64 = "0.22"
chat-proto = { git = "https://github.com/logos-messaging/chat_proto", rev = "37ec98a151f6d50aab2905802ac0a896477e62ea" }
de-mls = { git = "https://github.com/vacp2p/de-mls", branch = "main"}
de-mls = { git = "https://github.com/vacp2p/de-mls", rev = "2c7a8669c1492c749c02efd2c5ac45e93e4926a3"} # Expose Mls Extensions (#131)
double-ratchets = { path = "../double-ratchets" }
hashgraph-like-consensus = "0.5.1"
hashgraph-like-consensus = "0.6.0"
hex = "0.4.3"
openmls = { version = "0.8.1", features = ["libcrux-provider"] }
openmls_libcrux_crypto = "0.3.1"

View File

@ -1,15 +1,17 @@
mod direct_v1;
pub mod group_v1;
mod group_v2;
pub mod mls_extensions;
mod privatev1;
pub use crate::errors::ChatError;
use crate::outcomes::ConvoOutcome;
use crate::proto::EncryptedPayload;
use crate::service_context::{ExternalServices, ServiceContext};
use crate::types::ConvoMetadata;
pub use direct_v1::DirectV1Convo;
pub use group_v1::GroupV1Convo;
pub use group_v2::GroupV2Convo;
pub use group_v2::{GroupV2Clock, GroupV2Convo};
pub use privatev1::PrivateV1Convo;
use shared_traits::IdentIdRef;
@ -42,6 +44,14 @@ pub(crate) trait GroupConvo<S: ExternalServices>: Convo<S> + std::fmt::Debug + S
cx: &mut ServiceContext<S>,
members: &[IdentIdRef],
) -> Result<(), ChatError>;
/// Each current member's MLS leaf-credential content (hex-encoded), self
/// included.
fn members(&self) -> Result<Vec<Vec<u8>>, ChatError>;
// All GroupConvos MUST return ConvoMetadata
// the return type is Option<_> to support legacy ConvoTypes which
// are being phased out.
fn metadata(&self) -> Option<ConvoMetadata>;
}
pub(crate) trait Identified {

View File

@ -16,6 +16,7 @@ use crate::conversation::ConversationIdRef;
use crate::inbox_v2::MlsProvider;
use crate::service_context::{ExternalServices, ServiceContext};
use crate::types::ConvoMetadata;
use crate::utils::{blake2b_hex, hash_size};
use crate::{
DeliveryService, IdentityProvider,
@ -341,4 +342,16 @@ impl<S: ExternalServices> GroupConvo<S> for GroupV1Convo {
self.send_payload(cx, commit.to_bytes()?)
}
fn members(&self) -> Result<Vec<Vec<u8>>, ChatError> {
Ok(self
.mls_group
.members()
.map(|m| m.credential.serialized_content().to_vec())
.collect())
}
fn metadata(&self) -> Option<ConvoMetadata> {
None
}
}

View File

@ -2,7 +2,10 @@
// DeMLS and Libchat have different execution models, trait definitions and ownership/lifetimes of objects.
// The easies path is to do a Spike to see what it would take, gather the friction points and then iterate.
use crate::types::AddressedEncryptedPayload;
use crate::conversation::mls_extensions::{
ConvoMetaInfo, GROUP_METADATA_EXTENSION_TYPE, capabilities_with_group_metadata,
};
use crate::types::{AddressedEncryptedPayload, ConvoMetadata};
use crate::{Content, WakeupService};
use alloy::signers::local::PrivateKeySigner;
use blake2::{Blake2b, Digest, digest::consts::U6};
@ -11,18 +14,20 @@ use de_mls::protos::de_mls::messages::v1::{
AppMessage as AppMessageProto, MemberWelcome, app_message,
};
use de_mls::{
Conversation, ConversationConfig, ConversationEvent, PeerScoringService, ScoringConfig,
Conversation, ConversationEvent, MockClock, PeerScoringService, ScoringConfig, WallClock,
default_score_deltas,
defaults::{DefaultConsensusPlugin, DefaultPeerScoring, InMemoryPeerScoreStorage},
};
use hashgraph_like_consensus::signing::EthereumConsensusSigner;
use openmls::extensions::{Extension, Extensions, UnknownExtension};
use openmls::group::MlsGroupCreateConfig;
use openmls::prelude::tls_codec::Deserialize as _;
use openmls::prelude::{KeyPackageIn, OpenMlsProvider as _, ProtocolVersion};
use openmls_traits::crypto::OpenMlsCrypto;
use prost::Message;
use shared_traits::{IdentId, IdentIdRef};
use std::sync::Arc;
use std::time::Duration;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use tracing::{info, instrument, warn};
use crate::IdentityProvider;
@ -32,6 +37,29 @@ use crate::{
conversation::{ChatError, Convo, GroupConvo, Identified},
};
/// The de-mls time source: every conversation deadline (freeze windows,
/// consensus timeouts, auto-votes) and consensus wire timestamp is measured
/// against this clock. Production runs on system time; tests share one
/// `MockClock` with the harness scheduler so virtual time moves the
/// protocol's timers.
#[derive(Debug, Clone, Default)]
pub enum GroupV2Clock {
#[default]
System,
Mock(MockClock),
}
impl WallClock for GroupV2Clock {
fn now(&self) -> Duration {
match self {
GroupV2Clock::System => SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default(),
GroupV2Clock::Mock(clock) => clock.now(),
}
}
}
/// Local member id bytes — the account identity the protocol matches on,
/// shared with the MLS credential and the consensus member.
fn member_id<S: ExternalServices>(service_ctx: &ServiceContext<S>) -> Vec<u8> {
@ -58,24 +86,9 @@ fn make_consensus() -> DefaultConsensusPlugin {
DefaultConsensusPlugin::new(EthereumConsensusSigner::new(PrivateKeySigner::random()))
}
/// TEST-ONLY millisecond timers. de-mls deadlines are real wall-clock, so the
/// default 60s timers never fire under fast virtual time. Production needs a
/// real config injected from the caller, not these hardcoded values.
fn demls_config() -> ConversationConfig {
ConversationConfig {
commit_inactivity_duration: Duration::from_millis(50),
freeze_duration: Duration::from_millis(20),
voting_delay: Duration::from_millis(30),
election_voting_delay: Duration::from_millis(30),
consensus_timeout: Duration::from_millis(150),
proposal_expiration: Duration::from_millis(2000),
..ConversationConfig::default()
}
}
pub struct GroupV2Convo {
convo_id: String,
conversation: Conversation<DefaultConsensusPlugin, InMemoryPeerScoreStorage>,
conversation: Conversation<DefaultConsensusPlugin, InMemoryPeerScoreStorage, GroupV2Clock>,
/// Joiners WE invited, as `(member_id, signer_id)`: the de-mls member id
/// (the joiner's leaf credential content, read from its key package) paired
/// with the signer id its welcome is delivered to.
@ -95,28 +108,47 @@ fn rand_string(n: usize) -> String {
hex::encode(bytes)
}
fn group_config() -> MlsGroupCreateConfig {
fn group_config<S: ExternalServices>(
cx: &mut ServiceContext<S>,
name: &str,
desc: &str,
) -> MlsGroupCreateConfig {
let meta = ConvoMetaInfo::new(name, desc);
let extensions = Extensions::from_vec(vec![Extension::Unknown(
GROUP_METADATA_EXTENSION_TYPE,
UnknownExtension(meta.to_extension_bytes()),
)])
.expect("failed to create extensions");
MlsGroupCreateConfig::builder()
.use_ratchet_tree_extension(true)
.ciphersuite(cx.mls_provider.crypto().supported_ciphersuites()[0])
.capabilities(capabilities_with_group_metadata())
.use_ratchet_tree_extension(true) // Embed the ratchet tree in the Welcome so joiners can build the group
.with_group_context_extensions(extensions)
.build()
}
impl GroupV2Convo {
pub fn new<S: ExternalServices>(
service_ctx: &mut ServiceContext<S>,
name: &str,
desc: &str,
) -> Result<Self, ChatError> {
let convo_id = rand_string(5);
let group_config = group_config(service_ctx, name, desc);
let conversation = Conversation::create(
&convo_id,
&member_id(service_ctx),
&service_ctx.mls_provider,
service_ctx.mls_identity.get_credential(),
&group_config(),
&group_config,
&service_ctx.mls_identity,
&make_consensus(),
make_scoring(),
service_ctx.demls_clock.clone(),
rand_app_id(),
demls_config(),
service_ctx.demls_config.clone(),
)?;
let convo = GroupV2Convo {
convo_id,
@ -146,8 +178,9 @@ impl GroupV2Convo {
&welcome.conversation_sync_bytes,
&make_consensus(),
make_scoring(),
service_ctx.demls_clock.clone(),
rand_app_id(),
demls_config(),
service_ctx.demls_config.clone(),
)?
else {
return Err(ChatError::generic("welcome not addressed to this member"));
@ -277,14 +310,26 @@ where
service_ctx: &mut ServiceContext<S>,
members: &[IdentIdRef],
) -> Result<(), ChatError> {
// Record who WE invited before touching the conversation: after_op
// forwards a welcome only to joiners in pending_invites. Members are
// signer ids; the de-mls member id must match the id of the
// IdentityProvider that generated the key package (its MLS leaf
// credential content — de-mls matches members by credential), so it is
// read from the fetched key package rather than assumed equal to the
// signer id.
for member in members {
// Dedup the requested signers up front: an account can resolve to the
// same signer twice, or a caller can repeat one, and a duplicate would
// otherwise cost a redundant key-package fetch here and a second Add
// proposal for a member already being added in this batch.
let mut seen = std::collections::HashSet::new();
let members: Vec<IdentIdRef> = members
.iter()
.copied()
.filter(|m| seen.insert(m.as_str().to_string()))
.collect();
// Fetch and validate every key package before proposing any add, so a
// member with no key package fails the call before it opens proposals
// for the others. Members are signer ids; the de-mls member id must
// match the id of the IdentityProvider that generated the key package
// (its MLS leaf credential content — de-mls matches members by
// credential), so it is read from the fetched key package rather than
// assumed equal to the signer id.
let mut invites = Vec::with_capacity(members.len());
for member in &members {
let kp_bytes = service_ctx
.registry
.retrieve(member.as_str())
@ -298,17 +343,67 @@ where
.credential()
.serialized_content()
.to_vec();
self.pending_invites
.push((member_id.clone(), member.to_string()));
self.conversation.add_member(
invites.push((member_id, member.to_string(), kp_bytes));
}
// pending_invites drives welcome delivery: after_op forwards a welcome
// only to a joiner recorded here. Record a member only if de-mls will
// actually propose its add — recording one it silently drops strands an
// entry that a later re-join can match, firing a spurious duplicate
// welcome. de-mls drops self and members already in the group; and since
// add_member only opens a proposal, the committed roster won't reflect a
// member added earlier in this same loop, so the set tracks those too.
// Seed it with the roster and self, insert as we go, and one check
// covers all three.
let mut roster: std::collections::HashSet<Vec<u8>> =
self.conversation.members()?.into_iter().collect();
roster.insert(self.conversation.member_id_bytes().to_vec());
let mut result = Ok(());
for (member_id, signer_id, kp_bytes) in invites {
if !roster.insert(member_id.clone()) {
continue;
}
self.pending_invites.push((member_id.clone(), signer_id));
if let Err(e) = self.conversation.add_member(
&service_ctx.mls_provider,
&service_ctx.mls_identity,
&member_id,
&kp_bytes,
)?;
) {
self.pending_invites.pop();
result = Err(e.into());
break;
}
}
self.after_op(service_ctx)?;
Ok(())
// Flush even on a mid-loop failure: proposals already opened must be
// published and the wakeup re-armed, or they sit dormant until an
// unrelated frame drives the conversation.
let flushed = self.after_op(service_ctx).map(drop);
result.and(flushed)
}
fn members(&self) -> Result<Vec<Vec<u8>>, ChatError> {
// Guarantee the local member is listed so callers see the full roster.
let mut members = self.conversation.members()?;
let self_id = self.conversation.member_id_bytes().to_vec();
if !members.contains(&self_id) {
members.push(self_id);
}
Ok(members)
}
fn metadata(&self) -> Option<ConvoMetadata> {
let res = self.conversation.extensions().iter().find_map(|ext| {
if let Extension::Unknown(ext_type, UnknownExtension(bytes)) = ext
&& *ext_type == GROUP_METADATA_EXTENSION_TYPE
{
return ConvoMetaInfo::from_extension_bytes(bytes).ok();
};
None
});
res.map(Into::into)
}
// fn conversation_state(&self) -> Result<ConversationState, ChatError> {

View File

@ -0,0 +1,120 @@
use openmls::{
extensions::ExtensionType,
prelude::{
Capabilities,
tls_codec::{Deserialize, Error as TlsError, Serialize, Size, VLByteSlice, VLBytes},
},
};
use std::io::{Read, Write};
use crate::types::ConvoMetadata;
/// MLS extension type carrying our [`ConvoMetadata`]. In the private-use
/// range (0xF0000xFFFF) reserved by RFC 9420 for non-registered extensions.
pub const GROUP_METADATA_EXTENSION_TYPE: u16 = 0xFF01;
pub fn capabilities_with_group_metadata() -> Capabilities {
Capabilities::new(
None, // default protocol versions
None, // default ciphersuites
Some(&[ExtensionType::Unknown(GROUP_METADATA_EXTENSION_TYPE)]),
None, // default proposal types
None, // default credential types
)
}
/// Wire-format version of [`ConvoMetaInfo`], encoded as a `u16`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u16)]
enum ConvoMetaInfoVersion {
V1 = 1,
}
impl TryFrom<u16> for ConvoMetaInfoVersion {
type Error = TlsError;
fn try_from(value: u16) -> Result<Self, Self::Error> {
match value {
1 => Ok(Self::V1),
other => Err(TlsError::DecodingError(format!(
"unknown ConvoMetaInfo version {other}"
))),
}
}
}
#[derive(Debug, Clone)]
pub struct ConvoMetaInfo {
version: ConvoMetaInfoVersion,
name: String,
desc: String,
}
impl ConvoMetaInfo {
pub fn new(name: impl Into<String>, desc: impl Into<String>) -> Self {
Self {
version: ConvoMetaInfoVersion::V1,
name: name.into(),
desc: desc.into(),
}
}
pub fn to_extension_bytes(&self) -> Vec<u8> {
// TLS presentation-language encoding — matches the MLS stack's wire
// format. Writing to a Vec is infallible; the only error path is a
// field exceeding tls_codec's ~1 GiB length cap, unreachable here.
self.tls_serialize_detached()
.expect("ConvoMetaInfo serialization to Vec is infallible")
}
pub fn from_extension_bytes(bytes: &[u8]) -> Result<Self, TlsError> {
Self::tls_deserialize(&mut &bytes[..])
}
}
// Each field is encoded as a variable-length opaque (`opaque <V>`); `IdentId`
// and `String` aren't `tls_codec` types, so we encode/decode their UTF-8 bytes.
impl Size for ConvoMetaInfo {
fn tls_serialized_len(&self) -> usize {
(self.version as u16).tls_serialized_len()
+ VLByteSlice(self.name.as_bytes()).tls_serialized_len()
+ VLByteSlice(self.desc.as_bytes()).tls_serialized_len()
}
}
impl Serialize for ConvoMetaInfo {
fn tls_serialize<W: Write>(&self, writer: &mut W) -> Result<usize, TlsError> {
let mut written = (self.version as u16).tls_serialize(writer)?;
written += VLByteSlice(self.name.as_bytes()).tls_serialize(writer)?;
written += VLByteSlice(self.desc.as_bytes()).tls_serialize(writer)?;
Ok(written)
}
}
impl Deserialize for ConvoMetaInfo {
fn tls_deserialize<R: Read>(bytes: &mut R) -> Result<Self, TlsError> {
let version = ConvoMetaInfoVersion::try_from(u16::tls_deserialize(bytes)?)?;
let name = vl_string(bytes)?;
let desc = vl_string(bytes)?;
Ok(Self {
version,
name,
desc,
})
}
}
fn vl_string<R: Read>(bytes: &mut R) -> Result<String, TlsError> {
let raw = VLBytes::tls_deserialize(bytes)?;
String::from_utf8(raw.into())
.map_err(|_| TlsError::DecodingError("invalid utf-8 in ConvoMetaInfo".into()))
}
impl From<ConvoMetaInfo> for ConvoMetadata {
fn from(value: ConvoMetaInfo) -> Self {
Self {
name: value.name,
desc: value.desc,
}
}
}

View File

@ -3,7 +3,11 @@ use crate::conversation::{
ConversationIdRef, DirectV1Convo, GroupV1Convo, GroupV2Convo, Identified, PrivateV1Convo,
};
use crate::service_context::{ExternalServices, ServiceContext};
use crate::{DeliveryService, IdentityProvider, RegistrationService, WakeupService};
use crate::types::ConvoMetadata;
use crate::{
DeliveryService, GroupV2Clock, GroupV2Config, IdentityProvider, RegistrationService,
WakeupService,
};
use crate::{
conversation::{Convo, GroupConvo},
errors::ChatError,
@ -100,6 +104,17 @@ where
Ok(core)
}
pub fn set_group_v2_clock(&mut self, clock: GroupV2Clock) {
self.services.demls_clock = clock;
}
/// Overrides the GroupV2 (de-mls) timing/policy config. Applies to
/// conversations created/joined after the call; a creator's phase
/// durations reach joiners inside the welcome's `ConversationSync`.
pub fn set_group_v2_config(&mut self, config: GroupV2Config) {
self.services.demls_config = config;
}
/// Builds the inbox/account/MLS/causal state, subscribes both inbound
/// addresses, and assembles the service bundle — shared by both constructors.
fn assemble(
@ -140,6 +155,8 @@ where
causal,
identity,
wakeup_service,
demls_clock: GroupV2Clock::default(),
demls_config: GroupV2Config::default(),
},
inbox,
pq_inbox,
@ -224,7 +241,7 @@ impl<'a, S: ExternalServices + 'static> Core<S> {
&mut self,
participants: &[IdentIdRef],
) -> Result<ConversationId, ChatError> {
self.create_group_convo_v2(participants)
self.create_group_convo_v2(participants, "", "")
}
pub fn create_group_convo_v1(
@ -253,11 +270,13 @@ impl<'a, S: ExternalServices + 'static> Core<S> {
pub fn create_group_convo_v2(
&mut self,
participants: &[IdentIdRef],
name: &str,
desc: &str,
) -> Result<ConversationId, ChatError> {
// TODO: (P1) Ensure errors are handled properly. This is a high chance for
// desynchronized state: MlsGroup persistence, conversation persistence, and
// invite delivery all happen separately.
let mut convo = GroupV2Convo::new(&mut self.services)?;
let mut convo = GroupV2Convo::new(&mut self.services, name, desc)?;
convo.add_member(&mut self.services, participants)?;
let convo_id = convo.id().to_string();
@ -293,6 +312,28 @@ impl<'a, S: ExternalServices + 'static> Core<S> {
}
}
/// Each member's MLS leaf-credential content (hex-encoded); errors if
/// `convo_id` names a direct (non-group) conversation.
pub fn group_members(&mut self, convo_id: &str) -> Result<Vec<Vec<u8>>, ChatError> {
if self.cached_convos.contains_key(convo_id) {
let convo = self
.cached_convos
.get(convo_id)
.ok_or_else(|| ChatError::NoConvo(convo_id.to_string()))?;
match convo {
ConvoTypeOwned::Group(group_convo) => group_convo.members(),
ConvoTypeOwned::Direct(convo) => Err(ChatError::UnsupportedFunction(
convo.id().into(),
"List Members".into(),
)),
}
} else {
let convo = self.load_group_convo(convo_id)?;
convo.members()
}
}
pub fn list_conversations(&self) -> Result<Vec<ConversationId>, ChatError> {
// Check Legacy load_convo store
let records = self.services.store.load_conversations()?;
@ -304,9 +345,13 @@ impl<'a, S: ExternalServices + 'static> Core<S> {
convos.push(convo.to_string());
}
// Conversations may use both storage mechanisms.
// Remove duplicates
convos.dedup();
// A conversation can live in both the store and the in-memory cache (a
// DirectV1 join persists to the store and is also cached), so drop
// duplicates across the two. `Vec::dedup` only removes *consecutive*
// repeats and `cached_convos` iterates in nondeterministic HashMap
// order, so dedup through a set instead.
let mut seen = std::collections::HashSet::new();
convos.retain(|c| seen.insert(c.clone()));
Ok(convos)
}
@ -362,16 +407,13 @@ impl<'a, S: ExternalServices + 'static> Core<S> {
// Dispatch encrypted payload to the post-quantum inbox.
fn dispatch_to_inbox2(&mut self, payload: &[u8]) -> Result<PayloadOutcome, ChatError> {
if let Some(convo) = self.pq_inbox.handle_frame(&mut self.services, payload)? {
if let Some((convo, class)) = self.pq_inbox.handle_frame(&mut self.services, payload)? {
let convo_id = convo.id().to_string();
// Cache convos created by InboxV2
self.register_convo(ConvoTypeOwned::Group(convo))?;
Ok(PayloadOutcome::Inbox(InboxOutcome {
new_conversation: crate::NewConversation {
convo_id,
class: crate::ConversationClass::Group,
},
new_conversation: crate::NewConversation { convo_id, class },
initial: None,
}))
} else {
@ -485,6 +527,23 @@ impl<'a, S: ExternalServices + 'static> Core<S> {
.load_conversation(convo_id)?
.ok_or_else(|| ChatError::NoConvo(convo_id.into()))
}
pub fn convo_metadata(&self, convo_id: ConversationIdRef) -> Result<ConvoMetadata, ChatError> {
match self.cached_convos.get(convo_id) {
Some(ConvoTypeOwned::Group(group_convo)) => {
group_convo
.metadata()
.ok_or(ChatError::UnsupportedConvoType(
"metadata is not available for this legacy convo_type".into(),
))
}
Some(ConvoTypeOwned::Direct(_)) => Err(ChatError::UnsupportedFunction(
convo_id.into(),
"implementation coming".into(),
)),
None => Err(ChatError::NoConvo(convo_id.into())),
}
}
}
enum ConvoTypeOwned<S: ExternalServices> {

View File

@ -20,6 +20,8 @@ use crate::conversation::GroupConvo;
use crate::conversation::GroupV1Convo;
use crate::conversation::GroupV2Convo;
use crate::conversation::Identified as _;
use crate::conversation::mls_extensions::GROUP_METADATA_EXTENSION_TYPE;
use crate::outcomes::ConversationClass;
use crate::service_context::{ExternalServices, ServiceContext};
use crate::utils::{blake2b_hex, hash_size};
use crate::{AddressedEnvelope, IdentId, IdentIdRef, IdentityProvider};
@ -70,6 +72,10 @@ pub fn invite_user_v2<DS: DeliveryService>(
.map_err(ChatError::generic)
}
/// A convo built from an InboxV2 invite, paired with the display class its
/// invite type implies.
type ClassifiedConvo<S> = (Box<dyn GroupConvo<S>>, ConversationClass);
/// A PQ focused Conversation initializer.
/// InboxV2 is signer-scoped: it receives invites under this installation's
/// signer id (the hex of the signer's verifying key), supporting PQ based
@ -95,8 +101,11 @@ impl InboxV2 {
) -> Result<(), ChatError> {
let keypackage_bytes = Self::create_keypackage(cx)?.tls_serialize_detached()?;
// TODO: (P3) Each keypackage can only be used once either enable...
// "LastResort" package or publish multiple
// TODO: publishes a single key package per installation. The intended
// design is a pool of one-time key packages (the registry pops one per
// fetch, the client replenishes) with the last-resort key package as the
// exhaustion fallback rather than the primary; that needs pop/claim
// semantics in the registry service. Tracked in #169.
cx.registry
.register(&cx.mls_identity, keypackage_bytes)
.map_err(ChatError::generic)?;
@ -112,12 +121,15 @@ impl InboxV2 {
conversation_id_for(&self.ident_id)
}
/// 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.
#[instrument(name = "inboxV2.handle_frame", skip_all, fields(user_id = %service_ctx.mls_identity.display_name()))]
pub fn handle_frame<S: ExternalServices>(
&self,
service_ctx: &mut ServiceContext<S>,
payload_bytes: &[u8],
) -> Result<Option<Box<dyn GroupConvo<S>>>, ChatError> {
) -> Result<Option<ClassifiedConvo<S>>, ChatError> {
// On a broadcast transport the inbox address also receives traffic
// that isn't an invite (or that prost decodes into an empty frame).
// Treat anything we can't interpret as "not for us" and skip it,
@ -131,14 +143,15 @@ impl InboxV2 {
match payload {
InviteType::GroupV1(inv) => {
Ok(Some(Box::new(self.handle_heavy_invite(service_ctx, inv)?)))
let convo = self.handle_heavy_invite(service_ctx, inv)?;
Ok(Some((Box::new(convo), ConversationClass::Private)))
}
InviteType::GroupV2(welcome_bytes) => {
info!("Process WelcomeMessage");
let mw =
MemberWelcome::decode(welcome_bytes.as_slice()).map_err(ChatError::generic)?;
let convo = GroupV2Convo::new_from_welcome(service_ctx, &mw)?;
Ok(Some(Box::new(convo)))
Ok(Some((Box::new(convo), ConversationClass::Group)))
}
}
}
@ -183,11 +196,23 @@ impl InboxV2 {
fn create_keypackage<S: ExternalServices>(
cx: &ServiceContext<S>,
) -> Result<KeyPackage, ChatError> {
// Last-resort key package. openmls consumes (deletes) a normal key
// package's init key on the first welcome that uses it; since each
// installation publishes just one, a second group inviting it would find
// no matching key package and reject the welcome ("welcome not addressed
// to this member"). Last-resort key packages are retained, so one admits
// the installation to any number of groups. Every key-package extension
// must be advertised in the leaf capabilities, hence LastResort there.
let capabilities = Capabilities::builder()
.ciphersuites(vec![CIPHER_SUITE])
.extensions(vec![ExtensionType::ApplicationId])
.extensions(vec![
ExtensionType::ApplicationId,
ExtensionType::LastResort,
ExtensionType::Unknown(GROUP_METADATA_EXTENSION_TYPE),
])
.build();
let a = KeyPackage::builder()
.mark_as_last_resort()
.leaf_node_capabilities(capabilities)
.build(
CIPHER_SUITE,
@ -203,7 +228,7 @@ impl InboxV2 {
#[derive(Clone, PartialEq, Message)]
pub struct InboxV2Frame {
#[prost(oneof = "InviteType", tags = "1, 2")]
#[prost(oneof = "InviteType", tags = "1, 2, 3")]
pub payload: Option<InviteType>,
}

View File

@ -15,7 +15,16 @@ mod utils;
pub use causal_history::{Frontier, MissingMessage};
pub use chat_sqlite::ChatStorage;
pub use chat_sqlite::StorageConfig;
pub use conversation::GroupV2Clock;
pub use core::{ConversationId, Core, Introduction};
/// Timing/policy for GroupV2 conversations (de-mls's per-conversation config).
/// Defaults to the de-mls library defaults; inject via
/// [`Core::set_group_v2_config`]. The creator's phase durations (commit
/// inactivity, freeze, recovery, voting inactivity, proposal expiration,
/// consensus timeout) travel to joiners with the welcome and overwrite
/// theirs; vote delays and the policy fields stay local to each member.
pub use de_mls::ConversationConfig as GroupV2Config;
pub use de_mls::MockClock;
pub use errors::ChatError;
pub use outcomes::{
Content, ConversationClass, ConvoOutcome, InboxOutcome, NewConversation, PayloadOutcome,

View File

@ -5,6 +5,7 @@ use storage::ChatStore;
use crate::IdentityProvider;
use crate::causal_history::CausalHistoryStore;
use crate::conversation::GroupV2Clock;
use crate::inbox_v2::{MlsEphemeralPqProvider, MlsIdentityProvider};
use crate::service_traits::WakeupService;
use crate::{DeliveryService, RegistrationService};
@ -44,6 +45,12 @@ pub(crate) struct ServiceContext<S: ExternalServices> {
pub(crate) causal: CausalHistoryStore,
pub(crate) identity: Identity,
pub(crate) wakeup_service: S::WS,
/// Time source for GroupV2 (de-mls) conversations.
pub(crate) demls_clock: GroupV2Clock,
/// Timing/policy for GroupV2 (de-mls) conversations, applied at
/// create/join. The creator's phase durations reach joiners inside the
/// welcome's `ConversationSync`.
pub(crate) demls_config: de_mls::ConversationConfig,
}
#[cfg(test)]
@ -110,6 +117,8 @@ mod test_support {
causal: CausalHistoryStore::new(),
identity: Identity::new(name),
wakeup_service: NoopWakeups {},
demls_clock: GroupV2Clock::default(),
demls_config: de_mls::ConversationConfig::default(),
})
}
}

View File

@ -66,3 +66,9 @@ impl AddressedEncryptedPayload {
)
}
}
#[derive(Debug)]
pub struct ConvoMetadata {
pub name: String,
pub desc: String,
}

View File

@ -12,5 +12,5 @@ rand_core = { version = "0.6", features = ["getrandom"] }
sha2 = "0.10"
thiserror = "2"
x25519-dalek = { version = "2.0.1", features = ["static_secrets"] }
xeddsa = "1.0.2"
xeddsa = "=1.0.2"
zeroize = { version = "1.8.2", features = ["derive"] }

View File

@ -1,5 +1,6 @@
use crate::test_ident::TestIdent;
use libchat::{ConversationId, Core, IdentityProvider, PayloadOutcome};
use libchat::{GroupV2Clock, GroupV2Config};
use shared_traits::IdentId;
use std::collections::HashMap;
use std::fmt::Debug;
@ -148,9 +149,11 @@ impl<const N: usize> TestHarness<N> {
let ident = TestIdent::new(Self::names(i));
addresses.insert(i, ident.id().clone());
let core_client =
let mut core_client =
ClientType::new_with_name(ident, ds.clone(), rs.clone(), wp, MemStore::new())
.unwrap();
core_client.set_group_v2_clock(GroupV2Clock::Mock(ws.clock()));
core_client.set_group_v2_config(fast_group_v2_config());
let client = TestClient::init(core_client);
@ -291,6 +294,20 @@ impl TestHarness<4> {
}
}
/// Millisecond GroupV2 timers for virtual-time tests — the production
/// defaults converge too slowly for the harness's step sizes.
fn fast_group_v2_config() -> GroupV2Config {
GroupV2Config {
commit_inactivity_duration: Duration::from_millis(50),
freeze_duration: Duration::from_millis(20),
voting_delay: Duration::from_millis(30),
election_voting_delay: Duration::from_millis(30),
consensus_timeout: Duration::from_millis(150),
proposal_expiration: Duration::from_millis(2000),
..GroupV2Config::default()
}
}
#[cfg(test)]
mod tests {
use super::*;

View File

@ -1,4 +1,4 @@
use libchat::{ConversationId, WakeupService};
use libchat::{ConversationId, MockClock, WakeupService};
use std::cell::RefCell;
use std::cmp::Reverse;
use std::collections::BinaryHeap;
@ -87,6 +87,7 @@ impl InnerWakeupService {
pub struct TestWakeupService {
inner: Rc<RefCell<InnerWakeupService>>,
clock: MockClock,
}
impl Debug for TestWakeupService {
@ -103,9 +104,14 @@ impl TestWakeupService {
pub fn new() -> Self {
Self {
inner: Rc::new(RefCell::new(InnerWakeupService::new())),
clock: MockClock::new(),
}
}
pub fn clock(&self) -> MockClock {
self.clock.clone()
}
pub fn new_provider(&self, id: usize) -> TestWakeupProvider {
TestWakeupProvider {
service: self.inner.clone(),
@ -117,11 +123,7 @@ impl TestWakeupService {
pub fn advance_time(&mut self, duration: Duration) -> Vec<WakeupRecord> {
let mut srv = self.inner.borrow_mut();
trace!(?duration, "Advanced");
// de-mls deadlines are real wall-clock; sleep so the millisecond-scale
// commit/consensus timers actually elapse between poll cycles
// Note: This is error prone as WakeupService tracks its own `now` variable. Does not account for processing time.
std::thread::sleep(duration);
self.clock.advance(duration);
srv.now = srv.now.checked_add(duration).unwrap();
srv.get_expired()
}

View File

@ -18,7 +18,7 @@ fn groupv2_2way_roundtrip() {
let particpants = &[&harness.raya().addr()];
let convo_id = harness
.saro()
.create_group_convo_v2(particpants)
.create_group_convo_v2(particpants, "", "")
.expect("saro create group");
// Carry the invite through (commit, WelcomeReady, routing to Raya's inbox,
@ -56,7 +56,7 @@ fn core_client() {
let particpants = &[&harness.raya().addr()];
let convo_id = harness
.saro()
.create_group_convo_v2(particpants)
.create_group_convo_v2(particpants, "", "")
.expect("Saro create");
// Carry the invite through (commit, WelcomeReady, routing to Raya's inbox,
@ -114,7 +114,7 @@ fn core_client_batch_add() {
let particpants = &[&harness.raya().addr(), &harness.pax().addr()];
harness
.saro()
.create_group_convo_v2(particpants)
.create_group_convo_v2(particpants, "", "")
.expect("Saro create");
// Carry the invite through (commit, WelcomeReady, routing to Raya's inbox,
@ -144,7 +144,7 @@ fn core_client_four_members_two_epochs() {
let particpants = &[&harness.raya().addr(), &harness.pax().addr()];
let convo_id = harness
.saro()
.create_group_convo_v2(particpants)
.create_group_convo_v2(particpants, "", "")
.expect("Saro create");
// Carry the invite through (commit, WelcomeReady, routing to Raya's inbox,
@ -176,3 +176,137 @@ fn core_client_four_members_two_epochs() {
&& h.mira().check(&convo_id, MSG)
});
}
#[test]
fn group_name_propagation() {
let _ = tracing_subscriber::fmt()
.with_max_level(tracing::Level::INFO)
.with_test_writer()
.try_init();
let name = "Jankiest Friends";
let desc = "A cool group chat, with cool people";
let mut harness = TestHarness::<4>::new(|_, _| {});
let members = &[&harness.raya().addr()];
let convo_id = harness
.saro()
.create_group_convo_v2(members, name, desc)
.expect("Saro create");
// Carry the invite through (commit, WelcomeReady, routing to Raya's inbox,
// accept_welcome); settle until Raya has joined.
harness.process_until_label("Raya join", |h| h.raya().convo_count() == 1);
// Verfiy that Saro's Metadata is correct
assert_eq!(
harness.saro().convo_metadata(&convo_id).expect("meta").name,
name
);
assert_eq!(
harness.saro().convo_metadata(&convo_id).expect("meta").desc,
desc
);
// Verify that Raya has the same MetaData
assert_eq!(
harness.saro().convo_metadata(&convo_id).expect("meta").name,
harness.raya().convo_metadata(&convo_id).expect("meta").name
);
assert_eq!(
harness.saro().convo_metadata(&convo_id).expect("meta").desc,
harness.raya().convo_metadata(&convo_id).expect("meta").desc
);
// Epoch 2: Raya adds the 3rd member; settle until Pax has joined
let members = &[&harness.pax().addr()];
harness
.raya()
.group_add_member(&convo_id, members)
.expect("Add Pax");
harness.process_until_label("Pax join", |h| h.pax().convo_count() == 1);
// Verify that Pax has the same MetaData
assert_eq!(
harness.saro().convo_metadata(&convo_id).expect("meta").name,
harness.pax().convo_metadata(&convo_id).expect("meta").name
);
assert_eq!(
harness.saro().convo_metadata(&convo_id).expect("meta").desc,
harness.pax().convo_metadata(&convo_id).expect("meta").desc
);
}
#[test]
fn member_joins_two_groups() {
// The same installation is invited to two separate groups. Its single
// registered key package is consumed by the first join, so the second
// group's welcome must still admit it — otherwise it is rejected with
// "welcome not addressed to this member" and never joins. Regression for
// key-package reuse across groups.
let _ = tracing_subscriber::fmt()
.with_max_level(tracing::Level::INFO)
.with_test_writer()
.try_init();
let mut harness = TestHarness::<2>::new(|_, _| {});
let raya_addr = harness.raya().addr();
// Group 1: Saro invites Raya.
harness
.saro()
.create_group_convo_v2(&[&raya_addr], "", "")
.expect("saro create group 1");
harness.process_until_label("raya joins group 1", |h| h.raya().convo_count() == 1);
// Group 2: Saro invites Raya again, into a fresh group.
harness
.saro()
.create_group_convo_v2(&[&raya_addr], "", "")
.expect("saro create group 2");
harness.process_until_label("raya joins group 2", |h| h.raya().convo_count() == 2);
assert_eq!(
harness.raya().convo_count(),
2,
"raya did not join the second group"
);
}
#[test]
fn direct_v1_then_group_v2_reuses_key_package() {
// The reported flake: a DirectV1 (pairwise) conversation is opened with a
// peer first, then the same peer is invited to a GroupV2. Both fetch the
// peer's single registered key package; the DirectV1 join consumes it, so
// without a last-resort key package the group welcome finds no key package
// and is rejected ("welcome not addressed to this member").
let _ = tracing_subscriber::fmt()
.with_max_level(tracing::Level::INFO)
.with_test_writer()
.try_init();
let mut harness = TestHarness::<2>::new(|_, _| {});
let raya_addr = harness.raya().addr();
// 1. DirectV1 with Raya.
harness
.saro()
.create_direct_convo_v1(&[&raya_addr])
.expect("saro create direct");
harness.process_until_label("raya joins direct", |h| h.raya().convo_count() == 1);
// 2. GroupV2 inviting the same Raya.
harness
.saro()
.create_group_convo_v2(&[&raya_addr], "", "")
.expect("saro create group");
harness.process_until_label("raya joins group", |h| h.raya().convo_count() == 2);
assert_eq!(
harness.raya().convo_count(),
2,
"raya did not join the group after a direct chat"
);
}

View File

@ -1,7 +0,0 @@
//! Baked-in configuration for the Logos service stack.
/// The endpoint for the account and keypackage registration service.
pub const REGISTRY_ENDPOINT: &str = "https://devnet.chat-kc.logos.co";
/// The logos-delivery network preset the Logos client joins by default.
pub const NETWORK_PRESET: &str = "logos.dev";

View File

@ -1,89 +0,0 @@
//! The opinionated Logos client.
//!
//! `LogosChatClient` commits to the Logos service stack so independently built
//! clients share the same production services instead of each re-deriving them:
//! a delegate identity, the HTTP keypackage + account registry, encrypted
//! on-disk storage, and — unlike the generic [`ChatClientBuilder`] — the
//! logos-delivery transport itself. logos-delivery *is* the transport for the
//! Logos client, so the caller no longer supplies one; only per-client secrets
//! (the database path and key) and the network config are passed in.
//!
//! The logos-delivery transport carries a native dependency, so this whole
//! module is gated behind the `embedded-p2p-delivery` cargo feature. The registry
//! endpoint lives in [`crate::config`] instead, so it stays available to other
//! transports when the feature is off.
use components::{EmbeddedP2pDeliveryService, HttpRegistry, P2pConfig};
use crossbeam_channel::Receiver;
use libchat::{ChatStorage, StorageConfig};
use logos_account::TestLogosAccount;
use crate::ChatClientBuilder;
use crate::client::{ChatClient, Transport};
use crate::config::{NETWORK_PRESET, REGISTRY_ENDPOINT};
use crate::delegate::DelegateSigner;
use crate::errors::ClientError;
use crate::event::Event;
// logos-delivery already implements `DeliveryService`; teaching it the inbound
// half here (in the crate that owns `Transport`) makes it a full transport, so
// callers need no wrapper newtype.
impl Transport for EmbeddedP2pDeliveryService {
fn inbound(&mut self) -> Receiver<Vec<u8>> {
self.inbound_queue()
}
}
/// A [`ChatClient`] wired to the Logos service stack: a [`DelegateSigner`]
/// identity acting for a fresh dev account, the HTTP keypackage + account
/// registry ([`HttpRegistry`], which is both the keypackage store and the
/// account → device directory), encrypted [`ChatStorage`], and the
/// logos-delivery transport.
pub type LogosChatClient = ChatClient<EmbeddedP2pDeliveryService, HttpRegistry, ChatStorage>;
impl LogosChatClient {
/// Open a client on the Logos stack, starting a logos-delivery node on
/// `tcp_port` as its transport and persisting to the encrypted database at
/// `db_path` unlocked with `db_key`. When `preset`/`registry_url` are `Some`,
/// they override the baked-in network preset/registry endpoint (e.g. a local
/// deployment); otherwise the preconfigured values are used.
///
/// `db_path` is a per-client location, `db_key` is a secret, and `tcp_port`
/// is a per-client local resource, so all three are caller-supplied — never
/// baked into the library.
pub fn open(
db_path: impl Into<String>,
db_key: impl Into<String>,
tcp_port: u16,
preset: Option<&str>,
registry_url: Option<&str>,
) -> Result<(Self, Receiver<Event>), ClientError> {
let transport = EmbeddedP2pDeliveryService::start(P2pConfig {
preset: preset.unwrap_or(NETWORK_PRESET).to_string(),
tcp_port,
..Default::default()
})
.map_err(|e| ClientError::Transport(e.to_string()))?;
let endpoint = registry_url.unwrap_or(REGISTRY_ENDPOINT);
// A fresh account endorsing a fresh delegate each open: the account
// key is dropped after publishing the bundle, so devices cannot be
// added later. A caller-supplied, custody-holding account replaces
// this once the platform provides one.
let account = TestLogosAccount::new();
let delegate = DelegateSigner::random();
let mut registry = HttpRegistry::new(endpoint);
account
.add_delegate_signer(&mut registry, delegate.public_key())
.map_err(|e| ClientError::BundlePublish(e.to_string()))?;
ChatClientBuilder::new(account.address())
.ident(delegate)
.transport(transport)
.registration(registry)
.storage_config(StorageConfig::Encrypted {
path: db_path.into(),
key: db_key.into(),
})
.build()
}
}

View File

@ -1,14 +1,11 @@
[package]
name = "logos-chat"
name = "logos-generic-chat"
version = "0.1.0"
edition = "2024"
[lib]
crate-type = ["rlib"]
[features]
embedded-p2p-delivery = ["components/embedded_p2p_delivery"]
[dependencies]
# Workspace dependencies (sorted)
chat-sqlite = { workspace = true }

View File

@ -1,9 +1,9 @@
# message-exchange
An example Rust application built on top of [`crates/client`](../../).
An example Rust application built on top of [`crates/generic-chat`](../../).
It demonstrates that creating a working chat client in pure Rust is trivial: depend on
`crates/client`, pick a `DeliveryService` implementation (here the in-memory
`crates/generic-chat`, pick a `DeliveryService` implementation (here the in-memory
`InProcessDelivery` shipped with the crate), and wire up `ChatClient`. No boilerplate, no FFI.
## Running

View File

@ -1,6 +1,6 @@
use components::EphemeralRegistry;
use logos_account::TestLogosAccount;
use logos_chat::{ChatClientBuilder, Event, InProcessDelivery, MessageBus};
use logos_generic_chat::{ChatClientBuilder, Event, InProcessDelivery, MessageBus};
use std::time::Duration;
fn main() {

View File

@ -1,6 +1,6 @@
use components::EphemeralRegistry;
use crossbeam_channel::Receiver;
use libchat::{ChatError, ChatStorage, RegistrationService, StorageConfig};
use libchat::{ChatError, ChatStorage, GroupV2Config, RegistrationService, StorageConfig};
use logos_account::AccountDirectory;
use storage::ChatStore;
@ -20,6 +20,7 @@ pub struct ChatClientBuilder<I = Unset, T = Unset, R = Unset, S = Unset> {
transport: T,
registration: R,
storage: S,
group_v2: Option<GroupV2Config>,
}
impl ChatClientBuilder {
@ -35,6 +36,7 @@ impl ChatClientBuilder {
transport: Unset,
registration: Unset,
storage: Unset,
group_v2: None,
}
}
}
@ -47,6 +49,7 @@ impl<I, T, R, S> ChatClientBuilder<I, T, R, S> {
transport: self.transport,
registration: self.registration,
storage: self.storage,
group_v2: self.group_v2,
}
}
@ -57,6 +60,7 @@ impl<I, T, R, S> ChatClientBuilder<I, T, R, S> {
transport,
registration: self.registration,
storage: self.storage,
group_v2: self.group_v2,
}
}
@ -67,6 +71,7 @@ impl<I, T, R, S> ChatClientBuilder<I, T, R, S> {
transport: self.transport,
registration,
storage: self.storage,
group_v2: self.group_v2,
}
}
@ -77,6 +82,7 @@ impl<I, T, R, S> ChatClientBuilder<I, T, R, S> {
transport: self.transport,
registration: self.registration,
storage,
group_v2: self.group_v2,
}
}
@ -91,8 +97,18 @@ impl<I, T, R, S> ChatClientBuilder<I, T, R, S> {
transport: self.transport,
registration: self.registration,
storage,
group_v2: self.group_v2,
}
}
/// Timing/policy for GroupV2 conversations this client creates or joins.
/// Defaults to the de-mls library defaults; the creator's phase durations
/// travel to joiners with the welcome and overwrite theirs (vote delays
/// and policy fields stay local).
pub fn group_v2_config(mut self, config: GroupV2Config) -> Self {
self.group_v2 = Some(config);
self
}
}
type Built<T, R, S> = Result<(ChatClient<T, R, S>, Receiver<Event>), ClientError>;
@ -111,6 +127,7 @@ where
self.transport,
self.registration,
self.storage,
self.group_v2,
)
}
}
@ -124,6 +141,7 @@ impl<T: Transport + Send + 'static> ChatClientBuilder<Unset, T, Unset, Unset> {
self.transport,
EphemeralRegistry::new(),
ChatStorage::in_memory(),
self.group_v2,
)
}
}
@ -140,6 +158,7 @@ where
self.transport,
EphemeralRegistry::new(),
ChatStorage::in_memory(),
self.group_v2,
)
}
}
@ -157,6 +176,7 @@ where
self.transport,
self.registration,
ChatStorage::in_memory(),
self.group_v2,
)
}
}
@ -174,6 +194,7 @@ where
self.transport,
EphemeralRegistry::new(),
self.storage,
self.group_v2,
)
}
}
@ -191,6 +212,7 @@ where
self.transport,
self.registration,
ChatStorage::in_memory(),
self.group_v2,
)
}
}
@ -209,6 +231,7 @@ where
self.transport,
self.registration,
self.storage,
self.group_v2,
)
}
}
@ -226,6 +249,7 @@ where
self.transport,
EphemeralRegistry::new(),
self.storage,
self.group_v2,
)
}
}

View File

@ -1,3 +1,4 @@
use std::collections::HashSet;
use std::sync::Arc;
use std::thread::{self, JoinHandle};
@ -5,8 +6,8 @@ use components::{ThreadedWakeupService, WakeupEvent};
use crossbeam_channel::{Receiver, Sender, select};
use crypto::Ed25519VerifyingKey;
use libchat::{
ConversationId, ConvoOutcome, Core, DeliveryService, IdentId, IdentIdRef, InboxOutcome,
Introduction, PayloadOutcome, RegistrationService,
ConversationId, ConvoOutcome, Core, DeliveryService, GroupV2Config, IdentId, IdentIdRef,
InboxOutcome, Introduction, PayloadOutcome, RegistrationService,
};
use logos_account::{AccountDirectory, resolve_device_ids};
use parking_lot::Mutex;
@ -20,6 +21,19 @@ type ClientCore<T, R, S> = Core<(DelegateIdentity, T, R, ThreadedWakeupService,
type AccountAddressRef<'a> = &'a str;
type LocalSignerId = IdentId;
/// A member of a group conversation's roster.
///
/// Shares [`MessageSender`]'s field semantics: `account` is set only when the
/// member's credential claimed an account *and* the directory confirmed this
/// device belongs to it. Unlike a message sender, an unconfirmable claim does
/// not hide the member: it is cryptographically in the group, so it is listed
/// by `local_identity` (its device) with `account: None`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GroupMember {
pub account: Option<IdentId>,
pub local_identity: IdentId,
}
/// The transport as the client sees it: a [`DeliveryService`] for outbound
/// publishing plus the inbound payload stream the worker drains. One object owns
/// both directions of the boundary.
@ -73,6 +87,7 @@ where
mut transport: T,
reg: R,
storage: S,
group_v2: Option<GroupV2Config>,
) -> Result<(Self, Receiver<Event>), ClientError> {
let inbound = transport.inbound();
@ -80,7 +95,10 @@ where
let wakeup_service = ThreadedWakeupService::new(wakeup_tx);
let directory = reg.clone();
let ident = DelegateIdentity::new(ident, &account);
let core = Core::new_with_name(ident, transport, reg, wakeup_service, storage)?;
let mut core = Core::new_with_name(ident, transport, reg, wakeup_service, storage)?;
if let Some(config) = group_v2 {
core.set_group_v2_config(config);
}
Ok(Self::spawn(core, directory, account, inbound, wakeup_rx))
}
@ -151,6 +169,55 @@ where
.map_err(Into::into)
}
/// Create a GroupV2 conversation with the given accounts' devices. Each
/// account resolves to the signer ids its directory bundle endorses; the
/// group invite goes to every one of them. An empty slice creates a group
/// with only this client, to grow via [`Self::add_group_members`].
pub fn create_group_conversation(
&mut self,
accounts: &[AccountAddressRef],
) -> Result<ConversationId, ClientError> {
let signers = self.signers_from_accounts(accounts)?;
let signer_refs: Vec<IdentIdRef> = signers.iter().collect();
self.core
.lock()
.create_group_convo(&signer_refs)
.map_err(Into::into)
}
/// Add accounts' devices to an existing group conversation. The add is
/// staged as an MLS proposal and merged by the group's next commit (driven
/// asynchronously by the wakeup loop); each joiner's welcome is sent when
/// that commit lands, not when this call returns.
pub fn add_group_members(
&mut self,
convo_id: &str,
accounts: &[AccountAddressRef],
) -> Result<(), ClientError> {
let signers = self.signers_from_accounts(accounts)?;
let signer_refs: Vec<IdentIdRef> = signers.iter().collect();
self.core
.lock()
.group_add_member(convo_id, &signer_refs)
.map_err(Into::into)
}
/// The group's roster, one [`GroupMember`] per account (self included). An
/// account's several devices collapse to a single entry surfacing that
/// account; a member whose account claim the directory can't confirm stays
/// on the roster individually, keyed by its device. Costs one directory
/// lookup per member that claims an account, the same per-member cost a
/// received message's sender check pays.
pub fn group_members(&mut self, convo_id: &str) -> Result<Vec<GroupMember>, ClientError> {
let credentials = self.core.lock().group_members(convo_id)?;
let members = credentials
.iter()
.filter_map(|credential| roster_member(&self.directory, credential));
Ok(dedup_members(members))
}
/// Parse intro bundle bytes and initiate a private conversation. Outbound
/// envelopes are published by the core. Returns this side's conversation ID.
///
@ -193,6 +260,19 @@ where
.map_err(|e| ClientError::AccountResolution(e.to_string()))?;
Ok(device_ids.into_iter().map(IdentId::new).collect())
}
/// Resolve each account to its signer ids and flatten them, failing on the
/// first unresolvable account.
fn signers_from_accounts(
&self,
accounts: &[AccountAddressRef],
) -> Result<Vec<LocalSignerId>, ClientError> {
let mut signers = Vec::new();
for account in accounts {
signers.extend(self.signers_from_account(account)?);
}
Ok(signers)
}
}
impl<T, R, S> Drop for ChatClient<T, R, S>
@ -298,6 +378,62 @@ enum SenderError {
Unverified,
}
/// The resolution of a credential's account claim against the directory.
enum AccountClaim {
/// The credential claimed no account.
None,
/// Confirmed: the directory lists this device under the claimed account.
Verified(IdentId),
/// An account was claimed but could not be confirmed (see [`SenderError`]).
Unverified(SenderError),
}
/// Parse a wire credential into the device it names and the resolution of any
/// account claim, checked against the account → device directory. `Err` only
/// when no device can be attributed at all (missing or unparseable credential).
///
/// The account-claim policy is left to the caller: a message drops on an
/// unconfirmable claim, a roster entry keeps the device and forgoes the account.
fn parse_credential(
directory: &impl AccountDirectory,
encoded: &[u8],
) -> Result<(IdentId, AccountClaim), SenderError> {
// No credential at all: there is no device to attribute.
if encoded.is_empty() {
return Err(SenderError::Missing);
}
let Ok(data) = hex::decode(encoded) else {
tracing::warn!("credential is not valid hex");
return Err(SenderError::NotHex);
};
let Ok(cred) = DelegateCredential::try_from(data) else {
tracing::warn!("malformed credential");
return Err(SenderError::Malformed);
};
let device = IdentId::new(hex::encode(cred.delegate_id().as_ref()));
// An unassociated delegate asserts no account → device mapping.
let Some(account_addr) = cred.account_addr() else {
return Ok((device, AccountClaim::None));
};
let Some(account_key) = account_key_from_hex(account_addr) else {
tracing::warn!(account_addr, "account address is not a verifying key");
return Ok((
device,
AccountClaim::Unverified(SenderError::AccountNotAKey),
));
};
let claim = match directory.fetch(&account_key) {
Ok(Some(set)) if set.devices.iter().any(|d| d.as_str() == device.as_str()) => {
AccountClaim::Verified(IdentId::new(account_addr.to_string()))
}
_ => {
tracing::warn!(account_addr, device = %device.as_str(), "account → device mapping is wrong or unconfirmable");
AccountClaim::Unverified(SenderError::Unverified)
}
};
Ok((device, claim))
}
/// Decode and verify a message's sender from its credential, checked against the
/// account → device directory (our account store).
///
@ -309,48 +445,62 @@ fn decode_sender(
directory: &impl AccountDirectory,
encoded: &[u8],
) -> Result<MessageSender, SenderError> {
// No credential at all: there is no sender to attribute, so drop it.
if encoded.is_empty() {
return Err(SenderError::Missing);
}
let Ok(data) = hex::decode(encoded) else {
tracing::warn!("sender credential is not valid hex; dropping message");
return Err(SenderError::NotHex);
};
let cred = match DelegateCredential::try_from(data) {
Ok(cred) => cred,
Err(_) => {
tracing::warn!("malformed sender credential; dropping message");
return Err(SenderError::Malformed);
}
};
let device = hex::encode(cred.delegate_id().as_ref());
// An unassociated delegate asserts no account → device mapping.
let Some(account_addr) = cred.account_addr() else {
return Ok(MessageSender {
let (device, claim) = parse_credential(directory, encoded)?;
match claim {
AccountClaim::None => Ok(MessageSender {
account: None,
local_identity: IdentId::new(device),
});
};
let Some(account_key) = account_key_from_hex(account_addr) else {
tracing::warn!(
account_addr,
"sender account address is not a verifying key; dropping message"
);
return Err(SenderError::AccountNotAKey);
};
match directory.fetch(&account_key) {
Ok(Some(set)) if set.devices.iter().any(|d| d == &device) => Ok(MessageSender {
account: Some(IdentId::new(account_addr.to_string())),
local_identity: IdentId::new(device),
local_identity: device,
}),
_ => {
tracing::warn!(account_addr, %device, "account → device mapping is wrong or unconfirmable; dropping message");
Err(SenderError::Unverified)
}
AccountClaim::Verified(account) => Ok(MessageSender {
account: Some(account),
local_identity: device,
}),
// An unconfirmable account claim drops the message: every delivered
// message must carry a verified sender.
AccountClaim::Unverified(err) => Err(err),
}
}
/// Map a group member's credential (as reported by MLS, in the same hex-encoded
/// form a message carries as its sender) to a roster entry, tolerating an
/// unconfirmable account claim by listing the device without an account. `None`
/// only when the credential cannot be parsed, which does not happen for a real
/// MLS leaf.
fn roster_member(directory: &impl AccountDirectory, encoded: &[u8]) -> Option<GroupMember> {
let (device, claim) = parse_credential(directory, encoded).ok()?;
let account = match claim {
AccountClaim::Verified(account) => Some(account),
AccountClaim::None | AccountClaim::Unverified(_) => None,
};
Some(GroupMember {
account,
local_identity: device,
})
}
/// The key that decides whether two roster entries are the same member: a
/// verified account, so an account's several devices count once; or, for a
/// member with no confirmed account, its device — unique per MLS leaf, so it
/// never merges with another.
fn member_key(member: &GroupMember) -> &str {
member
.account
.as_ref()
.unwrap_or(&member.local_identity)
.as_str()
}
/// Collapse a roster to one entry per account (keeping the first-seen device as
/// the account's representative) while leaving account-less members individual,
/// order preserved.
fn dedup_members(members: impl IntoIterator<Item = GroupMember>) -> Vec<GroupMember> {
let mut seen = HashSet::new();
members
.into_iter()
.filter(|member| seen.insert(member_key(member).to_owned()))
.collect()
}
fn convo_events(outcome: ConvoOutcome, directory: &impl AccountDirectory) -> Vec<Event> {
let ConvoOutcome { convo_id, content } = outcome;
content
@ -397,7 +547,10 @@ mod sender_check_tests {
use libchat::IdentId;
use logos_account::{DeviceSet, SignedDeviceBundle};
use super::{MessageSender, SenderError, decode_sender};
use super::{
GroupMember, MessageSender, SenderError, decode_sender, dedup_members, member_key,
roster_member,
};
use crate::delegate::DelegateCredential;
/// In-test account → device directory. Holds device id sets keyed by the hex
@ -567,4 +720,117 @@ mod sender_check_tests {
Err(SenderError::AccountNotAKey)
);
}
/// A verified account claim surfaces the member's account and device — the
/// same happy path as a message sender.
#[test]
fn roster_verified_member_surfaces_account() {
let account = key();
let device = key();
let dir = FakeDir::with_devices(&account, &[&device]);
let cred = DelegateCredential::associated(&device, &hex::encode(account.as_ref()));
assert_eq!(
roster_member(&dir, &encoded(cred)),
Some(GroupMember {
account: Some(local_id(&account)),
local_identity: local_id(&device),
})
);
}
/// Unlike a message sender, a spoofed account claim does not hide the
/// member: the device is cryptographically in the group, so it is listed
/// with no account rather than dropped.
#[test]
fn roster_contradicted_claim_lists_device_without_account() {
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()));
assert_eq!(
roster_member(&dir, &encoded(cred)),
Some(GroupMember {
account: None,
local_identity: local_id(&spoofer),
})
);
}
/// A member whose credential claims no account is listed by device only.
#[test]
fn roster_unassociated_member_lists_device_without_account() {
let dir = FakeDir::default();
let device = key();
let cred = DelegateCredential::unassociated(&device);
assert_eq!(
roster_member(&dir, &encoded(cred)),
Some(GroupMember {
account: None,
local_identity: local_id(&device),
})
);
}
/// A directory outage leaves the account unconfirmed, but the member stays
/// on the roster by device (a message would drop here).
#[test]
fn roster_directory_outage_lists_device_without_account() {
let account = key();
let device = key();
let dir = FakeDir {
fail: true,
..Default::default()
};
let cred = DelegateCredential::associated(&device, &hex::encode(account.as_ref()));
assert_eq!(
roster_member(&dir, &encoded(cred)),
Some(GroupMember {
account: None,
local_identity: local_id(&device),
})
);
}
/// A non-key account address can't be confirmed, so the member is listed by
/// device without an account.
#[test]
fn roster_non_key_account_lists_device_without_account() {
let dir = FakeDir::default();
let device = key();
let cred = DelegateCredential::associated(&device, "user@example.com");
assert_eq!(
roster_member(&dir, &encoded(cred)),
Some(GroupMember {
account: None,
local_identity: local_id(&device),
})
);
}
/// The roster collapses an account's several devices into one entry (keeping
/// the first device seen) while leaving account-less members individual,
/// order preserved.
#[test]
fn dedup_collapses_account_devices_and_keeps_unknowns() {
let with_account = |account: &str, device: &str| GroupMember {
account: Some(IdentId::new(account.to_string())),
local_identity: IdentId::new(device.to_string()),
};
let device_only = |device: &str| GroupMember {
account: None,
local_identity: IdentId::new(device.to_string()),
};
let roster = dedup_members(vec![
with_account("alice", "alice-dev-1"),
with_account("alice", "alice-dev-2"),
device_only("orphan-x"),
with_account("bob", "bob-dev-1"),
device_only("orphan-y"),
]);
let keys: Vec<&str> = roster.iter().map(member_key).collect();
assert_eq!(keys, ["alice", "orphan-x", "bob", "orphan-y"]);
// Alice's collapsed entry keeps her first-seen device.
assert_eq!(roster[0].local_identity.as_str(), "alice-dev-1");
}
}

View File

@ -1,27 +1,21 @@
mod builder;
mod client;
mod config;
mod delegate;
mod delivery_in_process;
mod errors;
mod event;
#[cfg(feature = "embedded-p2p-delivery")]
mod logos;
pub use builder::{ChatClientBuilder, Unset};
pub use client::{ChatClient, Transport};
pub use config::{NETWORK_PRESET, REGISTRY_ENDPOINT};
pub use client::{ChatClient, GroupMember, Transport};
pub use delegate::DelegateSigner;
pub use delivery_in_process::{InProcessDelivery, MessageBus};
pub use errors::ClientError;
pub use event::{Event, MessageSender};
#[cfg(feature = "embedded-p2p-delivery")]
pub use logos::LogosChatClient;
// Re-export types callers need to interact with ChatClient.
pub use libchat::{
AddressedEnvelope, ChatStore, ConversationClass, ConversationId, DeliveryService,
IdentityProvider, RegistrationService, StorageConfig,
GroupV2Config, IdentityProvider, RegistrationService, StorageConfig,
};
// The directory trait bounds ChatClient's registry parameter, so callers
// writing code generic over ChatClient need it too.

View File

@ -0,0 +1,338 @@
//! GroupV2 through the threaded client: three accounts on the in-process
//! transport, driven purely over the public `ChatClient` API and its event
//! channel. Group commits and welcomes are minted asynchronously by de-mls
//! (wakeup-driven), so assertions wait for events rather than expecting a
//! fixed sequence.
use std::time::Duration;
use components::EphemeralRegistry;
use crossbeam_channel::Receiver;
use libchat::ChatStorage;
use logos_account::TestLogosAccount;
use logos_generic_chat::{
ChatClient, ChatClientBuilder, ConversationClass, DelegateSigner, Event, GroupV2Config,
InProcessDelivery, MessageBus,
};
/// Millisecond GroupV2 timers so the de-mls commit/consensus dance completes
/// in test time; the library defaults wait 60s before committing an add.
fn fast_group_v2_config() -> GroupV2Config {
GroupV2Config {
commit_inactivity_duration: Duration::from_millis(50),
freeze_duration: Duration::from_millis(20),
voting_delay: Duration::from_millis(30),
election_voting_delay: Duration::from_millis(30),
consensus_timeout: Duration::from_millis(150),
proposal_expiration: Duration::from_millis(2000),
..GroupV2Config::default()
}
}
type TestClient = ChatClient<InProcessDelivery, EphemeralRegistry, ChatStorage>;
/// A client for a fresh account: mints the account and a delegate, publishes
/// the endorsing bundle, and builds the client on the shared bus/registry with
/// the fast GroupV2 timers. Returns the account address peers invite by.
fn create_test_client(
message_bus: MessageBus,
mut reg: EphemeralRegistry,
) -> (TestClient, Receiver<Event>, String) {
let account = TestLogosAccount::new();
let delegate = DelegateSigner::random();
account
.add_delegate_signer(&mut reg, delegate.public_key())
.unwrap();
let (client, events) = ChatClientBuilder::new(account.address())
.ident(delegate)
.transport(InProcessDelivery::new(message_bus))
.registration(reg)
.group_v2_config(fast_group_v2_config())
.build()
.expect("client create");
let addr = client.addr().to_string();
(client, events, addr)
}
/// Wait until an event matching `f` arrives, skipping unrelated events (group
/// protocol traffic can interleave observations); panic after `timeout`.
fn wait_for_event<F, T>(events: &Receiver<Event>, label: &str, timeout: Duration, mut f: F) -> T
where
F: FnMut(&Event) -> Option<T>,
{
let deadline = std::time::Instant::now() + timeout;
loop {
let remaining = deadline
.checked_duration_since(std::time::Instant::now())
.unwrap_or_else(|| panic!("timed out waiting for {label}"));
match events.recv_timeout(remaining) {
Ok(event) => {
if let Some(out) = f(&event) {
return out;
}
}
Err(_) => panic!("timed out waiting for {label}"),
}
}
}
/// Wait for the group conversation to start on a joiner and return its id.
fn wait_for_group_started(events: &Receiver<Event>, label: &str) -> String {
wait_for_event(events, label, Duration::from_secs(10), |e| match e {
Event::ConversationStarted { convo_id, class } => {
assert_eq!(*class, ConversationClass::Group);
Some(convo_id.to_string())
}
_ => None,
})
}
/// Poll a client's roster for `convo_id` until its verified accounts equal
/// `expected` (order-independent), or panic after a timeout. The roster settles
/// asynchronously as each member applies the add commit, so it is polled rather
/// than snapshotted.
fn wait_for_members(client: &mut TestClient, convo_id: &str, expected: &[&str]) {
use std::collections::BTreeSet;
let want: BTreeSet<&str> = expected.iter().copied().collect();
let deadline = std::time::Instant::now() + Duration::from_secs(10);
loop {
let roster = client.group_members(convo_id).expect("group_members");
let got: BTreeSet<&str> = roster
.iter()
.filter_map(|m| m.account.as_ref().map(|a| a.as_str()))
.collect();
if got == want {
return;
}
if std::time::Instant::now() >= deadline {
panic!("roster did not converge for {convo_id}: got {got:?}, want {want:?}");
}
std::thread::sleep(Duration::from_millis(20));
}
}
/// Wait for `content` to arrive and return the sender's verified account.
fn wait_for_message(events: &Receiver<Event>, content: &[u8]) -> Option<String> {
let label = format!("MessageReceived({})", String::from_utf8_lossy(content));
wait_for_event(events, &label, Duration::from_secs(10), |e| match e {
Event::MessageReceived {
content: got,
sender,
..
} if got == content => Some(sender.account.as_ref().map(|a| a.as_str().to_string())),
_ => None,
})
}
/// A three-account group: saro creates it with raya, raya (a non-creator)
/// adds pax, and a message from each member reaches both others with a
/// directory-verified sender account.
#[test]
fn group_v2_three_members() {
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])
.expect("saro create group");
// The invite lands once saro's steward commit finalizes (wakeup-driven);
// both sides then share the de-mls conversation id.
let raya_convo_id = wait_for_group_started(&raya_events, "raya ConversationStarted");
assert_eq!(raya_convo_id, convo_id);
// Both sides see the two-account roster once the add commits.
wait_for_members(&mut saro, &convo_id, &[&saro_addr, &raya_addr]);
wait_for_members(&mut raya, &raya_convo_id, &[&saro_addr, &raya_addr]);
saro.send_message(&convo_id, b"hello raya").unwrap();
assert_eq!(
wait_for_message(&raya_events, b"hello raya").as_deref(),
Some(saro_addr.as_str())
);
raya.send_message(&raya_convo_id, b"hi saro").unwrap();
assert_eq!(
wait_for_message(&saro_events, b"hi saro").as_deref(),
Some(raya_addr.as_str())
);
// A non-creator grows the group: raya proposes pax, the steward commits,
// and raya (who holds the pending invite) routes the welcome to pax.
raya.add_group_members(&raya_convo_id, &[&pax_addr])
.expect("raya add pax");
let pax_convo_id = wait_for_group_started(&pax_events, "pax ConversationStarted");
assert_eq!(pax_convo_id, convo_id);
// Everyone is at the post-add epoch: a message from the creator reaches
// both peers, and one from the newest member reaches both elders.
saro.send_message(&convo_id, b"all three?").unwrap();
assert_eq!(
wait_for_message(&raya_events, b"all three?").as_deref(),
Some(saro_addr.as_str())
);
assert_eq!(
wait_for_message(&pax_events, b"all three?").as_deref(),
Some(saro_addr.as_str())
);
pax.send_message(&pax_convo_id, b"pax is in").unwrap();
assert_eq!(
wait_for_message(&saro_events, b"pax is in").as_deref(),
Some(pax_addr.as_str())
);
assert_eq!(
wait_for_message(&raya_events, b"pax is in").as_deref(),
Some(pax_addr.as_str())
);
// All three rosters converge on the same three accounts.
let all = [saro_addr.as_str(), raya_addr.as_str(), pax_addr.as_str()];
wait_for_members(&mut saro, &convo_id, &all);
wait_for_members(&mut raya, &raya_convo_id, &all);
wait_for_members(&mut pax, &pax_convo_id, &all);
assert_eq!(saro.list_conversations().unwrap().len(), 1);
assert_eq!(raya.list_conversations().unwrap().len(), 1);
assert_eq!(pax.list_conversations().unwrap().len(), 1);
}
/// The same two peers are invited to several groups at once. Each installation
/// registers a single key package, so admitting it to more than one group only
/// works if that key package survives a join — a regression guard for the
/// multi-group "welcome not addressed to this member" flake.
#[test]
fn peers_invited_to_many_groups() {
const GROUPS: usize = 3;
let bus = MessageBus::default();
let reg = EphemeralRegistry::new();
let (mut saro, _saro_events, saro_addr) = create_test_client(bus.clone(), reg.clone());
let (_raya, raya_events, raya_addr) = create_test_client(bus.clone(), reg.clone());
let (_pax, pax_events, pax_addr) = create_test_client(bus.clone(), reg.clone());
// Saro opens several groups, each inviting both Raya and Pax; every group
// reuses Raya's and Pax's one key package.
let mut convo_ids = Vec::new();
for _ in 0..GROUPS {
convo_ids.push(
saro.create_group_conversation(&[&raya_addr, &pax_addr])
.expect("saro create group"),
);
}
// Both peers must join all of them.
for _ in 0..GROUPS {
wait_for_group_started(&raya_events, "raya joins a group");
wait_for_group_started(&pax_events, "pax joins a group");
}
// Every group is live: a distinct message in each reaches both peers with
// the creator's verified account.
for (i, convo_id) in convo_ids.iter().enumerate() {
let msg = format!("hello group {i}").into_bytes();
saro.send_message(convo_id, &msg).unwrap();
assert_eq!(
wait_for_message(&raya_events, &msg).as_deref(),
Some(saro_addr.as_str())
);
assert_eq!(
wait_for_message(&pax_events, &msg).as_deref(),
Some(saro_addr.as_str())
);
}
assert_eq!(saro.list_conversations().unwrap().len(), GROUPS);
}
/// The creator is in its own roster from the start, with no other members: the
/// roster always includes self.
#[test]
fn group_creator_is_in_own_roster() {
let bus = MessageBus::default();
let reg = EphemeralRegistry::new();
let (mut saro, _saro_events, saro_addr) = create_test_client(bus.clone(), reg.clone());
let convo_id = saro.create_group_conversation(&[]).expect("empty group");
let roster = saro.group_members(&convo_id).expect("group_members");
let accounts: Vec<Option<&str>> = roster
.iter()
.map(|m| m.account.as_ref().map(|a| a.as_str()))
.collect();
assert_eq!(accounts, vec![Some(saro_addr.as_str())]);
}
/// A batch add is validated before any member is proposed: a member whose
/// account is endorsed in the directory but whose device registered no key
/// package fails the whole call, the resolvable member in the same batch is
/// not invited, and the group keeps working.
#[test]
fn add_batch_with_missing_key_package_invites_no_one() {
let bus = MessageBus::default();
let mut reg = EphemeralRegistry::new();
let (mut saro, _saro_events, _saro_addr) = create_test_client(bus.clone(), reg.clone());
let (_raya, raya_events, raya_addr) = create_test_client(bus.clone(), reg.clone());
let (_pax, pax_events, pax_addr) = create_test_client(bus.clone(), reg.clone());
// Ghost: its account endorses a device in the directory, but that device
// never registered a key package (no client was built for it).
let ghost_account = TestLogosAccount::new();
let ghost_delegate = DelegateSigner::random();
ghost_account
.add_delegate_signer(&mut reg, ghost_delegate.public_key())
.unwrap();
let convo_id = saro
.create_group_conversation(&[&raya_addr])
.expect("saro create group");
wait_for_group_started(&raya_events, "raya ConversationStarted");
saro.add_group_members(&convo_id, &[&ghost_account.address(), &pax_addr])
.expect_err("ghost has no key package");
// Pax was in the failed batch and must not have been invited.
assert!(
pax_events.recv_timeout(Duration::from_secs(1)).is_err(),
"pax must not join from a failed batch"
);
// The failed add left the group functional.
saro.send_message(&convo_id, b"still alive").unwrap();
wait_for_message(&raya_events, b"still alive");
}
/// Group membership is resolved through the account directory, so inviting an
/// address whose account never published a bundle fails at resolution — on
/// create and on add alike.
#[test]
fn group_invite_of_unpublished_account_is_an_error() {
let bus = MessageBus::default();
let reg = EphemeralRegistry::new();
let (mut saro, _saro_events, _saro_addr) = create_test_client(bus.clone(), reg.clone());
let unpublished = TestLogosAccount::new();
let err = saro
.create_group_conversation(&[&unpublished.address()])
.expect_err("no bundle published for the account");
assert!(matches!(
err,
logos_generic_chat::ClientError::AccountResolution(_)
));
let convo_id = saro.create_group_conversation(&[]).expect("empty group");
let err = saro
.add_group_members(&convo_id, &[&unpublished.address()])
.expect_err("no bundle published for the account");
assert!(matches!(
err,
logos_generic_chat::ClientError::AccountResolution(_)
));
}

View File

@ -4,9 +4,9 @@ use components::EphemeralRegistry;
use crossbeam_channel::{Receiver, Sender};
use crypto::Ed25519VerifyingKey;
use logos_account::TestLogosAccount;
use logos_chat::{
AddressedEnvelope, ChatClient, ChatClientBuilder, DelegateSigner, DeliveryService, Event,
InProcessDelivery, MessageBus, Transport,
use logos_generic_chat::{
AddressedEnvelope, ChatClient, ChatClientBuilder, ConversationClass, DelegateSigner,
DeliveryService, Event, InProcessDelivery, MessageBus, Transport,
};
/// Publish a signed device bundle endorsing `device` as a device of `account`,
@ -30,7 +30,7 @@ fn create_test_client(
ChatClient<InProcessDelivery, EphemeralRegistry, libchat::ChatStorage>,
Receiver<Event>,
),
logos_chat::ClientError,
logos_generic_chat::ClientError,
> {
let account = TestLogosAccount::new();
let delegate = DelegateSigner::random();
@ -166,8 +166,13 @@ fn direct_v1_by_account_address() {
assert_eq!(raya.addr(), raya_account_addr.as_str());
let convo_id = saro.create_direct_conversation(&raya_account_addr).unwrap();
// DirectV1 is the pairwise shape, so the joiner sees it classed Private even
// 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, .. } => Ok(convo_id),
Event::ConversationStarted { convo_id, class } => {
assert_eq!(class, ConversationClass::Private);
Ok(convo_id)
}
other => Err(other),
});
@ -375,10 +380,16 @@ fn unpublished_account_address_is_an_error() {
let err = saro
.create_direct_conversation(&unpublished.address())
.expect_err("no bundle published for the account");
assert!(matches!(err, logos_chat::ClientError::AccountResolution(_)));
assert!(matches!(
err,
logos_generic_chat::ClientError::AccountResolution(_)
));
let err = saro
.create_direct_conversation("not-an-account-address")
.expect_err("not an account key");
assert!(matches!(err, logos_chat::ClientError::AccountResolution(_)));
assert!(matches!(
err,
logos_generic_chat::ClientError::AccountResolution(_)
));
}

View File

@ -0,0 +1,16 @@
[package]
name = "logos-chat"
version = "0.1.0"
edition = "2024"
[lib]
crate-type = ["rlib"]
[dependencies]
# Workspace dependencies (sorted)
components = { workspace = true }
crossbeam-channel = { workspace = true }
embedded-logos-delivery = { workspace = true }
libchat = { workspace = true }
logos-account = { workspace = true, features = ["dev"] }
logos-generic-chat = { workspace = true }

View File

@ -0,0 +1,12 @@
mod logos;
pub use logos::{LogosChatClient, LogosConfig, REGISTRY_ENDPOINT, open, open_with_transport};
// Facade re-exports so callers need no direct dependency on the transport
// crate.
pub use embedded_logos_delivery::{
DEFAULT_NETWORK_PRESET, DEFAULT_TCP_PORT, EmbeddedLogosDelivery, P2pConfig,
};
// Re-export the transport-generic client surface so callers depend on this
// crate alone.
pub use logos_generic_chat::*;

View File

@ -0,0 +1,117 @@
//! The opinionated Logos client.
//!
//! [`open`] commits to the Logos service stack so independently built clients
//! share the same production services instead of each re-deriving them: a
//! delegate identity, the HTTP keypackage + account registry, and encrypted
//! on-disk storage. The stack is generic over the transport — any
//! [`Transport`] can be injected via [`open_with_transport`] — and the
//! concrete [`LogosChatClient`] commits to the embedded logos-delivery node,
//! whose native `liblogosdelivery` link keeps this crate outside the
//! workspace's default members.
//!
//! [`LogosChatClient`] points at `ChatClient`, which lives in
//! `logos-generic-chat`, so Rust's inherent-impl rule keeps the open
//! constructors off the alias; they are crate-level functions ([`open`],
//! [`open_with_transport`]) taking the all-inclusive [`LogosConfig`] instead.
use components::HttpRegistry;
use crossbeam_channel::Receiver;
use embedded_logos_delivery::{EmbeddedLogosDelivery, P2pConfig};
use libchat::{ChatStorage, StorageConfig};
use logos_account::TestLogosAccount;
use logos_generic_chat::{
ChatClient, ChatClientBuilder, ClientError, DelegateSigner, Event, Transport,
};
/// The endpoint for the account and keypackage registration service.
pub const REGISTRY_ENDPOINT: &str = "https://devnet.chat-kc.logos.co";
/// Configuration for opening a Logos client.
///
/// `db_path` (a per-client location) and `db_key` (a secret) are required and
/// never baked into the library. Everything else defaults: the registry
/// endpoint to the baked-in Logos value and the embedded node's p2p settings
/// to [`P2pConfig::default`]; override them with
/// [`set_registry_url`](Self::set_registry_url) and
/// [`set_p2p_config`](Self::set_p2p_config).
pub struct LogosConfig {
db_path: String,
db_key: String,
registry_url: String,
p2p_config: P2pConfig,
}
impl LogosConfig {
/// Config for the required per-client `db_path` and `db_key`. The registry
/// endpoint defaults to the baked-in Logos value; override it with
/// [`set_registry_url`](Self::set_registry_url).
pub fn new(db_path: impl Into<String>, db_key: impl Into<String>) -> Self {
Self {
db_path: db_path.into(),
db_key: db_key.into(),
registry_url: REGISTRY_ENDPOINT.to_string(),
p2p_config: P2pConfig::default(),
}
}
/// Override the registry endpoint (account + keypackage store; defaults to
/// the baked-in [`REGISTRY_ENDPOINT`]).
pub fn set_registry_url(&mut self, registry_url: impl Into<String>) {
self.registry_url = registry_url.into();
}
/// Override the embedded node's p2p settings (defaults to
/// [`P2pConfig::default`]). Only [`open`] starts an embedded node, so
/// [`open_with_transport`] ignores this.
pub fn set_p2p_config(&mut self, p2p_config: P2pConfig) {
self.p2p_config = p2p_config;
}
}
/// Open a client on the Logos stack per `config`, starting an embedded
/// logos-delivery node per its p2p settings as the transport. A convenience
/// over [`open_with_transport`] that commits to the [`LogosChatClient`]
/// transport.
pub fn open(config: LogosConfig) -> Result<(LogosChatClient, Receiver<Event>), ClientError> {
let transport = EmbeddedLogosDelivery::start(config.p2p_config.clone())
.map_err(|e| ClientError::Transport(e.to_string()))?;
open_with_transport(config, transport)
}
/// Open a client on the Logos stack per `config` with the injected transport,
/// persisting to the encrypted database.
#[allow(clippy::type_complexity)]
pub fn open_with_transport<T: Transport>(
config: LogosConfig,
transport: T,
) -> Result<(ChatClient<T, HttpRegistry, ChatStorage>, Receiver<Event>), ClientError> {
// A fresh account endorsing a fresh delegate each open: the account
// key is dropped after publishing the bundle, so devices cannot be
// added later. A caller-supplied, custody-holding account replaces
// this once the platform provides one.
let account = TestLogosAccount::new();
let delegate = DelegateSigner::random();
let mut registry = HttpRegistry::new(config.registry_url);
account
.add_delegate_signer(&mut registry, delegate.public_key())
.map_err(|e| ClientError::BundlePublish(e.to_string()))?;
ChatClientBuilder::new(account.address())
.ident(delegate)
.transport(transport)
.registration(registry)
.storage_config(StorageConfig::Encrypted {
path: config.db_path,
key: config.db_key,
})
.build()
}
/// The Logos client: a [`ChatClient`] wired to the Logos service stack — a
/// [`DelegateSigner`] identity acting for a fresh dev account, the HTTP
/// keypackage + account registry ([`HttpRegistry`], which is both the
/// keypackage store and the account → device directory), and encrypted
/// [`ChatStorage`] — running an embedded logos-delivery node as its
/// transport. Open one with [`open`], or swap the transport via
/// [`open_with_transport`].
pub type LogosChatClient = ChatClient<EmbeddedLogosDelivery, HttpRegistry, ChatStorage>;

View File

@ -2,10 +2,6 @@
name = "components"
version = "0.1.0"
edition = "2024"
links = "logosdelivery"
[features]
embedded_p2p_delivery = []
[dependencies]
# Workspace dependencies (sorted)

View File

@ -125,7 +125,7 @@ impl RegistrationService for HttpRegistry {
};
let url = format!("{}/v0/keypackage", self.base_url);
let resp = self.http.post(&url).json(&req).send()?;
let resp = send_retrying(|| self.http.post(&url).json(&req))?;
if !resp.status().is_success() {
let status = resp.status().as_u16();
let body = resp.text().unwrap_or_default();
@ -136,7 +136,7 @@ impl RegistrationService for HttpRegistry {
fn retrieve(&self, device_id: &str) -> Result<Option<Vec<u8>>, HttpRegistryError> {
let url = format!("{}/v0/keypackage/{}", self.base_url, device_id);
let resp = self.http.get(&url).send()?;
let resp = send_retrying(|| self.http.get(&url))?;
if resp.status().as_u16() == 404 {
return Ok(None);
}
@ -188,7 +188,7 @@ impl AccountDirectory for HttpRegistry {
};
let url = format!("{}/v0/account", self.base_url);
let resp = self.http.post(&url).json(&req).send()?;
let resp = send_retrying(|| self.http.post(&url).json(&req))?;
if !resp.status().is_success() {
let status = resp.status().as_u16();
let body = resp.text().unwrap_or_default();
@ -203,7 +203,7 @@ impl AccountDirectory for HttpRegistry {
self.base_url,
hex::encode(account.as_ref())
);
let resp = self.http.get(&url).send()?;
let resp = send_retrying(|| self.http.get(&url))?;
if resp.status().as_u16() == 404 {
return Ok(None);
}
@ -266,6 +266,70 @@ fn decode_payload(payload: &[u8]) -> Option<(u64, &[u8])> {
Some((timestamp_ms, &payload[8..]))
}
/// Retry budget for the registry's transient, load-induced 5xx/429 responses.
/// The service is reliable request-by-request but sheds concurrent bursts, so a
/// few backed-off retries let a request land once the burst clears. On that path
/// each retry returns fast, so the added cost is the ~3s worst-case backoff sum,
/// well inside chat_module's ~20s init IPC budget. A fully unreachable registry
/// instead costs up to MAX_RETRIES times the reqwest timeout, which no retry
/// budget can rescue.
const MAX_RETRIES: u32 = 4;
const RETRY_BASE_MS: u64 = 200;
const RETRY_MAX_BACKOFF_MS: u64 = 2000;
/// Send a request built by `build`, retrying transient failures — network errors
/// and 5xx/429 responses — with exponential backoff and full jitter. The
/// registry is reliable request-by-request but sheds concurrent bursts with a
/// 5xx, so a backed-off retry lands once the burst clears; a 4xx (and any other
/// final response) is returned to the caller unchanged. `build` is re-invoked per
/// attempt because sending consumes the builder.
fn send_retrying(
build: impl Fn() -> reqwest::blocking::RequestBuilder,
) -> Result<reqwest::blocking::Response, HttpRegistryError> {
let mut attempt = 0;
loop {
let outcome = build().send();
let transient = match &outcome {
Err(_) => true, // network error / timeout: worth another try
Ok(resp) => is_transient_status(resp.status()),
};
if !transient || attempt >= MAX_RETRIES {
return Ok(outcome?);
}
std::thread::sleep(backoff_with_jitter(attempt));
attempt += 1;
}
}
/// Whether a response status is worth retrying: 5xx (the registry sheds
/// concurrent load with these) or 429 (explicit backpressure). A 4xx is the
/// caller's fault and won't change on retry.
fn is_transient_status(status: reqwest::StatusCode) -> bool {
status.is_server_error() || status == reqwest::StatusCode::TOO_MANY_REQUESTS
}
/// Full-jitter exponential backoff: a random delay in
/// `[0, min(RETRY_MAX_BACKOFF_MS, RETRY_BASE_MS * 2^attempt)]`. The jitter
/// decorrelates concurrent publishers so their retries don't collide into the
/// same burst that failed them.
fn backoff_with_jitter(attempt: u32) -> Duration {
let exp = RETRY_BASE_MS.saturating_mul(1u64 << attempt.min(16));
Duration::from_millis(jitter_below(exp.min(RETRY_MAX_BACKOFF_MS)))
}
/// A value in `[0, max]`, seeded from the wall clock's sub-second nanos — enough
/// entropy to spread retries across processes without pulling in an RNG crate.
fn jitter_below(max: u64) -> u64 {
if max == 0 {
return 0;
}
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.subsec_nanos() as u64)
.unwrap_or(0);
nanos % (max + 1)
}
#[cfg(test)]
mod tests {
use super::*;
@ -325,4 +389,43 @@ mod tests {
.verify(&payload, &signature)
.expect("recovered key must verify the register-time signature");
}
/// Only 5xx and 429 are retried; 2xx/4xx are returned to the caller as-is.
#[test]
fn only_5xx_and_429_are_transient() {
use reqwest::StatusCode;
for s in [500u16, 502, 503, 504, 429] {
assert!(
is_transient_status(StatusCode::from_u16(s).unwrap()),
"{s} should be retried"
);
}
for s in [200u16, 201, 400, 401, 404, 409] {
assert!(
!is_transient_status(StatusCode::from_u16(s).unwrap()),
"{s} should not be retried"
);
}
}
/// Backoff never exceeds the exponential ceiling for its attempt, nor the
/// absolute cap — and the exponent shift can't overflow at high attempts.
#[test]
fn backoff_stays_within_the_cap() {
for attempt in 0..40u32 {
let ceiling = RETRY_BASE_MS
.saturating_mul(1u64 << attempt.min(16))
.min(RETRY_MAX_BACKOFF_MS);
let delay = backoff_with_jitter(attempt).as_millis() as u64;
assert!(delay <= ceiling, "attempt {attempt}: {delay} > {ceiling}");
}
}
#[test]
fn jitter_is_bounded() {
assert_eq!(jitter_below(0), 0);
for _ in 0..200 {
assert!(jitter_below(50) <= 50);
}
}
}

View File

@ -1,9 +1,3 @@
mod local_broadcaster;
pub use local_broadcaster::LocalBroadcaster;
#[cfg(feature = "embedded_p2p_delivery")]
pub mod embedded_p2p_delivery;
#[cfg(feature = "embedded_p2p_delivery")]
pub use embedded_p2p_delivery::{EmbeddedP2pDeliveryService, P2pConfig};

View File

@ -69,6 +69,10 @@ impl LocalBroadcaster {
/// Pulls all messages this consumer has not yet seen on `address`,
/// applying any registered filter. Advances the cursor so the same
/// messages are not returned again.
// clippy's question_mark (1.97+) wants `self.shared.borrow().read(next)?`, but
// `read` returns a reference into the RefCell `Ref`; the `?` form drops that
// guard at the `;` and `ae` would dangle. Keep the explicit match.
#[allow(clippy::question_mark)]
pub fn poll(&mut self) -> Option<Vec<u8>> {
loop {
let next = self.cursor;

View File

@ -0,0 +1,18 @@
[package]
name = "embedded-logos-delivery"
version = "0.1.0"
edition = "2024"
[dependencies]
# Workspace dependencies (sorted)
crossbeam-channel = { workspace = true }
libchat = { workspace = true }
logos-delivery = { workspace = true }
logos-generic-chat = { workspace = true }
# External dependencies (sorted)
base64 = "0.22"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
thiserror = "2"
tracing = "0.1"

View File

@ -5,17 +5,12 @@ use std::process::Command;
fn main() {
println!("cargo:rerun-if-env-changed=LOGOS_DELIVERY_LIB_DIR");
if std::env::var_os("CARGO_FEATURE_EMBEDDED_P2P_DELIVERY").is_none() {
return;
}
let Some(lib_dir) = locate_lib_dir() else {
println!(
"cargo:warning=embedded_p2p_delivery feature is enabled but \
liblogosdelivery could not be located; `cargo check`/`clippy` will \
pass, but building or testing will fail at link. Enter the dev shell \
with `nix develop` or set LOGOS_DELIVERY_LIB_DIR to the directory \
containing the library."
"cargo:warning=liblogosdelivery could not be located; `cargo check`/\
`clippy` will pass, but building or testing will fail at link. Enter \
the dev shell with `nix develop` or set LOGOS_DELIVERY_LIB_DIR to \
the directory containing the library."
);
return;
};

View File

@ -0,0 +1,100 @@
//! The embedded logos-delivery transport service.
//!
//! [`EmbeddedLogosDelivery`] implements [`DeliveryService`] over the embedded
//! node owned by [`ThreadedDeliveryWrapper`]. The wrapper handles the node
//! thread and hands back raw [`WakuEvent`]s; this crate supplies the
//! delivery-specific mapping — content topics, `/logos-chat/1/…` filtering, and
//! payload decoding.
//!
//! The native node is linked transitively via the `logos-delivery-rust` crate,
//! so this crate lives outside the workspace's default members; depend on it
//! (e.g. via the `logos-chat` crate) only when shipping the embedded node.
//!
//! ## Content topic mapping
//!
//! `AddressedEnvelope::delivery_address` maps to logos-delivery content topic
//! `/logos-chat/1/{delivery_address}/proto`.
use crossbeam_channel::Receiver;
use libchat::{AddressedEnvelope, DeliveryService};
use logos_delivery::{ThreadedDeliveryWrapper, WakuEvent};
pub use logos_delivery::{DeliveryError, P2pConfig};
use tracing::debug;
/// The logos-delivery network preset joined by default.
pub const DEFAULT_NETWORK_PRESET: &str = "logos.dev";
/// Default TCP port for the embedded logos-delivery node.
pub const DEFAULT_TCP_PORT: u16 = 60000;
/// The content-topic prefix carrying logos-chat traffic.
const CHAT_TOPIC_PREFIX: &str = "/logos-chat/1/";
pub fn content_topic_for(delivery_address: &str) -> String {
format!("{CHAT_TOPIC_PREFIX}{delivery_address}/proto")
}
// ── EmbeddedLogosDelivery ──────────────────────────────────────────────────
/// logos-delivery backed delivery service. Cheap to clone — all clones share
/// the same background node.
#[derive(Clone, Debug)]
pub struct EmbeddedLogosDelivery {
inner: ThreadedDeliveryWrapper<Vec<u8>>,
}
impl EmbeddedLogosDelivery {
/// Start the embedded logos-delivery node. Only chat payloads (on a
/// `/logos-chat/1/…` content topic) are kept on the inbound queue, decoded
/// to raw bytes.
pub fn start(cfg: P2pConfig) -> Result<Self, DeliveryError> {
let inner = ThreadedDeliveryWrapper::start(cfg, |event: WakuEvent| {
let msg = event.into_received()?;
if !msg.content_topic().starts_with(CHAT_TOPIC_PREFIX) {
return None;
}
msg.into_payload()
})?;
Ok(Self { inner })
}
/// Stop delivering messages addressed to `delivery_address`.
pub fn unsubscribe(&self, delivery_address: &str) -> Result<(), DeliveryError> {
self.inner.unsubscribe(&content_topic_for(delivery_address))
}
}
impl DeliveryService for EmbeddedLogosDelivery {
type Error = DeliveryError;
fn publish(&mut self, envelope: AddressedEnvelope) -> Result<(), DeliveryError> {
debug!(
topic = &content_topic_for(&envelope.delivery_address),
"Publish"
);
self.inner.publish(
&content_topic_for(&envelope.delivery_address),
&envelope.data,
)
}
fn subscribe(
&mut self,
delivery_address: &str,
) -> Result<(), <Self as DeliveryService>::Error> {
self.inner.subscribe(&content_topic_for(delivery_address))
}
}
// Teaching the service the inbound half makes it a full client transport, so
// callers need no wrapper newtype. The impl lives here (the crate owning the
// type) because the orphan rule bars it from the `logos-chat` crate, which
// owns neither the trait nor the type.
impl logos_generic_chat::Transport for EmbeddedLogosDelivery {
fn inbound(&mut self) -> Receiver<Vec<u8>> {
self.inner.inbound_queue()
}
}

View File

@ -0,0 +1,18 @@
[package]
name = "logos-delivery"
version = "0.1.0"
edition = "2024"
links = "logosdelivery"
[dependencies]
# Workspace dependencies (sorted)
crossbeam-channel = { workspace = true }
libchat = { workspace = true }
logos-generic-chat = { workspace = true }
# External dependencies (sorted)
base64 = "0.22"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
thiserror = "2"
tracing = "0.1"

View File

@ -0,0 +1,160 @@
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
fn main() {
println!("cargo:rerun-if-env-changed=LOGOS_DELIVERY_LIB_DIR");
let Some(lib_dir) = locate_lib_dir() else {
println!(
"cargo:warning=liblogosdelivery could not be located; `cargo check`/\
`clippy` will pass, but building or testing will fail at link. Enter \
the dev shell with `nix develop` or set LOGOS_DELIVERY_LIB_DIR to \
the directory containing the library."
);
return;
};
let out_dir = std::env::var("OUT_DIR").expect("OUT_DIR not set");
let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
// The shipped library carries a relocatable install name (@rpath on macOS,
// $ORIGIN soname on Linux), which would force every downstream BINARY to
// inject its own RPATH. Cargo propagates `rustc-link-search` and
// `rustc-link-lib` across crates, but NOT `rustc-link-arg` (the rpath) — so
// that relocatable name is exactly what makes consumers need their own
// build.rs. Instead, stamp a private copy with an ABSOLUTE install name;
// the propagating search + lib directives are then sufficient and consumers
// need zero build-script glue.
match target_os.as_str() {
"macos" => stamp_absolute_macos(&lib_dir, &out_dir),
"linux" => stamp_absolute_linux(&lib_dir, &out_dir),
other => panic!("unsupported OS for logos-delivery transport: {other}"),
}
println!("cargo:rustc-link-search=native={out_dir}");
println!("cargo:rustc-link-lib=dylib=logosdelivery");
}
/// Locate the native library directory as an ABSOLUTE, canonical path. Prefers
/// `LOGOS_DELIVERY_LIB_DIR`, then falls back to building it via nix. Returns
/// `None` when neither is available (e.g. `cargo check` without nix).
fn locate_lib_dir() -> Option<PathBuf> {
if let Ok(dir) = std::env::var("LOGOS_DELIVERY_LIB_DIR") {
if let Some(resolved) = resolve_lib_dir(&dir) {
return Some(resolved);
}
println!(
"cargo:warning=LOGOS_DELIVERY_LIB_DIR='{dir}' could not be resolved; \
falling back to `nix build`"
);
}
resolve_lib_dir(&nix_build_logos_delivery()?)
}
/// Resolve a lib dir to an absolute, canonical path. Cargo runs build scripts
/// with the cwd set to the crate dir, but a relative value (e.g. CI's
/// `./result/lib`) is anchored at the flake/workspace root where `nix build`
/// drops `result`. Canonicalizing also follows the `result` symlink to the
/// immutable store path, so the stamped install name / soname stays stable.
fn resolve_lib_dir(dir: &str) -> Option<PathBuf> {
let path = Path::new(dir);
let anchored = if path.is_absolute() {
path.to_path_buf()
} else {
let manifest = std::env::var("CARGO_MANIFEST_DIR").ok()?;
Path::new(&find_flake_root(&manifest)?).join(path)
};
anchored.canonicalize().ok()
}
/// Copy `liblogosdelivery.dylib` into `OUT_DIR` and rewrite its install name to
/// the absolute store path. The consumer records that absolute path, so dyld
/// loads the original file directly — whose own `@loader_path` RPATH resolves
/// `librln.dylib` beside it — with no RPATH needed on the consumer.
fn stamp_absolute_macos(lib_dir: &Path, out_dir: &str) {
let src = lib_dir.join("liblogosdelivery.dylib");
let dst = format!("{out_dir}/liblogosdelivery.dylib");
copy_writable(&src, Path::new(&dst));
run("install_name_tool", &["-id", path_str(&src), &dst]);
println!("cargo:rerun-if-changed={}", src.display());
}
/// Linux equivalent: an absolute `DT_SONAME` is recorded verbatim in the
/// consumer's `DT_NEEDED`, so `ld.so` loads it by path with no RPATH. Requires
/// `patchelf` at build time (provided by the nix devshell).
fn stamp_absolute_linux(lib_dir: &Path, out_dir: &str) {
let src = lib_dir.join("liblogosdelivery.so");
let dst = format!("{out_dir}/liblogosdelivery.so");
copy_writable(&src, Path::new(&dst));
run("patchelf", &["--set-soname", path_str(&src), &dst]);
println!("cargo:rerun-if-changed={}", src.display());
}
fn path_str(p: &Path) -> &str {
p.to_str()
.unwrap_or_else(|| panic!("non-UTF-8 path: {}", p.display()))
}
fn copy_writable(src: &Path, dst: &Path) {
use std::os::unix::fs::PermissionsExt;
fs::copy(src, dst)
.unwrap_or_else(|e| panic!("copy {} -> {}: {e}", src.display(), dst.display()));
// Store-sourced files are read-only; restore owner write so the install
// name / soname can be rewritten.
fs::set_permissions(dst, fs::Permissions::from_mode(0o644)).unwrap();
}
fn run(cmd: &str, args: &[&str]) {
let status = Command::new(cmd)
.args(args)
.status()
.unwrap_or_else(|e| panic!("failed to run `{cmd}`: {e}"));
assert!(status.success(), "`{cmd} {args:?}` failed with {status}");
}
fn nix_build_logos_delivery() -> Option<String> {
let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").ok()?;
let flake_root = find_flake_root(&manifest_dir)?;
println!("cargo:rerun-if-changed={flake_root}/flake.lock");
let output = Command::new("nix")
.args([
"build",
".#logos-delivery",
"--no-link",
"--print-out-paths",
])
.current_dir(&flake_root)
.output()
.ok()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
println!("cargo:warning=nix build .#logos-delivery failed: {stderr}");
return None;
}
let store_path = String::from_utf8(output.stdout).ok()?;
let lib_dir = format!("{}/lib", store_path.trim());
if std::path::Path::new(&lib_dir).exists() {
Some(lib_dir)
} else {
None
}
}
fn find_flake_root(start: &str) -> Option<String> {
let mut path = std::path::PathBuf::from(start);
loop {
if path.join("flake.nix").exists() {
return Some(path.to_string_lossy().into_owned());
}
if !path.pop() {
return None;
}
}
}

View File

@ -0,0 +1,5 @@
mod sys;
mod threaded;
mod wrapper;
pub use threaded::{DeliveryError, P2pConfig, ReceivedMessage, ThreadedDeliveryWrapper, WakuEvent};

View File

@ -1,17 +1,3 @@
//! logos-delivery backed [`client::DeliveryService`] implementation.
//!
//! `LogosDeliveryService` wraps an embedded logos-delivery node running on a
//! dedicated `std::thread`. All interaction is via synchronous `std::sync::mpsc`
//! channels.
//!
//! ## Content topic mapping
//!
//! `AddressedEnvelope::delivery_address` maps to logos-delivery content topic
//! `/logos-chat/1/{delivery_address}/proto`.
pub(crate) mod sys;
pub(crate) mod wrapper;
use std::sync::{Arc, Mutex, mpsc};
use std::thread;
use std::time::Duration;
@ -19,14 +5,9 @@ use std::time::Duration;
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64;
use crossbeam_channel::{Receiver, Sender};
use libchat::{AddressedEnvelope, DeliveryService};
use tracing::{error, info, warn};
use tracing::{error, info};
use wrapper::LogosNodeCtx;
pub fn content_topic_for(delivery_address: &str) -> String {
format!("/logos-chat/1/{delivery_address}/proto")
}
use crate::wrapper::LogosNodeCtx;
// ── Error ────────────────────────────────────────────────────────────────────
@ -36,33 +17,52 @@ pub enum DeliveryError {
StartupFailed(String),
#[error("publish failed: {0}")]
PublishFailed(String),
#[error("subscribe failed: {0}")]
SubscribeFailed(String),
#[error("unsubscribe failed: {0}")]
UnsubscribeFailed(String),
#[error("send channel closed")]
ChannelClosed,
}
// ── Internals ────────────────────────────────────────────────────────────────
struct OutboundCmd {
message_json: String,
/// A node operation to run on the serialized node thread.
#[derive(Debug)]
enum NodeOp {
Publish(String), // message_json
Subscribe(String), // content_topic
Unsubscribe(String), // content_topic
}
#[derive(Debug)]
struct NodeCmd {
op: NodeOp,
reply: mpsc::SyncSender<Result<(), DeliveryError>>,
}
type SubscriberList = Arc<Mutex<Vec<Sender<Vec<u8>>>>>;
type SubscriberList<T> = Arc<Mutex<Vec<Sender<T>>>>;
// ── P2pConfig ───────────────────────────────────────────────────────────────────
/// The logos-delivery network preset joined by default.
pub const DEFAULT_NETWORK_PRESET: &str = "logos.dev";
/// Default TCP port for the embedded logos-delivery node.
pub const DEFAULT_PORT: u16 = 60000;
#[derive(Debug, Clone)]
pub struct P2pConfig {
pub preset: String,
pub tcp_port: u16,
pub port: u16,
pub log_level: String,
}
impl Default for P2pConfig {
fn default() -> Self {
Self {
preset: "logos.dev".into(),
tcp_port: 60000,
preset: DEFAULT_NETWORK_PRESET.into(),
port: DEFAULT_PORT,
log_level: "ERROR".into(),
}
}
@ -81,16 +81,25 @@ struct WakuMessage {
}
/// Top-level event envelope received from the logos-delivery node callback.
#[derive(Debug, serde::Deserialize)]
struct WakuEvent {
#[derive(Debug, serde::Deserialize, Clone)]
pub struct WakuEvent {
#[serde(rename = "eventType")]
event_type: String,
message: Option<ReceivedMessage>,
}
impl WakuEvent {
/// The received message iff this is a `message_received` event.
pub fn into_received(self) -> Option<ReceivedMessage> {
(self.event_type == "message_received")
.then_some(self.message)
.flatten()
}
}
/// Message payload from a `message_received` event.
#[derive(Debug, serde::Deserialize)]
struct ReceivedMessage {
#[derive(Debug, serde::Deserialize, Clone)]
pub struct ReceivedMessage {
#[serde(rename = "contentTopic")]
content_topic: String,
/// The node may deliver the payload as either a base64 string or a JSON
@ -98,8 +107,19 @@ struct ReceivedMessage {
payload: WakuPayload,
}
impl ReceivedMessage {
pub fn content_topic(&self) -> &str {
&self.content_topic
}
/// Decode the payload to raw bytes, whichever wire form the node used.
pub fn into_payload(self) -> Option<Vec<u8>> {
self.payload.decode()
}
}
/// Untagged union that handles both payload representations.
#[derive(Debug, serde::Deserialize)]
#[derive(Debug, serde::Deserialize, Clone)]
#[serde(untagged)]
enum WakuPayload {
Base64(String),
@ -115,28 +135,54 @@ impl WakuPayload {
}
}
// ── EmbeddedP2pDeliveryService ──────────────────────────────────────────────────
// ── ThreadedDeliveryWrapper ─────────────────────────────────────────────────
/// logos-delivery backed delivery service. Cheap to clone — all clones share
/// the same background node.
#[derive(Clone, Debug)]
pub struct EmbeddedP2pDeliveryService {
outbound: mpsc::SyncSender<OutboundCmd>,
/// Owns the embedded node on a dedicated thread. Generic over the inbound item
/// type `T`: a caller-supplied mapper turns each raw [`WakuEvent`] into an
/// `Option<T>` on the callback thread, so filtering and decoding happen inline
/// with no relay thread. Cheap to clone — all clones share the same node.
pub struct ThreadedDeliveryWrapper<T = WakuEvent> {
outbound: mpsc::SyncSender<NodeCmd>,
#[allow(dead_code)]
subscribers: SubscriberList,
inbound_rx: Option<Receiver<Vec<u8>>>,
subscribers: SubscriberList<T>,
inbound_rx: Option<Receiver<T>>,
}
impl EmbeddedP2pDeliveryService {
/// Start the embedded logos-delivery node. The client drains inbound
/// payloads via [`Transport::inbound`].
pub fn start(cfg: P2pConfig) -> Result<Self, DeliveryError> {
let (out_tx, out_rx) = mpsc::sync_channel::<OutboundCmd>(256);
let subscribers: SubscriberList = Arc::new(Mutex::new(Vec::new()));
// Manual impls so `T` carries no `Clone`/`Debug` bound at the struct level —
// `Sender<T>`/`Receiver<T>` are `Clone` for every `T`.
impl<T> Clone for ThreadedDeliveryWrapper<T> {
fn clone(&self) -> Self {
Self {
outbound: self.outbound.clone(),
subscribers: self.subscribers.clone(),
inbound_rx: self.inbound_rx.clone(),
}
}
}
impl<T> std::fmt::Debug for ThreadedDeliveryWrapper<T> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ThreadedDeliveryWrapper")
.field("has_inbound", &self.inbound_rx.is_some())
.finish_non_exhaustive()
}
}
impl<T> ThreadedDeliveryWrapper<T> {
/// Start the embedded logos-delivery node. `map` runs on the node's event
/// callback for every received event; return `Some(item)` to enqueue it for
/// [`Self::inbound_queue`], or `None` to drop it. It must be non-blocking.
pub fn start<F>(cfg: P2pConfig, map: F) -> Result<Self, DeliveryError>
where
T: Clone + Send + 'static,
F: FnMut(WakuEvent) -> Option<T> + Send + 'static,
{
let (out_tx, out_rx) = mpsc::sync_channel::<NodeCmd>(256);
let subscribers: SubscriberList<T> = Arc::new(Mutex::new(Vec::new()));
let (ready_tx, ready_rx) = mpsc::channel::<Result<(), DeliveryError>>();
// Create the inbound channel before spawning so the receiver is
// registered inside the thread, before any event callback fires.
let (inbound_tx, inbound_rx) = crossbeam_channel::bounded::<Vec<u8>>(1024);
let (inbound_tx, inbound_rx) = crossbeam_channel::bounded::<T>(1024);
let subs_for_thread = subscribers.clone();
@ -144,7 +190,7 @@ impl EmbeddedP2pDeliveryService {
.name("logos-node".into())
.spawn(move || {
if let Err(panic) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
Self::node_thread(cfg, out_rx, subs_for_thread, inbound_tx, ready_tx);
Self::node_thread(cfg, out_rx, subs_for_thread, inbound_tx, ready_tx, map);
})) {
let msg = panic
.downcast_ref::<&str>()
@ -176,13 +222,58 @@ impl EmbeddedP2pDeliveryService {
})
}
fn node_thread(
/// Queue `op` on the node thread and block until it acknowledges.
fn send_cmd(&self, op: NodeOp) -> Result<(), DeliveryError> {
let (reply_tx, reply_rx) = mpsc::sync_channel(1);
self.outbound
.send(NodeCmd {
op,
reply: reply_tx,
})
.map_err(|_| DeliveryError::ChannelClosed)?;
reply_rx.recv().map_err(|_| DeliveryError::ChannelClosed)?
}
/// Start delivering messages on `content_topic`. Blocks until acknowledged.
pub fn subscribe(&self, content_topic: &str) -> Result<(), DeliveryError> {
self.send_cmd(NodeOp::Subscribe(content_topic.to_string()))
}
/// Stop delivering messages on `content_topic`. Blocks until acknowledged.
pub fn unsubscribe(&self, content_topic: &str) -> Result<(), DeliveryError> {
self.send_cmd(NodeOp::Unsubscribe(content_topic.to_string()))
}
/// Publish `payload` on `content_topic`. Blocks until the node acknowledges.
pub fn publish(&self, content_topic: &str, payload: &[u8]) -> Result<(), DeliveryError> {
let msg = WakuMessage {
content_topic: content_topic.to_string(),
payload: BASE64.encode(payload),
ephemeral: false,
};
let message_json =
serde_json::to_string(&msg).map_err(|e| DeliveryError::PublishFailed(e.to_string()))?;
self.send_cmd(NodeOp::Publish(message_json))
}
/// Take the inbound queue of mapped items. Callable once.
pub fn inbound_queue(&mut self) -> Receiver<T> {
self.inbound_rx
.take()
.expect("inbound_queue called more than once")
}
fn node_thread<F>(
cfg: P2pConfig,
out_rx: mpsc::Receiver<OutboundCmd>,
subscribers: SubscriberList,
inbound_tx: Sender<Vec<u8>>,
out_rx: mpsc::Receiver<NodeCmd>,
subscribers: SubscriberList<T>,
inbound_tx: Sender<T>,
ready_tx: mpsc::Sender<Result<(), DeliveryError>>,
) {
mut map: F,
) where
T: Clone + Send + 'static,
F: FnMut(WakuEvent) -> Option<T> + Send + 'static,
{
// discv5UdpPort defaults to 9000 in libwaku, so a second instance with
// a distinct --port still collides on UDP. Bind it to tcp_port so a
// single --port knob keeps both ports distinct across instances.
@ -190,8 +281,8 @@ impl EmbeddedP2pDeliveryService {
"logLevel": cfg.log_level,
"mode": "Core",
"preset": cfg.preset,
"tcpPort": cfg.tcp_port,
"discv5UdpPort": cfg.tcp_port,
"tcpPort": cfg.port,
"discv5UdpPort": cfg.port,
})
.to_string();
@ -210,20 +301,24 @@ impl EmbeddedP2pDeliveryService {
let subs_for_cb = subscribers.clone();
let event_closure = move |_ret: i32, data: &str| {
if let Some(payload) = Self::parse_message_received(data) {
let mut guard = match subs_for_cb.lock() {
Ok(g) => g,
Err(e) => {
error!("subscriber mutex poisoned: {e}");
return;
}
};
guard.retain(|tx| match tx.try_send(payload.clone()) {
Ok(()) => true,
Err(crossbeam_channel::TrySendError::Full(_)) => true,
Err(crossbeam_channel::TrySendError::Disconnected(_)) => false,
});
}
let Ok(event) = serde_json::from_str::<WakuEvent>(data) else {
return;
};
let Some(item) = map(event) else {
return;
};
let mut guard = match subs_for_cb.lock() {
Ok(g) => g,
Err(e) => {
error!("subscriber mutex poisoned: {e}");
return;
}
};
guard.retain(|tx| match tx.try_send(item.clone()) {
Ok(()) => true,
Err(crossbeam_channel::TrySendError::Full(_)) => true,
Err(crossbeam_channel::TrySendError::Disconnected(_)) => false,
});
};
node.set_event_callback(event_closure);
@ -241,74 +336,25 @@ impl EmbeddedP2pDeliveryService {
// surface such an event via its callback mechanism for this to work.
thread::sleep(Duration::from_secs(3));
let default_topic = content_topic_for("delivery_address");
if let Err(e) = node.subscribe(&default_topic) {
warn!("subscribe to {default_topic}: {e}");
} else {
info!("subscribed to {default_topic}");
}
let _ = ready_tx.send(Ok(()));
while let Ok(cmd) = out_rx.recv() {
let result = node
.send(&cmd.message_json)
.map(|_| ())
.map_err(DeliveryError::PublishFailed);
info!(">>>>> {:?} ", cmd);
let result = match cmd.op {
NodeOp::Publish(msg) => node
.send(&msg)
.map(|_| ())
.map_err(DeliveryError::PublishFailed),
NodeOp::Subscribe(topic) => node
.subscribe(&topic)
.map_err(DeliveryError::SubscribeFailed),
NodeOp::Unsubscribe(topic) => node
.unsubscribe(&topic)
.map_err(DeliveryError::UnsubscribeFailed),
};
let _ = cmd.reply.try_send(result);
}
info!("logos-node outbound loop finished");
}
fn parse_message_received(data: &str) -> Option<Vec<u8>> {
let event: WakuEvent = serde_json::from_str(data).ok()?;
if event.event_type != "message_received" {
return None;
}
let msg = event.message?;
if !msg.content_topic.starts_with("/logos-chat/1/") {
return None;
}
msg.payload.decode()
}
pub fn inbound_queue(&mut self) -> Receiver<Vec<u8>> {
self.inbound_rx
.take()
.expect("inbound_queue called more than once")
}
}
impl DeliveryService for EmbeddedP2pDeliveryService {
type Error = DeliveryError;
fn publish(&mut self, envelope: AddressedEnvelope) -> Result<(), DeliveryError> {
let msg = WakuMessage {
content_topic: content_topic_for(&envelope.delivery_address),
payload: BASE64.encode(&envelope.data),
ephemeral: false,
};
let message_json =
serde_json::to_string(&msg).map_err(|e| DeliveryError::PublishFailed(e.to_string()))?;
let (reply_tx, reply_rx) = mpsc::sync_channel(1);
self.outbound
.send(OutboundCmd {
message_json,
reply: reply_tx,
})
.map_err(|_| DeliveryError::ChannelClosed)?;
reply_rx.recv().map_err(|_| DeliveryError::ChannelClosed)?
}
fn subscribe(&mut self, _: &str) -> Result<(), <Self as DeliveryService>::Error> {
// This Service does not support filtering
Ok(())
info!("logos-node command loop finished");
}
}

View File

@ -126,6 +126,36 @@ impl LogosNodeCtx {
drop(unsafe { Box::from_raw(raw) });
return Err(format!("logosdelivery_subscribe returned {ret}"));
}
let result = rx
.recv()
.unwrap_or(Err("callback channel disconnected".into()));
drop(unsafe { Box::from_raw(raw) });
result
}
pub fn unsubscribe(&self, content_topic: &str) -> Result<(), String> {
let topic_cstr = CString::new(content_topic).map_err(|e| e.to_string())?;
let (tx, rx) = mpsc::sync_channel::<Result<(), String>>(1);
let closure = move |ret: i32, data: &str| {
let _ = tx.send(if ret == RET_OK {
Ok(())
} else {
Err(data.to_string())
});
};
let raw = Box::into_raw(Box::new(closure));
let cb = get_trampoline(unsafe { &*raw });
let ret = unsafe {
ffi::logosdelivery_unsubscribe(self.ctx, cb, raw as *const c_void, topic_cstr.as_ptr())
};
if ret != RET_OK {
drop(unsafe { Box::from_raw(raw) });
return Err(format!("logosdelivery_unsubscribe returned {ret}"));
}
let result = rx
.recv()
.unwrap_or(Err("callback channel disconnected".into()));