fix: enforce channel-level RBAC across WS broadcasts, REST endpoints, and frontend reactivity

Wire the bitwise permission engine end-to-end:

- Add sendToChannel() to ConnectionManager, filtering WS recipients by VIEW_CHANNEL
- Convert 6 channel-scoped events (messages, typing, reactions) from sendToServer to sendToChannel
- Add broadcastOverrideChange() to push channel_updated/channel_deleted per-user on override mutations
- Bridge legacy server_members.role TEXT to member_roles junction table on PATCH
- Add pushReadyPayload() to force re-sync frontend store after role changes
- Filter channels by VIEW_CHANNEL in GET /api/servers/:id to prevent initial load data leak
- Pre-compute viewers before CASCADE delete on channel_deleted
- Fix frontend channel event handlers to upsert/cleanup channelToServerMap and channelPermissions
- Add ChannelSettingsModal with Private Channel toggle and gear icon in ChannelSidebar
This commit is contained in:
Jannis Braun
2026-02-24 06:10:11 +01:00
parent 8030c89c6c
commit 76b8a43be2
10 changed files with 366 additions and 24 deletions
+8 -8
View File
@@ -210,8 +210,8 @@ function handleMessageCreate(event: Record<string, unknown>, userId: string): vo
const messageWithUser = getMessageWithUser(messageId);
if (messageWithUser) {
// Broadcast to all server members (including sender)
connectionManager.sendToServer(serverId, {
// Broadcast to members with VIEW_CHANNEL on this channel
connectionManager.sendToChannel(serverId, channelId, {
type: 'message_created',
message: messageWithUser,
});
@@ -255,7 +255,7 @@ function handleMessageEdit(event: Record<string, unknown>, userId: string): void
const updatedMessage = getMessageWithUser(messageId);
if (updatedMessage) {
connectionManager.sendToServer(serverId, {
connectionManager.sendToChannel(serverId, message.channelId, {
type: 'message_updated',
message: updatedMessage,
});
@@ -293,7 +293,7 @@ function handleMessageDelete(event: Record<string, unknown>, userId: string): vo
db.delete(schema.attachments).where(eq(schema.attachments.messageId, messageId)).run();
db.delete(schema.messages).where(eq(schema.messages.id, messageId)).run();
connectionManager.sendToServer(serverId, {
connectionManager.sendToChannel(serverId, message.channelId, {
type: 'message_deleted',
messageId,
channelId: message.channelId,
@@ -317,8 +317,8 @@ function handleTypingStart(event: Record<string, unknown>, userId: string, usern
clearTimeout(existing);
}
// Broadcast typing event (exclude sender)
connectionManager.sendToServer(serverId, {
// Broadcast typing event to channel viewers (exclude sender)
connectionManager.sendToChannel(serverId, channelId, {
type: 'typing',
channelId,
userId,
@@ -728,7 +728,7 @@ function handleReactionAdd(event: Record<string, unknown>, userId: string): void
createdAt: Date.now(),
}).run();
connectionManager.sendToServer(serverId, {
connectionManager.sendToChannel(serverId, message.channelId, {
type: 'reaction_added',
messageId,
reaction: {
@@ -766,7 +766,7 @@ function handleReactionRemove(event: Record<string, unknown>, userId: string): v
.run();
if (result.changes > 0) {
connectionManager.sendToServer(serverId, {
connectionManager.sendToChannel(serverId, message.channelId, {
type: 'reaction_removed',
messageId,
userId,
+37
View File
@@ -425,6 +425,29 @@ class ConnectionManager {
}
}
/** Send to server members who have VIEW_CHANNEL on the given channel. */
sendToChannel(serverId: string, channelId: string, event: ServerEvent, excludeUserId?: string): void {
const message = JSON.stringify(event);
for (const [userId, serverIds] of this.userServers) {
if (serverIds.has(serverId) && userId !== excludeUserId) {
const perms = computePermissions(userId, serverId, channelId);
if ((perms & PermissionBits.VIEW_CHANNEL) !== 0n) {
const connections = this.getUserConnections(userId);
for (const ws of connections) {
if (ws.readyState === 1) {
ws.send(message);
}
}
}
}
}
}
/** Expose userServers iterator for pre-delete viewer collection. */
getUserServerEntries(): IterableIterator<[string, Set<string>]> {
return this.userServers.entries();
}
/** Send to all DM channel members (queries dm_members table). */
sendToDmMembers(dmChannelId: string, event: ServerEvent, excludeUserId?: string): void {
const db = getDb();
@@ -470,6 +493,20 @@ class ConnectionManager {
getAllOnlineUserIds(): string[] {
return Array.from(this.connections.keys());
}
/** Push a fresh ready payload to a specific user, forcing full store re-sync. */
pushReadyPayload(userId: string): void {
const connections = this.getUserConnections(userId);
if (connections.size === 0) return;
const readyData = buildReadyPayload(userId);
const message = JSON.stringify({ type: 'ready', ...readyData });
for (const ws of connections) {
if (ws.readyState === 1) {
ws.send(message);
}
}
}
}
export const connectionManager = new ConnectionManager();