feat: bans system, voice moderation, and federated space settings fixes

Add ban/unban functionality with BansPanel in space settings, voice
moderation context menu (mute/deafen/disconnect), and fix federated
space settings panels to use origin-aware API client. Show domain
indicators for federated members in MembersPanel.
This commit is contained in:
Jannis Braun
2026-03-09 15:56:46 +01:00
parent 7e2986ca01
commit e2c18ad2b0
24 changed files with 1224 additions and 159 deletions
+12
View File
@@ -116,6 +116,18 @@ export function runMigrations(db: Database.Database): void {
);
`);
// Ensure bans table exists (idempotent)
db.exec(`
CREATE TABLE IF NOT EXISTS bans (
space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
reason TEXT,
banned_by TEXT NOT NULL REFERENCES users(id),
created_at INTEGER NOT NULL,
PRIMARY KEY (space_id, user_id)
);
`);
// Ensure join_requests table exists (idempotent)
db.exec(`
CREATE TABLE IF NOT EXISTS join_requests (
+10
View File
@@ -197,6 +197,16 @@ export const instanceSettings = sqliteTable('instance_settings', {
updatedAt: integer('updated_at').notNull(),
});
export const bans = sqliteTable('bans', {
spaceId: text('space_id').notNull().references(() => spaces.id, { onDelete: 'cascade' }),
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
reason: text('reason'),
bannedBy: text('banned_by').notNull().references(() => users.id),
createdAt: integer('created_at').notNull(),
}, (table) => ({
pk: primaryKey({ columns: [table.spaceId, table.userId] }),
}));
export const joinRequests = sqliteTable('join_requests', {
id: text('id').primaryKey(),
spaceId: text('space_id').notNull().references(() => spaces.id, { onDelete: 'cascade' }),
+22 -3
View File
@@ -1,8 +1,8 @@
import type { FastifyInstance } from 'fastify';
import { AccessToken } from 'livekit-server-sdk';
import { AccessToken, TrackSource } from 'livekit-server-sdk';
import { authenticate } from '../utils/auth.js';
import { config } from '../config.js';
import { getChannelSpaceId, hasPermission, isDmMember, PermissionBits } from '../utils/permissions.js';
import { getChannelSpaceId, hasPermission, computePermissions, isDmMember, PermissionBits } from '../utils/permissions.js';
import type { LiveKitTokenRequest, LiveKitTokenResponse } from '@backspace/shared';
export async function livekitRoutes(app: FastifyInstance): Promise<void> {
@@ -18,6 +18,10 @@ export async function livekitRoutes(app: FastifyInstance): Promise<void> {
// Determine room name based on channel type
let roomName: string;
// Default: full publish (DM calls always get full permissions)
let canSpeak = true;
let canStream = true;
if (dmChannelId && typeof dmChannelId === 'string') {
// DM call token
if (!isDmMember(dmChannelId, request.userId)) {
@@ -33,6 +37,10 @@ export async function livekitRoutes(app: FastifyInstance): Promise<void> {
if (!hasPermission(request.userId, spaceId, PermissionBits.CONNECT, channelId)) {
return reply.code(403).send({ error: 'Missing CONNECT permission', statusCode: 403 });
}
// Check SPEAK and STREAM permissions for granular token grants
const perms = computePermissions(request.userId, spaceId, channelId);
canSpeak = (perms & PermissionBits.SPEAK) !== 0n || (perms & PermissionBits.ADMINISTRATOR) !== 0n;
canStream = (perms & PermissionBits.STREAM) !== 0n || (perms & PermissionBits.ADMINISTRATOR) !== 0n;
roomName = channelId;
} else {
return reply.code(400).send({ error: 'channelId or dmChannelId is required', statusCode: 400 });
@@ -45,10 +53,21 @@ export async function livekitRoutes(app: FastifyInstance): Promise<void> {
ttl: '1h',
});
// Build canPublishSources based on permissions
const canPublishSources: TrackSource[] = [];
if (canSpeak) {
canPublishSources.push(TrackSource.MICROPHONE);
canPublishSources.push(TrackSource.CAMERA);
}
if (canStream) {
canPublishSources.push(TrackSource.SCREEN_SHARE, TrackSource.SCREEN_SHARE_AUDIO);
}
token.addGrant({
room: roomName,
roomJoin: true,
canPublish: true,
canPublish: canSpeak || canStream,
canPublishSources,
canSubscribe: true,
canPublishData: true,
});
+5
View File
@@ -260,6 +260,11 @@ export async function messageRoutes(app: FastifyInstance): Promise<void> {
return reply.code(403).send({ error: 'Missing SEND_MESSAGES permission', statusCode: 403 });
}
if (attachmentIds && attachmentIds.length > 0 &&
!hasPermission(request.userId, spaceId, PermissionBits.ATTACH_FILES, id)) {
return reply.code(403).send({ error: 'Missing ATTACH_FILES permission', statusCode: 403 });
}
if ((!content || typeof content !== 'string' || content.trim().length === 0) &&
(!attachmentIds || attachmentIds.length === 0)) {
return reply.code(400).send({ error: 'Message must have content or attachments', statusCode: 400 });
+149 -1
View File
@@ -3,7 +3,7 @@ import { eq, and, inArray } from 'drizzle-orm';
import { getDb, schema } from '../db/index.js';
import { authenticate } from '../utils/auth.js';
import { generateSnowflake } from '../utils/snowflake.js';
import { isMember, isSpaceOwner, hasPermission, computePermissions, PermissionBits } from '../utils/permissions.js';
import { isMember, isSpaceOwner, isBanned, hasPermission, computePermissions, PermissionBits } from '../utils/permissions.js';
import { DEFAULT_EVERYONE_PERMISSIONS, ALL_PERMISSIONS, permissionsToString } from '@backspace/shared/src/permissions.js';
import crypto from 'crypto';
import { connectionManager } from '../ws/handler.js';
@@ -411,6 +411,10 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
return reply.code(400).send({ error: 'Invalid invite code', statusCode: 400 });
}
if (isBanned(id, request.userId)) {
return reply.code(403).send({ error: 'You are banned from this space', statusCode: 403 });
}
if (isMember(id, request.userId)) {
return reply.code(409).send({ error: 'You are already a member of this space', statusCode: 409 });
}
@@ -463,6 +467,10 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
return reply.code(404).send({ error: 'Invalid invite code', statusCode: 404 });
}
if (isBanned(server.id, request.userId)) {
return reply.code(403).send({ error: 'You are banned from this space', statusCode: 403 });
}
if (isMember(server.id, request.userId)) {
return reply.code(409).send({ error: 'You are already a member of this space', statusCode: 409 });
}
@@ -931,4 +939,144 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
return reply.code(200).send({ success: true });
});
// ─── Ban Management ───────────────────────────────────────────────────────
// GET /api/spaces/:id/bans - List bans
app.get<{ Params: { id: string } }>('/api/spaces/:id/bans', {
preHandler: authenticate,
}, async (request, reply) => {
const { id } = request.params;
const db = getDb();
if (!hasPermission(request.userId, id, PermissionBits.BAN_MEMBERS)) {
return reply.code(403).send({ error: 'Missing BAN_MEMBERS permission', statusCode: 403 });
}
const banRows = db.select().from(schema.bans)
.where(eq(schema.bans.spaceId, id))
.all();
if (banRows.length === 0) return reply.code(200).send([]);
const userIds = [...new Set(banRows.map(b => b.userId))];
const bannedByIds = [...new Set(banRows.map(b => b.bannedBy))];
const allUserIds = [...new Set([...userIds, ...bannedByIds])];
const users = db.select().from(schema.users)
.where(inArray(schema.users.id, allUserIds))
.all();
const userMap = new Map(users.map(u => [u.id, u]));
const bans = banRows.map(b => {
const user = userMap.get(b.userId);
const moderator = userMap.get(b.bannedBy);
return {
spaceId: b.spaceId,
userId: b.userId,
reason: b.reason,
bannedBy: b.bannedBy,
createdAt: b.createdAt,
user: user ? sanitizeUser(user) : null,
moderator: moderator ? sanitizeUser(moderator) : null,
};
});
return reply.code(200).send(bans);
});
// POST /api/spaces/:id/bans - Ban a member
app.post<{ Params: { id: string }; Body: { userId: string; reason?: string } }>('/api/spaces/:id/bans', {
preHandler: authenticate,
}, async (request, reply) => {
const { id } = request.params;
const { userId: targetId, reason } = request.body;
const db = getDb();
if (!targetId || typeof targetId !== 'string') {
return reply.code(400).send({ error: 'userId is required', statusCode: 400 });
}
if (!hasPermission(request.userId, id, PermissionBits.BAN_MEMBERS)) {
return reply.code(403).send({ error: 'Missing BAN_MEMBERS permission', statusCode: 403 });
}
// Cannot ban the space owner
if (isSpaceOwner(id, targetId)) {
return reply.code(400).send({ error: 'Cannot ban the space owner', statusCode: 400 });
}
// Cannot ban yourself
if (targetId === request.userId) {
return reply.code(400).send({ error: 'Cannot ban yourself', statusCode: 400 });
}
// Check if already banned
if (isBanned(id, targetId)) {
return reply.code(409).send({ error: 'User is already banned', statusCode: 409 });
}
const now = Date.now();
db.transaction((tx) => {
// Insert ban record
tx.insert(schema.bans).values({
spaceId: id,
userId: targetId,
reason: reason?.trim() || null,
bannedBy: request.userId,
createdAt: now,
}).run();
// Remove member from space
tx.delete(schema.spaceMembers).where(and(
eq(schema.spaceMembers.spaceId, id),
eq(schema.spaceMembers.userId, targetId),
)).run();
// Remove member's role assignments
tx.delete(schema.memberRoles).where(and(
eq(schema.memberRoles.spaceId, id),
eq(schema.memberRoles.userId, targetId),
)).run();
});
// Broadcast member_left event so other clients update their member list
connectionManager.sendToSpace(id, {
type: 'member_left',
spaceId: id,
userId: targetId,
});
// Notify the banned user
connectionManager.sendToUser(targetId, {
type: 'member_banned',
spaceId: id,
reason: reason?.trim() || null,
});
return reply.code(200).send({ success: true });
});
// DELETE /api/spaces/:id/bans/:uid - Unban a user
app.delete<{ Params: { id: string; uid: string } }>('/api/spaces/:id/bans/:uid', {
preHandler: authenticate,
}, async (request, reply) => {
const { id, uid } = request.params;
const db = getDb();
if (!hasPermission(request.userId, id, PermissionBits.BAN_MEMBERS)) {
return reply.code(403).send({ error: 'Missing BAN_MEMBERS permission', statusCode: 403 });
}
const result = db.delete(schema.bans).where(and(
eq(schema.bans.spaceId, id),
eq(schema.bans.userId, uid),
)).run();
if (result.changes === 0) {
return reply.code(404).send({ error: 'Ban not found', statusCode: 404 });
}
return reply.code(200).send({ success: true });
});
}
+11
View File
@@ -155,3 +155,14 @@ export function isDmMember(dmChannelId: string, userId: string): boolean {
.get();
return member !== undefined;
}
export function isBanned(spaceId: string, userId: string): boolean {
const db = getDb();
const ban = db.select().from(schema.bans)
.where(and(
eq(schema.bans.spaceId, spaceId),
eq(schema.bans.userId, userId),
))
.get();
return ban !== undefined;
}
+181
View File
@@ -150,6 +150,15 @@ export function handleClientEvent(
case 'voice_status':
handleVoiceStatus(event, userId);
break;
case 'voice_server_mute':
handleVoiceServerMute(event, userId);
break;
case 'voice_server_deafen':
handleVoiceServerDeafen(event, userId);
break;
case 'voice_move':
handleVoiceMove(event, userId);
break;
default:
connectionManager.sendToUser(userId, {
type: 'error',
@@ -708,6 +717,11 @@ function handleReactionAdd(event: Record<string, unknown>, userId: string): void
const spaceId = getChannelSpaceId(message.channelId);
if (!spaceId || !isMember(spaceId, userId)) return;
if (!hasPermission(userId, spaceId, PermissionBits.ADD_REACTIONS, message.channelId)) {
connectionManager.sendToUser(userId, { type: 'error', message: 'Missing ADD_REACTIONS permission' });
return;
}
const reactionId = generateSnowflake();
const now = Date.now();
try {
@@ -1040,3 +1054,170 @@ function handleDmCallEnd(event: Record<string, unknown>, userId: string): void {
dmChannelId,
});
}
// ─── Voice Moderation Handlers ──────────────────────────────────────────────
function handleVoiceServerMute(event: Record<string, unknown>, userId: string): void {
const targetUserId = event.userId as string;
const muted = event.muted === true;
if (!targetUserId || typeof targetUserId !== 'string') {
connectionManager.sendToUser(userId, { type: 'error', message: 'userId is required' });
return;
}
// Find the target user's current room
const targetRoom = connectionManager.getUserRoom(targetUserId);
if (!targetRoom || targetRoom.room.roomType !== 'space') {
connectionManager.sendToUser(userId, { type: 'error', message: 'Target user is not in a voice channel' });
return;
}
const meta = targetRoom.room.metadata as SpaceRoomMeta;
if (!hasPermission(userId, meta.spaceId, PermissionBits.MUTE_MEMBERS, targetRoom.roomId)) {
connectionManager.sendToUser(userId, { type: 'error', message: 'Missing MUTE_MEMBERS permission' });
return;
}
// Cannot server-mute yourself
if (targetUserId === userId) {
connectionManager.sendToUser(userId, { type: 'error', message: 'Cannot server-mute yourself' });
return;
}
connectionManager.setServerMuted(targetUserId, muted);
// Broadcast to all space members
connectionManager.sendToSpace(meta.spaceId, {
type: 'voice_server_muted',
userId: targetUserId,
channelId: targetRoom.roomId,
muted,
});
}
function handleVoiceServerDeafen(event: Record<string, unknown>, userId: string): void {
const targetUserId = event.userId as string;
const deafened = event.deafened === true;
if (!targetUserId || typeof targetUserId !== 'string') {
connectionManager.sendToUser(userId, { type: 'error', message: 'userId is required' });
return;
}
const targetRoom = connectionManager.getUserRoom(targetUserId);
if (!targetRoom || targetRoom.room.roomType !== 'space') {
connectionManager.sendToUser(userId, { type: 'error', message: 'Target user is not in a voice channel' });
return;
}
const meta = targetRoom.room.metadata as SpaceRoomMeta;
if (!hasPermission(userId, meta.spaceId, PermissionBits.DEAFEN_MEMBERS, targetRoom.roomId)) {
connectionManager.sendToUser(userId, { type: 'error', message: 'Missing DEAFEN_MEMBERS permission' });
return;
}
if (targetUserId === userId) {
connectionManager.sendToUser(userId, { type: 'error', message: 'Cannot server-deafen yourself' });
return;
}
connectionManager.setServerDeafened(targetUserId, deafened);
connectionManager.sendToSpace(meta.spaceId, {
type: 'voice_server_deafened',
userId: targetUserId,
channelId: targetRoom.roomId,
deafened,
});
}
function handleVoiceMove(event: Record<string, unknown>, userId: string): void {
const targetUserId = event.userId as string;
const targetChannelId = event.targetChannelId as string;
if (!targetUserId || typeof targetUserId !== 'string') {
connectionManager.sendToUser(userId, { type: 'error', message: 'userId is required' });
return;
}
if (!targetChannelId || typeof targetChannelId !== 'string') {
connectionManager.sendToUser(userId, { type: 'error', message: 'targetChannelId is required' });
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.MOVE_MEMBERS, currentRoom.roomId)) {
connectionManager.sendToUser(userId, { type: 'error', message: 'Missing MOVE_MEMBERS permission' });
return;
}
// Verify target channel exists and is a voice/video channel in the same space
const db = getDb();
const targetChannel = db.select().from(schema.channels).where(eq(schema.channels.id, targetChannelId)).get();
if (!targetChannel || targetChannel.spaceId !== meta.spaceId) {
connectionManager.sendToUser(userId, { type: 'error', message: 'Target channel not found in this space' });
return;
}
if (targetChannel.type !== 'voice' && targetChannel.type !== 'video') {
connectionManager.sendToUser(userId, { type: 'error', message: 'Target channel is not a voice channel' });
return;
}
if (targetChannelId === currentRoom.roomId) {
connectionManager.sendToUser(userId, { type: 'error', message: 'User is already in that channel' });
return;
}
const oldChannelId = currentRoom.roomId;
// Leave current room
connectionManager.leaveRoom(oldChannelId, targetUserId);
// Broadcast leave from old channel
connectionManager.sendToSpace(meta.spaceId, {
type: 'voice_state_update',
channelId: oldChannelId,
userId: targetUserId,
action: 'leave',
});
// Lazy-create target room and join
connectionManager.createRoom(targetChannelId, 'space', { type: 'space', spaceId: meta.spaceId });
connectionManager.joinRoom(targetChannelId, targetUserId);
// Broadcast join to new channel
connectionManager.sendToSpace(meta.spaceId, {
type: 'voice_state_update',
channelId: targetChannelId,
userId: targetUserId,
action: 'join',
});
// Notify the moved user so they reconnect to LiveKit
connectionManager.sendToUser(targetUserId, {
type: 'voice_moved',
userId: targetUserId,
oldChannelId,
newChannelId: targetChannelId,
});
// Preserve voice user status during move
const status = connectionManager.getVoiceUserStatus(targetUserId);
if (status) {
connectionManager.sendToRoom(targetChannelId, {
type: 'voice_status_update',
userId: targetUserId,
channelId: targetChannelId,
isMuted: status.isMuted,
isDeafened: status.isDeafened,
isCameraOn: status.isCameraOn,
isScreenSharing: status.isScreenSharing,
});
}
}
+44 -1
View File
@@ -77,6 +77,9 @@ class ConnectionManager {
private pendingOfflineTimeouts: Map<string, NodeJS.Timeout> = new Map();
// 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();
addConnection(userId: string, ws: WebSocket): void {
if (!this.connections.has(userId)) {
@@ -301,6 +304,7 @@ class ConnectionManager {
room.participants.delete(userId);
this.userToRoom.delete(userId);
this.clearServerVoiceState(userId);
// Auto-cleanup empty space rooms (they're lazy-created)
if (room.participants.size === 0 && room.roomType === 'space') {
@@ -382,6 +386,29 @@ class ConnectionManager {
this.voiceUserStates.delete(userId);
}
setServerMuted(userId: string, muted: boolean): void {
if (muted) this.serverMutedUsers.add(userId);
else this.serverMutedUsers.delete(userId);
}
isServerMuted(userId: string): boolean {
return this.serverMutedUsers.has(userId);
}
setServerDeafened(userId: string, deafened: boolean): void {
if (deafened) this.serverDeafenedUsers.add(userId);
else this.serverDeafenedUsers.delete(userId);
}
isServerDeafened(userId: string): boolean {
return this.serverDeafenedUsers.has(userId);
}
clearServerVoiceState(userId: string): void {
this.serverMutedUsers.delete(userId);
this.serverDeafenedUsers.delete(userId);
}
getAllVoiceUserStates(): Map<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }> {
return this.voiceUserStates;
}
@@ -536,6 +563,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 }>;
readStates: ReadState[];
activeCalls: ActiveCallInfo[];
} {
@@ -847,6 +875,21 @@ function buildReadyPayload(userId: string): {
}
}
// Build server mute/deafen states for users currently in voice
const serverVoiceStates: Record<string, { serverMuted: boolean; serverDeafened: boolean }> = {};
for (const chId of Object.keys(voiceStates)) {
const usersInChannel = voiceStates[chId];
if (usersInChannel) {
for (const uid of usersInChannel) {
const sm = connectionManager.isServerMuted(uid);
const sd = connectionManager.isServerDeafened(uid);
if (sm || sd) {
serverVoiceStates[uid] = { serverMuted: sm, serverDeafened: sd };
}
}
}
}
// Fetch read states for unread tracking
const readStateRows = db.select()
.from(schema.readStates)
@@ -858,7 +901,7 @@ function buildReadyPayload(userId: string): {
lastReadMessageId: rs.lastReadMessageId,
}));
return { user, spaces, dmChannels, folders, voiceStates, voiceUserStates, readStates, activeCalls };
return { user, spaces, dmChannels, folders, voiceStates, voiceUserStates, serverVoiceStates, readStates, activeCalls };
}
export async function registerWebSocket(app: FastifyInstance): Promise<void> {