feat: implement pending join role management for community invites
This commit is contained in:
parent
11a987e0de
commit
70377f13b8
|
|
@ -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?;
|
||||
|
|
|
|||
|
|
@ -177,19 +177,38 @@ pub async fn join_community(
|
|||
) -> Result<CommunityMeta, String> {
|
||||
let invite = crate::protocol::community::InviteCode::decode(&invite_code)?;
|
||||
|
||||
let local_peer_id = {
|
||||
let identity = state.identity.lock().await;
|
||||
if identity.is_none() {
|
||||
return Err("no identity loaded".to_string());
|
||||
}
|
||||
drop(identity);
|
||||
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(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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<DiskStorage>,
|
||||
pub node_handle: Arc<Mutex<Option<crate::node::NodeHandle>>>,
|
||||
pub voice_channels: Arc<Mutex<HashMap<String, Vec<VoiceParticipant>>>>,
|
||||
pub pending_join_role_guard: Arc<Mutex<HashSet<String>>>,
|
||||
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 local_peer_id = {
|
||||
let identity = state.identity.lock().await;
|
||||
if identity.is_none() {
|
||||
return Err(ApiError(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"no identity loaded".into(),
|
||||
));
|
||||
}
|
||||
drop(identity);
|
||||
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<DevState>) -> ApiResult<serde_json::Valu
|
|||
state.storage.clone(),
|
||||
state.app_handle.clone(),
|
||||
state.voice_channels.clone(),
|
||||
state.pending_join_role_guard.clone(),
|
||||
custom_relay,
|
||||
)
|
||||
.await
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ mod protocol;
|
|||
mod storage;
|
||||
mod verification;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
|
|
@ -24,6 +24,8 @@ pub struct AppState {
|
|||
pub node_handle: Arc<Mutex<Option<node::NodeHandle>>>,
|
||||
// tracks which peers are in which voice channels, keyed by "community_id:channel_id"
|
||||
pub voice_channels: Arc<Mutex<HashMap<String, Vec<VoiceParticipant>>>>,
|
||||
// communities joined via invite that require initial role hardening
|
||||
pub pending_join_role_guard: Arc<Mutex<HashSet<String>>>,
|
||||
}
|
||||
|
||||
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));
|
||||
|
|
|
|||
|
|
@ -245,6 +245,7 @@ pub async fn start(
|
|||
storage: Arc<crate::storage::DiskStorage>,
|
||||
app_handle: tauri::AppHandle,
|
||||
voice_channels: VoiceChannelMap,
|
||||
pending_join_role_guard: Arc<Mutex<HashSet<String>>>,
|
||||
custom_relay_addr: Option<String>,
|
||||
) -> Result<NodeHandle, String> {
|
||||
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<Vec<u8>> = 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),
|
||||
|
|
|
|||
Loading…
Reference in New Issue