channels: add actor-owned admin mutations

This commit is contained in:
DeFiDude 2026-07-31 02:23:19 -06:00
parent d037a84999
commit d2ecaca7b0
5 changed files with 1823 additions and 6 deletions

File diff suppressed because it is too large Load diff

View file

@ -5,8 +5,9 @@
use std::sync::Arc;
use ratspeak_runtime::channel_hub::{
ChannelHubAdminSnapshot, ChannelHubSettings, ChannelHubSnapshot, HubStore,
channel_hub_hosting_supported,
ChannelHubAdminKeyChange, ChannelHubAdminMutation, ChannelHubAdminRoomPolicy,
ChannelHubAdminRoomRole, ChannelHubAdminSecret, ChannelHubAdminSnapshot, ChannelHubSettings,
ChannelHubSnapshot, HubStore, channel_hub_hosting_supported,
};
use serde::{Deserialize, Serialize};
use tauri::State;
@ -18,6 +19,98 @@ use crate::state::AppState;
const MAX_HUB_NAME_CHARS: usize = 64;
const MAX_GREETING_CHARS: usize = 512;
/// Complete room policy from the Admin Center. Keeping this non-optional
/// avoids a UI checkbox omission silently preserving stale authority.
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ChannelHubAdminRoomPolicyArgs {
pub invite_only: bool,
pub moderated: bool,
pub no_outside_messages: bool,
pub private: bool,
pub topic_operators_only: bool,
}
impl From<ChannelHubAdminRoomPolicyArgs> for ChannelHubAdminRoomPolicy {
fn from(value: ChannelHubAdminRoomPolicyArgs) -> Self {
Self {
invite_only: value.invite_only,
moderated: value.moderated,
no_outside_messages: value.no_outside_messages,
private: value.private,
topic_operators_only: value.topic_operators_only,
}
}
}
#[derive(Clone, Copy, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ChannelHubAdminRoomRoleArgs {
Operator,
Voice,
}
impl From<ChannelHubAdminRoomRoleArgs> for ChannelHubAdminRoomRole {
fn from(value: ChannelHubAdminRoomRoleArgs) -> Self {
match value {
ChannelHubAdminRoomRoleArgs::Operator => Self::Operator,
ChannelHubAdminRoomRoleArgs::Voice => Self::Voice,
}
}
}
/// Tagged owner intents. Deliberately no `Debug`: create and update can carry
/// a plaintext join key that must become `Zeroizing` runtime input immediately.
#[derive(Deserialize)]
#[serde(tag = "action", rename_all = "snake_case", deny_unknown_fields)]
pub enum ChannelHubAdminMutationArgs {
CreateChannel {
room: String,
#[serde(default)]
topic: Option<String>,
policy: ChannelHubAdminRoomPolicyArgs,
#[serde(default)]
join_key: Option<String>,
},
UpdateChannel {
room: String,
/// Complete topic value; an empty string explicitly clears it.
topic: String,
policy: ChannelHubAdminRoomPolicyArgs,
#[serde(default)]
join_key: Option<String>,
#[serde(default)]
clear_join_key: bool,
},
UnregisterChannel {
room: String,
},
SetRoomRole {
room: String,
target_identity: String,
role: ChannelHubAdminRoomRoleArgs,
enabled: bool,
},
SetRoomBan {
room: String,
target_identity: String,
banned: bool,
},
SetInvitation {
room: String,
target_identity: String,
invited: bool,
},
Kick {
room: String,
target_identity: String,
},
SetHubBan {
target_identity: String,
banned: bool,
},
}
#[derive(Debug, Deserialize)]
pub struct ChannelHubConfigArgs {
#[serde(default)]
@ -107,6 +200,108 @@ fn active_operator_identity(state: &State<'_, Arc<AppState>>) -> AppResult<(Stri
Ok((identity_id, bytes))
}
fn admin_target_identity(value: &str) -> AppResult<[u8; 16]> {
if !crate::helpers::validate_hex(value, 32, 32) {
return Err(AppError::bad_request(
"Target identity must be a complete 32-character identity hash",
));
}
hex::decode(value)
.ok()
.and_then(|bytes| <[u8; 16]>::try_from(bytes).ok())
.ok_or_else(|| AppError::bad_request("Target identity hash is invalid"))
}
fn admin_mutation(args: ChannelHubAdminMutationArgs) -> AppResult<ChannelHubAdminMutation> {
Ok(match args {
ChannelHubAdminMutationArgs::CreateChannel {
room,
topic,
policy,
join_key,
} => ChannelHubAdminMutation::CreateChannel {
room,
topic,
policy: policy.into(),
join_key: join_key.map(ChannelHubAdminSecret::new),
},
ChannelHubAdminMutationArgs::UpdateChannel {
room,
topic,
policy,
join_key,
clear_join_key,
} => {
// Wrap before checking the mutually-exclusive controls so even a
// rejected key is zeroized on this path.
let join_key = join_key.map(ChannelHubAdminSecret::new);
if join_key.is_some() && clear_join_key {
return Err(AppError::bad_request(
"A join key cannot be set and cleared in the same change",
));
}
let join_key = match (join_key, clear_join_key) {
(Some(key), false) => ChannelHubAdminKeyChange::Set(key),
(None, true) => ChannelHubAdminKeyChange::Clear,
(None, false) => ChannelHubAdminKeyChange::Keep,
(Some(_), true) => unreachable!("conflict returned above"),
};
ChannelHubAdminMutation::UpdateChannel {
room,
topic: Some(topic),
policy: policy.into(),
join_key,
}
}
ChannelHubAdminMutationArgs::UnregisterChannel { room } => {
ChannelHubAdminMutation::UnregisterChannel { room }
}
ChannelHubAdminMutationArgs::SetRoomRole {
room,
target_identity,
role,
enabled,
} => ChannelHubAdminMutation::SetRoomRole {
room,
target_identity: admin_target_identity(&target_identity)?,
role: role.into(),
enabled,
},
ChannelHubAdminMutationArgs::SetRoomBan {
room,
target_identity,
banned,
} => ChannelHubAdminMutation::SetRoomBan {
room,
target_identity: admin_target_identity(&target_identity)?,
banned,
},
ChannelHubAdminMutationArgs::SetInvitation {
room,
target_identity,
invited,
} => ChannelHubAdminMutation::SetInvitation {
room,
target_identity: admin_target_identity(&target_identity)?,
invited,
},
ChannelHubAdminMutationArgs::Kick {
room,
target_identity,
} => ChannelHubAdminMutation::Kick {
room,
target_identity: admin_target_identity(&target_identity)?,
},
ChannelHubAdminMutationArgs::SetHubBan {
target_identity,
banned,
} => ChannelHubAdminMutation::SetHubBan {
target_identity: admin_target_identity(&target_identity)?,
banned,
},
})
}
async fn overview(
state: &State<'_, Arc<AppState>>,
settings: ChannelHubSettings,
@ -191,6 +386,25 @@ pub async fn api_channel_hub_admin(
.map_err(AppError::database_unavailable)
}
#[tauri::command]
pub async fn channel_hub_admin_mutate(
state: State<'_, Arc<AppState>>,
args: ChannelHubAdminMutationArgs,
) -> AppResult<ChannelHubAdminSnapshot> {
ensure_supported()?;
let _control = state.channel_hub_control_lock.lock().await;
// Convert first so any join key becomes zeroizing input even when the
// identity or live actor check below rejects the request.
let mutation = admin_mutation(args)?;
let (_, actor_identity) = active_operator_identity(&state)?;
let hub = state.channel_hub_handle().ok_or_else(|| {
AppError::service_unavailable("Start the channel hub before making administrative changes")
})?;
hub.admin_mutate(actor_identity, mutation)
.await
.map_err(|error| AppError::new(error.code(), error.message()))
}
#[tauri::command]
pub async fn channel_hub_start(state: State<'_, Arc<AppState>>) -> AppResult<ChannelHubOverview> {
ensure_supported()?;
@ -307,4 +521,112 @@ mod tests {
assert!(updated.resource_send_enabled);
assert!(updated.resource_accept_enabled);
}
fn room_policy_args() -> ChannelHubAdminRoomPolicyArgs {
ChannelHubAdminRoomPolicyArgs {
invite_only: false,
moderated: false,
no_outside_messages: true,
private: false,
topic_operators_only: true,
}
}
#[test]
fn admin_mutation_conversion_requires_full_identity_hashes() {
let error = match admin_mutation(ChannelHubAdminMutationArgs::Kick {
room: "lobby".to_string(),
target_identity: "aabbcc".to_string(),
}) {
Err(error) => error,
Ok(_) => panic!("a partial identity hash must be rejected"),
};
assert_eq!(error.code, "bad_request");
let mutation = admin_mutation(ChannelHubAdminMutationArgs::SetRoomRole {
room: "lobby".to_string(),
target_identity: "BB".repeat(16),
role: ChannelHubAdminRoomRoleArgs::Voice,
enabled: true,
})
.unwrap();
let ChannelHubAdminMutation::SetRoomRole {
target_identity,
role,
..
} = mutation
else {
panic!("expected a room-role mutation");
};
assert_eq!(target_identity, [0xBB; 16]);
assert!(matches!(role, ChannelHubAdminRoomRole::Voice));
}
#[test]
fn admin_update_key_controls_are_explicit_and_mutually_exclusive() {
let error = match admin_mutation(ChannelHubAdminMutationArgs::UpdateChannel {
room: "vault".to_string(),
topic: String::new(),
policy: room_policy_args(),
join_key: Some("secret-key".to_string()),
clear_join_key: true,
}) {
Err(error) => error,
Ok(_) => panic!("set and clear must be mutually exclusive"),
};
assert_eq!(error.code, "bad_request");
let mutation = admin_mutation(ChannelHubAdminMutationArgs::UpdateChannel {
room: "vault".to_string(),
topic: String::new(),
policy: room_policy_args(),
join_key: None,
clear_join_key: false,
})
.unwrap();
let ChannelHubAdminMutation::UpdateChannel {
topic, join_key, ..
} = mutation
else {
panic!("expected an update mutation");
};
assert_eq!(topic.as_deref(), Some(""));
assert!(matches!(join_key, ChannelHubAdminKeyChange::Keep));
}
#[test]
fn admin_update_deserialization_rejects_partial_or_unknown_policy() {
let missing_topic = serde_json::json!({
"action": "update_channel",
"room": "vault",
"policy": {
"invite_only": false,
"moderated": false,
"no_outside_messages": true,
"private": false,
"topic_operators_only": true
}
});
assert!(
serde_json::from_value::<ChannelHubAdminMutationArgs>(missing_topic).is_err(),
"an omitted complete topic must not silently clear it"
);
let unknown_policy = serde_json::json!({
"action": "create_channel",
"room": "vault",
"policy": {
"invite_only": false,
"moderated": false,
"no_outside_messages": true,
"private": false,
"topic_operators_only": true,
"future_mode": true
}
});
assert!(
serde_json::from_value::<ChannelHubAdminMutationArgs>(unknown_policy).is_err(),
"unknown authority fields require an explicit model update"
);
}
}

View file

@ -520,6 +520,7 @@ fn channel_hub_persists_policy_only_and_gates_room_creation() {
for command in [
"channel_hub::api_channel_hub",
"channel_hub::api_channel_hub_admin",
"channel_hub::channel_hub_admin_mutate",
"channel_hub::channel_hub_start",
"channel_hub::channel_hub_stop",
"channel_hub::channel_hub_set_config",
@ -536,7 +537,7 @@ fn channel_hub_persists_policy_only_and_gates_room_creation() {
assert!(hub.contains("target_os = \"android\", target_os = \"ios\""));
assert_eq!(
commands.matches("ensure_supported()?;").count(),
4,
5,
"every hosting-specific hub command must reject mobile hosting"
);
assert!(runtime.contains("channel_hub_hosting_supported()"));
@ -616,6 +617,7 @@ fn channel_hub_persists_policy_only_and_gates_room_creation() {
assert!(state.contains("pub channel_hub_control_lock: tokio::sync::Mutex<()>"));
for command in [
"pub async fn api_channel_hub_admin",
"pub async fn channel_hub_admin_mutate",
"pub async fn channel_hub_start",
"pub async fn channel_hub_stop",
"pub async fn channel_hub_set_config",
@ -639,6 +641,108 @@ fn channel_hub_persists_policy_only_and_gates_room_creation() {
assert!(hub.contains("Do not derive"));
}
#[test]
fn channel_hub_admin_mutations_are_actor_owned_and_durable_before_ack() {
let root = repo_root();
let hub = read_source(root.join("crates/ratspeak-runtime/src/channel_hub.rs"))
.expect("channel hub runtime");
let commands = read_source(root.join("crates/ratspeak-tauri/src/commands/channel_hub.rs"))
.expect("channel hub commands");
// Plaintext join-key input has no formatting or serialization surface and
// becomes a zeroizing runtime value immediately at the IPC boundary.
for declaration in [
"pub struct ChannelHubAdminSecret",
"pub enum ChannelHubAdminMutation",
] {
let prefix = hub
.split(declaration)
.next()
.expect("sensitive runtime declaration");
let declaration_attributes = prefix.rsplit("\n\n").next().unwrap_or_default();
assert!(
!declaration_attributes.contains("derive("),
"{declaration} must not derive formatting or serialization"
);
}
let args_prefix = commands
.split("pub enum ChannelHubAdminMutationArgs")
.next()
.expect("sensitive IPC declaration");
let args_attributes = args_prefix.rsplit("\n\n").next().unwrap_or_default();
assert!(!args_attributes.lines().any(|line| {
let line = line.trim_start();
line.starts_with("#[derive") && line.contains("Debug")
}));
assert!(hub.contains("pub struct ChannelHubAdminSecret(Zeroizing<String>);"));
assert!(commands.contains("join_key.map(ChannelHubAdminSecret::new)"));
assert!(commands.contains("let join_key = join_key.map(ChannelHubAdminSecret::new);"));
// The Tauri command serializes with lifecycle changes, rejects stopped
// mutation, requires a full identity hash, and never writes HubStore.
let mutation_command = commands
.split("pub async fn channel_hub_admin_mutate")
.nth(1)
.and_then(|tail| tail.split("\n#[tauri::command]").next())
.expect("admin mutation command");
assert!(mutation_command.contains("channel_hub_control_lock.lock().await"));
assert!(mutation_command.contains("active_operator_identity(&state)"));
assert!(mutation_command.contains("state.channel_hub_handle().ok_or_else"));
assert!(mutation_command.contains("hub.admin_mutate(actor_identity, mutation)"));
assert!(!mutation_command.contains("HubStore"));
assert!(commands.contains("validate_hex(value, 32, 32)"));
// Authorization and every state transition happen in HubCore. Durable
// access state requires a registered room; live kick remains live-only;
// server operators cannot be deopped, kicked, or banned.
assert!(hub.contains("pub(crate) fn admin_mutate("));
assert!(hub.contains("if !self.server_ops.contains(&actor_identity)"));
assert!(hub.contains("fn require_registered_admin_room("));
assert!(hub.contains("\"A server operator cannot be removed through a channel role\""));
assert!(hub.contains("\"A server operator cannot be banned from a channel\""));
assert!(hub.contains("\"A server operator cannot be kicked\""));
assert!(hub.contains("\"A server operator cannot be banned from the hub\""));
assert!(hub.contains("\"Invitations require an invite-only or join-key channel\""));
// Local actions write only private evidence for their moderation/trust
// intent; they do not fabricate a link-scoped Activity transition.
let local_evidence = hub
.split("fn note_admin_moderated")
.nth(1)
.and_then(|tail| tail.split("fn broadcast_admin_topic").next())
.expect("local owner evidence helpers");
assert!(local_evidence.contains("self.push_evidence("));
assert!(!local_evidence.contains("self.events.push"));
// The actor applies the intent, flushes its complete SQLite projection,
// publishes state, and only then acknowledges with a fresh owner model.
let run_hub = hub
.split("async fn run_hub(")
.nth(1)
.and_then(|tail| tail.split("/// Every hub event").next())
.expect("hub service loop");
let applies = run_hub
.find("core.admin_mutate(actor_identity, mutation, &mut out)")
.expect("actor applies typed mutation");
let flushes = run_hub
.find("let persisted = flush_sends")
.expect("actor flushes mutation");
let replies = run_hub
.find("if let Some((result_tx, result)) = admin_reply")
.expect("actor replies after flush");
let sends = run_hub
.find("result_tx.send(result)")
.expect("actor sends mutation result");
assert!(applies < flushes && flushes < replies && replies < sends);
assert!(run_hub.contains("ADMIN_ERROR_REGISTRY_UNAVAILABLE"));
assert!(
hub.contains("const ADMIN_ERROR_REGISTRY_UNAVAILABLE: &str = \"registry_unavailable\";")
);
assert!(run_hub.contains("Ok(()) => Ok(core.admin_snapshot())"));
assert!(hub.contains("async fn flush_sends("));
assert!(hub.contains(") -> bool {"));
}
#[test]
fn channel_hub_shutdown_acknowledges_complete_teardown() {
let root = repo_root();

1
src-tauri/Cargo.lock generated
View file

@ -4475,6 +4475,7 @@ dependencies = [
"tauri-plugin-notification",
"tokio",
"tracing",
"url",
]
[[package]]

View file

@ -703,6 +703,7 @@ pub fn run() {
ratspeak_tauri::commands::channels::remove_saved_channel_room,
ratspeak_tauri::commands::channel_hub::api_channel_hub,
ratspeak_tauri::commands::channel_hub::api_channel_hub_admin,
ratspeak_tauri::commands::channel_hub::channel_hub_admin_mutate,
ratspeak_tauri::commands::channel_hub::channel_hub_start,
ratspeak_tauri::commands::channel_hub::channel_hub_stop,
ratspeak_tauri::commands::channel_hub::channel_hub_set_config,