262 lines
8.9 KiB
Rust
Raw Normal View History

mod identity;
mod mls_provider;
use crypto::Ed25519VerifyingKey;
pub use identity::MlsIdentityProvider;
refactor(core): replace Rc-based Context with a synchronous, Send-able Core (#123) Make the conversations core Send so the threaded client can own it behind an Arc<Mutex<Core>>: a background worker polls the transport and handles inbound payloads while the application thread issues outbound calls (send, create conversation). Sharing the core across those two threads means moving it into the spawned worker, which is only legal if it is Send. Access stays serialized by the client's Mutex (one thread at a time), so the core needs Send but not Sync and carries no lock of its own. See docs/adr/0001-client-event-system.md for the background-poller design. The Rc<RefCell> service-sharing is what made the core !Send. Context is de-Rc'd and renamed to Core, owning its services outright and driving the inbox and conversation primitives with plain &mut self. - Services (identity, delivery, store, registry, MLS context, causal history) are bundled into a ServiceContext<S> behind an ExternalServices trait, with S = (DS, RS, CS). Constructors live on the (DS, RS, CS) form because S cannot be inferred backwards through S::DS. - Inbox, InboxV2, PrivateV1Convo, and GroupV1Convo become non-generic and receive the ServiceContext bundle as a &mut/& parameter; no Rc or RefCell-as-shared-state remains, so Core is Send whenever its injected services are. - Dispatch branches on ConversationKind in one place: Core rebuilds the target as a Convo<S>/GroupConvo<S> trait object bound to the service bundle, so conversations never escape the orchestrator. - CausalHistoryStore drops its Rc, keeping a plain RefCell.
2026-06-08 21:55:33 +02:00
pub(crate) use mls_provider::MlsEphemeralPqProvider;
use shared_traits::IdentId;
use shared_traits::IdentIdRef;
use chat_proto::logoschat::envelope::EnvelopeV1;
use openmls::prelude::tls_codec::Serialize;
use openmls::prelude::*;
use prost::{Message, Oneof};
refactor(core): replace Rc-based Context with a synchronous, Send-able Core (#123) Make the conversations core Send so the threaded client can own it behind an Arc<Mutex<Core>>: a background worker polls the transport and handles inbound payloads while the application thread issues outbound calls (send, create conversation). Sharing the core across those two threads means moving it into the spawned worker, which is only legal if it is Send. Access stays serialized by the client's Mutex (one thread at a time), so the core needs Send but not Sync and carries no lock of its own. See docs/adr/0001-client-event-system.md for the background-poller design. The Rc<RefCell> service-sharing is what made the core !Send. Context is de-Rc'd and renamed to Core, owning its services outright and driving the inbox and conversation primitives with plain &mut self. - Services (identity, delivery, store, registry, MLS context, causal history) are bundled into a ServiceContext<S> behind an ExternalServices trait, with S = (DS, RS, CS). Constructors live on the (DS, RS, CS) form because S cannot be inferred backwards through S::DS. - Inbox, InboxV2, PrivateV1Convo, and GroupV1Convo become non-generic and receive the ServiceContext bundle as a &mut/& parameter; no Rc or RefCell-as-shared-state remains, so Core is Send whenever its injected services are. - Dispatch branches on ConversationKind in one place: Core rebuilds the target as a Convo<S>/GroupConvo<S> trait object bound to the service bundle, so conversations never escape the orchestrator. - CausalHistoryStore drops its Rc, keeping a plain RefCell.
2026-06-08 21:55:33 +02:00
use storage::{ConversationKind, ConversationMeta, ConversationStore};
use crate::ChatError;
use crate::DeliveryService;
use crate::IdentityProvider;
use crate::RegistrationService;
refactor(core): replace Rc-based Context with a synchronous, Send-able Core (#123) Make the conversations core Send so the threaded client can own it behind an Arc<Mutex<Core>>: a background worker polls the transport and handles inbound payloads while the application thread issues outbound calls (send, create conversation). Sharing the core across those two threads means moving it into the spawned worker, which is only legal if it is Send. Access stays serialized by the client's Mutex (one thread at a time), so the core needs Send but not Sync and carries no lock of its own. See docs/adr/0001-client-event-system.md for the background-poller design. The Rc<RefCell> service-sharing is what made the core !Send. Context is de-Rc'd and renamed to Core, owning its services outright and driving the inbox and conversation primitives with plain &mut self. - Services (identity, delivery, store, registry, MLS context, causal history) are bundled into a ServiceContext<S> behind an ExternalServices trait, with S = (DS, RS, CS). Constructors live on the (DS, RS, CS) form because S cannot be inferred backwards through S::DS. - Inbox, InboxV2, PrivateV1Convo, and GroupV1Convo become non-generic and receive the ServiceContext bundle as a &mut/& parameter; no Rc or RefCell-as-shared-state remains, so Core is Send whenever its injected services are. - Dispatch branches on ConversationKind in one place: Core rebuilds the target as a Convo<S>/GroupConvo<S> trait object bound to the service bundle, so conversations never escape the orchestrator. - CausalHistoryStore drops its Rc, keeping a plain RefCell.
2026-06-08 21:55:33 +02:00
use crate::conversation::ConversationId;
use crate::conversation::GroupV1Convo;
feat: introduce client event system (#106) * chore(flake): accept extra system attr; add perl for openssl-sys build forAllSystems calls the lambda with {system, pkgs}; strict destructuring requires `..` to ignore the system attribute. `pkgs.perl` is needed because openssl-sys is pulled vendored via libsqlite3-sys / rusqlite / chat-sqlite, and its `perl Configure` step needs FindBin.pm, which Fedora's system perl doesn't ship. * feat: introduce client event system - Core processing yields a `PayloadOutcome` enum — `Empty`, `Convo`, or `Inbox`. `ConvoOutcome` carries a conversation id and an optional decrypted `Content`; `InboxOutcome` adds a `NewConversation` (id + `ConversationClass`) for a peer-initiated conversation. - Client translates `PayloadOutcome` into app-facing `Vec<Event>` (`ConversationStarted`, `MessageReceived`) at the boundary, so the application loop sees discrete events rather than core types. - MLS group welcomes produce a `ConversationStarted` event with no initial content, fixing the silent-group-join case where the inbox layer dropped the observation. - C FFI exposes an `EventList` opaque type with indexed accessors and an `Invalid` sentinel for out-of-bounds / non-applicable reads. - Symmetric `Inbox` / `InboxV2` handlers: both return `Result<InboxOutcome, _>` and own the persistence + ephemeral-key cleanup for the conversations they create. - Updated and simplified `docs/adr/0001-client-event-system.md`. * chore(flake): bump nixpkgs to nixos-unstable-small Temporary. The two crates.io UA fixes (NixOS/nixpkgs#512735 for fetchCargoVendor's python-requests UA, NixOS/nixpkgs#524985 for importCargoLock's curl UA) haven't propagated to nixos-unstable yet. Switch to nixos-unstable-small and force logos-delivery to follow so the smoketest gets the same fix. Revert once nixos-unstable catches up. Refs: - https://github.com/rust-lang/crates.io/issues/13482 - https://github.com/rust-lang/crates.io/issues/13783 - https://crates.io/data-access
2026-05-28 23:51:15 +02:00
use crate::outcomes::{ConversationClass, InboxOutcome, NewConversation};
refactor(core): replace Rc-based Context with a synchronous, Send-able Core (#123) Make the conversations core Send so the threaded client can own it behind an Arc<Mutex<Core>>: a background worker polls the transport and handles inbound payloads while the application thread issues outbound calls (send, create conversation). Sharing the core across those two threads means moving it into the spawned worker, which is only legal if it is Send. Access stays serialized by the client's Mutex (one thread at a time), so the core needs Send but not Sync and carries no lock of its own. See docs/adr/0001-client-event-system.md for the background-poller design. The Rc<RefCell> service-sharing is what made the core !Send. Context is de-Rc'd and renamed to Core, owning its services outright and driving the inbox and conversation primitives with plain &mut self. - Services (identity, delivery, store, registry, MLS context, causal history) are bundled into a ServiceContext<S> behind an ExternalServices trait, with S = (DS, RS, CS). Constructors live on the (DS, RS, CS) form because S cannot be inferred backwards through S::DS. - Inbox, InboxV2, PrivateV1Convo, and GroupV1Convo become non-generic and receive the ServiceContext bundle as a &mut/& parameter; no Rc or RefCell-as-shared-state remains, so Core is Send whenever its injected services are. - Dispatch branches on ConversationKind in one place: Core rebuilds the target as a Convo<S>/GroupConvo<S> trait object bound to the service bundle, so conversations never escape the orchestrator. - CausalHistoryStore drops its Rc, keeping a plain RefCell.
2026-06-08 21:55:33 +02:00
use crate::service_context::{ExternalServices, ServiceContext};
use crate::utils::{blake2b_hex, hash_size};
use crate::{
AccountAuthority, AccountDirectory, AddressedEnvelope, SignedDeviceBundle,
encode_bundle_payload,
};
// Define unique Identifiers derivations used in InboxV2
fn delivery_address_for(ident_id: IdentIdRef) -> String {
blake2b_hex::<hash_size::DeliveryAddr>(&["InboxV2|", "delivery_address|", ident_id.as_str()])
}
fn conversation_id_for(ident_id: IdentIdRef) -> String {
blake2b_hex::<hash_size::ConvoId>(&["InboxV2|", "conversation_id|", ident_id.as_str()])
}
/// An Extension trait which extends OpenMlsProvider to add required functionality
/// All MLS based Conversation should use this trait for defining requirements.
pub trait MlsProvider: OpenMlsProvider {
fn invite_user<DS: DeliveryService>(
&self,
ds: &mut DS,
ident_id: IdentIdRef,
welcome: &MlsMessageOut,
) -> Result<(), ChatError>;
}
/// An PQ focused Conversation initializer.
/// InboxV2 Incorporates an Account based identity system to support PQ based conversation protocols
/// such as MLS.
refactor(core): replace Rc-based Context with a synchronous, Send-able Core (#123) Make the conversations core Send so the threaded client can own it behind an Arc<Mutex<Core>>: a background worker polls the transport and handles inbound payloads while the application thread issues outbound calls (send, create conversation). Sharing the core across those two threads means moving it into the spawned worker, which is only legal if it is Send. Access stays serialized by the client's Mutex (one thread at a time), so the core needs Send but not Sync and carries no lock of its own. See docs/adr/0001-client-event-system.md for the background-poller design. The Rc<RefCell> service-sharing is what made the core !Send. Context is de-Rc'd and renamed to Core, owning its services outright and driving the inbox and conversation primitives with plain &mut self. - Services (identity, delivery, store, registry, MLS context, causal history) are bundled into a ServiceContext<S> behind an ExternalServices trait, with S = (DS, RS, CS). Constructors live on the (DS, RS, CS) form because S cannot be inferred backwards through S::DS. - Inbox, InboxV2, PrivateV1Convo, and GroupV1Convo become non-generic and receive the ServiceContext bundle as a &mut/& parameter; no Rc or RefCell-as-shared-state remains, so Core is Send whenever its injected services are. - Dispatch branches on ConversationKind in one place: Core rebuilds the target as a Convo<S>/GroupConvo<S> trait object bound to the service bundle, so conversations never escape the orchestrator. - CausalHistoryStore drops its Rc, keeping a plain RefCell.
2026-06-08 21:55:33 +02:00
pub struct InboxV2 {
// Account_id field is an owned value, so it can be returned via reference.
ident_id: IdentId,
}
refactor(core): replace Rc-based Context with a synchronous, Send-able Core (#123) Make the conversations core Send so the threaded client can own it behind an Arc<Mutex<Core>>: a background worker polls the transport and handles inbound payloads while the application thread issues outbound calls (send, create conversation). Sharing the core across those two threads means moving it into the spawned worker, which is only legal if it is Send. Access stays serialized by the client's Mutex (one thread at a time), so the core needs Send but not Sync and carries no lock of its own. See docs/adr/0001-client-event-system.md for the background-poller design. The Rc<RefCell> service-sharing is what made the core !Send. Context is de-Rc'd and renamed to Core, owning its services outright and driving the inbox and conversation primitives with plain &mut self. - Services (identity, delivery, store, registry, MLS context, causal history) are bundled into a ServiceContext<S> behind an ExternalServices trait, with S = (DS, RS, CS). Constructors live on the (DS, RS, CS) form because S cannot be inferred backwards through S::DS. - Inbox, InboxV2, PrivateV1Convo, and GroupV1Convo become non-generic and receive the ServiceContext bundle as a &mut/& parameter; no Rc or RefCell-as-shared-state remains, so Core is Send whenever its injected services are. - Dispatch branches on ConversationKind in one place: Core rebuilds the target as a Convo<S>/GroupConvo<S> trait object bound to the service bundle, so conversations never escape the orchestrator. - CausalHistoryStore drops its Rc, keeping a plain RefCell.
2026-06-08 21:55:33 +02:00
impl InboxV2 {
pub fn new(ident_id: IdentId) -> Self {
Self { ident_id }
}
pub fn ident_id(&self) -> IdentIdRef<'_> {
&self.ident_id
}
/// Submit MlsKeypackage to registration service
refactor(core): replace Rc-based Context with a synchronous, Send-able Core (#123) Make the conversations core Send so the threaded client can own it behind an Arc<Mutex<Core>>: a background worker polls the transport and handles inbound payloads while the application thread issues outbound calls (send, create conversation). Sharing the core across those two threads means moving it into the spawned worker, which is only legal if it is Send. Access stays serialized by the client's Mutex (one thread at a time), so the core needs Send but not Sync and carries no lock of its own. See docs/adr/0001-client-event-system.md for the background-poller design. The Rc<RefCell> service-sharing is what made the core !Send. Context is de-Rc'd and renamed to Core, owning its services outright and driving the inbox and conversation primitives with plain &mut self. - Services (identity, delivery, store, registry, MLS context, causal history) are bundled into a ServiceContext<S> behind an ExternalServices trait, with S = (DS, RS, CS). Constructors live on the (DS, RS, CS) form because S cannot be inferred backwards through S::DS. - Inbox, InboxV2, PrivateV1Convo, and GroupV1Convo become non-generic and receive the ServiceContext bundle as a &mut/& parameter; no Rc or RefCell-as-shared-state remains, so Core is Send whenever its injected services are. - Dispatch branches on ConversationKind in one place: Core rebuilds the target as a Convo<S>/GroupConvo<S> trait object bound to the service bundle, so conversations never escape the orchestrator. - CausalHistoryStore drops its Rc, keeping a plain RefCell.
2026-06-08 21:55:33 +02:00
pub fn register<S: ExternalServices>(
&self,
cx: &mut ServiceContext<S>,
) -> 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
refactor(core): replace Rc-based Context with a synchronous, Send-able Core (#123) Make the conversations core Send so the threaded client can own it behind an Arc<Mutex<Core>>: a background worker polls the transport and handles inbound payloads while the application thread issues outbound calls (send, create conversation). Sharing the core across those two threads means moving it into the spawned worker, which is only legal if it is Send. Access stays serialized by the client's Mutex (one thread at a time), so the core needs Send but not Sync and carries no lock of its own. See docs/adr/0001-client-event-system.md for the background-poller design. The Rc<RefCell> service-sharing is what made the core !Send. Context is de-Rc'd and renamed to Core, owning its services outright and driving the inbox and conversation primitives with plain &mut self. - Services (identity, delivery, store, registry, MLS context, causal history) are bundled into a ServiceContext<S> behind an ExternalServices trait, with S = (DS, RS, CS). Constructors live on the (DS, RS, CS) form because S cannot be inferred backwards through S::DS. - Inbox, InboxV2, PrivateV1Convo, and GroupV1Convo become non-generic and receive the ServiceContext bundle as a &mut/& parameter; no Rc or RefCell-as-shared-state remains, so Core is Send whenever its injected services are. - Dispatch branches on ConversationKind in one place: Core rebuilds the target as a Convo<S>/GroupConvo<S> trait object bound to the service bundle, so conversations never escape the orchestrator. - CausalHistoryStore drops its Rc, keeping a plain RefCell.
2026-06-08 21:55:33 +02:00
cx.registry
.register(&cx.mls_identity, keypackage_bytes)
.map_err(ChatError::generic)
}
pub fn delivery_address(&self) -> String {
delivery_address_for(&self.ident_id)
}
pub fn id(&self) -> String {
conversation_id_for(&self.ident_id)
}
refactor(core): replace Rc-based Context with a synchronous, Send-able Core (#123) Make the conversations core Send so the threaded client can own it behind an Arc<Mutex<Core>>: a background worker polls the transport and handles inbound payloads while the application thread issues outbound calls (send, create conversation). Sharing the core across those two threads means moving it into the spawned worker, which is only legal if it is Send. Access stays serialized by the client's Mutex (one thread at a time), so the core needs Send but not Sync and carries no lock of its own. See docs/adr/0001-client-event-system.md for the background-poller design. The Rc<RefCell> service-sharing is what made the core !Send. Context is de-Rc'd and renamed to Core, owning its services outright and driving the inbox and conversation primitives with plain &mut self. - Services (identity, delivery, store, registry, MLS context, causal history) are bundled into a ServiceContext<S> behind an ExternalServices trait, with S = (DS, RS, CS). Constructors live on the (DS, RS, CS) form because S cannot be inferred backwards through S::DS. - Inbox, InboxV2, PrivateV1Convo, and GroupV1Convo become non-generic and receive the ServiceContext bundle as a &mut/& parameter; no Rc or RefCell-as-shared-state remains, so Core is Send whenever its injected services are. - Dispatch branches on ConversationKind in one place: Core rebuilds the target as a Convo<S>/GroupConvo<S> trait object bound to the service bundle, so conversations never escape the orchestrator. - CausalHistoryStore drops its Rc, keeping a plain RefCell.
2026-06-08 21:55:33 +02:00
pub fn handle_frame<S: ExternalServices>(
&self,
refactor(core): replace Rc-based Context with a synchronous, Send-able Core (#123) Make the conversations core Send so the threaded client can own it behind an Arc<Mutex<Core>>: a background worker polls the transport and handles inbound payloads while the application thread issues outbound calls (send, create conversation). Sharing the core across those two threads means moving it into the spawned worker, which is only legal if it is Send. Access stays serialized by the client's Mutex (one thread at a time), so the core needs Send but not Sync and carries no lock of its own. See docs/adr/0001-client-event-system.md for the background-poller design. The Rc<RefCell> service-sharing is what made the core !Send. Context is de-Rc'd and renamed to Core, owning its services outright and driving the inbox and conversation primitives with plain &mut self. - Services (identity, delivery, store, registry, MLS context, causal history) are bundled into a ServiceContext<S> behind an ExternalServices trait, with S = (DS, RS, CS). Constructors live on the (DS, RS, CS) form because S cannot be inferred backwards through S::DS. - Inbox, InboxV2, PrivateV1Convo, and GroupV1Convo become non-generic and receive the ServiceContext bundle as a &mut/& parameter; no Rc or RefCell-as-shared-state remains, so Core is Send whenever its injected services are. - Dispatch branches on ConversationKind in one place: Core rebuilds the target as a Convo<S>/GroupConvo<S> trait object bound to the service bundle, so conversations never escape the orchestrator. - CausalHistoryStore drops its Rc, keeping a plain RefCell.
2026-06-08 21:55:33 +02:00
payload_bytes: &[u8],
cx: &mut ServiceContext<S>,
) -> Result<InboxOutcome, ChatError> {
let inbox_frame = InboxV2Frame::decode(payload_bytes)?;
let Some(payload) = inbox_frame.payload else {
return Err(ChatError::BadParsing("InboxV2Payload missing"));
};
match payload {
InviteType::GroupV1(group_v1_heavy_invite) => {
refactor(core): replace Rc-based Context with a synchronous, Send-able Core (#123) Make the conversations core Send so the threaded client can own it behind an Arc<Mutex<Core>>: a background worker polls the transport and handles inbound payloads while the application thread issues outbound calls (send, create conversation). Sharing the core across those two threads means moving it into the spawned worker, which is only legal if it is Send. Access stays serialized by the client's Mutex (one thread at a time), so the core needs Send but not Sync and carries no lock of its own. See docs/adr/0001-client-event-system.md for the background-poller design. The Rc<RefCell> service-sharing is what made the core !Send. Context is de-Rc'd and renamed to Core, owning its services outright and driving the inbox and conversation primitives with plain &mut self. - Services (identity, delivery, store, registry, MLS context, causal history) are bundled into a ServiceContext<S> behind an ExternalServices trait, with S = (DS, RS, CS). Constructors live on the (DS, RS, CS) form because S cannot be inferred backwards through S::DS. - Inbox, InboxV2, PrivateV1Convo, and GroupV1Convo become non-generic and receive the ServiceContext bundle as a &mut/& parameter; no Rc or RefCell-as-shared-state remains, so Core is Send whenever its injected services are. - Dispatch branches on ConversationKind in one place: Core rebuilds the target as a Convo<S>/GroupConvo<S> trait object bound to the service bundle, so conversations never escape the orchestrator. - CausalHistoryStore drops its Rc, keeping a plain RefCell.
2026-06-08 21:55:33 +02:00
self.handle_heavy_invite(group_v1_heavy_invite, cx)
}
}
}
refactor(core): replace Rc-based Context with a synchronous, Send-able Core (#123) Make the conversations core Send so the threaded client can own it behind an Arc<Mutex<Core>>: a background worker polls the transport and handles inbound payloads while the application thread issues outbound calls (send, create conversation). Sharing the core across those two threads means moving it into the spawned worker, which is only legal if it is Send. Access stays serialized by the client's Mutex (one thread at a time), so the core needs Send but not Sync and carries no lock of its own. See docs/adr/0001-client-event-system.md for the background-poller design. The Rc<RefCell> service-sharing is what made the core !Send. Context is de-Rc'd and renamed to Core, owning its services outright and driving the inbox and conversation primitives with plain &mut self. - Services (identity, delivery, store, registry, MLS context, causal history) are bundled into a ServiceContext<S> behind an ExternalServices trait, with S = (DS, RS, CS). Constructors live on the (DS, RS, CS) form because S cannot be inferred backwards through S::DS. - Inbox, InboxV2, PrivateV1Convo, and GroupV1Convo become non-generic and receive the ServiceContext bundle as a &mut/& parameter; no Rc or RefCell-as-shared-state remains, so Core is Send whenever its injected services are. - Dispatch branches on ConversationKind in one place: Core rebuilds the target as a Convo<S>/GroupConvo<S> trait object bound to the service bundle, so conversations never escape the orchestrator. - CausalHistoryStore drops its Rc, keeping a plain RefCell.
2026-06-08 21:55:33 +02:00
fn persist_convo<S: ExternalServices>(
&self,
convo: &GroupV1Convo,
cx: &mut ServiceContext<S>,
) -> Result<(), ChatError> {
// TODO: (P2) Remove remote_convo_id this is an implementation detail specific to PrivateV1
// TODO: (P3) Implement From<Convo> for ConversationMeta
let meta = ConversationMeta {
local_convo_id: convo.id().to_string(),
remote_convo_id: "0".into(),
feat: introduce client event system (#106) * chore(flake): accept extra system attr; add perl for openssl-sys build forAllSystems calls the lambda with {system, pkgs}; strict destructuring requires `..` to ignore the system attribute. `pkgs.perl` is needed because openssl-sys is pulled vendored via libsqlite3-sys / rusqlite / chat-sqlite, and its `perl Configure` step needs FindBin.pm, which Fedora's system perl doesn't ship. * feat: introduce client event system - Core processing yields a `PayloadOutcome` enum — `Empty`, `Convo`, or `Inbox`. `ConvoOutcome` carries a conversation id and an optional decrypted `Content`; `InboxOutcome` adds a `NewConversation` (id + `ConversationClass`) for a peer-initiated conversation. - Client translates `PayloadOutcome` into app-facing `Vec<Event>` (`ConversationStarted`, `MessageReceived`) at the boundary, so the application loop sees discrete events rather than core types. - MLS group welcomes produce a `ConversationStarted` event with no initial content, fixing the silent-group-join case where the inbox layer dropped the observation. - C FFI exposes an `EventList` opaque type with indexed accessors and an `Invalid` sentinel for out-of-bounds / non-applicable reads. - Symmetric `Inbox` / `InboxV2` handlers: both return `Result<InboxOutcome, _>` and own the persistence + ephemeral-key cleanup for the conversations they create. - Updated and simplified `docs/adr/0001-client-event-system.md`. * chore(flake): bump nixpkgs to nixos-unstable-small Temporary. The two crates.io UA fixes (NixOS/nixpkgs#512735 for fetchCargoVendor's python-requests UA, NixOS/nixpkgs#524985 for importCargoLock's curl UA) haven't propagated to nixos-unstable yet. Switch to nixos-unstable-small and force logos-delivery to follow so the smoketest gets the same fix. Revert once nixos-unstable catches up. Refs: - https://github.com/rust-lang/crates.io/issues/13482 - https://github.com/rust-lang/crates.io/issues/13783 - https://crates.io/data-access
2026-05-28 23:51:15 +02:00
kind: ConversationKind::GroupV1,
};
refactor(core): replace Rc-based Context with a synchronous, Send-able Core (#123) Make the conversations core Send so the threaded client can own it behind an Arc<Mutex<Core>>: a background worker polls the transport and handles inbound payloads while the application thread issues outbound calls (send, create conversation). Sharing the core across those two threads means moving it into the spawned worker, which is only legal if it is Send. Access stays serialized by the client's Mutex (one thread at a time), so the core needs Send but not Sync and carries no lock of its own. See docs/adr/0001-client-event-system.md for the background-poller design. The Rc<RefCell> service-sharing is what made the core !Send. Context is de-Rc'd and renamed to Core, owning its services outright and driving the inbox and conversation primitives with plain &mut self. - Services (identity, delivery, store, registry, MLS context, causal history) are bundled into a ServiceContext<S> behind an ExternalServices trait, with S = (DS, RS, CS). Constructors live on the (DS, RS, CS) form because S cannot be inferred backwards through S::DS. - Inbox, InboxV2, PrivateV1Convo, and GroupV1Convo become non-generic and receive the ServiceContext bundle as a &mut/& parameter; no Rc or RefCell-as-shared-state remains, so Core is Send whenever its injected services are. - Dispatch branches on ConversationKind in one place: Core rebuilds the target as a Convo<S>/GroupConvo<S> trait object bound to the service bundle, so conversations never escape the orchestrator. - CausalHistoryStore drops its Rc, keeping a plain RefCell.
2026-06-08 21:55:33 +02:00
cx.store.save_conversation(&meta)?;
// TODO: (P1) Persist state
Ok(())
}
refactor(core): replace Rc-based Context with a synchronous, Send-able Core (#123) Make the conversations core Send so the threaded client can own it behind an Arc<Mutex<Core>>: a background worker polls the transport and handles inbound payloads while the application thread issues outbound calls (send, create conversation). Sharing the core across those two threads means moving it into the spawned worker, which is only legal if it is Send. Access stays serialized by the client's Mutex (one thread at a time), so the core needs Send but not Sync and carries no lock of its own. See docs/adr/0001-client-event-system.md for the background-poller design. The Rc<RefCell> service-sharing is what made the core !Send. Context is de-Rc'd and renamed to Core, owning its services outright and driving the inbox and conversation primitives with plain &mut self. - Services (identity, delivery, store, registry, MLS context, causal history) are bundled into a ServiceContext<S> behind an ExternalServices trait, with S = (DS, RS, CS). Constructors live on the (DS, RS, CS) form because S cannot be inferred backwards through S::DS. - Inbox, InboxV2, PrivateV1Convo, and GroupV1Convo become non-generic and receive the ServiceContext bundle as a &mut/& parameter; no Rc or RefCell-as-shared-state remains, so Core is Send whenever its injected services are. - Dispatch branches on ConversationKind in one place: Core rebuilds the target as a Convo<S>/GroupConvo<S> trait object bound to the service bundle, so conversations never escape the orchestrator. - CausalHistoryStore drops its Rc, keeping a plain RefCell.
2026-06-08 21:55:33 +02:00
fn handle_heavy_invite<S: ExternalServices>(
&self,
invite: GroupV1HeavyInvite,
cx: &mut ServiceContext<S>,
) -> Result<InboxOutcome, ChatError> {
let (msg_in, _rest) = MlsMessageIn::tls_deserialize_bytes(invite.welcome_bytes.as_slice())?;
let MlsMessageBodyIn::Welcome(welcome) = msg_in.extract() else {
return Err(ChatError::ProtocolExpectation(
"something else",
"Welcome".into(),
));
};
refactor(core): replace Rc-based Context with a synchronous, Send-able Core (#123) Make the conversations core Send so the threaded client can own it behind an Arc<Mutex<Core>>: a background worker polls the transport and handles inbound payloads while the application thread issues outbound calls (send, create conversation). Sharing the core across those two threads means moving it into the spawned worker, which is only legal if it is Send. Access stays serialized by the client's Mutex (one thread at a time), so the core needs Send but not Sync and carries no lock of its own. See docs/adr/0001-client-event-system.md for the background-poller design. The Rc<RefCell> service-sharing is what made the core !Send. Context is de-Rc'd and renamed to Core, owning its services outright and driving the inbox and conversation primitives with plain &mut self. - Services (identity, delivery, store, registry, MLS context, causal history) are bundled into a ServiceContext<S> behind an ExternalServices trait, with S = (DS, RS, CS). Constructors live on the (DS, RS, CS) form because S cannot be inferred backwards through S::DS. - Inbox, InboxV2, PrivateV1Convo, and GroupV1Convo become non-generic and receive the ServiceContext bundle as a &mut/& parameter; no Rc or RefCell-as-shared-state remains, so Core is Send whenever its injected services are. - Dispatch branches on ConversationKind in one place: Core rebuilds the target as a Convo<S>/GroupConvo<S> trait object bound to the service bundle, so conversations never escape the orchestrator. - CausalHistoryStore drops its Rc, keeping a plain RefCell.
2026-06-08 21:55:33 +02:00
let convo = GroupV1Convo::new_from_welcome(cx, welcome)?;
feat: introduce client event system (#106) * chore(flake): accept extra system attr; add perl for openssl-sys build forAllSystems calls the lambda with {system, pkgs}; strict destructuring requires `..` to ignore the system attribute. `pkgs.perl` is needed because openssl-sys is pulled vendored via libsqlite3-sys / rusqlite / chat-sqlite, and its `perl Configure` step needs FindBin.pm, which Fedora's system perl doesn't ship. * feat: introduce client event system - Core processing yields a `PayloadOutcome` enum — `Empty`, `Convo`, or `Inbox`. `ConvoOutcome` carries a conversation id and an optional decrypted `Content`; `InboxOutcome` adds a `NewConversation` (id + `ConversationClass`) for a peer-initiated conversation. - Client translates `PayloadOutcome` into app-facing `Vec<Event>` (`ConversationStarted`, `MessageReceived`) at the boundary, so the application loop sees discrete events rather than core types. - MLS group welcomes produce a `ConversationStarted` event with no initial content, fixing the silent-group-join case where the inbox layer dropped the observation. - C FFI exposes an `EventList` opaque type with indexed accessors and an `Invalid` sentinel for out-of-bounds / non-applicable reads. - Symmetric `Inbox` / `InboxV2` handlers: both return `Result<InboxOutcome, _>` and own the persistence + ephemeral-key cleanup for the conversations they create. - Updated and simplified `docs/adr/0001-client-event-system.md`. * chore(flake): bump nixpkgs to nixos-unstable-small Temporary. The two crates.io UA fixes (NixOS/nixpkgs#512735 for fetchCargoVendor's python-requests UA, NixOS/nixpkgs#524985 for importCargoLock's curl UA) haven't propagated to nixos-unstable yet. Switch to nixos-unstable-small and force logos-delivery to follow so the smoketest gets the same fix. Revert once nixos-unstable catches up. Refs: - https://github.com/rust-lang/crates.io/issues/13482 - https://github.com/rust-lang/crates.io/issues/13783 - https://crates.io/data-access
2026-05-28 23:51:15 +02:00
let convo_id: ConversationId = convo.id().to_string();
refactor(core): replace Rc-based Context with a synchronous, Send-able Core (#123) Make the conversations core Send so the threaded client can own it behind an Arc<Mutex<Core>>: a background worker polls the transport and handles inbound payloads while the application thread issues outbound calls (send, create conversation). Sharing the core across those two threads means moving it into the spawned worker, which is only legal if it is Send. Access stays serialized by the client's Mutex (one thread at a time), so the core needs Send but not Sync and carries no lock of its own. See docs/adr/0001-client-event-system.md for the background-poller design. The Rc<RefCell> service-sharing is what made the core !Send. Context is de-Rc'd and renamed to Core, owning its services outright and driving the inbox and conversation primitives with plain &mut self. - Services (identity, delivery, store, registry, MLS context, causal history) are bundled into a ServiceContext<S> behind an ExternalServices trait, with S = (DS, RS, CS). Constructors live on the (DS, RS, CS) form because S cannot be inferred backwards through S::DS. - Inbox, InboxV2, PrivateV1Convo, and GroupV1Convo become non-generic and receive the ServiceContext bundle as a &mut/& parameter; no Rc or RefCell-as-shared-state remains, so Core is Send whenever its injected services are. - Dispatch branches on ConversationKind in one place: Core rebuilds the target as a Convo<S>/GroupConvo<S> trait object bound to the service bundle, so conversations never escape the orchestrator. - CausalHistoryStore drops its Rc, keeping a plain RefCell.
2026-06-08 21:55:33 +02:00
self.persist_convo(&convo, cx)?;
feat: introduce client event system (#106) * chore(flake): accept extra system attr; add perl for openssl-sys build forAllSystems calls the lambda with {system, pkgs}; strict destructuring requires `..` to ignore the system attribute. `pkgs.perl` is needed because openssl-sys is pulled vendored via libsqlite3-sys / rusqlite / chat-sqlite, and its `perl Configure` step needs FindBin.pm, which Fedora's system perl doesn't ship. * feat: introduce client event system - Core processing yields a `PayloadOutcome` enum — `Empty`, `Convo`, or `Inbox`. `ConvoOutcome` carries a conversation id and an optional decrypted `Content`; `InboxOutcome` adds a `NewConversation` (id + `ConversationClass`) for a peer-initiated conversation. - Client translates `PayloadOutcome` into app-facing `Vec<Event>` (`ConversationStarted`, `MessageReceived`) at the boundary, so the application loop sees discrete events rather than core types. - MLS group welcomes produce a `ConversationStarted` event with no initial content, fixing the silent-group-join case where the inbox layer dropped the observation. - C FFI exposes an `EventList` opaque type with indexed accessors and an `Invalid` sentinel for out-of-bounds / non-applicable reads. - Symmetric `Inbox` / `InboxV2` handlers: both return `Result<InboxOutcome, _>` and own the persistence + ephemeral-key cleanup for the conversations they create. - Updated and simplified `docs/adr/0001-client-event-system.md`. * chore(flake): bump nixpkgs to nixos-unstable-small Temporary. The two crates.io UA fixes (NixOS/nixpkgs#512735 for fetchCargoVendor's python-requests UA, NixOS/nixpkgs#524985 for importCargoLock's curl UA) haven't propagated to nixos-unstable yet. Switch to nixos-unstable-small and force logos-delivery to follow so the smoketest gets the same fix. Revert once nixos-unstable catches up. Refs: - https://github.com/rust-lang/crates.io/issues/13482 - https://github.com/rust-lang/crates.io/issues/13783 - https://crates.io/data-access
2026-05-28 23:51:15 +02:00
Ok(InboxOutcome {
new_conversation: NewConversation {
convo_id,
class: ConversationClass::Group,
},
initial: None,
})
}
refactor(core): replace Rc-based Context with a synchronous, Send-able Core (#123) Make the conversations core Send so the threaded client can own it behind an Arc<Mutex<Core>>: a background worker polls the transport and handles inbound payloads while the application thread issues outbound calls (send, create conversation). Sharing the core across those two threads means moving it into the spawned worker, which is only legal if it is Send. Access stays serialized by the client's Mutex (one thread at a time), so the core needs Send but not Sync and carries no lock of its own. See docs/adr/0001-client-event-system.md for the background-poller design. The Rc<RefCell> service-sharing is what made the core !Send. Context is de-Rc'd and renamed to Core, owning its services outright and driving the inbox and conversation primitives with plain &mut self. - Services (identity, delivery, store, registry, MLS context, causal history) are bundled into a ServiceContext<S> behind an ExternalServices trait, with S = (DS, RS, CS). Constructors live on the (DS, RS, CS) form because S cannot be inferred backwards through S::DS. - Inbox, InboxV2, PrivateV1Convo, and GroupV1Convo become non-generic and receive the ServiceContext bundle as a &mut/& parameter; no Rc or RefCell-as-shared-state remains, so Core is Send whenever its injected services are. - Dispatch branches on ConversationKind in one place: Core rebuilds the target as a Convo<S>/GroupConvo<S> trait object bound to the service bundle, so conversations never escape the orchestrator. - CausalHistoryStore drops its Rc, keeping a plain RefCell.
2026-06-08 21:55:33 +02:00
fn create_keypackage<S: ExternalServices>(
cx: &ServiceContext<S>,
) -> Result<KeyPackage, ChatError> {
let capabilities = Capabilities::builder()
.ciphersuites(vec![
Ciphersuite::MLS_256_XWING_CHACHA20POLY1305_SHA256_Ed25519,
])
.extensions(vec![ExtensionType::ApplicationId])
.build();
let a = KeyPackage::builder()
.leaf_node_capabilities(capabilities)
.build(
Ciphersuite::MLS_256_XWING_CHACHA20POLY1305_SHA256_Ed25519,
refactor(core): replace Rc-based Context with a synchronous, Send-able Core (#123) Make the conversations core Send so the threaded client can own it behind an Arc<Mutex<Core>>: a background worker polls the transport and handles inbound payloads while the application thread issues outbound calls (send, create conversation). Sharing the core across those two threads means moving it into the spawned worker, which is only legal if it is Send. Access stays serialized by the client's Mutex (one thread at a time), so the core needs Send but not Sync and carries no lock of its own. See docs/adr/0001-client-event-system.md for the background-poller design. The Rc<RefCell> service-sharing is what made the core !Send. Context is de-Rc'd and renamed to Core, owning its services outright and driving the inbox and conversation primitives with plain &mut self. - Services (identity, delivery, store, registry, MLS context, causal history) are bundled into a ServiceContext<S> behind an ExternalServices trait, with S = (DS, RS, CS). Constructors live on the (DS, RS, CS) form because S cannot be inferred backwards through S::DS. - Inbox, InboxV2, PrivateV1Convo, and GroupV1Convo become non-generic and receive the ServiceContext bundle as a &mut/& parameter; no Rc or RefCell-as-shared-state remains, so Core is Send whenever its injected services are. - Dispatch branches on ConversationKind in one place: Core rebuilds the target as a Convo<S>/GroupConvo<S> trait object bound to the service bundle, so conversations never escape the orchestrator. - CausalHistoryStore drops its Rc, keeping a plain RefCell.
2026-06-08 21:55:33 +02:00
&cx.mls_provider,
&cx.mls_identity,
cx.mls_identity.get_credential(),
)
.expect("Failed to build KeyPackage");
Ok(a.key_package().clone())
}
}
// Publishing the account → device bundle needs the account key, so this method
// is available only when the registry also implements `AccountDirectory`. The
// signing authority is the `LogosAccount` wrapped by `mls_identity`; on testnet
// that is a local key (account key == device key), while an external signer
// would supply its own authority.
impl InboxV2 {
/// Add this installation's device key to the account's directory bundle.
///
/// Fetches the current (verified) device set, adds this device if absent,
/// bumps the lamport, re-signs with the account key, and publishes. Safe to
/// call repeatedly — an unchanged set is simply re-published, which also
/// refreshes the server's retention clock.
pub fn publish_device_bundle<S: ExternalServices>(
&self,
cx: &mut ServiceContext<S>,
) -> Result<(), ChatError> {
// On testnet `mls_identity` doubles as the `AccountAuthority` — the
// account key is the installation's own key.
let authority = &cx.mls_identity;
let account_pub = AccountAuthority::account_pub(authority).clone();
let device_key = cx.mls_identity.public_key().clone();
let device_hex = hex::encode(device_key.as_ref());
// Start from the devices already registered so other installations of
// this account are preserved across the upsert.
let existing = cx
.registry
.fetch(&account_pub)
.map_err(|e| ChatError::Generic(e.to_string()))?;
let (mut devices, next_lamport) = match existing {
Some(set) => {
let mut keys = Vec::with_capacity(set.devices.len() + 1);
for hex_id in &set.devices {
let bytes: [u8; 32] = hex::decode(hex_id)
.ok()
.and_then(|b| b.try_into().ok())
.ok_or_else(|| {
ChatError::Generic("directory returned a malformed device id".into())
})?;
let key = Ed25519VerifyingKey::from_bytes(&bytes).map_err(|_| {
ChatError::Generic("directory returned a malformed device key".into())
})?;
keys.push(key);
}
(keys, set.lamport + 1)
}
None => (Vec::new(), 0),
};
if !devices
.iter()
.any(|d| hex::encode(d.as_ref()) == device_hex)
{
devices.push(device_key);
}
let payload = encode_bundle_payload(next_lamport, &devices);
let signature = AccountAuthority::sign(authority, &payload)
.map_err(|e| ChatError::Generic(e.to_string()))?;
let bundle = SignedDeviceBundle {
account_pub,
payload,
signature,
};
cx.registry
.publish(&bundle)
.map_err(|e| ChatError::Generic(e.to_string()))
}
}
#[derive(Clone, PartialEq, Message)]
pub struct InboxV2Frame {
#[prost(oneof = "InviteType", tags = "1")]
pub payload: Option<InviteType>,
}
#[derive(Clone, PartialEq, Oneof)]
pub enum InviteType {
#[prost(message, tag = "1")]
GroupV1(GroupV1HeavyInvite),
}
#[derive(Clone, PartialEq, Message)]
pub struct GroupV1HeavyInvite {
#[prost(bytes, tag = "1")]
pub welcome_bytes: Vec<u8>,
}