From 70377f13b88dd5a5562817a08374a12ab21916e9 Mon Sep 17 00:00:00 2001 From: cloudwithax Date: Sun, 15 Feb 2026 23:17:44 -0500 Subject: [PATCH] feat: implement pending join role management for community invites --- src-tauri/src/commands/chat.rs | 1 + src-tauri/src/commands/community.rs | 41 +++++++++++++++++++---- src-tauri/src/commands/identity.rs | 5 +++ src-tauri/src/dev_server.rs | 50 +++++++++++++++++++++------ src-tauri/src/lib.rs | 6 +++- src-tauri/src/node/mod.rs | 52 +++++++++++++++++++++++++++++ 6 files changed, 138 insertions(+), 17 deletions(-) diff --git a/src-tauri/src/commands/chat.rs b/src-tauri/src/commands/chat.rs index afd5ae9..d5a88cd 100644 --- a/src-tauri/src/commands/chat.rs +++ b/src-tauri/src/commands/chat.rs @@ -32,6 +32,7 @@ pub async fn start_node(app: tauri::AppHandle, state: State<'_, AppState>) -> Re state.storage.clone(), app, state.voice_channels.clone(), + state.pending_join_role_guard.clone(), custom_relay, ) .await?; diff --git a/src-tauri/src/commands/community.rs b/src-tauri/src/commands/community.rs index 84bf0cd..3225bc1 100644 --- a/src-tauri/src/commands/community.rs +++ b/src-tauri/src/commands/community.rs @@ -177,19 +177,38 @@ pub async fn join_community( ) -> Result { let invite = crate::protocol::community::InviteCode::decode(&invite_code)?; - let identity = state.identity.lock().await; - if identity.is_none() { - return Err("no identity loaded".to_string()); - } - drop(identity); + let local_peer_id = { + let identity = state.identity.lock().await; + let id = identity.as_ref().ok_or("no identity loaded")?; + id.peer_id.to_string() + }; // create a placeholder document that will be backfilled via crdt sync // once we connect to existing community members through the relay let mut engine = state.crdt_engine.lock().await; - if !engine.has_community(&invite.community_id) { + let had_existing_doc = engine.has_community(&invite.community_id); + if !had_existing_doc { engine.create_placeholder_community(&invite.community_id, &invite.community_name, "")?; } + // joining via invite must never keep elevated local roles from stale local docs + if had_existing_doc { + if let Ok(members) = engine.get_members(&invite.community_id) { + let local_has_elevated_role = members.iter().any(|member| { + member.peer_id == local_peer_id + && member + .roles + .iter() + .any(|role| role == "owner" || role == "admin") + }); + + if local_has_elevated_role { + let roles = vec!["member".to_string()]; + let _ = engine.set_member_role(&invite.community_id, &local_peer_id, &roles); + } + } + } + let meta = engine.get_community_meta(&invite.community_id)?; let _ = state.storage.save_community_meta(&meta); @@ -199,6 +218,12 @@ pub async fn join_community( .unwrap_or_default(); drop(engine); + // mark this community for one-time role hardening on first sync merge + { + let mut guard = state.pending_join_role_guard.lock().await; + guard.insert(invite.community_id.clone()); + } + let node_handle = state.node_handle.lock().await; if let Some(ref handle) = *node_handle { // subscribe to the community presence topic @@ -318,6 +343,10 @@ pub async fn leave_community( // remove local cached community state so leave persists across restarts let mut engine = state.crdt_engine.lock().await; engine.remove_community(&community_id)?; + drop(engine); + + let mut guard = state.pending_join_role_guard.lock().await; + guard.remove(&community_id); Ok(()) } diff --git a/src-tauri/src/commands/identity.rs b/src-tauri/src/commands/identity.rs index f129e3c..e219748 100644 --- a/src-tauri/src/commands/identity.rs +++ b/src-tauri/src/commands/identity.rs @@ -397,6 +397,11 @@ pub async fn reset_identity(state: State<'_, AppState>) -> Result<(), String> { engine.clear(); } + { + let mut guard = state.pending_join_role_guard.lock().await; + guard.clear(); + } + // clear in-memory identity *identity = None; diff --git a/src-tauri/src/dev_server.rs b/src-tauri/src/dev_server.rs index 27b83ad..84bf43e 100644 --- a/src-tauri/src/dev_server.rs +++ b/src-tauri/src/dev_server.rs @@ -7,7 +7,7 @@ // // NEVER enable this in production builds. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::Arc; use std::time::{SystemTime, UNIX_EPOCH}; @@ -38,6 +38,7 @@ pub struct DevState { pub storage: Arc, pub node_handle: Arc>>, pub voice_channels: Arc>>>, + pub pending_join_role_guard: Arc>>, pub app_handle: tauri::AppHandle, } @@ -466,22 +467,40 @@ async fn join_community( let invite = crate::protocol::community::InviteCode::decode(&body.invite_code) .map_err(|e| ApiError(StatusCode::BAD_REQUEST, e))?; - let identity = state.identity.lock().await; - if identity.is_none() { - return Err(ApiError( - StatusCode::UNAUTHORIZED, - "no identity loaded".into(), - )); - } - drop(identity); + let local_peer_id = { + let identity = state.identity.lock().await; + let id = identity + .as_ref() + .ok_or_else(|| ApiError(StatusCode::UNAUTHORIZED, "no identity loaded".into()))?; + id.peer_id.to_string() + }; let mut engine = state.crdt_engine.lock().await; - if !engine.has_community(&invite.community_id) { + let had_existing_doc = engine.has_community(&invite.community_id); + if !had_existing_doc { engine .create_placeholder_community(&invite.community_id, &invite.community_name, "") .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, e))?; } + // joining via invite must never keep elevated local roles from stale local docs + if had_existing_doc { + if let Ok(members) = engine.get_members(&invite.community_id) { + let local_has_elevated_role = members.iter().any(|member| { + member.peer_id == local_peer_id + && member + .roles + .iter() + .any(|role| role == "owner" || role == "admin") + }); + + if local_has_elevated_role { + let roles = vec!["member".to_string()]; + let _ = engine.set_member_role(&invite.community_id, &local_peer_id, &roles); + } + } + } + let meta = engine .get_community_meta(&invite.community_id) .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, e))?; @@ -492,6 +511,12 @@ async fn join_community( .unwrap_or_default(); drop(engine); + // mark this community for one-time role hardening on first sync merge + { + let mut guard = state.pending_join_role_guard.lock().await; + guard.insert(invite.community_id.clone()); + } + // subscribe and discover via rendezvous let node_handle = state.node_handle.lock().await; if let Some(ref handle) = *node_handle { @@ -606,6 +631,10 @@ async fn leave_community( engine .remove_community(&community_id) .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, e))?; + drop(engine); + + let mut guard = state.pending_join_role_guard.lock().await; + guard.remove(&community_id); Ok(Json(serde_json::json!({ "ok": true }))) } @@ -1052,6 +1081,7 @@ async fn start_node(State(state): State) -> ApiResult>>, // tracks which peers are in which voice channels, keyed by "community_id:channel_id" pub voice_channels: Arc>>>, + // communities joined via invite that require initial role hardening + pub pending_join_role_guard: Arc>>, } impl AppState { @@ -44,6 +46,7 @@ impl AppState { storage, node_handle: Arc::new(Mutex::new(None)), voice_channels: Arc::new(Mutex::new(HashMap::new())), + pending_join_role_guard: Arc::new(Mutex::new(HashSet::new())), } } } @@ -89,6 +92,7 @@ pub fn run() { storage: std::sync::Arc::clone(&state.storage), node_handle: std::sync::Arc::clone(&state.node_handle), voice_channels: std::sync::Arc::clone(&state.voice_channels), + pending_join_role_guard: std::sync::Arc::clone(&state.pending_join_role_guard), app_handle: app.handle().clone(), }; tauri::async_runtime::spawn(dev_server::start(dev_state)); diff --git a/src-tauri/src/node/mod.rs b/src-tauri/src/node/mod.rs index 1b6fe51..e8d4324 100644 --- a/src-tauri/src/node/mod.rs +++ b/src-tauri/src/node/mod.rs @@ -245,6 +245,7 @@ pub async fn start( storage: Arc, app_handle: tauri::AppHandle, voice_channels: VoiceChannelMap, + pending_join_role_guard: Arc>>, custom_relay_addr: Option, ) -> Result { let mut swarm_instance = @@ -390,10 +391,61 @@ pub async fn start( } else { Vec::new() }; + let mut corrected_local_role = false; + let mut corrected_doc_bytes: Option> = None; + if merge_result.is_ok() { + let should_harden_join_role = { + let guard = pending_join_role_guard.lock().await; + guard.contains(&community_id) + }; + + if should_harden_join_role { + let local_peer_id = swarm_instance.local_peer_id().to_string(); + let local_has_elevated_role = engine + .get_members(&community_id) + .map(|members| { + members.iter().any(|member| { + member.peer_id == local_peer_id + && member.roles.iter().any(|role| role == "owner" || role == "admin") + }) + }) + .unwrap_or(false); + + if local_has_elevated_role { + let roles = vec!["member".to_string()]; + if engine.set_member_role(&community_id, &local_peer_id, &roles).is_ok() { + corrected_local_role = true; + corrected_doc_bytes = engine.get_doc_bytes(&community_id); + } + } + + let mut guard = pending_join_role_guard.lock().await; + guard.remove(&community_id); + } + } drop(engine); match merge_result { Ok(()) => { + if let Some(doc_bytes) = corrected_doc_bytes { + let corrected_snapshot = crate::crdt::sync::DocumentSnapshot { + community_id: community_id.clone(), + doc_bytes, + }; + let corrected_offer = crate::crdt::sync::SyncMessage::DocumentOffer(corrected_snapshot); + if let Ok(data) = serde_json::to_vec(&corrected_offer) { + let sync_topic = libp2p::gossipsub::IdentTopic::new(gossip::topic_for_sync()); + let _ = swarm_instance.behaviour_mut().gossipsub.publish(sync_topic, data); + } + } + + if corrected_local_role { + log::warn!( + "downgraded local elevated role to member during invite join sync for {}", + community_id + ); + } + // keep topic subscriptions aligned with merged channels let presence_topic = libp2p::gossipsub::IdentTopic::new( gossip::topic_for_presence(&community_id),