diff --git a/CLAUDE.md b/CLAUDE.md index 6db8e737..6a2ed661 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,7 +18,6 @@ crates/ ├── identity/ — Ed25519 identity, message signing, profiles (willow-identity) ├── messaging/ — Chat messages, HLC ordering, message store (willow-messaging) ├── crypto/ — E2E encryption: ChaCha20-Poly1305, X25519 key exchange (willow-crypto) -├── channel/ — Servers, channels, roles, permissions (willow-channel) ├── network/ — iroh-based P2P networking (willow-network) │ └── src/ │ ├── lib.rs — Module exports, re-exports @@ -181,14 +180,19 @@ interaction. ## Architecture Notes +### Authority Model + +See [`docs/specs/2026-04-12-state-authority-and-mutations.md`](docs/specs/2026-04-12-state-authority-and-mutations.md). +All authority checks live in `willow-state::materialize::apply_event` +and the `required_permission()` table. Permissions are checked before +an event is created — rejected events never enter the DAG. + ### Dependency Graph ``` willow-web → willow-client → willow-state → willow-network (iroh, iroh-gossip, iroh-blobs) → willow-crypto → willow-identity → willow-transport - → willow-channel → willow-crypto - → willow-identity → willow-messaging → willow-identity (defines SealedContent used by willow-crypto) ``` @@ -270,9 +274,9 @@ consistent ordering even when system clocks drift. ### Adding a new permission -1. Add a variant to `Permission` in `crates/channel/src/lib.rs` -2. Check it in the relevant server methods -3. Add tests +1. Add a variant to `Permission` in `crates/state/src/event.rs` +2. Add the `EventKind` → `Permission` mapping to `required_permission()` in `crates/state/src/materialize.rs` +3. Add state-machine tests: grant, revoke, rejection without permission ### Adding a new iroh protocol diff --git a/Cargo.lock b/Cargo.lock index c585182e..8e9c1ce4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5974,23 +5974,6 @@ dependencies = [ "willow-state", ] -[[package]] -name = "willow-channel" -version = "0.1.0" -dependencies = [ - "chrono", - "getrandom 0.2.17", - "getrandom 0.3.4", - "serde", - "thiserror 1.0.69", - "tokio", - "uuid", - "willow-crypto", - "willow-identity", - "willow-state", - "willow-transport", -] - [[package]] name = "willow-client" version = "0.1.0" @@ -6012,7 +5995,6 @@ dependencies = [ "wasm-bindgen-futures", "web-sys", "willow-actor", - "willow-channel", "willow-common", "willow-crypto", "willow-identity", @@ -6203,6 +6185,7 @@ dependencies = [ "willow-client", "willow-identity", "willow-network", + "willow-state", ] [[package]] diff --git a/crates/agent/src/resources.rs b/crates/agent/src/resources.rs index de6472a8..d5c26495 100644 --- a/crates/agent/src/resources.rs +++ b/crates/agent/src/resources.rs @@ -317,7 +317,7 @@ struct CurrentServerResource { #[derive(Serialize)] struct ChannelEntry { name: String, - kind: String, + kind: willow_state::ChannelKind, } #[derive(Serialize)] diff --git a/crates/channel/Cargo.toml b/crates/channel/Cargo.toml deleted file mode 100644 index 0253f791..00000000 --- a/crates/channel/Cargo.toml +++ /dev/null @@ -1,24 +0,0 @@ -[package] -name = "willow-channel" -edition.workspace = true -version.workspace = true -license.workspace = true -description = "Servers, channels, roles, and permissions for Willow" - -[dependencies] -willow-identity = { path = "../identity" } -willow-transport = { path = "../transport" } -willow-crypto = { path = "../crypto" } -willow-state = { path = "../state" } - -serde = { workspace = true } -chrono = { workspace = true } -uuid = { workspace = true } -thiserror = { workspace = true } - -[target.'cfg(target_arch = "wasm32")'.dependencies] -getrandom_02 = { package = "getrandom", version = "0.2", features = ["js"] } -getrandom_03 = { package = "getrandom", version = "0.3", features = ["wasm_js"] } - -[dev-dependencies] -tokio = { workspace = true } diff --git a/crates/channel/src/lib.rs b/crates/channel/src/lib.rs deleted file mode 100644 index 934c3a07..00000000 --- a/crates/channel/src/lib.rs +++ /dev/null @@ -1,1164 +0,0 @@ -//! # Willow Channel -//! -//! Servers, channels, roles, and permissions for the Willow P2P network. -//! -//! This crate defines data structures for servers, channels, roles, and -//! permissions. **All authority enforcement happens in -//! `willow-state::materialize::apply_*`.** Direct mutation of a [`Server`] -//! value is not an enforcement boundary — it is a data-shape operation -//! used by the materializer and tests. Code that needs to enforce trust -//! decisions (admin promotion, kicks, role assignment, etc.) MUST go -//! through the event-sourced [`willow_state`] machine, not through these -//! types. -//! -//! ## Data model -//! -//! Willow borrows Discord's organisational hierarchy: -//! -//! - A **[`Server`]** is a named community owned by a peer. It contains -//! channels and members. -//! - A **[`Channel`]** is a named conversation space inside a server. Channels -//! can be text or voice. -//! - A **[`Role`]** groups a set of [`Permission`]s under a name. -//! - **[`Member`]** tracks a peer's membership in a server, including which -//! roles they hold. -//! -//! ## Invite system -//! -//! Servers are private by default. New members join via an [`Invite`] — a -//! signed token that grants the bearer permission to join. Invites can be -//! one-time-use or have an expiration date. -//! -//! ## Examples -//! -//! ``` -//! use willow_channel::{Server, ChannelKind}; -//! use willow_identity::Identity; -//! -//! let owner = Identity::generate(); -//! let mut server = Server::new("My Server", owner.endpoint_id()); -//! -//! server.create_channel("general", ChannelKind::Text).unwrap(); -//! server.create_channel("voice-chat", ChannelKind::Voice).unwrap(); -//! -//! assert_eq!(server.channels().len(), 2); -//! ``` - -use std::collections::{HashMap, HashSet}; - -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - -use willow_identity::EndpointId; - -// ───── IDs ─────────────────────────────────────────────────────────────────── - -/// Unique identifier for a server. -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct ServerId(pub Uuid); - -impl ServerId { - pub fn new() -> Self { - Self(Uuid::new_v4()) - } -} - -impl Default for ServerId { - fn default() -> Self { - Self::new() - } -} - -impl std::fmt::Display for ServerId { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.0) - } -} - -/// Unique identifier for a channel within a server. -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct ChannelId(pub Uuid); - -impl ChannelId { - pub fn new() -> Self { - Self(Uuid::new_v4()) - } -} - -impl Default for ChannelId { - fn default() -> Self { - Self::new() - } -} - -impl std::fmt::Display for ChannelId { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.0) - } -} - -/// Unique identifier for a role. -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct RoleId(pub Uuid); - -impl RoleId { - pub fn new() -> Self { - Self(Uuid::new_v4()) - } -} - -impl Default for RoleId { - fn default() -> Self { - Self::new() - } -} - -impl std::fmt::Display for RoleId { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.0) - } -} - -/// Unique identifier for an invite. -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct InviteId(pub Uuid); - -impl InviteId { - pub fn new() -> Self { - Self(Uuid::new_v4()) - } -} - -impl Default for InviteId { - fn default() -> Self { - Self::new() - } -} - -// ───── Errors ──────────────────────────────────────────────────────────────── - -/// Errors produced by channel operations. -#[derive(Debug, thiserror::Error)] -pub enum ChannelError { - /// The caller does not have the required permission. - #[error("permission denied: {0} requires {1:?}")] - PermissionDenied(EndpointId, Permission), - - /// A channel with this name already exists in the server. - #[error("duplicate channel name: {0}")] - DuplicateChannelName(String), - - /// The referenced channel was not found. - #[error("channel not found: {0}")] - ChannelNotFound(ChannelId), - - /// The referenced role was not found. - #[error("role not found: {0}")] - RoleNotFound(RoleId), - - /// The peer is not a member of this server. - #[error("not a member: {0}")] - NotAMember(EndpointId), - - /// The invite has expired or already been used. - #[error("invite expired or invalid")] - InvalidInvite, -} - -// ───── Permissions ─────────────────────────────────────────────────────────── - -/// Re-export the single authoritative Permission enum from willow-state. -/// -/// All permissions are defined in the state crate and enforced by the -/// event-sourced state machine. Admin status is separate and managed -/// exclusively through the governance vote path. -pub use willow_state::Permission; - -// ───── Role ────────────────────────────────────────────────────────────────── - -/// A named bundle of [`Permission`]s that can be assigned to members. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct Role { - /// Unique ID. - pub id: RoleId, - /// Human-readable name (e.g. "Moderator"). - pub name: String, - /// The set of permissions this role grants. - pub permissions: HashSet, - /// Display color as a hex string (e.g. "#FF5733"). - pub color: Option, -} - -impl Role { - /// Create a new role with no permissions. - pub fn new(name: impl Into) -> Self { - Self { - id: RoleId::new(), - name: name.into(), - permissions: HashSet::new(), - color: None, - } - } - - /// Create a new role with a specific ID and no permissions. - pub fn with_id(id: RoleId, name: impl Into) -> Self { - Self { - id, - name: name.into(), - permissions: HashSet::new(), - color: None, - } - } - - /// Check whether this role grants a specific permission. - pub fn has_permission(&self, perm: Permission) -> bool { - self.permissions.contains(&perm) - } -} - -// ───── Channel ─────────────────────────────────────────────────────────────── - -/// Whether a channel carries text or voice. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum ChannelKind { - /// A text chat channel. - Text, - /// A voice (and optionally video/screenshare) channel. - Voice, -} - -/// A named conversation space inside a [`Server`]. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct Channel { - /// Unique ID. - pub id: ChannelId, - /// Display name (e.g. "general"). - pub name: String, - /// Optional description / topic. - pub topic: Option, - /// Text or voice. - pub kind: ChannelKind, - /// When this channel was created. - pub created_at: DateTime, - /// Hashes of pinned messages in this channel. - #[serde(default)] - pub pinned_messages: std::collections::BTreeSet, -} - -impl Channel { - /// Create a new channel. - pub fn new(name: impl Into, kind: ChannelKind) -> Self { - Self { - id: ChannelId::new(), - name: name.into(), - topic: None, - kind, - created_at: Utc::now(), - pinned_messages: std::collections::BTreeSet::new(), - } - } -} - -// ───── Member ──────────────────────────────────────────────────────────────── - -/// A peer's membership record within a server. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct Member { - /// The peer. - pub peer_id: EndpointId, - /// Roles assigned to this member. - pub roles: HashSet, - /// When they joined. - pub joined_at: DateTime, -} - -impl Member { - /// Create a new member with no roles. - pub fn new(peer_id: EndpointId) -> Self { - Self { - peer_id, - roles: HashSet::new(), - joined_at: Utc::now(), - } - } -} - -// ───── Invite ──────────────────────────────────────────────────────────────── - -/// A signed token granting the bearer permission to join a server. -/// -/// When created with [`Server::create_invite_for`], the invite includes -/// encrypted channel keys so the new member can decrypt messages. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Invite { - /// Unique ID. - pub id: InviteId, - /// The server this invite is for. - pub server_id: ServerId, - /// Who created the invite. - pub created_by: EndpointId, - /// When the invite was created. - pub created_at: DateTime, - /// When the invite expires (if ever). - pub expires_at: Option>, - /// Maximum number of uses (None = unlimited). - pub max_uses: Option, - /// How many times this invite has been used. - pub uses: u32, - /// Encrypted channel keys for the recipient. Each entry wraps a - /// channel's symmetric key using the recipient's X25519 public key. - #[serde(default)] - pub encrypted_keys: Vec<(ChannelId, willow_crypto::EncryptedChannelKey)>, -} - -impl Invite { - /// Check whether this invite is still valid. - pub fn is_valid(&self) -> bool { - if let Some(expires) = self.expires_at { - if Utc::now() > expires { - return false; - } - } - if let Some(max) = self.max_uses { - if self.uses >= max { - return false; - } - } - true - } -} - -// ───── Server ──────────────────────────────────────────────────────────────── - -/// A named community containing channels and members. -/// -/// Admins have implicit access to all permissions. Other members' -/// access is determined by their roles and direct permission grants. -/// -/// Identity-bearing fields (`id`, `name`, `description`, `admins`) are -/// private — direct mutation of them by external code would bypass the -/// event-sourced authority model in [`willow_state`]. Read them via -/// [`Server::id`], [`Server::name`], [`Server::description`], and -/// [`Server::admins`]. The materializer and integration code use the -/// explicitly-named `*_for_materializer` setters to populate these -/// fields after applying authoritative events. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Server { - /// Unique ID. - id: ServerId, - /// Display name. - name: String, - /// Optional description. - description: Option, - /// The peer who created the server (genesis author). - pub creator: EndpointId, - /// The set of peers with admin status. - admins: HashSet, - /// When the server was created. - pub created_at: DateTime, - - channels: HashMap, - roles: HashMap, - members: HashMap, - invites: HashMap, - - /// Per-channel symmetric encryption keys. Not serialized — keys are - /// distributed via invites and stored locally. - #[serde(skip)] - channel_keys: HashMap, -} - -impl Server { - /// Create a new server. The creator is the initial admin and member. - pub fn new(name: impl Into, creator: EndpointId) -> Self { - let mut members = HashMap::new(); - members.insert(creator, Member::new(creator)); - let mut admins = HashSet::new(); - admins.insert(creator); - - Self { - id: ServerId::new(), - name: name.into(), - description: None, - creator, - admins, - created_at: Utc::now(), - channels: HashMap::new(), - roles: HashMap::new(), - members, - invites: HashMap::new(), - channel_keys: HashMap::new(), - } - } - - /// Create a new server with a specific [`ServerId`]. - /// - /// Used by the join flow (and by the materializer if it ever needs - /// to seed a [`Server`] mirror) when the ID was decided elsewhere — - /// e.g. parsed from an invite payload or derived from the genesis - /// event hash. The creator is the initial admin and member, just - /// like [`Server::new`]. - pub fn with_id(id: ServerId, name: impl Into, creator: EndpointId) -> Self { - let mut server = Self::new(name, creator); - server.id = id; - server - } - - // ── Queries ────────────────────────────────────────────────────────── - - /// The server's unique ID. - pub fn id(&self) -> &ServerId { - &self.id - } - - /// The server's display name. - pub fn name(&self) -> &str { - &self.name - } - - /// The server's optional description / topic line. - pub fn description(&self) -> Option<&str> { - self.description.as_deref() - } - - /// The set of peers with admin status. - /// - /// This is a derived view — the authoritative admin set lives in - /// [`willow_state::ServerState::admins`]. Mutations to this set on - /// a `Server` value have no effect on trust decisions: they are a - /// data-shape mirror used by the materializer and tests. - pub fn admins(&self) -> &HashSet { - &self.admins - } - - // ── Materializer-only setters ──────────────────────────────────────── - // - // These exist so `willow_state::materialize` (a different crate) can - // populate the data-shape mirror after applying authoritative events. - // `pub(crate)` would not be visible to `willow_state`, so they are - // `pub` but explicitly named and `#[doc(hidden)]` to discourage - // accidental use. **They are NOT an enforcement boundary** — the - // event-sourced model in `willow_state` is the sole authority. - - /// Internal use only — call from `willow-state::materialize` or test - /// helpers. The event-sourced model is the authority; this is a - /// data-shape mutation, not an enforcement boundary. - #[doc(hidden)] - pub fn set_admin_for_materializer(&mut self, peer: EndpointId, is_admin: bool) { - if is_admin { - self.admins.insert(peer); - } else { - self.admins.remove(&peer); - } - } - - /// Internal use only — call from `willow-state::materialize` or test - /// helpers. The event-sourced model is the authority; this is a - /// data-shape mutation, not an enforcement boundary. - #[doc(hidden)] - pub fn set_name_for_materializer(&mut self, name: impl Into) { - self.name = name.into(); - } - - /// Internal use only — call from `willow-state::materialize` or test - /// helpers. The event-sourced model is the authority; this is a - /// data-shape mutation, not an enforcement boundary. - #[doc(hidden)] - pub fn set_description_for_materializer(&mut self, description: Option) { - self.description = description; - } - - /// All channels in this server. - pub fn channels(&self) -> Vec<&Channel> { - self.channels.values().collect() - } - - /// All roles defined in this server. - pub fn roles(&self) -> Vec<&Role> { - self.roles.values().collect() - } - - /// All current members. - pub fn members(&self) -> Vec<&Member> { - self.members.values().collect() - } - - /// Get a channel by ID. - pub fn channel(&self, id: &ChannelId) -> Option<&Channel> { - self.channels.get(id) - } - - /// Check whether a peer is a member of this server. - pub fn is_member(&self, peer: &EndpointId) -> bool { - self.members.contains_key(peer) - } - - /// Check whether a peer has a specific permission. - /// - /// Admins always have all permissions. - pub fn has_permission(&self, peer: &EndpointId, perm: Permission) -> bool { - if self.admins.contains(peer) { - return true; - } - - let member = match self.members.get(peer) { - Some(m) => m, - None => return false, - }; - - member.roles.iter().any(|role_id| { - self.roles - .get(role_id) - .map(|r| r.has_permission(perm)) - .unwrap_or(false) - }) - } - - // ── Mutations ──────────────────────────────────────────────────────── - - /// Create a new channel in this server. Generates and stores a - /// symmetric encryption key for the channel. - pub fn create_channel( - &mut self, - name: impl Into, - kind: ChannelKind, - ) -> Result { - let name = name.into(); - - if self.channels.values().any(|ch| ch.name == name) { - return Err(ChannelError::DuplicateChannelName(name)); - } - - let channel = Channel::new(name, kind); - let id = channel.id.clone(); - self.channel_keys - .insert(id.clone(), willow_crypto::generate_channel_key()); - self.channels.insert(id.clone(), channel); - Ok(id) - } - - /// Create a channel with a specific ID (for replaying remote ops). - pub fn create_channel_with_id( - &mut self, - id: ChannelId, - name: impl Into, - kind: ChannelKind, - ) -> Result { - let name = name.into(); - if self.channels.values().any(|ch| ch.name == name) { - return Err(ChannelError::DuplicateChannelName(name)); - } - let channel = Channel { - id: id.clone(), - name, - topic: None, - kind, - created_at: chrono::Utc::now(), - pinned_messages: std::collections::BTreeSet::new(), - }; - self.channel_keys - .insert(id.clone(), willow_crypto::generate_channel_key()); - self.channels.insert(id.clone(), channel); - Ok(id) - } - - /// Get the encryption key for a channel. - pub fn channel_key(&self, id: &ChannelId) -> Option<&willow_crypto::ChannelKey> { - self.channel_keys.get(id) - } - - /// Store a channel key (e.g. after decrypting from an invite). - pub fn set_channel_key(&mut self, id: ChannelId, key: willow_crypto::ChannelKey) { - self.channel_keys.insert(id, key); - } - - /// Delete a channel by ID. - pub fn delete_channel(&mut self, id: &ChannelId) -> Result<(), ChannelError> { - self.channels - .remove(id) - .map(|_| ()) - .ok_or_else(|| ChannelError::ChannelNotFound(id.clone())) - } - - /// Create a new role. - pub fn create_role(&mut self, role: Role) -> RoleId { - let id = role.id.clone(); - self.roles.insert(id.clone(), role); - id - } - - /// Toggle a permission on a role. Adds if missing, removes if present. - pub fn toggle_permission( - &mut self, - role_id: &RoleId, - perm: Permission, - ) -> Result<(), ChannelError> { - let role = self - .roles - .get_mut(role_id) - .ok_or_else(|| ChannelError::RoleNotFound(role_id.clone()))?; - if role.permissions.contains(&perm) { - role.permissions.remove(&perm); - } else { - role.permissions.insert(perm); - } - Ok(()) - } - - /// Explicitly set or clear a permission on a role. - pub fn set_permission( - &mut self, - role_id: &RoleId, - perm: Permission, - granted: bool, - ) -> Result<(), ChannelError> { - let role = self - .roles - .get_mut(role_id) - .ok_or_else(|| ChannelError::RoleNotFound(role_id.clone()))?; - if granted { - role.permissions.insert(perm); - } else { - role.permissions.remove(&perm); - } - Ok(()) - } - - /// Delete a role by ID. - pub fn delete_role(&mut self, role_id: &RoleId) -> Result<(), ChannelError> { - self.roles - .remove(role_id) - .ok_or_else(|| ChannelError::RoleNotFound(role_id.clone()))?; - // Remove the role from all members. - for member in self.members.values_mut() { - member.roles.remove(role_id); - } - Ok(()) - } - - /// Get a role by ID. - pub fn role(&self, id: &RoleId) -> Option<&Role> { - self.roles.get(id) - } - - /// Assign a role to a member. - pub fn assign_role(&mut self, peer: &EndpointId, role_id: &RoleId) -> Result<(), ChannelError> { - if !self.roles.contains_key(role_id) { - return Err(ChannelError::RoleNotFound(role_id.clone())); - } - - let member = self - .members - .get_mut(peer) - .ok_or(ChannelError::NotAMember(*peer))?; - - member.roles.insert(role_id.clone()); - Ok(()) - } - - /// Add a new member to the server. - pub fn add_member(&mut self, peer: EndpointId) { - self.members - .entry(peer) - .or_insert_with(|| Member::new(peer)); - } - - /// Remove a member from the server and rotate all channel keys. - /// - /// Cannot remove an admin. Returns the new channel keys so the app - /// can distribute them to remaining members. - pub fn remove_member( - &mut self, - peer: &EndpointId, - ) -> Result, ChannelError> { - if self.admins.contains(peer) { - return Err(ChannelError::PermissionDenied( - *peer, - Permission::ManageChannels, - )); - } - - self.members - .remove(peer) - .ok_or(ChannelError::NotAMember(*peer))?; - - // Rotate all channel keys so the removed member can't read future messages. - let mut new_keys = HashMap::new(); - for channel_id in self.channels.keys() { - let new_key = willow_crypto::generate_channel_key(); - self.channel_keys - .insert(channel_id.clone(), new_key.clone()); - new_keys.insert(channel_id.clone(), new_key); - } - - Ok(new_keys) - } - - /// Create an invite without encrypted keys (for backwards compat / tests). - pub fn create_invite( - &mut self, - created_by: EndpointId, - expires_at: Option>, - max_uses: Option, - ) -> Result { - if !self.is_member(&created_by) { - return Err(ChannelError::NotAMember(created_by)); - } - - let invite = Invite { - id: InviteId::new(), - server_id: self.id.clone(), - created_by, - created_at: Utc::now(), - expires_at, - max_uses, - uses: 0, - encrypted_keys: Vec::new(), - }; - - let id = invite.id.clone(); - self.invites.insert(id.clone(), invite); - Ok(id) - } - - /// Create an invite with encrypted channel keys for a specific recipient. - /// - /// The `recipient_ed25519_public` is the recipient's 32-byte Ed25519 - /// public key. Each channel's symmetric key is encrypted using ephemeral - /// X25519 DH so only the intended recipient can decrypt. - pub fn create_invite_for( - &mut self, - created_by: EndpointId, - recipient_ed25519_public: &[u8; 32], - expires_at: Option>, - max_uses: Option, - ) -> Result { - if !self.is_member(&created_by) { - return Err(ChannelError::NotAMember(created_by)); - } - - let mut encrypted_keys = Vec::new(); - for (channel_id, key) in &self.channel_keys { - if let Ok(enc) = willow_crypto::encrypt_channel_key_for(key, recipient_ed25519_public) { - encrypted_keys.push((channel_id.clone(), enc)); - } - } - - let invite = Invite { - id: InviteId::new(), - server_id: self.id.clone(), - created_by, - created_at: Utc::now(), - expires_at, - max_uses, - uses: 0, - encrypted_keys, - }; - - let id = invite.id.clone(); - self.invites.insert(id.clone(), invite); - Ok(id) - } - - /// Get an invite by ID. - pub fn invite(&self, id: &InviteId) -> Option<&Invite> { - self.invites.get(id) - } - - /// Use an invite to add a new member. - pub fn use_invite( - &mut self, - invite_id: &InviteId, - peer: EndpointId, - ) -> Result<(), ChannelError> { - let invite = self - .invites - .get_mut(invite_id) - .ok_or(ChannelError::InvalidInvite)?; - - if !invite.is_valid() { - return Err(ChannelError::InvalidInvite); - } - - invite.uses += 1; - self.add_member(peer); - Ok(()) - } -} - -// ───── Tests ───────────────────────────────────────────────────────────────── - -#[cfg(test)] -mod tests { - use super::*; - use willow_identity::Identity; - - fn owner_and_server() -> (EndpointId, Server) { - let owner = Identity::generate().endpoint_id(); - let server = Server::new("Test Server", owner); - (owner, server) - } - - #[test] - fn new_server_has_owner_as_member() { - let (owner, server) = owner_and_server(); - assert!(server.is_member(&owner)); - assert_eq!(server.members().len(), 1); - } - - #[test] - fn admin_has_all_permissions() { - let (owner, server) = owner_and_server(); - assert!(server.admins().contains(&owner)); - assert!(server.has_permission(&owner, Permission::ManageChannels)); - assert!(server.has_permission(&owner, Permission::SendMessages)); - assert!(server.has_permission(&owner, Permission::SyncProvider)); - } - - #[test] - fn non_member_has_no_permissions() { - let (_, server) = owner_and_server(); - let stranger = Identity::generate().endpoint_id(); - assert!(!server.has_permission(&stranger, Permission::SendMessages)); - } - - #[test] - fn create_channels() { - let (_, mut server) = owner_and_server(); - - let text_id = server.create_channel("general", ChannelKind::Text).unwrap(); - let voice_id = server - .create_channel("voice-chat", ChannelKind::Voice) - .unwrap(); - - assert_eq!(server.channels().len(), 2); - assert_eq!(server.channel(&text_id).unwrap().kind, ChannelKind::Text); - assert_eq!(server.channel(&voice_id).unwrap().kind, ChannelKind::Voice); - } - - #[test] - fn duplicate_channel_name_rejected() { - let (_, mut server) = owner_and_server(); - server.create_channel("general", ChannelKind::Text).unwrap(); - - let result = server.create_channel("general", ChannelKind::Text); - assert!(matches!(result, Err(ChannelError::DuplicateChannelName(_)))); - } - - #[test] - fn delete_channel() { - let (_, mut server) = owner_and_server(); - let id = server.create_channel("temp", ChannelKind::Text).unwrap(); - - server.delete_channel(&id).unwrap(); - assert!(server.channel(&id).is_none()); - } - - #[test] - fn delete_nonexistent_channel_fails() { - let (_, mut server) = owner_and_server(); - let fake_id = ChannelId::new(); - assert!(matches!( - server.delete_channel(&fake_id), - Err(ChannelError::ChannelNotFound(_)) - )); - } - - #[test] - fn roles_and_permissions() { - let (_, mut server) = owner_and_server(); - let alice = Identity::generate().endpoint_id(); - server.add_member(alice); - - // Alice starts with no permissions. - assert!(!server.has_permission(&alice, Permission::SendMessages)); - - // Create a moderator role. - let mut mod_role = Role::new("Moderator"); - mod_role.permissions.insert(Permission::SendMessages); - mod_role.permissions.insert(Permission::ManageChannels); - let role_id = server.create_role(mod_role); - - // Assign it to Alice. - server.assign_role(&alice, &role_id).unwrap(); - - assert!(server.has_permission(&alice, Permission::SendMessages)); - assert!(server.has_permission(&alice, Permission::ManageChannels)); - assert!(!server.has_permission(&alice, Permission::ManageRoles)); - } - - #[test] - fn admins_set_grants_everything() { - let (_, mut server) = owner_and_server(); - let bob = Identity::generate().endpoint_id(); - server.add_member(bob); - - // Promote bob to admin via the materializer-only setter — this - // is the data-shape mirror of an event the state machine would - // have applied. It is NOT an enforcement boundary. - server.set_admin_for_materializer(bob, true); - - // Admins have all permissions implicitly. - assert!(server.has_permission(&bob, Permission::ManageChannels)); - assert!(server.has_permission(&bob, Permission::ManageRoles)); - assert!(server.has_permission(&bob, Permission::CreateInvite)); - } - - #[test] - fn assign_role_to_non_member_fails() { - let (_, mut server) = owner_and_server(); - let role = Role::new("Test"); - let role_id = server.create_role(role); - - let stranger = Identity::generate().endpoint_id(); - assert!(matches!( - server.assign_role(&stranger, &role_id), - Err(ChannelError::NotAMember(_)) - )); - } - - #[test] - fn remove_member() { - let (_, mut server) = owner_and_server(); - let alice = Identity::generate().endpoint_id(); - server.add_member(alice); - - server.remove_member(&alice).unwrap(); - assert!(!server.is_member(&alice)); - } - - #[test] - fn cannot_remove_owner() { - let (owner, mut server) = owner_and_server(); - assert!(server.remove_member(&owner).is_err()); - } - - #[test] - fn invite_flow() { - let (owner, mut server) = owner_and_server(); - - // Owner creates an invite. - let invite_id = server.create_invite(owner, None, Some(1)).unwrap(); - - // A new peer uses it. - let newcomer = Identity::generate().endpoint_id(); - server.use_invite(&invite_id, newcomer).unwrap(); - assert!(server.is_member(&newcomer)); - - // Second use fails (max_uses = 1). - let another = Identity::generate().endpoint_id(); - assert!(matches!( - server.use_invite(&invite_id, another), - Err(ChannelError::InvalidInvite) - )); - } - - #[test] - fn expired_invite_rejected() { - let (owner, mut server) = owner_and_server(); - - // Create an invite that already expired. - let past = Utc::now() - chrono::Duration::hours(1); - let invite_id = server.create_invite(owner, Some(past), None).unwrap(); - - let peer = Identity::generate().endpoint_id(); - assert!(matches!( - server.use_invite(&invite_id, peer), - Err(ChannelError::InvalidInvite) - )); - } - - #[test] - fn server_serde_round_trip() { - let (_, mut server) = owner_and_server(); - server.create_channel("general", ChannelKind::Text).unwrap(); - - let bytes = willow_transport::pack(&server).unwrap(); - let decoded: Server = willow_transport::unpack(&bytes).unwrap(); - - assert_eq!(decoded.name(), server.name()); - assert_eq!(decoded.channels().len(), 1); - } - - #[test] - fn create_channel_generates_key() { - let (_, mut server) = owner_and_server(); - let ch_id = server.create_channel("secret", ChannelKind::Text).unwrap(); - assert!(server.channel_key(&ch_id).is_some()); - } - - #[test] - fn invite_with_encrypted_keys_round_trip() { - let (owner, mut server) = owner_and_server(); - let ch_id = server.create_channel("general", ChannelKind::Text).unwrap(); - - let newcomer = Identity::generate(); - let public_key = newcomer.public_key(); - let pub_bytes = public_key.as_bytes(); - - let invite_id = server - .create_invite_for(owner, pub_bytes, None, Some(1)) - .unwrap(); - - let invite = server.invite(&invite_id).unwrap(); - assert_eq!(invite.encrypted_keys.len(), 1); - assert_eq!(invite.encrypted_keys[0].0, ch_id); - - // Newcomer can decrypt the channel key. - let decrypted = - willow_crypto::decrypt_channel_key(&invite.encrypted_keys[0].1, &newcomer).unwrap(); - - // Verify it matches the original. - let original = server.channel_key(&ch_id).unwrap(); - assert_eq!(decrypted.as_bytes(), original.as_bytes()); - } - - #[test] - fn remove_member_rotates_channel_keys() { - let (_, mut server) = owner_and_server(); - let ch_id = server.create_channel("general", ChannelKind::Text).unwrap(); - let original_key = server.channel_key(&ch_id).unwrap().as_bytes().to_owned(); - - let alice = Identity::generate().endpoint_id(); - server.add_member(alice); - - let new_keys = server.remove_member(&alice).unwrap(); - - // Key should have changed. - let rotated_key = server.channel_key(&ch_id).unwrap().as_bytes().to_owned(); - assert_ne!(original_key, rotated_key); - - // Returned keys should match what's stored. - assert!(new_keys.contains_key(&ch_id)); - assert_eq!(new_keys[&ch_id].as_bytes(), &rotated_key); - } - - #[test] - fn remove_member_rotates_all_channels() { - let (_, mut server) = owner_and_server(); - let ch1 = server.create_channel("general", ChannelKind::Text).unwrap(); - let ch2 = server.create_channel("random", ChannelKind::Text).unwrap(); - - let alice = Identity::generate().endpoint_id(); - server.add_member(alice); - - let new_keys = server.remove_member(&alice).unwrap(); - assert_eq!(new_keys.len(), 2); - assert!(new_keys.contains_key(&ch1)); - assert!(new_keys.contains_key(&ch2)); - } - - #[test] - fn set_channel_key_overrides() { - let (_, mut server) = owner_and_server(); - let ch_id = server.create_channel("general", ChannelKind::Text).unwrap(); - - let original = server.channel_key(&ch_id).unwrap().as_bytes().to_owned(); - let new_key = willow_crypto::generate_channel_key(); - server.set_channel_key(ch_id.clone(), new_key); - - let updated = server.channel_key(&ch_id).unwrap().as_bytes().to_owned(); - assert_ne!(original, updated); - } - - #[test] - fn invite_valid_with_both_limits() { - let (owner, mut server) = owner_and_server(); - - let future = Utc::now() + chrono::Duration::hours(1); - let invite_id = server.create_invite(owner, Some(future), Some(2)).unwrap(); - - let newcomer1 = Identity::generate().endpoint_id(); - server.use_invite(&invite_id, newcomer1).unwrap(); - - let newcomer2 = Identity::generate().endpoint_id(); - server.use_invite(&invite_id, newcomer2).unwrap(); - - // Third use should fail (max_uses=2). - let newcomer3 = Identity::generate().endpoint_id(); - assert!(server.use_invite(&invite_id, newcomer3).is_err()); - } - - #[test] - fn non_member_cannot_create_invite() { - let (_, mut server) = owner_and_server(); - let stranger = Identity::generate().endpoint_id(); - assert!(server.create_invite(stranger, None, None).is_err()); - } - - #[test] - fn server_description() { - let (_, mut server) = owner_and_server(); - server.set_description_for_materializer(Some("A cool server".into())); - - let bytes = willow_transport::pack(&server).unwrap(); - let decoded: Server = willow_transport::unpack(&bytes).unwrap(); - assert_eq!(decoded.description(), Some("A cool server")); - } - - #[test] - fn channel_has_pinned_messages_field() { - let ch = Channel::new("general", ChannelKind::Text); - assert!(ch.pinned_messages.is_empty()); - } - - #[test] - fn channel_pinned_messages_serde_round_trip() { - let mut ch = Channel::new("general", ChannelKind::Text); - let hash = willow_state::EventHash([0xAB; 32]); - ch.pinned_messages.insert(hash); - - let bytes = willow_transport::pack(&ch).unwrap(); - let deserialized: Channel = willow_transport::unpack(&bytes).unwrap(); - assert_eq!(deserialized.pinned_messages.len(), 1); - assert!(deserialized.pinned_messages.contains(&hash)); - } - - #[test] - fn channel_pinned_messages_defaults_empty_for_old_data() { - // Simulate old serialized data without pinned_messages field. - // Serialize a channel, strip the pinned_messages via JSON manipulation, - // then deserialize to verify #[serde(default)] works. - let ch = Channel::new("general", ChannelKind::Text); - let bytes = willow_transport::pack(&ch).unwrap(); - // Even with an empty set serialized, deserialization should succeed. - let decoded: Channel = willow_transport::unpack(&bytes).unwrap(); - assert!(decoded.pinned_messages.is_empty()); - } - - #[test] - fn server_id_name_description_admins_are_encapsulated() { - // Regression test for issue #118: identity-bearing fields on - // `Server` are private. The only public way to read them is via - // accessor methods, and the only mutation paths are explicit - // `*_for_materializer` setters or the `with_id` constructor. - // This test exists so removing those accessors or re-exposing - // the fields breaks the build. - let (owner, server) = owner_and_server(); - - // Accessors return references / Option references. - let _: &ServerId = server.id(); - let _: &str = server.name(); - let _: Option<&str> = server.description(); - let _: &HashSet = server.admins(); - - // Mutation only via the explicit constructor / setters. - let custom_id = ServerId::new(); - let mut other = Server::with_id(custom_id.clone(), "Other", owner); - assert_eq!(other.id(), &custom_id); - - other.set_name_for_materializer("Renamed"); - assert_eq!(other.name(), "Renamed"); - - other.set_description_for_materializer(Some("desc".into())); - assert_eq!(other.description(), Some("desc")); - - let stranger = Identity::generate().endpoint_id(); - assert!(!other.admins().contains(&stranger)); - other.set_admin_for_materializer(stranger, true); - assert!(other.admins().contains(&stranger)); - other.set_admin_for_materializer(stranger, false); - assert!(!other.admins().contains(&stranger)); - } -} diff --git a/crates/client/Cargo.toml b/crates/client/Cargo.toml index 11b33e73..a0daac0c 100644 --- a/crates/client/Cargo.toml +++ b/crates/client/Cargo.toml @@ -12,7 +12,6 @@ test-utils = ["willow-network/test-utils"] willow-actor = { path = "../actor" } willow-identity = { path = "../identity" } willow-messaging = { path = "../messaging" } -willow-channel = { path = "../channel" } willow-network = { path = "../network" } willow-transport = { path = "../transport" } willow-crypto = { path = "../crypto" } diff --git a/crates/client/src/accessors.rs b/crates/client/src/accessors.rs index 48da8891..929faee1 100644 --- a/crates/client/src/accessors.rs +++ b/crates/client/src/accessors.rs @@ -135,7 +135,7 @@ impl ClientHandle { willow_actor::state::select(&self.event_state_addr, |es| es.admins.clone()).await } - pub async fn channel_kinds(&self) -> Vec<(String, String)> { + pub async fn channel_kinds(&self) -> Vec<(String, willow_state::ChannelKind)> { willow_actor::state::select(&self.event_state_addr, |es| { es.channels .values() diff --git a/crates/client/src/actions.rs b/crates/client/src/actions.rs index 60b5d7b1..b5723d5e 100644 --- a/crates/client/src/actions.rs +++ b/crates/client/src/actions.rs @@ -126,32 +126,13 @@ impl ClientHandle { pub async fn create_voice_channel(&self, name: &str) -> anyhow::Result<()> { let name = name.to_string(); - let name_for_event = name.clone(); - let ch_id_str = willow_actor::state::mutate( - &self.server_registry_addr, - move |reg| -> anyhow::Result { - let entry = reg - .active_mut() - .ok_or_else(|| anyhow::anyhow!("no active server"))?; - let ch_id = entry - .server - .create_channel(&name, willow_channel::ChannelKind::Voice)?; - let topic = util::make_topic(&entry.server, &name); - if let Some(key) = entry.server.channel_key(&ch_id) { - entry.keys.insert(topic.clone(), key.clone()); - } - let ch_id_str = ch_id.to_string(); - entry.topic_map.insert(topic, (name.clone(), ch_id)); - Ok(ch_id_str) - }, - ) - .await?; + let ch_id_str = uuid::Uuid::new_v4().to_string(); let event = self .mutation_handle .build_event(willow_state::EventKind::CreateChannel { - name: name_for_event, + name, channel_id: ch_id_str, - kind: "voice".to_string(), + kind: willow_state::ChannelKind::Voice, }) .await?; self.mutation_handle.apply_event(&event).await; @@ -200,21 +181,6 @@ impl ClientHandle { ) -> anyhow::Result<()> { let role_id = role_id.to_string(); let permission = permission.to_string(); - let rid = willow_channel::RoleId( - uuid::Uuid::parse_str(&role_id).map_err(|e| anyhow::anyhow!("invalid role_id: {e}"))?, - ); - let perm = parse_permission(&permission)?; - willow_actor::state::mutate( - &self.server_registry_addr, - move |reg| -> anyhow::Result<()> { - let entry = reg - .active_mut() - .ok_or_else(|| anyhow::anyhow!("no active server"))?; - entry.server.set_permission(&rid, perm, granted)?; - Ok(()) - }, - ) - .await?; let event = self .mutation_handle .build_event(willow_state::EventKind::SetPermission { @@ -234,29 +200,6 @@ impl ClientHandle { role_id: &str, ) -> anyhow::Result<()> { let role_id = role_id.to_string(); - let rid = willow_channel::RoleId( - uuid::Uuid::parse_str(&role_id).map_err(|e| anyhow::anyhow!("invalid role_id: {e}"))?, - ); - willow_actor::state::mutate( - &self.server_registry_addr, - move |reg| -> anyhow::Result<()> { - let entry = reg - .active_mut() - .ok_or_else(|| anyhow::anyhow!("no active server"))?; - let member_peer = entry - .server - .members() - .iter() - .find(|m| m.peer_id == peer_id) - .map(|m| m.peer_id); - let Some(peer) = member_peer else { - anyhow::bail!("peer not found"); - }; - entry.server.assign_role(&peer, &rid)?; - Ok(()) - }, - ) - .await?; let event = self .mutation_handle .build_event(willow_state::EventKind::AssignRole { peer_id, role_id }) diff --git a/crates/client/src/connect.rs b/crates/client/src/connect.rs index db4c1c4f..c16a082f 100644 --- a/crates/client/src/connect.rs +++ b/crates/client/src/connect.rs @@ -57,14 +57,17 @@ impl ClientHandle { } // Subscribe to channel topics from all servers. - let channel_topics: Vec = - willow_actor::state::select(&self.server_registry_addr, |reg| { + // Derive topic strings from event_state channels + server registry IDs. + let channel_topics: Vec = { + let es = willow_actor::state::get(&self.event_state_addr).await; + willow_actor::state::select(&self.server_registry_addr, move |reg| { reg.servers .values() - .flat_map(|entry| entry.topic_map.keys().cloned()) + .flat_map(|entry| entry.channel_topics(&es)) .collect() }) - .await; + .await + }; for topic_str in &channel_topics { let bootstrap = self.bootstrap_peers.clone(); @@ -125,14 +128,16 @@ impl ClientHandle { .broadcast_on_topic(ops::SERVER_OPS_TOPIC, data); } - let channel_topics: Vec = - willow_actor::state::select(&self.server_registry_addr, |reg| { + let channel_topics: Vec = { + let es = willow_actor::state::get(&self.event_state_addr).await; + willow_actor::state::select(&self.server_registry_addr, move |reg| { reg.servers .values() - .flat_map(|entry| entry.topic_map.keys().cloned()) + .flat_map(|entry| entry.channel_topics(&es)) .collect() }) - .await; + .await + }; for topic_str in channel_topics { let msg = ops::WireMessage::SyncRequest { state_hash, diff --git a/crates/client/src/invite.rs b/crates/client/src/invite.rs index 63147501..dfb7fc3a 100644 --- a/crates/client/src/invite.rs +++ b/crates/client/src/invite.rs @@ -58,19 +58,24 @@ pub struct EncryptedChannel { /// Generate a secure invite code encrypted for a specific recipient. /// -/// The `recipient_ed25519_public` is the 32-byte Ed25519 public key of -/// the intended recipient (extracted from their PeerId). +/// Takes the data it needs directly rather than a Server object: +/// - `server_name` / `server_id` / `genesis_author` for the invite payload +/// - `keys`: topic → channel key +/// - `topic_map`: topic → channel name +/// - `recipient_ed25519_public`: 32-byte Ed25519 public key /// /// Returns `None` if encryption fails. pub fn generate_invite( - server: &willow_channel::Server, + server_name: &str, + server_id: &str, + genesis_author: willow_identity::EndpointId, keys: &HashMap, - topic_map: &HashMap, + topic_map: &HashMap, recipient_ed25519_public: &[u8; 32], ) -> Option { let mut channels = Vec::new(); - for (topic, (name, _ch_id)) in topic_map { + for (topic, name) in topic_map { if let Some(key) = keys.get(topic) { let encrypted_key = willow_crypto::encrypt_channel_key_for(key, recipient_ed25519_public).ok()?; @@ -83,9 +88,9 @@ pub fn generate_invite( } let payload = InvitePayload { - server_name: server.name().to_string(), - server_id: server.id().to_string(), - genesis_author: server.creator, + server_name: server_name.to_string(), + server_id: server_id.to_string(), + genesis_author, sync_providers: Vec::new(), // populated by caller if known channels, }; @@ -147,39 +152,59 @@ mod tests { use super::*; use willow_identity::Identity; - #[test] - fn secure_invite_round_trip() { - use willow_channel::ChannelKind; - - let owner = Identity::generate(); - let recipient = Identity::generate(); - - // Owner creates a server with a channel. - let mut server = willow_channel::Server::new("Secure Server", owner.endpoint_id()); - let ch_id = server.create_channel("general", ChannelKind::Text).unwrap(); - + /// Helper: create a server_id, a channel key, and the corresponding + /// keys + topic_map for a single-channel server. + fn test_server_with_channels( + _server_name: &str, + channel_names: &[&str], + ) -> ( + String, // server_id + HashMap, // keys + HashMap, // topic_map: topic → name + ) { + let server_id = format!("test-server-{}", uuid::Uuid::new_v4()); let mut keys = HashMap::new(); let mut topic_map = HashMap::new(); - let topic = format!("{}/general", server.id()); - if let Some(key) = server.channel_key(&ch_id) { - keys.insert(topic.clone(), key.clone()); + for name in channel_names { + let topic = format!("{}/{}", server_id, name); + let key = willow_crypto::generate_channel_key(); + keys.insert(topic.clone(), key); + topic_map.insert(topic, name.to_string()); } - topic_map.insert(topic.clone(), ("general".into(), ch_id)); - // Get recipient's Ed25519 public key bytes. + (server_id, keys, topic_map) + } + + /// Helper to extract Ed25519 public key bytes from an Identity. + fn recipient_public_bytes(identity: &Identity) -> [u8; 32] { + *identity.endpoint_id().as_bytes() + } + + #[test] + fn secure_invite_round_trip() { + let owner = Identity::generate(); + let recipient = Identity::generate(); + + let (server_id, keys, topic_map) = test_server_with_channels("Secure Server", &["general"]); + let topic = format!("{}/general", server_id); let recipient_pub = recipient_public_bytes(&recipient); - // Generate invite encrypted for the recipient. - let code = generate_invite(&server, &keys, &topic_map, &recipient_pub).unwrap(); + let code = generate_invite( + "Secure Server", + &server_id, + owner.endpoint_id(), + &keys, + &topic_map, + &recipient_pub, + ) + .unwrap(); - // Recipient accepts the invite. let accepted = accept_invite(&code, &recipient).unwrap(); assert_eq!(accepted.server_name, "Secure Server"); assert_eq!(accepted.channel_keys.len(), 1); - // Verify the decrypted key matches the original. let (name, decrypted_key) = &accepted.channel_keys[&topic]; assert_eq!(name, "general"); assert_eq!(decrypted_key.as_bytes(), keys[&topic].as_bytes()); @@ -187,26 +212,22 @@ mod tests { #[test] fn wrong_recipient_cannot_decrypt() { - use willow_channel::ChannelKind; - let owner = Identity::generate(); let intended = Identity::generate(); let intruder = Identity::generate(); - let mut server = willow_channel::Server::new("Secure", owner.endpoint_id()); - let ch_id = server.create_channel("secret", ChannelKind::Text).unwrap(); - - let mut keys = HashMap::new(); - let mut topic_map = HashMap::new(); - let topic = format!("{}/secret", server.id()); - - if let Some(key) = server.channel_key(&ch_id) { - keys.insert(topic.clone(), key.clone()); - } - topic_map.insert(topic, ("secret".into(), ch_id)); + let (server_id, keys, topic_map) = test_server_with_channels("Secure", &["secret"]); let intended_pub = recipient_public_bytes(&intended); - let code = generate_invite(&server, &keys, &topic_map, &intended_pub).unwrap(); + let code = generate_invite( + "Secure", + &server_id, + owner.endpoint_id(), + &keys, + &topic_map, + &intended_pub, + ) + .unwrap(); // Intruder cannot decrypt the invite. assert!(accept_invite(&code, &intruder).is_none()); @@ -235,64 +256,45 @@ mod tests { #[test] fn multiple_channels_encrypted() { - use willow_channel::ChannelKind; - let owner = Identity::generate(); let recipient = Identity::generate(); - let mut server = willow_channel::Server::new("Multi", owner.endpoint_id()); - let ch1 = server.create_channel("general", ChannelKind::Text).unwrap(); - let ch2 = server.create_channel("random", ChannelKind::Text).unwrap(); - let ch3 = server.create_channel("voice", ChannelKind::Voice).unwrap(); - - let mut keys = HashMap::new(); - let mut topic_map = HashMap::new(); - - for (ch_id, name) in [(ch1, "general"), (ch2, "random"), (ch3, "voice")] { - let topic = format!("{}/{name}", server.id()); - if let Some(key) = server.channel_key(&ch_id) { - keys.insert(topic.clone(), key.clone()); - } - topic_map.insert(topic, (name.into(), ch_id)); - } + let (server_id, keys, topic_map) = + test_server_with_channels("Multi", &["general", "random", "voice"]); let recipient_pub = recipient_public_bytes(&recipient); - let code = generate_invite(&server, &keys, &topic_map, &recipient_pub).unwrap(); + let code = generate_invite( + "Multi", + &server_id, + owner.endpoint_id(), + &keys, + &topic_map, + &recipient_pub, + ) + .unwrap(); let accepted = accept_invite(&code, &recipient).unwrap(); assert_eq!(accepted.channel_keys.len(), 3); } - /// Helper to extract Ed25519 public key bytes from an Identity. - fn recipient_public_bytes(identity: &Identity) -> [u8; 32] { - *identity.endpoint_id().as_bytes() - } - #[test] fn generate_invite_via_endpoint_id_produces_valid_invite() { - use willow_channel::ChannelKind; - let owner = Identity::generate(); let joiner = Identity::generate(); - let mut server = willow_channel::Server::new("Join Test", owner.endpoint_id()); - let ch_id = server.create_channel("general", ChannelKind::Text).unwrap(); - - let mut keys = HashMap::new(); - let mut topic_map = HashMap::new(); - let topic = format!("{}/general", server.id()); - - if let Some(key) = server.channel_key(&ch_id) { - keys.insert(topic.clone(), key.clone()); - } - topic_map.insert(topic.clone(), ("general".into(), ch_id)); + let (server_id, keys, topic_map) = test_server_with_channels("Join Test", &["general"]); - // Use endpoint_id_to_ed25519_public — same path as JoinRequest handler. let pub_key = endpoint_id_to_ed25519_public(&joiner.endpoint_id()); - let code = generate_invite(&server, &keys, &topic_map, &pub_key); + let code = generate_invite( + "Join Test", + &server_id, + owner.endpoint_id(), + &keys, + &topic_map, + &pub_key, + ); assert!(code.is_some(), "generate_invite should produce a value"); - // Joiner can accept and decrypt the invite. let accepted = accept_invite(&code.unwrap(), &joiner).unwrap(); assert_eq!(accepted.server_name, "Join Test"); assert_eq!(accepted.channel_keys.len(), 1); diff --git a/crates/client/src/joining.rs b/crates/client/src/joining.rs index 7503e108..b9c9cf73 100644 --- a/crates/client/src/joining.rs +++ b/crates/client/src/joining.rs @@ -6,14 +6,51 @@ impl ClientHandle { recipient_peer_id: &willow_identity::EndpointId, ) -> anyhow::Result { let pub_key = invite::endpoint_id_to_ed25519_public(recipient_peer_id); - let invite_code = willow_actor::state::select(&self.server_registry_addr, move |reg| { - let entry = reg - .active() - .ok_or_else(|| anyhow::anyhow!("no active server"))?; - invite::generate_invite(&entry.server, &entry.keys, &entry.topic_map, &pub_key) - .ok_or_else(|| anyhow::anyhow!("invite generation failed")) + + // Gather server info from registry. + let (server_name, server_id, keys) = + willow_actor::state::select(&self.server_registry_addr, move |reg| { + let entry = reg + .active() + .ok_or_else(|| anyhow::anyhow!("no active server"))?; + Ok::<_, anyhow::Error>(( + entry.name.clone(), + entry.server_id.clone(), + entry.keys.clone(), + )) + }) + .await?; + + // Build topic_names from event_state channels + server_id. + let sid = server_id.clone(); + let topic_names: HashMap = + willow_actor::state::select(&self.event_state_addr, move |es| { + es.channels + .values() + .map(|ch| { + let topic = crate::util::make_topic(&sid, &ch.name); + (topic, ch.name.clone()) + }) + .collect() + }) + .await; + + // Get genesis author (first admin) from event_state. + let my_id = self.identity.endpoint_id(); + let genesis_author = willow_actor::state::select(&self.event_state_addr, move |es| { + es.admins.iter().next().copied().unwrap_or(my_id) }) - .await?; + .await; + + let invite_code = invite::generate_invite( + &server_name, + &server_id, + genesis_author, + &keys, + &topic_names, + &pub_key, + ) + .ok_or_else(|| anyhow::anyhow!("invite generation failed"))?; // Grant SendMessages permission to the joining peer so they can // actually send messages once they accept the invite. Without this, @@ -53,7 +90,7 @@ impl ClientHandle { // caller instead of silently inventing a fresh server id, which // would split-brain the joiner from the rest of the network // (issue #115). - let parsed_server_uuid = uuid::Uuid::parse_str(&server_id).map_err(|e| { + let _parsed_server_uuid = uuid::Uuid::parse_str(&server_id).map_err(|e| { crate::ClientError::MalformedInvite(format!("invalid server_id `{server_id}`: {e}")) })?; @@ -62,52 +99,29 @@ impl ClientHandle { &self.server_registry_addr, move |reg| -> Result, crate::ClientError> { if let Some(entry) = reg.servers.get_mut(&server_id) { - for (topic, (name, key)) in &accepted.channel_keys { + // Existing server — just merge in the channel keys. + for (topic, (_name, key)) in &accepted.channel_keys { entry.keys.insert(topic.clone(), key.clone()); - if !entry.topic_map.contains_key(topic) { - entry.topic_map.insert( - topic.clone(), - (name.clone(), willow_channel::ChannelId::new()), - ); - } } } else { - let mut server = willow_channel::Server::with_id( - willow_channel::ServerId(parsed_server_uuid), - &accepted.server_name, - accepted.genesis_author, - ); - let mut topic_map = HashMap::new(); + // New server — build a minimal ServerEntry. let mut keys = HashMap::new(); - for (topic, (name, key)) in &accepted.channel_keys { - let ch_id = server - .create_channel(name, willow_channel::ChannelKind::Text) - .map_err(|e| { - crate::ClientError::MalformedInvite(format!( - "could not create channel `{name}` from invite: {e}" - )) - })?; - server.set_channel_key(ch_id.clone(), key.clone()); + for (topic, (_name, key)) in &accepted.channel_keys { keys.insert(topic.clone(), key.clone()); - topic_map.insert(topic.clone(), (name.clone(), ch_id)); } reg.servers.insert( server_id.clone(), state_actors::ServerEntry { - server, + server_id: server_id.clone(), name: accepted.server_name.clone(), - topic_map, keys, unread: HashMap::new(), }, ); } reg.active_server = Some(server_id.clone()); - let topics = reg - .servers - .get(&server_id) - .map(|e| e.topic_map.keys().cloned().collect()) - .unwrap_or_default(); + // Derive channel topics from invite channel_keys. + let topics: Vec = accepted.channel_keys.keys().cloned().collect(); Ok(topics) }, ) diff --git a/crates/client/src/lib.rs b/crates/client/src/lib.rs index f274ce12..b7ed97cc 100644 --- a/crates/client/src/lib.rs +++ b/crates/client/src/lib.rs @@ -266,43 +266,16 @@ fn persist_servers(state: &ClientState) { let ids: Vec = state.servers.keys().cloned().collect(); storage::save_server_list(&ids); for (id, ctx) in &state.servers { - storage::save_server_by_id(id, &ctx.server, &ctx.keys); + let meta = storage::SavedServerMeta { + server_id: ctx.server_id.clone(), + name: ctx.name.clone(), + }; + storage::save_server_by_id(id, &meta, &ctx.keys); } } // on_connected is no longer needed — subscription is handled by connect(). -/// Reconcile `topic_map` channel IDs with `event_state.channels`. -/// -/// After event state is loaded or synced, the `topic_map` may have stale -/// channel IDs (from invite acceptance or legacy storage). This updates -/// them to match the authoritative IDs in `event_state`. -pub(crate) fn reconcile_topic_map(state: &mut ClientState) { - // Collect corrections first to avoid borrow conflicts. - let corrections: Vec<(String, willow_channel::ChannelId)> = state - .event_state - .channels - .iter() - .map(|(id_str, ch)| { - let cid = willow_channel::ChannelId( - uuid::Uuid::parse_str(id_str).unwrap_or_else(|_| uuid::Uuid::new_v4()), - ); - (ch.name.clone(), cid) - }) - .collect(); - - let Some(ctx) = state.active_mut() else { - return; - }; - for (ch_name, correct_id) in &corrections { - for (_topic, (map_name, map_id)) in ctx.topic_map.iter_mut() { - if map_name == ch_name { - *map_id = correct_id.clone(); - } - } - } -} - impl ClientHandle { /// Create a new client. Loads or generates identity, loads or creates /// the server with default channels, loads persisted messages. @@ -331,16 +304,10 @@ impl ClientHandle { if let Some(ids) = &server_ids { // Load each server from per-server storage. for id in ids { - if let Some((server, keys)) = storage::load_server_by_id(id) { - let mut topic_map = HashMap::new(); - for ch in server.channels() { - let topic = util::make_topic(&server, &ch.name); - topic_map.insert(topic, (ch.name.clone(), ch.id.clone())); - } - + if let Some((meta, keys)) = storage::load_server_by_id(id) { let ctx = ServerContext { - server, - topic_map, + server_id: meta.server_id, + name: meta.name, keys, unread: HashMap::new(), }; @@ -354,17 +321,11 @@ impl ClientHandle { // Fall back to legacy single-server storage. Do NOT create a default. if state.servers.is_empty() { - if let Some((server, keys)) = storage::load_server() { - let sid = server.id().to_string(); - let mut topic_map = HashMap::new(); - for ch in server.channels() { - let topic = util::make_topic(&server, &ch.name); - topic_map.insert(topic, (ch.name.clone(), ch.id.clone())); - } - + if let Some((meta, keys)) = storage::load_server() { + let sid = meta.server_id.clone(); let ctx = ServerContext { - server, - topic_map, + server_id: meta.server_id, + name: meta.name, keys, unread: HashMap::new(), }; @@ -385,24 +346,9 @@ impl ClientHandle { } else { state.event_state = willow_state::ServerState::new( sid.clone(), - ctx.server.name().to_string(), - ctx.server.creator, + ctx.name.clone(), + identity.endpoint_id(), ); - - // No persisted events -- seed event_state with existing - // channels from legacy storage so lookups work. - for (topic, (name, ch_id)) in &ctx.topic_map { - let _ = topic; - state.event_state.channels.insert( - ch_id.to_string(), - willow_state::Channel { - id: ch_id.to_string(), - name: name.clone(), - pinned_messages: std::collections::BTreeSet::new(), - kind: "text".to_string(), - }, - ); - } } } } @@ -443,9 +389,8 @@ impl ClientHandle { registry.servers.insert( id.clone(), state_actors::ServerEntry { - server: ctx.server.clone(), - name: ctx.server.name().to_string(), - topic_map: ctx.topic_map.clone(), + server_id: ctx.server_id.clone(), + name: ctx.name.clone(), keys: ctx.keys.clone(), unread: ctx.unread.clone(), }, @@ -611,8 +556,6 @@ impl ClientHandle { dag: dag_addr.clone(), }; - reconcile_topic_map(&mut state); - let handle = ClientHandle { system: system.handle(), network: None, @@ -634,8 +577,6 @@ impl ClientHandle { mutation_handle, }; - // reconcile_topic_map called above before spawning actor - let event_loop = ClientEventLoop { _system: system }; (handle, event_loop) @@ -674,14 +615,15 @@ fn load_identity() -> Identity { identity } -/// Parse a permission string into a [`willow_channel::Permission`]. -fn parse_permission(s: &str) -> anyhow::Result { +/// Parse a permission string into a [`willow_state::Permission`]. +#[allow(dead_code)] +fn parse_permission(s: &str) -> anyhow::Result { match s { - "SyncProvider" => Ok(willow_channel::Permission::SyncProvider), - "SendMessages" => Ok(willow_channel::Permission::SendMessages), - "CreateInvite" => Ok(willow_channel::Permission::CreateInvite), - "ManageChannels" => Ok(willow_channel::Permission::ManageChannels), - "ManageRoles" => Ok(willow_channel::Permission::ManageRoles), + "SyncProvider" => Ok(willow_state::Permission::SyncProvider), + "SendMessages" => Ok(willow_state::Permission::SendMessages), + "CreateInvite" => Ok(willow_state::Permission::CreateInvite), + "ManageChannels" => Ok(willow_state::Permission::ManageChannels), + "ManageRoles" => Ok(willow_state::Permission::ManageRoles), _ => anyhow::bail!("unknown permission: {s}"), } } @@ -696,34 +638,6 @@ pub fn test_client() -> ( let mut state = ClientState::new(identity.endpoint_id()); - // Create a minimal server. - let mut server = willow_channel::Server::new("Test Server", identity.endpoint_id()); - let ch_id = server - .create_channel("general", willow_channel::ChannelKind::Text) - .unwrap(); - let topic = util::make_topic(&server, "general"); - - let server_id = server.id().to_string(); - let mut topic_map = HashMap::new(); - let mut keys = HashMap::new(); - - if let Some(key) = server.channel_key(&ch_id) { - keys.insert(topic.clone(), key.clone()); - } - topic_map.insert(topic, ("general".to_string(), ch_id)); - - let ctx = ServerContext { - server, - topic_map, - keys, - unread: HashMap::new(), - }; - - state.servers.insert(server_id.clone(), ctx); - state.active_server = Some(server_id.clone()); - - let identity_clone = identity.clone(); - // Create a ManagedDag seeded with genesis — DAG and state are // atomically initialized together. let mut dag_state = state_actors::DagState { @@ -731,37 +645,38 @@ pub fn test_client() -> ( stashed: HashMap::new(), }; - // Create the general channel in the DAG so it's part of the - // authoritative state (not just manually injected into event_state). - let ch_id_str = state - .servers - .get(&server_id) - .and_then(|ctx| { - ctx.topic_map - .values() - .find(|(n, _)| n == "general") - .map(|(_, cid)| cid.to_string()) - }) - .unwrap_or_default(); - if !ch_id_str.is_empty() { - dag_state - .managed - .create_and_insert( - &identity, - willow_state::EventKind::CreateChannel { - channel_id: ch_id_str.clone(), - name: "general".to_string(), - kind: "text".to_string(), - }, - 0, - ) - .expect("channel creation must succeed in test"); - } + // Create the general channel in the DAG. + let ch_id_str = uuid::Uuid::new_v4().to_string(); + dag_state + .managed + .create_and_insert( + &identity, + willow_state::EventKind::CreateChannel { + channel_id: ch_id_str, + name: "general".to_string(), + kind: willow_state::ChannelKind::Text, + }, + 0, + ) + .expect("channel creation must succeed in test"); // Copy the materialized state from ManagedDag. state.event_state = dag_state.managed.state().clone(); - // Now spawn actors AFTER state is fully initialized (DAG materialized + channels seeded). + // Build a minimal ServerContext from the DAG state. + let server_id = state.event_state.server_id.clone(); + let ctx = ServerContext { + server_id: server_id.clone(), + name: "Test Server".to_string(), + keys: HashMap::new(), + unread: HashMap::new(), + }; + state.servers.insert(server_id.clone(), ctx); + state.active_server = Some(server_id.clone()); + + let identity_clone = identity.clone(); + + // Now spawn actors AFTER state is fully initialized. let sys = willow_actor::System::new(); let event_state_addr = sys.spawn(willow_actor::StateActor::new(state.event_state.clone())); let mut registry = state_actors::ServerRegistry::default(); @@ -769,9 +684,8 @@ pub fn test_client() -> ( registry.servers.insert( id.clone(), state_actors::ServerEntry { - server: ctx.server.clone(), - name: ctx.server.name().to_string(), - topic_map: ctx.topic_map.clone(), + server_id: ctx.server_id.clone(), + name: ctx.name.clone(), keys: ctx.keys.clone(), unread: ctx.unread.clone(), }, @@ -1094,24 +1008,27 @@ mod tests { // Build a real server + channel + key, then ask generate_invite // to encrypt the channel key for the test client. let inviter = Identity::generate(); - let mut server = willow_channel::Server::new("Tamper Server", inviter.endpoint_id()); - let ch_id = server - .create_channel("general", willow_channel::ChannelKind::Text) - .unwrap(); - let topic = util::make_topic(&server, "general"); + let server_id = uuid::Uuid::new_v4().to_string(); + let topic = format!("{}/general", server_id); + let key = willow_crypto::generate_channel_key(); let mut keys = HashMap::new(); - if let Some(k) = server.channel_key(&ch_id) { - keys.insert(topic.clone(), k.clone()); - } - let mut topic_map = HashMap::new(); - topic_map.insert(topic.clone(), ("general".to_string(), ch_id)); - let valid_code = invite::generate_invite(&server, &keys, &topic_map, &recipient_pub) - .expect("invite generation must succeed"); + keys.insert(topic.clone(), key); + let mut topic_names = HashMap::new(); + topic_names.insert(topic.clone(), "general".to_string()); + let valid_code = invite::generate_invite( + "Tamper Server", + &server_id, + inviter.endpoint_id(), + &keys, + &topic_names, + &recipient_pub, + ) + .expect("invite generation must succeed"); // Tamper with the embedded server_id so it no longer parses as // a UUID. This is the exact failure mode #115 describes: an // invite that looks valid right up to the point where we ask - // willow_channel for a ServerId. + // parsing as a UUID for the server ID. let raw = base64::decode(&valid_code).unwrap(); let mut payload: invite::InvitePayload = willow_transport::unpack(&raw).unwrap(); payload.server_id = "not-a-uuid".to_string(); diff --git a/crates/client/src/listeners.rs b/crates/client/src/listeners.rs index 46e6c6e1..bc5c23df 100644 --- a/crates/client/src/listeners.rs +++ b/crates/client/src/listeners.rs @@ -344,20 +344,54 @@ async fn process_received_message( } }; if should_respond { - // Generate invite for the requesting peer using the server registry. + // Generate invite for the requesting peer using event_state + registry. let server_registry = ctx.server_registry.clone(); + let event_state = ctx.event_state.clone(); let peer_endpoint = peer_id; - let invite_result = willow_actor::state::select(&server_registry, move |reg| { - let entry = reg.active()?; + + // Get server info from registry. + let reg_info = willow_actor::state::select(&server_registry, move |reg| { + reg.active().map(|entry| { + ( + entry.name.clone(), + entry.server_id.clone(), + entry.keys.clone(), + ) + }) + }) + .await; + + let invite_result = if let Some((server_name, server_id, keys)) = reg_info { + // Build topic_names and get owner from event_state. + let sid = server_id.clone(); + let fallback_id = ctx.identity.endpoint_id(); + let (topic_names, genesis_author) = + willow_actor::state::select(&event_state, move |es| { + let names: std::collections::HashMap = es + .channels + .values() + .map(|ch| { + let topic = crate::util::make_topic(&sid, &ch.name); + (topic, ch.name.clone()) + }) + .collect(); + let author = es.admins.iter().next().copied().unwrap_or(fallback_id); + (names, author) + }) + .await; + let pub_key = crate::invite::endpoint_id_to_ed25519_public(&peer_endpoint); crate::invite::generate_invite( - &entry.server, - &entry.keys, - &entry.topic_map, + &server_name, + &server_id, + genesis_author, + &keys, + &topic_names, &pub_key, ) - }) - .await; + } else { + None + }; if let Some(invite_data) = invite_result { let msg = crate::ops::WireMessage::JoinResponse { target_peer: peer_id, diff --git a/crates/client/src/mutations.rs b/crates/client/src/mutations.rs index 93a1e2c3..ae0e89d6 100644 --- a/crates/client/src/mutations.rs +++ b/crates/client/src/mutations.rs @@ -112,7 +112,7 @@ impl ClientMutations { .await } - /// Resolve channel name → channel ID via event state + server registry. + /// Resolve channel name → channel ID via event state. pub(crate) async fn resolve_channel_id(&self, channel: &str) -> anyhow::Result { let ch = channel.to_string(); let channel_id = willow_actor::state::select(&self.event_state, move |es| { @@ -122,22 +122,7 @@ impl ClientMutations { .map(|(id, _)| id.clone()) }) .await; - if let Some(id) = channel_id { - return Ok(id); - } - // Fall back to server registry topic_map. - let ch2 = channel.to_string(); - let from_registry = willow_actor::state::select(&self.server_registry, move |reg| { - reg.active().and_then(|entry| { - entry - .topic_map - .values() - .find(|(n, _)| n == &ch2) - .map(|(_, cid)| cid.to_string()) - }) - }) - .await; - from_registry.ok_or_else(|| anyhow::anyhow!("channel not found: {channel}")) + channel_id.ok_or_else(|| anyhow::anyhow!("channel not found: {channel}")) } /// Sync event_state mirror from ManagedDag, persist, and emit @@ -316,35 +301,15 @@ impl ClientMutations { /// Create a new text channel. pub async fn create_channel(&self, name: &str) -> anyhow::Result<()> { let name = name.to_string(); - let name_for_event = name.clone(); let name_for_switch = name.clone(); - // Create channel in Server object and update topic_map. - let ch_id_str = willow_actor::state::mutate( - &self.server_registry, - move |reg| -> anyhow::Result { - let entry = reg - .active_mut() - .ok_or_else(|| anyhow::anyhow!("no active server"))?; - let ch_id = entry - .server - .create_channel(&name, willow_channel::ChannelKind::Text)?; - let topic = util::make_topic(&entry.server, &name); - if let Some(key) = entry.server.channel_key(&ch_id) { - entry.keys.insert(topic.clone(), key.clone()); - } - let ch_id_str = ch_id.to_string(); - entry.topic_map.insert(topic, (name.clone(), ch_id)); - Ok(ch_id_str) - }, - ) - .await?; + let ch_id_str = uuid::Uuid::new_v4().to_string(); let event = self .build_event(EventKind::CreateChannel { - name: name_for_event, + name: name.clone(), channel_id: ch_id_str, - kind: "text".to_string(), + kind: willow_state::ChannelKind::Text, }) .await?; self.apply_event(&event).await; @@ -360,26 +325,9 @@ impl ClientMutations { pub async fn delete_channel(&self, name: &str) -> anyhow::Result<()> { let name = name.to_string(); let name_for_check = name.clone(); - let ch_id_str = willow_actor::state::mutate( - &self.server_registry, - move |reg| -> anyhow::Result { - let entry = reg - .active_mut() - .ok_or_else(|| anyhow::anyhow!("no active server"))?; - let (topic, ch_id) = entry - .topic_map - .iter() - .find(|(_, (n, _))| n == &name) - .map(|(t, (_, cid))| (t.clone(), cid.clone())) - .ok_or_else(|| anyhow::anyhow!("channel not found"))?; - let ch_id_str = ch_id.to_string(); - entry.server.delete_channel(&ch_id)?; - entry.topic_map.remove(&topic); - entry.keys.remove(&topic); - Ok(ch_id_str) - }, - ) - .await?; + + // Resolve channel ID from event state. + let ch_id_str = self.resolve_channel_id(&name).await?; let event = self .build_event(EventKind::DeleteChannel { @@ -388,14 +336,25 @@ impl ClientMutations { .await?; self.apply_event(&event).await; + // Remove the channel key from registry. + let name_for_key = name.clone(); + willow_actor::state::mutate(&self.server_registry, move |reg| { + if let Some(entry) = reg.active_mut() { + let topic = crate::util::make_topic(&entry.server_id, &name_for_key); + entry.keys.remove(&topic); + } + }) + .await; + // Switch to first remaining channel if we deleted the current one. let current = willow_actor::state::select(&self.chat_meta, |c| c.current_channel.clone()).await; if current == name_for_check { - let first = willow_actor::state::select(&self.server_registry, |reg| { - reg.active() - .map(|e| e.channel_names()) - .and_then(|names| names.into_iter().next()) + let first = willow_actor::state::select(&self.event_state, |es| { + es.channels + .values() + .map(|ch| ch.name.clone()) + .next() .unwrap_or_default() }) .await; @@ -507,21 +466,9 @@ impl ClientMutations { /// Create a new role. pub async fn create_role(&self, name: &str) -> anyhow::Result<()> { let name = name.to_string(); - let role_id = willow_channel::RoleId::new(); - let role = willow_channel::Role::with_id(role_id.clone(), &name); - willow_actor::state::mutate(&self.server_registry, |reg| -> anyhow::Result<()> { - let entry = reg - .active_mut() - .ok_or_else(|| anyhow::anyhow!("no active server"))?; - entry.server.create_role(role); - Ok(()) - }) - .await?; + let role_id = uuid::Uuid::new_v4().to_string(); let event = self - .build_event(EventKind::CreateRole { - name, - role_id: role_id.to_string(), - }) + .build_event(EventKind::CreateRole { name, role_id }) .await?; self.apply_event(&event).await; self.broadcast_event(&event); @@ -531,17 +478,6 @@ impl ClientMutations { /// Delete a role. pub async fn delete_role(&self, role_id: &str) -> anyhow::Result<()> { let role_id = role_id.to_string(); - let rid = willow_channel::RoleId( - uuid::Uuid::parse_str(&role_id).map_err(|e| anyhow::anyhow!("invalid role_id: {e}"))?, - ); - willow_actor::state::mutate(&self.server_registry, move |reg| -> anyhow::Result<()> { - let entry = reg - .active_mut() - .ok_or_else(|| anyhow::anyhow!("no active server"))?; - entry.server.delete_role(&rid)?; - Ok(()) - }) - .await?; let event = self.build_event(EventKind::DeleteRole { role_id }).await?; self.apply_event(&event).await; self.broadcast_event(&event); diff --git a/crates/client/src/ops.rs b/crates/client/src/ops.rs index a8f34300..822fe210 100644 --- a/crates/client/src/ops.rs +++ b/crates/client/src/ops.rs @@ -106,7 +106,7 @@ mod tests { willow_state::EventKind::CreateChannel { name: "general".to_string(), channel_id: "ch-1".to_string(), - kind: "text".to_string(), + kind: willow_state::ChannelKind::Text, }, ); @@ -153,7 +153,7 @@ mod tests { willow_state::EventKind::CreateChannel { name: "ch1".to_string(), channel_id: "cid1".to_string(), - kind: "text".to_string(), + kind: willow_state::ChannelKind::Text, }, ); let e2 = willow_state::Event::new( diff --git a/crates/client/src/persistence_actor.rs b/crates/client/src/persistence_actor.rs index c172bb02..2d95a0a5 100644 --- a/crates/client/src/persistence_actor.rs +++ b/crates/client/src/persistence_actor.rs @@ -111,9 +111,10 @@ impl Handler for PersistenceActor { } } -/// Persist server config (name, channels) and channel keys. +/// Persist server config (name, keys) and channel keys. pub struct PersistServerConfig { - pub server: willow_channel::Server, + pub server_id: String, + pub name: String, pub keys: std::collections::HashMap, } impl Message for PersistServerConfig { @@ -127,7 +128,11 @@ impl Handler for PersistenceActor { _ctx: &mut Context, ) -> impl std::future::Future + Send { if self.persistence_enabled { - storage::save_server(&msg.server, &msg.keys); + let meta = storage::SavedServerMeta { + server_id: msg.server_id, + name: msg.name, + }; + storage::save_server(&meta, &msg.keys); } async {} } @@ -157,7 +162,7 @@ impl Handler for PersistenceActor { /// Persist per-server config by ID. pub struct PersistServerById { pub server_id: String, - pub server: willow_channel::Server, + pub name: String, pub keys: std::collections::HashMap, } impl Message for PersistServerById { @@ -171,7 +176,11 @@ impl Handler for PersistenceActor { _ctx: &mut Context, ) -> impl std::future::Future + Send { if self.persistence_enabled { - storage::save_server_by_id(&msg.server_id, &msg.server, &msg.keys); + let meta = storage::SavedServerMeta { + server_id: msg.server_id.clone(), + name: msg.name, + }; + storage::save_server_by_id(&msg.server_id, &meta, &msg.keys); } async {} } diff --git a/crates/client/src/servers.rs b/crates/client/src/servers.rs index 6d5b0a6e..b24beb75 100644 --- a/crates/client/src/servers.rs +++ b/crates/client/src/servers.rs @@ -89,39 +89,26 @@ impl ClientHandle { pub async fn create_server(&self, name: &str) -> anyhow::Result { let name = name.to_string(); let name_for_state = name.clone(); - let peer_id = self.identity.endpoint_id(); - - let (server_id, ch_id_str) = willow_actor::state::mutate( - &self.server_registry_addr, - move |reg| -> anyhow::Result<(String, String)> { - let mut server = willow_channel::Server::new(&name, peer_id); - let server_id = server.id().to_string(); - let ch_id = server - .create_channel("general", willow_channel::ChannelKind::Text) - .map_err(|e| anyhow::anyhow!("{e:?}"))?; - let topic = util::make_topic(&server, "general"); - let mut topic_map = HashMap::new(); - let mut keys = HashMap::new(); - if let Some(key) = server.channel_key(&ch_id) { - keys.insert(topic.clone(), key.clone()); - } - let ch_id_str = ch_id.to_string(); - topic_map.insert(topic, ("general".to_string(), ch_id)); - reg.servers.insert( - server_id.clone(), - state_actors::ServerEntry { - server, - name: name.to_string(), - topic_map, - keys, - unread: HashMap::new(), - }, - ); - reg.active_server = Some(server_id.clone()); - Ok((server_id, ch_id_str)) - }, - ) - .await?; + + let server_id = uuid::Uuid::new_v4().to_string(); + let ch_id_str = uuid::Uuid::new_v4().to_string(); + + // Register the server in the server registry. + let sid = server_id.clone(); + let sname = name.clone(); + willow_actor::state::mutate(&self.server_registry_addr, move |reg| { + reg.servers.insert( + sid.clone(), + state_actors::ServerEntry { + server_id: sid.clone(), + name: sname, + keys: HashMap::new(), + unread: HashMap::new(), + }, + ); + reg.active_server = Some(sid); + }) + .await; // Stash the current server's DAG before seeding the new one. let old_sid = self.active_server_id().await; @@ -147,7 +134,7 @@ impl ClientHandle { .build_event(willow_state::EventKind::CreateChannel { name: "general".to_string(), channel_id: ch_id_str, - kind: "text".to_string(), + kind: willow_state::ChannelKind::Text, }) .await?; self.mutation_handle.apply_event(&event).await; diff --git a/crates/client/src/state.rs b/crates/client/src/state.rs index c055270b..0a248640 100644 --- a/crates/client/src/state.rs +++ b/crates/client/src/state.rs @@ -5,7 +5,6 @@ use std::collections::HashMap; -use willow_channel::Server; use willow_crypto::ChannelKey; use willow_identity::EndpointId; use willow_messaging::hlc::HLC; @@ -15,10 +14,10 @@ pub const DEFAULT_CHANNEL: &str = "general"; /// All state for a single server. pub struct ServerContext { - /// The channel server instance. - pub server: Server, - /// Maps gossipsub topic -> (channel_name, channel_id) for display + key lookup. - pub topic_map: HashMap, + /// Server ID (UUID string). + pub server_id: String, + /// Server display name. + pub name: String, /// Per-channel encryption keys, keyed by topic. pub keys: HashMap, /// Unread message counts per channel topic. @@ -28,27 +27,13 @@ pub struct ServerContext { impl ServerContext { /// Get the gossipsub topic for a channel by name. pub fn topic_for_name(&self, name: &str) -> Option { - self.topic_map - .iter() - .find(|(_, (n, _))| n == name) - .map(|(topic, _)| topic.clone()) + Some(crate::util::make_topic(&self.server_id, name)) } /// Get the channel name for a gossipsub topic. - pub fn name_for_topic(&self, topic: &str) -> Option<&str> { - self.topic_map.get(topic).map(|(name, _)| name.as_str()) - } - - /// List all channel names in sidebar order. - pub fn channel_names(&self) -> Vec { - let mut names: Vec<_> = self - .server - .channels() - .iter() - .map(|ch| ch.name.clone()) - .collect(); - names.sort(); - names + pub fn name_for_topic<'a>(&self, topic: &'a str) -> Option<&'a str> { + let prefix = format!("{}/", self.server_id); + topic.strip_prefix(&prefix) } } @@ -157,25 +142,19 @@ impl ClientState { .and_then(|id| self.servers.get_mut(id)) } - /// Channel names for the active server. - pub fn channel_names(&self) -> Vec { - self.active() - .map(|ctx| ctx.channel_names()) - .unwrap_or_default() - } - /// List all server IDs and names. pub fn server_list(&self) -> Vec<(String, String)> { self.servers .iter() - .map(|(id, ctx)| (id.clone(), ctx.server.name().to_string())) + .map(|(id, ctx)| (id.clone(), ctx.name.clone())) .collect() } /// Find which server owns a given topic. pub fn find_server_for_topic(&self, topic: &str) -> Option<&str> { for (id, ctx) in &self.servers { - if ctx.topic_map.contains_key(topic) { + let prefix = format!("{}/", ctx.server_id); + if topic.starts_with(&prefix) { return Some(id); } } diff --git a/crates/client/src/state_actors.rs b/crates/client/src/state_actors.rs index 659c29d6..d201841f 100644 --- a/crates/client/src/state_actors.rs +++ b/crates/client/src/state_actors.rs @@ -51,49 +51,40 @@ impl ServerRegistry { } /// Metadata for a single server. -#[derive(Clone, Debug)] +#[derive(Clone, Debug, PartialEq)] pub struct ServerEntry { - /// The channel server instance (stateful — has create_channel/delete_channel methods). - pub server: willow_channel::Server, + /// Server ID (UUID string). + pub server_id: String, /// Server display name. pub name: String, - /// Maps gossipsub topic → (channel_name, channel_id). - pub topic_map: HashMap, /// Per-channel encryption keys, keyed by topic. pub keys: HashMap, /// Unread message counts per channel topic. pub unread: HashMap, } -impl PartialEq for ServerEntry { - fn eq(&self, other: &Self) -> bool { - self.name == other.name - && self.topic_map == other.topic_map - && self.keys == other.keys - && self.unread == other.unread - } -} - impl ServerEntry { /// Get the gossipsub topic for a channel by name. pub fn topic_for_name(&self, name: &str) -> Option { - self.topic_map - .iter() - .find(|(_, (n, _))| n == name) - .map(|(topic, _)| topic.clone()) + Some(crate::util::make_topic(&self.server_id, name)) } /// Get the channel name for a gossipsub topic. - pub fn name_for_topic(&self, topic: &str) -> Option<&str> { - self.topic_map.get(topic).map(|(name, _)| name.as_str()) + pub fn name_for_topic<'a>(&self, topic: &'a str) -> Option<&'a str> { + let prefix = format!("{}/", self.server_id); + topic.strip_prefix(&prefix) } - /// List all channel names in sorted order. - pub fn channel_names(&self) -> Vec { - let mut names: Vec<_> = self.topic_map.values().map(|(n, _)| n.clone()).collect(); - names.sort(); - names.dedup(); - names + /// Derive channel topic strings from event state channels. + /// + /// Iterates over channels in the given `ServerState` and returns + /// topic strings of the form `"{server_id}/{channel_name}"`. + pub fn channel_topics(&self, event_state: &willow_state::ServerState) -> Vec { + event_state + .channels + .values() + .map(|ch| crate::util::make_topic(&self.server_id, &ch.name)) + .collect() } } diff --git a/crates/client/src/storage.rs b/crates/client/src/storage.rs index 5ca0c448..de847406 100644 --- a/crates/client/src/storage.rs +++ b/crates/client/src/storage.rs @@ -8,7 +8,6 @@ use std::collections::HashMap; -use willow_channel::Server; use willow_crypto::ChannelKey; // ---- Public types ----------------------------------------------------------- @@ -60,10 +59,17 @@ pub fn load_join_links(server_id: &str) -> Vec { .unwrap_or_default() } +/// Persisted server metadata (server ID and display name). +#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)] +pub struct SavedServerMeta { + pub server_id: String, + pub name: String, +} + // ---- Public API ------------------------------------------------------------- -pub fn save_server(server: &Server, keys: &HashMap) { - if let Ok(bytes) = willow_transport::pack(server) { +pub fn save_server(meta: &SavedServerMeta, keys: &HashMap) { + if let Ok(bytes) = willow_transport::pack(meta) { save_raw("server", &bytes); } let saved = SavedKeys( @@ -76,15 +82,15 @@ pub fn save_server(server: &Server, keys: &HashMap) { } } -pub fn load_server() -> Option<(Server, HashMap)> { - let server: Server = willow_transport::unpack(&load_raw("server")?).ok()?; +pub fn load_server() -> Option<(SavedServerMeta, HashMap)> { + let meta: SavedServerMeta = willow_transport::unpack(&load_raw("server")?).ok()?; let saved: SavedKeys = willow_transport::unpack(&load_raw("keys")?).ok()?; let keys = saved .0 .into_iter() .map(|(topic, bytes)| (topic, ChannelKey::from_bytes(bytes))) .collect(); - Some((server, keys)) + Some((meta, keys)) } pub fn save_identity_bytes(bytes: &[u8]) { @@ -108,8 +114,8 @@ pub fn load_settings() -> Option { // ---- Multi-server persistence ----------------------------------------------- /// Save a single server context by ID. -pub fn save_server_by_id(id: &str, server: &Server, keys: &HashMap) { - if let Ok(bytes) = willow_transport::pack(server) { +pub fn save_server_by_id(id: &str, meta: &SavedServerMeta, keys: &HashMap) { + if let Ok(bytes) = willow_transport::pack(meta) { save_raw(&format!("srv_{id}"), &bytes); } let saved = SavedKeys( @@ -123,15 +129,15 @@ pub fn save_server_by_id(id: &str, server: &Server, keys: &HashMap Option<(Server, HashMap)> { - let server: Server = willow_transport::unpack(&load_raw(&format!("srv_{id}"))?).ok()?; +pub fn load_server_by_id(id: &str) -> Option<(SavedServerMeta, HashMap)> { + let meta: SavedServerMeta = willow_transport::unpack(&load_raw(&format!("srv_{id}"))?).ok()?; let saved: SavedKeys = willow_transport::unpack(&load_raw(&format!("srvkeys_{id}"))?).ok()?; let keys = saved .0 .into_iter() .map(|(topic, bytes)| (topic, ChannelKey::from_bytes(bytes))) .collect(); - Some((server, keys)) + Some((meta, keys)) } /// Save the list of known server IDs. diff --git a/crates/client/src/util.rs b/crates/client/src/util.rs index 3ee53941..fa95d4e6 100644 --- a/crates/client/src/util.rs +++ b/crates/client/src/util.rs @@ -23,8 +23,8 @@ pub fn format_timestamp(ms: u64) -> String { } /// Build a gossipsub topic string from a server ID and channel name. -pub fn make_topic(server: &willow_channel::Server, channel_name: &str) -> String { - format!("{}/{}", server.id(), channel_name) +pub fn make_topic(server_id: &str, channel_name: &str) -> String { + format!("{}/{}", server_id, channel_name) } /// Get the current wall-clock time in milliseconds since the Unix epoch. diff --git a/crates/client/src/views.rs b/crates/client/src/views.rs index f0a7300d..d386d62d 100644 --- a/crates/client/src/views.rs +++ b/crates/client/src/views.rs @@ -38,7 +38,7 @@ pub struct ChannelsView { #[derive(Clone, Debug, PartialEq)] pub struct ChannelInfo { pub name: String, - pub kind: String, + pub kind: willow_state::ChannelKind, } /// Member list with online status. @@ -166,7 +166,7 @@ impl Clone for ClientViewHandle { /// Compute the messages view for the current channel. pub fn compute_messages_view( events: &Arc, - registry: &Arc, + _registry: &Arc, chat: &Arc, profiles: &Arc, local_peer_id: EndpointId, @@ -174,13 +174,6 @@ pub fn compute_messages_view( let ch = &chat.current_channel; let mut channel_ids: std::collections::HashSet = std::collections::HashSet::new(); - if let Some(entry) = registry.active() { - for (name, cid) in entry.topic_map.values() { - if name == ch { - channel_ids.insert(cid.to_string()); - } - } - } for (id, c) in &events.channels { if c.name == *ch { channel_ids.insert(id.clone()); @@ -289,28 +282,12 @@ pub fn compute_members_view( /// Compute the channels view. pub fn compute_channels_view( events: &Arc, - registry: &Arc, + _registry: &Arc, ) -> ChannelsView { let mut names: Vec = Vec::new(); let mut seen = std::collections::HashSet::new(); - // From server registry (authoritative channel list). - if let Some(entry) = registry.active() { - for ch in entry.server.channels() { - if seen.insert(ch.name.clone()) { - let kind = match ch.kind { - willow_channel::ChannelKind::Text => "text", - willow_channel::ChannelKind::Voice => "voice", - }; - names.push(ChannelInfo { - name: ch.name.clone(), - kind: kind.to_string(), - }); - } - } - } - - // From event state (may have channels not yet in registry). + // From event state (the single source of truth for channels). for ch in events.channels.values() { if seen.insert(ch.name.clone()) { names.push(ChannelInfo { diff --git a/crates/common/src/wire.rs b/crates/common/src/wire.rs index 830d160e..4e9ba317 100644 --- a/crates/common/src/wire.rs +++ b/crates/common/src/wire.rs @@ -148,7 +148,7 @@ mod tests { EventKind::CreateChannel { name: "ch".to_string(), channel_id: "cid".to_string(), - kind: "text".to_string(), + kind: willow_state::ChannelKind::Text, }, )]; diff --git a/crates/replay/src/role.rs b/crates/replay/src/role.rs index 8677a824..aa2ef712 100644 --- a/crates/replay/src/role.rs +++ b/crates/replay/src/role.rs @@ -159,6 +159,9 @@ impl ReplayRole { Err(InsertError::MissingGovernanceDep { .. }) => { warn!("rejected Vote event missing proposal dep"); } + Err(InsertError::PermissionDenied(reason)) => { + warn!(%reason, "rejected event: permission denied"); + } } } } diff --git a/crates/state/src/dag.rs b/crates/state/src/dag.rs index 5c4130e4..b88374e1 100644 --- a/crates/state/src/dag.rs +++ b/crates/state/src/dag.rs @@ -39,6 +39,8 @@ pub enum InsertError { vote: EventHash, proposal: EventHash, }, + /// Author lacks the required permission for this EventKind. + PermissionDenied(String), } impl std::fmt::Display for InsertError { @@ -72,6 +74,7 @@ impl std::fmt::Display for InsertError { f, "Vote event {vote} must include proposal {proposal} in deps" ), + Self::PermissionDenied(reason) => write!(f, "permission denied: {reason}"), } } } diff --git a/crates/state/src/event.rs b/crates/state/src/event.rs index f0c1fd9f..b4361500 100644 --- a/crates/state/src/event.rs +++ b/crates/state/src/event.rs @@ -65,11 +65,6 @@ pub enum VoteThreshold { // ───── EventKind ─────────────────────────────────────────────────────────── -/// Default channel kind for deserialization backward compatibility. -fn default_create_channel_kind() -> String { - "text".to_string() -} - /// All possible state mutations — 22 variants. #[derive(Clone, Debug, Serialize, Deserialize)] pub enum EventKind { @@ -102,8 +97,8 @@ pub enum EventKind { CreateChannel { name: String, channel_id: String, - #[serde(default = "default_create_channel_kind")] - kind: String, + #[serde(default)] + kind: crate::types::ChannelKind, }, /// Delete a channel by ID. DeleteChannel { channel_id: String }, diff --git a/crates/state/src/lib.rs b/crates/state/src/lib.rs index bf8eb075..40b1b25c 100644 --- a/crates/state/src/lib.rs +++ b/crates/state/src/lib.rs @@ -22,9 +22,9 @@ pub use dag::{EventDag, InsertError}; pub use event::{Event, EventKind, Permission, ProposedAction, VoteThreshold}; pub use hash::EventHash; pub use managed::ManagedDag; -pub use materialize::{apply_incremental, materialize, ApplyResult}; +pub use materialize::{apply_incremental, check_permission, materialize, ApplyResult}; pub use server::{PendingProposal, ServerState}; pub use sync::{ AuthorHead, AuthorRequest, ChainStatus, HeadsSummary, PendingBuffer, Snapshot, SyncMessage, }; -pub use types::{Channel, ChatMessage, Member, Profile, Role}; +pub use types::{Channel, ChannelKind, ChatMessage, Member, Profile, Role}; diff --git a/crates/state/src/managed.rs b/crates/state/src/managed.rs index a32e379d..d5b6c94b 100644 --- a/crates/state/src/managed.rs +++ b/crates/state/src/managed.rs @@ -169,6 +169,9 @@ impl ManagedDag { /// Create a local event and atomically insert + apply it. /// /// Computes cross-author causal dependencies from the current DAG heads. + /// **Permissions are checked before the event is created** — if the + /// author lacks permission, no event is signed and no sequence number + /// is advanced. pub fn create_and_insert( &mut self, identity: &Identity, @@ -179,6 +182,10 @@ impl ManagedDag { return Err(InsertError::NotGenesis); } + // Pre-check: reject before signing if the author lacks permission. + crate::materialize::check_permission(&self.state, &identity.endpoint_id(), &kind) + .map_err(InsertError::PermissionDenied)?; + let my_id = identity.endpoint_id(); let mut deps: Vec = self .dag diff --git a/crates/state/src/materialize.rs b/crates/state/src/materialize.rs index cda4668a..d18e9edb 100644 --- a/crates/state/src/materialize.rs +++ b/crates/state/src/materialize.rs @@ -2,7 +2,7 @@ //! //! The [`materialize`] function is the ONLY way to derive state from a //! DAG. It topologically sorts all events and replays them through -//! [`apply_unchecked`], producing identical output on all peers given the +//! [`apply_event`], producing identical output on all peers given the //! same DAG contents. use std::collections::{BTreeMap, BTreeSet}; @@ -43,7 +43,7 @@ pub fn materialize(dag: &EventDag) -> ServerState { let mut state = ServerState::new(&server_id, &name, genesis_author); for event in sorted { state.applied_events.insert(event.hash); - apply_unchecked(&mut state, event); + apply_event(&mut state, event); } state } @@ -56,15 +56,74 @@ pub fn apply_incremental(state: &mut ServerState, event: &Event) -> ApplyResult if !state.applied_events.insert(event.hash) { return ApplyResult::AlreadyApplied; } - apply_unchecked(state, event) + apply_event(state, event) +} + +// ───── Permission pre-check ──────────────────────────────────────────────── + +/// Check whether an author is allowed to emit a given [`EventKind`] +/// against the current state. +/// +/// This is the same authority logic used inside [`apply_event`], +/// extracted so callers can pre-check *before* signing an event. +/// Returns `Ok(())` if permitted, or an error string describing why +/// the author is not allowed. +/// +/// See `docs/specs/2026-04-12-state-authority-and-mutations.md` for +/// the full permission tier breakdown. +pub fn check_permission( + state: &ServerState, + author: &EndpointId, + kind: &EventKind, +) -> Result<(), String> { + // Governance: Propose/Vote require admin. + match kind { + EventKind::Propose { .. } | EventKind::Vote { .. } => { + if !state.is_admin(author) { + return Err("not an admin".into()); + } + return Ok(()); + } + EventKind::CreateServer { .. } => return Ok(()), + _ => {} + } + + // Admin-only events. + match kind { + EventKind::GrantPermission { .. } + | EventKind::RevokePermission { .. } + | EventKind::RenameServer { .. } + | EventKind::SetServerDescription { .. } => { + if !state.is_admin(author) { + return Err(format!("author '{}' is not an admin", author)); + } + } + _ => {} + } + + // Permission-gated events. + if let Some(ref perm) = required_permission(kind) { + if !state.has_permission(author, perm) { + return Err(format!("author '{}' lacks {:?} permission", author, perm)); + } + } + + Ok(()) } // ───── Internal ──────────────────────────────────────────────────────────── -/// Apply an event's mutation to state. Permission checks and governance -/// logic are enforced here. The DAG guarantees ordering and dedup. -fn apply_unchecked(state: &mut ServerState, event: &Event) -> ApplyResult { - // Governance events — handled specially. +/// Apply an event's mutation to state. Checks permissions via +/// [`check_permission`], then handles governance state mutations +/// (inserting proposals, recording votes) and delegates to +/// [`apply_mutation`] for everything else. +fn apply_event(state: &mut ServerState, event: &Event) -> ApplyResult { + // Permission / authority check (read-only against state). + if let Err(reason) = check_permission(state, &event.author, &event.kind) { + return ApplyResult::Rejected(reason); + } + + // Governance events mutate state after the permission check. match &event.kind { EventKind::CreateServer { .. } => { // No-op during replay — genesis data already extracted @@ -72,9 +131,6 @@ fn apply_unchecked(state: &mut ServerState, event: &Event) -> ApplyResult { return ApplyResult::Applied; } EventKind::Propose { action } => { - if !state.is_admin(&event.author) { - return ApplyResult::Rejected("not an admin".into()); - } state.pending_proposals.insert( event.hash, PendingProposal { @@ -87,9 +143,6 @@ fn apply_unchecked(state: &mut ServerState, event: &Event) -> ApplyResult { return ApplyResult::Applied; } EventKind::Vote { proposal, accept } => { - if !state.is_admin(&event.author) { - return ApplyResult::Rejected("not an admin".into()); - } match state.pending_proposals.get_mut(proposal) { Some(prop) => { prop.votes.insert(event.author, *accept); @@ -104,35 +157,6 @@ fn apply_unchecked(state: &mut ServerState, event: &Event) -> ApplyResult { _ => {} } - // Admin-only events. - match &event.kind { - EventKind::GrantPermission { .. } - | EventKind::RevokePermission { .. } - | EventKind::RenameServer { .. } - | EventKind::SetServerDescription { .. } => { - if !state.is_admin(&event.author) { - return ApplyResult::Rejected(format!("author '{}' is not an admin", event.author)); - } - } - _ => {} - } - - // Permission-checked events. - // required_permission returns: - // Message/EditMessage/DeleteMessage/Reaction → SendMessages - // CreateChannel/DeleteChannel/RenameChannel/RotateChannelKey → ManageChannels - // CreateRole/DeleteRole/SetPermission/AssignRole → ManageRoles - // All others → None - let required = required_permission(&event.kind); - if let Some(ref perm) = required { - if !state.has_permission(&event.author, perm) { - return ApplyResult::Rejected(format!( - "author '{}' lacks {:?} permission", - event.author, perm - )); - } - } - apply_mutation(state, event) } @@ -217,6 +241,11 @@ fn reevaluate_all_proposals(state: &mut ServerState) { } /// Map an EventKind to its required Permission (if any). +/// +/// This is the permission-gated enforcement table. See +/// `docs/specs/2026-04-12-state-authority-and-mutations.md` for the full authority model, +/// including which variants are checked elsewhere (governance block, +/// admin-only block) and which are intentionally unrestricted. fn required_permission(kind: &EventKind) -> Option { match kind { EventKind::Message { .. } @@ -234,8 +263,21 @@ fn required_permission(kind: &EventKind) -> Option { | EventKind::SetPermission { .. } | EventKind::AssignRole { .. } => Some(Permission::ManageRoles), - // Admin-only, governance, profile, pinning — no Permission - // variant, checked elsewhere or unrestricted. + // Variants that intentionally return None: + // CreateServer — genesis, checked structurally + // Propose, Vote — governance, checked in the governance block above + // GrantPermission, + // RevokePermission, + // RenameServer, + // SetServerDescription — admin-only, checked in the admin block above + // SetProfile — unrestricted (any member) + // PinMessage, + // UnpinMessage — unrestricted (any member) + // + // If a new EventKind variant is added and is NOT listed here or + // in an arm above, it will silently get no permission check. + // That is a bug. See docs/specs/2026-04-12-state-authority-and-mutations.md § "Adding a + // new event kind" for the required checklist. _ => None, } } @@ -449,7 +491,7 @@ fn apply_mutation(state: &mut ServerState, event: &Event) -> ApplyResult { state.description = description.clone(); } - // Governance events handled above in apply_unchecked. + // Governance events handled above in apply_event. EventKind::CreateServer { .. } | EventKind::Propose { .. } | EventKind::Vote { .. } => {} } @@ -516,7 +558,7 @@ mod tests { EventKind::CreateChannel { name: "general".into(), channel_id: "ch-1".into(), - kind: "text".into(), + kind: crate::types::ChannelKind::Text, }, ); let state = materialize(&dag); @@ -556,7 +598,7 @@ mod tests { EventKind::CreateChannel { name: "evil".into(), channel_id: "ch-evil".into(), - kind: "text".into(), + kind: crate::types::ChannelKind::Text, }, ); let state = materialize(&dag); @@ -585,7 +627,7 @@ mod tests { EventKind::CreateChannel { name: "general".into(), channel_id: "ch-1".into(), - kind: "text".into(), + kind: crate::types::ChannelKind::Text, }, ); let state = materialize(&dag); @@ -767,7 +809,7 @@ mod tests { EventKind::CreateChannel { name: "doomed".into(), channel_id: "ch-d".into(), - kind: "text".into(), + kind: crate::types::ChannelKind::Text, }, ); emit( diff --git a/crates/state/src/sync.rs b/crates/state/src/sync.rs index 371a84ae..e3a4b960 100644 --- a/crates/state/src/sync.rs +++ b/crates/state/src/sync.rs @@ -640,7 +640,7 @@ mod tests { EventKind::CreateChannel { channel_id: ch_id.into(), name: ch_name.into(), - kind: "text".into(), + kind: crate::types::ChannelKind::Text, }, vec![], 0, diff --git a/crates/state/src/tests.rs b/crates/state/src/tests.rs index 3374a130..dddc3b09 100644 --- a/crates/state/src/tests.rs +++ b/crates/state/src/tests.rs @@ -150,7 +150,7 @@ fn stress_concurrent_channel_creates() { EventKind::CreateChannel { name: format!("channel-{i}"), channel_id: format!("ch-{i}"), - kind: "text".into(), + kind: crate::types::ChannelKind::Text, }, vec![], 0, @@ -189,7 +189,7 @@ fn stress_concurrent_channel_creates() { EventKind::CreateChannel { name: format!("ch2-{i}"), channel_id: format!("ch2-{i}"), - kind: "text".into(), + kind: crate::types::ChannelKind::Text, }, vec![admin_head], 0, @@ -368,7 +368,7 @@ fn duplicate_create_channel_preserves_original() { EventKind::CreateChannel { name: "general".to_string(), channel_id: "ch-1".to_string(), - kind: "text".to_string(), + kind: crate::types::ChannelKind::Text, }, ); // Duplicate channel_id — should be ignored. @@ -378,7 +378,7 @@ fn duplicate_create_channel_preserves_original() { EventKind::CreateChannel { name: "different-name".to_string(), channel_id: "ch-1".to_string(), - kind: "voice".to_string(), + kind: crate::types::ChannelKind::Voice, }, ); @@ -742,12 +742,15 @@ fn channel_kind_is_preserved() { EventKind::CreateChannel { name: "voice-chat".to_string(), channel_id: "vc-1".to_string(), - kind: "voice".to_string(), + kind: crate::types::ChannelKind::Voice, }, ); let state = materialize(&dag); - assert_eq!(state.channels["vc-1"].kind, "voice"); + assert_eq!( + state.channels["vc-1"].kind, + crate::types::ChannelKind::Voice + ); } #[test] @@ -781,7 +784,7 @@ fn delete_channel_messages_not_from_other_channels() { EventKind::CreateChannel { name: "ch1".to_string(), channel_id: "ch-1".to_string(), - kind: "text".to_string(), + kind: crate::types::ChannelKind::Text, }, ); do_emit( @@ -790,7 +793,7 @@ fn delete_channel_messages_not_from_other_channels() { EventKind::CreateChannel { name: "ch2".to_string(), channel_id: "ch-2".to_string(), - kind: "text".to_string(), + kind: crate::types::ChannelKind::Text, }, ); do_emit( @@ -1818,7 +1821,7 @@ fn rotate_channel_key_by_outsider_is_rejected() { EventKind::CreateChannel { name: "general".to_string(), channel_id: "ch-general".to_string(), - kind: "text".to_string(), + kind: crate::types::ChannelKind::Text, }, ); @@ -1867,7 +1870,7 @@ fn rotate_channel_key_by_member_without_manage_channels_is_rejected() { EventKind::CreateChannel { name: "general".into(), channel_id: "ch1".into(), - kind: "text".into(), + kind: crate::types::ChannelKind::Text, }, vec![], 10, @@ -1926,7 +1929,7 @@ fn rotate_channel_key_by_admin_still_works() { EventKind::CreateChannel { name: "general".to_string(), channel_id: "ch-general".to_string(), - kind: "text".to_string(), + kind: crate::types::ChannelKind::Text, }, ); @@ -1966,7 +1969,7 @@ fn managed_dag_insert_and_apply_keeps_state_in_sync() { EventKind::CreateChannel { channel_id: "ch1".into(), name: "general".into(), - kind: "text".into(), + kind: crate::types::ChannelKind::Text, }, 1000, ) @@ -2039,7 +2042,7 @@ fn joined_peer_needs_grant_permission_to_send_messages() { EventKind::CreateChannel { name: "general".to_string(), channel_id: "ch-general".to_string(), - kind: "text".to_string(), + kind: crate::types::ChannelKind::Text, }, vec![], 10, @@ -2142,7 +2145,7 @@ fn sync_batch_with_grant_permission_allows_new_peer_to_send() { EventKind::CreateChannel { name: "general".into(), channel_id: "ch1".into(), - kind: "text".into(), + kind: crate::types::ChannelKind::Text, }, vec![], 10, @@ -2331,7 +2334,7 @@ fn pin_and_unpin_message() { EventKind::CreateChannel { name: "general".into(), channel_id: ch_id.clone(), - kind: "text".into(), + kind: crate::types::ChannelKind::Text, }, ); @@ -2448,3 +2451,241 @@ fn deep_pending_chain_does_not_stack_overflow() { ); assert_eq!(managed.pending().pending_count(), 0); } + +// ───── check_permission tests ────────────────────────────────────────────── + +#[test] +fn check_permission_allows_admin_propose() { + let owner = Identity::generate(); + let dag = test_dag(&owner); + let state = materialize(&dag); + + let kind = EventKind::Propose { + action: ProposedAction::KickMember { + peer_id: owner.endpoint_id(), + }, + }; + assert!(crate::materialize::check_permission(&state, &owner.endpoint_id(), &kind).is_ok()); +} + +#[test] +fn check_permission_rejects_non_admin_propose() { + let owner = Identity::generate(); + let peer = Identity::generate(); + let dag = test_dag(&owner); + let state = materialize(&dag); + + let kind = EventKind::Propose { + action: ProposedAction::KickMember { + peer_id: owner.endpoint_id(), + }, + }; + assert!(crate::materialize::check_permission(&state, &peer.endpoint_id(), &kind).is_err()); +} + +#[test] +fn check_permission_allows_granted_send_messages() { + let owner = Identity::generate(); + let peer = Identity::generate(); + let mut dag = test_dag(&owner); + + // Grant SendMessages to peer. + do_emit( + &mut dag, + &owner, + EventKind::GrantPermission { + peer_id: peer.endpoint_id(), + permission: Permission::SendMessages, + }, + ); + let state = materialize(&dag); + + let kind = EventKind::Message { + channel_id: "ch1".into(), + body: "hello".into(), + reply_to: None, + }; + assert!(crate::materialize::check_permission(&state, &peer.endpoint_id(), &kind).is_ok()); +} + +#[test] +fn check_permission_rejects_without_send_messages() { + let owner = Identity::generate(); + let peer = Identity::generate(); + let dag = test_dag(&owner); + let state = materialize(&dag); + + let kind = EventKind::Message { + channel_id: "ch1".into(), + body: "hello".into(), + reply_to: None, + }; + assert!(crate::materialize::check_permission(&state, &peer.endpoint_id(), &kind).is_err()); +} + +#[test] +fn check_permission_admin_implicitly_has_all() { + let owner = Identity::generate(); + let dag = test_dag(&owner); + let state = materialize(&dag); + + // Owner (admin) should pass all permission-gated checks without + // explicit grants. + for kind in [ + EventKind::Message { + channel_id: "ch1".into(), + body: "hi".into(), + reply_to: None, + }, + EventKind::CreateChannel { + name: "dev".into(), + channel_id: "ch2".into(), + kind: crate::types::ChannelKind::Text, + }, + EventKind::CreateRole { + name: "mod".into(), + role_id: "r1".into(), + }, + ] { + assert!( + crate::materialize::check_permission(&state, &owner.endpoint_id(), &kind).is_ok(), + "admin should pass check for {:?}", + kind + ); + } +} + +#[test] +fn check_permission_unrestricted_events_always_pass() { + let owner = Identity::generate(); + let peer = Identity::generate(); + let dag = test_dag(&owner); + let state = materialize(&dag); + + // Unrestricted events pass even for non-admin peers with no grants. + for kind in [ + EventKind::SetProfile { + display_name: "alice".into(), + }, + EventKind::PinMessage { + channel_id: "ch1".into(), + message_id: EventHash::ZERO, + }, + EventKind::UnpinMessage { + channel_id: "ch1".into(), + message_id: EventHash::ZERO, + }, + ] { + assert!( + crate::materialize::check_permission(&state, &peer.endpoint_id(), &kind).is_ok(), + "unrestricted event should pass for any peer: {:?}", + kind + ); + } +} + +#[test] +fn check_permission_rejects_non_admin_rename_server() { + let owner = Identity::generate(); + let peer = Identity::generate(); + let dag = test_dag(&owner); + let state = materialize(&dag); + + let kind = EventKind::RenameServer { + new_name: "hacked".into(), + }; + assert!(crate::materialize::check_permission(&state, &peer.endpoint_id(), &kind).is_err()); +} + +// ───── create_and_insert pre-check tests ─────────────────────────────────── + +#[test] +fn create_and_insert_rejects_without_permission() { + use crate::dag::InsertError; + use crate::managed::ManagedDag; + + let owner = Identity::generate(); + let peer = Identity::generate(); + let mut managed = ManagedDag::new(&owner, "Test", 5000); + + // Peer has no grants — should be rejected. + let result = managed.create_and_insert( + &peer, + EventKind::Message { + channel_id: "ch1".into(), + body: "hello".into(), + reply_to: None, + }, + 1000, + ); + assert!( + matches!(result, Err(InsertError::PermissionDenied(_))), + "expected PermissionDenied, got: {:?}", + result + ); +} + +#[test] +fn create_and_insert_does_not_advance_seq_on_rejection() { + use crate::managed::ManagedDag; + + let owner = Identity::generate(); + let peer = Identity::generate(); + let mut managed = ManagedDag::new(&owner, "Test", 5000); + + let seq_before = managed.dag().latest_seq(&peer.endpoint_id()); + + // Rejected — should not advance sequence. + let _ = managed.create_and_insert( + &peer, + EventKind::Message { + channel_id: "ch1".into(), + body: "hello".into(), + reply_to: None, + }, + 1000, + ); + + let seq_after = managed.dag().latest_seq(&peer.endpoint_id()); + assert_eq!( + seq_before, seq_after, + "sequence should not advance on rejection" + ); +} + +#[test] +fn create_and_insert_succeeds_with_permission() { + use crate::managed::ManagedDag; + + let owner = Identity::generate(); + let peer = Identity::generate(); + let mut managed = ManagedDag::new(&owner, "Test", 5000); + + // Grant SendMessages to peer. + managed + .create_and_insert( + &owner, + EventKind::GrantPermission { + peer_id: peer.endpoint_id(), + permission: Permission::SendMessages, + }, + 1000, + ) + .expect("admin grant should succeed"); + + // Now peer can send a message. + let result = managed.create_and_insert( + &peer, + EventKind::Message { + channel_id: "ch1".into(), + body: "hello".into(), + reply_to: None, + }, + 2000, + ); + assert!( + result.is_ok(), + "should succeed with permission: {:?}", + result.err() + ); +} diff --git a/crates/state/src/types.rs b/crates/state/src/types.rs index 13286801..c7cca861 100644 --- a/crates/state/src/types.rs +++ b/crates/state/src/types.rs @@ -11,6 +11,18 @@ use willow_identity::EndpointId; use crate::hash::EventHash; +/// Channel kind — text chat or voice. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum ChannelKind { + /// A text chat channel (default). + #[default] + #[serde(alias = "text")] + Text, + /// A voice (and optionally video/screenshare) channel. + #[serde(alias = "voice")] + Voice, +} + /// A named conversation space inside a server. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Channel { @@ -21,14 +33,9 @@ pub struct Channel { /// Hashes of pinned messages in this channel. #[serde(default)] pub pinned_messages: BTreeSet, - /// Channel kind: `"text"` or `"voice"`. Defaults to `"text"`. - #[serde(default = "default_channel_kind")] - pub kind: String, -} - -/// Default channel kind for deserialization backward compatibility. -fn default_channel_kind() -> String { - "text".to_string() + /// Text or voice. + #[serde(default)] + pub kind: ChannelKind, } /// A named bundle of permissions that can be assigned to members. diff --git a/crates/web/Cargo.toml b/crates/web/Cargo.toml index 6820a7c1..782f3a43 100644 --- a/crates/web/Cargo.toml +++ b/crates/web/Cargo.toml @@ -10,6 +10,7 @@ willow-actor = { path = "../actor" } willow-client = { path = "../client" } willow-identity = { path = "../identity" } willow-network = { path = "../network" } +willow-state = { path = "../state" } leptos = { version = "0.7", features = ["csr"] } tracing = { workspace = true } wasm-bindgen = "0.2" diff --git a/crates/web/src/components/command_palette.rs b/crates/web/src/components/command_palette.rs index 05029f6e..c357d31b 100644 --- a/crates/web/src/components/command_palette.rs +++ b/crates/web/src/components/command_palette.rs @@ -25,7 +25,7 @@ struct PaletteItem { /// Build the filtered result list from the current query and pre-fetched data. fn build_results( channels: &[String], - channel_kinds: &[(String, String)], + channel_kinds: &[(String, willow_state::ChannelKind)], servers: &[(String, String)], members: &[(String, String, bool)], query: &str, @@ -35,7 +35,9 @@ fn build_results( // Channels. for ch in channels { - let is_voice = channel_kinds.iter().any(|(n, k)| n == ch && k == "voice"); + let is_voice = channel_kinds + .iter() + .any(|(n, k)| n == ch && *k == willow_state::ChannelKind::Voice); if q.is_empty() || ch.to_lowercase().contains(&q) { items.push(PaletteItem { label: ch.clone(), diff --git a/crates/web/src/components/sidebar.rs b/crates/web/src/components/sidebar.rs index 8970765d..3c2c2783 100644 --- a/crates/web/src/components/sidebar.rs +++ b/crates/web/src/components/sidebar.rs @@ -170,7 +170,7 @@ pub fn Sidebar( let is_voice = { let name = ch_kind.clone(); move || { - app_state.server.channel_kinds.get().iter().any(|(n, k)| n == &name && k == "voice") + app_state.server.channel_kinds.get().iter().any(|(n, k)| n == &name && *k == willow_state::ChannelKind::Voice) } }; diff --git a/crates/web/src/state.rs b/crates/web/src/state.rs index b19c73da..7923d9a8 100644 --- a/crates/web/src/state.rs +++ b/crates/web/src/state.rs @@ -81,7 +81,7 @@ pub struct ServerState { pub roles: ReadSignal)>>, pub display_name: ReadSignal, pub server_owner: ReadSignal, - pub channel_kinds: ReadSignal>, + pub channel_kinds: ReadSignal>, /// Peer IDs that have the SyncProvider permission. pub sync_provider_ids: ReadSignal>, /// Peer IDs that have the Administrator permission. @@ -164,7 +164,7 @@ pub struct ServerWriteSignals { pub set_roles: WriteSignal)>>, pub set_display_name: WriteSignal, pub set_server_owner: WriteSignal, - pub set_channel_kinds: WriteSignal>, + pub set_channel_kinds: WriteSignal>, pub set_sync_provider_ids: WriteSignal>, pub set_admin_ids: WriteSignal>, } @@ -230,7 +230,8 @@ pub fn create_signals() -> (AppState, AppWriteSignals) { let (roles, set_roles) = signal(Vec::<(String, String, Vec)>::new()); let (display_name, set_display_name) = signal(String::new()); let (server_owner, set_server_owner) = signal(String::new()); - let (channel_kinds, set_channel_kinds) = signal(Vec::<(String, String)>::new()); + let (channel_kinds, set_channel_kinds) = + signal(Vec::<(String, willow_state::ChannelKind)>::new()); let (sync_provider_ids, set_sync_provider_ids) = signal(HashSet::::new()); let (admin_ids, set_admin_ids) = signal(HashSet::::new()); diff --git a/crates/worker/src/actors/network.rs b/crates/worker/src/actors/network.rs index c64f596e..850543b9 100644 --- a/crates/worker/src/actors/network.rs +++ b/crates/worker/src/actors/network.rs @@ -446,7 +446,7 @@ mod tests { willow_state::EventKind::CreateChannel { name: "ch".to_string(), channel_id: "c1".to_string(), - kind: "text".to_string(), + kind: willow_state::ChannelKind::Text, }, 100, ); diff --git a/docs/plans/2026-04-01-per-author-merkle-dag-state-plan.md b/docs/plans/2026-04-01-per-author-merkle-dag-state-plan.md index fcdbe6b8..41c7431d 100644 --- a/docs/plans/2026-04-01-per-author-merkle-dag-state-plan.md +++ b/docs/plans/2026-04-01-per-author-merkle-dag-state-plan.md @@ -362,14 +362,14 @@ pub fn meets_threshold(&self, yes_count: usize) -> bool { ### Internal -- `apply_unchecked(state, event) -> ApplyResult` — governance + +- `apply_event(state, event) -> ApplyResult` — governance + permission check + mutation - `apply_mutation(state, event) -> ApplyResult` — the match block for non-governance events - `apply_proposed_action(state, action)` — applies a voted-on action - `required_permission(kind) -> Option` -### Governance handling in apply_unchecked +### Governance handling in apply_event ``` CreateServer → no-op (genesis data extracted by materialize) @@ -403,7 +403,7 @@ Similarly, `SetVoteThreshold` re-evaluates all pending proposals. `GrantPermission`, `RevokePermission`, `RenameServer`, `SetServerDescription` require `is_admin` check directly in -`apply_unchecked` (not via `required_permission`). These are admin +`apply_event` (not via `required_permission`). These are admin actions that don't map to a specific `Permission` variant. ### The mutation match block @@ -426,7 +426,7 @@ Ported from current `apply_inner`. Changes: RevokeAdmin/KickMember and `reevaluate_all_proposals` for SetVoteThreshold -Total: 22 match arms in apply_unchecked (3 governance + 4 admin-only + 15 permission-checked). +Total: 22 match arms in apply_event (3 governance + 4 admin-only + 15 permission-checked). **Tests**: - `materialize_empty_dag` — just genesis → fresh state with genesis diff --git a/docs/plans/2026-04-12-state-authority-and-mutations.md b/docs/plans/2026-04-12-state-authority-and-mutations.md new file mode 100644 index 00000000..f4a35f99 --- /dev/null +++ b/docs/plans/2026-04-12-state-authority-and-mutations.md @@ -0,0 +1,165 @@ +# State Authority & Mutations — Implementation Plan + +**Date**: 2026-04-12 +**Spec**: `docs/specs/2026-04-12-state-authority-and-mutations.md` +**Scope**: Permission pre-check before event creation, catch-all safety + +## Overview + +Two changes to align the codebase with the spec: + +1. Add a permission pre-check to `ManagedDag::create_and_insert()` so + rejected events never enter the DAG or advance the sequence number. +2. The `required_permission()` catch-all annotation is already in place + (exhaustive variant comment at `materialize.rs:237-257`). No further + work needed for that requirement. + +All work is in `crates/state/src/`. Every step ends with +`cargo test -p willow-state` passing. + +## Step 1: Extract `check_permission` function + +**File**: `crates/state/src/materialize.rs` + +Extract the permission-checking logic from `apply_event()` into a +standalone public function that can be called *before* event creation: + +```rust +/// Check whether an author is allowed to emit a given EventKind +/// against the current state. Returns Ok(()) if permitted, or an +/// error string if not. +/// +/// This is the same logic as the permission tiers in apply_event(), +/// extracted so callers can pre-check before signing an event. +pub fn check_permission( + state: &ServerState, + author: &EndpointId, + kind: &EventKind, +) -> Result<(), String> { + // Governance: Propose/Vote require admin. + match kind { + EventKind::Propose { .. } | EventKind::Vote { .. } => { + if !state.is_admin(author) { + return Err("not an admin".into()); + } + return Ok(()); + } + EventKind::CreateServer { .. } => return Ok(()), + _ => {} + } + + // Admin-only events. + match kind { + EventKind::GrantPermission { .. } + | EventKind::RevokePermission { .. } + | EventKind::RenameServer { .. } + | EventKind::SetServerDescription { .. } => { + if !state.is_admin(author) { + return Err(format!("author '{}' is not an admin", author)); + } + } + _ => {} + } + + // Permission-gated events. + if let Some(ref perm) = required_permission(kind) { + if !state.has_permission(author, perm) { + return Err(format!("author '{}' lacks {:?} permission", author, perm)); + } + } + + Ok(()) +} +``` + +Then refactor `apply_event()` to call `check_permission()` internally, +keeping the logic in one place: + +```rust +fn apply_event(state: &mut ServerState, event: &Event) -> ApplyResult { + if let Err(reason) = check_permission(state, &event.author, &event.kind) { + return ApplyResult::Rejected(reason); + } + // governance state mutations (Propose inserts proposal, Vote records vote) + // ... + apply_mutation(state, event) +} +``` + +Note: governance events (Propose/Vote) mutate state in `apply_event` +after the permission check (inserting proposals, recording votes). +The `check_permission` function only checks *whether* the author is +allowed — the actual state mutation remains in `apply_event`. + +**Tests**: +- `check_permission_allows_admin_propose` +- `check_permission_rejects_non_admin_propose` +- `check_permission_allows_granted_send_messages` +- `check_permission_rejects_without_send_messages` +- `check_permission_admin_implicitly_has_all` +- `check_permission_unrestricted_events_always_pass` + +## Step 2: Add `PermissionDenied` variant to `InsertError` + +**File**: `crates/state/src/dag.rs` + +Add a new variant: + +```rust +pub enum InsertError { + // ... existing variants ... + /// Author lacks the required permission for this EventKind. + PermissionDenied(String), +} +``` + +Update the `Display` impl to format it. + +## Step 3: Pre-check in `create_and_insert` + +**File**: `crates/state/src/managed.rs` + +Add the permission check before event creation: + +```rust +pub fn create_and_insert( + &mut self, + identity: &Identity, + kind: EventKind, + timestamp_ms: u64, +) -> Result { + if !self.synced { + return Err(InsertError::NotGenesis); + } + + // Pre-check: reject before signing if the author lacks permission. + check_permission(&self.state, &identity.endpoint_id(), &kind) + .map_err(InsertError::PermissionDenied)?; + + // ... rest unchanged: compute deps, create event, insert_and_apply ... +} +``` + +Import `check_permission` from `crate::materialize`. + +**Tests**: +- `create_and_insert_rejects_without_permission` — create a + ManagedDag, do NOT grant SendMessages to a second identity, call + `create_and_insert` with a Message event from that identity, assert + `InsertError::PermissionDenied`. +- `create_and_insert_does_not_advance_seq_on_rejection` — after the + above rejection, verify `dag.latest_seq(&peer)` has not advanced. +- `create_and_insert_succeeds_with_permission` — grant SendMessages, + then create a Message event, assert success. + +## Step 4: Verify existing tests still pass + +Run `cargo test -p willow-state`. The refactored `apply_event` calls +`check_permission` internally, so all existing permission tests should +pass without modification. + +Run `cargo test -p willow-client` to verify the client's `build_event` +path (which calls `create_and_insert`) still works for authorized +actions. + +Run `just check` to confirm fmt, clippy, all tests, and WASM. diff --git a/docs/plans/2026-04-12-willow-channel-removal.md b/docs/plans/2026-04-12-willow-channel-removal.md new file mode 100644 index 00000000..daab8168 --- /dev/null +++ b/docs/plans/2026-04-12-willow-channel-removal.md @@ -0,0 +1,313 @@ +# Willow-Channel Removal — Implementation Plan + +**Date**: 2026-04-12 +**Spec**: `docs/specs/2026-04-12-willow-channel-removal.md` +**Depends on**: `docs/plans/2026-04-12-state-authority-and-mutations.md` +(permission pre-check must be in place before mutations are rewritten) + +## Overview + +Remove `willow-channel` and make `willow-state::ServerState` the +client's sole source of truth. Work is ordered to keep compilation +passing after each step. + +## Step ordering + +``` +Step 1 (ChannelKind in state) + ↓ +Step 2 (Move invite types) Step 3 (Rewrite mutations) + ↓ ↓ +Step 4 (Remove Server from client) + ↓ +Step 5 (Rewrite join path) + ↓ +Step 6 (Rewrite tests) + ↓ +Step 7 (Delete crate) + ↓ +Step 8 (Update docs) +``` + +Steps 2 and 3 can run in parallel after Step 1. Steps 4-8 are +sequential. Step 3 depends on the authority plan's pre-check being +in place. + +## Step 1: Add `ChannelKind` to willow-state + +**File**: `crates/state/src/types.rs` + +Add the enum with serde attributes that accept both the enum variant +names and the legacy `"text"` / `"voice"` strings: + +```rust +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum ChannelKind { + #[default] + Text, + Voice, +} +``` + +Serde's default derive on an enum serializes as the variant name +(`"Text"`, `"Voice"`). Existing DAG events were serialized with +lowercase strings (`"text"`, `"voice"`). To handle both, add a +custom deserializer or use `#[serde(alias = "text")]` / +`#[serde(alias = "voice")]` on each variant: + +```rust +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum ChannelKind { + #[default] + #[serde(alias = "text")] + Text, + #[serde(alias = "voice")] + Voice, +} +``` + +Update `Channel` to use it: + +```rust +pub struct Channel { + pub id: String, + pub name: String, + pub pinned_messages: BTreeSet, + #[serde(default)] + pub kind: ChannelKind, // was String +} +``` + +**File**: `crates/state/src/event.rs` + +Update `EventKind::CreateChannel`: + +```rust +CreateChannel { + name: String, + channel_id: String, + #[serde(default)] + kind: ChannelKind, // was String +}, +``` + +Delete the `default_create_channel_kind()` function (replaced by +`ChannelKind`'s `Default` impl). + +**File**: `crates/state/src/materialize.rs` + +Update `apply_mutation` for `CreateChannel` — `kind` is now a +`ChannelKind`, use `kind.clone()` directly. + +**File**: `crates/state/src/lib.rs` + +Add `ChannelKind` to the re-exports (currently only exports +`Channel, ChatMessage, Member, Profile, Role`). + +**Tests**: Update the ~12 tests in `crates/state/src/tests.rs` that +construct `EventKind::CreateChannel` with `kind: "text".into()` to +use `ChannelKind::Text`. Add a round-trip test verifying that +`"text"` (legacy string) deserializes to `ChannelKind::Text`. + +Run `cargo test -p willow-state`. + +## Step 2: Move `Invite` types to willow-client + +**Files**: +- `crates/client/src/invite.rs` — add `InviteId(Uuid)` and `Invite` + struct here (moved from `crates/channel/src/lib.rs`). +- Remove the `willow_channel::Invite` and `InviteId` imports. + +Update `generate_invite()` signature — instead of taking +`&willow_channel::Server`, take the data it needs directly: + +```rust +pub fn generate_invite( + server_name: &str, + server_id: &str, + genesis_author: EndpointId, + channels: &[(String, String)], // (topic, channel_name) + keys: &HashMap, + recipient_ed25519_public: &[u8; 32], +) -> Option +``` + +Note: `genesis_author` is needed for invite validation on the +receiving side. + +Update the 4 invite tests to construct state via `ManagedDag` instead +of `willow_channel::Server::new()`: +- `secure_invite_round_trip` +- `wrong_recipient_cannot_decrypt` +- `multiple_channels_encrypted` +- `generate_invite_via_endpoint_id_produces_valid_invite` + +Run `cargo test -p willow-client`. + +## Step 3: Rewrite client mutations to use event pipeline + +**Prerequisite**: The permission pre-check from the authority plan must +be in place, so `create_and_insert` rejects unauthorized events before +signing. + +Each sub-step removes the `willow_channel::Server` mutation and keeps +only the event pipeline call. Currently the code does both (mutates +Server AND emits event) — after this step, only the event remains. + +### 3a: `mutations.rs` — `create_channel` (line ~317) + +Remove `entry.server.create_channel(name, ChannelKind::Text)`. +Keep `build_event(EventKind::CreateChannel { ... })`. The channel ID +is generated as `Uuid::new_v4().to_string()`. + +Channel keys: currently read from `entry.server.channel_key()` after +`create_channel()`. After removal, key generation moves to the event +handler — when `apply_mutation` processes `CreateChannel`, the client +generates and stores the key in `ServerEntry.keys`. + +### 3b: `actions.rs` — `create_voice_channel` (line ~127) + +Same pattern: remove `entry.server.create_channel(name, Voice)`, +keep `build_event(EventKind::CreateChannel { kind: ChannelKind::Voice, ... })`. + +### 3c: `actions.rs` — role operations (lines ~195-267) + +Replace `willow_channel::RoleId(Uuid::parse_str(...))` with plain +`String` role IDs, since state uses strings throughout. + +### 3d: `mutations.rs` — role creation (line ~507) + +Replace `willow_channel::RoleId::new()` and +`willow_channel::Role::with_id()` with +`build_event(EventKind::CreateRole { role_id: Uuid::new_v4().to_string(), ... })`. + +### 3e: `servers.rs` — server creation (line ~97) + +Replace `willow_channel::Server::new(&name, peer_id)` and +`server.create_channel("general", ChannelKind::Text)` with event +pipeline calls through `ManagedDag`. + +Run `cargo test -p willow-client`. + +## Step 4: Remove `Server` from `ServerEntry` + +**Files**: `crates/client/src/state_actors.rs`, `crates/client/src/state.rs` + +Remove `server: willow_channel::Server` from `ServerEntry`. + +Remove `name: String` (read from `ServerState.server_name` instead). + +Remove `topic_map: HashMap`. +Derive topic-to-channel mapping from `ServerState.channels`: + +```rust +/// Derive topic string → channel name from ServerState. +pub fn topic_for_channel(server_id: &str, channels: &BTreeMap) -> HashMap { + channels.iter().map(|(_, ch)| { + (format!("{}/{}", server_id, ch.name), ch.name.clone()) + }).collect() +} +``` + +### 4a: `util.rs` + +Change `make_topic()` to take `&str` server ID instead of +`&willow_channel::Server`. Update all ~9 call sites. + +### 4b: `views.rs` + +Replace `willow_channel::ChannelKind` pattern matches with +`willow_state::ChannelKind`. Read channel list from +`ServerState.channels` instead of `entry.server.channels()`. + +(Depends on Step 1 — `ChannelKind` must be in `willow-state` first.) + +### 4c: `persistence_actor.rs` + +Change `PersistServerConfig` and `PersistServerById` to hold +server data from `ServerState` fields, not `willow_channel::Server`. + +### 4d: `storage.rs` + +Update `save_server()` / `load_server()` to serialize `ServerState` +instead of `willow_channel::Server`. + +### 4e: `lib.rs` + +Delete `reconcile_topic_map()` (no longer two representations to sync). + +Delete `parse_permission()` — callers import `willow_state::Permission` +directly. Update the call site in `actions.rs` (line ~206). + +Run `cargo test -p willow-client`. + +## Step 5: Rewrite invite join path + +**File**: `crates/client/src/joining.rs` + +The join path currently constructs a `willow_channel::Server` from +invite data (line ~75): + +```rust +let mut server = willow_channel::Server::with_id( + willow_channel::ServerId(parsed_server_uuid), + &accepted.server_name, + accepted.genesis_author, +); +``` + +Replace with constructing a minimal `ServerEntry` (without a `Server`) +populated from the invite's channel list and keys. The joining peer +does not have the full event history yet — it will receive the genesis +event and full DAG from peers during sync. The invite provides enough +information (server name, channels, keys) to bootstrap the UI while +sync completes. + +Run `cargo test -p willow-client`. + +## Step 6: Rewrite tests + +**File**: `crates/client/src/lib.rs` + +The `test_client()` helper (line ~691) constructs a +`willow_channel::Server`. Note: lines ~727-759 already partially use +`ManagedDag` — complete the migration by removing the `Server::new()` +on line ~700 and using `ManagedDag` exclusively. + +Tests that reference `willow_channel` types: +- `send_message_and_read_back` (line ~971) +- `create_channel_shows_in_list` (line ~982) +- Invite tamper test (line ~1094) + +**File**: `crates/client/src/invite.rs` + +Rewrite all 4 test functions to construct servers via `ManagedDag` +instead of `Server::new()`. + +**File**: `crates/client/src/servers.rs` + +Update any test code that creates `willow_channel::Server` directly. + +Run `cargo test -p willow-client`. + +## Step 7: Delete willow-channel + +- `rm -rf crates/channel/` (root `Cargo.toml` uses + `members = ["crates/*"]`, so deleting the directory is sufficient). +- Remove `willow-channel` from `crates/client/Cargo.toml`. +- `willow-state` is already in `crates/client/Cargo.toml`. +- Verify no remaining imports: `grep -r willow_channel crates/` + +Run `just check` (fmt + clippy + test + WASM). + +## Step 8: Update documentation + +**File**: `CLAUDE.md` + +- Remove `willow-channel` from the repository structure listing. +- Remove `willow-channel` from the dependency graph. +- Update "Adding a new permission" section (currently references + `willow-channel` for display purposes — remove that line). +- Remove the deprecation note under "Authority Model" (crate is gone). + +Run `just check` one final time. diff --git a/docs/specs/2026-04-01-per-author-merkle-dag-state-design.md b/docs/specs/2026-04-01-per-author-merkle-dag-state-design.md index d313c7ab..9d14d5bc 100644 --- a/docs/specs/2026-04-01-per-author-merkle-dag-state-design.md +++ b/docs/specs/2026-04-01-per-author-merkle-dag-state-design.md @@ -341,7 +341,7 @@ Admin C: Vote(yes) │ ← 3/3, threshold met → applied ``` There is no separate "resolution" event. The state change is inferred -during materialization: `apply_unchecked` processes a `Vote`, tallies +during materialization: `apply_event` processes a `Vote`, tallies the votes in the pending proposal, and if the threshold is met, calls `apply_proposed_action` inline. The action is a side effect of the Vote event, not a distinct event in the DAG. @@ -433,7 +433,7 @@ pub fn materialize(dag: &EventDag) -> ServerState { let sorted = dag.topological_sort(); let mut state = ServerState::new(&server_id, &name, genesis.author); for event in sorted { - apply_unchecked(&mut state, event); + apply_event(&mut state, event); } state } @@ -667,7 +667,7 @@ pub fn materialize(dag: &EventDag) -> ServerState { let sorted = dag.topological_sort(); let mut state = ServerState::new(&server_id, &name, genesis_author); for event in sorted { - apply_unchecked(&mut state, event); + apply_event(&mut state, event); } state } @@ -776,7 +776,7 @@ pub fn apply_incremental( state: &mut ServerState, event: &Event, ) -> ApplyResult { - apply_unchecked(state, event) + apply_event(state, event) } ``` @@ -800,7 +800,7 @@ them), and re-materialization is fast for reasonable event counts. A future optimization can detect when re-materialization is actually needed vs when the new dep doesn't affect ordering. -### apply_unchecked +### apply_event The core apply function is simplified — it no longer checks parent hashes or dedup (the DAG handles both structurally): @@ -809,7 +809,7 @@ hashes or dedup (the DAG handles both structurally): /// Apply an event's mutation to state. No structural validation — /// the DAG guarantees ordering and dedup. Permission checks and /// governance logic are enforced here. -fn apply_unchecked(state: &mut ServerState, event: &Event) -> ApplyResult { +fn apply_event(state: &mut ServerState, event: &Event) -> ApplyResult { match &event.kind { // Governance events — handled specially. EventKind::CreateServer { .. } => { @@ -1557,7 +1557,7 @@ code path between "single linear chain with parent state hash" and |---|---| | `Event` (old struct) | Replaced by new `Event` with `prev`, `deps`, `seq`, `sig` | | `apply()` | Parent-hash check is structurally unnecessary in DAG model | -| `apply_lenient()` | Replaced by `apply_unchecked()` (DAG guarantees ordering) | +| `apply_lenient()` | Replaced by `apply_event()` (DAG guarantees ordering) | | `merge()` | DAG union + topological sort replaces timestamp-sorted merge | | `find_common_ancestor()` | Per-author seq comparison replaces state-hash walking | | `StateHash` | No per-event state hash; snapshots use `SnapshotHash` | @@ -1580,7 +1580,7 @@ code path between "single linear chain with parent state hash" and | `Channel` | `pinned_messages` changes from `HashSet` to `HashSet` | | `Role`, `Member`, `Profile` | Unchanged | | `ChatMessage` | `id` and `reply_to` change from `String` to `EventHash` | -| Permission enforcement in apply | Moved to `apply_unchecked()`, governance events handled specially | +| Permission enforcement in apply | Moved to `apply_event()`, governance events handled specially | | Zero-I/O crate boundary | Preserved — state crate remains pure | ### New Modules @@ -1590,7 +1590,7 @@ code path between "single linear chain with parent state hash" and | `hash.rs` | `EventHash` (32-byte SHA-256 wrapper, `Ord`, `Display`) | | `event.rs` | `Event`, `EventKind`, `Permission`, `ProposedAction`, `VoteThreshold` | | `dag.rs` | `EventDag`, `InsertError`, topological sort, `heads_summary`, `events_since` | -| `materialize.rs` | `materialize()`, `apply_unchecked()`, `apply_incremental()` | +| `materialize.rs` | `materialize()`, `apply_event()`, `apply_incremental()` | | `sync.rs` | `HeadsSummary`, `AuthorHead`, `SyncMessage`, `AuthorRequest`, `ChainStatus`, `compare_chains`, `PendingBuffer` | | `snapshot.rs` | `Snapshot`, `SnapshotHash`, compaction | | `types.rs` | Unchanged — `Channel`, `Role`, `Member`, etc. | @@ -2045,7 +2045,7 @@ and what is deferred. | Section | What ships | |---|---| | Section 1 | `Event`, `EventHash`, `EventKind` (22 variants), `ProposedAction`, `VoteThreshold`, `EventDag`, `InsertError`, `PendingBuffer` | -| Section 2 | `materialize()`, `apply_unchecked()`, `apply_incremental()`, topological sort, `ServerState` (simplified) | +| Section 2 | `materialize()`, `apply_event()`, `apply_incremental()`, topological sort, `ServerState` (simplified) | | Section 3 | `HeadsSummary`, `SyncMessage`, `AuthorRequest`, sync flow | | Section 4 | `ChainStatus` (with `Forked` equivocation detection), `compare_chains()` | | Section 7 | Full deletion of legacy types, new module layout, new public API | diff --git a/docs/specs/2026-04-12-state-authority-and-mutations.md b/docs/specs/2026-04-12-state-authority-and-mutations.md new file mode 100644 index 00000000..22e159f4 --- /dev/null +++ b/docs/specs/2026-04-12-state-authority-and-mutations.md @@ -0,0 +1,135 @@ +# State, Authority, and Mutations + +> **One-sentence summary:** `willow-state` is the single source of truth. +> All authority checks live in `apply_event()` and the +> `required_permission()` table. Permissions are checked *before* an +> event is created — rejected events never enter the DAG. + +## Single source of truth + +`willow-state` owns the canonical representation of a server: the +`ServerState` struct, produced by replaying a per-author Merkle DAG of +signed events through `materialize()`. No other crate may hold +authoritative state or enforce trust decisions. + +- Client holds `ServerState` directly. +- UI derives reactive views from `ServerState` fields. +- All mutations flow through the event pipeline (no direct struct + mutation). + +## Local mutation flow + +Every local state change follows one path: + +``` +user action + → ManagedDag::create_and_insert() + 1. permission pre-check (reject before signing) + 2. dag.create_event() (sign, compute hash, set seq/prev/deps) + 3. dag.insert() (verify signature, check seq, dedup) + 4. apply_incremental() → apply_event() → apply_mutation() + → broadcast signed Event over gossip + → UI observes updated ServerState +``` + +**Permissions are checked before the event is created.** If the author +lacks the required permission, `create_and_insert` returns an error. +No event is signed, no sequence number is advanced, and the DAG does +not grow. + +This prevents a class of problems where rejected events accumulate in +the DAG. Since the author has already committed to their sequence +chain once an event is signed, a post-insert rejection would leave a +dead event in the DAG that cannot be removed without breaking the +author's chain. + +## Remote event flow + +``` +gossip delivers signed Event + → dag.insert() (verify signature, check seq, dedup) + → apply_incremental() → apply_event() + 1. governance check (Propose/Vote require is_admin) + 2. admin-only check (GrantPermission, RevokePermission, + RenameServer, SetServerDescription) + 3. permission check (required_permission() table) + 4. apply_mutation() (project into ServerState) + → UI observes updated ServerState +``` + +For remote events, the permission check happens after DAG insertion +because the sender has already committed to their chain. There are two +cases where a remote event is rejected: + +- **Out-of-order delivery:** A permission grant hasn't arrived yet. + The event is structurally valid but the local state doesn't reflect + the grant. This is a sync timing issue — the sender passed the + pre-check locally, so the permission exists in the full DAG. +- **Malicious sender:** The sender forged a chain without permission + checks. The event stays in the DAG but does not affect state. A + persistently-rejected author can be evicted at the network layer. + +## Permission tiers + +| Tier | Events | Enforcement | +|------|--------|-------------| +| **Governance (vote)** | `Propose`, `Vote` | `is_admin()` — only admins may propose or vote. Actions auto-apply when vote threshold is met. | +| **Admin-only** | `GrantPermission`, `RevokePermission`, `RenameServer`, `SetServerDescription` | `is_admin()` — any single admin can execute these directly. | +| **Permission-gated** | `Message`, `EditMessage`, `DeleteMessage`, `Reaction` → `SendMessages`; `CreateChannel`, `DeleteChannel`, `RenameChannel`, `RotateChannelKey` → `ManageChannels`; `CreateRole`, `DeleteRole`, `SetPermission`, `AssignRole` → `ManageRoles` | `has_permission()` — admins pass implicitly; non-admins need an explicit grant. | +| **Unrestricted** | `SetProfile`, `PinMessage`, `UnpinMessage` | No check — any member can execute. | +| **Genesis** | `CreateServer` | No-op on replay; the genesis author becomes the sole initial admin. | + +Admin status is tracked in `ServerState.admins` and is **not** a variant +of the `Permission` enum. It can only be granted or revoked through the +`ProposedAction::GrantAdmin` / `RevokeAdmin` vote path. This structural +separation makes it impossible to escalate to admin via a +`GrantPermission` event. + +## The `required_permission()` catch-all + +The `_ => None` arm in `required_permission()` silently passes any +unrecognised `EventKind` variant without a permission check. This is +the mechanism behind bug #109: a new variant that falls into the +catch-all gets zero enforcement. + +**Every variant that returns `None` is intentionally unrestricted or +checked elsewhere.** The catch-all arm MUST list these variants in a +comment so reviewers notice when a new variant is missing: + +- `CreateServer` — genesis, checked structurally +- `Propose`, `Vote` — governance, checked in the governance block +- `GrantPermission`, `RevokePermission` — admin-only, checked in the + admin block +- `RenameServer`, `SetServerDescription` — admin-only, checked in the + admin block +- `SetProfile` — intentionally unrestricted +- `PinMessage`, `UnpinMessage` — intentionally unrestricted + +If a variant is not in this list and not in a `required_permission()` +arm, it is a bug. + +## Checklists + +### Adding a new permission + +1. Add a variant to `Permission` in `crates/state/src/event.rs`. +2. Add the corresponding `EventKind` → `Permission` mapping to + `required_permission()` in `crates/state/src/materialize.rs`. +3. Implement `has_permission()` handling if the new permission needs + special logic (admins already pass implicitly). +4. Add state-machine tests: grant, revoke, rejection without permission. +5. Update UI if the permission should be visible in settings. + +### Adding a new event kind + +1. Add a variant to `EventKind` in `crates/state/src/event.rs`. +2. **Decide its authority tier** — governance, admin-only, + permission-gated, or unrestricted. +3. If permission-gated: add it to `required_permission()`. +4. If admin-only: add it to the admin-only match block in + `apply_event()`. +5. If unrestricted: add it to the comment on the `_ => None` arm + listing intentionally-unrestricted variants. +6. Handle it in `apply_mutation()`. +7. Add state-machine tests for application, dedup, and permission + rejection. diff --git a/docs/specs/2026-04-12-willow-channel-removal.md b/docs/specs/2026-04-12-willow-channel-removal.md new file mode 100644 index 00000000..9a1fa901 --- /dev/null +++ b/docs/specs/2026-04-12-willow-channel-removal.md @@ -0,0 +1,141 @@ +# Willow-Channel Removal + +> Remove the `willow-channel` crate entirely. Consolidate all shared +> types into `willow-state` and eliminate the dual-state representation +> in the client. + +## Problem + +The client currently holds two parallel representations of server state: + +- `willow_state::ServerState` — the authoritative event-sourced state. +- `willow_channel::Server` — a mutable in-memory copy with its own + `Channel`, `Role`, `Member` types and UUID-based IDs. + +These are kept in sync via `reconcile_topic_map()`, which is fragile +and a source of bugs. The `willow-channel` types duplicate what +`willow-state` already provides, with different ID schemes (`Uuid` +wrappers vs plain `String`), different collection types (`HashMap` vs +`BTreeMap`), and methods (`create_channel()`, `add_member()`) that +bypass the event pipeline. + +`willow-channel` is only used by `willow-client`. No other crate +depends on it. + +## Target architecture + +After removal: + +- `willow-state` is the sole owner of shared types (`Channel`, `Role`, + `Member`, `Permission`, `ServerState`). +- The client holds `ServerState` directly — no parallel `Server` copy. +- `ChannelKind` moves from `willow-channel` to `willow-state::types`. +- Invite helpers move to `willow-client::invite` (invites are + client-local, not event-sourced). +- All mutations go through the event pipeline; there are no mutable + `Server` methods like `create_channel()` or `add_member()`. +- UI derives reactive views from `ServerState` fields. + +## What willow-channel provides today + +| Type / Feature | Used for | Disposition | +|---|---|---| +| `ServerId(Uuid)`, `ChannelId(Uuid)`, `RoleId(Uuid)` | UUID-wrapped ID newtypes | **Delete.** State uses `String` IDs throughout; the event-sourced DAG derives server ID from the genesis hash. | +| `InviteId(Uuid)` | Invite identifiers | **Move** to `willow-client::invite`. | +| `ChannelKind` (Text/Voice) | Channel type discrimination | **Move** to `willow-state::types`. Replace `kind: String` in `EventKind::CreateChannel` and `types::Channel` with the enum. | +| `ChannelError` | Error type for channel ops | **Delete.** Errors come from `ApplyResult::Rejected` or `InsertError`. | +| `Permission` | Re-export of `willow_state::Permission` | **Delete re-export.** Consumers import from `willow-state` directly. | +| `Channel`, `Role`, `Member` structs | Data model | **Delete.** `willow-state::types` already defines these. `willow-channel::Channel` has extra fields (`topic: Option`, `created_at: DateTime`) not present in `willow-state::types::Channel` — drop them (not used in the event-sourced model). | +| `Invite` struct | Invite data | **Move** to `willow-client::invite`. | +| `Server` struct + methods | Mutable in-memory state | **Delete.** All mutation goes through the event pipeline. | +| `Server::channel_key()`, `set_channel_key()` | Channel key storage | **Move** key storage to client-local state (not part of `ServerState`). | +| Role `color` field | UI metadata | **Add** to `willow-state::types::Role` if needed. | + +## Client changes + +### `ServerEntry` (state_actors.rs) + +Before: +```rust +pub struct ServerEntry { + pub server: willow_channel::Server, + pub topic_map: HashMap, + pub keys: HashMap, + // ... +} +``` + +After: +```rust +pub struct ServerEntry { + pub keys: HashMap, + // ... +} +``` + +Server name, channels, roles, and members come from `ServerState` +directly. `topic_map` is derived from `ServerState.channels`. + +### Mutations + +Before: `entry.server.create_channel(name, kind)` (direct struct mutation). + +After: `build_event(EventKind::CreateChannel { ... })` (event pipeline). + +This is already how most mutations work. The exceptions are: +- `create_voice_channel` in `actions.rs` — mutates `Server` directly +- `create_channel` in `mutations.rs` — mutates `Server` directly +- Invite join path in `joining.rs` — constructs a `Server` from invite data + +These need to be rewritten to emit events instead. + +### Tests + +Several test functions create `willow_channel::Server` directly and +will break: + +- `crates/client/src/lib.rs` — `test_client()` helper, plus + `send_message_and_read_back`, `create_channel_shows_in_list`, and + invite-related tests all construct `Server::new()`. +- `crates/client/src/invite.rs` — four test functions + (`secure_invite_round_trip`, `wrong_recipient_cannot_decrypt`, + `multiple_channels_encrypted`, + `generate_invite_via_endpoint_id_produces_valid_invite`) create + servers with `Server::new()` and `create_channel()`. + +These must be rewritten to use event-sourced `ServerState` via +`ManagedDag` or replaced with state-machine-level tests. + +### Invite system + +`generate_invite()` currently takes a `&willow_channel::Server`. After +removal it takes the data it actually needs: server name, channel +list, and channel keys. The `Invite` and `InviteId` types move to +`willow-client::invite`. + +### Files to modify + +All changes are in `crates/client/src/`: + +| File | Changes | +|------|---------| +| `state_actors.rs` | Remove `server` field from `ServerEntry`, derive from `ServerState` | +| `state.rs` | Remove `server: Server` field, remove `topic_map` | +| `storage.rs` | Serialize/deserialize `ServerState` instead of `Server` | +| `mutations.rs` | Remove `Server::create_channel()` calls, use event pipeline | +| `actions.rs` | Same — replace direct mutation with events | +| `joining.rs` | Construct `ServerState` from invite data, not `Server` | +| `invite.rs` | Take channel list + keys directly, not `&Server` | +| `views.rs` | Read from `ServerState` instead of `Server` | +| `persistence_actor.rs` | Persist `ServerState`, not `Server` | +| `servers.rs` | Use event pipeline for server creation | +| `util.rs` | `make_topic()` takes `&str` server ID, not `&Server` | +| `lib.rs` | Remove `reconcile_topic_map()`, remove `parse_permission()` | + +### Workspace changes + +- Delete `crates/channel/` entirely. The root `Cargo.toml` uses + `members = ["crates/*"]`, so deleting the directory is sufficient. +- Remove `willow-channel` from `crates/client/Cargo.toml`. +- Add `willow-state` to `crates/client/Cargo.toml` (if not present). +- Update `CLAUDE.md` dependency graph to remove `willow-channel`.