From b4f66e01d961396ad0f80c8b802d2bfe512f37db Mon Sep 17 00:00:00 2001 From: Izan Gil <66965250+SrIzan10@users.noreply.github.com> Date: Tue, 24 Feb 2026 22:38:58 +0100 Subject: [PATCH] fix(chat): code review and documentation update --- apps/chat/src/index.ts | 17 +- apps/chat/src/utils/moderation.ts | 139 +++++++++++++-- apps/docs/src/content/docs/api/chat.mdx | 227 +++++++++++++++++++++--- 3 files changed, 339 insertions(+), 44 deletions(-) diff --git a/apps/chat/src/index.ts b/apps/chat/src/index.ts index 3ae8b35..7ceba1b 100644 --- a/apps/chat/src/index.ts +++ b/apps/chat/src/index.ts @@ -199,6 +199,14 @@ async function broadcastRestrictionStateToUser( }); } +const RATE_LIMIT_LUA = ` +local current = redis.call('INCR', KEYS[1]) +if current == 1 then + redis.call('EXPIRE', KEYS[1], ARGV[1]) +end +return current +`; + async function isRateLimited( channelId: string, userId: string, @@ -206,12 +214,7 @@ async function isRateLimited( windowSeconds: number ): Promise { const key = `chat:ratelimit:${channelId}:${userId}`; - const currentCount = await redis.incr(key); - - if (currentCount === 1) { - await redis.expire(key, windowSeconds); - } - + const currentCount = (await redis.eval(RATE_LIMIT_LUA, 1, key, String(windowSeconds))) as number; return currentCount > count; } @@ -649,6 +652,7 @@ app.get( broadcastToChannel(targetUsername, socket, msgObj as unknown as Record); } if (msg.type === 'emojiMsg') { + if (!socketState.chatUser) return; const emojis = msg.emojis as string[]; const emojiMap: Record = {}; @@ -675,6 +679,7 @@ app.get( ); } if (msg.type === 'emojiSearch') { + if (!socketState.chatUser) return; const rawSearchTerm = (msg.searchTerm as string)?.trim() ?? ''; if (!rawSearchTerm || rawSearchTerm.length > 50) { ws.send(JSON.stringify({ type: 'emojiSearchResponse', results: [] })); diff --git a/apps/chat/src/utils/moderation.ts b/apps/chat/src/utils/moderation.ts index 010954b..ecaba2b 100644 --- a/apps/chat/src/utils/moderation.ts +++ b/apps/chat/src/utils/moderation.ts @@ -6,6 +6,18 @@ import type { ChatUser, } from '../types/chat.js'; +const ROLE_RANK: Record | '__none__', number> = { + owner: 100, + manager: 50, + chatModerator: 10, + botModerator: 10, + __none__: 0, +}; + +function roleRank(role: ChatUser['channelRole']): number { + return role ? (ROLE_RANK[role] ?? 0) : ROLE_RANK.__none__; +} + type ModerationContext = { chatUser: ChatUser; targetUsername: string; @@ -67,31 +79,80 @@ export function sendModerationError( ); } -function requireModerationContext( +async function requireModerationContext( socket: ChatSocket, socketState: ChatSocket -): ModerationContext | null { - if ( - !socketState.isModerator || - !socketState.chatUser || - !socketState.targetUsername || - !socketState.channelId - ) { +): Promise { + if (!socketState.chatUser || !socketState.targetUsername || !socketState.channelId) { sendModerationError(socket, 'FORBIDDEN', 'You do not have permission to moderate this chat.'); return null; } + const chatUser = socketState.chatUser; + const channelId = socketState.channelId; + + const [channel, moderatorRecord] = await Promise.all([ + prisma.channel.findUnique({ + where: { id: channelId }, + select: { + ownerId: true, + managers: { select: { id: true } }, + chatModerators: { select: { id: true } }, + chatModeratorBots: { select: { id: true } }, + }, + }), + prisma.user.findUnique({ + where: { id: chatUser.moderatorUserId }, + select: { isAdmin: true }, + }), + ]); + + if (!channel) { + sendModerationError(socket, 'FORBIDDEN', 'You do not have permission to moderate this chat.'); + return null; + } + + const isPlatformAdmin = Boolean(moderatorRecord?.isAdmin); + + let channelRole: ChatUser['channelRole'] = null; + if (chatUser.isBot) { + if (channel.chatModeratorBots.some((b) => b.id === chatUser.id)) { + channelRole = 'botModerator'; + } + } else if (channel.ownerId === chatUser.id) { + channelRole = 'owner'; + } else if (channel.managers.some((m) => m.id === chatUser.id)) { + channelRole = 'manager'; + } else if (channel.chatModerators.some((m) => m.id === chatUser.id)) { + channelRole = 'chatModerator'; + } + + const isModerator = + isPlatformAdmin || + channelRole === 'owner' || + channelRole === 'manager' || + channelRole === 'chatModerator' || + channelRole === 'botModerator'; + + if (!isModerator) { + sendModerationError(socket, 'FORBIDDEN', 'You do not have permission to moderate this chat.'); + return null; + } + + const resolvedChatUser: ChatUser = { ...chatUser, isPlatformAdmin, channelRole }; + return { - chatUser: socketState.chatUser, + chatUser: resolvedChatUser, targetUsername: socketState.targetUsername, - channelId: socketState.channelId, + channelId, }; } async function resolveModerationTarget( socket: ChatSocket, actingModeratorUserId: string, - rawTargetUserId: unknown + rawTargetUserId: unknown, + channelId: string ) { const targetUserId = typeof rawTargetUserId === 'string' ? rawTargetUserId : ''; @@ -105,6 +166,9 @@ async function resolveModerationTarget( select: { isAdmin: true, personalChannel: { select: { name: true } }, + ownedChannels: { where: { id: channelId }, select: { id: true } }, + managedChannels: { where: { id: channelId }, select: { id: true } }, + moderatedChannels: { where: { id: channelId }, select: { id: true } }, }, }); @@ -113,9 +177,19 @@ async function resolveModerationTarget( return null; } + let targetChannelRole: ChatUser['channelRole'] = null; + if (targetUserRecord.ownedChannels.length > 0) { + targetChannelRole = 'owner'; + } else if (targetUserRecord.managedChannels.length > 0) { + targetChannelRole = 'manager'; + } else if (targetUserRecord.moderatedChannels.length > 0) { + targetChannelRole = 'chatModerator'; + } + return { targetUserId, targetUserRecord, + targetChannelRole, resolvedTargetUsername: targetUserRecord.personalChannel?.name ?? 'that user', }; } @@ -125,7 +199,7 @@ async function ensureAdminTargetModerationAllowed( actingModeratorUserId: string, targetIsAdmin: boolean ) { - if (process.env.NODE_ENV !== 'production' || !targetIsAdmin) { + if (!targetIsAdmin) { return true; } @@ -146,13 +220,33 @@ async function ensureAdminTargetModerationAllowed( return true; } +function ensureRoleHierarchyAllowed( + socket: ChatSocket, + actorRole: ChatUser['channelRole'], + actorIsPlatformAdmin: boolean, + targetRole: ChatUser['channelRole'] +): boolean { + if (actorIsPlatformAdmin) return true; + + if (roleRank(actorRole) <= roleRank(targetRole)) { + sendModerationError( + socket, + 'FORBIDDEN', + 'You cannot moderate a user with an equal or higher role than yours.' + ); + return false; + } + + return true; +} + export async function handleDeleteMessageCommand( socket: ChatSocket, socketState: ChatSocket, msg: ChatModerationCommand, deps: DeleteMessageDeps ) { - const context = requireModerationContext(socket, socketState); + const context = await requireModerationContext(socket, socketState); if (!context) { return; } @@ -186,13 +280,18 @@ export async function handleUserRestrictionCommand( msg: ChatModerationCommand, deps: UserRestrictionDeps ) { - const context = requireModerationContext(socket, socketState); + const context = await requireModerationContext(socket, socketState); if (!context) { return; } const actingModeratorUserId = context.chatUser.moderatorUserId; - const target = await resolveModerationTarget(socket, actingModeratorUserId, msg.targetUserId); + const target = await resolveModerationTarget( + socket, + actingModeratorUserId, + msg.targetUserId, + context.channelId + ); if (!target) { return; } @@ -206,6 +305,16 @@ export async function handleUserRestrictionCommand( return; } + const hierarchyAllowed = ensureRoleHierarchyAllowed( + socket, + context.chatUser.channelRole, + context.chatUser.isPlatformAdmin, + target.targetChannelRole + ); + if (!hierarchyAllowed) { + return; + } + if (msg.type === 'mod:unbanUser' || msg.type === 'mod:liftTimeout') { await prisma.chatUserBan.deleteMany({ where: { diff --git a/apps/docs/src/content/docs/api/chat.mdx b/apps/docs/src/content/docs/api/chat.mdx index 0de70bf..98ee353 100644 --- a/apps/docs/src/content/docs/api/chat.mdx +++ b/apps/docs/src/content/docs/api/chat.mdx @@ -21,64 +21,235 @@ Bot accounts are now supported. You can choose to connect as a bot by providing **Security Note:** When using the `?botAuth=` query parameter, be aware that query parameters may be logged in server logs, and/or proxy logs. Use the `Authorization` header method whenever possible. The query parameter method should only be used when connecting from an environment where headers cannot be set. It is highly advised to use a bot account for any automated task, and to implement anything pointed out in this page. + Once connected, you must implement a subroutine in your code to send ping messages every about 5 seconds. This is because of Cloudflare limitations. Messages are sent and received in JSON format. The following message types are supported: +- `session`: sent by the server immediately upon connection. + - received by client: + + ```json + { + "type": "session", + "viewer": { + "id": "user_id", + "username": "your_username" + }, + "permissions": { + "canModerate": false + }, + "moderation": { + "hasBlockedTerms": false, + "slowModeSeconds": 0, + "maxMessageLength": 400 + } + } + ``` + + `viewer` is `null` for unauthenticated (grant-only) connections. `canModerate` is `true` for channel owners, managers, moderators, and platform admins. + +- `chatAccess`: sent by the server on connect (for authenticated non-bot users) and whenever a user's restriction state changes. + - received by client: + + ```json + { + "type": "chatAccess", + "canSend": true, + "restriction": null + } + ``` + + When the user is restricted, `canSend` is `false` and `restriction` contains: + + ```json + { + "type": "timeout", + "reason": "Timed out by moderator", + "expiresAt": "2026-01-01T00:00:00.000Z" + } + ``` + + `type` is either `"timeout"` or `"ban"`. `expiresAt` is an ISO 8601 string for timeouts, or `null` for permanent bans. + +- `ping`: a ping message to keep the connection alive. + - sent by client: + + ```json + { + "type": "ping" + } + ``` + + - received by client: + + ```json + { + "type": "pong" + } + ``` + - `message`: a chat message. - sent by client: + ```json { "type": "message", "message": "Hello, world!" } ``` - - received by client: + + - received by client (broadcast to all viewers of the channel): + ```json { + "type": "message", + "msgId": "uuid-v4", "user": { "id": "user_id", "username": "user_who_sent_message", - "avatar": "https://emoji.slack-edge.com/avatar.png" + "pfpUrl": "https://example.com/avatar.png", + "displayName": "Display Name", + "isBot": false, + "isPlatformAdmin": false, + "channelRole": null }, "message": "Hello, world!" } ``` -- `ping`: a ping message to keep the connection alive. - - sent by client: - ```json - { - "type": "ping" - } - ``` - - received by client: - ```json - { - "type": "pong" - } - ``` -- `history`: a message containing the chat history. This is sent upon connection. + + `channelRole` is one of `"owner"`, `"manager"`, `"chatModerator"`, `"botModerator"`, or `null`. `displayName` may be `undefined` for regular users. + +- `history`: the recent chat history, sent upon connection. - received by client: + ```json { "type": "history", "messages": [ { + "type": "message", + "msgId": "uuid-v4", "user": { "id": "user_id", "username": "user_who_sent_message", - "avatar": "https://emoji.slack-edge.com/avatar.png" + "pfpUrl": "https://example.com/avatar.png", + "displayName": "Display Name", + "isBot": false, + "isPlatformAdmin": false, + "channelRole": null }, - "message": "Hello, world!", - "type": "message", - }, - ... + "message": "Hello, world!" + } ] } ``` + Up to 100 messages are returned. Each message has the same shape as a received `message` event. + +- `systemMsg`: a system notification broadcast to all viewers, e.g. when a user is banned or unbanned. + - received by client: + + ```json + { + "type": "systemMsg", + "message": "username was banned." + } + ``` + +- `moderationError`: sent to the acting client when a message or moderation action is rejected. + - received by client: + + ```json + { + "type": "moderationError", + "code": "RATE_LIMIT", + "message": "You are sending messages too fast.", + "restriction": null + } + ``` + + `restriction` is only present (non-null) for `TIMED_OUT` and `BANNED` codes, and has the same shape as the `restriction` field in `chatAccess`. Possible codes: + + | Code | Trigger | + | ------------------ | ---------------------------------------------- | + | `FORBIDDEN` | Not permitted to perform the action | + | `RATE_LIMIT` | Too many messages in the rate limit window | + | `SLOW_MODE` | Sent before the slow mode cooldown expired | + | `TIMED_OUT` | User is currently timed out | + | `BANNED` | User is permanently banned | + | `MESSAGE_TOO_LONG` | Message exceeds `maxMessageLength` | + | `BLOCKED_TERM` | Message contains a blocked term | + | `INVALID_TARGET` | Moderation target is invalid or does not exist | + | `INVALID_REQUEST` | Malformed moderation command | + | `NOT_FOUND` | Target message not found (delete) | + +## Moderation commands + +moderation commands are only available to authenticated users with the `canModerate` permission (`owner`, `manager`, `chatModerator`, `botModerator`, or platform admin). sending any of these without permission returns a `moderationError` with code `FORBIDDEN`. + +obviously, role hierarchy is enforced: a `chatModerator` cannot moderate a `manager` or `owner`. Platform admins bypass hierarchy checks entirely. + +- `mod:deleteMessage`: delete a message from the chat history and broadcast its removal. + - sent by client: + + ```json + { + "type": "mod:deleteMessage", + "msgId": "uuid-of-message-to-delete" + } + ``` + + - received by all clients on success: + + ```json + { + "type": "messageDeleted", + "msgId": "uuid-of-message-to-delete" + } + ``` + +- `mod:timeoutUser`: temporarily restrict a user from sending messages. + - sent by client: + + ```json + { + "type": "mod:timeoutUser", + "targetUserId": "user_id", + "durationSeconds": 300, + "reason": "Optional reason" + } + ``` + + `durationSeconds` is clamped between 10 and 86400 (24 hours). Defaults to 300 if omitted. On success, a `systemMsg` is broadcast and the target receives a `chatAccess` update. + +- `mod:banUser`: permanently ban a user from sending messages. + - sent by client: + + ```json + { + "type": "mod:banUser", + "targetUserId": "user_id", + "reason": "Optional reason" + } + ``` + + On success, a `systemMsg` is broadcast and the target receives a `chatAccess` update. + +- `mod:liftTimeout` / `mod:unbanUser`: remove an active timeout or ban. + - sent by client: + + ```json + { + "type": "mod:liftTimeout", + "targetUserId": "user_id" + } + ``` + + Both types behave identically and remove any active restriction for the target user. On success, a `systemMsg` is broadcast and the target receives a `chatAccess` update with `canSend: true`. + ## Emoji handling _diagram source: devin deepwiki_ @@ -119,22 +290,29 @@ The server then checks Redis for the emoji URL and returns it. When a user wants to look up an emoji (by typing `:(partial name)`), the server uses uFuzzy to find matching emojis in the Redis `emojis` hash key and returns the results. + + Here's what gets sent on the websocket: - `emojiMsg`: Looks up emojis - sent by client: + ```json { "type": "emojiMsg", "emojis": ["aga", "yapa", "heavysob", "yay", "yay-bounce"] } ``` + - received by client: + ```json { "type": "emojiMsgResponse", "emojis": { - // rough example of urls "aga": "https://emoji.slack-edge.com/aga.png", "yapa": "https://emoji.slack-edge.com/yapa.png", "heavysob": "https://emoji.slack-edge.com/heavysob.png", @@ -143,20 +321,23 @@ Here's what gets sent on the websocket: } } ``` + - `emojiSearch`: Searches for emojis - sent by client: + ```json { "type": "emojiSearch", "searchTerm": "aga" } ``` + - received by client: + ```json { "type": "emojiSearchResponse", "results": [ - // real results btw "aga", "aga-brick-throw", "aga-dance",