fix: rearchitect server mute/deafen pipeline to scope restrictions by spaceId

- Replaces global `userId` tracking with `spaceId:userId` composite keys across both backend and frontend, fixing the issue where server-muting a user in one space bled into others.
- Modifies client-side `ready` event hydration to merge voice states per-origin instead of completely overwriting the store, preventing federated connections from wiping out home instance mutes.
- Excludes server voice restrictions from `zustand/persist` so stale client caches don't override the server's authority on reload.
- Fixes React component reactivity by using reactive store selections for `spaceId` instead of imperative `getState()` calls, ensuring UI lockdown indicators accurately reflect the initial websocket handshake.
This commit is contained in:
Jannis Braun
2026-03-09 19:44:37 +01:00
parent d09d956a9d
commit 6a12fe2024
10 changed files with 132 additions and 73 deletions
+22 -9
View File
@@ -437,19 +437,21 @@ function handleVoiceJoin(event: Record<string, unknown>, userId: string): void {
.all();
for (const r of restrictions) {
if (r.restrictionType === 'mute') {
connectionManager.setServerMuted(userId, true);
connectionManager.setServerMuted(spaceId, userId, true);
connectionManager.sendToUser(userId, {
type: 'voice_server_muted',
userId,
channelId,
spaceId,
muted: true,
});
} else if (r.restrictionType === 'deafen') {
connectionManager.setServerDeafened(userId, true);
connectionManager.setServerDeafened(spaceId, userId, true);
connectionManager.sendToUser(userId, {
type: 'voice_server_deafened',
userId,
channelId,
spaceId,
deafened: true,
});
}
@@ -518,19 +520,21 @@ function handleVoiceJoin(event: Record<string, unknown>, userId: string): void {
for (const r of restrictions) {
if (r.restrictionType === 'mute') {
connectionManager.setServerMuted(userId, true);
connectionManager.setServerMuted(spaceId, userId, true);
connectionManager.sendToSpace(spaceId, {
type: 'voice_server_muted',
userId,
channelId,
spaceId,
muted: true,
});
} else if (r.restrictionType === 'deafen') {
connectionManager.setServerDeafened(userId, true);
connectionManager.setServerDeafened(spaceId, userId, true);
connectionManager.sendToSpace(spaceId, {
type: 'voice_server_deafened',
userId,
channelId,
spaceId,
deafened: true,
});
}
@@ -543,7 +547,6 @@ function handleVoiceLeave(userId: string): void {
broadcastRoomLeave(left.roomId, left.room, userId);
}
connectionManager.clearVoiceUserStatus(userId);
connectionManager.clearServerVoiceState(userId);
}
function handleVoiceStatus(event: Record<string, unknown>, userId: string): void {
@@ -557,9 +560,17 @@ function handleVoiceStatus(event: Record<string, unknown>, userId: string): void
const userRoom = connectionManager.getUserRoom(userId);
if (!userRoom) return;
let isSpaceMuted = false;
let isSpaceDeafened = false;
if (userRoom.room.roomType === 'space') {
const meta = userRoom.room.metadata as SpaceRoomMeta;
isSpaceMuted = connectionManager.isServerMuted(meta.spaceId, userId);
isSpaceDeafened = connectionManager.isServerDeafened(meta.spaceId, userId);
}
// Server-side enforcement: prevent clients from bypassing server mute/deafen
const effectiveMuted = connectionManager.isServerMuted(userId) ? true : isMuted;
const effectiveDeafened = connectionManager.isServerDeafened(userId) ? true : isDeafened;
const effectiveMuted = isSpaceMuted ? true : isMuted;
const effectiveDeafened = isSpaceDeafened ? true : isDeafened;
connectionManager.setVoiceUserStatus(userId, effectiveMuted, effectiveDeafened, isCameraOn, isScreenSharing);
@@ -1149,7 +1160,7 @@ function handleVoiceServerMute(event: Record<string, unknown>, userId: string):
return;
}
connectionManager.setServerMuted(targetUserId, muted);
connectionManager.setServerMuted(meta.spaceId, targetUserId, muted);
// Persist to DB
const db = getDb();
@@ -1176,6 +1187,7 @@ function handleVoiceServerMute(event: Record<string, unknown>, userId: string):
type: 'voice_server_muted',
userId: targetUserId,
channelId: targetRoom.roomId,
spaceId: meta.spaceId,
muted,
});
}
@@ -1206,7 +1218,7 @@ function handleVoiceServerDeafen(event: Record<string, unknown>, userId: string)
return;
}
connectionManager.setServerDeafened(targetUserId, deafened);
connectionManager.setServerDeafened(meta.spaceId, targetUserId, deafened);
// Persist to DB
const db = getDb();
@@ -1232,6 +1244,7 @@ function handleVoiceServerDeafen(event: Record<string, unknown>, userId: string)
type: 'voice_server_deafened',
userId: targetUserId,
channelId: targetRoom.roomId,
spaceId: meta.spaceId,
deafened,
});
}
+25 -18
View File
@@ -78,8 +78,8 @@ class ConnectionManager {
// roomId → Timeout for ringing DM rooms (60s auto-cleanup)
private ringingTimeouts: Map<string, NodeJS.Timeout> = new Map();
// Server-muted/deafened users (moderator action)
private serverMutedUsers: Set<string> = new Set();
private serverDeafenedUsers: Set<string> = new Set();
private serverMutedUsers: Set<string> = new Set(); // Stores spaceId:userId
private serverDeafenedUsers: Set<string> = new Set(); // Stores spaceId:userId
addConnection(userId: string, ws: WebSocket): void {
if (!this.connections.has(userId)) {
@@ -304,7 +304,11 @@ class ConnectionManager {
room.participants.delete(userId);
this.userToRoom.delete(userId);
this.clearServerVoiceState(userId);
if (room.roomType === 'space') {
const meta = room.metadata as SpaceRoomMeta;
this.clearServerVoiceState(meta.spaceId, userId);
}
// Auto-cleanup empty space rooms (they're lazy-created)
if (room.participants.size === 0 && room.roomType === 'space') {
@@ -386,27 +390,29 @@ class ConnectionManager {
this.voiceUserStates.delete(userId);
}
setServerMuted(userId: string, muted: boolean): void {
if (muted) this.serverMutedUsers.add(userId);
else this.serverMutedUsers.delete(userId);
setServerMuted(spaceId: string, userId: string, muted: boolean): void {
const key = `${spaceId}:${userId}`;
if (muted) this.serverMutedUsers.add(key);
else this.serverMutedUsers.delete(key);
}
isServerMuted(userId: string): boolean {
return this.serverMutedUsers.has(userId);
isServerMuted(spaceId: string, userId: string): boolean {
return this.serverMutedUsers.has(`${spaceId}:${userId}`);
}
setServerDeafened(userId: string, deafened: boolean): void {
if (deafened) this.serverDeafenedUsers.add(userId);
else this.serverDeafenedUsers.delete(userId);
setServerDeafened(spaceId: string, userId: string, deafened: boolean): void {
const key = `${spaceId}:${userId}`;
if (deafened) this.serverDeafenedUsers.add(key);
else this.serverDeafenedUsers.delete(key);
}
isServerDeafened(userId: string): boolean {
return this.serverDeafenedUsers.has(userId);
isServerDeafened(spaceId: string, userId: string): boolean {
return this.serverDeafenedUsers.has(`${spaceId}:${userId}`);
}
clearServerVoiceState(userId: string): void {
this.serverMutedUsers.delete(userId);
this.serverDeafenedUsers.delete(userId);
clearServerVoiceState(spaceId: string, userId: string): void {
this.serverMutedUsers.delete(`${spaceId}:${userId}`);
this.serverDeafenedUsers.delete(`${spaceId}:${userId}`);
}
getAllVoiceUserStates(): Map<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }> {
@@ -883,10 +889,11 @@ function buildReadyPayload(userId: string): {
.where(inArray(schema.voiceRestrictions.spaceId, spaceIds))
.all();
for (const r of allRestrictions) {
const existing = serverVoiceStates[r.userId] ?? { serverMuted: false, serverDeafened: false };
const key = `${r.spaceId}:${r.userId}`;
const existing = serverVoiceStates[key] ?? { serverMuted: false, serverDeafened: false };
if (r.restrictionType === 'mute') existing.serverMuted = true;
if (r.restrictionType === 'deafen') existing.serverDeafened = true;
serverVoiceStates[r.userId] = existing;
serverVoiceStates[key] = existing;
}
}