Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
8 changes: 4 additions & 4 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down
4 changes: 3 additions & 1 deletion apps/api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
130 changes: 130 additions & 0 deletions apps/api/src/lib/permissions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { auth } from "@repo/auth"
import {
canManageGuildAuthority,
type GuildAuthority,
guildAuthorityHasPermissions,
isGuildRole,
type PermissionRequest,
type StatementKey,
} from "@repo/auth/permissions"
Comment thread
BuckyMcYolo marked this conversation as resolved.
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<T extends StatementKey> = NonNullable<
PermissionRequest[T]
>[number]

function toGuildAuthority(
member: Pick<GuildMember, "role" | "userId">,
guild: Pick<Guild, "ownerId">
): 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.
*/
Comment thread
coderabbitai[bot] marked this conversation as resolved.
export async function checkPermission<
TResource extends StatementKey,
TPermissions extends readonly PermissionForStatement<TResource>[],
>(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<GuildMember, "role" | "userId">,
guild: Pick<Guild, "ownerId">,
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<GuildMember, "role" | "userId">,
target: Pick<GuildMember, "role" | "userId">,
guild: Pick<Guild, "ownerId">
) {
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<GuildMember, "communicationDisabledUntil">
) {
if (!member.communicationDisabledUntil) return false
return member.communicationDisabledUntil.getTime() > Date.now()
}

export function assertMemberCanCommunicate(
member: Pick<GuildMember, "communicationDisabledUntil">
) {
if (!isCommunicationDisabled(member)) return

throw new HTTPException(HttpStatusCodes.FORBIDDEN, {
message: "You are temporarily timed out and cannot send messages",
})
}
65 changes: 65 additions & 0 deletions apps/api/src/routes/v1/channels/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ListChannelsRoute> = async (c) => {
Expand Down Expand Up @@ -56,8 +59,13 @@ export const listChannels: AppRouteHandler<ListChannelsRoute> = async (c) => {

export const createChannel: AppRouteHandler<CreateChannelRoute> = 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({
Expand All @@ -81,8 +89,13 @@ export const reorderChannels: AppRouteHandler<ReorderChannelsRoute> = 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)]

Expand Down Expand Up @@ -134,6 +147,58 @@ export const getChannel: AppRouteHandler<GetChannelRoute> = async (c) => {
return c.json(ch, HttpStatusCodes.OK)
}

export const updateChannel: AppRouteHandler<UpdateChannelRoute> = 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<DeleteChannelRoute> = 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) => {
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/routes/v1/channels/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
55 changes: 55 additions & 0 deletions apps/api/src/routes/v1/channels/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,15 @@ import {
channelResponseSchema,
createChannelRequestSchema,
createChannelResponseSchema,
deleteChannelResponseSchema,
guildSlugParamsSchema,
listChannelsResponseSchema,
listMessagesQuerySchema,
listMessagesResponseSchema,
reorderChannelsRequestSchema,
reorderChannelsResponseSchema,
updateChannelRequestSchema,
updateChannelResponseSchema,
} from "./schema"

export const listChannels = createRoute({
Expand Down Expand Up @@ -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
Loading