feat: add voice disconnect permission and fix federation identity

- Add DISCONNECT_MEMBERS permission (bit 27) to disconnect users from voice
- Implement voice_disconnect WebSocket handler with permission checks
- Add disconnect button to voice user context menu
- Grant instance admins full permissions across all spaces
- Fix voice_disconnected handler to use federation-aware identity resolution
- Update CLAUDE.md with new event types and permission docs
This commit is contained in:
Jannis Braun
2026-03-10 02:13:20 +01:00
parent 3cef9cef32
commit e8fc40ab34
8 changed files with 92 additions and 2 deletions
+3
View File
@@ -531,6 +531,7 @@ All WebSocket messages are JSON over `/ws`. Client authenticates by sending `{ t
{ type: 'voice_join', channelId }
{ type: 'voice_leave' }
{ type: 'voice_status', isMuted?, isDeafened?, isCameraOn?, isScreenSharing? }
{ type: 'voice_disconnect', userId }
# DM Calls
{ type: 'dm_call_start', dmChannelId }
@@ -576,6 +577,7 @@ All WebSocket messages are JSON over `/ws`. Client authenticates by sending `{ t
# Voice
{ type: 'voice_state_update', channelId, userId, action: 'join' | 'leave' }
{ type: 'voice_status_update', userId, isMuted, isDeafened, isCameraOn, isScreenSharing }
{ type: 'voice_disconnected', userId, channelId }
# DM Calls
{ type: 'dm_call_incoming', dmChannelId, callerId, callerName }
@@ -615,6 +617,7 @@ Bitwise permission engine defined in `packages/shared/src/permissions.ts`. Store
| 24 | MOVE_MEMBERS | Move members between voice channels |
| 25 | USE_VOICE_ACTIVITY | Use voice activity detection |
| 26 | STREAM | Share screen in voice channels |
| 27 | DISCONNECT_MEMBERS | Disconnect members from voice channels |
**Resolution order:** Owner → @everyone role → Assigned roles (OR'd) → ADMINISTRATOR shortcut → Channel overrides (@everyone → role overrides → member override).
+4
View File
@@ -32,6 +32,10 @@ export function computePermissions(userId: string, spaceId: string, channelId?:
if (!space) return 0n;
if (space.ownerId === userId) return ALL_PERMISSIONS;
// 1b. Instance admin — full access across all spaces
const userRow = db.select().from(schema.users).where(eq(schema.users.id, userId)).get();
if (userRow?.isAdmin === 1) return ALL_PERMISSIONS;
// 2. Base permissions from @everyone role (id === spaceId)
const everyoneRole = db.select().from(schema.roles)
.where(and(eq(schema.roles.id, spaceId), eq(schema.roles.spaceId, spaceId)))
+53
View File
@@ -159,6 +159,9 @@ export function handleClientEvent(
case 'voice_move':
handleVoiceMove(event, userId);
break;
case 'voice_disconnect':
handleVoiceDisconnect(event, userId);
break;
default:
connectionManager.sendToUser(userId, {
type: 'error',
@@ -1338,3 +1341,53 @@ function handleVoiceMove(event: Record<string, unknown>, userId: string): void {
});
}
}
function handleVoiceDisconnect(event: Record<string, unknown>, userId: string): void {
const targetUserId = event.userId as string;
if (!targetUserId || typeof targetUserId !== 'string') {
connectionManager.sendToUser(userId, { type: 'error', message: 'userId is required' });
return;
}
if (targetUserId === userId) {
connectionManager.sendToUser(userId, { type: 'error', message: 'Cannot disconnect yourself' });
return;
}
// Find the target user's current room
const currentRoom = connectionManager.getUserRoom(targetUserId);
if (!currentRoom || currentRoom.room.roomType !== 'space') {
connectionManager.sendToUser(userId, { type: 'error', message: 'Target user is not in a voice channel' });
return;
}
const meta = currentRoom.room.metadata as SpaceRoomMeta;
if (!hasPermission(userId, meta.spaceId, PermissionBits.DISCONNECT_MEMBERS, currentRoom.roomId)) {
connectionManager.sendToUser(userId, { type: 'error', message: 'Missing DISCONNECT_MEMBERS permission' });
return;
}
const channelId = currentRoom.roomId;
// Remove from voice room
connectionManager.leaveRoom(channelId, targetUserId);
// Clear ephemeral voice status (mute/camera/etc)
connectionManager.clearVoiceUserStatus(targetUserId);
// Broadcast leave to all space members
connectionManager.sendToSpace(meta.spaceId, {
type: 'voice_state_update',
channelId,
userId: targetUserId,
action: 'leave',
});
// Notify the disconnected user so they clean up client-side
connectionManager.sendToUser(targetUserId, {
type: 'voice_disconnected',
userId: targetUserId,
channelId,
});
}
+1
View File
@@ -23,6 +23,7 @@ export const PermissionBits = {
MOVE_MEMBERS: 1n << 24n,
USE_VOICE_ACTIVITY: 1n << 25n,
STREAM: 1n << 26n,
DISCONNECT_MEMBERS: 1n << 27n,
} as const;
export type PermissionBit = (typeof PermissionBits)[keyof typeof PermissionBits];
+2
View File
@@ -240,6 +240,7 @@ export type ClientEvent =
| { type: 'voice_server_mute'; userId: string; muted: boolean }
| { type: 'voice_server_deafen'; userId: string; deafened: boolean }
| { type: 'voice_move'; userId: string; targetChannelId: string }
| { type: 'voice_disconnect'; userId: string }
| { type: 'ping' };
// Server → Client Events
@@ -282,6 +283,7 @@ export type ServerEvent =
| { 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_moved'; userId: string; oldChannelId: string; newChannelId: string }
| { type: 'voice_disconnected'; userId: string; channelId: string }
| { type: 'member_banned'; spaceId: string; reason: string | null }
| { type: 'pong' }
| { type: 'error'; message: string };
@@ -43,6 +43,7 @@ const PERMISSION_GROUPS: { name: string; perms: PermDef[] }[] = [
{ bit: PermissionBits.MUTE_MEMBERS, label: 'Mute Members' },
{ bit: PermissionBits.DEAFEN_MEMBERS, label: 'Deafen Members' },
{ bit: PermissionBits.MOVE_MEMBERS, label: 'Move Members' },
{ bit: PermissionBits.DISCONNECT_MEMBERS, label: 'Disconnect Members' },
{ bit: PermissionBits.USE_VOICE_ACTIVITY, label: 'Voice Activity' },
{ bit: PermissionBits.STREAM, label: 'Stream' },
],
@@ -28,6 +28,7 @@ export function VoiceModMenuItems({ targetUserId, channelId, onAction }: VoiceMo
const canMuteMembers = hasPermissionBit(myPerms, PermissionBits.MUTE_MEMBERS);
const canDeafenMembers = hasPermissionBit(myPerms, PermissionBits.DEAFEN_MEMBERS);
const canMoveMembers = hasPermissionBit(myPerms, PermissionBits.MOVE_MEMBERS);
const canDisconnectMembers = hasPermissionBit(myPerms, PermissionBits.DISCONNECT_MEMBERS);
const otherVoiceChannels = channels.filter(
(c) => (c.type === 'voice' || c.type === 'video') && c.id !== channelId,
@@ -39,7 +40,7 @@ export function VoiceModMenuItems({ targetUserId, channelId, onAction }: VoiceMo
const isServerMuted = serverMutedUserIds.has(`${spaceId}:${targetUserId}`);
const isServerDeafened = serverDeafenedUserIds.has(`${spaceId}:${targetUserId}`);
if (!canMuteMembers && !canDeafenMembers && !canMoveMembers) return null;
if (!canMuteMembers && !canDeafenMembers && !canMoveMembers && !canDisconnectMembers) return null;
const handleServerMute = () => {
wsSend({ type: 'voice_server_mute', userId: targetUserId, muted: !isServerMuted }, voiceOrigin);
@@ -56,6 +57,11 @@ export function VoiceModMenuItems({ targetUserId, channelId, onAction }: VoiceMo
onAction();
};
const handleDisconnect = () => {
wsSend({ type: 'voice_disconnect', userId: targetUserId }, voiceOrigin);
onAction();
};
const btnClass = 'w-full text-left px-2 py-1.5 mx-1.5 text-sm rounded-sm flex items-center gap-2 text-txt-secondary hover:bg-accent-primary hover:text-white';
const btnStyle = { width: 'calc(100% - 12px)' };
@@ -84,6 +90,17 @@ export function VoiceModMenuItems({ targetUserId, channelId, onAction }: VoiceMo
{isServerDeafened ? 'Server Undeafen' : 'Server Deafen'}
</button>
)}
{canDisconnectMembers && (
<>
<div className="h-px bg-white/[0.06] my-1 mx-1.5" />
<button onClick={handleDisconnect} className={`${btnClass} text-red-400 hover:text-white`} style={btnStyle}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" className="flex-shrink-0">
<path d="M12 9c-1.6 0-3.15.25-4.6.72v3.1c0 .39-.23.74-.56.9-.98.49-1.87 1.12-2.66 1.85-.18.18-.43.28-.7.28-.28 0-.53-.11-.71-.29L.29 13.08a.956.956 0 010-1.36C3.36 8.68 7.42 7 12 7s8.64 1.68 11.71 4.72c.18.18.29.44.29.71 0 .28-.11.53-.29.71l-2.48 2.48c-.18.18-.43.29-.71.29-.27 0-.52-.11-.7-.28a11.27 11.27 0 00-2.67-1.85.996.996 0 01-.56-.9v-3.1C15.15 9.25 13.6 9 12 9z" />
</svg>
Disconnect
</button>
</>
)}
{canMoveMembers && otherVoiceChannels.length > 0 && (
<MoveToSubmenu channels={otherVoiceChannels} onMove={handleMove} btnClass={btnClass} btnStyle={btnStyle} />
)}
@@ -224,7 +241,8 @@ export function VoiceUserContextMenu({ targetUserId, channelId, position, onClos
const canMuteMembers = hasPermissionBit(myPerms, PermissionBits.MUTE_MEMBERS);
const canDeafenMembers = hasPermissionBit(myPerms, PermissionBits.DEAFEN_MEMBERS);
const canMoveMembers = hasPermissionBit(myPerms, PermissionBits.MOVE_MEMBERS);
const hasModPerms = canMuteMembers || canDeafenMembers || canMoveMembers;
const canDisconnectMembers = hasPermissionBit(myPerms, PermissionBits.DISCONNECT_MEMBERS);
const hasModPerms = canMuteMembers || canDeafenMembers || canMoveMembers || canDisconnectMembers;
const participantVolumes = useVoiceStore((s) => s.participantVolumes);
const setParticipantVolume = useVoiceStore((s) => s.setParticipantVolume);
+8
View File
@@ -345,6 +345,14 @@ function handleEvent(origin: string, event: ServerEvent): void {
break;
}
case 'voice_disconnected': {
const myDisconnectId = isHome ? useAuthStore.getState().user?.id : getMyUserIdForOrigin(origin);
if (event.userId === myDisconnectId) {
useVoiceStore.getState().handleForceDisconnect();
}
break;
}
case 'member_joined':
if (!isHome) normalizeUserAssets(event.member.user, origin);
addMember(event.member);