diff --git a/CLAUDE.md b/CLAUDE.md index 514d31c..ae6804b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -74,3 +74,6 @@ All CSS lives in `packages/ui`. Apps do NOT have their own `globals.css`. ./lib/* → ./src/lib/*.ts ./hooks/* → ./src/hooks/*.ts ``` + +PS. If u add/edit routes in the API, make sure to build the API Client as the frontend relies on this being built to be up to date. +Otherwise you will receive errors when type checking diff --git a/ROADMAP.md b/ROADMAP.md index c056629..7077b19 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -25,13 +25,13 @@ - [x] Message deletion - [x] Message editing UI - [x] User profiles (bio, custom status, avatar upload) -- [ ] Channel edit/delete -- [x] Settings pages +- [x] Channel edit/delete +- [x] User settings page ## Phase 2 — Permissions & Moderation - [ ] Granular permission system (beyond owner/admin/member) -- [ ] Member management UI (kick, banish, silence, role assignment) +- [~] Member management UI (kick, banish, silence, role assignment) (in progress in this PR) - [ ] Rate limiting enforcement (API-level + per-channel) - [ ] Audit logs @@ -57,13 +57,13 @@ - [ ] Thread support - [ ] Notification preferences - [ ] Error handling & loading state improvements +- [ ] Other settings pages ## Phase 6 — Infrastructure - [ ] Structured logger (Pino/Winston) replacing `console.error` - [ ] Production environment management - [ ] Production startup guard for `REALTIME_CORS_ORIGIN` on localhost defaults -- [ ] Database migration workflow - [ ] Monitoring & logging (observability) - [ ] CORS lockdown for production domains diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 6667ffa..06ed05d 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -19,7 +19,9 @@ app.use( }) ) -app.on(["GET", "POST"], "/api/auth/**", (c) => auth.handler(c.req.raw)) +app.on(["POST", "GET"], "/api/auth/*", (c) => { + return auth.handler(c.req.raw) +}) configureOpenAPI(app) diff --git a/apps/api/src/lib/permissions.ts b/apps/api/src/lib/permissions.ts new file mode 100644 index 0000000..8d4b02e --- /dev/null +++ b/apps/api/src/lib/permissions.ts @@ -0,0 +1,130 @@ +import { auth } from "@repo/auth" +import { + canManageGuildAuthority, + type GuildAuthority, + guildAuthorityHasPermissions, + isGuildRole, + type PermissionRequest, + type StatementKey, +} from "@repo/auth/permissions" +import { HTTPException } from "hono/http-exception" +import * as HttpStatusCodes from "@/lib/helpers/http/status-codes" +import type { Guild, GuildMember } from "@/lib/types/app-types" + +// ── Type-Safe Permission Types ────────────────────────────────────── + +export type { StatementKey } + +export type PermissionForStatement = NonNullable< + PermissionRequest[T] +>[number] + +function toGuildAuthority( + member: Pick, + guild: Pick +): GuildAuthority { + if (!isGuildRole(member.role)) { + throw new HTTPException(HttpStatusCodes.FORBIDDEN, { + message: `Unknown guild role: ${member.role}`, + }) + } + + return { + role: member.role, + isOwner: guild.ownerId === member.userId, + } +} + +// ── Permission Check ────────────────────────────────────── + +/** + * Checks if the current user has the specified permissions in their active guild. + * Uses better-auth's hasPermission API and throws HTTPException(403) when the + * requested permission is missing. + * + * @example + * await checkPermission(c.req.raw.headers, "channel", ["update"]) + * + * // If the permission is missing, checkPermission(...) throws + * // HTTPException(403) from the internal !result.success branch. + */ +export async function checkPermission< + TResource extends StatementKey, + TPermissions extends readonly PermissionForStatement[], +>(headers: Headers, resource: TResource, permissions: TPermissions) { + const result = await auth.api.hasPermission({ + headers, + body: { + permissions: { + [resource]: [...permissions], + }, + }, + }) + + if (!result.success) { + throw new HTTPException(HttpStatusCodes.FORBIDDEN, { + message: `You do not have permission to ${permissions.join("/")} ${resource}`, + }) + } + + return true +} + +export function assertGuildPermission( + member: Pick, + guild: Pick, + requestedPermissions: PermissionRequest +) { + const authority = toGuildAuthority(member, guild) + + if (!guildAuthorityHasPermissions(authority, requestedPermissions)) { + throw new HTTPException(HttpStatusCodes.FORBIDDEN, { + message: "You do not have permission to perform this action", + }) + } + + return authority +} + +export function assertCanManageGuildMember( + actor: Pick, + target: Pick, + guild: Pick +) { + if (actor.userId === target.userId) { + throw new HTTPException(HttpStatusCodes.FORBIDDEN, { + message: "You cannot moderate yourself", + }) + } + + const actorAuthority = toGuildAuthority(actor, guild) + const targetAuthority = toGuildAuthority(target, guild) + + if (!canManageGuildAuthority(actorAuthority, targetAuthority)) { + throw new HTTPException(HttpStatusCodes.FORBIDDEN, { + message: "You cannot moderate this member", + }) + } + + return { + actorAuthority, + targetAuthority, + } +} + +export function isCommunicationDisabled( + member: Pick +) { + if (!member.communicationDisabledUntil) return false + return member.communicationDisabledUntil.getTime() > Date.now() +} + +export function assertMemberCanCommunicate( + member: Pick +) { + if (!isCommunicationDisabled(member)) return + + throw new HTTPException(HttpStatusCodes.FORBIDDEN, { + message: "You are temporarily timed out and cannot send messages", + }) +} diff --git a/apps/api/src/routes/v1/channels/handlers.ts b/apps/api/src/routes/v1/channels/handlers.ts index b1dff78..99a7386 100644 --- a/apps/api/src/routes/v1/channels/handlers.ts +++ b/apps/api/src/routes/v1/channels/handlers.ts @@ -2,14 +2,17 @@ import { db } from "@repo/db" import { channel } from "@repo/db/schema" import { and, asc, eq, inArray } from "drizzle-orm" import * as HttpStatusCodes from "@/lib/helpers/http/status-codes" +import { assertGuildPermission } from "@/lib/permissions" import { fetchMessagePage } from "@/lib/queries/messages" import type { AppRouteHandler } from "@/lib/types/app-types" import type { CreateChannelRoute, + DeleteChannelRoute, GetChannelRoute, ListChannelMessagesRoute, ListChannelsRoute, ReorderChannelsRoute, + UpdateChannelRoute, } from "./routes" export const listChannels: AppRouteHandler = async (c) => { @@ -56,8 +59,13 @@ export const listChannels: AppRouteHandler = async (c) => { export const createChannel: AppRouteHandler = async (c) => { const guild = c.var.guild + const member = c.var.member const body = c.req.valid("json") + assertGuildPermission(member, guild, { + channel: ["create"], + }) + const newChannel = await db .insert(channel) .values({ @@ -81,8 +89,13 @@ export const reorderChannels: AppRouteHandler = async ( c ) => { const guild = c.var.guild + const member = c.var.member const { channels: updates } = c.req.valid("json") + assertGuildPermission(member, guild, { + channel: ["update"], + }) + const channelIds = updates.map((u) => u.id) const uniqueChannelIds = [...new Set(channelIds)] @@ -134,6 +147,58 @@ export const getChannel: AppRouteHandler = async (c) => { return c.json(ch, HttpStatusCodes.OK) } +export const updateChannel: AppRouteHandler = async (c) => { + const guild = c.var.guild + const member = c.var.member + const { channelId } = c.req.valid("param") + const body = c.req.valid("json") + + assertGuildPermission(member, guild, { + channel: ["update"], + }) + + const updated = await db + .update(channel) + .set(body) + .where(and(eq(channel.id, channelId), eq(channel.guildId, guild.id))) + .returning() + .then((rows) => rows[0]) + + if (!updated) { + return c.json( + { success: false, message: "Channel not found" }, + HttpStatusCodes.NOT_FOUND + ) + } + + return c.json(updated, HttpStatusCodes.OK) +} + +export const deleteChannel: AppRouteHandler = async (c) => { + const guild = c.var.guild + const member = c.var.member + const { channelId } = c.req.valid("param") + + assertGuildPermission(member, guild, { + channel: ["delete"], + }) + + const deleted = await db + .delete(channel) + .where(and(eq(channel.id, channelId), eq(channel.guildId, guild.id))) + .returning({ id: channel.id }) + .then((rows) => rows[0]) + + if (!deleted) { + return c.json( + { success: false, message: "Channel not found" }, + HttpStatusCodes.NOT_FOUND + ) + } + + return c.json({ success: true }, HttpStatusCodes.OK) +} + export const listChannelMessages: AppRouteHandler< ListChannelMessagesRoute > = async (c) => { diff --git a/apps/api/src/routes/v1/channels/index.ts b/apps/api/src/routes/v1/channels/index.ts index 45264c8..5949d28 100644 --- a/apps/api/src/routes/v1/channels/index.ts +++ b/apps/api/src/routes/v1/channels/index.ts @@ -7,6 +7,8 @@ const channelsRouter = createRouter() .openapi(routes.createChannel, handlers.createChannel) .openapi(routes.reorderChannels, handlers.reorderChannels) .openapi(routes.getChannel, handlers.getChannel) + .openapi(routes.updateChannel, handlers.updateChannel) + .openapi(routes.deleteChannel, handlers.deleteChannel) .openapi(routes.listChannelMessages, handlers.listChannelMessages) export default channelsRouter diff --git a/apps/api/src/routes/v1/channels/routes.ts b/apps/api/src/routes/v1/channels/routes.ts index 2040321..6608662 100644 --- a/apps/api/src/routes/v1/channels/routes.ts +++ b/apps/api/src/routes/v1/channels/routes.ts @@ -13,12 +13,15 @@ import { channelResponseSchema, createChannelRequestSchema, createChannelResponseSchema, + deleteChannelResponseSchema, guildSlugParamsSchema, listChannelsResponseSchema, listMessagesQuerySchema, listMessagesResponseSchema, reorderChannelsRequestSchema, reorderChannelsResponseSchema, + updateChannelRequestSchema, + updateChannelResponseSchema, } from "./schema" export const listChannels = createRoute({ @@ -138,8 +141,60 @@ export const listChannelMessages = createRoute({ }, }) +export const updateChannel = createRoute({ + path: "/guilds/{guildSlug}/channels/{channelId}", + method: "patch", + summary: "Update a channel", + description: + "Updates a channel's name, topic, or other properties. Requires channel:update permission.", + tags: ["Channels"], + middleware: [guildAuthMiddleware] as const, + request: { + params: channelParamsSchema, + body: jsonContent({ + schema: updateChannelRequestSchema, + description: "Channel fields to update", + }), + }, + responses: { + [HttpStatusCodes.OK]: jsonContent({ + schema: updateChannelResponseSchema, + description: "Updated channel", + }), + [HttpStatusCodes.UNAUTHORIZED]: unauthorizedSchema, + [HttpStatusCodes.FORBIDDEN]: forbiddenSchema, + [HttpStatusCodes.NOT_FOUND]: notFoundSchema, + [HttpStatusCodes.INTERNAL_SERVER_ERROR]: internalServerErrorSchema, + }, +}) + +export const deleteChannel = createRoute({ + path: "/guilds/{guildSlug}/channels/{channelId}", + method: "delete", + summary: "Delete a channel", + description: + "Permanently deletes a channel and all its messages. Requires channel:delete permission.", + tags: ["Channels"], + middleware: [guildAuthMiddleware] as const, + request: { + params: channelParamsSchema, + }, + responses: { + [HttpStatusCodes.OK]: jsonContent({ + schema: deleteChannelResponseSchema, + description: "Channel deleted", + }), + [HttpStatusCodes.UNAUTHORIZED]: unauthorizedSchema, + [HttpStatusCodes.FORBIDDEN]: forbiddenSchema, + [HttpStatusCodes.NOT_FOUND]: notFoundSchema, + [HttpStatusCodes.INTERNAL_SERVER_ERROR]: internalServerErrorSchema, + }, +}) + export type ListChannelsRoute = typeof listChannels export type CreateChannelRoute = typeof createChannel export type ReorderChannelsRoute = typeof reorderChannels export type GetChannelRoute = typeof getChannel +export type UpdateChannelRoute = typeof updateChannel +export type DeleteChannelRoute = typeof deleteChannel export type ListChannelMessagesRoute = typeof listChannelMessages diff --git a/apps/api/src/routes/v1/channels/schema.ts b/apps/api/src/routes/v1/channels/schema.ts index 1af581c..b09a6c5 100644 --- a/apps/api/src/routes/v1/channels/schema.ts +++ b/apps/api/src/routes/v1/channels/schema.ts @@ -1,5 +1,9 @@ import { z } from "@hono/zod-openapi" -import { insertChannelSchema, selectChannelSchema } from "@repo/db/schema" +import { + insertChannelSchema, + selectChannelSchema, + updateChannelSchema, +} from "@repo/db/schema" import { listMessagesQuerySchema, listMessagesResponseSchema, @@ -44,6 +48,29 @@ export const createChannelRequestSchema = insertChannelSchema export const createChannelResponseSchema = selectChannelSchema +// ── Update / Delete ────────────────────────────────────────── + +const updateChannelRequestBaseSchema = updateChannelSchema + .pick({ + name: true, + topic: true, + rateLimitPerUser: true, + }) + .strict() + +export const updateChannelRequestSchema = updateChannelRequestBaseSchema.refine( + (value) => Object.values(value).some((field) => field !== undefined), + { + message: "At least one channel field must be provided", + } +) + +export const updateChannelResponseSchema = selectChannelSchema + +export const deleteChannelResponseSchema = z.object({ + success: z.literal(true), +}) + // ── Messages ────────────────────────────────────────── export { diff --git a/apps/api/src/routes/v1/guilds/handlers.ts b/apps/api/src/routes/v1/guilds/handlers.ts index 8f45982..56fa3c8 100644 --- a/apps/api/src/routes/v1/guilds/handlers.ts +++ b/apps/api/src/routes/v1/guilds/handlers.ts @@ -1,10 +1,25 @@ -import { db, eq, schema } from "@repo/db" +import { + getGuildAuthorityPosition, + getGuildRolePosition, +} from "@repo/auth/permissions" +import { and, db, eq, schema } from "@repo/db" import { PRESENCE_ONLINE_USERS_SET_KEY } from "@repo/realtime-types" import { asc } from "drizzle-orm" import * as HttpStatusCodes from "@/lib/helpers/http/status-codes" +import { + assertCanManageGuildMember, + assertGuildPermission, +} from "@/lib/permissions" import { getRedisClient } from "@/lib/redis" import type { AppRouteHandler } from "@/lib/types/app-types" -import type { ListGuildMembersRoute } from "@/routes/v1/guilds/routes" +import type { + BanGuildMemberRoute, + ClearGuildMemberTimeoutRoute, + KickGuildMemberRoute, + ListGuildMembersRoute, + TimeoutGuildMemberRoute, + UpdateGuildMemberRoleRoute, +} from "@/routes/v1/guilds/routes" const PRESENCE_MEMBERSHIP_CHUNK_SIZE = 250 @@ -37,6 +52,62 @@ async function listOnlineUserIds(userIds: string[]) { } } +function toGuildMemberPresence( + member: { + userId: string + name: string + username: string | null + displayUsername: string | null + image: string | null + role: string + communicationDisabledUntil: Date | null + communicationDisabledReason: string | null + }, + ownerId: string, + onlineUserIds: Set +) { + return { + userId: member.userId, + name: member.name, + username: member.username, + displayUsername: member.displayUsername, + image: member.image, + role: member.role, + isOwner: ownerId === member.userId, + status: onlineUserIds.has(member.userId) + ? ("online" as const) + : ("offline" as const), + communicationDisabledUntil: + member.communicationDisabledUntil?.toISOString() ?? null, + communicationDisabledReason: member.communicationDisabledReason, + } +} + +async function getGuildMemberRow(guildId: string, userId: string) { + return db + .select({ + userId: schema.guildMember.userId, + role: schema.guildMember.role, + communicationDisabledUntil: schema.guildMember.communicationDisabledUntil, + communicationDisabledReason: + schema.guildMember.communicationDisabledReason, + name: schema.user.name, + username: schema.user.username, + displayUsername: schema.user.displayUsername, + image: schema.user.image, + }) + .from(schema.guildMember) + .innerJoin(schema.user, eq(schema.guildMember.userId, schema.user.id)) + .where( + and( + eq(schema.guildMember.guildId, guildId), + eq(schema.guildMember.userId, userId) + ) + ) + .limit(1) + .then((rows) => rows[0] ?? null) +} + export const listGuildMembers: AppRouteHandler = async ( c ) => { @@ -46,6 +117,9 @@ export const listGuildMembers: AppRouteHandler = async ( .select({ userId: schema.guildMember.userId, role: schema.guildMember.role, + communicationDisabledUntil: schema.guildMember.communicationDisabledUntil, + communicationDisabledReason: + schema.guildMember.communicationDisabledReason, name: schema.user.name, username: schema.user.username, displayUsername: schema.user.displayUsername, @@ -64,17 +138,331 @@ export const listGuildMembers: AppRouteHandler = async ( guildId: guild.id, guildSlug: guild.slug, guildName: guild.name, - members: memberRows.map((member) => ({ - userId: member.userId, - name: member.name, - username: member.username, - displayUsername: member.displayUsername, - image: member.image, - role: member.role, - status: onlineUserIds.has(member.userId) - ? ("online" as const) - : ("offline" as const), - })), + ownerId: guild.ownerId, + members: memberRows.map((member) => + toGuildMemberPresence(member, guild.ownerId, onlineUserIds) + ), + }, + HttpStatusCodes.OK + ) +} + +export const updateGuildMemberRole: AppRouteHandler< + UpdateGuildMemberRoleRoute +> = async (c) => { + const guild = c.var.guild + const actor = c.var.member + const { userId } = c.req.valid("param") + const { role } = c.req.valid("json") + + const actorAuthority = assertGuildPermission(actor, guild, { + guildMember: ["role:update"], + }) + + const target = await getGuildMemberRow(guild.id, userId) + + if (!target) { + return c.json( + { success: false, message: "Guild member not found" }, + HttpStatusCodes.NOT_FOUND + ) + } + + assertCanManageGuildMember(actor, target, guild) + + if ( + !actorAuthority.isOwner && + getGuildRolePosition(role) <= getGuildAuthorityPosition(actorAuthority) + ) { + return c.json( + { success: false, message: "You cannot assign that role" }, + HttpStatusCodes.FORBIDDEN + ) + } + + await db + .update(schema.guildMember) + .set({ role }) + .where( + and( + eq(schema.guildMember.guildId, guild.id), + eq(schema.guildMember.userId, userId) + ) + ) + + const updatedMember = await getGuildMemberRow(guild.id, userId) + + if (!updatedMember) { + return c.json( + { success: false, message: "Guild member not found" }, + HttpStatusCodes.NOT_FOUND + ) + } + + const onlineUserIds = await listOnlineUserIds([updatedMember.userId]) + + return c.json( + { + success: true as const, + member: toGuildMemberPresence( + updatedMember, + guild.ownerId, + onlineUserIds + ), + }, + HttpStatusCodes.OK + ) +} + +export const kickGuildMember: AppRouteHandler = async ( + c +) => { + const guild = c.var.guild + const actor = c.var.member + const { userId } = c.req.valid("param") + + assertGuildPermission(actor, guild, { + guildMember: ["kick"], + }) + + const target = await getGuildMemberRow(guild.id, userId) + + if (!target) { + return c.json( + { success: false, message: "Guild member not found" }, + HttpStatusCodes.NOT_FOUND + ) + } + + assertCanManageGuildMember(actor, target, guild) + + await db + .delete(schema.guildMember) + .where( + and( + eq(schema.guildMember.guildId, guild.id), + eq(schema.guildMember.userId, userId) + ) + ) + + return c.json({ success: true as const }, HttpStatusCodes.OK) +} + +export const banGuildMember: AppRouteHandler = async ( + c +) => { + const guild = c.var.guild + const actor = c.var.member + const { userId } = c.req.valid("param") + const { reason, expiresAt } = c.req.valid("json") + + assertGuildPermission(actor, guild, { + guildMember: ["ban"], + }) + + const target = await getGuildMemberRow(guild.id, userId) + + if (!target) { + return c.json( + { success: false, message: "Guild member not found" }, + HttpStatusCodes.NOT_FOUND + ) + } + + assertCanManageGuildMember(actor, target, guild) + + const expiresAtDate = expiresAt ? new Date(expiresAt) : null + const banTimestamp = new Date() + + const ban = await db.transaction(async (tx) => { + const insertedBan = await tx + .insert(schema.guildBan) + .values({ + createdAt: banTimestamp, + guildId: guild.id, + userId, + bannedBy: actor.userId, + reason: reason ?? null, + expiresAt: expiresAtDate, + revokedAt: null, + revokeReason: null, + }) + .onConflictDoUpdate({ + target: [schema.guildBan.guildId, schema.guildBan.userId], + set: { + createdAt: banTimestamp, + bannedBy: actor.userId, + reason: reason ?? null, + expiresAt: expiresAtDate, + revokedAt: null, + revokeReason: null, + }, + }) + .returning({ + userId: schema.guildBan.userId, + guildId: schema.guildBan.guildId, + bannedBy: schema.guildBan.bannedBy, + reason: schema.guildBan.reason, + expiresAt: schema.guildBan.expiresAt, + createdAt: schema.guildBan.createdAt, + revokedAt: schema.guildBan.revokedAt, + }) + .then((rows) => rows[0]) + + await tx + .delete(schema.guildMember) + .where( + and( + eq(schema.guildMember.guildId, guild.id), + eq(schema.guildMember.userId, userId) + ) + ) + + return insertedBan + }) + + if (!ban) { + return c.json( + { success: false, message: "Failed to create guild ban" }, + HttpStatusCodes.INTERNAL_SERVER_ERROR + ) + } + + return c.json( + { + success: true as const, + ban: { + ...ban, + reason: ban.reason ?? null, + expiresAt: ban.expiresAt?.toISOString() ?? null, + createdAt: ban.createdAt.toISOString(), + revokedAt: ban.revokedAt?.toISOString() ?? null, + }, + }, + HttpStatusCodes.OK + ) +} + +export const timeoutGuildMember: AppRouteHandler< + TimeoutGuildMemberRoute +> = async (c) => { + const guild = c.var.guild + const actor = c.var.member + const { userId } = c.req.valid("param") + const { durationMinutes, reason } = c.req.valid("json") + + assertGuildPermission(actor, guild, { + guildMember: ["timeout"], + }) + + const target = await getGuildMemberRow(guild.id, userId) + + if (!target) { + return c.json( + { success: false, message: "Guild member not found" }, + HttpStatusCodes.NOT_FOUND + ) + } + + assertCanManageGuildMember(actor, target, guild) + + const communicationDisabledUntil = new Date( + Date.now() + durationMinutes * 60 * 1000 + ) + + await db + .update(schema.guildMember) + .set({ + communicationDisabledUntil, + communicationDisabledBy: actor.userId, + communicationDisabledReason: reason ?? null, + }) + .where( + and( + eq(schema.guildMember.guildId, guild.id), + eq(schema.guildMember.userId, userId) + ) + ) + + const updatedMember = await getGuildMemberRow(guild.id, userId) + + if (!updatedMember) { + return c.json( + { success: false, message: "Guild member not found" }, + HttpStatusCodes.NOT_FOUND + ) + } + + const onlineUserIds = await listOnlineUserIds([updatedMember.userId]) + + return c.json( + { + success: true as const, + member: toGuildMemberPresence( + updatedMember, + guild.ownerId, + onlineUserIds + ), + }, + HttpStatusCodes.OK + ) +} + +export const clearGuildMemberTimeout: AppRouteHandler< + ClearGuildMemberTimeoutRoute +> = async (c) => { + const guild = c.var.guild + const actor = c.var.member + const { userId } = c.req.valid("param") + + assertGuildPermission(actor, guild, { + guildMember: ["timeout"], + }) + + const target = await getGuildMemberRow(guild.id, userId) + + if (!target) { + return c.json( + { success: false, message: "Guild member not found" }, + HttpStatusCodes.NOT_FOUND + ) + } + + assertCanManageGuildMember(actor, target, guild) + + await db + .update(schema.guildMember) + .set({ + communicationDisabledUntil: null, + communicationDisabledBy: null, + communicationDisabledReason: null, + }) + .where( + and( + eq(schema.guildMember.guildId, guild.id), + eq(schema.guildMember.userId, userId) + ) + ) + + const updatedMember = await getGuildMemberRow(guild.id, userId) + + if (!updatedMember) { + return c.json( + { success: false, message: "Guild member not found" }, + HttpStatusCodes.NOT_FOUND + ) + } + + const onlineUserIds = await listOnlineUserIds([updatedMember.userId]) + + return c.json( + { + success: true as const, + member: toGuildMemberPresence( + updatedMember, + guild.ownerId, + onlineUserIds + ), }, HttpStatusCodes.OK ) diff --git a/apps/api/src/routes/v1/guilds/index.ts b/apps/api/src/routes/v1/guilds/index.ts index 85b26c9..809ea43 100644 --- a/apps/api/src/routes/v1/guilds/index.ts +++ b/apps/api/src/routes/v1/guilds/index.ts @@ -2,9 +2,12 @@ import { createRouter } from "@/lib/helpers/app/create-app" import * as handlers from "@/routes/v1/guilds/handlers" import * as routes from "@/routes/v1/guilds/routes" -const guildsRouter = createRouter().openapi( - routes.listGuildMembers, - handlers.listGuildMembers -) +const guildsRouter = createRouter() + .openapi(routes.listGuildMembers, handlers.listGuildMembers) + .openapi(routes.updateGuildMemberRole, handlers.updateGuildMemberRole) + .openapi(routes.kickGuildMember, handlers.kickGuildMember) + .openapi(routes.banGuildMember, handlers.banGuildMember) + .openapi(routes.timeoutGuildMember, handlers.timeoutGuildMember) + .openapi(routes.clearGuildMemberTimeout, handlers.clearGuildMemberTimeout) export default guildsRouter diff --git a/apps/api/src/routes/v1/guilds/routes.ts b/apps/api/src/routes/v1/guilds/routes.ts index 565bd47..cca4f12 100644 --- a/apps/api/src/routes/v1/guilds/routes.ts +++ b/apps/api/src/routes/v1/guilds/routes.ts @@ -8,7 +8,18 @@ import { unauthorizedSchema, } from "@/lib/helpers/openapi/schemas" import { guildAuthMiddleware } from "@/middleware/guild-auth" -import { guildSlugParamsSchema, listGuildMembersResponseSchema } from "./schema" +import { + banGuildMemberRequestSchema, + banGuildMemberResponseSchema, + guildMemberParamsSchema, + guildSlugParamsSchema, + listGuildMembersResponseSchema, + moderateGuildMemberResponseSchema, + timeoutGuildMemberRequestSchema, + timeoutGuildMemberResponseSchema, + updateGuildMemberRoleRequestSchema, + updateGuildMemberRoleResponseSchema, +} from "./schema" export const listGuildMembers = createRoute({ path: "/guilds/{guildSlug}/members", @@ -34,3 +45,140 @@ export const listGuildMembers = createRoute({ }) export type ListGuildMembersRoute = typeof listGuildMembers + +export const updateGuildMemberRole = createRoute({ + path: "/guilds/{guildSlug}/members/{userId}/role", + method: "patch", + summary: "Update a guild member role", + description: + "Updates a guild member's built-in role. Requires member role update permission and sufficient hierarchy.", + tags: ["Guilds"], + middleware: [guildAuthMiddleware] as const, + request: { + params: guildMemberParamsSchema, + body: jsonContent({ + schema: updateGuildMemberRoleRequestSchema, + description: "Updated built-in guild role", + }), + }, + responses: { + [HttpStatusCodes.OK]: jsonContent({ + schema: updateGuildMemberRoleResponseSchema, + description: "Updated guild member", + }), + [HttpStatusCodes.UNAUTHORIZED]: unauthorizedSchema, + [HttpStatusCodes.FORBIDDEN]: forbiddenSchema, + [HttpStatusCodes.NOT_FOUND]: notFoundSchema, + [HttpStatusCodes.INTERNAL_SERVER_ERROR]: internalServerErrorSchema, + }, +}) + +export type UpdateGuildMemberRoleRoute = typeof updateGuildMemberRole + +export const kickGuildMember = createRoute({ + path: "/guilds/{guildSlug}/members/{userId}/kick", + method: "post", + summary: "Kick a guild member", + description: + "Removes a member from the guild. Requires member kick permission and sufficient hierarchy.", + tags: ["Guilds"], + middleware: [guildAuthMiddleware] as const, + request: { + params: guildMemberParamsSchema, + }, + responses: { + [HttpStatusCodes.OK]: jsonContent({ + schema: moderateGuildMemberResponseSchema, + description: "Member kicked", + }), + [HttpStatusCodes.UNAUTHORIZED]: unauthorizedSchema, + [HttpStatusCodes.FORBIDDEN]: forbiddenSchema, + [HttpStatusCodes.NOT_FOUND]: notFoundSchema, + [HttpStatusCodes.INTERNAL_SERVER_ERROR]: internalServerErrorSchema, + }, +}) + +export type KickGuildMemberRoute = typeof kickGuildMember + +export const banGuildMember = createRoute({ + path: "/guilds/{guildSlug}/members/{userId}/ban", + method: "post", + summary: "Ban a guild member", + description: + "Bans a member from the guild and removes their active membership. Requires member ban permission and sufficient hierarchy.", + tags: ["Guilds"], + middleware: [guildAuthMiddleware] as const, + request: { + params: guildMemberParamsSchema, + body: jsonContent({ + schema: banGuildMemberRequestSchema, + description: "Ban metadata", + }), + }, + responses: { + [HttpStatusCodes.OK]: jsonContent({ + schema: banGuildMemberResponseSchema, + description: "Member banned", + }), + [HttpStatusCodes.UNAUTHORIZED]: unauthorizedSchema, + [HttpStatusCodes.FORBIDDEN]: forbiddenSchema, + [HttpStatusCodes.NOT_FOUND]: notFoundSchema, + [HttpStatusCodes.INTERNAL_SERVER_ERROR]: internalServerErrorSchema, + }, +}) + +export type BanGuildMemberRoute = typeof banGuildMember + +export const timeoutGuildMember = createRoute({ + path: "/guilds/{guildSlug}/members/{userId}/timeout", + method: "post", + summary: "Time out a guild member", + description: + "Temporarily disables a guild member's ability to communicate. Requires member timeout permission and sufficient hierarchy.", + tags: ["Guilds"], + middleware: [guildAuthMiddleware] as const, + request: { + params: guildMemberParamsSchema, + body: jsonContent({ + schema: timeoutGuildMemberRequestSchema, + description: "Timeout duration and optional reason", + }), + }, + responses: { + [HttpStatusCodes.OK]: jsonContent({ + schema: timeoutGuildMemberResponseSchema, + description: "Timed out guild member", + }), + [HttpStatusCodes.UNAUTHORIZED]: unauthorizedSchema, + [HttpStatusCodes.FORBIDDEN]: forbiddenSchema, + [HttpStatusCodes.NOT_FOUND]: notFoundSchema, + [HttpStatusCodes.INTERNAL_SERVER_ERROR]: internalServerErrorSchema, + }, +}) + +export type TimeoutGuildMemberRoute = typeof timeoutGuildMember + +export const clearGuildMemberTimeout = createRoute({ + path: "/guilds/{guildSlug}/members/{userId}/timeout", + method: "delete", + summary: "Clear a guild member timeout", + description: + "Restores a timed out member's ability to communicate. Requires member timeout permission and sufficient hierarchy.", + tags: ["Guilds"], + middleware: [guildAuthMiddleware] as const, + request: { + params: guildMemberParamsSchema, + }, + responses: { + [HttpStatusCodes.OK]: jsonContent({ + schema: timeoutGuildMemberResponseSchema, + description: "Updated guild member", + }), + [HttpStatusCodes.UNAUTHORIZED]: unauthorizedSchema, + [HttpStatusCodes.FORBIDDEN]: forbiddenSchema, + [HttpStatusCodes.NOT_FOUND]: notFoundSchema, + [HttpStatusCodes.INTERNAL_SERVER_ERROR]: internalServerErrorSchema, + }, +}) + +export type ClearGuildMemberTimeoutRoute = typeof clearGuildMemberTimeout diff --git a/apps/api/src/routes/v1/guilds/schema.ts b/apps/api/src/routes/v1/guilds/schema.ts index 3876528..5b9ff96 100644 --- a/apps/api/src/routes/v1/guilds/schema.ts +++ b/apps/api/src/routes/v1/guilds/schema.ts @@ -1,4 +1,5 @@ import { z } from "@hono/zod-openapi" +import { assignableGuildRoles } from "@repo/auth/permissions" import { guildSlugParamsSchema } from "@/routes/v1/channels/schema" export { guildSlugParamsSchema } @@ -10,12 +11,92 @@ export const guildMemberPresenceSchema = z.object({ displayUsername: z.string().nullable(), image: z.string().nullable(), role: z.string(), + isOwner: z.boolean(), status: z.enum(["online", "offline"]), + communicationDisabledUntil: z.string().datetime().nullable(), + communicationDisabledReason: z.string().nullable(), }) export const listGuildMembersResponseSchema = z.object({ guildId: z.string().uuid(), guildSlug: z.string(), guildName: z.string(), + ownerId: z.string().uuid(), members: z.array(guildMemberPresenceSchema), }) + +export const guildMemberParamsSchema = guildSlugParamsSchema.extend({ + userId: z + .string() + .uuid() + .openapi({ + param: { + name: "userId", + in: "path", + required: true, + }, + example: "00000000-0000-0000-0000-000000000000", + }), +}) + +export const updateGuildMemberRoleRequestSchema = z.object({ + role: z.enum(assignableGuildRoles), +}) + +export const updateGuildMemberRoleResponseSchema = z.object({ + success: z.literal(true), + member: guildMemberPresenceSchema, +}) + +export const moderateGuildMemberResponseSchema = z.object({ + success: z.literal(true), +}) + +export const guildBanSchema = z.object({ + userId: z.string().uuid(), + guildId: z.string().uuid(), + bannedBy: z.string().uuid(), + reason: z.string().nullable(), + expiresAt: z.string().datetime().nullable(), + createdAt: z.string().datetime(), + revokedAt: z.string().datetime().nullable(), +}) + +const optionalFutureExpiresAtSchema = z + .string() + .datetime() + .nullable() + .optional() + .refine( + (value) => { + if (value == null) return true + return new Date(value).getTime() > Date.now() + }, + { + message: "expiresAt must be in the future", + } + ) + +export const banGuildMemberRequestSchema = z.object({ + reason: z.string().trim().min(1).max(255).nullable().optional(), + expiresAt: optionalFutureExpiresAtSchema, +}) + +export const banGuildMemberResponseSchema = z.object({ + success: z.literal(true), + ban: guildBanSchema, +}) + +export const timeoutGuildMemberRequestSchema = z.object({ + durationMinutes: z + .number() + .int() + .min(1) + .max(60 * 24 * 28), + reason: z.string().trim().min(1).max(255).nullable().optional(), +}) + +export const timeoutGuildMemberResponseSchema = z.object({ + success: z.literal(true), + member: guildMemberPresenceSchema, +}) diff --git a/apps/api/src/routes/v1/uploads/handlers.ts b/apps/api/src/routes/v1/uploads/handlers.ts index 3d79650..7c74774 100644 --- a/apps/api/src/routes/v1/uploads/handlers.ts +++ b/apps/api/src/routes/v1/uploads/handlers.ts @@ -5,6 +5,7 @@ import { channel, channelMember, guildMember } from "@repo/db/schema" import { env } from "@repo/env/server" import { and, eq } from "drizzle-orm" import * as HttpStatusCodes from "@/lib/helpers/http/status-codes" +import { assertMemberCanCommunicate } from "@/lib/permissions" import { s3Client } from "@/lib/s3" import type { AppRouteHandler } from "@/lib/types/app-types" import type { AvatarPresignRoute, PresignRoute } from "./routes" @@ -41,7 +42,10 @@ export const presign: AppRouteHandler = async (c) => { // Guild channel — verify guild membership if (ch.guildId) { const member = await db - .select({ id: guildMember.id }) + .select({ + id: guildMember.id, + communicationDisabledUntil: guildMember.communicationDisabledUntil, + }) .from(guildMember) .where( and( @@ -58,6 +62,8 @@ export const presign: AppRouteHandler = async (c) => { HttpStatusCodes.FORBIDDEN ) } + + assertMemberCanCommunicate(member) } else if ( DM_CHANNEL_TYPES.includes(ch.type as (typeof DM_CHANNEL_TYPES)[number]) ) { diff --git a/apps/realtime/src/index.ts b/apps/realtime/src/index.ts index b1b6af3..0398b54 100644 --- a/apps/realtime/src/index.ts +++ b/apps/realtime/src/index.ts @@ -39,6 +39,7 @@ import { markUserConnected, markUserDisconnected, } from "@/services/presence" +import { enforceGuildMessageRateLimit } from "@/services/rate-limit" import { markChannelRead } from "@/services/read-states" type SocketData = { @@ -302,9 +303,23 @@ io.on("connection", (socket) => { socket.on("message:send", async (payload, ack) => { try { const parsed = sendMessagePayloadSchema.parse(payload) + const accessibleChannel = await assertUserCanAccessChannel( + socket.data.user.id, + parsed.channelId + ) + + if (accessibleChannel.guildId && accessibleChannel.memberRole) { + await enforceGuildMessageRateLimit(redisPresenceClient, { + guildId: accessibleChannel.guildId, + userId: socket.data.user.id, + role: accessibleChannel.memberRole, + }) + } + const createdMessage = await createMessage({ userId: socket.data.user.id, payload: parsed, + accessibleChannel, }) const fanout = await buildMessageFanout({ diff --git a/apps/realtime/src/services/channel-access.ts b/apps/realtime/src/services/channel-access.ts index b0c1e45..db3f440 100644 --- a/apps/realtime/src/services/channel-access.ts +++ b/apps/realtime/src/services/channel-access.ts @@ -4,6 +4,10 @@ export type AccessibleChannel = { id: string type: (typeof schema.channel.$inferSelect)["type"] guildId: string | null + memberRole: string | null + memberIsOwner: boolean + communicationDisabledUntil: Date | null + communicationDisabledReason: string | null } export async function assertUserCanAccessChannel( @@ -31,8 +35,16 @@ export async function assertUserCanAccessChannel( if (channelRecord.guildId) { const memberRecord = await db - .select({ id: schema.guildMember.id }) + .select({ + role: schema.guildMember.role, + communicationDisabledUntil: + schema.guildMember.communicationDisabledUntil, + communicationDisabledReason: + schema.guildMember.communicationDisabledReason, + ownerId: schema.guild.ownerId, + }) .from(schema.guildMember) + .innerJoin(schema.guild, eq(schema.guild.id, schema.guildMember.guildId)) .where( and( eq(schema.guildMember.guildId, channelRecord.guildId), @@ -46,7 +58,13 @@ export async function assertUserCanAccessChannel( throw new Error("Forbidden") } - return channelRecord + return { + ...channelRecord, + memberRole: memberRecord.role, + memberIsOwner: memberRecord.ownerId === userId, + communicationDisabledUntil: memberRecord.communicationDisabledUntil, + communicationDisabledReason: memberRecord.communicationDisabledReason, + } } const dmMemberRecord = await db @@ -65,5 +83,11 @@ export async function assertUserCanAccessChannel( throw new Error("Forbidden") } - return channelRecord + return { + ...channelRecord, + memberRole: null, + memberIsOwner: false, + communicationDisabledUntil: null, + communicationDisabledReason: null, + } } diff --git a/apps/realtime/src/services/messages.ts b/apps/realtime/src/services/messages.ts index 011860e..dbd7a44 100644 --- a/apps/realtime/src/services/messages.ts +++ b/apps/realtime/src/services/messages.ts @@ -12,9 +12,20 @@ import { assertUserCanAccessChannel, } from "./channel-access" +function assertChannelCommunicationAllowed(channel: AccessibleChannel) { + if (!channel.guildId) return + if (!channel.communicationDisabledUntil) return + if (channel.communicationDisabledUntil.getTime() <= Date.now()) return + + throw new Error( + "You are temporarily timed out and cannot perform this action" + ) +} + type CreateMessageInput = { userId: string payload: SendMessagePayload + accessibleChannel: AccessibleChannel } type DeleteMessageInput = { @@ -49,10 +60,12 @@ export type ToggleMessageReactionResult = { } export async function createMessage(input: CreateMessageInput) { - const channelRecord = await assertUserCanAccessChannel( - input.userId, - input.payload.channelId - ) + if (input.accessibleChannel.id !== input.payload.channelId) { + throw new Error("Channel mismatch") + } + + const channelRecord = input.accessibleChannel + assertChannelCommunicationAllowed(channelRecord) let hasReply = !!input.payload.referencedMessageId @@ -198,6 +211,7 @@ export async function deleteMessage( input.userId, input.payload.channelId ) + assertChannelCommunicationAllowed(channelRecord) const messageRecord = await db .select({ @@ -248,6 +262,7 @@ export async function editMessage( input.userId, input.payload.channelId ) + assertChannelCommunicationAllowed(channelRecord) const messageRecord = await db .select({ @@ -293,6 +308,7 @@ export async function toggleMessageReaction(input: ToggleMessageReactionInput) { input.userId, input.payload.channelId ) + assertChannelCommunicationAllowed(channelRecord) const messageRecord = await db .select({ diff --git a/apps/realtime/src/services/rate-limit.ts b/apps/realtime/src/services/rate-limit.ts new file mode 100644 index 0000000..666b787 --- /dev/null +++ b/apps/realtime/src/services/rate-limit.ts @@ -0,0 +1,49 @@ +import { getGuildMessageRateLimit, isGuildRole } from "@repo/auth/permissions" +import type { createClient } from "redis" + +type RedisClient = ReturnType + +const WINDOW_SECONDS = 60 +const KEY_TTL_SECONDS = 90 + +function getMessageRateLimitKey( + guildId: string, + userId: string, + timestamp: number +) { + const currentWindow = Math.floor(timestamp / (WINDOW_SECONDS * 1000)) + return `ratelimit:guild:${guildId}:user:${userId}:message:${currentWindow}` +} + +function getRetryAfterSeconds(timestamp: number) { + const elapsedSeconds = Math.floor(timestamp / 1000) % WINDOW_SECONDS + return Math.max(1, WINDOW_SECONDS - elapsedSeconds) +} + +export async function enforceGuildMessageRateLimit( + redis: RedisClient, + input: { + guildId: string + userId: string + role: string + } +) { + if (!isGuildRole(input.role)) { + throw new Error(`Unknown guild role: ${input.role}`) + } + + const now = Date.now() + const key = getMessageRateLimitKey(input.guildId, input.userId, now) + const nextCount = await redis.incr(key) + + if (nextCount === 1) { + await redis.expire(key, KEY_TTL_SECONDS) + } + + const limit = getGuildMessageRateLimit(input.role) + if (nextCount <= limit) return + + throw new Error( + `Rate limit exceeded. Try again in ${getRetryAfterSeconds(now)} seconds` + ) +} diff --git a/apps/web/src/components/sidebar/channel-panel/channel-list.tsx b/apps/web/src/components/sidebar/channel-panel/channel-list.tsx index 64602c7..9a33bdf 100644 --- a/apps/web/src/components/sidebar/channel-panel/channel-list.tsx +++ b/apps/web/src/components/sidebar/channel-panel/channel-list.tsx @@ -15,6 +15,8 @@ import { verticalListSortingStrategy, } from "@dnd-kit/sortable" import { CSS } from "@dnd-kit/utilities" +import { authClient } from "@repo/auth/client" +import type { GuildRole } from "@repo/auth/permissions" import { DropdownMenu, DropdownMenuContent, @@ -38,6 +40,9 @@ import { AnimatePresence, motion } from "motion/react" import { useCallback, useState } from "react" import { apiClient } from "@/lib/api-client" import type { Channel, ListChannelsResponse } from "@/lib/api-types" +import { canDeleteChannels, canManageChannels } from "@/lib/permissions" +import { DeleteChannelDialog } from "./delete-channel-dialog" +import { EditChannelDialog } from "./edit-channel-dialog" const channelIcons = { text: Hash, @@ -137,6 +142,21 @@ export function ChannelList() { }, }) + const { data: activeMember } = useQuery({ + queryKey: ["active-guild-member", guildSlug], + queryFn: async () => { + const res = await authClient.organization.getActiveMember() + return res.data + }, + enabled: !!guildSlug, + }) + const canManage = activeMember?.role + ? canManageChannels(activeMember.role as GuildRole) + : false + const canDelete = activeMember?.role + ? canDeleteChannels(activeMember.role as GuildRole) + : false + const [activeItem, setActiveItem] = useState<{ channel: Channel isCategory: boolean @@ -355,15 +375,16 @@ export function ChannelList() { ch.id)} strategy={verticalListSortingStrategy} + disabled={!canManage} >
{data.uncategorized.map((ch) => ( navigate({ to: "/$guildSlug/$channelId", @@ -383,6 +404,7 @@ export function ChannelList() { cat.id)} strategy={verticalListSortingStrategy} + disabled={!canManage} > {data.categories.map((cat) => ( navigate({ to: "/$guildSlug/$channelId", @@ -432,6 +456,8 @@ function SortableCategorySection({ channels, draggingCategory, activeChannelId, + canManage, + canDelete, onChannelClick, }: { id: string @@ -439,6 +465,8 @@ function SortableCategorySection({ channels: Channel[] draggingCategory: boolean activeChannelId?: string + canManage: boolean + canDelete: boolean onChannelClick?: (channelId: string) => void }) { const [collapsed, setCollapsed] = useState(false) @@ -449,7 +477,7 @@ function SortableCategorySection({ transform, transition, isDragging, - } = useSortable({ id }) + } = useSortable({ id, disabled: !canManage }) const style = { transform: CSS.Translate.toString(transform), @@ -462,8 +490,7 @@ function SortableCategorySection({