feat: real-time SPEAK permission enforcement in voice channels
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.
This commit is contained in:
@@ -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<void> {
|
||||
|
||||
// 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<void> {
|
||||
|
||||
// Notify all space members of the permission change
|
||||
broadcastOverrideChange(channel.spaceId, id);
|
||||
checkVoicePermissions(channel.spaceId);
|
||||
|
||||
return reply.code(200).send({ success: true });
|
||||
},
|
||||
|
||||
@@ -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<void> {
|
||||
|
||||
// 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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
for (const m of memberRows) {
|
||||
connectionManager.pushReadyPayload(m.userId);
|
||||
}
|
||||
checkVoicePermissions(id);
|
||||
|
||||
return reply.code(200).send({ success: true });
|
||||
});
|
||||
|
||||
@@ -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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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);
|
||||
|
||||
@@ -80,6 +80,8 @@ class ConnectionManager {
|
||||
// Server-muted/deafened users (moderator action)
|
||||
private serverMutedUsers: Set<string> = new Set(); // Stores spaceId:userId
|
||||
private serverDeafenedUsers: Set<string> = new Set(); // Stores spaceId:userId
|
||||
// Permission-muted users (SPEAK permission revoked while in voice)
|
||||
private permissionMutedUsers: Set<string> = 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<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }> {
|
||||
@@ -569,7 +582,7 @@ function buildReadyPayload(userId: string): {
|
||||
folders: SpaceFolder[];
|
||||
voiceStates: Record<string, string[]>;
|
||||
voiceUserStates: Record<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }>;
|
||||
serverVoiceStates: Record<string, { serverMuted: boolean; serverDeafened: boolean }>;
|
||||
serverVoiceStates: Record<string, { serverMuted: boolean; serverDeafened: boolean; permissionMuted: boolean }>;
|
||||
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<string, { serverMuted: boolean; serverDeafened: boolean }> = {};
|
||||
// Also includes ephemeral permission-mute state from in-memory Set
|
||||
const serverVoiceStates: Record<string, { serverMuted: boolean; serverDeafened: boolean; permissionMuted: boolean }> = {};
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user