From ce63c5ed36c474e9ea353291a940db779f5083bd Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Tue, 10 Mar 2026 14:50:01 +0100 Subject: [PATCH] feat: real-time SPEAK permission enforcement in voice channels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Permission changes now take effect immediately without requiring disconnect/reconnect. Modeled as "permission mute" parallel to server mute — server recomputes SPEAK for all voice participants on role/override changes and broadcasts state via WebSocket. Includes amber UI indicators and mic toggle blocking. --- packages/server/src/routes/channels.ts | 3 + packages/server/src/routes/spaces.ts | 5 ++ packages/server/src/ws/events.ts | 69 ++++++++++++++++++- packages/server/src/ws/handler.ts | 34 ++++++++- packages/shared/src/types.ts | 1 + .../src/components/layout/ChannelSidebar.tsx | 24 ++++--- .../src/components/layout/SpaceSidebar.tsx | 3 +- .../web/src/components/voice/VoiceChannel.tsx | 8 ++- .../web/src/components/voice/VoiceUser.tsx | 6 +- packages/web/src/hooks/useLiveKit.ts | 9 +-- packages/web/src/hooks/useWebSocket.ts | 18 ++++- packages/web/src/stores/uiStore.ts | 4 ++ packages/web/src/stores/voiceStore.ts | 17 ++++- 13 files changed, 173 insertions(+), 28 deletions(-) diff --git a/packages/server/src/routes/channels.ts b/packages/server/src/routes/channels.ts index 218208f6..920b5aa0 100644 --- a/packages/server/src/routes/channels.ts +++ b/packages/server/src/routes/channels.ts @@ -6,6 +6,7 @@ import { generateSnowflake } from '../utils/snowflake.js'; import { isMember, hasPermission, getChannelSpaceId, PermissionBits, computePermissions } from '../utils/permissions.js'; import { permissionsToString } from '@backspace/shared/src/permissions.js'; import { connectionManager } from '../ws/handler.js'; +import { checkVoicePermissions } from '../ws/events.js'; import type { CreateChannelRequest, UpdateChannelRequest, @@ -356,6 +357,7 @@ export async function channelRoutes(app: FastifyInstance): Promise { // Notify all space members of the permission change broadcastOverrideChange(channel.spaceId, id); + checkVoicePermissions(channel.spaceId); return reply.code(200).send({ success: true }); }); @@ -387,6 +389,7 @@ export async function channelRoutes(app: FastifyInstance): Promise { // Notify all space members of the permission change broadcastOverrideChange(channel.spaceId, id); + checkVoicePermissions(channel.spaceId); return reply.code(200).send({ success: true }); }, diff --git a/packages/server/src/routes/spaces.ts b/packages/server/src/routes/spaces.ts index 8dd3de40..1d943ffc 100644 --- a/packages/server/src/routes/spaces.ts +++ b/packages/server/src/routes/spaces.ts @@ -19,6 +19,7 @@ import type { Role, } from '@backspace/shared'; import { sanitizeUser } from '../utils/sanitize.js'; +import { checkVoicePermissions } from '../ws/events.js'; function rowToSpace(row: typeof schema.spaces.$inferSelect): Space { return { @@ -662,6 +663,7 @@ export async function spaceRoutes(app: FastifyInstance): Promise { // Force target user's client to re-sync with their new permissions connectionManager.pushReadyPayload(uid); + checkVoicePermissions(id); // Build response with populated roles const updatedMember = db.select() @@ -829,6 +831,7 @@ export async function spaceRoutes(app: FastifyInstance): Promise { for (const m of memberRows) { connectionManager.pushReadyPayload(m.userId); } + checkVoicePermissions(id); return reply.code(201).send(role); }); @@ -871,6 +874,7 @@ export async function spaceRoutes(app: FastifyInstance): Promise { for (const m of memberRows) { connectionManager.pushReadyPayload(m.userId); } + checkVoicePermissions(id); return reply.code(200).send(updated); }); @@ -903,6 +907,7 @@ export async function spaceRoutes(app: FastifyInstance): Promise { for (const m of memberRows) { connectionManager.pushReadyPayload(m.userId); } + checkVoicePermissions(id); return reply.code(200).send({ success: true }); }); diff --git a/packages/server/src/ws/events.ts b/packages/server/src/ws/events.ts index 2c4d783b..3b6324ac 100644 --- a/packages/server/src/ws/events.ts +++ b/packages/server/src/ws/events.ts @@ -3,11 +3,41 @@ import { getDb, schema } from '../db/index.js'; import { generateSnowflake } from '../utils/snowflake.js'; import { connectionManager } from './handler.js'; import type { VoiceRoom, DmRoomMeta, SpaceRoomMeta } from './handler.js'; -import { isMember, getChannelSpaceId, isDmMember, hasPermission, PermissionBits } from '../utils/permissions.js'; +import { isMember, getChannelSpaceId, isDmMember, hasPermission, computePermissions, PermissionBits } from '../utils/permissions.js'; import { broadcastDmMessage, getDmMessageWithUser } from '../routes/dm.js'; import type { MessageWithUser, Attachment, DmMessageWithUser } from '@backspace/shared'; import { sanitizeUser } from '../utils/sanitize.js'; +/** + * Re-evaluate SPEAK permission for all participants in voice channels + * belonging to the given space. On transition, updates the in-memory + * permissionMutedUsers Set and broadcasts voice_permission_muted events. + */ +export function checkVoicePermissions(spaceId: string): void { + for (const [roomId, room] of connectionManager.getAllRooms()) { + if (room.roomType !== 'space') continue; + const meta = room.metadata as SpaceRoomMeta; + if (meta.spaceId !== spaceId) continue; + + for (const userId of room.participants) { + const perms = computePermissions(userId, spaceId, roomId); + const canSpeak = (perms & PermissionBits.SPEAK) !== 0n || (perms & PermissionBits.ADMINISTRATOR) !== 0n; + const wasMuted = connectionManager.isPermissionMuted(spaceId, userId); + const shouldMute = !canSpeak; + + if (shouldMute !== wasMuted) { + connectionManager.setPermissionMuted(spaceId, userId, shouldMute); + connectionManager.sendToSpace(spaceId, { + type: 'voice_permission_muted', + userId, + spaceId, + muted: shouldMute, + }); + } + } + } +} + function getMessageWithUser(messageId: string): MessageWithUser | null { const db = getDb(); const message = db.select().from(schema.messages).where(eq(schema.messages.id, messageId)).get(); @@ -459,6 +489,22 @@ function handleVoiceJoin(event: Record, userId: string): void { }); } } + + // Re-check permission mute on re-registration + { + const perms = computePermissions(userId, spaceId, channelId); + const canSpeak = (perms & PermissionBits.SPEAK) !== 0n || (perms & PermissionBits.ADMINISTRATOR) !== 0n; + const shouldPermMute = !canSpeak; + connectionManager.setPermissionMuted(spaceId, userId, shouldPermMute); + if (shouldPermMute) { + connectionManager.sendToUser(userId, { + type: 'voice_permission_muted', + userId, + spaceId, + muted: true, + }); + } + } return; } @@ -542,6 +588,21 @@ function handleVoiceJoin(event: Record, userId: string): void { }); } } + + // Check SPEAK permission and apply permission mute if needed + { + const perms = computePermissions(userId, spaceId, channelId); + const canSpeak = (perms & PermissionBits.SPEAK) !== 0n || (perms & PermissionBits.ADMINISTRATOR) !== 0n; + if (!canSpeak) { + connectionManager.setPermissionMuted(spaceId, userId, true); + connectionManager.sendToSpace(spaceId, { + type: 'voice_permission_muted', + userId, + spaceId, + muted: true, + }); + } + } } function handleVoiceLeave(userId: string): void { @@ -565,14 +626,16 @@ function handleVoiceStatus(event: Record, userId: string): void let isSpaceMuted = false; let isSpaceDeafened = false; + let isPermMuted = 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); + isPermMuted = connectionManager.isPermissionMuted(meta.spaceId, userId); } - // Server-side enforcement: prevent clients from bypassing server mute/deafen - const effectiveMuted = isSpaceMuted ? true : isMuted; + // Server-side enforcement: prevent clients from bypassing server mute/deafen/permission mute + const effectiveMuted = (isSpaceMuted || isPermMuted) ? true : isMuted; const effectiveDeafened = isSpaceDeafened ? true : isDeafened; connectionManager.setVoiceUserStatus(userId, effectiveMuted, effectiveDeafened, isCameraOn, isScreenSharing); diff --git a/packages/server/src/ws/handler.ts b/packages/server/src/ws/handler.ts index ab53019b..b0923b16 100644 --- a/packages/server/src/ws/handler.ts +++ b/packages/server/src/ws/handler.ts @@ -80,6 +80,8 @@ class ConnectionManager { // Server-muted/deafened users (moderator action) private serverMutedUsers: Set = new Set(); // Stores spaceId:userId private serverDeafenedUsers: Set = new Set(); // Stores spaceId:userId + // Permission-muted users (SPEAK permission revoked while in voice) + private permissionMutedUsers: Set = new Set(); // Stores spaceId:userId addConnection(userId: string, ws: WebSocket): void { if (!this.connections.has(userId)) { @@ -413,6 +415,17 @@ class ConnectionManager { clearServerVoiceState(spaceId: string, userId: string): void { this.serverMutedUsers.delete(`${spaceId}:${userId}`); this.serverDeafenedUsers.delete(`${spaceId}:${userId}`); + this.permissionMutedUsers.delete(`${spaceId}:${userId}`); + } + + setPermissionMuted(spaceId: string, userId: string, muted: boolean): void { + const key = `${spaceId}:${userId}`; + if (muted) this.permissionMutedUsers.add(key); + else this.permissionMutedUsers.delete(key); + } + + isPermissionMuted(spaceId: string, userId: string): boolean { + return this.permissionMutedUsers.has(`${spaceId}:${userId}`); } getAllVoiceUserStates(): Map { @@ -569,7 +582,7 @@ function buildReadyPayload(userId: string): { folders: SpaceFolder[]; voiceStates: Record; voiceUserStates: Record; - serverVoiceStates: Record; + serverVoiceStates: Record; readStates: ReadState[]; activeCalls: ActiveCallInfo[]; } { @@ -882,7 +895,8 @@ function buildReadyPayload(userId: string): { } // Build server mute/deafen states from DB (authoritative source for all spaces the user belongs to) - const serverVoiceStates: Record = {}; + // Also includes ephemeral permission-mute state from in-memory Set + const serverVoiceStates: Record = {}; if (spaceIds.length > 0) { const allRestrictions = db.select() .from(schema.voiceRestrictions) @@ -890,11 +904,25 @@ function buildReadyPayload(userId: string): { .all(); for (const r of allRestrictions) { const key = `${r.spaceId}:${r.userId}`; - const existing = serverVoiceStates[key] ?? { serverMuted: false, serverDeafened: false }; + const existing = serverVoiceStates[key] ?? { serverMuted: false, serverDeafened: false, permissionMuted: false }; if (r.restrictionType === 'mute') existing.serverMuted = true; if (r.restrictionType === 'deafen') existing.serverDeafened = true; serverVoiceStates[key] = existing; } + // Include ephemeral permission-mute state for all voice participants in user's spaces + for (const [roomId, room] of connectionManager.getAllRooms()) { + if (room.roomType !== 'space') continue; + const meta = room.metadata as SpaceRoomMeta; + if (!spaceIds.includes(meta.spaceId)) continue; + for (const participantId of room.participants) { + if (connectionManager.isPermissionMuted(meta.spaceId, participantId)) { + const key = `${meta.spaceId}:${participantId}`; + const existing = serverVoiceStates[key] ?? { serverMuted: false, serverDeafened: false, permissionMuted: false }; + existing.permissionMuted = true; + serverVoiceStates[key] = existing; + } + } + } } // Fetch read states for unread tracking diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 96465387..4ac0e0c8 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -282,6 +282,7 @@ export type ServerEvent = | { type: 'join_request_declined'; request: JoinRequest } | { type: 'voice_server_muted'; userId: string; channelId: string; spaceId: string; muted: boolean } | { type: 'voice_server_deafened'; userId: string; channelId: string; spaceId: string; deafened: boolean } + | { type: 'voice_permission_muted'; userId: string; spaceId: string; muted: boolean } | { type: 'voice_moved'; userId: string; oldChannelId: string; newChannelId: string } | { type: 'voice_disconnected'; userId: string; channelId: string } | { type: 'member_banned'; spaceId: string; reason: string | null } diff --git a/packages/web/src/components/layout/ChannelSidebar.tsx b/packages/web/src/components/layout/ChannelSidebar.tsx index 786e5ecc..34d05dc3 100644 --- a/packages/web/src/components/layout/ChannelSidebar.tsx +++ b/packages/web/src/components/layout/ChannelSidebar.tsx @@ -37,27 +37,30 @@ export function ChannelSidebar() { const myOriginId = useSpaceStore((s) => currentVoiceChannelId ? getMyUserIdForOrigin(getChannelOrigin(currentVoiceChannelId)) : s.members.find(m => m.userId === user?.id)?.userId ?? user?.id); const serverMutedUserIds = useVoiceStore((s) => s.serverMutedUserIds); const serverDeafenedUserIds = useVoiceStore((s) => s.serverDeafenedUserIds); + const permissionMutedUserIds = useVoiceStore((s) => s.permissionMutedUserIds); const isServerMuted = !!(myOriginId && spaceId && serverMutedUserIds.has(`${spaceId}:${myOriginId}`)); const isServerDeafened = !!(myOriginId && spaceId && serverDeafenedUserIds.has(`${spaceId}:${myOriginId}`)); + const isPermissionMuted = !!(myOriginId && spaceId && permissionMutedUserIds.has(`${spaceId}:${myOriginId}`)); const navigate = useNavigate(); const location = useLocation(); const floatingPanelRef = useRef(null); - const [panelHeight, setPanelHeight] = useState(140); + const floatingPanelHeight = useUIStore((s) => s.floatingPanelHeight); + const setFloatingPanelHeight = useUIStore((s) => s.setFloatingPanelHeight); useEffect(() => { const el = floatingPanelRef.current; if (!el) return; const ro = new ResizeObserver((entries) => { const entry = entries[0]; - if (entry) setPanelHeight(entry.contentRect.height); + if (entry) setFloatingPanelHeight(entry.contentRect.height); }); ro.observe(el); return () => ro.disconnect(); - }, []); + }, [setFloatingPanelHeight]); const handleMicToggle = async () => { - if (isServerMuted || isServerDeafened) return; + if (isServerMuted || isServerDeafened || isPermissionMuted) return; const wasDeafened = useVoiceStore.getState().isDeafened; toggleMic(); broadcastVoiceStatus(); @@ -128,6 +131,7 @@ export function ChannelSidebar() { isDeafened={isDeafened} isServerMuted={isServerMuted} isServerDeafened={isServerDeafened} + isPermissionMuted={isPermissionMuted} onMicToggle={handleMicToggle} onDeafenToggle={handleDeafenToggle} onSettingsClick={(tab) => openModal('userSettings', tab ? { tab } : {})} @@ -145,7 +149,7 @@ export function ChannelSidebar() { Find or start a conversation -
+
{/* Channels */} -
+
{/* Text Channels */}
@@ -457,6 +461,7 @@ function UserAreaPanel({ isDeafened, isServerMuted, isServerDeafened, + isPermissionMuted, onMicToggle, onDeafenToggle, onSettingsClick, @@ -466,6 +471,7 @@ function UserAreaPanel({ isDeafened: boolean; isServerMuted: boolean; isServerDeafened: boolean; + isPermissionMuted: boolean; onMicToggle: () => void; onDeafenToggle: () => void; onSettingsClick: (tab?: string) => void; @@ -780,15 +786,15 @@ function UserAreaPanel({ {/* Input chevron */} diff --git a/packages/web/src/components/layout/SpaceSidebar.tsx b/packages/web/src/components/layout/SpaceSidebar.tsx index ad7be883..6016d8e0 100644 --- a/packages/web/src/components/layout/SpaceSidebar.tsx +++ b/packages/web/src/components/layout/SpaceSidebar.tsx @@ -170,6 +170,7 @@ export function SpaceSidebar() { const setShowDms = useUIStore((s) => s.setShowDms); const openModal = useUIStore((s) => s.openModal); const addToast = useUIStore((s) => s.addToast); + const floatingPanelHeight = useUIStore((s) => s.floatingPanelHeight); const setCurrentChannel = useChatStore((s) => s.setCurrentChannel); const unreadChannels = useChatStore((s) => s.unreadChannels); const instances = useInstanceStore((s) => s.instances); @@ -246,7 +247,7 @@ export function SpaceSidebar() { }; return ( -